fix(chat): 并行路角标改以 turn_begin.kind 为准,不再拿 leg 冒充并行

leg 是服务端**会话内的路序号**:beginLegLocked 对每一条新路都自增 legSeq,
普通串行回合照样占号——会话里发第二条普通消息,leg 就是 2。而角标旧判据是
leg >= 2,于是那条串行消息被挂上「并行路 2」,凭空声称存在并不存在的并行。
「是不是并行路」真正的判据是 turn_begin.kind(turn = 普通串行回合,
parallel = 忙时另起的一路);解析层本来就把它读出来了,只是绑定时丢掉了。

- TurnBinding 新增 parallel;withTurnBegin 收下 kind,由 isParallelLegKind
  判定:只有逐字等于 parallel 才算,turn、缺席、空串、大小写不同一律不算。
- 合并规则:新建按 kind 定;同 turnId 重复到达时取或(old.parallel || 本帧
  是并行),一旦为 true 就永远是 true。与 closed 同一条纪律——单调的既成事实
  不许被后到的帧翻回去;也顾及 steer_ack 可能先建一条没有 kind 的占位表、
  随后才由这一路自己的 turn_begin 补上并行的现实顺序。
- 降级:kind 不来的老服务端上 parallel 恒为 false,角标一枚都不挂——宁可少挂
  一枚,也不拿 leg 去猜、去多说一句假话。
- legBadgeOf 改成同时看 parallel 与 leg,三处调用点(提问侧、回复侧、流式气泡)
  都跟着传 parallel;文案仍是「并行路 N」。

测试:ParallelTurnsTest 补 kind=turn / kind=parallel / kind 缺席三档、重复帧的
合并(含「已收口的并行路不被重新打开也不被降级」);TurnStreamsTest 把
「leg>=2 就挂标」的断言改成以 kind 为准,并补上那条真机回归——会话里第二条
普通消息(leg=2、kind=turn)在提问侧与回复侧都不挂角标。
This commit is contained in:
AskaEth committed 2026-10-05 11:59:21 +08:00
1 parent 6e00697110
commit 39a63f8d30
7 files changed
+233 -32

No files matched your search

@@ -205,10 +205,31 @@ class TurnStreamsTest {
}
@Test
fun `the second leg carries its badge on the question and on the stream`() {
fun `a second serial turn carries no badge even though its leg is two`() {
// 真机那个 bug:会话里第二条**普通**消息串行走,服务端照样给它 leg=2(leg 是会话内第几条路),
// 而 kind 是 "turn"。角标必须以 kind 为准 ⇒ 一枚都不挂。
val turns = emptyList<TurnBinding>().withTurnBegin("t2", 2, listOf("m-1"), 1, kind = "turn")
val state = ChatUiState(messages = listOf(message("m-1")), turns = turns)
assertNull(state.legBadgeForMessage(state.messages.single()))
assertNull(state.legBadgeForTurn("t2"))
}
@Test
fun `a second turn without any kind carries no badge`() {
// 老服务端:整键不给。认不出来 ⇒ 不挂角标(降级方向是「少挂」,不是「照 leg 猜」)。
val turns = emptyList<TurnBinding>().withTurnBegin("t2", 2, listOf("m-1"), 2)
val state = ChatUiState(messages = listOf(message("m-1")), turns = turns)
assertNull(state.legBadgeForMessage(state.messages.single()))
assertNull(state.legBadgeForTurn("t2"))
}
@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, kind = "parallel")
val state = ChatUiState(messages = listOf(message("m-1")), turns = turns)
assertEquals("并行路 2", state.legBadgeForMessage(state.messages.single()))
assertEquals("并行路 2", state.legBadgeForTurn("t2"))
}
@@ -216,17 +237,27 @@ class TurnStreamsTest {
@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 turns = emptyList<TurnBinding>().withTurnBegin("t2", 2, listOf("m-1"), 2, kind = "parallel")
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 `the answer of a serial leg carries no badge through its own turn id`() {
// 同一条路在回复侧:leg=3 的串行回合也不许挂标(判据是 kind,不是 leg)。
val turns = emptyList<TurnBinding>().withTurnBegin("t3", 3, listOf("m-1"), 1, kind = "turn")
val answer = message("m-9", role = "assistant", turnId = "t3")
val state = ChatUiState(messages = listOf(message("m-1"), answer), turns = turns)
assertNull(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 turns = emptyList<TurnBinding>().withTurnBegin("t2", 2, listOf("m-1"), 2, kind = "parallel")
val answer = message("m-9", role = "assistant")
val state = ChatUiState(messages = listOf(message("m-1"), answer), turns = turns)