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
|
@@ -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, MCPTestClient } 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
|
|
|
8
9
|
describe('Claude Code MCP E2E Tests', () => {
|
|
9
10
|
let client: MCPTestClient;
|
|
@@ -40,40 +41,10 @@ describe('Claude Code MCP E2E Tests', () => {
|
|
|
40
41
|
|
|
41
42
|
expect(tools).toHaveLength(6);
|
|
42
43
|
const claudeCodeTool = tools.find((t: any) => t.name === 'run');
|
|
43
|
-
expect(claudeCodeTool).
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
type: 'object',
|
|
48
|
-
properties: {
|
|
49
|
-
prompt: {
|
|
50
|
-
type: 'string',
|
|
51
|
-
description: expect.stringContaining('Either this or prompt_file is required'),
|
|
52
|
-
},
|
|
53
|
-
prompt_file: {
|
|
54
|
-
type: 'string',
|
|
55
|
-
description: expect.stringContaining('Path to a file containing the prompt'),
|
|
56
|
-
},
|
|
57
|
-
workFolder: {
|
|
58
|
-
type: 'string',
|
|
59
|
-
description: expect.stringContaining('working directory'),
|
|
60
|
-
},
|
|
61
|
-
model: {
|
|
62
|
-
type: 'string',
|
|
63
|
-
description: expect.stringContaining('sonnet'),
|
|
64
|
-
},
|
|
65
|
-
reasoning_effort: {
|
|
66
|
-
type: 'string',
|
|
67
|
-
description: expect.stringContaining('model_reasoning_effort'),
|
|
68
|
-
},
|
|
69
|
-
session_id: {
|
|
70
|
-
type: 'string',
|
|
71
|
-
description: expect.stringContaining('session ID'),
|
|
72
|
-
},
|
|
73
|
-
},
|
|
74
|
-
required: ['workFolder'],
|
|
75
|
-
},
|
|
76
|
-
});
|
|
44
|
+
expect(claudeCodeTool.inputSchema.properties.model.description).toContain('sonnet');
|
|
45
|
+
expect(claudeCodeTool.inputSchema.properties.model.description).toContain('opencode');
|
|
46
|
+
expect(claudeCodeTool.inputSchema.properties.model.description).toContain('oc-<provider/model>');
|
|
47
|
+
expect(claudeCodeTool.inputSchema.properties.reasoning_effort.description).toContain('OpenCode');
|
|
77
48
|
|
|
78
49
|
// Verify other tools exist
|
|
79
50
|
expect(tools.some((t: any) => t.name === 'list_processes')).toBe(true);
|
|
@@ -219,6 +190,78 @@ describe('Claude Code MCP E2E Tests', () => {
|
|
|
219
190
|
});
|
|
220
191
|
});
|
|
221
192
|
|
|
193
|
+
describe('OpenCode flows', () => {
|
|
194
|
+
it('should execute and resume OpenCode runs through the MCP client', async () => {
|
|
195
|
+
await client.disconnect();
|
|
196
|
+
|
|
197
|
+
const opencodeArgsLogPath = join(testDir, 'opencode-args.log');
|
|
198
|
+
const { scriptPath } = createOpenCodeMock(testDir, {
|
|
199
|
+
argsLogPath: opencodeArgsLogPath,
|
|
200
|
+
defaultSessionId: 'ses-opencode-e2e',
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
client = createTestClient({
|
|
204
|
+
debug: false,
|
|
205
|
+
env: {
|
|
206
|
+
OPENCODE_CLI_NAME: scriptPath,
|
|
207
|
+
},
|
|
208
|
+
});
|
|
209
|
+
await client.connect();
|
|
210
|
+
|
|
211
|
+
const runResponse = await client.callTool('run', {
|
|
212
|
+
prompt: 'e2e OpenCode initial prompt',
|
|
213
|
+
workFolder: testDir,
|
|
214
|
+
model: 'opencode',
|
|
215
|
+
});
|
|
216
|
+
const runData = JSON.parse(runResponse[0].text);
|
|
217
|
+
expect(runData.agent).toBe('opencode');
|
|
218
|
+
|
|
219
|
+
const initialWait = JSON.parse((await client.callTool('wait', { pids: [runData.pid], timeout: 5 }))[0].text);
|
|
220
|
+
expect(initialWait).toHaveLength(1);
|
|
221
|
+
expect(initialWait[0]).toMatchObject({
|
|
222
|
+
pid: runData.pid,
|
|
223
|
+
agent: 'opencode',
|
|
224
|
+
status: 'completed',
|
|
225
|
+
exitCode: 0,
|
|
226
|
+
model: 'opencode',
|
|
227
|
+
session_id: 'ses-opencode-e2e',
|
|
228
|
+
agentOutput: {
|
|
229
|
+
message: 'Initial: e2e OpenCode initial prompt',
|
|
230
|
+
session_id: 'ses-opencode-e2e',
|
|
231
|
+
},
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
const resumedResponse = await client.callTool('run', {
|
|
235
|
+
prompt: 'e2e OpenCode resumed prompt',
|
|
236
|
+
workFolder: testDir,
|
|
237
|
+
model: 'oc-openai/gpt-5.4',
|
|
238
|
+
session_id: 'ses-opencode-e2e',
|
|
239
|
+
});
|
|
240
|
+
const resumedRunData = JSON.parse(resumedResponse[0].text);
|
|
241
|
+
|
|
242
|
+
const resumedWait = JSON.parse((await client.callTool('wait', { pids: [resumedRunData.pid], timeout: 5 }))[0].text);
|
|
243
|
+
expect(resumedWait).toHaveLength(1);
|
|
244
|
+
expect(resumedWait[0]).toMatchObject({
|
|
245
|
+
pid: resumedRunData.pid,
|
|
246
|
+
agent: 'opencode',
|
|
247
|
+
status: 'completed',
|
|
248
|
+
exitCode: 0,
|
|
249
|
+
model: 'oc-openai/gpt-5.4',
|
|
250
|
+
session_id: 'ses-opencode-e2e',
|
|
251
|
+
agentOutput: {
|
|
252
|
+
message: 'Resumed model openai/gpt-5.4: e2e OpenCode resumed prompt',
|
|
253
|
+
session_id: 'ses-opencode-e2e',
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
const invocationLog = readFileSync(opencodeArgsLogPath, 'utf-8').trim().split('\n');
|
|
258
|
+
expect(invocationLog[0]).toContain(`--dir ${testDir}`);
|
|
259
|
+
expect(invocationLog[0]).not.toContain('--model');
|
|
260
|
+
expect(invocationLog[1]).toContain('--session ses-opencode-e2e');
|
|
261
|
+
expect(invocationLog[1]).toContain('--model openai/gpt-5.4');
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
222
265
|
describe('Debug Mode', () => {
|
|
223
266
|
it('should log debug information when enabled', async () => {
|
|
224
267
|
// Debug logs go to stderr, which we capture in the client
|
|
@@ -250,30 +293,18 @@ describe('Integration Tests (Local Only)', () => {
|
|
|
250
293
|
rmSync(testDir, { recursive: true, force: true });
|
|
251
294
|
});
|
|
252
295
|
|
|
253
|
-
//
|
|
254
|
-
it.skip('should
|
|
296
|
+
// This smoke test only verifies that a real Claude CLI can be invoked.
|
|
297
|
+
it.skip('should invoke the real Claude CLI', async () => {
|
|
255
298
|
await client.connect();
|
|
256
|
-
|
|
257
|
-
const response = await client.callTool('run', {
|
|
258
|
-
prompt: 'Create a file called hello.txt with content "Hello from Claude"',
|
|
259
|
-
workFolder: testDir,
|
|
260
|
-
});
|
|
261
299
|
|
|
262
|
-
const filePath = join(testDir, 'hello.txt');
|
|
263
|
-
expect(existsSync(filePath)).toBe(true);
|
|
264
|
-
expect(readFileSync(filePath, 'utf-8')).toContain('Hello from Claude');
|
|
265
|
-
});
|
|
266
|
-
|
|
267
|
-
it.skip('should handle git operations with real Claude CLI', async () => {
|
|
268
|
-
await client.connect();
|
|
269
|
-
|
|
270
|
-
// Initialize git repo
|
|
271
300
|
const response = await client.callTool('run', {
|
|
272
|
-
prompt: '
|
|
301
|
+
prompt: 'Reply with hi',
|
|
273
302
|
workFolder: testDir,
|
|
274
303
|
});
|
|
275
304
|
|
|
276
|
-
expect(
|
|
277
|
-
|
|
305
|
+
expect(response).toEqual([{
|
|
306
|
+
type: 'text',
|
|
307
|
+
text: expect.stringContaining('pid'),
|
|
308
|
+
}]);
|
|
278
309
|
});
|
|
279
310
|
});
|
|
@@ -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, MCPTestClient } from './utils/mcp-client.js';
|
|
7
8
|
|
|
8
9
|
function parseToolJson(content: any): any {
|
|
@@ -109,6 +110,13 @@ describe('MCP Contract Tests', () => {
|
|
|
109
110
|
'session_id',
|
|
110
111
|
'workFolder',
|
|
111
112
|
]);
|
|
113
|
+
expect(runTool.description).toContain('OpenCode');
|
|
114
|
+
expect(runTool.inputSchema.properties.model.description).toContain('opencode');
|
|
115
|
+
expect(runTool.inputSchema.properties.model.description).toContain('oc-<provider/model>');
|
|
116
|
+
expect(runTool.inputSchema.properties.reasoning_effort.description).toContain('OpenCode do not support reasoning_effort');
|
|
117
|
+
expect(runTool.inputSchema.properties.session_id.description).toBe(
|
|
118
|
+
'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.'
|
|
119
|
+
);
|
|
112
120
|
|
|
113
121
|
const getResultTool = tools.find((tool: any) => tool.name === 'get_result');
|
|
114
122
|
expect(getResultTool.inputSchema.required).toEqual(['pid']);
|
|
@@ -470,6 +478,177 @@ printf '%s\n' '{"type":"system","session_id":"session-verbose-1"}'
|
|
|
470
478
|
).rejects.toThrow(/reasoning_effort is not supported for forge/i);
|
|
471
479
|
});
|
|
472
480
|
|
|
481
|
+
it('covers OpenCode end-to-end through the MCP process path', async () => {
|
|
482
|
+
await client.disconnect();
|
|
483
|
+
|
|
484
|
+
const opencodeArgsLogPath = join(testDir, 'opencode-args.log');
|
|
485
|
+
const { scriptPath: openCodeMockPath } = createOpenCodeMock(testDir, {
|
|
486
|
+
argsLogPath: opencodeArgsLogPath,
|
|
487
|
+
defaultSessionId: 'ses-opencode-contract',
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
client = createTestClient({
|
|
491
|
+
debug: false,
|
|
492
|
+
env: {
|
|
493
|
+
OPENCODE_CLI_NAME: openCodeMockPath,
|
|
494
|
+
},
|
|
495
|
+
});
|
|
496
|
+
await client.connect();
|
|
497
|
+
|
|
498
|
+
const initialRunResponse = await client.callTool('run', {
|
|
499
|
+
prompt: 'opencode-initial-prompt',
|
|
500
|
+
workFolder: testDir,
|
|
501
|
+
model: 'opencode',
|
|
502
|
+
});
|
|
503
|
+
const initialRunData = parseToolJson(initialRunResponse);
|
|
504
|
+
|
|
505
|
+
expect(initialRunData).toEqual({
|
|
506
|
+
pid: expect.any(Number),
|
|
507
|
+
status: 'started',
|
|
508
|
+
agent: 'opencode',
|
|
509
|
+
message: expect.any(String),
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
const initialWaitResponse = await client.callTool('wait', { pids: [initialRunData.pid], timeout: 5 });
|
|
513
|
+
const initialWaitData = parseToolJson(initialWaitResponse);
|
|
514
|
+
|
|
515
|
+
expect(initialWaitData).toHaveLength(1);
|
|
516
|
+
expect(initialWaitData[0]).toMatchObject({
|
|
517
|
+
pid: initialRunData.pid,
|
|
518
|
+
agent: 'opencode',
|
|
519
|
+
status: 'completed',
|
|
520
|
+
exitCode: 0,
|
|
521
|
+
model: 'opencode',
|
|
522
|
+
session_id: 'ses-opencode-contract',
|
|
523
|
+
agentOutput: {
|
|
524
|
+
message: 'Initial: opencode-initial-prompt',
|
|
525
|
+
session_id: 'ses-opencode-contract',
|
|
526
|
+
tokens: { total: 11833 },
|
|
527
|
+
cost: 0,
|
|
528
|
+
},
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
const resumedDefaultRunResponse = await client.callTool('run', {
|
|
532
|
+
prompt: 'opencode-resume-default',
|
|
533
|
+
workFolder: testDir,
|
|
534
|
+
model: 'opencode',
|
|
535
|
+
session_id: 'ses-opencode-contract',
|
|
536
|
+
});
|
|
537
|
+
const resumedDefaultRunData = parseToolJson(resumedDefaultRunResponse);
|
|
538
|
+
|
|
539
|
+
const resumedDefaultWaitResponse = await client.callTool('wait', { pids: [resumedDefaultRunData.pid], timeout: 5 });
|
|
540
|
+
const resumedDefaultWaitData = parseToolJson(resumedDefaultWaitResponse);
|
|
541
|
+
|
|
542
|
+
expect(resumedDefaultWaitData).toHaveLength(1);
|
|
543
|
+
expect(resumedDefaultWaitData[0]).toMatchObject({
|
|
544
|
+
pid: resumedDefaultRunData.pid,
|
|
545
|
+
agent: 'opencode',
|
|
546
|
+
status: 'completed',
|
|
547
|
+
exitCode: 0,
|
|
548
|
+
model: 'opencode',
|
|
549
|
+
session_id: 'ses-opencode-contract',
|
|
550
|
+
agentOutput: {
|
|
551
|
+
message: 'Resumed: opencode-resume-default',
|
|
552
|
+
session_id: 'ses-opencode-contract',
|
|
553
|
+
tokens: { total: 11833 },
|
|
554
|
+
cost: 0,
|
|
555
|
+
},
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
const resumedExplicitRunResponse = await client.callTool('run', {
|
|
559
|
+
prompt: 'opencode-resume-explicit',
|
|
560
|
+
workFolder: testDir,
|
|
561
|
+
model: 'oc-openai/gpt-5.4',
|
|
562
|
+
session_id: 'ses-opencode-contract',
|
|
563
|
+
});
|
|
564
|
+
const resumedExplicitRunData = parseToolJson(resumedExplicitRunResponse);
|
|
565
|
+
|
|
566
|
+
const resumedExplicitWaitResponse = await client.callTool('wait', { pids: [resumedExplicitRunData.pid], timeout: 5 });
|
|
567
|
+
const resumedExplicitWaitData = parseToolJson(resumedExplicitWaitResponse);
|
|
568
|
+
|
|
569
|
+
expect(resumedExplicitWaitData).toHaveLength(1);
|
|
570
|
+
expect(resumedExplicitWaitData[0]).toMatchObject({
|
|
571
|
+
pid: resumedExplicitRunData.pid,
|
|
572
|
+
agent: 'opencode',
|
|
573
|
+
status: 'completed',
|
|
574
|
+
exitCode: 0,
|
|
575
|
+
model: 'oc-openai/gpt-5.4',
|
|
576
|
+
session_id: 'ses-opencode-contract',
|
|
577
|
+
agentOutput: {
|
|
578
|
+
message: 'Resumed model openai/gpt-5.4: opencode-resume-explicit',
|
|
579
|
+
session_id: 'ses-opencode-contract',
|
|
580
|
+
tokens: { total: 11833 },
|
|
581
|
+
cost: 0,
|
|
582
|
+
},
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
const failedRunResponse = await client.callTool('run', {
|
|
586
|
+
prompt: 'please fail',
|
|
587
|
+
workFolder: testDir,
|
|
588
|
+
model: 'oc-openai/gpt-5.4',
|
|
589
|
+
});
|
|
590
|
+
const failedRunData = parseToolJson(failedRunResponse);
|
|
591
|
+
|
|
592
|
+
const compactFailedWait = parseToolJson(await client.callTool('wait', { pids: [failedRunData.pid], timeout: 5 }));
|
|
593
|
+
expect(compactFailedWait).toHaveLength(1);
|
|
594
|
+
expect(compactFailedWait[0]).toMatchObject({
|
|
595
|
+
pid: failedRunData.pid,
|
|
596
|
+
agent: 'opencode',
|
|
597
|
+
status: 'failed',
|
|
598
|
+
exitCode: 7,
|
|
599
|
+
model: 'oc-openai/gpt-5.4',
|
|
600
|
+
session_id: 'ses-opencode-contract',
|
|
601
|
+
stdout: expect.stringContaining('Partial failure output'),
|
|
602
|
+
stderr: expect.stringContaining('OpenCode failed for openai/gpt-5.4'),
|
|
603
|
+
});
|
|
604
|
+
expect(compactFailedWait[0]).not.toHaveProperty('agentOutput');
|
|
605
|
+
|
|
606
|
+
const verboseFailedResult = parseToolJson(await client.callTool('get_result', { pid: failedRunData.pid, verbose: true }));
|
|
607
|
+
expect(verboseFailedResult).toMatchObject({
|
|
608
|
+
pid: failedRunData.pid,
|
|
609
|
+
agent: 'opencode',
|
|
610
|
+
status: 'failed',
|
|
611
|
+
exitCode: 7,
|
|
612
|
+
model: 'oc-openai/gpt-5.4',
|
|
613
|
+
session_id: 'ses-opencode-contract',
|
|
614
|
+
stdout: expect.stringContaining('Partial failure output'),
|
|
615
|
+
stderr: expect.stringContaining('OpenCode failed for openai/gpt-5.4'),
|
|
616
|
+
agentOutput: {
|
|
617
|
+
message: 'Partial failure output',
|
|
618
|
+
session_id: 'ses-opencode-contract',
|
|
619
|
+
tokens: { total: 42 },
|
|
620
|
+
cost: 0,
|
|
621
|
+
},
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
const openCodeInvocations = readFileSync(opencodeArgsLogPath, 'utf-8').trim().split('\n');
|
|
625
|
+
expect(openCodeInvocations).toHaveLength(4);
|
|
626
|
+
expect(openCodeInvocations[0]).toContain('run --format json');
|
|
627
|
+
expect(openCodeInvocations[0]).toContain(`--dir ${testDir}`);
|
|
628
|
+
expect(openCodeInvocations[0]).not.toContain('--session');
|
|
629
|
+
expect(openCodeInvocations[0]).not.toContain('--model');
|
|
630
|
+
|
|
631
|
+
expect(openCodeInvocations[1]).toContain(`--dir ${testDir}`);
|
|
632
|
+
expect(openCodeInvocations[1]).toContain('--session ses-opencode-contract');
|
|
633
|
+
expect(openCodeInvocations[1]).not.toContain('--model');
|
|
634
|
+
|
|
635
|
+
expect(openCodeInvocations[2]).toContain(`--dir ${testDir}`);
|
|
636
|
+
expect(openCodeInvocations[2]).toContain('--session ses-opencode-contract');
|
|
637
|
+
expect(openCodeInvocations[2]).toContain('--model openai/gpt-5.4');
|
|
638
|
+
|
|
639
|
+
expect(openCodeInvocations[3]).toContain(`--dir ${testDir}`);
|
|
640
|
+
expect(openCodeInvocations[3]).toContain('--model openai/gpt-5.4');
|
|
641
|
+
|
|
642
|
+
await expect(
|
|
643
|
+
client.callTool('run', {
|
|
644
|
+
prompt: 'opencode-invalid-reasoning',
|
|
645
|
+
workFolder: testDir,
|
|
646
|
+
model: 'opencode',
|
|
647
|
+
reasoning_effort: 'high',
|
|
648
|
+
})
|
|
649
|
+
).rejects.toThrow(/reasoning_effort is not supported for opencode/i);
|
|
650
|
+
});
|
|
651
|
+
|
|
473
652
|
it('keeps key invalid-input errors stable', async () => {
|
|
474
653
|
await expect(
|
|
475
654
|
client.callTool('run', {
|
|
@@ -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
|
|
|
4
4
|
describe('parseCodexOutput', () => {
|
|
5
5
|
it('should parse basic Codex output with message and session_id', () => {
|
|
@@ -149,3 +149,75 @@ still streaming`;
|
|
|
149
149
|
expect(parseForgeOutput('plain text')).toBeNull();
|
|
150
150
|
});
|
|
151
151
|
});
|
|
152
|
+
|
|
153
|
+
describe('parseOpenCodeOutput', () => {
|
|
154
|
+
it('parses a single completed OpenCode step', () => {
|
|
155
|
+
const output = `{"type":"step_start","sessionID":"ses_1"}
|
|
156
|
+
{"type":"text","sessionID":"ses_1","part":{"type":"text","text":"Hello"}}
|
|
157
|
+
{"type":"step_finish","sessionID":"ses_1","part":{"type":"step-finish","tokens":{"total":11833},"cost":0}}`;
|
|
158
|
+
|
|
159
|
+
expect(parseOpenCodeOutput(output)).toEqual({
|
|
160
|
+
message: 'Hello',
|
|
161
|
+
session_id: 'ses_1',
|
|
162
|
+
tokens: { total: 11833 },
|
|
163
|
+
cost: 0,
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('returns the last completed step for multi-step output', () => {
|
|
168
|
+
const output = `{"type":"step_start","sessionID":"ses_2"}
|
|
169
|
+
{"type":"text","sessionID":"ses_2","part":{"type":"text","text":"First"}}
|
|
170
|
+
{"type":"step_finish","sessionID":"ses_2","part":{"type":"step-finish","tokens":{"total":10},"cost":0}}
|
|
171
|
+
{"type":"step_start","sessionID":"ses_2"}
|
|
172
|
+
{"type":"text","sessionID":"ses_2","part":{"type":"text","text":"Second"}}
|
|
173
|
+
{"type":"step_finish","sessionID":"ses_2","part":{"type":"step-finish","tokens":{"total":20},"cost":1}}`;
|
|
174
|
+
|
|
175
|
+
expect(parseOpenCodeOutput(output)).toEqual({
|
|
176
|
+
message: 'Second',
|
|
177
|
+
session_id: 'ses_2',
|
|
178
|
+
tokens: { total: 20 },
|
|
179
|
+
cost: 1,
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('resets the current-step buffer on each step_start', () => {
|
|
184
|
+
const output = `{"type":"step_start","sessionID":"ses_3"}
|
|
185
|
+
{"type":"text","sessionID":"ses_3","part":{"type":"text","text":"Discard me"}}
|
|
186
|
+
{"type":"step_start","sessionID":"ses_3"}
|
|
187
|
+
{"type":"text","sessionID":"ses_3","part":{"type":"text","text":"Keep me"}}
|
|
188
|
+
{"type":"step_finish","sessionID":"ses_3","part":{"type":"step-finish","tokens":{"total":5},"cost":0}}`;
|
|
189
|
+
|
|
190
|
+
expect(parseOpenCodeOutput(output)).toEqual({
|
|
191
|
+
message: 'Keep me',
|
|
192
|
+
session_id: 'ses_3',
|
|
193
|
+
tokens: { total: 5 },
|
|
194
|
+
cost: 0,
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('returns partial output when text exists without step_finish', () => {
|
|
199
|
+
const output = `{"type":"step_start","sessionID":"ses_4"}
|
|
200
|
+
{"type":"text","sessionID":"ses_4","part":{"type":"text","text":"Partial"}}`;
|
|
201
|
+
|
|
202
|
+
expect(parseOpenCodeOutput(output)).toEqual({
|
|
203
|
+
message: 'Partial',
|
|
204
|
+
session_id: 'ses_4',
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('ignores malformed lines and unknown event types', () => {
|
|
209
|
+
const output = `not-json
|
|
210
|
+
{"type":"unknown","sessionID":"ses_5"}
|
|
211
|
+
{"type":"text","sessionID":"ses_5","part":{"type":"text","text":"Hello"}}`;
|
|
212
|
+
|
|
213
|
+
expect(parseOpenCodeOutput(output)).toEqual({
|
|
214
|
+
message: 'Hello',
|
|
215
|
+
session_id: 'ses_5',
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('returns null when no useful OpenCode events exist', () => {
|
|
220
|
+
expect(parseOpenCodeOutput('{"type":"unknown"}')).toBeNull();
|
|
221
|
+
expect(parseOpenCodeOutput('')).toBeNull();
|
|
222
|
+
});
|
|
223
|
+
});
|
|
@@ -68,7 +68,7 @@ describe('Process Management Tests', () => {
|
|
|
68
68
|
async function setupServer() {
|
|
69
69
|
const { Server } = await import('@modelcontextprotocol/sdk/server/index.js');
|
|
70
70
|
|
|
71
|
-
vi.mocked(Server).mockImplementation(()
|
|
71
|
+
vi.mocked(Server).mockImplementation(function(this: any) {
|
|
72
72
|
mockServerInstance = {
|
|
73
73
|
setRequestHandler: vi.fn((schema: any, handler: Function) => {
|
|
74
74
|
handlers.set(schema.name, handler);
|
|
@@ -201,6 +201,41 @@ describe('ClaudeCodeServer Unit Tests', () => {
|
|
|
201
201
|
});
|
|
202
202
|
});
|
|
203
203
|
|
|
204
|
+
describe('findOpencodeCli function', () => {
|
|
205
|
+
it('should fallback to PATH for OpenCode when no override is configured', async () => {
|
|
206
|
+
mockHomedir.mockReturnValue('/home/user');
|
|
207
|
+
mockExistsSync.mockImplementation((path) => path === '/usr/bin/opencode');
|
|
208
|
+
mockAccessSync.mockImplementation((filePath) => {
|
|
209
|
+
if (filePath === '/usr/bin/opencode') return undefined;
|
|
210
|
+
throw new Error('not executable');
|
|
211
|
+
});
|
|
212
|
+
process.env.PATH = '/usr/bin';
|
|
213
|
+
|
|
214
|
+
const module = await import('../server.js');
|
|
215
|
+
// @ts-ignore
|
|
216
|
+
const findOpencodeCli = module.default?.findOpencodeCli || module.findOpencodeCli;
|
|
217
|
+
|
|
218
|
+
expect(findOpencodeCli()).toBe('/usr/bin/opencode');
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('should use custom name from OPENCODE_CLI_NAME', async () => {
|
|
222
|
+
process.env.OPENCODE_CLI_NAME = 'opencode-custom';
|
|
223
|
+
mockHomedir.mockReturnValue('/home/user');
|
|
224
|
+
mockExistsSync.mockImplementation((path) => path === '/usr/bin/opencode-custom');
|
|
225
|
+
mockAccessSync.mockImplementation((filePath) => {
|
|
226
|
+
if (filePath === '/usr/bin/opencode-custom') return undefined;
|
|
227
|
+
throw new Error('not executable');
|
|
228
|
+
});
|
|
229
|
+
process.env.PATH = '/usr/bin';
|
|
230
|
+
|
|
231
|
+
const module = await import('../server.js');
|
|
232
|
+
// @ts-ignore
|
|
233
|
+
const findOpencodeCli = module.default?.findOpencodeCli || module.findOpencodeCli;
|
|
234
|
+
|
|
235
|
+
expect(findOpencodeCli()).toBe('opencode-custom');
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
204
239
|
describe('spawnAsync function', () => {
|
|
205
240
|
let mockProcess: any;
|
|
206
241
|
|
|
@@ -333,29 +368,29 @@ describe('ClaudeCodeServer Unit Tests', () => {
|
|
|
333
368
|
);
|
|
334
369
|
});
|
|
335
370
|
|
|
336
|
-
it('should
|
|
371
|
+
it('should include OpenCode in setup logging', async () => {
|
|
337
372
|
mockHomedir.mockReturnValue('/home/user');
|
|
338
373
|
mockExistsSync.mockReturnValue(true);
|
|
339
|
-
|
|
340
|
-
const { Server } = await import('@modelcontextprotocol/sdk/server/index.js');
|
|
341
|
-
const mockSetRequestHandler = vi.fn();
|
|
374
|
+
|
|
342
375
|
vi.mocked(Server).mockImplementation(function(this: any) {
|
|
343
|
-
this.setRequestHandler =
|
|
376
|
+
this.setRequestHandler = vi.fn();
|
|
344
377
|
this.connect = vi.fn();
|
|
345
378
|
this.close = vi.fn();
|
|
346
379
|
this.onerror = undefined;
|
|
347
380
|
return this;
|
|
348
381
|
});
|
|
349
|
-
|
|
382
|
+
|
|
350
383
|
const module = await import('../server.js');
|
|
351
384
|
// @ts-ignore
|
|
352
385
|
const { ClaudeCodeServer } = module;
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
386
|
+
new ClaudeCodeServer();
|
|
387
|
+
|
|
388
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
389
|
+
expect.stringContaining('[Setup] Using OpenCode CLI command/path:')
|
|
390
|
+
);
|
|
357
391
|
});
|
|
358
392
|
|
|
393
|
+
|
|
359
394
|
it('should set up error handler', async () => {
|
|
360
395
|
mockHomedir.mockReturnValue('/home/user');
|
|
361
396
|
mockExistsSync.mockReturnValue(true);
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { chmodSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
export interface OpenCodeMockOptions {
|
|
5
|
+
argsLogPath?: string;
|
|
6
|
+
defaultSessionId?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface OpenCodeMockResult {
|
|
10
|
+
scriptPath: string;
|
|
11
|
+
argsLogPath?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function createOpenCodeMock(dir: string, options: OpenCodeMockOptions = {}): OpenCodeMockResult {
|
|
15
|
+
const scriptPath = join(dir, 'mock-opencode');
|
|
16
|
+
const defaultSessionId = options.defaultSessionId || 'ses-opencode-default';
|
|
17
|
+
const argsLogPath = options.argsLogPath;
|
|
18
|
+
const argsLogSection = argsLogPath
|
|
19
|
+
? `printf '%s\n' "$*" >> "${argsLogPath}"\n`
|
|
20
|
+
: '';
|
|
21
|
+
|
|
22
|
+
writeFileSync(
|
|
23
|
+
scriptPath,
|
|
24
|
+
`#!/bin/bash
|
|
25
|
+
set -euo pipefail
|
|
26
|
+
|
|
27
|
+
prompt=""
|
|
28
|
+
session_id=""
|
|
29
|
+
session_provided=0
|
|
30
|
+
model=""
|
|
31
|
+
work_dir=""
|
|
32
|
+
|
|
33
|
+
${argsLogSection}if [[ "\${1:-}" == "run" ]]; then
|
|
34
|
+
shift
|
|
35
|
+
fi
|
|
36
|
+
|
|
37
|
+
while [[ $# -gt 0 ]]; do
|
|
38
|
+
case "$1" in
|
|
39
|
+
--format)
|
|
40
|
+
shift 2
|
|
41
|
+
;;
|
|
42
|
+
--dir)
|
|
43
|
+
work_dir="$2"
|
|
44
|
+
shift 2
|
|
45
|
+
;;
|
|
46
|
+
--session)
|
|
47
|
+
session_id="$2"
|
|
48
|
+
session_provided=1
|
|
49
|
+
shift 2
|
|
50
|
+
;;
|
|
51
|
+
--model)
|
|
52
|
+
model="$2"
|
|
53
|
+
shift 2
|
|
54
|
+
;;
|
|
55
|
+
*)
|
|
56
|
+
prompt="$1"
|
|
57
|
+
shift
|
|
58
|
+
;;
|
|
59
|
+
esac
|
|
60
|
+
done
|
|
61
|
+
|
|
62
|
+
if [[ -z "$session_id" ]]; then
|
|
63
|
+
session_id="${defaultSessionId}"
|
|
64
|
+
fi
|
|
65
|
+
|
|
66
|
+
if [[ "$prompt" == *"sleep"* ]]; then
|
|
67
|
+
sleep 5
|
|
68
|
+
fi
|
|
69
|
+
|
|
70
|
+
if [[ "$prompt" == *"fail"* ]]; then
|
|
71
|
+
printf '{"type":"step_start","sessionID":"%s"}\n' "$session_id"
|
|
72
|
+
printf '{"type":"text","sessionID":"%s","part":{"type":"text","text":"Partial failure output"}}\n' "$session_id"
|
|
73
|
+
printf '{"type":"step_finish","sessionID":"%s","part":{"type":"step-finish","tokens":{"total":42},"cost":0}}\n' "$session_id"
|
|
74
|
+
printf 'OpenCode failed for %s in %s\n' "$model" "$work_dir" >&2
|
|
75
|
+
exit 7
|
|
76
|
+
fi
|
|
77
|
+
|
|
78
|
+
if [[ "$prompt" == *"multi-step"* ]]; then
|
|
79
|
+
printf '{"type":"step_start","sessionID":"%s"}\n' "$session_id"
|
|
80
|
+
printf '{"type":"text","sessionID":"%s","part":{"type":"text","text":"First step"}}\n' "$session_id"
|
|
81
|
+
printf '{"type":"step_finish","sessionID":"%s","part":{"type":"step-finish","tokens":{"total":11},"cost":0}}\n' "$session_id"
|
|
82
|
+
printf '{"type":"step_start","sessionID":"%s"}\n' "$session_id"
|
|
83
|
+
printf '{"type":"text","sessionID":"%s","part":{"type":"text","text":"Second step"}}\n' "$session_id"
|
|
84
|
+
printf '{"type":"step_finish","sessionID":"%s","part":{"type":"step-finish","tokens":{"total":22},"cost":1}}\n' "$session_id"
|
|
85
|
+
exit 0
|
|
86
|
+
fi
|
|
87
|
+
|
|
88
|
+
message_prefix="Initial"
|
|
89
|
+
if [[ $session_provided -eq 1 ]]; then
|
|
90
|
+
message_prefix="Resumed"
|
|
91
|
+
fi
|
|
92
|
+
if [[ -n "$model" ]]; then
|
|
93
|
+
message_prefix="Model $model"
|
|
94
|
+
if [[ $session_provided -eq 1 ]]; then
|
|
95
|
+
message_prefix="Resumed model $model"
|
|
96
|
+
fi
|
|
97
|
+
fi
|
|
98
|
+
|
|
99
|
+
printf '{"type":"step_start","sessionID":"%s"}\n' "$session_id"
|
|
100
|
+
printf '{"type":"text","sessionID":"%s","part":{"type":"text","text":"%s: %s"}}\n' "$session_id" "$message_prefix" "$prompt"
|
|
101
|
+
printf '{"type":"step_finish","sessionID":"%s","part":{"type":"step-finish","tokens":{"total":11833},"cost":0}}\n' "$session_id"
|
|
102
|
+
`,
|
|
103
|
+
'utf8',
|
|
104
|
+
);
|
|
105
|
+
chmodSync(scriptPath, 0o755);
|
|
106
|
+
|
|
107
|
+
return { scriptPath, argsLogPath };
|
|
108
|
+
}
|