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,281 @@
|
||||
package top.yeij.cyrene.ui.chat
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
|
||||
import top.yeij.cyrene.domain.chat.TurnBinding
|
||||
import top.yeij.cyrene.domain.chat.TurnBucket
|
||||
import top.yeij.cyrene.domain.chat.TurnPhase
|
||||
import top.yeij.cyrene.domain.chat.withTurnBegin
|
||||
import top.yeij.cyrene.domain.parse.ParsedBubble
|
||||
|
||||
/**
|
||||
* 流式气泡的**两级分桶**(规范 §1.4 / §1.7 第 1 条)在 UI 层的那一半。
|
||||
*
|
||||
* 这里的头号回归是「两路交错的 delta 不许糊成一条」——老代码只有一个 `StreamingState`,
|
||||
* 所有 `message.delta` 都往它上面堆;分桶之后每一路各有一份。第二件事是**单路逐字不变**:
|
||||
* 帧壳不带 `turnId` 时全部帧落匿名桶,`streams` 的长度恒为 1。
|
||||
*/
|
||||
class TurnStreamsTest {
|
||||
|
||||
private fun bucket(turnId: String) = TurnBucket("web", turnId)
|
||||
|
||||
private fun bubble(text: String) =
|
||||
ParsedBubble(body = text, parts = emptyList(), head = emptyList(), tail = emptyList())
|
||||
|
||||
private fun streaming(vararg texts: String) = StreamingState(bubbles = texts.map(::bubble))
|
||||
|
||||
private fun leg(turnId: String, vararg texts: String) = StreamingLeg(bucket(turnId), streaming(*texts))
|
||||
|
||||
// —— 两路各长各的 ——
|
||||
|
||||
@Test
|
||||
fun `two turns accumulate in their own legs`() {
|
||||
val legs = emptyList<StreamingLeg>()
|
||||
.withLeg(bucket("t1")) { it.copy(bubbles = listOf(bubble("第一路"))) }
|
||||
.withLeg(bucket("t2")) { it.copy(bubbles = listOf(bubble("第二路"))) }
|
||||
|
||||
assertEquals(2, legs.size)
|
||||
assertEquals(listOf("第一路"), bodies(legs, "t1"))
|
||||
assertEquals(listOf("第二路"), bodies(legs, "t2"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `interleaved deltas never merge into one leg`() {
|
||||
// 规范 §1.4 点名的那个错:按「当前气泡」累加,两条回复会被糊成一条。
|
||||
val legs = emptyList<StreamingLeg>()
|
||||
.withLeg(bucket("t1")) { it.copy(bubbles = it.bubbles + bubble("A1")) }
|
||||
.withLeg(bucket("t2")) { it.copy(bubbles = it.bubbles + bubble("B1")) }
|
||||
.withLeg(bucket("t1")) { it.copy(bubbles = it.bubbles + bubble("A2")) }
|
||||
.withLeg(bucket("t2")) { it.copy(bubbles = it.bubbles + bubble("B2")) }
|
||||
|
||||
assertEquals(listOf("A1", "A2"), bodies(legs, "t1"))
|
||||
assertEquals(listOf("B1", "B2"), bodies(legs, "t2"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `one turn is exactly one leg`() {
|
||||
// 「字段缺失 ⇒ 行为与今天逐字相同」在 UI 层的判据:单路时长度恒为 1。
|
||||
val legs = emptyList<StreamingLeg>()
|
||||
.withLeg(bucket("")) { it.copy(bubbles = listOf(bubble("A"))) }
|
||||
.withLeg(bucket("")) { it.copy(bubbles = it.bubbles + bubble("B")) }
|
||||
|
||||
assertEquals(1, legs.size)
|
||||
assertEquals(listOf("A", "B"), bodies(legs, ""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `legs keep their creation order`() {
|
||||
val legs = emptyList<StreamingLeg>()
|
||||
.withLeg(bucket("t1")) { it }
|
||||
.withLeg(bucket("t2")) { it }
|
||||
.withLeg(bucket("t1")) { it }
|
||||
|
||||
assertEquals(listOf("t1", "t2"), legs.map { it.bucket.turnId })
|
||||
}
|
||||
|
||||
// —— 新一轮开始时的收口 ——
|
||||
|
||||
@Test
|
||||
fun `beginning a turn drops the other finished legs`() {
|
||||
// 老代码的 freshTurnIfFinished:上一轮已结束就把那一个气泡整个清掉(内容已被落库行取代)。
|
||||
val legs = listOf(
|
||||
StreamingLeg(bucket("t1"), streaming("旧内容").copy(active = false)),
|
||||
StreamingLeg(bucket("t2"), streaming("还在跑")),
|
||||
)
|
||||
|
||||
val after = legs.beginLeg(bucket("t2"))
|
||||
|
||||
assertEquals(listOf("t2"), after.map { it.bucket.turnId })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `beginning a turn never touches a leg that is still running`() {
|
||||
val legs = listOf(
|
||||
StreamingLeg(bucket("t1"), streaming("另一路")),
|
||||
StreamingLeg(bucket("t2"), streaming("旧").copy(active = false)),
|
||||
)
|
||||
|
||||
val after = legs.beginLeg(bucket("t2"))
|
||||
|
||||
assertEquals(listOf("t1", "t2"), after.map { it.bucket.turnId })
|
||||
assertEquals(listOf("另一路"), bodies(after, "t1"))
|
||||
assertEquals(emptyList<String>(), bodies(after, "t2"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `beginning a turn on an unknown bucket adds nothing`() {
|
||||
// 新的桶由紧接着的 withLeg 建;这里凭空塞一个空态只会在坏时序下多出一个看不见的空气泡。
|
||||
val legs = listOf(leg("t1", "内容"))
|
||||
|
||||
assertEquals(legs, legs.beginLeg(bucket("t9")))
|
||||
}
|
||||
|
||||
// —— 收口 ——
|
||||
|
||||
@Test
|
||||
fun `finishing a leg keeps its text and stops the cursor`() {
|
||||
val legs = listOf(StreamingLeg(bucket("t1"), streaming("半句")))
|
||||
|
||||
val after = legs.finishingLeg(bucket("t1"), listOf(bubble("整句")))
|
||||
|
||||
assertEquals(listOf("整句"), bodies(after, "t1"))
|
||||
assertTrue(!after.single().state.active)
|
||||
assertTrue(!after.single().state.thinkingActive)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `finishing without a parser keeps the text that is already on screen`() {
|
||||
// 「没有新正文」不该被写成「正文是空的」——那会把已经显示出来的字擦掉。
|
||||
val legs = listOf(StreamingLeg(bucket("t1"), streaming("已经显示的")))
|
||||
|
||||
val after = legs.finishingLeg(bucket("t1"), null)
|
||||
|
||||
assertEquals(listOf("已经显示的"), bodies(after, "t1"))
|
||||
assertTrue(!after.single().state.active)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `finishing an unknown leg creates nothing`() {
|
||||
val legs = listOf(leg("t1", "内容"))
|
||||
|
||||
assertSame(legs, legs.finishingLeg(bucket("t9"), emptyList()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dropping the persisted leg keeps the other one`() {
|
||||
val legs = listOf(leg("t1", "A"), leg("t2", "B"))
|
||||
|
||||
val after = legs.withoutLeg(bucket("t1"))
|
||||
|
||||
assertEquals(listOf("t2"), after.map { it.bucket.turnId })
|
||||
assertSame(after, after.withoutLeg(bucket("t1")))
|
||||
}
|
||||
|
||||
// —— 六态在状态上的推导 ——
|
||||
|
||||
@Test
|
||||
fun `without any turn frames every message is where it is today`() {
|
||||
// 老服务端(没有 turn_begin / steer_ack.turnId):已落库 = ②,本地气泡 = ①。
|
||||
val state = ChatUiState(messages = listOf(message("m-1")))
|
||||
|
||||
assertEquals(TurnPhase.Sent, state.phaseOf(state.messages.single()))
|
||||
assertEquals(TurnPhase.Sending, state.phaseOf(PendingMessage("local-1", "你好")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a bound message is grey until its turn closes`() {
|
||||
val turns = emptyList<TurnBinding>().withTurnBegin("t1", 2, listOf("m-1"), 2)
|
||||
val bound = ChatUiState(messages = listOf(message("m-1")), turns = turns)
|
||||
|
||||
assertEquals(TurnPhase.Answering, bound.phaseOf(bound.messages.single()))
|
||||
assertTrue(bound.phaseOf(bound.messages.single()) != TurnPhase.Queued)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the queued mark is found through the client message id too`() {
|
||||
// 队列回执先到、落库后到:那时本地认识的键是 `localId`,而消息上的键是 `clientMsgId`。
|
||||
val state = ChatUiState(
|
||||
messages = listOf(message("m-1", clientMsgId = "local-1")),
|
||||
queuedKeys = setOf("local-1"),
|
||||
)
|
||||
|
||||
assertEquals(TurnPhase.Queued, state.phaseOf(state.messages.single()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the queued mark on a local bubble survives into the composer view`() {
|
||||
val state = ChatUiState(pending = listOf(PendingMessage("local-1", "你好")), queuedKeys = setOf("local-1"))
|
||||
|
||||
assertEquals(TurnPhase.Queued, state.phaseOf(state.pending.single()))
|
||||
}
|
||||
|
||||
// —— 路小标与跳转 ——
|
||||
|
||||
@Test
|
||||
fun `a single leg carries no badge`() {
|
||||
val turns = emptyList<TurnBinding>().withTurnBegin("t1", 1, listOf("m-1"), 1)
|
||||
val state = ChatUiState(messages = listOf(message("m-1")), turns = turns)
|
||||
|
||||
assertNull(state.legBadgeForMessage(state.messages.single()))
|
||||
assertNull(state.legBadgeForTurn("t1"))
|
||||
}
|
||||
|
||||
@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)
|
||||
val state = ChatUiState(messages = listOf(message("m-1")), turns = turns)
|
||||
|
||||
assertEquals("并行路 2", state.legBadgeForMessage(state.messages.single()))
|
||||
assertEquals("并行路 2", state.legBadgeForTurn("t2"))
|
||||
}
|
||||
|
||||
@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 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 `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 answer = message("m-9", role = "assistant")
|
||||
val state = ChatUiState(messages = listOf(message("m-1"), answer), turns = turns)
|
||||
|
||||
assertNull(state.legBadgeForMessage(answer))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `jumping to a question maps to the reverse layout index`() {
|
||||
// items = [已落库 m-1, m-2, m-3](升序);reverseLayout 下懒下标 = size - 1 - 升序下标。
|
||||
val turns = emptyList<TurnBinding>().withTurnBegin("t1", 1, listOf("m-1"), 1)
|
||||
val state = ChatUiState(
|
||||
messages = listOf(message("m-1"), message("m-2"), message("m-3")),
|
||||
turns = turns,
|
||||
)
|
||||
|
||||
assertEquals(2, state.jumpIndexForTurn("t1"))
|
||||
assertNull(state.jumpIndexForTurn("t9"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a turn whose question is not loaded has no jump target`() {
|
||||
val turns = emptyList<TurnBinding>().withTurnBegin("t1", 1, listOf("m-unknown"), 1)
|
||||
val state = ChatUiState(messages = listOf(message("m-1")), turns = turns)
|
||||
|
||||
assertNull(state.jumpIndexForTurn("t1"))
|
||||
}
|
||||
|
||||
// —— 气泡角标文案 ——
|
||||
|
||||
@Test
|
||||
fun `the footer only mentions queued and answering`() {
|
||||
assertEquals("来自 安卓", bubbleFooter("来自 安卓", TurnPhase.Sent, null))
|
||||
assertEquals("排队中 · 来自 安卓", bubbleFooter("来自 安卓", TurnPhase.Queued, null))
|
||||
assertEquals("已读,正在回答… · 来自 安卓", bubbleFooter("来自 安卓", TurnPhase.Answering, null))
|
||||
assertEquals("并行路 2 · 来自 安卓", bubbleFooter("来自 安卓", TurnPhase.Answered, "并行路 2"))
|
||||
assertNull(bubbleFooter(null, TurnPhase.Sent, null))
|
||||
}
|
||||
|
||||
private fun bodies(legs: List<StreamingLeg>, turnId: String): List<String> =
|
||||
legs.leg(bucket(turnId))?.state?.bubbles?.map { it.body }.orEmpty()
|
||||
|
||||
private fun message(id: String, role: String = "user", clientMsgId: String? = null, turnId: String? = null) =
|
||||
HistoryMessageDto(
|
||||
id = id,
|
||||
sessionId = "web",
|
||||
role = role,
|
||||
content = id,
|
||||
clientMsgId = clientMsgId,
|
||||
createdAt = 1L,
|
||||
turnId = turnId,
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user