kimi-code/packages/agent-core/test/tools/agent.test.ts
2026-05-22 15:54:50 +08:00

718 lines
23 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest';
import { ToolAccesses } from '../../src/loop';
import type { Logger, LogPayload } from '../../src/logging';
import type { ResolvedAgentProfile } from '../../src/profile';
import type { SessionSubagentHost } from '../../src/session/subagent-host';
import { BackgroundProcessManager } from '../../src/tools/background/manager';
import { AgentTool, AgentToolInputSchema } from '../../src/tools/builtin/collaboration/agent';
import { executeTool } from './fixtures/execute-tool';
const signal = new AbortController().signal;
function context<Input>(args: Input, toolCallId = 'call_agent') {
return { turnId: '0', toolCallId, args, signal };
}
function mockSubagentHost<T extends Pick<SessionSubagentHost, 'spawn'> & Partial<SessionSubagentHost>>(
host: T,
): T & SessionSubagentHost {
return { resume: vi.fn(), ...host } as unknown as T & SessionSubagentHost;
}
interface CapturedLogEntry {
readonly level: 'error' | 'warn' | 'info' | 'debug';
readonly message: string;
readonly payload: LogPayload | undefined;
}
function captureLogs(): { logger: Logger; entries: CapturedLogEntry[] } {
const entries: CapturedLogEntry[] = [];
const capture =
(level: CapturedLogEntry['level']) => (message: string, payload?: LogPayload) => {
entries.push({ level, message, payload });
};
const logger: Logger = {
error: capture('error'),
warn: capture('warn'),
info: capture('info'),
debug: capture('debug'),
createChild: () => logger,
};
return {
entries,
logger,
};
}
describe('AgentTool', () => {
it('accepts the snake_case background parameter', () => {
const parsed = AgentToolInputSchema.parse({
prompt: 'Investigate',
description: 'Find cause',
subagent_type: 'explore',
run_in_background: true,
});
expect(parsed).toMatchObject({
prompt: 'Investigate',
description: 'Find cause',
subagent_type: 'explore',
run_in_background: true,
});
});
it('exposes run_in_background and not runInBackground in the JSON schema', () => {
const host = mockSubagentHost({ spawn: vi.fn() });
const tool = new AgentTool(host);
const properties = (tool.parameters as { properties: Record<string, unknown> }).properties;
expect(properties).toHaveProperty('run_in_background');
expect(properties).not.toHaveProperty('runInBackground');
});
it('describes subagent_type and run_in_background parameters', () => {
const host = mockSubagentHost({ spawn: vi.fn() });
const tool = new AgentTool(host);
const properties = (
tool.parameters as {
properties: Record<string, { description?: string }>;
}
).properties;
const subagentTypeDescription = properties['subagent_type']?.description ?? '';
// #7: the description states the default is coder
expect(subagentTypeDescription).toContain('coder');
// #6: terminology aligned with the "Available agent types" prose heading —
// no longer "agent registry"
expect(subagentTypeDescription).not.toContain('registry');
expect(subagentTypeDescription).toContain('agent type');
expect(properties['run_in_background']?.description).toContain('false');
});
it('does not mention background-only timeout details in the timeout description', () => {
const host = mockSubagentHost({ spawn: vi.fn() });
const tool = new AgentTool(host);
const properties = (
tool.parameters as {
properties: Record<string, { description?: string }>;
}
).properties;
const timeoutDescription = properties['timeout']?.description ?? '';
// #5: the background default-timeout note is kept out of the static
// describe — it would mislead in the background-disabled variant
expect(timeoutDescription).not.toContain('Background');
expect(timeoutDescription).not.toContain('15min');
});
it('explains background timeout fallback in the background-enabled description without claiming a 15min default', () => {
const host = mockSubagentHost({ spawn: vi.fn() });
const tool = new AgentTool(host, new BackgroundProcessManager());
// #5: the background-enabled variant describes the real timeout fallback —
// an omitted timeout falls back to the operator-configured background
// timeout, or no time limit when the operator configured none — and must
// never claim an incorrect "15min default".
expect(tool.description).toContain('operator-configured background timeout');
expect(tool.description).toContain('no time limit');
expect(tool.description).not.toContain('15min');
});
it('does not expose a model parameter in the JSON schema', () => {
const host = mockSubagentHost({ spawn: vi.fn() });
const tool = new AgentTool(host);
const properties = (tool.parameters as { properties: Record<string, unknown> }).properties;
expect(properties).not.toHaveProperty('model');
});
it('renders the tool set for each subagent type', () => {
const host = mockSubagentHost({ spawn: vi.fn() });
const subagents = {
explore: profile({
name: 'explore',
description: 'Read-only exploration.',
tools: ['Read', 'Grep', 'Glob'],
}),
coder: profile({
name: 'coder',
description: 'General coding.',
tools: ['Read', 'Write', 'Edit', 'Bash'],
}),
};
const tool = new AgentTool(host, undefined, subagents);
expect(tool.description).toContain('Tools: Read, Grep, Glob');
expect(tool.description).toContain('Tools: Read, Write, Edit, Bash');
});
it('mentions resume preference and result visibility in the description', () => {
const host = mockSubagentHost({ spawn: vi.fn() });
const tool = new AgentTool(host);
expect(tool.description.toLowerCase()).toContain('resume');
expect(tool.description.toLowerCase()).toContain('only visible to you');
expect(tool.description.toLowerCase()).toContain('when not to');
});
it('normalizes the default subagent type into tool args', () => {
expect(
AgentToolInputSchema.parse({
prompt: 'Investigate',
description: 'Find cause',
}).subagent_type,
).toBe('coder');
expect(
AgentToolInputSchema.parse({
prompt: 'Investigate',
description: 'Find cause',
subagent_type: '',
}).subagent_type,
).toBe('coder');
expect(
AgentToolInputSchema.parse({
prompt: 'Continue',
description: 'Continue work',
resume: 'agent-existing',
}).subagent_type,
).toBeUndefined();
});
it('describes configured subagent types', () => {
const host = mockSubagentHost({ spawn: vi.fn() });
const subagents = {
explore: profile({
name: 'explore',
description: 'Read-only exploration.',
whenToUse: 'Use for searches.',
}),
coder: profile({ name: 'coder', description: 'General coding.' }),
};
const tool = new AgentTool(host, undefined, subagents);
expect(tool.description).toContain('Available agent types');
expect(tool.description).toContain('- explore: Read-only exploration. Use for searches.');
expect(tool.description).toContain('- coder: General coding.');
});
it('spawns a foreground subagent and returns its summary', async () => {
const host = mockSubagentHost({
spawn: vi.fn().mockResolvedValue({
agentId: 'agent-child',
profileName: 'explore',
resumed: false,
completion: Promise.resolve({ result: 'child result' }),
}),
});
const tool = new AgentTool(host);
const result = await executeTool(tool,
context({
prompt: 'Investigate',
description: 'Find cause',
subagent_type: 'explore',
}),
);
expect(host.spawn).toHaveBeenCalledWith('explore', {
parentToolCallId: 'call_agent',
prompt: 'Investigate',
description: 'Find cause',
runInBackground: false,
signal,
});
expect(result.output).toContain('agent_id: agent-child');
expect(result.output).toContain('actual_subagent_type: explore');
expect(result.output).toContain('child result');
});
it('falls back to coder for an empty subagent type', async () => {
const host = mockSubagentHost({
spawn: vi.fn().mockResolvedValue({
agentId: 'agent-child',
profileName: 'coder',
resumed: false,
completion: Promise.resolve({ result: 'child result' }),
}),
});
const tool = new AgentTool(host);
await executeTool(tool,
context({
prompt: 'Investigate',
description: 'Find cause',
subagent_type: '',
}),
);
expect(host.spawn).toHaveBeenCalledWith(
'coder',
expect.objectContaining({
parentToolCallId: 'call_agent',
}),
);
});
it('resumes a foreground subagent when resume is provided', async () => {
const host = mockSubagentHost({
spawn: vi.fn(),
resume: vi.fn().mockResolvedValue({
agentId: 'agent-existing',
profileName: 'explore',
resumed: true,
completion: Promise.resolve({ result: 'resumed result' }),
}),
});
const tool = new AgentTool(host);
const result = await executeTool(tool,
context({
prompt: 'Continue',
description: 'Continue work',
resume: 'agent-existing',
}),
);
expect(host.spawn).not.toHaveBeenCalled();
expect(host.resume).toHaveBeenCalledWith('agent-existing', {
parentToolCallId: 'call_agent',
prompt: 'Continue',
description: 'Continue work',
runInBackground: false,
signal,
});
expect(result.output).toContain('agent_id: agent-existing');
expect(result.output).toContain('actual_subagent_type: explore');
expect(result.output).toContain('resumed result');
});
it('returns an error when resuming with a subagent type', async () => {
const host = mockSubagentHost({
spawn: vi.fn(),
resume: vi.fn(),
});
const tool = new AgentTool(host);
const result = await executeTool(tool,
context({
prompt: 'Continue',
description: 'Continue work',
resume: 'agent-existing',
subagent_type: 'explore',
}),
);
expect(result).toMatchObject({
isError: true,
output: 'Cannot set subagent_type when resuming an existing agent. Resume by agent id only.',
});
expect(host.resume).not.toHaveBeenCalled();
});
it('does not consume a background task slot when validation fails before launch', async () => {
const completion = new Promise<{ result: string }>(() => {});
const background = new BackgroundProcessManager({ maxRunningTasks: 1 });
const host = mockSubagentHost({
spawn: vi.fn().mockResolvedValue({
agentId: 'agent-child',
profileName: 'coder',
resumed: false,
completion,
}),
resume: vi.fn(),
});
const tool = new AgentTool(host, background);
const invalid = await executeTool(tool,
context({
prompt: 'Continue',
description: 'Invalid background resume',
resume: 'agent-existing',
subagent_type: 'explore',
run_in_background: true,
}),
);
const valid = await executeTool(tool,
context({
prompt: 'Investigate',
description: 'Find cause',
run_in_background: true,
}),
);
expect(invalid).toMatchObject({
isError: true,
output: 'Cannot set subagent_type when resuming an existing agent. Resume by agent id only.',
});
expect(valid.output).toContain('status: running');
expect(host.resume).not.toHaveBeenCalled();
expect(host.spawn).toHaveBeenCalledTimes(1);
});
it('resumes by id without constraining the subagent type', async () => {
const host = mockSubagentHost({
spawn: vi.fn(),
resume: vi.fn().mockResolvedValue({
agentId: 'agent-existing',
profileName: 'explore',
resumed: true,
completion: Promise.resolve({ result: 'resumed result' }),
}),
});
const tool = new AgentTool(host);
const result = await executeTool(tool,
context({
prompt: 'Continue',
description: 'Continue work',
resume: 'agent-existing',
}),
);
expect(host.spawn).not.toHaveBeenCalled();
expect(host.resume).toHaveBeenCalledWith('agent-existing', {
parentToolCallId: 'call_agent',
prompt: 'Continue',
description: 'Continue work',
runInBackground: false,
signal,
});
expect(result.output).toContain('actual_subagent_type: explore');
});
it('declares no resource accesses so concurrent Agent calls can run in parallel', () => {
const host = mockSubagentHost({ spawn: vi.fn() });
const tool = new AgentTool(host);
const execution = tool.resolveExecution({
prompt: 'Investigate',
description: 'Find cause',
subagent_type: 'explore',
});
if (execution.isError === true) throw new Error('expected runnable execution');
expect(execution.accesses).toEqual(ToolAccesses.none());
});
it('uses the resumed agent profile in the activity description', () => {
const host = mockSubagentHost({
spawn: vi.fn(),
getProfileName: vi.fn().mockReturnValue('explore'),
});
const tool = new AgentTool(host);
const execution = tool.resolveExecution({
prompt: 'Continue',
description: 'Continue work',
resume: ' agent-existing ',
});
expect(execution.isError).toBeFalsy();
if (execution.isError === true) throw new Error('expected runnable execution');
expect(execution.description).toBe('Launching explore agent: Continue work');
expect(host.getProfileName).toHaveBeenCalledWith('agent-existing');
});
it('registers background subagents with the background manager', async () => {
const completion = new Promise<{ result: string }>(() => {});
const host = mockSubagentHost({
spawn: vi.fn().mockResolvedValue({
agentId: 'agent-child',
profileName: 'coder',
resumed: false,
completion,
}),
});
const background = new BackgroundProcessManager();
const tool = new AgentTool(host, background);
const result = await executeTool(tool,
context({
prompt: 'Investigate',
description: 'Find cause',
run_in_background: true,
}),
);
expect(result.output).toContain('status: running');
expect(result.output).toContain('agent_id: agent-child');
if (typeof result.output !== 'string') throw new TypeError('expected string output');
const taskId = result.output.match(/task_id: (agent-[0-9a-z]{8})/)?.[1];
expect(taskId).toBeDefined();
expect(background.getTask(taskId!)).toMatchObject({
status: 'running',
description: 'Find cause',
});
});
it('guides the AI with a non-blocking query hint and a resume hint on background launch', async () => {
const host = mockSubagentHost({
spawn: vi.fn().mockResolvedValue({
agentId: 'agent-child',
profileName: 'coder',
resumed: false,
completion: new Promise<{ result: string }>(() => {}),
}),
});
const background = new BackgroundProcessManager();
const tool = new AgentTool(host, background);
const result = await executeTool(tool,
context({
prompt: 'Investigate',
description: 'Find cause',
run_in_background: true,
}),
);
if (typeof result.output !== 'string') throw new TypeError('expected string output');
const taskId = result.output.match(/task_id: (agent-[0-9a-z]{8})/)?.[1];
expect(taskId).toBeDefined();
// M9: next_step — non-blocking progress check via TaskOutput
expect(result.output).toContain('next_step:');
expect(result.output).toContain(`TaskOutput(task_id="${taskId!}", block=false)`);
// M9: resume_hint — continue the same subagent instance
expect(result.output).toContain('resume_hint:');
expect(result.output).toContain('Agent(resume="agent-child"');
});
it('rejects background subagents when background management is unavailable', async () => {
const host = mockSubagentHost({
spawn: vi.fn().mockResolvedValue({
agentId: 'agent-child',
profileName: 'coder',
resumed: false,
completion: new Promise<{ result: string }>(() => {}),
}),
});
const tool = new AgentTool(host);
expect(tool.description).toContain('Background agent execution is disabled for this agent.');
expect(tool.description).not.toContain('the subagent runs detached from this turn');
const result = await executeTool(tool,
context({
prompt: 'Investigate',
description: 'Find cause',
run_in_background: true,
}),
);
expect(result).toMatchObject({
isError: true,
output:
'Background agent execution is not available for this agent because TaskList, TaskOutput, and TaskStop are not enabled.',
});
expect(host.spawn).not.toHaveBeenCalled();
});
it('does not spawn background subagents when the task limit is reached', async () => {
const background = new BackgroundProcessManager({ maxRunningTasks: 1 });
background.registerAgentTask(new Promise(() => {}), 'existing agent');
const host = mockSubagentHost({
spawn: vi.fn().mockResolvedValue({
agentId: 'agent-child',
profileName: 'coder',
resumed: false,
completion: new Promise<{ result: string }>(() => {}),
}),
});
const tool = new AgentTool(host, background);
const result = await executeTool(tool,
context({
prompt: 'Investigate',
description: 'Find cause',
run_in_background: true,
}),
);
expect(result).toMatchObject({
isError: true,
output: 'Too many background tasks are already running.',
});
expect(host.spawn).not.toHaveBeenCalled();
});
it('reserves a task slot before spawning concurrent background subagents', async () => {
const background = new BackgroundProcessManager({ maxRunningTasks: 1 });
const host = mockSubagentHost({
spawn: vi
.fn()
.mockResolvedValueOnce({
agentId: 'agent-first',
profileName: 'coder',
resumed: false,
completion: new Promise<{ result: string }>(() => {}),
})
.mockResolvedValueOnce({
agentId: 'agent-second',
profileName: 'coder',
resumed: false,
completion: Promise.resolve({ result: 'second result' }),
}),
});
const tool = new AgentTool(host, background);
const first = executeTool(tool,
context({
prompt: 'Investigate first',
description: 'Find first',
run_in_background: true,
}),
);
const second = executeTool(tool,
context({
prompt: 'Investigate second',
description: 'Find second',
run_in_background: true,
}),
);
const results = await Promise.all([first, second]);
expect(host.spawn).toHaveBeenCalledTimes(1);
expect(results).toContainEqual(
expect.objectContaining({ output: expect.stringContaining('status: running') }),
);
expect(results).toContainEqual(
expect.objectContaining({
isError: true,
output: 'Too many background tasks are already running.',
}),
);
});
it('returns tool errors when spawning fails', async () => {
const error = new Error('missing subagent');
const { logger, entries } = captureLogs();
const host = mockSubagentHost({
spawn: vi.fn().mockRejectedValue(error),
});
const tool = new AgentTool(host, undefined, undefined, { log: logger });
const result = await executeTool(tool,
context({ prompt: 'Investigate', description: 'Find cause' }),
);
expect(result).toMatchObject({
isError: true,
output: 'subagent error: missing subagent',
});
expect(entries).toEqual([
{
level: 'warn',
message: 'subagent launch failed',
payload: expect.objectContaining({
toolCallId: 'call_agent',
runInBackground: false,
operation: 'spawn',
subagentType: 'coder',
error,
}),
},
]);
});
it('logs background registration failures', async () => {
const error = new Error('background unavailable');
const { logger, entries } = captureLogs();
const host = mockSubagentHost({
spawn: vi.fn().mockResolvedValue({
agentId: 'agent-child',
profileName: 'coder',
resumed: false,
completion: new Promise<{ result: string }>(() => {}),
}),
});
const background = new BackgroundProcessManager();
vi.spyOn(background, 'registerAgentTask').mockImplementation(() => {
throw error;
});
const tool = new AgentTool(host, background, undefined, { log: logger });
const result = await executeTool(tool,
context({
prompt: 'Investigate',
description: 'Find cause',
run_in_background: true,
}),
);
expect(result).toMatchObject({
isError: true,
output: 'background unavailable',
});
expect(entries).toEqual([
{
level: 'warn',
message: 'background agent task registration failed',
payload: expect.objectContaining({
toolCallId: 'call_agent',
agentId: 'agent-child',
subagentType: 'coder',
error,
}),
},
]);
});
it('returns the spawned agent id when a foreground subagent times out', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
try {
const host = mockSubagentHost({
spawn: vi.fn((_profileName: string, options: { signal: AbortSignal }) =>
Promise.resolve({
agentId: 'agent-child',
profileName: 'coder',
resumed: false,
completion: new Promise<{ result: string }>((_resolve, reject) => {
options.signal.addEventListener(
'abort',
() => {
reject(options.signal.reason);
},
{ once: true },
);
}),
}),
),
});
const tool = new AgentTool(host);
const resultPromise = executeTool(tool,
context({
prompt: 'Investigate',
description: 'Find cause',
timeout: 30,
}),
);
await vi.advanceTimersByTimeAsync(30_000);
const result = await resultPromise;
expect(result).toMatchObject({ isError: true });
expect(result.output).toContain('agent_id: agent-child');
expect(result.output).toContain('actual_subagent_type: coder');
expect(result.output).toContain('status: failed');
expect(result.output).toContain('subagent error: Agent timed out after 30s.');
} finally {
vi.useRealTimers();
}
});
});
function profile(input: {
readonly name: string;
readonly description?: string;
readonly whenToUse?: string;
readonly tools?: readonly string[];
}): ResolvedAgentProfile {
return {
name: input.name,
description: input.description,
whenToUse: input.whenToUse,
systemPrompt: () => `${input.name} prompt`,
tools: [...(input.tools ?? [])],
};
}