Mock Transport
In-memory transport with controllable state and scripted responses.
The mock transport is a test and demo transport that implements
AgentClientTransport and adds control APIs for seeded state, canned responses,
watch events, files, commands, and inspection.
It does not implement a second client facade. It plugs into the real client:
const transport = createMockAgentTransport<MyContract>();
const client = createAgentClient(transport);From application code's point of view, client is a normal PAI client. The
mock only replaces the backend boundary.
app code
-> real AgentClient
-> real Thread and RunHandle
-> mock AgentClientTransport
-> in-memory store and scripted response queueUse runtime tests when you need real agent execution, lifecycle hooks, model calls, or tool execution. Use the mock transport when you want a deterministic client-visible backend without importing a server agent.
For convenience, createMockAgentClient() creates both the transport and the
real client facade:
const mock = createMockAgentClient<MyContract>();
mock.responses.queue((r) => r.assistant([r.text("Done.")]));
const run = await mock.client.thread("thread-1").send("Hello");
await run.waitUntilIdle();API Shape
The transport object is both an AgentClientTransport and a controllable
test harness:
type MockAgentTransport<
TContract extends AgentContract,
> = AgentClientTransport<TContract> & {
seed(seed: MockTransportSeedInput<TContract>): void;
reset(seed?: MockTransportSeedInput<TContract>): void;
responses: MockResponseController<TContract>;
events: MockEventController<TContract>;
commands: MockCommandController<TContract>;
files: MockFileController;
inspect: MockInspectionController<TContract>;
};The mock client fixture exposes those controls next to the real client:
type MockAgentClient<TContract extends AgentContract> = {
client: AgentClient<TContract>;
transport: MockAgentTransport<TContract>;
seed: MockAgentTransport<TContract>["seed"];
reset: MockAgentTransport<TContract>["reset"];
responses: MockAgentTransport<TContract>["responses"];
events: MockAgentTransport<TContract>["events"];
commands: MockAgentTransport<TContract>["commands"];
files: MockAgentTransport<TContract>["files"];
inspect: MockAgentTransport<TContract>["inspect"];
};The control APIs are intentionally outside AgentClientTransport. Production
transports do not have them; the shared client facade ignores them.
manifest is optional. The mock uses a minimal default manifest unless the code
under test calls client.getManifest() and needs realistic client-visible agent
metadata:
const mock = createMockAgentClient<MyContract>({
manifest: myGeneratedContractManifest,
});The manifest is not the server agent. It should contain only serializable client-safe metadata such as the agent name, tool descriptors, suspend/data channels, and commands.
The mock has no server schema to compute run defaults. When the contract's
metadata has required output fields, provide the canonical initial value
once for every dynamically created run:
const mock = createMockAgentClient<MyContract>({
initialMetadata: { rating: null },
});The option is optional when {} satisfies the client-readable metadata contract. Seeded
run fixtures still accept their own typed metadata, so a test can model
later values explicitly.
Tool provenance normally comes from the manifest. Use
registeredToolMetadata for request-time or test-only registrations that the
manifest cannot describe:
const mock = createMockAgentClient<MyContract>({
registeredToolMetadata: new Map([
[
"lookup_order",
{ executor: "server", toolId: "lookupOrder" } as const,
],
["browser_upload", { executor: "client" } as const],
]),
});A stable server definition produces a typed tool-${name} part with
toolMetadata.pai.toolId. A registration without a stable definition id uses
the SDK's dynamic-tool part and retains its name and executor provenance.
createClientHarness derives this map from a generated React contract, so React
tests rarely pass it directly.
Basic Usage
Seed the mock, queue a response, then interact with the client normally:
const mock = createMockAgentClient<EngineeringSourceContract>();
mock.seed((s) => {
const seeded = s.run({
threadId: "thread-telemetry",
messages: [
s.input("Find observability agencies in North America."),
s.output([
s.text("I found Northstar Engineering and Orbit Labs."),
]),
],
});
return {
threads: [
s.thread("thread-telemetry", {
title: "Warehouse telemetry platform",
messages: seeded.messages,
runs: [seeded.run],
}),
],
};
});
mock.responses.queue((r) =>
r.assistant([
r.text("Northstar is ranked first for observability work."),
r.tool("compareProposals").result({
input: { jobBriefId: "job-telemetry-platform" },
output: {
rankings: [{ agencyId: "agency-northstar", totalScore: 94 }],
},
}),
]),
);
const thread = mock.client.thread("thread-telemetry");
const run = await thread.send("Compare the proposals.");
const state = await run.waitUntilIdle();
state.messages; // PaiMessage<EngineeringSourceContract>[]
state.runs; // normalized run lifecyclethread.send(...) owns the user input. It appends the user message, creates a
run, marks the thread running, consumes the next queued mock response, emits
assistant output, and settles the run. Do not include the user message again in
the queued response.
Seeded State
seed() creates exact native transport state. The callback builder makes the
common case concise while keeping messages and runs as separate normalized
collections:
mock.seed((s) => {
const seeded = s.run({
threadId: "thread-1",
runId: "run-award",
status: "waiting",
messages: [
s.input("Draft the recommendation."),
s.output([
s.text("I need approval before finalizing."),
s.tool("approveAwardRecommendation").waiting({
input: {
jobBriefId: "job-1",
recommendedAgencyId: "agency-1",
},
action: {
name: "approval",
input: { message: "Approve the award recommendation?" },
id: "action-1",
},
}),
]),
],
});
return {
threads: [
s.thread("thread-1", {
title: "Award memo",
metadata: { projectId: "project-1" },
messages: seeded.messages,
runs: [seeded.run],
}),
],
files: [
s.file("file-brief", {
filename: "brief.pdf",
mediaType: "application/pdf",
body: new Blob(["brief contents"]),
}),
],
};
});s.input(), s.context(), and s.output() build native wire messages with the
correct producer and relation metadata. s.run() assigns one run identity to
the ordered messages and returns { run, messages }; pass both collections to
s.thread(). A run fixture requires at least one input message.
s.assistant([...]) builds a richer model message directly, while
s.standalone(message) removes run ownership for intentionally administrative
history. There is no cross-part identifier: native tools use toolCallId, data
parts use id, and text parts are ordered within their owning message.
Because messages is ordered, fixtures can represent lifecycle context exactly
where it entered a run:
s.run({
threadId: "thread-1",
messages: [
s.input("Draft the recommendation."),
s.output([s.text("I am checking the award constraints.")]),
s.context("The approval threshold is now $50,000.", { role: "system" }),
s.output([s.text("This recommendation requires approval.")]),
],
});Use s.input(text, { producer: "trigger" }) together with
initiator: "trigger" for trigger-created runs. The default initiator is
"send". Use s.output(parts, { producer: "lifecycle" }) for
lifecycle-authored output:
s.run({
threadId: "thread-1",
initiator: "trigger",
messages: [
s.input("Refresh the recommendation", { producer: "trigger" }),
s.output([s.text("Visible result")], { producer: "lifecycle" }),
],
});Waiting helpers emit an input-available native tool part plus a reserved
suspension data part. s.thread() derives the transport's action control state
from those chunks. Through the real client, the reserved chunk is projected
onto part.suspensions and part.pendingSuspension; the command remains a
sidecar resolved from the exact owning pair:
import { isPaiToolPart } from "@pai/protocol";
const thread = mock.client.thread("thread-1");
const state = await thread.refresh();
const message = state.messages.find((item) => item.role === "assistant");
const part = message?.parts.find(isPaiToolPart);
if (!message || !part) throw new Error("Expected a waiting tool");
const action = thread.getPendingAction(message, part);
if (!action) throw new Error("Expected a pending action");
mock.responses.queue((r) => r.assistant([r.text("Approval recorded.")]));
const resumed = await action.submit({ approved: true });
await resumed.run?.waitUntilIdle();mock.inspect.thread(...) intentionally returns the raw transport snapshot,
including reserved data-pai-* chunks. thread.refresh() and
thread.getState() return the projected public PaiMessage[] view.
reset(seed?) clears store state, response queues, live watchers, request logs,
and generated ids. Passing a seed immediately loads the next fixture.
Response Queue
Only generation actions consume response scripts:
thread.send(...);thread.retry(...);thread.regenerate(...).
Ordinary state operations such as renameThread, deleteThread, uploadFile,
readFile, executeCommand, and writeToolData mutate the local store directly
and do not consume the response queue.
Queue assistant responses for successful generations:
mock.responses.queue((r) =>
r.assistant([
r.text("Northstar is ranked first for observability work."),
r.tool("compareProposals").result({
input: { jobBriefId: "job-telemetry-platform" },
output: {
rankings: [{ agencyId: "agency-northstar", totalScore: 94 }],
},
}),
]),
);The response builder is a concise scripting DSL, not another stored message
format. The mock converts it to native text and tool parts before publishing
the assistant PaiMessage. Use mock.responses.size() to inspect the number of
unclaimed scripts and mock.responses.clear() to discard them.
Waiting tool fixtures separate model-provided tool input from the pending action
input the client submits. The tool name is already bound by
r.tool("approveAwardRecommendation"), so action.name is the suspend/action
name and the mock transport derives the full pending action name.
mock.responses.queue((r) =>
r.assistant([
r.text("The award memo is ready for approval."),
r.tool("approveAwardRecommendation").waiting({
input: {
jobBriefId: "job-telemetry-platform",
recommendedAgencyId: "agency-northstar",
},
action: {
name: "approval",
input: { message: "Approve the award recommendation?" },
},
}),
]),
);Client-executed tools (hasExecute: false) wait on an implicit action named
output. Its action input has the same shape as the tool input, and the mock
omits the raw suspendName discriminator just like the runtime:
mock.responses.queue((r) =>
r.assistant([
r.tool("browserUpload").waiting({
input: { fileName: "receipt.pdf" },
action: {
name: "output",
input: { fileName: "receipt.pdf" },
},
}),
]),
);If a backend-executed tool instead declares a suspend schema literally named
output, add declaredSuspend: true. This preserves output as the raw
suspend discriminator and types action.input from that suspend schema. The
same distinction applies to queued, seeded, and streamed waiting fixtures.
await response.tool("reviewJob", {
input: { jobId: "job-1" },
waiting: {
action: {
name: "output",
declaredSuspend: true,
input: { prompt: "Approve this job?" },
},
},
});Queue run errors for accepted generations that should fail:
mock.responses.queue((r) => r.error(new Error("Provider failed")));
const run = await thread.send("Draft the memo.");
const state = await run.waitUntilIdle();
expect(state.error).toMatchObject({
message: "Provider failed",
runId: run.runId,
operation: "send",
messageId: expect.any(String),
});A queued run error is consumed by the next admitted send, retry, or
regenerate run. The mock appends the user message, creates the run, consumes
the queued error, marks the run failed, clears the active run, sets
the public ThreadState.error, emits generation.failed, and leaves enough error context
for thread.retry() to regenerate the failed user turn.
Run errors are different from tool errors. A run error fails the generation and
sets ThreadState.error:
mock.responses.queue((r) => r.error(new Error("Provider failed")));A tool error is part of an assistant message. It creates a native
output-error tool part with errorText and does not, by itself, fail the run:
mock.responses.queue((r) =>
r.assistant([
r.tool("searchAgencies").error(new Error("Search service failed")),
]),
);If a generation action runs without a queued response, the run stays running until a response is queued. This makes timing tests straightforward:
const run = await thread.send("Draft the memo.");
expect(thread.getState().thread.status).toBe("running");
mock.responses.queue((r) => r.assistant([r.text("Draft complete.")]));
await run.waitUntilIdle();Use mock.events only when the behavior is not naturally modeled as a queued
response, such as an external thread-list update, a low-level watch event, or a
deliberate protocol edge case.
Streamed Responses
Use mock.responses.stream() when the test needs intermediate generation
states, not just the final response. The stream controller is consumed by the
next send, retry, or regenerate run. If a run is already waiting for a
mock response, it attaches immediately.
const response = mock.responses.stream();
const thread = mock.client.thread("thread-1");
const run = await thread.send("Compare proposals.");
expect(await response.waitUntilStarted()).toEqual({
threadId: "thread-1",
runId: run.runId,
});
const text = response.text();
await text.delta("Northstar");
await text.delta(" is ranked first.");
await text.end();
await response.complete();
await run.waitUntilIdle();response.text() and response.tool(name) return imperative controllers.
waitUntilStarted() resolves when the response is attached to its concrete
thread and run. Callback forms add lifecycle checks around the same controls:
await response.text(async (text) => {
await text.delta("Northstar");
await text.delta(" is ranked first.");
await text.end();
});Text callbacks must call end(). Tool callbacks must call output(...),
waiting(...), error(...), or cancelled(...). Starting another part inside
an active scoped callback throws, which keeps tests aligned with the ordered
model stream.
Tool input streaming models raw JSON argument chunks from a model provider:
const response = mock.responses.stream();
const run = await mock.client.thread("thread-1").send("Approve the award.");
await response.tool("approveAwardRecommendation", async (t) => {
await t.input.delta('{"jobBriefId":"job-telemetry-platform",');
await t.input.delta('"recommendedAgencyId":"agency-northstar"}');
await t.input.available({
jobBriefId: "job-telemetry-platform",
recommendedAgencyId: "agency-northstar",
});
await t.running();
await t.data("progress", { percent: 50 }, { id: "current" });
await t.output({ approved: true, memoId: "memo-1" });
});
await response.complete();
await run.waitUntilIdle();t.running() validates that complete input is available. It deliberately does
not write a custom running state or emit a chunk because native SDK tool parts
remain input-available until output or error arrives. t.cancelled() likewise
emits the native tool-output-error transition; cancellation is not stored as
a custom tool-part state.
For parts where intermediate states do not matter, use shorthands:
await response.text("I will compare the proposals.");
await response.tool("compareProposals", {
input: { jobBriefId: "job-telemetry-platform" },
output: {
rankings: [{ agencyId: "agency-northstar", totalScore: 94 }],
},
});Waiting tools expose pending actions through the real client facade:
import { isPaiToolPart } from "@pai/protocol";
await response.tool("approveAwardRecommendation", {
input: { jobBriefId: "job-telemetry-platform" },
waiting: {
action: {
name: "approval",
input: { message: "Approve Northstar for the pilot?" },
},
},
});
await response.complete();
const waiting = await run.waitUntilBlocked();
const message = waiting.messages.find((item) => item.role === "assistant");
const part = message?.parts.find(isPaiToolPart);
if (!message || !part) throw new Error("Expected a waiting tool");
const action = thread.getPendingAction(message, part);
if (!action) throw new Error("Expected a pending action");
mock.responses.queue((r) => r.assistant([r.text("Approval recorded.")]));
const resumed = await action.submit({ approved: true });
await resumed.run?.waitUntilIdle();For tests that do not care about exact JSON chunks but still need input streaming, use the object helper inside a scoped tool callback:
await response.tool("createJobBrief", async (t) => {
await t.input.stream(largeInput, {
chunkSize: 80,
delayMs: 10,
});
await t.output({ jobBriefId: "job-telemetry-platform" });
});The stream publishes the same native chunk grammar consumed by the real client:
| Control | Native chunks |
|---|---|
| First assistant part | start, start-step |
text.delta() / text.end() | text-start, text-delta, text-end |
tool.input.delta() / .available() | tool-input-start, tool-input-delta, tool-input-available |
tool.data() | data-pai-tool-data |
tool.waiting() | data-pai-suspension |
tool.output() | tool-output-available |
tool.error() / .cancelled() | tool-output-error |
response.complete() | finish-step, finish |
Each chunk is carried by a sequenced generation.message.chunk transport
event. The mock reduces it into the same native wire message that a real
transport would publish, then the client projects public PaiMessage parts.
Rendered React hooks, thread.refresh(), thread.getState(), and raw
mock.inspect.thread(...) therefore observe the same generation at their
respective public and transport boundaries.
response.complete() is always explicit. A tool output does not complete the
assistant response by itself, because a real assistant can continue with more
text or additional tool calls after a tool result. Use
response.fail(error) for a generation failure and response.abort(reason?)
to cancel the attached run.
Imperative Events
Most tests should use the response queue. events is the escape hatch for
transport-level behavior: reconnection, external state changes, thread-list
updates, or watch repair.
const watcher = thread.watch();
mock.events.pushThread("thread-1", {
type: "state.changed",
});
mock.events.pushThreadHead({
type: "thread.head.changed",
reason: "attributes",
head: renamedThreadHead,
});These events should flow through watchThread() and watchThreadHeads(). They
should not mutate React state directly or bypass the shared client reducer.
renamedThreadHead is an exact client-safe ThreadHeadDTO; tests that only
need an invalidation can use state.changed and let the client refresh.
events.completeRun(threadId, runId?) and events.failRun(threadId, error) are
active-run controls. They operate on the currently active run and throw a clear
test-author error if no run is active. Prefer queued responses for normal
success and failure fixtures:
mock.responses.queue((r) => r.error(new Error("Provider failed")));Commands And Files
Commands are client-visible functions, not generation responses. They should be registered as handlers:
mock.commands.set("refreshJobBrief", async ({ input }) => {
return {
jobBriefId: input.jobBriefId,
status: "evaluating",
};
});
await mock.client
.thread("thread-1")
.commands.refreshJobBrief({ jobBriefId: "job-1" });Files should live in the mock's in-memory file store:
mock.files.add({
fileId: "file-proposal",
filename: "proposal.pdf",
mediaType: "application/pdf",
body: new Blob(["proposal contents"]),
});
const uploaded = await mock.client.files.upload({
filename: "notes.txt",
mediaType: "text/plain",
body: new Blob(["notes"]),
});The mock should generate file ids for uploads, preserve metadata, implement
readFile(), and return either mock URLs or null from createFileUrl()
according to its configuration.
Inspection
Assertions should inspect backend state through a dedicated inspection API, rather than reaching into private maps:
expect(mock.inspect.requests.send()).toHaveLength(1);
expect(mock.inspect.thread("thread-1")?.thread.status).toBe("idle");
expect(mock.inspect.thread("thread-1")?.messages).toEqual(
expect.arrayContaining([expect.objectContaining({ role: "assistant" })]),
);
expect(mock.inspect.files()).toContainEqual(
expect.objectContaining({ fileId: "file-proposal" }),
);Inspection should be read-only. Mutations should go through seed, reset,
responses, events, commands, files, or normal client operations.
What The Mock Should Not Do
The mock transport should not:
- call a model;
- execute server tools;
- run lifecycle hooks;
- enforce runtime locks or leases;
- test HTTP, SSE, WebSocket, or IPC framing;
- reimplement
AgentClient,Thread, orRunHandle.
Those belong to runtime tests, adapter tests, or transport-specific tests. The mock transport's job is to provide a deterministic backend boundary for the real client facade.
Conformance
The mock transport should pass the same transport conformance suite as other transports, with profiles for watch streams, thread heads, files, commands, and regeneration. That suite should catch drift between the mock and direct/runtime or HTTP-backed clients.
If React testing utilities are built on the mock transport, they should compose like this:
const Pai = createPaiReact<SupportPai>();
const SupportAI = Pai.agent("support");
const t = createReactTestUtils({ Pai, AI: SupportAI });
await using h = t.createClientHarness(options);
render(<App />, { wrapper: h.Provider });React utilities can wrap this setup, but the underlying behavior should still be the real shared client facade over a conforming mock transport.