mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 16:45:22 +00:00
355 lines
14 KiB
TypeScript
355 lines
14 KiB
TypeScript
/**
|
|
* usage-reporting.test.ts — proves subagent spend actually reaches the parent
|
|
* session (#193), through the real registered tools.
|
|
*
|
|
* Pi folds `toolResult.usage` into `getSessionStats()`, which is what the
|
|
* footer, the statusline and `/cost` read. So the observable contract is not
|
|
* "we tracked a number" but "the tool result carries a complete pi `Usage`" —
|
|
* and every assertion here is about that object: that it appears, that it
|
|
* appears exactly once per message of spend, that it never appears when the
|
|
* user did not ask for it, and that it is complete enough for pi to consume
|
|
* without throwing.
|
|
*/
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
vi.mock("../src/agent-runner.js", async () => {
|
|
const actual = await vi.importActual<typeof import("../src/agent-runner.js")>("../src/agent-runner.js");
|
|
return { ...actual, runAgent: vi.fn(), resumeAgent: vi.fn() };
|
|
});
|
|
|
|
import { resumeAgent, runAgent } from "../src/agent-runner.js";
|
|
import { registerAgents } from "../src/agent-types.js";
|
|
import subagentsExtension from "../src/index.js";
|
|
import { addUsage } from "../src/usage.js";
|
|
import { ctx, flush, type Hermetic, hermeticDir, makePi } from "./helpers/boot-extension.js";
|
|
|
|
/** Drive one foreground run that spends `usage` on a single assistant message. */
|
|
function runSpending(usage: { input: number; output: number; cacheWrite: number; cacheRead?: number; cost?: number }) {
|
|
vi.mocked(runAgent).mockImplementation(async (_c: any, _t: any, _p: any, opts: any) => {
|
|
opts.onAssistantUsage?.(usage);
|
|
return { responseText: "done", session: { dispose: vi.fn(), messages: [] } as any, aborted: false, steered: false };
|
|
});
|
|
}
|
|
|
|
/** Nothing spent — the agent errored before any message_end fired. */
|
|
function runSpendingNothing() {
|
|
vi.mocked(runAgent).mockImplementation(async () => (
|
|
{ responseText: "done", session: { dispose: vi.fn(), messages: [] } as any, aborted: false, steered: false }
|
|
));
|
|
}
|
|
|
|
const spawn = (tools: Map<string, any>, toolCallId: string | undefined) =>
|
|
tools.get("Agent").execute(
|
|
toolCallId,
|
|
{ prompt: "go", description: "spend", subagent_type: "general-purpose", run_in_background: false },
|
|
undefined, undefined, ctx(),
|
|
);
|
|
|
|
describe("reporting subagent usage back to the parent session", () => {
|
|
let hermetic: Hermetic;
|
|
|
|
function boot(settings: Record<string, unknown>) {
|
|
hermetic = hermeticDir({ settings });
|
|
const { pi, tools, lifecycle } = makePi();
|
|
subagentsExtension(pi);
|
|
return { pi, tools, lifecycle };
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.mocked(runAgent).mockReset();
|
|
vi.mocked(resumeAgent).mockReset();
|
|
});
|
|
|
|
afterEach(() => {
|
|
delete (globalThis as any)[Symbol.for("pi-subagents:manager")];
|
|
registerAgents(new Map());
|
|
hermetic?.restore();
|
|
});
|
|
|
|
it("attaches a complete pi Usage to the tool result", async () => {
|
|
const { tools } = boot({ reportUsage: true });
|
|
runSpending({ input: 100, output: 50, cacheWrite: 10, cacheRead: 900, cost: 0.0123 });
|
|
|
|
const result = await spawn(tools, "tc-1");
|
|
|
|
// Every field pi's `addUsageToTotals` touches must exist: it reads
|
|
// `usage.cost.total` with no guard, so a partial object throws inside pi.
|
|
expect(result.usage).toEqual({
|
|
input: 100,
|
|
output: 50,
|
|
// Included, unlike our own display total (#38): pi sums cacheRead across
|
|
// the parent's own messages into this same figure, so withholding it
|
|
// would make a subagent's rows count differently from every other row.
|
|
cacheRead: 900,
|
|
cacheWrite: 10,
|
|
totalTokens: 1060,
|
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0.0123 },
|
|
});
|
|
});
|
|
|
|
it("reports each message's spend exactly once", async () => {
|
|
const { tools } = boot({ reportUsage: true });
|
|
runSpending({ input: 100, output: 50, cacheWrite: 10, cost: 0.0123 });
|
|
|
|
await spawn(tools, "tc-1");
|
|
runSpendingNothing();
|
|
const second = await spawn(tools, "tc-2");
|
|
|
|
// Not "the second result is smaller" — a pool that failed to reset would
|
|
// re-report the first run's spend here, and the parent's totals would climb
|
|
// on every later tool call for work that happened once.
|
|
expect(second.usage).toBeUndefined();
|
|
});
|
|
|
|
it("carries what a later run spends on the later result", async () => {
|
|
const { tools } = boot({ reportUsage: true });
|
|
runSpending({ input: 100, output: 50, cacheWrite: 10, cost: 0.01 });
|
|
await spawn(tools, "tc-1");
|
|
|
|
runSpending({ input: 7, output: 3, cacheWrite: 0, cost: 0.002 });
|
|
const second = await spawn(tools, "tc-2");
|
|
|
|
expect(second.usage.totalTokens).toBe(10);
|
|
expect(second.usage.cost.total).toBe(0.002);
|
|
});
|
|
|
|
it("attaches nothing when the setting is off", async () => {
|
|
const { tools } = boot({ reportUsage: false });
|
|
runSpending({ input: 100, output: 50, cacheWrite: 10, cost: 0.0123 });
|
|
|
|
const result = await spawn(tools, "tc-1");
|
|
|
|
expect(result.usage).toBeUndefined();
|
|
// And the text result is untouched — the setting must not change what the
|
|
// orchestrator reads.
|
|
expect(result.content[0].text).toContain("Agent completed");
|
|
});
|
|
|
|
it("defaults to off", async () => {
|
|
const { tools } = boot({});
|
|
runSpending({ input: 100, output: 50, cacheWrite: 10, cost: 0.0123 });
|
|
|
|
expect((await spawn(tools, "tc-1")).usage).toBeUndefined();
|
|
});
|
|
|
|
it("attaches nothing to a call with no tool-call id, and loses none of it", async () => {
|
|
// The `@handle` mention path: a fork of the conversation calls the
|
|
// registered tool with `undefined`, and its session is discarded. Usage hung
|
|
// on that result is spend the user paid for and nobody counted.
|
|
const { tools } = boot({ reportUsage: true });
|
|
runSpending({ input: 100, output: 50, cacheWrite: 10, cost: 0.0123 });
|
|
|
|
const cloned = await spawn(tools, undefined);
|
|
expect(cloned.usage).toBeUndefined();
|
|
|
|
// It was not dropped — the next result the real session gets carries it.
|
|
runSpendingNothing();
|
|
const real = await spawn(tools, "tc-2");
|
|
expect(real.usage.cost.total).toBe(0.0123);
|
|
expect(real.usage.totalTokens).toBe(160);
|
|
});
|
|
|
|
it("attaches nothing when a run produced no usage at all", async () => {
|
|
const { tools } = boot({ reportUsage: true });
|
|
runSpendingNothing();
|
|
|
|
expect((await spawn(tools, "tc-1")).usage).toBeUndefined();
|
|
});
|
|
|
|
it("reports an unpriced model's tokens with a zero cost rather than dropping them", async () => {
|
|
const { tools } = boot({ reportUsage: true });
|
|
runSpending({ input: 100, output: 50, cacheWrite: 10, cost: 0 });
|
|
|
|
const result = await spawn(tools, "tc-1");
|
|
|
|
expect(result.usage.totalTokens).toBe(160);
|
|
expect(result.usage.cost.total).toBe(0);
|
|
});
|
|
|
|
it("reports what a background resume spends, on the next call", async () => {
|
|
// Resuming detached is the default since #237, and it runs through a
|
|
// different manager path again — one whose result is an ID, not the spend.
|
|
// The next tool call is what has to carry it.
|
|
const { pi, tools } = boot({ reportUsage: true });
|
|
runSpending({ input: 100, output: 50, cacheWrite: 0, cost: 0.01 });
|
|
await spawn(tools, "tc-1");
|
|
await flush();
|
|
const id = pi.events.emit.mock.calls.find((c: any[]) => c[0] === "subagents:completed")?.[1]?.id;
|
|
|
|
vi.mocked(resumeAgent).mockImplementation(async (_session: any, _prompt: any, opts: any) => {
|
|
opts.onAssistantUsage?.({ input: 7, output: 3, cacheWrite: 0, cost: 0.002 });
|
|
return { text: "resumed" };
|
|
});
|
|
|
|
const started = await tools.get("Agent").execute(
|
|
"tc-2",
|
|
{ prompt: "more", description: "spend", subagent_type: "general-purpose", resume: id, run_in_background: true },
|
|
undefined, undefined, ctx(),
|
|
);
|
|
await flush();
|
|
runSpendingNothing();
|
|
const next = await spawn(tools, "tc-3");
|
|
|
|
// Which of the two carries it depends on how fast the detached run
|
|
// finishes, so the invariant is that it is reported once and in full —
|
|
// not that it lands on a particular call.
|
|
const reported = [started.usage, next.usage].filter(Boolean);
|
|
expect(reported).toHaveLength(1);
|
|
expect(reported[0].cost.total).toBe(0.002);
|
|
expect(reported[0].totalTokens).toBe(10);
|
|
});
|
|
|
|
it("counts a nested child's spend once, on the top-level agent's report", async () => {
|
|
// Nested agents are hidden from every reporting surface, so their spend is
|
|
// deliberately double-booked into every ancestor record to stay visible
|
|
// somewhere. Anything that summed those records would bill this session
|
|
// twice for one child message — which is why the pool is fed from the
|
|
// manager hook instead.
|
|
const { pi, tools } = boot({ reportUsage: true });
|
|
let nested = false;
|
|
|
|
vi.mocked(runAgent).mockImplementation(async (_c: any, _t: any, _p: any, opts: any) => {
|
|
opts.onAssistantUsage?.({ input: 100, output: 50, cacheWrite: 0, cost: 0.01 });
|
|
if (!nested) {
|
|
nested = true;
|
|
// `nestedRuntime` is exactly what nested-tools.ts is handed: the real
|
|
// manager and the id of the agent that owns the child.
|
|
const { manager, parentAgentId } = opts.nestedRuntime;
|
|
const childId = manager.spawn(pi, ctx(), "general-purpose", "sub", {
|
|
description: "nested",
|
|
isBackground: false,
|
|
parentAgentId,
|
|
// The ancestor walk, verbatim from nested-tools.
|
|
onAssistantUsage: (u: any) => addUsage(manager.getRecord(parentAgentId).lifetimeUsage, u),
|
|
});
|
|
await manager.getRecord(childId).promise;
|
|
}
|
|
return { responseText: "done", session: { dispose: vi.fn(), messages: [] } as any, aborted: false, steered: false };
|
|
});
|
|
|
|
const result = await spawn(tools, "tc-1");
|
|
|
|
// Two messages, one per agent — not three, which is what summing the
|
|
// double-booked parent record would have produced.
|
|
expect(nested).toBe(true);
|
|
expect(result.usage.totalTokens).toBe(300);
|
|
expect(result.usage.cost.total).toBeCloseTo(0.02, 10);
|
|
});
|
|
|
|
it("reports what a resume spends, not only the first run", async () => {
|
|
// A resumed agent runs through a different manager path from a spawn, with
|
|
// its own usage wiring. Miss it and every continuation of an agent is free
|
|
// as far as the parent session is concerned.
|
|
const { pi, tools } = boot({ reportUsage: true });
|
|
runSpending({ input: 100, output: 50, cacheWrite: 0, cost: 0.01 });
|
|
await spawn(tools, "tc-1");
|
|
await flush();
|
|
|
|
vi.mocked(resumeAgent).mockImplementation(async (_session: any, _prompt: any, opts: any) => {
|
|
opts.onAssistantUsage?.({ input: 7, output: 3, cacheWrite: 0, cost: 0.002 });
|
|
return { text: "resumed" };
|
|
});
|
|
|
|
const id = pi.events.emit.mock.calls.find((c: any[]) => c[0] === "subagents:completed")?.[1]?.id;
|
|
const result = await tools.get("Agent").execute(
|
|
"tc-2",
|
|
{ prompt: "more", description: "spend", subagent_type: "general-purpose", resume: id, run_in_background: false },
|
|
undefined, undefined, ctx(),
|
|
);
|
|
|
|
expect(result.usage.totalTokens).toBe(10);
|
|
expect(result.usage.cost.total).toBe(0.002);
|
|
});
|
|
|
|
describe("the lifecycle event payload", () => {
|
|
/** The payload `subagents:completed` was emitted with. */
|
|
async function completedPayload(pi: any) {
|
|
await flush();
|
|
const call = pi.events.emit.mock.calls.find((c: any[]) => c[0] === "subagents:completed");
|
|
return call?.[1];
|
|
}
|
|
|
|
it("carries the run's spend as a pi Usage", async () => {
|
|
// pi's convention for handing spend to a consumer: every extension-facing
|
|
// payload that carries it takes the whole object. Following it means
|
|
// `usage.cost.total` is where a listener already looks, and whatever pi
|
|
// adds to `Usage` later needs no change here.
|
|
const { pi, tools } = boot({});
|
|
runSpending({ input: 100, output: 50, cacheWrite: 10, cacheRead: 900, cost: 0.0123 });
|
|
|
|
await spawn(tools, "tc-1");
|
|
|
|
expect((await completedPayload(pi)).usage).toEqual({
|
|
input: 100,
|
|
output: 50,
|
|
cacheRead: 900,
|
|
cacheWrite: 10,
|
|
totalTokens: 1060,
|
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0.0123 },
|
|
});
|
|
});
|
|
|
|
it("carries it regardless of either setting", async () => {
|
|
// Both settings govern what a human is shown or what the session counts.
|
|
// A listener subscribed to the event asked for the data itself.
|
|
const { pi, tools } = boot({ reportUsage: false, showCost: false });
|
|
runSpending({ input: 100, output: 50, cacheWrite: 0, cost: 0.01 });
|
|
|
|
await spawn(tools, "tc-1");
|
|
|
|
expect((await completedPayload(pi)).usage.cost.total).toBe(0.01);
|
|
});
|
|
|
|
it("keeps `tokens` as the display total it has always been", async () => {
|
|
// The other convention — a flat view model like pi's own SessionStats,
|
|
// excluding cacheRead (#38). It is not derived from `usage` and must not
|
|
// start matching it.
|
|
const { pi, tools } = boot({});
|
|
runSpending({ input: 100, output: 50, cacheWrite: 10, cacheRead: 900, cost: 0.0123 });
|
|
|
|
await spawn(tools, "tc-1");
|
|
|
|
expect((await completedPayload(pi)).tokens).toEqual({ input: 100, output: 50, total: 160 });
|
|
});
|
|
|
|
it("omits usage entirely when nothing was spent", async () => {
|
|
// So a listener can tell "spent nothing" from "never ran".
|
|
const { pi, tools } = boot({});
|
|
runSpendingNothing();
|
|
|
|
await spawn(tools, "tc-1");
|
|
|
|
const payload = await completedPayload(pi);
|
|
expect(payload.usage).toBeUndefined();
|
|
expect(payload.tokens).toBeUndefined();
|
|
});
|
|
|
|
it("reports an unpriced model's tokens with a zero cost", async () => {
|
|
const { pi, tools } = boot({});
|
|
runSpending({ input: 100, output: 50, cacheWrite: 0, cost: 0 });
|
|
|
|
await spawn(tools, "tc-1");
|
|
|
|
const usage = (await completedPayload(pi)).usage;
|
|
expect(usage.totalTokens).toBe(150);
|
|
expect(usage.cost.total).toBe(0);
|
|
});
|
|
});
|
|
|
|
it("reports spend through get_subagent_result too", async () => {
|
|
// Background agents finish with no tool result of their own to ride on;
|
|
// whichever of our tools is called next has to carry them.
|
|
const { tools } = boot({ reportUsage: true });
|
|
runSpending({ input: 100, output: 50, cacheWrite: 10, cost: 0.0123 });
|
|
|
|
await spawn(tools, undefined); // spend accumulates, nothing attached
|
|
await flush();
|
|
|
|
const result = await tools.get("get_subagent_result").execute(
|
|
"tc-2", { agent_id: "nope" }, undefined, undefined, ctx(),
|
|
);
|
|
|
|
expect(result.usage.cost.total).toBe(0.0123);
|
|
});
|
|
});
|