PAIPAI

Renderer Tests

Test extracted tool renderers with typed props and no providers, client, runtime, agent, or model.

Renderer tests are for pure presentation components. They are the smallest React testing layer: build typed tool props, render the component, and assert on the UI.

Use this layer when the behavior belongs to one renderer component and does not need PAI providers, hooks, client state, runtime state, or a model.

Test ClassExamplesUtility
Streaming input UIPartial argument previews and progressive formst.toolProps(toolName).inputStreaming(...)
Complete input UIValidated input before execution startst.toolProps(toolName).inputAvailable(...)
Completed output UIRanking cards, generated memo previews, extracted recordst.toolProps(toolName).outputAvailable(...)
In-progress UILoading copy, disabled controls, progress rowst.toolProps(toolName).running(...)
Suspended action UIApproval buttons, resume payloads, cancel/fail controlst.toolProps(toolName).waiting(...)
Suspension history UIWizard steps, previous approvals/rejections, retry contextTool prop fixtures with suspensions
Failure UIRecoverable errors, validation messages, retry affordancest.toolProps(toolName).outputError(...)
State matrix snapshotsEvery tool state for one rendererinputStreaming, inputAvailable, running, waiting, outputAvailable, outputError, cancelled

Shared renderers written against a generated tool spec can use toolSpecProps<TSpec>(toolName) directly, without creating a PAI root or agent binding. See the testing API reference.

Completed Output

toolProps is typed from the contract, so the input and output must match the selected tool. This lets renderer tests stay as small as ordinary component tests while still using the real PAI tool shape.

import { render, screen } from "@testing-library/react";
import { createReactTestUtils } from "@pai/react-test-utils";
import { Pai, SupportAI } from "./pai";
import { CompareProposalsRenderer } from "./CompareProposalsRenderer";

const t = createReactTestUtils({ Pai, AI: SupportAI });
const compareProposals = t.toolProps("compareProposals");

it("renders completed proposal rankings", () => {
  const tool = compareProposals.outputAvailable({
    input: { jobBriefId: "job-telemetry-platform" },
    output: {
      scorecardsCreated: 2,
      rankings: [
        {
          agency: { id: "agency-northstar", name: "Northstar Engineering" },
          totalScore: 94,
        },
      ],
    },
  });

  render(<CompareProposalsRenderer tool={tool} />);

  expect(screen.getByText(/Northstar Engineering/)).toBeTruthy();
  expect(screen.getByText(/94/)).toBeTruthy();
});
import type { ToolRenderProps } from "@pai/react";
import type { SupportContract } from "./pai";

export function CompareProposalsRenderer({
  tool,
}: {
  tool: ToolRenderProps<SupportContract, "compareProposals">;
}) {
  if (tool.runStatus === "running") {
    return <article aria-label="Proposal comparison">Scoring proposals...</article>;
  }

  if (tool.part.state === "output-error") {
    return <article aria-label="Proposal comparison">{tool.part.errorText}</article>;
  }

  if (tool.part.state !== "output-available") {
    return <article aria-label="Proposal comparison">Waiting for proposals...</article>;
  }

  return (
    <article aria-label="Proposal comparison">
      <h2>Ranked agencies</h2>
      <ol>
        {tool.part.output.rankings.map((ranking) => (
          <li key={ranking.agency.id}>
            {ranking.agency.name} · {ranking.totalScore}
          </li>
        ))}
      </ol>
    </article>
  );
}

Waiting Actions

Use waiting props when a tool renderer asks a human or browser client to do something before the tool can continue. The fixture provides a typed tool.action.submit(...) sidecar and records whatever your test wants to assert. The native state remains on tool.part.state.

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 { AwardApprovalRenderer } from "./AwardApprovalRenderer";

const t = createReactTestUtils({ Pai, AI: SupportAI });
const approveAward = t.toolProps("approveAwardRecommendation");

it("submits an approval payload", async () => {
  const user = userEvent.setup();
  const submissions: Array<{ approved: boolean; comment?: string }> = [];
  const tool = approveAward.waiting({
    input: {
      jobBriefId: "job-telemetry-platform",
      recommendedAgencyId: "agency-northstar",
    },
    action: {
      name: "approval",
      input: {
        message: "Approve Northstar for the telemetry platform pilot?",
      },
      onSubmit: (resume) => {
        submissions.push(resume);
      },
    },
  });

  render(<AwardApprovalRenderer tool={tool} />);

  await user.click(screen.getByRole("button", { name: "Approve" }));

  await waitFor(() =>
    expect(submissions).toEqual([
      { approved: true, comment: "Approved from renderer." },
    ]),
  );
});
import type { ToolRenderProps } from "@pai/react";
import type { SupportContract } from "./pai";

export function AwardApprovalRenderer({
  tool,
}: {
  tool: ToolRenderProps<SupportContract, "approveAwardRecommendation">;
}) {
  if (!tool.action) {
    return <p>Award recommendation is {tool.part.state}.</p>;
  }

  return (
    <article aria-label="Award approval">
      <p>{tool.action.input.message}</p>
      <button
        type="button"
        onClick={() =>
          void tool.action.submit({
            approved: true,
            comment: "Approved from renderer.",
          })
        }
      >
        Approve
      </button>
    </article>
  );
}

Suspension History

Suspend history is tool information. Renderers can use it for multi-step flows, wizard forms, retry UI, audit rows, or "you already rejected this once" context.

The testing API makes that state easy to fixture. A renderer should not need a full runtime test just to prove it displays previous suspend/resume records correctly.

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

const tool = collectRequirements.waiting({
  input: { jobBriefId: "job-telemetry-platform" },
  action: {
    id: "action-ask-budget",
    name: "askBudget",
    input: {
      question: "What budget range should agencies target?",
    },
  },
  suspensions: [
    {
      actionId: "action-ask-timeline",
      name: "askTimeline",
      input: {
        question: "When does the platform need to launch?",
      },
      suspendedAt: "2026-08-21T01:00:00.000Z",
      state: "submitted",
      resume: { timelineWeeks: 16 },
      resolvedAt: "2026-08-21T01:01:00.000Z",
    },
    {
      actionId: "action-ask-region",
      name: "askRegion",
      input: {
        question: "Which regions should the agency support?",
      },
      suspendedAt: "2026-08-21T01:02:00.000Z",
      state: "submitted",
      resume: { regions: ["North America", "Europe"] },
      resolvedAt: "2026-08-21T01:03:00.000Z",
    },
    {
      actionId: "action-ask-budget",
      name: "askBudget",
      input: { question: "What budget range should agencies target?" },
      suspendedAt: "2026-08-21T01:04:00.000Z",
      state: "pending",
    },
  ],
});

suspensions is part of the shared tool fixture options, not a special option that only belongs to waiting(...). A tool can suspend, resume into more work, suspend again, complete, fail, or be cancelled. The renderer may still need that history in any final or intermediate state.

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

collectRequirements.running({
  input: { jobBriefId: "job-telemetry-platform" },
  suspensions: [
    {
      actionId: "action-ask-timeline",
      name: "askTimeline",
      input: { question: "When does the platform need to launch?" },
      suspendedAt: "2026-08-21T01:00:00.000Z",
      state: "submitted",
      resume: { timelineWeeks: 16 },
      resolvedAt: "2026-08-21T01:01:00.000Z",
    },
  ],
});

collectRequirements.outputAvailable({
  input: { jobBriefId: "job-telemetry-platform" },
  output: {
    requirementsId: "requirements-1",
  },
  suspensions: [
    {
      actionId: "action-ask-timeline",
      name: "askTimeline",
      input: { question: "When does the platform need to launch?" },
      suspendedAt: "2026-08-21T01:00:00.000Z",
      state: "submitted",
      resume: { timelineWeeks: 16 },
      resolvedAt: "2026-08-21T01:01:00.000Z",
    },
    {
      actionId: "action-ask-budget",
      name: "askBudget",
      input: { question: "What budget range should agencies target?" },
      suspendedAt: "2026-08-21T01:04:00.000Z",
      state: "submitted",
      resume: { budgetUsd: 420000 },
      resolvedAt: "2026-08-21T01:05:00.000Z",
    },
  ],
});

collectRequirements.outputError({
  input: { jobBriefId: "job-telemetry-platform" },
  error: "Budget is required after resume.",
  suspensions: [
    {
      actionId: "action-ask-budget",
      name: "askBudget",
      input: { question: "What budget range should agencies target?" },
      suspendedAt: "2026-08-21T01:04:00.000Z",
      state: "failed",
      resolvedAt: "2026-08-21T01:05:00.000Z",
    },
  ],
});

State Matrices

Renderer tests are a good place to cover every native tool state and derived display state without constructing a thread.

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

it.each([
  {
    name: "input-streaming",
    tool: compareProposals.inputStreaming({
      input: { jobBriefId: "job-telemetry" },
    }),
  },
  {
    name: "input-available",
    tool: compareProposals.inputAvailable({
      input: { jobBriefId: "job-telemetry-platform" },
    }),
  },
  {
    name: "running",
    tool: compareProposals.running({
      input: { jobBriefId: "job-telemetry-platform" },
    }),
  },
  {
    name: "output-error",
    tool: compareProposals.outputError({
      input: { jobBriefId: "job-telemetry-platform" },
      error: "No submitted proposals",
    }),
  },
  {
    name: "cancelled",
    tool: compareProposals.cancelled({
      input: { jobBriefId: "job-telemetry-platform" },
    }),
  },
])("renders $name state", ({ tool }) => {
  render(<CompareProposalsRenderer tool={tool} />);
  expect(screen.getByLabelText("Proposal comparison")).toBeTruthy();
});

Use a higher layer when the thing you need to prove is provider wiring, hook behavior, thread state updates, or runtime projection.

On this page