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 **一律原样**。
This commit is contained in:
AskaEth committed 2026-10-07 10:50:23 +08:00
1 parent 53c3c5c8ee
commit 83a2a88b3c
4 files changed
+577 -2

No files matched your search

@@ -403,9 +403,16 @@ data class ToolCallState(
* 契约明确不要这么读。渲染判据见 [toolResultTextOf]。
*/
val resultSummary: String? = null,
/** `tool_call.end` 已到(参数流完整)。 */
/**
* `tool_call.end` 已到(参数流完整)——**或者**连接重建收口时这一张卡不再可能续上
* ([settledAfterReconnect])。
*
* 两个来源合并成一位是刻意的:它在本端**只服务渲染**([ToolCard] 在 `phase == null` 时用
* 它选标题),而两种成因要说的都是同一件事——**这一张卡没有省略号可挂了**(见
* [settledAfterReconnect] 为什么必须去掉那个省略号)。
*/
val done: Boolean = false,
/** 执行阶段;`null` = 尚未进入执行。 */
/** 执行阶段;`null` = 尚未进入执行,或收口后不再有「进行中」可言(见 [settledAfterReconnect])。 */
val phase: ToolPhase? = null,
)
@@ -484,6 +491,42 @@ fun applyToolExecution(
}
}
/**
* 连接重建时一张工具卡的收口(纯函数):**还在跑**的那一张不再声称自己在跑。
*
* 现场(主人报的):后端卡在工具执行里被重启 ⇒ `tool_execution.end` 永远不会来 ⇒ 卡片永久停在
* 「执行 X…」/「调用工具…」那一格,一个**进行中**的字面承诺就此永远挂在那里。
*
* ## 为什么落点是 `phase = null` + `done = true`,而不是别的
* 1. **不许**给一个不知道结局的工具下结论:`phase = Error` 会说出「工具调用失败」——服务端从没
* 这么说过(它可能只是本端没等到),这正是 `toolResultTextOf` 那条纪律(「摘要缺席只表示
* *不知道*」)同一个方向的事,只是换了一张卡;
* 2. **不许**给 [ToolPhase] 加第四个取值:它逐字对齐后端流程图的 tool 节点 status
* (契约 v0.50 §3.1/§3.5),加一个本端专有的档就是在本端自造一套枚举,而枚举是**契约面**;
* 3. 于是只剩「把它退回**还没进入执行**那一档」这一条路:`phase = null` + `done = true` 在
* [ToolCard] 里渲染成「工具调用 · <工具名>」——**不再有省略号、也不下任何结论**。这比
* 「执行 X…」多一点诚实:工具调用确实发生过(`tool_call.*` 帧都在),而它的结局本端不知道。
*
* `done = true` 是为了让 `phase` 已是 `null` 的那张卡(只到了 `tool_call.*`、没到
* `tool_execution.*`)也从「调用工具…」的省略号里出来——**省略号就是「还在跑」**,
* 收口之后它不再在跑,那个省略号必须去掉。
*
* 幂等:已经 `Done` / `Error` 的卡**一个字节都不动**(那是服务端给的结论,本端不许改写它,
* 也不许把它降级成 `null`);重复调用返回同一个实例。
*/
fun List<ToolCallState>.settledAfterReconnect(): List<ToolCallState> =
if (none { it.phase == ToolPhase.Running || !it.done }) {
this
} else {
map {
when {
it.phase == ToolPhase.Running -> it.copy(phase = null, done = true)
!it.done -> it.copy(done = true)
else -> it
}
}
}
/** 正在流式的助手回复。 */
data class StreamingState(
val bubbles: List<ParsedBubble> = emptyList(),
@@ -927,6 +970,19 @@ class ChatViewModel(
*/
private var gapCursor: Long? = null
/**
* 本 VM 是否**曾经**观测到过 [WsState.Connected] —— 「这一次 Connected 是**第一次接上**还是
* **接回来**」的唯一凭据(判据见 [isReconnect],它决定要不要 [settledAfterReconnect])。
*
* 为什么用「一位只置不翻的记忆」而不是「上一跳是不是 `Reconnecting`」:`ws.state` 是
* **StateFlow**,中间那一跳会被**合流吞掉**(收集器只看到 `Connected → Connected`),
* 而那一刻它仍然是一次**真实的重建**。一个只置不翻的位不受合流影响。
*
* 为什么在 VM 里而不是在 `ChatSocket` 上:问的是「**本 VM** 有没有见过连上」——连接是进程内
* 共享的一条,别的会话见过不算(本 VM 可能是离线期间才建起来的,见 [isReconnect] 第 3 条)。
*/
private var everConnected = false
/**
* 本会话的**首屏**([loadFirstPage] 那一次)走到哪儿了;判据见 [reconnectPull]。
*
@@ -1132,9 +1188,22 @@ class ChatViewModel(
*
* 补拉**必须先判「本端已知它没了」**([sessionKnownGone]):那个会话的 VM 在会话被删之后
* 仍然活着,不判就会在每一次重连都发一次注定 `404 session_not_found` 的往返(S7)。
*
* ⚠️ 本方法还承担一件**与补拉无关**的事:**连接重建那一刻把「进行中」全部收口**
* ([isReconnect] + [settledAfterReconnect])。理由与触发时机的取舍写在 `ReconnectReset.kt`
* 顶上:后端重启之后那一轮在服务端**已经不存在了**,本端等不到任何收口帧,不自己收就会永久
* 停在灰态 /「思考中…」/「正在思考」那一格,而且那条永远 `active` 的流式路会让「跟随底部」
* 永久失效。它**只**改「进行中的外观」:不碰消息、不碰待发、不碰草稿、不重拉。
*/
private fun onConnectionChanged(connection: WsState) {
val previous = _state.value.connection
// 收口刻意放在下面那道「同值早退」**之前**:状态流会合流,`Connected → Reconnecting →
// Connected` 中间那一跳**可能被吞掉**,那时收集器只看到 `Connected → Connected`,
// 而它仍然是一次**真实的重建**。它幂等(无变化时 [settledAfterReconnect] 返回同一个实例),
// 所以多跑一次没有代价。
if (isReconnect(previous, connection, everConnected)) _state.update { it.settledAfterReconnect() }
if (connection == WsState.Connected) everConnected = true
_state.update { it.copy(connection = connection) }
if (connection == previous) return
@@ -0,0 +1,134 @@
package top.yeij.cyrene.ui.chat
import top.yeij.cyrene.data.ws.WsState
import top.yeij.cyrene.domain.chat.allClosed
/**
* **连接重建**(不是第一次接上)时把本端的「进行中」一次性收口。
*
* ## 它修的是什么(主人 2026-10-06 报的现场)
* 后端卡在半路(回合在跑、或工具在跑)时被**重启**:那一轮在服务端**已经不存在了**,
* 所以本端**永远不会**再收到它的任何收口帧(`turn.end` / `stream_end` / 助手落库行)。
* 而本端「进行中」的每一条闸都只认**帧**:
*
* - 灰态(④「已读,正在回答…」)的唯一闸是 [top.yeij.cyrene.domain.chat.TurnBinding.closed],
* 它只由 `turn.end` / 同 `turnId` 的助手落库行驱动(`ChatViewModel.onEvent`);
* - 「思考中…」/「执行 X…」的唯一闸是流式路自己的 `thinkingActive` / `active` / 工具卡的 `phase`
* (收口只发生在 `stream_end` / 助手落库行);
* - 底栏那句「正在思考…」的唯一闸是 `session_busy` / `session_idle` 报的 [ChatUiState.busyMode]。
*
* 三条路都断在「没有帧」上 ⇒ **没有任何一条会自己收口**,界面于是永久停在那一格:气泡一直灰着、
* 卡片一直转、底栏一直说她在思考。三条里今天唯一被兜住的是 `session_idle` / `session_interrupted`
* 那两帧(`allClosed()` 的两个既有调用点)——而**后端重启时它们永远不会到**(说这话的进程已经没了)。
*
* 还有一处**不显眼但很要命**的连带:那条永远 `active` 的流式路永远是消息列表的**末项**
* ([ChatUiState.items] 的最后一项,`reverseLayout` 下就是**屏幕最下方**),而 `ChatScreen` 的
* 「跟随底部」判的正是 `items.lastOrNull()?.key`(`ChatScreen.kt:508`)——末项不再变化 ⇒
* **此后新到的消息不再把列表滚到底**,于是「最新一条」永远停在那一张转着的卡上。用户看到的就是
* 「拉不到新消息」(消息其实进得来,是屏幕不再跟着它走)。
*
* 收口之后这张卡变成**惰性**的(不再转、不再声称在思考):它仍留在列表里(正文一个字都不删,
* 见下面「不许碰的东西」),直到它自己那一路的落库行到达、或**下一轮** `message.start` 把它当作
* 「已结束的路」丢掉(`TurnStreams.beginLeg`)——那时末项恢复推进,跟随底部也就回来了。
* 这条残留如实登记:收口**不**替这张卡删内容,所以它自己要等一个后续事件才消失。
*
* ## 触发时机:只做「连接重建」((a)),**不做静默超时**((b))
* 判据落在 [isReconnect] 上,也就是 `WsState` **非 Connected → Connected** 的那一跳。三条理由:
* 1. **确定性**:后端重启必然重建连接,而重建连接**必然**让 `WsState` 走一遍 `Reconnecting →
* Connected`(`ChatSocket.runConnectionLoop`)。这条判据是**事件驱动**的,不需要时间常量;
* 2. **可验证**:假后端 `POST /__control/ws-close`(或直接重启进程)就能复现,设备上判据是
* 「一条已在飞的回合 + 一次真踢」,不依赖「等多久」这种主观量;
* 3. **与既有纪律一致**:本项目自 W38 起就定死「状态驱动,不按时间轮」(见
* `ChatViewModel.retryFirstPage` 的 KDoc),(b) 会在同一张表上引入第二个口径。
*
* (b)(某一路 N 秒没有任何帧就认为它结束了)**刻意没做**,代价与风险如实记在这里,留主人拍板:
* - 它要多一个**时间常量**,而那个数只能拍:太小会把「正常但慢」的回合(长思维链、长工具执行、
* 服务端排队)误杀成已结束,太大则回到「卡住很久」的老样子;
* - 它能在**连接没断**时兜底(后端进程活着但编排器死锁、或帧被中间设备悄悄丢掉),而 (a) 兜不住
* 这一档;但这一档今天**没有现场证据**(主人报的是「重启后端」,那是 (a)),按「先修有证据的」
* 不动它;
* - 两者叠加不会互相破坏(都是幂等收口),真要做时**先做 (a)、再把它当第二级**即可。
*
* ## 「第一次连接」不算重建
* 冷启动的 `Idle → Connecting → Connected` 必须**一个字节都不收口**,两条路都会踩到:
* - **刚进会话的 VM**:它订阅时连接可能**已经是** Connected,于是它看到的第一次 Connected 与
* `history` / `turn_begin` / `steer_ack` 那一批首帧**几乎同时**到达(帧走 `socket.events`、
* 状态走 `socket.state`,两条流各自投递)。这时候本端手里可能**已经有**一条正当地灰着的气泡
* (另一台设备起的那一轮),把它当成重建收口就是**在用户刚打开会话的瞬间**凭空把那一轮说成已答;
* - **冷启动**:`Idle → Connecting → Connected` 是每一个会话的第一跳,与「后端重启过」毫无关系。
*
* 所以判据必须是「**曾经**到过 Connected 之后又回到 Connected」,而不是「这一次是不是 Connected」。
*/
internal fun isReconnect(previous: WsState, current: WsState, everConnected: Boolean): Boolean {
// 不是「接上了」,谈不上重建(Connecting / Reconnecting / Offline / Idle 都不收口)。
if (current != WsState.Connected) return false
// 本 VM 见过 Connected ⇒ 这一次接上必然是**接回来**。这一条是主判据,而且**不受状态流合流影响**
// (理由与「为什么是一位记忆而不是看上一跳」见 `ChatViewModel.everConnected` 的 KDoc)。
if (everConnected) return true
// 没记过账时的第二证据:上一跳是**重连退避**(`ChatSocket.runConnectionLoop` 只在断开之后写
// `Reconnecting`;首次握手走的是 `Connecting`)。这一档只在一处是真的:**本 VM 是在离线期间
// 建起来的**(进次级会话时连接就是断的)⇒ 它自己的第一次 Connected 确实发生在一次重建之后。
// 它手里那时**什么都没有**(没有 turn / 没有流式路 / 没有 busyMode),所以 [settledAfterReconnect]
// 对它是恒等变换——判在这里不会误伤任何东西,反过来说也不必为它加第二条口径。
return previous == WsState.Reconnecting
}
/**
* 连接重建时把本端「进行中」的五个面一次性收口(纯函数,VM 与测试共用)。
*
* 收口的是**服务端那一轮留下来的本端投影**,一件一件说清它为什么该收:
* - [ChatUiState.busyMode]:「正在思考…」是**服务端报的**事实(`session_busy`)。旧连接没了,
* 这个事实就**不可知**了;继续显示它等于替服务端保证一件已经问不到的事;
* - [ChatUiState.activeTurns]:同上(「并行 N 路」);归 `0` = 「不知道」,正是
* [ChatUiState.activeTurns] 文档里那个「不知道」的取值;
* - [ChatUiState.queueDepth]:「已排队 N 条」也是服务端队列的事实,与 `session_idle` / 被打断
* 那两处的处置**逐字一致**(那两处也是 `busyMode = null, queueDepth = 0` + 全部收口);
* - [ChatUiState.turns] ⇒ `allClosed()`:未收口的绑定全部标 `closed`(气泡 ④ → ⑤);
* - [ChatUiState.streams] ⇒ `allFinished()`:每一路停止生长(思维链不再「思考中」、工具卡不再
* 「执行 X…」),**正文一个字都不删**。
*
* ## 收口之后**不许**碰的东西(可降级;也是本函数的红线)
* [ChatUiState.messages] / [ChatUiState.pending] / [ChatUiState.queuedKeys] /
* [ChatUiState.queuedForConnection] / 草稿与附件 / [ChatUiState.error] / [ChatUiState.sessionGone]
* **一律原样**:收口只改「进行中的外观」,**不删消息、不整表重拉、不给用户多加一条横幅**。
* 绑定表本身也**不删**(只标 `closed`)——并行路角标(`legBadgeForMessage`)靠它,删了等于事后
* 篡改历史。于是写死一条纪律:**收口是可降级的**,最坏的结果只是「少灰一会儿」。
*
* ## 「闪动」:收口会不会被随后到达的帧翻回进行中(如实回答)
* - 灰态**不会**:[top.yeij.cyrene.domain.chat.TurnBinding.closed] 是**单调**的既成事实
* (`withTurnBegin` 合并时也**保持原值**,见那里第 2 条),所以「已答」之后不会退回「回答中」。
* - 流式卡片**会**:那一帧正文/思维链真的又来了,`withLeg` 就会把它重新变成 `active` /
* `thinkingActive` —— 这是**如实**的(它确实又在生成了),而且是**自愈**的:正文不会因为
* 本端收过一次口而丢字(收口不删内容,后续 delta 照旧追加到同一路)。
* - `busyMode` **会**:服务端下一次 `session_busy` 一到就恢复。
*
* 也就是说:**代价落在「服务端那一轮其实还活着」(只是连接抖动过)这一档**——本端会提前一点点
* 把气泡显示成已答。这个代价是刻意选的,因为它比另一个方向小得多:今天的行为(永远灰着、
* 卡片永远转、底栏永远说她在思考、跟随底部永远失效)是**永久坏掉**,且用户唯一的出路是退出重进。
*
* ## 一条如实登记的行为变化
* 清掉 [ChatUiState.busyMode] 会**连带**改变下一次发送的声明:`ChatViewModel.emitAndDeliver` 用
* `busyMode != null` 算 `interruptScopeForSend` / `parallelForSend`(`ChatViewModel.kt:1793`)。
* 于是「她忙时发消息」偏好选「打断」的那一档,在重建后的**第一次**发送不再先发 `interrupt`。
* 如实说:连接重建之后本端**确实不知道**那一轮还在不在,而不打断的后果(这一条被服务端排在后面
* 或并行处理)比对着一个已经不存在的回合发 `interrupt` 小得多。服务端下一次报 `session_busy`
* 就恢复原样。
*/
internal fun ChatUiState.settledAfterReconnect(): ChatUiState {
val closedTurns = turns.allClosed()
val finishedStreams = streams.allFinished()
// 没有任何「进行中」可收时返回**同一个实例**:每次「接回来」都会调它一次,而无变化时制造新
// 实例会让 StateFlow 白白发一轮(也让测试里的 `assertSame` 失去意义)。
if (closedTurns === turns && finishedStreams === streams &&
busyMode == null && activeTurns == 0 && queueDepth == 0
) {
return this
}
return copy(
busyMode = null,
activeTurns = 0,
queueDepth = 0,
turns = closedTurns,
streams = finishedStreams,
)
}
@@ -108,6 +108,49 @@ fun List<StreamingLeg>.finishingLeg(
fun List<StreamingLeg>.withoutLeg(bucket: TurnBucket): List<StreamingLeg> =
if (none { it.bucket == bucket }) this else filterNot { it.bucket == bucket }
/**
* **连接重建时收口每一路**(纯函数,[top.yeij.cyrene.ui.chat.settledAfterReconnect] 用它)。
*
* 与 [finishingLeg] 的关系:同一件事(这一路不再生长、思维链与工具卡不再声称自己在跑),
* 区别只在**触发点**——[finishingLeg] 是「收到了这一路的 `stream_end`」,这里没有那一帧,
* 是「连接重建了,而服务端那一轮在旧连接上,永远不会再有后续帧」(判据见
* [top.yeij.cyrene.ui.chat.isReconnect],理由见它那一份 KDoc)。
*
* 两条纪律:
* 1. **正文与思维链一个字都不删**——用户可能正读到一半,而这已经不是服务端能再给一次的东西;
* 2. **没有任何可显示内容的那一路整条丢掉**:`active = true` 而什么都不画时,[StreamingBubble]
* 会画一个只有光标的空气泡,收口它等于把它变成一个**一个像素都不画、却仍占一个列表项**
* (也因此占着「最新一项」这个位置)的幽灵——那种路丢掉是本函数的**唯一**删除动作,
* 而它删的是「本来就没有的东西」。
*
* 无变化时返回**同一个实例**(与 [finishingLeg] / `withoutLeg` 同一条纪律)。
*/
fun List<StreamingLeg>.allFinished(): List<StreamingLeg> {
val next = mapNotNull { it.finishedLeg() }
return if (next == this) this else next
}
/**
* 这一路**收口后**的样子;收口之后**什么都画不出来**时返回 `null`(整路丢掉,见 [allFinished])。
*
* 三件事与 [finishingLeg] 逐字同义:不再 `active`(光标停、兜底路径不再当它是新回合)、
* 思维链不再 `thinkingActive`(标题从「思考中…」变回「思维链」),外加工具卡也一起收口
* ([settledAfterReconnect],标题从「执行 X…」变回「工具调用 · X」)。
*
* ⚠️ `bubbles` / `thinking` / 工具卡的参数与输出**一个都没动**:这是「不再声称在跑」,
* 不是「清空这一路」。
*/
private fun StreamingLeg.finishedLeg(): StreamingLeg? {
val settled = state.copy(
active = false,
thinkingActive = false,
tools = state.tools.settledAfterReconnect(),
)
val anythingToShow =
settled.bubbles.isNotEmpty() || settled.thinking.isNotBlank() || settled.tools.isNotEmpty()
return if (anythingToShow) copy(state = settled) else null
}
/**
* 这条**已落库**消息处在哪一态(六态判定,规范 §1.1)。
*
@@ -0,0 +1,329 @@
package top.yeij.cyrene.ui.chat
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
import top.yeij.cyrene.data.ws.WsState
import top.yeij.cyrene.domain.chat.TurnBinding
import top.yeij.cyrene.domain.chat.TurnBucket
import top.yeij.cyrene.domain.chat.TurnPhase
import top.yeij.cyrene.domain.chat.allClosed
import top.yeij.cyrene.domain.chat.withTurnBegin
import top.yeij.cyrene.domain.parse.ParsedBubble
/**
* **连接重建时把「进行中」全部收口**([isReconnect] + [settledAfterReconnect])。
*
* 修的是主人 2026-10-06 报的那个现场:后端卡在回合里被重启 ⇒ 那一轮在服务端已经不存在 ⇒
* 本端**永远**等不到 `turn.end` / `stream_end` / 助手落库行 ⇒ 气泡永久灰着、思维链永久「思考中…」、
* 工具卡永久「执行 X…」、底栏永久「正在思考」。
*
* 四组测试各钉一件事:
* 1. **收口真的发生**:连接重建 ⇒ 未收口的绑定全 `closed`(六态 ④ → ⑤)、流式卡片全部停止生长;
* 2. **第一次连接不算重建**:`Idle/Connecting → Connected` 一个字节都不收(判据的穷举表在下面);
* 3. **幂等且单调**:已经收口的不会被翻回,收两次等于一次;
* 4. **可降级**:消息 / 待发 / 草稿 / 队列 / 错误**一律原样**——收口只改「进行中的外观」。
*/
class ReconnectResetTest {
private fun bucket(turnId: String) = TurnBucket("web", turnId)
private fun bubble(text: String) =
ParsedBubble(body = text, parts = emptyList(), head = emptyList(), tail = emptyList())
private fun message(id: String, role: String = "user", turnId: String? = null) =
HistoryMessageDto(
id = id,
sessionId = "web",
role = role,
content = id,
createdAt = 1L,
turnId = turnId,
)
/** 两路都还开着 + 一切「进行中」的字面承诺都挂着 —— 后端重启那一刻的本端样子。 */
private fun stuckState() = ChatUiState(
messages = listOf(message("m-a")),
pending = listOf(PendingMessage("local-1", "还没发出去")),
streams = listOf(StreamingLeg(bucket("t1"), streaming("半句回复", thinking = "她在想…"))),
turns = emptyList<TurnBinding>()
.withTurnBegin("t1", 1, listOf("m-a"), 2)
.withTurnBegin("t2", 2, listOf("m-b"), 2, kind = "parallel"),
queuedKeys = setOf("local-1"),
queueDepth = 3,
draft = "打到一半的字",
busyMode = "thinking",
activeTurns = 2,
error = "上一次失败的原因",
)
private fun streaming(text: String, thinking: String = "") = StreamingState(
bubbles = text.takeIf { it.isNotBlank() }?.let { listOf(bubble(it)) }.orEmpty(),
thinking = thinking,
thinkingActive = thinking.isNotBlank(),
active = true,
)
// —— 1. 收口真的发生 ——
@Test
fun `a rebuilt connection closes every binding that was still open`() {
// 两路都还在飞(并行的两路也一样)⇒ 一次收口把**所有**未收口的绑定标 closed。
// 六态的判据只有这一个闸(`turnPhaseOf` 第 2/3 条),所以这就是「气泡不再永远灰着」。
val stuck = stuckState()
assertEquals(TurnPhase.Answering, stuck.phaseOf(stuck.messages.single()))
val settled = stuck.settledAfterReconnect()
assertTrue(settled.turns.isNotEmpty())
assertTrue("每一路都该收口", settled.turns.all { it.closed })
assertEquals(TurnPhase.Answered, settled.phaseOf(settled.messages.single()))
}
@Test
fun `a rebuilt connection stops every leg from growing`() {
// 「思考中…」/「执行 X…」的唯一闸就是这几个位;没有卡住的那一路不在这里(它还在正常跑)。
val stuck = stuckState().copy(
streams = listOf(
StreamingLeg(bucket("t1"), streaming("半句回复", thinking = "她在想…")),
StreamingLeg(
bucket("t2"),
StreamingState(tools = listOf(ToolCallState("call-1", 0, tool = "search"))),
),
),
)
val settled = stuck.settledAfterReconnect()
assertEquals(2, settled.streams.size)
settled.streams.forEach { leg ->
assertFalse("收口后不再生长", leg.state.active)
assertFalse("标题不该再是「思考中…」", leg.state.thinkingActive)
}
assertEquals(listOf("半句回复"), settled.streams.first().state.bubbles.map { it.body })
assertEquals("她在想…", settled.streams.first().state.thinking)
}
@Test
fun `a rebuilt connection takes the busy banner down with it`() {
// 底栏那句「正在思考…」是**服务端报的事实**(session_busy)——旧连接没了它就不可知了。
// 与 `session_idle` / `session_interrupted` 那两处的处置逐字一致。
val settled = stuckState().settledAfterReconnect()
assertNull(settled.busyMode)
assertEquals(0, settled.activeTurns)
assertEquals(0, settled.queueDepth)
}
// —— 2. 「是不是重建」的判据(穷举) ——
@Test
fun `the first connection is never a rebuild`() {
// 冷启动:Idle → Connecting → Connected。把这一跳当重建会在用户刚打开会话的瞬间
// 把正当在跑的路收口掉(首屏一到就可能有 turn_begin 把它绑成灰态)。
assertFalse(isReconnect(WsState.Idle, WsState.Connected, everConnected = false))
assertFalse(isReconnect(WsState.Connecting, WsState.Connected, everConnected = false))
}
@Test
fun `coming back to connected after a rebuild is a rebuild`() {
// 后端重启:Connected → Reconnecting → Connected。这是本判据的主场。
assertTrue(isReconnect(WsState.Reconnecting, WsState.Connected, everConnected = true))
// 合流吞掉中间那一跳:收集器只看到 Connected → Connected,它**仍然是**一次真实的重建。
assertTrue(isReconnect(WsState.Connected, WsState.Connected, everConnected = true))
// 没记过账时的第二证据:上一跳是重连退避(只在本 VM 是离线期间建起来时才走到)。
assertTrue(isReconnect(WsState.Reconnecting, WsState.Connected, everConnected = false))
// 离线 → 重新 connect(`close()` 之后 attempt 归零走 Connecting)也是一次重建,
// 只要本 VM 见过 Connected。
assertTrue(isReconnect(WsState.Offline, WsState.Connected, everConnected = true))
}
@Test
fun `anything that is not connected is not a rebuild`() {
// 掉下去的那一刻不收口(那一刻正是「进行中」最要紧的时候:还在等它自己跑完)。
for (previous in WsState.entries) {
for (ever in listOf(false, true)) {
for (current in WsState.entries.filter { it != WsState.Connected }) {
assertFalse(
"previous=$previous current=$current ever=$ever",
isReconnect(previous, current, ever),
)
}
}
}
}
@Test
fun `the judgement is exhaustive over every state triple`() {
// 5 × 5 × 2 = 50 格全表。期望值**独立**写一遍(当前不是 Connected ⇒ 假;见过 Connected ⇒
// 真;没记过账但上一跳是重连退避 ⇒ 真;其余(第一次接上)⇒ 假)。
for (previous in WsState.entries) {
for (current in WsState.entries) {
for (ever in listOf(false, true)) {
val expected = when {
current != WsState.Connected -> false
ever -> true
previous == WsState.Reconnecting -> true
else -> false
}
assertEquals(
"previous=$previous current=$current everConnected=$ever",
expected,
isReconnect(previous, current, ever),
)
}
}
}
}
// —— 3. 幂等 / 单调 ——
@Test
fun `settling twice is the same as settling once`() {
val once = stuckState().settledAfterReconnect()
assertSame(once, once.settledAfterReconnect())
}
@Test
fun `nothing in flight returns the very same instance`() {
// 每次 Connected 都会调它;没有「进行中」可收时必须返回同一个实例(否则 StateFlow 白发一轮)。
val idle = ChatUiState(messages = listOf(message("m-a")))
assertSame(idle, idle.settledAfterReconnect())
assertSame(emptyList<StreamingLeg>(), emptyList<StreamingLeg>().allFinished())
assertSame(emptyList<ToolCallState>(), emptyList<ToolCallState>().settledAfterReconnect())
}
@Test
fun `a binding that was already closed is never flipped back`() {
// `closed` 是**单调**的既成事实(`withTurnBegin` 合并时也保持原值)——收口之后
// 一句迟到的「这一路还在跑」不许把它翻回灰态。
val closed = emptyList<TurnBinding>()
.withTurnBegin("t1", 1, listOf("m-a"), 1)
.allClosed()
val state = ChatUiState(messages = listOf(message("m-a")), turns = closed)
val settled = state.settledAfterReconnect()
assertTrue(settled.turns.all { it.closed })
assertEquals(TurnPhase.Answered, settled.phaseOf(settled.messages.single()))
// 一句迟到的 turn_begin 也不会把它翻回去(既有的 withTurnBegin 纪律,这里一起钉住)。
val reopened = settled.turns.withTurnBegin("t1", 1, listOf("m-a"), 1)
assertEquals(TurnPhase.Answered, settled.copy(turns = reopened).phaseOf(settled.messages.single()))
}
// —— 4. 可降级:收口只改「进行中的外观」 ——
@Test
fun `settling touches nothing the user typed or sent`() {
val stuck = stuckState()
val settled = stuck.settledAfterReconnect()
assertSame(stuck.messages, settled.messages)
assertSame(stuck.pending, settled.pending)
assertSame(stuck.queuedKeys, settled.queuedKeys)
assertEquals(stuck.draft, settled.draft)
assertEquals(stuck.error, settled.error)
assertFalse(settled.sessionGone)
// 绑定表**只标 closed,不删**:并行路角标靠它,删了等于事后篡改历史。
assertEquals(stuck.turns.map { it.turnId }, settled.turns.map { it.turnId })
assertEquals(stuck.turns.map { it.messageIds }, settled.turns.map { it.messageIds })
}
// —— 工具卡的收口 ——
@Test
fun `a running tool card stops claiming to be running`() {
val tools = listOf(
ToolCallState(
id = "call-1",
index = 0,
tool = "search",
arguments = "{\"q\":1}",
output = "前 3 条",
done = true,
phase = ToolPhase.Running,
),
)
val settled = tools.settledAfterReconnect()
val card = settled.single()
assertNull("不再声称在跑", card.phase)
assertTrue("省略号必须去掉", card.done)
// 参数与输出一个都没动:这是「不再是进行中」,不是「清空这张卡」。
assertEquals("{\"q\":1}", card.arguments)
assertEquals("前 3 条", card.output)
assertEquals("search", card.tool)
}
@Test
fun `a tool call without an end frame loses its ellipsis too`() {
// 只到了 `tool_call.*`、没到 `tool_execution.*`:标题是「调用工具…」——那个省略号
// 同样是「还在跑」,收口之后它不再在跑。
val settled = listOf(ToolCallState("call-1", 0, tool = "search")).settledAfterReconnect()
assertTrue(settled.single().done)
assertNull(settled.single().phase)
}
@Test
fun `a tool card with a verdict from the server is left alone`() {
// Done / Error 是**服务端给的结论**,本端不许改写它,更不许把它降级成「不知道」。
val done = ToolCallState("c1", 0, phase = ToolPhase.Done, done = true, resultSummary = "2 条")
val failed = ToolCallState("c2", 1, phase = ToolPhase.Error, done = true)
val tools = listOf(done, failed).also { assertEquals(2, it.size) }
val settled = tools.settledAfterReconnect()
assertSame(ToolPhase.Done, settled[0].phase)
assertSame(ToolPhase.Error, settled[1].phase)
assertEquals("2 条", settled[0].resultSummary)
assertSame(tools, tools.settledAfterReconnect())
}
@Test
fun `a real frame after settling can put the card back to running`() {
// 如实登记「闪动」的方向:收口之后**真的又来了一帧**时,卡片会重新变成「执行 X…」——
// 那是如实的(它确实又在跑了),而且是自愈的:这条帧不来的话卡片就停在「工具调用 · X」。
val settled = listOf(ToolCallState("c1", 0, tool = "search", phase = ToolPhase.Running))
.settledAfterReconnect()
val again = applyToolExecution(settled, "c1", "search", "running")
assertSame(ToolPhase.Running, again.single().phase)
}
@Test
fun `an empty leg is dropped instead of lingering as a ghost`() {
// `active = true` 而什么都不画时,气泡里只有一个光标;收口它等于留下一个**一个像素都不画、
// 却仍占着列表末项**的幽灵(末项正是「跟随底部」的判据)。这一路本来就没内容,丢掉它
// 是本函数唯一的删除动作。
val empty = listOf(StreamingLeg(bucket("t1"), StreamingState(active = true)))
assertEquals(emptyList<StreamingLeg>(), empty.allFinished())
}
@Test
fun `a leg with only a running tool card survives as an inert card`() {
val leg = StreamingLeg(
bucket("t1"),
StreamingState(
active = true,
tools = listOf(ToolCallState("c1", 0, tool = "search", phase = ToolPhase.Running)),
),
)
val settled = listOf(leg).allFinished()
assertEquals(1, settled.size)
assertFalse(settled.single().state.active)
assertNull(settled.single().state.tools.single().phase)
}
}