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: '\nTrying PowerShell\n\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: '\nI found the files but forgot to answer.\n', 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: '\nI found the files but forgot to answer.\n', 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(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', }), ]); }); });