feat(chat): 忙时并行回合的客户端半边 —— 六态 + 灰态 + (sessionId, turnId) 两级分桶 + 新帧消费(全部可降级)

依据:Cyrene_v2/DevDocs/_specs/忙时并行-规范草案.md(§1 行为 / §2 接口 / §4 主人裁决)。
后端 v0.65 尚未落地这些帧,所以本次的新键与新帧全部按可降级实现:帧不来、字段缺席时,
App 在今天的真后端上行为与改前逐字相同(下面的「降级保证」逐条给了证据)。

一、改了哪些文件

新增(纯逻辑,可单测)
- app/src/main/kotlin/top/yeij/cyrene/domain/chat/ParallelTurns.kt
  两级分桶键 TurnBucket(sessionId, turnId) 与 turnBucketOf / closingBucketOf;
  六态枚举 TurnPhase 与 turnPhaseOf;灰态收口判据 closesAnswering;
  消息↔回合绑定 TurnBinding 及其纯函数(withTurnBegin / withSteeredMessage / closingTurn /
  allClosed / bindingOfMessage);路小标文案 legBadgeOf。
- app/src/main/kotlin/top/yeij/cyrene/ui/chat/TurnStreams.kt
  StreamingLeg(bucket, StreamingState) 与 withLeg / beginLeg / finishingLeg / withoutLeg;
  六态在状态上的推导 ChatUiState.phaseOf(消息 / 乐观气泡);
  路小标与「跳到提问」的查表 legBadgeForMessage / legBadgeForTurn / jumpIndexForTurn。

修改
- data/api/dto/MessageDto.kt:HistoryMessageDto 增可选 turnId(历史行 / 落库行都用它)。
- data/api/dto/ChatDto.kt:ChatRequest 增可选 parallel(REST 与 WS chat 同形)。
- data/ws/WsOutbound.kt:chat 增可选 parallel、interrupt 增可选 turnId,省略即整键不进线上。
- data/ws/WsEvent.kt:新增 turn_begin / turn.end / steer_ack 三个事件与其载荷;
  session_busy / session_idle / pending_drain 增可选 activeTurns,session_interrupted 增
  activeTurns + cancelledTurns,pending_ack 增可选 reason。
- ui/chat/ChatViewModel.kt:流式解析器与「本回合见过结构化正文帧」由单值升为按桶的 map/set,
  新增「本会话最近见过的 turnId」锚点;消费 turn_begin / steer_ack / turn.end;
  助手落库行按其 turnId 收口灰态并只摘掉它自己那一路的流式气泡;排队回执记进 queuedKeys。
- ui/chat/MessageBubble.kt:MessageBubble / PendingBubble 增 phase 与 legBadge,
  正文气泡增灰态配色,新增并行路小标(可点即跳);角标文案抽成纯函数 bubbleFooter。
- ui/chat/ChatScreen.kt:按桶取每一路的流式状态并渲染;传入六态与小标;忙碌行加「并行 N 路」。
- 新增 4 个测试文件(共 82 条):ParallelTurnsTest、TurnStreamsTest、
  WsEventTurnFramesTest、WsOutboundParallelTest。

二、为什么这么做

- 界面必须按 (sessionId, turnId) 两级分桶(规范 §1.7 第 1 件硬要求):两路并行时两个不同
  turnId 的 message.delta / thinking.delta / tool_call 会交错到达,照老代码「一律累加到当前那
  一个流式气泡」会把两条回复糊成一条(规范 §1.4 点名的那件事)。现在每一路各有一份解析器、
  一份思维链、一份工具卡表、一个流式气泡。
- 灰态(④ 已读、未答)的唯一权威收口事件是 turn.end(规范 §1.2)。本端另外在 stream_end
  (既有的「整轮结束」帧,v0.36 起带 turnId)与「同 turnId 的助手落库行」上也收口:
  前者在 turn.end 到位之前是唯一能收口的帧,后者是规范给的兜底。三者顺序不定、重复到达都无害
  (纯函数幂等)。
- 六态判定与收口判据的不变量都抽成纯函数,可直接单测;判定顺序即证据强度:
  被拒 > 已绑定未收口 > 已绑定已收口 > 本地乐观气泡 > 排队回执。

三、字段缺失时的降级保证(本次最要紧的一条)

- 全链路没有一处「turnId 为空就不渲染」。turnBucketOf 在帧壳没给 turnId 时先回落本会话最近
  见过的 turnId、再回落匿名桶(turnId 为空串)⇒ 所有无 turnId 的帧落在同一个桶里,
  那就是老代码那唯一一个流式气泡。
- turn_begin / steer_ack.turnId / turn.end 不来 ⇒ 一条绑定都建不起来 ⇒ 六态里只可能出现
  ①②③⑤,灰态根本不会进入;界面与改前逐字相同。
- activeTurns 缺席时不动这个计数(null 是「不知道」,0 是「确实空闲」,两件事不许互相顶替);
  pending_ack.reason 缺席就是 null,绝不伪造。
- 上行三个新字段(chat.parallel、interrupt.turnId、ChatRequest.parallel)本端今天一个字节都
  不发:真并行要用户显式声明,而声明入口不在本次范围内。服务端照旧走排队 + 回合内 steer。
- 已被单测钉住的证据:
  ParallelTurnsTest「所有无 turnId 的帧落进同一个桶」「两个空白 turnId 不算匹配,绝不收口」;
  TurnStreamsTest「单路时长度恒为 1」「没有任何 turn 帧时已落库=②、本地气泡=①」;
  WsEventTurnFramesTest「老形状的帧解出来新键一律为 null(不是 0、不是空串)」;
  WsOutboundParallelTest「省略时那条帧逐字节相同,且不带该键」。

四、等 v0.65 的部分

- turn_begin / steer_ack.turnId / turn.end 的真实到达,历史行的可选 turnId,activeTurns /
  pending_ack.reason / cancelledTurns 的真实取值。
- 真并行的触发入口(谁在什么时候把 chat.parallel 置 true)——本次只备好线上形状,不接 UI。
- 仍未决且影响客户端的两项:graph_update 两路互相顶掉(规范未决 10,等 design/19 裁决);
  按 turnId 单独核算用量(规范未决 14,可选)。

五、规范里与本端既有定案冲突的一处(如实登记)

规范 §1.1 ⑥「被拒」要求气泡带红色「未送达 · 会话正忙」并可重试;本端被拒(pending_full /
定向 chat_busy)的既有定案是摘掉乐观气泡 + 横幅如实告知 + 正文还给输入栏(可再按一次发送,
幂等键保证不会落成第二条消息)。既有行为由现有测试钉住,本次不改;六态判定里保留了 Rejected
档并由单测直接覆盖,只是本端的现实路径走的是回滚那一支。

验证:assembleDebug 绿 + testDebugUnitTest 绿(0 失败 0 错误 0 跳过)。
This commit is contained in:
AskaEth committed 2026-10-05 09:10:45 +08:00
1 parent 5e6dc76ae8
commit 29299b190e
13 files changed
+2124 -116

No files matched your search

@@ -0,0 +1,224 @@
package top.yeij.cyrene.data.ws
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* 忙时并行的**下行帧**:新帧 `turn_begin`、`turn.end`、`steer_ack` 的帧壳 `turnId`,
* 以及忙态帧的可选计数与 `pending_ack.reason`(规范 §2.4 ~ §2.7)。
*
* 两组断言各占一半:
* - **新键到位**(并行真的开起来时客户端有东西可用);
* - **老帧逐字不变**(v0.64 形状的帧解出来,新键一律是 `null`——`null` 是「没给」,
* 不是 0、不是空串;消费方据此一个字节都不动)。
*/
class WsEventTurnFramesTest {
// —— 新帧 turn_begin(规范 §2.4)——
@Test
fun `turn begin is parsed with every field of the spec`() {
val frame = """
{"type":"turn_begin","sessionId":"web","turnId":"turn-9f3c1a2b3c4d5e6f",
"data":{"kind":"parallel","messageIds":["msg-7f2a19c4e5"],"leg":2,"activeTurns":2}}
""".trimIndent()
val event = WsEvent.parse(frame)
assertTrue("turn_begin 必须被认出来(它是灰态的唯一入口)", event is WsEvent.TurnBegin)
event as WsEvent.TurnBegin
assertEquals("web", event.sessionId)
assertEquals("turn-9f3c1a2b3c4d5e6f", event.turnId)
assertEquals("parallel", event.kind)
assertEquals(listOf("msg-7f2a19c4e5"), event.messageIds)
assertEquals(2, event.leg)
assertEquals(2, event.activeTurns)
}
@Test
fun `a turn begin without a shell turnId keeps its message ids but no turn id`() {
// 帧壳 turnId 是这一路的唯一权威标识:没有它就不能建立绑定(绑了也收不了口)。
// 解析层如实给出 null,由消费方决定「只消费 activeTurns」。
val frame = """{"type":"turn_begin","sessionId":"web","data":{"kind":"turn","messageIds":["m-1"],"leg":1}}"""
val event = WsEvent.parse(frame) as WsEvent.TurnBegin
assertNull(event.turnId)
assertEquals(listOf("m-1"), event.messageIds)
assertEquals(0, event.activeTurns)
}
@Test
fun `a turn begin falls back to the session id inside the payload`() {
// 规范说会话 id 只在帧壳里(data 内不重复);真出现了也照既有口径取有值者。
val frame = """{"type":"turn_begin","data":{"sessionId":"web:abc","messageIds":["m-1"]}}"""
val event = WsEvent.parse(frame) as WsEvent.TurnBegin
assertEquals("web:abc", event.sessionId)
}
@Test
fun `blank message ids inside turn begin are dropped`() {
val frame = """{"type":"turn_begin","sessionId":"web","turnId":"t1","data":{"messageIds":["m-1"," "]}}"""
val event = WsEvent.parse(frame) as WsEvent.TurnBegin
assertEquals(listOf("m-1"), event.messageIds)
}
// —— steer_ack 的帧壳 turnId(规范 §2.5)——
@Test
fun `steer ack carries the shell turn id next to its v0_57 payload`() {
val frame = """
{"type":"steer_ack","sessionId":"web","turnId":"turn-9f3c1a2b3c4d5e6f",
"data":{"messageId":"msg-7f2a19c4e5","atStep":2}}
""".trimIndent()
val event = WsEvent.parse(frame)
assertTrue(event is WsEvent.SteerAck)
event as WsEvent.SteerAck
assertEquals("web", event.sessionId)
assertEquals("turn-9f3c1a2b3c4d5e6f", event.turnId)
assertEquals("msg-7f2a19c4e5", event.messageId)
assertEquals(2, event.atStep)
}
@Test
fun `a legacy steer ack without a turn id still parses with a null turn id`() {
// v0.57 ~ v0.64 的帧:事件照旧成形(不会被当未知帧丢掉),只是没有 turnId 可以绑定。
val frame = """{"type":"steer_ack","sessionId":"web","data":{"messageId":"m-1","atStep":1}}"""
val event = WsEvent.parse(frame) as WsEvent.SteerAck
assertNull(event.turnId)
assertEquals("m-1", event.messageId)
}
// —— turn.end(规范 §1.2 的收口事件)——
@Test
fun `turn end is parsed from the frame shell alone`() {
val frame = """{"type":"turn.end","sessionId":"web","turnId":"turn-1"}"""
val event = WsEvent.parse(frame)
assertTrue(event is WsEvent.TurnEnd)
event as WsEvent.TurnEnd
assertEquals("web", event.sessionId)
assertEquals("turn-1", event.turnId)
}
@Test
fun `turn end without any payload is not a bad frame`() {
// 后端 `streamEventData` 对 `StreamKindTurnEnd` 返回 nil(载荷天然是空的)。
// 若解析层要求 data 对象才认,这一帧就会被丢掉 —— 而那正是「灰态收不了口」的成因。
assertTrue(WsEvent.parse("""{"type":"turn.end","sessionId":"web","turnId":"t1"}""") is WsEvent.TurnEnd)
assertTrue(WsEvent.parse("""{"type":"turn.end"}""") is WsEvent.TurnEnd)
}
// —— 忙态帧的可选计数(规范 §2.6)——
@Test
fun `session busy carries active turns when the server reports them`() {
val frame = """{"type":"session_busy","sessionId":"web","data":{"sessionId":"web","mode":"executing","activeTurns":2}}"""
val event = WsEvent.parse(frame) as WsEvent.SessionBusy
assertEquals("executing", event.mode)
assertEquals(2, event.activeTurns)
}
@Test
fun `a missing active turns key stays null rather than becoming zero`() {
// 「缺席」与「0」绝不能互相顶替:拿缺席当 0 会把并行两路误报成空闲。
val old = WsEvent.parse("""{"type":"session_busy","sessionId":"web","data":{"sessionId":"web","mode":"thinking"}}""")
assertNull((old as WsEvent.SessionBusy).activeTurns)
}
@Test
fun `an explicit zero active turns is reported as zero`() {
val frame = """{"type":"session_idle","sessionId":"web","data":{"sessionId":"web","mode":"idle","activeTurns":0}}"""
assertEquals(0, (WsEvent.parse(frame) as WsEvent.SessionIdle).activeTurns)
}
@Test
fun `pending drain and interrupted carry their optional counts`() {
val drain = WsEvent.parse(
"""{"type":"pending_drain","sessionId":"web","data":{"sessionId":"web","mode":"thinking","queueDepth":1,"activeTurns":1}}""",
) as WsEvent.PendingDrain
assertEquals(1, drain.activeTurns)
val interrupted = WsEvent.parse(
"""{"type":"session_interrupted","sessionId":"web","data":{"sessionId":"web","mode":"executing","dropped":0,"cancelledTurns":1,"activeTurns":1}}""",
) as WsEvent.SessionInterrupted
assertEquals(1, interrupted.cancelledTurns)
assertEquals(1, interrupted.activeTurns)
}
@Test
fun `pending ack carries the reason code when the server gives one`() {
val frame =
"""{"type":"pending_ack","sessionId":"web","data":{"sessionId":"web","mode":"executing","queueDepth":2,"reason":"parallel_limit"}}"""
val event = WsEvent.parse(frame) as WsEvent.PendingAck
assertEquals("parallel_limit", event.reason)
assertEquals(2, event.queueDepth)
}
@Test
fun `an ordinary pending ack has no reason at all`() {
// 服务端明写「其它情况该键缺席,绝不伪造」——所以这里必须是 null,不能是 ""。
val frame = """{"type":"pending_ack","sessionId":"web","data":{"sessionId":"web","mode":"thinking","queueDepth":1}}"""
assertNull((WsEvent.parse(frame) as WsEvent.PendingAck).reason)
}
// —— 历史行的可选 turnId(规范 §2.7)——
@Test
fun `a persisted message may carry a turn id`() {
val frame = """
{"type":"message_persisted","sessionId":"web","data":{"message":
{"id":"msg-7f2a19c4e5","sessionId":"web","role":"user","content":"顺便帮我查一下明天的天气",
"createdAt":1760000000123,"turnId":"turn-9f3c1a2b3c4d5e6f"}}}
""".trimIndent()
val event = WsEvent.parse(frame) as WsEvent.MessagePersisted
assertEquals("turn-9f3c1a2b3c4d5e6f", event.message?.turnId)
}
@Test
fun `an old persisted row has no turn id`() {
val frame = """
{"type":"message_persisted","sessionId":"web","data":{"message":
{"id":"msg-1","sessionId":"web","role":"assistant","content":"好的","createdAt":1}}}
""".trimIndent()
val event = WsEvent.parse(frame) as WsEvent.MessagePersisted
assertNull(event.message?.turnId)
}
@Test
fun `history rows may carry turn ids and old rows simply do not`() {
val frame = """
{"type":"history","data":{"messages":[
{"id":"m-1","sessionId":"web","role":"user","content":"甲","createdAt":1,"turnId":"t1"},
{"id":"m-2","sessionId":"web","role":"assistant","content":"乙","createdAt":2}]}}
""".trimIndent()
val event = WsEvent.parse(frame) as WsEvent.History
assertEquals("t1", event.messages[0].turnId)
assertNull(event.messages[1].turnId)
}
}
@@ -0,0 +1,83 @@
package top.yeij.cyrene.data.ws
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Test
/**
* 忙着并行规范的上行两处**纯增量**(§2.2 `chat.parallel` / §2.3 `interrupt.turnId`)。
*
* 本层只负责一件事:**省略时整键不进线上、给值时原样发出**。本端今天一个字节都不发这两个字段
* (真并行要用户显式声明,声明入口不在本次范围内),所以这一组测试真正钉住的是
* 「老形状的那条帧仍然逐字节相同」——那是与老服务端兼容的全部依据。
*/
class WsOutboundParallelTest {
/** 加入 `parallel` 之前那条 `chat` 帧的逐字形状。 */
private val legacyChat = """{"type":"chat","data":{"sessionId":"web","text":"你好","deviceId":"d"}}"""
/** 加入 `turnId` 之前那条 `interrupt` 帧的逐字形状。 */
private val legacyInterrupt = """{"type":"interrupt","data":{"sessionId":"web","scope":"current"}}"""
// —— chat.parallel ——
@Test
fun `a chat without parallel is today's frame byte for byte`() {
assertEquals(legacyChat, WsOutbound.Chat(text = "你好", sessionId = "web", deviceId = "d").toJson())
assertEquals(
legacyChat,
WsOutbound.Chat(text = "你好", sessionId = "web", deviceId = "d", parallel = null).toJson(),
)
}
@Test
fun `omitting parallel omits the key itself rather than sending null`() {
val json = WsOutbound.Chat(text = "你好", sessionId = "web").toJson()
// `"parallel":null` 与「整键省略」在服务端不是一回事:后者才是「未声明」(规范 §2.2)。
assertFalse("省略时不该出现 parallel 键:$json", json.contains("parallel"))
}
@Test
fun `parallel true is carried as a real boolean`() {
val json = WsOutbound.Chat(text = "你好", sessionId = "web", parallel = true).toJson()
assertEquals("""{"type":"chat","data":{"sessionId":"web","text":"你好","parallel":true}}""", json)
}
@Test
fun `parallel false is sent as false and never silently dropped`() {
// 显式 false 与省略在服务端同义(都落回排队),但客户端**不改写**用户给了什么。
assertEquals(
"""{"type":"chat","data":{"sessionId":"web","text":"你好","parallel":false}}""",
WsOutbound.Chat(text = "你好", sessionId = "web", parallel = false).toJson(),
)
}
// —— interrupt.turnId ——
@Test
fun `an interrupt without a turn id is today's frame byte for byte`() {
assertEquals(legacyInterrupt, WsOutbound.Interrupt(sessionId = "web", scope = "current").toJson())
assertEquals(
legacyInterrupt,
WsOutbound.Interrupt(sessionId = "web", scope = "current", turnId = null).toJson(),
)
}
@Test
fun `a turn id is carried next to the scope`() {
assertEquals(
"""{"type":"interrupt","data":{"sessionId":"web","scope":"current","turnId":"turn-9f3c"}}""",
WsOutbound.Interrupt(sessionId = "web", scope = "current", turnId = "turn-9f3c").toJson(),
)
}
@Test
fun `the session id stays first so older servers see the same key order`() {
assertEquals(
"""{"type":"interrupt","data":{"sessionId":"web","turnId":"turn-1"}}""",
WsOutbound.Interrupt(sessionId = "web", turnId = "turn-1").toJson(),
)
}
}
@@ -0,0 +1,355 @@
package top.yeij.cyrene.domain.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
/**
* 忙时并行的纯逻辑:**两级分桶 / 六态判定 / 灰态收口 / 消息↔回合绑定**。
*
* 这一层不碰协程也不碰 Android,所以「规范怎么说」与「代码怎么做」可以逐条对着钉死。
* 三组测试的界线也是规范 §1.7 那三件事:分桶、绑定与收口、六态。
*
* 另外有一组**降级**测试(`字段缺失`):服务的今天没有 `turn_begin` / `steer_ack.turnId`,
* 也没有 `parallel`,本模块必须做到「只按今天收到的帧走,行为与今天逐字相同」。
*/
class ParallelTurnsTest {
// —— 分桶(规范 §1.4 / §1.7 第 1 条)——
@Test
fun `a frame without a turnId lands in the anonymous bucket`() {
val bucket = turnBucketOf("web", null, null)
assertEquals(TurnBucket("web", ""), bucket)
assertTrue(bucket.isAnonymous)
}
@Test
fun `every frame without a turnId lands in the same bucket`() {
// 这一条就是「字段缺失 ⇒ 行为与今天逐字相同」的判据:老代码只有一个流式气泡,
// 分桶之后这些帧必须全部落在**同一个**桶里,否则一条回复会被劈成两半。
val a = turnBucketOf("web", null, null)
val b = turnBucketOf("web", " ", null)
val c = turnBucketOf("web", "", "")
assertEquals(a, b)
assertEquals(a, c)
}
@Test
fun `the frame turnId wins over the remembered one`() {
assertEquals(
TurnBucket("web", "turn-2"),
turnBucketOf("web", "turn-2", "turn-1"),
)
}
@Test
fun `a missing turnId falls back to the remembered one`() {
// 本会话正在跑 turn-1,某一帧没带 turnId ⇒ 它属于 turn-1(老代码里就是那唯一一个气泡)。
assertEquals(
TurnBucket("web", "turn-1"),
turnBucketOf("web", null, "turn-1"),
)
}
@Test
fun `a blank frame turnId counts as missing`() {
assertEquals(
TurnBucket("web", "turn-1"),
turnBucketOf("web", " ", "turn-1"),
)
}
@Test
fun `two turns in one session never share a bucket`() {
val leg1 = turnBucketOf("web", "turn-1", null)
val leg2 = turnBucketOf("web", "turn-2", "turn-1")
assertFalse(leg1 == leg2)
}
@Test
fun `the same turnId in another session is another bucket`() {
// 两级分桶的第二级:`turnId` 只在会话内唯一,跨会话不能合并。
assertFalse(turnBucketOf("web", "turn-1", null) == turnBucketOf("web:abc", "turn-1", null))
}
// —— 收口帧的落桶(不许新开桶)——
@Test
fun `a closing frame lands on its own bucket when it is known`() {
assertEquals(
TurnBucket("web", "turn-1"),
closingBucketOf("web", "turn-1", "turn-1", listOf("turn-1", "turn-2")),
)
}
@Test
fun `a closing frame with an unknown turnId falls back to the remembered bucket`() {
assertEquals(
TurnBucket("web", "turn-1"),
closingBucketOf("web", "turn-9", "turn-1", listOf("turn-1")),
)
}
@Test
fun `a closing frame with no recognisable id lands on the most recent bucket`() {
// 为什么这条不能省:老代码里 `stream_end` 无条件收口那唯一一个气泡。若给它新开一个桶,
// 真正在跑的那个桶就永远收不了口(光标一直转)。
assertEquals(
TurnBucket("web", "turn-2"),
closingBucketOf("web", null, null, listOf("turn-1", "turn-2")),
)
}
@Test
fun `a closing frame never invents a bucket out of nothing`() {
assertEquals(TurnBucket("web", ""), closingBucketOf("web", "turn-1", null, emptyList()))
}
// —— 六态判定(规范 §1.1)——
@Test
fun `a local bubble is sending until it is bound or queued`() {
assertEquals(
TurnPhase.Sending,
turnPhaseOf(local = true, binding = null, queued = false, rejected = false),
)
}
@Test
fun `a queued local bubble is queued not sending`() {
assertEquals(
TurnPhase.Queued,
turnPhaseOf(local = true, binding = null, queued = true, rejected = false),
)
}
@Test
fun `a persisted bubble with nothing else is sent`() {
assertEquals(
TurnPhase.Sent,
turnPhaseOf(local = false, binding = null, queued = false, rejected = false),
)
}
@Test
fun `a queued persisted bubble is queued and not grey`() {
// 规范 §1.3:`queued` **不是**灰态。排队与「已读、未答」是两件事,不许合并。
assertEquals(
TurnPhase.Queued,
turnPhaseOf(local = false, binding = null, queued = true, rejected = false),
)
}
@Test
fun `a bound message that is not closed is answering`() {
assertEquals(
TurnPhase.Answering,
turnPhaseOf(local = false, binding = binding("turn-1"), queued = false, rejected = false),
)
}
@Test
fun `a bound message that is closed is answered`() {
assertEquals(
TurnPhase.Answered,
turnPhaseOf(local = false, binding = binding("turn-1").copy(closed = true), queued = false, rejected = false),
)
}
@Test
fun `a rejection outranks every other signal`() {
// ⑥ 是最重的事实:这条根本没进去。再拿「有没有被接手」去描述它就是错的。
assertEquals(
TurnPhase.Rejected,
turnPhaseOf(local = false, binding = binding("turn-1"), queued = true, rejected = true),
)
assertEquals(
TurnPhase.Rejected,
turnPhaseOf(local = true, binding = null, queued = false, rejected = true),
)
}
@Test
fun `an unanswered binding beats the queued mark`() {
// ③ → ④ 的换态:被接手之后,「排队中」这个角标必须让位给灰态。
assertEquals(
TurnPhase.Answering,
turnPhaseOf(local = false, binding = binding("turn-1"), queued = true, rejected = false),
)
}
// —— 灰态收口(规范 §1.2)——
@Test
fun `the matching turn end closes the grey state`() {
assertTrue(closesAnswering("turn-1", "turn-1"))
}
@Test
fun `another turn never closes this one`() {
assertFalse(closesAnswering("turn-1", "turn-2"))
}
@Test
fun `two blanks are not a match`() {
// 「都为空所以相等」会让老客户端 / 老服务端上的消息被随便一帧误收口——这条是刻意的。
assertFalse(closesAnswering(null, null))
assertFalse(closesAnswering("", " "))
assertFalse(closesAnswering("turn-1", null))
assertFalse(closesAnswering(null, "turn-1"))
}
// —— 绑定表 ——
private fun binding(turnId: String, vararg messageIds: String) =
TurnBinding(turnId = turnId, messageIds = messageIds.toList())
@Test
fun `turn begin binds the messages it names`() {
val turns = emptyList<TurnBinding>().withTurnBegin("turn-1", 1, listOf("m-a"), 1)
assertEquals(1, turns.size)
assertEquals(TurnBinding("turn-1", 1, listOf("m-a"), 1), turns.single())
assertEquals("turn-1", turns.bindingOfMessage("m-a")?.turnId)
assertNull(turns.bindingOfMessage("m-b"))
}
@Test
fun `two legs keep their own messages`() {
val turns = emptyList<TurnBinding>()
.withTurnBegin("turn-1", 1, listOf("m-a"), 1)
.withTurnBegin("turn-2", 2, listOf("m-b"), 2)
assertEquals(listOf("turn-1", "turn-2"), turns.map { it.turnId })
assertEquals("turn-1", turns.bindingOfMessage("m-a")?.turnId)
assertEquals("turn-2", turns.bindingOfMessage("m-b")?.turnId)
}
@Test
fun `a repeated turn begin merges instead of replacing`() {
val turns = emptyList<TurnBinding>()
.withTurnBegin("turn-1", 1, listOf("m-a"), 1)
.withTurnBegin("turn-1", 1, listOf("m-b"), 1)
assertEquals(listOf("m-a", "m-b"), turns.single().messageIds)
}
@Test
fun `a repeated turn begin never reopens a closed leg`() {
// 重开会让气泡回到灰态,而第二次收口永远不会再来(那一帧已经发过了)。
val closed = emptyList<TurnBinding>()
.withTurnBegin("turn-1", 1, listOf("m-a"), 1)
.closingTurn("turn-1")
val again = closed.withTurnBegin("turn-1", 1, listOf("m-a"), 1)
assertTrue(again.single().closed)
}
@Test
fun `a blank turnId in turn begin changes nothing`() {
val turns = emptyList<TurnBinding>()
assertSame(turns, turns.withTurnBegin("", 1, listOf("m-a"), 1))
assertSame(turns, turns.withTurnBegin(" ", 1, listOf("m-a"), 1))
}
@Test
fun `steer ack binds a message to an existing leg`() {
val turns = emptyList<TurnBinding>()
.withTurnBegin("turn-1", 1, listOf("m-a"), 1)
.withSteeredMessage("turn-1", "m-b")
assertEquals(listOf("m-a", "m-b"), turns.single().messageIds)
assertEquals("turn-1", turns.bindingOfMessage("m-b")?.turnId)
}
@Test
fun `steer ack creates a leg when the turn is unknown`() {
// 服务端可能先发 steer_ack 再补 turn_begin;先建一条只有这一条消息的表,之后合并即可。
val turns = emptyList<TurnBinding>().withSteeredMessage("turn-1", "m-b")
assertEquals(TurnBinding("turn-1", UNKNOWN_LEG, listOf("m-b")), turns.single())
}
@Test
fun `steer ack with a blank turnId binds nothing`() {
val turns = listOf(binding("turn-1", "m-a"))
assertSame(turns, turns.withSteeredMessage("", "m-b"))
assertSame(turns, turns.withSteeredMessage("turn-1", " "))
}
@Test
fun `closing one turn leaves the other leg open`() {
val turns = emptyList<TurnBinding>()
.withTurnBegin("turn-1", 1, listOf("m-a"), 1)
.withTurnBegin("turn-2", 2, listOf("m-b"), 2)
.closingTurn("turn-2")
assertEquals(TurnPhase.Answering, phaseOfMessage(turns, "m-a"))
assertEquals(TurnPhase.Answered, phaseOfMessage(turns, "m-b"))
}
@Test
fun `closing with an unknown id closes nothing and returns the same instance`() {
val turns = listOf(binding("turn-1", "m-a"))
assertSame(turns, turns.closingTurn(null))
assertSame(turns, turns.closingTurn(""))
assertSame(turns, turns.closingTurn("turn-9"))
}
@Test
fun `closing twice is the same as closing once`() {
val once = listOf(binding("turn-1", "m-a")).closingTurn("turn-1")
assertSame(once, once.closingTurn("turn-1"))
}
@Test
fun `all closed closes every open leg and is idempotent`() {
val turns = emptyList<TurnBinding>()
.withTurnBegin("turn-1", 1, listOf("m-a"), 1)
.withTurnBegin("turn-2", 2, listOf("m-b"), 2)
val closed = turns.allClosed()
assertTrue(closed.all { it.closed })
assertSame(closed, closed.allClosed())
}
@Test
fun `a blank message id never matches a binding`() {
val turns = listOf(binding("turn-1", ""))
assertNull(turns.bindingOfMessage(""))
assertNull(turns.bindingOfMessage(" "))
}
// —— 路小标 ——
@Test
fun `only the second leg and later get a badge`() {
// 单路是常态:给唯一一路挂「并行路 1」只是给每条回复加一个新的视觉噪音。
assertNull(legBadgeOf(UNKNOWN_LEG))
assertNull(legBadgeOf(1))
assertEquals("并行路 2", legBadgeOf(2))
assertEquals("并行路 3", legBadgeOf(3))
}
private fun phaseOfMessage(turns: List<TurnBinding>, messageId: String): TurnPhase =
turnPhaseOf(
local = false,
binding = turns.bindingOfMessage(messageId),
queued = false,
rejected = false,
)
}
@@ -0,0 +1,281 @@
package top.yeij.cyrene.ui.chat
import org.junit.Assert.assertEquals
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.domain.chat.TurnBinding
import top.yeij.cyrene.domain.chat.TurnBucket
import top.yeij.cyrene.domain.chat.TurnPhase
import top.yeij.cyrene.domain.chat.withTurnBegin
import top.yeij.cyrene.domain.parse.ParsedBubble
/**
* 流式气泡的**两级分桶**(规范 §1.4 / §1.7 第 1 条)在 UI 层的那一半。
*
* 这里的头号回归是「两路交错的 delta 不许糊成一条」——老代码只有一个 `StreamingState`,
* 所有 `message.delta` 都往它上面堆;分桶之后每一路各有一份。第二件事是**单路逐字不变**:
* 帧壳不带 `turnId` 时全部帧落匿名桶,`streams` 的长度恒为 1。
*/
class TurnStreamsTest {
private fun bucket(turnId: String) = TurnBucket("web", turnId)
private fun bubble(text: String) =
ParsedBubble(body = text, parts = emptyList(), head = emptyList(), tail = emptyList())
private fun streaming(vararg texts: String) = StreamingState(bubbles = texts.map(::bubble))
private fun leg(turnId: String, vararg texts: String) = StreamingLeg(bucket(turnId), streaming(*texts))
// —— 两路各长各的 ——
@Test
fun `two turns accumulate in their own legs`() {
val legs = emptyList<StreamingLeg>()
.withLeg(bucket("t1")) { it.copy(bubbles = listOf(bubble("第一路"))) }
.withLeg(bucket("t2")) { it.copy(bubbles = listOf(bubble("第二路"))) }
assertEquals(2, legs.size)
assertEquals(listOf("第一路"), bodies(legs, "t1"))
assertEquals(listOf("第二路"), bodies(legs, "t2"))
}
@Test
fun `interleaved deltas never merge into one leg`() {
// 规范 §1.4 点名的那个错:按「当前气泡」累加,两条回复会被糊成一条。
val legs = emptyList<StreamingLeg>()
.withLeg(bucket("t1")) { it.copy(bubbles = it.bubbles + bubble("A1")) }
.withLeg(bucket("t2")) { it.copy(bubbles = it.bubbles + bubble("B1")) }
.withLeg(bucket("t1")) { it.copy(bubbles = it.bubbles + bubble("A2")) }
.withLeg(bucket("t2")) { it.copy(bubbles = it.bubbles + bubble("B2")) }
assertEquals(listOf("A1", "A2"), bodies(legs, "t1"))
assertEquals(listOf("B1", "B2"), bodies(legs, "t2"))
}
@Test
fun `one turn is exactly one leg`() {
// 「字段缺失 ⇒ 行为与今天逐字相同」在 UI 层的判据:单路时长度恒为 1。
val legs = emptyList<StreamingLeg>()
.withLeg(bucket("")) { it.copy(bubbles = listOf(bubble("A"))) }
.withLeg(bucket("")) { it.copy(bubbles = it.bubbles + bubble("B")) }
assertEquals(1, legs.size)
assertEquals(listOf("A", "B"), bodies(legs, ""))
}
@Test
fun `legs keep their creation order`() {
val legs = emptyList<StreamingLeg>()
.withLeg(bucket("t1")) { it }
.withLeg(bucket("t2")) { it }
.withLeg(bucket("t1")) { it }
assertEquals(listOf("t1", "t2"), legs.map { it.bucket.turnId })
}
// —— 新一轮开始时的收口 ——
@Test
fun `beginning a turn drops the other finished legs`() {
// 老代码的 freshTurnIfFinished:上一轮已结束就把那一个气泡整个清掉(内容已被落库行取代)。
val legs = listOf(
StreamingLeg(bucket("t1"), streaming("旧内容").copy(active = false)),
StreamingLeg(bucket("t2"), streaming("还在跑")),
)
val after = legs.beginLeg(bucket("t2"))
assertEquals(listOf("t2"), after.map { it.bucket.turnId })
}
@Test
fun `beginning a turn never touches a leg that is still running`() {
val legs = listOf(
StreamingLeg(bucket("t1"), streaming("另一路")),
StreamingLeg(bucket("t2"), streaming("旧").copy(active = false)),
)
val after = legs.beginLeg(bucket("t2"))
assertEquals(listOf("t1", "t2"), after.map { it.bucket.turnId })
assertEquals(listOf("另一路"), bodies(after, "t1"))
assertEquals(emptyList<String>(), bodies(after, "t2"))
}
@Test
fun `beginning a turn on an unknown bucket adds nothing`() {
// 新的桶由紧接着的 withLeg 建;这里凭空塞一个空态只会在坏时序下多出一个看不见的空气泡。
val legs = listOf(leg("t1", "内容"))
assertEquals(legs, legs.beginLeg(bucket("t9")))
}
// —— 收口 ——
@Test
fun `finishing a leg keeps its text and stops the cursor`() {
val legs = listOf(StreamingLeg(bucket("t1"), streaming("半句")))
val after = legs.finishingLeg(bucket("t1"), listOf(bubble("整句")))
assertEquals(listOf("整句"), bodies(after, "t1"))
assertTrue(!after.single().state.active)
assertTrue(!after.single().state.thinkingActive)
}
@Test
fun `finishing without a parser keeps the text that is already on screen`() {
// 「没有新正文」不该被写成「正文是空的」——那会把已经显示出来的字擦掉。
val legs = listOf(StreamingLeg(bucket("t1"), streaming("已经显示的")))
val after = legs.finishingLeg(bucket("t1"), null)
assertEquals(listOf("已经显示的"), bodies(after, "t1"))
assertTrue(!after.single().state.active)
}
@Test
fun `finishing an unknown leg creates nothing`() {
val legs = listOf(leg("t1", "内容"))
assertSame(legs, legs.finishingLeg(bucket("t9"), emptyList()))
}
@Test
fun `dropping the persisted leg keeps the other one`() {
val legs = listOf(leg("t1", "A"), leg("t2", "B"))
val after = legs.withoutLeg(bucket("t1"))
assertEquals(listOf("t2"), after.map { it.bucket.turnId })
assertSame(after, after.withoutLeg(bucket("t1")))
}
// —— 六态在状态上的推导 ——
@Test
fun `without any turn frames every message is where it is today`() {
// 老服务端(没有 turn_begin / steer_ack.turnId):已落库 = ②,本地气泡 = ①。
val state = ChatUiState(messages = listOf(message("m-1")))
assertEquals(TurnPhase.Sent, state.phaseOf(state.messages.single()))
assertEquals(TurnPhase.Sending, state.phaseOf(PendingMessage("local-1", "你好")))
}
@Test
fun `a bound message is grey until its turn closes`() {
val turns = emptyList<TurnBinding>().withTurnBegin("t1", 2, listOf("m-1"), 2)
val bound = ChatUiState(messages = listOf(message("m-1")), turns = turns)
assertEquals(TurnPhase.Answering, bound.phaseOf(bound.messages.single()))
assertTrue(bound.phaseOf(bound.messages.single()) != TurnPhase.Queued)
}
@Test
fun `the queued mark is found through the client message id too`() {
// 队列回执先到、落库后到:那时本地认识的键是 `localId`,而消息上的键是 `clientMsgId`。
val state = ChatUiState(
messages = listOf(message("m-1", clientMsgId = "local-1")),
queuedKeys = setOf("local-1"),
)
assertEquals(TurnPhase.Queued, state.phaseOf(state.messages.single()))
}
@Test
fun `the queued mark on a local bubble survives into the composer view`() {
val state = ChatUiState(pending = listOf(PendingMessage("local-1", "你好")), queuedKeys = setOf("local-1"))
assertEquals(TurnPhase.Queued, state.phaseOf(state.pending.single()))
}
// —— 路小标与跳转 ——
@Test
fun `a single leg carries no badge`() {
val turns = emptyList<TurnBinding>().withTurnBegin("t1", 1, listOf("m-1"), 1)
val state = ChatUiState(messages = listOf(message("m-1")), turns = turns)
assertNull(state.legBadgeForMessage(state.messages.single()))
assertNull(state.legBadgeForTurn("t1"))
}
@Test
fun `the second leg carries its badge on the question and on the stream`() {
val turns = emptyList<TurnBinding>().withTurnBegin("t2", 2, listOf("m-1"), 2)
val state = ChatUiState(messages = listOf(message("m-1")), turns = turns)
assertEquals("并行路 2", state.legBadgeForMessage(state.messages.single()))
assertEquals("并行路 2", state.legBadgeForTurn("t2"))
}
@Test
fun `the answer of a leg carries the same badge through its own turn id`() {
// 回复侧:历史行的可选 turnId(规范 §2.7)指出它是哪一路产出的 ⇒ 落库之后小标仍在。
val turns = emptyList<TurnBinding>().withTurnBegin("t2", 2, listOf("m-1"), 2)
val answer = message("m-9", role = "assistant", turnId = "t2")
val state = ChatUiState(messages = listOf(message("m-1"), answer), turns = turns)
assertEquals("并行路 2", state.legBadgeForMessage(answer))
}
@Test
fun `an old answer row without a turn id carries no badge`() {
// v0.65 之前历史行没有 turnId ⇒ 挂不出标,也就一个像素都不多画。
val turns = emptyList<TurnBinding>().withTurnBegin("t2", 2, listOf("m-1"), 2)
val answer = message("m-9", role = "assistant")
val state = ChatUiState(messages = listOf(message("m-1"), answer), turns = turns)
assertNull(state.legBadgeForMessage(answer))
}
@Test
fun `jumping to a question maps to the reverse layout index`() {
// items = [已落库 m-1, m-2, m-3](升序);reverseLayout 下懒下标 = size - 1 - 升序下标。
val turns = emptyList<TurnBinding>().withTurnBegin("t1", 1, listOf("m-1"), 1)
val state = ChatUiState(
messages = listOf(message("m-1"), message("m-2"), message("m-3")),
turns = turns,
)
assertEquals(2, state.jumpIndexForTurn("t1"))
assertNull(state.jumpIndexForTurn("t9"))
}
@Test
fun `a turn whose question is not loaded has no jump target`() {
val turns = emptyList<TurnBinding>().withTurnBegin("t1", 1, listOf("m-unknown"), 1)
val state = ChatUiState(messages = listOf(message("m-1")), turns = turns)
assertNull(state.jumpIndexForTurn("t1"))
}
// —— 气泡角标文案 ——
@Test
fun `the footer only mentions queued and answering`() {
assertEquals("来自 安卓", bubbleFooter("来自 安卓", TurnPhase.Sent, null))
assertEquals("排队中 · 来自 安卓", bubbleFooter("来自 安卓", TurnPhase.Queued, null))
assertEquals("已读,正在回答… · 来自 安卓", bubbleFooter("来自 安卓", TurnPhase.Answering, null))
assertEquals("并行路 2 · 来自 安卓", bubbleFooter("来自 安卓", TurnPhase.Answered, "并行路 2"))
assertNull(bubbleFooter(null, TurnPhase.Sent, null))
}
private fun bodies(legs: List<StreamingLeg>, turnId: String): List<String> =
legs.leg(bucket(turnId))?.state?.bubbles?.map { it.body }.orEmpty()
private fun message(id: String, role: String = "user", clientMsgId: String? = null, turnId: String? = null) =
HistoryMessageDto(
id = id,
sessionId = "web",
role = role,
content = id,
clientMsgId = clientMsgId,
createdAt = 1L,
turnId = turnId,
)
}