opencode-pilot 0.19.1 → 0.20.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -2
- package/bin/opencode-pilot +149 -17
- package/package.json +1 -1
- package/plugin/index.js +51 -13
- package/service/actions.js +175 -68
- package/service/server.js +5 -3
- package/service/version.js +34 -0
- package/test/unit/actions.test.js +337 -0
- package/test/unit/server.test.js +90 -0
package/README.md
CHANGED
|
@@ -31,7 +31,7 @@ npm install -g opencode-pilot
|
|
|
31
31
|
}
|
|
32
32
|
```
|
|
33
33
|
|
|
34
|
-
The daemon will auto-start when OpenCode launches.
|
|
34
|
+
The daemon will auto-start when OpenCode launches. If a newer version of the plugin is installed, the daemon will automatically restart to pick up the new version.
|
|
35
35
|
|
|
36
36
|
Or start manually:
|
|
37
37
|
|
|
@@ -117,7 +117,8 @@ sources:
|
|
|
117
117
|
|
|
118
118
|
```bash
|
|
119
119
|
opencode-pilot start # Start the service (foreground)
|
|
120
|
-
opencode-pilot
|
|
120
|
+
opencode-pilot stop # Stop the running service
|
|
121
|
+
opencode-pilot status # Show version and service status
|
|
121
122
|
opencode-pilot config # Validate and show config
|
|
122
123
|
opencode-pilot clear # Show state summary
|
|
123
124
|
opencode-pilot clear --all # Clear all processed state
|
package/bin/opencode-pilot
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import { fileURLToPath } from "url";
|
|
15
15
|
import { dirname, join } from "path";
|
|
16
|
-
import { existsSync, readFileSync } from "fs";
|
|
16
|
+
import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync } from "fs";
|
|
17
17
|
import { execSync } from "child_process";
|
|
18
18
|
import os from "os";
|
|
19
19
|
import YAML from "yaml";
|
|
@@ -39,12 +39,73 @@ function findServiceDir() {
|
|
|
39
39
|
const serviceDir = findServiceDir();
|
|
40
40
|
|
|
41
41
|
// Paths
|
|
42
|
-
const
|
|
43
|
-
const
|
|
42
|
+
const PILOT_CONFIG_DIR = join(os.homedir(), ".config/opencode/pilot");
|
|
43
|
+
const PILOT_CONFIG_FILE = join(PILOT_CONFIG_DIR, "config.yaml");
|
|
44
|
+
const PILOT_TEMPLATES_DIR = join(PILOT_CONFIG_DIR, "templates");
|
|
45
|
+
const PILOT_PID_FILE = join(PILOT_CONFIG_DIR, "pilot.pid");
|
|
44
46
|
|
|
45
47
|
// Default port
|
|
46
48
|
const DEFAULT_PORT = 4097;
|
|
47
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Write PID file
|
|
52
|
+
*/
|
|
53
|
+
function writePidFile() {
|
|
54
|
+
try {
|
|
55
|
+
mkdirSync(PILOT_CONFIG_DIR, { recursive: true });
|
|
56
|
+
writeFileSync(PILOT_PID_FILE, String(process.pid));
|
|
57
|
+
} catch (err) {
|
|
58
|
+
console.warn(`[opencode-pilot] Could not write PID file: ${err.message}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Remove PID file
|
|
64
|
+
*/
|
|
65
|
+
function removePidFile() {
|
|
66
|
+
try {
|
|
67
|
+
if (existsSync(PILOT_PID_FILE)) {
|
|
68
|
+
unlinkSync(PILOT_PID_FILE);
|
|
69
|
+
}
|
|
70
|
+
} catch {
|
|
71
|
+
// Ignore errors on cleanup
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Read PID from file
|
|
77
|
+
* @returns {number|null} PID or null if not found/invalid
|
|
78
|
+
*/
|
|
79
|
+
function readPidFile() {
|
|
80
|
+
try {
|
|
81
|
+
if (existsSync(PILOT_PID_FILE)) {
|
|
82
|
+
const content = readFileSync(PILOT_PID_FILE, "utf8").trim();
|
|
83
|
+
const pid = parseInt(content, 10);
|
|
84
|
+
if (!isNaN(pid) && pid > 0) {
|
|
85
|
+
return pid;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
} catch {
|
|
89
|
+
// Ignore errors
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Check if a process is running
|
|
96
|
+
* @param {number} pid - Process ID
|
|
97
|
+
* @returns {boolean} True if process is running
|
|
98
|
+
*/
|
|
99
|
+
function isProcessRunning(pid) {
|
|
100
|
+
try {
|
|
101
|
+
// Signal 0 doesn't kill, just checks if process exists
|
|
102
|
+
process.kill(pid, 0);
|
|
103
|
+
return true;
|
|
104
|
+
} catch {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
48
109
|
/**
|
|
49
110
|
* Load port from config file
|
|
50
111
|
* @returns {number} Port number
|
|
@@ -114,7 +175,8 @@ Usage:
|
|
|
114
175
|
|
|
115
176
|
Commands:
|
|
116
177
|
start Start the polling service (foreground)
|
|
117
|
-
|
|
178
|
+
stop Stop the running service
|
|
179
|
+
status Show service status and version
|
|
118
180
|
config Validate and show configuration
|
|
119
181
|
clear Clear processed state entries
|
|
120
182
|
test-source NAME Test a source by fetching items and showing mappings
|
|
@@ -133,7 +195,8 @@ The service handles:
|
|
|
133
195
|
|
|
134
196
|
Examples:
|
|
135
197
|
opencode-pilot start # Start service (foreground)
|
|
136
|
-
opencode-pilot
|
|
198
|
+
opencode-pilot stop # Stop the service
|
|
199
|
+
opencode-pilot status # Check status and version
|
|
137
200
|
opencode-pilot config # Validate and show config
|
|
138
201
|
opencode-pilot clear --all # Clear all processed state
|
|
139
202
|
opencode-pilot clear --expired # Clear expired entries
|
|
@@ -157,6 +220,9 @@ async function startCommand() {
|
|
|
157
220
|
|
|
158
221
|
console.log("[opencode-pilot] Starting polling service...");
|
|
159
222
|
|
|
223
|
+
// Write PID file
|
|
224
|
+
writePidFile();
|
|
225
|
+
|
|
160
226
|
// Dynamic import of the service module
|
|
161
227
|
const { startService, stopService } = await import(serverPath);
|
|
162
228
|
|
|
@@ -167,29 +233,91 @@ async function startCommand() {
|
|
|
167
233
|
const service = await startService(config);
|
|
168
234
|
|
|
169
235
|
// Handle graceful shutdown
|
|
170
|
-
|
|
171
|
-
console.log(
|
|
236
|
+
const shutdown = async (signal) => {
|
|
237
|
+
console.log(`[opencode-pilot] Received ${signal}, shutting down...`);
|
|
238
|
+
removePidFile();
|
|
172
239
|
await stopService(service);
|
|
173
240
|
process.exit(0);
|
|
174
|
-
}
|
|
241
|
+
};
|
|
175
242
|
|
|
176
|
-
process.on("
|
|
177
|
-
|
|
178
|
-
await stopService(service);
|
|
179
|
-
process.exit(0);
|
|
180
|
-
});
|
|
243
|
+
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
244
|
+
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
181
245
|
|
|
182
246
|
// Keep running until signal received
|
|
183
247
|
await new Promise(() => {});
|
|
184
248
|
}
|
|
185
249
|
|
|
250
|
+
// ============================================================================
|
|
251
|
+
// Stop Command
|
|
252
|
+
// ============================================================================
|
|
253
|
+
|
|
254
|
+
function stopCommand() {
|
|
255
|
+
const pid = readPidFile();
|
|
256
|
+
|
|
257
|
+
if (!pid) {
|
|
258
|
+
console.log("Service is not running (no PID file found)");
|
|
259
|
+
process.exit(0);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (!isProcessRunning(pid)) {
|
|
263
|
+
console.log(`Service is not running (stale PID file for pid ${pid})`);
|
|
264
|
+
removePidFile();
|
|
265
|
+
process.exit(0);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
console.log(`Stopping opencode-pilot (pid ${pid})...`);
|
|
269
|
+
|
|
270
|
+
try {
|
|
271
|
+
process.kill(pid, "SIGTERM");
|
|
272
|
+
|
|
273
|
+
// Wait for process to exit (up to 5 seconds)
|
|
274
|
+
let attempts = 0;
|
|
275
|
+
const maxAttempts = 50;
|
|
276
|
+
const checkInterval = 100;
|
|
277
|
+
|
|
278
|
+
const waitForExit = () => {
|
|
279
|
+
if (!isProcessRunning(pid)) {
|
|
280
|
+
console.log("Service stopped");
|
|
281
|
+
process.exit(0);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
attempts++;
|
|
285
|
+
if (attempts >= maxAttempts) {
|
|
286
|
+
console.log("Service did not stop gracefully, sending SIGKILL...");
|
|
287
|
+
try {
|
|
288
|
+
process.kill(pid, "SIGKILL");
|
|
289
|
+
} catch {
|
|
290
|
+
// Process may have exited
|
|
291
|
+
}
|
|
292
|
+
removePidFile();
|
|
293
|
+
console.log("Service killed");
|
|
294
|
+
process.exit(0);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
setTimeout(waitForExit, checkInterval);
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
setTimeout(waitForExit, checkInterval);
|
|
301
|
+
} catch (err) {
|
|
302
|
+
if (err.code === "ESRCH") {
|
|
303
|
+
console.log("Service is not running");
|
|
304
|
+
removePidFile();
|
|
305
|
+
} else {
|
|
306
|
+
console.error(`Failed to stop service: ${err.message}`);
|
|
307
|
+
process.exit(1);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
186
312
|
// ============================================================================
|
|
187
313
|
// Status Command
|
|
188
314
|
// ============================================================================
|
|
189
315
|
|
|
190
|
-
function statusCommand() {
|
|
191
|
-
|
|
192
|
-
|
|
316
|
+
async function statusCommand() {
|
|
317
|
+
const { getVersion } = await import(join(serviceDir, "version.js"));
|
|
318
|
+
const version = getVersion();
|
|
319
|
+
console.log(`opencode-pilot v${version}`);
|
|
320
|
+
console.log("=".repeat(`opencode-pilot v${version}`.length));
|
|
193
321
|
console.log("");
|
|
194
322
|
|
|
195
323
|
// Service running? Check if HTTP responds
|
|
@@ -702,8 +830,12 @@ async function main() {
|
|
|
702
830
|
await startCommand();
|
|
703
831
|
break;
|
|
704
832
|
|
|
833
|
+
case "stop":
|
|
834
|
+
stopCommand();
|
|
835
|
+
break;
|
|
836
|
+
|
|
705
837
|
case "status":
|
|
706
|
-
statusCommand();
|
|
838
|
+
await statusCommand();
|
|
707
839
|
break;
|
|
708
840
|
|
|
709
841
|
case "config":
|
package/package.json
CHANGED
package/plugin/index.js
CHANGED
|
@@ -2,12 +2,14 @@
|
|
|
2
2
|
//
|
|
3
3
|
// Add "opencode-pilot" to your opencode.json plugins array to enable.
|
|
4
4
|
// The plugin checks if the daemon is running and starts it if needed.
|
|
5
|
+
// If the running daemon has a different version, it will be restarted.
|
|
5
6
|
|
|
6
7
|
import { existsSync, readFileSync } from 'fs'
|
|
7
|
-
import { spawn } from 'child_process'
|
|
8
|
+
import { spawn, spawnSync } from 'child_process'
|
|
8
9
|
import { join } from 'path'
|
|
9
10
|
import { homedir } from 'os'
|
|
10
11
|
import YAML from 'yaml'
|
|
12
|
+
import { getVersion } from '../service/version.js'
|
|
11
13
|
|
|
12
14
|
const DEFAULT_PORT = 4097
|
|
13
15
|
const CONFIG_PATH = join(homedir(), '.config', 'opencode', 'pilot', 'config.yaml')
|
|
@@ -32,31 +34,67 @@ function getPort() {
|
|
|
32
34
|
}
|
|
33
35
|
|
|
34
36
|
/**
|
|
35
|
-
*
|
|
37
|
+
* Start the daemon as a detached background process
|
|
38
|
+
*/
|
|
39
|
+
function startDaemon() {
|
|
40
|
+
try {
|
|
41
|
+
const child = spawn('npx', ['opencode-pilot', 'start'], {
|
|
42
|
+
detached: true,
|
|
43
|
+
stdio: 'ignore',
|
|
44
|
+
})
|
|
45
|
+
child.unref()
|
|
46
|
+
} catch {
|
|
47
|
+
// Ignore start errors
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Stop the daemon synchronously
|
|
53
|
+
*/
|
|
54
|
+
function stopDaemon() {
|
|
55
|
+
try {
|
|
56
|
+
spawnSync('npx', ['opencode-pilot', 'stop'], {
|
|
57
|
+
stdio: 'ignore',
|
|
58
|
+
timeout: 10000,
|
|
59
|
+
})
|
|
60
|
+
} catch {
|
|
61
|
+
// Ignore stop errors
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* OpenCode plugin that auto-starts the daemon if not running.
|
|
67
|
+
* If a daemon is running with a different version, it will be restarted.
|
|
36
68
|
*/
|
|
37
69
|
export const PilotPlugin = async () => {
|
|
38
70
|
const port = getPort()
|
|
71
|
+
const ourVersion = getVersion()
|
|
39
72
|
|
|
40
73
|
try {
|
|
41
74
|
// Check if daemon is already running
|
|
42
75
|
const res = await fetch(`http://localhost:${port}/health`, {
|
|
43
76
|
signal: AbortSignal.timeout(1000)
|
|
44
77
|
})
|
|
78
|
+
|
|
45
79
|
if (res.ok) {
|
|
46
|
-
//
|
|
80
|
+
// Check version
|
|
81
|
+
const data = await res.json()
|
|
82
|
+
const runningVersion = data.version
|
|
83
|
+
|
|
84
|
+
if (runningVersion && runningVersion !== ourVersion && ourVersion !== 'unknown') {
|
|
85
|
+
// Version mismatch - restart daemon
|
|
86
|
+
console.log(`[opencode-pilot] Version mismatch (running: ${runningVersion}, plugin: ${ourVersion}), restarting...`)
|
|
87
|
+
stopDaemon()
|
|
88
|
+
// Small delay to ensure port is released
|
|
89
|
+
await new Promise(resolve => setTimeout(resolve, 500))
|
|
90
|
+
startDaemon()
|
|
91
|
+
}
|
|
92
|
+
// else: same version, nothing to do
|
|
47
93
|
return {}
|
|
48
94
|
}
|
|
49
95
|
} catch {
|
|
50
|
-
// Not running, start it
|
|
51
|
-
|
|
52
|
-
const child = spawn('npx', ['opencode-pilot', 'start'], {
|
|
53
|
-
detached: true,
|
|
54
|
-
stdio: 'ignore',
|
|
55
|
-
})
|
|
56
|
-
child.unref()
|
|
57
|
-
} catch {
|
|
58
|
-
// Ignore start errors
|
|
59
|
-
}
|
|
96
|
+
// Not running or error, start it
|
|
97
|
+
startDaemon()
|
|
60
98
|
}
|
|
61
99
|
|
|
62
100
|
return {}
|
package/service/actions.js
CHANGED
|
@@ -14,6 +14,84 @@ import { resolveWorktreeDirectory, getProjectInfo, getProjectInfoForDirectory }
|
|
|
14
14
|
import path from "path";
|
|
15
15
|
import os from "os";
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Parse a slash command from the beginning of a prompt
|
|
19
|
+
* Returns null if the prompt doesn't start with a slash command
|
|
20
|
+
*
|
|
21
|
+
* @param {string} prompt - The prompt text
|
|
22
|
+
* @returns {object|null} { command, arguments, rest } or null
|
|
23
|
+
*/
|
|
24
|
+
export function parseSlashCommand(prompt) {
|
|
25
|
+
if (!prompt || typeof prompt !== 'string') {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Match /command at the start, followed by optional arguments on the same line
|
|
30
|
+
// Command names can contain letters, numbers, hyphens, and underscores
|
|
31
|
+
const match = prompt.match(/^\/([a-zA-Z0-9_-]+)(?:\s+(.*))?$/m);
|
|
32
|
+
|
|
33
|
+
if (!match) {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const command = match[1];
|
|
38
|
+
const firstLineArgs = match[2]?.trim() || '';
|
|
39
|
+
|
|
40
|
+
// Find where the first line ends to get the "rest" of the prompt
|
|
41
|
+
const firstNewline = prompt.indexOf('\n');
|
|
42
|
+
const rest = firstNewline >= 0 ? prompt.slice(firstNewline + 1).trim() : '';
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
command,
|
|
46
|
+
arguments: firstLineArgs,
|
|
47
|
+
rest,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Send a command to a session via the /command endpoint
|
|
53
|
+
*
|
|
54
|
+
* @param {string} serverUrl - Server URL
|
|
55
|
+
* @param {string} sessionId - Session ID
|
|
56
|
+
* @param {string} directory - Working directory
|
|
57
|
+
* @param {object} parsedCommand - Parsed command from parseSlashCommand
|
|
58
|
+
* @param {object} [options] - Options
|
|
59
|
+
* @param {string} [options.agent] - Agent to use
|
|
60
|
+
* @param {string} [options.model] - Model to use
|
|
61
|
+
* @param {function} [options.fetch] - Custom fetch function (for testing)
|
|
62
|
+
* @returns {Promise<Response>} The fetch response
|
|
63
|
+
*/
|
|
64
|
+
async function sendCommand(serverUrl, sessionId, directory, parsedCommand, options = {}) {
|
|
65
|
+
const fetchFn = options.fetch || fetch;
|
|
66
|
+
|
|
67
|
+
const commandUrl = new URL(`/session/${sessionId}/command`, serverUrl);
|
|
68
|
+
commandUrl.searchParams.set('directory', directory);
|
|
69
|
+
|
|
70
|
+
// Build command body per OpenCode API schema
|
|
71
|
+
const commandBody = {
|
|
72
|
+
command: parsedCommand.command,
|
|
73
|
+
arguments: parsedCommand.arguments,
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// Add agent if specified
|
|
77
|
+
if (options.agent) {
|
|
78
|
+
commandBody.agent = options.agent;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Add model if specified (the /command endpoint takes model as a single string)
|
|
82
|
+
if (options.model) {
|
|
83
|
+
commandBody.model = options.model;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
debug(`sendCommand: POST ${commandUrl} command=${parsedCommand.command} args=${parsedCommand.arguments}`);
|
|
87
|
+
|
|
88
|
+
return fetchFn(commandUrl.toString(), {
|
|
89
|
+
method: 'POST',
|
|
90
|
+
headers: { 'Content-Type': 'application/json' },
|
|
91
|
+
body: JSON.stringify(commandBody),
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
17
95
|
/**
|
|
18
96
|
* Get running opencode server ports by parsing lsof output
|
|
19
97
|
* @returns {Promise<number[]>} Array of port numbers
|
|
@@ -455,50 +533,65 @@ export async function sendMessageToSession(serverUrl, sessionId, directory, prom
|
|
|
455
533
|
debug(`sendMessageToSession: updated title for session ${sessionId}`);
|
|
456
534
|
}
|
|
457
535
|
|
|
458
|
-
// Step 2:
|
|
459
|
-
const
|
|
460
|
-
messageUrl.searchParams.set('directory', directory);
|
|
461
|
-
|
|
462
|
-
const messageBody = {
|
|
463
|
-
parts: [{ type: 'text', text: prompt }],
|
|
464
|
-
};
|
|
465
|
-
|
|
466
|
-
if (options.agent) {
|
|
467
|
-
messageBody.agent = options.agent;
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
if (options.model) {
|
|
471
|
-
const [providerID, modelID] = options.model.includes('/')
|
|
472
|
-
? options.model.split('/', 2)
|
|
473
|
-
: ['anthropic', options.model];
|
|
474
|
-
messageBody.providerID = providerID;
|
|
475
|
-
messageBody.modelID = modelID;
|
|
476
|
-
}
|
|
536
|
+
// Step 2: Check if the prompt starts with a slash command
|
|
537
|
+
const parsedCommand = parseSlashCommand(prompt);
|
|
477
538
|
|
|
478
539
|
// Use AbortController with timeout (same pattern as createSessionViaApi)
|
|
479
540
|
const controller = new AbortController();
|
|
480
541
|
const timeoutId = setTimeout(() => controller.abort(), 10000);
|
|
481
542
|
|
|
482
543
|
try {
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
544
|
+
let response;
|
|
545
|
+
|
|
546
|
+
if (parsedCommand) {
|
|
547
|
+
// Use the /command endpoint for slash commands
|
|
548
|
+
debug(`sendMessageToSession: detected command /${parsedCommand.command}`);
|
|
549
|
+
response = await sendCommand(serverUrl, sessionId, directory, parsedCommand, {
|
|
550
|
+
agent: options.agent,
|
|
551
|
+
model: options.model,
|
|
552
|
+
fetch: (url, opts) => fetchFn(url, { ...opts, signal: controller.signal }),
|
|
553
|
+
});
|
|
554
|
+
} else {
|
|
555
|
+
// Use the /message endpoint for regular prompts
|
|
556
|
+
const messageUrl = new URL(`/session/${sessionId}/message`, serverUrl);
|
|
557
|
+
messageUrl.searchParams.set('directory', directory);
|
|
558
|
+
|
|
559
|
+
const messageBody = {
|
|
560
|
+
parts: [{ type: 'text', text: prompt }],
|
|
561
|
+
};
|
|
562
|
+
|
|
563
|
+
if (options.agent) {
|
|
564
|
+
messageBody.agent = options.agent;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
if (options.model) {
|
|
568
|
+
const [providerID, modelID] = options.model.includes('/')
|
|
569
|
+
? options.model.split('/', 2)
|
|
570
|
+
: ['anthropic', options.model];
|
|
571
|
+
messageBody.providerID = providerID;
|
|
572
|
+
messageBody.modelID = modelID;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
response = await fetchFn(messageUrl.toString(), {
|
|
576
|
+
method: 'POST',
|
|
577
|
+
headers: { 'Content-Type': 'application/json' },
|
|
578
|
+
body: JSON.stringify(messageBody),
|
|
579
|
+
signal: controller.signal,
|
|
580
|
+
});
|
|
581
|
+
}
|
|
489
582
|
|
|
490
583
|
clearTimeout(timeoutId);
|
|
491
584
|
|
|
492
|
-
if (!
|
|
493
|
-
const errorText = await
|
|
494
|
-
throw new Error(`Failed to send message: ${
|
|
585
|
+
if (!response.ok) {
|
|
586
|
+
const errorText = await response.text();
|
|
587
|
+
throw new Error(`Failed to send ${parsedCommand ? 'command' : 'message'}: ${response.status} ${errorText}`);
|
|
495
588
|
}
|
|
496
589
|
|
|
497
|
-
debug(`sendMessageToSession: sent message to session ${sessionId}`);
|
|
590
|
+
debug(`sendMessageToSession: sent ${parsedCommand ? 'command' : 'message'} to session ${sessionId}`);
|
|
498
591
|
} catch (abortErr) {
|
|
499
592
|
clearTimeout(timeoutId);
|
|
500
593
|
if (abortErr.name === 'AbortError') {
|
|
501
|
-
debug(`sendMessageToSession:
|
|
594
|
+
debug(`sendMessageToSession: request started for session ${sessionId} (response aborted as expected)`);
|
|
502
595
|
} else {
|
|
503
596
|
throw abortErr;
|
|
504
597
|
}
|
|
@@ -610,58 +703,72 @@ export async function createSessionViaApi(serverUrl, directory, prompt, options
|
|
|
610
703
|
});
|
|
611
704
|
}
|
|
612
705
|
|
|
613
|
-
// Step 3:
|
|
614
|
-
const
|
|
615
|
-
messageUrl.searchParams.set('directory', directory);
|
|
616
|
-
|
|
617
|
-
// Build message body
|
|
618
|
-
const messageBody = {
|
|
619
|
-
parts: [{ type: 'text', text: prompt }],
|
|
620
|
-
};
|
|
621
|
-
|
|
622
|
-
// Add agent if specified
|
|
623
|
-
if (options.agent) {
|
|
624
|
-
messageBody.agent = options.agent;
|
|
625
|
-
}
|
|
626
|
-
|
|
627
|
-
// Add model if specified (format: provider/model)
|
|
628
|
-
if (options.model) {
|
|
629
|
-
const [providerID, modelID] = options.model.includes('/')
|
|
630
|
-
? options.model.split('/', 2)
|
|
631
|
-
: ['anthropic', options.model];
|
|
632
|
-
messageBody.providerID = providerID;
|
|
633
|
-
messageBody.modelID = modelID;
|
|
634
|
-
}
|
|
706
|
+
// Step 3: Check if the prompt starts with a slash command
|
|
707
|
+
const parsedCommand = parseSlashCommand(prompt);
|
|
635
708
|
|
|
636
|
-
// Use AbortController with timeout for the
|
|
637
|
-
// The
|
|
638
|
-
//
|
|
639
|
-
// request was accepted (2xx status), not wait for the full response.
|
|
709
|
+
// Use AbortController with timeout for the request
|
|
710
|
+
// The endpoints return a chunked/streaming response that stays open until
|
|
711
|
+
// the agent completes. We only need to verify the request was accepted.
|
|
640
712
|
const controller = new AbortController();
|
|
641
713
|
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout
|
|
642
714
|
|
|
643
715
|
try {
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
716
|
+
let response;
|
|
717
|
+
|
|
718
|
+
if (parsedCommand) {
|
|
719
|
+
// Use the /command endpoint for slash commands
|
|
720
|
+
debug(`createSessionViaApi: detected command /${parsedCommand.command}`);
|
|
721
|
+
response = await sendCommand(serverUrl, session.id, directory, parsedCommand, {
|
|
722
|
+
agent: options.agent,
|
|
723
|
+
model: options.model,
|
|
724
|
+
fetch: (url, opts) => fetchFn(url, { ...opts, signal: controller.signal }),
|
|
725
|
+
});
|
|
726
|
+
} else {
|
|
727
|
+
// Use the /message endpoint for regular prompts
|
|
728
|
+
const messageUrl = new URL(`/session/${session.id}/message`, serverUrl);
|
|
729
|
+
messageUrl.searchParams.set('directory', directory);
|
|
730
|
+
|
|
731
|
+
// Build message body
|
|
732
|
+
const messageBody = {
|
|
733
|
+
parts: [{ type: 'text', text: prompt }],
|
|
734
|
+
};
|
|
735
|
+
|
|
736
|
+
// Add agent if specified
|
|
737
|
+
if (options.agent) {
|
|
738
|
+
messageBody.agent = options.agent;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// Add model if specified (format: provider/model)
|
|
742
|
+
if (options.model) {
|
|
743
|
+
const [providerID, modelID] = options.model.includes('/')
|
|
744
|
+
? options.model.split('/', 2)
|
|
745
|
+
: ['anthropic', options.model];
|
|
746
|
+
messageBody.providerID = providerID;
|
|
747
|
+
messageBody.modelID = modelID;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
response = await fetchFn(messageUrl.toString(), {
|
|
751
|
+
method: 'POST',
|
|
752
|
+
headers: { 'Content-Type': 'application/json' },
|
|
753
|
+
body: JSON.stringify(messageBody),
|
|
754
|
+
signal: controller.signal,
|
|
755
|
+
});
|
|
756
|
+
}
|
|
650
757
|
|
|
651
758
|
clearTimeout(timeoutId);
|
|
652
759
|
|
|
653
|
-
if (!
|
|
654
|
-
const errorText = await
|
|
655
|
-
throw new Error(`Failed to send message: ${
|
|
760
|
+
if (!response.ok) {
|
|
761
|
+
const errorText = await response.text();
|
|
762
|
+
throw new Error(`Failed to send ${parsedCommand ? 'command' : 'message'}: ${response.status} ${errorText}`);
|
|
656
763
|
}
|
|
657
764
|
|
|
658
|
-
debug(`createSessionViaApi: sent message to session ${session.id}`);
|
|
765
|
+
debug(`createSessionViaApi: sent ${parsedCommand ? 'command' : 'message'} to session ${session.id}`);
|
|
659
766
|
} catch (abortErr) {
|
|
660
767
|
clearTimeout(timeoutId);
|
|
661
768
|
// AbortError is expected - we intentionally abort after verifying the request started
|
|
662
|
-
// The server accepted our
|
|
769
|
+
// The server accepted our request, we just don't need to wait for the response
|
|
663
770
|
if (abortErr.name === 'AbortError') {
|
|
664
|
-
debug(`createSessionViaApi:
|
|
771
|
+
debug(`createSessionViaApi: request started for session ${session.id} (response aborted as expected)`);
|
|
665
772
|
} else {
|
|
666
773
|
throw abortErr;
|
|
667
774
|
}
|
package/service/server.js
CHANGED
|
@@ -10,6 +10,7 @@ import { fileURLToPath } from 'url'
|
|
|
10
10
|
import { homedir } from 'os'
|
|
11
11
|
import { join } from 'path'
|
|
12
12
|
import YAML from 'yaml'
|
|
13
|
+
import { getVersion } from './version.js'
|
|
13
14
|
|
|
14
15
|
// Default configuration
|
|
15
16
|
const DEFAULT_HTTP_PORT = 4097
|
|
@@ -56,10 +57,11 @@ function createHttpServer_(port) {
|
|
|
56
57
|
return
|
|
57
58
|
}
|
|
58
59
|
|
|
59
|
-
// GET /health - Health check
|
|
60
|
+
// GET /health - Health check with version
|
|
60
61
|
if (req.method === 'GET' && url.pathname === '/health') {
|
|
61
|
-
|
|
62
|
-
res.
|
|
62
|
+
const version = getVersion()
|
|
63
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
64
|
+
res.end(JSON.stringify({ status: 'ok', version }))
|
|
63
65
|
return
|
|
64
66
|
}
|
|
65
67
|
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Shared version utility for opencode-pilot
|
|
2
|
+
//
|
|
3
|
+
// Returns the version from package.json
|
|
4
|
+
|
|
5
|
+
import { existsSync, readFileSync } from 'fs'
|
|
6
|
+
import { join, dirname } from 'path'
|
|
7
|
+
import { fileURLToPath } from 'url'
|
|
8
|
+
|
|
9
|
+
const __filename = fileURLToPath(import.meta.url)
|
|
10
|
+
const __dirname = dirname(__filename)
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Get version from package.json
|
|
14
|
+
* Checks multiple locations for compatibility with different install methods
|
|
15
|
+
* @returns {string} Version string or 'unknown'
|
|
16
|
+
*/
|
|
17
|
+
export function getVersion() {
|
|
18
|
+
const candidates = [
|
|
19
|
+
join(__dirname, '..', 'package.json'), // Development: service/../package.json
|
|
20
|
+
join(__dirname, '..', '..', 'package.json'), // Homebrew: libexec/../package.json
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
for (const packagePath of candidates) {
|
|
24
|
+
try {
|
|
25
|
+
if (existsSync(packagePath)) {
|
|
26
|
+
const pkg = JSON.parse(readFileSync(packagePath, 'utf8'))
|
|
27
|
+
if (pkg.version) return pkg.version
|
|
28
|
+
}
|
|
29
|
+
} catch {
|
|
30
|
+
// Try next candidate
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return 'unknown'
|
|
34
|
+
}
|
|
@@ -97,6 +97,96 @@ describe('actions.js', () => {
|
|
|
97
97
|
});
|
|
98
98
|
});
|
|
99
99
|
|
|
100
|
+
describe('parseSlashCommand', () => {
|
|
101
|
+
test('parses /review command with URL argument', async () => {
|
|
102
|
+
const { parseSlashCommand } = await import('../../service/actions.js');
|
|
103
|
+
|
|
104
|
+
const result = parseSlashCommand('/review https://github.com/org/repo/pull/123');
|
|
105
|
+
|
|
106
|
+
assert.strictEqual(result.command, 'review');
|
|
107
|
+
assert.strictEqual(result.arguments, 'https://github.com/org/repo/pull/123');
|
|
108
|
+
assert.strictEqual(result.rest, '');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('parses /devcontainer command with URL argument', async () => {
|
|
112
|
+
const { parseSlashCommand } = await import('../../service/actions.js');
|
|
113
|
+
|
|
114
|
+
const result = parseSlashCommand('/devcontainer https://github.com/org/repo/issues/456');
|
|
115
|
+
|
|
116
|
+
assert.strictEqual(result.command, 'devcontainer');
|
|
117
|
+
assert.strictEqual(result.arguments, 'https://github.com/org/repo/issues/456');
|
|
118
|
+
assert.strictEqual(result.rest, '');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('parses command with multiline rest content', async () => {
|
|
122
|
+
const { parseSlashCommand } = await import('../../service/actions.js');
|
|
123
|
+
|
|
124
|
+
const prompt = `/review https://github.com/org/repo/pull/123
|
|
125
|
+
|
|
126
|
+
Review this pull request:
|
|
127
|
+
|
|
128
|
+
Check for bugs and security issues.`;
|
|
129
|
+
|
|
130
|
+
const result = parseSlashCommand(prompt);
|
|
131
|
+
|
|
132
|
+
assert.strictEqual(result.command, 'review');
|
|
133
|
+
assert.strictEqual(result.arguments, 'https://github.com/org/repo/pull/123');
|
|
134
|
+
assert.ok(result.rest.includes('Review this pull request'));
|
|
135
|
+
assert.ok(result.rest.includes('Check for bugs'));
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('parses command without arguments', async () => {
|
|
139
|
+
const { parseSlashCommand } = await import('../../service/actions.js');
|
|
140
|
+
|
|
141
|
+
const result = parseSlashCommand('/help');
|
|
142
|
+
|
|
143
|
+
assert.strictEqual(result.command, 'help');
|
|
144
|
+
assert.strictEqual(result.arguments, '');
|
|
145
|
+
assert.strictEqual(result.rest, '');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('parses command with hyphen in name', async () => {
|
|
149
|
+
const { parseSlashCommand } = await import('../../service/actions.js');
|
|
150
|
+
|
|
151
|
+
const result = parseSlashCommand('/my-custom-command arg1 arg2');
|
|
152
|
+
|
|
153
|
+
assert.strictEqual(result.command, 'my-custom-command');
|
|
154
|
+
assert.strictEqual(result.arguments, 'arg1 arg2');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('returns null for regular prompt without slash', async () => {
|
|
158
|
+
const { parseSlashCommand } = await import('../../service/actions.js');
|
|
159
|
+
|
|
160
|
+
const result = parseSlashCommand('Fix the bug in the login page');
|
|
161
|
+
|
|
162
|
+
assert.strictEqual(result, null);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test('returns null for prompt with slash in middle', async () => {
|
|
166
|
+
const { parseSlashCommand } = await import('../../service/actions.js');
|
|
167
|
+
|
|
168
|
+
const result = parseSlashCommand('Check the path/to/file for issues');
|
|
169
|
+
|
|
170
|
+
assert.strictEqual(result, null);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test('returns null for empty string', async () => {
|
|
174
|
+
const { parseSlashCommand } = await import('../../service/actions.js');
|
|
175
|
+
|
|
176
|
+
assert.strictEqual(parseSlashCommand(''), null);
|
|
177
|
+
assert.strictEqual(parseSlashCommand(null), null);
|
|
178
|
+
assert.strictEqual(parseSlashCommand(undefined), null);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test('returns null for just a slash', async () => {
|
|
182
|
+
const { parseSlashCommand } = await import('../../service/actions.js');
|
|
183
|
+
|
|
184
|
+
const result = parseSlashCommand('/');
|
|
185
|
+
|
|
186
|
+
assert.strictEqual(result, null);
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
100
190
|
describe('getActionConfig', () => {
|
|
101
191
|
test('merges source, repo, and defaults', async () => {
|
|
102
192
|
const { getActionConfig } = await import('../../service/actions.js');
|
|
@@ -903,6 +993,253 @@ describe('actions.js', () => {
|
|
|
903
993
|
assert.ok(result.warning, 'Should include warning about message failure');
|
|
904
994
|
assert.ok(result.warning.includes('Failed to send message'), 'Warning should mention message failure');
|
|
905
995
|
});
|
|
996
|
+
|
|
997
|
+
test('uses /command endpoint for slash commands', async () => {
|
|
998
|
+
const { createSessionViaApi } = await import('../../service/actions.js');
|
|
999
|
+
|
|
1000
|
+
const mockSessionId = 'ses_cmd123';
|
|
1001
|
+
let commandCalled = false;
|
|
1002
|
+
let commandUrl = null;
|
|
1003
|
+
let commandBody = null;
|
|
1004
|
+
let messageCalled = false;
|
|
1005
|
+
|
|
1006
|
+
const mockFetch = async (url, opts) => {
|
|
1007
|
+
const urlObj = new URL(url);
|
|
1008
|
+
|
|
1009
|
+
if (urlObj.pathname === '/session' && opts?.method === 'POST') {
|
|
1010
|
+
return {
|
|
1011
|
+
ok: true,
|
|
1012
|
+
json: async () => ({ id: mockSessionId }),
|
|
1013
|
+
};
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
if (urlObj.pathname.includes('/command') && opts?.method === 'POST') {
|
|
1017
|
+
commandCalled = true;
|
|
1018
|
+
commandUrl = url;
|
|
1019
|
+
commandBody = JSON.parse(opts.body);
|
|
1020
|
+
return {
|
|
1021
|
+
ok: true,
|
|
1022
|
+
json: async () => ({ success: true }),
|
|
1023
|
+
};
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
if (urlObj.pathname.includes('/message') && opts?.method === 'POST') {
|
|
1027
|
+
messageCalled = true;
|
|
1028
|
+
return {
|
|
1029
|
+
ok: true,
|
|
1030
|
+
json: async () => ({ success: true }),
|
|
1031
|
+
};
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
// PATCH for title update
|
|
1035
|
+
if (opts?.method === 'PATCH') {
|
|
1036
|
+
return { ok: true, json: async () => ({}) };
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
return { ok: false, text: async () => 'Not found' };
|
|
1040
|
+
};
|
|
1041
|
+
|
|
1042
|
+
const result = await createSessionViaApi(
|
|
1043
|
+
'http://localhost:4096',
|
|
1044
|
+
'/path/to/project',
|
|
1045
|
+
'/review https://github.com/org/repo/pull/123',
|
|
1046
|
+
{ fetch: mockFetch }
|
|
1047
|
+
);
|
|
1048
|
+
|
|
1049
|
+
assert.ok(result.success, 'Should succeed');
|
|
1050
|
+
assert.ok(commandCalled, 'Should call /command endpoint');
|
|
1051
|
+
assert.ok(!messageCalled, 'Should NOT call /message endpoint');
|
|
1052
|
+
assert.ok(commandUrl.includes('/command'), 'URL should be /command endpoint');
|
|
1053
|
+
assert.strictEqual(commandBody.command, 'review', 'Should pass command name');
|
|
1054
|
+
assert.strictEqual(commandBody.arguments, 'https://github.com/org/repo/pull/123', 'Should pass arguments');
|
|
1055
|
+
});
|
|
1056
|
+
|
|
1057
|
+
test('uses /message endpoint for regular prompts (not slash commands)', async () => {
|
|
1058
|
+
const { createSessionViaApi } = await import('../../service/actions.js');
|
|
1059
|
+
|
|
1060
|
+
const mockSessionId = 'ses_msg123';
|
|
1061
|
+
let commandCalled = false;
|
|
1062
|
+
let messageCalled = false;
|
|
1063
|
+
let messageBody = null;
|
|
1064
|
+
|
|
1065
|
+
const mockFetch = async (url, opts) => {
|
|
1066
|
+
const urlObj = new URL(url);
|
|
1067
|
+
|
|
1068
|
+
if (urlObj.pathname === '/session' && opts?.method === 'POST') {
|
|
1069
|
+
return {
|
|
1070
|
+
ok: true,
|
|
1071
|
+
json: async () => ({ id: mockSessionId }),
|
|
1072
|
+
};
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
if (urlObj.pathname.includes('/command') && opts?.method === 'POST') {
|
|
1076
|
+
commandCalled = true;
|
|
1077
|
+
return {
|
|
1078
|
+
ok: true,
|
|
1079
|
+
json: async () => ({ success: true }),
|
|
1080
|
+
};
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
if (urlObj.pathname.includes('/message') && opts?.method === 'POST') {
|
|
1084
|
+
messageCalled = true;
|
|
1085
|
+
messageBody = JSON.parse(opts.body);
|
|
1086
|
+
return {
|
|
1087
|
+
ok: true,
|
|
1088
|
+
json: async () => ({ success: true }),
|
|
1089
|
+
};
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
return { ok: false, text: async () => 'Not found' };
|
|
1093
|
+
};
|
|
1094
|
+
|
|
1095
|
+
const result = await createSessionViaApi(
|
|
1096
|
+
'http://localhost:4096',
|
|
1097
|
+
'/path/to/project',
|
|
1098
|
+
'Fix the bug in the login page',
|
|
1099
|
+
{ fetch: mockFetch }
|
|
1100
|
+
);
|
|
1101
|
+
|
|
1102
|
+
assert.ok(result.success, 'Should succeed');
|
|
1103
|
+
assert.ok(!commandCalled, 'Should NOT call /command endpoint');
|
|
1104
|
+
assert.ok(messageCalled, 'Should call /message endpoint');
|
|
1105
|
+
assert.strictEqual(messageBody.parts[0].text, 'Fix the bug in the login page', 'Should pass prompt as message text');
|
|
1106
|
+
});
|
|
1107
|
+
});
|
|
1108
|
+
|
|
1109
|
+
describe('sendMessageToSession slash command routing', () => {
|
|
1110
|
+
test('uses /command endpoint for slash commands', async () => {
|
|
1111
|
+
const { sendMessageToSession } = await import('../../service/actions.js');
|
|
1112
|
+
|
|
1113
|
+
let commandCalled = false;
|
|
1114
|
+
let commandBody = null;
|
|
1115
|
+
let messageCalled = false;
|
|
1116
|
+
|
|
1117
|
+
const mockFetch = async (url, opts) => {
|
|
1118
|
+
const urlObj = new URL(url);
|
|
1119
|
+
|
|
1120
|
+
if (urlObj.pathname.includes('/command') && opts?.method === 'POST') {
|
|
1121
|
+
commandCalled = true;
|
|
1122
|
+
commandBody = JSON.parse(opts.body);
|
|
1123
|
+
return {
|
|
1124
|
+
ok: true,
|
|
1125
|
+
json: async () => ({ success: true }),
|
|
1126
|
+
};
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
if (urlObj.pathname.includes('/message') && opts?.method === 'POST') {
|
|
1130
|
+
messageCalled = true;
|
|
1131
|
+
return {
|
|
1132
|
+
ok: true,
|
|
1133
|
+
json: async () => ({ success: true }),
|
|
1134
|
+
};
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
// PATCH for title update
|
|
1138
|
+
if (opts?.method === 'PATCH') {
|
|
1139
|
+
return { ok: true, json: async () => ({}) };
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
return { ok: false, text: async () => 'Not found' };
|
|
1143
|
+
};
|
|
1144
|
+
|
|
1145
|
+
const result = await sendMessageToSession(
|
|
1146
|
+
'http://localhost:4096',
|
|
1147
|
+
'ses_existing',
|
|
1148
|
+
'/path/to/project',
|
|
1149
|
+
'/devcontainer https://github.com/org/repo/issues/456',
|
|
1150
|
+
{ fetch: mockFetch }
|
|
1151
|
+
);
|
|
1152
|
+
|
|
1153
|
+
assert.ok(result.success, 'Should succeed');
|
|
1154
|
+
assert.ok(commandCalled, 'Should call /command endpoint');
|
|
1155
|
+
assert.ok(!messageCalled, 'Should NOT call /message endpoint');
|
|
1156
|
+
assert.strictEqual(commandBody.command, 'devcontainer', 'Should pass command name');
|
|
1157
|
+
assert.strictEqual(commandBody.arguments, 'https://github.com/org/repo/issues/456', 'Should pass arguments');
|
|
1158
|
+
});
|
|
1159
|
+
|
|
1160
|
+
test('uses /message endpoint for regular prompts', async () => {
|
|
1161
|
+
const { sendMessageToSession } = await import('../../service/actions.js');
|
|
1162
|
+
|
|
1163
|
+
let commandCalled = false;
|
|
1164
|
+
let messageCalled = false;
|
|
1165
|
+
let messageBody = null;
|
|
1166
|
+
|
|
1167
|
+
const mockFetch = async (url, opts) => {
|
|
1168
|
+
const urlObj = new URL(url);
|
|
1169
|
+
|
|
1170
|
+
if (urlObj.pathname.includes('/command') && opts?.method === 'POST') {
|
|
1171
|
+
commandCalled = true;
|
|
1172
|
+
return {
|
|
1173
|
+
ok: true,
|
|
1174
|
+
json: async () => ({ success: true }),
|
|
1175
|
+
};
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
if (urlObj.pathname.includes('/message') && opts?.method === 'POST') {
|
|
1179
|
+
messageCalled = true;
|
|
1180
|
+
messageBody = JSON.parse(opts.body);
|
|
1181
|
+
return {
|
|
1182
|
+
ok: true,
|
|
1183
|
+
json: async () => ({ success: true }),
|
|
1184
|
+
};
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
return { ok: false, text: async () => 'Not found' };
|
|
1188
|
+
};
|
|
1189
|
+
|
|
1190
|
+
const result = await sendMessageToSession(
|
|
1191
|
+
'http://localhost:4096',
|
|
1192
|
+
'ses_existing',
|
|
1193
|
+
'/path/to/project',
|
|
1194
|
+
'Please fix the bug',
|
|
1195
|
+
{ fetch: mockFetch }
|
|
1196
|
+
);
|
|
1197
|
+
|
|
1198
|
+
assert.ok(result.success, 'Should succeed');
|
|
1199
|
+
assert.ok(!commandCalled, 'Should NOT call /command endpoint');
|
|
1200
|
+
assert.ok(messageCalled, 'Should call /message endpoint');
|
|
1201
|
+
assert.strictEqual(messageBody.parts[0].text, 'Please fix the bug', 'Should pass prompt as message text');
|
|
1202
|
+
});
|
|
1203
|
+
|
|
1204
|
+
test('passes agent and model to /command endpoint', async () => {
|
|
1205
|
+
const { sendMessageToSession } = await import('../../service/actions.js');
|
|
1206
|
+
|
|
1207
|
+
let commandBody = null;
|
|
1208
|
+
|
|
1209
|
+
const mockFetch = async (url, opts) => {
|
|
1210
|
+
const urlObj = new URL(url);
|
|
1211
|
+
|
|
1212
|
+
if (urlObj.pathname.includes('/command') && opts?.method === 'POST') {
|
|
1213
|
+
commandBody = JSON.parse(opts.body);
|
|
1214
|
+
return {
|
|
1215
|
+
ok: true,
|
|
1216
|
+
json: async () => ({ success: true }),
|
|
1217
|
+
};
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
// PATCH for title update
|
|
1221
|
+
if (opts?.method === 'PATCH') {
|
|
1222
|
+
return { ok: true, json: async () => ({}) };
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
return { ok: false, text: async () => 'Not found' };
|
|
1226
|
+
};
|
|
1227
|
+
|
|
1228
|
+
await sendMessageToSession(
|
|
1229
|
+
'http://localhost:4096',
|
|
1230
|
+
'ses_existing',
|
|
1231
|
+
'/path/to/project',
|
|
1232
|
+
'/review https://github.com/org/repo/pull/123',
|
|
1233
|
+
{
|
|
1234
|
+
fetch: mockFetch,
|
|
1235
|
+
agent: 'code',
|
|
1236
|
+
model: 'anthropic/claude-sonnet-4-20250514',
|
|
1237
|
+
}
|
|
1238
|
+
);
|
|
1239
|
+
|
|
1240
|
+
assert.strictEqual(commandBody.agent, 'code', 'Should pass agent');
|
|
1241
|
+
assert.strictEqual(commandBody.model, 'anthropic/claude-sonnet-4-20250514', 'Should pass model as string');
|
|
1242
|
+
});
|
|
906
1243
|
});
|
|
907
1244
|
|
|
908
1245
|
describe('session reuse', () => {
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for service/server.js - HTTP server and health endpoint
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { test, describe, afterEach } from 'node:test';
|
|
6
|
+
import assert from 'node:assert';
|
|
7
|
+
import { readFileSync } from 'fs';
|
|
8
|
+
import { join, dirname } from 'path';
|
|
9
|
+
import { fileURLToPath } from 'url';
|
|
10
|
+
|
|
11
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
12
|
+
const __dirname = dirname(__filename);
|
|
13
|
+
|
|
14
|
+
// Get expected version from package.json
|
|
15
|
+
function getExpectedVersion() {
|
|
16
|
+
const packagePath = join(__dirname, '..', '..', 'package.json');
|
|
17
|
+
const pkg = JSON.parse(readFileSync(packagePath, 'utf8'));
|
|
18
|
+
return pkg.version;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('service/server.js', () => {
|
|
22
|
+
let service = null;
|
|
23
|
+
|
|
24
|
+
afterEach(async () => {
|
|
25
|
+
if (service) {
|
|
26
|
+
const { stopService } = await import('../../service/server.js');
|
|
27
|
+
await stopService(service);
|
|
28
|
+
service = null;
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe('health endpoint', () => {
|
|
33
|
+
test('returns JSON with status and version', async () => {
|
|
34
|
+
const { startService, stopService } = await import('../../service/server.js');
|
|
35
|
+
|
|
36
|
+
// Start service on random port
|
|
37
|
+
service = await startService({
|
|
38
|
+
httpPort: 0, // Let OS assign port
|
|
39
|
+
enablePolling: false
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const port = service.httpServer.address().port;
|
|
43
|
+
const res = await fetch(`http://localhost:${port}/health`);
|
|
44
|
+
|
|
45
|
+
assert.strictEqual(res.status, 200);
|
|
46
|
+
assert.strictEqual(res.headers.get('content-type'), 'application/json');
|
|
47
|
+
|
|
48
|
+
const data = await res.json();
|
|
49
|
+
assert.strictEqual(data.status, 'ok');
|
|
50
|
+
assert.strictEqual(typeof data.version, 'string');
|
|
51
|
+
assert.strictEqual(data.version, getExpectedVersion());
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe('CORS', () => {
|
|
57
|
+
test('OPTIONS returns CORS headers', async () => {
|
|
58
|
+
const { startService } = await import('../../service/server.js');
|
|
59
|
+
|
|
60
|
+
service = await startService({
|
|
61
|
+
httpPort: 0,
|
|
62
|
+
enablePolling: false
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const port = service.httpServer.address().port;
|
|
66
|
+
const res = await fetch(`http://localhost:${port}/health`, {
|
|
67
|
+
method: 'OPTIONS'
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
assert.strictEqual(res.status, 204);
|
|
71
|
+
assert.strictEqual(res.headers.get('access-control-allow-origin'), '*');
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe('unknown routes', () => {
|
|
76
|
+
test('returns 404 for unknown paths', async () => {
|
|
77
|
+
const { startService } = await import('../../service/server.js');
|
|
78
|
+
|
|
79
|
+
service = await startService({
|
|
80
|
+
httpPort: 0,
|
|
81
|
+
enablePolling: false
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const port = service.httpServer.address().port;
|
|
85
|
+
const res = await fetch(`http://localhost:${port}/unknown`);
|
|
86
|
+
|
|
87
|
+
assert.strictEqual(res.status, 404);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
});
|