PAIPAI

Tool Tests

Call one execute with runTool. No model, no HTTP, no thread runtime.

A tool test proves the function you wrote. PAI still parses input, builds ctx, and records suspend / fail / data writes. There is no agent loop.

pnpm add -D @pai/test-utils
import { runTool } from "@pai/test-utils";
import { searchDocs } from "./search-docs";

const result = await runTool(searchDocs, {
  input: { query: "tenancy" },
  context: { workspaceId: "ws_1", docs: fakeDocs },
});

expect(result.expectOutput().hits[0]?.title).toBe("Identity");

context is the tool's contextSchema (or unknown if the tool has none). Mock repositories the same way you would in any backend test.

A tool that declares capabilities gets them from capabilities. For the sandbox, pass createInMemorySandbox() and read back what the tool wrote: runTool(tool, { input, capabilities: { sandbox: createInMemorySandbox() } }).

Outcome checks

CallWhen to use it
expectOutput()The tool returned. Throws if it did not.
expectSuspended()The tool called ctx.suspend.
expectFailed()The tool called ctx.fail. Returns { code, message, retryable? }.
expectThrew()The tool raised an exception. Returns the raw value.
expectCancelled()The signal aborted the call.

failed and threw are different events. A planned "not found" should be ctx.fail. An unexpected crash should throw.

The handle also has resume (below), status, data, dataWrites, agentRuns, attempts, and suspendHistory.

Suspend and resume

const waiting = await runTool(publishPost, {
  input: { draftId: "draft_1" },
  context: { drafts: fakeDrafts },
});

expect(waiting.expectSuspended().name).toBe("approve");

const done = await waiting.resume("approve", { approved: true });
expect(done.expectOutput()).toEqual({ published: true });

The second execute sees ctx.entry.type === "resume". Same as production — see Human approval.

Fail vs throw

const failed = await runTool(getRecord, {
  input: { id: "missing" },
  context: { records: fakeRecords },
});
expect(failed.expectFailed()).toEqual({
  code: "tool_failed",
  message: "No record missing.",
});

const threw = await runTool(getRecord, {
  input: { id: "x" },
  context: { records: explodingRecords },
});
expect(threw.expectThrew()).toBeInstanceOf(DatabaseDownError);

Cancellation

Pass a signal and abort it. A tool that ignores ctx.signal will still look "running" after Stop.

const controller = new AbortController();
const pending = runTool(slowSearch, {
  input: { query: "tenancy" },
  context: { docs: fakeDocs },
  signal: controller.signal,
});
controller.abort();
expect((await pending).expectCancelled()).toBeDefined();

What runTool will not do

Tools that declare a nested tools map need a runtime test. runTool has no agent and no child-invocation policy.

Next

On this page