Skip to content

Contract tests

A contract test verifies that an actor obeys what it advertises. Unit tests check that handlers do the right thing when called; contract tests check that the bus-level shape matches static accepts / emits / $introspect. Both are necessary; they catch different bugs.

Why contract tests exist

LLM agents and other actors discover what an actor does by reading $introspect. If your actor emits 'foo.changed' but only declares 'foo.updated' in static emits, agents pick up the wrong subscription topic and the system silently misbehaves. A contract test catches the drift before it ships.

What a contract test asserts

Five things, in order of value:

  1. $introspect returns the declared contract. Mount the actor, request $introspect, assert the response matches the static fields.
  2. Every op in static accepts has a real handler. No declared op should silently no-op.
  3. Every op the actor emits in practice is declared in static emits. Catches typos.
  4. Every field has a description. Required by the Actor Communication Contract.
  5. Error specs and output schemas (when declared) match real responses. Required for $introspect { depth: 'full' } consumers.

Asserting $introspect

ts
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
  MatrixRuntime, InMemoryTransport, InMemoryBroker, RequestReply,
} from '@open-matrix/core';
import { Echo } from '../src/echo-actor';

test('Echo $introspect matches static accepts/emits', async () => {
  const broker = new InMemoryBroker();
  const transport = new InMemoryTransport(broker);
  const runtime = new MatrixRuntime({ transport });

  await runtime.create(Echo, 'echo');

  const intro = await RequestReply.request<{
    accepts: Record<string, { description: string }>;
    emits: Record<string, { description: string }>;
  }>(transport, 'echo', '$introspect', {});

  // Every static accept op shows up in $introspect.
  for (const op of Object.keys(Echo.accepts)) {
    assert.ok(intro.accepts[op], `expected ${op} in accepts`);
    assert.ok(intro.accepts[op].description, `${op} missing description`);
  }
  // No extra ops snuck in.
  for (const op of Object.keys(intro.accepts)) {
    assert.ok(Echo.accepts[op] || op.startsWith('$'), `unexpected op ${op}`);
  }

  await runtime.shutdown();
});

Asserting "every accept has a handler"

ts
import { toHandlerCase } from '@open-matrix/core';

test('Echo declares no orphan accepts', () => {
  const actor = new Echo();
  for (const op of Object.keys(Echo.accepts)) {
    if (op.startsWith('$')) continue; // system op, base class supplies handler
    const handlerName = `on${toHandlerCase(op)}`;
    assert.equal(typeof (actor as any)[handlerName], 'function',
      `${Echo.name}.accepts['${op}'] declared but ${handlerName} not implemented`);
  }
});

toHandlerCase is the same function the framework uses for dispatch (projects/matrix-3/packages/core/src/engine/utils/NameUtils.ts). Reusing it ensures the test sees what the framework would see.

Asserting "every emit op is declared"

This is harder to check statically. The pattern is dynamic: drive the actor through a representative scenario, collect every emit, assert each one is in static emits.

ts
test('Echo emits only declared ops', async () => {
  const broker = new InMemoryBroker();
  const transport = new InMemoryTransport(broker);
  const runtime = new MatrixRuntime({ transport });

  await runtime.create(Echo, 'echo');

  const seen = new Set<string>();
  transport.subscribe('echo.$events', (payload) => {
    const env = payload as MxEnvelope;
    if (env?.op && !env.op.startsWith('$') && !env.op.startsWith('lifecycle.')) {
      seen.add(env.op);
    }
  });

  // Drive the actor through every code path that emits.
  await RequestReply.request(transport, 'echo', 'echo.say', { msg: 'hi' });
  // ... add more drivers to cover all emit branches ...

  for (const op of seen) {
    assert.ok(Echo.emits[op], `${op} emitted but not declared in static emits`);
  }

  await runtime.shutdown();
});

Filter out system events ($stateChanged, $propertyChanged) and the framework-emitted lifecycle.available/lifecycle.removed — they are emitted by the base class, not your code.

Asserting "every field has a description"

The Actor Communication Contract requires it (root CLAUDE.md § "THE RULES" #3). A simple recursive check:

ts
function assertAllFieldsHaveDescriptions(spec: Record<string, any>, path: string[] = []) {
  for (const [key, value] of Object.entries(spec)) {
    const here = [...path, key];
    if (key === 'description') {
      assert.ok(typeof value === 'string' && value.length > 0,
        `description at ${here.join('.')} must be a non-empty string`);
    } else if (value && typeof value === 'object') {
      assertAllFieldsHaveDescriptions(value, here);
    }
  }
}

test('Echo accepts schemas have descriptions everywhere', () => {
  for (const [op, spec] of Object.entries(Echo.accepts)) {
    assertAllFieldsHaveDescriptions(spec as any, [op]);
  }
});

Repeat for emits, streams, state, subscribes, blackboard.

Asserting "ops not in accepts are dropped"

The framework silently drops ops not in accepts ∪ system ops ∪ framework ops (MatrixActor.ts:2916-2917). Assert that the actor doesn't accidentally implement a handler for an undeclared op:

ts
test('Echo does not respond to undeclared ops', async () => {
  // ... mount actor ...
  let responded = false;
  await RequestReply.request(transport, 'echo', 'echo.unknown', {}, { timeoutMs: 200 })
    .then(() => { responded = true; })
    .catch(() => {});
  assert.equal(responded, false, 'echo responded to undeclared op');
});

The request should time out (no reply) — that's the desired behaviour.

Pattern: shared contract-test suite

When multiple actors implement the same contract (e.g., several inference providers all implement IInferenceClient), factor the contract assertions into a shared suite that any implementation can plug into:

ts
// tests/contract/inference-client.contract.ts
export function inferenceClientContract<T extends MatrixActor>(
  ActorClass: new () => T,
  setup: () => Promise<{ runtime: MatrixRuntime; transport: ITransportAdapter }>,
) {
  test(`${ActorClass.name} satisfies inference contract — $introspect shape`, async () => { ... });
  test(`${ActorClass.name} satisfies inference contract — request shape`, async () => { ... });
}

Then each implementation file imports and runs the suite:

ts
import { inferenceClientContract } from '../contract/inference-client.contract';
inferenceClientContract(AnthropicProvider, () => makeRuntime());
inferenceClientContract(OpenAIProvider, () => makeRuntime());

This is how the inference-driver packages in projects/omega-drivers/ test their providers against a single contract.

Where contract tests live

For a single-package contract: in the package's tests/. For a contract that spans multiple packages: in projects/matrix-3/tests/ under a folder named for the contract (tests/contract/, tests/inference/).

See also

Source: projects/matrix-3/packages/core/src/core/MatrixActor.ts:840-842, 2916-2917 ($introspect, op admittance), WORKSTREAMS/core-and-packaging/ACTOR-COMMUNICATION-CONTRACT.md (the rule that field descriptions are required).