PAIPAI

createReactTestUtils()

Contract-bound fixtures for tool cards and chat pages. No agent runtime.

createReactTestUtils is the usual import. It returns card fixtures (toolProps), a mounted chat harness (createClientHarness), plus the same t.model helpers as createTestUtils().

There is no PAI runtime behind it. You script the thread state a server would have sent. Waiting cards, a mounted chat, and client tools are under Examples.

import { createReactTestUtils } from "@pai/react-test-utils";
import { Assistant } from "./pai";

const t = createReactTestUtils(Assistant);

render(
  <SearchDocsCard
    tool={t.toolProps("searchDocs").outputAvailable({
      input: { query: "tenancy" },
      output: { hits: [{ title: "Identity", url: "/guides/identity" }] },
    })}
  />,
);

The package also exports createClientToolHarness() and TestChatUI for a canned chat shell.

Import

import {
  createReactTestUtils,
  createClientToolHarness,
  TestChatUI,
} from "@pai/react-test-utils";

Signature

function createReactTestUtils(
  binding: PaiAgentBinding | { Pai; AI },
): ReactContractTestUtils;

Parameters

binding: PaiAgentBinding | { Pai, AI }

Usually Pai.agent("main"). Pass { Pai, AI } only for a custom binding.

Returns

ReactContractTestUtils

Includes createTestUtils() (t.model.queue, t.getTool, t.waitForTool, …).

toolProps: (name) => BoundToolRenderPropsFactory

Build ToolRenderProps for one card. No providers required.

inputStreaming: (options?: { input? }) => ToolRenderProps

Arguments still arriving.

inputAvailable: (options: { input }) => ToolRenderProps

Arguments complete, not running.

running: (options?: { input? }) => ToolRenderProps

Execute in flight.

waiting: (options: { input?, action }) => ToolRenderProps

Suspended. tool.action.submit calls action.onSubmit.

action: { name, input, onSubmit? }

name: string

input: suspend payload

onSubmit?: (resume) => void

outputAvailable: (options: { input?, output }) => ToolRenderProps

Done.

outputError: (options: { input?, error }) => ToolRenderProps

Failed. error is Error | string.

cancelled: (options?: { input? }) => ToolRenderProps

Stopped.

createClientHarness: (options?) => CreateClientHarnessResult

Real Pai.Provider + ThreadProvider over a fake transport. Use await using.

options?: object

threadId?: string

clientData?: TContract['clientData']

toolRenderers?: ToolRendererMap

observe?: boolean

Provider: (props: { children? }) => ReactElement

Wrap the UI under test.

driver: ClientHarnessDriver

send: Thread['send']

Same as the live client.

responses.queue: (response) => Promise<void>

Script the next assistant turn. Builders: response.assistant([...]), response.text(...), response.tool(name).result({ input, output }).

events: ClientHarnessEventController

Inject live events (approvals, file updates).

thread: Thread

client: AgentClient

threadId: string

toolRenderers: (map) => map

Identity helper so toolRenderers stays typed in tests.

Also includes message/run factories from createClientHarnessFactories (t.message, t.tool, t.run, t.threadState) for seeding.

createClientToolHarness()

Test one browser tool with no thread.

function createClientToolHarness(definition): ClientToolHarness;

execute: (input) => Promise<output>

Run execute and return validated output.

call: (input) => ClientToolCall

Pending call whose renderer and executor share action/ToolData. Execution is explicit.

View: ComponentType

Mount the tool renderer.

execute: () => Promise<output>

Drive automatic execution.

action: PendingActionView

action.submit completes a render-only tool.

render: ClientToolRenderFixtures

Static lifecycle fixtures. Same names as toolProps (inputStreaming, outputAvailable, outputError, …).

Examples

Approval card

waiting builds tool.action. The card still calls tool.action.submit. onSubmit is the spy the fixture runs when that happens.

const onSubmit = vi.fn();

render(
  <PublishCard
    tool={t.toolProps("publishPost").waiting({
      input: { draftId: "draft_1" },
      action: {
        name: "approve",
        input: { title: "Tenancy", summary: "Publish the identity guide." },
        onSubmit,
      },
    })}
  />,
);

fireEvent.click(screen.getByRole("button", { name: "Approve" }));
expect(onSubmit).toHaveBeenCalledWith({ approved: true });

Other card states

const searchDocs = t.toolProps("searchDocs");

render(
  <SearchDocsCard
    tool={searchDocs.inputStreaming({ input: { query: "ten" } })}
  />,
);
render(
  <SearchDocsCard
    tool={searchDocs.outputError({
      input: { query: "tenancy" },
      error: "offline",
    })}
  />,
);
render(
  <SearchDocsCard
    tool={searchDocs.cancelled({ input: { query: "tenancy" } })}
  />,
);

Chat page

createClientHarness mounts the real Pai.Provider and ThreadProvider over a fake transport. Queue the assistant turn, then send like the UI would. Use await using. Use TestChatUI instead of your Chat when you only need a composer and transcript.

await using harness = t.createClientHarness({ threadId: "thread-1" });

render(
  <harness.Provider>
    <Chat />
  </harness.Provider>,
);

await harness.driver.responses.queue((response) =>
  response.assistant([response.text("See the identity guide.")]),
);

const run = await harness.driver.send("Where is tenancy documented?");
await run.waitUntilIdle();

Tool result, then text:

await harness.driver.responses.queue((response) =>
  response.assistant([
    response.tool("searchDocs").result({
      input: { query: "tenancy" },
      output: { hits: [{ title: "Identity", url: "/guides/identity" }] },
    }),
    response.text("See the identity guide."),
  ]),
);

There is no model and no execute. The harness plays the transcript a real server would have sent.

TestChatUI

Use the canned shell when you only need a composer and transcript.

await using harness = t.createClientHarness({ threadId: "thread-1" });
render(<TestChatUI />, { wrapper: harness.Provider });

Client tool

createClientToolHarness tests one browser function with no thread. execute is the usual path when you do not render UI.

import { createClientToolHarness } from "@pai/react-test-utils";
import { z } from "zod";

const getSelection = createClientToolHarness({
  name: "getSelection",
  description: "Read the current selection",
  inputSchema: z.object({}),
  outputSchema: z.object({ text: z.string() }),
  execute: async () => ({ text: "hello" }),
});

await expect(getSelection.execute({})).resolves.toEqual({ text: "hello" });

call starts pending. Mount View, then execute when the test is ready to drive the renderer.

const getSelection = createClientToolHarness({
  name: "getSelection",
  description: "Read the current selection",
  inputSchema: z.object({}),
  outputSchema: z.object({ text: z.string() }),
  execute: async () => ({ text: "hello" }),
  render: ({ part }) =>
    part.state === "output-available" ? (
      <p>{part.output.text}</p>
    ) : (
      <p>Reading…</p>
    ),
});

const call = getSelection.call({});
render(<call.View />);
expect(screen.getByText("Reading…")).toBeTruthy();

await act(async () => {
  await call.execute();
});
expect(screen.getByText("hello")).toBeTruthy();

A render-only tool (no execute) completes through call.action.submit:

await call.action.submit({ approved: true });

Static client-tool states

Same getSelection harness as above.

render(
  getSelection.render.outputAvailable({
    input: {},
    output: { text: "hello" },
  }),
);
render(
  getSelection.render.outputError({
    input: {},
    error: "offline",
  }),
);

Learn more

On this page