PAIPAI

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, events

Use 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 ClassExamplesDriver Surface
Chat surfacesComposer submit, transcript rendering, disabled statesh.driver.responses, h.driver.inspect
Existing stateLoaded transcript, empty state, completed tool cardsseed or await h.driver.seed(...)
Thread listsuseThreads(), rename/delete, onThreadCreated refreshawait h.driver.seed(...), await h.driver.events...
Pending actionsApproval controls, browser handoffs, resume payloadsh.driver.responses, pending-action sidecars
Files and commandsUpload UI, generated file links, server command callsh.driver.files, h.driver.commands
Streaming transitionsLoading state, text deltas, tool input streaming, progress, blocked runs, recoverable failuresh.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.client and h.thread for 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.

On this page