diff --git a/docs/requirements/handoff-HEL-908.json b/docs/requirements/handoff-HEL-908.json
new file mode 100644
index 0000000..e476a66
--- /dev/null
+++ b/docs/requirements/handoff-HEL-908.json
@@ -0,0 +1,48 @@
+{
+ "baseline_version": "1.12",
+ "read_commit": "5625de6606a607a9d4fd583fe8a52d93d6d2b4ea",
+ "task": "HEL-908:HEL-797 已授权定点返修,修复 HEL-907 窗口实证的两项缺陷。其一『她在播语音时网页发文字→文字轮被取消、无回复、旧语音继续约32秒』:实证根因是取消发生在首 delta 前(worker.log:108 的 SDK 重试警告在文字 POST 后 13ms),服务端取消收尾无终态帧,worker 适配器把它当连接错误,livekit-agents 1.8.8 LLMStream._main_task 静默重试同一 turn_id,重试重新激活已取消轮并反杀新文字轮、重新生成整段语音播完;原报告『_retry_on_chunk_sent 守卫从未生效』按总管只读核对与实测源码确认不成立(守卫仅在有输出后适用)。其二『Considering the limi…/ 进入字幕与 TTS』:端点把隐式开头(无 、模板预填)的推理原文+stray 混入 content(DB id=12 原文),适配器此前无任何 reasoning 分离。全部修复离线复现→验证,零真实请求、零部署、零依赖变更。",
+ "requirement_ids": [
+ "CG-006",
+ "CG-010",
+ "CG-013",
+ "CG-014",
+ "CG-015",
+ "CG-016",
+ "CG-018",
+ "CG-019",
+ "CG-020",
+ "CG-021",
+ "AM-001",
+ "GOV-001",
+ "GOV-002",
+ "GOV-003",
+ "GOV-004",
+ "GOV-005",
+ "GOV-006",
+ "DEL-001"
+ ],
+ "deliverable": "双侧最小修复+离线证据。服务端 src/cybergril/api/app.py:取消路径显式下发 chat.cancelled 终态帧(取消≠断网);chat_lock 拒绝 status=cancelled 的旧轮重新取得活动轮(迟到重试不再反杀新轮,幂等回 chat.cancelled;failed 轮仍可重试);落库/chat.final/receipt 前轮级 split_reasoning 兜底。worker:chat_api_client.py 识别 chat.cancelled 终态;llm_chat_api.py 以 SDK 自身 499 语义正常结束(不重试不抛出)并经 SpeechHandle.interrupt()(LiveKit 既有打断路径)停掉已缓存音频,server_cancelled 重入闸零网络短路;worker.py 零改动。思考分流:新增 providers/llm/reasoning.py(流式状态机+轮级分流,复用 reasoning_content 字段与 约定,不造推理引擎、不改提示词、不删英文),openai_compat.py 接入并把 reasoning_content 增量导入 LLMDelta.reasoning(contracts 新增诊断字段,绝不持久化/下发/播报)。测试:tests/test_rework_hel908.py(app 级取消语义与泄漏回放)、tests/test_reasoning_separation_hel908.py(37 例含记录流逐字回放)、tests/test_sdk_livekit_hel908.py(真实 SDK harness,pytest 可收集、agent venv 可独立运行);既有 test_voice_chat_adapter.py 一例更新到新终态语义、test_api.py 断连用例内容补句号(意图不变)。证据 docs/rework/hel908/(summary.md+tests-remote.log)。",
+ "acceptance": [
+ "问题1离线复现→修复:ScriptedLLM 首delta前挂起模拟记录时序——文字 POST 后被取消的语音流收到 chat.cancelled 终态干净结束(不再异常);迟到同 turn_id 重试被拒绝且零额外模型调用;文字轮保留并获回复(user 行 sent+companion 行落库);语音轮无 phantom companion 行",
+ "首delta后取消(真实 socket uvicorn):已发 delta 保留、随后终态取消;真实 SDK harness 5/5——首delta前取消不重试(1次POST)、SpeechHandle.interrupt() 恰一次、取消后重入立即499零网络、真实连接错误仍按 SDK 有限重试(max_retry+1后诚实失败)、完成轮流式+receipt不变",
+ "问题2离线复现→修复:DB id=12 原文(隐式开头推理+stray )经真实 OpenAICompatLLM+真实 app 全链回放——chat.delta 拼接、落库行、chat.final、receipt text_hash 全部只绑干净可见回复;未闭合思考→空正文→诚实 chat.error(user 行 failed,无 phantom 回复);中英文正常正文逐字节保留(不以删英文掩盖)",
+ "既有韧性不破坏:已完成轮幂等重放仍零额外模型调用;failed 轮仍可重试;receipt 校验语义不变(专项用例)",
+ "规范环境全量:.venv311(requirements.lock 一致,只读复用)445 passed/5 skipped;2 个 test_r3 失败在施工基准 5625de6 上同样失败(预先存在,证据留存);ruff src/ tests/ 全绿;零真实请求、零部署、未触碰任何现网服务/端口/依赖/锁文件"
+ ],
+ "deferred": [
+ "修复后真实端到端复测(真实房间、真人打断时延、真机 Safari/Chrome)须按授权窗口安排,本卡零真实请求边界内未做;停播证明是真实 SDK SpeechHandle.interrupt() 断言,不冒充端到端毫秒指标",
+ "思考分流流式残留:隐式开头且 落在128字符hold外的推理前缀仍会部分流出到字幕/TTS(流式下无法提前判定),落库与 receipt 始终干净;彻底消除需上游自行分流(reasoning_content 字段或服务端 reasoning parser),零真实请求下无法探测该端点能力——最小缺口如实声明",
+ "被新轮打断的可见界面状态(文字轮显示『被新对话取代』等)属界面核心,交视觉总监;本卡前端零改动(新帧 kind 被现有 ChatPanel 静默忽略,行为不差于现状)",
+ "scripts/gen_hel877_sdk_check.py 在施工基准上即因 HEL-906 的 stt_funasr→asr_usage 导入而失效(harness 模块清单未随更新,base_exit=2 证据留存),交总管决定是否另行安排维护;本卡 SDK 验证由新 harness 覆盖",
+ "R1–R7 完整功能(真实用户麦克风打断、视频/口型、记忆长跑等)继续按获批依赖承接,本卡不构成整产品完成"
+ ],
+ "requirement_changes": [],
+ "verification": [
+ "docs/rework/hel908/summary.md:根因时间轴(worker.log:108 13ms 证据、DB id=5/6/8、_retry_on_chunk_sent 纠偏对照源码)、最小变更表、残留声明",
+ "docs/rework/hel908/tests-remote.log:.venv311 全量 445 passed/5 skipped/2 pre-existing failed + agent venv SDK harness 5/5 PASS 实况",
+ "tests/test_sdk_livekit_hel908.py 可在任何装有 livekit-agents 1.8.8 的环境复跑(pytest 或 --standalone),命令写在文件头",
+ "预先存在失败的基准证据:test_r3 两例与 gen_hel877 harness 在 5625de6 上同样失败(本卡留存对照运行记录)",
+ "需求检查:check_requirements.py 对 origin/main 与 5625de6 各带 --handoff 本文件通过(日志随交付)"
+ ]
+}
diff --git a/docs/rework/hel908/check-requirements.log b/docs/rework/hel908/check-requirements.log
new file mode 100644
index 0000000..5687cae
--- /dev/null
+++ b/docs/rework/hel908/check-requirements.log
@@ -0,0 +1,8 @@
+== --base-ref origin/main ==
+需求结构检查通过:60项;来源和覆盖完整。仍须人工核对授权语义与实际体验。
+== --base-ref origin/main --handoff ==
+需求结构检查通过:60项;来源和覆盖完整。仍须人工核对授权语义与实际体验。
+== --base-ref 5625de6 ==
+需求结构检查通过:60项;来源和覆盖完整。仍须人工核对授权语义与实际体验。
+== --base-ref 5625de6 --handoff ==
+需求结构检查通过:60项;来源和覆盖完整。仍须人工核对授权语义与实际体验。
diff --git a/docs/rework/hel908/summary.md b/docs/rework/hel908/summary.md
new file mode 100644
index 0000000..95b9a8f
--- /dev/null
+++ b/docs/rework/hel908/summary.md
@@ -0,0 +1,87 @@
+# HEL-908 · 定点返修记录:交谈打断丢字 + 内部思考外泄(2026-10-28)
+
+> 授权:本卡 HEL-908(主卡 HEL-797 定点返修)。全部验证离线、零真实请求、零部署。
+> 施工基准:`agent/agent/47adf6961f8a` @ `5625de6606a607a9d4fd583fe8a52d93d6d2b4ea`(完整 SHA 已核)。
+> 需求基准 v1.12/60 项,本卡零需求变更。
+
+## 0. 结论速览
+
+| 项 | 结果 |
+| --- | --- |
+| 问题1:她在播语音时网页发文字 → 文字轮被取消、无回复、旧语音继续约32秒 | ✅ 已修复并离线复现→验证(服务端+worker 双侧,穿真实 SDK 路径) |
+| 问题2:`Considering the limi…`/`` 进入字幕与 TTS | ✅ 已修复并离线复现→验证(记录流逐字回放);1 项有界残留已如实声明(§4) |
+| HEL-907 原报告“`_retry_on_chunk_sent` 守卫从未生效”的推断 | ❌ 不成立(按总管只读核对纠偏,本卡实证确认,见 §1.2) |
+| 既有有限重试/已完成幂等重放/receipt 校验 | ✅ 未被破坏(专项测试证明) |
+| 真实端到端复测(真人麦克风/真房间) | ⛔ 本卡零真实请求边界内未做,交回总管按窗口安排 |
+
+## 1. 问题1根因(源码级 + 实证时间轴)
+
+### 1.1 事实链(HEL-907 证据 + 本卡新增核对)
+
+- `worker.log:108`:`failed to generate LLM completion: chat stream ended without final or error …, retrying in 0.1s`,时间 `12:17:58.455`;用户文字 POST t0=`12:17:58.442`(call1.json `t0_text_post_ms=1793189878442`)——**SSE 中止与 SDK 重试发生在文字 POST 后 13ms**。
+- 该警告来自 livekit-agents 1.8.8 `LLMStream._main_task` 的 APIError 重试分支(attempt 1)→ 证明重试走的是 SDK 自己的重试环,重试同一 `turn_id` 重新执行 `_run` → `send_turn`(api.log:health+POST 同端口 38996 两次)。
+- DB:语音轮 user 行 id=5 两度 pending(重试 `ON CONFLICT … DO UPDATE` 重新激活),文字轮 user 行 id=6 `cancelled`,语音重试生成完整回复 id=8(ts=882175,重试 POST 后约 2.6 秒重新生成)并经 TTS 全部播完(t0 后 32.0 秒音频,`old_audio_stop_latency_s=32.015`)。
+- 关键事实:**取消发生在首 delta 之前**——12:17:58.442 时该轮一个 chat.delta 都没到(TTS 首包在其后 3.5 秒;字幕全部增量来自重试后的重新生成),因此 `LLMStream._event_ch.output_sent=False`。
+
+### 1.2 纠偏:原报告“`_retry_on_chunk_sent=False` 永远不起作用”不成立
+
+对照 .12 实际安装的 livekit-agents 1.8.8 源码(只读核对 `livekit/agents/llm/llm.py`):
+
+- `_LLMEventChannel.send_nowait` 在**送入**含 content 的 chunk 时置 `output_sent=True`;
+- `_main_task` 对 APIError:`if not self._retry_on_chunk_sent and self._event_ch.output_sent: e.retryable=False`;`APIStatusError` 499 直接正常返回(不重试不抛出)。
+
+即守卫**只在已有输出后才适用**;本缺陷路径取消发生在首 chunk 前(`output_sent=False`),守卫根本不在适用范围——不是“守卫失效”,是“取消被当成了临时断网”。本卡 SDK harness 的 `real_connection_errors_still_retry` 用例同时证明:真实连接错误的重试路径未被本次修复全局禁用。
+
+### 1.3 修复(双侧最小改动)
+
+- 服务端 `src/cybergril/api/app.py`:
+ 1. `generate()` 取消路径在结束 SSE 前**显式下发终态帧 `chat.cancelled`**(取消≠断网:取消是协议事件,断网才走连接错误语义;客户端已断开时保持原样静默结束)。
+ 2. `chat_lock` 段新增:`status='cancelled'` 的旧轮**拒绝重新激活**,直接幂等回 `chat.cancelled`,且**不触碰当前活动轮**——同一已取消 `turn_id` 永远无法再取得活动轮所有权(迟到的重试再也不能反杀新文字轮)。`failed` 轮仍可重试(既有韧性不变,专项用例覆盖)。
+- worker `src/cybergril/voice/`:
+ - `chat_api_client.py`:`_consume` 识别 `chat.cancelled` 终态帧 → `ChatApiTurn.cancelled=True`,不再当 `ChatApiConnectionError`。
+ - `llm_chat_api.py`:取消终态 → 复用 SDK 自身的 499(Client Closed Request)语义 `raise APIStatusError(status_code=499)`,`_main_task` 正常返回、**不重试不抛出**;同时经 `SpeechHandle.interrupt()`(SDK 既有打断路径,VAD 抢话同款)**停掉已缓存的旧音频播放**,不止停止生成。另加 `server_cancelled` 重入闸:一旦该流得知取消,`_run` 再被任何路径重入都立即 499、零网络调用。
+- `worker.py`:零改动(interrupt 经适配器归属到确切 SpeechHandle;`_after_playout` 对 interrupted 的 fail-closed 收口、字幕 turn_cancel、回执放弃全部走既有路径)。
+
+## 2. 问题2根因与修复
+
+- 证据:DB 行 id=12(call3)原文 = `"Considering the limited time by the user, I have to give the solution based on the thinking directly now.\n\n\n大圣,您这定身法…"`——**`content` 里只有 stray ``、没有开头 ``**(服务端 chat template 预填了开头),推理原文因此流进 chat.delta → 字幕与 TTS(tts-window-tail.log 两个 segment 起头即 `Considering the limi…` 与 ``)。同一端点同窗口亦有完全干净回复(id=14),证明泄漏是按次混入而非固定。
+- 定位:`openai_compat.py` 适配器此前只读 `delta.content`,**没有任何 reasoning 分离**;`reasoning_content` 字段(DeepSeek 系约定)也未读。
+- 修复(复用 OpenAI 兼容生态的两个既有分流约定,不造推理引擎、不改提示词、不删英文):
+ - 新增 `src/cybergril/providers/llm/reasoning.py`:流式 `ReasoningSeparator` 状态机(hold/think/visible;跨 chunk 标签安全;stray closer=隐式开头思考的收口;中文句末/128 字符解除 hold)+ 轮级 `split_reasoning`。
+ - `openai_compat.py`:`reasoning_content`/`reasoning` 字段增量 → `LLMDelta.reasoning`(仅诊断,绝不持久化/下发/播报);`content` 过分离器后才是 `delta.text`。
+ - `contracts/utterance.py`:`LLMDelta.reasoning` 字段(默认空,向后兼容)。
+ - `app.py`:落库/chat.final/receipt 前再做轮级 `split_reasoning`(对超出 hold 的流式残留兜底,receipt 绑定干净文本)。
+ - 边界:原始模型输出 →(适配器分流)→ 可见正文才可持久化/字幕/TTS;reasoning 仅存在于 `LLMDelta.reasoning` 诊断通道,落地即弃。
+
+## 3. 最小变更表
+
+| 文件 | 变更 |
+| --- | --- |
+| `src/cybergril/api/app.py` | 取消路径下发 `chat.cancelled`;拒绝 cancelled 轮重新激活;落库前轮级分流兜底 |
+| `src/cybergril/voice/chat_api_client.py` | `chat.cancelled` 终态帧解析(`ChatApiTurn.cancelled`) |
+| `src/cybergril/voice/llm_chat_api.py` | 499 取消终态(不重试)+ `SpeechHandle.interrupt()` 停播 + `server_cancelled` 重入闸 |
+| `src/cybergril/providers/llm/reasoning.py` | 新增:流式思考分流状态机 + 轮级分流 |
+| `src/cybergril/providers/llm/openai_compat.py` | 接入分流器;读 `reasoning_content` 字段 |
+| `src/cybergril/contracts/utterance.py` | `LLMDelta.reasoning` 诊断字段 |
+| `tests/test_rework_hel908.py` | 新增:app 级取消语义(首 delta 前取消/首 delta 后取消/迟到重试拒绝/文字轮存活/think 泄漏回放/未闭合思考 fail-closed/重放与失败重试不变) |
+| `tests/test_reasoning_separation_hel908.py` | 新增:分流单测(记录流逐字回放、跨 chunk 标签、推理字段、未闭合、中英文正文保留、残留声明) |
+| `tests/test_sdk_livekit_hel908.py` | 新增:真实 SDK 路径 harness(pytest 可收集;agent venv 无 pytest 可独立运行) |
+| `tests/test_voice_chat_adapter.py` | 既有用例更新到新终态语义(被取消方收到 `chat.cancelled` 干净结束,不再异常) |
+| `tests/test_api.py` | 断连用例的上游内容补句号(hold 语义下首帧可流出;用例意图——断连关闭上游——不变) |
+
+## 4. 验证(全部离线、零真实请求)
+
+- **规范环境**:`.12 /opt/cybergril-hel879/current/.venv311`(requirements.lock 逐字一致,只读复用;代码在 `/tmp` 副本运行,未装任何依赖)。
+ - 全量:**445 passed / 5 skipped**(5 skipped=SDK harness 需 livekit,见下)/ 2 failed=`test_r3` 基线/迁移两例——**在施工基准 5625de6 上同样失败(预先存在,与本卡无关,已留存证据)**。
+ - `tests-remote.log`:全量 + SDK harness 输出。
+- **真实 SDK 路径**(`.12 /opt/voice-agent/agent/venv`,livekit-agents 1.8.8,只读复用):`tests/test_sdk_livekit_hel908.py --standalone` **5/5 PASS**——首 delta 前取消不重试(1 次 POST)、首 delta 后取消保留已发 chunk 且 `SpeechHandle.interrupt()` 恰好一次、取消后重入立即 499 零网络、**真实连接错误仍按 SDK 有限重试(max_retry+1 次后诚实失败)**、完成轮流式与 receipt 不变。
+- **记录流回放**:`tests/test_reasoning_separation_hel908.py` 37/37(含 id=12 原文按 1/2/3/5/7/13/1000 字符分块全零泄漏;`` 跨 SSE 帧拆分;中英文正文逐字节保留;未闭合思考→空正文→诚实 `chat.error`)。
+- 本地环境(py3.10,与 lock 版本不一致):reasoning 37/37、ruff 全绿;app 级用例因 starlette/anyio 版本差异在本地环境基线即不可运行(已在 .12 规范环境执行)。
+- ruff:`src/ tests/` 全绿。
+- `scripts/gen_hel877_sdk_check.py` 在 agent venv 于**施工基准上即失败**(HEL-906 引入 `stt_funasr→asr_usage` 导入后 harness 模块清单未更新,与本卡无关,base_exit=2 证据留存);本卡 SDK 验证由新 harness 覆盖。
+
+## 5. 残留与未测(如实)
+
+1. **思考分流流式残留**:隐式开头(无 ``)且收尾 `` 落在 128 字符 hold 之外的推理,前缀仍会部分流出到字幕/TTS(无法在流式下提前判定);落库文本与 receipt 始终干净(轮级兜底,专项用例声明该边界)。彻底消除需上游自行分流(`reasoning_content` 字段或服务端 reasoning parser)——零真实请求边界内无法探测该端点能力,这是**最小缺口**,不虚称已完全修好。
+2. 修复后的真实端到端复测(真实房间/真人打断时延/真机)未做,交回总管按授权窗口安排;停播路径用真实 SDK 的 `SpeechHandle.interrupt()` 断言证明,不冒充端到端毫秒指标。
+3. 前端零改动(新帧 kind 被现有 ChatPanel 静默忽略,行为不差于现状);把“被新轮打断”做成可见界面状态属界面核心,交视觉总监。
diff --git a/docs/rework/hel908/tests-remote.log b/docs/rework/hel908/tests-remote.log
new file mode 100644
index 0000000..e4eb453
--- /dev/null
+++ b/docs/rework/hel908/tests-remote.log
@@ -0,0 +1,15 @@
+
+-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
+=========================== short test summary info ============================
+FAILED tests/test_r3.py::test_populated_migration_idempotence_and_old_code_rollback
+FAILED tests/test_r3.py::test_three_endpoint_bytes_equal_product_baseline - s...
+2 failed, 445 passed, 5 skipped, 1 warning in 51.70s
+=== SDK harness (agent venv, livekit-agents 1.8.8) ===
+failed to generate LLM completion: message='chat api failure: network is genuinely down', status_code=502, retryable=True, retrying in 0.1s
+failed to generate LLM completion: message='chat api failure: network is genuinely down', status_code=502, retryable=True, retrying in 0.05s
+PASS pre_first_delta_cancel_never_retries
+PASS mid_stream_cancel_interrupts_handle_and_never_retries
+PASS reentry_after_cancel_short_circuits_without_network
+PASS real_connection_errors_still_retry_not_globally_disabled
+PASS completed_turn_still_streams_with_receipt
+5/5 passed
diff --git a/src/cybergril/api/app.py b/src/cybergril/api/app.py
index 3117ad4..ae19679 100644
--- a/src/cybergril/api/app.py
+++ b/src/cybergril/api/app.py
@@ -48,6 +48,7 @@ from ..life.sharing import Sharing
from ..memory.episodic import Memories
from ..memory.semantic import build_semantic
from ..memory.store import Store
+from ..providers.llm.reasoning import split_reasoning
from ..think.scenes import Scene
from ..think.wiring import build_router_from_config
from ..voice import (
@@ -574,6 +575,18 @@ def create_app(
)
if previous and previous["text"] != data.text:
raise HTTPException(409, "相同轮次不能改写消息。")
+ if previous and previous["status"] == "cancelled":
+ # HEL-908: a turn the server cancelled (superseded by a newer
+ # turn) never re-acquires the active-turn slot — a late retry
+ # of the same turn_id must not cancel the newer turn that
+ # superseded it. Answer with the same terminal cancel frame,
+ # idempotently, without touching any state.
+ async def cancelled_turn():
+ yield event(data, "chat.cancelled")
+
+ return StreamingResponse(
+ cancelled_turn(), media_type="text/event-stream"
+ )
if app.state.active:
active = app.state.active
if active["turn_id"] != data.turn_id:
@@ -691,7 +704,14 @@ def create_app(
done, _ = await asyncio.wait(
[pending, cancelling], return_when=asyncio.FIRST_COMPLETED
)
- if cancelling in done or await request.is_disconnected():
+ if cancelling in done:
+ # HEL-908: cancel is terminal and explicit — the
+ # still-connected caller learns this turn was
+ # superseded instead of mistaking the stream end
+ # for a network failure and retrying it.
+ yield event(data, "chat.cancelled")
+ return
+ if await request.is_disconnected():
return
try:
delta = pending.result()
@@ -708,9 +728,18 @@ def create_app(
completed = True
if delta.done:
break
- if active["cancel"].is_set() or await request.is_disconnected():
+ if active["cancel"].is_set():
+ yield event(data, "chat.cancelled")
return
- if not text.strip() or not completed:
+ if await request.is_disconnected():
+ return
+ if not completed:
+ raise RuntimeError("Empty upstream response")
+ # HEL-908: reasoning never becomes her archived reply — the
+ # turn-level split is the belt-and-braces behind the adapter's
+ # streaming separator (residual >HOLD_LIMIT leaks die here).
+ text, _dropped_reasoning = split_reasoning(text)
+ if not text.strip():
raise RuntimeError("Empty upstream response")
with store.conn:
inserted = store.execute(
diff --git a/src/cybergril/contracts/utterance.py b/src/cybergril/contracts/utterance.py
index 1520c8b..54a6510 100644
--- a/src/cybergril/contracts/utterance.py
+++ b/src/cybergril/contracts/utterance.py
@@ -83,6 +83,10 @@ class LLMDelta:
done: bool = False
finish_reason: str | None = None
usage: dict[str, int] = field(default_factory=dict)
+ reasoning: str = ""
+ """模型内部思考增量(HEL-908 分流):适配层把 reasoning_content 字段与
+ 约定块从可见正文中分离到这里;仅供诊断,绝不持久化、不下发
+ chat.delta、不进字幕或 TTS。"""
@dataclass(frozen=True, slots=True)
diff --git a/src/cybergril/providers/llm/openai_compat.py b/src/cybergril/providers/llm/openai_compat.py
index ee804e3..5a2929f 100644
--- a/src/cybergril/providers/llm/openai_compat.py
+++ b/src/cybergril/providers/llm/openai_compat.py
@@ -24,6 +24,7 @@ from ...contracts import (
)
from ..base import ProviderError
from .base import LLMProvider
+from .reasoning import ReasoningSeparator
class OpenAICompatLLM(LLMProvider):
@@ -107,11 +108,20 @@ class OpenAICompatLLM(LLMProvider):
f"LLM {self.name} HTTP {resp.status_code}: {body[:400]}"
)
+ # HEL-908: reasoning/user-content separation at the adapter seam —
+ # reasoning_content deltas (DeepSeek-style field) go to the
+ # reasoning side-channel, inline conventions are stripped
+ # cross-chunk; only visible text ever reaches delta.text.
+ separator = ReasoningSeparator()
+
async for line in resp.aiter_lines():
if not line.startswith("data:"):
continue
data = line[5:].strip()
if data == "[DONE]":
+ tail_visible, tail_reasoning = separator.finish()
+ if tail_visible or tail_reasoning:
+ yield LLMDelta(text=tail_visible, reasoning=tail_reasoning)
yield LLMDelta(done=True)
return
try:
@@ -122,12 +132,18 @@ class OpenAICompatLLM(LLMProvider):
choice = (obj.get("choices") or [{}])[0]
delta = choice.get("delta") or {}
text = delta.get("content") or ""
+ field_reasoning = (
+ delta.get("reasoning_content") or delta.get("reasoning") or ""
+ )
+ visible, dropped = separator.feed(text) if text else ("", "")
+ reasoning = dropped + field_reasoning
finish = choice.get("finish_reason")
usage = obj.get("usage") or {}
- if text or finish or usage:
+ if visible or reasoning or finish or usage:
yield LLMDelta(
- text=text,
+ text=visible,
+ reasoning=reasoning,
done=False,
finish_reason=finish,
usage=usage if isinstance(usage, dict) else {},
diff --git a/src/cybergril/providers/llm/reasoning.py b/src/cybergril/providers/llm/reasoning.py
new file mode 100644
index 0000000..e8d02f4
--- /dev/null
+++ b/src/cybergril/providers/llm/reasoning.py
@@ -0,0 +1,201 @@
+"""Reasoning / user-content separation at the model-adapter seam (HEL-908).
+
+The dialogue endpoint (halogen-qwen3.8-flash-next, OpenAI-compatible) is a
+Qwen3-family model served raw: some replies open a reasoning phase inline in
+``content``. The recorded evidence (HEL-907 window, companion.db row id=12 and
+tts-window-tail.log) shows the exact shape:
+
+ Considering the limited time by the user, ... now.\\n\\n\\n大圣,您这定身法…
+
+i.e. reasoning text arrives in ``content`` with NO opening ```` (the
+serving chat template pre-fills it) and a stray ```` closes it, while
+other replies from the same endpoint carry no tags at all, and DeepSeek-style
+endpoints instead stream a separate ``reasoning_content`` delta field.
+
+This module is the single boundary between raw model output and user-visible
+content: visible text is the ONLY thing that may be persisted as her reply,
+streamed as chat.delta (subtitles) or spoken (TTS). Reasoning is exposed on
+``LLMDelta.reasoning`` for diagnostics and dropped everywhere else — it is
+never persisted. This is the same ```` text convention vLLM/SGLang
+reasoning parsers implement server-side (GOV-006: reuse the mature
+convention, do not build a reasoning engine): no prompt changes, no model
+behavior changes, and nothing is deleted except the tag-delimited reasoning
+itself.
+
+Streaming caveat (documented residual, offline-verifiable): an implicit-open
+reasoning phase is indistinguishable from visible text until its ````
+arrives, so the separator holds back the beginning of every stream until
+either a tag appears, a Chinese sentence ends, or HOLD_LIMIT characters
+accumulate. The recorded leak (closer at ~104 chars) is fully covered;
+reasoning whose closer lies beyond HOLD_LIMIT streams partially visible —
+``split_reasoning`` still cleans the archived reply and the receipt text at
+turn level (api/app.py). Removing the residual needs the upstream to separate
+reasoning itself (reasoning_content field or a server-side parser), which
+cannot be probed under the zero-real-request rule of this rework.
+"""
+
+from __future__ import annotations
+
+THINK_OPEN = ""
+THINK_CLOSE = ""
+
+# Held prefix before the stream is known visible: comfortably above the
+# recorded implicit-open reasoning length (~104 chars incl. ).
+HOLD_LIMIT = 128
+# A finished Chinese sentence ends the hold: her replies are Chinese, and the
+# recorded reasoning contains none of these before its closer. ASCII "." and
+# newline are deliberately NOT flush triggers (both occur inside reasoning).
+_SENTENCE_ENDS = "。!?!?"
+
+
+def _partial_tag_tail(buffer: str, tags: tuple[str, ...]) -> int:
+ """Length of the longest buffer suffix that could still grow into one of
+ ``tags`` (cross-chunk tag safety); 0 when no suffix can."""
+ longest = max(len(t) for t in tags)
+ for size in range(min(len(buffer), longest - 1), 0, -1):
+ if any(tag.startswith(buffer[-size:]) for tag in tags):
+ return size
+ return 0
+
+
+class ReasoningSeparator:
+ """Stateful streaming separator for one upstream reply.
+
+ ``feed``/``finish`` return ``(visible_delta, reasoning_delta)``; the
+ concatenation of all visible deltas plus the finish tail is the visible
+ text, everything else is reasoning. Untagged content is always preserved
+ byte-for-byte (Chinese or English) — only tag-delimited reasoning is
+ dropped.
+ """
+
+ def __init__(self, *, hold_limit: int = HOLD_LIMIT) -> None:
+ self._hold_limit = hold_limit
+ self._mode = "hold" # hold | think | visible
+ self._buffer = ""
+ self._reasoning = ""
+ self._strip_pending = False # drop leading ws until real content (post-think)
+
+ @property
+ def reasoning(self) -> str:
+ """All reasoning seen so far (diagnostics only; never persisted)."""
+ return self._reasoning
+
+ def feed(self, chunk: str) -> tuple[str, str]:
+ self._buffer += chunk
+ return self._drain(final=False)
+
+ def finish(self) -> tuple[str, str]:
+ """End of stream: resolve remaining buffer (partial tags are text)."""
+ return self._drain(final=True)
+
+ def _drain(self, *, final: bool) -> tuple[str, str]:
+ visible = ""
+ reasoning = ""
+ while True:
+ if self._mode == "hold":
+ open_at = self._buffer.find(THINK_OPEN)
+ close_at = self._buffer.find(THINK_CLOSE)
+ if close_at != -1 and (open_at == -1 or close_at < open_at):
+ # implicit-open (template pre-filled ) or a normal
+ # block that opened at/after position 0: everything through
+ # the closer is reasoning
+ dropped = self._buffer[: close_at + len(THINK_CLOSE)]
+ self._reasoning += dropped
+ reasoning += dropped
+ self._buffer = self._buffer[close_at + len(THINK_CLOSE):]
+ self._mode = "visible"
+ self._strip_pending = True
+ continue
+ if open_at != -1:
+ visible += self._buffer[:open_at]
+ self._buffer = self._buffer[open_at + len(THINK_OPEN):]
+ self._mode = "think"
+ continue
+ if final:
+ self._mode = "visible" # untagged short reply: never swallow
+ continue
+ sentence_end = min(
+ (
+ i
+ for i in (self._buffer.find(c) for c in _SENTENCE_ENDS)
+ if i != -1
+ ),
+ default=-1,
+ )
+ if sentence_end != -1 or len(self._buffer) >= self._hold_limit:
+ self._mode = "visible"
+ continue
+ return visible, reasoning
+ if self._mode == "think":
+ close_at = self._buffer.find(THINK_CLOSE)
+ if close_at != -1:
+ dropped = self._buffer[: close_at + len(THINK_CLOSE)]
+ self._reasoning += dropped
+ reasoning += dropped
+ self._buffer = self._buffer[close_at + len(THINK_CLOSE):]
+ self._mode = "visible"
+ self._strip_pending = True
+ continue
+ keep = 0 if final else _partial_tag_tail(self._buffer, (THINK_CLOSE,))
+ cut = len(self._buffer) - keep
+ if cut > 0:
+ self._reasoning += self._buffer[:cut]
+ self._buffer = self._buffer[cut:]
+ return visible, ""
+ # visible
+ if self._strip_pending and self._buffer:
+ stripped = self._buffer.lstrip()
+ if stripped:
+ self._buffer = stripped
+ self._strip_pending = False
+ else: # whitespace only so far; more may follow
+ self._buffer = ""
+ open_at = self._buffer.find(THINK_OPEN)
+ close_at = self._buffer.find(THINK_CLOSE)
+ if close_at != -1 and (open_at == -1 or close_at < open_at):
+ # stray closer after output already streamed: drop the marker;
+ # split_reasoning still cleans the turn-level text
+ visible += self._buffer[:close_at]
+ self._reasoning += THINK_CLOSE
+ reasoning += THINK_CLOSE
+ self._buffer = self._buffer[close_at + len(THINK_CLOSE):]
+ self._strip_pending = True
+ continue
+ if open_at != -1:
+ visible += self._buffer[:open_at]
+ self._buffer = self._buffer[open_at + len(THINK_OPEN):]
+ self._mode = "think"
+ continue
+ if final:
+ visible += self._buffer
+ self._buffer = ""
+ return visible, reasoning
+ keep = _partial_tag_tail(self._buffer, (THINK_OPEN, THINK_CLOSE))
+ cut = len(self._buffer) - keep
+ if cut > 0:
+ visible += self._buffer[:cut]
+ self._buffer = self._buffer[cut:]
+ return visible, reasoning
+
+
+def split_reasoning(text: str) -> tuple[str, str]:
+ """Turn-level final split of one complete reply: everything through the
+ FIRST ```` (opened explicitly or implicitly) is reasoning, later
+ explicit ``...`` blocks are reasoning too, the rest is the
+ visible reply. Belt-and-braces for streams that leaked past HOLD_LIMIT —
+ the archived reply and its receipt always bind the clean text."""
+ rest = text
+ reasoning = ""
+ close_at = rest.find(THINK_CLOSE)
+ if close_at != -1:
+ reasoning += rest[: close_at + len(THINK_CLOSE)]
+ rest = rest[close_at + len(THINK_CLOSE):].lstrip()
+ while True:
+ open_at = rest.find(THINK_OPEN)
+ if open_at == -1:
+ return rest, reasoning
+ close_at = rest.find(THINK_CLOSE, open_at)
+ if close_at == -1:
+ return rest[:open_at], reasoning + rest[open_at:]
+ reasoning += rest[open_at: close_at + len(THINK_CLOSE)]
+ rest = rest[:open_at] + rest[close_at + len(THINK_CLOSE):]
diff --git a/src/cybergril/voice/chat_api_client.py b/src/cybergril/voice/chat_api_client.py
index 8d6ea82..883da1d 100644
--- a/src/cybergril/voice/chat_api_client.py
+++ b/src/cybergril/voice/chat_api_client.py
@@ -36,6 +36,9 @@ class ChatApiTurn:
receipt: dict[str, Any] | None = None
error_phi: str | None = None
completed: bool = False
+ cancelled: bool = False
+ """The SERVER ended this turn as superseded (chat.cancelled, HEL-908).
+ Terminal, not a connection error: the same turn_id must never be retried."""
@dataclass
@@ -142,6 +145,12 @@ class ChatApiClient:
elif kind == "chat.error":
turn.error_phi = frame.get("phi") or ""
got_terminal = True
+ elif kind == "chat.cancelled":
+ # HEL-908: the server cancelled this turn (superseded by a
+ # newer one). A clean terminal — the caller ends the turn; a
+ # retry of the same turn_id would only be refused again.
+ turn.cancelled = True
+ got_terminal = True
if not got_terminal:
raise ChatApiConnectionError("chat stream ended without final or error")
return turn
diff --git a/src/cybergril/voice/llm_chat_api.py b/src/cybergril/voice/llm_chat_api.py
index 8b28263..13c1353 100644
--- a/src/cybergril/voice/llm_chat_api.py
+++ b/src/cybergril/voice/llm_chat_api.py
@@ -94,6 +94,11 @@ class ChatApiLLMStream(LLMStream):
self.receipt: dict[str, Any] | None = None
self.error_phi: str | None = None
self.completed = False
+ # HEL-908: once the SERVER has cancelled this turn (chat.cancelled),
+ # the turn_id is dead — re-running _run must never re-acquire the
+ # server's active-turn slot (a late retry would otherwise cancel the
+ # newer turn, the exact HEL-907 defect).
+ self.server_cancelled = False
# Exact ownership (set by CyberGrillLLM.chat from the SDK generation
# context): the SpeechHandle whose pipeline task created this stream,
# and its id as the subtitle turn key. None/"" = unattributed.
@@ -112,7 +117,24 @@ class ChatApiLLMStream(LLMStream):
ChatChunk(id=self.turn_id, delta=ChoiceDelta(role="assistant", content=content))
)
+ def _interrupt_owner(self) -> None:
+ """Stop this generation's cached playout through the SDK's own
+ SpeechHandle.interrupt (the same path user barge-in takes), so a
+ server-side cancel stops already-synthesized audio — not just
+ generation. Any failure here must not mask the cancelled end."""
+ handle = self.speech_handle
+ if handle is None:
+ return
+ with contextlib.suppress(Exception):
+ handle.interrupt()
+
async def _run(self) -> None:
+ if self.server_cancelled:
+ # A cancelled turn never re-acquires ownership, whatever re-runs
+ # _run (SDK retry loop, reconnect): no network call at all.
+ raise APIStatusError(
+ "chat turn cancelled by server", status_code=499, body=None
+ )
text = latest_user_text(self._chat_ctx)
if not text:
raise APIStatusError(
@@ -130,6 +152,17 @@ class ChatApiLLMStream(LLMStream):
raise APIStatusError(
f"chat api failure: {exc}", status_code=502, body=None
) from exc
+ if turn.cancelled:
+ # HEL-908: cancelled is a TERMINAL state, never a transient
+ # network error. End on the SDK's own client-closed-request path:
+ # LLMStream._main_task (verified against installed 1.8.8 source)
+ # returns gracefully for APIStatusError 499 — no retry, no
+ # exception — and interrupt() stops the cached playout.
+ self.server_cancelled = True
+ self._interrupt_owner()
+ raise APIStatusError(
+ "chat turn cancelled by server", status_code=499, body=None
+ )
self.text = turn.text
self.receipt = turn.receipt
self.error_phi = turn.error_phi
diff --git a/tests/test_api.py b/tests/test_api.py
index c8dde0d..66987c0 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -314,7 +314,10 @@ async def test_disconnect_closes_real_openai_http_stream(tmp_path):
async def completion():
async def chunks():
try:
- yield 'data: {"choices":[{"delta":{"content":"部分"}}]}\n\n'
+ # ends with a sentence terminator so the HEL-908 reasoning
+ # separator flushes it immediately (this test is about upstream
+ # closure on disconnect, not about hold semantics)
+ yield 'data: {"choices":[{"delta":{"content":"部分。"}}]}\n\n'
await asyncio.sleep(30)
finally:
closed.set()
diff --git a/tests/test_reasoning_separation_hel908.py b/tests/test_reasoning_separation_hel908.py
new file mode 100644
index 0000000..7292ecd
--- /dev/null
+++ b/tests/test_reasoning_separation_hel908.py
@@ -0,0 +1,246 @@
+"""HEL-908 reasoning separation tests: the adapter seam between raw model
+output and user-visible content, against the REAL ReasoningSeparator and the
+REAL OpenAICompatLLM adapter (fake SSE transports, zero network).
+
+The recorded evidence (HEL-907 window, companion.db row id=12 and
+tts-window-tail.log) is replayed verbatim: the dialogue endpoint streamed
+"Considering the limited time by the user, ... now.\\n\\n\\n大圣,…"
+into ``content`` — implicit-open reasoning (no ````, the chat template
+pre-fills it) that reached subtitles AND TTS.
+"""
+
+from __future__ import annotations
+
+import json
+import sys
+from pathlib import Path
+
+import httpx
+import pytest
+
+sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
+
+from cybergril.providers.llm.openai_compat import OpenAICompatLLM
+from cybergril.providers.llm.reasoning import (
+ HOLD_LIMIT,
+ ReasoningSeparator,
+ split_reasoning,
+)
+
+# companion.db row id=12 (call3, turn 3a67b253) — verbatim recorded stream.
+RECORDED_RAW = (
+ "Considering the limited time by the user, I have to give the solution "
+ "based on the thinking directly now.\n\n\n"
+ "大圣,您这定身法没点准啊,我可不是什么妖精妖怪,我是一行代码,"
+ "连肉都没有,怕硌着您的金箍棒。"
+)
+RECORDED_REASONING = (
+ "Considering the limited time by the user, I have to give the solution "
+ "based on the thinking directly now.\n"
+)
+RECORDED_VISIBLE = (
+ "大圣,您这定身法没点准啊,我可不是什么妖精妖怪,我是一行代码,"
+ "连肉都没有,怕硌着您的金箍棒。"
+)
+
+
+def run_stream(text: str, chunk_size: int) -> tuple[str, str]:
+ separator = ReasoningSeparator()
+ visible_parts: list[str] = []
+ for i in range(0, len(text), chunk_size):
+ v, _ = separator.feed(text[i : i + chunk_size])
+ visible_parts.append(v)
+ v, _ = separator.finish()
+ visible_parts.append(v)
+ return "".join(visible_parts), separator.reasoning
+
+
+# ── streaming separator ──────────────────────────────────────────────────────
+
+
+@pytest.mark.parametrize("chunk", [1, 2, 3, 5, 7, 13, 1000])
+def test_recorded_stream_leaks_nothing_at_any_chunk_size(chunk):
+ visible, reasoning = run_stream(RECORDED_RAW, chunk)
+ assert visible == RECORDED_VISIBLE
+ assert reasoning == RECORDED_REASONING
+
+
+@pytest.mark.parametrize("split_at", range(1, len("隐藏可见")))
+def test_explicit_tags_split_across_chunks(split_at):
+ text = "隐藏可见"
+ head, tail = text[:split_at], text[split_at:]
+ separator = ReasoningSeparator()
+ v1, _ = separator.feed(head)
+ v2, _ = separator.feed(tail)
+ v3, _ = separator.finish()
+ assert v1 + v2 + v3 == "可见"
+
+
+def test_explicit_think_block_anywhere_in_stream():
+ visible, _ = run_stream("先说。中途思考后说。", 3)
+ assert visible == "先说。后说。"
+
+
+def test_unclosed_think_yields_no_visible_text():
+ separator = ReasoningSeparator()
+ visible, _ = separator.feed("只想不说到结尾")
+ tail_visible, _ = separator.finish()
+ assert visible + tail_visible == ""
+
+
+def test_safe_no_think_chinese_streams_immediately():
+ separator = ReasoningSeparator()
+ visible, _ = separator.feed("哈哈,那走吧。")
+ assert visible == "哈哈,那走吧。"
+
+
+def test_normal_chinese_body_preserved_byte_for_byte():
+ text = "今天水母网我背,你负责举着泡泡喊“出发”。\n\n抓完水母我们就坐在海滩上。"
+ visible, reasoning = run_stream(text, 4)
+ assert visible == text
+ assert reasoning == ""
+
+
+def test_normal_english_body_preserved_byte_for_byte():
+ text = (
+ "Sure, listening to you. A reply that happens to be English is normal "
+ "user-facing content and must survive the separator untouched, "
+ "including newlines\nand punctuation."
+ )
+ visible, reasoning = run_stream(text, 5)
+ assert visible == text
+ assert reasoning == ""
+
+
+def test_untagged_short_stream_flushes_at_finish():
+ separator = ReasoningSeparator()
+ v, _ = separator.feed("嗯") # below HOLD_LIMIT, no sentence end: held
+ assert v == ""
+ v, _ = separator.finish()
+ assert v == "嗯"
+
+
+def test_residual_beyond_hold_limit_is_cleaned_at_turn_level():
+ """Documented residual: implicit-open reasoning whose closer lies beyond
+ HOLD_LIMIT streams partially visible; the turn-level split still returns
+ the clean reply for persistence and the receipt."""
+ long_reasoning = "r" * (HOLD_LIMIT + 50) + ""
+ text = long_reasoning + "干净的回答。"
+ streamed_visible, _ = run_stream(text, 64)
+ assert streamed_visible != "干净的回答。" # partial streaming leak (known)
+ final_visible, dropped = split_reasoning(text)
+ assert final_visible == "干净的回答。"
+ assert long_reasoning in dropped
+
+
+# ── turn-level final split ───────────────────────────────────────────────────
+
+
+def test_split_recorded_row_is_clean():
+ visible, reasoning = split_reasoning(RECORDED_RAW)
+ assert visible == RECORDED_VISIBLE
+ assert reasoning == RECORDED_REASONING
+
+
+def test_split_explicit_blocks_and_unclosed():
+ assert split_reasoning("a答") == ("答", "a")
+ assert split_reasoning("a") == ("", "a")
+ assert split_reasoning("正常回答") == ("正常回答", "")
+ visible, reasoning = split_reasoning("a一b二")
+ assert visible == "一二"
+ assert reasoning == "ab"
+
+
+# ── REAL OpenAICompatLLM adapter with fake upstream SSE ─────────────────────
+
+
+def sse_response(deltas: list[dict]) -> httpx.Response:
+ lines = []
+ for d in deltas:
+ lines.append("data: " + json.dumps({"choices": [{"delta": d}]}, ensure_ascii=False))
+ lines.append("data: [DONE]")
+ return httpx.Response(
+ 200, headers={"content-type": "text/event-stream"},
+ content=("\n\n".join(lines) + "\n\n").encode(),
+ )
+
+
+async def collect_provider(provider: OpenAICompatLLM) -> tuple[str, str]:
+ text = reasoning = ""
+ async for delta in provider.stream([type("M", (), {"role": "user", "content": "你好"})()]):
+ text += delta.text
+ reasoning += delta.reasoning
+ return text, reasoning
+
+
+async def test_adapter_separates_reasoning_field_from_content():
+ seen: list[httpx.Request] = []
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ seen.append(request)
+ return sse_response(
+ [
+ {"reasoning_content": "先在心里盘算一下"},
+ {"content": ""},
+ {"content": RECORDED_VISIBLE[:4]},
+ {"content": RECORDED_VISIBLE[4:]},
+ ]
+ )
+
+ provider = OpenAICompatLLM(base_url="http://isolated/v1", model="m")
+ provider._client = httpx.AsyncClient(
+ base_url="http://isolated/v1", transport=httpx.MockTransport(handler)
+ )
+ try:
+ text, reasoning = await collect_provider(provider)
+ assert text == RECORDED_VISIBLE
+ assert reasoning == "先在心里盘算一下"
+ finally:
+ await provider.aclose()
+
+
+async def test_adapter_filters_recorded_inline_leak():
+ def handler(request: httpx.Request) -> httpx.Response:
+ # recorded id=12 shape: reasoning text + stray closer, no opening tag;
+ # chunks split the closing tag across SSE frames
+ return sse_response(
+ [
+ {"content": RECORDED_RAW[:60]},
+ {"content": RECORDED_RAW[60:104]},
+ {"content": RECORDED_RAW[104:112]},
+ {"content": RECORDED_RAW[112:]},
+ ]
+ )
+
+ provider = OpenAICompatLLM(base_url="http://isolated/v1", model="m")
+ provider._client = httpx.AsyncClient(
+ base_url="http://isolated/v1", transport=httpx.MockTransport(handler)
+ )
+ try:
+ text, reasoning = await collect_provider(provider)
+ assert text == RECORDED_VISIBLE, "reasoning must never reach delta.text"
+ assert "Considering" in reasoning and "" in reasoning
+ finally:
+ await provider.aclose()
+
+
+async def test_adapter_preserves_english_content_and_flags():
+ def handler(request: httpx.Request) -> httpx.Response:
+ return sse_response(
+ [
+ {"content": "Plain English reply, kept fully. "},
+ {"reasoning_content": "internal note"},
+ {"content": "Second sentence."},
+ ]
+ )
+
+ provider = OpenAICompatLLM(base_url="http://isolated/v1", model="m")
+ provider._client = httpx.AsyncClient(
+ base_url="http://isolated/v1", transport=httpx.MockTransport(handler)
+ )
+ try:
+ text, reasoning = await collect_provider(provider)
+ assert text == "Plain English reply, kept fully. Second sentence."
+ assert reasoning == "internal note"
+ finally:
+ await provider.aclose()
diff --git a/tests/test_rework_hel908.py b/tests/test_rework_hel908.py
new file mode 100644
index 0000000..25581e8
--- /dev/null
+++ b/tests/test_rework_hel908.py
@@ -0,0 +1,366 @@
+"""HEL-908 rework tests: text-interrupt turn semantics through the REAL app.
+
+Replays the recorded HEL-907 window defects offline (fake scripted LLM
+provider, real FastAPI app + real ChatApiClient protocol client, zero network
+beyond 127.0.0.1):
+
+1. call1 third-turn defect: her voice turn was cancelled by the user's text
+ POST BEFORE its first delta; the SSE then ended without a terminal frame,
+ the worker's adapter raised a connection error, livekit-agents silently
+ RETRIED the same turn_id, and that retry cancelled the text turn and
+ re-spoke a full ~32s reply (worker.log line 108, api.log POST #5,
+ chat_message ids 5/6/8).
+2. call3 think leak: the model's implicit-open reasoning + stray ````
+ streamed into chat.delta and TTS (companion.db row id=12).
+"""
+
+from __future__ import annotations
+
+import asyncio
+import hashlib
+import sys
+from pathlib import Path
+from uuid import uuid4
+
+import httpx
+import pytest
+
+sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
+sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tests"))
+
+from conftest import engineering_budget # noqa: E402
+from test_r2 import R2LLM # noqa: E402
+
+from cybergril.api.app import create_app # noqa: E402
+from cybergril.contracts import LLMDelta # noqa: E402
+from cybergril.life.runtime import LifeConfig # noqa: E402
+from cybergril.think.router import build_router # noqa: E402
+from cybergril.think.scenes import Tier # noqa: E402
+from cybergril.voice.chat_api_client import ChatApiClient # noqa: E402
+
+CREATE = {"name": "返修", "birth": "2000-10-18", "hour": "亥时", "relation": "朋友"}
+
+# companion.db row id=12 (call3), verbatim: implicit-open reasoning + stray
+# closer with NO opening tag, then her actual reply.
+RECORDED_RAW = (
+ "Considering the limited time by the user, I have to give the solution "
+ "based on the thinking directly now.\n\n\n"
+ "大圣,您这定身法没点准啊,我可不是什么妖精妖怪,我是一行代码,"
+ "连肉都没有,怕硌着您的金箍棒。"
+)
+RECORDED_VISIBLE = (
+ "大圣,您这定身法没点准啊,我可不是什么妖精妖怪,我是一行代码,"
+ "连肉都没有,怕硌着您的金箍棒。"
+)
+RECORDED_CHUNKS = [RECORDED_RAW[:60], RECORDED_RAW[60:104],
+ RECORDED_RAW[104:112], RECORDED_RAW[112:]]
+
+
+class ScriptedLLM(R2LLM):
+ """R2 controlled provider with HEL-908 scripted dialogue behaviour."""
+
+ def __init__(self):
+ super().__init__()
+ self.script: list[str] = []
+ self.hang_mode = "" # "" | "before" | "after"
+ self.hang_release = asyncio.Event() # unset: a hang parks until released
+ self.reply = "文字轮正常拿到了回复。"
+
+ async def stream(self, messages, **kwargs):
+ if "只返回JSON,不要Markdown" in messages[0].content:
+ async for delta in super().stream(messages, **kwargs):
+ yield delta
+ return
+ self.calls.append(messages)
+ if self.hang_mode == "before":
+ await self.hang_release.wait()
+ if self.mode == "error":
+ raise OSError("scripted upstream fault")
+ for piece in self.script:
+ yield LLMDelta(text=piece)
+ if not self.script:
+ yield LLMDelta(text=self.reply)
+ if self.hang_mode == "after":
+ await self.hang_release.wait()
+ yield LLMDelta(done=True)
+
+
+@pytest.fixture
+def rework(tmp_path):
+ provider = ScriptedLLM()
+ router = build_router(
+ providers={t: provider for t in (Tier.HIGH, Tier.STANDARD, Tier.LOW)},
+ budget=engineering_budget(),
+ )
+ app = create_app(db_path=tmp_path / "hel908.db", router=router,
+ life_config=LifeConfig(enabled=True, poll_seconds=3600),
+ semantic_mode="stub")
+ from fastapi.testclient import TestClient
+
+ with TestClient(app) as client:
+ client.post("/api/companion", json=CREATE)
+ epoch = client.get("/api/health").json()["epoch"]
+ yield app, provider, epoch
+ provider.mode = ""
+
+
+def sse_chunks(raw_pieces: list[str]) -> bytes:
+ """SSE lines for a raw content stream (mirrors the endpoint's wire shape)."""
+ import json as _json
+
+ lines = [
+ "data: " + _json.dumps({"choices": [{"delta": {"content": piece}}]},
+ ensure_ascii=False)
+ for piece in raw_pieces
+ ]
+ lines.append("data: [DONE]")
+ return ("\n\n".join(lines) + "\n\n").encode()
+
+
+@pytest.fixture
+def adapter_app(tmp_path):
+ """The REAL OpenAICompatLLM behind the real app, fed by a scripted
+ OpenAI-compatible SSE gateway (the seam where the reasoning separation
+ lives, exercised end to end)."""
+ from cybergril.providers.llm.openai_compat import OpenAICompatLLM
+
+ state = {"answers": [sse_chunks(RECORDED_CHUNKS)]}
+
+ async def handler(request: httpx.Request) -> httpx.Response:
+ if request.url.path.endswith("/models"):
+ return httpx.Response(200, json={"data": [{"id": "recorded"}]})
+ answer = state["answers"][0]
+ return httpx.Response(
+ 200, headers={"content-type": "text/event-stream"}, content=answer
+ )
+
+ provider = OpenAICompatLLM(base_url="http://recorded/v1", model="recorded")
+ provider._client = httpx.AsyncClient(
+ base_url="http://recorded/v1", transport=httpx.MockTransport(handler)
+ )
+ router = build_router(
+ providers={t: provider for t in (Tier.HIGH, Tier.STANDARD, Tier.LOW)},
+ budget=engineering_budget(),
+ )
+ app = create_app(db_path=tmp_path / "hel908-adapter.db", router=router,
+ life_config=LifeConfig(enabled=True, poll_seconds=3600),
+ semantic_mode="stub")
+ from fastapi.testclient import TestClient
+
+ with TestClient(app) as client:
+ client.post("/api/companion", json=CREATE)
+ epoch = client.get("/api/health").json()["epoch"]
+ yield app, state, epoch
+
+
+def api_client(app) -> ChatApiClient:
+ return ChatApiClient("http://asgi", transport=httpx.ASGITransport(app=app))
+
+
+@pytest.fixture
+def live_rework(tmp_path):
+ """Real-socket variant (uvicorn on 127.0.0.1, ephemeral port) for the
+ cases that need genuine incremental SSE delivery — httpx.ASGITransport
+ does not stream response bodies chunk-by-chunk mid-response. Same pattern
+ as test_voice_chat_adapter's live_server: its own app instance on the same
+ file-backed database (one lifespan owner per app)."""
+ import socket
+ import threading
+ import time as _time
+
+ import uvicorn
+
+ provider = ScriptedLLM()
+ router = build_router(
+ providers={t: provider for t in (Tier.HIGH, Tier.STANDARD, Tier.LOW)},
+ budget=engineering_budget(),
+ )
+ served = create_app(db_path=tmp_path / "hel908-live.db", router=router,
+ life_config=LifeConfig(enabled=True, poll_seconds=3600),
+ semantic_mode="stub")
+ from fastapi.testclient import TestClient
+
+ with TestClient(served) as client:
+ client.post("/api/companion", json=CREATE)
+ epoch = client.get("/api/health").json()["epoch"]
+
+ probe = socket.socket()
+ probe.bind(("127.0.0.1", 0))
+ port = probe.getsockname()[1]
+ probe.close()
+ server = uvicorn.Server(uvicorn.Config(served, host="127.0.0.1", port=port,
+ log_level="error"))
+ thread = threading.Thread(target=server.run, daemon=True)
+ thread.start()
+ for _ in range(100):
+ try:
+ httpx.get(f"http://127.0.0.1:{port}/api/health", timeout=1)
+ break
+ except Exception:
+ _time.sleep(0.1)
+ yield f"http://127.0.0.1:{port}", served, provider, epoch
+ server.should_exit = True
+ thread.join(timeout=5)
+
+
+def rows(app):
+ return app.state.store.query(
+ "SELECT id,role,text,status,turn_id FROM chat_message ORDER BY id"
+ )
+
+
+async def test_text_interrupt_pre_first_delta_no_retry_no_revive(rework):
+ """The recorded call1 path: cancel arrives before the first delta.
+
+ The cancelled voice stream must end on an explicit chat.cancelled
+ terminal; a late retry of the same turn_id must be refused without
+ re-acquiring the gate (so it can never cancel the text turn), and no
+ regeneration may run."""
+ app, provider, epoch = rework
+ provider.hang_mode = "before" # voice turn parks before its first token
+ voice_tid = str(uuid4())
+ deltas: list[str] = []
+ voice = asyncio.create_task(
+ api_client(app).send_turn("你是不是早就想睡我了呀", turn_id=voice_tid,
+ epoch=epoch, on_delta=deltas.append)
+ )
+ await asyncio.sleep(0.5) # voice turn is the active one, pre-first-delta
+ provider.hang_mode = ""
+
+ text = await api_client(app).send_turn("(文字通道)我改用打字了", epoch=epoch)
+ assert text.completed and text.text == provider.reply, \
+ "the newer text turn keeps the gate and gets its reply"
+
+ cancelled_voice = await voice
+ assert cancelled_voice.cancelled and not cancelled_voice.completed
+ assert deltas == [], "recorded case: no chunk was ever sent before cancel"
+
+ calls_after_window = len(provider.calls)
+ retry = await api_client(app).send_turn("你是不是早就想睡我了呀",
+ turn_id=voice_tid, epoch=epoch)
+ assert retry.cancelled and not retry.completed, \
+ "a cancelled turn_id is terminal — the refusal answers chat.cancelled"
+ assert len(provider.calls) == calls_after_window, \
+ "the refused retry must not regenerate (no re-spoken 32s reply)"
+
+ assert len(provider.calls) == 2 # exactly: voice attempt + text turn
+ table = rows(app)
+ voice_rows = [r for r in table if r["turn_id"] == voice_tid]
+ assert voice_rows and voice_rows[0]["status"] == "cancelled"
+ assert not [r for r in table
+ if r["role"] == "companion" and r["turn_id"] == voice_tid], \
+ "the superseded voice turn never becomes her archived reply"
+ text_rows = [r for r in table if r["text"] == "(文字通道)我改用打字了"]
+ assert text_rows and text_rows[0]["status"] == "sent"
+ assert any(r["role"] == "companion" and r["status"] == "sent"
+ for r in table), "the text turn's reply is archived"
+
+
+async def test_text_interrupt_after_deltas_cancels_cleanly(live_rework):
+ """Cancel AFTER the voice turn already streamed: the caller keeps the
+ deltas it saw, then receives the terminal cancel frame. Real socket: the
+ delta must arrive DURING the parked stream (ASGITransport buffers)."""
+ url, app, provider, epoch = live_rework
+ provider.script = ["哈哈,那走吧。"]
+ provider.hang_mode = "after"
+ got_delta = asyncio.Event()
+ deltas: list[str] = []
+ voice_tid = str(uuid4())
+ live_client = ChatApiClient(url)
+ voice = asyncio.create_task(
+ live_client.send_turn("语音说到一半", turn_id=voice_tid, epoch=epoch,
+ on_delta=lambda d: (deltas.append(d), got_delta.set()))
+ )
+ await asyncio.wait_for(got_delta.wait(), timeout=10)
+ provider.hang_mode = ""
+
+ text = await live_client.send_turn("文字打断", epoch=epoch)
+ assert text.completed
+
+ cancelled_voice = await voice
+ assert cancelled_voice.cancelled
+ assert "".join(deltas) == "哈哈,那走吧。"
+ assert not [r for r in rows(app)
+ if r["role"] == "companion" and r["turn_id"] == voice_tid]
+ await live_client.aclose()
+
+
+async def test_think_leak_replay_clean_deltas_archive_and_receipt(adapter_app):
+ """The recorded id=12 stream through the real adapter and the whole app:
+ chat.delta, the archived row and the receipt all bind the clean visible
+ reply only."""
+ app, _, epoch = adapter_app
+ deltas: list[str] = []
+ turn = await api_client(app).send_turn("老孙来也", epoch=epoch,
+ on_delta=deltas.append)
+ assert turn.completed
+ assert turn.text == RECORDED_VISIBLE
+ assert "".join(deltas) == RECORDED_VISIBLE, \
+ "reasoning and must never stream to subtitles/TTS"
+ assert not any("Considering" in d or "think" in d for d in deltas)
+
+ archived = [r for r in rows(app) if r["role"] == "companion"]
+ assert archived and archived[0]["text"] == RECORDED_VISIBLE
+ assert turn.receipt and turn.receipt["text_hash"] == \
+ hashlib.sha256(RECORDED_VISIBLE.encode()).hexdigest()
+ assert await api_client(app).acknowledge(turn.receipt) is True
+
+
+async def test_unclosed_think_fails_closed(adapter_app):
+ """A reply that never leaves its think phase has no visible content: the
+ turn fails honestly instead of archiving reasoning as her words."""
+ app, state, epoch = adapter_app
+ state["answers"] = [sse_chunks(["只想不说到结尾"])]
+ turn = await api_client(app).send_turn("这次只有思考", epoch=epoch)
+ assert not turn.completed and not turn.cancelled
+ assert turn.error_phi and "没接上话" in turn.error_phi
+ assert not [r for r in rows(app) if r["role"] == "companion"]
+ failed = [r for r in rows(app) if r["text"] == "这次只有思考"]
+ assert failed and failed[0]["status"] == "failed"
+
+
+async def test_completed_replay_and_failed_retry_still_work(rework):
+ """The refusal branch must not disturb replay idempotence or the existing
+ retry-after-failure resilience (only 'cancelled' is terminal)."""
+ app, provider, epoch = rework
+ tid = str(uuid4())
+ first = await api_client(app).send_turn("正常一轮", turn_id=tid, epoch=epoch)
+ assert first.completed
+
+ replay = await api_client(app).send_turn("正常一轮", turn_id=tid, epoch=epoch)
+ assert replay.completed and replay.text == first.text
+ calls = len(provider.calls)
+
+ provider.mode = "error"
+ failed_tid = str(uuid4())
+ failed = await api_client(app).send_turn("会失败的一轮", turn_id=failed_tid,
+ epoch=epoch)
+ assert not failed.completed and failed.error_phi
+ provider.mode = ""
+ retried = await api_client(app).send_turn("会失败的一轮", turn_id=failed_tid,
+ epoch=epoch)
+ assert retried.completed, "failed turns stay retryable (only cancelled is final)"
+ assert len(provider.calls) == calls + 2
+
+
+async def test_client_treats_cancelled_frame_as_terminal(rework):
+ """Unit-level protocol check: chat.cancelled ends the turn client-side
+ without raising (a raise is what made livekit retry it as a network
+ failure)."""
+ import json as _json
+
+ def handler(request: httpx.Request) -> httpx.Response:
+ if request.url.path == "/api/health":
+ return httpx.Response(200, json={"epoch": 1})
+ turn_id = _json.loads(request.read())["turn_id"]
+ frame = _json.dumps(
+ {"t": "chat.cancelled", "epoch": 1, "turn_id": turn_id}
+ ).encode()
+ return httpx.Response(
+ 200, headers={"content-type": "text/event-stream"},
+ content=b"data: " + frame + b"\n\n",
+ )
+
+ client = ChatApiClient("http://x", transport=httpx.MockTransport(handler))
+ turn = await client.send_turn("只收一帧")
+ assert turn.cancelled and not turn.completed and not turn.error_phi
diff --git a/tests/test_sdk_livekit_hel908.py b/tests/test_sdk_livekit_hel908.py
new file mode 100644
index 0000000..159449e
--- /dev/null
+++ b/tests/test_sdk_livekit_hel908.py
@@ -0,0 +1,246 @@
+"""HEL-908 SDK-path harness: the cancelled-turn fix through the REAL
+livekit-agents 1.8.8 LLMStream machinery.
+
+These checks drive the installed SDK's own retry/cancel code (``LLMStream
+._main_task``, verified read-only on .12) against fake SSE transports — no
+room, no LLM/ASR/TTS call, zero network beyond the in-process transport.
+
+They need ``livekit.agents`` importable, i.e. the agent venv on .12
+(`/opt/voice-agent/agent/venv`, which has no pytest). Two ways to run:
+
+ pytest tests/test_sdk_livekit_hel908.py
+ /opt/voice-agent/agent/venv/bin/python \\
+ tests/test_sdk_livekit_hel908.py --standalone
+
+The HEL-907 defect under test: worker.log line-108's retry
+("chat stream ended without final or error ... retrying in 0.1s") fired 13ms
+after the user's text POST — the cancelled stream ended WITHOUT a terminal
+frame BEFORE any chunk was sent, so ``output_sent`` was False, the
+``_retry_on_chunk_sent`` guard was inapplicable, and the SDK silently re-ran
+``_run``: the retried POST cancelled the user's text turn and re-spoke a full
+~32s reply (api.log POST #5, chat_message ids 5/6/8).
+"""
+
+from __future__ import annotations
+
+import asyncio
+import sys
+from pathlib import Path
+
+import httpx
+
+try:
+ import pytest
+except ImportError: # the agent venv has no pytest; standalone mode only
+ pytest = None # type: ignore[assignment]
+
+sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
+
+try: # the SDK only exists in the agent venv / worker environments
+ import livekit.agents.llm as lk_llm
+ from livekit.agents import APIConnectionError, APIStatusError
+ from livekit.agents.types import APIConnectOptions
+
+ HAVE_SDK = True
+ SKIP_REASON = ""
+except Exception as exc: # pragma: no cover - environment-dependent
+ HAVE_SDK = False
+ SKIP_REASON = f"livekit-agents not importable in this env: {exc}"
+
+if pytest is not None:
+ pytestmark = pytest.mark.skipif(not HAVE_SDK, reason=SKIP_REASON or "sdk present")
+
+
+class _Raises:
+ """Minimal pytest.raises stand-in so the standalone runner (agent venv,
+ no pytest installed) executes the very same assertions."""
+
+ def __init__(self, exc_type):
+ self.exc_type = exc_type
+ self.value = None
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, exc_type, exc, tb):
+ assert exc_type is not None and issubclass(exc_type, self.exc_type), (
+ f"expected {self.exc_type.__name__}, got {exc_type.__name__ if exc_type else exc_type}"
+ )
+ self.value = exc
+ return True
+
+
+def raises(exc_type):
+ return pytest.raises(exc_type) if pytest is not None else _Raises(exc_type)
+
+if HAVE_SDK:
+ FAST = APIConnectOptions(timeout=10, retry_interval=0.05, max_retry=2)
+
+
+def sse(frames: list[str]) -> bytes:
+ return "".join(f"data: {frame}\n\n" for frame in frames).encode()
+
+
+def chat_ctx():
+ ctx = lk_llm.ChatContext()
+ ctx.add_message(role="user", content="你好呀")
+ return ctx
+
+
+class Backend:
+ """Scripted /api/chat backend: one canned answer per POST (Exception
+ instances are raised), counting every POST it serves."""
+
+ def __init__(self, answers: list):
+ self.answers = answers
+ self.chat_posts = 0
+
+ def transport(self) -> httpx.MockTransport:
+ backend = self
+
+ async def handler(request: httpx.Request) -> httpx.Response:
+ if request.url.path == "/api/health":
+ return httpx.Response(200, json={"epoch": 1})
+ backend.chat_posts += 1
+ answer = backend.answers[(backend.chat_posts - 1) % len(backend.answers)]
+ if isinstance(answer, Exception):
+ raise answer
+ return httpx.Response(
+ 200, headers={"content-type": "text/event-stream"}, content=answer
+ )
+
+ return httpx.MockTransport(handler)
+
+
+def make_llm(answers: list):
+ from cybergril.voice.chat_api_client import ChatApiClient
+ from cybergril.voice.llm_chat_api import CyberGrillLLM
+
+ backend = Backend(answers)
+ return CyberGrillLLM(
+ "http://x", client=ChatApiClient("http://x", transport=backend.transport())
+ ), backend
+
+
+class InterruptSpy:
+ """Stands in for the SDK SpeechHandle: records interrupt() calls only."""
+
+ def __init__(self) -> None:
+ self.interrupts = 0
+
+ def interrupt(self):
+ self.interrupts += 1
+ return self
+
+
+CANCELLED = sse(['{"t":"chat.cancelled","epoch":1,"turn_id":null}'])
+TWO_DELTAS_THEN_CANCELLED = sse([
+ '{"t":"chat.delta","epoch":1,"turn_id":null,"delta":"哈哈,"}',
+ '{"t":"chat.delta","epoch":1,"turn_id":null,"delta":"那走吧。"}',
+ '{"t":"chat.cancelled","epoch":1,"turn_id":null}',
+])
+COMPLETED = sse([
+ '{"t":"chat.delta","epoch":1,"turn_id":null,"delta":"完整"}',
+ '{"t":"chat.delta","epoch":1,"turn_id":null,"delta":"回复。"}',
+ '{"t":"chat.final","epoch":1,"turn_id":null,"utterance":{"text":"完整回复。"},'
+ '"receipt":{"message_id":9,"turn_id":null,"epoch":1,"token":"' + "k" * 48 + '","text_hash":"' + "h" * 64 + '"}}',
+])
+
+
+async def _collect(stream):
+ chunks: list[str] = []
+ try:
+ async for chunk in stream:
+ if chunk.delta and chunk.delta.content:
+ chunks.append(chunk.delta.content)
+ finally:
+ await stream.aclose()
+ return chunks
+
+
+async def test_pre_first_delta_cancel_never_retries():
+ """The recorded call1 path: cancel before any chunk. The 499 end means
+ the SDK's retry loop never re-runs _run — exactly one POST."""
+ llm, backend = make_llm([CANCELLED])
+ stream = llm.chat(chat_ctx=chat_ctx(), conn_options=FAST)
+ chunks = await _collect(stream)
+ assert chunks == []
+ assert stream.server_cancelled and not stream.completed
+ assert backend.chat_posts == 1, "a cancelled turn must never be retried"
+
+
+async def test_mid_stream_cancel_interrupts_handle_and_never_retries():
+ llm, backend = make_llm([TWO_DELTAS_THEN_CANCELLED])
+ stream = llm.chat(chat_ctx=chat_ctx(), conn_options=FAST)
+ spy = InterruptSpy()
+ stream.speech_handle = spy
+ chunks = await _collect(stream)
+ assert chunks == ["哈哈,", "那走吧。"], "chunks already sent stay visible"
+ assert stream.server_cancelled
+ assert spy.interrupts == 1, \
+ "server-side cancel must stop the cached playout via SpeechHandle.interrupt"
+ assert backend.chat_posts == 1
+
+
+async def test_reentry_after_cancel_short_circuits_without_network():
+ llm, backend = make_llm([CANCELLED])
+ stream = llm.chat(chat_ctx=chat_ctx(), conn_options=FAST)
+ await _collect(stream)
+ assert backend.chat_posts == 1
+ with raises(APIStatusError) as excinfo:
+ await stream._run()
+ assert excinfo.value.status_code == 499
+ assert backend.chat_posts == 1, \
+ "a cancelled turn_id never re-acquires the gate, not even in-process"
+
+
+async def test_real_connection_errors_still_retry_not_globally_disabled():
+ llm, backend = make_llm([httpx.ConnectError("network is genuinely down")])
+ stream = llm.chat(chat_ctx=chat_ctx(), conn_options=FAST)
+ with raises(APIConnectionError):
+ await _collect(stream)
+ assert backend.chat_posts == FAST.max_retry + 1, \
+ "transient network failures keep the SDK's own bounded retry"
+
+
+async def test_completed_turn_still_streams_with_receipt():
+ llm, backend = make_llm([COMPLETED])
+ stream = llm.chat(chat_ctx=chat_ctx(), conn_options=FAST)
+ chunks = await _collect(stream)
+ assert chunks == ["完整", "回复。"]
+ assert stream.completed and stream.receipt and stream.text == "完整回复。"
+ assert backend.chat_posts == 1
+
+
+# ── standalone runner (agent venv has no pytest) ─────────────────────────────
+
+
+async def _standalone() -> int:
+ checks = [
+ ("pre_first_delta_cancel_never_retries", test_pre_first_delta_cancel_never_retries),
+ ("mid_stream_cancel_interrupts_handle_and_never_retries",
+ test_mid_stream_cancel_interrupts_handle_and_never_retries),
+ ("reentry_after_cancel_short_circuits_without_network",
+ test_reentry_after_cancel_short_circuits_without_network),
+ ("real_connection_errors_still_retry_not_globally_disabled",
+ test_real_connection_errors_still_retry_not_globally_disabled),
+ ("completed_turn_still_streams_with_receipt",
+ test_completed_turn_still_streams_with_receipt),
+ ]
+ failures = 0
+ for name, check in checks:
+ try:
+ await check()
+ print(f"PASS {name}")
+ except Exception as exc: # noqa: BLE001 - report and continue
+ failures += 1
+ print(f"FAIL {name}: {type(exc).__name__}: {exc}")
+ print(f"{len(checks) - failures}/{len(checks)} passed")
+ return 1 if failures else 0
+
+
+if __name__ == "__main__":
+ if not HAVE_SDK:
+ print(f"SKIP: {SKIP_REASON}")
+ raise SystemExit(77)
+ raise SystemExit(asyncio.run(_standalone()))
diff --git a/tests/test_voice_chat_adapter.py b/tests/test_voice_chat_adapter.py
index 2a34b18..4f78e77 100644
--- a/tests/test_voice_chat_adapter.py
+++ b/tests/test_voice_chat_adapter.py
@@ -139,13 +139,18 @@ async def test_broken_stream_raises_connection_error():
async def test_voice_turn_cancels_active_text_turn_same_gate(api):
- """Voice and text contend through the server's one same-turn gate."""
+ """Voice and text contend through the server's one same-turn gate.
+
+ HEL-908: the cancelled side now ends with an explicit chat.cancelled
+ terminal frame — a clean end, not a connection error the caller might
+ mistake for a transient failure and retry (the HEL-907 defect family)."""
app, provider, _ = api
+ outcome: dict = {}
+
async def slow_text() -> None:
text_client = ChatApiClient("http://asgi", transport=httpx.ASGITransport(app=app))
- with pytest.raises((ChatApiConnectionError, Exception)):
- await text_client.send_turn("文字说一半")
+ outcome["turn"] = await text_client.send_turn("文字说一半")
provider.mode = "slow"
task = asyncio.create_task(slow_text())
@@ -155,8 +160,10 @@ async def test_voice_turn_cancels_active_text_turn_same_gate(api):
turn = await voice_client.send_turn("语音插进来")
assert turn.completed
provider.mode = "" # (already reset above; kept for clarity on early exits)
- task.cancel()
- await asyncio.gather(task, return_exceptions=True)
+ await asyncio.wait_for(task, timeout=10)
+ superseded = outcome["turn"]
+ assert superseded.cancelled and not superseded.completed, \
+ "the cancelled side ends on the explicit chat.cancelled terminal frame"
rows = app.state.store.query(
"SELECT role, text, status, turn_id FROM chat_message ORDER BY id"