返回 DeepSeek-Reasonix
independent-session-boundaries.test.ts
根目录 / desktop / frontend / src / __tests__ / independent-session-boundaries.test.ts
1 import assert from "node:assert/strict";
2 import { test } from "node:test";
3 import { projectSessionIdentity, projectSessionRowKey } from "../lib/projectSessionIdentity";
4 import { createProjectTreeRuntimeProjection } from "../lib/projectTreeRuntime";
5 import { buildSidebarRegionProps } from "../app-shell/chromeRegionBuilders";
6 import {
7 projectTreeReadActivityKey,
8 projectTreeSeedReadActivity,
9 projectTreeTopicHasUnreadActivity,
10 projectTreeWithoutTopics,
11 topicIsActive,
12 mergeProjectTopicPage,
13 mergeIncompleteProjectTopicPage,
14 } from "../lib/projectTreeTopic";
15 import type { ProjectNode, ProjectRuntimeTopic } from "../lib/types";
16
17 const root = "/repo";
18 const topicId = "shared-topic";
19
20 function legacySession(id: string, activity = 10): ProjectNode {
21 return {
22 key: `legacy-${id}`, kind: "topic", label: `Legacy ${id}`, root, topicId,
23 sessionPath: `/legacy/${id}.jsonl`, lastActivityAt: activity, children: [],
24 };
25 }
26
27 function canonicalSession(id: string, sequence = 10): ProjectNode {
28 return {
29 ...legacySession(id), key: `session-${id}`,
30 session: { hostId: "local", sessionId: id },
31 resultSequence: sequence, turnsState: "ready",
32 };
33 }
34
35 function catalog(...children: ProjectNode[]): ProjectNode[] {
36 return [{ key: "project", kind: "project", label: "Project", root, children }];
37 }
38
39 function runtime(node: ProjectNode): ProjectRuntimeTopic {
40 return { scope: "project", workspaceRoot: root, node: { ...node, open: true, running: true, status: "thinking" } };
41 }
42
43 test("adoption replaces a pinned source in first and incomplete pages", () => {
44 const source = { ...legacySession("b"), pinned: true };
45 const adopted = { ...canonicalSession("b"), identityAliases: [projectSessionIdentity(source)], pinned: true };
46 for (const merge of [
47 (rows: ProjectNode[], page: ProjectNode[]) => mergeProjectTopicPage(rows, page, false),
48 mergeIncompleteProjectTopicPage,
49 ]) {
50 const rows = merge([source], [adopted]);
51 assert.equal(rows.length, 1, "source and canonical aliases are one actionable row");
52 assert.equal(rows[0]?.session?.sessionId, "b");
53 assert.deepEqual(merge(rows, [source]), rows, "a delayed source page cannot undo adoption");
54 }
55 });
56
57 test("mounted row keys isolate hosts even when protocol display keys are equal", () => {
58 const a = { ...canonicalSession("same-id"), key: "same-display-key", session: { hostId: "host-a", sessionId: "same-id" } };
59 const b = { ...a, session: { hostId: "host-b", sessionId: "same-id" } };
60 assert.notEqual(projectSessionRowKey(a), projectSessionRowKey(b));
61 });
62
63 test("opening one legacy source does not select a sibling with the same topic", () => {
64 const a = legacySession("a");
65 const b = legacySession("b");
66 assert.equal(topicIsActive(a, "project", root, topicId, a.sessionPath), true);
67 assert.equal(topicIsActive(b, "project", root, topicId, a.sessionPath), false);
68 });
69
70 test("full App chrome passes a canonical tab without sessionPath as an exact sidebar target", () => {
71 type Input = Parameters<typeof buildSidebarRegionProps>[0];
72 const props = buildSidebarRegionProps({
73 className: "", t: (key => key) as Input["t"], paletteShortcut: "",
74 shell: { sidebarCollapsed: false } as Input["shell"],
75 geometry: { sidebarResizeMinWidth: 264, sidebarRenderWidth: 264 } as Input["geometry"],
76 topics: {} as Input["topics"], commands: {} as Input["commands"],
77 projectTree: {
78 activeTab: { id: "tab-b", scope: "project", workspaceRoot: root, topicId,
79 session: { hostId: "local", sessionId: "b" }, sessionId: "b" } as Input["projectTree"]["activeTab"],
80 activeRemote: undefined,
81 imTopicSources: {}, refreshSignal: 0, searchExpanded: false, searchFocusSignal: 0,
82 showShortcutBadges: false, shortcutPlatform: undefined, onVisibleTopicsChange: () => {},
83 draftSummaries: [], onOpenDraft: undefined,
84 },
85 });
86 const active = props.projectTree;
87 assert.equal(active.activeSessionPath, "session-id:b");
88 assert.equal(topicIsActive(canonicalSession("b"), active.activeScope, active.activeWorkspaceRoot, active.activeTopicId, active.activeSessionPath), true);
89 assert.equal(topicIsActive(canonicalSession("a"), active.activeScope, active.activeWorkspaceRoot, active.activeTopicId, active.activeSessionPath), false);
90 });
91
92 test("legacy sources sharing a topic have distinct read records", () => {
93 const a = legacySession("a");
94 const b = legacySession("b");
95 const aKey = projectTreeReadActivityKey(a);
96 const bKey = projectTreeReadActivityKey(b);
97 assert.ok(aKey);
98 assert.ok(bKey);
99 assert.notEqual(aKey, bKey);
100 });
101
102 test("viewing legacy A cannot hide a new unread result in legacy B", () => {
103 const a = legacySession("a", 100);
104 const b = legacySession("b", 20);
105 const bKey = projectTreeReadActivityKey(b)!;
106 assert.equal(projectTreeTopicHasUnreadActivity(
107 b, { [bKey]: 10 }, "project", root, topicId, a.sessionPath,
108 ), true);
109 });
110
111 test("a session tombstone fences stale runtime after the archived row leaves the catalog", () => {
112 const a = canonicalSession("a");
113 const b = canonicalSession("b");
114 const projection = createProjectTreeRuntimeProjection();
115 const running = projection.apply(catalog(a, b), [runtime(b)]);
116 const tombstones = new Set([projectSessionIdentity(b)]);
117 const archived = projectTreeWithoutTopics(running, tombstones);
118 const delayed = projection.apply(archived, [runtime(b)], tombstones);
119 assert.deepEqual(delayed[0]?.children?.map(row => row.session?.sessionId), ["a"]);
120 assert.deepEqual(projection.apply(delayed, [], tombstones)[0]?.children?.map(row => row.session?.sessionId), ["a"]);
121 });
122
123 test("a session tombstone fences an incoming stale directory and runtime together", () => {
124 const a = canonicalSession("a");
125 const b = canonicalSession("b");
126 const tombstones = new Set([projectSessionIdentity(b)]);
127 const projected = createProjectTreeRuntimeProjection().apply(catalog(a, b), [runtime(b)], tombstones);
128 assert.deepEqual(projected[0]?.children?.map(row => row.session?.sessionId), ["a"]);
129 });
130
131 test("adoption merges a legacy catalog source with its canonical runtime without losing its row metadata", () => {
132 const source = legacySession("b");
133 const adopted = { ...canonicalSession("b"), identityAliases: [projectSessionIdentity(source)] };
134 const projected = createProjectTreeRuntimeProjection().apply(catalog(source), [runtime(adopted)]);
135 assert.equal(projected[0]?.children?.length, 1);
136 const row = projected[0]?.children?.[0];
137 assert.equal(row?.session?.sessionId, "b");
138 assert.equal(row?.label, source.label);
139 assert.equal(row?.sessionPath, source.sessionPath);
140 assert.equal(row?.status, "thinking");
141 });
142
143 test("a delayed ready catalog cannot lower an established read baseline and make old results unread", () => {
144 const b = canonicalSession("b", 20);
145 const key = projectTreeReadActivityKey(b)!;
146 const read = { [key]: 20 };
147 const delayed = projectTreeSeedReadActivity(catalog({ ...b, resultSequence: 10 }), read);
148 assert.equal(delayed[key], 20);
149 const current = projectTreeSeedReadActivity(catalog(b), delayed);
150 assert.equal(current[key], 20);
151 assert.equal(projectTreeTopicHasUnreadActivity(b, current), false);
152 });
153
154 test("runtime source reconciliation never aliases equal paths belonging to different hosts", () => {
155 const local = canonicalSession("same");
156 const remote = { ...canonicalSession("same"), session: { hostId: "remote", sessionId: "same" } };
157 const projected = createProjectTreeRuntimeProjection().apply(catalog(local), [runtime(remote)]);
158 assert.equal(projected[0]?.children?.length, 2);
159 const localRow = projected[0]?.children?.find(row => row.session?.hostId === "local");
160 assert.equal(localRow?.running, undefined);
161 });
162
162 lines TYPESCRIPT