The agent-loop testkit

The agent-loop testkit is test tooling in the DataMind Oda source. It gives a test the standard services an agent loop needs, mounts the production loop, and provides inbox stubs, so tests that target the loop or a durable inbox do not rebuild that wiring. To choose between the test benches, see Testing tiers.

Mount the prerequisite services

mountAgentLoopTestDependencies mounts the six services an agent loop depends on, in this fixed order:

  1. model
  2. session store
  3. projection registry
  4. system prompt
  5. tool runtime
  6. agent registry

Start the real loop

mountAgentLoopTestHarness mounts the production loop with an empty agent list, so no agents exist until you create them, and returns two helpers:

HelperWhat it does
createCreates a production agent with its fresh session; the promise resolves to that agent.
claimAdmits pending messages through the production loop driver's own claim operation.

The context owns the loop and every agent created by the testkit. A test sets it up in this order (imports omitted):

ts
const ctx = new Context()

await mountAgentLoopTestDependencies(ctx)
// Mount the model adapter and any load-order-sensitive plugins here.
const helpers = await mountAgentLoopTestHarness(ctx)
const agent = await helpers.create(SessionId('test-agent'))

agent.inbox.append('next-turn', message)
const admitted = helpers.claim(agent, 'next-turn', 1)

Work with inboxes

Two stubs stand in for an inbox when the test does not need real inbox behaviour, and one real inbox comes with the testkit:

Limits

Questions, answered

In what order does the testkit mount the prerequisite services?

Model, session store, projection registry, system prompt, tool runtime, then agent registry. The helper stops before the agent loop, so the test controls the loop's load order.

Does the testkit register a model adapter?

No. The dependency helper mounts no adapter and stops before the loop, so the test decides which adapter runs. Register the one the test exercises after the prerequisites, before the loop starts.

What does the testkit start with?

The production loop with an empty agent list. No agent exists until you create one with create, and the context owns it.

What does a claim return?

The next step's messages first, then one next-turn message when one is requested. The claim runs through the production loop driver's own operation, and each admitted message fires agent/inbox/claimed with the turn that owns it.

Why does my test agent refuse inbox mutations?

It uses the fail-fast stub, unsupportedInbox, which refuses every mutation by design and throws this test Agent does not support Inbox mutations at the first attempt. Use the mutable stub when the test edits pending input, or a real agent from the testkit.

Why was my message refused as already pending?

A message with that id is already pending in a real inbox. The refusal names it: message "<id>" is already pending. Use a new message, or edit or remove the pending one first.

Can I test durable events or projections with the stub?

No. The mutable stub is process-local and never writes to a session. Durable events, projection validation and live inbox notifications all require a real agent created by the testkit.

What happens to the services when one fails to load?

The helper call rejects. Services activated before the failure stay owned by the context and unwind when the context is disposed.

What do I dispose after a test?

The owning context. Dispose it after every test so its agents reach quiescence and their scoped registrations unwind.

Is the testkit a replacement for the other benches?

No. It provides the loop's prerequisite services and a driver for the real loop; the other benches cover the interface, model traffic and recorded sessions. Testing tiers compares them.