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