ai-cli-mcp 2.18.0 → 2.20.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/CHANGELOG.md +26 -0
- package/README.ja.md +37 -11
- package/README.md +44 -11
- package/dist/app/cli.js +2 -1
- package/dist/app/mcp.js +65 -13
- package/dist/cli-builder.js +13 -6
- package/dist/cli-process-service.js +81 -95
- package/dist/cli-utils.js +6 -0
- package/dist/cli.js +1 -1
- package/dist/model-catalog.js +3 -2
- package/dist/parsers.js +111 -8
- package/dist/process-service.js +5 -4
- package/package.json +26 -2
- package/server.json +3 -3
- package/.gemini/settings.json +0 -11
- package/.github/dependabot.yml +0 -28
- package/.github/pull_request_template.md +0 -28
- package/.github/workflows/ci.yml +0 -34
- package/.github/workflows/dependency-review.yml +0 -22
- package/.github/workflows/publish.yml +0 -89
- package/.github/workflows/test.yml +0 -20
- package/.github/workflows/watch-session-prs.yml +0 -276
- package/.husky/pre-commit +0 -1
- package/.mcp.json +0 -11
- package/.releaserc.json +0 -18
- package/.vscode/settings.json +0 -3
- package/CONTRIBUTING.md +0 -81
- package/dist/__tests__/app-cli.test.js +0 -392
- package/dist/__tests__/cli-bin-smoke.test.js +0 -101
- package/dist/__tests__/cli-builder.test.js +0 -442
- package/dist/__tests__/cli-process-service.test.js +0 -655
- package/dist/__tests__/cli-utils.test.js +0 -171
- package/dist/__tests__/e2e.test.js +0 -256
- package/dist/__tests__/edge-cases.test.js +0 -130
- package/dist/__tests__/error-cases.test.js +0 -292
- package/dist/__tests__/mcp-contract.test.js +0 -636
- package/dist/__tests__/mocks.js +0 -32
- package/dist/__tests__/model-alias.test.js +0 -36
- package/dist/__tests__/parsers.test.js +0 -500
- package/dist/__tests__/peek.test.js +0 -36
- package/dist/__tests__/process-management.test.js +0 -871
- package/dist/__tests__/server.test.js +0 -809
- package/dist/__tests__/setup.js +0 -11
- package/dist/__tests__/utils/claude-mock.js +0 -80
- package/dist/__tests__/utils/mcp-client.js +0 -121
- package/dist/__tests__/utils/opencode-mock.js +0 -91
- package/dist/__tests__/utils/persistent-mock.js +0 -28
- package/dist/__tests__/utils/test-helpers.js +0 -11
- package/dist/__tests__/validation.test.js +0 -308
- package/dist/__tests__/version-print.test.js +0 -65
- package/dist/__tests__/wait.test.js +0 -260
- package/docs/RELEASE_CHECKLIST.md +0 -65
- package/docs/cli-architecture.md +0 -275
- package/docs/concept.md +0 -154
- package/docs/development.md +0 -156
- package/docs/e2e-testing.md +0 -148
- package/docs/prd.md +0 -146
- package/docs/session-stacking.md +0 -67
- package/src/__tests__/app-cli.test.ts +0 -495
- package/src/__tests__/cli-bin-smoke.test.ts +0 -136
- package/src/__tests__/cli-builder.test.ts +0 -549
- package/src/__tests__/cli-process-service.test.ts +0 -759
- package/src/__tests__/cli-utils.test.ts +0 -200
- package/src/__tests__/e2e.test.ts +0 -311
- package/src/__tests__/edge-cases.test.ts +0 -176
- package/src/__tests__/error-cases.test.ts +0 -370
- package/src/__tests__/mcp-contract.test.ts +0 -755
- package/src/__tests__/mocks.ts +0 -35
- package/src/__tests__/model-alias.test.ts +0 -44
- package/src/__tests__/parsers.test.ts +0 -564
- package/src/__tests__/peek.test.ts +0 -44
- package/src/__tests__/process-management.test.ts +0 -1043
- package/src/__tests__/server.test.ts +0 -1020
- package/src/__tests__/setup.ts +0 -13
- package/src/__tests__/utils/claude-mock.ts +0 -87
- package/src/__tests__/utils/mcp-client.ts +0 -159
- package/src/__tests__/utils/opencode-mock.ts +0 -108
- package/src/__tests__/utils/persistent-mock.ts +0 -33
- package/src/__tests__/utils/test-helpers.ts +0 -13
- package/src/__tests__/validation.test.ts +0 -369
- package/src/__tests__/version-print.test.ts +0 -81
- package/src/__tests__/wait.test.ts +0 -302
- package/src/app/cli.ts +0 -424
- package/src/app/mcp.ts +0 -466
- package/src/bin/ai-cli-mcp.ts +0 -7
- package/src/bin/ai-cli.ts +0 -11
- package/src/cli-builder.ts +0 -274
- package/src/cli-parse.ts +0 -105
- package/src/cli-process-service.ts +0 -708
- package/src/cli-utils.ts +0 -258
- package/src/cli.ts +0 -124
- package/src/model-catalog.ts +0 -87
- package/src/parsers.ts +0 -840
- package/src/peek.ts +0 -95
- package/src/process-result.ts +0 -88
- package/src/process-service.ts +0 -367
- package/src/server.ts +0 -10
- package/tsconfig.json +0 -16
- package/vitest.config.e2e.ts +0 -27
- package/vitest.config.ts +0 -22
- package/vitest.config.unit.ts +0 -28
package/src/__tests__/mocks.ts
DELETED
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
import { vi } from 'vitest';
|
|
2
|
-
|
|
3
|
-
// Mock Claude CLI responses
|
|
4
|
-
export const mockClaudeResponse = (stdout: string, stderr = '', exitCode = 0) => {
|
|
5
|
-
return {
|
|
6
|
-
stdout: { on: vi.fn((event, cb) => event === 'data' && cb(stdout)) },
|
|
7
|
-
stderr: { on: vi.fn((event, cb) => event === 'data' && cb(stderr)) },
|
|
8
|
-
on: vi.fn((event, cb) => {
|
|
9
|
-
if (event === 'exit') setTimeout(() => cb(exitCode), 10);
|
|
10
|
-
}),
|
|
11
|
-
};
|
|
12
|
-
};
|
|
13
|
-
|
|
14
|
-
// Mock MCP request builder
|
|
15
|
-
export const createMCPRequest = (tool: string, args: any, id = 1) => ({
|
|
16
|
-
jsonrpc: '2.0',
|
|
17
|
-
method: 'tools/call',
|
|
18
|
-
params: {
|
|
19
|
-
name: tool,
|
|
20
|
-
arguments: args,
|
|
21
|
-
},
|
|
22
|
-
id,
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
// Mock file system operations
|
|
26
|
-
export const setupTestEnvironment = () => {
|
|
27
|
-
const testFiles = new Map<string, string>();
|
|
28
|
-
|
|
29
|
-
return {
|
|
30
|
-
writeFile: (path: string, content: string) => testFiles.set(path, content),
|
|
31
|
-
readFile: (path: string) => testFiles.get(path),
|
|
32
|
-
exists: (path: string) => testFiles.has(path),
|
|
33
|
-
cleanup: () => testFiles.clear(),
|
|
34
|
-
};
|
|
35
|
-
};
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
|
|
3
|
-
// Test the model alias resolution logic directly
|
|
4
|
-
describe('Model Alias Resolution', () => {
|
|
5
|
-
// Define the same MODEL_ALIASES as in server.ts
|
|
6
|
-
const MODEL_ALIASES: Record<string, string> = {
|
|
7
|
-
'haiku': 'claude-3-5-haiku-20241022'
|
|
8
|
-
};
|
|
9
|
-
|
|
10
|
-
// Replicate the resolveModelAlias function
|
|
11
|
-
function resolveModelAlias(model: string): string {
|
|
12
|
-
return MODEL_ALIASES[model] || model;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
it('should resolve haiku alias to full model name', () => {
|
|
16
|
-
expect(resolveModelAlias('haiku')).toBe('claude-3-5-haiku-20241022');
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
it('should pass through non-alias model names unchanged', () => {
|
|
20
|
-
expect(resolveModelAlias('sonnet')).toBe('sonnet');
|
|
21
|
-
expect(resolveModelAlias('opus')).toBe('opus');
|
|
22
|
-
expect(resolveModelAlias('claude-3-opus-20240229')).toBe('claude-3-opus-20240229');
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
it('should pass through empty strings', () => {
|
|
26
|
-
expect(resolveModelAlias('')).toBe('');
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
it('should be case-sensitive', () => {
|
|
30
|
-
// Should not resolve uppercase version
|
|
31
|
-
expect(resolveModelAlias('Haiku')).toBe('Haiku');
|
|
32
|
-
expect(resolveModelAlias('HAIKU')).toBe('HAIKU');
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
it('should handle undefined input gracefully', () => {
|
|
36
|
-
// TypeScript would normally prevent this, but testing for runtime safety
|
|
37
|
-
expect(resolveModelAlias(undefined as any)).toBe(undefined);
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
it('should handle null input gracefully', () => {
|
|
41
|
-
// TypeScript would normally prevent this, but testing for runtime safety
|
|
42
|
-
expect(resolveModelAlias(null as any)).toBe(null);
|
|
43
|
-
});
|
|
44
|
-
});
|
|
@@ -1,564 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { parseCodexOutput, parseClaudeOutput, parseForgeOutput, parseGeminiOutput, parseOpenCodeOutput, PeekEventExtractor, PeekMessageExtractor } from '../parsers.js';
|
|
3
|
-
|
|
4
|
-
describe('parseCodexOutput', () => {
|
|
5
|
-
it('should parse basic Codex output with message and session_id', () => {
|
|
6
|
-
const output = `
|
|
7
|
-
{"type":"thread.started","thread_id":"test-session-id"}
|
|
8
|
-
{"type":"turn.started"}
|
|
9
|
-
{"type":"item.completed","item":{"type":"agent_message","text":"Hello world"}}
|
|
10
|
-
{"type":"turn.completed"}
|
|
11
|
-
`;
|
|
12
|
-
const result = parseCodexOutput(output);
|
|
13
|
-
expect(result).toEqual({
|
|
14
|
-
message: "Hello world",
|
|
15
|
-
session_id: "test-session-id",
|
|
16
|
-
token_count: null,
|
|
17
|
-
tools: undefined
|
|
18
|
-
});
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
it('should extract MCP tool calls', () => {
|
|
22
|
-
const output = `
|
|
23
|
-
{"type":"thread.started","thread_id":"tool-test-id"}
|
|
24
|
-
{"type":"turn.started"}
|
|
25
|
-
{"type":"item.completed","item":{"id":"item_1","type":"mcp_tool_call","server":"acm","tool":"run","arguments":{"model":"gemini-2.5-flash","prompt":"hi"},"result":{"content":[{"text":"started","type":"text"}]},"status":"completed"}}
|
|
26
|
-
{"type":"item.completed","item":{"type":"agent_message","text":"Tool executed"}}
|
|
27
|
-
{"type":"turn.completed"}
|
|
28
|
-
`;
|
|
29
|
-
const result = parseCodexOutput(output);
|
|
30
|
-
|
|
31
|
-
expect(result.message).toBe("Tool executed");
|
|
32
|
-
expect(result.session_id).toBe("tool-test-id");
|
|
33
|
-
expect(result.tools).toHaveLength(1);
|
|
34
|
-
expect(result.tools[0]).toEqual({
|
|
35
|
-
tool: "run",
|
|
36
|
-
server: "acm",
|
|
37
|
-
input: { model: "gemini-2.5-flash", prompt: "hi" },
|
|
38
|
-
output: { content: [{ text: "started", type: "text" }] }
|
|
39
|
-
});
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
it('should handle multiple tool calls', () => {
|
|
43
|
-
const output = `
|
|
44
|
-
{"type":"item.completed","item":{"type":"mcp_tool_call","tool":"tool1","arguments":{"arg":1},"result":"res1"}}
|
|
45
|
-
{"type":"item.completed","item":{"type":"mcp_tool_call","tool":"tool2","arguments":{"arg":2},"result":"res2"}}
|
|
46
|
-
`;
|
|
47
|
-
const result = parseCodexOutput(output);
|
|
48
|
-
expect(result.tools).toHaveLength(2);
|
|
49
|
-
expect(result.tools[0].tool).toBe("tool1");
|
|
50
|
-
expect(result.tools[1].tool).toBe("tool2");
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
it('should return null for empty input', () => {
|
|
54
|
-
expect(parseCodexOutput("")).toBeNull();
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
it('should handle invalid JSON gracefully', () => {
|
|
58
|
-
const output = `
|
|
59
|
-
{"type":"valid"}
|
|
60
|
-
INVALID_JSON
|
|
61
|
-
{"type":"item.completed","item":{"type":"agent_message","text":"Still parses valid lines"}}
|
|
62
|
-
`;
|
|
63
|
-
const result = parseCodexOutput(output);
|
|
64
|
-
expect(result.message).toBe("Still parses valid lines");
|
|
65
|
-
});
|
|
66
|
-
});
|
|
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('PeekEventExtractor', () => {
|
|
194
|
-
const ts = '2026-04-12T02:10:00.000Z';
|
|
195
|
-
|
|
196
|
-
it('emits only message events when include_tool_calls is false', () => {
|
|
197
|
-
const extractor = new PeekEventExtractor('codex', { includeToolCalls: false });
|
|
198
|
-
const output = [
|
|
199
|
-
'{"type":"item.started","item":{"id":"item_0","type":"command_execution","command":"echo secret","status":"in_progress"}}',
|
|
200
|
-
'{"type":"item.completed","item":{"id":"item_0","type":"command_execution","command":"echo secret","aggregated_output":"secret output\\n","exit_code":0,"status":"completed"}}',
|
|
201
|
-
'{"type":"item.completed","item":{"id":"item_1","type":"agent_message","text":"Visible Codex message"}}',
|
|
202
|
-
].join('\n') + '\n';
|
|
203
|
-
|
|
204
|
-
expect(extractor.push(output, ts)).toEqual([
|
|
205
|
-
{ kind: 'message', ts, text: 'Visible Codex message' },
|
|
206
|
-
]);
|
|
207
|
-
});
|
|
208
|
-
|
|
209
|
-
it('emits Codex command and MCP tool_call events without raw output when include_tool_calls is true', () => {
|
|
210
|
-
const extractor = new PeekEventExtractor('codex', { includeToolCalls: true });
|
|
211
|
-
const output = [
|
|
212
|
-
'{"type":"item.started","item":{"id":"cmd_0","type":"command_execution","command":"/bin/sh -c \\"echo secret\\"","status":"in_progress"}}',
|
|
213
|
-
'{"type":"item.completed","item":{"id":"cmd_0","type":"command_execution","command":"/bin/sh -c \\"echo secret\\"","aggregated_output":"secret output\\n","exit_code":0,"status":"completed"}}',
|
|
214
|
-
'{"type":"item.started","item":{"id":"mcp_0","type":"mcp_tool_call","server":"acm","tool":"list_processes","arguments":{},"status":"in_progress"}}',
|
|
215
|
-
'{"type":"item.completed","item":{"id":"mcp_0","type":"mcp_tool_call","server":"acm","tool":"list_processes","arguments":{},"result":{"content":[{"type":"text","text":"secret result"}]},"status":"completed"}}',
|
|
216
|
-
].join('\n') + '\n';
|
|
217
|
-
|
|
218
|
-
expect(extractor.push(output, ts)).toEqual([
|
|
219
|
-
{
|
|
220
|
-
kind: 'tool_call',
|
|
221
|
-
ts,
|
|
222
|
-
phase: 'started',
|
|
223
|
-
id: 'cmd_0',
|
|
224
|
-
tool: 'command_execution',
|
|
225
|
-
summary: '/bin/sh -c "echo secret"',
|
|
226
|
-
},
|
|
227
|
-
{
|
|
228
|
-
kind: 'tool_call',
|
|
229
|
-
ts,
|
|
230
|
-
phase: 'completed',
|
|
231
|
-
id: 'cmd_0',
|
|
232
|
-
tool: 'command_execution',
|
|
233
|
-
summary: '/bin/sh -c "echo secret"',
|
|
234
|
-
status: 'success',
|
|
235
|
-
exit_code: 0,
|
|
236
|
-
},
|
|
237
|
-
{
|
|
238
|
-
kind: 'tool_call',
|
|
239
|
-
ts,
|
|
240
|
-
phase: 'started',
|
|
241
|
-
id: 'mcp_0',
|
|
242
|
-
tool: 'list_processes',
|
|
243
|
-
server: 'acm',
|
|
244
|
-
summary: 'acm.list_processes',
|
|
245
|
-
},
|
|
246
|
-
{
|
|
247
|
-
kind: 'tool_call',
|
|
248
|
-
ts,
|
|
249
|
-
phase: 'completed',
|
|
250
|
-
id: 'mcp_0',
|
|
251
|
-
tool: 'list_processes',
|
|
252
|
-
server: 'acm',
|
|
253
|
-
summary: 'acm.list_processes',
|
|
254
|
-
status: 'success',
|
|
255
|
-
},
|
|
256
|
-
]);
|
|
257
|
-
});
|
|
258
|
-
|
|
259
|
-
it('emits Claude MCP tool_call events paired by id', () => {
|
|
260
|
-
const extractor = new PeekEventExtractor('claude', { includeToolCalls: true });
|
|
261
|
-
const output = [
|
|
262
|
-
'{"type":"assistant","message":{"content":[{"type":"tool_use","id":"toolu_1","name":"mcp__acm__list_processes","input":{}}]}}',
|
|
263
|
-
'{"type":"user","message":{"content":[{"tool_use_id":"toolu_1","type":"tool_result","content":[{"type":"text","text":"secret result"}]}]}}',
|
|
264
|
-
'{"type":"assistant","message":{"content":[{"type":"text","text":"Done."}]}}',
|
|
265
|
-
].join('\n') + '\n';
|
|
266
|
-
|
|
267
|
-
expect(extractor.push(output, ts)).toEqual([
|
|
268
|
-
{
|
|
269
|
-
kind: 'tool_call',
|
|
270
|
-
ts,
|
|
271
|
-
phase: 'started',
|
|
272
|
-
id: 'toolu_1',
|
|
273
|
-
tool: 'mcp__acm__list_processes',
|
|
274
|
-
server: 'acm',
|
|
275
|
-
summary: 'acm.list_processes',
|
|
276
|
-
},
|
|
277
|
-
{
|
|
278
|
-
kind: 'tool_call',
|
|
279
|
-
ts,
|
|
280
|
-
phase: 'completed',
|
|
281
|
-
id: 'toolu_1',
|
|
282
|
-
tool: 'mcp__acm__list_processes',
|
|
283
|
-
server: 'acm',
|
|
284
|
-
summary: 'acm.list_processes',
|
|
285
|
-
status: 'success',
|
|
286
|
-
},
|
|
287
|
-
{ kind: 'message', ts, text: 'Done.' },
|
|
288
|
-
]);
|
|
289
|
-
});
|
|
290
|
-
|
|
291
|
-
it('emits Gemini MCP tool_call events and joined assistant message events', () => {
|
|
292
|
-
const extractor = new PeekEventExtractor('gemini', { includeToolCalls: true });
|
|
293
|
-
const output = [
|
|
294
|
-
'{"type":"tool_use","timestamp":"2026-04-12T02:56:29.992Z","tool_name":"mcp_acm_list_processes","tool_id":"mcp_1","parameters":{}}',
|
|
295
|
-
'{"type":"tool_result","timestamp":"2026-04-12T02:56:30.059Z","tool_id":"mcp_1","status":"success","output":"secret result"}',
|
|
296
|
-
'{"type":"message","timestamp":"2026-04-12T02:56:32.855Z","role":"assistant","content":"The tool ","delta":true}',
|
|
297
|
-
'{"type":"message","timestamp":"2026-04-12T02:56:32.902Z","role":"assistant","content":"succeeded.","delta":true}',
|
|
298
|
-
'{"type":"result","timestamp":"2026-04-12T02:56:32.954Z","status":"success","stats":{"tool_calls":1}}',
|
|
299
|
-
].join('\n') + '\n';
|
|
300
|
-
|
|
301
|
-
expect(extractor.push(output, ts)).toEqual([
|
|
302
|
-
{
|
|
303
|
-
kind: 'tool_call',
|
|
304
|
-
ts,
|
|
305
|
-
phase: 'started',
|
|
306
|
-
id: 'mcp_1',
|
|
307
|
-
tool: 'mcp_acm_list_processes',
|
|
308
|
-
server: 'acm',
|
|
309
|
-
summary: 'acm.list_processes',
|
|
310
|
-
},
|
|
311
|
-
{
|
|
312
|
-
kind: 'tool_call',
|
|
313
|
-
ts,
|
|
314
|
-
phase: 'completed',
|
|
315
|
-
id: 'mcp_1',
|
|
316
|
-
tool: 'mcp_acm_list_processes',
|
|
317
|
-
server: 'acm',
|
|
318
|
-
summary: 'acm.list_processes',
|
|
319
|
-
status: 'success',
|
|
320
|
-
},
|
|
321
|
-
{ kind: 'message', ts, text: 'The tool succeeded.' },
|
|
322
|
-
]);
|
|
323
|
-
});
|
|
324
|
-
|
|
325
|
-
it('emits OpenCode completed MCP tool_call events from tool_use state', () => {
|
|
326
|
-
const extractor = new PeekEventExtractor('opencode', { includeToolCalls: true });
|
|
327
|
-
const output = [
|
|
328
|
-
'{"type":"tool_use","timestamp":1775962663837,"sessionID":"ses-1","part":{"id":"part-1","type":"tool","tool":"acm_list_processes","callID":"call_1","state":{"status":"completed","input":{},"output":"secret result","metadata":{"truncated":false},"time":{"start":1775962663834,"end":1775962663837}}}}',
|
|
329
|
-
].join('\n') + '\n';
|
|
330
|
-
|
|
331
|
-
expect(extractor.push(output, ts)).toEqual([
|
|
332
|
-
{
|
|
333
|
-
kind: 'tool_call',
|
|
334
|
-
ts,
|
|
335
|
-
phase: 'completed',
|
|
336
|
-
id: 'call_1',
|
|
337
|
-
tool: 'acm_list_processes',
|
|
338
|
-
server: 'acm',
|
|
339
|
-
summary: 'acm.list_processes',
|
|
340
|
-
status: 'success',
|
|
341
|
-
duration_ms: 3,
|
|
342
|
-
},
|
|
343
|
-
]);
|
|
344
|
-
});
|
|
345
|
-
});
|
|
346
|
-
|
|
347
|
-
describe('parseGeminiOutput', () => {
|
|
348
|
-
it('should parse legacy final JSON output', () => {
|
|
349
|
-
const output = JSON.stringify({
|
|
350
|
-
session_id: 'gemini-session-json',
|
|
351
|
-
response: 'Legacy Gemini final response',
|
|
352
|
-
stats: {
|
|
353
|
-
total_tokens: 123,
|
|
354
|
-
},
|
|
355
|
-
});
|
|
356
|
-
|
|
357
|
-
expect(parseGeminiOutput(output)).toEqual({
|
|
358
|
-
session_id: 'gemini-session-json',
|
|
359
|
-
response: 'Legacy Gemini final response',
|
|
360
|
-
stats: {
|
|
361
|
-
total_tokens: 123,
|
|
362
|
-
},
|
|
363
|
-
});
|
|
364
|
-
});
|
|
365
|
-
|
|
366
|
-
it('should normalize a single-line Gemini assistant stream event', () => {
|
|
367
|
-
const output = '{"type":"message","timestamp":"2026-04-11T14:44:53.820Z","role":"assistant","content":"Only answer","delta":true}';
|
|
368
|
-
|
|
369
|
-
const result = parseGeminiOutput(output);
|
|
370
|
-
|
|
371
|
-
expect(result).toMatchObject({
|
|
372
|
-
message: 'Only answer',
|
|
373
|
-
session_id: null,
|
|
374
|
-
});
|
|
375
|
-
expect(result).not.toHaveProperty('type');
|
|
376
|
-
expect(result).not.toHaveProperty('content');
|
|
377
|
-
});
|
|
378
|
-
|
|
379
|
-
it('should parse Gemini stream-json NDJSON output', () => {
|
|
380
|
-
const output = [
|
|
381
|
-
'{"type":"init","timestamp":"2026-04-11T14:44:42.293Z","session_id":"gemini-session-stream","model":"gemini-3.1-pro-preview"}',
|
|
382
|
-
'{"type":"message","timestamp":"2026-04-11T14:44:42.294Z","role":"user","content":"hidden user text"}',
|
|
383
|
-
'{"type":"message","timestamp":"2026-04-11T14:44:53.820Z","role":"assistant","content":"First logical assistant response.","delta":true}',
|
|
384
|
-
'{"type":"tool_use","timestamp":"2026-04-11T14:44:53.821Z","tool_name":"run_shell_command","tool_id":"tool-1","parameters":{"command":"echo hidden"}}',
|
|
385
|
-
'{"type":"tool_result","timestamp":"2026-04-11T14:45:03.011Z","tool_id":"tool-1","status":"success","output":"hidden command output"}',
|
|
386
|
-
'{"type":"message","timestamp":"2026-04-11T14:45:10.315Z","role":"assistant","content":"Final assistant ","delta":true}',
|
|
387
|
-
'{"type":"message","timestamp":"2026-04-11T14:45:10.316Z","role":"assistant","content":"response.","delta":true}',
|
|
388
|
-
'{"type":"result","timestamp":"2026-04-11T14:45:10.380Z","status":"success","response":"Result response is not the parsed message","stats":{"total_tokens":21999}}',
|
|
389
|
-
].join('\n') + '\n';
|
|
390
|
-
|
|
391
|
-
expect(parseGeminiOutput(output)).toEqual({
|
|
392
|
-
message: 'Final assistant response.',
|
|
393
|
-
session_id: 'gemini-session-stream',
|
|
394
|
-
stats: {
|
|
395
|
-
total_tokens: 21999,
|
|
396
|
-
},
|
|
397
|
-
tools: [
|
|
398
|
-
{
|
|
399
|
-
tool: 'run_shell_command',
|
|
400
|
-
input: { command: 'echo hidden' },
|
|
401
|
-
output: 'hidden command output',
|
|
402
|
-
status: 'success',
|
|
403
|
-
},
|
|
404
|
-
],
|
|
405
|
-
});
|
|
406
|
-
});
|
|
407
|
-
});
|
|
408
|
-
|
|
409
|
-
describe('parseClaudeOutput', () => {
|
|
410
|
-
it('should parse legacy JSON output', () => {
|
|
411
|
-
const output = JSON.stringify({
|
|
412
|
-
content: [{ type: 'text', text: 'Hello' }]
|
|
413
|
-
});
|
|
414
|
-
const result = parseClaudeOutput(output);
|
|
415
|
-
expect(result).toEqual({
|
|
416
|
-
content: [{ type: 'text', text: 'Hello' }]
|
|
417
|
-
});
|
|
418
|
-
});
|
|
419
|
-
|
|
420
|
-
it('should parse stream-json (NDJSON) output', () => {
|
|
421
|
-
const output = `
|
|
422
|
-
{"type":"system","session_id":"test-claude-session"}
|
|
423
|
-
{"type":"assistant","message":{"content":[{"type":"text","text":"Thinking..."}]}}
|
|
424
|
-
{"type":"assistant","message":{"content":[{"type":"tool_use","id":"call_1","name":"mcp__acm__run","input":{"prompt":"hi"}}]}}
|
|
425
|
-
{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"call_1","content":"done"}]}}
|
|
426
|
-
{"type":"result","result":"Final Answer","is_error":false}
|
|
427
|
-
`;
|
|
428
|
-
const result = parseClaudeOutput(output);
|
|
429
|
-
|
|
430
|
-
expect(result.message).toBe("Final Answer");
|
|
431
|
-
expect(result.session_id).toBe("test-claude-session");
|
|
432
|
-
expect(result.tools).toHaveLength(1);
|
|
433
|
-
expect(result.tools[0]).toEqual({
|
|
434
|
-
tool: "mcp__acm__run",
|
|
435
|
-
input: { prompt: "hi" },
|
|
436
|
-
output: "done"
|
|
437
|
-
});
|
|
438
|
-
});
|
|
439
|
-
|
|
440
|
-
it('should handle invalid NDJSON lines gracefully', () => {
|
|
441
|
-
const output = `
|
|
442
|
-
{"type":"system"}
|
|
443
|
-
INVALID_LINE
|
|
444
|
-
{"type":"result","result":"Success"}
|
|
445
|
-
`;
|
|
446
|
-
const result = parseClaudeOutput(output);
|
|
447
|
-
expect(result.message).toBe("Success");
|
|
448
|
-
});
|
|
449
|
-
});
|
|
450
|
-
|
|
451
|
-
describe('parseForgeOutput', () => {
|
|
452
|
-
it('should parse initialized forge output with a conversation id', () => {
|
|
453
|
-
const output = `● [21:09:01] Initialize 123e4567-e89b-12d3-a456-426614174000
|
|
454
|
-
Hello from Forge
|
|
455
|
-
● [21:09:08] Finished 123e4567-e89b-12d3-a456-426614174000
|
|
456
|
-
`;
|
|
457
|
-
|
|
458
|
-
expect(parseForgeOutput(output)).toEqual({
|
|
459
|
-
message: 'Hello from Forge',
|
|
460
|
-
session_id: '123e4567-e89b-12d3-a456-426614174000',
|
|
461
|
-
});
|
|
462
|
-
});
|
|
463
|
-
|
|
464
|
-
it('should parse resumed forge output with multiline assistant content', () => {
|
|
465
|
-
const output = `● [21:09:33] Continue conv-123
|
|
466
|
-
Line one
|
|
467
|
-
|
|
468
|
-
Line three
|
|
469
|
-
● [21:09:37] Finished conv-123
|
|
470
|
-
`;
|
|
471
|
-
|
|
472
|
-
expect(parseForgeOutput(output)).toEqual({
|
|
473
|
-
message: 'Line one\n\nLine three',
|
|
474
|
-
session_id: 'conv-123',
|
|
475
|
-
});
|
|
476
|
-
});
|
|
477
|
-
|
|
478
|
-
it('should return the current message while forge output is still in progress', () => {
|
|
479
|
-
const output = `● [21:09:33] Continue conv-456
|
|
480
|
-
Partial answer
|
|
481
|
-
still streaming`;
|
|
482
|
-
|
|
483
|
-
expect(parseForgeOutput(output)).toEqual({
|
|
484
|
-
message: 'Partial answer\nstill streaming',
|
|
485
|
-
session_id: 'conv-456',
|
|
486
|
-
});
|
|
487
|
-
});
|
|
488
|
-
|
|
489
|
-
it('should return null for unrelated forge output', () => {
|
|
490
|
-
expect(parseForgeOutput('plain text')).toBeNull();
|
|
491
|
-
});
|
|
492
|
-
});
|
|
493
|
-
|
|
494
|
-
describe('parseOpenCodeOutput', () => {
|
|
495
|
-
it('parses a single completed OpenCode step', () => {
|
|
496
|
-
const output = `{"type":"step_start","sessionID":"ses_1"}
|
|
497
|
-
{"type":"text","sessionID":"ses_1","part":{"type":"text","text":"Hello"}}
|
|
498
|
-
{"type":"step_finish","sessionID":"ses_1","part":{"type":"step-finish","tokens":{"total":11833},"cost":0}}`;
|
|
499
|
-
|
|
500
|
-
expect(parseOpenCodeOutput(output)).toEqual({
|
|
501
|
-
message: 'Hello',
|
|
502
|
-
session_id: 'ses_1',
|
|
503
|
-
tokens: { total: 11833 },
|
|
504
|
-
cost: 0,
|
|
505
|
-
});
|
|
506
|
-
});
|
|
507
|
-
|
|
508
|
-
it('returns the last completed step for multi-step output', () => {
|
|
509
|
-
const output = `{"type":"step_start","sessionID":"ses_2"}
|
|
510
|
-
{"type":"text","sessionID":"ses_2","part":{"type":"text","text":"First"}}
|
|
511
|
-
{"type":"step_finish","sessionID":"ses_2","part":{"type":"step-finish","tokens":{"total":10},"cost":0}}
|
|
512
|
-
{"type":"step_start","sessionID":"ses_2"}
|
|
513
|
-
{"type":"text","sessionID":"ses_2","part":{"type":"text","text":"Second"}}
|
|
514
|
-
{"type":"step_finish","sessionID":"ses_2","part":{"type":"step-finish","tokens":{"total":20},"cost":1}}`;
|
|
515
|
-
|
|
516
|
-
expect(parseOpenCodeOutput(output)).toEqual({
|
|
517
|
-
message: 'Second',
|
|
518
|
-
session_id: 'ses_2',
|
|
519
|
-
tokens: { total: 20 },
|
|
520
|
-
cost: 1,
|
|
521
|
-
});
|
|
522
|
-
});
|
|
523
|
-
|
|
524
|
-
it('resets the current-step buffer on each step_start', () => {
|
|
525
|
-
const output = `{"type":"step_start","sessionID":"ses_3"}
|
|
526
|
-
{"type":"text","sessionID":"ses_3","part":{"type":"text","text":"Discard me"}}
|
|
527
|
-
{"type":"step_start","sessionID":"ses_3"}
|
|
528
|
-
{"type":"text","sessionID":"ses_3","part":{"type":"text","text":"Keep me"}}
|
|
529
|
-
{"type":"step_finish","sessionID":"ses_3","part":{"type":"step-finish","tokens":{"total":5},"cost":0}}`;
|
|
530
|
-
|
|
531
|
-
expect(parseOpenCodeOutput(output)).toEqual({
|
|
532
|
-
message: 'Keep me',
|
|
533
|
-
session_id: 'ses_3',
|
|
534
|
-
tokens: { total: 5 },
|
|
535
|
-
cost: 0,
|
|
536
|
-
});
|
|
537
|
-
});
|
|
538
|
-
|
|
539
|
-
it('returns partial output when text exists without step_finish', () => {
|
|
540
|
-
const output = `{"type":"step_start","sessionID":"ses_4"}
|
|
541
|
-
{"type":"text","sessionID":"ses_4","part":{"type":"text","text":"Partial"}}`;
|
|
542
|
-
|
|
543
|
-
expect(parseOpenCodeOutput(output)).toEqual({
|
|
544
|
-
message: 'Partial',
|
|
545
|
-
session_id: 'ses_4',
|
|
546
|
-
});
|
|
547
|
-
});
|
|
548
|
-
|
|
549
|
-
it('ignores malformed lines and unknown event types', () => {
|
|
550
|
-
const output = `not-json
|
|
551
|
-
{"type":"unknown","sessionID":"ses_5"}
|
|
552
|
-
{"type":"text","sessionID":"ses_5","part":{"type":"text","text":"Hello"}}`;
|
|
553
|
-
|
|
554
|
-
expect(parseOpenCodeOutput(output)).toEqual({
|
|
555
|
-
message: 'Hello',
|
|
556
|
-
session_id: 'ses_5',
|
|
557
|
-
});
|
|
558
|
-
});
|
|
559
|
-
|
|
560
|
-
it('returns null when no useful OpenCode events exist', () => {
|
|
561
|
-
expect(parseOpenCodeOutput('{"type":"unknown"}')).toBeNull();
|
|
562
|
-
expect(parseOpenCodeOutput('')).toBeNull();
|
|
563
|
-
});
|
|
564
|
-
});
|