1342 lines
41 KiB
TypeScript
1342 lines
41 KiB
TypeScript
|
|
jest.mock('../src/modules/netaclaw/gateway/session.js', () => ({
|
||
|
|
NetaClawSessionService: class NetaClawSessionService {},
|
||
|
|
}));
|
||
|
|
|
||
|
|
import { NetaClawSubagentService } from '../src/modules/netaclaw/service/subagent.js';
|
||
|
|
|
||
|
|
describe('NetaClawSubagentService', () => {
|
||
|
|
const flushAsync = async () => {
|
||
|
|
await Promise.resolve();
|
||
|
|
await Promise.resolve();
|
||
|
|
};
|
||
|
|
|
||
|
|
const createService = () => {
|
||
|
|
const service = new NetaClawSubagentService();
|
||
|
|
service.subagentDefaults = {
|
||
|
|
enabled: true,
|
||
|
|
maxConcurrent: 3,
|
||
|
|
};
|
||
|
|
service.subagentRepo = {
|
||
|
|
create: jest.fn((value: any) => ({ ...value })),
|
||
|
|
save: jest.fn(async (value: any) => ({ id: 101, ...value })),
|
||
|
|
update: jest.fn(async () => undefined),
|
||
|
|
} as any;
|
||
|
|
service.toolResolver = {
|
||
|
|
resolve: jest.fn(),
|
||
|
|
} as any;
|
||
|
|
service.agentService = {
|
||
|
|
info: jest.fn(),
|
||
|
|
agentRepo: {
|
||
|
|
findOneBy: jest.fn(),
|
||
|
|
findOne: jest.fn(),
|
||
|
|
},
|
||
|
|
} as any;
|
||
|
|
service.channelService = {
|
||
|
|
resolveForAgent: jest.fn(),
|
||
|
|
} as any;
|
||
|
|
service.skillLoader = {
|
||
|
|
loadSkills: jest.fn(async () => []),
|
||
|
|
buildSkillsPrompt: jest.fn(() => ''),
|
||
|
|
} as any;
|
||
|
|
service.sessionService = {
|
||
|
|
getSessionTreeSession: jest.fn(async () => ({ cwd: 'C:\\derived-workspace' })),
|
||
|
|
} as any;
|
||
|
|
service.agentRunner = jest.fn();
|
||
|
|
return service;
|
||
|
|
};
|
||
|
|
|
||
|
|
const baseContext = {
|
||
|
|
sessionId: 'session-1',
|
||
|
|
parentMessageId: 12,
|
||
|
|
parentToolCallId: 'tool-1',
|
||
|
|
parentAgent: {
|
||
|
|
id: 88,
|
||
|
|
name: 'supervisor',
|
||
|
|
systemPrompt: 'You are the parent agent. Keep answers concise.',
|
||
|
|
skills: ['repo-skill'],
|
||
|
|
config: {
|
||
|
|
middleware: {
|
||
|
|
maxToolRounds: 9,
|
||
|
|
},
|
||
|
|
},
|
||
|
|
modelConfig: {
|
||
|
|
modelId: 'gpt-4o-mini',
|
||
|
|
},
|
||
|
|
subagentConfig: {
|
||
|
|
allowedPresetAgentIds: [77],
|
||
|
|
},
|
||
|
|
},
|
||
|
|
parentModel: {
|
||
|
|
model: 'openai:gpt-4o-mini',
|
||
|
|
apiKey: 'api-key',
|
||
|
|
baseUrl: 'https://example.invalid/v1',
|
||
|
|
capability: 'text',
|
||
|
|
},
|
||
|
|
task: {
|
||
|
|
goal: 'Inspect the repository and summarize the migration risks.',
|
||
|
|
context: 'Focus on backend service boundaries.',
|
||
|
|
mode: 'preset' as const,
|
||
|
|
agentName: 'preset-researcher',
|
||
|
|
agentId: 77,
|
||
|
|
toolNames: ['bash', 'delegate_task', 'clarify', 'memory'],
|
||
|
|
maxToolRounds: 6,
|
||
|
|
},
|
||
|
|
sortOrder: 4,
|
||
|
|
};
|
||
|
|
|
||
|
|
const presetAgent = {
|
||
|
|
id: 77,
|
||
|
|
name: 'preset-researcher',
|
||
|
|
status: 1,
|
||
|
|
systemPrompt: 'Preset researcher instructions.',
|
||
|
|
skills: ['file-counting'],
|
||
|
|
tools: { inheritCoreTools: true },
|
||
|
|
modelConfig: {
|
||
|
|
modelId: 'gpt-5.4',
|
||
|
|
apiKey: 'preset-key',
|
||
|
|
apiUrl: 'https://preset.invalid/v1',
|
||
|
|
},
|
||
|
|
config: {
|
||
|
|
middleware: {
|
||
|
|
maxToolRounds: 5,
|
||
|
|
},
|
||
|
|
},
|
||
|
|
};
|
||
|
|
|
||
|
|
it('runSingle persists a running row, executes the preset agent, and stores a completed summary', async () => {
|
||
|
|
const service = createService();
|
||
|
|
(service.agentService.info as jest.Mock).mockResolvedValue(presetAgent);
|
||
|
|
(service.toolResolver.resolve as jest.Mock).mockResolvedValue({
|
||
|
|
tools: [{ name: 'bash' }],
|
||
|
|
toolNames: ['bash'],
|
||
|
|
toolManifest: [
|
||
|
|
{
|
||
|
|
name: 'bash',
|
||
|
|
visibility: 'tool',
|
||
|
|
capability: 'text',
|
||
|
|
kind: 'builtin',
|
||
|
|
executionMode: 'parallel',
|
||
|
|
supportedInWorker: true,
|
||
|
|
requiresShell: true,
|
||
|
|
},
|
||
|
|
],
|
||
|
|
toolRuntimeRoutes: [
|
||
|
|
{
|
||
|
|
name: 'bash',
|
||
|
|
kind: 'builtin',
|
||
|
|
executionMode: 'parallel',
|
||
|
|
supportedInWorker: true,
|
||
|
|
workerRoutingHint: 'local',
|
||
|
|
runtimeRoute: 'worker-local',
|
||
|
|
blockedReason: null,
|
||
|
|
policySources: ['toolManifest', 'worker-policy'],
|
||
|
|
policyDerived: true,
|
||
|
|
},
|
||
|
|
],
|
||
|
|
toolPromptHints: {},
|
||
|
|
});
|
||
|
|
(service.agentRunner as jest.Mock).mockResolvedValue({
|
||
|
|
finalContent: 'Risk summary',
|
||
|
|
finalOutput: 'Risk summary',
|
||
|
|
usage: { inputTokens: 20, outputTokens: 8 },
|
||
|
|
toolCallCount: 2,
|
||
|
|
messages: [],
|
||
|
|
});
|
||
|
|
|
||
|
|
const result = await service.runSingle(baseContext);
|
||
|
|
|
||
|
|
expect(service.subagentRepo.create).toHaveBeenCalledWith(
|
||
|
|
expect.objectContaining({
|
||
|
|
sessionId: 'session-1',
|
||
|
|
parentMessageId: 12,
|
||
|
|
parentToolCallId: 'tool-1',
|
||
|
|
parentAgentId: 88,
|
||
|
|
sourceType: 'preset',
|
||
|
|
presetAgentId: 77,
|
||
|
|
name: 'preset-researcher',
|
||
|
|
goal: 'Inspect the repository and summarize the migration risks.',
|
||
|
|
context: 'Focus on backend service boundaries.',
|
||
|
|
status: 'running',
|
||
|
|
sortOrder: 4,
|
||
|
|
startedAt: expect.any(Date),
|
||
|
|
})
|
||
|
|
);
|
||
|
|
expect(service.toolResolver.resolve).toHaveBeenCalledWith(
|
||
|
|
expect.objectContaining({
|
||
|
|
agent: presetAgent,
|
||
|
|
modelCapability: 'text',
|
||
|
|
hasSkills: true,
|
||
|
|
delegationRole: 'subagent',
|
||
|
|
})
|
||
|
|
);
|
||
|
|
expect(service.agentRunner).toHaveBeenCalledWith(
|
||
|
|
expect.objectContaining({
|
||
|
|
tools: [{ name: 'bash' }],
|
||
|
|
toolNames: ['bash'],
|
||
|
|
userMessage: 'Inspect the repository and summarize the migration risks.',
|
||
|
|
history: [],
|
||
|
|
beforeToolCall: expect.any(Function),
|
||
|
|
afterToolCall: expect.any(Function),
|
||
|
|
agentConfig: expect.objectContaining({
|
||
|
|
name: 'preset-researcher',
|
||
|
|
model: 'openai:gpt-5.4',
|
||
|
|
apiKey: 'preset-key',
|
||
|
|
baseUrl: 'https://preset.invalid/v1',
|
||
|
|
maxToolRounds: 6,
|
||
|
|
}),
|
||
|
|
})
|
||
|
|
);
|
||
|
|
const prompt = (service.agentRunner as jest.Mock).mock.calls[0][0].agentConfig.systemPrompt;
|
||
|
|
expect(prompt).toContain('Available tools for this delegated run: bash.');
|
||
|
|
expect(prompt).toContain('Available preset skills for this delegated run: file-counting.');
|
||
|
|
expect(prompt).toContain('Never fabricate counts, file paths, command results, or success states.');
|
||
|
|
expect(prompt).toContain('If a matching skill is available and the task clearly fits it, load it with `read_skill` and follow it.');
|
||
|
|
expect(prompt).toContain('If the task asks to count files, include the count and the filenames or evidence used for the count.');
|
||
|
|
|
||
|
|
const runnerParams = (service.agentRunner as jest.Mock).mock.calls[0][0];
|
||
|
|
await runnerParams.beforeToolCall({
|
||
|
|
toolCallId: 'call-1',
|
||
|
|
name: 'bash',
|
||
|
|
label: 'Bash',
|
||
|
|
args: { cmd: 'dir' },
|
||
|
|
});
|
||
|
|
await runnerParams.afterToolCall({
|
||
|
|
toolCallId: 'call-1',
|
||
|
|
name: 'bash',
|
||
|
|
label: 'Bash',
|
||
|
|
args: { cmd: 'dir' },
|
||
|
|
result: 'done',
|
||
|
|
isError: false,
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(service.subagentRepo.update).toHaveBeenCalledWith(
|
||
|
|
101,
|
||
|
|
expect.objectContaining({
|
||
|
|
status: 'completed',
|
||
|
|
model: 'openai:gpt-5.4',
|
||
|
|
toolNames: ['bash'],
|
||
|
|
summary: 'Risk summary',
|
||
|
|
tokenUsage: { inputTokens: 20, outputTokens: 8, totalTokens: 28 },
|
||
|
|
resultPayload: expect.objectContaining({
|
||
|
|
finalContent: 'Risk summary',
|
||
|
|
rawFinalContent: 'Risk summary',
|
||
|
|
finalOutput: 'Risk summary',
|
||
|
|
toolCallCount: 2,
|
||
|
|
proxiedToolNames: [],
|
||
|
|
pendingProxyToolNames: [],
|
||
|
|
processEvents: expect.arrayContaining([
|
||
|
|
expect.objectContaining({ type: 'run_start' }),
|
||
|
|
expect.objectContaining({ type: 'run_end' }),
|
||
|
|
]),
|
||
|
|
toolRuntimeRoutes: expect.any(Array),
|
||
|
|
}),
|
||
|
|
error: null,
|
||
|
|
endedAt: expect.any(Date),
|
||
|
|
})
|
||
|
|
);
|
||
|
|
expect(result).toEqual({
|
||
|
|
id: 101,
|
||
|
|
sessionId: 'session-1',
|
||
|
|
parentMessageId: 12,
|
||
|
|
parentToolCallId: 'tool-1',
|
||
|
|
parentAgentId: 88,
|
||
|
|
sourceType: 'preset',
|
||
|
|
presetAgentId: 77,
|
||
|
|
name: 'preset-researcher',
|
||
|
|
goal: 'Inspect the repository and summarize the migration risks.',
|
||
|
|
context: 'Focus on backend service boundaries.',
|
||
|
|
status: 'completed',
|
||
|
|
model: 'openai:gpt-5.4',
|
||
|
|
summary: 'Risk summary',
|
||
|
|
toolNames: ['bash'],
|
||
|
|
tokenUsage: { inputTokens: 20, outputTokens: 8, totalTokens: 28 },
|
||
|
|
resultPayload: expect.objectContaining({
|
||
|
|
finalContent: 'Risk summary',
|
||
|
|
toolCallCount: 2,
|
||
|
|
proxiedToolNames: [],
|
||
|
|
pendingProxyToolNames: [],
|
||
|
|
processEvents: expect.arrayContaining([
|
||
|
|
expect.objectContaining({ type: 'run_start' }),
|
||
|
|
expect.objectContaining({ type: 'run_end' }),
|
||
|
|
]),
|
||
|
|
}),
|
||
|
|
sortOrder: 4,
|
||
|
|
error: null,
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
it('runSingle captures in-process tool events when the runner invokes tool hooks', async () => {
|
||
|
|
const service = createService();
|
||
|
|
(service.agentService.info as jest.Mock).mockResolvedValue(presetAgent);
|
||
|
|
(service.toolResolver.resolve as jest.Mock).mockResolvedValue({
|
||
|
|
tools: [{ name: 'bash', label: 'Bash' }],
|
||
|
|
toolNames: ['bash'],
|
||
|
|
toolManifest: [
|
||
|
|
{
|
||
|
|
name: 'bash',
|
||
|
|
visibility: 'tool',
|
||
|
|
capability: 'text',
|
||
|
|
kind: 'builtin',
|
||
|
|
executionMode: 'parallel',
|
||
|
|
supportedInWorker: true,
|
||
|
|
requiresShell: true,
|
||
|
|
},
|
||
|
|
],
|
||
|
|
toolRuntimeRoutes: [
|
||
|
|
{
|
||
|
|
name: 'bash',
|
||
|
|
kind: 'builtin',
|
||
|
|
executionMode: 'parallel',
|
||
|
|
supportedInWorker: true,
|
||
|
|
workerRoutingHint: 'local',
|
||
|
|
runtimeRoute: 'worker-local',
|
||
|
|
blockedReason: null,
|
||
|
|
policySources: ['toolManifest', 'worker-policy'],
|
||
|
|
policyDerived: true,
|
||
|
|
},
|
||
|
|
],
|
||
|
|
toolPromptHints: {},
|
||
|
|
});
|
||
|
|
(service.agentRunner as jest.Mock).mockImplementation(async (params: any) => {
|
||
|
|
await params.beforeToolCall?.({
|
||
|
|
toolCallId: 'call-2',
|
||
|
|
name: 'bash',
|
||
|
|
label: 'Bash',
|
||
|
|
args: { cmd: 'dir' },
|
||
|
|
});
|
||
|
|
await params.afterToolCall?.({
|
||
|
|
toolCallId: 'call-2',
|
||
|
|
name: 'bash',
|
||
|
|
label: 'Bash',
|
||
|
|
args: { cmd: 'dir' },
|
||
|
|
result: 'ok',
|
||
|
|
isError: false,
|
||
|
|
});
|
||
|
|
return {
|
||
|
|
finalContent: 'Hooked summary',
|
||
|
|
usage: { inputTokens: 10, outputTokens: 4 },
|
||
|
|
toolCallCount: 1,
|
||
|
|
messages: [],
|
||
|
|
};
|
||
|
|
});
|
||
|
|
|
||
|
|
const result = await service.runSingle(baseContext);
|
||
|
|
|
||
|
|
expect(result.status).toBe('completed');
|
||
|
|
expect(result.resultPayload.processEvents).toEqual(
|
||
|
|
expect.arrayContaining([
|
||
|
|
expect.objectContaining({ type: 'run_start' }),
|
||
|
|
expect.objectContaining({ type: 'tool_call', toolCallId: 'call-2', name: 'bash' }),
|
||
|
|
expect.objectContaining({ type: 'tool_result', toolCallId: 'call-2', result: 'ok' }),
|
||
|
|
expect.objectContaining({ type: 'run_end' }),
|
||
|
|
])
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('runSingle returns a structured failed result and marks the row failed when the runner fails', async () => {
|
||
|
|
const service = createService();
|
||
|
|
(service.agentService.info as jest.Mock).mockResolvedValue(presetAgent);
|
||
|
|
(service.toolResolver.resolve as jest.Mock).mockResolvedValue({
|
||
|
|
tools: [{ name: 'bash' }],
|
||
|
|
toolNames: ['bash'],
|
||
|
|
toolManifest: [
|
||
|
|
{
|
||
|
|
name: 'bash',
|
||
|
|
visibility: 'tool',
|
||
|
|
capability: 'text',
|
||
|
|
kind: 'builtin',
|
||
|
|
executionMode: 'parallel',
|
||
|
|
supportedInWorker: true,
|
||
|
|
requiresShell: true,
|
||
|
|
},
|
||
|
|
],
|
||
|
|
toolRuntimeRoutes: [
|
||
|
|
{
|
||
|
|
name: 'bash',
|
||
|
|
kind: 'builtin',
|
||
|
|
executionMode: 'parallel',
|
||
|
|
supportedInWorker: true,
|
||
|
|
workerRoutingHint: 'local',
|
||
|
|
runtimeRoute: 'worker-local',
|
||
|
|
blockedReason: null,
|
||
|
|
policySources: ['toolManifest', 'worker-policy'],
|
||
|
|
policyDerived: true,
|
||
|
|
},
|
||
|
|
],
|
||
|
|
toolPromptHints: {},
|
||
|
|
});
|
||
|
|
(service.agentRunner as jest.Mock).mockRejectedValue(new Error('runner exploded'));
|
||
|
|
|
||
|
|
await expect(service.runSingle(baseContext)).resolves.toEqual({
|
||
|
|
id: 101,
|
||
|
|
sessionId: 'session-1',
|
||
|
|
parentMessageId: 12,
|
||
|
|
parentToolCallId: 'tool-1',
|
||
|
|
parentAgentId: 88,
|
||
|
|
sourceType: 'preset',
|
||
|
|
presetAgentId: 77,
|
||
|
|
name: 'preset-researcher',
|
||
|
|
goal: 'Inspect the repository and summarize the migration risks.',
|
||
|
|
context: 'Focus on backend service boundaries.',
|
||
|
|
status: 'failed',
|
||
|
|
model: 'openai:gpt-5.4',
|
||
|
|
summary: '',
|
||
|
|
toolNames: ['bash'],
|
||
|
|
tokenUsage: null,
|
||
|
|
resultPayload: {
|
||
|
|
processEvents: [],
|
||
|
|
toolRuntimeRoutes: [
|
||
|
|
expect.objectContaining({
|
||
|
|
name: 'bash',
|
||
|
|
runtimeRoute: 'worker-local',
|
||
|
|
blockedReason: null,
|
||
|
|
}),
|
||
|
|
],
|
||
|
|
},
|
||
|
|
sortOrder: 4,
|
||
|
|
error: 'runner exploded',
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(service.subagentRepo.update).toHaveBeenCalledWith(
|
||
|
|
101,
|
||
|
|
expect.objectContaining({
|
||
|
|
status: 'failed',
|
||
|
|
model: 'openai:gpt-5.4',
|
||
|
|
toolNames: ['bash'],
|
||
|
|
error: 'runner exploded',
|
||
|
|
endedAt: expect.any(Date),
|
||
|
|
})
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('runSingle can execute through the subprocess runner when explicitly configured', async () => {
|
||
|
|
const service = createService();
|
||
|
|
service.subagentDefaults = {
|
||
|
|
...service.subagentDefaults,
|
||
|
|
executionMode: 'subprocess',
|
||
|
|
process: {
|
||
|
|
timeoutMs: 15000,
|
||
|
|
workerPath: 'worker.js',
|
||
|
|
workspaceRoots: ['C:\\repo'],
|
||
|
|
allowShell: true,
|
||
|
|
readonly: true,
|
||
|
|
},
|
||
|
|
};
|
||
|
|
service.processRunner = {
|
||
|
|
run: jest.fn(async (_envelope: any, onEvent?: (event: any) => void) => {
|
||
|
|
onEvent?.({
|
||
|
|
type: 'run_start',
|
||
|
|
runId: 'subagent-101',
|
||
|
|
timestamp: '2026-04-22T00:00:00.000Z',
|
||
|
|
envelope: { sessionId: 'session-1', parentMessageId: 12 },
|
||
|
|
});
|
||
|
|
onEvent?.({
|
||
|
|
type: 'tool_call',
|
||
|
|
runId: 'subagent-101',
|
||
|
|
timestamp: '2026-04-22T00:00:01.000Z',
|
||
|
|
name: 'bash',
|
||
|
|
toolCallId: 'call-1',
|
||
|
|
args: { command: 'pwd' },
|
||
|
|
});
|
||
|
|
onEvent?.({
|
||
|
|
type: 'tool_result',
|
||
|
|
runId: 'subagent-101',
|
||
|
|
timestamp: '2026-04-22T00:00:02.000Z',
|
||
|
|
name: 'bash',
|
||
|
|
toolCallId: 'call-1',
|
||
|
|
result: 'C:\\repo',
|
||
|
|
});
|
||
|
|
onEvent?.({
|
||
|
|
type: 'run_end',
|
||
|
|
runId: 'subagent-101',
|
||
|
|
timestamp: '2026-04-22T00:00:03.000Z',
|
||
|
|
result: {
|
||
|
|
finalContent: 'Subprocess risk summary',
|
||
|
|
toolCallCount: 0,
|
||
|
|
},
|
||
|
|
});
|
||
|
|
return {
|
||
|
|
finalContent: 'Subprocess risk summary',
|
||
|
|
usage: { inputTokens: 7, outputTokens: 9 },
|
||
|
|
toolCallCount: 0,
|
||
|
|
};
|
||
|
|
}),
|
||
|
|
} as any;
|
||
|
|
(service.agentService.info as jest.Mock).mockResolvedValue(presetAgent);
|
||
|
|
(service.toolResolver.resolve as jest.Mock).mockResolvedValue({
|
||
|
|
tools: [],
|
||
|
|
toolNames: [],
|
||
|
|
toolManifest: [],
|
||
|
|
toolPromptHints: {},
|
||
|
|
});
|
||
|
|
|
||
|
|
await expect(service.runSingle({
|
||
|
|
...baseContext,
|
||
|
|
task: {
|
||
|
|
goal: 'Summarize the repository architecture.',
|
||
|
|
context: 'No filesystem inspection required.',
|
||
|
|
mode: 'preset',
|
||
|
|
agentId: 77,
|
||
|
|
agentName: 'preset-researcher',
|
||
|
|
},
|
||
|
|
})).resolves.toEqual(
|
||
|
|
expect.objectContaining({
|
||
|
|
status: 'completed',
|
||
|
|
summary: 'Subprocess risk summary',
|
||
|
|
tokenUsage: { inputTokens: 7, outputTokens: 9, totalTokens: 16 },
|
||
|
|
})
|
||
|
|
);
|
||
|
|
|
||
|
|
expect(service.agentRunner).not.toHaveBeenCalled();
|
||
|
|
expect((service.processRunner.run as jest.Mock).mock.calls[0][0]).toEqual(
|
||
|
|
expect.objectContaining({
|
||
|
|
protocolVersion: 1,
|
||
|
|
runId: 'subagent-101',
|
||
|
|
sessionId: 'session-1',
|
||
|
|
parentMessageId: 12,
|
||
|
|
parentToolCallId: 'tool-1',
|
||
|
|
parentAgentId: 88,
|
||
|
|
toolNames: [],
|
||
|
|
toolManifest: [],
|
||
|
|
timeoutMs: 15000,
|
||
|
|
policy: {
|
||
|
|
workspaceRoots: ['C:\\derived-workspace', 'C:\\repo'],
|
||
|
|
allowShell: false,
|
||
|
|
readonly: true,
|
||
|
|
},
|
||
|
|
task: expect.objectContaining({
|
||
|
|
goal: 'Summarize the repository architecture.',
|
||
|
|
}),
|
||
|
|
agentConfig: expect.objectContaining({
|
||
|
|
name: 'preset-researcher',
|
||
|
|
model: 'openai:gpt-5.4',
|
||
|
|
}),
|
||
|
|
})
|
||
|
|
);
|
||
|
|
expect(service.subagentRepo.update).toHaveBeenCalledWith(
|
||
|
|
101,
|
||
|
|
expect.objectContaining({
|
||
|
|
status: 'completed',
|
||
|
|
summary: 'Subprocess risk summary',
|
||
|
|
resultPayload: expect.objectContaining({
|
||
|
|
finalContent: 'Subprocess risk summary',
|
||
|
|
toolCallCount: 0,
|
||
|
|
processEvents: expect.arrayContaining([
|
||
|
|
expect.objectContaining({ type: 'run_start', runId: 'subagent-101' }),
|
||
|
|
expect.objectContaining({ type: 'tool_call', name: 'bash', toolCallId: 'call-1' }),
|
||
|
|
expect.objectContaining({ type: 'tool_result', name: 'bash', result: 'C:\\repo' }),
|
||
|
|
expect.objectContaining({ type: 'run_end', runId: 'subagent-101' }),
|
||
|
|
]),
|
||
|
|
}),
|
||
|
|
})
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('derives subprocess worker policy from session cwd and tool manifest', async () => {
|
||
|
|
const service = createService();
|
||
|
|
service.subagentDefaults = {
|
||
|
|
...service.subagentDefaults,
|
||
|
|
executionMode: 'subprocess',
|
||
|
|
process: {
|
||
|
|
timeoutMs: 15000,
|
||
|
|
workerPath: 'worker.js',
|
||
|
|
},
|
||
|
|
};
|
||
|
|
service.processRunner = {
|
||
|
|
run: jest.fn(async () => ({
|
||
|
|
finalContent: 'derived policy summary',
|
||
|
|
usage: { inputTokens: 1, outputTokens: 2 },
|
||
|
|
toolCallCount: 1,
|
||
|
|
})),
|
||
|
|
} as any;
|
||
|
|
(service.agentService.info as jest.Mock).mockResolvedValue(presetAgent);
|
||
|
|
(service.toolResolver.resolve as jest.Mock).mockResolvedValue({
|
||
|
|
tools: [],
|
||
|
|
toolNames: ['bash'],
|
||
|
|
toolManifest: [
|
||
|
|
{
|
||
|
|
name: 'bash',
|
||
|
|
visibility: 'tool',
|
||
|
|
capability: 'text',
|
||
|
|
kind: 'builtin',
|
||
|
|
executionMode: 'parallel',
|
||
|
|
supportedInWorker: true,
|
||
|
|
requiresShell: true,
|
||
|
|
},
|
||
|
|
],
|
||
|
|
toolRuntimeRoutes: [
|
||
|
|
{
|
||
|
|
name: 'bash',
|
||
|
|
kind: 'builtin',
|
||
|
|
executionMode: 'parallel',
|
||
|
|
supportedInWorker: true,
|
||
|
|
workerRoutingHint: 'local',
|
||
|
|
runtimeRoute: 'worker-local',
|
||
|
|
blockedReason: null,
|
||
|
|
policySources: ['toolManifest', 'worker-policy'],
|
||
|
|
policyDerived: true,
|
||
|
|
},
|
||
|
|
],
|
||
|
|
toolPromptHints: {},
|
||
|
|
});
|
||
|
|
|
||
|
|
await service.runSingle({
|
||
|
|
...baseContext,
|
||
|
|
sessionCwd: undefined,
|
||
|
|
task: {
|
||
|
|
goal: 'Inspect workspace with bash.',
|
||
|
|
mode: 'preset',
|
||
|
|
agentId: 77,
|
||
|
|
agentName: 'preset-researcher',
|
||
|
|
toolNames: ['bash'],
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(service.sessionService.getSessionTreeSession).toHaveBeenCalledWith('session-1', 88);
|
||
|
|
expect((service.processRunner.run as jest.Mock).mock.calls[0][0]).toEqual(expect.objectContaining({
|
||
|
|
policy: {
|
||
|
|
workspaceRoots: ['C:\\derived-workspace'],
|
||
|
|
allowShell: true,
|
||
|
|
readonly: true,
|
||
|
|
},
|
||
|
|
}));
|
||
|
|
});
|
||
|
|
|
||
|
|
it('derives workspace roots for main-process-proxy tools from task roots before config fallback', async () => {
|
||
|
|
const service = createService();
|
||
|
|
service.subagentDefaults = {
|
||
|
|
...service.subagentDefaults,
|
||
|
|
executionMode: 'subprocess',
|
||
|
|
process: {
|
||
|
|
timeoutMs: 15000,
|
||
|
|
workerPath: 'worker.js',
|
||
|
|
workspaceRoots: ['C:\\config-root'],
|
||
|
|
},
|
||
|
|
};
|
||
|
|
service.processRunner = {
|
||
|
|
run: jest.fn(async () => ({
|
||
|
|
finalContent: 'proxy manifest summary',
|
||
|
|
usage: { inputTokens: 3, outputTokens: 4 },
|
||
|
|
toolCallCount: 1,
|
||
|
|
metadata: {
|
||
|
|
pendingProxyToolNames: ['write_file'],
|
||
|
|
proxiedToolNames: ['write_file'],
|
||
|
|
},
|
||
|
|
})),
|
||
|
|
} as any;
|
||
|
|
(service.agentService.info as jest.Mock).mockResolvedValue(presetAgent);
|
||
|
|
(service.toolResolver.resolve as jest.Mock).mockResolvedValue({
|
||
|
|
tools: [{ name: 'write_file' }],
|
||
|
|
toolNames: ['write_file'],
|
||
|
|
toolManifest: [
|
||
|
|
{
|
||
|
|
name: 'write_file',
|
||
|
|
visibility: 'tool',
|
||
|
|
capability: 'text',
|
||
|
|
kind: 'builtin',
|
||
|
|
executionMode: 'sequential',
|
||
|
|
supportedInWorker: false,
|
||
|
|
workerRoutingHint: 'main-process-proxy',
|
||
|
|
requiresWrite: true,
|
||
|
|
},
|
||
|
|
],
|
||
|
|
toolPromptHints: {},
|
||
|
|
});
|
||
|
|
|
||
|
|
await service.runSingle({
|
||
|
|
...baseContext,
|
||
|
|
sessionCwd: null,
|
||
|
|
task: {
|
||
|
|
...baseContext.task,
|
||
|
|
goal: 'Write a report file into the delegated workspace.',
|
||
|
|
toolNames: ['write_file'],
|
||
|
|
workspaceRoots: ['C:\\task-root'],
|
||
|
|
} as any,
|
||
|
|
});
|
||
|
|
|
||
|
|
expect((service.processRunner.run as jest.Mock).mock.calls[0][0]).toEqual(expect.objectContaining({
|
||
|
|
policy: {
|
||
|
|
workspaceRoots: ['C:\\task-root', 'C:\\config-root'],
|
||
|
|
allowShell: false,
|
||
|
|
readonly: true,
|
||
|
|
},
|
||
|
|
toolManifest: [
|
||
|
|
expect.objectContaining({
|
||
|
|
name: 'write_file',
|
||
|
|
workerRoutingHint: 'main-process-proxy',
|
||
|
|
}),
|
||
|
|
],
|
||
|
|
}));
|
||
|
|
});
|
||
|
|
|
||
|
|
it('adds the current user desktop directory to worker policy roots for desktop evidence tasks', async () => {
|
||
|
|
const service = createService();
|
||
|
|
const originalUserProfile = process.env.USERPROFILE;
|
||
|
|
process.env.USERPROFILE = 'C:\\Users\\lixin';
|
||
|
|
service.subagentDefaults = {
|
||
|
|
...service.subagentDefaults,
|
||
|
|
executionMode: 'subprocess',
|
||
|
|
process: {
|
||
|
|
timeoutMs: 15000,
|
||
|
|
workerPath: 'worker.js',
|
||
|
|
},
|
||
|
|
};
|
||
|
|
service.processRunner = {
|
||
|
|
run: jest.fn(async () => ({
|
||
|
|
finalContent: 'desktop summary',
|
||
|
|
usage: { inputTokens: 3, outputTokens: 4 },
|
||
|
|
toolCallCount: 1,
|
||
|
|
})),
|
||
|
|
} as any;
|
||
|
|
(service.agentService.info as jest.Mock).mockResolvedValue(presetAgent);
|
||
|
|
(service.toolResolver.resolve as jest.Mock).mockResolvedValue({
|
||
|
|
tools: [],
|
||
|
|
toolNames: ['bash', 'find_files'],
|
||
|
|
toolManifest: [
|
||
|
|
{
|
||
|
|
name: 'bash',
|
||
|
|
visibility: 'tool',
|
||
|
|
capability: 'text',
|
||
|
|
kind: 'builtin',
|
||
|
|
executionMode: 'parallel',
|
||
|
|
supportedInWorker: true,
|
||
|
|
requiresShell: true,
|
||
|
|
},
|
||
|
|
{
|
||
|
|
name: 'find_files',
|
||
|
|
visibility: 'tool',
|
||
|
|
capability: 'text',
|
||
|
|
kind: 'builtin',
|
||
|
|
executionMode: 'parallel',
|
||
|
|
supportedInWorker: true,
|
||
|
|
},
|
||
|
|
],
|
||
|
|
toolRuntimeRoutes: [
|
||
|
|
{
|
||
|
|
name: 'bash',
|
||
|
|
kind: 'builtin',
|
||
|
|
executionMode: 'parallel',
|
||
|
|
supportedInWorker: true,
|
||
|
|
workerRoutingHint: 'local',
|
||
|
|
runtimeRoute: 'worker-local',
|
||
|
|
blockedReason: null,
|
||
|
|
policySources: ['toolManifest', 'worker-policy'],
|
||
|
|
policyDerived: true,
|
||
|
|
},
|
||
|
|
{
|
||
|
|
name: 'find_files',
|
||
|
|
kind: 'builtin',
|
||
|
|
executionMode: 'parallel',
|
||
|
|
supportedInWorker: true,
|
||
|
|
workerRoutingHint: 'local',
|
||
|
|
runtimeRoute: 'worker-local',
|
||
|
|
blockedReason: null,
|
||
|
|
policySources: ['toolManifest', 'worker-policy'],
|
||
|
|
policyDerived: true,
|
||
|
|
},
|
||
|
|
],
|
||
|
|
toolPromptHints: {},
|
||
|
|
});
|
||
|
|
|
||
|
|
try {
|
||
|
|
await service.runSingle({
|
||
|
|
...baseContext,
|
||
|
|
sessionCwd: 'C:\\Users\\lixin\\Desktop\\RZYX_ZT\\Neta-monorepo',
|
||
|
|
task: {
|
||
|
|
goal: 'Count how many xlsx files are on the desktop.',
|
||
|
|
context: 'Use tools to inspect the user desktop.',
|
||
|
|
mode: 'preset',
|
||
|
|
agentId: 77,
|
||
|
|
agentName: 'preset-researcher',
|
||
|
|
toolNames: ['bash', 'find_files'],
|
||
|
|
},
|
||
|
|
});
|
||
|
|
} finally {
|
||
|
|
process.env.USERPROFILE = originalUserProfile;
|
||
|
|
}
|
||
|
|
|
||
|
|
expect((service.processRunner.run as jest.Mock).mock.calls[0][0]).toEqual(expect.objectContaining({
|
||
|
|
policy: expect.objectContaining({
|
||
|
|
workspaceRoots: [
|
||
|
|
'C:\\Users\\lixin\\Desktop\\RZYX_ZT\\Neta-monorepo',
|
||
|
|
'C:\\Users\\lixin\\Desktop',
|
||
|
|
],
|
||
|
|
}),
|
||
|
|
}));
|
||
|
|
expect(service.toolResolver.resolve).toHaveBeenCalledWith(expect.objectContaining({
|
||
|
|
runtimePolicy: expect.objectContaining({
|
||
|
|
sessionCwd: 'C:\\Users\\lixin\\Desktop\\RZYX_ZT\\Neta-monorepo',
|
||
|
|
workspaceRoots: [
|
||
|
|
'C:\\Users\\lixin\\Desktop\\RZYX_ZT\\Neta-monorepo',
|
||
|
|
'C:\\Users\\lixin\\Desktop',
|
||
|
|
],
|
||
|
|
}),
|
||
|
|
}));
|
||
|
|
});
|
||
|
|
|
||
|
|
it('runSingle fails counting-style tasks that return results without any tool calls', async () => {
|
||
|
|
const service = createService();
|
||
|
|
(service.agentService.info as jest.Mock).mockResolvedValue(presetAgent);
|
||
|
|
(service.toolResolver.resolve as jest.Mock).mockResolvedValue({
|
||
|
|
tools: [{ name: 'bash' }],
|
||
|
|
toolNames: ['bash'],
|
||
|
|
toolManifest: [
|
||
|
|
{
|
||
|
|
name: 'bash',
|
||
|
|
visibility: 'tool',
|
||
|
|
capability: 'text',
|
||
|
|
kind: 'builtin',
|
||
|
|
executionMode: 'parallel',
|
||
|
|
supportedInWorker: true,
|
||
|
|
requiresShell: true,
|
||
|
|
},
|
||
|
|
],
|
||
|
|
toolRuntimeRoutes: [
|
||
|
|
{
|
||
|
|
name: 'bash',
|
||
|
|
kind: 'builtin',
|
||
|
|
executionMode: 'parallel',
|
||
|
|
supportedInWorker: true,
|
||
|
|
workerRoutingHint: 'local',
|
||
|
|
runtimeRoute: 'worker-local',
|
||
|
|
blockedReason: null,
|
||
|
|
policySources: ['toolManifest', 'worker-policy'],
|
||
|
|
policyDerived: true,
|
||
|
|
},
|
||
|
|
],
|
||
|
|
toolPromptHints: {},
|
||
|
|
});
|
||
|
|
(service.agentRunner as jest.Mock).mockResolvedValue({
|
||
|
|
finalContent: 'There are 5 xlsx files on the desktop.',
|
||
|
|
usage: { inputTokens: 10, outputTokens: 3 },
|
||
|
|
toolCallCount: 0,
|
||
|
|
messages: [],
|
||
|
|
});
|
||
|
|
|
||
|
|
await expect(service.runSingle({
|
||
|
|
...baseContext,
|
||
|
|
task: {
|
||
|
|
goal: 'Count how many xlsx files are on the desktop.',
|
||
|
|
context: 'Return only the final count.',
|
||
|
|
mode: 'preset',
|
||
|
|
agentId: 77,
|
||
|
|
agentName: 'preset-researcher',
|
||
|
|
toolNames: ['bash'],
|
||
|
|
},
|
||
|
|
})).resolves.toEqual(
|
||
|
|
expect.objectContaining({
|
||
|
|
status: 'failed',
|
||
|
|
error: 'Subagent did not produce a usable final answer for a task that requires external evidence',
|
||
|
|
})
|
||
|
|
);
|
||
|
|
|
||
|
|
expect(service.subagentRepo.update).toHaveBeenCalledWith(
|
||
|
|
101,
|
||
|
|
expect.objectContaining({
|
||
|
|
status: 'failed',
|
||
|
|
error: 'Subagent did not produce a usable final answer for a task that requires external evidence',
|
||
|
|
})
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('fails evidence tasks that make tool calls but still produce no usable final output', async () => {
|
||
|
|
const service = createService();
|
||
|
|
(service.agentService.info as jest.Mock).mockResolvedValue(presetAgent);
|
||
|
|
(service.toolResolver.resolve as jest.Mock).mockResolvedValue({
|
||
|
|
tools: [{ name: 'bash' }, { name: 'list_dir' }],
|
||
|
|
toolNames: ['bash', 'list_dir'],
|
||
|
|
toolManifest: [
|
||
|
|
{
|
||
|
|
name: 'bash',
|
||
|
|
visibility: 'tool',
|
||
|
|
capability: 'text',
|
||
|
|
kind: 'builtin',
|
||
|
|
executionMode: 'parallel',
|
||
|
|
supportedInWorker: true,
|
||
|
|
requiresShell: true,
|
||
|
|
},
|
||
|
|
{
|
||
|
|
name: 'list_dir',
|
||
|
|
visibility: 'tool',
|
||
|
|
capability: 'text',
|
||
|
|
kind: 'builtin',
|
||
|
|
executionMode: 'parallel',
|
||
|
|
supportedInWorker: true,
|
||
|
|
},
|
||
|
|
],
|
||
|
|
toolRuntimeRoutes: [
|
||
|
|
{
|
||
|
|
name: 'bash',
|
||
|
|
kind: 'builtin',
|
||
|
|
executionMode: 'parallel',
|
||
|
|
supportedInWorker: true,
|
||
|
|
workerRoutingHint: 'local',
|
||
|
|
runtimeRoute: 'worker-local',
|
||
|
|
blockedReason: null,
|
||
|
|
policySources: ['toolManifest', 'worker-policy'],
|
||
|
|
policyDerived: true,
|
||
|
|
},
|
||
|
|
{
|
||
|
|
name: 'list_dir',
|
||
|
|
kind: 'builtin',
|
||
|
|
executionMode: 'parallel',
|
||
|
|
supportedInWorker: true,
|
||
|
|
workerRoutingHint: 'local',
|
||
|
|
runtimeRoute: 'worker-local',
|
||
|
|
blockedReason: null,
|
||
|
|
policySources: ['toolManifest', 'worker-policy'],
|
||
|
|
policyDerived: true,
|
||
|
|
},
|
||
|
|
],
|
||
|
|
toolPromptHints: {},
|
||
|
|
});
|
||
|
|
(service.agentRunner as jest.Mock).mockResolvedValue({
|
||
|
|
finalContent: '<think>\nTrying PowerShell\n</think>\n\n',
|
||
|
|
finalOutput: '',
|
||
|
|
usage: { inputTokens: 22, outputTokens: 6 },
|
||
|
|
toolCallCount: 5,
|
||
|
|
messages: [],
|
||
|
|
});
|
||
|
|
|
||
|
|
await expect(service.runSingle({
|
||
|
|
...baseContext,
|
||
|
|
task: {
|
||
|
|
goal: 'Count how many image files are on the desktop.',
|
||
|
|
context: 'Use tools and return the final count only.',
|
||
|
|
mode: 'preset',
|
||
|
|
agentId: 77,
|
||
|
|
agentName: 'preset-researcher',
|
||
|
|
toolNames: ['bash', 'list_dir'],
|
||
|
|
},
|
||
|
|
})).resolves.toEqual(
|
||
|
|
expect.objectContaining({
|
||
|
|
status: 'failed',
|
||
|
|
error: 'Subagent did not produce a usable final answer for a task that requires external evidence',
|
||
|
|
})
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('uses captured tool results as a bounded fallback when evidence tasks produce only thinking output', async () => {
|
||
|
|
const service = createService();
|
||
|
|
(service.agentService.info as jest.Mock).mockResolvedValue(presetAgent);
|
||
|
|
(service.toolResolver.resolve as jest.Mock).mockResolvedValue({
|
||
|
|
tools: [{ name: 'find_files' }],
|
||
|
|
toolNames: ['find_files'],
|
||
|
|
toolManifest: [
|
||
|
|
{
|
||
|
|
name: 'find_files',
|
||
|
|
visibility: 'tool',
|
||
|
|
capability: 'text',
|
||
|
|
kind: 'builtin',
|
||
|
|
executionMode: 'parallel',
|
||
|
|
supportedInWorker: true,
|
||
|
|
},
|
||
|
|
],
|
||
|
|
toolRuntimeRoutes: [
|
||
|
|
{
|
||
|
|
name: 'find_files',
|
||
|
|
kind: 'builtin',
|
||
|
|
executionMode: 'parallel',
|
||
|
|
supportedInWorker: true,
|
||
|
|
workerRoutingHint: 'local',
|
||
|
|
runtimeRoute: 'worker-local',
|
||
|
|
blockedReason: null,
|
||
|
|
policySources: ['toolManifest', 'worker-policy'],
|
||
|
|
policyDerived: true,
|
||
|
|
},
|
||
|
|
],
|
||
|
|
toolPromptHints: {},
|
||
|
|
});
|
||
|
|
(service.agentRunner as jest.Mock).mockResolvedValue({
|
||
|
|
finalContent: '<think>\nI found the files but forgot to answer.\n</think>',
|
||
|
|
finalOutput: '',
|
||
|
|
usage: { inputTokens: 22, outputTokens: 6 },
|
||
|
|
toolCallCount: 1,
|
||
|
|
toolResults: [
|
||
|
|
{
|
||
|
|
name: 'find_files',
|
||
|
|
result: [
|
||
|
|
'C:\\Users\\lixin\\Desktop\\111.jpg',
|
||
|
|
'C:\\Users\\lixin\\Desktop\\11123.jpg',
|
||
|
|
'C:\\Users\\lixin\\Desktop\\20260417-185140.jpg',
|
||
|
|
].join('\n'),
|
||
|
|
toolCallId: 'tool-call-1',
|
||
|
|
},
|
||
|
|
],
|
||
|
|
messages: [],
|
||
|
|
});
|
||
|
|
|
||
|
|
await expect(service.runSingle({
|
||
|
|
...baseContext,
|
||
|
|
task: {
|
||
|
|
goal: '请查找我桌面上有多少张图片。',
|
||
|
|
context: 'Use tools and return the final count only.',
|
||
|
|
mode: 'preset',
|
||
|
|
agentId: 77,
|
||
|
|
agentName: 'preset-researcher',
|
||
|
|
toolNames: ['find_files'],
|
||
|
|
},
|
||
|
|
})).resolves.toEqual(
|
||
|
|
expect.objectContaining({
|
||
|
|
status: 'completed',
|
||
|
|
summary: [
|
||
|
|
'根据工具结果统计,图片文件共 3 个。',
|
||
|
|
'- 111.jpg',
|
||
|
|
'- 11123.jpg',
|
||
|
|
'- 20260417-185140.jpg',
|
||
|
|
].join('\n'),
|
||
|
|
resultPayload: expect.objectContaining({
|
||
|
|
finalOutput: [
|
||
|
|
'根据工具结果统计,图片文件共 3 个。',
|
||
|
|
'- 111.jpg',
|
||
|
|
'- 11123.jpg',
|
||
|
|
'- 20260417-185140.jpg',
|
||
|
|
].join('\n'),
|
||
|
|
rawFinalContent: '<think>\nI found the files but forgot to answer.\n</think>',
|
||
|
|
toolResults: [
|
||
|
|
{
|
||
|
|
name: 'find_files',
|
||
|
|
result: [
|
||
|
|
'C:\\Users\\lixin\\Desktop\\111.jpg',
|
||
|
|
'C:\\Users\\lixin\\Desktop\\11123.jpg',
|
||
|
|
'C:\\Users\\lixin\\Desktop\\20260417-185140.jpg',
|
||
|
|
].join('\n'),
|
||
|
|
toolCallId: 'tool-call-1',
|
||
|
|
},
|
||
|
|
],
|
||
|
|
}),
|
||
|
|
})
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('fails evidence tasks before execution when filesystem tools are selected but all worker routes are blocked', async () => {
|
||
|
|
const service = createService();
|
||
|
|
(service.agentService.info as jest.Mock).mockResolvedValue(presetAgent);
|
||
|
|
(service.toolResolver.resolve as jest.Mock).mockResolvedValue({
|
||
|
|
tools: [{ name: 'bash' }, { name: 'find_files' }],
|
||
|
|
toolNames: ['bash', 'find_files'],
|
||
|
|
toolManifest: [
|
||
|
|
{
|
||
|
|
name: 'bash',
|
||
|
|
visibility: 'tool',
|
||
|
|
capability: 'text',
|
||
|
|
kind: 'builtin',
|
||
|
|
executionMode: 'parallel',
|
||
|
|
supportedInWorker: true,
|
||
|
|
requiresShell: true,
|
||
|
|
},
|
||
|
|
{
|
||
|
|
name: 'find_files',
|
||
|
|
visibility: 'tool',
|
||
|
|
capability: 'text',
|
||
|
|
kind: 'builtin',
|
||
|
|
executionMode: 'parallel',
|
||
|
|
supportedInWorker: true,
|
||
|
|
},
|
||
|
|
],
|
||
|
|
toolRuntimeRoutes: [
|
||
|
|
{
|
||
|
|
name: 'bash',
|
||
|
|
kind: 'builtin',
|
||
|
|
executionMode: 'parallel',
|
||
|
|
supportedInWorker: true,
|
||
|
|
workerRoutingHint: 'local',
|
||
|
|
runtimeRoute: 'disabled',
|
||
|
|
blockedReason: 'workspace_root_required',
|
||
|
|
policySources: ['session-workspace', 'worker-policy'],
|
||
|
|
policyDerived: true,
|
||
|
|
},
|
||
|
|
{
|
||
|
|
name: 'find_files',
|
||
|
|
kind: 'builtin',
|
||
|
|
executionMode: 'parallel',
|
||
|
|
supportedInWorker: true,
|
||
|
|
workerRoutingHint: 'local',
|
||
|
|
runtimeRoute: 'disabled',
|
||
|
|
blockedReason: 'workspace_root_required',
|
||
|
|
policySources: ['session-workspace', 'worker-policy'],
|
||
|
|
policyDerived: true,
|
||
|
|
},
|
||
|
|
],
|
||
|
|
toolPromptHints: {},
|
||
|
|
});
|
||
|
|
|
||
|
|
await expect(service.runSingle({
|
||
|
|
...baseContext,
|
||
|
|
task: {
|
||
|
|
goal: 'Count how many xlsx files are on the desktop.',
|
||
|
|
context: 'Return only the final count.',
|
||
|
|
mode: 'preset',
|
||
|
|
agentId: 77,
|
||
|
|
agentName: 'preset-researcher',
|
||
|
|
toolNames: ['bash', 'find_files'],
|
||
|
|
},
|
||
|
|
})).resolves.toEqual(
|
||
|
|
expect.objectContaining({
|
||
|
|
status: 'failed',
|
||
|
|
error: expect.stringContaining(
|
||
|
|
'Subagent evidence task has no executable filesystem/search tools under current worker policy'
|
||
|
|
),
|
||
|
|
})
|
||
|
|
);
|
||
|
|
|
||
|
|
expect(service.agentRunner).not.toHaveBeenCalled();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('runSingle defaults to the single allowed preset agent when no target is specified', async () => {
|
||
|
|
const service = createService();
|
||
|
|
(service.agentService.info as jest.Mock).mockResolvedValue(presetAgent);
|
||
|
|
(service.toolResolver.resolve as jest.Mock).mockResolvedValue({
|
||
|
|
tools: [{ name: 'bash' }],
|
||
|
|
toolNames: ['bash'],
|
||
|
|
toolManifest: [
|
||
|
|
{
|
||
|
|
name: 'bash',
|
||
|
|
visibility: 'tool',
|
||
|
|
capability: 'text',
|
||
|
|
kind: 'builtin',
|
||
|
|
executionMode: 'parallel',
|
||
|
|
supportedInWorker: true,
|
||
|
|
requiresShell: true,
|
||
|
|
},
|
||
|
|
],
|
||
|
|
toolPromptHints: {},
|
||
|
|
});
|
||
|
|
(service.agentRunner as jest.Mock).mockResolvedValue({
|
||
|
|
finalContent: 'Preset result',
|
||
|
|
usage: { inputTokens: 10, outputTokens: 4 },
|
||
|
|
toolCallCount: 1,
|
||
|
|
messages: [],
|
||
|
|
});
|
||
|
|
|
||
|
|
await expect(service.runSingle({
|
||
|
|
...baseContext,
|
||
|
|
task: {
|
||
|
|
goal: baseContext.task.goal,
|
||
|
|
context: baseContext.task.context,
|
||
|
|
toolNames: ['bash'],
|
||
|
|
},
|
||
|
|
} as any)).resolves.toEqual(
|
||
|
|
expect.objectContaining({
|
||
|
|
sourceType: 'preset',
|
||
|
|
presetAgentId: 77,
|
||
|
|
name: 'supervisor-subagent',
|
||
|
|
status: 'completed',
|
||
|
|
})
|
||
|
|
);
|
||
|
|
|
||
|
|
expect(service.agentService.info).toHaveBeenCalledWith(77);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('runSingle rejects missing explicit target when multiple preset agents are allowed', async () => {
|
||
|
|
const service = createService();
|
||
|
|
|
||
|
|
await expect(service.runSingle({
|
||
|
|
...baseContext,
|
||
|
|
parentAgent: {
|
||
|
|
...baseContext.parentAgent,
|
||
|
|
subagentConfig: {
|
||
|
|
allowedPresetAgentIds: [77, 99],
|
||
|
|
},
|
||
|
|
},
|
||
|
|
task: {
|
||
|
|
goal: 'Choose a worker',
|
||
|
|
},
|
||
|
|
} as any)).rejects.toThrow(
|
||
|
|
'Multiple preset subagents are allowed (77, 99); specify agentId or agentName explicitly.'
|
||
|
|
);
|
||
|
|
|
||
|
|
expect(service.subagentRepo.save).not.toHaveBeenCalled();
|
||
|
|
expect(service.toolResolver.resolve).not.toHaveBeenCalled();
|
||
|
|
expect(service.agentRunner).not.toHaveBeenCalled();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('runSingle blocks preset agents that are not in the parent allow-list', async () => {
|
||
|
|
const service = createService();
|
||
|
|
(service.agentService.info as jest.Mock).mockResolvedValue({
|
||
|
|
...presetAgent,
|
||
|
|
id: 66,
|
||
|
|
name: 'forbidden-agent',
|
||
|
|
});
|
||
|
|
|
||
|
|
await expect(service.runSingle({
|
||
|
|
...baseContext,
|
||
|
|
task: {
|
||
|
|
...baseContext.task,
|
||
|
|
agentId: 66,
|
||
|
|
agentName: 'forbidden-agent',
|
||
|
|
},
|
||
|
|
})).resolves.toEqual(expect.objectContaining({
|
||
|
|
status: 'failed',
|
||
|
|
sourceType: 'preset',
|
||
|
|
presetAgentId: 66,
|
||
|
|
error: 'Preset subagent "forbidden-agent" is not allowed by parent agent policy',
|
||
|
|
}));
|
||
|
|
|
||
|
|
expect(service.agentRunner).not.toHaveBeenCalled();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('runBatch enforces bounded concurrency and returns results in sortOrder', async () => {
|
||
|
|
const service = createService();
|
||
|
|
let active = 0;
|
||
|
|
let maxObserved = 0;
|
||
|
|
const releases: Array<() => void> = [];
|
||
|
|
|
||
|
|
jest.spyOn(service, 'runSingle').mockImplementation(async (ctx: any) => {
|
||
|
|
active += 1;
|
||
|
|
maxObserved = Math.max(maxObserved, active);
|
||
|
|
await new Promise<void>(resolve => {
|
||
|
|
releases.push(() => {
|
||
|
|
active -= 1;
|
||
|
|
resolve();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
return {
|
||
|
|
id: ctx.sortOrder + 1,
|
||
|
|
sortOrder: ctx.sortOrder,
|
||
|
|
goal: ctx.task.goal,
|
||
|
|
status: 'completed',
|
||
|
|
} as any;
|
||
|
|
});
|
||
|
|
|
||
|
|
const batchPromise = service.runBatch({
|
||
|
|
...baseContext,
|
||
|
|
maxConcurrent: 2,
|
||
|
|
tasks: [
|
||
|
|
{ goal: 'task-2', mode: 'preset', agentId: 77 },
|
||
|
|
{ goal: 'task-0', mode: 'preset', agentId: 77 },
|
||
|
|
{ goal: 'task-3', mode: 'preset', agentId: 77 },
|
||
|
|
{ goal: 'task-1', mode: 'preset', agentId: 77 },
|
||
|
|
],
|
||
|
|
});
|
||
|
|
|
||
|
|
await flushAsync();
|
||
|
|
expect(maxObserved).toBe(2);
|
||
|
|
expect(service.runSingle).toHaveBeenNthCalledWith(
|
||
|
|
1,
|
||
|
|
expect.objectContaining({ sortOrder: 0, task: expect.objectContaining({ goal: 'task-2' }) })
|
||
|
|
);
|
||
|
|
expect(service.runSingle).toHaveBeenNthCalledWith(
|
||
|
|
2,
|
||
|
|
expect.objectContaining({ sortOrder: 1, task: expect.objectContaining({ goal: 'task-0' }) })
|
||
|
|
);
|
||
|
|
|
||
|
|
releases.shift()?.();
|
||
|
|
releases.shift()?.();
|
||
|
|
await flushAsync();
|
||
|
|
|
||
|
|
expect(service.runSingle).toHaveBeenNthCalledWith(
|
||
|
|
3,
|
||
|
|
expect.objectContaining({ sortOrder: 2, task: expect.objectContaining({ goal: 'task-3' }) })
|
||
|
|
);
|
||
|
|
expect(service.runSingle).toHaveBeenNthCalledWith(
|
||
|
|
4,
|
||
|
|
expect.objectContaining({ sortOrder: 3, task: expect.objectContaining({ goal: 'task-1' }) })
|
||
|
|
);
|
||
|
|
|
||
|
|
releases.splice(0).forEach(release => release());
|
||
|
|
|
||
|
|
await expect(batchPromise).resolves.toEqual([
|
||
|
|
{ id: 1, sortOrder: 0, goal: 'task-2', status: 'completed' },
|
||
|
|
{ id: 2, sortOrder: 1, goal: 'task-0', status: 'completed' },
|
||
|
|
{ id: 3, sortOrder: 2, goal: 'task-3', status: 'completed' },
|
||
|
|
{ id: 4, sortOrder: 3, goal: 'task-1', status: 'completed' },
|
||
|
|
]);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('clamps batch concurrency to at least one', async () => {
|
||
|
|
const service = createService();
|
||
|
|
jest.spyOn(service, 'runSingle').mockResolvedValue({ status: 'completed' } as any);
|
||
|
|
|
||
|
|
await service.runBatch({
|
||
|
|
...baseContext,
|
||
|
|
maxConcurrent: 0,
|
||
|
|
tasks: [
|
||
|
|
{ goal: 'task-a', mode: 'preset', agentId: 77 },
|
||
|
|
{ goal: 'task-b', mode: 'preset', agentId: 77 },
|
||
|
|
],
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(service.runSingle).toHaveBeenNthCalledWith(
|
||
|
|
1,
|
||
|
|
expect.objectContaining({ sortOrder: 0, task: expect.objectContaining({ goal: 'task-a' }) })
|
||
|
|
);
|
||
|
|
expect(service.runSingle).toHaveBeenNthCalledWith(
|
||
|
|
2,
|
||
|
|
expect.objectContaining({ sortOrder: 1, task: expect.objectContaining({ goal: 'task-b' }) })
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('runBatch preserves partial success and stable order when one task fails', async () => {
|
||
|
|
const service = createService();
|
||
|
|
jest.spyOn(service, 'runSingle').mockImplementation(async (ctx: any) => {
|
||
|
|
if (ctx.sortOrder === 1) {
|
||
|
|
return {
|
||
|
|
id: 102,
|
||
|
|
sessionId: ctx.sessionId,
|
||
|
|
parentMessageId: ctx.parentMessageId,
|
||
|
|
parentToolCallId: ctx.parentToolCallId,
|
||
|
|
parentAgentId: ctx.parentAgent?.id ?? null,
|
||
|
|
sourceType: 'preset',
|
||
|
|
presetAgentId: 77,
|
||
|
|
name: `${ctx.task.goal}-agent`,
|
||
|
|
goal: ctx.task.goal,
|
||
|
|
context: null,
|
||
|
|
status: 'failed',
|
||
|
|
model: ctx.parentModel.model,
|
||
|
|
summary: '',
|
||
|
|
toolNames: [],
|
||
|
|
tokenUsage: null,
|
||
|
|
resultPayload: {},
|
||
|
|
sortOrder: ctx.sortOrder,
|
||
|
|
error: 'runner exploded',
|
||
|
|
} as any;
|
||
|
|
}
|
||
|
|
|
||
|
|
return {
|
||
|
|
id: 101,
|
||
|
|
sessionId: ctx.sessionId,
|
||
|
|
parentMessageId: ctx.parentMessageId,
|
||
|
|
parentToolCallId: ctx.parentToolCallId,
|
||
|
|
parentAgentId: ctx.parentAgent?.id ?? null,
|
||
|
|
sourceType: 'preset',
|
||
|
|
presetAgentId: 77,
|
||
|
|
name: `${ctx.task.goal}-agent`,
|
||
|
|
goal: ctx.task.goal,
|
||
|
|
context: null,
|
||
|
|
status: 'completed',
|
||
|
|
model: ctx.parentModel.model,
|
||
|
|
summary: `${ctx.task.goal}-summary`,
|
||
|
|
toolNames: ['bash'],
|
||
|
|
tokenUsage: null,
|
||
|
|
resultPayload: { finalContent: `${ctx.task.goal}-summary`, toolCallCount: 0 },
|
||
|
|
sortOrder: ctx.sortOrder,
|
||
|
|
error: null,
|
||
|
|
} as any;
|
||
|
|
});
|
||
|
|
|
||
|
|
await expect(service.runBatch({
|
||
|
|
...baseContext,
|
||
|
|
maxConcurrent: 2,
|
||
|
|
tasks: [
|
||
|
|
{ goal: 'task-success', mode: 'preset', agentId: 77 },
|
||
|
|
{ goal: 'task-failure', mode: 'preset', agentId: 77 },
|
||
|
|
],
|
||
|
|
})).resolves.toEqual([
|
||
|
|
expect.objectContaining({
|
||
|
|
goal: 'task-success',
|
||
|
|
sortOrder: 0,
|
||
|
|
status: 'completed',
|
||
|
|
error: null,
|
||
|
|
}),
|
||
|
|
expect.objectContaining({
|
||
|
|
goal: 'task-failure',
|
||
|
|
sortOrder: 1,
|
||
|
|
status: 'failed',
|
||
|
|
error: 'runner exploded',
|
||
|
|
}),
|
||
|
|
]);
|
||
|
|
});
|
||
|
|
});
|