feat(web): 流式帧按会话过滤,非激活会话不再串入当前气泡
- 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:
1 parent
61cc32c44b
commit
04a8ee0727
4 files changed
+171
-7
No files matched your search
@@ -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`);
|
||||
Reference in new issue
Block a user