Agent Development Kit · Prove & ship
Testing agents without a model
An agent test that calls a real model is slow, costs money, and disagrees with itself. The test kit replaces one thing — the model — with turns you write down. Your tools still execute, your context still renders, the ledger still accrues. Every cell on this page runs here, with no key and no network.
Step 1
A scripted turn
First, the agent under test. It is an ordinary agent — nothing about it knows it is being tested — with one tool that computes a price and one that stops to ask the guest a question. Build it once; every cell below runs it.
import { adk } from '@animahealth/adk'
import { openai } from '@animahealth/adk/openai'
import { z } from 'zod'
const app = adk()
const quoteNights = app.tool({
name: 'quote_nights',
description: 'Quote the price of a stay',
schema: z.object({ nights: z.number(), guests: z.number() }),
execute: (ctx) => ({ total: ctx.args.nights * ctx.args.guests * 40 }),
})
// A tool with a `yieldSchema` and no `execute` stops the run and waits for an answer.
const askNight = app.tool({
name: 'ask_night',
description: 'Ask the guest which night they want',
schema: z.object({ question: z.string() }),
yieldSchema: z.object({ night: z.string() }),
finalize: (ctx) => ({ night: ctx.input?.night ?? 'unknown' }),
})
const concierge = app.agent({
name: 'concierge',
model: openai('gpt-5.6-luna'),
context: [app.context.system('Quote stays with the tools.'), app.context.history()],
tools: [quoteNights, askNight],
})
concierge.name
Note the model: line. It names a real provider, and this page has no key — yet
the next cell runs. runTest builds its own runner with a mock adapter registered
for the provider, and a registered adapter beats the one the agent's model config would
resolve. Nothing reaches the network, so you never have to keep a separate "test agent" in
sync with the one you ship.
A test is the agent plus a list of steps. user(text) is a message in;
model(text) is the reply the model would have produced. Edit the script and run
again — the reply is whatever you say it is.
import { getLastAssistantText, model, runTest, user } from '@animahealth/adk/testing'
const greeting = await runTest(concierge, [
user('Hi, are you open in March?'),
model('We are — how many nights were you thinking?'),
])
getLastAssistantText(greeting.events)
model('plain text') is shorthand for model({ text: 'plain text' }),
symmetric with user. The config form is what you reach for when a turn is more
than words: a tool decision, a reasoning summary, a thrown error, a delay, a chunked stream.
Section 4 lists every field.
Step 2
The mock replaces the model, not your code
This is the load-bearing idea of the whole kit. Scripting
{ toolCalls: [{ name, args }] } decides that the tool is called and with
what — it does not decide the answer. Your execute runs for real, against the
arguments the script chose, and the number below is computed in this page by the tool you
wrote in step 1.
import { getToolResults } from '@animahealth/adk/testing'
const quoted = await runTest(concierge, [
user('Two nights for three of us — what does that cost?'),
model({ toolCalls: [{ name: 'quote_nights', args: { nights: 2, guests: 3 } }] }),
model('Two nights for three guests comes to 240.'),
])
getToolResults(quoted.events)
Two model steps for one question, because a tool-using turn is two model
round-trips: one to decide the call, one to answer with its result. Change the arithmetic in
the tool and this cell changes with it; change the second model line and it does
not. That is the seam — the script owns the model's judgement, your code owns the answer.
Failure travels the same path: a tool that throws lands in the ledger as a
tool_result carrying error, and the run continues — see
guardrails and recovery.
Step 3
What the run left behind
Assertions read the event history, not a transcript. TestResult.events is an
alias for the session ledger — see five primitives and one ledger.
import { getToolCalls } from '@animahealth/adk/testing'
const ledger = {
status: quoted.status,
iterations: quoted.iterations,
calls: getToolCalls(quoted.events),
events: quoted.events.map((event) => event.type),
streamed: quoted.streamEvents.map((event) => event.type),
}
ledger
Read it left to right: the user's message, the invocation opening, a
model_start/model_end pair for the decision, the
tool_call and the tool_result your code produced, a second model
pair for the reply, then the assistant text. iterations counts those two model
steps. Everything a real run records, the scripted run records too.
events is the durable ledger; streamEvents is what a subscriber saw
while the run was in flight — the same events without what the session already held, plus the
deltas a chunked turn emits and the ledger never keeps. The one thing a scripted run cannot
report is cost. There were no tokens, so quoted.result.usage is undefined.
Measure cost against a real model, on the streaming and cost page.
A TestResult also carries result — the whole untouched
RunResult, for anything the aliases do not cover.
Four helpers cover most assertions, and each takes an event list:
getLastAssistantText, getToolCalls, getToolResults, and
findEventsByType(events, 'thought') for anything else.
findStreamEventsByType is their counterpart over streamEvents, and
collectStream drains a live stream into events plus result.
Step 4
The step vocabulary
Four step builders, and the runner walks them in order. user and
model you have seen. input and result exist for the
other direction: when the agent stops and waits, they are how the test answers it.
| Step | What it does |
|---|---|
user(text) |
A message from the user. The first one starts the run; later ones answer a run that yielded for a message. |
model(text | config) |
One model call. A string is { text }; the config form is below. |
input({ toolName: value }) |
The value a yielded tool was waiting for, matched by tool name. Applies to the yield
raised by the model step it follows.
|
result({ toolName: value }) |
Its sibling, for supplying a yielded call's result. Both resolve the yielded call the
same way, through the session's tool input; prefer input.
|
Here is the pair in motion. ask_night yields, the run stops, the
input step hands back a night, and finalize turns it into the tool's
result — all inside one runTest call.
import { input } from '@animahealth/adk/testing'
const resumed = await runTest(concierge, [
user('Book me a room'),
model({ toolCalls: [{ name: 'ask_night', args: { question: 'Which night?' } }] }),
input({ ask_night: { night: 'Friday' } }),
model('Booked for Friday.'),
])
const waited = {
status: resumed.status,
events: resumed.events.map((event) => event.type),
results: getToolResults(resumed.events),
}
waited
tool_yield, invocation_yield, tool_input,
invocation_resume — the whole pause is in the ledger, which is what lets a
sleeping agent be a database row. Delete the input line and run again: the test
comes back with status yielded_tool, still waiting. Yielding tools have their own
chapter, stopping to ask.
A model turn's config is a small, closed set:
| Field | Scripts |
|---|---|
text |
An assistant reply. |
thought |
A reasoning summary, landing as a thought event. |
toolCalls |
{ name, args } pairs — one tool_call each. |
error |
An Error the model call throws instead of answering. |
delayMs |
A pause before the turn — for asserting on timeouts. |
streamChunks, chunkSize |
Emit the text and thought as deltas, sliced to chunkSize characters
(default 10), so a streaming consumer has something to consume.
|
Step 5
Stand-ins, failures, and the adapter underneath
Not every test needs a real agent. mockAgent(name, config?) — positional name
first — returns an agent with no tools and a history renderer, useful as the neighbour in a
multi-agent test or as the thing a router hands off to. Its config.responses are
consumed before the script's model steps.
And failure is a step like any other. model({ error }) throws from inside the
model call, so the runTest promise rejects — catch it, and assert on what your
error handlers did.
import { mockAgent } from '@animahealth/adk/testing'
const triage = mockAgent('triage', { responses: [{ text: 'Escalating to a human.' }] })
const stubbed = await runTest(triage, [user('My booking vanished')])
let failure = 'the run did not fail'
try {
await runTest(mockAgent('flaky'), [
user('hello'),
model({ error: new Error('429 rate limited') }),
])
} catch (thrown) {
failure = thrown instanceof Error ? thrown.message : String(thrown)
}
const stubs = { stubbed: getLastAssistantText(stubbed.events), failure }
stubs
Underneath both is MockAdapter, and you can hold it yourself. An app built with
adk({ adapters: { openai: mockAdapter } }) runs the ordinary
app.run path — sessions, hooks, stores, everything the quickstart showed — with
the model replaced. That is the way to test the code around the agent rather than the
agent's turns.
import { MockAdapter } from '@animahealth/adk/testing'
const scripted = new MockAdapter({
responses: [
{ toolCalls: [{ name: 'quote_nights', args: { nights: 3, guests: 2 } }] },
{ text: 'Three nights for two is 240.' },
],
defaultResponse: { text: 'the script ran out' },
})
const offline = adk({ adapters: { openai: scripted } })
const first = await offline.run(concierge, 'Three nights for two?')
const second = await offline.run(concierge, 'And a fourth night?')
const adapterRuns = { first: first.output.text, second: second.output.text }
adapterRuns
The second run prints the fallback: one adapter holds one cursor, and it keeps advancing
across runs. When the responses are exhausted, defaultResponse answers — and its
own default is the string Mock response, which is how an under-scripted test
passes while asserting nothing. Set defaultResponse to something you would
notice, or call scripted.reset() between runs.
Step 6
In your repo, with no credentials
Everything above is a plain async function call, so it drops into any runner. Nothing reads an
environment variable, opens a socket, or asks for a key — which means agent tests pass on a
fork, on a pull request from outside your org, and on a laptop with no secrets. This block
imports vitest, which this page's substrate does not serve, so it is shown rather
than run.
import { describe, expect, it } from 'vitest'
import { getToolResults, model, runTest, user } from '@animahealth/adk/testing'
import { concierge } from './concierge'
describe('concierge', () => {
it('quotes from the tool, not from the model', async () => {
const outcome = await runTest(concierge, [
user('Two nights for three of us?'),
model({ toolCalls: [{ name: 'quote_nights', args: { nights: 2, guests: 3 } }] }),
model('That comes to 240.'),
])
expect(outcome.status).toBe('completed')
expect(getToolResults(outcome.events)).toEqual([
{ name: 'quote_nights', result: { total: 240 } },
])
})
})
The kit also ships vitest matchers — toHaveAssistantText,
toHaveToolCall, toHaveToolResult, toHaveEventSequence,
toHaveStatus, toHaveState, toHaveEvent,
toBeUuid — registered by await setupAdkMatchers(). One caveat: their
type declaration is not re-exported from /testing, so TypeScript sees them only
once your project picks it up. The helper-based assertions above need no setup at all.
One more piece of the kit these cells did not need: createTestContext(agent).
Scripted turns prove behaviour; to score quality against a real model, go on to
measuring agents.