fix(ws): 流式帧按 sessionId 收敛,修跨会话串台并退役 A1/D14
服务端从 v0.36 起就在结构化流帧(message.* / thinking.* / tool_call.* /
tool_execution.*)上带了 sessionId + turnId,而本端 WsEvent.parse 把这两个字段
**丢掉了**——所以「同一时刻只有一个会话在流式」这条长期约束(A1/D14)其实是
客户端自己造成的,不是后端限制。
而广播是发给**每条已认证连接**、不按会话订阅;每个 ChatScreen 的 VM 又各建一条
ChatSocket,切会话后旧 socket 与旧 VM 都还活着——于是旧 VM 会拿新会话的 delta
跑自己的流式状态机,思维链/工具卡/正文全线串台。
- data/ws/WsEvent.kt:基类开 open val sessionId(子类按帧壳覆盖),透传到 10 个
结构化事件类型;新增纯函数 isForSession()——**只排除「明确属于别的会话」的帧**,
空值视为「无法判定」而放行(与 historyForSession 对空值的处理一致)
- ui/chat/ChatViewModel.kt:onEvent 顶部加一道会话闸;history 帧**刻意豁免**,
它的归属由帧内每条消息的 sessionId 判定(比帧壳更细,即 2a49c25 的语义)
- data/ws/ChatSocket.kt:改掉那段描述「同一时刻只允许一个会话在流式」的过时 KDoc
顺带核实:**没有任何代码实现过 D14**(切会话时不拦截,sendInterrupt 更是零调用点),
它只是文档假设 + 解析器丢字段的副作用 → 因此 D14 退役、A1 闭环。
测试:194 全绿(178 → +16)。新增 WsEventSessionScopeTest:12 种帧类型的解码
(按真实帧壳形状)、向后兼容(缺字段 / 显式 null)、实抓的 history 帧、isForSession 规则。
未验证项:跨会话修复只由单测 + 后端自带的 ws_hub_stream_scope_test 覆盖,
没有实跑「两个会话同时流式」。
This commit is contained in:
1 parent
2a49c25e1b
commit
d12a920096
4 files changed
+322
-21
No files matched your search
@@ -0,0 +1,250 @@
|
||||
package top.yeij.cyrene.data.ws
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* A1 顺带项:**结构化流帧的 `sessionId` 必须被解码出来**,并按会话过滤。
|
||||
*
|
||||
* 帧形状不是手写猜的:
|
||||
* - 帧壳(`type` / `turnId` / `seq` / `sessionId` / `data`)取自 `gateway/wsEvent` 的
|
||||
* JSON tag 与 `ws_hub_stream.go` 的 `broadcast`;`history` 那一帧是**线上抓的**
|
||||
* (`http://127.0.0.1:10942`,只连不写)。
|
||||
* - 每类 `data` 取自 `gateway/ws_bridge.go:streamEventData`(`streamId` 只在非空时并进
|
||||
* `data`,这里保留它,因为真实线上就是带的)。
|
||||
* - 「结构化流帧带 sessionId」由后端端到端测试钉死
|
||||
* (`gateway/ws_hub_stream_scope_test.go:TestStreamFramesCarrySessionScope`,
|
||||
* 它解的就是连接上的原始 JSON)。
|
||||
*/
|
||||
class WsEventSessionScopeTest {
|
||||
|
||||
/** 帧壳上的一轮 id,形如 `turn-` + 8 字节 hex(`ws_hub_turn.go:newTurnID`)。 */
|
||||
private val turnId = "turn-9f2c1a4b7d0e3355"
|
||||
|
||||
private fun frame(
|
||||
type: String,
|
||||
data: String? = null,
|
||||
sessionId: String? = "web",
|
||||
turnId: String? = this.turnId,
|
||||
seq: Int? = 41,
|
||||
): String = buildString {
|
||||
append("""{"type":"$type"""")
|
||||
if (turnId != null) append(""","turnId":"$turnId"""")
|
||||
if (seq != null) append(""","seq":$seq""")
|
||||
if (sessionId != null) append(""","sessionId":"$sessionId"""")
|
||||
if (data != null) append(""","data":$data""")
|
||||
append("}")
|
||||
}
|
||||
|
||||
private fun parse(raw: String): WsEvent =
|
||||
requireNotNull(WsEvent.parse(raw)) { "帧没解出来:$raw" }
|
||||
|
||||
// —— 正文 ——
|
||||
|
||||
@Test
|
||||
fun `message start carries the session from the frame shell`() {
|
||||
val ev = parse(frame("message.start", """{"index":0,"streamId":"s-6b1f"}"""))
|
||||
as WsEvent.MessageStart
|
||||
|
||||
assertEquals(0, ev.index)
|
||||
assertEquals("web", ev.sessionId)
|
||||
assertEquals(turnId, ev.turnId)
|
||||
assertEquals(41L, ev.seq)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `message delta carries the session from the frame shell`() {
|
||||
val ev = parse(frame("message.delta", """{"delta":"Hel","index":0,"streamId":"s-6b1f"}""", seq = 42))
|
||||
as WsEvent.MessageDelta
|
||||
|
||||
assertEquals("Hel", ev.delta)
|
||||
assertEquals("web", ev.sessionId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `message end carries the session from the frame shell`() {
|
||||
val ev = parse(frame("message.end", """{"index":0,"streamId":"s-6b1f"}""", seq = 43))
|
||||
as WsEvent.MessageEnd
|
||||
|
||||
assertEquals("web", ev.sessionId)
|
||||
}
|
||||
|
||||
// —— 思维链 ——
|
||||
|
||||
@Test
|
||||
fun `thinking frames carry the session from the frame shell`() {
|
||||
val start = parse(frame("thinking.start", """{"index":0,"streamId":"s-6b1f"}"""))
|
||||
as WsEvent.ThinkingStart
|
||||
val delta = parse(frame("thinking.delta", """{"delta":"嗯","index":0,"streamId":"s-6b1f"}"""))
|
||||
as WsEvent.ThinkingDelta
|
||||
val end = parse(frame("thinking.end", """{"index":0,"streamId":"s-6b1f"}"""))
|
||||
as WsEvent.ThinkingEnd
|
||||
|
||||
assertEquals("web", start.sessionId)
|
||||
assertEquals("web", delta.sessionId)
|
||||
assertEquals("web", end.sessionId)
|
||||
}
|
||||
|
||||
// —— 工具调用 ——
|
||||
|
||||
@Test
|
||||
fun `tool call frames carry the session from the frame shell`() {
|
||||
val start = parse(frame("tool_call.start", """{"id":"call_1","index":0,"streamId":"s-6b1f"}"""))
|
||||
as WsEvent.ToolCallStart
|
||||
val delta = parse(
|
||||
frame("tool_call.delta", """{"id":"call_1","index":0,"delta":"{\"q\":","streamId":"s-6b1f"}"""),
|
||||
) as WsEvent.ToolCallDelta
|
||||
val end = parse(frame("tool_call.end", """{"id":"call_1","index":0,"streamId":"s-6b1f"}"""))
|
||||
as WsEvent.ToolCallEnd
|
||||
|
||||
assertEquals("web", start.sessionId)
|
||||
assertEquals("web", delta.sessionId)
|
||||
assertEquals("web", end.sessionId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tool execution update carries the session from the frame shell`() {
|
||||
// tool_execution.* 的 data 用 `tool`/`phase` 而不是 `index`(ws_bridge.go:toolExecutionData)。
|
||||
val ev = parse(
|
||||
frame(
|
||||
"tool_execution.update",
|
||||
"""{"tool":"read_file","phase":"running","id":"call_1","delta":"12 行"}""",
|
||||
),
|
||||
) as WsEvent.ToolExecutionUpdate
|
||||
|
||||
assertEquals("call_1", ev.id)
|
||||
assertEquals("12 行", ev.delta)
|
||||
assertEquals("web", ev.sessionId)
|
||||
}
|
||||
|
||||
// —— 整段文本 / 收尾 ——
|
||||
|
||||
@Test
|
||||
fun `stream chunk and stream end carry the session from the frame shell`() {
|
||||
val chunk = parse(frame("stream_chunk", """{"delta":"整段"}""", seq = 44)) as WsEvent.StreamChunk
|
||||
val end = parse(frame("stream_end", """{"stopReason":"stop","steps":1}""", seq = 45)) as WsEvent.StreamEnd
|
||||
|
||||
assertEquals("web", chunk.sessionId)
|
||||
assertEquals("整段", chunk.delta)
|
||||
assertEquals("web", end.sessionId)
|
||||
assertEquals("stop", end.stopReason)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every structured stream event type exposes the same frame session`() {
|
||||
// 一次扫完全套:漏一个类型就会在真机上表现为「那一类帧串台」。
|
||||
val cases = mapOf(
|
||||
"message.start" to """{"index":0}""",
|
||||
"message.delta" to """{"delta":"a","index":0}""",
|
||||
"message.end" to """{"index":0}""",
|
||||
"thinking.start" to """{"index":0}""",
|
||||
"thinking.delta" to """{"delta":"a","index":0}""",
|
||||
"thinking.end" to """{"index":0}""",
|
||||
"tool_call.start" to """{"id":"call_1","index":0}""",
|
||||
"tool_call.delta" to """{"id":"call_1","index":0,"delta":"a"}""",
|
||||
"tool_call.end" to """{"id":"call_1","index":0}""",
|
||||
"tool_execution.update" to """{"tool":"read_file","phase":"running","id":"call_1","delta":"x"}""",
|
||||
"stream_chunk" to """{"delta":"a"}""",
|
||||
"stream_end" to """{"stopReason":"stop"}""",
|
||||
)
|
||||
|
||||
for ((type, data) in cases) {
|
||||
val ev = parse(frame(type, data, sessionId = "sess-9c3"))
|
||||
assertEquals("$type 的 sessionId 透了没有?", "sess-9c3", ev.sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
// —— 向后兼容:帧壳不带 sessionId ——
|
||||
|
||||
@Test
|
||||
fun `frames without a session on the shell decode to a null session`() {
|
||||
// 旧服务端(v0.36 之前)/ 连接级错误帧就是这样:字段整个缺席。
|
||||
val ev = parse(frame("message.delta", """{"delta":"hi","index":0}""", sessionId = null))
|
||||
as WsEvent.MessageDelta
|
||||
|
||||
assertNull(ev.sessionId)
|
||||
assertEquals("hi", ev.delta)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an explicit null session on the shell decodes to a null session`() {
|
||||
val ev = parse("""{"type":"message.delta","sessionId":null,"data":{"delta":"hi","index":0}}""")
|
||||
as WsEvent.MessageDelta
|
||||
|
||||
assertNull(ev.sessionId)
|
||||
}
|
||||
|
||||
// —— 线上抓到的 history 帧(逐字照抄形状) ——
|
||||
|
||||
@Test
|
||||
fun `the captured live history frame decodes with its shell session`() {
|
||||
// 2026-10-04 从 http://127.0.0.1:10942/ws/chat 只连不写抓下来的首帧,
|
||||
// 内容换成合成值;`seq` / `turnId` 在真实帧里**根本不存在**。
|
||||
val live = """
|
||||
{"type":"history","sessionId":"web","data":{"messages":[
|
||||
{"id":"c78a10cdc3f141fdde4a4bb14179a55a","sessionId":"web","role":"assistant",
|
||||
"content":"因为那是你呀,亲爱的♪","platform":"weixin-ilink",
|
||||
"deviceId":"weixin-ilink:probe","adapterId":"weixin-ilink:05487d7f",
|
||||
"createdAt":1788854366025}]}}
|
||||
""".trimIndent()
|
||||
|
||||
val ev = parse(live) as WsEvent.History
|
||||
|
||||
assertEquals("web", ev.sessionId)
|
||||
assertNull("真实 history 帧不带 seq", ev.seq)
|
||||
assertNull("真实 history 帧不带 turnId", ev.turnId)
|
||||
assertEquals(1, ev.messages.size)
|
||||
assertEquals("web", ev.messages.first().sessionId)
|
||||
}
|
||||
|
||||
// —— 过滤规则(纯函数) ——
|
||||
|
||||
@Test
|
||||
fun `a frame for this session is accepted`() {
|
||||
val ev = parse(frame("message.delta", """{"delta":"hi","index":0}""", sessionId = "sess-9c3"))
|
||||
|
||||
assertTrue(ev.isForSession("sess-9c3"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a frame for another session is rejected`() {
|
||||
val ev = parse(frame("message.delta", """{"delta":"hi","index":0}""", sessionId = "web"))
|
||||
|
||||
assertFalse(ev.isForSession("sess-9c3"))
|
||||
// 主会话的 VM 反过来也不该吃次级会话的帧。
|
||||
assertTrue(ev.isForSession("web"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a frame without a session is accepted by everyone`() {
|
||||
// 空值 = 「无法判定」,不是「不属于本会话」:放行才能与修复前的行为一致。
|
||||
val ev = parse(frame("message.delta", """{"delta":"hi","index":0}""", sessionId = null))
|
||||
|
||||
assertTrue(ev.isForSession("web"))
|
||||
assertTrue(ev.isForSession("sess-9c3"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a blank session on the shell is treated as unknown and accepted`() {
|
||||
val ev = parse(frame("message.delta", """{"delta":"hi","index":0}""", sessionId = ""))
|
||||
|
||||
assertTrue(ev.isForSession("web"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the session rule also covers the orchestration and error frames`() {
|
||||
val busy = parse(
|
||||
"""{"type":"session_busy","sessionId":"sess-9c3","data":{"mode":"thinking"}}""",
|
||||
) as WsEvent.SessionBusy
|
||||
val foreign = parse(
|
||||
"""{"type":"error","sessionId":"sess-9c3","data":{"message":"boom"}}""",
|
||||
) as WsEvent.Error
|
||||
|
||||
assertTrue(busy.isForSession("sess-9c3"))
|
||||
assertFalse(busy.isForSession("web"))
|
||||
assertFalse(foreign.isForSession("web"))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user