GPU_GUARD_MONOREPO/packages/backend/test/subagent_service.test.ts

1342 lines
41 KiB
TypeScript
Raw Normal View History

2026-05-20 21:39:12 +08:00
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',
}),
]);
});
});