opencode-pilot 0.24.10 → 0.24.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,274 @@
1
+ /**
2
+ * Integration tests against a REAL OpenCode server.
3
+ *
4
+ * These tests verify actual API behavior — not mocked assumptions.
5
+ * They require a running OpenCode instance (the desktop app) with access
6
+ * to this repo's project. Tests are skipped when no server is available.
7
+ *
8
+ * What these tests prove:
9
+ *
10
+ * 1. Creating a session with a sandbox directory sets `session.directory`
11
+ * to the sandbox path AND resolves the correct `projectID` (same as
12
+ * the parent repo). This disproves the assumption that sandbox
13
+ * directories produce `projectID = 'global'`.
14
+ *
15
+ * 2. PATCH /session/:id does NOT change `session.directory`. The
16
+ * `?directory` query param on PATCH is a routing parameter only.
17
+ *
18
+ * These facts mean createSessionViaApi only needs to POST with the
19
+ * working directory — no PATCH-based "re-scoping" is needed.
20
+ */
21
+ import { describe, it, before, after } from "node:test";
22
+ import assert from "node:assert";
23
+ import path from "node:path";
24
+
25
+ // ─── Server discovery ───────────────────────────────────────────────────────
26
+
27
+ const PROJECT_DIR = path.resolve(import.meta.dirname, "../..");
28
+ const SERVER_URL = "http://localhost:4096";
29
+ const SANDBOX_NAME = "test-real-server";
30
+
31
+ let serverAvailable = false;
32
+ let projectID = null;
33
+ let sandboxDir = null;
34
+ const createdSessionIds = [];
35
+
36
+ async function checkServer() {
37
+ try {
38
+ const encoded = encodeURIComponent(PROJECT_DIR);
39
+ const res = await fetch(`${SERVER_URL}/session?directory=${encoded}`);
40
+ if (!res.ok) return false;
41
+
42
+ // Also verify this project is known
43
+ const projRes = await fetch(`${SERVER_URL}/project`);
44
+ if (!projRes.ok) return false;
45
+ const projects = await projRes.json();
46
+ const match = projects.find((p) => p.worktree === PROJECT_DIR);
47
+ if (!match) return false;
48
+ projectID = match.id;
49
+ return true;
50
+ } catch {
51
+ return false;
52
+ }
53
+ }
54
+
55
+ async function createSandbox() {
56
+ const encoded = encodeURIComponent(PROJECT_DIR);
57
+ const res = await fetch(
58
+ `${SERVER_URL}/experimental/worktree?directory=${encoded}`,
59
+ {
60
+ method: "POST",
61
+ headers: { "Content-Type": "application/json" },
62
+ body: JSON.stringify({ name: SANDBOX_NAME }),
63
+ }
64
+ );
65
+ if (!res.ok) return null;
66
+ const wt = await res.json();
67
+ return wt.directory;
68
+ }
69
+
70
+ async function findOrCreateSandbox() {
71
+ const encoded = encodeURIComponent(PROJECT_DIR);
72
+ const res = await fetch(
73
+ `${SERVER_URL}/experimental/worktree?directory=${encoded}`
74
+ );
75
+ if (res.ok) {
76
+ const worktrees = await res.json();
77
+ const existing = worktrees.find((w) => w.endsWith(`/${SANDBOX_NAME}`));
78
+ if (existing) return existing;
79
+ }
80
+ return createSandbox();
81
+ }
82
+
83
+ async function archiveSession(id, directory) {
84
+ const encoded = encodeURIComponent(directory);
85
+ await fetch(`${SERVER_URL}/session/${id}?directory=${encoded}`, {
86
+ method: "PATCH",
87
+ headers: { "Content-Type": "application/json" },
88
+ body: JSON.stringify({ time: { archived: Date.now() } }),
89
+ }).catch(() => {});
90
+ }
91
+
92
+ // ─── Test suite ─────────────────────────────────────────────────────────────
93
+
94
+ describe("real server: session directory behavior", { skip: false }, () => {
95
+ before(async () => {
96
+ serverAvailable = await checkServer();
97
+ if (!serverAvailable) return;
98
+ sandboxDir = await findOrCreateSandbox();
99
+ });
100
+
101
+ after(async () => {
102
+ if (!serverAvailable) return;
103
+ // Archive test sessions so they don't clutter the UI
104
+ for (const { id, directory } of createdSessionIds) {
105
+ await archiveSession(id, directory);
106
+ }
107
+ });
108
+
109
+ it("skip: no OpenCode server running", { skip: !false }, function () {
110
+ // This is a sentinel — replaced dynamically in before()
111
+ });
112
+
113
+ it("POST /session with sandbox dir → correct directory AND projectID", async (t) => {
114
+ if (!serverAvailable) return t.skip("no OpenCode server");
115
+ if (!sandboxDir) return t.skip("could not create sandbox");
116
+
117
+ const encoded = encodeURIComponent(sandboxDir);
118
+ const res = await fetch(`${SERVER_URL}/session?directory=${encoded}`, {
119
+ method: "POST",
120
+ headers: { "Content-Type": "application/json" },
121
+ body: JSON.stringify({}),
122
+ });
123
+
124
+ assert.ok(res.ok, `POST /session should succeed (got ${res.status})`);
125
+ const session = await res.json();
126
+ createdSessionIds.push({ id: session.id, directory: sandboxDir });
127
+
128
+ // The session's directory must be the sandbox path — this is where the
129
+ // agent will operate. This was the bug: prior code created with the
130
+ // project dir, so the agent worked in the wrong directory.
131
+ assert.strictEqual(
132
+ session.directory,
133
+ sandboxDir,
134
+ "session.directory must be the sandbox path (where agent operates)"
135
+ );
136
+
137
+ // The projectID must match the parent repo's project — NOT 'global'.
138
+ // This disproves the assumption that led to 4 regression-fix cycles.
139
+ assert.strictEqual(
140
+ session.projectID,
141
+ projectID,
142
+ "session.projectID must match the parent repo (sandbox is a git worktree of the same repo)"
143
+ );
144
+ });
145
+
146
+ it("POST /session with project dir → project directory and same projectID", async (t) => {
147
+ if (!serverAvailable) return t.skip("no OpenCode server");
148
+
149
+ const encoded = encodeURIComponent(PROJECT_DIR);
150
+ const res = await fetch(`${SERVER_URL}/session?directory=${encoded}`, {
151
+ method: "POST",
152
+ headers: { "Content-Type": "application/json" },
153
+ body: JSON.stringify({}),
154
+ });
155
+
156
+ assert.ok(res.ok, `POST /session should succeed (got ${res.status})`);
157
+ const session = await res.json();
158
+ createdSessionIds.push({ id: session.id, directory: PROJECT_DIR });
159
+
160
+ assert.strictEqual(
161
+ session.directory,
162
+ PROJECT_DIR,
163
+ "session.directory must be the project path"
164
+ );
165
+
166
+ assert.strictEqual(
167
+ session.projectID,
168
+ projectID,
169
+ "session.projectID must be the same whether created from sandbox or project dir"
170
+ );
171
+ });
172
+
173
+ it("PATCH /session/:id does NOT change session.directory", async (t) => {
174
+ if (!serverAvailable) return t.skip("no OpenCode server");
175
+ if (!sandboxDir) return t.skip("could not create sandbox");
176
+
177
+ // Create session with sandbox dir
178
+ const encoded = encodeURIComponent(sandboxDir);
179
+ const createRes = await fetch(
180
+ `${SERVER_URL}/session?directory=${encoded}`,
181
+ {
182
+ method: "POST",
183
+ headers: { "Content-Type": "application/json" },
184
+ body: JSON.stringify({}),
185
+ }
186
+ );
187
+ const session = await createRes.json();
188
+ createdSessionIds.push({ id: session.id, directory: sandboxDir });
189
+
190
+ // PATCH with project dir (this is what the "re-scoping" code tried to do)
191
+ const projectEncoded = encodeURIComponent(PROJECT_DIR);
192
+ const patchRes = await fetch(
193
+ `${SERVER_URL}/session/${session.id}?directory=${projectEncoded}`,
194
+ {
195
+ method: "PATCH",
196
+ headers: { "Content-Type": "application/json" },
197
+ body: JSON.stringify({ title: "patched-test" }),
198
+ }
199
+ );
200
+ assert.ok(patchRes.ok, `PATCH should succeed (got ${patchRes.status})`);
201
+ const patched = await patchRes.json();
202
+
203
+ // The directory must NOT have changed — PATCH only updates title/archived
204
+ assert.strictEqual(
205
+ patched.directory,
206
+ sandboxDir,
207
+ "PATCH must NOT change session.directory (it only updates title/archived)"
208
+ );
209
+
210
+ // Title should have been updated
211
+ assert.strictEqual(patched.title, "patched-test", "title should be updated");
212
+
213
+ // Read it back to be sure
214
+ const readRes = await fetch(
215
+ `${SERVER_URL}/session?directory=${encoded}`
216
+ );
217
+ const sessions = await readRes.json();
218
+ const readBack = sessions.find((s) => s.id === session.id);
219
+ assert.ok(readBack, "session should be readable from sandbox dir");
220
+ assert.strictEqual(
221
+ readBack.directory,
222
+ sandboxDir,
223
+ "read-back confirms directory unchanged after PATCH"
224
+ );
225
+ });
226
+
227
+ it("GET /session?directory filters by exact session.directory match", async (t) => {
228
+ if (!serverAvailable) return t.skip("no OpenCode server");
229
+ if (!sandboxDir) return t.skip("could not create sandbox");
230
+
231
+ // Create session with sandbox dir
232
+ const encoded = encodeURIComponent(sandboxDir);
233
+ const createRes = await fetch(
234
+ `${SERVER_URL}/session?directory=${encoded}`,
235
+ {
236
+ method: "POST",
237
+ headers: { "Content-Type": "application/json" },
238
+ body: JSON.stringify({}),
239
+ }
240
+ );
241
+ const session = await createRes.json();
242
+ createdSessionIds.push({ id: session.id, directory: sandboxDir });
243
+
244
+ // Query with sandbox dir — should find it (exact match on session.directory)
245
+ const fromSandbox = await fetch(
246
+ `${SERVER_URL}/session?directory=${encoded}`
247
+ );
248
+ const sandboxSessions = await fromSandbox.json();
249
+ const foundFromSandbox = sandboxSessions.find((s) => s.id === session.id);
250
+ assert.ok(
251
+ foundFromSandbox,
252
+ "session should be found when querying with sandbox dir (exact match)"
253
+ );
254
+
255
+ // Query with project dir — should NOT find it because session.directory
256
+ // is the sandbox path, not the project path. The ?directory param on
257
+ // GET /session is both a project-routing param (middleware) AND an exact
258
+ // filter on session.directory (route handler). Since session.directory
259
+ // is the sandbox path, it won't match the project path filter.
260
+ // This is actually correct behavior: it means session reuse via
261
+ // findReusableSession naturally isolates sandbox sessions from project
262
+ // sessions — each sandbox only sees its own sessions.
263
+ const projectEncoded = encodeURIComponent(PROJECT_DIR);
264
+ const fromProject = await fetch(
265
+ `${SERVER_URL}/session?directory=${projectEncoded}`
266
+ );
267
+ const projectSessions = await fromProject.json();
268
+ const foundFromProject = projectSessions.find((s) => s.id === session.id);
269
+ assert.ok(
270
+ !foundFromProject,
271
+ "session should NOT appear in project dir listing (directory filter is an exact match on session.directory)"
272
+ );
273
+ });
274
+ });
@@ -564,9 +564,9 @@ describe("integration: worktree creation with worktree_name", () => {
564
564
  assert.ok(worktreeCreateCalled, "Should create worktree when worktree_name is configured");
565
565
  assert.strictEqual(createdWorktreeName, "pr-42", "Should expand worktree_name template");
566
566
  assert.ok(sessionCreated, "Should create session");
567
- // Session creation uses the worktree directory (sets working directory)
568
- // The PATCH with project directory re-associates it with the correct project
569
- assert.strictEqual(sessionDirectory, "/worktree/pr-42", "Session should be created in worktree directory");
567
+ // Session creation uses the worktree directory (sets actual working dir)
568
+ // PATCH with project dir handles UI scoping (best-effort)
569
+ assert.strictEqual(sessionDirectory, "/worktree/pr-42", "Session should be created with worktree as working directory");
570
570
  });
571
571
 
572
572
  it("reuses stored directory when reprocessing same item", async () => {
@@ -618,8 +618,8 @@ describe("integration: worktree creation with worktree_name", () => {
618
618
  assert.ok(result.success, "Action should succeed");
619
619
  // Should NOT create a new worktree since we have existing_directory
620
620
  assert.strictEqual(worktreeCreateCalled, false, "Should NOT create new worktree when existing_directory provided");
621
- // Session creation uses the existing worktree directory (sets working directory)
622
- assert.strictEqual(sessionDirectory, existingWorktreeDir, "Session should be created in existing worktree directory");
621
+ // Session creation uses the worktree directory (sets actual working dir)
622
+ assert.strictEqual(sessionDirectory, existingWorktreeDir, "Session should be created with worktree as working directory");
623
623
  });
624
624
 
625
625
  it("skips session reuse when working in a worktree", async () => {
@@ -631,7 +631,6 @@ describe("integration: worktree creation with worktree_name", () => {
631
631
  let sessionListQueried = false;
632
632
  let sessionCreated = false;
633
633
  let sessionCreateDirectory = null;
634
- let patchDirectory = null;
635
634
 
636
635
  const existingWorktreeDir = "/worktree/calm-wizard";
637
636
 
@@ -655,10 +654,7 @@ describe("integration: worktree creation with worktree_name", () => {
655
654
  sessionCreateDirectory = req.query?.directory;
656
655
  return { body: { id: "ses_new" } };
657
656
  },
658
- "PATCH /session/ses_new": (req) => {
659
- patchDirectory = req.query?.directory;
660
- return { body: {} };
661
- },
657
+ "PATCH /session/ses_new": () => ({ body: {} }),
662
658
  "POST /session/ses_new/message": () => ({ body: { success: true } }),
663
659
  "POST /session/ses_new/command": () => ({ body: { success: true } }),
664
660
  });
@@ -678,13 +674,10 @@ describe("integration: worktree creation with worktree_name", () => {
678
674
  // Should NOT query for existing sessions when in a worktree
679
675
  assert.strictEqual(sessionListQueried, false,
680
676
  "Should skip session reuse entirely when in a worktree");
681
- // Should create a new session with the worktree directory (correct working dir)
677
+ // Should create a new session with the worktree as working directory
682
678
  assert.ok(sessionCreated, "Should create a new session");
683
679
  assert.strictEqual(sessionCreateDirectory, existingWorktreeDir,
684
- "Session should be created in worktree directory");
685
- // PATCH re-associates the session with the correct project
686
- assert.strictEqual(patchDirectory, "/proj",
687
- "PATCH should use project directory for correct project scoping");
680
+ "New session should be created with worktree as working directory");
688
681
  });
689
682
  });
690
683
 
@@ -974,3 +967,275 @@ describe("integration: stacked PR session reuse", () => {
974
967
  }
975
968
  });
976
969
  });
970
+
971
+ /**
972
+ * Session creation invariants
973
+ *
974
+ * These tests encode the three correctness requirements for every session
975
+ * created by pilot. They assert OUTCOMES (which directory was used for which
976
+ * API call), not implementation details (which function was called or how
977
+ * parameters were threaded).
978
+ *
979
+ * DO NOT CHANGE these tests when refactoring session creation internals.
980
+ * They should only change if the desired behavior changes.
981
+ *
982
+ * See service/session-context.js for the full invariant documentation.
983
+ *
984
+ * A. Project scoping – POST /session uses projectDirectory
985
+ * B. Working directory – messages use workingDirectory (the worktree path)
986
+ * C. Session isolation – worktree sessions are never reused across PRs
987
+ *
988
+ * Implementation note: these tests use a fetch interceptor (options.fetch)
989
+ * to capture which URL parameters were used for each API call. This is more
990
+ * reliable than reading the directory inside the mock server's request
991
+ * handler, because the AbortController in createSessionViaApi aborts the
992
+ * connection after receiving response headers — which can race with the mock
993
+ * server's body-buffering. The interceptor captures the URL synchronously
994
+ * before the request is sent, avoiding the race entirely.
995
+ */
996
+ describe("session creation invariants", () => {
997
+ let mockServer;
998
+
999
+ afterEach(async () => {
1000
+ if (mockServer) {
1001
+ await mockServer.close();
1002
+ mockServer = null;
1003
+ }
1004
+ });
1005
+
1006
+ /**
1007
+ * Build a fetch interceptor that records which directory was used for
1008
+ * POST /session (session creation) and POST /session/:id/message or
1009
+ * POST /session/:id/command (message delivery), then forwards to the
1010
+ * real mock server.
1011
+ */
1012
+ function makeFetchInterceptor(calls) {
1013
+ return async (url, opts) => {
1014
+ const u = new URL(url);
1015
+ const method = opts?.method || "GET";
1016
+
1017
+ // Short-circuit message/command: return mock 200 directly.
1018
+ // The AbortController in sendMessageToSession/createSessionViaApi aborts
1019
+ // the connection after response headers arrive, which races with the mock
1020
+ // server's body-buffering (req.on("end") never fires). Returning a
1021
+ // synthetic Response here avoids the race entirely.
1022
+ if (method === "POST" && /^\/session\/[^/]+\/(message|command)$/.test(u.pathname)) {
1023
+ calls.messageDirectory = u.searchParams.get("directory");
1024
+ calls.messageSessionId = u.pathname.split("/")[2];
1025
+ return new Response(JSON.stringify({ success: true }), {
1026
+ status: 200,
1027
+ headers: { "Content-Type": "application/json" },
1028
+ });
1029
+ }
1030
+
1031
+ if (method === "POST" && u.pathname === "/session") {
1032
+ calls.sessionCreateDirectory = u.searchParams.get("directory");
1033
+ calls.sessionCreated = true;
1034
+ }
1035
+ if (method === "PATCH" && /^\/session\/[^/]+$/.test(u.pathname)) {
1036
+ calls.patchDirectory = u.searchParams.get("directory");
1037
+ calls.patchSessionId = u.pathname.split("/")[2];
1038
+ }
1039
+ if (method === "GET" && u.pathname === "/session") {
1040
+ calls.sessionListQueried = true;
1041
+ }
1042
+ return fetch(url, opts);
1043
+ };
1044
+ }
1045
+
1046
+ it("session created with worktree directory, message sent to worktree", async () => {
1047
+ // POST /session must use the worktree directory — this sets session.directory
1048
+ // and determines where the agent operates. OpenCode derives the correct
1049
+ // projectID from the git root (sandbox worktrees share the same root commit).
1050
+ // Verified against a real server in test/integration/real-server.test.js.
1051
+
1052
+ const calls = {};
1053
+
1054
+ mockServer = await createMockServer({
1055
+ "GET /project": () => ({
1056
+ body: [{ id: "proj_1", worktree: "/proj", sandboxes: [], time: { created: 1 } }],
1057
+ }),
1058
+ "GET /experimental/worktree": () => ({ body: [] }),
1059
+ "POST /experimental/worktree": (req) => ({
1060
+ body: { name: req.body?.name, directory: `/worktree/${req.body?.name}` },
1061
+ }),
1062
+ "GET /session": () => ({ body: [] }),
1063
+ "GET /session/status": () => ({ body: {} }),
1064
+ "POST /session": () => ({ body: { id: "ses_inv" } }),
1065
+ "PATCH /session/ses_inv": () => ({ body: {} }),
1066
+ });
1067
+
1068
+ const result = await executeAction(
1069
+ { number: 99, title: "Invariant test PR" },
1070
+ { path: "/proj", prompt: "review", worktree_name: "pr-{number}" },
1071
+ { discoverServer: async () => mockServer.url, fetch: makeFetchInterceptor(calls) }
1072
+ );
1073
+
1074
+ assert.ok(result.success, `Action should succeed (warning: ${result.warning || "none"})`);
1075
+
1076
+ // Session creation uses the worktree directory
1077
+ assert.strictEqual(calls.sessionCreateDirectory, "/worktree/pr-99",
1078
+ "POST /session must use workingDirectory so agent operates in worktree");
1079
+
1080
+ // Message also uses the worktree directory
1081
+ assert.strictEqual(calls.messageDirectory, "/worktree/pr-99",
1082
+ "POST /message must use workingDirectory for correct file operations");
1083
+ });
1084
+
1085
+ it("reprocessing uses existing worktree directory for session and message", async () => {
1086
+ // When reprocessing an item (e.g., new feedback on a PR), the existing worktree
1087
+ // directory is passed. Session creation and messages must both use it.
1088
+
1089
+ const calls = {};
1090
+
1091
+ mockServer = await createMockServer({
1092
+ "GET /project": () => ({
1093
+ body: [{ id: "proj_1", worktree: "/proj", sandboxes: [], time: { created: 1 } }],
1094
+ }),
1095
+ "GET /session": () => ({ body: [] }),
1096
+ "GET /session/status": () => ({ body: {} }),
1097
+ "POST /session": () => ({ body: { id: "ses_reprocess_inv" } }),
1098
+ "PATCH /session/ses_reprocess_inv": () => ({ body: {} }),
1099
+ });
1100
+
1101
+ const result = await executeAction(
1102
+ { number: 42, title: "Reprocessed PR" },
1103
+ {
1104
+ path: "/proj",
1105
+ prompt: "review",
1106
+ worktree_name: "pr-{number}",
1107
+ existing_directory: "/worktree/calm-wizard",
1108
+ },
1109
+ { discoverServer: async () => mockServer.url, fetch: makeFetchInterceptor(calls) }
1110
+ );
1111
+
1112
+ assert.ok(result.success, `Action should succeed (warning: ${result.warning || "none"})`);
1113
+
1114
+ // Session creation uses the existing worktree directory
1115
+ assert.strictEqual(calls.sessionCreateDirectory, "/worktree/calm-wizard",
1116
+ "POST /session must use workingDirectory for correct session directory");
1117
+
1118
+ // Message uses the existing worktree directory
1119
+ assert.strictEqual(calls.messageDirectory, "/worktree/calm-wizard",
1120
+ "POST /message must use workingDirectory for correct file operations");
1121
+ });
1122
+
1123
+ it("invariant C: second PR in same project gets its own session", async () => {
1124
+ // Two PRs in the same project (/proj). PR #1 already has an active session.
1125
+ // PR #2 works in a different worktree. Session reuse must NOT return PR #1's
1126
+ // session — each PR must get its own session.
1127
+
1128
+ const calls = { sessionListQueried: false, sessionCreated: false };
1129
+
1130
+ mockServer = await createMockServer({
1131
+ "GET /project": () => ({
1132
+ body: [{ id: "proj_1", worktree: "/proj", sandboxes: [], time: { created: 1 } }],
1133
+ }),
1134
+ "GET /experimental/worktree": () => ({ body: ["/worktree/pr-200"] }),
1135
+ "GET /session": () => ({
1136
+ // Return PR #1's session — this should NOT be reused for PR #2
1137
+ body: [{ id: "ses_pr1", time: { created: 1, updated: 2 } }],
1138
+ }),
1139
+ "GET /session/status": () => ({ body: { ses_pr1: { type: "idle" } } }),
1140
+ "POST /session": () => ({ body: { id: "ses_pr2" } }),
1141
+ "PATCH /session/ses_pr2": () => ({ body: {} }),
1142
+ });
1143
+
1144
+ const result = await executeAction(
1145
+ { number: 200, title: "PR #200" },
1146
+ {
1147
+ path: "/proj",
1148
+ prompt: "review",
1149
+ worktree_name: "pr-{number}",
1150
+ existing_directory: "/worktree/pr-200",
1151
+ },
1152
+ { discoverServer: async () => mockServer.url, fetch: makeFetchInterceptor(calls) }
1153
+ );
1154
+
1155
+ assert.ok(result.success, `Action should succeed (warning: ${result.warning || "none"})`);
1156
+
1157
+ // Invariant C: session reuse is skipped entirely when in a worktree
1158
+ assert.strictEqual(calls.sessionListQueried, false,
1159
+ "INVARIANT C VIOLATED: worktree sessions must not query for reusable sessions");
1160
+
1161
+ // A new session must be created for this PR
1162
+ assert.ok(calls.sessionCreated,
1163
+ "INVARIANT C VIOLATED: each worktree PR must get its own new session");
1164
+
1165
+ // The returned session must be the new one, not PR #1's
1166
+ assert.strictEqual(result.sessionId, "ses_pr2",
1167
+ "INVARIANT C VIOLATED: result must reference the newly created session, not PR #1's");
1168
+ });
1169
+
1170
+ it("invariant C exception: non-worktree sessions ARE reused", async () => {
1171
+ // Session reuse should still work for items NOT running in a worktree.
1172
+ // A non-worktree item (path == cwd) should find and reuse an existing session.
1173
+
1174
+ const calls = { sessionListQueried: false, sessionCreated: false };
1175
+
1176
+ mockServer = await createMockServer({
1177
+ "GET /project": () => ({
1178
+ body: [{ id: "proj_1", worktree: "/proj", sandboxes: [], time: { created: 1 } }],
1179
+ }),
1180
+ "GET /session": () => ({
1181
+ body: [{ id: "ses_existing", time: { created: 1, updated: 2 } }],
1182
+ }),
1183
+ "GET /session/status": () => ({ body: { ses_existing: { type: "idle" } } }),
1184
+ "POST /session": () => ({ body: { id: "ses_should_not_create" } }),
1185
+ "PATCH /session/ses_existing": () => ({ body: {} }),
1186
+ });
1187
+
1188
+ const result = await executeAction(
1189
+ { number: 1, title: "Non-worktree item" },
1190
+ {
1191
+ path: "/proj",
1192
+ prompt: "review",
1193
+ // No worktree_name, no existing_directory → non-worktree mode
1194
+ },
1195
+ { discoverServer: async () => mockServer.url, fetch: makeFetchInterceptor(calls) }
1196
+ );
1197
+
1198
+ assert.ok(result.success, `Action should succeed (warning: ${result.warning || "none"})`);
1199
+
1200
+ // Non-worktree: session reuse SHOULD work
1201
+ assert.ok(calls.sessionListQueried,
1202
+ "Non-worktree items should query for reusable sessions");
1203
+ assert.strictEqual(calls.sessionCreated, false,
1204
+ "Should reuse existing session, not create a new one");
1205
+ assert.strictEqual(result.sessionId, "ses_existing",
1206
+ "Should return the reused session ID");
1207
+ assert.ok(result.sessionReused,
1208
+ "Should flag the session as reused");
1209
+ });
1210
+
1211
+ it("invariants A+B hold for non-worktree sessions (degenerate case)", async () => {
1212
+ // When there is no worktree, both directories are the same.
1213
+ // POST /session and POST /message should both use the project directory.
1214
+
1215
+ const calls = {};
1216
+
1217
+ mockServer = await createMockServer({
1218
+ "GET /project": () => ({
1219
+ body: [{ id: "proj_1", worktree: "/proj", sandboxes: [], time: { created: 1 } }],
1220
+ }),
1221
+ "GET /session": () => ({ body: [] }),
1222
+ "GET /session/status": () => ({ body: {} }),
1223
+ "POST /session": () => ({ body: { id: "ses_nwt" } }),
1224
+ "PATCH /session/ses_nwt": () => ({ body: {} }),
1225
+ });
1226
+
1227
+ const result = await executeAction(
1228
+ { number: 1, title: "No worktree" },
1229
+ { path: "/proj", prompt: "review" },
1230
+ { discoverServer: async () => mockServer.url, fetch: makeFetchInterceptor(calls) }
1231
+ );
1232
+
1233
+ assert.ok(result.success, `Action should succeed (warning: ${result.warning || "none"})`);
1234
+
1235
+ // Both should be the project directory when no worktree is involved
1236
+ assert.strictEqual(calls.sessionCreateDirectory, "/proj",
1237
+ "Non-worktree: session creation should use project directory");
1238
+ assert.strictEqual(calls.messageDirectory, "/proj",
1239
+ "Non-worktree: message should use project directory");
1240
+ });
1241
+ });