merge: W52B —— 抽屉能看出哪个会话正在跑(S9)+ 行内「停止」入口(S5)

This commit is contained in:
AskaEth committed 2026-10-06 15:56:23 +08:00
commit 37289391f8
6 files changed
+452 -15

No files matched your search

@@ -209,11 +209,16 @@ class ChatSocket(
* 与加入 `scope` 之前**逐字节相同**);`"current"` = 只取消在跑回合、保留待处理队列。
* 其它取值原样发出,服务端**定向**回 `error{bad_request}` 且不打断任何会话(本层不预判取值域)。
*
* **调用方只有一处**:`ChatViewModel.interruptRunningTurn()`——它在一条消息真的要走上去**之前**
* 按 App 偏好(`InterruptMode`)决定要不要先打断,且**只在会话正忙时**发(判据是纯函数
* `interruptScopeForSend`)。默认档「追加队列」与「并行」这一档都恒不发(后者的 `wireScope`
* 就是 `null`:并行与打断互斥),所以本方法只服务「打断」这一档。
* 返回 false 只有一种原因(WS 未连接):调用方**静默**并不阻断本次发送,理由见那里的 KDoc。
* **调用方有两处,两处都只发 `"current"`**(本端从不发 `all`,见 `devdocs/02` 的 A2 补记):
* 1. `ChatViewModel.interruptRunningTurn()`——它在一条消息真的要走上去**之前**按 App 偏好
* (`InterruptMode`)决定要不要先打断,且**只在会话正忙时**发(判据是纯函数
* `interruptScopeForSend`)。默认档「追加队列」与「并行」这一档都恒不发(后者的 `wireScope`
* 就是 `null`:并行与打断互斥),所以本方法只服务「打断」这一档。
* 2. `SessionsViewModel.stopGenerating(sessionId)`——抽屉里那一行的「停止」(W52B):它是一个
* **独立的动作**(不读偏好),停的是**指定会话**正在跑的那一轮。`WsOutbound.Interrupt`
* 本来就带 `sessionId`,所以「停某一个会话」这条既有出口表达得了,不需要新帧。
*
* 返回 false 只有一种原因(WS 未连接):两处调用方都**静默**,理由见各自的 KDoc。
*/
fun sendInterrupt(sessionId: String, scope: String? = null): Boolean =
send(WsOutbound.Interrupt(sessionId = sessionId, scope = scope))
@@ -137,6 +137,7 @@ fun ChatHomeScreen(
onDelete = { sessionsViewModel.deleteSession(it.id) },
onTogglePin = { sessionsViewModel.setPinned(it.id, !it.pinned) },
onRename = { sessionId, title -> sessionsViewModel.renameSession(sessionId, title) },
onStopGenerating = { sessionsViewModel.stopGenerating(it.id) },
onOpenIot = { /* P2:IoT 页面 */ },
onOpenAdmin = {
drawerOpen = false
@@ -0,0 +1,88 @@
package top.yeij.cyrene.ui.sessions
import top.yeij.cyrene.data.ws.WsEvent
/**
* 「这个会话正在跑」的本端账本(**纯逻辑**,`SessionsViewModel` 与测试共用)。
*
* 形状是 `Map<会话 id, 本端见过的、还没收到收尾帧的在途路 id>`。**空集合也是在跑**——
* `session_busy` 帧本身通常不带 `turnId`(它走编排器的状态通道),只凭它也知道那个会话忙。
*
* ## 只有活帧能点亮,而且绝不外推
* 唯二的点亮来源是 [WsEvent.SessionBusy](服务端说它忙)与 [WsEvent.TurnBegin](服务端刚起了
* 一路):两条都是**连接着的这一刻**推下来的帧。握手**不回放**该状态(S9 登记的那一格),所以
* 「本端连上之后还没收到过任何一条这样的帧」= **不知道** ⇒ 抽屉里什么都不显示(宁可没有)。
* 也不从别的线索外推——REST 列表不带这个状态,聊天页有没有在转也不带会话归属,
* 拿它们当证据就等于把「本端不知道」说成「它闲着」或「它忙着」。
*
* 把 [WsEvent.TurnBegin] 也算证据,是因为它与 [WsEvent.SessionBusy] **同源**:服务端启动一路时
* 两条一起发(一个是状态、一个是这一路的身份)。收到它就等于服务端在那一刻确认「这个会话在跑」,
* 不是本端的推断——而它比 `session_busy` 更频繁(每一路都有),所以 `session_busy` 那一帧如果
* 因为重连错过,下一路起来时灯还能亮回来。
*
* ## 收尾:三条帧,方向都是「宁可少显示」
* - [WsEvent.SessionIdle](规范 §1.5:**全部**在途路收尾且 pending 空)⇒ 无条件收掉。
* - [WsEvent.SessionInterrupted] ⇒ 只有 `activeTurns` 说没有别的路在跑时才收掉。`scope:"current"`
* 只取消在跑的那一路、别的路照跑,无条件收掉就会在并行时显示「已经停了」——那正是 v2 前端审计
* 记成「会错」的一条(`Cyrene_v2/DevDocs/audit/前端同步缺口-2026-10-05.md` 的 A7)。
* 键缺席(`null`,v0.65 之前的老服务端)⇒ 那一版还没有并行这回事,单路被打断就是真的闲了 ⇒ 收掉。
* - [WsEvent.TurnEnd] ⇒ 只收它**认得**的那一路([WsEvent.TurnBegin] 记下来过),且收掉之后一路不剩
* 才灭灯。帧壳 `turnId` 没见过(本端中途才接上、或那一帧的键缺席)就什么都不做:拿别的一路的
* 收尾去灭灯,会把还在跑的那一路报成空闲。
*
* `stream_end` **刻意不参与**:它只结束某一次模型流(一轮里有多个正文段、工具循环里还有多次),
* 而 `turn.end` 才结束整轮。按它灭灯就是上面 A7 那类错误的另一个入口。
*
* 幂等:同一帧重复到达(重连补拉、服务端重复广播)不改变结论。
*/
internal fun Map<String, Set<String>>.applyBusyFrame(event: WsEvent): Map<String, Set<String>> {
val sessionId = event.sessionId.sessionKey() ?: return this
return when (event) {
is WsEvent.SessionBusy ->
this + (sessionId to this[sessionId].plusTurn(event.turnId.sessionKey()))
is WsEvent.TurnBegin ->
this + (sessionId to this[sessionId].plusTurn(event.turnId.sessionKey()))
is WsEvent.TurnEnd -> {
val turnId = event.turnId.sessionKey()
val tracked = this[sessionId].orEmpty()
if (turnId == null || turnId !in tracked) {
this
} else {
val left = tracked - turnId
if (left.isEmpty()) this - sessionId else this + (sessionId to left)
}
}
is WsEvent.SessionIdle -> this - sessionId
is WsEvent.SessionInterrupted ->
if ((event.activeTurns ?: 0) > 0) this else this - sessionId
else -> this
}
}
/**
* 抽屉里那一行「正在跑」的字。
*
* 与聊天页输入栏下方那一行**逐字相同**(`ChatScreen.busyLabel`):同一类服务端帧说的是同一件事,
* 两处各写一句就会各自漂移。措辞不分 `mode`(`thinking` / `executing` / `subagent`)——聊天页
* 也只看「忙不忙」这一个事实,用户要的是「她在干活、可以停」,不是编排细节。
*/
internal const val SESSION_BUSY_LABEL = "正在思考…"
/**
* 停止入口上的字。用文字而不是图标:`MiuixIcons.Basic.*` 里只有 7 个(`Close` / `Search` / …),
* 没有一个读作「停止」,而**猜**一个语义相近的图标(`Pause`?`Remove`?)正是本项目禁止的动作
* (先例:`LoginScreen` 的「显示 / 隐藏」也是在 `IconButton` 里放两个字)。
*/
internal const val SESSION_STOP_LABEL = "停止"
/** 帧里的 id:空白 = 坏帧(不知道说的是谁)⇒ 整帧不消费,与 `SessionsViewModel.onSessionRead` 同一条口径。 */
private fun String?.sessionKey(): String? = this?.takeIf { it.isNotBlank() }
/** 把这一路记进去;`turnId` 缺席(帧壳没给)时只维持「在跑」这个事实,不记无法配对的路。 */
private fun Set<String>?.plusTurn(turnId: String?): Set<String> =
if (turnId == null) orEmpty() else orEmpty() + turnId
@@ -62,6 +62,9 @@ import top.yukonga.miuix.kmp.theme.MiuixTheme
* 两边签名逐字对应。
* @param onDismissError 收起列表下方那条错误([SessionsUiState.error])。它不会自己消失,
* 所以必须有个关闭入口;关掉的只是**本端已经显示的**这一条(边界见 `SessionsViewModel.dismissError`)。
* @param onStopGenerating 停掉这个会话正在跑的那一轮(只对 [SessionsUiState.busySessions] 里那些行
* 出现)。它落到的出口与「她忙时发消息 = 打断」那一档**是同一个**(见
* `SessionsViewModel.stopGenerating`)。
*/
@Composable
fun ColumnScope.SessionDrawerContent(
@@ -71,6 +74,7 @@ fun ColumnScope.SessionDrawerContent(
onDelete: (SessionItem) -> Unit,
onTogglePin: (SessionItem) -> Unit,
onRename: (String, String) -> Unit,
onStopGenerating: (SessionItem) -> Unit,
onOpenIot: () -> Unit,
onOpenAdmin: () -> Unit,
onOpenSettings: () -> Unit,
@@ -111,11 +115,16 @@ fun ColumnScope.SessionDrawerContent(
// 角色名在这里回填而不是在 VM 里拼进 SessionItem:这里能拿到角色表(也就能表达
// 「角色表还没回来」的降级),而且 [personaLabelFor] 是一条可单测的纯函数。
personaLabel = personaLabelFor(session.personaId, state.personas),
// 「谁在跑」是**整个列表**共用的一份(`session_busy` 说的是别的会话时本端也要收下),
// 所以它挂在 state 上、按 id 逐行查,而不是塞进 SessionItem(那一份是 REST 快照的
// 投影,每次重拉都会被整表替换,塞进去反而要让重拉记得把它带回来)。
busy = session.id in state.busySessions,
active = session.id == state.activeSessionId,
onClick = { onSelect(session.id) },
onTogglePin = { onTogglePin(session) },
onRename = { pendingRename = session },
onDelete = { pendingDelete = session },
onStop = { onStopGenerating(session) },
)
}
}
@@ -327,16 +336,22 @@ private val PERSONA_LIST_MAX_HEIGHT = 320.dp
*
* @param personaLabel 该会话绑定的**角色名**([personaLabelFor] 的回填;拿不到就不显示)。
* 它挂在摘要行前面——「这个会话是谁在答话」比最后一条消息更该先被看到。
* @param busy 这个会话**本端已知**正在跑(S9)。它多带来两样东西:摘要行下面那行「正在思考…」,
* 以及把置顶按钮换成「停止」。判据与收尾规则见 `session_busy` 那一套(`SessionsViewModel`)。
* 注意它**不是**「服务端此刻在跑」——握手不回放该状态,没收到过帧的会话就是 `false`(宁可没有)。
* @param onStop 停掉正在跑的那一轮([SessionsViewModel.stopGenerating])。
*/
@Composable
private fun SessionRow(
session: SessionItem,
personaLabel: String?,
busy: Boolean,
active: Boolean,
onClick: () -> Unit,
onTogglePin: () -> Unit,
onRename: () -> Unit,
onDelete: () -> Unit,
onStop: () -> Unit,
) {
val titleColor = BasicComponentDefaults.titleColor().color
val summaryColor = BasicComponentDefaults.summaryColor().color
@@ -355,16 +370,30 @@ private fun SessionRow(
modifier = Modifier.padding(horizontal = 4.dp),
)
}
IconButton(onClick = onTogglePin) {
Icon(
imageVector = if (session.pinned) MiuixIcons.Unpin else MiuixIcons.Pin,
contentDescription = if (session.pinned) "取消置顶" else "置顶",
tint = if (session.pinned) {
MiuixTheme.colorScheme.primary
} else {
MiuixTheme.colorScheme.onSurfaceVariantSummary
},
)
// 「停止」与置顶**同一个槽位**,不是并列的两个:上面那段注释算过,`endActions` 的
// 硬上限(内部可用宽度的 60%)连现有的三个 40dp 动作都快顶不住,再并一个就会把
// 图标挤到裁剪。正在跑的那几行里「停」比「置顶」要紧,而置顶**不丢**:这一轮结束
// (灯灭)它立刻回来。
if (busy) {
IconButton(onClick = onStop) {
Text(
text = SESSION_STOP_LABEL,
style = MiuixTheme.textStyles.footnote1,
color = MiuixTheme.colorScheme.primary,
)
}
} else {
IconButton(onClick = onTogglePin) {
Icon(
imageVector = if (session.pinned) MiuixIcons.Unpin else MiuixIcons.Pin,
contentDescription = if (session.pinned) "取消置顶" else "置顶",
tint = if (session.pinned) {
MiuixTheme.colorScheme.primary
} else {
MiuixTheme.colorScheme.onSurfaceVariantSummary
},
)
}
}
if (!session.isMain) {
IconButton(onClick = onDelete) {
@@ -416,6 +445,18 @@ private fun SessionRow(
overflow = TextOverflow.Ellipsis,
)
}
// 第三行只给正在跑的会话。**另起一行而不是顶掉摘要**:摘要(最后一条消息)在这一刻
// 仍然有用——用户切走之后正是靠它认回「我刚才在哪个会话里说话」——而这一行是整行
// 宽度的普通文本,不需要从 `endActions` 或标题列里分走任何一个 dp。
if (busy) {
Text(
text = SESSION_BUSY_LABEL,
fontSize = MiuixTheme.textStyles.body2.fontSize,
color = MiuixTheme.colorScheme.primary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
@@ -17,6 +17,7 @@ import top.yeij.cyrene.data.api.CyreneException
import top.yeij.cyrene.data.api.dto.SessionSummaryDto
import top.yeij.cyrene.data.ws.ChatSocket
import top.yeij.cyrene.data.ws.WsEvent
import top.yeij.cyrene.data.ws.WsState
data class SessionItem(
val id: String,
@@ -58,6 +59,13 @@ data class SessionsUiState(
val personasLoaded: Boolean = false,
/** 角色目录拉取失败的原因。与会话列表的 [error] 分开:它只影响弹层里那几行,不动列表状态。 */
val personaError: String? = null,
/**
* **本端已知正在跑**的会话 id(S9)。空 = 一个都不知道,抽屉里不显示任何「正在生成」。
*
* 它**不是**「服务端此刻在跑哪些会话」——握手不回放该状态,本端只知道**连上之后**收到过帧的
* 那些(判据与收尾规则见 [applyBusyFrame])。所以它是「宁可没有」的一侧:拿不准就不显示。
*/
val busySessions: Set<String> = emptySet(),
)
/**
@@ -128,6 +136,15 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() {
*/
private var trailingRefreshJob: Job? = null
/**
* 「谁在跑」的账本(S9):会话 id → 本端见过的、还没收到收尾帧的在途路 id。
*
* 与 [SessionsUiState.busySessions] 是**同一件事的两面**:这一份带配对用的路 id(`turn.end`
* 要靠它才知道收掉的是哪一路),推给界面的那一份只要 id 的集合。两份只在一处一起写
* ([onBusyFrame]),不会各自漂移。
*/
private var busyTurns: Map<String, Set<String>> = emptyMap()
/**
* 手里这份列表是**为哪个身份**拉回来的([fingerprint]);`null` = 这份列表没有主人
* (登出清空之后、换账号还没重拉)。
@@ -180,6 +197,7 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() {
trailingRefreshJob = null
silentRefreshThrottle = SilentRefreshThrottle()
pulledForIdentity = null
busyTurns = emptyMap()
_state.value = SessionsUiState()
}
@@ -207,6 +225,18 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() {
}
// 未读的两个下行来源都在这里收敛;见 [onEvent]。
viewModelScope.launch { socket.events.collect(::onEvent) }
// 「谁在跑」只在**连接着的这段时间**里成立:握手不回放该状态,掉线期间服务端那边起了
// 什么、结束了什么,本端一个字节都收不到(S9 登记的那一格)。所以一离开 Connected 就当场
// 作废——否则一次「掉线时正好跑完」会让那盏灯**永远**亮着,而它旁边那个「停止」也永远点
// 不动(发出去只会撞上服务端对一个空闲会话的 no-op):那是这个功能唯一会骗到人的地方。
// 代价是重连后要等下一路起来灯才回来(可能是这一轮、也可能是下一轮)——宁可没有。
viewModelScope.launch {
socket.state.collect { ws ->
if (ws == WsState.Connected || busyTurns.isEmpty()) return@collect
busyTurns = emptyMap()
_state.update { it.copy(busySessions = emptySet()) }
}
}
}
/**
@@ -394,6 +424,9 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() {
// 记的是「本端已知」而不是「用户界面该怎么表现」:这不是要让谁弹一句告知
// (用户自己删的,他知道),只是让下一次补拉**事前**就判得掉。
container.goneSessions.mark(sessionId)
// 这个会话在本端从此没有行可显示 ⇒ 「谁在跑」里那条也当场作废(留着它只会让
// 账本永远不为空,而它对应的那一行早就不存在了)。
forgetBusy(sessionId)
if (_state.value.activeSessionId == sessionId) {
select(SessionSummaryDto.MAIN_SESSION_ID)
}
@@ -457,8 +490,14 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() {
* 一个会话」的场景(`ChatViewModel`)用的,而本 VM 持有**整个会话列表**——按 activeSessionId
* 过滤会把「别的会话被读了」这一帧直接丢掉,正是最该处理的一半。所以改为按帧里的会话 id
* **逐行定位**([applyReadCursor],影响面严格限制在那一行)。
*
* 「谁在跑」(S9)比未读更早折一遍:[onBusyFrame] 的判据与上面的纪律**同一条**——它认的是
* 帧里那个会话 id,不是当前打开的那个(抽屉要回答的正是「别的会话在不在跑」)。这一折**只读**
* 帧、不碰下面的分支,所以两条既有路径(W49 的节流静默重拉、[persistedRoute] 的两档)语义
* 一个字节都没变。
*/
private fun onEvent(event: WsEvent) {
onBusyFrame(event)
when (event) {
// 别处(Web 面板 / 别的设备 / 本端另一次请求)读了:清掉那一行的红点。
is WsEvent.SessionRead -> onSessionRead(event)
@@ -474,6 +513,58 @@ class SessionsViewModel(private val container: AppContainer) : ViewModel() {
}
}
/**
* 把一帧折进「谁在跑」的账本(判据与收尾规则全在纯函数 [applyBusyFrame] 里),并只在**结论
* 真的变了**时推一次状态。
*
* 「变了才推」不是优化而是必需:这个函数对**每一帧**都会跑一遍,而流式那一串(`message.delta`
* / `thinking.delta`)是刷新率级的。无关帧在 [applyBusyFrame] 里原样返回**同一张表**,这里据此
* 直接返回,`_state` 因此一帧都不会被这些帧碰到。
*/
private fun onBusyFrame(event: WsEvent) {
val next = busyTurns.applyBusyFrame(event)
if (next == busyTurns) return
busyTurns = next
_state.update { it.copy(busySessions = next.keys.toSet()) }
}
/** 本端从此不再(也不该再)关心这个会话在不在跑:它已经被删掉、本端再也没有可显示的那一行。 */
private fun forgetBusy(sessionId: String) {
if (sessionId !in busyTurns) return
busyTurns = busyTurns - sessionId
_state.update { it.copy(busySessions = busyTurns.keys.toSet()) }
}
/**
* 停掉**某一个会话**正在跑的那一轮(抽屉里那一行「停止」,W52B)。
*
* 走的是**既有的上行出口** [ChatSocket.sendInterrupt]:`interrupt{sessionId, scope}` 本来就带
* 会话归属(`WsOutbound.Interrupt` 的第一个参数就是它),所以「停某一个会话」既有的帧表达得了,
* **不需要新造帧**。
*
* ## 为什么恒发 `scope:"current"`
* 不读「她忙时发消息」那一档偏好:用户按的是一个**明确的动作**(停),不是「她忙时我该怎么办」
* 那档策略的延续。取值与 `InterruptMode.Interrupt.wireScope` 同一个,两条理由:
* 1. **本端从不发 `all`** 是一条既有不变式(`devdocs/02` 的 A2 补记、`devdocs/03` §4),
* `dropped > 0` 那条「已打断,丢弃 N 条」的告知面在本端**不存在**;
* 2. `all` 会连**待处理队列**一起清掉,而队列里那些是用户自己敲进去、还没发出去的东西。
* 静默丢掉它们比不给这个按钮更坏。
*
* 于是这个按钮的语义是「**停掉正在跑的这一轮**」:排队中的消息照常会跑、灯随后会再亮起来——
* 那是实情,不是没按到。
*
* ## 刻意不在这里乐观灭灯
* 灯由服务端帧驱动(`session_interrupted` / `turn.end` / `session_idle`,见 [applyBusyFrame])。
* 本端自己先灭,就等于替服务端宣布了一件还没发生的事;而帧一到,灯自然就灭了。
*
* 发不出去(WS 没连着)时**静默**:与 `ChatViewModel.interruptRunningTurn` 同一条纪律
* (那里写了三条理由)。而这一刻灯本来也不会亮——连接一断,账本当场作废(见 init 里那条订阅)。
*/
fun stopGenerating(sessionId: String) {
if (sessionId.isBlank()) return
socket.sendInterrupt(sessionId, STOP_GENERATING_SCOPE)
}
private fun onSessionRead(event: WsEvent.SessionRead) {
if (event.sessionId.isBlank()) return
recordReadCursor(event.sessionId, event.readAt)
@@ -612,6 +703,17 @@ internal fun unreadBadgeText(count: Int): String? = when {
/** 角标显示的未读上限;超过就显示 `99+`。 */
internal const val UNREAD_BADGE_MAX = 99
/**
* 「停止」那一帧的 `scope`(见 [SessionsViewModel.stopGenerating])。
*
* 取值必须与 `InterruptMode.Interrupt.wireScope` 一致——本端**从不发 `all`** 是一条既有不变式
* (`devdocs/02` 的 A2 补记、`devdocs/03` §4),而 `all` 会静默清空待处理队列。
* 这里写字面量而不是引用那个枚举:那边的类型是 `String?`(`Parallel` 档就是 `null`),
* 直接传下去会让「取值一旦被改坏」退化成「发出一条省略 scope 的帧」= 服务端眼里的 `"all"`,
* 而那正是最坏的方向。两处一致由 `SessionBusyTest` 钉住。
*/
internal const val STOP_GENERATING_SCOPE = "current"
/** 整表替换(REST 快照)时,把本地已知已读的那些行压回 0 未读;见 `SessionsViewModel.readCursors`。 */
internal fun List<SessionItem>.withReadCursors(cursors: Map<String, Long>): List<SessionItem> =
map { item -> cursors[item.id]?.let { readAt -> item.withReadCursor(readAt) } ?: item }
@@ -0,0 +1,200 @@
package top.yeij.cyrene.ui.sessions
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
import top.yeij.cyrene.core.prefs.InterruptMode
import top.yeij.cyrene.data.ws.WsEvent
/**
* S9「这个会话正在生成」那一套纯逻辑:账本的折帧规则,以及「停止」帧的 scope。
*
* 这一批全部是**放在 JVM 上能断言的**那一半——屏幕上到底长什么样(第三行、按钮挤不挤得下)
* 只有单测证明不了,报告里如实标了。
*
* 两组纪律在每一条里都能看到:
* 1. **只有活帧能点亮**(`session_busy` / `turn_begin`),握手不回放 ⇒ 没收到帧的会话**一定**不显示;
* 2. **收尾宁可少显示**:拿不准(`turn.end` 认不得那一路、并行的另一路还在跑)就继续亮着,
* 但绝不在「其实已经闲了」时还亮着——那是这个功能唯一会骗到人的方向。
*/
class SessionBusyTest {
private fun busy(sessionId: String) = mapOf(sessionId to emptySet<String>())
// —— 点亮 ——
@Test
fun `an empty ledger means nothing is shown`() {
// 冷启动、握手之后:服务端不回放该状态,本端什么都不知道 ⇒ 抽屉里不显示「正在生成」。
val after = emptyMap<String, Set<String>>()
.applyBusyFrame(WsEvent.SessionRead(sessionId = "web", readAt = 1L))
.applyBusyFrame(WsEvent.MessagePersisted(message = null, sessionId = "web"))
assertTrue(after.isEmpty())
}
@Test
fun `session_busy lights up its own session`() {
val after = emptyMap<String, Set<String>>()
.applyBusyFrame(WsEvent.SessionBusy(sessionId = "web:1", mode = "thinking"))
assertTrue("web:1" in after)
}
@Test
fun `session_busy for a session does not touch its neighbours`() {
val after = busy("web").applyBusyFrame(WsEvent.SessionBusy(sessionId = "web:1", mode = "executing"))
assertTrue("web" in after)
assertTrue("web:1" in after)
assertEquals(2, after.size)
}
@Test
fun `turn_begin is live evidence too`() {
// 它与 session_busy 同源(服务端启动一路时两条一起发),但更频繁:session_busy 那一帧
// 若因重连错过,下一路起来时灯还能亮回来。
val after = emptyMap<String, Set<String>>()
.applyBusyFrame(WsEvent.TurnBegin(sessionId = "web:1", turnId = "t1"))
assertTrue("web:1" in after)
assertEquals(setOf("t1"), after["web:1"])
}
@Test
fun `a blank session id is a bad frame and changes nothing`() {
// 空白 = 不知道说的是谁(与 onSessionRead 同一条口径):既不点亮,也不误灭别人。
val after = busy("web").applyBusyFrame(WsEvent.SessionBusy(sessionId = "", mode = "thinking"))
assertEquals(setOf("web"), after.keys)
}
@Test
fun `repeating a frame is idempotent`() {
val frame = WsEvent.SessionBusy(sessionId = "web", mode = "thinking")
val once = emptyMap<String, Set<String>>().applyBusyFrame(frame)
assertEquals(once, once.applyBusyFrame(frame))
}
// —— 收尾 ——
@Test
fun `session_idle always clears`() {
// 规范 §1.5:它只在**全部**在途路收尾且 pending 空时才发 ⇒ 到了就是真的闲了。
val after = busy("web").applyBusyFrame(WsEvent.SessionIdle(sessionId = "web", mode = "idle"))
assertTrue(after.isEmpty())
}
@Test
fun `turn_end with no matching turn_begin changes nothing`() {
// 本端中途才接上(没见过这一路的 turn_begin):拿别人家的收尾去灭灯,会把还在跑的那一路
// 报成空闲。宁可继续亮着,等 session_idle 这个权威口径。
val after = busy("web").applyBusyFrame(WsEvent.TurnEnd(sessionId = "web", turnId = "t-unknown"))
assertTrue("web" in after)
}
@Test
fun `turn_end retires the leg it knows about`() {
val after = emptyMap<String, Set<String>>()
.applyBusyFrame(WsEvent.TurnBegin(sessionId = "web", turnId = "t1"))
.applyBusyFrame(WsEvent.TurnEnd(sessionId = "web", turnId = "t1"))
assertTrue(after.isEmpty())
}
@Test
fun `two parallel legs need both turn_end frames to clear`() {
// 忙时并行规范 §2.3:先收尾的那一路不是「这个会话闲了」。
val started = emptyMap<String, Set<String>>()
.applyBusyFrame(WsEvent.TurnBegin(sessionId = "web", turnId = "t1"))
.applyBusyFrame(WsEvent.TurnBegin(sessionId = "web", turnId = "t2"))
val afterFirst = started.applyBusyFrame(WsEvent.TurnEnd(sessionId = "web", turnId = "t1"))
val afterSecond = afterFirst.applyBusyFrame(WsEvent.TurnEnd(sessionId = "web", turnId = "t2"))
assertTrue("t2 还在跑,灯不能灭", "web" in afterFirst)
assertTrue(afterSecond.isEmpty())
}
@Test
fun `an interrupt that leaves other legs running keeps the light on`() {
// `scope:"current"` 只取消在跑的那一路、别的路照跑。无条件收掉就会在并行时显示「已经停了」
// ——那正是 v2 前端审计记成「会错」的那条(前端同步缺口 A7)。
val after = busy("web").applyBusyFrame(
WsEvent.SessionInterrupted(sessionId = "web", mode = "executing", dropped = 0, activeTurns = 1),
)
assertTrue("web" in after)
}
@Test
fun `an interrupt with nothing left clears`() {
val after = busy("web").applyBusyFrame(
WsEvent.SessionInterrupted(sessionId = "web", mode = "idle", dropped = 0, activeTurns = 0),
)
assertTrue(after.isEmpty())
}
@Test
fun `an old server without activeTurns also clears on interrupt`() {
// `activeTurns` 是忙时并行规范(v0.65)才加的键。键缺席的那一版根本没有「另一路」这回事,
// 单路被打断就是真的闲了 ⇒ 收掉。
val after = busy("web").applyBusyFrame(
WsEvent.SessionInterrupted(sessionId = "web", mode = "idle", dropped = 0),
)
assertTrue(after.isEmpty())
}
@Test
fun `stream_end never clears`() {
// 它只结束某一次模型流(一轮里有多个正文段、工具循环里还有多次),结束整轮的是 turn.end。
// 按它灭灯就是 A7 那类错误的另一个入口。
val after = busy("web").applyBusyFrame(
WsEvent.StreamEnd(stopReason = "stop", sessionId = "web", turnId = "t1"),
)
assertTrue("web" in after)
}
@Test
fun `an unrelated frame keeps the very same ledger`() {
// 流式那一串是刷新率级的。无关帧必须原样返回**同一张表**,否则 `SessionsViewModel` 会为
// 每一帧 delta 重新分配、重推一次状态(那正是「每帧都在重组抽屉」的写法)。
val ledger = busy("web")
val after = ledger.applyBusyFrame(WsEvent.MessageDelta(delta = "一", index = 0, sessionId = "web"))
assertSame(ledger, after)
}
// —— 文案与「停止」帧的 scope ——
@Test
fun `the busy label says what the chat page says`() {
// 同一个服务端帧(忙)说的是同一件事,两处各写一句就会各自漂移;顺带钉住它不带「…」以外的
// 编排细节(mode 不参与措辞)。
assertEquals("正在思考…", SESSION_BUSY_LABEL)
assertEquals("停止", SESSION_STOP_LABEL)
}
@Test
fun `the stop frame keeps the client-only-current invariant`() {
// 本端**从不发 `all`**(devdocs/02 A2 补记、devdocs/03 §4):它连待处理队列一起清掉,而
// 「已丢弃 N 条」那句话在本端没有落点。所以这个常量必须与「打断」档同一个取值。
assertEquals(InterruptMode.Interrupt.wireScope, STOP_GENERATING_SCOPE)
assertFalse("空串在服务端也是 `all`,绝不能滑到那一档", STOP_GENERATING_SCOPE.isBlank())
}
@Test
fun `the ui state ships knowing nothing`() {
assertTrue(SessionsUiState().busySessions.isEmpty())
}
}