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 demoIt 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.
Related
- Testing package — the full
@dudousxd/nestjs-agent-testingsurface: every in-memory double and what SPI each one satisfies - Tools —
readvsaction, 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
Billing export
Inject AGENT_GOVERNANCE_QUERIES and build your own CSV/JSON spend export off the same read-model the standalone dashboard and Telescope tab consume — the interface and the diagnostics channel are both public.
Configuration
The full AgentModule.forRoot() options reference, forRootAsync, and how @Agent-decorated classes register into the AgentRegistry.