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.
Files changed (60) hide show
  1. package/.github/dependabot.yml +28 -0
  2. package/.github/workflows/ci.yml +4 -1
  3. package/.github/workflows/dependency-review.yml +22 -0
  4. package/CHANGELOG.md +14 -0
  5. package/README.ja.md +83 -6
  6. package/README.md +83 -7
  7. package/dist/__tests__/app-cli.test.js +80 -5
  8. package/dist/__tests__/cli-bin-smoke.test.js +43 -0
  9. package/dist/__tests__/cli-builder.test.js +93 -15
  10. package/dist/__tests__/cli-process-service.test.js +162 -0
  11. package/dist/__tests__/cli-utils.test.js +31 -0
  12. package/dist/__tests__/e2e.test.js +79 -52
  13. package/dist/__tests__/mcp-contract.test.js +162 -0
  14. package/dist/__tests__/parsers.test.js +224 -1
  15. package/dist/__tests__/peek.test.js +35 -0
  16. package/dist/__tests__/process-management.test.js +160 -1
  17. package/dist/__tests__/server.test.js +39 -9
  18. package/dist/__tests__/utils/opencode-mock.js +91 -0
  19. package/dist/__tests__/validation.test.js +40 -2
  20. package/dist/app/cli.js +47 -5
  21. package/dist/app/mcp.js +53 -4
  22. package/dist/cli-builder.js +67 -28
  23. package/dist/cli-parse.js +11 -5
  24. package/dist/cli-process-service.js +241 -20
  25. package/dist/cli-utils.js +14 -23
  26. package/dist/cli.js +6 -4
  27. package/dist/model-catalog.js +13 -1
  28. package/dist/parsers.js +242 -28
  29. package/dist/peek.js +56 -0
  30. package/dist/process-result.js +9 -2
  31. package/dist/process-service.js +103 -17
  32. package/dist/server.js +1 -2
  33. package/package.json +9 -6
  34. package/src/__tests__/app-cli.test.ts +95 -4
  35. package/src/__tests__/cli-bin-smoke.test.ts +62 -1
  36. package/src/__tests__/cli-builder.test.ts +111 -15
  37. package/src/__tests__/cli-process-service.test.ts +180 -0
  38. package/src/__tests__/cli-utils.test.ts +34 -0
  39. package/src/__tests__/e2e.test.ts +87 -55
  40. package/src/__tests__/mcp-contract.test.ts +188 -0
  41. package/src/__tests__/parsers.test.ts +260 -1
  42. package/src/__tests__/peek.test.ts +43 -0
  43. package/src/__tests__/process-management.test.ts +185 -1
  44. package/src/__tests__/server.test.ts +49 -13
  45. package/src/__tests__/utils/opencode-mock.ts +108 -0
  46. package/src/__tests__/validation.test.ts +48 -2
  47. package/src/app/cli.ts +52 -4
  48. package/src/app/mcp.ts +54 -4
  49. package/src/cli-builder.ts +91 -32
  50. package/src/cli-parse.ts +11 -5
  51. package/src/cli-process-service.ts +304 -17
  52. package/src/cli-utils.ts +37 -33
  53. package/src/cli.ts +6 -4
  54. package/src/model-catalog.ts +24 -1
  55. package/src/parsers.ts +299 -33
  56. package/src/peek.ts +88 -0
  57. package/src/process-result.ts +11 -2
  58. package/src/process-service.ts +134 -15
  59. package/src/server.ts +2 -2
  60. 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, existsSync } from 'node:fs';
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;
@@ -28,45 +29,16 @@ describe('Claude Code MCP E2E Tests', () => {
28
29
  describe('Tool Registration', () => {
29
30
  it('should register run tool', async () => {
30
31
  const tools = await client.listTools();
31
- expect(tools).toHaveLength(6);
32
+ expect(tools).toHaveLength(7);
32
33
  const claudeCodeTool = tools.find((t) => t.name === 'run');
33
- expect(claudeCodeTool).toEqual({
34
- name: 'run',
35
- description: expect.stringContaining('AI Agent Runner'),
36
- inputSchema: {
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);
41
+ expect(tools.some((t) => t.name === 'peek')).toBe(true);
70
42
  expect(tools.some((t) => t.name === 'kill_process')).toBe(true);
71
43
  });
72
44
  });
@@ -180,6 +152,70 @@ describe('Claude Code MCP E2E Tests', () => {
180
152
  }]);
181
153
  });
182
154
  });
155
+ describe('OpenCode flows', () => {
156
+ it('should execute and resume OpenCode runs through the MCP client', async () => {
157
+ await client.disconnect();
158
+ const opencodeArgsLogPath = join(testDir, 'opencode-args.log');
159
+ const { scriptPath } = createOpenCodeMock(testDir, {
160
+ argsLogPath: opencodeArgsLogPath,
161
+ defaultSessionId: 'ses-opencode-e2e',
162
+ });
163
+ client = createTestClient({
164
+ debug: false,
165
+ env: {
166
+ OPENCODE_CLI_NAME: scriptPath,
167
+ },
168
+ });
169
+ await client.connect();
170
+ const runResponse = await client.callTool('run', {
171
+ prompt: 'e2e OpenCode initial prompt',
172
+ workFolder: testDir,
173
+ model: 'opencode',
174
+ });
175
+ const runData = JSON.parse(runResponse[0].text);
176
+ expect(runData.agent).toBe('opencode');
177
+ const initialWait = JSON.parse((await client.callTool('wait', { pids: [runData.pid], timeout: 5 }))[0].text);
178
+ expect(initialWait).toHaveLength(1);
179
+ expect(initialWait[0]).toMatchObject({
180
+ pid: runData.pid,
181
+ agent: 'opencode',
182
+ status: 'completed',
183
+ exitCode: 0,
184
+ model: 'opencode',
185
+ session_id: 'ses-opencode-e2e',
186
+ agentOutput: {
187
+ message: 'Initial: e2e OpenCode initial prompt',
188
+ session_id: 'ses-opencode-e2e',
189
+ },
190
+ });
191
+ const resumedResponse = await client.callTool('run', {
192
+ prompt: 'e2e OpenCode resumed prompt',
193
+ workFolder: testDir,
194
+ model: 'oc-openai/gpt-5.4',
195
+ session_id: 'ses-opencode-e2e',
196
+ });
197
+ const resumedRunData = JSON.parse(resumedResponse[0].text);
198
+ const resumedWait = JSON.parse((await client.callTool('wait', { pids: [resumedRunData.pid], timeout: 5 }))[0].text);
199
+ expect(resumedWait).toHaveLength(1);
200
+ expect(resumedWait[0]).toMatchObject({
201
+ pid: resumedRunData.pid,
202
+ agent: 'opencode',
203
+ status: 'completed',
204
+ exitCode: 0,
205
+ model: 'oc-openai/gpt-5.4',
206
+ session_id: 'ses-opencode-e2e',
207
+ agentOutput: {
208
+ message: 'Resumed model openai/gpt-5.4: e2e OpenCode resumed prompt',
209
+ session_id: 'ses-opencode-e2e',
210
+ },
211
+ });
212
+ const invocationLog = readFileSync(opencodeArgsLogPath, 'utf-8').trim().split('\n');
213
+ expect(invocationLog[0]).toContain(`--dir ${testDir}`);
214
+ expect(invocationLog[0]).not.toContain('--model');
215
+ expect(invocationLog[1]).toContain('--session ses-opencode-e2e');
216
+ expect(invocationLog[1]).toContain('--model openai/gpt-5.4');
217
+ });
218
+ });
183
219
  describe('Debug Mode', () => {
184
220
  it('should log debug information when enabled', async () => {
185
221
  // Debug logs go to stderr, which we capture in the client
@@ -205,25 +241,16 @@ describe('Integration Tests (Local Only)', () => {
205
241
  }
206
242
  rmSync(testDir, { recursive: true, force: true });
207
243
  });
208
- // These tests will only run locally when Claude is available
209
- it.skip('should create a file with real Claude CLI', async () => {
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 () => {
244
+ // This smoke test only verifies that a real Claude CLI can be invoked.
245
+ it.skip('should invoke the real Claude CLI', async () => {
220
246
  await client.connect();
221
- // Initialize git repo
222
247
  const response = await client.callTool('run', {
223
- prompt: 'Initialize a git repository and create a README.md file',
248
+ prompt: 'Reply with hi',
224
249
  workFolder: testDir,
225
250
  });
226
- expect(existsSync(join(testDir, '.git'))).toBe(true);
227
- expect(existsSync(join(testDir, 'README.md'))).toBe(true);
251
+ expect(response).toEqual([{
252
+ type: 'text',
253
+ text: expect.stringContaining('pid'),
254
+ }]);
228
255
  });
229
256
  });
@@ -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);
@@ -83,6 +84,7 @@ describe('MCP Contract Tests', () => {
83
84
  'get_result',
84
85
  'kill_process',
85
86
  'list_processes',
87
+ 'peek',
86
88
  'run',
87
89
  'wait',
88
90
  ]);
@@ -96,6 +98,11 @@ describe('MCP Contract Tests', () => {
96
98
  'session_id',
97
99
  'workFolder',
98
100
  ]);
101
+ expect(runTool.description).toContain('OpenCode');
102
+ expect(runTool.inputSchema.properties.model.description).toContain('opencode');
103
+ expect(runTool.inputSchema.properties.model.description).toContain('oc-<provider/model>');
104
+ expect(runTool.inputSchema.properties.reasoning_effort.description).toContain('OpenCode do not support reasoning_effort');
105
+ 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
106
  const getResultTool = tools.find((tool) => tool.name === 'get_result');
100
107
  expect(getResultTool.inputSchema.required).toEqual(['pid']);
101
108
  expect(Object.keys(getResultTool.inputSchema.properties).sort()).toEqual([
@@ -109,6 +116,13 @@ describe('MCP Contract Tests', () => {
109
116
  'timeout',
110
117
  'verbose',
111
118
  ]);
119
+ const peekTool = tools.find((tool) => tool.name === 'peek');
120
+ expect(peekTool.inputSchema.required).toEqual(['pids']);
121
+ expect(Object.keys(peekTool.inputSchema.properties).sort()).toEqual([
122
+ 'peek_time_sec',
123
+ 'pids',
124
+ ]);
125
+ expect(peekTool.description).toContain('One-shot');
112
126
  });
113
127
  it('preserves the stdio MCP smoke flow and response shapes', async () => {
114
128
  const runResponse = await client.callTool('run', {
@@ -407,6 +421,154 @@ printf '%s\n' '{"type":"system","session_id":"session-verbose-1"}'
407
421
  reasoning_effort: 'high',
408
422
  })).rejects.toThrow(/reasoning_effort is not supported for forge/i);
409
423
  });
424
+ it('covers OpenCode end-to-end through the MCP process path', async () => {
425
+ await client.disconnect();
426
+ const opencodeArgsLogPath = join(testDir, 'opencode-args.log');
427
+ const { scriptPath: openCodeMockPath } = createOpenCodeMock(testDir, {
428
+ argsLogPath: opencodeArgsLogPath,
429
+ defaultSessionId: 'ses-opencode-contract',
430
+ });
431
+ client = createTestClient({
432
+ debug: false,
433
+ env: {
434
+ OPENCODE_CLI_NAME: openCodeMockPath,
435
+ },
436
+ });
437
+ await client.connect();
438
+ const initialRunResponse = await client.callTool('run', {
439
+ prompt: 'opencode-initial-prompt',
440
+ workFolder: testDir,
441
+ model: 'opencode',
442
+ });
443
+ const initialRunData = parseToolJson(initialRunResponse);
444
+ expect(initialRunData).toEqual({
445
+ pid: expect.any(Number),
446
+ status: 'started',
447
+ agent: 'opencode',
448
+ message: expect.any(String),
449
+ });
450
+ const initialWaitResponse = await client.callTool('wait', { pids: [initialRunData.pid], timeout: 5 });
451
+ const initialWaitData = parseToolJson(initialWaitResponse);
452
+ expect(initialWaitData).toHaveLength(1);
453
+ expect(initialWaitData[0]).toMatchObject({
454
+ pid: initialRunData.pid,
455
+ agent: 'opencode',
456
+ status: 'completed',
457
+ exitCode: 0,
458
+ model: 'opencode',
459
+ session_id: 'ses-opencode-contract',
460
+ agentOutput: {
461
+ message: 'Initial: opencode-initial-prompt',
462
+ session_id: 'ses-opencode-contract',
463
+ tokens: { total: 11833 },
464
+ cost: 0,
465
+ },
466
+ });
467
+ const resumedDefaultRunResponse = await client.callTool('run', {
468
+ prompt: 'opencode-resume-default',
469
+ workFolder: testDir,
470
+ model: 'opencode',
471
+ session_id: 'ses-opencode-contract',
472
+ });
473
+ const resumedDefaultRunData = parseToolJson(resumedDefaultRunResponse);
474
+ const resumedDefaultWaitResponse = await client.callTool('wait', { pids: [resumedDefaultRunData.pid], timeout: 5 });
475
+ const resumedDefaultWaitData = parseToolJson(resumedDefaultWaitResponse);
476
+ expect(resumedDefaultWaitData).toHaveLength(1);
477
+ expect(resumedDefaultWaitData[0]).toMatchObject({
478
+ pid: resumedDefaultRunData.pid,
479
+ agent: 'opencode',
480
+ status: 'completed',
481
+ exitCode: 0,
482
+ model: 'opencode',
483
+ session_id: 'ses-opencode-contract',
484
+ agentOutput: {
485
+ message: 'Resumed: opencode-resume-default',
486
+ session_id: 'ses-opencode-contract',
487
+ tokens: { total: 11833 },
488
+ cost: 0,
489
+ },
490
+ });
491
+ const resumedExplicitRunResponse = await client.callTool('run', {
492
+ prompt: 'opencode-resume-explicit',
493
+ workFolder: testDir,
494
+ model: 'oc-openai/gpt-5.4',
495
+ session_id: 'ses-opencode-contract',
496
+ });
497
+ const resumedExplicitRunData = parseToolJson(resumedExplicitRunResponse);
498
+ const resumedExplicitWaitResponse = await client.callTool('wait', { pids: [resumedExplicitRunData.pid], timeout: 5 });
499
+ const resumedExplicitWaitData = parseToolJson(resumedExplicitWaitResponse);
500
+ expect(resumedExplicitWaitData).toHaveLength(1);
501
+ expect(resumedExplicitWaitData[0]).toMatchObject({
502
+ pid: resumedExplicitRunData.pid,
503
+ agent: 'opencode',
504
+ status: 'completed',
505
+ exitCode: 0,
506
+ model: 'oc-openai/gpt-5.4',
507
+ session_id: 'ses-opencode-contract',
508
+ agentOutput: {
509
+ message: 'Resumed model openai/gpt-5.4: opencode-resume-explicit',
510
+ session_id: 'ses-opencode-contract',
511
+ tokens: { total: 11833 },
512
+ cost: 0,
513
+ },
514
+ });
515
+ const failedRunResponse = await client.callTool('run', {
516
+ prompt: 'please fail',
517
+ workFolder: testDir,
518
+ model: 'oc-openai/gpt-5.4',
519
+ });
520
+ const failedRunData = parseToolJson(failedRunResponse);
521
+ const compactFailedWait = parseToolJson(await client.callTool('wait', { pids: [failedRunData.pid], timeout: 5 }));
522
+ expect(compactFailedWait).toHaveLength(1);
523
+ expect(compactFailedWait[0]).toMatchObject({
524
+ pid: failedRunData.pid,
525
+ agent: 'opencode',
526
+ status: 'failed',
527
+ exitCode: 7,
528
+ model: 'oc-openai/gpt-5.4',
529
+ session_id: 'ses-opencode-contract',
530
+ stdout: expect.stringContaining('Partial failure output'),
531
+ stderr: expect.stringContaining('OpenCode failed for openai/gpt-5.4'),
532
+ });
533
+ expect(compactFailedWait[0]).not.toHaveProperty('agentOutput');
534
+ const verboseFailedResult = parseToolJson(await client.callTool('get_result', { pid: failedRunData.pid, verbose: true }));
535
+ expect(verboseFailedResult).toMatchObject({
536
+ pid: failedRunData.pid,
537
+ agent: 'opencode',
538
+ status: 'failed',
539
+ exitCode: 7,
540
+ model: 'oc-openai/gpt-5.4',
541
+ session_id: 'ses-opencode-contract',
542
+ stdout: expect.stringContaining('Partial failure output'),
543
+ stderr: expect.stringContaining('OpenCode failed for openai/gpt-5.4'),
544
+ agentOutput: {
545
+ message: 'Partial failure output',
546
+ session_id: 'ses-opencode-contract',
547
+ tokens: { total: 42 },
548
+ cost: 0,
549
+ },
550
+ });
551
+ const openCodeInvocations = readFileSync(opencodeArgsLogPath, 'utf-8').trim().split('\n');
552
+ expect(openCodeInvocations).toHaveLength(4);
553
+ expect(openCodeInvocations[0]).toContain('run --format json');
554
+ expect(openCodeInvocations[0]).toContain(`--dir ${testDir}`);
555
+ expect(openCodeInvocations[0]).not.toContain('--session');
556
+ expect(openCodeInvocations[0]).not.toContain('--model');
557
+ expect(openCodeInvocations[1]).toContain(`--dir ${testDir}`);
558
+ expect(openCodeInvocations[1]).toContain('--session ses-opencode-contract');
559
+ expect(openCodeInvocations[1]).not.toContain('--model');
560
+ expect(openCodeInvocations[2]).toContain(`--dir ${testDir}`);
561
+ expect(openCodeInvocations[2]).toContain('--session ses-opencode-contract');
562
+ expect(openCodeInvocations[2]).toContain('--model openai/gpt-5.4');
563
+ expect(openCodeInvocations[3]).toContain(`--dir ${testDir}`);
564
+ expect(openCodeInvocations[3]).toContain('--model openai/gpt-5.4');
565
+ await expect(client.callTool('run', {
566
+ prompt: 'opencode-invalid-reasoning',
567
+ workFolder: testDir,
568
+ model: 'opencode',
569
+ reasoning_effort: 'high',
570
+ })).rejects.toThrow(/reasoning_effort is not supported for opencode/i);
571
+ });
410
572
  it('keeps key invalid-input errors stable', async () => {
411
573
  await expect(client.callTool('run', {
412
574
  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, parseGeminiOutput, parseOpenCodeOutput, PeekMessageExtractor } from '../parsers.js';
3
3
  describe('parseCodexOutput', () => {
4
4
  it('should parse basic Codex output with message and session_id', () => {
5
5
  const output = `
@@ -58,6 +58,168 @@ INVALID_JSON
58
58
  expect(result.message).toBe("Still parses valid lines");
59
59
  });
60
60
  });
61
+ describe('PeekMessageExtractor', () => {
62
+ const ts = '2026-04-11T12:34:56.789Z';
63
+ it('extracts only Codex agent_message text', () => {
64
+ const extractor = new PeekMessageExtractor('codex');
65
+ const output = [
66
+ '{"type":"item.completed","item":{"type":"reasoning","text":"hidden"}}',
67
+ '{"type":"item.completed","item":{"type":"command_execution","aggregated_output":"secret command output"}}',
68
+ '{"type":"item.completed","item":{"type":"agent_message","text":"Visible Codex message"}}',
69
+ '{"msg":{"type":"token_count","total":123}}',
70
+ '{"msg":{"type":"agent_message","message":"Visible legacy Codex message"}}',
71
+ ].join('\n') + '\n';
72
+ expect(extractor.push(output, ts)).toEqual([
73
+ { ts, text: 'Visible Codex message' },
74
+ { ts, text: 'Visible legacy Codex message' },
75
+ ]);
76
+ });
77
+ it('extracts only Claude assistant text content', () => {
78
+ const extractor = new PeekMessageExtractor('claude');
79
+ const output = [
80
+ '{"type":"assistant","message":{"content":[{"type":"text","text":"Visible Claude text"},{"type":"tool_use","id":"tool-1","name":"Read","input":{"file_path":"/tmp/a"}}]}}',
81
+ '{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"tool-1","content":"secret"}]}}',
82
+ '{"type":"result","result":"Final result is not peek assistant text"}',
83
+ ].join('\n') + '\n';
84
+ expect(extractor.push(output, ts)).toEqual([
85
+ { ts, text: 'Visible Claude text' },
86
+ ]);
87
+ });
88
+ it('extracts only Gemini assistant message content', () => {
89
+ const extractor = new PeekMessageExtractor('gemini');
90
+ const output = [
91
+ '{"type":"message","timestamp":"2026-04-11T14:44:42.294Z","role":"user","content":"hidden user text"}',
92
+ '{"type":"message","timestamp":"2026-04-11T14:44:53.820Z","role":"assistant","content":"Visible Gemini text","delta":true}',
93
+ '{"type":"tool_use","timestamp":"2026-04-11T14:44:53.821Z","tool_name":"run_shell_command","parameters":{"command":"echo secret"}}',
94
+ '{"type":"tool_result","timestamp":"2026-04-11T14:45:03.011Z","status":"success","output":"secret command output"}',
95
+ '{"type":"result","timestamp":"2026-04-11T14:45:10.380Z","status":"success","response":"Final result is not peek assistant text"}',
96
+ ].join('\n') + '\n';
97
+ expect(extractor.push(output, ts)).toEqual([
98
+ { ts, text: 'Visible Gemini text' },
99
+ ]);
100
+ });
101
+ it('joins split Gemini assistant chunks into one peek message on flush', () => {
102
+ const extractor = new PeekMessageExtractor('gemini');
103
+ const output = [
104
+ '{"type":"message","timestamp":"2026-04-11T14:44:53.820Z","role":"assistant","content":"Step 2 done. Starting step ","delta":true}',
105
+ '{"type":"message","timestamp":"2026-04-11T14:44:53.821Z","role":"assistant","content":"3.","delta":true}',
106
+ ].join('\n') + '\n';
107
+ expect(extractor.push(output, ts)).toEqual([]);
108
+ expect(extractor.flush('2026-04-11T12:34:59.000Z')).toEqual([
109
+ { ts: '2026-04-11T12:34:59.000Z', text: 'Step 2 done. Starting step 3.' },
110
+ ]);
111
+ });
112
+ it('emits separate Gemini peek messages when a boundary separates logical messages', () => {
113
+ const extractor = new PeekMessageExtractor('gemini');
114
+ const output = [
115
+ '{"type":"message","timestamp":"2026-04-11T14:44:53.820Z","role":"assistant","content":"Starting step ","delta":true}',
116
+ '{"type":"message","timestamp":"2026-04-11T14:44:53.821Z","role":"assistant","content":"1.","delta":true}',
117
+ '{"type":"tool_use","timestamp":"2026-04-11T14:44:53.822Z","tool_name":"run_shell_command","parameters":{"command":"echo secret"}}',
118
+ '{"type":"tool_result","timestamp":"2026-04-11T14:45:03.011Z","status":"success","output":"secret command output"}',
119
+ '{"type":"message","timestamp":"2026-04-11T14:45:10.315Z","role":"assistant","content":"Final ","delta":true}',
120
+ '{"type":"message","timestamp":"2026-04-11T14:45:10.316Z","role":"assistant","content":"answer.","delta":true}',
121
+ '{"type":"result","timestamp":"2026-04-11T14:45:10.380Z","status":"success","response":"Final result response is not peek text","stats":{"total_tokens":21999}}',
122
+ ].join('\n') + '\n';
123
+ expect(extractor.push(output, ts)).toEqual([
124
+ { ts, text: 'Starting step 1.' },
125
+ { ts, text: 'Final answer.' },
126
+ ]);
127
+ expect(extractor.flush(ts)).toEqual([]);
128
+ });
129
+ it('does not emit Gemini user, tool, tool result, stats, or result response text', () => {
130
+ const extractor = new PeekMessageExtractor('gemini');
131
+ const output = [
132
+ '{"type":"message","timestamp":"2026-04-11T14:44:42.294Z","role":"user","content":"hidden user text"}',
133
+ '{"type":"tool_use","timestamp":"2026-04-11T14:44:53.821Z","tool_name":"run_shell_command","parameters":{"command":"echo secret"}}',
134
+ '{"type":"tool_result","timestamp":"2026-04-11T14:45:03.011Z","status":"success","output":"secret command output"}',
135
+ '{"type":"result","timestamp":"2026-04-11T14:45:10.380Z","status":"success","response":"Final result response is not peek text","stats":{"total_tokens":21999}}',
136
+ ].join('\n') + '\n';
137
+ expect(extractor.push(output, ts)).toEqual([]);
138
+ expect(extractor.flush(ts)).toEqual([]);
139
+ });
140
+ it('denies unsupported agents and invalid shapes by default', () => {
141
+ const extractor = new PeekMessageExtractor('forge');
142
+ const output = [
143
+ '{"type":"assistant","message":{"content":[{"type":"text","text":"not supported here"}]}}',
144
+ '{"type":"item.completed","item":{"type":"agent_message","text":"not supported here"}}',
145
+ '{"type":"text","part":{"type":"text","text":"not supported here"}}',
146
+ '{"type":"message","role":"assistant","content":"not supported here"}',
147
+ 'plain stdout',
148
+ ].join('\n') + '\n';
149
+ expect(extractor.push(output, ts)).toEqual([]);
150
+ });
151
+ it('extracts only OpenCode natural-language text events', () => {
152
+ const extractor = new PeekMessageExtractor('opencode');
153
+ const output = [
154
+ '{"type":"text","timestamp":1775918783605,"sessionID":"ses-1","part":{"type":"text","text":"OpenCode visible text"}}',
155
+ '{"type":"tool_use","timestamp":1775918783606,"sessionID":"ses-1","part":{"type":"tool","state":{"output":"secret command output"},"metadata":{"output":"secret metadata output"}}}',
156
+ '{"type":"text","timestamp":1775918783607,"sessionID":"ses-1","part":{"type":"tool","text":"wrong part type"}}',
157
+ ].join('\n') + '\n';
158
+ expect(extractor.push(output, ts)).toEqual([
159
+ { ts, text: 'OpenCode visible text' },
160
+ ]);
161
+ });
162
+ it('can flush a complete JSON event without a trailing newline', () => {
163
+ const extractor = new PeekMessageExtractor('codex');
164
+ expect(extractor.push('{"type":"item.completed","item":{"type":"agent_message","text":"pending"}}', ts)).toEqual([]);
165
+ expect(extractor.flush(ts)).toEqual([{ ts, text: 'pending' }]);
166
+ });
167
+ });
168
+ describe('parseGeminiOutput', () => {
169
+ it('should parse legacy final JSON output', () => {
170
+ const output = JSON.stringify({
171
+ session_id: 'gemini-session-json',
172
+ response: 'Legacy Gemini final response',
173
+ stats: {
174
+ total_tokens: 123,
175
+ },
176
+ });
177
+ expect(parseGeminiOutput(output)).toEqual({
178
+ session_id: 'gemini-session-json',
179
+ response: 'Legacy Gemini final response',
180
+ stats: {
181
+ total_tokens: 123,
182
+ },
183
+ });
184
+ });
185
+ it('should normalize a single-line Gemini assistant stream event', () => {
186
+ const output = '{"type":"message","timestamp":"2026-04-11T14:44:53.820Z","role":"assistant","content":"Only answer","delta":true}';
187
+ const result = parseGeminiOutput(output);
188
+ expect(result).toMatchObject({
189
+ message: 'Only answer',
190
+ session_id: null,
191
+ });
192
+ expect(result).not.toHaveProperty('type');
193
+ expect(result).not.toHaveProperty('content');
194
+ });
195
+ it('should parse Gemini stream-json NDJSON output', () => {
196
+ const output = [
197
+ '{"type":"init","timestamp":"2026-04-11T14:44:42.293Z","session_id":"gemini-session-stream","model":"gemini-3.1-pro-preview"}',
198
+ '{"type":"message","timestamp":"2026-04-11T14:44:42.294Z","role":"user","content":"hidden user text"}',
199
+ '{"type":"message","timestamp":"2026-04-11T14:44:53.820Z","role":"assistant","content":"First logical assistant response.","delta":true}',
200
+ '{"type":"tool_use","timestamp":"2026-04-11T14:44:53.821Z","tool_name":"run_shell_command","tool_id":"tool-1","parameters":{"command":"echo hidden"}}',
201
+ '{"type":"tool_result","timestamp":"2026-04-11T14:45:03.011Z","tool_id":"tool-1","status":"success","output":"hidden command output"}',
202
+ '{"type":"message","timestamp":"2026-04-11T14:45:10.315Z","role":"assistant","content":"Final assistant ","delta":true}',
203
+ '{"type":"message","timestamp":"2026-04-11T14:45:10.316Z","role":"assistant","content":"response.","delta":true}',
204
+ '{"type":"result","timestamp":"2026-04-11T14:45:10.380Z","status":"success","response":"Result response is not the parsed message","stats":{"total_tokens":21999}}',
205
+ ].join('\n') + '\n';
206
+ expect(parseGeminiOutput(output)).toEqual({
207
+ message: 'Final assistant response.',
208
+ session_id: 'gemini-session-stream',
209
+ stats: {
210
+ total_tokens: 21999,
211
+ },
212
+ tools: [
213
+ {
214
+ tool: 'run_shell_command',
215
+ input: { command: 'echo hidden' },
216
+ output: 'hidden command output',
217
+ status: 'success',
218
+ },
219
+ ],
220
+ });
221
+ });
222
+ });
61
223
  describe('parseClaudeOutput', () => {
62
224
  it('should parse legacy JSON output', () => {
63
225
  const output = JSON.stringify({
@@ -132,3 +294,64 @@ still streaming`;
132
294
  expect(parseForgeOutput('plain text')).toBeNull();
133
295
  });
134
296
  });
297
+ describe('parseOpenCodeOutput', () => {
298
+ it('parses a single completed OpenCode step', () => {
299
+ const output = `{"type":"step_start","sessionID":"ses_1"}
300
+ {"type":"text","sessionID":"ses_1","part":{"type":"text","text":"Hello"}}
301
+ {"type":"step_finish","sessionID":"ses_1","part":{"type":"step-finish","tokens":{"total":11833},"cost":0}}`;
302
+ expect(parseOpenCodeOutput(output)).toEqual({
303
+ message: 'Hello',
304
+ session_id: 'ses_1',
305
+ tokens: { total: 11833 },
306
+ cost: 0,
307
+ });
308
+ });
309
+ it('returns the last completed step for multi-step output', () => {
310
+ const output = `{"type":"step_start","sessionID":"ses_2"}
311
+ {"type":"text","sessionID":"ses_2","part":{"type":"text","text":"First"}}
312
+ {"type":"step_finish","sessionID":"ses_2","part":{"type":"step-finish","tokens":{"total":10},"cost":0}}
313
+ {"type":"step_start","sessionID":"ses_2"}
314
+ {"type":"text","sessionID":"ses_2","part":{"type":"text","text":"Second"}}
315
+ {"type":"step_finish","sessionID":"ses_2","part":{"type":"step-finish","tokens":{"total":20},"cost":1}}`;
316
+ expect(parseOpenCodeOutput(output)).toEqual({
317
+ message: 'Second',
318
+ session_id: 'ses_2',
319
+ tokens: { total: 20 },
320
+ cost: 1,
321
+ });
322
+ });
323
+ it('resets the current-step buffer on each step_start', () => {
324
+ const output = `{"type":"step_start","sessionID":"ses_3"}
325
+ {"type":"text","sessionID":"ses_3","part":{"type":"text","text":"Discard me"}}
326
+ {"type":"step_start","sessionID":"ses_3"}
327
+ {"type":"text","sessionID":"ses_3","part":{"type":"text","text":"Keep me"}}
328
+ {"type":"step_finish","sessionID":"ses_3","part":{"type":"step-finish","tokens":{"total":5},"cost":0}}`;
329
+ expect(parseOpenCodeOutput(output)).toEqual({
330
+ message: 'Keep me',
331
+ session_id: 'ses_3',
332
+ tokens: { total: 5 },
333
+ cost: 0,
334
+ });
335
+ });
336
+ it('returns partial output when text exists without step_finish', () => {
337
+ const output = `{"type":"step_start","sessionID":"ses_4"}
338
+ {"type":"text","sessionID":"ses_4","part":{"type":"text","text":"Partial"}}`;
339
+ expect(parseOpenCodeOutput(output)).toEqual({
340
+ message: 'Partial',
341
+ session_id: 'ses_4',
342
+ });
343
+ });
344
+ it('ignores malformed lines and unknown event types', () => {
345
+ const output = `not-json
346
+ {"type":"unknown","sessionID":"ses_5"}
347
+ {"type":"text","sessionID":"ses_5","part":{"type":"text","text":"Hello"}}`;
348
+ expect(parseOpenCodeOutput(output)).toEqual({
349
+ message: 'Hello',
350
+ session_id: 'ses_5',
351
+ });
352
+ });
353
+ it('returns null when no useful OpenCode events exist', () => {
354
+ expect(parseOpenCodeOutput('{"type":"unknown"}')).toBeNull();
355
+ expect(parseOpenCodeOutput('')).toBeNull();
356
+ });
357
+ });
@@ -0,0 +1,35 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { appendPeekMessages, validatePeekPids, validatePeekTimeSec } from '../peek.js';
3
+ describe('peek helpers', () => {
4
+ it('dedupes pids while preserving first occurrence order', () => {
5
+ expect(validatePeekPids([3, 1, 3, 2, 1])).toEqual([3, 1, 2]);
6
+ });
7
+ it('validates pid and time limits', () => {
8
+ expect(() => validatePeekPids([])).toThrow(/1..32/);
9
+ expect(() => validatePeekPids([1.5])).toThrow(/positive safe integers/);
10
+ expect(() => validatePeekPids([Number.MAX_SAFE_INTEGER + 1])).toThrow(/positive safe integers/);
11
+ expect(validatePeekTimeSec(undefined)).toBe(10);
12
+ expect(validatePeekTimeSec(60)).toBe(60);
13
+ expect(() => validatePeekTimeSec(0)).toThrow(/positive integer/);
14
+ expect(() => validatePeekTimeSec(1.5)).toThrow(/positive integer/);
15
+ expect(() => validatePeekTimeSec(61)).toThrow(/positive integer/);
16
+ });
17
+ it('keeps the first 50 messages and marks truncation when later messages are dropped', () => {
18
+ const process = {
19
+ pid: 123,
20
+ agent: 'codex',
21
+ status: 'running',
22
+ messages: [],
23
+ truncated: false,
24
+ error: null,
25
+ };
26
+ appendPeekMessages(process, Array.from({ length: 55 }, (_, index) => ({
27
+ ts: '2026-04-11T12:34:56.789Z',
28
+ text: `message ${index}`,
29
+ })));
30
+ expect(process.messages).toHaveLength(50);
31
+ expect(process.messages[0].text).toBe('message 0');
32
+ expect(process.messages[49].text).toBe('message 49');
33
+ expect(process.truncated).toBe(true);
34
+ });
35
+ });