diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt index 17cfeaa..17ae64f 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt @@ -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.settledAfterReconnect(): List = + 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 = 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 diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ReconnectReset.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ReconnectReset.kt new file mode 100644 index 0000000..3cabae7 --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ReconnectReset.kt @@ -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, + ) +} diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/TurnStreams.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/TurnStreams.kt index 01061cf..2c71476 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/TurnStreams.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/TurnStreams.kt @@ -108,6 +108,49 @@ fun List.finishingLeg( fun List.withoutLeg(bucket: TurnBucket): List = 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.allFinished(): List { + 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)。 * diff --git a/app/src/test/kotlin/top/yeij/cyrene/ui/chat/ReconnectResetTest.kt b/app/src/test/kotlin/top/yeij/cyrene/ui/chat/ReconnectResetTest.kt new file mode 100644 index 0000000..6c1b59c --- /dev/null +++ b/app/src/test/kotlin/top/yeij/cyrene/ui/chat/ReconnectResetTest.kt @@ -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() + .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(), emptyList().allFinished()) + assertSame(emptyList(), emptyList().settledAfterReconnect()) + } + + @Test + fun `a binding that was already closed is never flipped back`() { + // `closed` 是**单调**的既成事实(`withTurnBegin` 合并时也保持原值)——收口之后 + // 一句迟到的「这一路还在跑」不许把它翻回灰态。 + val closed = emptyList() + .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(), 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) + } +}