192 lines
6.3 KiB
TypeScript
192 lines
6.3 KiB
TypeScript
|
|
import * as fs from 'fs';
|
||
|
|
import * as os from 'os';
|
||
|
|
import * as path from 'path';
|
||
|
|
import { SubagentProcessRunner } from '../src/modules/netaclaw/subagent/process_runner.js';
|
||
|
|
import {
|
||
|
|
SUBAGENT_WORKER_PROTOCOL_VERSION,
|
||
|
|
SubagentTaskEnvelope,
|
||
|
|
} from '../src/modules/netaclaw/subagent/process_protocol.js';
|
||
|
|
|
||
|
|
describe('SubagentProcessRunner', () => {
|
||
|
|
const tempDirs: string[] = [];
|
||
|
|
|
||
|
|
afterEach(() => {
|
||
|
|
tempDirs.splice(0).forEach(dir => {
|
||
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
const writeWorker = (content: string): string => {
|
||
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'neta-subagent-worker-'));
|
||
|
|
tempDirs.push(dir);
|
||
|
|
const workerPath = path.join(dir, 'worker.js');
|
||
|
|
fs.writeFileSync(workerPath, content, 'utf8');
|
||
|
|
return workerPath;
|
||
|
|
};
|
||
|
|
|
||
|
|
const createEnvelope = (overrides: Partial<SubagentTaskEnvelope> = {}): SubagentTaskEnvelope => ({
|
||
|
|
protocolVersion: SUBAGENT_WORKER_PROTOCOL_VERSION,
|
||
|
|
runId: 'run-1',
|
||
|
|
sessionId: 'session-1',
|
||
|
|
parentMessageId: 12,
|
||
|
|
parentToolCallId: 'tool-call-1',
|
||
|
|
parentAgentId: 88,
|
||
|
|
parentEntryId: 'entry-1',
|
||
|
|
task: {
|
||
|
|
goal: 'Summarize the repo',
|
||
|
|
context: 'Focus on backend boundaries.',
|
||
|
|
agentId: 77,
|
||
|
|
agentName: 'researcher',
|
||
|
|
},
|
||
|
|
agentConfig: {
|
||
|
|
name: 'researcher',
|
||
|
|
systemPrompt: 'You are isolated.',
|
||
|
|
model: 'openai:gpt-5.4',
|
||
|
|
apiKey: 'test-key',
|
||
|
|
maxToolRounds: 3,
|
||
|
|
},
|
||
|
|
toolNames: ['bash'],
|
||
|
|
...overrides,
|
||
|
|
});
|
||
|
|
|
||
|
|
it('spawns a worker process, streams JSONL events, and resolves on run_end', async () => {
|
||
|
|
const workerPath = writeWorker(`
|
||
|
|
process.stdin.resume();
|
||
|
|
process.stdin.setEncoding('utf8');
|
||
|
|
let buffer = '';
|
||
|
|
process.stdin.on('data', chunk => {
|
||
|
|
buffer += chunk;
|
||
|
|
const index = buffer.indexOf('\\n');
|
||
|
|
if (index === -1) return;
|
||
|
|
const envelope = JSON.parse(buffer.slice(0, index));
|
||
|
|
const timestamp = new Date().toISOString();
|
||
|
|
process.stdout.write(JSON.stringify({ type: 'run_start', runId: envelope.runId, timestamp }) + '\\n');
|
||
|
|
process.stdout.write(JSON.stringify({ type: 'token', runId: envelope.runId, timestamp, text: 'hello' }) + '\\n');
|
||
|
|
process.stdout.write(JSON.stringify({
|
||
|
|
type: 'run_end',
|
||
|
|
runId: envelope.runId,
|
||
|
|
timestamp,
|
||
|
|
result: {
|
||
|
|
finalContent: 'worker result',
|
||
|
|
usage: { inputTokens: 3, outputTokens: 4 },
|
||
|
|
toolCallCount: 1
|
||
|
|
}
|
||
|
|
}) + '\\n');
|
||
|
|
});
|
||
|
|
`);
|
||
|
|
const events: string[] = [];
|
||
|
|
const runner = new SubagentProcessRunner({ workerPath });
|
||
|
|
|
||
|
|
const resultPromise = runner.run(createEnvelope(), event => {
|
||
|
|
events.push(event.type);
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(runner.getActiveRunIds()).toEqual(['run-1']);
|
||
|
|
await expect(resultPromise).resolves.toEqual({
|
||
|
|
finalContent: 'worker result',
|
||
|
|
usage: { inputTokens: 3, outputTokens: 4 },
|
||
|
|
toolCallCount: 1,
|
||
|
|
});
|
||
|
|
expect(events).toEqual(['run_start', 'token', 'run_end']);
|
||
|
|
expect(runner.getActiveRunIds()).toEqual([]);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('handles main-process proxy tool calls over worker stdin/stdout JSONL', async () => {
|
||
|
|
const workerPath = writeWorker(`
|
||
|
|
process.stdin.resume();
|
||
|
|
process.stdin.setEncoding('utf8');
|
||
|
|
let buffer = '';
|
||
|
|
let envelope = null;
|
||
|
|
process.stdin.on('data', chunk => {
|
||
|
|
buffer += chunk;
|
||
|
|
const lines = buffer.split(/\\r?\\n/);
|
||
|
|
buffer = lines.pop() || '';
|
||
|
|
for (const line of lines) {
|
||
|
|
if (!line.trim()) continue;
|
||
|
|
const message = JSON.parse(line);
|
||
|
|
const timestamp = new Date().toISOString();
|
||
|
|
if (!envelope) {
|
||
|
|
envelope = message;
|
||
|
|
process.stdout.write(JSON.stringify({
|
||
|
|
type: 'proxy_tool_call',
|
||
|
|
runId: envelope.runId,
|
||
|
|
timestamp,
|
||
|
|
requestId: 'proxy-1',
|
||
|
|
name: 'write_file',
|
||
|
|
toolCallId: 'tool-call-proxy',
|
||
|
|
args: { path: '/tmp/example.txt', content: 'hello' }
|
||
|
|
}) + '\\n');
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
process.stdout.write(JSON.stringify({
|
||
|
|
type: 'run_end',
|
||
|
|
runId: envelope.runId,
|
||
|
|
timestamp,
|
||
|
|
result: {
|
||
|
|
finalContent: message.result,
|
||
|
|
usage: { inputTokens: 1, outputTokens: 2 },
|
||
|
|
toolCallCount: 1
|
||
|
|
}
|
||
|
|
}) + '\\n');
|
||
|
|
}
|
||
|
|
});
|
||
|
|
`);
|
||
|
|
const events: string[] = [];
|
||
|
|
const runner = new SubagentProcessRunner({ workerPath });
|
||
|
|
|
||
|
|
await expect(runner.run(
|
||
|
|
createEnvelope(),
|
||
|
|
event => events.push(event.type),
|
||
|
|
async event => `proxied:${event.name}:${event.args.content}`
|
||
|
|
)).resolves.toEqual({
|
||
|
|
finalContent: 'proxied:write_file:hello',
|
||
|
|
usage: { inputTokens: 1, outputTokens: 2 },
|
||
|
|
toolCallCount: 1,
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(events).toEqual(['proxy_tool_call', 'run_end']);
|
||
|
|
expect(runner.getActiveRunIds()).toEqual([]);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('rejects malformed worker JSONL output', async () => {
|
||
|
|
const workerPath = writeWorker(`
|
||
|
|
process.stdout.write('not-json\\n');
|
||
|
|
`);
|
||
|
|
const runner = new SubagentProcessRunner({ workerPath });
|
||
|
|
|
||
|
|
await expect(runner.run(createEnvelope())).rejects.toThrow(
|
||
|
|
'Invalid subagent worker JSONL event'
|
||
|
|
);
|
||
|
|
expect(runner.getActiveRunIds()).toEqual([]);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('rejects non-zero exits when the worker does not emit a terminal event', async () => {
|
||
|
|
const workerPath = writeWorker(`
|
||
|
|
process.stderr.write('boom');
|
||
|
|
process.exit(7);
|
||
|
|
`);
|
||
|
|
const runner = new SubagentProcessRunner({ workerPath });
|
||
|
|
|
||
|
|
await expect(runner.run(createEnvelope())).rejects.toThrow(
|
||
|
|
'Subagent process exited with code 7: boom'
|
||
|
|
);
|
||
|
|
expect(runner.getActiveRunIds()).toEqual([]);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('can cancel an active worker process by runId', async () => {
|
||
|
|
const workerPath = writeWorker(`
|
||
|
|
process.stdin.resume();
|
||
|
|
setInterval(() => {}, 1000);
|
||
|
|
`);
|
||
|
|
const runner = new SubagentProcessRunner({ workerPath });
|
||
|
|
const resultPromise = runner.run(createEnvelope({ runId: 'run-cancel' }));
|
||
|
|
|
||
|
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||
|
|
expect(runner.getActiveRunIds()).toEqual(['run-cancel']);
|
||
|
|
expect(runner.cancel('run-cancel')).toBe(true);
|
||
|
|
|
||
|
|
await expect(resultPromise).rejects.toThrow('Subagent process exited with code');
|
||
|
|
expect(runner.getActiveRunIds()).toEqual([]);
|
||
|
|
});
|
||
|
|
});
|