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.

Audience · engineers writing agent tests Needs · nothing (no API key) Import · @animahealth/adk/testing

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.