TL;DR. There is no purpose-built “routing test runner” yet. The working recipe in 2026: drive your subagent headlessly with
claude -p --output-format stream-json --verbose(or the Agent SDK) and assert on thetool_useblock whosenameisAgent/Taskand itssubagent_type[1] — or, for a real test harness, use Promptfoo’sclaude-agent-sdkprovider, which normalises dispatches intometadata.toolCallsand gives you a deterministicskill-used/not-skill-usedassertion pair [2][3]. Thedescriptionfield is the only thing the router reads, so the routing test IS a description test [4]. Write positive prompts, near-miss prompts that must route to a sibling, and assert both the agent you want and the ones you don’t.
What “routing works” actually decomposes into
The brief’s three checks map to three different assertion surfaces:
| Check | What you’re really testing | Where the evidence lives |
|---|---|---|
| (a) right prompts → dispatch | Is the description discoverable by the router? |
tool_use block, name=Agent, subagent_type=<your-agent> [1] |
| (b) wrong prompts → no dispatch | Is the description over-broad / stealing traffic? |
absence of that subagent_type; a sibling’s subagent_type instead [3] |
| (c) isolated context behaves | Inputs arrived, shape returned, tools held | the prompt string, structured_output, permission_denials [5][1] |
(a) and (b) are deterministic given a transcript. (c) is partly deterministic (tool restrictions, output schema) and partly probabilistic (did the model use the inputs well) — only the deterministic half is in scope here.
The description is the trigger, not documentation
Claude uses each subagent’s description to decide when to delegate; you write it so “Claude knows when to use it” [4]. In the SDK the same field is “Natural language description of when to use this agent” and is the only routing signal besides the prompt and current context [1]. Three dispatch paths exist, and your smoke test must distinguish them:
- Automatic — the router matches the task to a
description. This is the only path worth testing; the other two bypass the thing you’re verifying. - Explicit by name / @-mention — “Use the code-reviewer agent to…” bypasses automatic matching [1]. Useful as a positive control (proves the agent runs at all), useless for testing routing.
general-purposefallback — Claude can spawn the built-ingeneral-purposeagent without you defining anything, so an absent dispatch to your agent doesn’t mean no delegation happened [1].
Anthropic’s own troubleshooting list for “Claude not delegating” is, in effect, the failure taxonomy your smoke test should cover: Agent not in allowedTools (invocation denied), no explicit prompting, or a vague description [1]. Filesystem agents in .claude/agents/ load at startup only — edit the description, restart the session, or your test runs the old one [1].
(a) Asserting the dispatch happened
Naming caveat that breaks naive greps (2026)
The dispatch tool was renamed Task → Agent in Claude Code v2.1.63. Current SDKs emit "Agent" in tool_use blocks but still say "Task" in the system:init tools list and in result.permission_denials[].tool_name — so any assertion must match both strings [1]. As of v2.1.172, subagents can spawn subagents (depth-limited to 5), which means a dispatch can be nested rather than top-level [1].
Technique comparison
| Technique | Determinism | CI-able | What you assert on | Cost |
|---|---|---|---|---|
Interactive --verbose / transcript |
manual | ✗ | eyeball the “delegating to X” line | low |
Headless claude -p stream-json + jq |
high | ✓ | tool_use.name ∈ {Agent,Task}, .input.subagent_type [6] |
low |
| Agent SDK message loop | high | ✓ | block.name, block.input.subagent_type, parent_tool_use_id [1] |
low |
Promptfoo claude-agent-sdk provider |
high | ✓ | metadata.toolCalls, skill-used / not-skill-used [2][3] |
med |
| OpenTelemetry traces / hooks | high | ✓ (obs) | nested claude_code.tool span, tool_name=Task [7] |
infra |
Headless: the cheapest CI gate
claude -p --output-format stream-json --verbose \
"Refactor the auth module for clarity" \
> run.jsonl
# stream-json REQUIRES --verbose, else you get minimal output
Each line is one JSON event with a type field [6]. Find the dispatch:
jq -c 'select(.type=="assistant") | .message.content[]
| select(.type=="tool_use" and (.name=="Task" or .name=="Agent"))
| {agent: .input.subagent_type}' run.jsonl
The subagent_type in the tool_use.input is the agent the router chose — that string is your pass/fail [1][6]. Messages produced inside a subagent carry parent_tool_use_id, so you can prove work ran in the isolated context, not the parent [1].
Agent SDK: assert in TS/Python
The SDK docs ship a near-verbatim routing probe — iterate the stream, match block.name in ("Task","Agent"), read block.input.subagent_type, and flag any message with a parent_tool_use_id as “running inside subagent” [1]. Wrap that in your test runner (xUnit/Vitest) and you have a routing assertion in the stack your team already tests with. Define the agent inline via the agents param so the test is self-contained and not dependent on .claude/agents/ files [1].
Promptfoo: the closest thing to a routing-test framework
Promptfoo’s claude-agent-sdk provider (promptfoo ⭐ 23k, Jun 2026) captures every dispatch in response.metadata.toolCalls; each entry has name, input, and a parentToolUseId that is non-null for subagent calls and null for top-level — that field alone proves a subagent ran [2][8]. By default only tool_use/tool_result blocks from the subagent surface; set forward_subagent_text: true to assert against the nested transcript [2]. For Skills (the sibling extension type), it normalises Skill calls into metadata.skillCalls and exposes a deterministic skill-used assertion [2].
A JS assertion that a specific agent was dispatched:
const calls = context.providerResponse?.metadata?.toolCalls || [];
return calls.some(t =>
(t.name === 'Agent' || t.name === 'Task') &&
t.input?.subagent_type === 'code-reviewer');
(b) Asserting wrong prompts DON’T route
This is the half teams skip, and it’s where an over-eager description does its damage — a greedy agent silently eats prompts meant for a sibling. Promptfoo’s guide names the pattern directly: add near-miss prompts that should route to a sibling, then assert both the agent you want and the ones you don’t [3].
tests:
- description: Eval authoring stays out of provider setup
vars:
request: Write a regression eval for this existing provider.
assert:
- type: skill-used
value: promptfoo-evals
- type: not-skill-used
value: promptfoo-provider-setup
The same dual assertion expresses in raw metadata.toolCalls: assert your subagent_type is absent (or a named sibling is present) for the near-miss corpus. The guide’s third layer matters too: confirm the chosen agent changed the actual work, not just the trace — a correct-by-accident answer from the wrong agent still fails the intent [3].
Build the corpus as three buckets per agent: positive (must dispatch), near-miss (must dispatch to a named sibling), unrelated (must not dispatch at all). Routing is probabilistic, so run each prompt N times and gate on a hit-rate, not a single pass — single-shot routing assertions are flaky by construction.
(c) Verifying the isolated context behaves
Inputs arrived
The only channel from parent to subagent is the Agent tool’s prompt string — no parent history, no parent system prompt, no preloaded skills unless listed in AgentDefinition.skills [1]. So the test for “did it get what it needs” is: inspect the tool_use.input.prompt the parent generated and assert your required file paths / error text / IDs are literally in it. If they’re not, the bug is in the parent’s dispatch prompt, not your agent.
Return shape
Free-form text is unassertable. Pin the contract with structured outputs: pass outputFormat: { type: "json_schema", schema } (TS) / output_format (Python); the result message then carries a validated structured_output field, and the SDK re-prompts on mismatch [5]. Generate the schema from Zod (z.toJSONSchema()) or Pydantic (.model_json_schema()) so the test shares the type your code uses [5]. On failure the result subtype is error_max_structured_output_retries — a clean red/green for “agent returned the expected shape” [5].
⚠ Caveat: structured output binds the top-level query(), which is the cleanest path when you test the agent as a main-thread agent. When it runs as a true subagent, assert the shape of the verbatim final message the parent receives (the Agent tool result) instead.
Tool restrictions hold
A subagent restricted via tools: ["Read","Grep","Glob"] (or disallowedTools) should never execute Edit/Write/Bash [1]. Negative test: feed it a prompt that tempts a write and assert the result’s permission_denials contains the blocked tool (the SDK still labels these with the legacy Task name) [1]. Belt-and-braces: a SubagentStop / PostToolUse hook can hard-fail the run if an out-of-scope tool or file write slips through, turning the policy into an enforced gate rather than a hope.
Observability for fleet-level routing
For a shipped agent, OpenTelemetry watches routing in production, not just in CI. Set CLAUDE_CODE_ENABLE_TELEMETRY=1; subagent llm_request and tool spans nest under the parent’s claude_code.tool span, so the whole delegation chain is one trace and a dispatch surfaces as a span with tool_name=Task [7]. Hooks give the same signal cheaper: a PreToolUse/PostToolUse hook keyed on tool_name=task fires on every dispatch and can log or block it [7]. The official observability doc covers the metrics/events/traces split [9]. Treat this as monitoring, not a pre-ship gate — it tells you routing drifted after it drifted.
Honest state of tooling, June 2026
- No dedicated routing-test framework exists. Promptfoo’s
claude-agent-sdkprovider is the only off-the-shelf harness with first-class dispatch/skill assertions [2]; everything else is you-write-the-jq. If someone claims a “subagent router unit-test tool”, be skeptical. - ⚠ Active regression that breaks transcript-based assertions. anthropics/claude-code#17591 (claude-code ⭐ 134k): since v2.0.77 (filed 12 Jan 2026, still open)
TaskOutputreturns the raw JSONL session transcript instead of the subagent’s summary, flooding parent context [10]. If your assertion parsesTaskOutput, pin to ≤2.0.76 or read the dispatch from thetool_useblock instead of the result. - Reverse-engineered router internals. Piebald-AI/claude-code-system-prompts ⭐ 11k mirrors the Agent/Task tool description and built-in subagent prompts per version — useful to see what text the router actually matches your description against [11].
- Routing is non-deterministic. Every assertion above should gate on a hit-rate over N runs, with explicit positive/near-miss/unrelated buckets. A single green run is not evidence the routing is robust.