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:
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())
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user