Aviary
Recipes

Offline testing

Exercise the whole agent loop — chat, tool calling, HITL approval — with @dudousxd/nestjs-agent-testing's in-memory doubles. No API key, no Redis, fully deterministic.

Testing agent behavior shouldn't need a real LLM key or infrastructure. @dudousxd/nestjs-agent-testing ships an in-memory double for every SPI the module depends on — model, store, sink, quota, governance — so a full AgentModule.forRoot boots and runs turns in a plain Nest testing module.


Wire in-memory everything

// src/app.e2e.spec.ts
import { Agent, AgentModule, AgentService, AiTool, HeaderActorResolver } from '@dudousxd/nestjs-agent';
import {
  FakeModelProvider,
  InMemoryAgentStore,
  InMemoryQuotaStore,
  InMemoryTokenStreamSink,
  type FakeScript,
} from '@dudousxd/nestjs-agent-testing';
import { Injectable } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { z } from 'zod';

@AiTool({
  name: 'getWeather',
  kind: 'read',
  description: 'Current weather for a city.',
  input: z.object({ city: z.string() }),
})
@Injectable()
class GetWeatherTool {
  async execute(input: { city: string }) {
    return { tempC: 21, city: input.city };
  }
}

// Agents are `@Agent`-decorated providers, discovered at boot — not a `defaultAgent` config object.
@Agent({ name: 'default', systemPrompt: 'test agent' })
@Injectable()
class TestAgent {}

async function collect(iterable: AsyncIterable<Uint8Array>): Promise<string> {
  const decoder = new TextDecoder();
  let out = '';
  for await (const chunk of iterable) {
    out += decoder.decode(chunk);
  }
  return out;
}

Script the model, run a turn

FakeModelProvider takes a FakeScript — a pure function of the turn's args and how many assistant turns have already happened — so a scripted conversation stays deterministic across replays. Here it asks for getWeather on turn 0, then answers on turn 1:

it('auto-executes a read tool then answers', async () => {
  const script: FakeScript = (_args, turnIndex) =>
    turnIndex === 0
      ? { text: 'checking', toolCall: { name: 'getWeather', input: { city: 'Recife' } } }
      : { text: 'it is 21C' };

  const store = new InMemoryAgentStore();
  const moduleRef = await Test.createTestingModule({
    imports: [
      AgentModule.forRoot({
        model: new FakeModelProvider(script),
        store,
        sink: new InMemoryTokenStreamSink(),
        quota: new InMemoryQuotaStore(200_000),
        actorResolver: new HeaderActorResolver(),
      }),
    ],
    providers: [GetWeatherTool, TestAgent],
  }).compile();

  const app = moduleRef.createNestApplication();
  await app.init();
  const service = app.get(AgentService);

  const { runId } = await service.chat({ actor: { id: 'u1', roles: ['ADMIN'] }, message: 'weather?' });
  const streamed = await collect(service.subscribe(runId));

  expect(streamed).toContain('it is 21C');
  expect(store.toolCallRows()[0]).toMatchObject({ toolName: 'getWeather', status: 'executed' });

  await app.close();
});

Exercising HITL approval

An action tool suspends instead of auto-executing; drive the approval from the test itself via AgentService.approve:

it('suspends an action tool until approved, then executes', async () => {
  const script: FakeScript = (_args, turnIndex) =>
    turnIndex === 0
      ? { text: 'about to purge', toolCall: { name: 'purgeCache', input: { key: 'cfg' } } }
      : { text: 'purged ok' };

  // ...same module wiring as above, with a `kind: 'action'` PurgeCacheTool provider...

  const { runId } = await service.chat({ actor: { id: 'u1', roles: ['ADMIN'] }, message: 'purge it' });

  const collected = collect(service.subscribe(runId));
  await new Promise((resolve) => setTimeout(resolve, 20)); // let the loop reach the approval gate
  // approve by tool-call id — the server derives the run; FakeModelProvider ids calls `call-<turn>-<name>`
  await service.approve({ id: 'u1', roles: ['ADMIN'] }, 'call-0-purgeCache');

  expect(await collected).toContain('purged ok');
});

Governance doubles, when you need them

InMemoryGovernanceQueries wraps an InMemoryAgentStore and takes an optional pricing map (default empty → zero cost, tokens still counted) — bind it to AGENT_GOVERNANCE_QUERIES to test a billing export or the dashboard/telescope wiring without a database. InMemoryQuotaStore and InMemoryTokenStreamSink are the same doubles used above; none of the five require Redis, Postgres, or an API key.


The runnable proof

The full picture — durable turns, SSE streaming, HITL approval, multi-agent delegation, and the diagnostics channel, all offline — lives in examples/agent-demo in the library's repo. It wires FakeModelProvider, InMemoryAgentStore, InMemoryQuotaStore, and InMemoryGovernanceQueries together with the real durable runner (InMemoryStateStore, no Redis) and the standalone dashboard:

pnpm --filter agent-demo demo

It prints three scenarios end to end — a read tool auto-executing, an action tool suspending for approval and resuming, and an orchestrator delegating to a sub-agent — against a real, booted Nest app, using nothing but the doubles above.


  • Testing package — the full @dudousxd/nestjs-agent-testing surface: every in-memory double and what SPI each one satisfies
  • Toolsread vs action, and why the approval flow exists
  • Human-in-the-loop & Durability — the same approval flow under the durable runner, where the pause is a real suspend instead of an in-process hold

On this page