ai-cli-mcp 2.14.1 → 2.16.0
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/.github/dependabot.yml +28 -0
- package/.github/workflows/ci.yml +4 -1
- package/.github/workflows/dependency-review.yml +22 -0
- package/CHANGELOG.md +14 -0
- package/README.ja.md +83 -6
- package/README.md +83 -7
- package/dist/__tests__/app-cli.test.js +80 -5
- package/dist/__tests__/cli-bin-smoke.test.js +43 -0
- package/dist/__tests__/cli-builder.test.js +93 -15
- package/dist/__tests__/cli-process-service.test.js +162 -0
- package/dist/__tests__/cli-utils.test.js +31 -0
- package/dist/__tests__/e2e.test.js +79 -52
- package/dist/__tests__/mcp-contract.test.js +162 -0
- package/dist/__tests__/parsers.test.js +224 -1
- package/dist/__tests__/peek.test.js +35 -0
- package/dist/__tests__/process-management.test.js +160 -1
- package/dist/__tests__/server.test.js +39 -9
- package/dist/__tests__/utils/opencode-mock.js +91 -0
- package/dist/__tests__/validation.test.js +40 -2
- package/dist/app/cli.js +47 -5
- package/dist/app/mcp.js +53 -4
- package/dist/cli-builder.js +67 -28
- package/dist/cli-parse.js +11 -5
- package/dist/cli-process-service.js +241 -20
- package/dist/cli-utils.js +14 -23
- package/dist/cli.js +6 -4
- package/dist/model-catalog.js +13 -1
- package/dist/parsers.js +242 -28
- package/dist/peek.js +56 -0
- package/dist/process-result.js +9 -2
- package/dist/process-service.js +103 -17
- package/dist/server.js +1 -2
- package/package.json +9 -6
- package/src/__tests__/app-cli.test.ts +95 -4
- package/src/__tests__/cli-bin-smoke.test.ts +62 -1
- package/src/__tests__/cli-builder.test.ts +111 -15
- package/src/__tests__/cli-process-service.test.ts +180 -0
- package/src/__tests__/cli-utils.test.ts +34 -0
- package/src/__tests__/e2e.test.ts +87 -55
- package/src/__tests__/mcp-contract.test.ts +188 -0
- package/src/__tests__/parsers.test.ts +260 -1
- package/src/__tests__/peek.test.ts +43 -0
- package/src/__tests__/process-management.test.ts +185 -1
- package/src/__tests__/server.test.ts +49 -13
- package/src/__tests__/utils/opencode-mock.ts +108 -0
- package/src/__tests__/validation.test.ts +48 -2
- package/src/app/cli.ts +52 -4
- package/src/app/mcp.ts +54 -4
- package/src/cli-builder.ts +91 -32
- package/src/cli-parse.ts +11 -5
- package/src/cli-process-service.ts +304 -17
- package/src/cli-utils.ts +37 -33
- package/src/cli.ts +6 -4
- package/src/model-catalog.ts +24 -1
- package/src/parsers.ts +299 -33
- package/src/peek.ts +88 -0
- package/src/process-result.ts +11 -2
- package/src/process-service.ts +134 -15
- package/src/server.ts +2 -2
- package/vitest.config.unit.ts +2 -3
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
CLI_HELP_TEXT,
|
|
4
4
|
DOCTOR_HELP_TEXT,
|
|
5
5
|
MODELS_HELP_TEXT,
|
|
6
|
+
PEEK_HELP_TEXT,
|
|
6
7
|
RESULT_HELP_TEXT,
|
|
7
8
|
RUN_HELP_TEXT,
|
|
8
9
|
WAIT_HELP_TEXT,
|
|
@@ -199,6 +200,64 @@ describe('ai-cli app', () => {
|
|
|
199
200
|
expect(waitForProcesses).not.toHaveBeenCalled();
|
|
200
201
|
});
|
|
201
202
|
|
|
203
|
+
it('dispatches peek with deduped pid arguments and time', async () => {
|
|
204
|
+
const stdout = vi.fn();
|
|
205
|
+
const stderr = vi.fn();
|
|
206
|
+
const peekProcesses = vi.fn().mockResolvedValue({
|
|
207
|
+
peek_started_at: '2026-04-11T12:34:56.789Z',
|
|
208
|
+
observed_duration_sec: 0.01,
|
|
209
|
+
processes: [],
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
const exitCode = await runCli(
|
|
213
|
+
['peek', '123', '456', '123', '--time', '5'],
|
|
214
|
+
{
|
|
215
|
+
stdout,
|
|
216
|
+
stderr,
|
|
217
|
+
peekProcesses,
|
|
218
|
+
}
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
expect(exitCode).toBe(0);
|
|
222
|
+
expect(peekProcesses).toHaveBeenCalledWith([123, 456], 5);
|
|
223
|
+
expect(stdout).toHaveBeenCalledWith(expect.stringContaining('"peek_started_at"'));
|
|
224
|
+
expect(stderr).not.toHaveBeenCalled();
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('defaults peek time and rejects --follow', async () => {
|
|
228
|
+
const stdout = vi.fn();
|
|
229
|
+
const stderr = vi.fn();
|
|
230
|
+
const peekProcesses = vi.fn().mockResolvedValue({
|
|
231
|
+
peek_started_at: '2026-04-11T12:34:56.789Z',
|
|
232
|
+
observed_duration_sec: 0.01,
|
|
233
|
+
processes: [],
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
const defaultExitCode = await runCli(['peek', '123'], { stdout, stderr, peekProcesses });
|
|
237
|
+
expect(defaultExitCode).toBe(0);
|
|
238
|
+
expect(peekProcesses).toHaveBeenCalledWith([123], 10);
|
|
239
|
+
|
|
240
|
+
const followExitCode = await runCli(['peek', '123', '--follow'], { stdout, stderr, peekProcesses });
|
|
241
|
+
expect(followExitCode).toBe(1);
|
|
242
|
+
expect(stderr).toHaveBeenCalledWith('peek does not support --follow in v1\n');
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('rejects invalid peek time values', async () => {
|
|
246
|
+
const stdout = vi.fn();
|
|
247
|
+
const stderr = vi.fn();
|
|
248
|
+
const peekProcesses = vi.fn();
|
|
249
|
+
|
|
250
|
+
const exitCode = await runCli(['peek', '123', '--time', '1.5'], {
|
|
251
|
+
stdout,
|
|
252
|
+
stderr,
|
|
253
|
+
peekProcesses,
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
expect(exitCode).toBe(1);
|
|
257
|
+
expect(stderr).toHaveBeenCalledWith(expect.stringContaining('peek_time_sec must be a positive integer'));
|
|
258
|
+
expect(peekProcesses).not.toHaveBeenCalled();
|
|
259
|
+
});
|
|
260
|
+
|
|
202
261
|
it('dispatches ps, result, and kill', async () => {
|
|
203
262
|
const stdout = vi.fn();
|
|
204
263
|
const stderr = vi.fn();
|
|
@@ -236,12 +295,22 @@ describe('ai-cli app', () => {
|
|
|
236
295
|
const stderr = vi.fn();
|
|
237
296
|
|
|
238
297
|
const exitCode = await runCli(['models'], { stdout, stderr });
|
|
298
|
+
const payload = JSON.parse(stdout.mock.calls[0][0]);
|
|
239
299
|
|
|
240
300
|
expect(exitCode).toBe(0);
|
|
241
|
-
expect(
|
|
242
|
-
expect(
|
|
243
|
-
expect(
|
|
244
|
-
expect(
|
|
301
|
+
expect(payload.aliases).toEqual(expect.any(Array));
|
|
302
|
+
expect(payload.claude).toContain('sonnet');
|
|
303
|
+
expect(payload.codex).toContain('gpt-5.4');
|
|
304
|
+
expect(payload.forge).toEqual(['forge']);
|
|
305
|
+
expect(payload.opencode).toEqual(['opencode']);
|
|
306
|
+
expect(payload.dynamicModelBackends).toEqual({
|
|
307
|
+
opencode: {
|
|
308
|
+
explicitPrefix: 'oc-',
|
|
309
|
+
explicitPattern: 'oc-<provider/model>',
|
|
310
|
+
discoveryCommand: 'opencode models',
|
|
311
|
+
modelsAreDynamic: true,
|
|
312
|
+
},
|
|
313
|
+
});
|
|
245
314
|
expect(stderr).not.toHaveBeenCalled();
|
|
246
315
|
});
|
|
247
316
|
|
|
@@ -273,6 +342,12 @@ describe('ai-cli app', () => {
|
|
|
273
342
|
available: true,
|
|
274
343
|
lookup: 'path',
|
|
275
344
|
},
|
|
345
|
+
opencode: {
|
|
346
|
+
configuredCommand: 'opencode',
|
|
347
|
+
resolvedPath: '/tmp/bin/opencode',
|
|
348
|
+
available: true,
|
|
349
|
+
lookup: 'path',
|
|
350
|
+
},
|
|
276
351
|
});
|
|
277
352
|
|
|
278
353
|
const exitCode = await runCli(['doctor'], { stdout, stderr, getDoctorStatus });
|
|
@@ -307,6 +382,8 @@ describe('ai-cli app', () => {
|
|
|
307
382
|
expect(stdout).toHaveBeenCalledWith(expect.stringContaining('gpt-5.2-codex'));
|
|
308
383
|
expect(stdout).toHaveBeenCalledWith(expect.stringContaining('gemini-2.5-pro'));
|
|
309
384
|
expect(stdout).toHaveBeenCalledWith(expect.stringContaining('forge'));
|
|
385
|
+
expect(stdout).toHaveBeenCalledWith(expect.stringContaining('opencode'));
|
|
386
|
+
expect(stdout).toHaveBeenCalledWith(expect.stringContaining('oc-openai/gpt-5.4'));
|
|
310
387
|
expect(stderr).not.toHaveBeenCalled();
|
|
311
388
|
});
|
|
312
389
|
|
|
@@ -336,6 +413,18 @@ describe('ai-cli app', () => {
|
|
|
336
413
|
expect(stderr).not.toHaveBeenCalled();
|
|
337
414
|
});
|
|
338
415
|
|
|
416
|
+
it('prints detailed help for peek --help', async () => {
|
|
417
|
+
const stdout = vi.fn();
|
|
418
|
+
const stderr = vi.fn();
|
|
419
|
+
|
|
420
|
+
const exitCode = await runCli(['peek', '--help'], { stdout, stderr });
|
|
421
|
+
|
|
422
|
+
expect(exitCode).toBe(0);
|
|
423
|
+
expect(stdout).toHaveBeenCalledWith(PEEK_HELP_TEXT);
|
|
424
|
+
expect(stdout).toHaveBeenCalledWith(expect.stringContaining('No --follow mode'));
|
|
425
|
+
expect(stderr).not.toHaveBeenCalled();
|
|
426
|
+
});
|
|
427
|
+
|
|
339
428
|
it('prints detailed help for models --help', async () => {
|
|
340
429
|
const stdout = vi.fn();
|
|
341
430
|
const stderr = vi.fn();
|
|
@@ -355,6 +444,7 @@ describe('ai-cli app', () => {
|
|
|
355
444
|
|
|
356
445
|
expect(exitCode).toBe(0);
|
|
357
446
|
expect(stdout).toHaveBeenCalledWith(DOCTOR_HELP_TEXT);
|
|
447
|
+
expect(stdout).toHaveBeenCalledWith(expect.stringContaining('OpenCode'));
|
|
358
448
|
expect(stderr).not.toHaveBeenCalled();
|
|
359
449
|
});
|
|
360
450
|
|
|
@@ -366,6 +456,7 @@ describe('ai-cli app', () => {
|
|
|
366
456
|
|
|
367
457
|
expect(exitCode).toBe(0);
|
|
368
458
|
expect(stdout).toHaveBeenCalledWith(DOCTOR_HELP_TEXT);
|
|
459
|
+
expect(stdout).toHaveBeenCalledWith(expect.stringContaining('OpenCode'));
|
|
369
460
|
expect(stderr).not.toHaveBeenCalled();
|
|
370
461
|
});
|
|
371
462
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { execFileSync } from 'node:child_process';
|
|
2
|
-
import { chmodSync,
|
|
2
|
+
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { tmpdir } from 'node:os';
|
|
4
4
|
import { delimiter, join } from 'node:path';
|
|
5
5
|
import { afterEach, describe, expect, it } from 'vitest';
|
|
@@ -24,6 +24,62 @@ afterEach(() => {
|
|
|
24
24
|
}
|
|
25
25
|
});
|
|
26
26
|
|
|
27
|
+
describe('cli helper entrypoint smoke', () => {
|
|
28
|
+
it('prints help for cli.run with OpenCode examples', () => {
|
|
29
|
+
const output = execFileSync(
|
|
30
|
+
'node',
|
|
31
|
+
['--import', 'tsx', 'src/cli.ts', '--help'],
|
|
32
|
+
{
|
|
33
|
+
cwd: process.cwd(),
|
|
34
|
+
encoding: 'utf8',
|
|
35
|
+
env: process.env,
|
|
36
|
+
}
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
expect(output).toContain('Usage: npm run -s cli.run -- --model <model> --workFolder <path> --prompt "..." [options]');
|
|
40
|
+
expect(output).toContain('opencode');
|
|
41
|
+
expect(output).toContain('oc-openai/gpt-5.4');
|
|
42
|
+
expect(output).toContain('OpenCode');
|
|
43
|
+
expect(output).toContain('npm run -s cli.run.parse -- --agent opencode < raw.txt');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('prints help for cli.run.parse with OpenCode agent support', () => {
|
|
47
|
+
const output = execFileSync(
|
|
48
|
+
'node',
|
|
49
|
+
['--import', 'tsx', 'src/cli-parse.ts', '--help'],
|
|
50
|
+
{
|
|
51
|
+
cwd: process.cwd(),
|
|
52
|
+
encoding: 'utf8',
|
|
53
|
+
env: process.env,
|
|
54
|
+
}
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
expect(output).toContain('Usage: npm run -s cli.run.parse -- --agent <claude|codex|gemini|forge|opencode>');
|
|
58
|
+
expect(output).toContain('Agent type: claude, codex, gemini, forge, or opencode');
|
|
59
|
+
expect(output).toContain('npm run -s cli.run.parse -- --agent opencode < raw.txt');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('parses OpenCode NDJSON through cli.run.parse', () => {
|
|
63
|
+
const output = execFileSync(
|
|
64
|
+
'node',
|
|
65
|
+
['--import', 'tsx', 'src/cli-parse.ts', '--agent', 'opencode'],
|
|
66
|
+
{
|
|
67
|
+
cwd: process.cwd(),
|
|
68
|
+
encoding: 'utf8',
|
|
69
|
+
env: process.env,
|
|
70
|
+
input: '{"type":"step_start","sessionID":"ses_cli_parse"}\n{"type":"text","sessionID":"ses_cli_parse","part":{"type":"text","text":"Hello from cli.parse"}}\n{"type":"step_finish","sessionID":"ses_cli_parse","part":{"type":"step-finish","tokens":{"total":9},"cost":1}}\n',
|
|
71
|
+
}
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
expect(JSON.parse(output)).toEqual({
|
|
75
|
+
message: 'Hello from cli.parse',
|
|
76
|
+
session_id: 'ses_cli_parse',
|
|
77
|
+
tokens: { total: 9 },
|
|
78
|
+
cost: 1,
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
27
83
|
describe('ai-cli entrypoint smoke', () => {
|
|
28
84
|
it('prints doctor output for the ai-cli entrypoint', () => {
|
|
29
85
|
const fakeBinDir = makeTempDir('ai-cli-bin-');
|
|
@@ -31,6 +87,7 @@ describe('ai-cli entrypoint smoke', () => {
|
|
|
31
87
|
writeExecutable(fakeBinDir, 'codex');
|
|
32
88
|
writeExecutable(fakeBinDir, 'gemini');
|
|
33
89
|
writeExecutable(fakeBinDir, 'forge');
|
|
90
|
+
writeExecutable(fakeBinDir, 'opencode');
|
|
34
91
|
|
|
35
92
|
const output = execFileSync(
|
|
36
93
|
'node',
|
|
@@ -45,6 +102,7 @@ describe('ai-cli entrypoint smoke', () => {
|
|
|
45
102
|
CODEX_CLI_NAME: 'codex',
|
|
46
103
|
GEMINI_CLI_NAME: 'gemini',
|
|
47
104
|
FORGE_CLI_NAME: 'forge',
|
|
105
|
+
OPENCODE_CLI_NAME: 'opencode',
|
|
48
106
|
},
|
|
49
107
|
}
|
|
50
108
|
);
|
|
@@ -53,6 +111,7 @@ describe('ai-cli entrypoint smoke', () => {
|
|
|
53
111
|
expect(output).toContain('"codex"');
|
|
54
112
|
expect(output).toContain('"gemini"');
|
|
55
113
|
expect(output).toContain('"forge"');
|
|
114
|
+
expect(output).toContain('"opencode"');
|
|
56
115
|
expect(output).toContain('"available": true');
|
|
57
116
|
});
|
|
58
117
|
|
|
@@ -71,5 +130,7 @@ describe('ai-cli entrypoint smoke', () => {
|
|
|
71
130
|
expect(output).toContain('--model <model>');
|
|
72
131
|
expect(output).toContain('claude-ultra');
|
|
73
132
|
expect(output).toContain('forge');
|
|
133
|
+
expect(output).toContain('opencode');
|
|
134
|
+
expect(output).toContain('oc-openai/gpt-5.4');
|
|
74
135
|
});
|
|
75
136
|
});
|
|
@@ -23,6 +23,7 @@ const DEFAULT_CLI_PATHS = {
|
|
|
23
23
|
codex: '/usr/bin/codex',
|
|
24
24
|
gemini: '/usr/bin/gemini',
|
|
25
25
|
forge: '/usr/bin/forge',
|
|
26
|
+
opencode: '/usr/bin/opencode',
|
|
26
27
|
};
|
|
27
28
|
|
|
28
29
|
describe('cli-builder', () => {
|
|
@@ -104,6 +105,15 @@ describe('cli-builder', () => {
|
|
|
104
105
|
'reasoning_effort is not supported for forge.'
|
|
105
106
|
);
|
|
106
107
|
});
|
|
108
|
+
|
|
109
|
+
it('should reject reasoning_effort for opencode explicitly', () => {
|
|
110
|
+
expect(() => getReasoningEffort('opencode', 'high')).toThrow(
|
|
111
|
+
'reasoning_effort is not supported for opencode.'
|
|
112
|
+
);
|
|
113
|
+
expect(() => getReasoningEffort('oc-openai/gpt-5.4', 'high')).toThrow(
|
|
114
|
+
'reasoning_effort is not supported for opencode.'
|
|
115
|
+
);
|
|
116
|
+
});
|
|
107
117
|
});
|
|
108
118
|
|
|
109
119
|
describe('buildCliCommand', () => {
|
|
@@ -378,7 +388,7 @@ describe('cli-builder', () => {
|
|
|
378
388
|
expect(cmd.cliPath).toBe('/usr/bin/gemini');
|
|
379
389
|
expect(cmd.args).toContain('-y');
|
|
380
390
|
expect(cmd.args).toContain('--output-format');
|
|
381
|
-
expect(cmd.args).toContain('json');
|
|
391
|
+
expect(cmd.args).toContain('stream-json');
|
|
382
392
|
expect(cmd.args).toContain('--model');
|
|
383
393
|
expect(cmd.args).toContain('gemini-2.5-pro');
|
|
384
394
|
});
|
|
@@ -409,43 +419,129 @@ describe('cli-builder', () => {
|
|
|
409
419
|
});
|
|
410
420
|
});
|
|
411
421
|
|
|
412
|
-
describe('
|
|
413
|
-
it('should build
|
|
422
|
+
describe('opencode agent', () => {
|
|
423
|
+
it('should build default opencode command without --model', () => {
|
|
414
424
|
const cmd = buildCliCommand({
|
|
415
425
|
prompt: 'test',
|
|
416
426
|
workFolder: '/tmp',
|
|
417
|
-
model: '
|
|
427
|
+
model: 'opencode',
|
|
418
428
|
cliPaths: DEFAULT_CLI_PATHS,
|
|
419
429
|
});
|
|
420
430
|
|
|
421
|
-
expect(cmd.agent).toBe('
|
|
422
|
-
expect(cmd.cliPath).toBe('/usr/bin/
|
|
423
|
-
expect(cmd.
|
|
424
|
-
expect(cmd.args).toEqual(['
|
|
431
|
+
expect(cmd.agent).toBe('opencode');
|
|
432
|
+
expect(cmd.cliPath).toBe('/usr/bin/opencode');
|
|
433
|
+
expect(cmd.cwd).toBe('/tmp');
|
|
434
|
+
expect(cmd.args).toEqual(['run', '--format', 'json', '--dir', '/tmp', 'test']);
|
|
435
|
+
expect(cmd.args).not.toContain('--model');
|
|
425
436
|
});
|
|
426
437
|
|
|
427
|
-
it('should
|
|
438
|
+
it('should route valid explicit OpenCode model syntax', () => {
|
|
428
439
|
const cmd = buildCliCommand({
|
|
429
440
|
prompt: 'test',
|
|
430
441
|
workFolder: '/tmp',
|
|
431
|
-
model: '
|
|
432
|
-
session_id: 'forge-conv-123',
|
|
442
|
+
model: 'oc-openai/gpt-5.4',
|
|
433
443
|
cliPaths: DEFAULT_CLI_PATHS,
|
|
434
444
|
});
|
|
435
445
|
|
|
436
|
-
expect(cmd.
|
|
446
|
+
expect(cmd.agent).toBe('opencode');
|
|
447
|
+
expect(cmd.resolvedModel).toBe('oc-openai/gpt-5.4');
|
|
448
|
+
expect(cmd.args).toEqual([
|
|
449
|
+
'run',
|
|
450
|
+
'--format',
|
|
451
|
+
'json',
|
|
452
|
+
'--dir',
|
|
453
|
+
'/tmp',
|
|
454
|
+
'--model',
|
|
455
|
+
'openai/gpt-5.4',
|
|
456
|
+
'test',
|
|
457
|
+
]);
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it.each([
|
|
461
|
+
'oc-',
|
|
462
|
+
'oc-openai',
|
|
463
|
+
'oc-/gpt-5.4',
|
|
464
|
+
'oc-openai/',
|
|
465
|
+
])('should reject invalid explicit OpenCode syntax: %s', (model) => {
|
|
466
|
+
expect(() =>
|
|
467
|
+
buildCliCommand({
|
|
468
|
+
prompt: 'test',
|
|
469
|
+
workFolder: '/tmp',
|
|
470
|
+
model,
|
|
471
|
+
cliPaths: DEFAULT_CLI_PATHS,
|
|
472
|
+
})
|
|
473
|
+
).toThrow('Invalid OpenCode model. Expected exact syntax oc-<provider/model>.');
|
|
437
474
|
});
|
|
438
475
|
|
|
439
|
-
it('
|
|
476
|
+
it.each([' oc-openai/gpt-5.4', 'oc-openai/gpt-5.4 '])(
|
|
477
|
+
'should reject explicit OpenCode models with surrounding whitespace: %s',
|
|
478
|
+
(model) => {
|
|
479
|
+
expect(() =>
|
|
480
|
+
buildCliCommand({
|
|
481
|
+
prompt: 'test',
|
|
482
|
+
workFolder: '/tmp',
|
|
483
|
+
model,
|
|
484
|
+
cliPaths: DEFAULT_CLI_PATHS,
|
|
485
|
+
})
|
|
486
|
+
).toThrow('Invalid OpenCode model. Expected exact syntax oc-<provider/model>.');
|
|
487
|
+
}
|
|
488
|
+
);
|
|
489
|
+
|
|
490
|
+
it('should reject reasoning_effort for OpenCode in command building', () => {
|
|
440
491
|
expect(() =>
|
|
441
492
|
buildCliCommand({
|
|
442
493
|
prompt: 'test',
|
|
443
494
|
workFolder: '/tmp',
|
|
444
|
-
model: '
|
|
495
|
+
model: 'opencode',
|
|
445
496
|
reasoning_effort: 'high',
|
|
446
497
|
cliPaths: DEFAULT_CLI_PATHS,
|
|
447
498
|
})
|
|
448
|
-
).toThrow('reasoning_effort is not supported for
|
|
499
|
+
).toThrow('reasoning_effort is not supported for opencode.');
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
it('should build resumed default OpenCode command', () => {
|
|
503
|
+
const cmd = buildCliCommand({
|
|
504
|
+
prompt: 'resume prompt',
|
|
505
|
+
workFolder: '/tmp',
|
|
506
|
+
model: 'opencode',
|
|
507
|
+
session_id: 'ses-123',
|
|
508
|
+
cliPaths: DEFAULT_CLI_PATHS,
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
expect(cmd.args).toEqual([
|
|
512
|
+
'run',
|
|
513
|
+
'--format',
|
|
514
|
+
'json',
|
|
515
|
+
'--dir',
|
|
516
|
+
'/tmp',
|
|
517
|
+
'--session',
|
|
518
|
+
'ses-123',
|
|
519
|
+
'resume prompt',
|
|
520
|
+
]);
|
|
521
|
+
expect(cmd.args).not.toContain('--model');
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
it('should build resumed explicit OpenCode command', () => {
|
|
525
|
+
const cmd = buildCliCommand({
|
|
526
|
+
prompt: 'resume prompt',
|
|
527
|
+
workFolder: '/tmp',
|
|
528
|
+
model: 'oc-openai/gpt-5.4',
|
|
529
|
+
session_id: 'ses-456',
|
|
530
|
+
cliPaths: DEFAULT_CLI_PATHS,
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
expect(cmd.args).toEqual([
|
|
534
|
+
'run',
|
|
535
|
+
'--format',
|
|
536
|
+
'json',
|
|
537
|
+
'--dir',
|
|
538
|
+
'/tmp',
|
|
539
|
+
'--session',
|
|
540
|
+
'ses-456',
|
|
541
|
+
'--model',
|
|
542
|
+
'openai/gpt-5.4',
|
|
543
|
+
'resume prompt',
|
|
544
|
+
]);
|
|
449
545
|
});
|
|
450
546
|
});
|
|
451
547
|
});
|
|
@@ -3,6 +3,7 @@ import { join } from 'node:path';
|
|
|
3
3
|
import { tmpdir } from 'node:os';
|
|
4
4
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
5
5
|
import { CliProcessService } from '../cli-process-service.js';
|
|
6
|
+
import { createOpenCodeMock } from './utils/opencode-mock.js';
|
|
6
7
|
|
|
7
8
|
function createMockCliScript(dir: string, name: string, options: { ignoreSigterm?: boolean } = {}): string {
|
|
8
9
|
const scriptPath = join(dir, name);
|
|
@@ -66,6 +67,7 @@ describe('CliProcessService', () => {
|
|
|
66
67
|
codex: scriptPath,
|
|
67
68
|
gemini: scriptPath,
|
|
68
69
|
forge: scriptPath,
|
|
70
|
+
opencode: scriptPath,
|
|
69
71
|
},
|
|
70
72
|
});
|
|
71
73
|
|
|
@@ -120,6 +122,74 @@ describe('CliProcessService', () => {
|
|
|
120
122
|
expect(readFileSync(join(processDir, 'meta.json'), 'utf-8')).toContain('"status": "completed"');
|
|
121
123
|
});
|
|
122
124
|
|
|
125
|
+
it('peeks only appended natural-language messages from detached logs', async () => {
|
|
126
|
+
const root = mkdtempSync(join(tmpdir(), 'ai-cli-cli-service-'));
|
|
127
|
+
tempDirs.push(root);
|
|
128
|
+
const scriptPath = join(root, 'mock-claude-peek');
|
|
129
|
+
writeFileSync(
|
|
130
|
+
scriptPath,
|
|
131
|
+
`#!/bin/bash
|
|
132
|
+
printf '%s\n' '{"type":"assistant","message":{"content":[{"type":"text","text":"old cli message"}]}}'
|
|
133
|
+
sleep 2
|
|
134
|
+
printf '%s\n' '{"type":"assistant","message":{"content":[{"type":"text","text":"new cli message"},{"type":"tool_use","id":"tool-1","name":"Read","input":{"file_path":"/tmp/a"}}]}}'
|
|
135
|
+
printf '%s\n' '{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"tool-1","content":"secret"}]}}'
|
|
136
|
+
`
|
|
137
|
+
);
|
|
138
|
+
chmodSync(scriptPath, 0o755);
|
|
139
|
+
const stateDir = join(root, 'state');
|
|
140
|
+
const workFolder = join(root, 'work');
|
|
141
|
+
mkdirSync(workFolder, { recursive: true });
|
|
142
|
+
|
|
143
|
+
const service = new CliProcessService({
|
|
144
|
+
stateDir,
|
|
145
|
+
cliPaths: {
|
|
146
|
+
claude: scriptPath,
|
|
147
|
+
codex: scriptPath,
|
|
148
|
+
gemini: scriptPath,
|
|
149
|
+
forge: scriptPath,
|
|
150
|
+
opencode: scriptPath,
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const runResult = await service.startProcess({
|
|
155
|
+
prompt: 'hello peek',
|
|
156
|
+
cwd: workFolder,
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
const processDir = join(stateDir, 'cwds', encodeCwd(realpathSync(workFolder)), String(runResult.pid));
|
|
160
|
+
const stdoutPath = join(processDir, 'stdout.log');
|
|
161
|
+
const startedAt = Date.now();
|
|
162
|
+
while (Date.now() - startedAt < 5000 && !readFileSync(stdoutPath, 'utf-8').includes('old cli message')) {
|
|
163
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
164
|
+
}
|
|
165
|
+
expect(readFileSync(stdoutPath, 'utf-8')).toContain('old cli message');
|
|
166
|
+
|
|
167
|
+
const peekResult = await service.peekProcesses([runResult.pid, runResult.pid, 999999], 3);
|
|
168
|
+
|
|
169
|
+
expect(peekResult.processes).toHaveLength(2);
|
|
170
|
+
expect(peekResult.processes[0]).toMatchObject({
|
|
171
|
+
pid: runResult.pid,
|
|
172
|
+
agent: 'claude',
|
|
173
|
+
status: 'completed',
|
|
174
|
+
messages: [
|
|
175
|
+
{
|
|
176
|
+
ts: expect.any(String),
|
|
177
|
+
text: 'new cli message',
|
|
178
|
+
},
|
|
179
|
+
],
|
|
180
|
+
truncated: false,
|
|
181
|
+
error: null,
|
|
182
|
+
});
|
|
183
|
+
expect(peekResult.processes[1]).toEqual({
|
|
184
|
+
pid: 999999,
|
|
185
|
+
agent: null,
|
|
186
|
+
status: 'not_found',
|
|
187
|
+
messages: [],
|
|
188
|
+
truncated: false,
|
|
189
|
+
error: 'process not found',
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
123
193
|
it('returns compact results by default and full results when verbose is true', async () => {
|
|
124
194
|
const root = mkdtempSync(join(tmpdir(), 'ai-cli-cli-service-'));
|
|
125
195
|
tempDirs.push(root);
|
|
@@ -145,6 +215,7 @@ printf '%s\n' '{"type":"system","session_id":"session-cli-1"}'
|
|
|
145
215
|
codex: scriptPath,
|
|
146
216
|
gemini: scriptPath,
|
|
147
217
|
forge: scriptPath,
|
|
218
|
+
opencode: scriptPath,
|
|
148
219
|
},
|
|
149
220
|
});
|
|
150
221
|
|
|
@@ -255,6 +326,7 @@ printf '%s\n' '{"type":"system","session_id":"session-cli-1"}'
|
|
|
255
326
|
codex: scriptPath,
|
|
256
327
|
gemini: scriptPath,
|
|
257
328
|
forge: scriptPath,
|
|
329
|
+
opencode: scriptPath,
|
|
258
330
|
},
|
|
259
331
|
});
|
|
260
332
|
|
|
@@ -294,6 +366,7 @@ printf '%s\n' '{"type":"system","session_id":"session-cli-1"}'
|
|
|
294
366
|
codex: '/bin/sh',
|
|
295
367
|
gemini: '/bin/sh',
|
|
296
368
|
forge: '/bin/sh',
|
|
369
|
+
opencode: '/bin/sh',
|
|
297
370
|
},
|
|
298
371
|
});
|
|
299
372
|
|
|
@@ -368,6 +441,7 @@ printf '%s\n' '{"type":"system","session_id":"session-cli-1"}'
|
|
|
368
441
|
codex: '/bin/sh',
|
|
369
442
|
gemini: '/bin/sh',
|
|
370
443
|
forge: '/bin/sh',
|
|
444
|
+
opencode: '/bin/sh',
|
|
371
445
|
},
|
|
372
446
|
});
|
|
373
447
|
|
|
@@ -426,6 +500,7 @@ printf '%s\n' '{"type":"system","session_id":"session-cli-1"}'
|
|
|
426
500
|
codex: '/bin/sh',
|
|
427
501
|
gemini: '/bin/sh',
|
|
428
502
|
forge: '/bin/sh',
|
|
503
|
+
opencode: '/bin/sh',
|
|
429
504
|
},
|
|
430
505
|
});
|
|
431
506
|
|
|
@@ -502,6 +577,7 @@ printf '%s\n' '{"type":"system","session_id":"session-cli-1"}'
|
|
|
502
577
|
codex: '/bin/sh',
|
|
503
578
|
gemini: '/bin/sh',
|
|
504
579
|
forge: '/bin/sh',
|
|
580
|
+
opencode: '/bin/sh',
|
|
505
581
|
},
|
|
506
582
|
});
|
|
507
583
|
|
|
@@ -564,6 +640,7 @@ Forge assistant reply
|
|
|
564
640
|
codex: '/bin/sh',
|
|
565
641
|
gemini: '/bin/sh',
|
|
566
642
|
forge: '/bin/sh',
|
|
643
|
+
opencode: '/bin/sh',
|
|
567
644
|
},
|
|
568
645
|
});
|
|
569
646
|
|
|
@@ -575,4 +652,107 @@ Forge assistant reply
|
|
|
575
652
|
session_id: 'forge-conv-1',
|
|
576
653
|
});
|
|
577
654
|
});
|
|
655
|
+
|
|
656
|
+
it('parses successful OpenCode detached runs from stdout only', async () => {
|
|
657
|
+
const root = mkdtempSync(join(tmpdir(), 'ai-cli-cli-service-'));
|
|
658
|
+
tempDirs.push(root);
|
|
659
|
+
const stateDir = join(root, 'state');
|
|
660
|
+
const workFolder = join(root, 'opencode-project');
|
|
661
|
+
mkdirSync(workFolder, { recursive: true });
|
|
662
|
+
const argsLogPath = join(root, 'opencode-args.log');
|
|
663
|
+
const { scriptPath } = createOpenCodeMock(root, { argsLogPath });
|
|
664
|
+
|
|
665
|
+
const service = new CliProcessService({
|
|
666
|
+
stateDir,
|
|
667
|
+
cliPaths: {
|
|
668
|
+
claude: '/bin/sh',
|
|
669
|
+
codex: '/bin/sh',
|
|
670
|
+
gemini: '/bin/sh',
|
|
671
|
+
forge: '/bin/sh',
|
|
672
|
+
opencode: scriptPath,
|
|
673
|
+
},
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
const runResult = await service.startProcess({
|
|
677
|
+
prompt: 'hello opencode',
|
|
678
|
+
cwd: workFolder,
|
|
679
|
+
model: 'opencode',
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
const waited = await service.waitForProcesses([runResult.pid], 5);
|
|
683
|
+
expect(waited).toHaveLength(1);
|
|
684
|
+
expect(waited[0]).toMatchObject({
|
|
685
|
+
pid: runResult.pid,
|
|
686
|
+
agent: 'opencode',
|
|
687
|
+
status: 'completed',
|
|
688
|
+
exitCode: 0,
|
|
689
|
+
model: 'opencode',
|
|
690
|
+
session_id: 'ses-opencode-default',
|
|
691
|
+
agentOutput: {
|
|
692
|
+
message: 'Initial: hello opencode',
|
|
693
|
+
session_id: 'ses-opencode-default',
|
|
694
|
+
tokens: { total: 11833 },
|
|
695
|
+
cost: 0,
|
|
696
|
+
},
|
|
697
|
+
});
|
|
698
|
+
expect(waited[0]).not.toHaveProperty('stdout');
|
|
699
|
+
expect(waited[0]).not.toHaveProperty('stderr');
|
|
700
|
+
expect(readFileSync(argsLogPath, 'utf8')).toContain(`--dir ${workFolder}`);
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
it('preserves raw stdout and stderr for failed detached OpenCode runs', async () => {
|
|
704
|
+
const root = mkdtempSync(join(tmpdir(), 'ai-cli-cli-service-'));
|
|
705
|
+
tempDirs.push(root);
|
|
706
|
+
const stateDir = join(root, 'state');
|
|
707
|
+
const workFolder = join(root, 'opencode-fail-project');
|
|
708
|
+
mkdirSync(workFolder, { recursive: true });
|
|
709
|
+
const { scriptPath } = createOpenCodeMock(root);
|
|
710
|
+
|
|
711
|
+
const service = new CliProcessService({
|
|
712
|
+
stateDir,
|
|
713
|
+
cliPaths: {
|
|
714
|
+
claude: '/bin/sh',
|
|
715
|
+
codex: '/bin/sh',
|
|
716
|
+
gemini: '/bin/sh',
|
|
717
|
+
forge: '/bin/sh',
|
|
718
|
+
opencode: scriptPath,
|
|
719
|
+
},
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
const runResult = await service.startProcess({
|
|
723
|
+
prompt: 'please fail',
|
|
724
|
+
cwd: workFolder,
|
|
725
|
+
model: 'oc-openai/gpt-5.4',
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
const [compactResult] = await service.waitForProcesses([runResult.pid], 5);
|
|
729
|
+
expect(compactResult).toMatchObject({
|
|
730
|
+
pid: runResult.pid,
|
|
731
|
+
agent: 'opencode',
|
|
732
|
+
status: 'failed',
|
|
733
|
+
exitCode: 7,
|
|
734
|
+
model: 'oc-openai/gpt-5.4',
|
|
735
|
+
session_id: 'ses-opencode-default',
|
|
736
|
+
stdout: expect.stringContaining('Partial failure output'),
|
|
737
|
+
stderr: expect.stringContaining('OpenCode failed for openai/gpt-5.4'),
|
|
738
|
+
});
|
|
739
|
+
expect(compactResult).not.toHaveProperty('agentOutput');
|
|
740
|
+
|
|
741
|
+
const verboseResult = await service.getProcessResult(runResult.pid, true);
|
|
742
|
+
expect(verboseResult).toMatchObject({
|
|
743
|
+
pid: runResult.pid,
|
|
744
|
+
agent: 'opencode',
|
|
745
|
+
status: 'failed',
|
|
746
|
+
exitCode: 7,
|
|
747
|
+
session_id: 'ses-opencode-default',
|
|
748
|
+
stdout: expect.stringContaining('Partial failure output'),
|
|
749
|
+
stderr: expect.stringContaining('OpenCode failed for openai/gpt-5.4'),
|
|
750
|
+
agentOutput: {
|
|
751
|
+
message: 'Partial failure output',
|
|
752
|
+
session_id: 'ses-opencode-default',
|
|
753
|
+
tokens: { total: 42 },
|
|
754
|
+
cost: 0,
|
|
755
|
+
},
|
|
756
|
+
});
|
|
757
|
+
});
|
|
578
758
|
});
|