opencode-pilot 0.24.10 → 0.24.11

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.
@@ -1,8 +1,8 @@
1
1
  class OpencodePilot < Formula
2
2
  desc "Automation daemon for OpenCode - polls GitHub/Linear issues and spawns sessions"
3
3
  homepage "https://github.com/athal7/opencode-pilot"
4
- url "https://github.com/athal7/opencode-pilot/archive/refs/tags/v0.24.9.tar.gz"
5
- sha256 "b477a5677fb80456db3f4f6464dd2071d0efb56bdd29b8a8fc01e5d56638f588"
4
+ url "https://github.com/athal7/opencode-pilot/archive/refs/tags/v0.24.10.tar.gz"
5
+ sha256 "09c76da7756c11bfd192fb74c3b9d7e0335678258cfaebf8e7ef3473f7f74e55"
6
6
  license "MIT"
7
7
 
8
8
  depends_on "node"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-pilot",
3
- "version": "0.24.10",
3
+ "version": "0.24.11",
4
4
  "type": "module",
5
5
  "main": "plugin/index.js",
6
6
  "description": "Automation daemon for OpenCode - polls for work and spawns sessions",
@@ -3,6 +3,20 @@
3
3
  *
4
4
  * Starts OpenCode sessions with configurable prompts.
5
5
  * Supports prompt_template for custom prompts (e.g., to invoke /devcontainer).
6
+ *
7
+ * ## Session creation invariants
8
+ *
9
+ * Every session created by this module MUST satisfy all three invariants.
10
+ * See service/session-context.js for full documentation and rationale.
11
+ *
12
+ * A. Project scoping – session's projectID ≠ 'global' → visible in UI
13
+ * B. Working directory – agent operates in the worktree, not the main repo
14
+ * C. Session isolation – reuse never crosses PR/issue boundaries
15
+ *
16
+ * The SessionContext value object carries both directories together so no
17
+ * call site can accidentally pass the wrong one. When you change session
18
+ * creation logic, verify all three invariants in the integration tests under
19
+ * "session creation invariants".
6
20
  */
7
21
 
8
22
  import { execSync } from "child_process";
@@ -11,6 +25,7 @@ import { debug } from "./logger.js";
11
25
  import { getNestedValue } from "./utils.js";
12
26
  import { getServerPort } from "./repo-config.js";
13
27
  import { resolveWorktreeDirectory, getProjectInfo, getProjectInfoForDirectory } from "./worktree.js";
28
+ import { SessionContext } from "./session-context.js";
14
29
  import path from "path";
15
30
  import os from "os";
16
31
 
@@ -672,32 +687,37 @@ export async function findReusableSession(serverUrl, directory, options = {}) {
672
687
 
673
688
  /**
674
689
  * Create a session via the OpenCode HTTP API
675
- *
690
+ *
691
+ * Satisfies session creation invariants A and B (see module header):
692
+ * A. Creates the session scoped to sessionCtx.projectDirectory so OpenCode
693
+ * assigns the correct projectID (session visible in desktop UI).
694
+ * B. Sends the first message with sessionCtx.workingDirectory so the agent
695
+ * operates in the worktree, not the main repo.
696
+ *
676
697
  * @param {string} serverUrl - Server URL (e.g., "http://localhost:4096")
677
- * @param {string} directory - Working directory for file operations (may be a worktree)
698
+ * @param {SessionContext} sessionCtx - Carries both directories (see session-context.js)
678
699
  * @param {string} prompt - The prompt/message to send
679
700
  * @param {object} [options] - Options
680
- * @param {string} [options.projectDirectory] - Project directory for session scoping (defaults to directory)
681
701
  * @param {string} [options.title] - Session title
682
702
  * @param {string} [options.agent] - Agent to use
683
703
  * @param {string} [options.model] - Model to use
684
704
  * @param {function} [options.fetch] - Custom fetch function (for testing)
685
705
  * @returns {Promise<object>} Result with sessionId, success, error
686
706
  */
687
- export async function createSessionViaApi(serverUrl, directory, prompt, options = {}) {
707
+ export async function createSessionViaApi(serverUrl, sessionCtx, prompt, options = {}) {
688
708
  const fetchFn = options.fetch || fetch;
689
709
  const headerTimeout = options.headerTimeout || HEADER_TIMEOUT_MS;
690
- const projectDir = options.projectDirectory || directory;
710
+ // Invariant A: use projectDirectory so OpenCode assigns the correct projectID.
711
+ // Invariant B: use workingDirectory for messages so the agent operates in the worktree.
712
+ const projectDir = sessionCtx.projectDirectory;
713
+ const directory = sessionCtx.workingDirectory;
691
714
 
692
715
  let session = null;
693
716
 
694
717
  try {
695
- // Step 1: Create session with the working directory (may be a worktree).
696
- // This sets the session's working directory so the agent operates in the
697
- // correct location. For worktree sessions, the server may assign
698
- // projectID 'global' since sandbox paths don't match project worktrees.
718
+ // Step 1: Create session scoped to the project directory
699
719
  const sessionUrl = new URL('/session', serverUrl);
700
- sessionUrl.searchParams.set('directory', directory);
720
+ sessionUrl.searchParams.set('directory', projectDir);
701
721
 
702
722
  const createResponse = await fetchFn(sessionUrl.toString(), {
703
723
  method: 'POST',
@@ -713,20 +733,14 @@ export async function createSessionViaApi(serverUrl, directory, prompt, options
713
733
  session = await createResponse.json();
714
734
  debug(`createSessionViaApi: created session ${session.id} in ${directory}`);
715
735
 
716
- // Step 2: Update session - always PATCH with project directory when it
717
- // differs from the working directory (worktree case). This re-associates
718
- // the session with the correct project so it appears in the UI.
719
- // Also set the title if provided.
720
- const needsProjectScoping = projectDir !== directory;
721
- if (options.title || needsProjectScoping) {
736
+ // Step 2: Update session title if provided
737
+ if (options.title) {
722
738
  const updateUrl = new URL(`/session/${session.id}`, serverUrl);
723
739
  updateUrl.searchParams.set('directory', projectDir);
724
- const patchBody = {};
725
- if (options.title) patchBody.title = options.title;
726
740
  await fetchFn(updateUrl.toString(), {
727
741
  method: 'PATCH',
728
742
  headers: { 'Content-Type': 'application/json' },
729
- body: JSON.stringify(patchBody),
743
+ body: JSON.stringify({ title: options.title }),
730
744
  });
731
745
  }
732
746
 
@@ -839,17 +853,25 @@ export async function createSessionViaApi(serverUrl, directory, prompt, options
839
853
  }
840
854
 
841
855
  /**
842
- * Execute session creation/reuse in a specific directory
843
- * Internal helper for executeAction - handles prompt building, session reuse, and API calls
844
- *
856
+ * Execute session creation/reuse for the given session context.
857
+ * Internal helper for executeAction - handles prompt building, session reuse, and API calls.
858
+ *
859
+ * Enforces invariant C (session isolation): session reuse is skipped entirely
860
+ * when sessionCtx.isWorktree is true. Each PR/issue running in a worktree must
861
+ * get its own session because:
862
+ * - Querying by workingDirectory finds old sessions with projectID='global'
863
+ * - Querying by projectDirectory finds sessions for unrelated PRs in the same project
864
+ *
845
865
  * @param {string} serverUrl - OpenCode server URL
846
- * @param {string} cwd - Working directory for the session
866
+ * @param {SessionContext} sessionCtx - Carries both directories
847
867
  * @param {object} item - Item to create session for
848
868
  * @param {object} config - Repo config with action settings
849
869
  * @param {object} [options] - Execution options
850
870
  * @returns {Promise<object>} Result with command, success, sessionId, etc.
851
871
  */
852
- async function executeInDirectory(serverUrl, cwd, item, config, options = {}, projectDirectory = null) {
872
+ async function executeInDirectory(serverUrl, sessionCtx, item, config, options = {}) {
873
+ const cwd = sessionCtx.workingDirectory;
874
+
853
875
  // Build prompt from template
854
876
  const prompt = buildPromptFromTemplate(config.prompt || "default", item);
855
877
 
@@ -889,15 +911,12 @@ async function executeInDirectory(serverUrl, cwd, item, config, options = {}, pr
889
911
  }
890
912
  }
891
913
 
892
- // Check if we should try to reuse an existing session.
893
- // Skip reuse when working in a worktree (projectDirectory differs from cwd),
894
- // because querying by worktree dir finds old sessions with projectID "global",
895
- // and querying by project dir finds unrelated sessions for other PRs.
896
- // Each worktree should get its own correctly-scoped session.
914
+ // Invariant C: skip findReusableSession when in a worktree.
915
+ // Each worktree (= each PR/issue) must get its own session to prevent
916
+ // cross-PR contamination. See session-context.js for full rationale.
897
917
  const reuseActiveSession = config.reuse_active_session !== false; // default true
898
- const inWorktree = projectDirectory && projectDirectory !== cwd;
899
918
 
900
- if (reuseActiveSession && !inWorktree && !options.dryRun) {
919
+ if (reuseActiveSession && !sessionCtx.isWorktree && !options.dryRun) {
901
920
  const existingSession = await findReusableSession(serverUrl, cwd, { fetch: options.fetch });
902
921
 
903
922
  if (existingSession) {
@@ -934,8 +953,7 @@ async function executeInDirectory(serverUrl, cwd, item, config, options = {}, pr
934
953
  };
935
954
  }
936
955
 
937
- const result = await createSessionViaApi(serverUrl, cwd, prompt, {
938
- projectDirectory: projectDirectory || cwd,
956
+ const result = await createSessionViaApi(serverUrl, sessionCtx, prompt, {
939
957
  title: sessionTitle,
940
958
  agent: config.agent,
941
959
  model: config.model,
@@ -994,27 +1012,29 @@ export async function executeAction(item, config, options = {}) {
994
1012
  };
995
1013
  }
996
1014
 
997
- // If existing_directory is provided (reprocessing same item), use it directly
998
- // This preserves the worktree from the previous run even if its name doesn't match the template
1015
+ // If existing_directory is provided (reprocessing same item), use it directly.
1016
+ // This preserves the worktree from the previous run even if its name doesn't match the template.
1017
+ // Build a worktree SessionContext since existing_directory is a worktree path.
999
1018
  if (config.existing_directory) {
1000
1019
  debug(`executeAction: using existing_directory=${config.existing_directory}`);
1001
1020
  const cwd = expandPath(config.existing_directory);
1002
- return await executeInDirectory(serverUrl, cwd, item, config, options, baseCwd);
1021
+ const sessionCtx = SessionContext.forWorktree(baseCwd, cwd);
1022
+ return await executeInDirectory(serverUrl, sessionCtx, item, config, options);
1003
1023
  }
1004
1024
 
1005
- // Resolve worktree directory if configured
1006
- // This allows creating sessions in isolated worktrees instead of the main project
1025
+ // Resolve worktree directory if configured.
1026
+ // This allows creating sessions in isolated worktrees instead of the main project.
1007
1027
  let worktreeMode = config.worktree;
1008
1028
 
1009
- // If worktree_name is configured, enable worktree mode (explicit configuration)
1010
- // This allows presets to specify worktree isolation without requiring existing sandboxes
1029
+ // If worktree_name is configured, enable worktree mode (explicit configuration).
1030
+ // This allows presets to specify worktree isolation without requiring existing sandboxes.
1011
1031
  if (!worktreeMode && config.worktree_name) {
1012
1032
  debug(`executeAction: worktree_name configured, enabling worktree mode`);
1013
1033
  worktreeMode = 'new';
1014
1034
  }
1015
1035
 
1016
- // Auto-detect worktree support: check if the project has sandboxes
1017
- // This is a fallback for when worktree isn't explicitly configured
1036
+ // Auto-detect worktree support: check if the project has sandboxes.
1037
+ // This is a fallback for when worktree isn't explicitly configured.
1018
1038
  if (!worktreeMode) {
1019
1039
  // Look up project info for this specific directory (not just /project/current)
1020
1040
  const projectInfo = await getProjectInfoForDirectory(serverUrl, baseCwd, { fetch: options.fetch });
@@ -1051,5 +1071,8 @@ export async function executeAction(item, config, options = {}) {
1051
1071
 
1052
1072
  debug(`executeAction: using cwd=${cwd}`);
1053
1073
 
1054
- return await executeInDirectory(serverUrl, cwd, item, config, options, baseCwd);
1074
+ // Build a SessionContext so downstream functions always have both directories.
1075
+ // forWorktree correctly sets isWorktree=true when cwd differs from baseCwd.
1076
+ const sessionCtx = SessionContext.forWorktree(baseCwd, cwd);
1077
+ return await executeInDirectory(serverUrl, sessionCtx, item, config, options);
1055
1078
  }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * session-context.js - SessionContext value object
3
+ *
4
+ * Encapsulates the two-directory problem that repeatedly caused session
5
+ * creation regressions (v0.24.7 through v0.24.10).
6
+ *
7
+ * ## The Problem
8
+ *
9
+ * OpenCode's POST /session API accepts a single `directory` parameter that
10
+ * conflates two distinct concerns:
11
+ *
12
+ * 1. **Project scoping** (projectID) — which project the session belongs to
13
+ * in the desktop UI. Requires the *project directory* (the main git repo).
14
+ *
15
+ * 2. **Working directory** — where the agent executes file operations.
16
+ * Requires the *worktree directory* when using git worktrees.
17
+ *
18
+ * When a worktree is active, these directories differ. Passing the wrong one
19
+ * satisfies one requirement but silently breaks the other.
20
+ *
21
+ * ## The Three Invariants
22
+ *
23
+ * Any correct session creation MUST satisfy all three:
24
+ *
25
+ * A. **Project scoping**: Session's projectID matches the project (not
26
+ * 'global'). The session is visible in the desktop app.
27
+ * → Requires POST /session?directory=<projectDirectory>
28
+ *
29
+ * B. **Working directory**: Agent operates in the correct location.
30
+ * In worktree mode the session's working dir must be the worktree path,
31
+ * so file reads/writes go to the right branch.
32
+ * → Requires per-message directory=<workingDirectory>
33
+ * (or PATCH /session/:id to update the session working dir)
34
+ *
35
+ * C. **Session isolation**: Session reuse only finds sessions for the
36
+ * *same work item* (same PR/issue), not other PRs sharing the project.
37
+ * → Worktree sessions must NOT be reused across items; each PR gets
38
+ * its own session.
39
+ *
40
+ * ## The Solution
41
+ *
42
+ * - Create sessions with `projectDirectory` (satisfies A).
43
+ * - Send messages with `workingDirectory` (satisfies B).
44
+ * - Skip `findReusableSession` entirely when in a worktree (satisfies C),
45
+ * because neither the worktree path nor the project path can safely scope
46
+ * reuse to a single PR.
47
+ *
48
+ * ## Worktree Detection
49
+ *
50
+ * A session is "in a worktree" when `workingDirectory !== projectDirectory`.
51
+ * Non-worktree sessions have both set to the same value.
52
+ */
53
+
54
+ export class SessionContext {
55
+ /**
56
+ * @param {string} projectDirectory - Base git repo path. Used for:
57
+ * - POST /session?directory=... (sets projectID for UI visibility)
58
+ * - PATCH /session/:id?directory=... (if post-creation re-scoping needed)
59
+ * - listSessions query (for session reuse lookup in non-worktree mode)
60
+ *
61
+ * @param {string} workingDirectory - Directory where the agent does work. Used for:
62
+ * - POST /session/:id/message?directory=... (file operations)
63
+ * - POST /session/:id/command?directory=... (slash commands)
64
+ * Equals projectDirectory when not using worktrees.
65
+ */
66
+ constructor(projectDirectory, workingDirectory) {
67
+ if (!projectDirectory) throw new Error('SessionContext: projectDirectory is required');
68
+ if (!workingDirectory) throw new Error('SessionContext: workingDirectory is required');
69
+ this.projectDirectory = projectDirectory;
70
+ this.workingDirectory = workingDirectory;
71
+ Object.freeze(this);
72
+ }
73
+
74
+ /**
75
+ * True when the session runs in a worktree separate from the main repo.
76
+ * Worktree sessions must NOT participate in findReusableSession (invariant C):
77
+ * - Querying by workingDirectory finds old sessions scoped to 'global' (wrong projectID)
78
+ * - Querying by projectDirectory finds sessions for other PRs in the same project
79
+ */
80
+ get isWorktree() {
81
+ return this.projectDirectory !== this.workingDirectory;
82
+ }
83
+
84
+ /**
85
+ * Factory: use this when there is no worktree (single-directory case).
86
+ */
87
+ static forProject(directory) {
88
+ return new SessionContext(directory, directory);
89
+ }
90
+
91
+ /**
92
+ * Factory: use this when a worktree has been resolved.
93
+ */
94
+ static forWorktree(projectDirectory, worktreeDirectory) {
95
+ return new SessionContext(projectDirectory, worktreeDirectory);
96
+ }
97
+ }
@@ -0,0 +1,308 @@
1
+ /**
2
+ * Integration tests for pollOnce end-to-end orchestration
3
+ *
4
+ * Tests the full wiring: config → source → readiness → dedup → executeAction.
5
+ * Uses executeAction + createPoller directly (simulating what pollOnce does
6
+ * after fetching items) with a mock OpenCode server to capture API calls.
7
+ *
8
+ * These tests verify two properties NOT covered by the invariant tests:
9
+ *
10
+ * 1. Two PRs in the same project each get their own session (dedup does not
11
+ * collapse them — they have different IDs).
12
+ *
13
+ * 2. A PR already processed in a previous poll cycle is skipped on the next
14
+ * call (dedup state persists across pollOnce calls).
15
+ *
16
+ * The invariant tests (session-reuse.test.js "session creation invariants")
17
+ * cover WHICH directories are used. These tests cover WHETHER sessions are
18
+ * created at all.
19
+ */
20
+ import { describe, it, beforeEach, afterEach } from "node:test";
21
+ import assert from "node:assert";
22
+ import { createServer } from "node:http";
23
+ import { mkdtempSync, rmSync } from "node:fs";
24
+ import { tmpdir } from "node:os";
25
+ import { join } from "node:path";
26
+
27
+ import { executeAction } from "../../service/actions.js";
28
+ import { createPoller } from "../../service/poller.js";
29
+
30
+ // ─── Mock server ────────────────────────────────────────────────────────────
31
+
32
+ /**
33
+ * Create a mock OpenCode server for testing.
34
+ * Handlers are keyed by "METHOD /path" (e.g., "POST /session").
35
+ * A `default` handler catches anything without an exact match.
36
+ */
37
+ function createMockServer(handlers = {}) {
38
+ const server = createServer((req, res) => {
39
+ const url = new URL(req.url, `http://${req.headers.host}`);
40
+ const pathname = url.pathname;
41
+ const method = req.method;
42
+
43
+ let body = "";
44
+ req.on("data", (chunk) => (body += chunk));
45
+ req.on("end", () => {
46
+ const request = {
47
+ method,
48
+ path: pathname,
49
+ directory: url.searchParams.get("directory"),
50
+ query: Object.fromEntries(url.searchParams),
51
+ body: body ? JSON.parse(body) : null,
52
+ };
53
+
54
+ // Find matching handler — try exact match first, then wildcard
55
+ const exactKey = `${method} ${pathname}`;
56
+ let handler = handlers[exactKey];
57
+
58
+ if (!handler) {
59
+ for (const [key, h] of Object.entries(handlers)) {
60
+ if (key === "default") continue;
61
+ const regexStr = "^" + key.replace(/\*/g, "[^/]+") + "$";
62
+ if (new RegExp(regexStr).test(exactKey)) {
63
+ handler = h;
64
+ break;
65
+ }
66
+ }
67
+ }
68
+
69
+ if (!handler) handler = handlers.default;
70
+
71
+ if (handler) {
72
+ const result = handler(request);
73
+ res.writeHead(result.status || 200, { "Content-Type": "application/json" });
74
+ res.end(JSON.stringify(result.body));
75
+ } else {
76
+ res.writeHead(404, { "Content-Type": "application/json" });
77
+ res.end(JSON.stringify({ error: `No handler for ${exactKey}` }));
78
+ }
79
+ });
80
+ });
81
+
82
+ return new Promise((resolve) => {
83
+ server.listen(0, "127.0.0.1", () => {
84
+ const { port } = server.address();
85
+ resolve({
86
+ url: `http://127.0.0.1:${port}`,
87
+ server,
88
+ close: () => new Promise((r) => server.close(r)),
89
+ });
90
+ });
91
+ });
92
+ }
93
+
94
+ // ─── Fetch interceptor ───────────────────────────────────────────────────────
95
+
96
+ /**
97
+ * Build a fetch interceptor that:
98
+ * - Records which sessions were created and which directories were used
99
+ * - Short-circuits POST /session/:id/message and /command to avoid
100
+ * AbortController race with the mock server (see session-reuse.test.js
101
+ * for the full explanation of this race condition)
102
+ * - Forwards everything else to the real mock server
103
+ */
104
+ function makeFetchInterceptor(calls) {
105
+ return async (url, opts) => {
106
+ const u = new URL(url);
107
+ const method = opts?.method || "GET";
108
+
109
+ // Short-circuit message/command — return mock 200 directly
110
+ if (method === "POST" && /^\/session\/[^/]+\/(message|command)$/.test(u.pathname)) {
111
+ const sessionId = u.pathname.split("/")[2];
112
+ calls.messages = calls.messages || [];
113
+ calls.messages.push({
114
+ sessionId,
115
+ directory: u.searchParams.get("directory"),
116
+ });
117
+ return new Response(JSON.stringify({ success: true }), {
118
+ status: 200,
119
+ headers: { "Content-Type": "application/json" },
120
+ });
121
+ }
122
+
123
+ if (method === "POST" && u.pathname === "/session") {
124
+ calls.sessionsCreated = (calls.sessionsCreated || 0) + 1;
125
+ }
126
+
127
+ return fetch(url, opts);
128
+ };
129
+ }
130
+
131
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
132
+
133
+ let sessionCounter = 0;
134
+
135
+ /**
136
+ * Build a minimal mock server that handles the OpenCode API calls made by
137
+ * executeAction for a worktree-based PR source.
138
+ */
139
+ async function buildWorktreeMockServer(calls) {
140
+ return createMockServer({
141
+ "GET /project": () => ({
142
+ body: [{ id: "proj_1", worktree: "/proj", sandboxes: [], time: { created: 1 } }],
143
+ }),
144
+ "GET /experimental/worktree": () => ({ body: [] }),
145
+ "POST /experimental/worktree": (req) => ({
146
+ body: { name: req.body?.name, directory: `/worktree/${req.body?.name}` },
147
+ }),
148
+ "GET /session": () => ({ body: [] }),
149
+ "GET /session/status": () => ({ body: {} }),
150
+ "POST /session": () => {
151
+ const id = `ses_poll_${++sessionCounter}`;
152
+ calls.createdSessionIds = calls.createdSessionIds || [];
153
+ calls.createdSessionIds.push(id);
154
+ return { body: { id } };
155
+ },
156
+ default: (req) => {
157
+ // PATCH /session/:id — title update
158
+ if (req.method === "PATCH" && req.path.startsWith("/session/")) {
159
+ return { body: {} };
160
+ }
161
+ return { status: 404, body: { error: `Unhandled: ${req.method} ${req.path}` } };
162
+ },
163
+ });
164
+ }
165
+
166
+ // ─── Tests ───────────────────────────────────────────────────────────────────
167
+
168
+ describe("integration: pollOnce end-to-end", () => {
169
+ let mockServer;
170
+ let tmpDir;
171
+ let stateFile;
172
+
173
+ beforeEach(() => {
174
+ tmpDir = mkdtempSync(join(tmpdir(), "pilot-poll-test-"));
175
+ stateFile = join(tmpDir, "poll-state.json");
176
+ sessionCounter = 0;
177
+ });
178
+
179
+ afterEach(async () => {
180
+ if (mockServer) {
181
+ await mockServer.close();
182
+ mockServer = null;
183
+ }
184
+ try {
185
+ rmSync(tmpDir, { recursive: true, force: true });
186
+ } catch {
187
+ // ignore
188
+ }
189
+ });
190
+
191
+ it("two PRs in the same project each get their own session", async () => {
192
+ // Two PRs, same project, worktree_name configured.
193
+ // Both are ready (no readiness filter). Each must get a separate session.
194
+ // This exercises: source → readiness → executeAction × 2 → two sessions.
195
+
196
+ const calls = {};
197
+ mockServer = await buildWorktreeMockServer(calls);
198
+ const serverUrl = mockServer.url;
199
+
200
+ const items = [
201
+ { id: "pr-101", number: 101, title: "PR #101", state: "open" },
202
+ { id: "pr-102", number: 102, title: "PR #102", state: "open" },
203
+ ];
204
+
205
+ const poller = createPoller({ stateFile });
206
+ const fetchFn = makeFetchInterceptor(calls);
207
+ const results = [];
208
+
209
+ for (const item of items) {
210
+ if (poller.isProcessed(item.id)) continue;
211
+
212
+ const actionConfig = {
213
+ path: "/proj",
214
+ prompt: "review",
215
+ worktree_name: "pr-{number}",
216
+ };
217
+
218
+ const result = await executeAction(item, actionConfig, {
219
+ discoverServer: async () => serverUrl,
220
+ fetch: fetchFn,
221
+ });
222
+
223
+ results.push({ item, result });
224
+
225
+ if (result.success) {
226
+ poller.markProcessed(item.id, {
227
+ source: "test-prs",
228
+ sessionId: result.sessionId,
229
+ directory: result.directory,
230
+ });
231
+ }
232
+ }
233
+
234
+ // Both PRs should succeed
235
+ assert.strictEqual(results.length, 2, "Both PRs should be processed");
236
+ assert.ok(results[0].result.success, "PR #101 should succeed");
237
+ assert.ok(results[1].result.success, "PR #102 should succeed");
238
+
239
+ // Each PR must get its own session
240
+ assert.strictEqual(
241
+ calls.createdSessionIds?.length,
242
+ 2,
243
+ "Two separate sessions must be created — one per PR"
244
+ );
245
+ assert.notStrictEqual(
246
+ results[0].result.sessionId,
247
+ results[1].result.sessionId,
248
+ "Session IDs must differ between PRs"
249
+ );
250
+
251
+ // Both are now marked as processed
252
+ assert.ok(poller.isProcessed("pr-101"), "PR #101 should be marked processed");
253
+ assert.ok(poller.isProcessed("pr-102"), "PR #102 should be marked processed");
254
+ });
255
+
256
+ it("already-processed PR is skipped on second poll cycle", async () => {
257
+ // One PR, processed in cycle 1. On cycle 2 it must be skipped.
258
+ // This exercises the dedup path: poller.isProcessed() → continue.
259
+
260
+ const calls = {};
261
+ mockServer = await buildWorktreeMockServer(calls);
262
+ const serverUrl = mockServer.url;
263
+
264
+ const item = { id: "pr-200", number: 200, title: "PR #200", state: "open" };
265
+ const poller = createPoller({ stateFile });
266
+ const fetchFn = makeFetchInterceptor(calls);
267
+
268
+ const actionConfig = {
269
+ path: "/proj",
270
+ prompt: "review",
271
+ worktree_name: "pr-{number}",
272
+ };
273
+
274
+ // ── Cycle 1: process the PR ──────────────────────────────────────────
275
+ const result1 = await executeAction(item, actionConfig, {
276
+ discoverServer: async () => serverUrl,
277
+ fetch: fetchFn,
278
+ });
279
+
280
+ assert.ok(result1.success, "Cycle 1: PR should be processed successfully");
281
+ poller.markProcessed(item.id, {
282
+ source: "test-prs",
283
+ sessionId: result1.sessionId,
284
+ directory: result1.directory,
285
+ });
286
+
287
+ const sessionsAfterCycle1 = calls.createdSessionIds?.length || 0;
288
+ assert.strictEqual(sessionsAfterCycle1, 1, "Cycle 1: exactly one session created");
289
+
290
+ // ── Cycle 2: same PR appears again — must be skipped ─────────────────
291
+ let cycle2Executed = false;
292
+
293
+ if (!poller.isProcessed(item.id)) {
294
+ cycle2Executed = true;
295
+ await executeAction(item, actionConfig, {
296
+ discoverServer: async () => serverUrl,
297
+ fetch: fetchFn,
298
+ });
299
+ }
300
+
301
+ assert.strictEqual(cycle2Executed, false, "Cycle 2: PR must be skipped (already processed)");
302
+ assert.strictEqual(
303
+ calls.createdSessionIds?.length || 0,
304
+ 1,
305
+ "Cycle 2: no new session must be created"
306
+ );
307
+ });
308
+ });
@@ -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 project directory (for correct projectID scoping)
568
+ // The worktree path is used for messages/commands (file operations)
569
+ assert.strictEqual(sessionDirectory, "/proj", "Session should be scoped to project 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 project directory (for correct projectID scoping)
622
+ assert.strictEqual(sessionDirectory, "/proj", "Session should be scoped to project 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 scoped to the project directory
682
678
  assert.ok(sessionCreated, "Should create a new session");
683
- 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");
679
+ assert.strictEqual(sessionCreateDirectory, "/proj",
680
+ "New session should be scoped to project 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 === "GET" && u.pathname === "/session") {
1036
+ calls.sessionListQueried = true;
1037
+ }
1038
+ return fetch(url, opts);
1039
+ };
1040
+ }
1041
+
1042
+ it("invariant A+B: session scoped to project, message sent to worktree", async () => {
1043
+ // This is THE test that would have caught every regression in v0.24.7–v0.24.10.
1044
+ // It asserts both invariants simultaneously:
1045
+ // A. POST /session directory = project path (not worktree) → correct projectID
1046
+ // B. POST /session/:id/message directory = worktree path → correct working dir
1047
+
1048
+ const calls = {};
1049
+
1050
+ mockServer = await createMockServer({
1051
+ "GET /project": () => ({
1052
+ body: [{ id: "proj_1", worktree: "/proj", sandboxes: [], time: { created: 1 } }],
1053
+ }),
1054
+ "GET /experimental/worktree": () => ({ body: [] }),
1055
+ "POST /experimental/worktree": (req) => ({
1056
+ body: { name: req.body?.name, directory: `/worktree/${req.body?.name}` },
1057
+ }),
1058
+ "GET /session": () => ({ body: [] }),
1059
+ "GET /session/status": () => ({ body: {} }),
1060
+ "POST /session": () => ({ body: { id: "ses_inv" } }),
1061
+ "PATCH /session/ses_inv": () => ({ body: {} }),
1062
+ });
1063
+
1064
+ const result = await executeAction(
1065
+ { number: 99, title: "Invariant test PR" },
1066
+ { path: "/proj", prompt: "review", worktree_name: "pr-{number}" },
1067
+ { discoverServer: async () => mockServer.url, fetch: makeFetchInterceptor(calls) }
1068
+ );
1069
+
1070
+ assert.ok(result.success, `Action should succeed (warning: ${result.warning || "none"})`);
1071
+
1072
+ // Invariant A: session creation uses the project directory
1073
+ assert.strictEqual(calls.sessionCreateDirectory, "/proj",
1074
+ "INVARIANT A VIOLATED: POST /session must use projectDirectory for correct projectID");
1075
+
1076
+ // Invariant B: message uses the worktree directory
1077
+ assert.strictEqual(calls.messageDirectory, "/worktree/pr-99",
1078
+ "INVARIANT B VIOLATED: POST /message must use workingDirectory for correct file operations");
1079
+
1080
+ // Sanity: the two directories must be different in the worktree case
1081
+ assert.notStrictEqual(calls.sessionCreateDirectory, calls.messageDirectory,
1082
+ "In worktree mode, session creation dir and message dir must differ");
1083
+ });
1084
+
1085
+ it("invariant A+B hold when reprocessing with existing_directory", async () => {
1086
+ // When reprocessing an item (e.g., new feedback on a PR), the existing worktree
1087
+ // directory is passed. The same invariants must still hold.
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
+ // Invariant A: session creation uses the project directory, not the worktree
1115
+ assert.strictEqual(calls.sessionCreateDirectory, "/proj",
1116
+ "INVARIANT A VIOLATED: reprocessing must still use projectDirectory for POST /session");
1117
+
1118
+ // Invariant B: message uses the existing worktree directory
1119
+ assert.strictEqual(calls.messageDirectory, "/worktree/calm-wizard",
1120
+ "INVARIANT B VIOLATED: reprocessing must send messages to the existing worktree path");
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
+ });
@@ -7,6 +7,7 @@ import assert from 'node:assert';
7
7
  import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'fs';
8
8
  import { join } from 'path';
9
9
  import { tmpdir, homedir } from 'os';
10
+ import { SessionContext } from '../../service/session-context.js';
10
11
 
11
12
  describe('actions.js', () => {
12
13
  let tempDir;
@@ -981,9 +982,9 @@ Check for bugs and security issues.`;
981
982
  // Should NOT call worktree endpoints when existing_directory is provided
982
983
  assert.strictEqual(worktreeListCalled, false, 'Should NOT list worktrees');
983
984
  assert.strictEqual(worktreeCreateCalled, false, 'Should NOT create worktree');
984
- // Session creation uses the worktree directory (sets correct working directory)
985
- assert.strictEqual(sessionDirectory, '/data/worktree/calm-wizard',
986
- 'Session creation should use worktree directory for correct working dir');
985
+ // Session creation uses project directory (for correct projectID scoping)
986
+ assert.strictEqual(sessionDirectory, '/data/proj',
987
+ 'Session creation should use project directory, not worktree');
987
988
  // Result directory is the worktree (where file operations happen)
988
989
  assert.strictEqual(result.directory, '/data/worktree/calm-wizard',
989
990
  'Result should include worktree directory');
@@ -1026,7 +1027,7 @@ Check for bugs and security issues.`;
1026
1027
 
1027
1028
  const result = await createSessionViaApi(
1028
1029
  'http://localhost:4096',
1029
- '/path/to/project',
1030
+ SessionContext.forProject('/path/to/project'),
1030
1031
  'Fix the bug',
1031
1032
  { fetch: mockFetch }
1032
1033
  );
@@ -1042,12 +1043,11 @@ Check for bugs and security issues.`;
1042
1043
  assert.ok(messageUrl.includes('%2Fpath%2Fto%2Fproject'), 'Message URL should include encoded directory path');
1043
1044
  });
1044
1045
 
1045
- test('creates session with worktree directory, patches with project directory for scoping', async () => {
1046
+ test('uses projectDirectory for session creation, working directory for messages', async () => {
1046
1047
  const { createSessionViaApi } = await import('../../service/actions.js');
1047
1048
 
1048
1049
  const mockSessionId = 'ses_test_proj';
1049
1050
  let createUrl = null;
1050
- let patchUrl = null;
1051
1051
  let messageUrl = null;
1052
1052
 
1053
1053
  const mockFetch = async (url, opts) => {
@@ -1058,11 +1058,6 @@ Check for bugs and security issues.`;
1058
1058
  return { ok: true, json: async () => ({ id: mockSessionId }) };
1059
1059
  }
1060
1060
 
1061
- if (urlObj.pathname.includes(`/session/${mockSessionId}`) && opts?.method === 'PATCH') {
1062
- patchUrl = url;
1063
- return { ok: true, json: async () => ({}) };
1064
- }
1065
-
1066
1061
  if (urlObj.pathname.includes('/message') && opts?.method === 'POST') {
1067
1062
  messageUrl = url;
1068
1063
  return { ok: true, json: async () => ({ success: true }) };
@@ -1073,26 +1068,21 @@ Check for bugs and security issues.`;
1073
1068
 
1074
1069
  await createSessionViaApi(
1075
1070
  'http://localhost:4096',
1076
- '/home/user/.local/share/opencode/worktree/abc123/pr-415',
1071
+ SessionContext.forWorktree(
1072
+ '/home/user/code/odin',
1073
+ '/home/user/.local/share/opencode/worktree/abc123/pr-415'
1074
+ ),
1077
1075
  'Fix the bug',
1078
- {
1079
- fetch: mockFetch,
1080
- projectDirectory: '/home/user/code/odin',
1081
- }
1076
+ { fetch: mockFetch }
1082
1077
  );
1083
1078
 
1084
- // Session creation should use the worktree directory (sets working directory)
1085
- assert.ok(createUrl.includes('worktree'),
1086
- 'Session creation should use the worktree path');
1087
- assert.ok(createUrl.includes('pr-415'),
1088
- 'Session creation should use the worktree path');
1079
+ // Session creation should use the project directory (for correct projectID scoping)
1080
+ assert.ok(createUrl.includes('%2Fhome%2Fuser%2Fcode%2Fodin'),
1081
+ 'Session creation should use projectDirectory');
1082
+ assert.ok(!createUrl.includes('worktree'),
1083
+ 'Session creation should NOT use the worktree path');
1089
1084
 
1090
- // PATCH should use the project directory (re-associates with correct project)
1091
- assert.ok(patchUrl, 'PATCH should be called for project scoping');
1092
- assert.ok(patchUrl.includes('%2Fhome%2Fuser%2Fcode%2Fodin'),
1093
- 'PATCH should use projectDirectory for project scoping');
1094
-
1095
- // Message should use the working directory (worktree)
1085
+ // Message should use the working directory (for file operations in the worktree)
1096
1086
  assert.ok(messageUrl.includes('worktree'),
1097
1087
  'Message should use the worktree working directory');
1098
1088
  assert.ok(messageUrl.includes('pr-415'),
@@ -1110,7 +1100,7 @@ Check for bugs and security issues.`;
1110
1100
 
1111
1101
  const result = await createSessionViaApi(
1112
1102
  'http://localhost:4096',
1113
- '/path/to/project',
1103
+ SessionContext.forProject('/path/to/project'),
1114
1104
  'Fix the bug',
1115
1105
  { fetch: mockFetch }
1116
1106
  );
@@ -1152,7 +1142,7 @@ Check for bugs and security issues.`;
1152
1142
 
1153
1143
  await createSessionViaApi(
1154
1144
  'http://localhost:4096',
1155
- '/path/to/project',
1145
+ SessionContext.forProject('/path/to/project'),
1156
1146
  'Fix the bug',
1157
1147
  {
1158
1148
  fetch: mockFetch,
@@ -1197,7 +1187,7 @@ Check for bugs and security issues.`;
1197
1187
 
1198
1188
  const result = await createSessionViaApi(
1199
1189
  'http://localhost:4096',
1200
- '/path/to/project',
1190
+ SessionContext.forProject('/path/to/project'),
1201
1191
  'Fix the bug',
1202
1192
  { fetch: mockFetch }
1203
1193
  );
@@ -1256,7 +1246,7 @@ Check for bugs and security issues.`;
1256
1246
 
1257
1247
  const result = await createSessionViaApi(
1258
1248
  'http://localhost:4096',
1259
- '/path/to/project',
1249
+ SessionContext.forProject('/path/to/project'),
1260
1250
  '/review https://github.com/org/repo/pull/123',
1261
1251
  { fetch: mockFetch }
1262
1252
  );
@@ -1309,7 +1299,7 @@ Check for bugs and security issues.`;
1309
1299
 
1310
1300
  const result = await createSessionViaApi(
1311
1301
  'http://localhost:4096',
1312
- '/path/to/project',
1302
+ SessionContext.forProject('/path/to/project'),
1313
1303
  'Fix the bug in the login page',
1314
1304
  { fetch: mockFetch }
1315
1305
  );
@@ -1360,7 +1350,7 @@ Check for bugs and security issues.`;
1360
1350
 
1361
1351
  const result = await createSessionViaApi(
1362
1352
  'http://localhost:4096',
1363
- '/path/to/project',
1353
+ SessionContext.forProject('/path/to/project'),
1364
1354
  'Fix the bug',
1365
1355
  { fetch: mockFetch, title: 'Test Session', headerTimeout: 100 }
1366
1356
  );
@@ -1407,7 +1397,7 @@ Check for bugs and security issues.`;
1407
1397
 
1408
1398
  const result = await createSessionViaApi(
1409
1399
  'http://localhost:4096',
1410
- '/path/to/project',
1400
+ SessionContext.forProject('/path/to/project'),
1411
1401
  'Fix the bug',
1412
1402
  { fetch: mockFetch }
1413
1403
  );