返回 DeepSeek-Reasonix
independent-session-rename.test.tsx
根目录 / desktop / frontend / src / __tests__ / independent-session-rename.test.tsx
1 import assert from "node:assert/strict";
2 import { test } from "node:test";
3 import React, { act } from "react";
4 import { createRoot } from "react-dom/client";
5 import { JSDOM } from "jsdom";
6 import { desktopProjectAdapter } from "../app-runtime/desktopProjectAdapter";
7 import { useHistoryCommands } from "../app-runtime/useHistoryCommands";
8 import { useProjectTopicCommands } from "../app-runtime/useProjectTopicCommands";
9 import { renameProjectTopic, type ProjectTopicPorts } from "../app-runtime/projectTopicOwner";
10 import type { RemoteSessionView } from "../lib/remoteTypes";
11 import type { HistoryViewState } from "../app-runtime/historyViewProjection";
12 import type { SessionMeta } from "../lib/types";
13 import type { SessionSelector } from "../generated/desktopContract.generated";
14 import { installDesktopHostStub } from "./desktopHostStub";
15
16 const sharedTopic = "shared-topic";
17 function savedSession(id: string, canonical: boolean): SessionMeta {
18 return {
19 path: `/sessions/${id}.jsonl`, sessionId: canonical ? id : undefined,
20 hostId: "local", topicId: sharedTopic, title: `Original ${id}`, preview: id,
21 turns: 1, createdAt: 1, lastActivityAt: 1, modTime: 1, current: false, open: false,
22 scope: "project", workspaceRoot: "/repo",
23 };
24 }
25
26 function environment(sessions: SessionMeta[], gate?: Promise<void>) {
27 const dom = new JSDOM("<div id='root'></div>");
28 Object.assign(globalThis, { window: dom.window, document: dom.window.document, IS_REACT_ACT_ENVIRONMENT: true });
29 const root = createRoot(document.getElementById("root")!);
30 const requests: { selector: SessionSelector; title: string }[] = [];
31 let bulkCalls = 0;
32 const host = installDesktopHostStub({
33 RenameTopic: async (topicId: string, title: string) => {
34 bulkCalls++;
35 await gate;
36 for (const session of sessions) if (session.topicId === topicId) session.title = title;
37 },
38 RenameSessionTarget: async (selector: SessionSelector, title: string) => {
39 requests.push({ selector, title });
40 await gate;
41 const target = selector.ref
42 ? sessions.find(session => session.sessionId === selector.ref?.sessionId && session.hostId === selector.ref?.hostId)
43 : sessions.find(session => session.path === selector.sessionPath);
44 assert.ok(target, "rename must resolve the explicit saved session");
45 target.title = title;
46 return { applied: true, targetKey: target.path };
47 },
48 });
49 return { dom, root, requests, bulkCalls: () => bulkCalls, close: async () => {
50 await act(async () => root.unmount()); host.uninstall(); dom.window.close();
51 } };
52 }
53
54 for (const canonical of [true, false]) {
55 test(`history rename isolates B from same-topic A (${canonical ? "canonical" : "legacy source"})`, async () => {
56 const sessions = [savedSession("a", canonical), savedSession("b", canonical)];
57 const env = environment(sessions);
58 let commands!: ReturnType<typeof useHistoryCommands>;
59 let view: HistoryViewState | null = { kind: "history", source: "all", sessions };
60 function Probe() {
61 commands = useHistoryCommands({ running: false,
62 setHistView: update => { view = typeof update === "function" ? update(view) : update; },
63 ports: {
64 listSessions: async () => sessions,
65 deleteSession: async () => {},
66 renameSession: async () => { throw new Error("history must use the explicit target API"); },
67 openPage: () => {},
68 },
69 });
70 return null;
71 }
72 try {
73 await act(async () => env.root.render(<Probe />));
74 await act(async () => commands.onRenameHistorySession(sessions[1]!, "Only B"));
75 assert.equal(sessions[0]!.title, "Original a");
76 assert.equal(sessions[1]!.title, "Only B");
77 assert.equal(env.bulkCalls(), 0, "single-session history UI must never call the topic bulk API");
78 assert.equal(env.requests.length, 1);
79 if (canonical) assert.deepEqual(env.requests[0]!.selector.ref, { hostId: "local", sessionId: "b" });
80 else assert.equal(env.requests[0]!.selector.sessionPath, "/sessions/b.jsonl");
81 } finally { await env.close(); }
82 });
83 }
84
85 test("top title rename keeps B as its target when the active session changes to same-topic A", async () => {
86 let complete!: () => void;
87 const gate = new Promise<void>(resolve => { complete = resolve; });
88 const sessions = [savedSession("a", true), savedSession("b", true)];
89 const env = environment(sessions, gate);
90 let commands!: ReturnType<typeof useProjectTopicCommands>;
91 let activeSyncs = 0;
92 const ports = { ...desktopProjectAdapter,
93 markChanged: () => {}, refreshTabs: async () => [], syncActive: async () => { activeSyncs++; },
94 };
95 function Probe({ id }: { id: string }) {
96 const target = { kind: "local" as const, topicId: sharedTopic,
97 selector: { ref: { hostId: "local", sessionId: id } } };
98 commands = useProjectTopicCommands({
99 visible: { tabId: `tab-${id}`, sessionKey: id },
100 topic: { id: sharedTopic, title: `Original ${id}`, target }, ports,
101 navigation: { openBlank: async () => {}, enqueue: async () => {}, switchFolder: async () => {} },
102 reportError: error => { throw error; },
103 });
104 return null;
105 }
106 try {
107 await act(async () => env.root.render(<Probe id="b" />));
108 await act(async () => commands.startActiveTopicRename());
109 await act(async () => commands.setTopicTitleDraft("Only B"));
110 let pending!: Promise<void>;
111 await act(async () => { pending = commands.commitActiveTopicRename(); });
112 await act(async () => env.root.render(<Probe id="a" />));
113 await act(async () => { complete(); await pending; });
114 assert.equal(sessions[0]!.title, "Original a");
115 assert.equal(sessions[1]!.title, "Only B");
116 assert.equal(env.bulkCalls(), 0);
117 assert.deepEqual(env.requests.map(request => request.selector.ref), [{ hostId: "local", sessionId: "b" }]);
118 assert.equal(commands.topicbarEditing, false);
119 assert.equal(activeSyncs, 0, "B's completion cannot replace the newly selected A");
120 } finally { complete(); await env.close(); }
121 });
122
123 function remoteRenameFixture(sessions: RemoteSessionView[]) {
124 const writes: { host: string; workspace: string; name: string; title: string }[] = [];
125 const ports: ProjectTopicPorts = {
126 renameLocal: async () => { throw new Error("remote targets must never reach the local owner"); },
127 listRemote: async () => sessions,
128 renameRemote: async (host, workspace, name, title) => { writes.push({ host, workspace, name, title }); },
129 markChanged: () => {}, refreshTabs: async () => [], syncActive: async () => {},
130 };
131 const authority = { checkpoint() {}, ownsUI: () => true };
132 return { writes, rename: (sessionId: string) => renameProjectTopic({
133 ports, title: "Only B", target: {
134 kind: "remote", hostId: "host-a", workspace: "/repo", sessionPath: "/shared/history.jsonl", sessionId,
135 },
136 }, authority) };
137 }
138
139 test("remote top rename resolves sessionId before an equal path on another session", async () => {
140 const fixture = remoteRenameFixture([
141 { name: "a", sessionId: "a", path: "/shared/history.jsonl", title: "A", turns: 1 },
142 { name: "b", sessionId: "b", path: "/shared/history.jsonl", title: "B", turns: 1 },
143 ]);
144 await fixture.rename("b");
145 assert.deepEqual(fixture.writes, [{ host: "host-a", workspace: "/repo", name: "b", title: "Only B" }]);
146 });
147
148 test("remote top rename rejects a missing explicit sessionId instead of falling back to its old path", async () => {
149 const fixture = remoteRenameFixture([
150 { name: "a", sessionId: "a", path: "/shared/history.jsonl", title: "A", turns: 1 },
151 ]);
152 await assert.rejects(fixture.rename("b"));
153 assert.deepEqual(fixture.writes, []);
154 });
155
156 test("remote top rename rejects duplicate protocol names even when sessionId resolves one row", async () => {
157 const fixture = remoteRenameFixture([
158 { name: "duplicate", sessionId: "a", path: "/other/history.jsonl", title: "A", turns: 1 },
159 { name: "duplicate", sessionId: "b", path: "/shared/history.jsonl", title: "B", turns: 1 },
160 ]);
161 await assert.rejects(fixture.rename("b"));
162 assert.deepEqual(fixture.writes, [], "name-only protocol writes cannot safely distinguish these rows");
163 });
164
164 lines Plain Text