1233 lines
42 KiB
TypeScript
1233 lines
42 KiB
TypeScript
|
|
import { NetaClawToolResolverService } from '../src/modules/netaclaw/service/tool_resolver.js';
|
|||
|
|
|
|||
|
|
describe('NetaClawToolResolverService', () => {
|
|||
|
|
const service = new NetaClawToolResolverService();
|
|||
|
|
|
|||
|
|
it('matches text tools with any model capability', () => {
|
|||
|
|
expect(service.matchCapability('text', 'text')).toBe(true);
|
|||
|
|
expect(service.matchCapability('text', 'vision')).toBe(true);
|
|||
|
|
expect(service.matchCapability('text', 'multimodal')).toBe(true);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('matches vision tools only for vision or multimodal models', () => {
|
|||
|
|
expect(service.matchCapability('vision', 'text')).toBe(false);
|
|||
|
|
expect(service.matchCapability('vision', 'vision')).toBe(true);
|
|||
|
|
expect(service.matchCapability('vision', 'multimodal')).toBe(true);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('matches multimodal tools only for multimodal models', () => {
|
|||
|
|
expect(service.matchCapability('multimodal', 'text')).toBe(false);
|
|||
|
|
expect(service.matchCapability('multimodal', 'vision')).toBe(false);
|
|||
|
|
expect(service.matchCapability('multimodal', 'multimodal')).toBe(true);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('filters disabled and incompatible tools while keeping prompt hints', async () => {
|
|||
|
|
service.toolRegistry = {
|
|||
|
|
all: jest.fn().mockResolvedValue([
|
|||
|
|
{ name: 'bash', status: 1, capability: 'text', promptHint: null },
|
|||
|
|
{ name: 'patch', status: 1, capability: 'text', promptHint: null },
|
|||
|
|
{ name: 'read_skill', status: 1, capability: 'text', promptHint: '先阅读技能说明' },
|
|||
|
|
{ name: 'delegate_task', status: 0, capability: 'text', promptHint: null },
|
|||
|
|
{ name: 'image_generate', status: 1, capability: 'vision', promptHint: null },
|
|||
|
|
]),
|
|||
|
|
} as any;
|
|||
|
|
service.skillLoader = {} as any;
|
|||
|
|
|
|||
|
|
const result = await service.resolve({
|
|||
|
|
agent: {
|
|||
|
|
toolsets: ['base'],
|
|||
|
|
tools: { disabled: ['patch'] },
|
|||
|
|
} as any,
|
|||
|
|
modelCapability: 'text',
|
|||
|
|
hasSkills: true,
|
|||
|
|
crewRole: 'master',
|
|||
|
|
crewContext: {},
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
expect(result.toolNames).toEqual(expect.arrayContaining(['bash', 'read_skill']));
|
|||
|
|
expect(result.toolNames).not.toContain('patch');
|
|||
|
|
expect(result.toolNames).not.toContain('delegate_task');
|
|||
|
|
expect(result.toolPromptHints).toEqual({ read_skill: '先阅读技能说明' });
|
|||
|
|
expect(result.disabledReasons).toEqual(
|
|||
|
|
expect.arrayContaining([
|
|||
|
|
{ name: 'patch', reason: 'agent_tool_disabled' },
|
|||
|
|
{ name: 'delegate_task', reason: 'globally_disabled' },
|
|||
|
|
])
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('does not fall back to default toolsets when agent explicitly saves an empty toolset list', async () => {
|
|||
|
|
service.toolRegistry = {
|
|||
|
|
all: jest.fn().mockResolvedValue([
|
|||
|
|
{ name: 'bash', status: 1, capability: 'text', promptHint: null },
|
|||
|
|
{ name: 'clarify', status: 1, capability: 'text', promptHint: null },
|
|||
|
|
]),
|
|||
|
|
} as any;
|
|||
|
|
service.skillLoader = {} as any;
|
|||
|
|
|
|||
|
|
const result = await service.resolve({
|
|||
|
|
agent: {
|
|||
|
|
toolsets: [],
|
|||
|
|
tools: { disabled: [] },
|
|||
|
|
} as any,
|
|||
|
|
modelCapability: 'text',
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
expect(result.toolNames).toEqual([]);
|
|||
|
|
expect(result.tools).toEqual([]);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('inherits core tools by default in the new config shape', async () => {
|
|||
|
|
service.toolRegistry = {
|
|||
|
|
all: jest.fn().mockResolvedValue([
|
|||
|
|
{ name: 'clarify', status: 1, capability: 'text', promptHint: null, isCore: 1, toolset: 'interaction' },
|
|||
|
|
{ name: 'todo', status: 1, capability: 'text', promptHint: null, isCore: 1, toolset: 'planning' },
|
|||
|
|
{ name: 'bash', status: 1, capability: 'text', promptHint: null, isCore: 0, toolset: 'base' },
|
|||
|
|
]),
|
|||
|
|
} as any;
|
|||
|
|
service.skillLoader = {} as any;
|
|||
|
|
|
|||
|
|
const result = await service.resolve({
|
|||
|
|
agent: {
|
|||
|
|
tools: {
|
|||
|
|
inheritCoreTools: true,
|
|||
|
|
enabled: [],
|
|||
|
|
disabled: [],
|
|||
|
|
},
|
|||
|
|
} as any,
|
|||
|
|
modelCapability: 'text',
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
expect(result.toolNames).toEqual(expect.arrayContaining(['clarify', 'todo']));
|
|||
|
|
expect(result.toolNames).not.toContain('bash');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('supports explicitly enabling non-core tools in the new config shape', async () => {
|
|||
|
|
service.toolRegistry = {
|
|||
|
|
all: jest.fn().mockResolvedValue([
|
|||
|
|
{ name: 'clarify', status: 1, capability: 'text', promptHint: null, isCore: 1, toolset: 'interaction' },
|
|||
|
|
{ name: 'bash', status: 1, capability: 'text', promptHint: null, isCore: 0, toolset: 'base' },
|
|||
|
|
]),
|
|||
|
|
} as any;
|
|||
|
|
service.skillLoader = {} as any;
|
|||
|
|
|
|||
|
|
const result = await service.resolve({
|
|||
|
|
agent: {
|
|||
|
|
tools: {
|
|||
|
|
inheritCoreTools: true,
|
|||
|
|
enabled: ['bash'],
|
|||
|
|
disabled: [],
|
|||
|
|
},
|
|||
|
|
} as any,
|
|||
|
|
modelCapability: 'text',
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
expect(result.toolNames).toEqual(expect.arrayContaining(['clarify', 'bash']));
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('supports explicitly disabling core tools for the current agent', async () => {
|
|||
|
|
service.toolRegistry = {
|
|||
|
|
all: jest.fn().mockResolvedValue([
|
|||
|
|
{ name: 'clarify', status: 1, capability: 'text', promptHint: null, isCore: 1, toolset: 'interaction' },
|
|||
|
|
{ name: 'todo', status: 1, capability: 'text', promptHint: null, isCore: 1, toolset: 'planning' },
|
|||
|
|
]),
|
|||
|
|
} as any;
|
|||
|
|
service.skillLoader = {} as any;
|
|||
|
|
|
|||
|
|
const result = await service.resolve({
|
|||
|
|
agent: {
|
|||
|
|
tools: {
|
|||
|
|
inheritCoreTools: true,
|
|||
|
|
enabled: [],
|
|||
|
|
disabled: ['todo'],
|
|||
|
|
},
|
|||
|
|
} as any,
|
|||
|
|
modelCapability: 'text',
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
expect(result.toolNames).toContain('clarify');
|
|||
|
|
expect(result.toolNames).not.toContain('todo');
|
|||
|
|
expect(result.disabledReasons).toEqual(
|
|||
|
|
expect.arrayContaining([{ name: 'todo', reason: 'agent_tool_disabled' }])
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('keeps global disabled higher priority than agent enabled', async () => {
|
|||
|
|
service.toolRegistry = {
|
|||
|
|
all: jest.fn().mockResolvedValue([
|
|||
|
|
{ name: 'clarify', status: 1, capability: 'text', promptHint: null, isCore: 1, toolset: 'interaction' },
|
|||
|
|
{ name: 'bash', status: 0, capability: 'text', promptHint: null, isCore: 0, toolset: 'base' },
|
|||
|
|
]),
|
|||
|
|
} as any;
|
|||
|
|
service.skillLoader = {} as any;
|
|||
|
|
|
|||
|
|
const result = await service.resolve({
|
|||
|
|
agent: {
|
|||
|
|
tools: {
|
|||
|
|
inheritCoreTools: true,
|
|||
|
|
enabled: ['bash'],
|
|||
|
|
disabled: [],
|
|||
|
|
},
|
|||
|
|
} as any,
|
|||
|
|
modelCapability: 'text',
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
expect(result.toolNames).toContain('clarify');
|
|||
|
|
expect(result.toolNames).not.toContain('bash');
|
|||
|
|
expect(result.disabledReasons).toEqual(
|
|||
|
|
expect.arrayContaining([{ name: 'bash', reason: 'globally_disabled' }])
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('classifies edit as a sequential write tool routed through main-process proxy for subprocess workers', async () => {
|
|||
|
|
service.toolRegistry = {
|
|||
|
|
all: jest.fn().mockResolvedValue([
|
|||
|
|
{ name: 'edit', status: 1, capability: 'text', promptHint: null, isCore: 0, toolset: 'base' },
|
|||
|
|
]),
|
|||
|
|
} as any;
|
|||
|
|
service.skillLoader = {} as any;
|
|||
|
|
|
|||
|
|
const result = await service.resolve({
|
|||
|
|
agent: {
|
|||
|
|
tools: {
|
|||
|
|
inheritCoreTools: false,
|
|||
|
|
enabled: ['edit'],
|
|||
|
|
disabled: [],
|
|||
|
|
},
|
|||
|
|
} as any,
|
|||
|
|
modelCapability: 'text',
|
|||
|
|
delegationRole: 'subagent',
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
expect(result.toolNames).toEqual(['edit']);
|
|||
|
|
expect(result.tools.map(tool => tool.name)).toEqual(['edit']);
|
|||
|
|
expect(result.toolManifest).toEqual([
|
|||
|
|
expect.objectContaining({
|
|||
|
|
name: 'edit',
|
|||
|
|
kind: 'builtin',
|
|||
|
|
executionMode: 'sequential',
|
|||
|
|
supportedInWorker: false,
|
|||
|
|
workerRoutingHint: 'main-process-proxy',
|
|||
|
|
requiresWrite: true,
|
|||
|
|
}),
|
|||
|
|
]);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('classifies search tools as worker-local readonly builtin tools', async () => {
|
|||
|
|
service.toolRegistry = {
|
|||
|
|
all: jest.fn().mockResolvedValue([
|
|||
|
|
{ name: 'find_files', status: 1, capability: 'text', promptHint: null, isCore: 0, toolset: 'base' },
|
|||
|
|
{ name: 'grep', status: 1, capability: 'text', promptHint: null, isCore: 0, toolset: 'base' },
|
|||
|
|
]),
|
|||
|
|
} as any;
|
|||
|
|
service.skillLoader = {} as any;
|
|||
|
|
|
|||
|
|
const result = await service.resolve({
|
|||
|
|
agent: {
|
|||
|
|
tools: {
|
|||
|
|
inheritCoreTools: false,
|
|||
|
|
enabled: ['find_files', 'grep'],
|
|||
|
|
disabled: [],
|
|||
|
|
},
|
|||
|
|
} as any,
|
|||
|
|
modelCapability: 'text',
|
|||
|
|
delegationRole: 'subagent',
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
expect(result.toolNames).toEqual(['find_files', 'grep']);
|
|||
|
|
expect(result.tools.map(tool => tool.name)).toEqual(['find_files', 'grep']);
|
|||
|
|
expect(result.toolManifest).toEqual([
|
|||
|
|
expect.objectContaining({
|
|||
|
|
name: 'find_files',
|
|||
|
|
kind: 'builtin',
|
|||
|
|
executionMode: 'parallel',
|
|||
|
|
supportedInWorker: true,
|
|||
|
|
workerRoutingHint: 'local',
|
|||
|
|
}),
|
|||
|
|
expect.objectContaining({
|
|||
|
|
name: 'grep',
|
|||
|
|
kind: 'builtin',
|
|||
|
|
executionMode: 'parallel',
|
|||
|
|
supportedInWorker: true,
|
|||
|
|
workerRoutingHint: 'local',
|
|||
|
|
}),
|
|||
|
|
]);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('resolves mysql toolset tools when enabled for an agent with toolset mysql', async () => {
|
|||
|
|
service.toolRegistry = {
|
|||
|
|
all: jest.fn().mockResolvedValue([
|
|||
|
|
{ name: 'mysql_list_sources', status: 1, capability: 'text', promptHint: null, isCore: 0, toolset: 'mysql' },
|
|||
|
|
{ name: 'mysql_schema', status: 1, capability: 'text', promptHint: null, isCore: 0, toolset: 'mysql' },
|
|||
|
|
{ name: 'mysql_table_sample', status: 1, capability: 'text', promptHint: null, isCore: 0, toolset: 'mysql' },
|
|||
|
|
{ name: 'mysql_query', status: 1, capability: 'text', promptHint: null, isCore: 0, toolset: 'mysql' },
|
|||
|
|
]),
|
|||
|
|
} as any;
|
|||
|
|
service.skillLoader = {} as any;
|
|||
|
|
service.dataSourceService = {} as any;
|
|||
|
|
service.mysqlIntrospection = {} as any;
|
|||
|
|
service.mysqlQuery = {} as any;
|
|||
|
|
|
|||
|
|
const result = await service.resolve({
|
|||
|
|
agent: {
|
|||
|
|
id: 10,
|
|||
|
|
toolsets: ['mysql'],
|
|||
|
|
tools: { disabled: [] },
|
|||
|
|
} as any,
|
|||
|
|
modelCapability: 'text',
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const expectedNames = ['mysql_list_sources', 'mysql_schema', 'mysql_table_sample', 'mysql_query'];
|
|||
|
|
expect(result.toolNames).toEqual(expectedNames);
|
|||
|
|
expect(result.tools.map(tool => tool.name)).toEqual(expectedNames);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('routes mysql tools through main process proxy for subagent workers', async () => {
|
|||
|
|
service.toolRegistry = {
|
|||
|
|
all: jest.fn().mockResolvedValue([
|
|||
|
|
{ name: 'mysql_query', status: 1, capability: 'text', promptHint: null, isCore: 0, toolset: 'mysql' },
|
|||
|
|
]),
|
|||
|
|
} as any;
|
|||
|
|
service.skillLoader = {} as any;
|
|||
|
|
service.dataSourceService = {} as any;
|
|||
|
|
service.mysqlIntrospection = {} as any;
|
|||
|
|
service.mysqlQuery = {} as any;
|
|||
|
|
|
|||
|
|
const result = await service.resolve({
|
|||
|
|
agent: {
|
|||
|
|
tools: {
|
|||
|
|
inheritCoreTools: false,
|
|||
|
|
enabled: ['mysql_query'],
|
|||
|
|
disabled: [],
|
|||
|
|
},
|
|||
|
|
} as any,
|
|||
|
|
modelCapability: 'text',
|
|||
|
|
delegationRole: 'subagent',
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
expect(result.toolNames).toEqual(['mysql_query']);
|
|||
|
|
expect(result.tools.map(tool => tool.name)).toEqual(['mysql_query']);
|
|||
|
|
expect(result.toolManifest).toEqual([
|
|||
|
|
expect.objectContaining({
|
|||
|
|
name: 'mysql_query',
|
|||
|
|
kind: 'custom',
|
|||
|
|
executionMode: 'parallel',
|
|||
|
|
supportedInWorker: false,
|
|||
|
|
workerRoutingHint: 'main-process-proxy',
|
|||
|
|
}),
|
|||
|
|
]);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('keeps legacy toolset config compatible when new fields are absent', async () => {
|
|||
|
|
service.toolRegistry = {
|
|||
|
|
all: jest.fn().mockResolvedValue([
|
|||
|
|
{ name: 'bash', status: 1, capability: 'text', promptHint: null, isCore: 0, toolset: 'base' },
|
|||
|
|
{ name: 'todo', status: 1, capability: 'text', promptHint: null, isCore: 1, toolset: 'planning' },
|
|||
|
|
{ name: 'clarify', status: 1, capability: 'text', promptHint: null, isCore: 1, toolset: 'interaction' },
|
|||
|
|
]),
|
|||
|
|
} as any;
|
|||
|
|
service.skillLoader = {} as any;
|
|||
|
|
|
|||
|
|
const result = await service.resolve({
|
|||
|
|
agent: {
|
|||
|
|
toolsets: ['base'],
|
|||
|
|
tools: { disabled: [] },
|
|||
|
|
} as any,
|
|||
|
|
modelCapability: 'text',
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
expect(result.toolNames).toEqual(['bash']);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('instantiates session delegation tools for supervisor role from session delegation context when enabled', async () => {
|
|||
|
|
service.toolRegistry = {
|
|||
|
|
all: jest.fn().mockResolvedValue([
|
|||
|
|
{ name: 'bash', status: 1, capability: 'text', promptHint: null, isCore: 0, toolset: 'base' },
|
|||
|
|
{ name: 'delegate_task', status: 1, capability: 'text', promptHint: null, isCore: 0, toolset: 'crew' },
|
|||
|
|
{ name: 'delegate_parallel', status: 1, capability: 'text', promptHint: null, isCore: 0, toolset: 'crew' },
|
|||
|
|
]),
|
|||
|
|
} as any;
|
|||
|
|
service.skillLoader = {} as any;
|
|||
|
|
|
|||
|
|
const memberAgent = { name: 'worker' };
|
|||
|
|
const runSingle = jest.fn().mockResolvedValue({ status: 'completed', result: 'session-ok' });
|
|||
|
|
const runBatch = jest.fn().mockResolvedValue([{ status: 'completed', result: 'parallel-ok' }]);
|
|||
|
|
const subagentContext = {
|
|||
|
|
kind: 'session-subagent',
|
|||
|
|
sessionId: 'session-1',
|
|||
|
|
parentMessageId: 202,
|
|||
|
|
memberAgents: [memberAgent],
|
|||
|
|
runSingle,
|
|||
|
|
runBatch,
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const result = await service.resolve({
|
|||
|
|
agent: {
|
|||
|
|
toolsets: ['base'],
|
|||
|
|
tools: { disabled: [] },
|
|||
|
|
subagentConfig: { enabled: true },
|
|||
|
|
} as any,
|
|||
|
|
modelCapability: 'text',
|
|||
|
|
delegationRole: 'supervisor',
|
|||
|
|
subagentContext,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
expect(result.toolNames).toEqual(
|
|||
|
|
expect.arrayContaining(['bash', 'delegate_task', 'delegate_parallel'])
|
|||
|
|
);
|
|||
|
|
expect(result.tools.map(tool => tool.name)).toEqual(
|
|||
|
|
expect.arrayContaining(['bash', 'delegate_task', 'delegate_parallel'])
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
const delegateTaskTool = result.tools.find(tool => tool.name === 'delegate_task');
|
|||
|
|
const delegateParallelTool = result.tools.find(tool => tool.name === 'delegate_parallel');
|
|||
|
|
|
|||
|
|
expect(delegateTaskTool).toBeDefined();
|
|||
|
|
expect(delegateParallelTool).toBeDefined();
|
|||
|
|
|
|||
|
|
const taskResult = await delegateTaskTool!.execute('1', {
|
|||
|
|
agent_name: 'worker',
|
|||
|
|
task_description: 'do work',
|
|||
|
|
});
|
|||
|
|
const parallelResult = await delegateParallelTool!.execute('2', {
|
|||
|
|
tasks: [{ agent_name: 'worker', task_description: 'do work' }],
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
expect(runSingle).toHaveBeenCalledWith(
|
|||
|
|
{
|
|||
|
|
goal: 'do work',
|
|||
|
|
context: undefined,
|
|||
|
|
mode: undefined,
|
|||
|
|
agentName: 'worker',
|
|||
|
|
agentId: null,
|
|||
|
|
toolNames: undefined,
|
|||
|
|
},
|
|||
|
|
{ parentToolCallId: '1' }
|
|||
|
|
);
|
|||
|
|
expect(runBatch).toHaveBeenCalledWith(
|
|||
|
|
[{
|
|||
|
|
goal: 'do work',
|
|||
|
|
context: undefined,
|
|||
|
|
mode: undefined,
|
|||
|
|
agentName: 'worker',
|
|||
|
|
agentId: null,
|
|||
|
|
toolNames: undefined,
|
|||
|
|
}],
|
|||
|
|
{ parentToolCallId: '2', mode: 'parallel' }
|
|||
|
|
);
|
|||
|
|
expect(taskResult).toEqual({
|
|||
|
|
type: 'text',
|
|||
|
|
text: JSON.stringify({
|
|||
|
|
delegationMode: 'session-subagent',
|
|||
|
|
relayPolicy: {
|
|||
|
|
preserveExactValues: true,
|
|||
|
|
noParaphrase: true,
|
|||
|
|
noNormalization: true,
|
|||
|
|
instructions: [
|
|||
|
|
'Use the delegated result as the source of truth.',
|
|||
|
|
'Do not rewrite filenames, paths, counts, IDs, or other enumerated values.',
|
|||
|
|
'If you present delegated items to the user, copy them exactly from `result`.',
|
|||
|
|
],
|
|||
|
|
},
|
|||
|
|
result: { status: 'completed', result: 'session-ok' },
|
|||
|
|
}),
|
|||
|
|
});
|
|||
|
|
expect(parallelResult).toEqual({
|
|||
|
|
type: 'text',
|
|||
|
|
text: JSON.stringify({
|
|||
|
|
delegationMode: 'session-subagent',
|
|||
|
|
relayPolicy: {
|
|||
|
|
preserveExactValues: true,
|
|||
|
|
noParaphrase: true,
|
|||
|
|
noNormalization: true,
|
|||
|
|
instructions: [
|
|||
|
|
'Use the delegated results as the source of truth.',
|
|||
|
|
'Read `taskResults` from top to bottom and summarize each entry independently.',
|
|||
|
|
'Each `taskResults[i]` already binds one task to exactly one delegated result.',
|
|||
|
|
'Do not mix evidence across different `taskResults` entries.',
|
|||
|
|
'Do not rewrite filenames, paths, counts, IDs, or other enumerated values.',
|
|||
|
|
'If you present delegated items to the user, copy them exactly from the paired `taskResults[i].result`.',
|
|||
|
|
],
|
|||
|
|
},
|
|||
|
|
taskResults: [{
|
|||
|
|
sortOrder: 0,
|
|||
|
|
task: {
|
|||
|
|
sortOrder: 0,
|
|||
|
|
goal: 'do work',
|
|||
|
|
context: undefined,
|
|||
|
|
mode: undefined,
|
|||
|
|
agentName: 'worker',
|
|||
|
|
agentId: null,
|
|||
|
|
toolNames: undefined,
|
|||
|
|
},
|
|||
|
|
result: { status: 'completed', result: 'parallel-ok' },
|
|||
|
|
}],
|
|||
|
|
}),
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('delegate_task uses session subagent context without crew-shaped _delegate and returns structured subagent data', async () => {
|
|||
|
|
service.toolRegistry = {
|
|||
|
|
all: jest.fn().mockResolvedValue([
|
|||
|
|
{ name: 'delegate_task', status: 1, capability: 'text', promptHint: null, isCore: 0, toolset: 'crew' },
|
|||
|
|
]),
|
|||
|
|
} as any;
|
|||
|
|
service.skillLoader = {} as any;
|
|||
|
|
|
|||
|
|
const runSingle = jest.fn().mockResolvedValue({
|
|||
|
|
id: 601,
|
|||
|
|
sessionId: 'session-1',
|
|||
|
|
parentMessageId: 202,
|
|||
|
|
status: 'completed',
|
|||
|
|
name: 'planner',
|
|||
|
|
goal: 'draft plan',
|
|||
|
|
summary: 'structured summary',
|
|||
|
|
error: null,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const result = await service.resolve({
|
|||
|
|
agent: {
|
|||
|
|
tools: {
|
|||
|
|
inheritCoreTools: false,
|
|||
|
|
enabled: ['delegate_task'],
|
|||
|
|
disabled: [],
|
|||
|
|
},
|
|||
|
|
subagentConfig: { enabled: true },
|
|||
|
|
} as any,
|
|||
|
|
modelCapability: 'text',
|
|||
|
|
delegationRole: 'supervisor',
|
|||
|
|
subagentContext: {
|
|||
|
|
kind: 'session-subagent',
|
|||
|
|
sessionId: 'session-1',
|
|||
|
|
parentMessageId: 202,
|
|||
|
|
memberAgents: [],
|
|||
|
|
runSingle,
|
|||
|
|
runBatch: jest.fn(),
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const delegateTaskTool = result.tools.find(tool => tool.name === 'delegate_task');
|
|||
|
|
expect(delegateTaskTool).toBeDefined();
|
|||
|
|
|
|||
|
|
const toolResult = await delegateTaskTool!.execute('call-1', {
|
|||
|
|
goal: 'draft plan',
|
|||
|
|
context: 'focus on API boundaries',
|
|||
|
|
mode: 'preset',
|
|||
|
|
agentName: 'planner',
|
|||
|
|
toolNames: ['bash'],
|
|||
|
|
} as any);
|
|||
|
|
|
|||
|
|
expect(runSingle).toHaveBeenCalledWith(
|
|||
|
|
expect.objectContaining({
|
|||
|
|
goal: 'draft plan',
|
|||
|
|
context: 'focus on API boundaries',
|
|||
|
|
mode: 'preset',
|
|||
|
|
agentName: 'planner',
|
|||
|
|
toolNames: ['bash'],
|
|||
|
|
}),
|
|||
|
|
{ parentToolCallId: 'call-1' }
|
|||
|
|
);
|
|||
|
|
expect(toolResult).toEqual({
|
|||
|
|
type: 'text',
|
|||
|
|
text: JSON.stringify({
|
|||
|
|
delegationMode: 'session-subagent',
|
|||
|
|
relayPolicy: {
|
|||
|
|
preserveExactValues: true,
|
|||
|
|
noParaphrase: true,
|
|||
|
|
noNormalization: true,
|
|||
|
|
instructions: [
|
|||
|
|
'Use the delegated result as the source of truth.',
|
|||
|
|
'Do not rewrite filenames, paths, counts, IDs, or other enumerated values.',
|
|||
|
|
'If you present delegated items to the user, copy them exactly from `result`.',
|
|||
|
|
],
|
|||
|
|
},
|
|||
|
|
result: {
|
|||
|
|
id: 601,
|
|||
|
|
sessionId: 'session-1',
|
|||
|
|
parentMessageId: 202,
|
|||
|
|
status: 'completed',
|
|||
|
|
name: 'planner',
|
|||
|
|
goal: 'draft plan',
|
|||
|
|
summary: 'structured summary',
|
|||
|
|
error: null,
|
|||
|
|
},
|
|||
|
|
}),
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('delegate_parallel uses session subagent batch runner and preserves partial failures', async () => {
|
|||
|
|
service.toolRegistry = {
|
|||
|
|
all: jest.fn().mockResolvedValue([
|
|||
|
|
{ name: 'delegate_parallel', status: 1, capability: 'text', promptHint: null, isCore: 0, toolset: 'crew' },
|
|||
|
|
]),
|
|||
|
|
} as any;
|
|||
|
|
service.skillLoader = {} as any;
|
|||
|
|
|
|||
|
|
const runBatch = jest.fn().mockResolvedValue([
|
|||
|
|
{
|
|||
|
|
id: 701,
|
|||
|
|
sortOrder: 0,
|
|||
|
|
status: 'completed',
|
|||
|
|
name: 'worker-a',
|
|||
|
|
goal: 'task-a',
|
|||
|
|
summary: 'ok',
|
|||
|
|
error: null,
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
id: 702,
|
|||
|
|
sortOrder: 1,
|
|||
|
|
status: 'failed',
|
|||
|
|
name: 'worker-b',
|
|||
|
|
goal: 'task-b',
|
|||
|
|
summary: '',
|
|||
|
|
error: 'runner exploded',
|
|||
|
|
},
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
const result = await service.resolve({
|
|||
|
|
agent: {
|
|||
|
|
tools: {
|
|||
|
|
inheritCoreTools: false,
|
|||
|
|
enabled: ['delegate_parallel'],
|
|||
|
|
disabled: [],
|
|||
|
|
},
|
|||
|
|
subagentConfig: { enabled: true },
|
|||
|
|
} as any,
|
|||
|
|
modelCapability: 'text',
|
|||
|
|
delegationRole: 'supervisor',
|
|||
|
|
subagentContext: {
|
|||
|
|
kind: 'session-subagent',
|
|||
|
|
sessionId: 'session-1',
|
|||
|
|
parentMessageId: 202,
|
|||
|
|
memberAgents: [],
|
|||
|
|
runSingle: jest.fn(),
|
|||
|
|
runBatch,
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const delegateParallelTool = result.tools.find(tool => tool.name === 'delegate_parallel');
|
|||
|
|
expect(delegateParallelTool).toBeDefined();
|
|||
|
|
|
|||
|
|
const toolResult = await delegateParallelTool!.execute('call-2', {
|
|||
|
|
tasks: [
|
|||
|
|
{ goal: 'task-a', agentName: 'worker-a', context: 'first' },
|
|||
|
|
{ goal: 'task-b', agentName: 'worker-b', context: 'second' },
|
|||
|
|
],
|
|||
|
|
} as any);
|
|||
|
|
|
|||
|
|
expect(runBatch).toHaveBeenCalledWith(
|
|||
|
|
[
|
|||
|
|
{ goal: 'task-a', agentName: 'worker-a', context: 'first', mode: undefined, agentId: null, toolNames: undefined },
|
|||
|
|
{ goal: 'task-b', agentName: 'worker-b', context: 'second', mode: undefined, agentId: null, toolNames: undefined },
|
|||
|
|
],
|
|||
|
|
{ parentToolCallId: 'call-2', mode: 'parallel' }
|
|||
|
|
);
|
|||
|
|
expect(toolResult).toEqual({
|
|||
|
|
type: 'text',
|
|||
|
|
text: JSON.stringify({
|
|||
|
|
delegationMode: 'session-subagent',
|
|||
|
|
relayPolicy: {
|
|||
|
|
preserveExactValues: true,
|
|||
|
|
noParaphrase: true,
|
|||
|
|
noNormalization: true,
|
|||
|
|
instructions: [
|
|||
|
|
'Use the delegated results as the source of truth.',
|
|||
|
|
'Read `taskResults` from top to bottom and summarize each entry independently.',
|
|||
|
|
'Each `taskResults[i]` already binds one task to exactly one delegated result.',
|
|||
|
|
'Do not mix evidence across different `taskResults` entries.',
|
|||
|
|
'Do not rewrite filenames, paths, counts, IDs, or other enumerated values.',
|
|||
|
|
'If you present delegated items to the user, copy them exactly from the paired `taskResults[i].result`.',
|
|||
|
|
],
|
|||
|
|
},
|
|||
|
|
taskResults: [
|
|||
|
|
{
|
|||
|
|
sortOrder: 0,
|
|||
|
|
task: { sortOrder: 0, goal: 'task-a', context: 'first', agentName: 'worker-a', agentId: null },
|
|||
|
|
result: {
|
|||
|
|
id: 701,
|
|||
|
|
sortOrder: 0,
|
|||
|
|
status: 'completed',
|
|||
|
|
name: 'worker-a',
|
|||
|
|
goal: 'task-a',
|
|||
|
|
summary: 'ok',
|
|||
|
|
error: null,
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
sortOrder: 1,
|
|||
|
|
task: { sortOrder: 1, goal: 'task-b', context: 'second', agentName: 'worker-b', agentId: null },
|
|||
|
|
result: {
|
|||
|
|
id: 702,
|
|||
|
|
sortOrder: 1,
|
|||
|
|
status: 'failed',
|
|||
|
|
name: 'worker-b',
|
|||
|
|
goal: 'task-b',
|
|||
|
|
summary: '',
|
|||
|
|
error: 'runner exploded',
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
],
|
|||
|
|
}),
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('injects preset subagent selection guidance into delegate tool hints for supervisor role', async () => {
|
|||
|
|
service.toolRegistry = {
|
|||
|
|
all: jest.fn().mockResolvedValue([
|
|||
|
|
{ name: 'delegate_task', status: 1, capability: 'text', promptHint: null, isCore: 0, toolset: 'crew' },
|
|||
|
|
{ name: 'delegate_parallel', status: 1, capability: 'text', promptHint: null, isCore: 0, toolset: 'crew' },
|
|||
|
|
]),
|
|||
|
|
} as any;
|
|||
|
|
service.skillLoader = {} as any;
|
|||
|
|
|
|||
|
|
const result = await service.resolve({
|
|||
|
|
agent: {
|
|||
|
|
tools: {
|
|||
|
|
inheritCoreTools: false,
|
|||
|
|
enabled: ['delegate_task', 'delegate_parallel'],
|
|||
|
|
disabled: [],
|
|||
|
|
},
|
|||
|
|
subagentConfig: { enabled: true },
|
|||
|
|
} as any,
|
|||
|
|
modelCapability: 'text',
|
|||
|
|
delegationRole: 'supervisor',
|
|||
|
|
subagentContext: {
|
|||
|
|
kind: 'session-subagent',
|
|||
|
|
sessionId: 'session-1',
|
|||
|
|
parentMessageId: 202,
|
|||
|
|
memberAgents: [
|
|||
|
|
{ id: 11, name: 'agent_file_search', label: '文件检索专家' },
|
|||
|
|
{ id: 12, name: 'agent_excel_scan', label: 'Excel扫描专家' },
|
|||
|
|
],
|
|||
|
|
runSingle: jest.fn(),
|
|||
|
|
runBatch: jest.fn(),
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
expect(result.toolPromptHints.delegate_task).toContain('文件检索专家');
|
|||
|
|
expect(result.toolPromptHints.delegate_task).toContain('Excel扫描专家');
|
|||
|
|
expect(result.toolPromptHints.delegate_task).toContain('must choose the most suitable one yourself');
|
|||
|
|
expect(result.toolPromptHints.delegate_task).toContain('mode: "preset"');
|
|||
|
|
expect(result.toolPromptHints.delegate_task).toContain('For parallel delegation, read `taskResults` sequentially and summarize each entry independently.');
|
|||
|
|
expect(result.toolPromptHints.delegate_parallel).toContain('agent_excel_scan');
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('wraps delegated garbled-looking filenames as exact relay data instead of flattening them', async () => {
|
|||
|
|
service.toolRegistry = {
|
|||
|
|
all: jest.fn().mockResolvedValue([
|
|||
|
|
{ name: 'delegate_parallel', status: 1, capability: 'text', promptHint: null, isCore: 0, toolset: 'crew' },
|
|||
|
|
]),
|
|||
|
|
} as any;
|
|||
|
|
service.skillLoader = {} as any;
|
|||
|
|
|
|||
|
|
const runBatch = jest.fn().mockResolvedValue([
|
|||
|
|
{
|
|||
|
|
id: 801,
|
|||
|
|
sortOrder: 0,
|
|||
|
|
status: 'completed',
|
|||
|
|
name: 'agent_file_search',
|
|||
|
|
goal: 'list xlsx files',
|
|||
|
|
summary: 'C:\\Users\\lixin\\Desktop\\2.26.xlsx\nC:\\Users\\lixin\\Desktop\\2ͷ-μݼ.xlsx',
|
|||
|
|
error: null,
|
|||
|
|
resultPayload: {
|
|||
|
|
finalContent: 'C:\\Users\\lixin\\Desktop\\2.26.xlsx\nC:\\Users\\lixin\\Desktop\\2ͷ-μݼ.xlsx',
|
|||
|
|
toolCallCount: 1,
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
const result = await service.resolve({
|
|||
|
|
agent: {
|
|||
|
|
tools: {
|
|||
|
|
inheritCoreTools: false,
|
|||
|
|
enabled: ['delegate_parallel'],
|
|||
|
|
disabled: [],
|
|||
|
|
},
|
|||
|
|
subagentConfig: { enabled: true },
|
|||
|
|
} as any,
|
|||
|
|
modelCapability: 'text',
|
|||
|
|
delegationRole: 'supervisor',
|
|||
|
|
subagentContext: {
|
|||
|
|
kind: 'session-subagent',
|
|||
|
|
sessionId: 'session-1',
|
|||
|
|
parentMessageId: 202,
|
|||
|
|
memberAgents: [],
|
|||
|
|
runSingle: jest.fn(),
|
|||
|
|
runBatch,
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const delegateParallelTool = result.tools.find(tool => tool.name === 'delegate_parallel');
|
|||
|
|
const toolResult = await delegateParallelTool!.execute('call-3', {
|
|||
|
|
tasks: [{ goal: 'list xlsx files', mode: 'preset', agentName: 'agent_file_search' }],
|
|||
|
|
} as any);
|
|||
|
|
|
|||
|
|
expect(toolResult).toEqual({
|
|||
|
|
type: 'text',
|
|||
|
|
text: JSON.stringify({
|
|||
|
|
delegationMode: 'session-subagent',
|
|||
|
|
relayPolicy: {
|
|||
|
|
preserveExactValues: true,
|
|||
|
|
noParaphrase: true,
|
|||
|
|
noNormalization: true,
|
|||
|
|
instructions: [
|
|||
|
|
'Use the delegated results as the source of truth.',
|
|||
|
|
'Read `taskResults` from top to bottom and summarize each entry independently.',
|
|||
|
|
'Each `taskResults[i]` already binds one task to exactly one delegated result.',
|
|||
|
|
'Do not mix evidence across different `taskResults` entries.',
|
|||
|
|
'Do not rewrite filenames, paths, counts, IDs, or other enumerated values.',
|
|||
|
|
'If you present delegated items to the user, copy them exactly from the paired `taskResults[i].result`.',
|
|||
|
|
],
|
|||
|
|
},
|
|||
|
|
taskResults: [
|
|||
|
|
{
|
|||
|
|
sortOrder: 0,
|
|||
|
|
task: {
|
|||
|
|
sortOrder: 0,
|
|||
|
|
goal: 'list xlsx files',
|
|||
|
|
mode: 'preset',
|
|||
|
|
agentName: 'agent_file_search',
|
|||
|
|
agentId: null,
|
|||
|
|
context: undefined,
|
|||
|
|
toolNames: undefined,
|
|||
|
|
},
|
|||
|
|
result: {
|
|||
|
|
id: 801,
|
|||
|
|
sortOrder: 0,
|
|||
|
|
status: 'completed',
|
|||
|
|
name: 'agent_file_search',
|
|||
|
|
goal: 'list xlsx files',
|
|||
|
|
summary: 'C:\\Users\\lixin\\Desktop\\2.26.xlsx\nC:\\Users\\lixin\\Desktop\\2ͷ-μݼ.xlsx',
|
|||
|
|
error: null,
|
|||
|
|
resultPayload: {
|
|||
|
|
finalContent: 'C:\\Users\\lixin\\Desktop\\2.26.xlsx\nC:\\Users\\lixin\\Desktop\\2ͷ-μݼ.xlsx',
|
|||
|
|
toolCallCount: 1,
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
],
|
|||
|
|
}),
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('wraps a longer first result and a shorter second result into taskResults entries without requiring array zipping', async () => {
|
|||
|
|
service.toolRegistry = {
|
|||
|
|
all: jest.fn().mockResolvedValue([
|
|||
|
|
{ name: 'delegate_parallel', status: 1, capability: 'text', promptHint: null, isCore: 0, toolset: 'crew' },
|
|||
|
|
]),
|
|||
|
|
} as any;
|
|||
|
|
service.skillLoader = {} as any;
|
|||
|
|
|
|||
|
|
const runBatch = jest.fn().mockResolvedValue([
|
|||
|
|
{
|
|||
|
|
id: 901,
|
|||
|
|
sortOrder: 0,
|
|||
|
|
status: 'completed',
|
|||
|
|
name: '444',
|
|||
|
|
goal: '检查桌面 .xlsx 文件',
|
|||
|
|
summary: '桌面共检测到5个.xlsx文件:\n1. 2.26加入服务期限.xlsx\n2. 2月客服部-何佳菁.xlsx\n3. 3.xlsx\n4. 3月客服部-何佳菁.xlsx\n5. 阿维塔正式结婚证核查结果(2026.4.27).xlsx',
|
|||
|
|
error: null,
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
id: 902,
|
|||
|
|
sortOrder: 1,
|
|||
|
|
status: 'completed',
|
|||
|
|
name: '编号89757',
|
|||
|
|
goal: '检查桌面 .pdf 文件',
|
|||
|
|
summary: '桌面上共有 1 个 PDF 文件:质量认证证书电子版.pdf',
|
|||
|
|
error: null,
|
|||
|
|
},
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
const result = await service.resolve({
|
|||
|
|
agent: {
|
|||
|
|
tools: {
|
|||
|
|
inheritCoreTools: false,
|
|||
|
|
enabled: ['delegate_parallel'],
|
|||
|
|
disabled: [],
|
|||
|
|
},
|
|||
|
|
subagentConfig: { enabled: true },
|
|||
|
|
} as any,
|
|||
|
|
modelCapability: 'text',
|
|||
|
|
delegationRole: 'supervisor',
|
|||
|
|
subagentContext: {
|
|||
|
|
kind: 'session-subagent',
|
|||
|
|
sessionId: 'session-1',
|
|||
|
|
parentMessageId: 202,
|
|||
|
|
memberAgents: [],
|
|||
|
|
runSingle: jest.fn(),
|
|||
|
|
runBatch,
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const delegateParallelTool = result.tools.find(tool => tool.name === 'delegate_parallel');
|
|||
|
|
const toolResult = await delegateParallelTool!.execute('call-4', {
|
|||
|
|
tasks: [
|
|||
|
|
{ goal: '检查桌面 .xlsx 文件', mode: 'preset', agentName: '444' },
|
|||
|
|
{ goal: '检查桌面 .pdf 文件', mode: 'preset', agentName: '编号89757' },
|
|||
|
|
],
|
|||
|
|
} as any);
|
|||
|
|
|
|||
|
|
expect(toolResult).toEqual({
|
|||
|
|
type: 'text',
|
|||
|
|
text: JSON.stringify({
|
|||
|
|
delegationMode: 'session-subagent',
|
|||
|
|
relayPolicy: {
|
|||
|
|
preserveExactValues: true,
|
|||
|
|
noParaphrase: true,
|
|||
|
|
noNormalization: true,
|
|||
|
|
instructions: [
|
|||
|
|
'Use the delegated results as the source of truth.',
|
|||
|
|
'Read `taskResults` from top to bottom and summarize each entry independently.',
|
|||
|
|
'Each `taskResults[i]` already binds one task to exactly one delegated result.',
|
|||
|
|
'Do not mix evidence across different `taskResults` entries.',
|
|||
|
|
'Do not rewrite filenames, paths, counts, IDs, or other enumerated values.',
|
|||
|
|
'If you present delegated items to the user, copy them exactly from the paired `taskResults[i].result`.',
|
|||
|
|
],
|
|||
|
|
},
|
|||
|
|
taskResults: [
|
|||
|
|
{
|
|||
|
|
sortOrder: 0,
|
|||
|
|
task: {
|
|||
|
|
sortOrder: 0,
|
|||
|
|
goal: '检查桌面 .xlsx 文件',
|
|||
|
|
mode: 'preset',
|
|||
|
|
agentName: '444',
|
|||
|
|
agentId: null,
|
|||
|
|
context: undefined,
|
|||
|
|
toolNames: undefined,
|
|||
|
|
},
|
|||
|
|
result: {
|
|||
|
|
id: 901,
|
|||
|
|
sortOrder: 0,
|
|||
|
|
status: 'completed',
|
|||
|
|
name: '444',
|
|||
|
|
goal: '检查桌面 .xlsx 文件',
|
|||
|
|
summary: '桌面共检测到5个.xlsx文件:\n1. 2.26加入服务期限.xlsx\n2. 2月客服部-何佳菁.xlsx\n3. 3.xlsx\n4. 3月客服部-何佳菁.xlsx\n5. 阿维塔正式结婚证核查结果(2026.4.27).xlsx',
|
|||
|
|
error: null,
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
sortOrder: 1,
|
|||
|
|
task: {
|
|||
|
|
sortOrder: 1,
|
|||
|
|
goal: '检查桌面 .pdf 文件',
|
|||
|
|
mode: 'preset',
|
|||
|
|
agentName: '编号89757',
|
|||
|
|
agentId: null,
|
|||
|
|
context: undefined,
|
|||
|
|
toolNames: undefined,
|
|||
|
|
},
|
|||
|
|
result: {
|
|||
|
|
id: 902,
|
|||
|
|
sortOrder: 1,
|
|||
|
|
status: 'completed',
|
|||
|
|
name: '编号89757',
|
|||
|
|
goal: '检查桌面 .pdf 文件',
|
|||
|
|
summary: '桌面上共有 1 个 PDF 文件:质量认证证书电子版.pdf',
|
|||
|
|
error: null,
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
],
|
|||
|
|
}),
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('hides delegation-only tools when no valid delegation context is active', async () => {
|
|||
|
|
service.toolRegistry = {
|
|||
|
|
all: jest.fn().mockResolvedValue([
|
|||
|
|
{ name: 'bash', status: 1, capability: 'text', promptHint: null, isCore: 0, toolset: 'base' },
|
|||
|
|
{ name: 'delegate_task', status: 1, capability: 'text', promptHint: null, isCore: 0, toolset: 'crew' },
|
|||
|
|
{ name: 'delegate_parallel', status: 1, capability: 'text', promptHint: null, isCore: 0, toolset: 'crew' },
|
|||
|
|
{ name: 'escalate', status: 1, capability: 'text', promptHint: null, isCore: 0, toolset: 'crew' },
|
|||
|
|
]),
|
|||
|
|
} as any;
|
|||
|
|
service.skillLoader = {} as any;
|
|||
|
|
|
|||
|
|
const result = await service.resolve({
|
|||
|
|
agent: {
|
|||
|
|
tools: {
|
|||
|
|
inheritCoreTools: false,
|
|||
|
|
enabled: ['bash', 'delegate_task', 'delegate_parallel', 'escalate'],
|
|||
|
|
disabled: [],
|
|||
|
|
},
|
|||
|
|
} as any,
|
|||
|
|
modelCapability: 'text',
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
expect(result.toolNames).toEqual(['bash']);
|
|||
|
|
expect(result.tools.map(tool => tool.name)).toEqual(['bash']);
|
|||
|
|
expect(result.disabledReasons).toEqual(
|
|||
|
|
expect.arrayContaining([
|
|||
|
|
{ name: 'delegate_task', reason: 'delegation_context_required' },
|
|||
|
|
{ name: 'delegate_parallel', reason: 'delegation_context_required' },
|
|||
|
|
{ name: 'escalate', reason: 'crew_context_required' },
|
|||
|
|
])
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('instantiates delegate tools at most once with crew precedence when crew master and supervisor flags are both present', async () => {
|
|||
|
|
service.toolRegistry = {
|
|||
|
|
all: jest.fn().mockResolvedValue([
|
|||
|
|
{ name: 'delegate_task', status: 1, capability: 'text', promptHint: null, isCore: 0, toolset: 'crew' },
|
|||
|
|
{ name: 'delegate_parallel', status: 1, capability: 'text', promptHint: null, isCore: 0, toolset: 'crew' },
|
|||
|
|
]),
|
|||
|
|
} as any;
|
|||
|
|
service.skillLoader = {} as any;
|
|||
|
|
|
|||
|
|
const crewMember = { name: 'crew-worker' };
|
|||
|
|
const crewContext = {
|
|||
|
|
memberAgents: [crewMember],
|
|||
|
|
_delegate: {
|
|||
|
|
executeSubAgent: jest.fn().mockResolvedValue({ status: 'completed', result: 'crew-ok' }),
|
|||
|
|
executeParallel: jest.fn().mockResolvedValue([{ status: 'completed', result: 'crew-parallel-ok' }]),
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
const sessionContext = {
|
|||
|
|
kind: 'session-subagent',
|
|||
|
|
memberAgents: [{ name: 'session-worker' }],
|
|||
|
|
runSingle: jest.fn().mockResolvedValue({ status: 'completed', result: 'session-ok' }),
|
|||
|
|
runBatch: jest.fn().mockResolvedValue([{ status: 'completed', result: 'session-parallel-ok' }]),
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const result = await service.resolve({
|
|||
|
|
agent: {
|
|||
|
|
toolsets: [],
|
|||
|
|
tools: { disabled: [] },
|
|||
|
|
subagentConfig: { enabled: true },
|
|||
|
|
} as any,
|
|||
|
|
modelCapability: 'text',
|
|||
|
|
crewRole: 'master',
|
|||
|
|
delegationRole: 'supervisor',
|
|||
|
|
crewContext,
|
|||
|
|
subagentContext: sessionContext,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
expect(result.toolNames).toEqual(
|
|||
|
|
expect.arrayContaining(['delegate_task', 'delegate_parallel'])
|
|||
|
|
);
|
|||
|
|
expect(result.tools.filter(tool => tool.name === 'delegate_task')).toHaveLength(1);
|
|||
|
|
expect(result.tools.filter(tool => tool.name === 'delegate_parallel')).toHaveLength(1);
|
|||
|
|
|
|||
|
|
const delegateTaskTool = result.tools.find(tool => tool.name === 'delegate_task');
|
|||
|
|
const delegateParallelTool = result.tools.find(tool => tool.name === 'delegate_parallel');
|
|||
|
|
|
|||
|
|
const taskResult = await delegateTaskTool!.execute('1', {
|
|||
|
|
agent_name: 'crew-worker',
|
|||
|
|
task_description: 'crew work',
|
|||
|
|
});
|
|||
|
|
const parallelResult = await delegateParallelTool!.execute('2', {
|
|||
|
|
tasks: [{ agent_name: 'crew-worker', task_description: 'parallel crew work' }],
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
expect(crewContext._delegate.executeSubAgent).toHaveBeenCalledWith(
|
|||
|
|
crewContext,
|
|||
|
|
crewMember,
|
|||
|
|
'crew work',
|
|||
|
|
undefined,
|
|||
|
|
);
|
|||
|
|
expect(crewContext._delegate.executeParallel).toHaveBeenCalledWith(
|
|||
|
|
crewContext,
|
|||
|
|
[{ agent_name: 'crew-worker', task_description: 'parallel crew work' }],
|
|||
|
|
);
|
|||
|
|
expect(sessionContext.runSingle).not.toHaveBeenCalled();
|
|||
|
|
expect(sessionContext.runBatch).not.toHaveBeenCalled();
|
|||
|
|
expect(taskResult).toEqual({
|
|||
|
|
type: 'text',
|
|||
|
|
text: JSON.stringify({ status: 'completed', result: 'crew-ok' }),
|
|||
|
|
});
|
|||
|
|
expect(parallelResult).toEqual({
|
|||
|
|
type: 'text',
|
|||
|
|
text: JSON.stringify([{ status: 'completed', result: 'crew-parallel-ok' }]),
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('does not expose crew-only escalate for session supervisors', async () => {
|
|||
|
|
service.toolRegistry = {
|
|||
|
|
all: jest.fn().mockResolvedValue([
|
|||
|
|
{ name: 'delegate_task', status: 1, capability: 'text', promptHint: null, isCore: 0, toolset: 'crew' },
|
|||
|
|
{ name: 'delegate_parallel', status: 1, capability: 'text', promptHint: null, isCore: 0, toolset: 'crew' },
|
|||
|
|
{ name: 'escalate', status: 1, capability: 'text', promptHint: null, isCore: 0, toolset: 'crew' },
|
|||
|
|
]),
|
|||
|
|
} as any;
|
|||
|
|
service.skillLoader = {} as any;
|
|||
|
|
|
|||
|
|
const result = await service.resolve({
|
|||
|
|
agent: {
|
|||
|
|
tools: {
|
|||
|
|
inheritCoreTools: false,
|
|||
|
|
enabled: ['delegate_task', 'delegate_parallel', 'escalate'],
|
|||
|
|
disabled: [],
|
|||
|
|
},
|
|||
|
|
subagentConfig: { enabled: true },
|
|||
|
|
} as any,
|
|||
|
|
modelCapability: 'text',
|
|||
|
|
delegationRole: 'supervisor',
|
|||
|
|
subagentContext: {
|
|||
|
|
kind: 'session-subagent',
|
|||
|
|
memberAgents: [{ name: 'worker' }],
|
|||
|
|
runSingle: jest.fn(),
|
|||
|
|
runBatch: jest.fn(),
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
expect(result.toolNames).toEqual(expect.arrayContaining(['delegate_task', 'delegate_parallel']));
|
|||
|
|
expect(result.toolNames).not.toContain('escalate');
|
|||
|
|
expect(result.tools.map(tool => tool.name)).toEqual(['delegate_task', 'delegate_parallel']);
|
|||
|
|
expect(result.disabledReasons).toEqual(
|
|||
|
|
expect.arrayContaining([{ name: 'escalate', reason: 'crew_context_required' }])
|
|||
|
|
);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('strips delegation clarify and memory tools for subagent role while preserving ordinary tools', async () => {
|
|||
|
|
service.toolRegistry = {
|
|||
|
|
all: jest.fn().mockResolvedValue([
|
|||
|
|
{ name: 'bash', status: 1, capability: 'text', promptHint: null, isCore: 0, toolset: 'base' },
|
|||
|
|
{ name: 'clarify', status: 1, capability: 'text', promptHint: null, isCore: 1, toolset: 'interaction' },
|
|||
|
|
{ name: 'memory_save', status: 1, capability: 'text', promptHint: null, isCore: 0, toolset: 'memory' },
|
|||
|
|
{ name: 'memory_recall', status: 1, capability: 'text', promptHint: null, isCore: 0, toolset: 'memory' },
|
|||
|
|
{ name: 'delegate_task', status: 1, capability: 'text', promptHint: null, isCore: 0, toolset: 'crew' },
|
|||
|
|
{ name: 'delegate_parallel', status: 1, capability: 'text', promptHint: null, isCore: 0, toolset: 'crew' },
|
|||
|
|
{ name: 'escalate', status: 1, capability: 'text', promptHint: null, isCore: 0, toolset: 'crew' },
|
|||
|
|
]),
|
|||
|
|
} as any;
|
|||
|
|
service.skillLoader = {} as any;
|
|||
|
|
service.memoryProviderRegistry = {
|
|||
|
|
getProvider: jest.fn().mockResolvedValue({ provider: {} }),
|
|||
|
|
} as any;
|
|||
|
|
|
|||
|
|
const result = await service.resolve({
|
|||
|
|
agent: {
|
|||
|
|
tools: {
|
|||
|
|
inheritCoreTools: false,
|
|||
|
|
enabled: [
|
|||
|
|
'bash',
|
|||
|
|
'clarify',
|
|||
|
|
'memory_save',
|
|||
|
|
'memory_recall',
|
|||
|
|
'delegate_task',
|
|||
|
|
'delegate_parallel',
|
|||
|
|
'escalate',
|
|||
|
|
],
|
|||
|
|
disabled: [],
|
|||
|
|
},
|
|||
|
|
config: {
|
|||
|
|
memory: {
|
|||
|
|
enabled: true,
|
|||
|
|
backend: 'mysql',
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
} as any,
|
|||
|
|
modelCapability: 'text',
|
|||
|
|
memoryEnabled: true,
|
|||
|
|
delegationRole: 'subagent',
|
|||
|
|
subagentContext: {
|
|||
|
|
memberAgents: [{ name: 'worker' }],
|
|||
|
|
_delegate: {
|
|||
|
|
executeSubAgent: jest.fn(),
|
|||
|
|
executeParallel: jest.fn(),
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 当前行为:toolNames 会按 subagent 过滤成仅保留 bash,
|
|||
|
|
// 但运行时仍会在 memoryEnabled=true 时追加 memory 工具实例。
|
|||
|
|
expect(result.toolNames).toEqual(['bash']);
|
|||
|
|
expect(result.tools.map(tool => tool.name)).toEqual(['bash', 'memory_save', 'memory_recall', 'memory_list_types', 'memory_stats']);
|
|||
|
|
expect(result.builtinToolNames).toEqual(['bash']);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('keeps crew master delegation behavior for crew context', async () => {
|
|||
|
|
service.toolRegistry = {
|
|||
|
|
all: jest.fn().mockResolvedValue([
|
|||
|
|
{ name: 'delegate_task', status: 1, capability: 'text', promptHint: null, isCore: 0, toolset: 'crew' },
|
|||
|
|
]),
|
|||
|
|
} as any;
|
|||
|
|
service.skillLoader = {} as any;
|
|||
|
|
|
|||
|
|
const crewContext = {
|
|||
|
|
memberAgents: [{ name: 'worker' }],
|
|||
|
|
_delegate: {
|
|||
|
|
executeSubAgent: jest.fn().mockResolvedValue({ status: 'completed', result: 'crew-ok' }),
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const result = await service.resolve({
|
|||
|
|
agent: {
|
|||
|
|
toolsets: [],
|
|||
|
|
tools: { disabled: [] },
|
|||
|
|
} as any,
|
|||
|
|
modelCapability: 'text',
|
|||
|
|
crewRole: 'master',
|
|||
|
|
crewContext,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
expect(result.toolNames).toContain('delegate_task');
|
|||
|
|
|
|||
|
|
const delegateTaskTool = result.tools.find(tool => tool.name === 'delegate_task');
|
|||
|
|
expect(delegateTaskTool).toBeDefined();
|
|||
|
|
|
|||
|
|
const taskResult = await delegateTaskTool!.execute('1', {
|
|||
|
|
agent_name: 'worker',
|
|||
|
|
task_description: 'crew work',
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
expect(crewContext._delegate.executeSubAgent).toHaveBeenCalledWith(
|
|||
|
|
crewContext,
|
|||
|
|
crewContext.memberAgents[0],
|
|||
|
|
'crew work',
|
|||
|
|
undefined,
|
|||
|
|
);
|
|||
|
|
expect(taskResult).toEqual({
|
|||
|
|
type: 'text',
|
|||
|
|
text: JSON.stringify({ status: 'completed', result: 'crew-ok' }),
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('passes injected operations into builtin tool factories', async () => {
|
|||
|
|
service.toolRegistry = {
|
|||
|
|
all: jest.fn().mockResolvedValue([
|
|||
|
|
{ name: 'read_file', status: 1, capability: 'text', promptHint: null, isCore: 0, toolset: 'base' },
|
|||
|
|
]),
|
|||
|
|
} as any;
|
|||
|
|
service.skillLoader = {} as any;
|
|||
|
|
|
|||
|
|
const readFile = jest.fn().mockResolvedValue(Buffer.from('injected-from-ops', 'utf-8'));
|
|||
|
|
const operations = {
|
|||
|
|
file: {
|
|||
|
|
readFile,
|
|||
|
|
writeFile: jest.fn(),
|
|||
|
|
appendFile: jest.fn(),
|
|||
|
|
access: jest.fn().mockResolvedValue(undefined),
|
|||
|
|
isDirectory: jest.fn().mockResolvedValue(false),
|
|||
|
|
readDir: jest.fn().mockResolvedValue([]),
|
|||
|
|
mkdir: jest.fn().mockResolvedValue(undefined),
|
|||
|
|
realpath: jest.fn().mockImplementation(async (p: string) => p),
|
|||
|
|
},
|
|||
|
|
process: {
|
|||
|
|
exec: jest.fn(),
|
|||
|
|
},
|
|||
|
|
search: {
|
|||
|
|
ripgrep: jest.fn(),
|
|||
|
|
fd: jest.fn(),
|
|||
|
|
},
|
|||
|
|
} as any;
|
|||
|
|
|
|||
|
|
const result = await service.resolve({
|
|||
|
|
agent: {
|
|||
|
|
toolsets: ['base'],
|
|||
|
|
tools: { disabled: [] },
|
|||
|
|
} as any,
|
|||
|
|
modelCapability: 'text',
|
|||
|
|
operations,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const tool = result.tools.find(item => item.name === 'read_file');
|
|||
|
|
expect(tool).toBeDefined();
|
|||
|
|
|
|||
|
|
const toolResult = await tool!.execute('tool-1', { path: '/tmp/demo.txt' });
|
|||
|
|
expect(readFile).toHaveBeenCalled();
|
|||
|
|
expect(toolResult).toEqual({ type: 'text', text: 'injected-from-ops' });
|
|||
|
|
});
|
|||
|
|
});
|