@ai-devkit/agent-manager 0.25.0 → 0.26.1
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/README.md +14 -0
- package/dist/__tests__/adapters/CodexAdapter.test.js +249 -0
- package/dist/__tests__/adapters/CodexAdapter.test.js.map +1 -1
- package/dist/__tests__/print/ClaudeCliProbe.test.js +53 -0
- package/dist/__tests__/print/ClaudeCliProbe.test.js.map +1 -0
- package/dist/__tests__/print/ClaudePrintAgent.integration.test.js +69 -0
- package/dist/__tests__/print/ClaudePrintAgent.integration.test.js.map +1 -0
- package/dist/__tests__/print/ClaudePrintAgentService.test.js +108 -0
- package/dist/__tests__/print/ClaudePrintAgentService.test.js.map +1 -0
- package/dist/__tests__/print/ClaudePrintRunner.test.js +187 -0
- package/dist/__tests__/print/ClaudePrintRunner.test.js.map +1 -0
- package/dist/__tests__/print/PrintAgent.test.js +17 -0
- package/dist/__tests__/print/PrintAgent.test.js.map +1 -0
- package/dist/__tests__/print/PrintAgentStore.test.js +307 -0
- package/dist/__tests__/print/PrintAgentStore.test.js.map +1 -0
- package/dist/__tests__/terminal/TmuxManager.test.js +9 -0
- package/dist/__tests__/terminal/TmuxManager.test.js.map +1 -1
- package/dist/adapters/CodexAdapter.d.ts +9 -1
- package/dist/adapters/CodexAdapter.d.ts.map +1 -1
- package/dist/adapters/CodexAdapter.js +106 -24
- package/dist/adapters/CodexAdapter.js.map +1 -1
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/dist/print/ClaudeCliProbe.d.ts +20 -0
- package/dist/print/ClaudeCliProbe.d.ts.map +1 -0
- package/dist/print/ClaudeCliProbe.js +57 -0
- package/dist/print/ClaudeCliProbe.js.map +1 -0
- package/dist/print/ClaudePrintAgentService.d.ts +44 -0
- package/dist/print/ClaudePrintAgentService.d.ts.map +1 -0
- package/dist/print/ClaudePrintAgentService.js +66 -0
- package/dist/print/ClaudePrintAgentService.js.map +1 -0
- package/dist/print/ClaudePrintRunner.d.ts +32 -0
- package/dist/print/ClaudePrintRunner.d.ts.map +1 -0
- package/dist/print/ClaudePrintRunner.js +128 -0
- package/dist/print/ClaudePrintRunner.js.map +1 -0
- package/dist/print/PrintAgent.d.ts +57 -0
- package/dist/print/PrintAgent.d.ts.map +1 -0
- package/dist/print/PrintAgent.js +42 -0
- package/dist/print/PrintAgent.js.map +1 -0
- package/dist/print/PrintAgentStore.d.ts +69 -0
- package/dist/print/PrintAgentStore.d.ts.map +1 -0
- package/dist/print/PrintAgentStore.js +484 -0
- package/dist/print/PrintAgentStore.js.map +1 -0
- package/dist/terminal/TmuxManager.d.ts +2 -2
- package/dist/terminal/TmuxManager.d.ts.map +1 -1
- package/dist/terminal/TmuxManager.js +5 -7
- package/dist/terminal/TmuxManager.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/adapters/CodexAdapter.test.ts +155 -0
- package/src/__tests__/fixtures/fake-claude.cjs +24 -0
- package/src/__tests__/print/ClaudeCliProbe.test.ts +32 -0
- package/src/__tests__/print/ClaudePrintAgent.integration.test.ts +56 -0
- package/src/__tests__/print/ClaudePrintAgentService.test.ts +46 -0
- package/src/__tests__/print/ClaudePrintRunner.test.ts +105 -0
- package/src/__tests__/print/PrintAgent.test.ts +21 -0
- package/src/__tests__/print/PrintAgentStore.test.ts +192 -0
- package/src/__tests__/terminal/TmuxManager.test.ts +10 -0
- package/src/adapters/CodexAdapter.ts +147 -27
- package/src/index.ts +39 -0
- package/src/print/ClaudeCliProbe.ts +58 -0
- package/src/print/ClaudePrintAgentService.ts +94 -0
- package/src/print/ClaudePrintRunner.ts +139 -0
- package/src/print/PrintAgent.ts +86 -0
- package/src/print/PrintAgentStore.ts +503 -0
- package/src/terminal/TmuxManager.ts +5 -7
package/package.json
CHANGED
|
@@ -1133,6 +1133,52 @@ describe('CodexAdapter', () => {
|
|
|
1133
1133
|
expect(session.summary).toBe('Last message');
|
|
1134
1134
|
});
|
|
1135
1135
|
|
|
1136
|
+
it('should extract summary from current Codex response_item messages', () => {
|
|
1137
|
+
const parseSession = (adapter as any).parseSession.bind(adapter);
|
|
1138
|
+
const filePath = path.join(tmpDir, 'response-item-summary.jsonl');
|
|
1139
|
+
fs.writeFileSync(filePath, [
|
|
1140
|
+
JSON.stringify({ type: 'session_meta', payload: { id: 'sess-ri', timestamp: '2026-03-18T15:00:00Z', cwd: '/repo' } }),
|
|
1141
|
+
JSON.stringify({
|
|
1142
|
+
type: 'response_item',
|
|
1143
|
+
timestamp: '2026-03-18T15:01:00Z',
|
|
1144
|
+
payload: {
|
|
1145
|
+
type: 'message',
|
|
1146
|
+
role: 'assistant',
|
|
1147
|
+
content: [{ type: 'output_text', text: 'Parsed from current schema' }],
|
|
1148
|
+
},
|
|
1149
|
+
}),
|
|
1150
|
+
].join('\n'));
|
|
1151
|
+
|
|
1152
|
+
const session = parseSession(undefined, filePath);
|
|
1153
|
+
expect(session.summary).toBe('Parsed from current schema');
|
|
1154
|
+
expect(session.lastPayloadType).toBe('agent_message');
|
|
1155
|
+
});
|
|
1156
|
+
|
|
1157
|
+
it('should treat completed Codex AgentMessage events as waiting for list status', () => {
|
|
1158
|
+
const parseSession = (adapter as any).parseSession.bind(adapter);
|
|
1159
|
+
const determineStatus = (adapter as any).determineStatus.bind(adapter);
|
|
1160
|
+
const filePath = path.join(tmpDir, 'agent-message-status.jsonl');
|
|
1161
|
+
fs.writeFileSync(filePath, [
|
|
1162
|
+
JSON.stringify({ type: 'session_meta', payload: { id: 'sess-am', timestamp: '2026-03-18T15:00:00Z', cwd: '/repo' } }),
|
|
1163
|
+
JSON.stringify({
|
|
1164
|
+
type: 'event_msg',
|
|
1165
|
+
timestamp: new Date().toISOString(),
|
|
1166
|
+
payload: {
|
|
1167
|
+
type: 'item_completed',
|
|
1168
|
+
item: {
|
|
1169
|
+
type: 'AgentMessage',
|
|
1170
|
+
content: [{ type: 'Text', text: 'Waiting for the user now' }],
|
|
1171
|
+
},
|
|
1172
|
+
},
|
|
1173
|
+
}),
|
|
1174
|
+
].join('\n'));
|
|
1175
|
+
|
|
1176
|
+
const session = parseSession(undefined, filePath);
|
|
1177
|
+
expect(session.summary).toBe('Waiting for the user now');
|
|
1178
|
+
expect(session.lastPayloadType).toBe('agent_message');
|
|
1179
|
+
expect(determineStatus(session)).toBe(AgentStatus.WAITING);
|
|
1180
|
+
});
|
|
1181
|
+
|
|
1136
1182
|
it('should handle malformed JSON lines gracefully', () => {
|
|
1137
1183
|
const parseSession = (adapter as any).parseSession.bind(adapter);
|
|
1138
1184
|
const filePath = path.join(tmpDir, 'malformed.jsonl');
|
|
@@ -1212,6 +1258,115 @@ describe('CodexAdapter', () => {
|
|
|
1212
1258
|
expect(messages[1]).toEqual({ role: 'assistant', content: 'I found the issue', timestamp: '2026-03-27T10:00:05Z' });
|
|
1213
1259
|
});
|
|
1214
1260
|
|
|
1261
|
+
it('should parse Codex response_item message records', () => {
|
|
1262
|
+
const filePath = writeJsonl([
|
|
1263
|
+
{ type: 'session_meta', payload: { id: 'sess-1', cwd: '/repo', timestamp: '2026-03-27T10:00:00Z' } },
|
|
1264
|
+
{
|
|
1265
|
+
type: 'response_item',
|
|
1266
|
+
timestamp: '2026-03-27T10:00:01Z',
|
|
1267
|
+
payload: {
|
|
1268
|
+
type: 'message',
|
|
1269
|
+
role: 'user',
|
|
1270
|
+
content: [{ type: 'input_text', text: 'Fix the bug' }],
|
|
1271
|
+
},
|
|
1272
|
+
},
|
|
1273
|
+
{
|
|
1274
|
+
type: 'response_item',
|
|
1275
|
+
timestamp: '2026-03-27T10:00:05Z',
|
|
1276
|
+
payload: {
|
|
1277
|
+
type: 'message',
|
|
1278
|
+
role: 'assistant',
|
|
1279
|
+
content: [{ type: 'output_text', text: 'I found the issue' }],
|
|
1280
|
+
},
|
|
1281
|
+
},
|
|
1282
|
+
]);
|
|
1283
|
+
|
|
1284
|
+
const messages = adapter.getConversation(filePath);
|
|
1285
|
+
expect(messages).toHaveLength(2);
|
|
1286
|
+
expect(messages[0]).toEqual({ role: 'user', content: 'Fix the bug', timestamp: '2026-03-27T10:00:01Z' });
|
|
1287
|
+
expect(messages[1]).toEqual({ role: 'assistant', content: 'I found the issue', timestamp: '2026-03-27T10:00:05Z' });
|
|
1288
|
+
});
|
|
1289
|
+
|
|
1290
|
+
it('should parse completed Codex AgentMessage event records', () => {
|
|
1291
|
+
const filePath = writeJsonl([
|
|
1292
|
+
{ type: 'session_meta', payload: { id: 'sess-1', cwd: '/repo', timestamp: '2026-03-27T10:00:00Z' } },
|
|
1293
|
+
{
|
|
1294
|
+
type: 'event_msg',
|
|
1295
|
+
timestamp: '2026-03-27T10:00:05Z',
|
|
1296
|
+
payload: {
|
|
1297
|
+
type: 'item_completed',
|
|
1298
|
+
item: {
|
|
1299
|
+
type: 'AgentMessage',
|
|
1300
|
+
content: [{ type: 'Text', text: 'I found the issue' }],
|
|
1301
|
+
},
|
|
1302
|
+
},
|
|
1303
|
+
},
|
|
1304
|
+
]);
|
|
1305
|
+
|
|
1306
|
+
const messages = adapter.getConversation(filePath);
|
|
1307
|
+
expect(messages).toEqual([
|
|
1308
|
+
{ role: 'assistant', content: 'I found the issue', timestamp: '2026-03-27T10:00:05Z' },
|
|
1309
|
+
]);
|
|
1310
|
+
});
|
|
1311
|
+
|
|
1312
|
+
it('should not duplicate mirrored current Codex message records', () => {
|
|
1313
|
+
const filePath = writeJsonl([
|
|
1314
|
+
{ type: 'session_meta', payload: { id: 'sess-1', cwd: '/repo', timestamp: '2026-03-27T10:00:00Z' } },
|
|
1315
|
+
{
|
|
1316
|
+
type: 'response_item',
|
|
1317
|
+
timestamp: '2026-03-27T10:00:01Z',
|
|
1318
|
+
payload: {
|
|
1319
|
+
type: 'message',
|
|
1320
|
+
role: 'user',
|
|
1321
|
+
content: [{ type: 'input_text', text: 'Fix the bug' }],
|
|
1322
|
+
internal_chat_message_metadata_passthrough: { turn_id: 'turn-1' },
|
|
1323
|
+
},
|
|
1324
|
+
},
|
|
1325
|
+
{
|
|
1326
|
+
type: 'event_msg',
|
|
1327
|
+
timestamp: '2026-03-27T10:00:01.001Z',
|
|
1328
|
+
payload: {
|
|
1329
|
+
type: 'item_completed',
|
|
1330
|
+
turn_id: 'turn-1',
|
|
1331
|
+
item: {
|
|
1332
|
+
type: 'UserMessage',
|
|
1333
|
+
content: [{ type: 'text', text: 'Fix the bug' }],
|
|
1334
|
+
},
|
|
1335
|
+
},
|
|
1336
|
+
},
|
|
1337
|
+
{
|
|
1338
|
+
type: 'event_msg',
|
|
1339
|
+
timestamp: '2026-03-27T10:00:05Z',
|
|
1340
|
+
payload: {
|
|
1341
|
+
type: 'item_completed',
|
|
1342
|
+
turn_id: 'turn-1',
|
|
1343
|
+
item: {
|
|
1344
|
+
type: 'AgentMessage',
|
|
1345
|
+
id: 'msg-1',
|
|
1346
|
+
content: [{ type: 'Text', text: 'I found the issue' }],
|
|
1347
|
+
},
|
|
1348
|
+
},
|
|
1349
|
+
},
|
|
1350
|
+
{
|
|
1351
|
+
type: 'response_item',
|
|
1352
|
+
timestamp: '2026-03-27T10:00:05.005Z',
|
|
1353
|
+
payload: {
|
|
1354
|
+
type: 'message',
|
|
1355
|
+
id: 'msg-1',
|
|
1356
|
+
role: 'assistant',
|
|
1357
|
+
content: [{ type: 'output_text', text: 'I found the issue' }],
|
|
1358
|
+
internal_chat_message_metadata_passthrough: { turn_id: 'turn-1' },
|
|
1359
|
+
},
|
|
1360
|
+
},
|
|
1361
|
+
]);
|
|
1362
|
+
|
|
1363
|
+
const messages = adapter.getConversation(filePath);
|
|
1364
|
+
expect(messages).toEqual([
|
|
1365
|
+
{ role: 'user', content: 'Fix the bug', timestamp: '2026-03-27T10:00:01Z' },
|
|
1366
|
+
{ role: 'assistant', content: 'I found the issue', timestamp: '2026-03-27T10:00:05.005Z' },
|
|
1367
|
+
]);
|
|
1368
|
+
});
|
|
1369
|
+
|
|
1215
1370
|
it('should skip session_meta entry', () => {
|
|
1216
1371
|
const filePath = writeJsonl([
|
|
1217
1372
|
{ type: 'session_meta', payload: { id: 'sess-1', cwd: '/repo', timestamp: '2026-03-27T10:00:00Z' } },
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
const fs = require('node:fs');
|
|
3
|
+
|
|
4
|
+
const args = process.argv.slice(2);
|
|
5
|
+
if (args[0] === '--version') {
|
|
6
|
+
process.stdout.write('fake-claude 2.1.220\n');
|
|
7
|
+
process.exit(0);
|
|
8
|
+
}
|
|
9
|
+
if (args[0] === '--help') {
|
|
10
|
+
process.stdout.write('--print -p --session-id --resume --output-format stream-json --verbose\n');
|
|
11
|
+
process.exit(0);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
let prompt = '';
|
|
15
|
+
process.stdin.setEncoding('utf8');
|
|
16
|
+
process.stdin.on('data', (chunk) => { prompt += chunk; });
|
|
17
|
+
process.stdin.on('end', () => {
|
|
18
|
+
const flag = args.includes('--session-id') ? '--session-id' : '--resume';
|
|
19
|
+
const sessionId = args[args.indexOf(flag) + 1];
|
|
20
|
+
const capture = process.env.AI_DEVKIT_FAKE_CLAUDE_CAPTURE;
|
|
21
|
+
if (capture) fs.appendFileSync(capture, `${JSON.stringify({ args, prompt, cwd: process.cwd() })}\n`);
|
|
22
|
+
process.stdout.write(`${JSON.stringify({ type: 'system', subtype: 'init', session_id: sessionId })}\n`);
|
|
23
|
+
process.stdout.write(`${JSON.stringify({ type: 'result', session_id: sessionId, result: `answer:${prompt}` })}\n`);
|
|
24
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
describe('ClaudeCliProbe', () => {
|
|
4
|
+
it('validates only version/help and requires the print session flags', async () => {
|
|
5
|
+
const api = await import('../../index.js') as Record<string, unknown>;
|
|
6
|
+
expect(api).toHaveProperty('ClaudeCliProbe');
|
|
7
|
+
const exec = vi.fn()
|
|
8
|
+
.mockResolvedValueOnce({ stdout: '2.1.220\n', stderr: '' })
|
|
9
|
+
.mockResolvedValueOnce({
|
|
10
|
+
stdout: '--print --session-id --resume --output-format stream-json', stderr: '',
|
|
11
|
+
});
|
|
12
|
+
const Probe = api.ClaudeCliProbe as new (options: unknown) => { validate(): Promise<unknown> };
|
|
13
|
+
|
|
14
|
+
await expect(new Probe({ exec }).validate()).resolves.toEqual({
|
|
15
|
+
executable: 'claude', version: '2.1.220',
|
|
16
|
+
});
|
|
17
|
+
expect(exec.mock.calls).toEqual([
|
|
18
|
+
['claude', ['--version']],
|
|
19
|
+
['claude', ['--help']],
|
|
20
|
+
]);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('rejects a CLI missing a required capability', async () => {
|
|
24
|
+
const api = await import('../../index.js') as Record<string, unknown>;
|
|
25
|
+
const exec = vi.fn()
|
|
26
|
+
.mockResolvedValueOnce({ stdout: 'old', stderr: '' })
|
|
27
|
+
.mockResolvedValueOnce({ stdout: '--print only', stderr: '' });
|
|
28
|
+
const Probe = api.ClaudeCliProbe as new (options: unknown) => { validate(): Promise<unknown> };
|
|
29
|
+
|
|
30
|
+
await expect(new Probe({ exec }).validate()).rejects.toMatchObject({ code: 'CLAUDE_CLI_UNSUPPORTED' });
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
6
|
+
import {
|
|
7
|
+
ClaudeCliProbe,
|
|
8
|
+
ClaudePrintAgentService,
|
|
9
|
+
ClaudePrintRunner,
|
|
10
|
+
PrintAgentStore,
|
|
11
|
+
} from '../../index.js';
|
|
12
|
+
|
|
13
|
+
const roots: string[] = [];
|
|
14
|
+
const originalCapture = process.env.AI_DEVKIT_FAKE_CLAUDE_CAPTURE;
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
if (originalCapture === undefined) delete process.env.AI_DEVKIT_FAKE_CLAUDE_CAPTURE;
|
|
18
|
+
else process.env.AI_DEVKIT_FAKE_CLAUDE_CAPTURE = originalCapture;
|
|
19
|
+
for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe('Claude print-agent fake-provider journey', () => {
|
|
23
|
+
it('creates without invocation, then starts and resumes the same session through stdin', async () => {
|
|
24
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'print-agent-integration-'));
|
|
25
|
+
roots.push(root);
|
|
26
|
+
const cwd = path.join(root, 'project');
|
|
27
|
+
fs.mkdirSync(cwd);
|
|
28
|
+
const capture = path.join(root, 'capture.jsonl');
|
|
29
|
+
process.env.AI_DEVKIT_FAKE_CLAUDE_CAPTURE = capture;
|
|
30
|
+
const executable = fileURLToPath(new URL('../fixtures/fake-claude.cjs', import.meta.url));
|
|
31
|
+
const store = new PrintAgentStore({ filePath: path.join(root, 'state', 'print-agents.json') });
|
|
32
|
+
const service = new ClaudePrintAgentService({
|
|
33
|
+
store,
|
|
34
|
+
probe: new ClaudeCliProbe({ executable }),
|
|
35
|
+
runner: new ClaudePrintRunner(),
|
|
36
|
+
executable,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const created = await service.create({ name: 'reviewer', cwd });
|
|
40
|
+
expect(fs.existsSync(capture)).toBe(false);
|
|
41
|
+
|
|
42
|
+
await expect(service.send(created.id, 'first secret')).resolves.toMatchObject({ result: 'answer:first secret' });
|
|
43
|
+
await expect(service.send(created.id, 'follow up')).resolves.toMatchObject({ result: 'answer:follow up' });
|
|
44
|
+
|
|
45
|
+
const invocations = fs.readFileSync(capture, 'utf8').trim().split('\n').map((line) => JSON.parse(line));
|
|
46
|
+
expect(invocations[0]).toMatchObject({ prompt: 'first secret', cwd: fs.realpathSync(cwd) });
|
|
47
|
+
expect(invocations[0].args).toContain('--session-id');
|
|
48
|
+
expect(invocations[0].args).not.toContain('first secret');
|
|
49
|
+
expect(invocations[1]).toMatchObject({ prompt: 'follow up', cwd: fs.realpathSync(cwd) });
|
|
50
|
+
expect(invocations[1].args).toContain('--resume');
|
|
51
|
+
expect(invocations[1].args[invocations[1].args.indexOf('--resume') + 1]).toBe(created.providerSessionId);
|
|
52
|
+
|
|
53
|
+
const persisted = await store.getById(created.id);
|
|
54
|
+
expect(persisted).toMatchObject({ state: 'ready', sessionHealth: 'healthy' });
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
describe('ClaudePrintAgentService', () => {
|
|
4
|
+
it('validates before create and does not run Claude', async () => {
|
|
5
|
+
const api = await import('../../index.js') as Record<string, unknown>;
|
|
6
|
+
expect(api).toHaveProperty('ClaudePrintAgentService');
|
|
7
|
+
const probe = { validate: vi.fn().mockResolvedValue({ executable: 'claude', version: '2.1.220' }) };
|
|
8
|
+
const store = { create: vi.fn().mockResolvedValue({ id: 'agent-id', name: 'reviewer' }) };
|
|
9
|
+
const runner = { run: vi.fn() };
|
|
10
|
+
const Service = api.ClaudePrintAgentService as new (options: unknown) => any;
|
|
11
|
+
|
|
12
|
+
await expect(new Service({ store, probe, runner }).create({ name: 'reviewer', cwd: '/project' }))
|
|
13
|
+
.resolves.toMatchObject({ id: 'agent-id' });
|
|
14
|
+
expect(probe.validate).toHaveBeenCalledOnce();
|
|
15
|
+
expect(store.create).toHaveBeenCalledWith({ name: 'reviewer', cwd: '/project' });
|
|
16
|
+
expect(runner.run).not.toHaveBeenCalled();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('runs first and resumed sends and records provider identity/results', async () => {
|
|
20
|
+
const api = await import('../../index.js') as Record<string, unknown>;
|
|
21
|
+
const base = { id: 'id', name: 'reviewer', providerSessionId: 'session', sessionHealth: 'uninitialized' };
|
|
22
|
+
const store = {
|
|
23
|
+
resolve: vi.fn().mockResolvedValue(base),
|
|
24
|
+
acquireRun: vi.fn()
|
|
25
|
+
.mockResolvedValueOnce({ agent: base, token: 'one' })
|
|
26
|
+
.mockResolvedValueOnce({ agent: { ...base, sessionHealth: 'healthy' }, token: 'two' }),
|
|
27
|
+
recordProviderProcess: vi.fn(), completeRun: vi.fn().mockResolvedValue({}),
|
|
28
|
+
};
|
|
29
|
+
const runner = { run: vi.fn().mockImplementation(async (request) => {
|
|
30
|
+
await request.onSpawn({ pid: 42, startedAt: 'start' });
|
|
31
|
+
return { sessionId: 'session', result: 'answer', exitCode: 0 };
|
|
32
|
+
}) };
|
|
33
|
+
const Service = api.ClaudePrintAgentService as new (options: unknown) => any;
|
|
34
|
+
const service = new Service({ store, probe: { validate: vi.fn() }, runner, executable: 'fake-claude' });
|
|
35
|
+
|
|
36
|
+
await service.send('reviewer', 'first');
|
|
37
|
+
await service.send('id', 'later');
|
|
38
|
+
|
|
39
|
+
expect(runner.run.mock.calls[0][0]).toMatchObject({ prompt: 'first', firstRun: true, executable: 'fake-claude' });
|
|
40
|
+
expect(runner.run.mock.calls[1][0]).toMatchObject({ prompt: 'later', firstRun: false, executable: 'fake-claude' });
|
|
41
|
+
expect(store.recordProviderProcess).toHaveBeenCalledWith('id', 'one', { pid: 42, startedAt: 'start' });
|
|
42
|
+
expect(store.completeRun).toHaveBeenCalledWith('id', 'one', expect.objectContaining({
|
|
43
|
+
status: 'succeeded', exitCode: 0, sessionHealth: 'healthy',
|
|
44
|
+
}));
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
import { PassThrough, Writable } from 'node:stream';
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import type { PrintAgent } from '../../index.js';
|
|
5
|
+
|
|
6
|
+
function agent(): PrintAgent {
|
|
7
|
+
return {
|
|
8
|
+
id: '11111111-1111-4111-8111-111111111111', name: 'reviewer', provider: 'claude', mode: 'print',
|
|
9
|
+
cwd: '/project', providerSessionId: '22222222-2222-4222-8222-222222222222', state: 'running',
|
|
10
|
+
sessionHealth: 'uninitialized', createdAt: '', updatedAt: '', lastActiveAt: null, lastResult: null,
|
|
11
|
+
activeRun: null,
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function fakeSpawn(events: object[], exitCode = 0) {
|
|
16
|
+
const calls: unknown[][] = [];
|
|
17
|
+
const promptChunks: Buffer[] = [];
|
|
18
|
+
const child = new EventEmitter() as any;
|
|
19
|
+
child.pid = 4242;
|
|
20
|
+
child.stdout = new PassThrough();
|
|
21
|
+
child.stderr = new PassThrough();
|
|
22
|
+
child.stdin = new Writable({
|
|
23
|
+
write(chunk, _encoding, callback) { promptChunks.push(Buffer.from(chunk)); callback(); },
|
|
24
|
+
final(callback) {
|
|
25
|
+
for (const event of events) child.stdout.write(`${JSON.stringify(event)}\n`);
|
|
26
|
+
child.stdout.end();
|
|
27
|
+
queueMicrotask(() => child.emit('close', exitCode, null));
|
|
28
|
+
callback();
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
const spawn = vi.fn((...args: unknown[]) => { calls.push(args); return child; });
|
|
32
|
+
return { spawn, calls, promptChunks };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe('ClaudePrintRunner', () => {
|
|
36
|
+
it('starts a caller-assigned session and persists provider identity before stdin', async () => {
|
|
37
|
+
const api = await import('../../index.js') as Record<string, unknown>;
|
|
38
|
+
expect(api).toHaveProperty('ClaudePrintRunner');
|
|
39
|
+
const fixture = fakeSpawn([
|
|
40
|
+
{ type: 'system', subtype: 'init', session_id: agent().providerSessionId },
|
|
41
|
+
{ type: 'result', session_id: agent().providerSessionId, result: 'done' },
|
|
42
|
+
]);
|
|
43
|
+
let persisted = false;
|
|
44
|
+
const Runner = api.ClaudePrintRunner as new (options: unknown) => any;
|
|
45
|
+
const runner = new Runner({ spawn: fixture.spawn, processInspector: {
|
|
46
|
+
getIdentity: () => ({ pid: 4242, startedAt: 'provider-start' }),
|
|
47
|
+
} });
|
|
48
|
+
|
|
49
|
+
const result = await runner.run({
|
|
50
|
+
agent: agent(), prompt: 'secret prompt', executable: 'claude-test', firstRun: true,
|
|
51
|
+
onSpawn: async () => { expect(fixture.promptChunks).toHaveLength(0); persisted = true; },
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
expect(persisted).toBe(true);
|
|
55
|
+
expect(fixture.calls[0]).toEqual([
|
|
56
|
+
'claude-test',
|
|
57
|
+
['-p', '--session-id', agent().providerSessionId, '--output-format', 'stream-json', '--verbose'],
|
|
58
|
+
expect.objectContaining({ cwd: '/project', shell: false, stdio: ['pipe', 'pipe', 'pipe'] }),
|
|
59
|
+
]);
|
|
60
|
+
expect(JSON.stringify(fixture.calls)).not.toContain('secret prompt');
|
|
61
|
+
expect(Buffer.concat(fixture.promptChunks).toString()).toBe('secret prompt');
|
|
62
|
+
expect(result).toEqual({ sessionId: agent().providerSessionId, result: 'done', exitCode: 0 });
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('uses exact resume and rejects a mismatched result session', async () => {
|
|
66
|
+
const api = await import('../../index.js') as Record<string, unknown>;
|
|
67
|
+
const fixture = fakeSpawn([{ type: 'result', session_id: 'wrong', result: 'nope' }]);
|
|
68
|
+
const Runner = api.ClaudePrintRunner as new (options: unknown) => any;
|
|
69
|
+
const runner = new Runner({ spawn: fixture.spawn, processInspector: {
|
|
70
|
+
getIdentity: () => ({ pid: 4242, startedAt: 'provider-start' }),
|
|
71
|
+
} });
|
|
72
|
+
|
|
73
|
+
await expect(runner.run({
|
|
74
|
+
agent: agent(), prompt: 'followup', executable: 'claude', firstRun: false, onSpawn: vi.fn(),
|
|
75
|
+
})).rejects.toMatchObject({ code: 'CLAUDE_SESSION_MISMATCH' });
|
|
76
|
+
expect(fixture.calls[0]![1]).toEqual([
|
|
77
|
+
'-p', '--resume', agent().providerSessionId, '--output-format', 'stream-json', '--verbose',
|
|
78
|
+
]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('does not disclose provider stderr in a failed-run error', async () => {
|
|
82
|
+
const api = await import('../../index.js') as Record<string, unknown>;
|
|
83
|
+
const fixture = fakeSpawn([], 1);
|
|
84
|
+
const Runner = api.ClaudePrintRunner as new (options: unknown) => any;
|
|
85
|
+
const runner = new Runner({ spawn: fixture.spawn, processInspector: {
|
|
86
|
+
getIdentity: () => ({ pid: 4242, startedAt: 'provider-start' }),
|
|
87
|
+
} });
|
|
88
|
+
fixture.spawn.mockImplementationOnce((...args: unknown[]) => {
|
|
89
|
+
const child = (fakeSpawn([], 1).spawn as any)(...args);
|
|
90
|
+
child.stdin = new Writable({
|
|
91
|
+
final(callback) {
|
|
92
|
+
child.stderr.write('secret prompt echoed by provider');
|
|
93
|
+
child.stderr.end();
|
|
94
|
+
queueMicrotask(() => child.emit('close', 1, null));
|
|
95
|
+
callback();
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
return child;
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
await expect(runner.run({
|
|
102
|
+
agent: agent(), prompt: 'secret prompt', firstRun: true, onSpawn: vi.fn(),
|
|
103
|
+
})).rejects.not.toThrow(/secret prompt/);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
describe('print-agent public domain', () => {
|
|
4
|
+
it('exports a classified busy error without exposing prompt data', async () => {
|
|
5
|
+
const api = await import('../../index.js') as Record<string, unknown>;
|
|
6
|
+
|
|
7
|
+
expect(api).toHaveProperty('PrintAgentBusyError');
|
|
8
|
+
const ErrorType = api.PrintAgentBusyError as new (agentId: string, name: string) => Error & {
|
|
9
|
+
code: string;
|
|
10
|
+
agentId: string;
|
|
11
|
+
};
|
|
12
|
+
const error = new ErrorType('agent-id', 'reviewer');
|
|
13
|
+
|
|
14
|
+
expect(error).toMatchObject({
|
|
15
|
+
name: 'PrintAgentBusyError',
|
|
16
|
+
code: 'PRINT_AGENT_BUSY',
|
|
17
|
+
agentId: 'agent-id',
|
|
18
|
+
message: 'Print agent "reviewer" is busy.',
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
});
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
5
|
+
|
|
6
|
+
const tempDirs: string[] = [];
|
|
7
|
+
|
|
8
|
+
afterEach(() => {
|
|
9
|
+
for (const dir of tempDirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true });
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
async function loadStore(): Promise<any> {
|
|
13
|
+
const api = await import('../../index.js') as Record<string, unknown>;
|
|
14
|
+
expect(api).toHaveProperty('PrintAgentStore');
|
|
15
|
+
return api.PrintAgentStore;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function fixture(): { root: string; cwd: string; filePath: string } {
|
|
19
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'print-agent-store-'));
|
|
20
|
+
tempDirs.push(root);
|
|
21
|
+
const cwd = path.join(root, 'project');
|
|
22
|
+
fs.mkdirSync(cwd);
|
|
23
|
+
return { root, cwd, filePath: path.join(root, 'state', 'print-agents.json') };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('PrintAgentStore create/list/resolve', () => {
|
|
27
|
+
it('creates distinct durable identities with a canonical cwd and lists them', async () => {
|
|
28
|
+
const PrintAgentStore = await loadStore();
|
|
29
|
+
const { cwd, filePath } = fixture();
|
|
30
|
+
const store = new PrintAgentStore({ filePath, now: () => new Date('2026-08-07T09:00:00Z') });
|
|
31
|
+
|
|
32
|
+
const agent = await store.create({ name: 'reviewer', cwd });
|
|
33
|
+
|
|
34
|
+
expect(agent).toMatchObject({
|
|
35
|
+
name: 'reviewer',
|
|
36
|
+
provider: 'claude',
|
|
37
|
+
mode: 'print',
|
|
38
|
+
cwd: fs.realpathSync(cwd),
|
|
39
|
+
state: 'ready',
|
|
40
|
+
sessionHealth: 'uninitialized',
|
|
41
|
+
activeRun: null,
|
|
42
|
+
});
|
|
43
|
+
expect(agent.id).toMatch(/^[0-9a-f-]{36}$/);
|
|
44
|
+
expect(agent.providerSessionId).toMatch(/^[0-9a-f-]{36}$/);
|
|
45
|
+
expect(agent.id).not.toBe(agent.providerSessionId);
|
|
46
|
+
expect(await store.list()).toEqual([agent]);
|
|
47
|
+
expect(fs.statSync(filePath).mode & 0o777).toBe(0o600);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('resolves exact ids and names and rejects duplicate names', async () => {
|
|
51
|
+
const PrintAgentStore = await loadStore();
|
|
52
|
+
const { cwd, filePath } = fixture();
|
|
53
|
+
const store = new PrintAgentStore({ filePath });
|
|
54
|
+
const agent = await store.create({ name: 'Reviewer', cwd });
|
|
55
|
+
|
|
56
|
+
expect(await store.resolve(agent.id)).toMatchObject({ id: agent.id });
|
|
57
|
+
expect(await store.resolve('reviewer')).toMatchObject({ id: agent.id });
|
|
58
|
+
expect(await store.resolve('view')).toBeNull();
|
|
59
|
+
await expect(store.create({ name: 'reviewer', cwd })).rejects.toMatchObject({
|
|
60
|
+
code: 'PRINT_AGENT_NAME_CONFLICT',
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('rejects missing cwd, malformed storage, and symlinked store targets', async () => {
|
|
65
|
+
const PrintAgentStore = await loadStore();
|
|
66
|
+
const { root, cwd, filePath } = fixture();
|
|
67
|
+
const store = new PrintAgentStore({ filePath });
|
|
68
|
+
|
|
69
|
+
await expect(store.create({ name: 'missing', cwd: path.join(root, 'missing') }))
|
|
70
|
+
.rejects.toMatchObject({ code: 'PRINT_AGENT_STORE' });
|
|
71
|
+
|
|
72
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
73
|
+
fs.writeFileSync(filePath, '{bad json', { mode: 0o600 });
|
|
74
|
+
await expect(store.list()).rejects.toMatchObject({ code: 'PRINT_AGENT_STORE' });
|
|
75
|
+
|
|
76
|
+
fs.rmSync(filePath);
|
|
77
|
+
const target = path.join(root, 'target.json');
|
|
78
|
+
fs.writeFileSync(target, JSON.stringify({ version: 1, agents: [] }));
|
|
79
|
+
fs.symlinkSync(target, filePath);
|
|
80
|
+
await expect(store.create({ name: 'unsafe', cwd })).rejects.toMatchObject({
|
|
81
|
+
code: 'PRINT_AGENT_STORE',
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('recovers an abandoned old mutation lock after a crash', async () => {
|
|
86
|
+
const PrintAgentStore = await loadStore();
|
|
87
|
+
const { cwd, filePath } = fixture();
|
|
88
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
89
|
+
const lockPath = `${filePath}.lock`;
|
|
90
|
+
fs.mkdirSync(lockPath);
|
|
91
|
+
const old = new Date(Date.now() - 60_000);
|
|
92
|
+
fs.utimesSync(lockPath, old, old);
|
|
93
|
+
const store = new PrintAgentStore({ filePath, mutationLockStaleMs: 10 });
|
|
94
|
+
|
|
95
|
+
await expect(store.create({ name: 'recovered', cwd })).resolves.toMatchObject({ name: 'recovered' });
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe('PrintAgentStore run ownership', () => {
|
|
100
|
+
it('fails fast when another exact owner is live and completes only for its token', async () => {
|
|
101
|
+
const PrintAgentStore = await loadStore();
|
|
102
|
+
const { cwd, filePath } = fixture();
|
|
103
|
+
const live = new Map<number, string>([[process.pid, 'owner-start']]);
|
|
104
|
+
const processInspector = { getIdentity: (pid: number) => {
|
|
105
|
+
const startedAt = live.get(pid);
|
|
106
|
+
return startedAt ? { pid, startedAt } : null;
|
|
107
|
+
} };
|
|
108
|
+
const store = new PrintAgentStore({ filePath, processInspector });
|
|
109
|
+
const agent = await store.create({ name: 'runner', cwd });
|
|
110
|
+
|
|
111
|
+
const acquired = await store.acquireRun(agent.id);
|
|
112
|
+
await expect(store.acquireRun(agent.id)).rejects.toMatchObject({ code: 'PRINT_AGENT_BUSY' });
|
|
113
|
+
await expect(store.completeRun(agent.id, 'wrong-token', {
|
|
114
|
+
status: 'succeeded', exitCode: 0, summary: 'done', sessionHealth: 'healthy',
|
|
115
|
+
})).rejects.toMatchObject({ code: 'PRINT_AGENT_STORE' });
|
|
116
|
+
|
|
117
|
+
const completed = await store.completeRun(agent.id, acquired.token, {
|
|
118
|
+
status: 'succeeded', exitCode: 0, summary: 'done', sessionHealth: 'healthy',
|
|
119
|
+
});
|
|
120
|
+
expect(completed).toMatchObject({ state: 'ready', sessionHealth: 'healthy', activeRun: null });
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('retains busy for a live provider then recovers a dead run without signaling it', async () => {
|
|
124
|
+
const PrintAgentStore = await loadStore();
|
|
125
|
+
const { cwd, filePath } = fixture();
|
|
126
|
+
const live = new Map<number, string>([[process.pid, 'owner-start'], [4242, 'provider-start']]);
|
|
127
|
+
const processInspector = { getIdentity: (pid: number) => {
|
|
128
|
+
const startedAt = live.get(pid);
|
|
129
|
+
return startedAt ? { pid, startedAt } : null;
|
|
130
|
+
} };
|
|
131
|
+
const first = new PrintAgentStore({ filePath, processInspector });
|
|
132
|
+
const agent = await first.create({ name: 'recoverable', cwd });
|
|
133
|
+
const run = await first.acquireRun(agent.id);
|
|
134
|
+
await first.recordProviderProcess(agent.id, run.token, { pid: 4242, startedAt: 'provider-start' });
|
|
135
|
+
|
|
136
|
+
live.delete(process.pid);
|
|
137
|
+
await expect(first.acquireRun(agent.id)).rejects.toMatchObject({ code: 'PRINT_AGENT_BUSY' });
|
|
138
|
+
|
|
139
|
+
live.delete(4242);
|
|
140
|
+
live.set(process.pid, 'replacement-owner-start');
|
|
141
|
+
const recovered = await first.acquireRun(agent.id);
|
|
142
|
+
expect(recovered.agent).toMatchObject({
|
|
143
|
+
state: 'running',
|
|
144
|
+
lastResult: { status: 'interrupted' },
|
|
145
|
+
});
|
|
146
|
+
await first.completeRun(agent.id, recovered.token, {
|
|
147
|
+
status: 'failed', exitCode: 1, summary: 'failed', sessionHealth: 'unknown',
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('reconciles an old incomplete lock to degraded during list', async () => {
|
|
152
|
+
const PrintAgentStore = await loadStore();
|
|
153
|
+
const { root, cwd, filePath } = fixture();
|
|
154
|
+
const live = new Map<number, string>([[process.pid, 'owner-start']]);
|
|
155
|
+
const store = new PrintAgentStore({ filePath, incompleteLockGraceMs: 10, processInspector: {
|
|
156
|
+
getIdentity: (pid: number) => {
|
|
157
|
+
const startedAt = live.get(pid);
|
|
158
|
+
return startedAt ? { pid, startedAt } : null;
|
|
159
|
+
},
|
|
160
|
+
} });
|
|
161
|
+
const agent = await store.create({ name: 'crashed', cwd });
|
|
162
|
+
await store.acquireRun(agent.id);
|
|
163
|
+
const lockPath = path.join(root, 'state', 'print-agent-locks', `${agent.id}.lock`);
|
|
164
|
+
fs.unlinkSync(path.join(lockPath, 'owner.json'));
|
|
165
|
+
const old = new Date(Date.now() - 1000);
|
|
166
|
+
fs.utimesSync(lockPath, old, old);
|
|
167
|
+
live.clear();
|
|
168
|
+
|
|
169
|
+
const listed = await store.list();
|
|
170
|
+
|
|
171
|
+
expect(listed[0]).toMatchObject({
|
|
172
|
+
state: 'degraded',
|
|
173
|
+
sessionHealth: 'unknown',
|
|
174
|
+
activeRun: null,
|
|
175
|
+
lastResult: { status: 'interrupted' },
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('rejects send acquisition when the bound cwd is replaced by a symlink', async () => {
|
|
180
|
+
const PrintAgentStore = await loadStore();
|
|
181
|
+
const { root, cwd, filePath } = fixture();
|
|
182
|
+
const store = new PrintAgentStore({ filePath });
|
|
183
|
+
const agent = await store.create({ name: 'bound', cwd });
|
|
184
|
+
const moved = path.join(root, 'moved-project');
|
|
185
|
+
const other = path.join(root, 'other-project');
|
|
186
|
+
fs.renameSync(cwd, moved);
|
|
187
|
+
fs.mkdirSync(other);
|
|
188
|
+
fs.symlinkSync(other, cwd);
|
|
189
|
+
|
|
190
|
+
await expect(store.acquireRun(agent.id)).rejects.toMatchObject({ code: 'PRINT_AGENT_STORE' });
|
|
191
|
+
});
|
|
192
|
+
});
|