opencode-pilot 0.24.9 → 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.
|
|
5
|
-
sha256 "
|
|
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
package/service/actions.js
CHANGED
|
@@ -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,24 +687,30 @@ 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 {
|
|
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,
|
|
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
|
-
//
|
|
691
|
-
//
|
|
692
|
-
const projectDir =
|
|
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;
|
|
693
714
|
|
|
694
715
|
let session = null;
|
|
695
716
|
|
|
@@ -832,17 +853,25 @@ export async function createSessionViaApi(serverUrl, directory, prompt, options
|
|
|
832
853
|
}
|
|
833
854
|
|
|
834
855
|
/**
|
|
835
|
-
* Execute session creation/reuse
|
|
836
|
-
* Internal helper for executeAction - handles prompt building, session reuse, and API calls
|
|
837
|
-
*
|
|
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
|
+
*
|
|
838
865
|
* @param {string} serverUrl - OpenCode server URL
|
|
839
|
-
* @param {
|
|
866
|
+
* @param {SessionContext} sessionCtx - Carries both directories
|
|
840
867
|
* @param {object} item - Item to create session for
|
|
841
868
|
* @param {object} config - Repo config with action settings
|
|
842
869
|
* @param {object} [options] - Execution options
|
|
843
870
|
* @returns {Promise<object>} Result with command, success, sessionId, etc.
|
|
844
871
|
*/
|
|
845
|
-
async function executeInDirectory(serverUrl,
|
|
872
|
+
async function executeInDirectory(serverUrl, sessionCtx, item, config, options = {}) {
|
|
873
|
+
const cwd = sessionCtx.workingDirectory;
|
|
874
|
+
|
|
846
875
|
// Build prompt from template
|
|
847
876
|
const prompt = buildPromptFromTemplate(config.prompt || "default", item);
|
|
848
877
|
|
|
@@ -882,15 +911,12 @@ async function executeInDirectory(serverUrl, cwd, item, config, options = {}, pr
|
|
|
882
911
|
}
|
|
883
912
|
}
|
|
884
913
|
|
|
885
|
-
//
|
|
886
|
-
//
|
|
887
|
-
//
|
|
888
|
-
// and querying by project dir finds unrelated sessions for other PRs.
|
|
889
|
-
// 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.
|
|
890
917
|
const reuseActiveSession = config.reuse_active_session !== false; // default true
|
|
891
|
-
const inWorktree = projectDirectory && projectDirectory !== cwd;
|
|
892
918
|
|
|
893
|
-
if (reuseActiveSession && !
|
|
919
|
+
if (reuseActiveSession && !sessionCtx.isWorktree && !options.dryRun) {
|
|
894
920
|
const existingSession = await findReusableSession(serverUrl, cwd, { fetch: options.fetch });
|
|
895
921
|
|
|
896
922
|
if (existingSession) {
|
|
@@ -927,8 +953,7 @@ async function executeInDirectory(serverUrl, cwd, item, config, options = {}, pr
|
|
|
927
953
|
};
|
|
928
954
|
}
|
|
929
955
|
|
|
930
|
-
const result = await createSessionViaApi(serverUrl,
|
|
931
|
-
projectDirectory: projectDirectory || cwd,
|
|
956
|
+
const result = await createSessionViaApi(serverUrl, sessionCtx, prompt, {
|
|
932
957
|
title: sessionTitle,
|
|
933
958
|
agent: config.agent,
|
|
934
959
|
model: config.model,
|
|
@@ -987,27 +1012,29 @@ export async function executeAction(item, config, options = {}) {
|
|
|
987
1012
|
};
|
|
988
1013
|
}
|
|
989
1014
|
|
|
990
|
-
// If existing_directory is provided (reprocessing same item), use it directly
|
|
991
|
-
// 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.
|
|
992
1018
|
if (config.existing_directory) {
|
|
993
1019
|
debug(`executeAction: using existing_directory=${config.existing_directory}`);
|
|
994
1020
|
const cwd = expandPath(config.existing_directory);
|
|
995
|
-
|
|
1021
|
+
const sessionCtx = SessionContext.forWorktree(baseCwd, cwd);
|
|
1022
|
+
return await executeInDirectory(serverUrl, sessionCtx, item, config, options);
|
|
996
1023
|
}
|
|
997
1024
|
|
|
998
|
-
// Resolve worktree directory if configured
|
|
999
|
-
// 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.
|
|
1000
1027
|
let worktreeMode = config.worktree;
|
|
1001
1028
|
|
|
1002
|
-
// If worktree_name is configured, enable worktree mode (explicit configuration)
|
|
1003
|
-
// 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.
|
|
1004
1031
|
if (!worktreeMode && config.worktree_name) {
|
|
1005
1032
|
debug(`executeAction: worktree_name configured, enabling worktree mode`);
|
|
1006
1033
|
worktreeMode = 'new';
|
|
1007
1034
|
}
|
|
1008
1035
|
|
|
1009
|
-
// Auto-detect worktree support: check if the project has sandboxes
|
|
1010
|
-
// 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.
|
|
1011
1038
|
if (!worktreeMode) {
|
|
1012
1039
|
// Look up project info for this specific directory (not just /project/current)
|
|
1013
1040
|
const projectInfo = await getProjectInfoForDirectory(serverUrl, baseCwd, { fetch: options.fetch });
|
|
@@ -1044,5 +1071,8 @@ export async function executeAction(item, config, options = {}) {
|
|
|
1044
1071
|
|
|
1045
1072
|
debug(`executeAction: using cwd=${cwd}`);
|
|
1046
1073
|
|
|
1047
|
-
|
|
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);
|
|
1048
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
|
+
});
|
|
@@ -967,3 +967,275 @@ describe("integration: stacked PR session reuse", () => {
|
|
|
967
967
|
}
|
|
968
968
|
});
|
|
969
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;
|
|
@@ -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
|
);
|
|
@@ -1067,12 +1068,12 @@ Check for bugs and security issues.`;
|
|
|
1067
1068
|
|
|
1068
1069
|
await createSessionViaApi(
|
|
1069
1070
|
'http://localhost:4096',
|
|
1070
|
-
|
|
1071
|
+
SessionContext.forWorktree(
|
|
1072
|
+
'/home/user/code/odin',
|
|
1073
|
+
'/home/user/.local/share/opencode/worktree/abc123/pr-415'
|
|
1074
|
+
),
|
|
1071
1075
|
'Fix the bug',
|
|
1072
|
-
{
|
|
1073
|
-
fetch: mockFetch,
|
|
1074
|
-
projectDirectory: '/home/user/code/odin',
|
|
1075
|
-
}
|
|
1076
|
+
{ fetch: mockFetch }
|
|
1076
1077
|
);
|
|
1077
1078
|
|
|
1078
1079
|
// Session creation should use the project directory (for correct projectID scoping)
|
|
@@ -1099,7 +1100,7 @@ Check for bugs and security issues.`;
|
|
|
1099
1100
|
|
|
1100
1101
|
const result = await createSessionViaApi(
|
|
1101
1102
|
'http://localhost:4096',
|
|
1102
|
-
'/path/to/project',
|
|
1103
|
+
SessionContext.forProject('/path/to/project'),
|
|
1103
1104
|
'Fix the bug',
|
|
1104
1105
|
{ fetch: mockFetch }
|
|
1105
1106
|
);
|
|
@@ -1141,7 +1142,7 @@ Check for bugs and security issues.`;
|
|
|
1141
1142
|
|
|
1142
1143
|
await createSessionViaApi(
|
|
1143
1144
|
'http://localhost:4096',
|
|
1144
|
-
'/path/to/project',
|
|
1145
|
+
SessionContext.forProject('/path/to/project'),
|
|
1145
1146
|
'Fix the bug',
|
|
1146
1147
|
{
|
|
1147
1148
|
fetch: mockFetch,
|
|
@@ -1186,7 +1187,7 @@ Check for bugs and security issues.`;
|
|
|
1186
1187
|
|
|
1187
1188
|
const result = await createSessionViaApi(
|
|
1188
1189
|
'http://localhost:4096',
|
|
1189
|
-
'/path/to/project',
|
|
1190
|
+
SessionContext.forProject('/path/to/project'),
|
|
1190
1191
|
'Fix the bug',
|
|
1191
1192
|
{ fetch: mockFetch }
|
|
1192
1193
|
);
|
|
@@ -1245,7 +1246,7 @@ Check for bugs and security issues.`;
|
|
|
1245
1246
|
|
|
1246
1247
|
const result = await createSessionViaApi(
|
|
1247
1248
|
'http://localhost:4096',
|
|
1248
|
-
'/path/to/project',
|
|
1249
|
+
SessionContext.forProject('/path/to/project'),
|
|
1249
1250
|
'/review https://github.com/org/repo/pull/123',
|
|
1250
1251
|
{ fetch: mockFetch }
|
|
1251
1252
|
);
|
|
@@ -1298,7 +1299,7 @@ Check for bugs and security issues.`;
|
|
|
1298
1299
|
|
|
1299
1300
|
const result = await createSessionViaApi(
|
|
1300
1301
|
'http://localhost:4096',
|
|
1301
|
-
'/path/to/project',
|
|
1302
|
+
SessionContext.forProject('/path/to/project'),
|
|
1302
1303
|
'Fix the bug in the login page',
|
|
1303
1304
|
{ fetch: mockFetch }
|
|
1304
1305
|
);
|
|
@@ -1349,7 +1350,7 @@ Check for bugs and security issues.`;
|
|
|
1349
1350
|
|
|
1350
1351
|
const result = await createSessionViaApi(
|
|
1351
1352
|
'http://localhost:4096',
|
|
1352
|
-
'/path/to/project',
|
|
1353
|
+
SessionContext.forProject('/path/to/project'),
|
|
1353
1354
|
'Fix the bug',
|
|
1354
1355
|
{ fetch: mockFetch, title: 'Test Session', headerTimeout: 100 }
|
|
1355
1356
|
);
|
|
@@ -1396,7 +1397,7 @@ Check for bugs and security issues.`;
|
|
|
1396
1397
|
|
|
1397
1398
|
const result = await createSessionViaApi(
|
|
1398
1399
|
'http://localhost:4096',
|
|
1399
|
-
'/path/to/project',
|
|
1400
|
+
SessionContext.forProject('/path/to/project'),
|
|
1400
1401
|
'Fix the bug',
|
|
1401
1402
|
{ fetch: mockFetch }
|
|
1402
1403
|
);
|