feat(web): 流式帧按会话过滤,非激活会话不再串入当前气泡
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- WsDownEvent 增 sessionId;流式分支(message/thinking/tool_call/tool_execution/stream_chunk/stream_end 及 legacy thinking/tool_used)统一过 streamFrameApplies
- 会话列表未解析(activeSessionId 为空)时保持改前行为,避免误丢主人自己的流
- 抽出 lib/streamScope.ts 承载该判断,ws.ts 回到 500 行红线内
- messageSync.test.ts 增 21 项断言(跨会话丢弃/激活追加/无 session 兼容/未解析回退)
This commit is contained in:
AskaEth committed 2026-10-03 16:08:53 +08:00
1 parent 61cc32c44b
commit 04a8ee0727
4 files changed
+171 -7

No files matched your search

+111 -5
View File
@@ -1,7 +1,10 @@
// Logic test for the D3 realtime-sync frames (`message_edited` / `message_deleted`).
// The project has no frontend test runner and no jsdom, so this is a dependency-free
// plain-node check: it drives the real frame decoder (`dispatchFrame` in lib/ws.ts)
// and the real store, and throws on the first failure.
// Logic test for the realtime-sync frames: the D3 edit/delete pair
// (`message_edited` / `message_deleted`) and the streaming session guard
// (design/16 §4.2 — a stream frame for another conversation must not land in the
// bubble on screen). The project has no frontend test runner and no jsdom, so this
// is a dependency-free plain-node check: it drives the real frame decoder
// (`dispatchFrame` in lib/ws.ts) and the real store, and throws on the first
// failure.
//
// Run it with the esbuild that already ships with vite (no new dependency, no
// package.json change); the banner supplies the `window.localStorage` the store's
@@ -110,4 +113,107 @@ useAppStore.getState().updateMessageById("m-ghost", { content: "x" });
useAppStore.getState().removeMessageById("m-ghost");
same(messages().length, 2, "store id-actions no-op on an unknown id");
console.log(`message_edited/message_deleted logic test: ${checks} checks passed`);
// ⑤ Streaming session guard (design/16 §4.2). The hub broadcasts every session's
// stream to every connection, and `appendChunk` writes into the last assistant
// message of the single active list — so a delta carrying another sessionId
// would otherwise splice that conversation's tokens into this bubble.
function streaming(): ChatMessage[] {
return [
{ id: "s1", sessionId: MAIN, role: "user", content: "hi", createdAt: 3000 },
{
id: "s2",
sessionId: MAIN,
role: "assistant",
content: "partial",
streaming: true,
tools: [],
createdAt: 4000,
},
];
}
// Another session's delta never touches this bubble …
seed(streaming(), MAIN);
dispatchFrame({ type: "message.delta", sessionId: OTHER, data: { delta: "LEAK", index: 0 } });
same(messages().map((m) => m.content), ["hi", "partial"], "delta for another session is dropped");
// … the active session's delta is appended as before …
seed(streaming(), MAIN);
dispatchFrame({ type: "message.delta", sessionId: MAIN, data: { delta: " world", index: 0 } });
same(messages()[1].content, "partial world", "delta for the active session is appended");
// … a payload-carried sessionId is honored too …
seed(streaming(), MAIN);
dispatchFrame({ type: "message.delta", sessionId: OTHER, data: { sessionId: MAIN, delta: "!" } });
same(messages()[1].content, "partial!", "a payload sessionId overrides the envelope's");
// … and a frame with no sessionId at all keeps the pre-guard behavior.
seed(streaming(), MAIN);
dispatchFrame({ type: "message.delta", data: { delta: " old", index: 0 } });
same(messages()[1].content, "partial old", "an unscoped delta keeps the legacy behavior");
// … as does the owner's own stream before the session list has resolved the active
// session: the store still holds "" while the frames carry the main session, so
// dropping them would hide a reply the user is watching.
seed(streaming(), "");
dispatchFrame({ type: "message.delta", sessionId: MAIN, data: { delta: " early", index: 0 } });
same(
messages()[1].content,
"partial early",
"a delta before the active session resolves keeps the legacy behavior"
);
// The legacy text channel (stream_chunk) is filtered the same way.
seed(streaming(), MAIN);
dispatchFrame({ type: "stream_chunk", sessionId: OTHER, data: { delta: "LEAK" } });
same(messages()[1].content, "partial", "legacy stream_chunk for another session is dropped");
seed(streaming(), MAIN);
dispatchFrame({ type: "stream_chunk", data: { delta: " tail" } });
same(messages()[1].content, "partial tail", "an unscoped stream_chunk keeps the legacy behavior");
// Thinking segments: the accumulator and the rendered block belong to the active
// session only.
seed(streaming(), MAIN);
dispatchFrame({ type: "thinking.start", sessionId: MAIN, data: { index: 0 } });
dispatchFrame({ type: "thinking.delta", sessionId: OTHER, data: { delta: "other-thought", index: 0 } });
ok(messages()[1].thinking === undefined, "thinking.delta for another session is dropped");
dispatchFrame({ type: "thinking.delta", sessionId: MAIN, data: { delta: "mine", index: 0 } });
same(messages()[1].thinking, "mine", "thinking.delta for the active session accumulates");
dispatchFrame({ type: "thinking.end", sessionId: OTHER, data: { index: 0 } });
same(messages()[1].thinking, "mine", "thinking.end for another session leaves the block alone");
dispatchFrame({ type: "thinking.end", sessionId: MAIN, data: { index: 0 } });
same(messages()[1].thinking, "mine", "thinking.end for the active session keeps the text");
// Tool cards land on the last assistant message too.
seed(streaming(), MAIN);
dispatchFrame({ type: "tool_call.start", sessionId: OTHER, data: { id: "toolu_1", name: "web_search" } });
same(messages()[1].tools, [], "tool_call.start for another session adds no card");
dispatchFrame({ type: "tool_call.start", sessionId: MAIN, data: { id: "toolu_1", name: "web_search" } });
same(
(messages()[1].tools ?? []).map((t) => t.name),
["web_search"],
"tool_call.start for the active session adds the card"
);
seed(streaming(), MAIN);
dispatchFrame({ type: "tool_used", sessionId: OTHER, data: { name: "shell" } });
same(messages()[1].tools, [], "legacy tool_used for another session adds no card");
// stream_end carries single-slot state (the streaming flag + the busy badge), so
// another session finishing its turn must not close this one.
seed(streaming(), MAIN);
useAppStore.setState({ sessionBusy: true, pendingCount: 2 });
dispatchFrame({ type: "stream_end", sessionId: OTHER, data: { stopReason: "end_turn" } });
ok(messages()[1].streaming === true, "a foreign stream_end does not close the active bubble");
ok(useAppStore.getState().sessionBusy === true, "a foreign stream_end does not clear the busy badge");
same(useAppStore.getState().pendingCount, 2, "a foreign stream_end does not clear the pending count");
dispatchFrame({ type: "stream_end", sessionId: MAIN, data: { stopReason: "end_turn" } });
ok(messages()[1].streaming === false, "the active session's stream_end closes the bubble");
ok(useAppStore.getState().sessionBusy === false, "the active session's stream_end clears busy");
same(useAppStore.getState().pendingCount, 0, "the active session's stream_end clears the pending count");
seed(streaming(), MAIN);
dispatchFrame({ type: "stream_end", data: { stopReason: "end_turn" } });
ok(messages()[1].streaming === false, "an unscoped stream_end keeps the legacy behavior");
console.log(`message sync logic test: ${checks} checks passed`);