| 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 |