ai-cli-mcp 2.14.1 → 2.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/dependabot.yml +28 -0
- package/.github/workflows/ci.yml +4 -1
- package/.github/workflows/dependency-review.yml +22 -0
- package/CHANGELOG.md +14 -0
- package/README.ja.md +83 -6
- package/README.md +83 -7
- package/dist/__tests__/app-cli.test.js +80 -5
- package/dist/__tests__/cli-bin-smoke.test.js +43 -0
- package/dist/__tests__/cli-builder.test.js +93 -15
- package/dist/__tests__/cli-process-service.test.js +162 -0
- package/dist/__tests__/cli-utils.test.js +31 -0
- package/dist/__tests__/e2e.test.js +79 -52
- package/dist/__tests__/mcp-contract.test.js +162 -0
- package/dist/__tests__/parsers.test.js +224 -1
- package/dist/__tests__/peek.test.js +35 -0
- package/dist/__tests__/process-management.test.js +160 -1
- package/dist/__tests__/server.test.js +39 -9
- package/dist/__tests__/utils/opencode-mock.js +91 -0
- package/dist/__tests__/validation.test.js +40 -2
- package/dist/app/cli.js +47 -5
- package/dist/app/mcp.js +53 -4
- package/dist/cli-builder.js +67 -28
- package/dist/cli-parse.js +11 -5
- package/dist/cli-process-service.js +241 -20
- package/dist/cli-utils.js +14 -23
- package/dist/cli.js +6 -4
- package/dist/model-catalog.js +13 -1
- package/dist/parsers.js +242 -28
- package/dist/peek.js +56 -0
- package/dist/process-result.js +9 -2
- package/dist/process-service.js +103 -17
- package/dist/server.js +1 -2
- package/package.json +9 -6
- package/src/__tests__/app-cli.test.ts +95 -4
- package/src/__tests__/cli-bin-smoke.test.ts +62 -1
- package/src/__tests__/cli-builder.test.ts +111 -15
- package/src/__tests__/cli-process-service.test.ts +180 -0
- package/src/__tests__/cli-utils.test.ts +34 -0
- package/src/__tests__/e2e.test.ts +87 -55
- package/src/__tests__/mcp-contract.test.ts +188 -0
- package/src/__tests__/parsers.test.ts +260 -1
- package/src/__tests__/peek.test.ts +43 -0
- package/src/__tests__/process-management.test.ts +185 -1
- package/src/__tests__/server.test.ts +49 -13
- package/src/__tests__/utils/opencode-mock.ts +108 -0
- package/src/__tests__/validation.test.ts +48 -2
- package/src/app/cli.ts +52 -4
- package/src/app/mcp.ts +54 -4
- package/src/cli-builder.ts +91 -32
- package/src/cli-parse.ts +11 -5
- package/src/cli-process-service.ts +304 -17
- package/src/cli-utils.ts +37 -33
- package/src/cli.ts +6 -4
- package/src/model-catalog.ts +24 -1
- package/src/parsers.ts +299 -33
- package/src/peek.ts +88 -0
- package/src/process-result.ts +11 -2
- package/src/process-service.ts +134 -15
- package/src/server.ts +2 -2
- package/vitest.config.unit.ts +2 -3
|
@@ -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
|
|
|
4
4
|
describe('parseCodexOutput', () => {
|
|
5
5
|
it('should parse basic Codex output with message and session_id', () => {
|
|
@@ -65,6 +65,193 @@ INVALID_JSON
|
|
|
65
65
|
});
|
|
66
66
|
});
|
|
67
67
|
|
|
68
|
+
describe('PeekMessageExtractor', () => {
|
|
69
|
+
const ts = '2026-04-11T12:34:56.789Z';
|
|
70
|
+
|
|
71
|
+
it('extracts only Codex agent_message text', () => {
|
|
72
|
+
const extractor = new PeekMessageExtractor('codex');
|
|
73
|
+
const output = [
|
|
74
|
+
'{"type":"item.completed","item":{"type":"reasoning","text":"hidden"}}',
|
|
75
|
+
'{"type":"item.completed","item":{"type":"command_execution","aggregated_output":"secret command output"}}',
|
|
76
|
+
'{"type":"item.completed","item":{"type":"agent_message","text":"Visible Codex message"}}',
|
|
77
|
+
'{"msg":{"type":"token_count","total":123}}',
|
|
78
|
+
'{"msg":{"type":"agent_message","message":"Visible legacy Codex message"}}',
|
|
79
|
+
].join('\n') + '\n';
|
|
80
|
+
|
|
81
|
+
expect(extractor.push(output, ts)).toEqual([
|
|
82
|
+
{ ts, text: 'Visible Codex message' },
|
|
83
|
+
{ ts, text: 'Visible legacy Codex message' },
|
|
84
|
+
]);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('extracts only Claude assistant text content', () => {
|
|
88
|
+
const extractor = new PeekMessageExtractor('claude');
|
|
89
|
+
const output = [
|
|
90
|
+
'{"type":"assistant","message":{"content":[{"type":"text","text":"Visible Claude text"},{"type":"tool_use","id":"tool-1","name":"Read","input":{"file_path":"/tmp/a"}}]}}',
|
|
91
|
+
'{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"tool-1","content":"secret"}]}}',
|
|
92
|
+
'{"type":"result","result":"Final result is not peek assistant text"}',
|
|
93
|
+
].join('\n') + '\n';
|
|
94
|
+
|
|
95
|
+
expect(extractor.push(output, ts)).toEqual([
|
|
96
|
+
{ ts, text: 'Visible Claude text' },
|
|
97
|
+
]);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('extracts only Gemini assistant message content', () => {
|
|
101
|
+
const extractor = new PeekMessageExtractor('gemini');
|
|
102
|
+
const output = [
|
|
103
|
+
'{"type":"message","timestamp":"2026-04-11T14:44:42.294Z","role":"user","content":"hidden user text"}',
|
|
104
|
+
'{"type":"message","timestamp":"2026-04-11T14:44:53.820Z","role":"assistant","content":"Visible Gemini text","delta":true}',
|
|
105
|
+
'{"type":"tool_use","timestamp":"2026-04-11T14:44:53.821Z","tool_name":"run_shell_command","parameters":{"command":"echo secret"}}',
|
|
106
|
+
'{"type":"tool_result","timestamp":"2026-04-11T14:45:03.011Z","status":"success","output":"secret command output"}',
|
|
107
|
+
'{"type":"result","timestamp":"2026-04-11T14:45:10.380Z","status":"success","response":"Final result is not peek assistant text"}',
|
|
108
|
+
].join('\n') + '\n';
|
|
109
|
+
|
|
110
|
+
expect(extractor.push(output, ts)).toEqual([
|
|
111
|
+
{ ts, text: 'Visible Gemini text' },
|
|
112
|
+
]);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('joins split Gemini assistant chunks into one peek message on flush', () => {
|
|
116
|
+
const extractor = new PeekMessageExtractor('gemini');
|
|
117
|
+
const output = [
|
|
118
|
+
'{"type":"message","timestamp":"2026-04-11T14:44:53.820Z","role":"assistant","content":"Step 2 done. Starting step ","delta":true}',
|
|
119
|
+
'{"type":"message","timestamp":"2026-04-11T14:44:53.821Z","role":"assistant","content":"3.","delta":true}',
|
|
120
|
+
].join('\n') + '\n';
|
|
121
|
+
|
|
122
|
+
expect(extractor.push(output, ts)).toEqual([]);
|
|
123
|
+
expect(extractor.flush('2026-04-11T12:34:59.000Z')).toEqual([
|
|
124
|
+
{ ts: '2026-04-11T12:34:59.000Z', text: 'Step 2 done. Starting step 3.' },
|
|
125
|
+
]);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('emits separate Gemini peek messages when a boundary separates logical messages', () => {
|
|
129
|
+
const extractor = new PeekMessageExtractor('gemini');
|
|
130
|
+
const output = [
|
|
131
|
+
'{"type":"message","timestamp":"2026-04-11T14:44:53.820Z","role":"assistant","content":"Starting step ","delta":true}',
|
|
132
|
+
'{"type":"message","timestamp":"2026-04-11T14:44:53.821Z","role":"assistant","content":"1.","delta":true}',
|
|
133
|
+
'{"type":"tool_use","timestamp":"2026-04-11T14:44:53.822Z","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":"message","timestamp":"2026-04-11T14:45:10.315Z","role":"assistant","content":"Final ","delta":true}',
|
|
136
|
+
'{"type":"message","timestamp":"2026-04-11T14:45:10.316Z","role":"assistant","content":"answer.","delta":true}',
|
|
137
|
+
'{"type":"result","timestamp":"2026-04-11T14:45:10.380Z","status":"success","response":"Final result response is not peek text","stats":{"total_tokens":21999}}',
|
|
138
|
+
].join('\n') + '\n';
|
|
139
|
+
|
|
140
|
+
expect(extractor.push(output, ts)).toEqual([
|
|
141
|
+
{ ts, text: 'Starting step 1.' },
|
|
142
|
+
{ ts, text: 'Final answer.' },
|
|
143
|
+
]);
|
|
144
|
+
expect(extractor.flush(ts)).toEqual([]);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('does not emit Gemini user, tool, tool result, stats, or result response text', () => {
|
|
148
|
+
const extractor = new PeekMessageExtractor('gemini');
|
|
149
|
+
const output = [
|
|
150
|
+
'{"type":"message","timestamp":"2026-04-11T14:44:42.294Z","role":"user","content":"hidden user text"}',
|
|
151
|
+
'{"type":"tool_use","timestamp":"2026-04-11T14:44:53.821Z","tool_name":"run_shell_command","parameters":{"command":"echo secret"}}',
|
|
152
|
+
'{"type":"tool_result","timestamp":"2026-04-11T14:45:03.011Z","status":"success","output":"secret command output"}',
|
|
153
|
+
'{"type":"result","timestamp":"2026-04-11T14:45:10.380Z","status":"success","response":"Final result response is not peek text","stats":{"total_tokens":21999}}',
|
|
154
|
+
].join('\n') + '\n';
|
|
155
|
+
|
|
156
|
+
expect(extractor.push(output, ts)).toEqual([]);
|
|
157
|
+
expect(extractor.flush(ts)).toEqual([]);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('denies unsupported agents and invalid shapes by default', () => {
|
|
161
|
+
const extractor = new PeekMessageExtractor('forge');
|
|
162
|
+
const output = [
|
|
163
|
+
'{"type":"assistant","message":{"content":[{"type":"text","text":"not supported here"}]}}',
|
|
164
|
+
'{"type":"item.completed","item":{"type":"agent_message","text":"not supported here"}}',
|
|
165
|
+
'{"type":"text","part":{"type":"text","text":"not supported here"}}',
|
|
166
|
+
'{"type":"message","role":"assistant","content":"not supported here"}',
|
|
167
|
+
'plain stdout',
|
|
168
|
+
].join('\n') + '\n';
|
|
169
|
+
|
|
170
|
+
expect(extractor.push(output, ts)).toEqual([]);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('extracts only OpenCode natural-language text events', () => {
|
|
174
|
+
const extractor = new PeekMessageExtractor('opencode');
|
|
175
|
+
const output = [
|
|
176
|
+
'{"type":"text","timestamp":1775918783605,"sessionID":"ses-1","part":{"type":"text","text":"OpenCode visible text"}}',
|
|
177
|
+
'{"type":"tool_use","timestamp":1775918783606,"sessionID":"ses-1","part":{"type":"tool","state":{"output":"secret command output"},"metadata":{"output":"secret metadata output"}}}',
|
|
178
|
+
'{"type":"text","timestamp":1775918783607,"sessionID":"ses-1","part":{"type":"tool","text":"wrong part type"}}',
|
|
179
|
+
].join('\n') + '\n';
|
|
180
|
+
|
|
181
|
+
expect(extractor.push(output, ts)).toEqual([
|
|
182
|
+
{ ts, text: 'OpenCode visible text' },
|
|
183
|
+
]);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('can flush a complete JSON event without a trailing newline', () => {
|
|
187
|
+
const extractor = new PeekMessageExtractor('codex');
|
|
188
|
+
expect(extractor.push('{"type":"item.completed","item":{"type":"agent_message","text":"pending"}}', ts)).toEqual([]);
|
|
189
|
+
expect(extractor.flush(ts)).toEqual([{ ts, text: 'pending' }]);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe('parseGeminiOutput', () => {
|
|
194
|
+
it('should parse legacy final JSON output', () => {
|
|
195
|
+
const output = JSON.stringify({
|
|
196
|
+
session_id: 'gemini-session-json',
|
|
197
|
+
response: 'Legacy Gemini final response',
|
|
198
|
+
stats: {
|
|
199
|
+
total_tokens: 123,
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
expect(parseGeminiOutput(output)).toEqual({
|
|
204
|
+
session_id: 'gemini-session-json',
|
|
205
|
+
response: 'Legacy Gemini final response',
|
|
206
|
+
stats: {
|
|
207
|
+
total_tokens: 123,
|
|
208
|
+
},
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('should normalize a single-line Gemini assistant stream event', () => {
|
|
213
|
+
const output = '{"type":"message","timestamp":"2026-04-11T14:44:53.820Z","role":"assistant","content":"Only answer","delta":true}';
|
|
214
|
+
|
|
215
|
+
const result = parseGeminiOutput(output);
|
|
216
|
+
|
|
217
|
+
expect(result).toMatchObject({
|
|
218
|
+
message: 'Only answer',
|
|
219
|
+
session_id: null,
|
|
220
|
+
});
|
|
221
|
+
expect(result).not.toHaveProperty('type');
|
|
222
|
+
expect(result).not.toHaveProperty('content');
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('should parse Gemini stream-json NDJSON output', () => {
|
|
226
|
+
const output = [
|
|
227
|
+
'{"type":"init","timestamp":"2026-04-11T14:44:42.293Z","session_id":"gemini-session-stream","model":"gemini-3.1-pro-preview"}',
|
|
228
|
+
'{"type":"message","timestamp":"2026-04-11T14:44:42.294Z","role":"user","content":"hidden user text"}',
|
|
229
|
+
'{"type":"message","timestamp":"2026-04-11T14:44:53.820Z","role":"assistant","content":"First logical assistant response.","delta":true}',
|
|
230
|
+
'{"type":"tool_use","timestamp":"2026-04-11T14:44:53.821Z","tool_name":"run_shell_command","tool_id":"tool-1","parameters":{"command":"echo hidden"}}',
|
|
231
|
+
'{"type":"tool_result","timestamp":"2026-04-11T14:45:03.011Z","tool_id":"tool-1","status":"success","output":"hidden command output"}',
|
|
232
|
+
'{"type":"message","timestamp":"2026-04-11T14:45:10.315Z","role":"assistant","content":"Final assistant ","delta":true}',
|
|
233
|
+
'{"type":"message","timestamp":"2026-04-11T14:45:10.316Z","role":"assistant","content":"response.","delta":true}',
|
|
234
|
+
'{"type":"result","timestamp":"2026-04-11T14:45:10.380Z","status":"success","response":"Result response is not the parsed message","stats":{"total_tokens":21999}}',
|
|
235
|
+
].join('\n') + '\n';
|
|
236
|
+
|
|
237
|
+
expect(parseGeminiOutput(output)).toEqual({
|
|
238
|
+
message: 'Final assistant response.',
|
|
239
|
+
session_id: 'gemini-session-stream',
|
|
240
|
+
stats: {
|
|
241
|
+
total_tokens: 21999,
|
|
242
|
+
},
|
|
243
|
+
tools: [
|
|
244
|
+
{
|
|
245
|
+
tool: 'run_shell_command',
|
|
246
|
+
input: { command: 'echo hidden' },
|
|
247
|
+
output: 'hidden command output',
|
|
248
|
+
status: 'success',
|
|
249
|
+
},
|
|
250
|
+
],
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
68
255
|
describe('parseClaudeOutput', () => {
|
|
69
256
|
it('should parse legacy JSON output', () => {
|
|
70
257
|
const output = JSON.stringify({
|
|
@@ -149,3 +336,75 @@ still streaming`;
|
|
|
149
336
|
expect(parseForgeOutput('plain text')).toBeNull();
|
|
150
337
|
});
|
|
151
338
|
});
|
|
339
|
+
|
|
340
|
+
describe('parseOpenCodeOutput', () => {
|
|
341
|
+
it('parses a single completed OpenCode step', () => {
|
|
342
|
+
const output = `{"type":"step_start","sessionID":"ses_1"}
|
|
343
|
+
{"type":"text","sessionID":"ses_1","part":{"type":"text","text":"Hello"}}
|
|
344
|
+
{"type":"step_finish","sessionID":"ses_1","part":{"type":"step-finish","tokens":{"total":11833},"cost":0}}`;
|
|
345
|
+
|
|
346
|
+
expect(parseOpenCodeOutput(output)).toEqual({
|
|
347
|
+
message: 'Hello',
|
|
348
|
+
session_id: 'ses_1',
|
|
349
|
+
tokens: { total: 11833 },
|
|
350
|
+
cost: 0,
|
|
351
|
+
});
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it('returns the last completed step for multi-step output', () => {
|
|
355
|
+
const output = `{"type":"step_start","sessionID":"ses_2"}
|
|
356
|
+
{"type":"text","sessionID":"ses_2","part":{"type":"text","text":"First"}}
|
|
357
|
+
{"type":"step_finish","sessionID":"ses_2","part":{"type":"step-finish","tokens":{"total":10},"cost":0}}
|
|
358
|
+
{"type":"step_start","sessionID":"ses_2"}
|
|
359
|
+
{"type":"text","sessionID":"ses_2","part":{"type":"text","text":"Second"}}
|
|
360
|
+
{"type":"step_finish","sessionID":"ses_2","part":{"type":"step-finish","tokens":{"total":20},"cost":1}}`;
|
|
361
|
+
|
|
362
|
+
expect(parseOpenCodeOutput(output)).toEqual({
|
|
363
|
+
message: 'Second',
|
|
364
|
+
session_id: 'ses_2',
|
|
365
|
+
tokens: { total: 20 },
|
|
366
|
+
cost: 1,
|
|
367
|
+
});
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it('resets the current-step buffer on each step_start', () => {
|
|
371
|
+
const output = `{"type":"step_start","sessionID":"ses_3"}
|
|
372
|
+
{"type":"text","sessionID":"ses_3","part":{"type":"text","text":"Discard me"}}
|
|
373
|
+
{"type":"step_start","sessionID":"ses_3"}
|
|
374
|
+
{"type":"text","sessionID":"ses_3","part":{"type":"text","text":"Keep me"}}
|
|
375
|
+
{"type":"step_finish","sessionID":"ses_3","part":{"type":"step-finish","tokens":{"total":5},"cost":0}}`;
|
|
376
|
+
|
|
377
|
+
expect(parseOpenCodeOutput(output)).toEqual({
|
|
378
|
+
message: 'Keep me',
|
|
379
|
+
session_id: 'ses_3',
|
|
380
|
+
tokens: { total: 5 },
|
|
381
|
+
cost: 0,
|
|
382
|
+
});
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it('returns partial output when text exists without step_finish', () => {
|
|
386
|
+
const output = `{"type":"step_start","sessionID":"ses_4"}
|
|
387
|
+
{"type":"text","sessionID":"ses_4","part":{"type":"text","text":"Partial"}}`;
|
|
388
|
+
|
|
389
|
+
expect(parseOpenCodeOutput(output)).toEqual({
|
|
390
|
+
message: 'Partial',
|
|
391
|
+
session_id: 'ses_4',
|
|
392
|
+
});
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
it('ignores malformed lines and unknown event types', () => {
|
|
396
|
+
const output = `not-json
|
|
397
|
+
{"type":"unknown","sessionID":"ses_5"}
|
|
398
|
+
{"type":"text","sessionID":"ses_5","part":{"type":"text","text":"Hello"}}`;
|
|
399
|
+
|
|
400
|
+
expect(parseOpenCodeOutput(output)).toEqual({
|
|
401
|
+
message: 'Hello',
|
|
402
|
+
session_id: 'ses_5',
|
|
403
|
+
});
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
it('returns null when no useful OpenCode events exist', () => {
|
|
407
|
+
expect(parseOpenCodeOutput('{"type":"unknown"}')).toBeNull();
|
|
408
|
+
expect(parseOpenCodeOutput('')).toBeNull();
|
|
409
|
+
});
|
|
410
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { appendPeekMessages, validatePeekPids, validatePeekTimeSec, type PeekProcessResult } from '../peek.js';
|
|
3
|
+
|
|
4
|
+
describe('peek helpers', () => {
|
|
5
|
+
it('dedupes pids while preserving first occurrence order', () => {
|
|
6
|
+
expect(validatePeekPids([3, 1, 3, 2, 1])).toEqual([3, 1, 2]);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('validates pid and time limits', () => {
|
|
10
|
+
expect(() => validatePeekPids([])).toThrow(/1..32/);
|
|
11
|
+
expect(() => validatePeekPids([1.5])).toThrow(/positive safe integers/);
|
|
12
|
+
expect(() => validatePeekPids([Number.MAX_SAFE_INTEGER + 1])).toThrow(/positive safe integers/);
|
|
13
|
+
expect(validatePeekTimeSec(undefined)).toBe(10);
|
|
14
|
+
expect(validatePeekTimeSec(60)).toBe(60);
|
|
15
|
+
expect(() => validatePeekTimeSec(0)).toThrow(/positive integer/);
|
|
16
|
+
expect(() => validatePeekTimeSec(1.5)).toThrow(/positive integer/);
|
|
17
|
+
expect(() => validatePeekTimeSec(61)).toThrow(/positive integer/);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('keeps the first 50 messages and marks truncation when later messages are dropped', () => {
|
|
21
|
+
const process: PeekProcessResult = {
|
|
22
|
+
pid: 123,
|
|
23
|
+
agent: 'codex',
|
|
24
|
+
status: 'running',
|
|
25
|
+
messages: [],
|
|
26
|
+
truncated: false,
|
|
27
|
+
error: null,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
appendPeekMessages(
|
|
31
|
+
process,
|
|
32
|
+
Array.from({ length: 55 }, (_, index) => ({
|
|
33
|
+
ts: '2026-04-11T12:34:56.789Z',
|
|
34
|
+
text: `message ${index}`,
|
|
35
|
+
})),
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
expect(process.messages).toHaveLength(50);
|
|
39
|
+
expect(process.messages[0].text).toBe('message 0');
|
|
40
|
+
expect(process.messages[49].text).toBe('message 49');
|
|
41
|
+
expect(process.truncated).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -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);
|
|
@@ -118,6 +118,190 @@ describe('Process Management Tests', () => {
|
|
|
118
118
|
expect(response.message).toBe('claude process started successfully');
|
|
119
119
|
});
|
|
120
120
|
|
|
121
|
+
it('should peek only natural-language messages observed after registration', async () => {
|
|
122
|
+
const { handlers } = await setupServer();
|
|
123
|
+
|
|
124
|
+
const mockProcess = new EventEmitter() as any;
|
|
125
|
+
mockProcess.pid = 12345;
|
|
126
|
+
mockProcess.stdout = new EventEmitter();
|
|
127
|
+
mockProcess.stderr = new EventEmitter();
|
|
128
|
+
mockProcess.kill = vi.fn();
|
|
129
|
+
|
|
130
|
+
mockSpawn.mockReturnValue(mockProcess);
|
|
131
|
+
|
|
132
|
+
const callToolHandler = handlers.get('callTool')!;
|
|
133
|
+
await callToolHandler!({
|
|
134
|
+
params: {
|
|
135
|
+
name: 'run',
|
|
136
|
+
arguments: {
|
|
137
|
+
prompt: 'test prompt',
|
|
138
|
+
workFolder: '/tmp'
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
mockProcess.stdout.emit('data', '{"type":"assistant","message":{"content":[{"type":"text","text":"old message"}]}}\n');
|
|
144
|
+
|
|
145
|
+
const peekPromise = callToolHandler!({
|
|
146
|
+
params: {
|
|
147
|
+
name: 'peek',
|
|
148
|
+
arguments: {
|
|
149
|
+
pids: [12345, 12345, 99999],
|
|
150
|
+
peek_time_sec: 1,
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
setTimeout(() => {
|
|
156
|
+
mockProcess.stdout.emit('data', '{"type":"assistant","message":{"content":[{"type":"text","text":"new message"},{"type":"tool_use","id":"tool-1","name":"Read","input":{"file_path":"/tmp/a"}}]}}\n');
|
|
157
|
+
mockProcess.stdout.emit('data', '{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"tool-1","content":"secret"}]}}\n');
|
|
158
|
+
mockProcess.emit('close', 0);
|
|
159
|
+
}, 10);
|
|
160
|
+
|
|
161
|
+
const result = await peekPromise;
|
|
162
|
+
const response = JSON.parse(result.content[0].text);
|
|
163
|
+
|
|
164
|
+
expect(response.processes).toHaveLength(2);
|
|
165
|
+
expect(response.processes[0]).toMatchObject({
|
|
166
|
+
pid: 12345,
|
|
167
|
+
agent: 'claude',
|
|
168
|
+
status: 'completed',
|
|
169
|
+
messages: [
|
|
170
|
+
{
|
|
171
|
+
ts: expect.any(String),
|
|
172
|
+
text: 'new message',
|
|
173
|
+
},
|
|
174
|
+
],
|
|
175
|
+
truncated: false,
|
|
176
|
+
error: null,
|
|
177
|
+
});
|
|
178
|
+
expect(response.processes[1]).toEqual({
|
|
179
|
+
pid: 99999,
|
|
180
|
+
agent: null,
|
|
181
|
+
status: 'not_found',
|
|
182
|
+
messages: [],
|
|
183
|
+
truncated: false,
|
|
184
|
+
error: 'process not found',
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('should peek OpenCode text events and exclude OpenCode tool output', async () => {
|
|
189
|
+
const { handlers } = await setupServer();
|
|
190
|
+
|
|
191
|
+
const mockProcess = new EventEmitter() as any;
|
|
192
|
+
mockProcess.pid = 12346;
|
|
193
|
+
mockProcess.stdout = new EventEmitter();
|
|
194
|
+
mockProcess.stderr = new EventEmitter();
|
|
195
|
+
mockProcess.kill = vi.fn();
|
|
196
|
+
|
|
197
|
+
mockSpawn.mockReturnValue(mockProcess);
|
|
198
|
+
|
|
199
|
+
const callToolHandler = handlers.get('callTool')!;
|
|
200
|
+
await callToolHandler!({
|
|
201
|
+
params: {
|
|
202
|
+
name: 'run',
|
|
203
|
+
arguments: {
|
|
204
|
+
prompt: 'opencode peek prompt',
|
|
205
|
+
workFolder: '/tmp',
|
|
206
|
+
model: 'opencode',
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
const peekPromise = callToolHandler!({
|
|
212
|
+
params: {
|
|
213
|
+
name: 'peek',
|
|
214
|
+
arguments: {
|
|
215
|
+
pids: [12346],
|
|
216
|
+
peek_time_sec: 1,
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
setTimeout(() => {
|
|
222
|
+
mockProcess.stdout.emit('data', '{"type":"text","timestamp":1775918783605,"sessionID":"ses-1","part":{"type":"text","text":"OpenCode visible text"}}\n');
|
|
223
|
+
mockProcess.stdout.emit('data', '{"type":"tool_use","timestamp":1775918783606,"sessionID":"ses-1","part":{"type":"tool","state":{"output":"secret command output"},"metadata":{"output":"secret metadata output"}}}\n');
|
|
224
|
+
mockProcess.emit('close', 0);
|
|
225
|
+
}, 10);
|
|
226
|
+
|
|
227
|
+
const result = await peekPromise;
|
|
228
|
+
const response = JSON.parse(result.content[0].text);
|
|
229
|
+
|
|
230
|
+
expect(response.processes).toHaveLength(1);
|
|
231
|
+
expect(response.processes[0]).toMatchObject({
|
|
232
|
+
pid: 12346,
|
|
233
|
+
agent: 'opencode',
|
|
234
|
+
status: 'completed',
|
|
235
|
+
messages: [
|
|
236
|
+
{
|
|
237
|
+
ts: expect.any(String),
|
|
238
|
+
text: 'OpenCode visible text',
|
|
239
|
+
},
|
|
240
|
+
],
|
|
241
|
+
truncated: false,
|
|
242
|
+
error: null,
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('should peek Gemini assistant message events and exclude tool output', async () => {
|
|
247
|
+
const { handlers } = await setupServer();
|
|
248
|
+
|
|
249
|
+
const mockProcess = new EventEmitter() as any;
|
|
250
|
+
mockProcess.pid = 12347;
|
|
251
|
+
mockProcess.stdout = new EventEmitter();
|
|
252
|
+
mockProcess.stderr = new EventEmitter();
|
|
253
|
+
mockProcess.kill = vi.fn();
|
|
254
|
+
|
|
255
|
+
mockSpawn.mockReturnValue(mockProcess);
|
|
256
|
+
|
|
257
|
+
const callToolHandler = handlers.get('callTool')!;
|
|
258
|
+
await callToolHandler!({
|
|
259
|
+
params: {
|
|
260
|
+
name: 'run',
|
|
261
|
+
arguments: {
|
|
262
|
+
prompt: 'gemini peek prompt',
|
|
263
|
+
workFolder: '/tmp',
|
|
264
|
+
model: 'gemini-2.5-pro',
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
const peekPromise = callToolHandler!({
|
|
270
|
+
params: {
|
|
271
|
+
name: 'peek',
|
|
272
|
+
arguments: {
|
|
273
|
+
pids: [12347],
|
|
274
|
+
peek_time_sec: 1,
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
setTimeout(() => {
|
|
280
|
+
mockProcess.stdout.emit('data', '{"type":"message","timestamp":"2026-04-11T14:44:42.294Z","role":"user","content":"hidden user text"}\n');
|
|
281
|
+
mockProcess.stdout.emit('data', '{"type":"message","timestamp":"2026-04-11T14:44:53.820Z","role":"assistant","content":"Visible Gemini text","delta":true}\n');
|
|
282
|
+
mockProcess.stdout.emit('data', '{"type":"tool_result","timestamp":"2026-04-11T14:45:03.011Z","status":"success","output":"secret command output"}\n');
|
|
283
|
+
mockProcess.emit('close', 0);
|
|
284
|
+
}, 10);
|
|
285
|
+
|
|
286
|
+
const result = await peekPromise;
|
|
287
|
+
const response = JSON.parse(result.content[0].text);
|
|
288
|
+
|
|
289
|
+
expect(response.processes).toHaveLength(1);
|
|
290
|
+
expect(response.processes[0]).toMatchObject({
|
|
291
|
+
pid: 12347,
|
|
292
|
+
agent: 'gemini',
|
|
293
|
+
status: 'completed',
|
|
294
|
+
messages: [
|
|
295
|
+
{
|
|
296
|
+
ts: expect.any(String),
|
|
297
|
+
text: 'Visible Gemini text',
|
|
298
|
+
},
|
|
299
|
+
],
|
|
300
|
+
truncated: false,
|
|
301
|
+
error: null,
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
|
|
121
305
|
it('should handle process with model parameter', async () => {
|
|
122
306
|
const { handlers } = await setupServer();
|
|
123
307
|
|
|
@@ -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);
|
|
@@ -462,14 +497,15 @@ describe('ClaudeCodeServer Unit Tests', () => {
|
|
|
462
497
|
const handler = listToolsCall[1];
|
|
463
498
|
const result = await handler();
|
|
464
499
|
|
|
465
|
-
expect(result.tools).toHaveLength(
|
|
500
|
+
expect(result.tools).toHaveLength(7);
|
|
466
501
|
expect(result.tools[0].name).toBe('run');
|
|
467
502
|
expect(result.tools[0].description).toContain('AI Agent Runner');
|
|
468
503
|
expect(result.tools[1].name).toBe('list_processes');
|
|
469
504
|
expect(result.tools[2].name).toBe('get_result');
|
|
470
505
|
expect(result.tools[3].name).toBe('wait');
|
|
471
|
-
expect(result.tools[4].name).toBe('
|
|
472
|
-
expect(result.tools[5].name).toBe('
|
|
506
|
+
expect(result.tools[4].name).toBe('peek');
|
|
507
|
+
expect(result.tools[5].name).toBe('kill_process');
|
|
508
|
+
expect(result.tools[6].name).toBe('cleanup_processes');
|
|
473
509
|
});
|
|
474
510
|
|
|
475
511
|
it('should handle CallToolRequest', async () => {
|