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

@@ -235,10 +235,14 @@ sealed class WsEvent {
* ② 建立「消息 ↔ 回合」绑定,好在收口时用该 `turnId` 的 `turn.end` 配对;③ 按 `turnId` 分桶。
*
* [kind] ∈ `"turn"` \| `"parallel"`:服务端**实际**如何起这条路(新回合 / 续跑 = `"turn"`,
* 声明并行且照准 = `"parallel"`)。**仅供展示**,[messageIds] 才是判据——规范 §2.4 明写
* 「客户端的状态机不必依赖它」,本端因此**不**用它做任何分支。
* 声明并行且照准 = `"parallel"`)。规范 §2.4 说状态机不必依赖它,本端照办:[messageIds] 仍是
* 六态的唯一判据。但**「这一路是不是并行路」只有它说了算**——[leg] 是**会话内第几条路**
* (服务端对每条新路都自增,普通串行回合照样占号),拿它当并行的证据会挂出并不存在的
* 「并行路 N」。所以本端把它带进绑定(`TurnBinding.parallel`),只喂给路小标那一处;
* 缺席(老服务端整键不给)时为空串 ⇒ 认不出来 ⇒ 不挂角标。
*
* [leg] 是该路的会话内序号(1 基,`1` = 唯一 / 第一路),界面上的「并行路 N」标就取它。
* [leg] 是该路的会话内序号(1 基,`1` = 第一路),注意它**不是**「并行组里的第几路」。
* 小标文案取它,但是否挂标取 [kind]。
* [activeTurns] 是**本次启动后**该会话的在途路数。
*
* 帧壳的 `sessionId` 是权威归属(`data` 内不重复,规范 §2.4);本端仍按既有口径
@@ -18,11 +18,22 @@ package top.yeij.cyrene.domain.chat
* 一条都还没见过时落进**匿名桶**(`turnId == ""`)。于是「所有帧都不带 `turnId`」时全部帧落进
* **同一个桶**——也就是**今天那一个流式气泡**,行为逐字相同。
* 同理 `turn_begin` / `steer_ack` 不来时,六态里只会出现 ①②③⑤,灰态(④)根本不会进入。
* `kind` 不来时(老服务端)也只是**不挂并行路角标**——[isParallelLegKind] 认不出来就说不并行,
* 绝不拿 `leg` 去替它撑门面。
*/
/** 「路序号未知」:`steer_ack` 只给 `turnId`、不给 `leg`(规范 §2.5 的载荷只有 messageId / atStep)。 */
const val UNKNOWN_LEG: Int = 0
/**
* `turn_begin.kind` 里表示「忙时另起的一路」的取值(规范 §2.4)。
*
* 另一个取值 `"turn"` 表示「新回合 / 续跑」——也就是**普通串行回合**,它不在这里单列常量:
* 本端只认得出 `"parallel"` 这一个,其余一切取值(含 `"turn"`)都按「不是并行」处理
* (见 [isParallelLegKind] 的 KDoc)。
*/
private const val PARALLEL_LEG_KIND = "parallel"
/**
* 流式分桶键:`sessionId` → `turnId` 两级(规范 §1.4 / §1.7 第 1 条)。
*
@@ -156,10 +167,16 @@ fun closesAnswering(boundTurnId: String?, closingTurnId: String?): Boolean {
/**
* 消息 ↔ 回合的绑定:**一路**(规范 §2.4 的 `turn_begin` 说的是「编排器每次启动一路发一次」)。
*
* 它由两件事建立:`turn_begin`(一次给全 `messageIds` / `leg` / `activeTurns`)与 `steer_ack`
* 它由两件事建立:`turn_begin`(一次给全 `messageIds` / `leg` / `activeTurns` / `kind`)与 `steer_ack`
* (只补「某条消息被哪一路吃进」,没有 `leg`)。
* 只在 `turnId` **非空**时才会被建立——`turnId` 是这张表的键,也是收口的唯一凭据(见
* [closesAnswering]);没有它建出来的绑定永远收不了口,那不是绑定,是一个永远灰着的气泡。
*
* ## [leg] 与 [parallel] 是两件事(这张表最容易被读错的一处)
* [leg] 只是**会话内第几条路**:服务端 `beginLegLocked` 对**每一条新路**都自增 `legSeq`,普通串行
* 回合照样占号——会话里第二条普通消息的 `leg` 就是 2。所以 `leg >= 2` **完全不等于**并行,拿它当
* 并行的证据会挂出并不存在的「并行路 N」。
* 真正说明「这一路是忙时另起的一路」的只有 `turn_begin.kind`,落到 [parallel](见 [isParallelLegKind])。
*/
data class TurnBinding(
/** 这一路的权威回合标识(与该路全部流帧的 `turnId` 同值)。 */
@@ -170,6 +187,8 @@ data class TurnBinding(
val messageIds: List<String> = emptyList(),
/** 服务端最近一次报的该会话在途路数(`turn_begin.activeTurns`;0 = 没报过)。 */
val activeTurns: Int = 0,
/** 这一路是不是**真并行路**(服务端 `turn_begin.kind == "parallel"`,见 [isParallelLegKind])。 */
val parallel: Boolean = false,
/** 该路是否已收口(`turn.end` / 同 `turnId` 的助手落库行)。 */
val closed: Boolean = false,
)
@@ -178,6 +197,20 @@ data class TurnBinding(
fun List<TurnBinding>.bindingOfMessage(messageId: String): TurnBinding? =
firstOrNull { binding -> messageId.isNotBlank() && binding.messageIds.contains(messageId) }
/**
* `turn_begin.kind` ⇒ 「这一路是不是并行路」(纯函数,规范 §2.4)。
*
* 只有**逐字等于** `"parallel"` 才算并行:`"turn"`(新回合 / 续跑)不算,缺席 / 空串 / 大小写不同 /
* 以后新增的其它取值也一律不算。两侧空白按缺席的写法一并容忍(与 `parallelLimitNoticeFor` 认
* `pending_ack.reason` 同一条口径),大小写**不**容忍——认不出来就是认不出来。
*
* **为什么认不出来就算「不是并行」**:`kind` 是服务端后加的键,本端必须能在**老服务端**(整键不给)
* 上跑,而 [TurnBinding.leg] 又**根本不足以**当判据(它是会话内第几条路,串行回合照样占号)。
* 两件事叠起来只剩一个如实的答案:认不出来就说「不知道」,而「不知道」在界面上等于**不挂角标**
* ——宁可少挂一枚,也不能挂一枚声称存在并不存在的并行的标。
*/
fun isParallelLegKind(kind: String?): Boolean = kind?.trim() == PARALLEL_LEG_KIND
/**
* 收下一条 `turn_begin`(纯函数,返回新表)。
*
@@ -185,23 +218,47 @@ fun List<TurnBinding>.bindingOfMessage(messageId: String): TurnBinding? =
* `leg` / `activeTurns` 用新报的值,**`closed` 保持原值**——一次重复到达的 `turn_begin` 不该把一条
* 已经收口的路「重新打开」(那会让气泡回到灰态并且再也等不到第二次收口)。
* 表里没有它时按**追加**处理:两路的顺序就是它们被启动的顺序。
*
* ## [TurnBinding.parallel] 的合并规则:一个只说真话的**单调**事实
* 新建时它 = [kind] 是不是 `"parallel"`(见 [isParallelLegKind],认不出来 ⇒ `false`);
* 已存在时取**或**(`old.parallel || 本帧是并行`)——**一旦为 true 就永远是 true**。
*
* 三条理由:
* 1. 「这一路是被并行起出来的」是**既成事实**:服务端同一条路的 `kind` 由创建那一刻的标志决定、
* 之后不可变,所以重复帧本不该各说各话;真各说各话时以「说过 parallel」为准——它不会凭一个
* **更弱**(认不出 `kind`)的重复帧把已确立的并行抹掉,而并行只会多挂一枚有据可依的角标;
* 2. 与 [TurnBinding.closed] 同一条纪律:**单调的既成事实不许被后到的帧翻回去**;
* 3. 现实里真有「先弱后强」的顺序:[withSteeredMessage] 可能先按 `steer_ack` 建一条占位表
* (那时没有 `kind`,只能是 `false`),随后那一路自己的 `turn_begin` 才是唯一能把它标成并行的地方。
*
* [kind] 缺席(默认空串)= 老服务端 / 老形状的帧 ⇒ 这一路永远 `false` ⇒ 不挂角标,与今天可解释地
* 一致(少一枚角标,但没有多说一句假话)。
*/
fun List<TurnBinding>.withTurnBegin(
turnId: String,
leg: Int,
messageIds: List<String>,
activeTurns: Int,
kind: String = "",
): List<TurnBinding> {
val key = turnId.orNullIfBlank() ?: return this
val parallel = isParallelLegKind(kind)
val index = indexOfFirst { it.turnId == key }
if (index < 0) {
return this + TurnBinding(key, leg, messageIds.filter(String::isNotBlank), activeTurns)
return this + TurnBinding(
turnId = key,
leg = leg,
messageIds = messageIds.filter(String::isNotBlank),
activeTurns = activeTurns,
parallel = parallel,
)
}
val old = this[index]
val merged = old.copy(
leg = leg.takeIf { it > UNKNOWN_LEG } ?: old.leg,
messageIds = (old.messageIds + messageIds.filter(String::isNotBlank)).distinct(),
activeTurns = activeTurns.coerceAtLeast(0),
parallel = old.parallel || parallel,
)
return toMutableList().also { it[index] = merged }
}
@@ -255,14 +312,20 @@ fun List<TurnBinding>.allClosed(): List<TurnBinding> {
}
/**
* 路标文案(规范 §1.4 A 的「小标」):`leg >= 2` 才成句,其余返回 `null`。
* 路标文案(规范 §1.4 A 的「小标」):**真的是并行路**且 `leg >= 2` 时才成句,其余返回 `null`。
*
* **为什么 1 路不挂标**:单路是今天的常态(`parallel:true` 是客户端显式声明才有的能力,
* 规范 §0 的默认行为一条),给唯一一路也挂个「并行路 1」等于给每条回复都加一个新的视觉噪音,
* 却什么都说明不了。所以「有没有小标」本身就精确表达「这里是不是真的有多路」。
* `leg` 未知([UNKNOWN_LEG])同理不挂——不能把「不知道第几路」说成「第 0 路」。
* 两个条件各挡一类错误,缺一不可:
* - [parallel] 为 `false` ⇒ **恒不挂**,哪怕 `leg` 是 2、3、99。`leg` 是**会话内第几条路**
* (服务端对每条新路都自增,普通串行回合照样占号),拿它当并行的证据就是**对用户说谎**:
* 会话里第二条普通消息会凭空冒出一枚「并行路 2」,而那一刻一条并行都没有。是不是并行路
* 只有 `turn_begin.kind`(⇒ [TurnBinding.parallel])说了算;
* - [leg] 小于 2(含 [UNKNOWN_LEG])⇒ 不挂:单路是今天的常态(`parallel:true` 是客户端显式声明
* 才有的能力,规范 §0 的默认行为一条),给唯一一路也挂个「并行路 1」等于给每条回复都加一个新的
* 视觉噪音,却什么都说明不了。所以「有没有小标」本身就精确表达「这里是不是真的有多路」。
* `leg` 未知同理不挂——不能把「不知道第几路」说成「第 0 路」。
*/
fun legBadgeOf(leg: Int): String? = if (leg >= 2) "并行路 $leg" else null
fun legBadgeOf(leg: Int, parallel: Boolean): String? =
if (parallel && leg >= 2) "并行路 $leg" else null
/** 空白串等同于缺席(服务端是「整键缺席」,但真给空串时按同一件事处理,与既有各纯函数一致)。 */
private fun String?.orNullIfBlank(): String? = this?.takeIf { it.isNotBlank() }
@@ -2048,11 +2048,14 @@ class ChatViewModel(
/**
* 新帧 `turn_begin` 的处置(忙时并行规范 §2.4):把这一路与它归属的用户消息钉死。
*
* 两件事,各自独立降级:
* 三件事,各自独立降级:
* 1. **`activeTurns`** 只要给了就消费(忙徽标上的「并行 N 路」靠它);不给就不动。
* 2. **消息 ↔ 回合绑定**只在帧壳 `turnId` 非空时建立——`turnId` 是收口的唯一凭据,
* 没有它建出来的绑定**永远收不了口**(灰着不动的气泡比不灰更糟)。帧壳缺席时本端
* 只当这一帧没来过(= 今天的样子)。
* 3. **`kind`** 原样带进 [withTurnBegin],由它落成绑定上的 `parallel`——**这是「并行路 N」
* 角标的唯一判据**。本端不在这里解释它(认不认识由 `isParallelLegKind` 一个人说了算),
* 也不拿 `leg` 去顶替:`leg` 是会话内第几条路,普通串行回合照样占号。
*
* 顺带把被这一刻绑上的消息从 [ChatUiState.queuedKeys] 里摘掉:它们已经从「排队中」进了
* 「回答中」,留着只会让 ③ 的判据在未来某次重放里被重新读到(六态判定里绑定优先,所以这不是
@@ -2064,7 +2067,7 @@ class ChatViewModel(
val turns = if (turnId == null) {
state.turns
} else {
state.turns.withTurnBegin(turnId, event.leg, event.messageIds, event.activeTurns)
state.turns.withTurnBegin(turnId, event.leg, event.messageIds, event.activeTurns, event.kind)
}
state.copy(
turns = turns,
@@ -130,7 +130,7 @@ fun isContentlessUserMessage(message: HistoryMessageDto): Boolean =
* `r.Host` 拼、且原样落库 ⇒ 换地址后旧图取不回)。空串 = 不替换([ImageHostRebase] 会原样返回)。
* @param phase 这条消息在**太忙时并行六态**里的哪一态(见 [ChatUiState.phaseOf] / 规范 §1.1)。
* 只有 ③ 排队中与 ④ 回答中改变外观,①②⑤ 一律按今天的样子画(所以老服务端上逐字不变)。
* @param legBadge 「并行 N 路」小标(`leg >= 2` 才有,见 [legBadgeOf])。挂在来源角标那一行里:
* @param legBadge 「并行路 N」小标(**真的是并行路**且 `leg >= 2` 才有,见 [legBadgeOf])。挂在来源角标那一行里:
* 规范 §1.4 A 要的就是「同一路的两条消息在界面上看得出来是一路」——提问侧挂同一个标,
* 回复侧在 [StreamingBubble] 上挂一次,用户就能把它们配起来。
* @param onEdit 用户选中「编辑」:交给上层打开编辑弹层(本组件不自己发请求)。
@@ -416,8 +416,9 @@ private fun DocumentChip(model: DocumentChipModel, onRemove: (() -> Unit)?, onCl
* ## 并行路小标(规范 §1.4 A / 主人裁决 ①)
* [legBadge] 非空时在最上方挂一枚小标(「并行路 2」),点它跳到**这一路归属的那条提问**
* ([onJumpToQuestion])。这就是规范给的单列形态:回复仍按历史顺序先后成条,靠小标而不是靠分栏
* 表达「这是另起的一路」。单路(`leg < 2`)时 [legBadge] 为 `null`,**一个像素都不多画**——
* 挂标这件事本身就是「这里有真正的多路」的信号([legBadgeOf] 的 KDoc)。
* 表达「这是另起的一路」。**不是并行路**(`turn_begin.kind != "parallel"`)或单路(`leg < 2`)时
* [legBadge] 为 `null`,**一个像素都不多画**——挂标这件事本身就是「这里有真正的多路」的信号
* ([legBadgeOf] 的 KDoc)。
*
* 跳不到(那条提问不在已加载的窗口里、或这一路没绑消息)时**不显示「跳到提问」**:一个按下去
* 什么都不发生的入口比没有入口更糟。[onJumpToQuestion] 传 `null` 即这一档。
@@ -142,24 +142,27 @@ fun ChatUiState.phaseOf(item: PendingMessage): TurnPhase = turnPhaseOf(
)
/**
* 这条消息在界面上该挂什么小标(规范 §1.4 A:`leg >= 2` 才挂「并行 N 路」)。
* 这条消息在界面上该挂什么小标(规范 §1.4 A:**真的是并行路**且 `leg >= 2` 才挂「并行 N 路」)。
*
* 两个方向都要认,因为「同一路的两条消息」有两头:
* - **提问侧**(user 行):靠 `turn_begin.messageIds` / `steer_ack` 建立的**绑定**;
* - **回复侧**(assistant 行):靠历史行上那个**可选** `turnId`(规范 §2.7,v0.65 起才有)。
*
* 判据交给 [legBadgeOf]:它同时看这一路的 `parallel`(只看 `turn_begin.kind`)与 `leg`,
* 所以普通串行回合的 `leg = 2 / 3 / …` **挂不出标**——那是会话内第几条路,不是「并行路」。
*
* 老服务端上两条都查不到(没有 `turn_begin`,历史行的 `turnId` 整键省略)⇒ 恒 `null` ⇒
* 一个标都不挂,与今天逐字相同。
*/
fun ChatUiState.legBadgeForMessage(message: HistoryMessageDto): String? {
turns.bindingOfMessage(message.id)?.let { return legBadgeOf(it.leg) }
turns.bindingOfMessage(message.id)?.let { return legBadgeOf(it.leg, it.parallel) }
val producedBy = message.turnId?.takeIf { it.isNotBlank() } ?: return null
return turns.firstOrNull { it.turnId == producedBy }?.let { legBadgeOf(it.leg) }
return turns.firstOrNull { it.turnId == producedBy }?.let { legBadgeOf(it.leg, it.parallel) }
}
/** 这一路的小标(按 `turnId` 查;`null` = 一路 / 序号未知,两种都不挂标)。 */
/** 这一路的小标(按 `turnId` 查;`null` = 不是并行路 / 一路 / 序号未知,几种都不挂标)。 */
fun ChatUiState.legBadgeForTurn(turnId: String): String? =
turns.firstOrNull { it.turnId == turnId }?.let { legBadgeOf(it.leg) }
turns.firstOrNull { it.turnId == turnId }?.let { legBadgeOf(it.leg, it.parallel) }
/**
* 从某一路跳到**它归属的那条提问**:返回聊天列表(`reverseLayout`)里的项下标;
@@ -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)