PAIPAI

React Tests

Cards with toolProps. Chat pages with createClientHarness. No runtime.

React tests prove UI. They do not run an agent. You either pass renderer props for one card, or script public thread state for a chat page.

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

const t = createReactTestUtils(Assistant);

createReactTestUtils takes the agent binding (Pai.agent("main")). It includes the same t.model helpers as @pai/test-utils, plus toolProps and createClientHarness.

One tool card

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

render(
  <SearchDocsCard
    tool={searchDocs.outputAvailable({
      input: { query: "tenancy" },
      output: { hits: [{ title: "Identity", url: "/guides/identity" }] },
    })}
  />,
);
FactoryTool part state
inputStreaming({ input? })Arguments still arriving
inputAvailable({ input })Arguments complete, not running
running({ input? })Execute in flight
waiting({ input, action })Suspended; tool.action is set
outputAvailable({ input, output })Done
outputError({ input, error })Failed
cancelled({ input? })Stopped

Use waiting for approval cards. Pass action: { name, input, onSubmit } to build the fixture. 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 });

Failed and cancelled cards use outputError({ input, error }) and cancelled({ input? }).

Chat page

createClientHarness mounts the real Pai.Provider and ThreadProvider over a fake transport. You queue assistant responses, then send like the UI would.

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 results:

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.

Client tools

Test the browser function in isolation with createClientToolHarness. There is no thread.

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" });

That is a client-tool test, not a runtime test. The browser function is real; the agent is not. Mount the same clientTools on Pai.agent("main", { clientTools }) in the product — see Client tools.

Next

On this page