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 Class | Examples | Utility |
|---|---|---|
| Streaming input UI | Partial argument previews and progressive forms | t.toolProps(toolName).inputStreaming(...) |
| Complete input UI | Validated input before execution starts | t.toolProps(toolName).inputAvailable(...) |
| Completed output UI | Ranking cards, generated memo previews, extracted records | t.toolProps(toolName).outputAvailable(...) |
| In-progress UI | Loading copy, disabled controls, progress rows | t.toolProps(toolName).running(...) |
| Suspended action UI | Approval buttons, resume payloads, cancel/fail controls | t.toolProps(toolName).waiting(...) |
| Suspension history UI | Wizard steps, previous approvals/rejections, retry context | Tool prop fixtures with suspensions |
| Failure UI | Recoverable errors, validation messages, retry affordances | t.toolProps(toolName).outputError(...) |
| State matrix snapshots | Every tool state for one renderer | inputStreaming, 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.