merge: W60 —— 后端重启后自愈(连接重建即收口未结束的回合/流式卡/工具卡,修「永久卡在正在思考」)
This commit is contained in:
commit
55612a3922
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user