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