feat(sessions): 抽屉里看得见「哪个会话正在生成」,并给一个能停的入口(S5 + S9)

S9 登记的是「这个会话正在生成」既看不见、也没有可查的状态面:`session_busy` /
`session_idle` / `turn_begin` 都带 sessionId 且在广播,但 `SessionsViewModel.onEvent`
一个都不认。S5 要的「能看见 + 能停」正是同一份状态的下游。

看得见(S9)
- 新增 `ui/sessions/SessionBusy.kt`:把下行帧折成「本端已知在跑的会话」账本,判据是纯函数
  `applyBusyFrame`(可单测,先例 `silentRefreshStep`)。点亮只认 `session_busy` 与
  `turn_begin` 这两条活帧——握手不回放该状态,所以没收到过帧的会话一定不显示(宁可没有);
  收尾认 `session_idle`(无条件)、`turn_end`(只收本端认得的那一路,收完一路不剩才灭灯)、
  `session_interrupted`(`activeTurns > 0` 时**不**灭——`scope:"current"` 只停一路,无条件灭
  就是 v2 前端审计记成「会错」的 A7 那条)。
- `SessionsViewModel` 用一路订阅把账本接进来,并在**离开 Connected 时当场作废**:掉线期间
  服务端那边跑完了什么本端收不到,留着就是一盏永远不灭、按钮永远点不动的灯。
- 抽屉那一行:忙碌时摘要行下面多一行「正在思考…」(与聊天页 `busyLabel` 逐字相同)。
  另起一行而不顶掉摘要,也不从 `endActions` / 标题列里分走任何一个 dp。

能停(S5)
- 新的上行动作 `SessionsViewModel.stopGenerating(sessionId)`,走**既有的** interrupt 出口
  (`WsOutbound.Interrupt` 本来就带 sessionId,既有的帧表达得了「停某一个会话」,没有新造帧)。
  恒发 `scope:"current"`:本端从不发 `all` 是一条既有不变式,而 `all` 会静默清空待处理队列
  (那份队列是用户自己敲进去的),本端也没有「丢弃 N 条」的告知面。
- 抽屉行里「停止」与「置顶」共用**同一个 endActions 槽位**:抽屉固定 300dp,`BasicComponent`
  把内部可用宽度的 60% 当 `endActions` 硬上限,现有的三个 40dp 动作已经贴着边,
  再并一个会把图标挤到裁剪。这一轮结束(灯灭)置顶立刻回来。

纪律
- `onEvent` 里只**前置**一次折帧,`when` 的分支一个字节没动 ⇒ W49 的节流静默重拉与
  [persistedRoute] 的两档语义不变;无关帧(流式 delta)原样返回同一张表,不重推状态。
- 新增 18 条单测钉住上述每一条(含「turn_id 认不得就不灭灯」「并行两路都要收」「stream_end
  不灭灯」「停止不发 all」)。
This commit is contained in:
AskaEth committed 2026-10-06 15:55:04 +08:00
1 parent 4fce12249e
commit 84f343ce5f
6 files changed
+452 -15

No files matched your search

@@ -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())
}
}