Cogitator
Testing

Test Utilities

@cogitator-ai/test-utils — a scriptable mock LLM backend, a recording memory adapter, fixtures and async/stream helpers for testing Cogitator code.

@cogitator-ai/test-utils gives you deterministic stand-ins for the parts of a run that are slow, paid or non-deterministic:

pnpm add -D @cogitator-ai/test-utils
ExportWhat it is
MockLLMBackend, createMockLLMBackend()An LLMBackend that answers with scripted responses and records every request
MockMemoryAdapter, createMockMemoryAdapter()An in-process MemoryAdapter that records every method call
createTestAgentConfig(), createTestAgent()Agent configs and agent-shaped objects with test defaults
createUserMessage(), createToolCall(), …Message, tool call and tool result fixtures
createTestTool(), createCalculatorTool(), …Tool-shaped fixtures with JSON Schema parameters
createMockRunResult(), …RunResult fixtures for code that consumes run results
collectStreamContent(), …Drain ChatStreamChunk streams
waitFor(), eventually(), delay(), withTimeout()Async test helpers

MockLLMBackend

A Cogitator runs agents on the backends you hand it in llm.backends: an agent with model mock/<anything> runs on the backend registered under mock, and the backend receives <anything> as request.model.

import { Cogitator, Agent, tool } from '@cogitator-ai/core';
import { MockLLMBackend, createToolCall } from '@cogitator-ai/test-utils';
import { z } from 'zod';

const getWeather = tool({
  name: 'get_weather',
  description: 'Get the weather for a city',
  parameters: z.object({ city: z.string() }),
  execute: async ({ city }) => ({ city, temperature: 22 }),
});

const mock = new MockLLMBackend().setResponses([
  { toolCalls: [createToolCall('get_weather', { city: 'Tokyo' })] },
  { content: 'It is 22°C in Tokyo.' },
]);

const cog = new Cogitator({ llm: { backends: { mock } } });
const agent = new Agent({
  name: 'weather',
  model: 'mock/test-model',
  instructions: 'You are a weather assistant.',
  tools: [getWeather],
});

const result = await cog.run(agent, { input: 'Weather in Tokyo?' });

expect(result.output).toBe('It is 22°C in Tokyo.');
expect(result.toolCalls.map((call) => call.name)).toEqual(['get_weather']);
expect(mock.getCallCount()).toBe(2);
expect(mock.getLastCall()?.model).toBe('test-model');

await cog.close();

The tool really runs: the first response asks for get_weather, the runtime executes it and sends the result back, and the second response ends the run.

Scripting Responses

MethodEffect
setResponse(response)Answer every chat() call with response
setResponses([r1, r2, …])Answer calls in order; once the list is used up, the last response repeats
setToolCallResponse(toolCalls, content?)Shortcut for setResponse({ content, toolCalls, finishReason: 'tool_calls' })
setDefaultDelay(ms)Wait ms before every response and stream chunk
reset()Forget responses, stream chunks and recorded calls

All setters return the backend, so they chain. A MockChatResponse is a partial ChatResponse plus two test-only fields:

interface MockChatResponse extends Partial<ChatResponse> {
  content?: string; // default ''
  toolCalls?: ToolCall[];
  finishReason?: 'stop' | 'tool_calls' | 'length' | 'error'; // default: 'tool_calls' with toolCalls, else 'stop'
  error?: Error; // thrown instead of answering
  delay?: number; // ms, overrides setDefaultDelay
}

Without any response set, chat() answers with empty content and finishReason: 'stop'. Usage is estimated (about four characters per token) unless the response sets usage.

An error response makes chat() throw. The runtime retries LLM calls by default, so for failure tests turn retries off (or expect maxRetries + 1 calls):

const mock = new MockLLMBackend().setResponse({ error: new Error('provider down') });
const cog = new Cogitator({ llm: { backends: { mock }, retry: false } });

await expect(cog.run(agent, { input: 'hi' })).rejects.toThrow('provider down');

Streaming

chatStream() plays scripted chunks; it never uses the chat() responses. The runtime streams only when the run has both stream: true and onToken:

const mock = new MockLLMBackend().setStreamChunks([
  { content: 'Once ' },
  { content: 'upon a time' },
]);
const cog = new Cogitator({ llm: { backends: { mock } } });

const tokens: string[] = [];
const result = await cog.run(agent, {
  input: 'Tell me a story',
  stream: true,
  onToken: (token) => tokens.push(token),
});

expect(tokens).toEqual(['Once ', 'upon a time']);
expect(result.output).toBe('Once upon a time');

setMultipleStreamChunks([[…], […]]) scripts one chunk list per chatStream() call (the last list repeats), so a streamed tool call followed by a streamed answer looks like this:

mock.setMultipleStreamChunks([
  [{ toolCalls: [{ id: 'call_1', name: 'get_weather', arguments: { city: 'Oslo' } }] }],
  [{ content: 'It is cold in Oslo.' }],
]);

The last chunk of each list carries finishReason (default 'stop') and usage.

Inspecting Requests

MethodReturns
getCalls()Every ChatRequest received (chat() and chatStream())
getLastCall()The most recent request, or undefined
getCallCount()Number of requests
wasCalledWith(predicate)Whether any request matches (request) => boolean

Each request is recorded as a snapshot of what was sent at that moment: the runtime keeps appending to the same conversation, but getCalls()[0].messages still holds only the messages of the first call, and later calls show the tool results added in between.

expect(mock.getCalls()[0].tools?.map((t) => t.name)).toEqual(['get_weather']);
expect(
  mock.wasCalledWith((req) => req.messages[0].content === 'You are a weather assistant.')
).toBe(true);

provider of the mock is always 'openai'; routing goes by the llm.backends key, not by this field.

Your Own Scripted Backend

When responses depend on the request (who is asking, which tool results came back), implement LLMBackend directly — that is what the core package's own tests do:

import type { ChatRequest, ChatResponse, LLMBackend } from '@cogitator-ai/types';

const requests: ChatRequest[] = [];
const backend: LLMBackend = {
  provider: 'openai',
  async chat(request): Promise<ChatResponse> {
    requests.push({ ...request, messages: [...request.messages] });
    const usage = { inputTokens: 1, outputTokens: 1, totalTokens: 2 };
    const hasToolResult = request.messages.some((m) => m.role === 'tool');
    return hasToolResult
      ? { id: 'r2', content: 'done', finishReason: 'stop', usage }
      : {
          id: 'r1',
          content: '',
          toolCalls: [{ id: 'call_1', name: 'get_weather', arguments: { city: 'Paris' } }],
          finishReason: 'tool_calls',
          usage,
        };
  },
  async *chatStream() {
    yield { id: 's', delta: {}, finishReason: 'stop' };
  },
};

const cog = new Cogitator({ llm: { backends: { mock: backend } } });

Copy messages on the way in, as MockLLMBackend does, so each recorded request keeps what was sent at that moment rather than the conversation as it ended.

MockMemoryAdapter

An in-process MemoryAdapter (provider: 'memory') that keeps threads and entries in maps and records every call. Hand it to a Cogitator through the memory setter:

import { MockMemoryAdapter, MockLLMBackend } from '@cogitator-ai/test-utils';

const memory = new MockMemoryAdapter();
await memory.connect();

const mock = new MockLLMBackend().setResponse({ content: 'Hi there' });
const cog = new Cogitator({ llm: { backends: { mock } } });
cog.memory = memory;

await cog.run(agent, { input: 'Hello', threadId: 'thread-1' });
await cog.run(agent, { input: 'Again', threadId: 'thread-1' });

expect(memory.getCallsFor('createThread')).toHaveLength(1);
expect(memory.getCallsFor('addEntry').length).toBeGreaterThan(0);

Besides the MemoryAdapter methods it has getCalls() ({ method, args }[]), getCallsFor(method), isConnected() and reset() (clears threads, entries, calls and the connection flag). cog.close() disconnects it.

Fixtures

Agents

import { Agent } from '@cogitator-ai/core';
import { createTestAgentConfig, createTestAgent } from '@cogitator-ai/test-utils';

const agent = new Agent(createTestAgentConfig({ tools: [getWeather] }));

createTestAgentConfig(overrides?) returns an AgentConfig with a random id, name: 'test-agent', model: 'mock/test-model', instructions: 'You are a helpful test assistant.', temperature: 0.7, maxIterations: 10, timeout: 30000 and no tools — the model fits a backend registered as mock. Wrap it in new Agent() to run it.

createTestAgent(overrides?) returns a plain object implementing the Agent interface from @cogitator-ai/types (with clone() and serialize()), for code that only needs something agent-shaped. It is not an instance of the core Agent class.

Messages, Tool Calls, Run Results

FixtureReturns
createUserMessage(text) / createAssistantMessage(text) / createSystemMessage(text)Message with that role
createToolMessage(toolCallId, content, name?)Message with role: 'tool'
createMultimodalMessage(parts) / createImageUrlMessage(text, url)User message with content parts
createConversation(...{ role, content })Message[] of user and assistant turns
createToolCall(name, args?)ToolCall with a random call_… id
createToolResult(toolCall, result, error?)ToolResult answering that call
createMockRunResult(output, options?)A complete RunResult (usage 100/50 tokens, empty trace)
createRunResultWithToolCalls(output, toolCalls, options?) / createRunResultWithMessages(output, messages, options?)RunResult with those fields
const call = createToolCall('get_weather', { city: 'Tokyo' });
const history = [
  createUserMessage('Weather in Tokyo?'),
  createToolMessage(call.id, JSON.stringify({ temperature: 22 }), 'get_weather'),
];
const result = createMockRunResult('It is 22°C.', { toolCalls: [call], messages: history });

Tools

createTestTool(options?), createCalculatorTool() (name calculator, a op b only), createWeatherTool() (name get_weather, always 22°C and sunny), createFailingTool(message?) (throws), createSlowTool(delayMs?) and createToolSchema(name, description?) build tool-shaped objects (SimpleTool) whose parameters are JSON Schema rather than Zod. Use them where code takes tool definitions or schemas, or call execute() directly:

const calculator = createCalculatorTool();
await expect(calculator.execute({ expression: '6 * 7' }, context)).resolves.toEqual({ result: 42 });

They are not Tool objects from tool() (no Zod schema, no toJSON()). For agent runs, define tools with tool() as usual.

Helpers

HelperBehaviour
collectStreamContent(stream)Concatenates delta.content of a ChatStreamChunk generator
collectStreamChunks(stream)All chunks as an array
collectStreamToolCalls(stream){ name, arguments } of every complete streamed tool call
createMockReadableStream(chunks)A ReadableStream<Uint8Array> of the encoded strings, e.g. for SSE parsers
waitFor(condition, { timeout?, interval? })Polls until condition() is truthy and returns it (defaults 5000 / 50 ms)
eventually(assertion, { timeout?, interval? })Retries an assertion until it passes, then rethrows the last failure
delay(ms)Resolves after ms
withTimeout(promise, ms)Rejects with Operation timed out after …ms if promise is slower
const mock = new MockLLMBackend().setStreamChunks([{ content: 'Hel' }, { content: 'lo' }]);
const text = await collectStreamContent(mock.chatStream({ model: 'm', messages: [] }));
expect(text).toBe('Hello');

const job = startBackgroundJob();
await eventually(() => expect(job.done).toBe(true), { timeout: 2000 });

On this page