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-utilsimport { 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
| Call | When 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.