Testing
Ring 0 with the package's helpers: a vitest suite with no network, no key and no model, and the rings above.
Ring 0 is the ring your own suite lives in: no network, no key, no model, no gateway. It runs on
every commit, in a second, and it is where nearly every mistake is caught. pinecall new writes
one beside the agent, run 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: its docstrings and parameter 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.
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 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, as this.call; a CallWorld takes { 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) |
one tool call as Pinecall runs it: arguments checked, writes authored by the tool, the result cut to its preview |
runHook(agent, name, ...args) |
a hook such as onCall, its writes authored by it |
recalled(agent, facts) |
facts as if memory had recalled them, for a render() that asks remembers |
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.
What is worth a test#
The things a prompt makes true, not the things a method returns:
- 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, and its error is what the model reads — assert on it.
- 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 — tool.call in, tool.result out — mount it on a
gateway that is not there, from @pinecall/agents/client/testing. It answers the app's socket on
this machine:
import { mount } from "@pinecall/agents";
import { Pinecall } from "@pinecall/agents/client";
import { FakeGateway } from "@pinecall/agents/client/testing";
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 with pc.close() and gateway.close().
gateway.finds([...]) scripts what every search answers, and gateway.cut() drops the socket, so
a test can watch the agent dial back.
The rings above#
The verbs are the one pinecall CLI's, the same for a TypeScript project as for a Ruby or a Python
one, in the same layout: agents/<slug>/agent.tsx, test/<slug>/goldens/, test/<slug>/memory/,
docs/<slug>/.
| ring | what it asks | how |
|---|---|---|
| 1 | does the agent hold its goldens? | pinecall test — the goldens under test/<slug>/goldens/, each call served by your project's @pinecall/agents/serve |
| 2 | does it hold on a real line? | pinecall test --voice · pinecall simulate --voice |
| 3 | what does one real call score? | pinecall eval <call-id> |
| 4 | what did every call score? | call.score, written by the runtime at hang-up |
The index and memory have goldens of their own — whether retrieval returns the chunk a question
needs (pinecall docs eval), whether recall brings back the fact a question needs
(pinecall memory eval), and what the hang-up's one model call writes, replaces and must never
keep (pinecall remember). They are files beside the project, run by the CLI and the same for every
language: Testing knowledge & memory.