Agora

Testing

Drive the agent offline — the FakeModelProvider and echoScript, and in-memory doubles for the store, sink, quota, and governance queries, all from @adonis-agora/agent/testing.

Everything the agent depends on is a seam, so a test can run a full turn with no API key and no database. The @adonis-agora/agent/testing subpath ships a deterministic model and in-memory doubles for every dependency.

The fake model

FakeModelProvider is a deterministic, offline ModelProvider. You give it a script — a pure function of the turn's args and the turn index — and it streams the scripted text to the sink and optionally requests one tool call:

import { FakeModelProvider } from '@adonis-agora/agent/testing'

const model = new FakeModelProvider((args, turnIndex) => {
  if (turnIndex === 0) {
    // first turn: ask to call a tool
    return { text: 'Let me check that.', toolCall: { name: 'getWeather', input: { city: 'Lisbon' } } }
  }
  // second turn: finish with the tool result in hand
  return { text: 'It is 21°C in Lisbon.' }
})

turnIndex is derived from the message history (the count of assistant turns so far), so the script is a pure function of its inputs — deterministic and replay-safe, with no internal counter. A scripted turn can also report a costUsd, exactly as a gateway provider would, to exercise cost accounting.

For the trivial case, echoScript streams a fixed reply and never calls a tool:

import { FakeModelProvider, echoScript } from '@adonis-agora/agent/testing'

const model = new FakeModelProvider(echoScript('ok'))

In-memory doubles

Each infrastructure seam has an in-memory implementation of the same SPI:

DoubleSPINotes
InMemoryAgentStoreAgentStoreThe behavioral twin of the Lucid store — threads, messages, tool calls, usage.
InMemoryTokenStreamSinkTokenStreamSinkBuffers and replays deltas per run, so you can assert on streamed text.
InMemoryQuotaStoreQuotaStoreA single-process daily quota for exercising the fail-closed budget gate.
InMemoryGovernanceQueriesAgentGovernanceQueriesThe read-model (spend by model/actor, usage trend, runs, tool stats, reliability, approvals) over in-memory data, with a settable InMemoryModelPrice table.
InMemoryPricingStoreAgentPricingStoreAn in-memory model pricing table for exercising the cost fold.
InMemoryActorDirectoryActorDirectoryA fixed actorRef → label map for governance-surface tests.
InMemoryAttachmentStagingStoreAttachmentStagingStoreStages uploaded bytes into a data: URL — exercises the attachments route offline.
FakeEmbeddingProvider / FakeReranker / inMemoryRetrieverRAG SPIsDeterministic embedding, reranking, and an in-memory retriever for RAG tests.
FakeMediaManager / fakePdfExtractor / inMemoryMediaRagIngestionmedia→RAGDrive media ingestion with no media library or PDF peer.
import {
  FakeModelProvider,
  echoScript,
  InMemoryAgentStore,
  InMemoryTokenStreamSink,
  InMemoryQuotaStore,
} from '@adonis-agora/agent/testing'

A minimal turn in a test

Wire the fakes into an AgentDepsFactory, build an InlineAgentRunner, and start a run:

import {
  AgentDepsFactory,
  AgentRegistry,
  ToolRegistry,
  DefaultToolAuthorizer,
  InlineAgentRunner,
} from '@adonis-agora/agent'
import {
  FakeModelProvider,
  echoScript,
  InMemoryAgentStore,
  InMemoryTokenStreamSink,
} from '@adonis-agora/agent/testing'

const store = new InMemoryAgentStore()
const sink = new InMemoryTokenStreamSink()
const registry = new ToolRegistry()
const agents = new AgentRegistry()
agents.register({ name: 'default' })

const factory = new AgentDepsFactory({
  model: new FakeModelProvider(echoScript('hello')),
  store,
  sink,
  rolesPolicy: new DefaultToolAuthorizer(['ADMIN']),
  registry,
  agents,
})

const runner = new InlineAgentRunner(factory, store)
const { runId } = await runner.start({
  threadId: (await store.createThread({ actor: { id: 'u1', roles: ['ADMIN'] }, persona: 'default' })).id,
  actor: { id: 'u1', roles: ['ADMIN'] },
  userText: 'hi',
})

// collect the streamed text
let text = ''
for await (const frame of sink.subscribe(runId)) {
  if (frame.t === 'text') text += frame.v
}

subscribe() yields typed StreamFrame objects, not bytes — { t: 'text', v } for a token chunk and { t: 'component', name, data } for a Generative UI frame. Narrowing on t is what lets one loop assert on the prose and the emitted components separately. (The SSE encoding happens later, in the provider's route handler; a test never sees it.)

Because the fakes are behavioral twins

The in-memory store, sink, and quota implement the same SPIs as their production counterparts, so a test that passes against the fakes exercises the identical loop, governance, and accounting code paths — you're testing the real thing, just without the network or a database.

On this page