just-another-sdk

Testing

Test agents offline and deterministically, with no API key and no bill.

Agent tests that hit a real model are slow, flaky, and expensive, so they end up not being written. just-another-sdk/testing ships the helpers to avoid that — the same ones the SDK's own offline test suite uses, which is why they run in about 300ms with no key and no network.

# nothing to install — it is part of the package

Scripting a model

mockProvider takes a script of turns. A turn with toolCalls makes the agent execute tools; a turn with only text ends the run.

import { describe, expect, it } from 'vitest'
import { Agent } from 'just-another-sdk'
import { mockProvider } from 'just-another-sdk/testing'

it('answers using the weather tool', async () => {
  const model = mockProvider([
    { toolCalls: [{ toolName: 'get_weather', input: { city: 'Paris' } }] },
    { text: 'It is 18°C and clear in Paris.' },
  ])

  const agent = new Agent({ name: 'test', model, tools: [getWeather] })
  const result = await agent.run('Weather in Paris?')

  expect(result.text).toBe('It is 18°C and clear in Paris.')
  expect(result.stopReason).toBe('finish')
  expect(result.turns).toBe(2)
})

Asserting on what was sent

Every request is recorded, so you can test the prompt as well as the answer:

expect(model.calls).toHaveLength(2)
expect(model.calls[0].system).toBe('Be concise.')
expect(model.calls[0].tools?.map((tool) => tool.name)).toEqual(['get_weather'])

// The second call must carry the tool-call turn and its result
const second = model.calls[1].messages
expect(second.at(-2)?.role).toBe('assistant')
expect(second.at(-1)?.role).toBe('tool')

Prop

Type

Streaming

The mock implements stream() as well as generate(), and both derive their response from the same place — so "the deltas concatenate to the final text" is guaranteed by construction rather than something your test has to assume:

const model = mockProvider([{ text: 'Hello there.' }])
const stream = new Agent({ name: 'test', model }).stream('hi')

let streamed = ''
for await (const chunk of stream.textStream()) streamed += chunk

expect(streamed).toBe((await stream).text)

To test the fallback for providers that do not stream, omit the method entirely:

const model = mockProvider([{ text: 'ok' }], { supportsStreaming: false })
// agent.stream() still works; `model.streamCallCount` stays 0.

Tool calls stream as two fragments each, so argument reassembly is genuinely exercised. streamCallCount tells you which path a run took.

Dynamic responses

Pass a function to react to the incoming request:

const model = mockProvider((request, index) => {
  const lastMessage = request.messages.at(-1)
  if (index === 0)
    return { toolCalls: [{ toolName: 'search', input: { q: 'x' } }] }
  return { text: `I saw ${request.messages.length} messages.` }
})

Catching extra model calls

By default the last turn repeats when the script runs out, which keeps a loop test from failing for the wrong reason. Use 'throw' to assert an exact call count:

const model = mockProvider([{ text: 'done' }], { onExhausted: 'throw' })
// A second model call now fails the test loudly.

Asserting on events

collectEvents records the run so you can test what the agent did, not just what it returned. An ordering assertion is the cheapest way to catch a regression in the loop:

import { collectEvents, mockProvider } from 'just-another-sdk/testing'

const collected = collectEvents()

await new Agent({ name: 'test', model, tools: [getWeather] }).run('go', {
  onEvent: collected.listener,
})

expect(collected.types()).toEqual([
  'run.start',
  'model.request',
  'model.response',
  'tool.start',
  'tool.end',
  'model.request',
  'model.response',
  'run.finish',
])

expect(collected.first('tool.end')?.isError).toBe(false)
expect(collected.last('run.finish')?.stopReason).toBe('finish')
expect(collected.count('model.request')).toBe(2)

Testing the paths that matter

The interesting tests are the failure ones, and they are all offline.

it('recovers when a tool throws', async () => {
  const broken = tool({
    name: 'broken',
    description: 'Always fails.',
    execute: () => {
      throw new Error('upstream down')
    },
  })

  const model = mockProvider([
    { toolCalls: [{ toolName: 'broken' }] },
    { text: 'Sorry, that service is unavailable.' },
  ])

  const result = await new Agent({ name: 'test', model, tools: [broken] }).run(
    'go',
  )

  // The run completed rather than throwing
  expect(result.stopReason).toBe('finish')
  expect(result.steps[0].toolResults[0].isError).toBe(true)
})

it('stops at maxTurns instead of looping forever', async () => {
  // A model that always calls a tool — the pathological case
  const model = mockProvider([
    { toolCalls: [{ toolName: 'get_weather', input: { city: 'X' } }] },
  ])

  const result = await new Agent({
    name: 'test',
    model,
    tools: [getWeather],
    maxTurns: 3,
  }).run('go')

  expect(result.stopReason).toBe('max_turns')
  expect(model.calls).toHaveLength(3)
})

it('rejects invalid tool input without calling the handler', async () => {
  const execute = vi.fn()
  const strict = tool({
    name: 'strict',
    description: 'x',
    inputSchema: z.object({ city: z.string() }),
    execute,
  })

  const model = mockProvider([
    { toolCalls: [{ toolName: 'strict', input: { city: 123 } }] },
    { text: 'recovered' },
  ])

  await new Agent({ name: 'test', model, tools: [strict] }).run('go')

  expect(execute).not.toHaveBeenCalled()
})

it('surfaces a provider failure as a typed error', async () => {
  const model = mockProvider([{ error: new Error('boom') }])
  const agent = new Agent({ name: 'test', model })

  await expect(agent.run('go')).rejects.toMatchObject({
    code: 'provider_error',
  })
})

Structured output, including the repair

A malformed-then-valid script is the repair path — no extra mock capability needed:

it('repairs an answer that failed the schema', async () => {
  const model = mockProvider(
    [
      { text: 'Sure! ```json\n{"severity":"high","summary":"x"}\n```' },
      { text: '{"severity":4,"summary":"x"}' },
    ],
    { onExhausted: 'throw' }, // fails loudly if a third call happens
  )

  const result = await new Agent({
    name: 'test',
    model,
    outputSchema: z.object({ severity: z.number(), summary: z.string() }),
  }).run('go')

  expect(result.output.severity).toBe(4)
  expect(result.steps.map((s) => s.kind)).toEqual(['turn', 'repair'])
  expect(result.turns).toBe(1) // a repair is not a turn
})

Drop maxOutputRetries: 0 in to assert the failure instead — it rejects with an InvalidOutputError carrying issues and rawText.

Guardrails and approval

The two assertions worth writing: a rejected input costs nothing, and a blocked tool call leaves the run recoverable.

it('rejects before spending a token', async () => {
  const model = mockProvider([{ text: 'never reached' }])
  const agent = new Agent({
    name: 'test',
    model,
    inputGuardrails: [{ name: 'too-long', check: () => ({ reject: 'Nope.' }) }],
  })

  await expect(agent.run('hi')).rejects.toThrow(GuardrailError)
  expect(model.calls).toHaveLength(0) // the receipt
})

it('suspends and resumes', async () => {
  const error = (await agent
    .run('refund 500')
    .catch((e) => e)) as ApprovalRequiredError

  const final = await agent.resumeApproval(error.suspension, [
    {
      toolCallId: error.suspension.pending.calls[0].toolCallId,
      approved: true,
    },
  ])

  expect(final.steps.map((s) => s.kind)).toEqual(['resume', 'turn'])
})

Testing a provider

Providers accept an injected fetch, so you can exercise the real translation and error-mapping code with no network:

const fetchMock: typeof fetch = (url, init) =>
  Promise.resolve(
    new Response(
      JSON.stringify({
        choices: [{ finish_reason: 'stop', message: { content: 'hi' } }],
        usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
      }),
      { status: 200, headers: { 'content-type': 'application/json' } },
    ),
  )

const model = openrouter('anthropic/claude-opus-5', {
  apiKey: 'sk-test',
  fetch: fetchMock,
})

Use it to pin error mapping too:

const rateLimited: typeof fetch = () =>
  Promise.resolve(
    new Response('{}', { status: 429, headers: { 'retry-after': '30' } }),
  )

await expect(agent.run('go')).rejects.toMatchObject({
  code: 'rate_limit_error',
  retryable: true,
  retryAfterMs: 30_000,
})

Should you ever test against a real model?

Yes — but keep it in a separate suite, gated on a key being present, so CI stays fast and deterministic:

// The whole suite disappears when there is no key.
describe.skipIf(!process.env.OPENROUTER_API_KEY)('live', () => {
  it('round-trips a real tool call', async () => {
    const agent = new Agent({
      name: 'live',
      model: openrouter('openai/gpt-4o-mini'), // cheapest tool-capable model
      tools: [getWeather],
      maxOutputTokens: 128,
    })

    const result = await agent.run('What is the weather in Paris?')
    expect(result.stopReason).toBe('finish')
    expect(result.usage.inputTokens).toBeGreaterThan(0)
  }, 60_000) // real calls need a real timeout
})

This is worth the effort. A mock provider proves the loop is correct; only a real model proves the wire format is — request translation, tool-call round-tripping, and provider error mapping. This SDK's own live suite caught a bug the entire offline suite missed: a run-level timeout was being reported as a network error, because real fetch rejects with the abort reason object while the mock rejected with a generic AbortError.

The SDK's own version of this is packages/core/test/live.test.ts — 12 tests covering parallel tool calls, failure recovery, forced tool choice, maxTurns, authentication failure, cancellation, and multi-turn context. Total cost per run is a fraction of a cent.

Next

On this page