kimi-code/packages/agent-core/test/tools/agent.test.ts
Kai 9c9716125e
feat: Harden the default system prompt and built-in tool descriptions (#1102)
* feat(agent-core): strengthen default system prompt

Add high-confidence, prompt-only guardrails to the default agent system prompt:

- Personality/candor: extend the HELPFUL/CONCISE/ACCURATE line with CANDID, and
  require plainly stating what could not be run, reproduced, or verified.
- Reminders: avoid cheerleading; voice evidence-based disagreement; deliver
  complete code with no placeholders; update now-stale comments/docstrings after
  a change; re-check the user's latest request before finalizing a reply.
- Context Management: explain automatic compaction — continue from the summary,
  re-establish transient state with tools, do not restart from scratch.
- Output formatting: replies render as Markdown in the terminal; keep lists flat;
  no emojis unless the user uses them first.
- Project Information: frame injected AGENTS.md as project context, not a
  privileged instruction channel that can override system rules.

Prompt text only; no code or template-variable changes.

* feat(agent-core): hoist key working rules into the system prompt

Lift a few high-leverage rules from individual tool descriptions up into the
default system prompt, so they shape default behavior before any specific tool
is in play (kept terse and integrated, not bolted on):

- Planning: for multi-step or multi-file work, maintain a `TodoList` (one item
  in_progress, mark done as it finishes) and prefer `EnterPlanMode` first when
  the approach isn't settled.
- Default to making progress, not asking: once the goal is clear and sanctioned,
  carry it through and work blockers yourself; ask only when the answer would
  change the next step. Explicitly does not override stopping to discuss an
  unclear goal or waiting for go-ahead before writing code.
- Tool routing: prefer dedicated tools (Read/Glob/Grep/Write/Edit) over raw
  shell when one fits; keep Bash for genuine shell work.
- Definition of done: verify with the checks that cover the change before
  marking it complete, independent of whether a TodoList is in use.
- Delegation: explore subagents also keep intermediate file contents out of your
  own context — you get a conclusion back, not a pile of dumps.

Prompt text only; no code or template-variable changes.

* fix: clarify guidelines for file pattern matching and tool usage in explore.yaml and system.md

* fix(agent-core): hide the Skills section from agents without the Skill tool

Subagents (coder/explore/plan) inherit the root system prompt but lack the Skill tool, yet KIMI_SKILLS was rendered unconditionally — leaking the full skill listing into agents that cannot invoke any skill. Gate KIMI_SKILLS on the profile's tool set and wrap the '# Skills' section in {% if KIMI_SKILLS %} so it disappears for those profiles.

Also note in the Working Directory section that Bash enforces none of the workspace/secret-file guards, so the model must hold that discipline itself.

Tests: assert the Skills section renders for the root agent and is absent for Skill-less subagents; update the prompt-rendering fixtures for the new gating.

* fix(agent-core): gate Agent, background-task, and TodoList guidance by tool availability

Subagents (coder/explore/plan) inherit the root system prompt but lack the Agent, TaskList, and TodoList tools, so they were shown usage guidance for tools they cannot call. Derive HAS_AGENT/HAS_TASKLIST/HAS_TODOLIST from each profile's tool set and gate those sections with inline {% if %}, so they render only for agents that hold the tool.

Root rendering is byte-identical (the inline tags collapse to the original text when the flag is set). The cross-tool secret-file guard stays shared, since explore/plan still hold Read/Grep/Glob.

Tests: assert the gated guidance is present for the root agent and absent for explore/plan, while the shared secret-file guard remains.

* refactor(agent-core): move Agent-delegation and Glob-anchor guidance into the tool descriptions

The Agent-delegation paragraph in the system prompt duplicated mechanics already documented on the Agent tool itself (new-vs-resume, zero-context briefing, foreground default / run_in_background threshold), so remove it. HAS_AGENT still gates the explore-delegation bullet, which carries the 'when to delegate' nudge the tool description deliberately omits.

Move the proactive 'anchor the pattern up front' guidance into the Glob tool description (it previously only described the reactive 'refine after hitting the cap' path) and drop the now-redundant Glob bullet from the system prompt.

Tests: drop the assertions tied to the removed Agent paragraph; HAS_AGENT gating stays covered via the explore bullet.

* test(agent-core): add guidance for blast-radius and concrete examples in agent profiles

* docs: update descriptions for skill-tool and fetch-url; enhance web-search citation instructions

* feat(agent-core): disclose enforced constraints in tool descriptions; fix GetGoal field doc

Surface runtime-enforced behavior in the Agent / AgentSwarm / AskUserQuestion / Goal
tool descriptions so the model learns the rules from the tool, not from a failed call:

- Agent: resuming excludes subagent_type (setting both is rejected)
- AgentSwarm: at least 2 items unless resuming, prompt_template required and must
  contain {{item}}, distinct resulting prompts; plus Agent-vs-AgentSwarm fan-out note
- AskUserQuestion: result is {answers}; an empty answers with a dismissal note means
  the user declined — fall back to best judgment instead of re-asking
- CreateGoal: creating fails when a goal already exists (use replace)
- SetGoalBudget: state the hard 1s-24h time-budget band
- UpdateGoal: do not mark blocked merely because work is hard/slow/incomplete
- GetGoal: drop the advertised self-report / evaluator-verdict fields — GoalSnapshot
  never held them, so the tool never returned them

Each change is covered by a description assertion.

* fix(agent-core): soften AskUserQuestion answers-keying wording to match the code

The answers object is passed through from the host/RPC layer (QuestionAnswers is
Record<string, string | true>); this code does not key it by question text. Describe
what the keys identify instead of asserting a guarantee the code does not provide.

* feat(agent-core): tighten Bash/Grep/Write/Edit tool descriptions

- Bash: prefer the cwd argument (or absolute paths) over a cd from an earlier
  call, since each call runs in a fresh shell
- Grep: note that files_with_matches is ordered most-recently-modified first
- Write: do not create documentation/README files unless the user asks
- Edit: frame replace_all with its rename-across-file use-case

Each change is covered by a description assertion.

* feat(agent-core): refine plan-mode/todo/cron tool descriptions

- ExitPlanMode: describe what a good plan contains (specific, verifiable steps
  grounded in the codebase, not vague filler)
- TodoList: stop calling it useful 'in Plan mode' — plan-mode planning goes to
  the plan file; TodoList tracks execution progress
- CronCreate: warn that a one-shot whose pinned day/month already passed this
  year is rejected; document the 50-task session cap and the 8 KiB prompt cap
- CronCreate: drop the bench-only KIMI_CRON_NO_STALE / KIMI_CRON_NO_JITTER env
  knobs from the model-facing description (CI-only; the model never sets them)

Each change is covered by a description assertion.

* refactor(agent-core): dedupe ExitPlanMode options docs into the param schema; trim EnterPlanMode workflow

- ExitPlanMode: the options field mechanics (label format, recommended, count,
  single-option=plain-approval, reserved labels) now live only in the options
  param describe; the tool description routes to it and keeps the yolo/manual UI
  behavior it uniquely documents. The options consistency test now enforces a
  single source of truth (describe) plus the schema-consistency guard, instead
  of requiring the same facts in both surfaces.
- EnterPlanMode: trim the duplicated 'What Happens in Plan Mode' steps to a
  pointer (the full workflow is injected unconditionally once plan mode is
  active), keeping the explore-subagent recommendation.

* fix(agent-core): correct prompt/code inaccuracies found in the final audit

Every item below was re-verified against the live code:

- Skill: drop the never-fired recursion-depth cap (production never seeds depth);
  keep the <kimi-skill-loaded> 'already loaded, don't re-invoke' guard
- TaskOutput: terminal_reason can also be `failed`, not just timed_out/stopped
- Grep: count_matches emits per-file `path:count`, with the total reported separately
- Plan mode: the reminder names TaskStop/CronCreate/CronDelete as blocked (they are
  hard-denied by plan-mode-guard-deny)
- Bash: the failure trailer is non-zero-exit-specific; timeout/interrupt differ
- CreateGoal: replace also covers a blocked goal, not just active/paused
- UpdateGoal: it also injects the completion/blocked outcome prompt, so it does more
  than 'only record the status'
- FetchURL: state the universal http/https contract instead of provider-internal SSRF
  and 10 MiB limits (the primary Moonshot fetcher enforces neither)
- TodoList: query mode triggers on omitting `todos`, not on zero args
- TaskList: command/PID/exit code are shell-task fields only
- CronCreate: the returned fields include `cron`
- SetGoalBudget: turn/token budgets are rounded up to >= 1, not rejected below 1

Each change is covered by a description/param assertion; plan.test.ts snapshots
refreshed for the longer plan-mode reminder.

* fix(agent-core): gate prompt tool guidance on runtime availability, not declared profile tools

The HAS_* / Skills gating computed flags from the profile's declared tools, but
Agent/AgentSwarm only register when a subagentHost exists (ToolManager
.initializeBuiltinTools). A runtime built without a subagentHost (e.g. direct SDK
construction) therefore rendered the explore-delegation guidance for an Agent
tool the model could not call.

SystemPromptContext now carries an optional availableTools; buildTemplateVars
gates on it when present and falls back to the declared tools otherwise. useProfile
passes the profile tools minus Agent/AgentSwarm when no subagentHost is wired, so
the render reflects what the model can actually call. The normal session path
(subagentHost always defaulted) is unchanged.

* fix(agent-core): exempt the plan-mode plan file from the Write *.md ban

Plan mode writes its plan to plans/<id>.md (plan/index.ts) and the reminder tells
the model to create it with Write when missing, which contradicted Write's blanket
'do not create *.md unless asked' guard. Carve the plan file out of the ban.

* fix(agent-core): scope plan-mode prompt guidance and the Write *.md ban to runtime reality

- Gate the TodoList bullet's "enter plan mode via EnterPlanMode" suggestion
  on a new HAS_ENTERPLANMODE flag. A custom profile that keeps TodoList but
  drops EnterPlanMode no longer steers the model toward a tool it cannot call;
  the default profile render is unchanged.
- Reframe the Write *.md prohibition around intent (unsolicited docs) instead
  of a blanket extension ban, so artifacts a task or project instruction
  requires — the plan-mode plan file, a repo-mandated changeset — are no
  longer contradicted by the tool's own rules.

* refactor(agent-core): move tool-coupled guidance into tool descriptions

The default system prompt carried tool-usage guidance behind {% if HAS_* %}
gates that re-derived, in prose, the availability the tool schema already
encodes — and the same guidance was duplicated in each tool's own
description. Drop the four gated blocks (background Bash, Agent/explore,
TodoList, EnterPlanMode) and the compaction TaskList/TodoList bullets; the
tool descriptions, shipped only when the tool is registered, already carry
the same instructions, so subagents and tool-trimmed profiles are no longer
pointed at tools they lack.

Fold the two genuinely unique lines into the tool descriptions: bash.md
gains "return control after starting a background task", agent.md gains the
context-hygiene reason to delegate. Collapse the compaction bullets into one
tool-agnostic sentence. Remove the now-unused availableTools / HAS_* render
machinery.

* fix(skill-tool): clarify no-reinvoke guard and argument handling in tool description

* feat(fetch-url): indicate content retrieval mode in output for better model context

* fix(agent-core): correct goal-budget rounding and task-output failure docs

set-goal-budget.md said turn/token budgets are "rounded up", but the code uses
Math.round — say "rounded to the nearest whole number" instead. task-output.md
implied every failed task carries terminal_reason/stop_reason, but a plain
non-zero command exit carries only status plus exit_code; describe that exit_code
path and reserve terminal_reason for non-exit endings (timeout, explicit stop,
or an internal error with no exit code).

* fix(agent-core): scope free-work guidance by role and steer one-shots near-term

The blast-radius paragraph told every profile that local work — including
editing files — may be done freely, but the read-only explore/plan subagents
render it too; scope it to "work your role permits" so it no longer undercuts
their read-only constraints.

The one-shot cron guidance leaned on a year-boundary heuristic ("avoid a
day/month already passed this year") that misfires across Dec 31 to Jan 1 and
duplicated a limit the code already enforces. Replace it with a plain near-term
nudge and leave the hard future-window guard in code.

* fix(enter-plan-mode): clarify availability of Agent tool in plan mode description

* fix(agent-core): surface Grep count_matches total and pagination in output

count_matches put the aggregate "Found N occurrences" summary and the
"Results truncated... use offset=N to see more" notice on the result's
message field, which normalizeToolResult drops before the result reaches the
model. The model saw only the path:count lines and could miss the total and,
worse, the pagination cue — so it would not know to page through truncated
counts. Append both to output after the path:count lines, the same way the
content and files_with_matches modes already inline their notices.

* fix(grep): reorder count summary and results in output for clarity

* chore(changeset): consolidate prompt-hardening changesets into one

Squash the five per-change changesets for this PR into a single concise
entry; they all bump @moonshot-ai/kimi-code (patch) for the same
system-prompt and tool-description hardening work.

* fix(agent-core): stop the agent from blocking on background tasks

Both the Agent and Bash background-launch messages invited the model to "peek
at progress" via TaskOutput, and the foreground-vs-background guidance had been
thinned to a single parameter hint. Together that led the model to launch a
background subagent and then immediately wait on it through TaskOutput —
defeating the point of background execution.

Make both launch messages take the same anti-wait stance the user-detach path
already uses (do NOT wait, poll, or call TaskOutput on it), restore
foreground-by-default guidance in the Agent background description (run in the
background only when you have other work and do not need the result to proceed),
and add a TaskOutput backstop against using it to sit and wait. Also fold the
fix into the consolidated changeset.
2026-06-26 16:56:40 +08:00

907 lines
30 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 { AgentTool, AgentToolInputSchema } from '../../src/tools/builtin/collaboration/agent';
import { userCancellationReason } from '../../src/utils/abort';
import { agentTask, createBackgroundManager } from '../agent/background/helpers';
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;
}
function agentTool(
host: SessionSubagentHost,
background = createBackgroundManager().manager,
subagents?: ResolvedAgentProfile['subagents'],
options?: ConstructorParameters<typeof AgentTool>[3],
): AgentTool {
return new AgentTool(host, background, subagents, options);
}
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 = 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 = 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('documents that resume excludes subagent_type', () => {
const host = mockSubagentHost({ spawn: vi.fn() });
const tool = agentTool(host);
const properties = (
tool.parameters as { properties: Record<string, { description?: string }> }
).properties;
// Passing both resume and subagent_type is rejected at runtime (agent.ts execution()),
// so the resume param must steer the model away from it.
expect((properties['resume']?.description ?? '').toLowerCase()).toContain('subagent_type');
});
it('does not expose a timeout parameter in the JSON schema', () => {
const host = mockSubagentHost({ spawn: vi.fn() });
const tool = agentTool(host);
const properties = (tool.parameters as { properties: Record<string, unknown> }).properties;
expect(properties).not.toHaveProperty('timeout');
});
it('explains the fixed background subagent timeout', () => {
const host = mockSubagentHost({ spawn: vi.fn() });
const tool = agentTool(host);
expect(tool.description).toContain('fixed 30-minute timeout');
expect(tool.description).not.toContain('operator-configured background timeout');
expect(tool.description).not.toContain('no time limit');
// Background guidance must steer foreground-by-default, so the model doesn't
// background-launch a result it needs and then block waiting on it.
expect(tool.description).toContain('Default to a foreground subagent');
});
it('does not expose a model parameter in the JSON schema', () => {
const host = mockSubagentHost({ spawn: vi.fn() });
const tool = 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 = agentTool(host, createBackgroundManager().manager, 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 = 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');
// Moved here from system.md: the context-hygiene reason to delegate.
expect(tool.description.toLowerCase()).toContain('out of your own context');
});
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 = agentTool(host, createBackgroundManager().manager, 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 = agentTool(host);
const result = await executeTool(tool,
context({
prompt: 'Investigate',
description: 'Find cause',
subagent_type: 'explore',
}),
);
expect(host.spawn).toHaveBeenCalledWith(
expect.objectContaining({
profileName: 'explore',
parentToolCallId: 'call_agent',
prompt: 'Investigate',
description: 'Find cause',
runInBackground: false,
signal: expect.any(AbortSignal),
}),
);
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 = agentTool(host);
await executeTool(tool,
context({
prompt: 'Investigate',
description: 'Find cause',
subagent_type: '',
}),
);
expect(host.spawn).toHaveBeenCalledWith(
expect.objectContaining({
parentToolCallId: 'call_agent',
profileName: 'coder',
}),
);
});
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 = 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',
expect.objectContaining({
parentToolCallId: 'call_agent',
prompt: 'Continue',
description: 'Continue work',
runInBackground: false,
signal: expect.any(AbortSignal),
}),
);
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 = 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 = createBackgroundManager({ maxRunningTasks: 1 }).manager;
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 = 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',
expect.objectContaining({
parentToolCallId: 'call_agent',
prompt: 'Continue',
description: 'Continue work',
runInBackground: false,
signal: expect.any(AbortSignal),
}),
);
expect(result.output).toContain('actual_subagent_type: explore');
});
it('declares no resource accesses so concurrent Agent calls can run in parallel', async () => {
const host = mockSubagentHost({ spawn: vi.fn() });
const tool = agentTool(host);
const execution = await 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', async () => {
const host = mockSubagentHost({
spawn: vi.fn(),
getProfileName: vi.fn().mockReturnValue('explore'),
});
const tool = agentTool(host);
const execution = await 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 = createBackgroundManager().manager;
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',
timeoutMs: 30 * 60 * 1000,
});
});
it('can detach a foreground subagent through the background manager', async () => {
let resolveCompletion: (value: { result: string }) => void = () => {};
const completion = new Promise<{ result: string }>((resolve) => {
resolveCompletion = resolve;
});
const markActiveChildDetached = vi.fn();
const host = mockSubagentHost({
markActiveChildDetached,
spawn: vi.fn().mockResolvedValue({
agentId: 'agent-child',
profileName: 'coder',
resumed: false,
completion,
}),
});
const background = createBackgroundManager().manager;
const tool = new AgentTool(host, background);
const running = executeTool(tool,
context({
prompt: 'Investigate',
description: 'Find cause',
}),
);
await vi.waitFor(() => {
expect(background.list(false)).toHaveLength(1);
});
const task = background.list(false)[0]!;
expect(task).toMatchObject({
kind: 'agent',
detached: false,
agentId: 'agent-child',
});
background.detach(task.taskId);
const result = await running;
expect(markActiveChildDetached).toHaveBeenCalledWith('agent-child');
expect(result.output).toContain(`task_id: ${task.taskId}`);
expect(result.output).toContain('agent_id: agent-child');
expect(result.output).toContain('automatic_notification: true');
resolveCompletion({ result: 'finished later' });
await expect(background.wait(task.taskId)).resolves.toMatchObject({
status: 'completed',
detached: true,
});
});
it('does not recommend disabled task tools when a foreground subagent is detached', async () => {
let resolveCompletion: (value: { result: string }) => void = () => {};
const completion = new Promise<{ result: string }>((resolve) => {
resolveCompletion = resolve;
});
const host = mockSubagentHost({
spawn: vi.fn().mockResolvedValue({
agentId: 'agent-child',
profileName: 'coder',
resumed: false,
completion,
}),
});
const background = createBackgroundManager().manager;
const tool = agentTool(host, background, undefined, { allowBackground: false });
const running = executeTool(
tool,
context({
prompt: 'Investigate',
description: 'Find cause',
}),
);
await vi.waitFor(() => {
expect(background.list(false)).toHaveLength(1);
});
const task = background.list(false)[0]!;
background.detach(task.taskId);
const result = await running;
expect(result.output).toContain(`task_id: ${task.taskId}`);
expect(result.output).toContain('next_step: The completion arrives automatically');
expect(result.output).not.toContain('TaskOutput');
expect(result.output).not.toContain('TaskStop');
resolveCompletion({ result: 'finished later' });
await expect(background.wait(task.taskId)).resolves.toMatchObject({
status: 'completed',
detached: true,
});
});
it('steers the AI away from waiting and gives 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 = createBackgroundManager().manager;
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 steers away from waiting on a background launch (no poll/TaskOutput).
expect(result.output).toContain('next_step:');
expect(result.output).toContain('do NOT wait, poll, or call TaskOutput on it');
expect(result.output).not.toContain('block=false');
// M9: resume_hint — continue the same subagent instance
expect(result.output).toContain('resume_hint:');
expect(result.output).toContain('Agent(resume="agent-child"');
// The hint disambiguates the two look-alike identifiers in this output:
// `agent_id` (what `subagentHost.resume` accepts) and `task_id` (the
// BackgroundManager ledger id, which also shows up as `source_id` in
// later <notification> entries). LLMs regularly copy the wrong one.
expect(result.output).toMatch(/agent_id.*not.*task_id|task_id.*not.*agent_id/i);
// Recovery scenario — `task.lost` etc. — must be called out so the
// model knows the hint is not only for happy-path follow-up work.
expect(result.output).toMatch(/task\.lost|task\.failed|task\.killed/);
});
it('rejects background subagents when background execution is disabled', async () => {
const host = mockSubagentHost({
spawn: vi.fn().mockResolvedValue({
agentId: 'agent-child',
profileName: 'coder',
resumed: false,
completion: new Promise<{ result: string }>(() => {}),
}),
});
const tool = agentTool(host, createBackgroundManager().manager, undefined, {
allowBackground: false,
});
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('returns an error when background registration hits the task limit', async () => {
const background = createBackgroundManager({ maxRunningTasks: 1 }).manager;
background.registerTask(agentTask(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).toHaveBeenCalledTimes(1);
});
it('rejects one of two concurrent background subagents when the task limit is reached', async () => {
const background = createBackgroundManager({ maxRunningTasks: 1 }).manager;
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(2);
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 = agentTool(host, createBackgroundManager().manager, 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 = createBackgroundManager().manager;
vi.spyOn(background, 'registerTask').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('reports a deliberate user interruption when a foreground subagent is cancelled by the user', async () => {
const controller = new AbortController();
const host = mockSubagentHost({
spawn: vi.fn(
(
profileNameOrOptions: string | { readonly signal: AbortSignal },
legacyOptions?: { readonly signal: AbortSignal },
) =>
Promise.resolve({
agentId: 'agent-child',
profileName: 'coder',
resumed: false,
completion: new Promise<{ result: string }>((_resolve, reject) => {
const signal =
typeof profileNameOrOptions === 'string'
? legacyOptions!.signal
: profileNameOrOptions.signal;
const onAbort = (): void => {
reject(signal.reason);
};
if (signal.aborted) onAbort();
else signal.addEventListener('abort', onAbort, { once: true });
}),
}),
),
});
const tool = agentTool(host);
const resultPromise = executeTool(tool, {
turnId: '0',
toolCallId: 'call_agent',
args: { prompt: 'Investigate', description: 'Find cause' },
signal: controller.signal,
});
// Let spawn wire up and the tool reach `await handle.completion`.
await new Promise((resolve) => setTimeout(resolve, 0));
controller.abort(userCancellationReason());
const result = await resultPromise;
expect(result.isError).toBe(true);
expect(result.output).toContain('status: failed');
// The old message ("The subagent was stopped by the user.") is too weak —
// the model still blamed a "system limit". The new message rules that out.
expect(result.output).not.toContain('was stopped by the user');
expect(result.output).toContain('not a system error');
expect(result.output).toContain('capacity');
expect(result.output).toContain('wait for the user');
});
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(
(
profileNameOrOptions: string | { readonly signal: AbortSignal },
legacyOptions?: { readonly signal: AbortSignal },
) =>
Promise.resolve({
agentId: 'agent-child',
profileName: 'coder',
resumed: false,
completion: new Promise<{ result: string }>((_resolve, reject) => {
const signal =
typeof profileNameOrOptions === 'string'
? legacyOptions!.signal
: profileNameOrOptions.signal;
signal.addEventListener(
'abort',
() => {
reject(signal.reason);
},
{ once: true },
);
}),
}),
),
});
const tool = agentTool(host);
const resultPromise = executeTool(tool,
context({
prompt: 'Investigate',
description: 'Find cause',
}),
);
await vi.advanceTimersByTimeAsync(30 * 60 * 1000);
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 30 minutes.');
expect(result.output).toContain('resume_hint:');
expect(result.output).toContain('Agent(resume="agent-child", prompt="continue")');
expect(result.output).toContain('do not set subagent_type');
expect(result.output).toContain('retains its prior context');
} 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 ?? [])],
};
}