Files
Cyrene-For-Android/app
AskaEth 83a2a88b3c fix(chat): 连接重建(后端重启)时把「进行中」全部收口,不再永久卡在思考/工具调用
改了什么
- 新增纯判据 `isReconnect(previous, current, everConnected)`(ui/chat/ReconnectReset.kt):
  只有「**曾经**到过 Connected 之后又回到 Connected」才算重建,第一次接上一个字节都不动。
  主判据是 VM 自己那位只置不翻的 `everConnected`(状态流会合流,`Connected → Reconnecting →
  Connected` 中间那一跳可能被吞掉,只置不翻的位不受影响);第二证据是上一跳为 `Reconnecting`
  (只在「本 VM 是离线期间建起来的」时成立,且那一刻它手里什么都没有,收口是恒等变换)。
- 新增 `ChatUiState.settledAfterReconnect()`(纯函数):把服务端那一轮在本端留下的五个面一次收口 ——
  `turns.allClosed()`(未收口绑定全标 closed,气泡 ④ 灰态 → ⑤)、`streams.allFinished()`
  (每一路停止生长:思维链不再「思考中…」、工具卡不再「执行 X…」)、`busyMode = null`、
  `activeTurns = 0`、`queueDepth = 0`。无变化时返回**同一个实例**。
- `TurnStreams.kt` 新增 `allFinished()`:正文与思维链一个字都不删;**唯一**的删除是「本来就什么都
  没有」的空路(`active` 而一个像素都不画的那种,留着就是一个占着列表末项的幽灵)。
- 工具卡收口 `List<ToolCallState>.settledAfterReconnect()`:`Running` ⇒ `phase = null` + `done = true`
  (渲染成「工具调用 · X」——不再有省略号、也不替服务端下「失败」的结论);`Done`/`Error` 一个字节
  都不动。`ToolPhase` 枚举**没加第四档**(它逐字对齐后端流程图 status,是契约面)。
- 接线在 `ChatViewModel.onConnectionChanged`:收口刻意放在既有的「同值早退」**之前**(见上),
  补拉那一半(`reconnectPull` / `gapCursor` / `firstPageState`)**一个字都没改**。

为什么
后端卡在回合里被重启 ⇒ 那一轮在服务端已经不存在 ⇒ 本端**永远**等不到 `turn.end` / `stream_end` /
助手落库行,而本端「进行中」的每一条闸都只认帧(灰态只认 `TurnBinding.closed`、卡片只认
`thinkingActive`/`active`/`phase`、底栏只认 `busyMode`)⇒ 三条路都不会自己收口,界面永久停在那一格。
今天唯一的兜底是 `session_idle` / `session_interrupted` 的那两个 `allClosed()` 调用点,而说那两句话的
进程已经没了。连带的第二半:那条永远 `active` 的流式路永远是 `items` 的最后一项(`reverseLayout` 下就是
屏幕最下方),而 `ChatScreen` 的「跟随底部」判的正是 `items.lastOrNull()?.key` ⇒ 末项不再变化 ⇒
此后新消息不再把列表滚到底(用户视角就是「拉不到新消息」)。

触发时机只做「连接重建」(a),**不做静默超时** (b):理由、代价与风险((b) 要多一个只能拍的时间常量、
会把正常但慢的回合误杀;但它能兜住「连接没断而编排器死锁」这一档,今天没有现场证据)都写在
`ReconnectReset.kt` 顶上,留主人拍板。

如实登记的两处代价
1. 收口**不删**正文/思维链/工具卡内容,所以那张卡本身要等一个后续事件才消失(它自己那一路的落库行,
   或下一轮 `message.start` 的 `beginLeg` 把它当「已结束的路」丢掉)。若主人更想要「末项立刻让开」,
   代价是那半段正文从屏幕上消失——我没选它(硬要求:不许动消息内容)。
2. `busyMode = null` 连带改变下一次发送的声明(`emitAndDeliver` 用 `busyMode != null` 算
   `interruptScopeForSend` / `parallelForSend`):重建后的**第一次**发送不再先发 `interrupt`。如实说:
   重建之后本端确实不知道那一轮还在不在;服务端下一次报 `session_busy` 就恢复。
「闪动」口径:`closed` 单调(迟到的帧不会把它翻回灰态),流式卡片会被后续真帧重新拉回「在跑」
(那是如实的、且不丢字),`busyMode` 由服务端下一帧恢复。

验证
- `./gradlew.bat assembleDebug` BUILD SUCCESSFUL(app-debug.apk 已产出)
- `./gradlew.bat testDebugUnitTest` 全量:**115 suites / 1405 tests / 0 failures / 0 errors**
  (基线 114 / 1388 ⇒ +1 suite、+17 tests,全部来自新增的 `ReconnectResetTest`)
- 新增 `ReconnectResetTest`(17 例):连接重建 ⇒ 未收口绑定全 closed(且 `phaseOf` 真的变成 Answered);
  第一次连接(`Idle`/`Connecting → Connected`)不收口;判据 5×5×2 = 50 格全表穷举;`settledAfterReconnect`
  幂等且无变化时 `assertSame`;已 closed 的不会被 `withTurnBegin` 翻回;流式卡片全停且正文保留、空路被丢;
  工具卡 `Running → null+done`、`Done`/`Error` 原样、后续真帧能放回 `Running`;
  消息/待发/草稿/队列/error/绑定 messageIds **一律原样**。
2026-10-07 10:50:23 +08:00
..