diff --git a/app/src/main/kotlin/top/yeij/cyrene/data/ws/WsEvent.kt b/app/src/main/kotlin/top/yeij/cyrene/data/ws/WsEvent.kt index dc6bb22..544412a 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/data/ws/WsEvent.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/data/ws/WsEvent.kt @@ -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);本端仍按既有口径 diff --git a/app/src/main/kotlin/top/yeij/cyrene/domain/chat/ParallelTurns.kt b/app/src/main/kotlin/top/yeij/cyrene/domain/chat/ParallelTurns.kt index 22bd8ed..68b40fe 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/domain/chat/ParallelTurns.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/domain/chat/ParallelTurns.kt @@ -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 = 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.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.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.withTurnBegin( turnId: String, leg: Int, messageIds: List, activeTurns: Int, + kind: String = "", ): List { 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.allClosed(): List { } /** - * 路标文案(规范 §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() } diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt index 994215b..79b9300 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ChatViewModel.kt @@ -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, diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/MessageBubble.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/MessageBubble.kt index 32a9afa..73e3131 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/MessageBubble.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/MessageBubble.kt @@ -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` 即这一档。 diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/TurnStreams.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/TurnStreams.kt index 5212a1c..c53c62e 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/TurnStreams.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/TurnStreams.kt @@ -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`)里的项下标; diff --git a/app/src/test/kotlin/top/yeij/cyrene/domain/chat/ParallelTurnsTest.kt b/app/src/test/kotlin/top/yeij/cyrene/domain/chat/ParallelTurnsTest.kt index 664ddd1..975f71d 100644 --- a/app/src/test/kotlin/top/yeij/cyrene/domain/chat/ParallelTurnsTest.kt +++ b/app/src/test/kotlin/top/yeij/cyrene/domain/chat/ParallelTurnsTest.kt @@ -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().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().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().withTurnBegin("turn-2", 2, listOf("m-b"), 2) + val blank = emptyList().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() + .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() + .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() + .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, messageId: String): TurnPhase = diff --git a/app/src/test/kotlin/top/yeij/cyrene/ui/chat/TurnStreamsTest.kt b/app/src/test/kotlin/top/yeij/cyrene/ui/chat/TurnStreamsTest.kt index 281cbf1..5627c64 100644 --- a/app/src/test/kotlin/top/yeij/cyrene/ui/chat/TurnStreamsTest.kt +++ b/app/src/test/kotlin/top/yeij/cyrene/ui/chat/TurnStreamsTest.kt @@ -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().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().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().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().withTurnBegin("t2", 2, listOf("m-1"), 2) + val turns = emptyList().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().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().withTurnBegin("t2", 2, listOf("m-1"), 2) + val turns = emptyList().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)