ai-cli-mcp 2.12.0 → 2.14.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 (46) hide show
  1. package/.github/workflows/publish.yml +25 -0
  2. package/CHANGELOG.md +20 -0
  3. package/README.ja.md +20 -5
  4. package/README.md +20 -6
  5. package/dist/__tests__/app-cli.test.js +34 -2
  6. package/dist/__tests__/cli-bin-smoke.test.js +4 -0
  7. package/dist/__tests__/cli-builder.test.js +37 -0
  8. package/dist/__tests__/cli-process-service.test.js +180 -5
  9. package/dist/__tests__/cli-utils.test.js +31 -0
  10. package/dist/__tests__/mcp-contract.test.js +287 -9
  11. package/dist/__tests__/parsers.test.js +37 -1
  12. package/dist/__tests__/process-management.test.js +2 -1
  13. package/dist/app/cli.js +8 -6
  14. package/dist/app/mcp.js +16 -8
  15. package/dist/cli-builder.js +14 -0
  16. package/dist/cli-parse.js +8 -5
  17. package/dist/cli-process-service.js +13 -23
  18. package/dist/cli-utils.js +17 -0
  19. package/dist/cli.js +4 -3
  20. package/dist/model-catalog.js +4 -1
  21. package/dist/parsers.js +55 -0
  22. package/dist/process-result.js +51 -0
  23. package/dist/process-service.js +11 -22
  24. package/dist/server.js +1 -1
  25. package/package.json +2 -2
  26. package/server.json +1 -1
  27. package/src/__tests__/app-cli.test.ts +43 -1
  28. package/src/__tests__/cli-bin-smoke.test.ts +4 -0
  29. package/src/__tests__/cli-builder.test.ts +47 -0
  30. package/src/__tests__/cli-process-service.test.ts +200 -5
  31. package/src/__tests__/cli-utils.test.ts +34 -0
  32. package/src/__tests__/mcp-contract.test.ts +325 -9
  33. package/src/__tests__/parsers.test.ts +44 -1
  34. package/src/__tests__/process-management.test.ts +2 -1
  35. package/src/app/cli.ts +9 -7
  36. package/src/app/mcp.ts +17 -8
  37. package/src/cli-builder.ts +18 -3
  38. package/src/cli-parse.ts +8 -5
  39. package/src/cli-process-service.ts +12 -23
  40. package/src/cli-utils.ts +21 -1
  41. package/src/cli.ts +4 -3
  42. package/src/model-catalog.ts +5 -1
  43. package/src/parsers.ts +61 -0
  44. package/src/process-result.ts +79 -0
  45. package/src/process-service.ts +11 -24
  46. package/src/server.ts +1 -1
@@ -56,6 +56,7 @@ describe('CliProcessService', () => {
56
56
  claude: scriptPath,
57
57
  codex: scriptPath,
58
58
  gemini: scriptPath,
59
+ forge: scriptPath,
59
60
  },
60
61
  });
61
62
  const runResult = await service.startProcess({
@@ -71,8 +72,18 @@ describe('CliProcessService', () => {
71
72
  expect(existsSync(join(processDir, 'stderr.log'))).toBe(true);
72
73
  const waitResult = await service.waitForProcesses([runResult.pid], 5);
73
74
  expect(waitResult).toHaveLength(1);
74
- expect(waitResult[0].pid).toBe(runResult.pid);
75
- expect(waitResult[0].status).toBe('completed');
75
+ expect(waitResult[0]).toMatchObject({
76
+ pid: runResult.pid,
77
+ agent: 'claude',
78
+ status: 'completed',
79
+ exitCode: null,
80
+ model: 'sonnet',
81
+ stdout: expect.any(String),
82
+ stderr: expect.any(String),
83
+ });
84
+ expect(waitResult[0]).not.toHaveProperty('startTime');
85
+ expect(waitResult[0]).not.toHaveProperty('workFolder');
86
+ expect(waitResult[0]).not.toHaveProperty('prompt');
76
87
  const listed = await service.listProcesses();
77
88
  expect(listed).toContainEqual({
78
89
  pid: runResult.pid,
@@ -80,11 +91,130 @@ describe('CliProcessService', () => {
80
91
  status: 'completed',
81
92
  });
82
93
  const result = await service.getProcessResult(runResult.pid, false);
83
- expect(result.pid).toBe(runResult.pid);
84
- expect(result.status).toBe('completed');
85
- expect(result.stdout).toContain('Command executed successfully');
94
+ expect(result).toMatchObject({
95
+ pid: runResult.pid,
96
+ agent: 'claude',
97
+ status: 'completed',
98
+ exitCode: null,
99
+ model: 'sonnet',
100
+ stdout: expect.stringContaining('Command executed successfully'),
101
+ stderr: expect.any(String),
102
+ });
103
+ expect(result).not.toHaveProperty('startTime');
104
+ expect(result).not.toHaveProperty('workFolder');
105
+ expect(result).not.toHaveProperty('prompt');
86
106
  expect(readFileSync(join(processDir, 'meta.json'), 'utf-8')).toContain('"status": "completed"');
87
107
  });
108
+ it('returns compact results by default and full results when verbose is true', async () => {
109
+ const root = mkdtempSync(join(tmpdir(), 'ai-cli-cli-service-'));
110
+ tempDirs.push(root);
111
+ const scriptPath = join(root, 'mock-claude-json');
112
+ writeFileSync(scriptPath, `#!/bin/bash
113
+ printf '%s\n' '{"type":"assistant","message":{"content":[{"type":"tool_use","id":"tool-1","name":"Read","input":{"file_path":"/tmp/demo.txt"}}]}}'
114
+ printf '%s\n' '{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"tool-1","content":[{"type":"text","text":"demo output"}]}]}}'
115
+ printf '%s\n' '{"type":"result","result":"Completed cli-process-service test"}'
116
+ printf '%s\n' '{"type":"system","session_id":"session-cli-1"}'
117
+ `);
118
+ chmodSync(scriptPath, 0o755);
119
+ const stateDir = join(root, 'state');
120
+ const workFolder = join(root, 'work');
121
+ mkdirSync(workFolder, { recursive: true });
122
+ const service = new CliProcessService({
123
+ stateDir,
124
+ cliPaths: {
125
+ claude: scriptPath,
126
+ codex: scriptPath,
127
+ gemini: scriptPath,
128
+ forge: scriptPath,
129
+ },
130
+ });
131
+ const runResult = await service.startProcess({
132
+ prompt: 'hello structured output',
133
+ cwd: workFolder,
134
+ });
135
+ const compactWait = await service.waitForProcesses([runResult.pid], 5);
136
+ expect(compactWait).toHaveLength(1);
137
+ expect(compactWait[0]).toMatchObject({
138
+ pid: runResult.pid,
139
+ agent: 'claude',
140
+ status: 'completed',
141
+ exitCode: null,
142
+ model: null,
143
+ session_id: 'session-cli-1',
144
+ agentOutput: {
145
+ message: 'Completed cli-process-service test',
146
+ session_id: 'session-cli-1',
147
+ },
148
+ });
149
+ expect(compactWait[0]).not.toHaveProperty('startTime');
150
+ expect(compactWait[0]).not.toHaveProperty('workFolder');
151
+ expect(compactWait[0]).not.toHaveProperty('prompt');
152
+ expect(compactWait[0].agentOutput).not.toHaveProperty('tools');
153
+ const compactResult = await service.getProcessResult(runResult.pid, false);
154
+ expect(compactResult).toMatchObject({
155
+ pid: runResult.pid,
156
+ agent: 'claude',
157
+ status: 'completed',
158
+ exitCode: null,
159
+ model: null,
160
+ session_id: 'session-cli-1',
161
+ agentOutput: {
162
+ message: 'Completed cli-process-service test',
163
+ session_id: 'session-cli-1',
164
+ },
165
+ });
166
+ expect(compactResult).not.toHaveProperty('startTime');
167
+ expect(compactResult).not.toHaveProperty('workFolder');
168
+ expect(compactResult).not.toHaveProperty('prompt');
169
+ expect(compactResult.agentOutput).not.toHaveProperty('tools');
170
+ const verboseWait = await service.waitForProcesses([runResult.pid], 5, true);
171
+ expect(verboseWait).toHaveLength(1);
172
+ expect(verboseWait[0]).toMatchObject({
173
+ pid: runResult.pid,
174
+ agent: 'claude',
175
+ status: 'completed',
176
+ exitCode: null,
177
+ model: null,
178
+ startTime: expect.any(String),
179
+ workFolder,
180
+ prompt: 'hello structured output',
181
+ session_id: 'session-cli-1',
182
+ agentOutput: {
183
+ message: 'Completed cli-process-service test',
184
+ session_id: 'session-cli-1',
185
+ tools: [
186
+ {
187
+ tool: 'Read',
188
+ input: { file_path: '/tmp/demo.txt' },
189
+ output: 'demo output',
190
+ },
191
+ ],
192
+ },
193
+ });
194
+ const verboseResult = await service.getProcessResult(runResult.pid, true);
195
+ expect(verboseResult).toMatchObject({
196
+ pid: runResult.pid,
197
+ agent: 'claude',
198
+ status: 'completed',
199
+ exitCode: null,
200
+ model: null,
201
+ startTime: expect.any(String),
202
+ workFolder,
203
+ prompt: 'hello structured output',
204
+ session_id: 'session-cli-1',
205
+ agentOutput: {
206
+ message: 'Completed cli-process-service test',
207
+ session_id: 'session-cli-1',
208
+ tools: [
209
+ {
210
+ tool: 'Read',
211
+ input: { file_path: '/tmp/demo.txt' },
212
+ output: 'demo output',
213
+ },
214
+ ],
215
+ },
216
+ });
217
+ });
88
218
  it('can terminate a tracked process', async () => {
89
219
  const root = mkdtempSync(join(tmpdir(), 'ai-cli-cli-service-'));
90
220
  tempDirs.push(root);
@@ -98,6 +228,7 @@ describe('CliProcessService', () => {
98
228
  claude: scriptPath,
99
229
  codex: scriptPath,
100
230
  gemini: scriptPath,
231
+ forge: scriptPath,
101
232
  },
102
233
  });
103
234
  const runResult = await service.startProcess({
@@ -130,6 +261,7 @@ describe('CliProcessService', () => {
130
261
  claude: '/bin/sh',
131
262
  codex: '/bin/sh',
132
263
  gemini: '/bin/sh',
264
+ forge: '/bin/sh',
133
265
  },
134
266
  });
135
267
  writeFileSync(join(processDir, 'meta.json'), JSON.stringify({
@@ -212,6 +344,7 @@ describe('CliProcessService', () => {
212
344
  claude: '/bin/sh',
213
345
  codex: '/bin/sh',
214
346
  gemini: '/bin/sh',
347
+ forge: '/bin/sh',
215
348
  },
216
349
  });
217
350
  const killSpy = vi.spyOn(globalThis.process, 'kill').mockImplementation((target, signal) => {
@@ -230,4 +363,46 @@ describe('CliProcessService', () => {
230
363
  expect(existsSync(failedDir)).toBe(false);
231
364
  killSpy.mockRestore();
232
365
  });
366
+ it('parses forge output from detached process logs', async () => {
367
+ const root = mkdtempSync(join(tmpdir(), 'ai-cli-cli-service-'));
368
+ tempDirs.push(root);
369
+ const stateDir = join(root, 'state');
370
+ const workFolder = join(root, 'forge-project');
371
+ mkdirSync(workFolder, { recursive: true });
372
+ const pid = 54321;
373
+ const processDir = join(stateDir, 'cwds', encodeCwd(realpathSync(workFolder)), String(pid));
374
+ mkdirSync(processDir, { recursive: true });
375
+ writeFileSync(join(processDir, 'stdout.log'), `● [21:09:01] Initialize forge-conv-1
376
+ Forge assistant reply
377
+ ● [21:09:08] Finished forge-conv-1
378
+ `);
379
+ writeFileSync(join(processDir, 'stderr.log'), '');
380
+ writeFileSync(join(processDir, 'meta.json'), JSON.stringify({
381
+ pid,
382
+ prompt: 'hello forge',
383
+ workFolder,
384
+ model: 'forge',
385
+ toolType: 'forge',
386
+ startTime: new Date().toISOString(),
387
+ stdoutPath: join(processDir, 'stdout.log'),
388
+ stderrPath: join(processDir, 'stderr.log'),
389
+ status: 'completed',
390
+ }));
391
+ const service = new CliProcessService({
392
+ stateDir,
393
+ cliPaths: {
394
+ claude: '/bin/sh',
395
+ codex: '/bin/sh',
396
+ gemini: '/bin/sh',
397
+ forge: '/bin/sh',
398
+ },
399
+ });
400
+ const result = await service.getProcessResult(pid, false);
401
+ expect(result.agent).toBe('forge');
402
+ expect(result.session_id).toBe('forge-conv-1');
403
+ expect(result.agentOutput).toEqual({
404
+ message: 'Forge assistant reply',
405
+ session_id: 'forge-conv-1',
406
+ });
407
+ });
233
408
  });
@@ -15,6 +15,7 @@ describe('cli-utils doctor status', () => {
15
15
  delete process.env.CLAUDE_CLI_NAME;
16
16
  delete process.env.CODEX_CLI_NAME;
17
17
  delete process.env.GEMINI_CLI_NAME;
18
+ delete process.env.FORGE_CLI_NAME;
18
19
  process.env.PATH = '/mock/bin:/usr/bin';
19
20
  });
20
21
  afterEach(() => {
@@ -36,6 +37,12 @@ describe('cli-utils doctor status', () => {
36
37
  available: true,
37
38
  lookup: 'path',
38
39
  });
40
+ expect(status.forge).toEqual({
41
+ configuredCommand: 'forge',
42
+ resolvedPath: null,
43
+ available: false,
44
+ lookup: 'path',
45
+ });
39
46
  });
40
47
  it('does not mark non-executable PATH entries as available', async () => {
41
48
  mockAccessSync.mockImplementation(() => {
@@ -49,6 +56,12 @@ describe('cli-utils doctor status', () => {
49
56
  available: false,
50
57
  lookup: 'path',
51
58
  });
59
+ expect(status.forge).toEqual({
60
+ configuredCommand: 'forge',
61
+ resolvedPath: null,
62
+ available: false,
63
+ lookup: 'path',
64
+ });
52
65
  });
53
66
  it('reports invalid relative env paths as doctor errors', async () => {
54
67
  process.env.CLAUDE_CLI_NAME = './relative/claude';
@@ -106,4 +119,22 @@ describe('cli-utils doctor status', () => {
106
119
  lookup: 'env',
107
120
  });
108
121
  });
122
+ it('supports forge lookup via FORGE_CLI_NAME', async () => {
123
+ process.env.FORGE_CLI_NAME = 'forge-custom';
124
+ mockAccessSync.mockImplementation((filePath) => {
125
+ if (filePath === '/mock/bin/forge-custom') {
126
+ return undefined;
127
+ }
128
+ throw new Error('not executable');
129
+ });
130
+ const { getCliDoctorStatus, findForgeCli } = await import('../cli-utils.js');
131
+ const status = getCliDoctorStatus();
132
+ expect(status.forge).toEqual({
133
+ configuredCommand: 'forge-custom',
134
+ resolvedPath: '/mock/bin/forge-custom',
135
+ available: true,
136
+ lookup: 'env',
137
+ });
138
+ expect(findForgeCli()).toBe('forge-custom');
139
+ });
109
140
  });
@@ -1,5 +1,5 @@
1
1
  import { afterAll, afterEach, beforeEach, describe, expect, it } from 'vitest';
2
- import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { cleanupSharedMock, getSharedMock } from './utils/persistent-mock.js';
@@ -16,6 +16,49 @@ function expectProcessSummaryShape(processInfo) {
16
16
  status: expect.any(String),
17
17
  });
18
18
  }
19
+ function createForgeMockScript(dir, argsLogPath) {
20
+ const scriptPath = join(dir, 'mock-forge');
21
+ writeFileSync(scriptPath, `#!/bin/bash
22
+ set -euo pipefail
23
+
24
+ log_file="${argsLogPath}"
25
+ prompt=""
26
+ conversation_id=""
27
+
28
+ printf '%s\\n' "$*" >> "$log_file"
29
+
30
+ while [[ $# -gt 0 ]]; do
31
+ case "$1" in
32
+ -C)
33
+ shift 2
34
+ ;;
35
+ -p)
36
+ prompt="$2"
37
+ shift 2
38
+ ;;
39
+ --conversation-id)
40
+ conversation_id="$2"
41
+ shift 2
42
+ ;;
43
+ *)
44
+ shift
45
+ ;;
46
+ esac
47
+ done
48
+
49
+ if [[ -n "$conversation_id" ]]; then
50
+ printf '● [21:09:33] Continue %s\\n' "$conversation_id"
51
+ printf 'Resumed: %s\\n' "$prompt"
52
+ printf '● [21:09:37] Finished %s\\n' "$conversation_id"
53
+ else
54
+ printf '● [21:09:01] Initialize forge-session-1\\n'
55
+ printf 'Initial: %s\\n' "$prompt"
56
+ printf '● [21:09:08] Finished forge-session-1\\n'
57
+ fi
58
+ `);
59
+ chmodSync(scriptPath, 0o755);
60
+ return scriptPath;
61
+ }
19
62
  describe('MCP Contract Tests', () => {
20
63
  let client;
21
64
  let testDir;
@@ -64,6 +107,7 @@ describe('MCP Contract Tests', () => {
64
107
  expect(Object.keys(waitTool.inputSchema.properties).sort()).toEqual([
65
108
  'pids',
66
109
  'timeout',
110
+ 'verbose',
67
111
  ]);
68
112
  });
69
113
  it('preserves the stdio MCP smoke flow and response shapes', async () => {
@@ -91,20 +135,30 @@ describe('MCP Contract Tests', () => {
91
135
  pid: runData.pid,
92
136
  agent: 'claude',
93
137
  status: expect.any(String),
94
- startTime: expect.any(String),
95
- workFolder: testDir,
96
- prompt: 'create a file called contract.txt with content "hello"',
97
138
  model: 'haiku',
98
139
  stdout: expect.any(String),
99
140
  stderr: expect.any(String),
100
141
  });
142
+ expect(getResultData).toHaveProperty('exitCode');
143
+ expect(getResultData).not.toHaveProperty('startTime');
144
+ expect(getResultData).not.toHaveProperty('workFolder');
145
+ expect(getResultData).not.toHaveProperty('prompt');
101
146
  const waitResponse = await client.callTool('wait', { pids: [runData.pid], timeout: 5 });
102
147
  const waitData = parseToolJson(waitResponse);
103
148
  expect(Array.isArray(waitData)).toBe(true);
104
149
  expect(waitData).toHaveLength(1);
105
- expect(waitData[0].pid).toBe(runData.pid);
106
- expect(waitData[0].agent).toBe('claude');
107
- expect(waitData[0].status).toBe('completed');
150
+ expect(waitData[0]).toMatchObject({
151
+ pid: runData.pid,
152
+ agent: 'claude',
153
+ status: 'completed',
154
+ exitCode: 0,
155
+ model: 'haiku',
156
+ stdout: expect.any(String),
157
+ stderr: expect.any(String),
158
+ });
159
+ expect(waitData[0]).not.toHaveProperty('startTime');
160
+ expect(waitData[0]).not.toHaveProperty('workFolder');
161
+ expect(waitData[0]).not.toHaveProperty('prompt');
108
162
  const cleanupResponse = await client.callTool('cleanup_processes', {});
109
163
  const cleanupData = parseToolJson(cleanupResponse);
110
164
  expect(cleanupData).toEqual({
@@ -114,12 +168,13 @@ describe('MCP Contract Tests', () => {
114
168
  });
115
169
  expect(cleanupData.removedPids).toContain(runData.pid);
116
170
  });
117
- it('accepts prompt_file and keeps the run response shape stable', async () => {
171
+ it('preserves successful prompt_file execution through the MCP process path', async () => {
118
172
  const promptFile = join(testDir, 'prompt.txt');
119
- writeFileSync(promptFile, 'create a file called from-file.txt');
173
+ writeFileSync(promptFile, 'Create a file from prompt_file');
120
174
  const runResponse = await client.callTool('run', {
121
175
  prompt_file: promptFile,
122
176
  workFolder: testDir,
177
+ model: 'haiku',
123
178
  });
124
179
  const runData = parseToolJson(runResponse);
125
180
  expect(runData).toEqual({
@@ -128,6 +183,229 @@ describe('MCP Contract Tests', () => {
128
183
  agent: 'claude',
129
184
  message: expect.any(String),
130
185
  });
186
+ const waitResponse = await client.callTool('wait', { pids: [runData.pid], timeout: 5 });
187
+ const waitData = parseToolJson(waitResponse);
188
+ expect(waitData).toHaveLength(1);
189
+ expect(waitData[0]).toMatchObject({
190
+ pid: runData.pid,
191
+ agent: 'claude',
192
+ status: 'completed',
193
+ exitCode: 0,
194
+ model: 'haiku',
195
+ stdout: expect.stringContaining('Created file successfully'),
196
+ stderr: '',
197
+ });
198
+ expect(waitData[0]).not.toHaveProperty('prompt');
199
+ expect(waitData[0]).not.toHaveProperty('workFolder');
200
+ expect(waitData[0]).not.toHaveProperty('startTime');
201
+ });
202
+ it('returns compact results by default and full results when verbose is true for parsed output', async () => {
203
+ await client.disconnect();
204
+ const verboseMockPath = join(testDir, 'verbose-claude');
205
+ writeFileSync(verboseMockPath, `#!/bin/bash
206
+ printf '%s\n' '{"type":"assistant","message":{"content":[{"type":"tool_use","id":"tool-1","name":"Read","input":{"file_path":"/tmp/demo.txt"}}]}}'
207
+ printf '%s\n' '{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"tool-1","content":[{"type":"text","text":"demo output"}]}]}}'
208
+ printf '%s\n' '{"type":"result","result":"Completed contract verbose test"}'
209
+ printf '%s\n' '{"type":"system","session_id":"session-verbose-1"}'
210
+ `);
211
+ chmodSync(verboseMockPath, 0o755);
212
+ client = createTestClient({ claudeCliName: verboseMockPath, debug: false });
213
+ await client.connect();
214
+ const runResponse = await client.callTool('run', {
215
+ prompt: 'verbose-shape-test',
216
+ workFolder: testDir,
217
+ });
218
+ const runData = parseToolJson(runResponse);
219
+ const completedWait = parseToolJson(await client.callTool('wait', { pids: [runData.pid], timeout: 5 }));
220
+ expect(completedWait).toHaveLength(1);
221
+ expect(completedWait[0].status).toBe('completed');
222
+ const compactResult = parseToolJson(await client.callTool('get_result', { pid: runData.pid }));
223
+ expect(compactResult).toMatchObject({
224
+ pid: runData.pid,
225
+ agent: 'claude',
226
+ status: 'completed',
227
+ exitCode: 0,
228
+ model: null,
229
+ session_id: 'session-verbose-1',
230
+ agentOutput: {
231
+ message: 'Completed contract verbose test',
232
+ session_id: 'session-verbose-1',
233
+ },
234
+ });
235
+ expect(compactResult).not.toHaveProperty('startTime');
236
+ expect(compactResult).not.toHaveProperty('workFolder');
237
+ expect(compactResult).not.toHaveProperty('prompt');
238
+ expect(compactResult.agentOutput).not.toHaveProperty('tools');
239
+ const verboseResult = parseToolJson(await client.callTool('get_result', { pid: runData.pid, verbose: true }));
240
+ expect(verboseResult).toMatchObject({
241
+ pid: runData.pid,
242
+ agent: 'claude',
243
+ status: 'completed',
244
+ exitCode: 0,
245
+ model: null,
246
+ startTime: expect.any(String),
247
+ workFolder: testDir,
248
+ prompt: 'verbose-shape-test',
249
+ session_id: 'session-verbose-1',
250
+ agentOutput: {
251
+ message: 'Completed contract verbose test',
252
+ session_id: 'session-verbose-1',
253
+ tools: [
254
+ {
255
+ tool: 'Read',
256
+ input: { file_path: '/tmp/demo.txt' },
257
+ output: 'demo output',
258
+ },
259
+ ],
260
+ },
261
+ });
262
+ const compactWait = parseToolJson(await client.callTool('wait', { pids: [runData.pid], timeout: 5 }));
263
+ expect(compactWait).toHaveLength(1);
264
+ expect(compactWait[0]).toMatchObject({
265
+ pid: runData.pid,
266
+ agent: 'claude',
267
+ status: 'completed',
268
+ exitCode: 0,
269
+ model: null,
270
+ session_id: 'session-verbose-1',
271
+ agentOutput: {
272
+ message: 'Completed contract verbose test',
273
+ session_id: 'session-verbose-1',
274
+ },
275
+ });
276
+ expect(compactWait[0]).not.toHaveProperty('startTime');
277
+ expect(compactWait[0]).not.toHaveProperty('workFolder');
278
+ expect(compactWait[0]).not.toHaveProperty('prompt');
279
+ expect(compactWait[0].agentOutput).not.toHaveProperty('tools');
280
+ const verboseWait = parseToolJson(await client.callTool('wait', { pids: [runData.pid], timeout: 5, verbose: true }));
281
+ expect(verboseWait).toHaveLength(1);
282
+ expect(verboseWait[0]).toMatchObject({
283
+ pid: runData.pid,
284
+ agent: 'claude',
285
+ status: 'completed',
286
+ exitCode: 0,
287
+ model: null,
288
+ startTime: expect.any(String),
289
+ workFolder: testDir,
290
+ prompt: 'verbose-shape-test',
291
+ session_id: 'session-verbose-1',
292
+ agentOutput: {
293
+ message: 'Completed contract verbose test',
294
+ session_id: 'session-verbose-1',
295
+ tools: [
296
+ {
297
+ tool: 'Read',
298
+ input: { file_path: '/tmp/demo.txt' },
299
+ output: 'demo output',
300
+ },
301
+ ],
302
+ },
303
+ });
304
+ });
305
+ it('covers forge end-to-end through the MCP process path', async () => {
306
+ await client.disconnect();
307
+ const forgeArgsLogPath = join(testDir, 'forge-args.log');
308
+ const forgeMockPath = createForgeMockScript(testDir, forgeArgsLogPath);
309
+ client = createTestClient({
310
+ debug: false,
311
+ env: {
312
+ FORGE_CLI_NAME: forgeMockPath,
313
+ },
314
+ });
315
+ await client.connect();
316
+ const initialRunResponse = await client.callTool('run', {
317
+ prompt: 'forge-initial-prompt',
318
+ workFolder: testDir,
319
+ model: 'forge',
320
+ });
321
+ const initialRunData = parseToolJson(initialRunResponse);
322
+ expect(initialRunData).toEqual({
323
+ pid: expect.any(Number),
324
+ status: 'started',
325
+ agent: 'forge',
326
+ message: expect.any(String),
327
+ });
328
+ const initialWaitResponse = await client.callTool('wait', { pids: [initialRunData.pid], timeout: 5 });
329
+ const initialWaitData = parseToolJson(initialWaitResponse);
330
+ expect(initialWaitData).toHaveLength(1);
331
+ expect(initialWaitData[0]).toMatchObject({
332
+ pid: initialRunData.pid,
333
+ agent: 'forge',
334
+ status: 'completed',
335
+ session_id: 'forge-session-1',
336
+ agentOutput: {
337
+ message: 'Initial: forge-initial-prompt',
338
+ session_id: 'forge-session-1',
339
+ },
340
+ });
341
+ const initialResultResponse = await client.callTool('get_result', { pid: initialRunData.pid });
342
+ const initialResultData = parseToolJson(initialResultResponse);
343
+ expect(initialResultData).toMatchObject({
344
+ pid: initialRunData.pid,
345
+ agent: 'forge',
346
+ status: 'completed',
347
+ session_id: 'forge-session-1',
348
+ agentOutput: {
349
+ message: 'Initial: forge-initial-prompt',
350
+ session_id: 'forge-session-1',
351
+ },
352
+ });
353
+ const resumedRunResponse = await client.callTool('run', {
354
+ prompt: 'forge-resume-prompt',
355
+ workFolder: testDir,
356
+ model: 'forge',
357
+ session_id: 'forge-session-1',
358
+ });
359
+ const resumedRunData = parseToolJson(resumedRunResponse);
360
+ expect(resumedRunData).toEqual({
361
+ pid: expect.any(Number),
362
+ status: 'started',
363
+ agent: 'forge',
364
+ message: expect.any(String),
365
+ });
366
+ const resumedWaitResponse = await client.callTool('wait', { pids: [resumedRunData.pid], timeout: 5 });
367
+ const resumedWaitData = parseToolJson(resumedWaitResponse);
368
+ expect(resumedWaitData).toHaveLength(1);
369
+ expect(resumedWaitData[0]).toMatchObject({
370
+ pid: resumedRunData.pid,
371
+ agent: 'forge',
372
+ status: 'completed',
373
+ session_id: 'forge-session-1',
374
+ agentOutput: {
375
+ message: 'Resumed: forge-resume-prompt',
376
+ session_id: 'forge-session-1',
377
+ },
378
+ });
379
+ const resumedResultResponse = await client.callTool('get_result', { pid: resumedRunData.pid });
380
+ const resumedResultData = parseToolJson(resumedResultResponse);
381
+ expect(resumedResultData).toMatchObject({
382
+ pid: resumedRunData.pid,
383
+ agent: 'forge',
384
+ status: 'completed',
385
+ session_id: 'forge-session-1',
386
+ agentOutput: {
387
+ message: 'Resumed: forge-resume-prompt',
388
+ session_id: 'forge-session-1',
389
+ },
390
+ });
391
+ const forgeInvocations = readFileSync(forgeArgsLogPath, 'utf-8').trim().split('\n');
392
+ expect(forgeInvocations).toHaveLength(2);
393
+ expect(forgeInvocations[0]).toContain(`-C ${testDir}`);
394
+ expect(forgeInvocations[0]).toContain('-p forge-initial-prompt');
395
+ expect(forgeInvocations[0]).not.toContain('--model');
396
+ expect(forgeInvocations[0]).not.toContain('--agent');
397
+ expect(forgeInvocations[0]).not.toContain('--conversation-id');
398
+ expect(forgeInvocations[1]).toContain(`-C ${testDir}`);
399
+ expect(forgeInvocations[1]).toContain('--conversation-id forge-session-1');
400
+ expect(forgeInvocations[1]).toContain('-p forge-resume-prompt');
401
+ expect(forgeInvocations[1]).not.toContain('--model');
402
+ expect(forgeInvocations[1]).not.toContain('--agent');
403
+ await expect(client.callTool('run', {
404
+ prompt: 'forge-invalid-reasoning',
405
+ workFolder: testDir,
406
+ model: 'forge',
407
+ reasoning_effort: 'high',
408
+ })).rejects.toThrow(/reasoning_effort is not supported for forge/i);
131
409
  });
132
410
  it('keeps key invalid-input errors stable', async () => {
133
411
  await expect(client.callTool('run', {
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { parseCodexOutput, parseClaudeOutput } from '../parsers.js';
2
+ import { parseCodexOutput, parseClaudeOutput, parseForgeOutput } from '../parsers.js';
3
3
  describe('parseCodexOutput', () => {
4
4
  it('should parse basic Codex output with message and session_id', () => {
5
5
  const output = `
@@ -96,3 +96,39 @@ INVALID_LINE
96
96
  expect(result.message).toBe("Success");
97
97
  });
98
98
  });
99
+ describe('parseForgeOutput', () => {
100
+ it('should parse initialized forge output with a conversation id', () => {
101
+ const output = `● [21:09:01] Initialize 123e4567-e89b-12d3-a456-426614174000
102
+ Hello from Forge
103
+ ● [21:09:08] Finished 123e4567-e89b-12d3-a456-426614174000
104
+ `;
105
+ expect(parseForgeOutput(output)).toEqual({
106
+ message: 'Hello from Forge',
107
+ session_id: '123e4567-e89b-12d3-a456-426614174000',
108
+ });
109
+ });
110
+ it('should parse resumed forge output with multiline assistant content', () => {
111
+ const output = `● [21:09:33] Continue conv-123
112
+ Line one
113
+
114
+ Line three
115
+ ● [21:09:37] Finished conv-123
116
+ `;
117
+ expect(parseForgeOutput(output)).toEqual({
118
+ message: 'Line one\n\nLine three',
119
+ session_id: 'conv-123',
120
+ });
121
+ });
122
+ it('should return the current message while forge output is still in progress', () => {
123
+ const output = `● [21:09:33] Continue conv-456
124
+ Partial answer
125
+ still streaming`;
126
+ expect(parseForgeOutput(output)).toEqual({
127
+ message: 'Partial answer\nstill streaming',
128
+ session_id: 'conv-456',
129
+ });
130
+ });
131
+ it('should return null for unrelated forge output', () => {
132
+ expect(parseForgeOutput('plain text')).toBeNull();
133
+ });
134
+ });