- 16.4.5:非激活会话的帧**不再丢弃**,按自己的 sessionId 落进该会话桶(新增 store/sessionCache.ts 纯函数 + useAppStore.sessionMessages/flowGraphs);切会话改为「存回/取出」而非清空;流式增量 O(1) 定位桶、 只延长已存在的助手气泡(不凭一帧 delta 凭空造消息) - 16.4.2:图按会话入桶(上限 12 个),Backthink 页取 sess-backthink 的图而不再被用户会话图顶替; 空态区分「实时通道未连接」与「尚未产生链路图」;wire_runtime 的图源白名单注释按裁决口径重写(零行为改动) - 修 16.4.1 的缺口:handlers/session.ts 补 `session_idle` / `session_interrupted`(此前落到未知帧兜底 ⇒ sessionBusy 可能卡 true ⇒ 预约永不落地);忙态**置忙与置闲两侧**统一按 frameIsActive 过滤(只过滤置闲 会让后台会话的忙帧点亮徽标而它自己的 idle 被丢 ⇒ 永久卡忙) - 清理:删除 lib/streamScope.ts(口径已被 16.4.5 推翻)与只剩测试在用的 messageEventApplies - 复核:tsc 0 错 + vite build 成功;messageSync 69 / session 19 项逻辑测试;4 条变异反证
413 lines
21 KiB
TypeScript
413 lines
21 KiB
TypeScript
// Logic test for the realtime-sync frames: the D3 edit/delete pair
|
|
// (`message_edited` / `message_deleted`), the per-session message cache
|
|
// (裁决 16.4.5 — a frame is written into the bucket its own `sessionId` names, and
|
|
// switching sessions keeps the list instead of clearing it), the per-session flow
|
|
// graphs (裁决 16.4.2 — a backthink graph never overwrites the active session's),
|
|
// and the session read cursor (Android 协同 B3/D4 — the `session_read` frame clears
|
|
// exactly the conversation it names, and opening a session pushes its forward-only
|
|
// cursor). 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), the real read path
|
|
// (`markSessionOpenedRead` in lib/sessionRead.ts, via lib/api) 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
|
|
// persist middleware probes, which is simply absent under node:
|
|
//
|
|
// cd frontend/web \
|
|
// && ./node_modules/.bin/esbuild src/store/messageSync.test.ts \
|
|
// --bundle --platform=node --format=esm \
|
|
// --banner:js='globalThis.window={localStorage:{getItem:()=>null,setItem:()=>{},removeItem:()=>{}}};' \
|
|
// --outfile=/tmp/cyrene-message-sync.test.mjs \
|
|
// && node /tmp/cyrene-message-sync.test.mjs
|
|
//
|
|
// `tsc --noEmit` (part of `npm run build`) type-checks this file, which is why it
|
|
// imports nothing from node:*.
|
|
|
|
import { dispatchFrame } from "../lib/ws";
|
|
import { markSessionOpenedRead } from "../lib/sessionRead";
|
|
import { frameIsActive } from "../lib/ws/handlers/shared";
|
|
import { useAppStore } from "./useAppStore";
|
|
import { BACKTHINK_SESSION_ID } from "./sessionCache";
|
|
import type { ChatMessage, SessionMeta } from "../lib/types";
|
|
|
|
let checks = 0;
|
|
|
|
function ok(cond: boolean, what: string): void {
|
|
checks += 1;
|
|
if (!cond) throw new Error(`FAILED: ${what}`);
|
|
}
|
|
|
|
function same<T>(got: T, want: T, what: string): void {
|
|
ok(JSON.stringify(got) === JSON.stringify(want), `${what} — got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`);
|
|
}
|
|
|
|
const MAIN = "web";
|
|
const OTHER = "web:sub-1";
|
|
|
|
/** Reset the store to a focused session holding a fixed message list (and no
|
|
* buckets, so each case starts from a known cache state). */
|
|
function seed(messages: ChatMessage[], activeSessionId: string): void {
|
|
useAppStore.setState({ activeSessionId, messages, sessionMessages: {} });
|
|
}
|
|
|
|
function messages(): ChatMessage[] {
|
|
return useAppStore.getState().messages;
|
|
}
|
|
|
|
/** The cached message bucket of a session that is not on screen. */
|
|
function bucket(sessionId: string): ChatMessage[] {
|
|
return useAppStore.getState().sessionMessages[sessionId] ?? [];
|
|
}
|
|
|
|
function base(): ChatMessage[] {
|
|
return [
|
|
{ id: "m1", sessionId: MAIN, role: "user", content: "old", mode: "chat", createdAt: 1000 },
|
|
{ id: "m2", sessionId: MAIN, role: "assistant", content: "reply", createdAt: 2000 },
|
|
];
|
|
}
|
|
|
|
// ① Session filter: a frame scoped to another conversation never touches this one.
|
|
// 这是单槽全局状态(忙态 / 上下文 / 代审小字 / 回合结束的收口)共用的判据,
|
|
// 实现在 lib/ws/handlers/shared.ts。
|
|
ok(frameIsActive(MAIN, MAIN), "same session applies");
|
|
ok(!frameIsActive(OTHER, MAIN), "another session does not apply");
|
|
ok(frameIsActive(undefined, MAIN), "an unscoped (id-only) frame applies");
|
|
// 激活会话尚未解析(会话列表没回来)时沿用旧行为,不把主人正看着的流丢掉。
|
|
ok(frameIsActive(MAIN, ""), "an unresolved active session keeps the legacy behavior");
|
|
|
|
// ② message_edited patches the addressed bubble in place, by id, in the active
|
|
// session — and leaves its siblings alone.
|
|
seed(base(), MAIN);
|
|
dispatchFrame({ type: "message_edited", sessionId: MAIN, data: { id: "m1", sessionId: MAIN, content: "new", mode: "chat", createdAt: 1000 } });
|
|
same(
|
|
messages().map((m) => m.content),
|
|
["new", "reply"],
|
|
"edit patches only the addressed message"
|
|
);
|
|
same(messages().find((m) => m.id === "m1")?.mode, "chat", "edit keeps the message mode");
|
|
ok(messages().find((m) => m.id === "m1")?.createdAt === 1000, "edit keeps createdAt");
|
|
|
|
// ③ An edit frame for another session is ignored (no cross-session write) …
|
|
seed(base(), MAIN);
|
|
dispatchFrame({ type: "message_edited", sessionId: OTHER, data: { id: "m3", sessionId: OTHER, content: "elsewhere" } });
|
|
same(messages().map((m) => m.content), ["old", "reply"], "edit for another session does not apply");
|
|
|
|
// … and an edit for an id we do not hold never appends a row.
|
|
seed(base(), MAIN);
|
|
dispatchFrame({ type: "message_edited", sessionId: MAIN, data: { id: "m-ghost", sessionId: MAIN, content: "ghost" } });
|
|
same(messages().length, 2, "edit for an unknown id does not append");
|
|
|
|
// A malformed frame (no content) must not blank out a rendered bubble.
|
|
seed(base(), MAIN);
|
|
dispatchFrame({ type: "message_edited", sessionId: MAIN, data: { id: "m1", sessionId: MAIN } });
|
|
same(messages()[0].content, "old", "content-less edit frame is dropped");
|
|
|
|
// ④ message_deleted removes the addressed bubble in the active session only.
|
|
seed(base(), MAIN);
|
|
dispatchFrame({ type: "message_deleted", sessionId: MAIN, data: { id: "m1" } });
|
|
same(messages().map((m) => m.id), ["m2"], "delete removes the addressed message");
|
|
|
|
// A delete frame for another session is ignored.
|
|
seed(base(), MAIN);
|
|
dispatchFrame({ type: "message_deleted", sessionId: OTHER, data: { id: "m1" } });
|
|
same(messages().map((m) => m.id), ["m1", "m2"], "delete for another session does not apply");
|
|
|
|
// An unscoped delete still applies (the id is globally unique, so it can only ever
|
|
// match its own message).
|
|
seed(base(), MAIN);
|
|
dispatchFrame({ type: "message_deleted", data: { id: "m2" } });
|
|
same(messages().map((m) => m.id), ["m1"], "unscoped delete applies by id");
|
|
|
|
// A delete for an id we do not hold is a no-op.
|
|
seed(base(), MAIN);
|
|
dispatchFrame({ type: "message_deleted", sessionId: MAIN, data: { id: "m-ghost" } });
|
|
same(messages().length, 2, "delete for an unknown id is a no-op");
|
|
|
|
// The store actions are also safe on their own (defensive: duplicate frames).
|
|
seed(base(), MAIN);
|
|
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");
|
|
|
|
// ⑤ 会话级消息缓存(裁决 16.4.5 / design/16 §4.3)。hub 把每个会话的帧都广播给每条
|
|
// 连接,因此一帧要落到**它自己那个会话**:属于屏幕上这个会话就写当前列表,属于别的
|
|
// 会话就写它的消息桶(切过去即见),而不是被丢掉。只有单槽全局状态(忙态 / 上下文)
|
|
// 仍只认「屏幕上这个会话」。
|
|
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,
|
|
},
|
|
];
|
|
}
|
|
|
|
// A foreign delta never touches this bubble, and it does not invent a bubble in the
|
|
// other session's bucket either (a delta can only extend an assistant bubble that
|
|
// already exists there — the persisted row covers the rest).
|
|
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 does not touch the active bubble");
|
|
same(bucket(OTHER), [], "a delta without a bubble in that session invents nothing");
|
|
|
|
// … but when the bucket already holds that turn's bubble — the common case, because
|
|
// switching away stashes the optimistic bubbles into it — the delta lands there and
|
|
// is on screen the moment the session is switched to.
|
|
seed(streaming(), MAIN);
|
|
useAppStore.setState({ sessionMessages: { [OTHER]: streaming() } });
|
|
dispatchFrame({ type: "message.delta", sessionId: OTHER, data: { delta: "+bg", index: 0 } });
|
|
same(bucket(OTHER)[1].content, "partial+bg", "a background turn keeps streaming into its own bucket");
|
|
same(messages()[1].content, "partial", "…and the active bubble stays untouched");
|
|
useAppStore.getState().setActiveSession(OTHER);
|
|
same(messages()[1].content, "partial+bg", "switching to the background session shows its live text");
|
|
useAppStore.getState().setActiveSession(MAIN);
|
|
same(messages().map((m) => m.content), ["hi", "partial"], "switching back restores the first session (nothing cleared)");
|
|
|
|
// A persisted message for another session is cached as well (never dropped).
|
|
seed(base(), MAIN);
|
|
dispatchFrame({
|
|
type: "message_persisted",
|
|
sessionId: OTHER,
|
|
data: { message: { id: "m9", sessionId: OTHER, role: "assistant", content: "bg", createdAt: 5000 } },
|
|
});
|
|
same(bucket(OTHER).map((m) => m.content), ["bg"], "message_persisted for another session is cached in its bucket");
|
|
same(messages().length, 2, "…and leaves the active list alone");
|
|
|
|
// … 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");
|
|
|
|
// Before the session list has resolved the active session the store still holds "",
|
|
// while the frames carry the main session: the text is filed under that session (not
|
|
// spliced into the unresolved list) and appears as soon as the list resolves.
|
|
seed(streaming(), "");
|
|
useAppStore.setState({ sessionMessages: { [MAIN]: streaming() } });
|
|
dispatchFrame({ type: "message.delta", sessionId: MAIN, data: { delta: " early", index: 0 } });
|
|
same(bucket(MAIN)[1].content, "partial early", "a delta before the active session resolves lands in the named session's bucket");
|
|
useAppStore.getState().setActiveSession(MAIN);
|
|
same(messages()[1].content, "partial early", "…and is on screen once the active session resolves");
|
|
|
|
// The legacy text channel (stream_chunk) is routed 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 does not touch the active bubble");
|
|
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 are per session, so a
|
|
// background turn's reasoning never lands in the block on screen.
|
|
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 does not touch this bubble");
|
|
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 of their own session 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 here");
|
|
same(bucket(OTHER), [], "…and invents no bucket for it");
|
|
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 here");
|
|
|
|
// stream_end carries single-slot state (the streaming flag + the busy badge), so
|
|
// another session finishing its turn must not close this one — it only closes its
|
|
// own bubble, in its own bucket.
|
|
seed(streaming(), MAIN);
|
|
useAppStore.setState({ sessionBusy: true, pendingCount: 2, sessionMessages: { [OTHER]: streaming() } });
|
|
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(bucket(OTHER)[1].streaming === false, "…it closes that turn's bubble in its own bucket");
|
|
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");
|
|
|
|
seed(streaming(), MAIN);
|
|
useAppStore.setState({ sessionBusy: true, pendingCount: 2 });
|
|
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");
|
|
|
|
// ⑥ Unread badges / read cursor (Android 协同 B3/D4). The session list carries a
|
|
// per-session `unreadCount`; `session_read` is the frame another end emits after
|
|
// it marked a conversation read, and it must clear exactly that conversation.
|
|
function seedSessions(list: SessionMeta[]): void {
|
|
useAppStore.setState({
|
|
sessions: Object.fromEntries(list.map((s) => [s.id, s])),
|
|
activeSessionId: MAIN,
|
|
});
|
|
}
|
|
|
|
function unreadOf(id: string): number | undefined {
|
|
return useAppStore.getState().sessions[id]?.unreadCount;
|
|
}
|
|
|
|
function twoUnread(): SessionMeta[] {
|
|
return [
|
|
{ id: MAIN, type: "main", createdAt: 1, unreadCount: 3 },
|
|
{ id: OTHER, type: "sub", createdAt: 2, unreadCount: 5 },
|
|
];
|
|
}
|
|
|
|
// A frame naming another session clears that session — and only that session: the
|
|
// conversation on screen keeps the dot that belongs to it.
|
|
seedSessions(twoUnread());
|
|
dispatchFrame({ type: "session_read", sessionId: OTHER, data: { readAt: 100 } });
|
|
same(unreadOf(OTHER), 0, "session_read for another session clears that session's dot");
|
|
same(unreadOf(MAIN), 3, "session_read for another session leaves the active dot alone");
|
|
|
|
// A frame naming the active session clears it.
|
|
seedSessions(twoUnread());
|
|
dispatchFrame({ type: "session_read", sessionId: MAIN, data: { readAt: 200 } });
|
|
same(unreadOf(MAIN), 0, "session_read for the active session clears its dot");
|
|
same(unreadOf(OTHER), 5, "session_read for the active session leaves the other dot alone");
|
|
|
|
// A payload-carried sessionId wins (the envelope may be bare) …
|
|
seedSessions(twoUnread());
|
|
dispatchFrame({ type: "session_read", data: { sessionId: MAIN, readAt: 300 } });
|
|
same(unreadOf(MAIN), 0, "a payload sessionId is honored");
|
|
same(unreadOf(OTHER), 5, "a payload sessionId still scopes to one session");
|
|
|
|
// … an unattributable frame (no session id anywhere) touches nothing rather than
|
|
// guessing whose badge to clear …
|
|
seedSessions(twoUnread());
|
|
dispatchFrame({ type: "session_read", data: { readAt: 400 } });
|
|
same(unreadOf(MAIN), 3, "an unattributable session_read clears no active dot");
|
|
same(unreadOf(OTHER), 5, "an unattributable session_read clears no other dot");
|
|
|
|
// … and an unknown session id is a no-op: no row is invented, nothing is cleared.
|
|
seedSessions(twoUnread());
|
|
dispatchFrame({ type: "session_read", sessionId: "web:ghost", data: { readAt: 500 } });
|
|
ok(
|
|
!("web:ghost" in useAppStore.getState().sessions),
|
|
"a session_read for an unknown session invents no row"
|
|
);
|
|
same(unreadOf(MAIN), 3, "a session_read for an unknown session clears nothing");
|
|
|
|
// A session the backend did not annotate (older backend, no `unreadCount`) has
|
|
// nothing to clear — the frame leaves the entry exactly as it was rather than
|
|
// writing a 0 the backend never reported. The badge reads absent as 0.
|
|
seedSessions([{ id: MAIN, type: "main", createdAt: 1 }]);
|
|
dispatchFrame({ type: "session_read", sessionId: MAIN, data: { readAt: 600 } });
|
|
ok(unreadOf(MAIN) === undefined, "a session without unreadCount is left unannotated");
|
|
|
|
// ⑦ The open-a-session trigger. `markSessionOpenedRead` (fired once the history
|
|
// page has landed) zeroes the badge locally and pushes the forward-only cursor;
|
|
// a session already reading as read must not produce a request on every switch.
|
|
// The REST layer is exercised for real — only `fetch` is stubbed.
|
|
const calls: { url: string; method?: string; body?: unknown }[] = [];
|
|
const originalFetch = globalThis.fetch;
|
|
const originalLocalStorage = globalThis.localStorage;
|
|
// The bundle's banner supplies `window.localStorage` (the store's persist
|
|
// middleware probes it); the REST layer reads the bare `localStorage` global,
|
|
// which node does not expose without --experimental-webstorage.
|
|
globalThis.localStorage = window.localStorage;
|
|
globalThis.fetch = ((url: RequestInfo | URL, init?: RequestInit) => {
|
|
calls.push({ url: String(url), method: init?.method, body: init?.body });
|
|
return Promise.resolve({
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => ({ data: { sessionId: MAIN, readAt: 1 } }),
|
|
} as unknown as Response);
|
|
}) as typeof fetch;
|
|
|
|
seedSessions([{ id: MAIN, type: "main", createdAt: 1, unreadCount: 4 }]);
|
|
markSessionOpenedRead(MAIN);
|
|
same(unreadOf(MAIN), 0, "opening an unread session clears its badge immediately");
|
|
same(calls.length, 1, "opening an unread session sends exactly one read call");
|
|
same(calls[0].url, "/api/v1/sessions/web/read", "the read call targets the read endpoint");
|
|
same(calls[0].method, "POST", "the read call is a POST");
|
|
same(calls[0].body, "{}", "an omitted `before` sends an empty body, meaning now");
|
|
|
|
// Re-opening a session that already reads as read sends nothing.
|
|
markSessionOpenedRead(MAIN);
|
|
same(calls.length, 1, "an already-read session sends no further read call");
|
|
// Same for an unknown session id and for an empty one.
|
|
markSessionOpenedRead("web:ghost");
|
|
markSessionOpenedRead("");
|
|
same(calls.length, 1, "an unknown or empty session sends no read call");
|
|
|
|
// A stale open (the session was switched away and its metadata is gone) is a
|
|
// no-op too — the await'd page can resolve after the list was replaced.
|
|
seedSessions([]);
|
|
markSessionOpenedRead(MAIN);
|
|
same(calls.length, 1, "a missing session metadata entry sends no read call");
|
|
|
|
globalThis.fetch = originalFetch;
|
|
globalThis.localStorage = originalLocalStorage;
|
|
|
|
// ⑦ 会话级链路图(裁决 16.4.2)。`graph_update` 快照按它自己的 sessionId 入桶:
|
|
// 后台思考的图不会顶掉管理端 Flow / Agents 页正在看的「当前会话」图,管理后台
|
|
// 因此能同时拿到每一路(用户会话 / 后台思考)的完整链路。
|
|
seed([], MAIN);
|
|
dispatchFrame({
|
|
type: "graph_update",
|
|
data: { id: "web-1", nodes: [], edges: [], sessionId: MAIN },
|
|
});
|
|
dispatchFrame({
|
|
type: "graph_update",
|
|
data: { id: "sess-backthink-1", nodes: [], edges: [], sessionId: BACKTHINK_SESSION_ID },
|
|
});
|
|
same(
|
|
useAppStore.getState().flowGraphs[MAIN]?.id,
|
|
"web-1",
|
|
"a graph is stored under its own sessionId"
|
|
);
|
|
same(
|
|
useAppStore.getState().flowGraphs[BACKTHINK_SESSION_ID]?.id,
|
|
"sess-backthink-1",
|
|
"the backthink graph lives in its own bucket"
|
|
);
|
|
same(
|
|
useAppStore.getState().flowGraph?.id,
|
|
"web-1",
|
|
"the compatibility alias stays on the active session (a backthink graph never takes it over)"
|
|
);
|
|
|
|
// A graph without a sessionId (legacy/unscoped frame) falls back to the active session.
|
|
seed([], MAIN);
|
|
dispatchFrame({ type: "graph_update", data: { id: "bare-1", nodes: [], edges: [] } });
|
|
same(
|
|
useAppStore.getState().flowGraphs[MAIN]?.id,
|
|
"bare-1",
|
|
"an unscoped graph falls back to the active session"
|
|
);
|
|
|
|
console.log(`message sync logic test: ${checks} checks passed`);
|