242 lines
7.8 KiB
TypeScript
242 lines
7.8 KiB
TypeScript
import { EventEmitter } from 'events';
|
|
import { SUBAGENT_WORKER_PROTOCOL_VERSION } from '../src/modules/netaclaw/subagent/process_protocol.js';
|
|
|
|
jest.mock('../src/modules/netaclaw/runtime/model_selection.js', () => ({
|
|
initDefaultProviders: jest.fn(),
|
|
}));
|
|
|
|
jest.mock('../src/modules/netaclaw/runtime/agent.js', () => ({
|
|
runAgent: jest.fn(),
|
|
}));
|
|
|
|
describe('subagent worker', () => {
|
|
let originalStdin: NodeJS.ReadStream;
|
|
let originalStdoutWrite: typeof process.stdout.write;
|
|
let originalExitCode: typeof process.exitCode;
|
|
let stdoutLines: any[];
|
|
|
|
beforeEach(() => {
|
|
jest.resetModules();
|
|
stdoutLines = [];
|
|
originalStdin = process.stdin;
|
|
originalStdoutWrite = process.stdout.write;
|
|
originalExitCode = process.exitCode;
|
|
process.exitCode = undefined;
|
|
process.stdout.write = jest.fn((chunk: any) => {
|
|
String(chunk).split(/\r?\n/).filter(Boolean).forEach(line => {
|
|
stdoutLines.push(JSON.parse(line));
|
|
});
|
|
return true;
|
|
}) as any;
|
|
});
|
|
|
|
afterEach(() => {
|
|
Object.defineProperty(process, 'stdin', {
|
|
configurable: true,
|
|
value: originalStdin,
|
|
});
|
|
process.stdout.write = originalStdoutWrite;
|
|
process.exitCode = originalExitCode;
|
|
});
|
|
|
|
const installStdin = (payload: unknown) => {
|
|
const stdin = new EventEmitter() as NodeJS.ReadStream;
|
|
stdin.setEncoding = jest.fn() as any;
|
|
Object.defineProperty(process, 'stdin', {
|
|
configurable: true,
|
|
value: stdin,
|
|
});
|
|
setImmediate(() => {
|
|
stdin.emit('data', `${JSON.stringify(payload)}\n`);
|
|
stdin.emit('end');
|
|
});
|
|
};
|
|
|
|
const importWorker = async () => {
|
|
jest.isolateModules(() => {
|
|
require('../src/modules/netaclaw/subagent/worker.js');
|
|
});
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
};
|
|
|
|
const baseEnvelope = {
|
|
protocolVersion: SUBAGENT_WORKER_PROTOCOL_VERSION,
|
|
runId: 'run-worker-1',
|
|
sessionId: 'session-1',
|
|
task: {
|
|
goal: 'Summarize architecture.',
|
|
},
|
|
agentConfig: {
|
|
name: 'worker-agent',
|
|
systemPrompt: 'Use concise output.',
|
|
model: 'openai:gpt-5.4',
|
|
apiKey: 'test-key',
|
|
},
|
|
toolNames: [],
|
|
toolManifest: [],
|
|
};
|
|
|
|
it('executes a no-tool agent run and emits run_end', async () => {
|
|
const { runAgent } = require('../src/modules/netaclaw/runtime/agent.js');
|
|
runAgent.mockImplementation(async (params: any) => {
|
|
params.onToken('partial');
|
|
return {
|
|
finalContent: 'worker final',
|
|
usage: { inputTokens: 5, outputTokens: 6 },
|
|
toolCallCount: 0,
|
|
messages: [{ role: 'assistant', content: 'worker final' }],
|
|
};
|
|
});
|
|
installStdin(baseEnvelope);
|
|
|
|
await importWorker();
|
|
|
|
expect(runAgent).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
tools: [],
|
|
toolNames: [],
|
|
userMessage: 'Summarize architecture.',
|
|
history: [],
|
|
agentConfig: expect.objectContaining({
|
|
name: 'worker-agent',
|
|
}),
|
|
})
|
|
);
|
|
expect(stdoutLines.map(line => line.type)).toEqual(['run_start', 'token', 'run_end']);
|
|
expect(stdoutLines[2].result).toEqual({
|
|
finalContent: 'worker final',
|
|
usage: { inputTokens: 5, outputTokens: 6 },
|
|
toolCallCount: 0,
|
|
metadata: { messageCount: 1, proxiedToolNames: [] },
|
|
});
|
|
});
|
|
|
|
it('passes supported worker-local tools into runAgent', async () => {
|
|
const { runAgent } = require('../src/modules/netaclaw/runtime/agent.js');
|
|
runAgent.mockResolvedValue({
|
|
finalContent: 'tool-backed final',
|
|
usage: { inputTokens: 2, outputTokens: 3 },
|
|
toolCallCount: 1,
|
|
messages: [{ role: 'assistant', content: 'tool-backed final' }],
|
|
});
|
|
installStdin({
|
|
...baseEnvelope,
|
|
toolManifest: [
|
|
{ name: 'bash', visibility: 'tool', capability: 'text', kind: 'builtin', executionMode: 'parallel', supportedInWorker: true, requiresShell: true },
|
|
{ name: 'read_file', visibility: 'tool', capability: 'text', kind: 'builtin', executionMode: 'parallel', supportedInWorker: true },
|
|
{ name: 'list_dir', visibility: 'tool', capability: 'text', kind: 'builtin', executionMode: 'parallel', supportedInWorker: true },
|
|
],
|
|
});
|
|
|
|
await importWorker();
|
|
|
|
expect(runAgent).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
toolNames: ['bash', 'read_file', 'list_dir'],
|
|
tools: [
|
|
expect.objectContaining({ name: 'bash' }),
|
|
expect.objectContaining({ name: 'read_file' }),
|
|
expect.objectContaining({ name: 'list_dir' }),
|
|
],
|
|
})
|
|
);
|
|
expect(stdoutLines.map(line => line.type)).toEqual(['run_start', 'run_end']);
|
|
expect(stdoutLines[1].result.finalContent).toBe('tool-backed final');
|
|
});
|
|
|
|
it('rejects unsupported tool execution until worker registry wiring exists', async () => {
|
|
installStdin({
|
|
...baseEnvelope,
|
|
toolManifest: [
|
|
{ name: 'write_file', visibility: 'tool', capability: 'text', kind: 'builtin', executionMode: 'sequential', supportedInWorker: false, requiresWrite: true },
|
|
{ name: 'delegate_task', visibility: 'tool', capability: 'text', kind: 'delegation', executionMode: 'sequential', supportedInWorker: false },
|
|
],
|
|
});
|
|
|
|
await importWorker();
|
|
|
|
expect(stdoutLines.map(line => line.type)).toEqual(['run_start', 'run_error']);
|
|
expect(stdoutLines[1].error).toBe(
|
|
'Subagent subprocess worker does not support tools: write_file, delegate_task'
|
|
);
|
|
expect(process.exitCode).toBe(1);
|
|
});
|
|
|
|
it('falls back to toolNames when manifest is absent for backward compatibility', async () => {
|
|
const { runAgent } = require('../src/modules/netaclaw/runtime/agent.js');
|
|
runAgent.mockResolvedValue({
|
|
finalContent: 'legacy fallback final',
|
|
usage: { inputTokens: 1, outputTokens: 1 },
|
|
toolCallCount: 0,
|
|
messages: [{ role: 'assistant', content: 'legacy fallback final' }],
|
|
});
|
|
installStdin({
|
|
...baseEnvelope,
|
|
toolManifest: undefined,
|
|
toolNames: ['read_file'],
|
|
policy: {
|
|
workspaceRoots: ['C:\\repo'],
|
|
},
|
|
});
|
|
|
|
await importWorker();
|
|
|
|
expect(runAgent).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
toolNames: ['read_file'],
|
|
tools: [expect.objectContaining({ name: 'read_file' })],
|
|
})
|
|
);
|
|
expect(stdoutLines.map(line => line.type)).toEqual(['run_start', 'run_end']);
|
|
});
|
|
|
|
it('uses manifest routes as the canonical toolNames passed into runAgent', async () => {
|
|
const { runAgent } = require('../src/modules/netaclaw/runtime/agent.js');
|
|
runAgent.mockResolvedValue({
|
|
finalContent: 'manifest canonical final',
|
|
usage: { inputTokens: 1, outputTokens: 2 },
|
|
toolCallCount: 0,
|
|
messages: [{ role: 'assistant', content: 'manifest canonical final' }],
|
|
});
|
|
installStdin({
|
|
...baseEnvelope,
|
|
toolNames: ['read_file', 'write_file'],
|
|
toolManifest: [
|
|
{
|
|
name: 'read_file',
|
|
visibility: 'tool',
|
|
capability: 'text',
|
|
kind: 'builtin',
|
|
executionMode: 'parallel',
|
|
supportedInWorker: true,
|
|
workerRoutingHint: 'local',
|
|
},
|
|
{
|
|
name: 'write_file',
|
|
visibility: 'tool',
|
|
capability: 'text',
|
|
kind: 'builtin',
|
|
executionMode: 'sequential',
|
|
supportedInWorker: false,
|
|
workerRoutingHint: 'main-process-proxy',
|
|
requiresWrite: true,
|
|
},
|
|
],
|
|
});
|
|
|
|
await importWorker();
|
|
|
|
expect(runAgent).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
toolNames: ['read_file', 'write_file'],
|
|
tools: [
|
|
expect.objectContaining({ name: 'read_file' }),
|
|
expect.objectContaining({ name: 'write_file' }),
|
|
],
|
|
})
|
|
);
|
|
expect(stdoutLines.map(line => line.type)).toEqual(['run_start', 'log', 'run_end']);
|
|
});
|
|
});
|