pinecallDocs
Guides / Unit tests

Unit tests

The class as software: tools, stages and the prompt, with no network, no key and no model.

Your agent is a class, so test it like one. A unit test calls a tool, reads the state and reads the prompt, with no network, no key and no model. It runs in milliseconds and costs nothing, so it belongs on every push.

What a unit test covers#

What it does not cover: whether a model chooses the right tool from your docstrings. That is what goldens are for.

The helpers#

Every helper comes from @pinecall/agents:

Export What it does
describe(AgentClass, source) hands the class its own source, once per file, so tool docstrings and parameter types survive the transpiler
seal(agent) starts recording state changes; field initializers before it are the baseline
setCall(agent, call) gives the agent a call to read in render() and its tools, as this.call
CallWorld the call itself: { id, contact, from, channel } and a callback for what the agent does to it
toolNamed(agent, name) a declared tool, by name
runTool(agent, tool, args) runs one tool call as Pinecall runs it: arguments validated, the result trimmed to the tool's preview
runHook(agent, name, ...args) runs a hook such as onCall as Pinecall runs it
recalled(agent, facts) records facts as if memory had recalled them, for a render() that reads them
promptOf(agent) the prompt as blocks, each with a name and its text
showPrompt(agent) the prompt as one printed page, as pinecall prompt shows it

agent.visibleTools() lists the tools the model would see in the current state.

Example#

This is the test pinecall new writes beside the agent. It runs with npm test (vitest):

test/front-desk/agent.test.ts
import { readFileSync } from "node:fs";

import { CallWorld, describe as describeClass, promptOf, runTool, seal, setCall, toolNamed, type ToolDeclaration } from "@pinecall/agents";
import { beforeEach, expect, it } from "vitest";

import FrontDesk from "../../agents/front-desk/agent.js";

// The class's own source: the parameters' types survive the transpiler.
describeClass(FrontDesk, readFileSync(new URL("../../agents/front-desk/agent.tsx", import.meta.url), "utf8"));

let agent: FrontDesk;

beforeEach(() => {
  agent = seal(new FrontDesk());
  setCall(agent, new CallWorld({ id: "CA_1", contact: "+15550100", channel: "phone" }, () => undefined));
});

function view(): string {
  return promptOf(agent).blocks.find((block) => block.name === "view")?.text ?? "";
}

it("asks for a message until there is one", () => {
  expect(view()).toContain("ask for their name and what the call is about");
});

it("ends the call with the message it took", async () => {
  await runTool(agent, toolNamed(agent, "takeMessage") as ToolDeclaration, { name: "Ana", about: "a refund" });

  expect(agent.stage).toBe("done");
  expect(view()).toContain("Ana, about a refund");
});

Skip describe(...) and the first tool refuses to build: tool takeMessage: without a docstring no model can choose it. The transpiler strips comments, so the class needs its own source handed back.

In Ruby, pinecall new writes the same test with Minitest, run with bundle exec rake:

test/front_desk/agent_test.rb
@agent = FrontDesk.new.seal
@agent.serving(Pinecall::CallWorld.new(id: "CA_1", contact: "+15550100", channel: "phone") { |*| nil })
@agent.run_tool("take_message", { name: "Ana", about: "a refund" })
Pinecall.render(@agent, line: { channel: "phone" })[:view]

In Python, pinecall new --python writes the same test with pytest, run with uv run pytest:

test/front-desk/test_agent.py
AGENT = load(Path(__file__).parents[2] / "agents" / "front-desk" / "agent.py")
agent = AGENT().seal()
agent.serving(CallWorld(CallLine(id="CA_1", contact="+15550100", channel="phone")))
agent.run_tool("take_message", {"name": "Ana", "about": "a refund"})
render(agent)["view"]

Three tests worth writing#

  • Each tool's effect on state. Call it with good arguments and with bad ones. A tool that refuses should leave the state as it was.
  • Each stage's tools. After a tool moves the stage, visibleTools() should offer the next step and nothing that comes later.
  • The prompt blocks. Render the class in three states and check that every block except the view is byte-for-byte the same in all three, and the view changed. The prompt cache depends on it, and nothing else notices when render() starts writing into a cached block.

The whole app, in process#

To test the agent the way the gateway drives it, with tool.call in and tool.result out, use the fake gateway from @pinecall/agents/client/testing. It answers the app's socket on this machine:

TypeScript
import { Pinecall } from "@pinecall/agents/client";
import { FakeGateway } from "@pinecall/agents/client/testing";
import { mount } from "@pinecall/agents";
// readFileSync and FrontDesk imported as in the test above

const SOURCE = readFileSync(new URL("../../agents/front-desk/agent.tsx", import.meta.url), "utf8");
const gateway = await FakeGateway.start({ apiKey: "pk_test" });
const pc = new Pinecall({ url: gateway.url, apiKey: "pk_test" });
mount(FrontDesk, { pc, source: SOURCE, slug: "front-desk" });
await pc.connect();

gateway.emit("front-desk", "CA_1", "call.started", {
  channel: "web", direction: "inbound", from: "web_ana", to: "front-desk", caller: null, started_at: Date.now() / 1000,
});
gateway.emit("front-desk", "CA_1", "tool.call", {
  call_id: "t-1", name: "takeMessage", arguments: { name: "Ana", about: "a refund" },
});
// gateway.commandsOf("tool.result") holds what a model would have been sent;
// gateway.commandsOf("tools.set") the tools it would see. Close both with pc.close() and gateway.close().

Next steps#