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#
render() writes for a state, block by block.
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):
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:
@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:
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:
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().