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