Client Harness Tests
Test React providers and hooks with a real client facade backed by a controllable mock transport.
Client harness tests render real PAI React providers and hooks against a mock
client-visible backend. The UI talks to a normal AgentClient; the test sets up
the mock backend state and responses behind that client.
The important rule is that a client harness test should still read like a React
interaction test. Arrange the backend with h.driver, act through the UI with
your test driver's user-event helpers, and assert visible behavior through the
rendered UI. Use mock
inspection only for side effects that the UI does not expose.
PAI does not render the component for you. Create a harness, then pass its provider to your app's normal React test render helper. These examples use React Testing Library, but the harness is just a React provider and does not depend on that renderer.
React component
-> real PAI React providers and hooks
-> real AgentClient facade
-> mock transport state, responses, files, commands, eventsUse this layer when the behavior depends on React wiring, provider context, hook state, thread lists, pending actions, or client-side rendering, but not on server agent execution.
| Test Class | Examples | Driver Surface |
|---|---|---|
| Chat surfaces | Composer submit, transcript rendering, disabled states | h.driver.responses, h.driver.inspect |
| Existing state | Loaded transcript, empty state, completed tool cards | seed or await h.driver.seed(...) |
| Thread lists | useThreads(), rename/delete, onThreadCreated refresh | await h.driver.seed(...), await h.driver.events... |
| Pending actions | Approval controls, browser handoffs, resume payloads | h.driver.responses, pending-action sidecars |
| Files and commands | Upload UI, generated file links, server command calls | h.driver.files, h.driver.commands |
| Streaming transitions | Loading state, text deltas, tool input streaming, progress, blocked runs, recoverable failures | h.driver.responses.stream() |
Move to backend agent/runtime tests when lifecycle hooks, model behavior, or real tools are part of the behavior under test.
Waiting fixtures use the action name from the contract. For a client-executed
tool, use its implicit output action normally; the fixture stores no raw
suspendName. If a backend tool explicitly declares a suspend point named
output, add declaredSuspend: true beside action.name so the fixture keeps
that schema-declared discriminator.
Testing Style
Prefer this shape:
await using h = t.createClientHarness({
threadId: "thread-1",
seed,
});
await h.driver.responses.queue((r) =>
r.assistant([r.text("Northstar is ranked first.")]),
);
render(<ChatSurface />, { wrapper: h.Provider });
await user.type(screen.getByRole("textbox", { name: "Message" }), "Compare");
await user.click(screen.getByRole("button", { name: "Send" }));
expect(await screen.findByText("Northstar is ranked first.")).toBeTruthy();Do not call client.thread(...).send(...), thread.create(), or action
submission methods directly when the rendered UI can perform the same action.
Those calls bypass the React behavior the test is supposed to protect.
Default Harness
For most tests, create a client harness and render with your app's existing test helper:
await using h = t.createClientHarness({
threadId: "thread-1",
});
await h.driver.responses.queue((r) =>
r.assistant([r.text("Northstar is ranked first.")]),
);
render(<ChatSurface />, { wrapper: h.Provider });The returned harness exposes the mock backend controls and a provider that mounts real PAI React providers around your UI:
type CreateClientHarnessResult<TContract> = {
driver: ClientHarnessDriver<TContract>;
client: AgentClient<TContract>;
thread: Thread<TContract>;
threadId: ThreadId;
Provider(props: { children?: React.ReactNode }): React.ReactElement | null;
[Symbol.asyncDispose](): Promise<void>;
};That keeps the API small:
- use
await h.driver.seed(...)for state after render; - use
await h.driver.responses.queue(...)for sends, retries, and regenerates; - use
h.driver.commands.set(...)for command results; - use
h.driver.files.add(...)for file fixtures; - use
await h.driver.events...for live state changes; - use
h.driver.inspect...for non-visible side-effect assertions.
Seed Before Render
Loaded-state tests often need data before the component mounts. Use the seed
option for the common case:
import { render, screen } from "@testing-library/react";
import { createReactTestUtils } from "@pai/react-test-utils";
import { Pai, SupportAI } from "./pai";
import { ChatSurface } from "./ChatSurface";
const t = createReactTestUtils({ Pai, AI: SupportAI });
it("renders an existing transcript", async () => {
await using h = t.createClientHarness({
threadId: "thread-1",
seed: (s) => {
const run = s.run({
threadId: "thread-1",
messages: [
s.input("Compare proposals."),
s.output([s.text("Northstar is ranked first.")]),
],
});
return {
threads: [
s.thread("thread-1", {
title: "Telemetry platform sourcing",
messages: run.messages,
runs: [run.run],
}),
],
};
},
});
render(<ChatSurface />, { wrapper: h.Provider });
expect(screen.getByText("Compare proposals.")).toBeTruthy();
expect(screen.getByText("Northstar is ranked first.")).toBeTruthy();
});import { type FormEvent } from "react";
import { SupportAI } from "./pai";
export function ChatSurface() {
const chat = SupportAI.useChat();
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const form = event.currentTarget;
const message = new FormData(form).get("message");
if (typeof message !== "string" || message.trim() === "") return;
await chat.send(message);
form.reset();
}
return (
<main>
{chat.messages.flatMap((message) =>
message.parts.flatMap((part, index) =>
part.type === "text" ? (
<p key={`${message.id}:${index}`}>{part.text}</p>
) : (
[]
),
),
)}
<form onSubmit={submit}>
<textarea name="message" aria-label="Message" />
<button type="submit" disabled={chat.isRunning}>
Send
</button>
</form>
</main>
);
}Queue Responses
thread.send(...) owns the user message. Queue only the assistant response or
the run error. The mock appends the user turn, creates the run, consumes the next
queued response, updates thread state, and settles the run.
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
it("sends a message and renders the assistant response", async () => {
const user = userEvent.setup();
await using h = t.createClientHarness({
threadId: "thread-1",
});
await h.driver.responses.queue((r) =>
r.assistant([
r.text("I found Northstar Engineering."),
r.tool("searchAgencies").result({
input: { capability: "observability" },
output: {
agencies: [
{ id: "agency-northstar", name: "Northstar Engineering" },
],
},
}),
]),
);
render(<ChatSurface />, { wrapper: h.Provider });
await user.type(
screen.getByRole("textbox", { name: "Message" }),
"Find observability agencies",
);
await user.click(screen.getByRole("button", { name: "Send" }));
expect(await screen.findByText("I found Northstar Engineering.")).toBeTruthy();
expect(h.driver.inspect.requests.send()[0]).toMatchObject({
threadId: "thread-1",
input: "Find observability agencies",
});
});Queue Run Errors
Use h.driver.responses.queue((r) => r.error(...)) when an accepted generation
should fail. The mock applies the error to the run that consumes the response,
sets chat.state.error with the failed user message id, and leaves the thread
ready
for chat.retry() or chat.regenerate(...).
it("retries after a provider failure", async () => {
const user = userEvent.setup();
await using h = t.createClientHarness({
threadId: "thread-1",
});
await h.driver.responses.queue((r) => r.error(new Error("Provider failed")));
render(<RetryableChatSurface />, { wrapper: h.Provider });
await user.type(screen.getByRole("textbox", { name: "Message" }), "Draft");
await user.click(screen.getByRole("button", { name: "Send" }));
expect(await screen.findByText("Provider failed")).toBeTruthy();
expect(screen.getByRole("button", { name: "Retry" })).toBeEnabled();
await h.driver.responses.queue((r) =>
r.assistant([r.text("Draft complete.")]),
);
await user.click(screen.getByRole("button", { name: "Retry" }));
expect(await screen.findByText("Draft complete.")).toBeTruthy();
});The component uses the normal chat controller:
function RetryableChatSurface() {
const chat = SupportAI.useChat();
return (
<main>
{chat.state.error ? <p>{chat.state.error.message}</p> : null}
{chat.messages.flatMap((message) =>
message.parts.flatMap((part, index) =>
part.type === "text" ? (
<p key={`${message.id}:${index}`}>{part.text}</p>
) : (
[]
),
),
)}
<form
onSubmit={(event) => {
event.preventDefault();
const form = event.currentTarget;
const message = new FormData(form).get("message");
if (typeof message === "string" && message.trim()) {
void chat.send(message);
form.reset();
}
}}
>
<textarea name="message" aria-label="Message" />
<button type="submit" disabled={chat.isRunning}>
Send
</button>
</form>
<button
type="button"
disabled={!chat.state.error?.messageId || chat.isRunning}
onClick={() => void chat.retry()}
>
Retry
</button>
</main>
);
}Run errors are different from tool errors. Queue r.error(...) to fail the
whole generation and exercise retry/regenerate UI. Queue
r.tool("name").error(...) inside r.assistant(...) to render an output-error
tool part while the run itself can still complete.
Thread Lists
Seed thread lists before render when testing loaded state. When testing thread
creation, create the thread through the same client or React hook path the app
uses. Do not call seed() again after render; seeding is fixture setup, not an
event simulation API.
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { createReactTestUtils } from "@pai/react-test-utils";
import { Pai, SupportAI } from "./pai";
import { ThreadWorkspace } from "./ThreadWorkspace";
const t = createReactTestUtils({ Pai, AI: SupportAI });
it("refreshes the sidebar when the workspace creates a thread", async () => {
const user = userEvent.setup();
await using h = t.createClientHarness({
seed: (s) => ({
threads: [
s.thread("thread-existing", {
title: "Existing sourcing thread",
}),
],
}),
});
render(<ThreadWorkspace />, { wrapper: h.Provider });
expect(await screen.findByText("Existing sourcing thread")).toBeTruthy();
await user.click(screen.getByRole("button", { name: "New sourcing thread" }));
await waitFor(() => {
expect(screen.getAllByRole("listitem")).toHaveLength(2);
});
});import { useState } from "react";
import { SupportAI } from "./pai";
export function ThreadWorkspace() {
const threads = SupportAI.useThreads({
limit: 10,
onThreadCreated: async (_thread, context) => {
await context.refresh();
},
});
const [activeThreadId, setActiveThreadId] = useState<string | null>(null);
async function createThread() {
const thread = threads.create();
setActiveThreadId(thread.threadId);
await thread.send("Start sourcing.");
}
return (
<main>
<aside>
<button type="button" onClick={createThread}>
New sourcing thread
</button>
<ul aria-label="Threads">
{threads.items.map((thread) => (
<li key={thread.threadId}>{thread.title ?? thread.threadId}</li>
))}
</ul>
</aside>
{activeThreadId ? (
<SupportAI.ThreadProvider threadId={activeThreadId}>
<SupportChat />
</SupportAI.ThreadProvider>
) : null}
</main>
);
}
function SupportChat() {
const chat = SupportAI.useChat();
return <p>{chat.status}</p>;
}The test never calls the thread API directly. It proves the rendered workspace
creates a thread, sends the first message, receives the onThreadCreated
callback, and refreshes the list.
Pending Actions
Pending-action tests queue a response with a waiting tool. The React component then sees the same pending action shape it would see from a real client.
it("submits an award approval from the rendered tool", async () => {
const user = userEvent.setup();
await using h = t.createClientHarness({
threadId: "thread-1",
});
await h.driver.responses.queue((r) =>
r.assistant([
r.tool("approveAwardRecommendation").waiting({
input: {
jobBriefId: "job-telemetry-platform",
recommendedAgencyId: "agency-northstar",
},
action: {
name: "approval",
input: {
message: "Approve Northstar for the telemetry platform pilot?",
},
id: "action-approval",
},
}),
]),
);
render(<AwardApprovalWorkspace />, { wrapper: h.Provider });
await user.click(screen.getByRole("button", { name: "Request approval" }));
await user.click(await screen.findByRole("button", { name: "Approve" }));
expect(h.driver.inspect.requests.respondToAction()[0]).toMatchObject({
action: "submit",
payload: { approved: true },
});
});The component can be ordinary app UI. The test does not need a special action driver:
function AwardApprovalWorkspace() {
const chat = SupportAI.useChat();
SupportAI.useToolRenderer("approveAwardRecommendation", (tool) => {
if (
tool.action === null ||
tool.part.state !== "input-available" ||
!tool.action
) {
return null;
}
const action = tool.action;
return (
<article aria-label="Award approval">
<p>Approve {tool.part.input.recommendedAgencyId}?</p>
<button
type="button"
onClick={() => void action.submit({ approved: true })}
>
Approve
</button>
</article>
);
});
return (
<button
type="button"
onClick={() => void chat.send("Approve the award.")}
>
Request approval
</button>
);
}The test interacts with the same controls a user would. h.driver.inspect only
checks the submitted payload because that payload is usually not visible in the
page.
If the UI needs the action to already exist on first render, seed the waiting tool before render:
await using h = t.createClientHarness({
threadId: "thread-1",
seed: (s) => {
const run = s.run({
threadId: "thread-1",
status: "waiting",
messages: [
s.input("Approve the award."),
s.output([
s.tool("approveAwardRecommendation").waiting({
input: {
jobBriefId: "job-telemetry-platform",
recommendedAgencyId: "agency-northstar",
},
action: {
id: "action-approval",
name: "approval",
input: {
message:
"Approve Northstar for the telemetry platform pilot?",
},
},
}),
]),
],
});
return {
threads: [
s.thread("thread-1", {
messages: run.messages,
runs: [run.run],
}),
],
};
},
});
render(<AwardApprovalRendererRegistration />, { wrapper: h.Provider });Streaming Transition Tests
Most client harness tests should assert final client-visible state. Use
h.driver.responses.stream() when the UI must respond correctly while a
generation is still in progress.
it("renders streamed tool input before the completed result", async () => {
const user = userEvent.setup();
await using h = t.createClientHarness({
threadId: "thread-1",
});
const response = h.driver.responses.stream();
render(<ChatSurface />, { wrapper: h.Provider });
await user.type(
screen.getByRole("textbox", { name: "Message" }),
"Compare proposals",
);
await user.click(screen.getByRole("button", { name: "Send" }));
expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
await response.tool("compareProposals", async (t) => {
await t.input.delta('{"jobBriefId":"job');
expect(await screen.findByText("Reading request...")).toBeTruthy();
await t.input.delta('-telemetry-platform"}');
await t.input.available({
jobBriefId: "job-telemetry-platform",
});
await t.running();
await t.data("progress", { percent: 50 }, { id: "current" });
expect(await screen.findByText("Progress 50%")).toBeTruthy();
await t.output({
rankings: [
{
agencyId: "agency-northstar",
agencyName: "Northstar Engineering",
totalScore: 94,
},
],
});
});
await response.complete();
expect(await screen.findByText("Northstar Engineering")).toBeTruthy();
expect(await screen.findByRole("button", { name: "Send" })).not.toBeDisabled();
});For simpler transition tests, you can still start the interaction before queueing a final response. The mock keeps the run active until a queued response arrives:
await user.click(screen.getByRole("button", { name: "Send" }));
expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
await h.driver.responses.queue((r) =>
r.assistant([r.text("Draft complete.")]),
);
expect(await screen.findByText("Draft complete.")).toBeTruthy();Use await h.driver.events... only for external events that are not naturally modeled
as a response, such as another client changing thread state or a low-level
protocol edge case. We should not reintroduce onSend on the React renderer
because that creates a second backend model to learn and maintain.
Tool Definition Ids
Harness tool parts and generation events carry the same definition
id a real run stamps in
part.toolMetadata.pai.toolId. Rendered UI rarely needs it — renderers resolve
by tool name — but code that switches on the definition id sees the same native
part metadata as production.
The ids come from the root's runtime contract, which
pai generate client passes to createPaiReact for you, so
generated bindings need no setup. A hand-written root created without a contract
produces parts with a name and no id; provide exact provenance when a test needs
it there:
await using h = t.createClientHarness({
registeredToolMetadata: new Map([
[
"lookup_order",
{ executor: "server", toolId: "lookupOrder" } as const,
],
]),
});Asserting on the id directly means reading the transcript, which is the protocol-level escape hatch below rather than the normal path:
import { isPaiToolPart } from "@pai/protocol";
import { getToolName } from "ai";
const lookup = h.thread
.getState()
.messages.flatMap((message) => message.parts)
.find(
(part) => isPaiToolPart(part) && getToolName(part) === "lookup_order",
);
expect(lookup).toMatchObject({
toolMetadata: {
pai: { executor: "server", toolId: "lookupOrder" },
},
});Escape Hatches
Most UI behavior can be tested through the rendered app. The few useful escape hatches are:
h.driver.inspect...for request payloads, uploads, command calls, or action submissions that are not visible in the UI;await h.driver.events...for external backend events that the current component cannot trigger, such as another client creating a thread or a long-running run finishing;h.clientandh.threadfor rare shared test drivers or protocol-level React tests where the rendered component intentionally does not expose the action.
If the user could do the thing through the rendered UI, the test should do it through the rendered UI.
Mental Model
The optimal DX is one mock backend, plus ordinary React testing:
await using h = t.createClientHarness({
threadId: "thread-1",
seed,
});
await h.driver.responses.queue(...);
h.driver.commands.set(...);
h.driver.files.add(...);
await h.driver.events.pushThread(...);
render(<App />, {
wrapper: ({ children }) => (
<ThemeProvider>
<h.Provider>{children}</h.Provider>
</ThemeProvider>
),
});
await user.click(screen.getByRole("button", { name: "Send" }));
expect(await screen.findByText("Failed to send message")).toBeTruthy();
expect(h.driver.inspect.requests.send()).toHaveLength(1);The React test kit should provide convenience, not new semantics:
t.createClientHarness({ seed })creates and seeds a mock for simple tests;- the app still uses real PAI React providers, hooks, and client facade;
- backend setup and backend-only assertions go through
h.driver; - direct client/thread calls are an escape hatch, not the main testing style.
That keeps client harness tests close to how the app actually runs: a React tree using a real PAI client facade, with only the backend boundary replaced.