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.
- package/Formula/opencode-pilot.rb +2 -2
- package/package.json +1 -1
- package/service/actions.js +76 -43
- package/service/session-context.js +83 -0
- package/test/integration/poll-once.test.js +308 -0
- package/test/integration/real-server.test.js +274 -0
- package/test/integration/session-reuse.test.js +280 -15
- package/test/unit/actions.test.js +27 -27
|
@@ -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
|
|
568
|
-
//
|
|
569
|
-
assert.strictEqual(sessionDirectory, "/worktree/pr-42", "Session should be created
|
|
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
|
|
622
|
-
assert.strictEqual(sessionDirectory, existingWorktreeDir, "Session should be created
|
|
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": (
|
|
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
|
|
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
|
-
"
|
|
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
|
+
});
|