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

@@ -13,8 +13,9 @@ import org.junit.Test
* 这一层不碰协程也不碰 Android,所以「规范怎么说」与「代码怎么做」可以逐条对着钉死。
* 三组测试的界线也是规范 §1.7 那三件事:分桶、绑定与收口、六态。
*
* 另外有一组**降级**测试(`字段缺失`):服务的今天没有 `turn_begin` / `steer_ack.turnId`,
* 也没有 `parallel`,本模块必须做到「只按今天收到的帧走,行为与今天逐字相同」。
* 另外有一组**降级**测试(`字段缺失`):老服务端没有 `turn_begin` / `steer_ack.turnId` /
* `turn_begin.kind`,本模块必须做到「只按收到的帧走,行为与今天**可解释**地一致」——认不出来的
* 事实一律降级成「不知道」,而「不知道」在界面上等于**不挂角标**(宁可少挂一枚,也不多说一句假话)。
*/
class ParallelTurnsTest {
@@ -334,15 +335,110 @@ class ParallelTurnsTest {
assertNull(turns.bindingOfMessage(" "))
}
// —— 路小标 ——
// —— 路小标(判据是「真并行」,不是 leg)——
@Test
fun `only the second leg and later get a badge`() {
fun `only a parallel leg with a real ordinal gets a badge`() {
// 单路是常态:给唯一一路挂「并行路 1」只是给每条回复加一个新的视觉噪音。
assertNull(legBadgeOf(UNKNOWN_LEG))
assertNull(legBadgeOf(1))
assertEquals("并行路 2", legBadgeOf(2))
assertEquals("并行路 3", legBadgeOf(3))
assertNull(legBadgeOf(UNKNOWN_LEG, parallel = true))
assertNull(legBadgeOf(1, parallel = true))
assertEquals("并行路 2", legBadgeOf(2, parallel = true))
assertEquals("并行路 3", legBadgeOf(3, parallel = true))
}
@Test
fun `a serial leg never gets a badge however large its ordinal is`() {
// 这条钉的就是那个真机 bug:leg 是**会话内第几条路**,会话里第二条普通消息的 leg 就是 2。
// 挂不出标才是对的——那一刻一条并行都没有,挂标就是对用户说谎。
assertNull(legBadgeOf(UNKNOWN_LEG, parallel = false))
assertNull(legBadgeOf(1, parallel = false))
assertNull(legBadgeOf(2, parallel = false))
assertNull(legBadgeOf(99, parallel = false))
}
// —— kind ⇒ parallel(规范 §2.4)——
@Test
fun `only the exact parallel kind counts`() {
assertTrue(isParallelLegKind("parallel"))
assertFalse(isParallelLegKind("turn"))
assertFalse(isParallelLegKind(""))
assertFalse(isParallelLegKind(null))
// 大小写与拼写都不容忍:认不出来就不认(少挂一枚角标好过多说一句假话)。
assertFalse(isParallelLegKind("Parallel"))
assertFalse(isParallelLegKind("PARALLEL"))
assertFalse(isParallelLegKind("parallels"))
// 两侧空白按缺席的写法一并容忍(与 parallelLimitNoticeFor 认 reason 同一条口径)。
assertTrue(isParallelLegKind(" parallel "))
}
@Test
fun `a turn kind leg is bound as not parallel`() {
val turns = emptyList<TurnBinding>().withTurnBegin("turn-2", 2, listOf("m-b"), 1, kind = "turn")
val leg = turns.single()
assertFalse(leg.parallel)
assertNull(legBadgeOf(leg.leg, leg.parallel))
}
@Test
fun `a parallel kind leg is bound as parallel`() {
val turns = emptyList<TurnBinding>().withTurnBegin("turn-2", 2, listOf("m-b"), 2, kind = "parallel")
val leg = turns.single()
assertTrue(leg.parallel)
assertEquals("并行路 2", legBadgeOf(leg.leg, leg.parallel))
}
@Test
fun `a missing kind degrades to not parallel`() {
// 老服务端(整键不给)与空串是同一件事:认不出来 ⇒ 不挂角标,绝不拿 leg 顶替。
val absent = emptyList<TurnBinding>().withTurnBegin("turn-2", 2, listOf("m-b"), 2)
val blank = emptyList<TurnBinding>().withTurnBegin("turn-2", 2, listOf("m-b"), 2, kind = "")
assertFalse(absent.single().parallel)
assertFalse(blank.single().parallel)
assertNull(legBadgeOf(absent.single().leg, absent.single().parallel))
assertNull(legBadgeOf(blank.single().leg, blank.single().parallel))
}
// —— parallel 的合并规则:单调事实 ——
@Test
fun `a repeat that says parallel upgrades the binding`() {
// steer_ack 可能先按「某条消息被吃进这一路」建一条没有 kind 的占位表(只能是 false),
// 随后那一路自己的 turn_begin 才是唯一能把它标成并行的地方——合并必须允许升上去。
val turns = emptyList<TurnBinding>()
.withSteeredMessage("turn-2", "m-b")
.withTurnBegin("turn-2", 2, listOf("m-b"), 2, kind = "parallel")
assertTrue(turns.single().parallel)
assertEquals("并行路 2", legBadgeOf(turns.single().leg, turns.single().parallel))
}
@Test
fun `a weaker repeat never demotes a parallel leg`() {
// 与 closed 同一条纪律:单调的既成事实不许被后到的帧翻回去。三种「更弱」的形状各钉一次。
val parallel = emptyList<TurnBinding>()
.withTurnBegin("turn-2", 2, listOf("m-b"), 2, kind = "parallel")
assertTrue(parallel.withTurnBegin("turn-2", 2, listOf("m-b"), 2, kind = "turn").single().parallel)
assertTrue(parallel.withTurnBegin("turn-2", 2, listOf("m-b"), 2).single().parallel)
assertTrue(parallel.withTurnBegin("turn-2", 2, listOf("m-b"), 2, kind = "").single().parallel)
assertTrue(parallel.withTurnBegin("turn-2", 2, listOf("m-b"), 2, kind = "parallel").single().parallel)
}
@Test
fun `a closed parallel leg stays closed and parallel when the frame repeats`() {
// 既有先例(重复帧不许重新打开已收口的路)+ 新字段一起钉住:既不能重开,也不能降级。
val closed = emptyList<TurnBinding>()
.withTurnBegin("turn-2", 2, listOf("m-b"), 2, kind = "parallel")
.closingTurn("turn-2")
val again = closed.withTurnBegin("turn-2", 2, listOf("m-b"), 2, kind = "turn").single()
assertTrue(again.closed)
assertTrue(again.parallel)
}
private fun phaseOfMessage(turns: List<TurnBinding>, messageId: String): TurnPhase =
@@ -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)