ai-cli-mcp 2.14.1 → 2.15.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 +7 -0
- package/README.ja.md +25 -6
- package/README.md +25 -7
- package/dist/__tests__/app-cli.test.js +24 -4
- package/dist/__tests__/cli-bin-smoke.test.js +43 -0
- package/dist/__tests__/cli-builder.test.js +92 -14
- package/dist/__tests__/cli-process-service.test.js +103 -0
- package/dist/__tests__/cli-utils.test.js +31 -0
- package/dist/__tests__/e2e.test.js +77 -51
- package/dist/__tests__/mcp-contract.test.js +154 -0
- package/dist/__tests__/parsers.test.js +62 -1
- package/dist/__tests__/process-management.test.js +1 -1
- package/dist/__tests__/server.test.js +35 -6
- package/dist/__tests__/utils/opencode-mock.js +91 -0
- package/dist/__tests__/validation.test.js +40 -2
- package/dist/app/cli.js +4 -4
- package/dist/app/mcp.js +8 -4
- package/dist/cli-builder.js +66 -27
- package/dist/cli-parse.js +11 -5
- package/dist/cli-process-service.js +139 -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 +57 -26
- package/dist/process-result.js +9 -2
- package/dist/process-service.js +23 -17
- package/dist/server.js +1 -2
- package/package.json +9 -6
- package/src/__tests__/app-cli.test.ts +24 -4
- package/src/__tests__/cli-bin-smoke.test.ts +62 -1
- package/src/__tests__/cli-builder.test.ts +110 -14
- package/src/__tests__/cli-process-service.test.ts +112 -0
- package/src/__tests__/cli-utils.test.ts +34 -0
- package/src/__tests__/e2e.test.ts +85 -54
- package/src/__tests__/mcp-contract.test.ts +179 -0
- package/src/__tests__/parsers.test.ts +73 -1
- package/src/__tests__/process-management.test.ts +1 -1
- package/src/__tests__/server.test.ts +45 -10
- package/src/__tests__/utils/opencode-mock.ts +108 -0
- package/src/__tests__/validation.test.ts +48 -2
- package/src/app/cli.ts +4 -4
- package/src/app/mcp.ts +8 -4
- package/src/cli-builder.ts +90 -31
- package/src/cli-parse.ts +11 -5
- package/src/cli-process-service.ts +171 -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 +77 -31
- package/src/process-result.ts +11 -2
- package/src/process-service.ts +28 -15
- package/src/server.ts +2 -2
- package/vitest.config.unit.ts +2 -3
|
@@ -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
|
function createMockCliScript(dir, name, options = {}) {
|
|
7
8
|
const scriptPath = join(dir, name);
|
|
8
9
|
writeFileSync(scriptPath, `#!/bin/bash
|
|
@@ -57,6 +58,7 @@ describe('CliProcessService', () => {
|
|
|
57
58
|
codex: scriptPath,
|
|
58
59
|
gemini: scriptPath,
|
|
59
60
|
forge: scriptPath,
|
|
61
|
+
opencode: scriptPath,
|
|
60
62
|
},
|
|
61
63
|
});
|
|
62
64
|
const runResult = await service.startProcess({
|
|
@@ -126,6 +128,7 @@ printf '%s\n' '{"type":"system","session_id":"session-cli-1"}'
|
|
|
126
128
|
codex: scriptPath,
|
|
127
129
|
gemini: scriptPath,
|
|
128
130
|
forge: scriptPath,
|
|
131
|
+
opencode: scriptPath,
|
|
129
132
|
},
|
|
130
133
|
});
|
|
131
134
|
const runResult = await service.startProcess({
|
|
@@ -229,6 +232,7 @@ printf '%s\n' '{"type":"system","session_id":"session-cli-1"}'
|
|
|
229
232
|
codex: scriptPath,
|
|
230
233
|
gemini: scriptPath,
|
|
231
234
|
forge: scriptPath,
|
|
235
|
+
opencode: scriptPath,
|
|
232
236
|
},
|
|
233
237
|
});
|
|
234
238
|
const runResult = await service.startProcess({
|
|
@@ -262,6 +266,7 @@ printf '%s\n' '{"type":"system","session_id":"session-cli-1"}'
|
|
|
262
266
|
codex: '/bin/sh',
|
|
263
267
|
gemini: '/bin/sh',
|
|
264
268
|
forge: '/bin/sh',
|
|
269
|
+
opencode: '/bin/sh',
|
|
265
270
|
},
|
|
266
271
|
});
|
|
267
272
|
writeFileSync(join(processDir, 'meta.json'), JSON.stringify({
|
|
@@ -321,6 +326,7 @@ printf '%s\n' '{"type":"system","session_id":"session-cli-1"}'
|
|
|
321
326
|
codex: '/bin/sh',
|
|
322
327
|
gemini: '/bin/sh',
|
|
323
328
|
forge: '/bin/sh',
|
|
329
|
+
opencode: '/bin/sh',
|
|
324
330
|
},
|
|
325
331
|
});
|
|
326
332
|
const killSpy = vi.spyOn(globalThis.process, 'kill').mockImplementation((target, signal) => {
|
|
@@ -368,6 +374,7 @@ printf '%s\n' '{"type":"system","session_id":"session-cli-1"}'
|
|
|
368
374
|
codex: '/bin/sh',
|
|
369
375
|
gemini: '/bin/sh',
|
|
370
376
|
forge: '/bin/sh',
|
|
377
|
+
opencode: '/bin/sh',
|
|
371
378
|
},
|
|
372
379
|
});
|
|
373
380
|
const result = await service.cleanupProcesses();
|
|
@@ -429,6 +436,7 @@ printf '%s\n' '{"type":"system","session_id":"session-cli-1"}'
|
|
|
429
436
|
codex: '/bin/sh',
|
|
430
437
|
gemini: '/bin/sh',
|
|
431
438
|
forge: '/bin/sh',
|
|
439
|
+
opencode: '/bin/sh',
|
|
432
440
|
},
|
|
433
441
|
});
|
|
434
442
|
const killSpy = vi.spyOn(globalThis.process, 'kill').mockImplementation((target, signal) => {
|
|
@@ -479,6 +487,7 @@ Forge assistant reply
|
|
|
479
487
|
codex: '/bin/sh',
|
|
480
488
|
gemini: '/bin/sh',
|
|
481
489
|
forge: '/bin/sh',
|
|
490
|
+
opencode: '/bin/sh',
|
|
482
491
|
},
|
|
483
492
|
});
|
|
484
493
|
const result = await service.getProcessResult(pid, false);
|
|
@@ -489,4 +498,98 @@ Forge assistant reply
|
|
|
489
498
|
session_id: 'forge-conv-1',
|
|
490
499
|
});
|
|
491
500
|
});
|
|
501
|
+
it('parses successful OpenCode detached runs from stdout only', async () => {
|
|
502
|
+
const root = mkdtempSync(join(tmpdir(), 'ai-cli-cli-service-'));
|
|
503
|
+
tempDirs.push(root);
|
|
504
|
+
const stateDir = join(root, 'state');
|
|
505
|
+
const workFolder = join(root, 'opencode-project');
|
|
506
|
+
mkdirSync(workFolder, { recursive: true });
|
|
507
|
+
const argsLogPath = join(root, 'opencode-args.log');
|
|
508
|
+
const { scriptPath } = createOpenCodeMock(root, { argsLogPath });
|
|
509
|
+
const service = new CliProcessService({
|
|
510
|
+
stateDir,
|
|
511
|
+
cliPaths: {
|
|
512
|
+
claude: '/bin/sh',
|
|
513
|
+
codex: '/bin/sh',
|
|
514
|
+
gemini: '/bin/sh',
|
|
515
|
+
forge: '/bin/sh',
|
|
516
|
+
opencode: scriptPath,
|
|
517
|
+
},
|
|
518
|
+
});
|
|
519
|
+
const runResult = await service.startProcess({
|
|
520
|
+
prompt: 'hello opencode',
|
|
521
|
+
cwd: workFolder,
|
|
522
|
+
model: 'opencode',
|
|
523
|
+
});
|
|
524
|
+
const waited = await service.waitForProcesses([runResult.pid], 5);
|
|
525
|
+
expect(waited).toHaveLength(1);
|
|
526
|
+
expect(waited[0]).toMatchObject({
|
|
527
|
+
pid: runResult.pid,
|
|
528
|
+
agent: 'opencode',
|
|
529
|
+
status: 'completed',
|
|
530
|
+
exitCode: 0,
|
|
531
|
+
model: 'opencode',
|
|
532
|
+
session_id: 'ses-opencode-default',
|
|
533
|
+
agentOutput: {
|
|
534
|
+
message: 'Initial: hello opencode',
|
|
535
|
+
session_id: 'ses-opencode-default',
|
|
536
|
+
tokens: { total: 11833 },
|
|
537
|
+
cost: 0,
|
|
538
|
+
},
|
|
539
|
+
});
|
|
540
|
+
expect(waited[0]).not.toHaveProperty('stdout');
|
|
541
|
+
expect(waited[0]).not.toHaveProperty('stderr');
|
|
542
|
+
expect(readFileSync(argsLogPath, 'utf8')).toContain(`--dir ${workFolder}`);
|
|
543
|
+
});
|
|
544
|
+
it('preserves raw stdout and stderr for failed detached OpenCode runs', async () => {
|
|
545
|
+
const root = mkdtempSync(join(tmpdir(), 'ai-cli-cli-service-'));
|
|
546
|
+
tempDirs.push(root);
|
|
547
|
+
const stateDir = join(root, 'state');
|
|
548
|
+
const workFolder = join(root, 'opencode-fail-project');
|
|
549
|
+
mkdirSync(workFolder, { recursive: true });
|
|
550
|
+
const { scriptPath } = createOpenCodeMock(root);
|
|
551
|
+
const service = new CliProcessService({
|
|
552
|
+
stateDir,
|
|
553
|
+
cliPaths: {
|
|
554
|
+
claude: '/bin/sh',
|
|
555
|
+
codex: '/bin/sh',
|
|
556
|
+
gemini: '/bin/sh',
|
|
557
|
+
forge: '/bin/sh',
|
|
558
|
+
opencode: scriptPath,
|
|
559
|
+
},
|
|
560
|
+
});
|
|
561
|
+
const runResult = await service.startProcess({
|
|
562
|
+
prompt: 'please fail',
|
|
563
|
+
cwd: workFolder,
|
|
564
|
+
model: 'oc-openai/gpt-5.4',
|
|
565
|
+
});
|
|
566
|
+
const [compactResult] = await service.waitForProcesses([runResult.pid], 5);
|
|
567
|
+
expect(compactResult).toMatchObject({
|
|
568
|
+
pid: runResult.pid,
|
|
569
|
+
agent: 'opencode',
|
|
570
|
+
status: 'failed',
|
|
571
|
+
exitCode: 7,
|
|
572
|
+
model: 'oc-openai/gpt-5.4',
|
|
573
|
+
session_id: 'ses-opencode-default',
|
|
574
|
+
stdout: expect.stringContaining('Partial failure output'),
|
|
575
|
+
stderr: expect.stringContaining('OpenCode failed for openai/gpt-5.4'),
|
|
576
|
+
});
|
|
577
|
+
expect(compactResult).not.toHaveProperty('agentOutput');
|
|
578
|
+
const verboseResult = await service.getProcessResult(runResult.pid, true);
|
|
579
|
+
expect(verboseResult).toMatchObject({
|
|
580
|
+
pid: runResult.pid,
|
|
581
|
+
agent: 'opencode',
|
|
582
|
+
status: 'failed',
|
|
583
|
+
exitCode: 7,
|
|
584
|
+
session_id: 'ses-opencode-default',
|
|
585
|
+
stdout: expect.stringContaining('Partial failure output'),
|
|
586
|
+
stderr: expect.stringContaining('OpenCode failed for openai/gpt-5.4'),
|
|
587
|
+
agentOutput: {
|
|
588
|
+
message: 'Partial failure output',
|
|
589
|
+
session_id: 'ses-opencode-default',
|
|
590
|
+
tokens: { total: 42 },
|
|
591
|
+
cost: 0,
|
|
592
|
+
},
|
|
593
|
+
});
|
|
594
|
+
});
|
|
492
595
|
});
|
|
@@ -16,6 +16,7 @@ describe('cli-utils doctor status', () => {
|
|
|
16
16
|
delete process.env.CODEX_CLI_NAME;
|
|
17
17
|
delete process.env.GEMINI_CLI_NAME;
|
|
18
18
|
delete process.env.FORGE_CLI_NAME;
|
|
19
|
+
delete process.env.OPENCODE_CLI_NAME;
|
|
19
20
|
process.env.PATH = '/mock/bin:/usr/bin';
|
|
20
21
|
});
|
|
21
22
|
afterEach(() => {
|
|
@@ -43,6 +44,12 @@ describe('cli-utils doctor status', () => {
|
|
|
43
44
|
available: false,
|
|
44
45
|
lookup: 'path',
|
|
45
46
|
});
|
|
47
|
+
expect(status.opencode).toEqual({
|
|
48
|
+
configuredCommand: 'opencode',
|
|
49
|
+
resolvedPath: null,
|
|
50
|
+
available: false,
|
|
51
|
+
lookup: 'path',
|
|
52
|
+
});
|
|
46
53
|
});
|
|
47
54
|
it('does not mark non-executable PATH entries as available', async () => {
|
|
48
55
|
mockAccessSync.mockImplementation(() => {
|
|
@@ -62,6 +69,12 @@ describe('cli-utils doctor status', () => {
|
|
|
62
69
|
available: false,
|
|
63
70
|
lookup: 'path',
|
|
64
71
|
});
|
|
72
|
+
expect(status.opencode).toEqual({
|
|
73
|
+
configuredCommand: 'opencode',
|
|
74
|
+
resolvedPath: null,
|
|
75
|
+
available: false,
|
|
76
|
+
lookup: 'path',
|
|
77
|
+
});
|
|
65
78
|
});
|
|
66
79
|
it('reports invalid relative env paths as doctor errors', async () => {
|
|
67
80
|
process.env.CLAUDE_CLI_NAME = './relative/claude';
|
|
@@ -137,4 +150,22 @@ describe('cli-utils doctor status', () => {
|
|
|
137
150
|
});
|
|
138
151
|
expect(findForgeCli()).toBe('forge-custom');
|
|
139
152
|
});
|
|
153
|
+
it('supports OpenCode lookup via OPENCODE_CLI_NAME', async () => {
|
|
154
|
+
process.env.OPENCODE_CLI_NAME = 'opencode-custom';
|
|
155
|
+
mockAccessSync.mockImplementation((filePath) => {
|
|
156
|
+
if (filePath === '/mock/bin/opencode-custom') {
|
|
157
|
+
return undefined;
|
|
158
|
+
}
|
|
159
|
+
throw new Error('not executable');
|
|
160
|
+
});
|
|
161
|
+
const { getCliDoctorStatus, findOpencodeCli } = await import('../cli-utils.js');
|
|
162
|
+
const status = getCliDoctorStatus();
|
|
163
|
+
expect(status.opencode).toEqual({
|
|
164
|
+
configuredCommand: 'opencode-custom',
|
|
165
|
+
resolvedPath: '/mock/bin/opencode-custom',
|
|
166
|
+
available: true,
|
|
167
|
+
lookup: 'env',
|
|
168
|
+
});
|
|
169
|
+
expect(findOpencodeCli()).toBe('opencode-custom');
|
|
170
|
+
});
|
|
140
171
|
});
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach, afterEach, afterAll } from 'vitest';
|
|
2
|
-
import { mkdtempSync, rmSync, readFileSync
|
|
2
|
+
import { mkdtempSync, rmSync, readFileSync } from 'node:fs';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
import { createTestClient } from './utils/mcp-client.js';
|
|
6
6
|
import { getSharedMock, cleanupSharedMock } from './utils/persistent-mock.js';
|
|
7
|
+
import { createOpenCodeMock } from './utils/opencode-mock.js';
|
|
7
8
|
describe('Claude Code MCP E2E Tests', () => {
|
|
8
9
|
let client;
|
|
9
10
|
let testDir;
|
|
@@ -30,40 +31,10 @@ describe('Claude Code MCP E2E Tests', () => {
|
|
|
30
31
|
const tools = await client.listTools();
|
|
31
32
|
expect(tools).toHaveLength(6);
|
|
32
33
|
const claudeCodeTool = tools.find((t) => t.name === 'run');
|
|
33
|
-
expect(claudeCodeTool).
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
type: 'object',
|
|
38
|
-
properties: {
|
|
39
|
-
prompt: {
|
|
40
|
-
type: 'string',
|
|
41
|
-
description: expect.stringContaining('Either this or prompt_file is required'),
|
|
42
|
-
},
|
|
43
|
-
prompt_file: {
|
|
44
|
-
type: 'string',
|
|
45
|
-
description: expect.stringContaining('Path to a file containing the prompt'),
|
|
46
|
-
},
|
|
47
|
-
workFolder: {
|
|
48
|
-
type: 'string',
|
|
49
|
-
description: expect.stringContaining('working directory'),
|
|
50
|
-
},
|
|
51
|
-
model: {
|
|
52
|
-
type: 'string',
|
|
53
|
-
description: expect.stringContaining('sonnet'),
|
|
54
|
-
},
|
|
55
|
-
reasoning_effort: {
|
|
56
|
-
type: 'string',
|
|
57
|
-
description: expect.stringContaining('model_reasoning_effort'),
|
|
58
|
-
},
|
|
59
|
-
session_id: {
|
|
60
|
-
type: 'string',
|
|
61
|
-
description: expect.stringContaining('session ID'),
|
|
62
|
-
},
|
|
63
|
-
},
|
|
64
|
-
required: ['workFolder'],
|
|
65
|
-
},
|
|
66
|
-
});
|
|
34
|
+
expect(claudeCodeTool.inputSchema.properties.model.description).toContain('sonnet');
|
|
35
|
+
expect(claudeCodeTool.inputSchema.properties.model.description).toContain('opencode');
|
|
36
|
+
expect(claudeCodeTool.inputSchema.properties.model.description).toContain('oc-<provider/model>');
|
|
37
|
+
expect(claudeCodeTool.inputSchema.properties.reasoning_effort.description).toContain('OpenCode');
|
|
67
38
|
// Verify other tools exist
|
|
68
39
|
expect(tools.some((t) => t.name === 'list_processes')).toBe(true);
|
|
69
40
|
expect(tools.some((t) => t.name === 'get_result')).toBe(true);
|
|
@@ -180,6 +151,70 @@ describe('Claude Code MCP E2E Tests', () => {
|
|
|
180
151
|
}]);
|
|
181
152
|
});
|
|
182
153
|
});
|
|
154
|
+
describe('OpenCode flows', () => {
|
|
155
|
+
it('should execute and resume OpenCode runs through the MCP client', async () => {
|
|
156
|
+
await client.disconnect();
|
|
157
|
+
const opencodeArgsLogPath = join(testDir, 'opencode-args.log');
|
|
158
|
+
const { scriptPath } = createOpenCodeMock(testDir, {
|
|
159
|
+
argsLogPath: opencodeArgsLogPath,
|
|
160
|
+
defaultSessionId: 'ses-opencode-e2e',
|
|
161
|
+
});
|
|
162
|
+
client = createTestClient({
|
|
163
|
+
debug: false,
|
|
164
|
+
env: {
|
|
165
|
+
OPENCODE_CLI_NAME: scriptPath,
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
await client.connect();
|
|
169
|
+
const runResponse = await client.callTool('run', {
|
|
170
|
+
prompt: 'e2e OpenCode initial prompt',
|
|
171
|
+
workFolder: testDir,
|
|
172
|
+
model: 'opencode',
|
|
173
|
+
});
|
|
174
|
+
const runData = JSON.parse(runResponse[0].text);
|
|
175
|
+
expect(runData.agent).toBe('opencode');
|
|
176
|
+
const initialWait = JSON.parse((await client.callTool('wait', { pids: [runData.pid], timeout: 5 }))[0].text);
|
|
177
|
+
expect(initialWait).toHaveLength(1);
|
|
178
|
+
expect(initialWait[0]).toMatchObject({
|
|
179
|
+
pid: runData.pid,
|
|
180
|
+
agent: 'opencode',
|
|
181
|
+
status: 'completed',
|
|
182
|
+
exitCode: 0,
|
|
183
|
+
model: 'opencode',
|
|
184
|
+
session_id: 'ses-opencode-e2e',
|
|
185
|
+
agentOutput: {
|
|
186
|
+
message: 'Initial: e2e OpenCode initial prompt',
|
|
187
|
+
session_id: 'ses-opencode-e2e',
|
|
188
|
+
},
|
|
189
|
+
});
|
|
190
|
+
const resumedResponse = await client.callTool('run', {
|
|
191
|
+
prompt: 'e2e OpenCode resumed prompt',
|
|
192
|
+
workFolder: testDir,
|
|
193
|
+
model: 'oc-openai/gpt-5.4',
|
|
194
|
+
session_id: 'ses-opencode-e2e',
|
|
195
|
+
});
|
|
196
|
+
const resumedRunData = JSON.parse(resumedResponse[0].text);
|
|
197
|
+
const resumedWait = JSON.parse((await client.callTool('wait', { pids: [resumedRunData.pid], timeout: 5 }))[0].text);
|
|
198
|
+
expect(resumedWait).toHaveLength(1);
|
|
199
|
+
expect(resumedWait[0]).toMatchObject({
|
|
200
|
+
pid: resumedRunData.pid,
|
|
201
|
+
agent: 'opencode',
|
|
202
|
+
status: 'completed',
|
|
203
|
+
exitCode: 0,
|
|
204
|
+
model: 'oc-openai/gpt-5.4',
|
|
205
|
+
session_id: 'ses-opencode-e2e',
|
|
206
|
+
agentOutput: {
|
|
207
|
+
message: 'Resumed model openai/gpt-5.4: e2e OpenCode resumed prompt',
|
|
208
|
+
session_id: 'ses-opencode-e2e',
|
|
209
|
+
},
|
|
210
|
+
});
|
|
211
|
+
const invocationLog = readFileSync(opencodeArgsLogPath, 'utf-8').trim().split('\n');
|
|
212
|
+
expect(invocationLog[0]).toContain(`--dir ${testDir}`);
|
|
213
|
+
expect(invocationLog[0]).not.toContain('--model');
|
|
214
|
+
expect(invocationLog[1]).toContain('--session ses-opencode-e2e');
|
|
215
|
+
expect(invocationLog[1]).toContain('--model openai/gpt-5.4');
|
|
216
|
+
});
|
|
217
|
+
});
|
|
183
218
|
describe('Debug Mode', () => {
|
|
184
219
|
it('should log debug information when enabled', async () => {
|
|
185
220
|
// Debug logs go to stderr, which we capture in the client
|
|
@@ -205,25 +240,16 @@ describe('Integration Tests (Local Only)', () => {
|
|
|
205
240
|
}
|
|
206
241
|
rmSync(testDir, { recursive: true, force: true });
|
|
207
242
|
});
|
|
208
|
-
//
|
|
209
|
-
it.skip('should
|
|
210
|
-
await client.connect();
|
|
211
|
-
const response = await client.callTool('run', {
|
|
212
|
-
prompt: 'Create a file called hello.txt with content "Hello from Claude"',
|
|
213
|
-
workFolder: testDir,
|
|
214
|
-
});
|
|
215
|
-
const filePath = join(testDir, 'hello.txt');
|
|
216
|
-
expect(existsSync(filePath)).toBe(true);
|
|
217
|
-
expect(readFileSync(filePath, 'utf-8')).toContain('Hello from Claude');
|
|
218
|
-
});
|
|
219
|
-
it.skip('should handle git operations with real Claude CLI', async () => {
|
|
243
|
+
// This smoke test only verifies that a real Claude CLI can be invoked.
|
|
244
|
+
it.skip('should invoke the real Claude CLI', async () => {
|
|
220
245
|
await client.connect();
|
|
221
|
-
// Initialize git repo
|
|
222
246
|
const response = await client.callTool('run', {
|
|
223
|
-
prompt: '
|
|
247
|
+
prompt: 'Reply with hi',
|
|
224
248
|
workFolder: testDir,
|
|
225
249
|
});
|
|
226
|
-
expect(
|
|
227
|
-
|
|
250
|
+
expect(response).toEqual([{
|
|
251
|
+
type: 'text',
|
|
252
|
+
text: expect.stringContaining('pid'),
|
|
253
|
+
}]);
|
|
228
254
|
});
|
|
229
255
|
});
|
|
@@ -3,6 +3,7 @@ import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'nod
|
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
import { cleanupSharedMock, getSharedMock } from './utils/persistent-mock.js';
|
|
6
|
+
import { createOpenCodeMock } from './utils/opencode-mock.js';
|
|
6
7
|
import { createTestClient } from './utils/mcp-client.js';
|
|
7
8
|
function parseToolJson(content) {
|
|
8
9
|
expect(content).toHaveLength(1);
|
|
@@ -96,6 +97,11 @@ describe('MCP Contract Tests', () => {
|
|
|
96
97
|
'session_id',
|
|
97
98
|
'workFolder',
|
|
98
99
|
]);
|
|
100
|
+
expect(runTool.description).toContain('OpenCode');
|
|
101
|
+
expect(runTool.inputSchema.properties.model.description).toContain('opencode');
|
|
102
|
+
expect(runTool.inputSchema.properties.model.description).toContain('oc-<provider/model>');
|
|
103
|
+
expect(runTool.inputSchema.properties.reasoning_effort.description).toContain('OpenCode do not support reasoning_effort');
|
|
104
|
+
expect(runTool.inputSchema.properties.session_id.description).toBe('Optional session ID to resume a previous session. Supported for Claude, Codex, Gemini, Forge, and OpenCode. OpenCode resumes in-place via --session and may also be combined with explicit oc-<provider/model> selection.');
|
|
99
105
|
const getResultTool = tools.find((tool) => tool.name === 'get_result');
|
|
100
106
|
expect(getResultTool.inputSchema.required).toEqual(['pid']);
|
|
101
107
|
expect(Object.keys(getResultTool.inputSchema.properties).sort()).toEqual([
|
|
@@ -407,6 +413,154 @@ printf '%s\n' '{"type":"system","session_id":"session-verbose-1"}'
|
|
|
407
413
|
reasoning_effort: 'high',
|
|
408
414
|
})).rejects.toThrow(/reasoning_effort is not supported for forge/i);
|
|
409
415
|
});
|
|
416
|
+
it('covers OpenCode end-to-end through the MCP process path', async () => {
|
|
417
|
+
await client.disconnect();
|
|
418
|
+
const opencodeArgsLogPath = join(testDir, 'opencode-args.log');
|
|
419
|
+
const { scriptPath: openCodeMockPath } = createOpenCodeMock(testDir, {
|
|
420
|
+
argsLogPath: opencodeArgsLogPath,
|
|
421
|
+
defaultSessionId: 'ses-opencode-contract',
|
|
422
|
+
});
|
|
423
|
+
client = createTestClient({
|
|
424
|
+
debug: false,
|
|
425
|
+
env: {
|
|
426
|
+
OPENCODE_CLI_NAME: openCodeMockPath,
|
|
427
|
+
},
|
|
428
|
+
});
|
|
429
|
+
await client.connect();
|
|
430
|
+
const initialRunResponse = await client.callTool('run', {
|
|
431
|
+
prompt: 'opencode-initial-prompt',
|
|
432
|
+
workFolder: testDir,
|
|
433
|
+
model: 'opencode',
|
|
434
|
+
});
|
|
435
|
+
const initialRunData = parseToolJson(initialRunResponse);
|
|
436
|
+
expect(initialRunData).toEqual({
|
|
437
|
+
pid: expect.any(Number),
|
|
438
|
+
status: 'started',
|
|
439
|
+
agent: 'opencode',
|
|
440
|
+
message: expect.any(String),
|
|
441
|
+
});
|
|
442
|
+
const initialWaitResponse = await client.callTool('wait', { pids: [initialRunData.pid], timeout: 5 });
|
|
443
|
+
const initialWaitData = parseToolJson(initialWaitResponse);
|
|
444
|
+
expect(initialWaitData).toHaveLength(1);
|
|
445
|
+
expect(initialWaitData[0]).toMatchObject({
|
|
446
|
+
pid: initialRunData.pid,
|
|
447
|
+
agent: 'opencode',
|
|
448
|
+
status: 'completed',
|
|
449
|
+
exitCode: 0,
|
|
450
|
+
model: 'opencode',
|
|
451
|
+
session_id: 'ses-opencode-contract',
|
|
452
|
+
agentOutput: {
|
|
453
|
+
message: 'Initial: opencode-initial-prompt',
|
|
454
|
+
session_id: 'ses-opencode-contract',
|
|
455
|
+
tokens: { total: 11833 },
|
|
456
|
+
cost: 0,
|
|
457
|
+
},
|
|
458
|
+
});
|
|
459
|
+
const resumedDefaultRunResponse = await client.callTool('run', {
|
|
460
|
+
prompt: 'opencode-resume-default',
|
|
461
|
+
workFolder: testDir,
|
|
462
|
+
model: 'opencode',
|
|
463
|
+
session_id: 'ses-opencode-contract',
|
|
464
|
+
});
|
|
465
|
+
const resumedDefaultRunData = parseToolJson(resumedDefaultRunResponse);
|
|
466
|
+
const resumedDefaultWaitResponse = await client.callTool('wait', { pids: [resumedDefaultRunData.pid], timeout: 5 });
|
|
467
|
+
const resumedDefaultWaitData = parseToolJson(resumedDefaultWaitResponse);
|
|
468
|
+
expect(resumedDefaultWaitData).toHaveLength(1);
|
|
469
|
+
expect(resumedDefaultWaitData[0]).toMatchObject({
|
|
470
|
+
pid: resumedDefaultRunData.pid,
|
|
471
|
+
agent: 'opencode',
|
|
472
|
+
status: 'completed',
|
|
473
|
+
exitCode: 0,
|
|
474
|
+
model: 'opencode',
|
|
475
|
+
session_id: 'ses-opencode-contract',
|
|
476
|
+
agentOutput: {
|
|
477
|
+
message: 'Resumed: opencode-resume-default',
|
|
478
|
+
session_id: 'ses-opencode-contract',
|
|
479
|
+
tokens: { total: 11833 },
|
|
480
|
+
cost: 0,
|
|
481
|
+
},
|
|
482
|
+
});
|
|
483
|
+
const resumedExplicitRunResponse = await client.callTool('run', {
|
|
484
|
+
prompt: 'opencode-resume-explicit',
|
|
485
|
+
workFolder: testDir,
|
|
486
|
+
model: 'oc-openai/gpt-5.4',
|
|
487
|
+
session_id: 'ses-opencode-contract',
|
|
488
|
+
});
|
|
489
|
+
const resumedExplicitRunData = parseToolJson(resumedExplicitRunResponse);
|
|
490
|
+
const resumedExplicitWaitResponse = await client.callTool('wait', { pids: [resumedExplicitRunData.pid], timeout: 5 });
|
|
491
|
+
const resumedExplicitWaitData = parseToolJson(resumedExplicitWaitResponse);
|
|
492
|
+
expect(resumedExplicitWaitData).toHaveLength(1);
|
|
493
|
+
expect(resumedExplicitWaitData[0]).toMatchObject({
|
|
494
|
+
pid: resumedExplicitRunData.pid,
|
|
495
|
+
agent: 'opencode',
|
|
496
|
+
status: 'completed',
|
|
497
|
+
exitCode: 0,
|
|
498
|
+
model: 'oc-openai/gpt-5.4',
|
|
499
|
+
session_id: 'ses-opencode-contract',
|
|
500
|
+
agentOutput: {
|
|
501
|
+
message: 'Resumed model openai/gpt-5.4: opencode-resume-explicit',
|
|
502
|
+
session_id: 'ses-opencode-contract',
|
|
503
|
+
tokens: { total: 11833 },
|
|
504
|
+
cost: 0,
|
|
505
|
+
},
|
|
506
|
+
});
|
|
507
|
+
const failedRunResponse = await client.callTool('run', {
|
|
508
|
+
prompt: 'please fail',
|
|
509
|
+
workFolder: testDir,
|
|
510
|
+
model: 'oc-openai/gpt-5.4',
|
|
511
|
+
});
|
|
512
|
+
const failedRunData = parseToolJson(failedRunResponse);
|
|
513
|
+
const compactFailedWait = parseToolJson(await client.callTool('wait', { pids: [failedRunData.pid], timeout: 5 }));
|
|
514
|
+
expect(compactFailedWait).toHaveLength(1);
|
|
515
|
+
expect(compactFailedWait[0]).toMatchObject({
|
|
516
|
+
pid: failedRunData.pid,
|
|
517
|
+
agent: 'opencode',
|
|
518
|
+
status: 'failed',
|
|
519
|
+
exitCode: 7,
|
|
520
|
+
model: 'oc-openai/gpt-5.4',
|
|
521
|
+
session_id: 'ses-opencode-contract',
|
|
522
|
+
stdout: expect.stringContaining('Partial failure output'),
|
|
523
|
+
stderr: expect.stringContaining('OpenCode failed for openai/gpt-5.4'),
|
|
524
|
+
});
|
|
525
|
+
expect(compactFailedWait[0]).not.toHaveProperty('agentOutput');
|
|
526
|
+
const verboseFailedResult = parseToolJson(await client.callTool('get_result', { pid: failedRunData.pid, verbose: true }));
|
|
527
|
+
expect(verboseFailedResult).toMatchObject({
|
|
528
|
+
pid: failedRunData.pid,
|
|
529
|
+
agent: 'opencode',
|
|
530
|
+
status: 'failed',
|
|
531
|
+
exitCode: 7,
|
|
532
|
+
model: 'oc-openai/gpt-5.4',
|
|
533
|
+
session_id: 'ses-opencode-contract',
|
|
534
|
+
stdout: expect.stringContaining('Partial failure output'),
|
|
535
|
+
stderr: expect.stringContaining('OpenCode failed for openai/gpt-5.4'),
|
|
536
|
+
agentOutput: {
|
|
537
|
+
message: 'Partial failure output',
|
|
538
|
+
session_id: 'ses-opencode-contract',
|
|
539
|
+
tokens: { total: 42 },
|
|
540
|
+
cost: 0,
|
|
541
|
+
},
|
|
542
|
+
});
|
|
543
|
+
const openCodeInvocations = readFileSync(opencodeArgsLogPath, 'utf-8').trim().split('\n');
|
|
544
|
+
expect(openCodeInvocations).toHaveLength(4);
|
|
545
|
+
expect(openCodeInvocations[0]).toContain('run --format json');
|
|
546
|
+
expect(openCodeInvocations[0]).toContain(`--dir ${testDir}`);
|
|
547
|
+
expect(openCodeInvocations[0]).not.toContain('--session');
|
|
548
|
+
expect(openCodeInvocations[0]).not.toContain('--model');
|
|
549
|
+
expect(openCodeInvocations[1]).toContain(`--dir ${testDir}`);
|
|
550
|
+
expect(openCodeInvocations[1]).toContain('--session ses-opencode-contract');
|
|
551
|
+
expect(openCodeInvocations[1]).not.toContain('--model');
|
|
552
|
+
expect(openCodeInvocations[2]).toContain(`--dir ${testDir}`);
|
|
553
|
+
expect(openCodeInvocations[2]).toContain('--session ses-opencode-contract');
|
|
554
|
+
expect(openCodeInvocations[2]).toContain('--model openai/gpt-5.4');
|
|
555
|
+
expect(openCodeInvocations[3]).toContain(`--dir ${testDir}`);
|
|
556
|
+
expect(openCodeInvocations[3]).toContain('--model openai/gpt-5.4');
|
|
557
|
+
await expect(client.callTool('run', {
|
|
558
|
+
prompt: 'opencode-invalid-reasoning',
|
|
559
|
+
workFolder: testDir,
|
|
560
|
+
model: 'opencode',
|
|
561
|
+
reasoning_effort: 'high',
|
|
562
|
+
})).rejects.toThrow(/reasoning_effort is not supported for opencode/i);
|
|
563
|
+
});
|
|
410
564
|
it('keeps key invalid-input errors stable', async () => {
|
|
411
565
|
await expect(client.callTool('run', {
|
|
412
566
|
prompt: 'missing workFolder',
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { parseCodexOutput, parseClaudeOutput, parseForgeOutput } from '../parsers.js';
|
|
2
|
+
import { parseCodexOutput, parseClaudeOutput, parseForgeOutput, parseOpenCodeOutput } from '../parsers.js';
|
|
3
3
|
describe('parseCodexOutput', () => {
|
|
4
4
|
it('should parse basic Codex output with message and session_id', () => {
|
|
5
5
|
const output = `
|
|
@@ -132,3 +132,64 @@ still streaming`;
|
|
|
132
132
|
expect(parseForgeOutput('plain text')).toBeNull();
|
|
133
133
|
});
|
|
134
134
|
});
|
|
135
|
+
describe('parseOpenCodeOutput', () => {
|
|
136
|
+
it('parses a single completed OpenCode step', () => {
|
|
137
|
+
const output = `{"type":"step_start","sessionID":"ses_1"}
|
|
138
|
+
{"type":"text","sessionID":"ses_1","part":{"type":"text","text":"Hello"}}
|
|
139
|
+
{"type":"step_finish","sessionID":"ses_1","part":{"type":"step-finish","tokens":{"total":11833},"cost":0}}`;
|
|
140
|
+
expect(parseOpenCodeOutput(output)).toEqual({
|
|
141
|
+
message: 'Hello',
|
|
142
|
+
session_id: 'ses_1',
|
|
143
|
+
tokens: { total: 11833 },
|
|
144
|
+
cost: 0,
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
it('returns the last completed step for multi-step output', () => {
|
|
148
|
+
const output = `{"type":"step_start","sessionID":"ses_2"}
|
|
149
|
+
{"type":"text","sessionID":"ses_2","part":{"type":"text","text":"First"}}
|
|
150
|
+
{"type":"step_finish","sessionID":"ses_2","part":{"type":"step-finish","tokens":{"total":10},"cost":0}}
|
|
151
|
+
{"type":"step_start","sessionID":"ses_2"}
|
|
152
|
+
{"type":"text","sessionID":"ses_2","part":{"type":"text","text":"Second"}}
|
|
153
|
+
{"type":"step_finish","sessionID":"ses_2","part":{"type":"step-finish","tokens":{"total":20},"cost":1}}`;
|
|
154
|
+
expect(parseOpenCodeOutput(output)).toEqual({
|
|
155
|
+
message: 'Second',
|
|
156
|
+
session_id: 'ses_2',
|
|
157
|
+
tokens: { total: 20 },
|
|
158
|
+
cost: 1,
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
it('resets the current-step buffer on each step_start', () => {
|
|
162
|
+
const output = `{"type":"step_start","sessionID":"ses_3"}
|
|
163
|
+
{"type":"text","sessionID":"ses_3","part":{"type":"text","text":"Discard me"}}
|
|
164
|
+
{"type":"step_start","sessionID":"ses_3"}
|
|
165
|
+
{"type":"text","sessionID":"ses_3","part":{"type":"text","text":"Keep me"}}
|
|
166
|
+
{"type":"step_finish","sessionID":"ses_3","part":{"type":"step-finish","tokens":{"total":5},"cost":0}}`;
|
|
167
|
+
expect(parseOpenCodeOutput(output)).toEqual({
|
|
168
|
+
message: 'Keep me',
|
|
169
|
+
session_id: 'ses_3',
|
|
170
|
+
tokens: { total: 5 },
|
|
171
|
+
cost: 0,
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
it('returns partial output when text exists without step_finish', () => {
|
|
175
|
+
const output = `{"type":"step_start","sessionID":"ses_4"}
|
|
176
|
+
{"type":"text","sessionID":"ses_4","part":{"type":"text","text":"Partial"}}`;
|
|
177
|
+
expect(parseOpenCodeOutput(output)).toEqual({
|
|
178
|
+
message: 'Partial',
|
|
179
|
+
session_id: 'ses_4',
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
it('ignores malformed lines and unknown event types', () => {
|
|
183
|
+
const output = `not-json
|
|
184
|
+
{"type":"unknown","sessionID":"ses_5"}
|
|
185
|
+
{"type":"text","sessionID":"ses_5","part":{"type":"text","text":"Hello"}}`;
|
|
186
|
+
expect(parseOpenCodeOutput(output)).toEqual({
|
|
187
|
+
message: 'Hello',
|
|
188
|
+
session_id: 'ses_5',
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
it('returns null when no useful OpenCode events exist', () => {
|
|
192
|
+
expect(parseOpenCodeOutput('{"type":"unknown"}')).toBeNull();
|
|
193
|
+
expect(parseOpenCodeOutput('')).toBeNull();
|
|
194
|
+
});
|
|
195
|
+
});
|
|
@@ -60,7 +60,7 @@ describe('Process Management Tests', () => {
|
|
|
60
60
|
});
|
|
61
61
|
async function setupServer() {
|
|
62
62
|
const { Server } = await import('@modelcontextprotocol/sdk/server/index.js');
|
|
63
|
-
vi.mocked(Server).mockImplementation(()
|
|
63
|
+
vi.mocked(Server).mockImplementation(function () {
|
|
64
64
|
mockServerInstance = {
|
|
65
65
|
setRequestHandler: vi.fn((schema, handler) => {
|
|
66
66
|
handlers.set(schema.name, handler);
|