feat(chat): 忙时并行回合的客户端半边 —— 六态 + 灰态 + (sessionId, turnId) 两级分桶 + 新帧消费(全部可降级)

依据:Cyrene_v2/DevDocs/_specs/忙时并行-规范草案.md(§1 行为 / §2 接口 / §4 主人裁决)。
后端 v0.65 尚未落地这些帧,所以本次的新键与新帧全部按可降级实现:帧不来、字段缺席时,
App 在今天的真后端上行为与改前逐字相同(下面的「降级保证」逐条给了证据)。

一、改了哪些文件

新增(纯逻辑,可单测)
- app/src/main/kotlin/top/yeij/cyrene/domain/chat/ParallelTurns.kt
  两级分桶键 TurnBucket(sessionId, turnId) 与 turnBucketOf / closingBucketOf;
  六态枚举 TurnPhase 与 turnPhaseOf;灰态收口判据 closesAnswering;
  消息↔回合绑定 TurnBinding 及其纯函数(withTurnBegin / withSteeredMessage / closingTurn /
  allClosed / bindingOfMessage);路小标文案 legBadgeOf。
- app/src/main/kotlin/top/yeij/cyrene/ui/chat/TurnStreams.kt
  StreamingLeg(bucket, StreamingState) 与 withLeg / beginLeg / finishingLeg / withoutLeg;
  六态在状态上的推导 ChatUiState.phaseOf(消息 / 乐观气泡);
  路小标与「跳到提问」的查表 legBadgeForMessage / legBadgeForTurn / jumpIndexForTurn。

修改
- data/api/dto/MessageDto.kt:HistoryMessageDto 增可选 turnId(历史行 / 落库行都用它)。
- data/api/dto/ChatDto.kt:ChatRequest 增可选 parallel(REST 与 WS chat 同形)。
- data/ws/WsOutbound.kt:chat 增可选 parallel、interrupt 增可选 turnId,省略即整键不进线上。
- data/ws/WsEvent.kt:新增 turn_begin / turn.end / steer_ack 三个事件与其载荷;
  session_busy / session_idle / pending_drain 增可选 activeTurns,session_interrupted 增
  activeTurns + cancelledTurns,pending_ack 增可选 reason。
- ui/chat/ChatViewModel.kt:流式解析器与「本回合见过结构化正文帧」由单值升为按桶的 map/set,
  新增「本会话最近见过的 turnId」锚点;消费 turn_begin / steer_ack / turn.end;
  助手落库行按其 turnId 收口灰态并只摘掉它自己那一路的流式气泡;排队回执记进 queuedKeys。
- ui/chat/MessageBubble.kt:MessageBubble / PendingBubble 增 phase 与 legBadge,
  正文气泡增灰态配色,新增并行路小标(可点即跳);角标文案抽成纯函数 bubbleFooter。
- ui/chat/ChatScreen.kt:按桶取每一路的流式状态并渲染;传入六态与小标;忙碌行加「并行 N 路」。
- 新增 4 个测试文件(共 82 条):ParallelTurnsTest、TurnStreamsTest、
  WsEventTurnFramesTest、WsOutboundParallelTest。

二、为什么这么做

- 界面必须按 (sessionId, turnId) 两级分桶(规范 §1.7 第 1 件硬要求):两路并行时两个不同
  turnId 的 message.delta / thinking.delta / tool_call 会交错到达,照老代码「一律累加到当前那
  一个流式气泡」会把两条回复糊成一条(规范 §1.4 点名的那件事)。现在每一路各有一份解析器、
  一份思维链、一份工具卡表、一个流式气泡。
- 灰态(④ 已读、未答)的唯一权威收口事件是 turn.end(规范 §1.2)。本端另外在 stream_end
  (既有的「整轮结束」帧,v0.36 起带 turnId)与「同 turnId 的助手落库行」上也收口:
  前者在 turn.end 到位之前是唯一能收口的帧,后者是规范给的兜底。三者顺序不定、重复到达都无害
  (纯函数幂等)。
- 六态判定与收口判据的不变量都抽成纯函数,可直接单测;判定顺序即证据强度:
  被拒 > 已绑定未收口 > 已绑定已收口 > 本地乐观气泡 > 排队回执。

三、字段缺失时的降级保证(本次最要紧的一条)

- 全链路没有一处「turnId 为空就不渲染」。turnBucketOf 在帧壳没给 turnId 时先回落本会话最近
  见过的 turnId、再回落匿名桶(turnId 为空串)⇒ 所有无 turnId 的帧落在同一个桶里,
  那就是老代码那唯一一个流式气泡。
- turn_begin / steer_ack.turnId / turn.end 不来 ⇒ 一条绑定都建不起来 ⇒ 六态里只可能出现
  ①②③⑤,灰态根本不会进入;界面与改前逐字相同。
- activeTurns 缺席时不动这个计数(null 是「不知道」,0 是「确实空闲」,两件事不许互相顶替);
  pending_ack.reason 缺席就是 null,绝不伪造。
- 上行三个新字段(chat.parallel、interrupt.turnId、ChatRequest.parallel)本端今天一个字节都
  不发:真并行要用户显式声明,而声明入口不在本次范围内。服务端照旧走排队 + 回合内 steer。
- 已被单测钉住的证据:
  ParallelTurnsTest「所有无 turnId 的帧落进同一个桶」「两个空白 turnId 不算匹配,绝不收口」;
  TurnStreamsTest「单路时长度恒为 1」「没有任何 turn 帧时已落库=②、本地气泡=①」;
  WsEventTurnFramesTest「老形状的帧解出来新键一律为 null(不是 0、不是空串)」;
  WsOutboundParallelTest「省略时那条帧逐字节相同,且不带该键」。

四、等 v0.65 的部分

- turn_begin / steer_ack.turnId / turn.end 的真实到达,历史行的可选 turnId,activeTurns /
  pending_ack.reason / cancelledTurns 的真实取值。
- 真并行的触发入口(谁在什么时候把 chat.parallel 置 true)——本次只备好线上形状,不接 UI。
- 仍未决且影响客户端的两项:graph_update 两路互相顶掉(规范未决 10,等 design/19 裁决);
  按 turnId 单独核算用量(规范未决 14,可选)。

五、规范里与本端既有定案冲突的一处(如实登记)

规范 §1.1 ⑥「被拒」要求气泡带红色「未送达 · 会话正忙」并可重试;本端被拒(pending_full /
定向 chat_busy)的既有定案是摘掉乐观气泡 + 横幅如实告知 + 正文还给输入栏(可再按一次发送,
幂等键保证不会落成第二条消息)。既有行为由现有测试钉住,本次不改;六态判定里保留了 Rejected
档并由单测直接覆盖,只是本端的现实路径走的是回滚那一支。

验证:assembleDebug 绿 + testDebugUnitTest 绿(0 失败 0 错误 0 跳过)。
This commit is contained in:
AskaEth committed 2026-10-05 09:10:45 +08:00
1 parent 5e6dc76ae8
commit 29299b190e
13 files changed
+2124 -116

No files matched your search

@@ -0,0 +1,355 @@
package top.yeij.cyrene.domain.chat
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* 忙时并行的纯逻辑:**两级分桶 / 六态判定 / 灰态收口 / 消息↔回合绑定**。
*
* 这一层不碰协程也不碰 Android,所以「规范怎么说」与「代码怎么做」可以逐条对着钉死。
* 三组测试的界线也是规范 §1.7 那三件事:分桶、绑定与收口、六态。
*
* 另外有一组**降级**测试(`字段缺失`):服务的今天没有 `turn_begin` / `steer_ack.turnId`,
* 也没有 `parallel`,本模块必须做到「只按今天收到的帧走,行为与今天逐字相同」。
*/
class ParallelTurnsTest {
// —— 分桶(规范 §1.4 / §1.7 第 1 条)——
@Test
fun `a frame without a turnId lands in the anonymous bucket`() {
val bucket = turnBucketOf("web", null, null)
assertEquals(TurnBucket("web", ""), bucket)
assertTrue(bucket.isAnonymous)
}
@Test
fun `every frame without a turnId lands in the same bucket`() {
// 这一条就是「字段缺失 ⇒ 行为与今天逐字相同」的判据:老代码只有一个流式气泡,
// 分桶之后这些帧必须全部落在**同一个**桶里,否则一条回复会被劈成两半。
val a = turnBucketOf("web", null, null)
val b = turnBucketOf("web", " ", null)
val c = turnBucketOf("web", "", "")
assertEquals(a, b)
assertEquals(a, c)
}
@Test
fun `the frame turnId wins over the remembered one`() {
assertEquals(
TurnBucket("web", "turn-2"),
turnBucketOf("web", "turn-2", "turn-1"),
)
}
@Test
fun `a missing turnId falls back to the remembered one`() {
// 本会话正在跑 turn-1,某一帧没带 turnId ⇒ 它属于 turn-1(老代码里就是那唯一一个气泡)。
assertEquals(
TurnBucket("web", "turn-1"),
turnBucketOf("web", null, "turn-1"),
)
}
@Test
fun `a blank frame turnId counts as missing`() {
assertEquals(
TurnBucket("web", "turn-1"),
turnBucketOf("web", " ", "turn-1"),
)
}
@Test
fun `two turns in one session never share a bucket`() {
val leg1 = turnBucketOf("web", "turn-1", null)
val leg2 = turnBucketOf("web", "turn-2", "turn-1")
assertFalse(leg1 == leg2)
}
@Test
fun `the same turnId in another session is another bucket`() {
// 两级分桶的第二级:`turnId` 只在会话内唯一,跨会话不能合并。
assertFalse(turnBucketOf("web", "turn-1", null) == turnBucketOf("web:abc", "turn-1", null))
}
// —— 收口帧的落桶(不许新开桶)——
@Test
fun `a closing frame lands on its own bucket when it is known`() {
assertEquals(
TurnBucket("web", "turn-1"),
closingBucketOf("web", "turn-1", "turn-1", listOf("turn-1", "turn-2")),
)
}
@Test
fun `a closing frame with an unknown turnId falls back to the remembered bucket`() {
assertEquals(
TurnBucket("web", "turn-1"),
closingBucketOf("web", "turn-9", "turn-1", listOf("turn-1")),
)
}
@Test
fun `a closing frame with no recognisable id lands on the most recent bucket`() {
// 为什么这条不能省:老代码里 `stream_end` 无条件收口那唯一一个气泡。若给它新开一个桶,
// 真正在跑的那个桶就永远收不了口(光标一直转)。
assertEquals(
TurnBucket("web", "turn-2"),
closingBucketOf("web", null, null, listOf("turn-1", "turn-2")),
)
}
@Test
fun `a closing frame never invents a bucket out of nothing`() {
assertEquals(TurnBucket("web", ""), closingBucketOf("web", "turn-1", null, emptyList()))
}
// —— 六态判定(规范 §1.1)——
@Test
fun `a local bubble is sending until it is bound or queued`() {
assertEquals(
TurnPhase.Sending,
turnPhaseOf(local = true, binding = null, queued = false, rejected = false),
)
}
@Test
fun `a queued local bubble is queued not sending`() {
assertEquals(
TurnPhase.Queued,
turnPhaseOf(local = true, binding = null, queued = true, rejected = false),
)
}
@Test
fun `a persisted bubble with nothing else is sent`() {
assertEquals(
TurnPhase.Sent,
turnPhaseOf(local = false, binding = null, queued = false, rejected = false),
)
}
@Test
fun `a queued persisted bubble is queued and not grey`() {
// 规范 §1.3:`queued` **不是**灰态。排队与「已读、未答」是两件事,不许合并。
assertEquals(
TurnPhase.Queued,
turnPhaseOf(local = false, binding = null, queued = true, rejected = false),
)
}
@Test
fun `a bound message that is not closed is answering`() {
assertEquals(
TurnPhase.Answering,
turnPhaseOf(local = false, binding = binding("turn-1"), queued = false, rejected = false),
)
}
@Test
fun `a bound message that is closed is answered`() {
assertEquals(
TurnPhase.Answered,
turnPhaseOf(local = false, binding = binding("turn-1").copy(closed = true), queued = false, rejected = false),
)
}
@Test
fun `a rejection outranks every other signal`() {
// ⑥ 是最重的事实:这条根本没进去。再拿「有没有被接手」去描述它就是错的。
assertEquals(
TurnPhase.Rejected,
turnPhaseOf(local = false, binding = binding("turn-1"), queued = true, rejected = true),
)
assertEquals(
TurnPhase.Rejected,
turnPhaseOf(local = true, binding = null, queued = false, rejected = true),
)
}
@Test
fun `an unanswered binding beats the queued mark`() {
// ③ → ④ 的换态:被接手之后,「排队中」这个角标必须让位给灰态。
assertEquals(
TurnPhase.Answering,
turnPhaseOf(local = false, binding = binding("turn-1"), queued = true, rejected = false),
)
}
// —— 灰态收口(规范 §1.2)——
@Test
fun `the matching turn end closes the grey state`() {
assertTrue(closesAnswering("turn-1", "turn-1"))
}
@Test
fun `another turn never closes this one`() {
assertFalse(closesAnswering("turn-1", "turn-2"))
}
@Test
fun `two blanks are not a match`() {
// 「都为空所以相等」会让老客户端 / 老服务端上的消息被随便一帧误收口——这条是刻意的。
assertFalse(closesAnswering(null, null))
assertFalse(closesAnswering("", " "))
assertFalse(closesAnswering("turn-1", null))
assertFalse(closesAnswering(null, "turn-1"))
}
// —— 绑定表 ——
private fun binding(turnId: String, vararg messageIds: String) =
TurnBinding(turnId = turnId, messageIds = messageIds.toList())
@Test
fun `turn begin binds the messages it names`() {
val turns = emptyList<TurnBinding>().withTurnBegin("turn-1", 1, listOf("m-a"), 1)
assertEquals(1, turns.size)
assertEquals(TurnBinding("turn-1", 1, listOf("m-a"), 1), turns.single())
assertEquals("turn-1", turns.bindingOfMessage("m-a")?.turnId)
assertNull(turns.bindingOfMessage("m-b"))
}
@Test
fun `two legs keep their own messages`() {
val turns = emptyList<TurnBinding>()
.withTurnBegin("turn-1", 1, listOf("m-a"), 1)
.withTurnBegin("turn-2", 2, listOf("m-b"), 2)
assertEquals(listOf("turn-1", "turn-2"), turns.map { it.turnId })
assertEquals("turn-1", turns.bindingOfMessage("m-a")?.turnId)
assertEquals("turn-2", turns.bindingOfMessage("m-b")?.turnId)
}
@Test
fun `a repeated turn begin merges instead of replacing`() {
val turns = emptyList<TurnBinding>()
.withTurnBegin("turn-1", 1, listOf("m-a"), 1)
.withTurnBegin("turn-1", 1, listOf("m-b"), 1)
assertEquals(listOf("m-a", "m-b"), turns.single().messageIds)
}
@Test
fun `a repeated turn begin never reopens a closed leg`() {
// 重开会让气泡回到灰态,而第二次收口永远不会再来(那一帧已经发过了)。
val closed = emptyList<TurnBinding>()
.withTurnBegin("turn-1", 1, listOf("m-a"), 1)
.closingTurn("turn-1")
val again = closed.withTurnBegin("turn-1", 1, listOf("m-a"), 1)
assertTrue(again.single().closed)
}
@Test
fun `a blank turnId in turn begin changes nothing`() {
val turns = emptyList<TurnBinding>()
assertSame(turns, turns.withTurnBegin("", 1, listOf("m-a"), 1))
assertSame(turns, turns.withTurnBegin(" ", 1, listOf("m-a"), 1))
}
@Test
fun `steer ack binds a message to an existing leg`() {
val turns = emptyList<TurnBinding>()
.withTurnBegin("turn-1", 1, listOf("m-a"), 1)
.withSteeredMessage("turn-1", "m-b")
assertEquals(listOf("m-a", "m-b"), turns.single().messageIds)
assertEquals("turn-1", turns.bindingOfMessage("m-b")?.turnId)
}
@Test
fun `steer ack creates a leg when the turn is unknown`() {
// 服务端可能先发 steer_ack 再补 turn_begin;先建一条只有这一条消息的表,之后合并即可。
val turns = emptyList<TurnBinding>().withSteeredMessage("turn-1", "m-b")
assertEquals(TurnBinding("turn-1", UNKNOWN_LEG, listOf("m-b")), turns.single())
}
@Test
fun `steer ack with a blank turnId binds nothing`() {
val turns = listOf(binding("turn-1", "m-a"))
assertSame(turns, turns.withSteeredMessage("", "m-b"))
assertSame(turns, turns.withSteeredMessage("turn-1", " "))
}
@Test
fun `closing one turn leaves the other leg open`() {
val turns = emptyList<TurnBinding>()
.withTurnBegin("turn-1", 1, listOf("m-a"), 1)
.withTurnBegin("turn-2", 2, listOf("m-b"), 2)
.closingTurn("turn-2")
assertEquals(TurnPhase.Answering, phaseOfMessage(turns, "m-a"))
assertEquals(TurnPhase.Answered, phaseOfMessage(turns, "m-b"))
}
@Test
fun `closing with an unknown id closes nothing and returns the same instance`() {
val turns = listOf(binding("turn-1", "m-a"))
assertSame(turns, turns.closingTurn(null))
assertSame(turns, turns.closingTurn(""))
assertSame(turns, turns.closingTurn("turn-9"))
}
@Test
fun `closing twice is the same as closing once`() {
val once = listOf(binding("turn-1", "m-a")).closingTurn("turn-1")
assertSame(once, once.closingTurn("turn-1"))
}
@Test
fun `all closed closes every open leg and is idempotent`() {
val turns = emptyList<TurnBinding>()
.withTurnBegin("turn-1", 1, listOf("m-a"), 1)
.withTurnBegin("turn-2", 2, listOf("m-b"), 2)
val closed = turns.allClosed()
assertTrue(closed.all { it.closed })
assertSame(closed, closed.allClosed())
}
@Test
fun `a blank message id never matches a binding`() {
val turns = listOf(binding("turn-1", ""))
assertNull(turns.bindingOfMessage(""))
assertNull(turns.bindingOfMessage(" "))
}
// —— 路小标 ——
@Test
fun `only the second leg and later get a badge`() {
// 单路是常态:给唯一一路挂「并行路 1」只是给每条回复加一个新的视觉噪音。
assertNull(legBadgeOf(UNKNOWN_LEG))
assertNull(legBadgeOf(1))
assertEquals("并行路 2", legBadgeOf(2))
assertEquals("并行路 3", legBadgeOf(3))
}
private fun phaseOfMessage(turns: List<TurnBinding>, messageId: String): TurnPhase =
turnPhaseOf(
local = false,
binding = turns.bindingOfMessage(messageId),
queued = false,
rejected = false,
)
}