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.
- package/.github/workflows/publish.yml +25 -0
- package/CHANGELOG.md +20 -0
- package/README.ja.md +20 -5
- package/README.md +20 -6
- package/dist/__tests__/app-cli.test.js +34 -2
- package/dist/__tests__/cli-bin-smoke.test.js +4 -0
- package/dist/__tests__/cli-builder.test.js +37 -0
- package/dist/__tests__/cli-process-service.test.js +180 -5
- package/dist/__tests__/cli-utils.test.js +31 -0
- package/dist/__tests__/mcp-contract.test.js +287 -9
- package/dist/__tests__/parsers.test.js +37 -1
- package/dist/__tests__/process-management.test.js +2 -1
- package/dist/app/cli.js +8 -6
- package/dist/app/mcp.js +16 -8
- package/dist/cli-builder.js +14 -0
- package/dist/cli-parse.js +8 -5
- package/dist/cli-process-service.js +13 -23
- package/dist/cli-utils.js +17 -0
- package/dist/cli.js +4 -3
- package/dist/model-catalog.js +4 -1
- package/dist/parsers.js +55 -0
- package/dist/process-result.js +51 -0
- package/dist/process-service.js +11 -22
- package/dist/server.js +1 -1
- package/package.json +2 -2
- package/server.json +1 -1
- package/src/__tests__/app-cli.test.ts +43 -1
- package/src/__tests__/cli-bin-smoke.test.ts +4 -0
- package/src/__tests__/cli-builder.test.ts +47 -0
- package/src/__tests__/cli-process-service.test.ts +200 -5
- package/src/__tests__/cli-utils.test.ts +34 -0
- package/src/__tests__/mcp-contract.test.ts +325 -9
- package/src/__tests__/parsers.test.ts +44 -1
- package/src/__tests__/process-management.test.ts +2 -1
- package/src/app/cli.ts +9 -7
- package/src/app/mcp.ts +17 -8
- package/src/cli-builder.ts +18 -3
- package/src/cli-parse.ts +8 -5
- package/src/cli-process-service.ts +12 -23
- package/src/cli-utils.ts +21 -1
- package/src/cli.ts +4 -3
- package/src/model-catalog.ts +5 -1
- package/src/parsers.ts +61 -0
- package/src/process-result.ts +79 -0
- package/src/process-service.ts +11 -24
- 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]
|
|
75
|
-
|
|
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
|
|
84
|
-
|
|
85
|
-
|
|
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]
|
|
106
|
-
|
|
107
|
-
|
|
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('
|
|
171
|
+
it('preserves successful prompt_file execution through the MCP process path', async () => {
|
|
118
172
|
const promptFile = join(testDir, 'prompt.txt');
|
|
119
|
-
writeFileSync(promptFile, '
|
|
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
|
+
});
|