mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 08:35:57 +00:00
feat: enable permission-aware subagents
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* isolated-provider.e2e.test.ts — reachability guard for PR #152 (issue #151).
|
||||
*
|
||||
* PR #152 fixes isolated subagents dropping extension-registered custom providers
|
||||
* on Pi >= 0.80.8 (the `modelRegistry` → `modelRuntime` migration). agent-runner
|
||||
* forwards the parent's runtime, read off the ModelRegistry facade as
|
||||
* `ctx.modelRegistry.runtime` via `as unknown as { runtime }`.
|
||||
*
|
||||
* That forwarding is already guarded by the unit test in test/agent-runner.test.ts
|
||||
* ("passes the parent model runtime …") — but against a MOCK whose `.runtime` is
|
||||
* hand-set. The mock cannot catch the one thing that would silently break the fix:
|
||||
* `.runtime` is a `private readonly` field on the real ModelRegistry, absent from
|
||||
* the public type AND the package exports. If a future Pi renames it, makes it a
|
||||
* true #private, or moves the module, the cast quietly yields `undefined`, the fix
|
||||
* omits `modelRuntime`, and the bug returns with no failing test.
|
||||
*
|
||||
* This test closes exactly that gap and nothing else: it asserts the real facade
|
||||
* exposes a runtime-reachable `.runtime` that IS the runtime it wraps. It is not a
|
||||
* guard for the forwarding itself (that's the unit test's job) — a fuller e2e that
|
||||
* drives real `runAgent` end-to-end is tracked as a follow-up.
|
||||
*
|
||||
* VERSION GATE: `.runtime` only exists in the post-migration facade world (Pi >=
|
||||
* 0.80.8, where `ModelRuntime` is first exported). The repo's dev dependency is
|
||||
* pinned post-migration, so this runs by default — but it stays gated because CI
|
||||
* also runs the suite against the peer-range floor (see .github/workflows/ci.yml),
|
||||
* where Pi predates the migration. There we DYNAMICALLY import Pi and skip
|
||||
* cleanly; a static `import { ModelRuntime }` would be a link-time error.
|
||||
*/
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterAll, describe, expect, it } from "vitest";
|
||||
|
||||
// Dynamic, so this file LOADS on pre-migration Pi (a static `import { ModelRuntime }`
|
||||
// would be a link-time error there — 0.80.6 doesn't export it).
|
||||
const pi = (await import("@earendil-works/pi-coding-agent")) as Record<string, unknown>;
|
||||
const ModelRuntime = pi.ModelRuntime as
|
||||
| { create(opts?: Record<string, unknown>): Promise<ModelRuntimeLike> }
|
||||
| undefined;
|
||||
|
||||
// The migration is exactly "ModelRuntime now exists". Absent ⇒ pre-0.80.8 ⇒ there
|
||||
// is no `.runtime` facade to guard.
|
||||
const MIGRATED = typeof ModelRuntime?.create === "function";
|
||||
const RT = ModelRuntime as { create(opts?: Record<string, unknown>): Promise<ModelRuntimeLike> };
|
||||
|
||||
// The one method the reach scenario needs; `.runtime` itself is private (reached below).
|
||||
interface ModelRuntimeLike {
|
||||
registerProvider(id: string, config: Record<string, unknown>): void;
|
||||
}
|
||||
|
||||
const tmpDirs: string[] = [];
|
||||
afterAll(() => {
|
||||
for (const d of tmpDirs.splice(0)) rmSync(d, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe.skipIf(!MIGRATED)("PR #152 reach: real ModelRegistry exposes .runtime (Pi >= 0.80.8)", () => {
|
||||
it("ctx.modelRegistry.runtime is reachable and IS the runtime it wraps", async () => {
|
||||
// A real, configured runtime — as an extension leaves it after registerProvider.
|
||||
const dir = mkdtempSync(join(tmpdir(), "iso-prov-"));
|
||||
tmpDirs.push(dir);
|
||||
const runtime = await RT.create({
|
||||
authPath: join(dir, "auth.json"),
|
||||
modelsPath: join(dir, "models.json"),
|
||||
allowModelNetwork: false,
|
||||
});
|
||||
|
||||
// `.runtime` is private and not in the package exports — reach the compiled
|
||||
// class by file path, exactly the field the patch's cast depends on. If Pi
|
||||
// moves/renames/#privates it, THIS line fails loudly instead of the fix
|
||||
// silently no-op'ing back to the #151 bug.
|
||||
const indexUrl = import.meta.resolve("@earendil-works/pi-coding-agent");
|
||||
const mrUrl = indexUrl.replace(/index\.js$/, "core/model-registry.js");
|
||||
const { ModelRegistry } = (await import(mrUrl)) as {
|
||||
ModelRegistry: new (rt: ModelRuntimeLike) => { runtime?: unknown };
|
||||
};
|
||||
|
||||
const facade = new ModelRegistry(runtime);
|
||||
// This is the exact expression agent-runner reads (`ctx.modelRegistry.runtime`).
|
||||
expect((facade as { runtime?: unknown }).runtime).toBe(runtime);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* mention-clone-tool-reachability.e2e.test.ts — reachability guard for the one
|
||||
* tool the mention clone is built around.
|
||||
*
|
||||
* `runMentionClone` hands a session ONE tool and expects the model to call it.
|
||||
* Whether that tool ever reaches the model is decided entirely inside Pi, by
|
||||
* `createAgentSession`'s allowlist plumbing — and the unit tests cannot see it:
|
||||
* their `createAgentSession` is a mock that hands `customTools[0]` straight to
|
||||
* the model turn, so a session option that silently strips the tool passes
|
||||
* every one of them.
|
||||
*
|
||||
* That is not hypothetical. The clone shipped with `noTools: "all"` on the
|
||||
* reading its doc comment invites ("start with no tools enabled" — no
|
||||
* built-ins, keep mine). Pi turns that flag into an EMPTY allowlist, and an
|
||||
* empty array is truthy, so `AgentSession` builds an empty `Set` and
|
||||
* `isAllowedTool` rejects every name — custom tools are filtered by the same
|
||||
* predicate as built-ins. Every mention was prompted with no tools, answered in
|
||||
* prose, and fell back to a direct start with a warning. The unit suite stayed
|
||||
* green throughout.
|
||||
*
|
||||
* So this asserts against a REAL session, on the two things a mock cannot
|
||||
* establish:
|
||||
* 1. the clone's `Agent` tool is actually active on it, and
|
||||
* 2. nothing else is — the invisible turn cannot read, write or run anything.
|
||||
*
|
||||
* No network/LLM: a faux provider satisfies session construction, and the
|
||||
* assertion is on the constructed tool set rather than on a model turn.
|
||||
*/
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Real pi-mono session construction; a cold first run under full-suite CPU
|
||||
// contention can exceed vitest's 5s default.
|
||||
vi.setConfig({ testTimeout: 30_000 });
|
||||
|
||||
// Hoisted so the (lifted) mock factory can reach it. Everything except the
|
||||
// capture is the real module — the point is to construct a REAL session.
|
||||
const { sessions } = vi.hoisted(() => ({ sessions: [] as any[] }));
|
||||
|
||||
vi.mock("@earendil-works/pi-coding-agent", async () => {
|
||||
const actual = await vi.importActual<any>("@earendil-works/pi-coding-agent");
|
||||
return {
|
||||
...actual,
|
||||
createAgentSession: async (opts: any) => {
|
||||
const created = await actual.createAgentSession(opts);
|
||||
sessions.push(created.session);
|
||||
return created;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { runMentionClone } from "../../src/mention-clone.js";
|
||||
import { fauxModelBackend } from "../helpers/faux-model-backend.js";
|
||||
import { registerFauxProvider } from "../helpers/pi-ai.js";
|
||||
|
||||
describe("mention clone tool reachability against real pi-mono", () => {
|
||||
let cwd: string;
|
||||
let faux: ReturnType<typeof registerFauxProvider>;
|
||||
|
||||
beforeEach(() => {
|
||||
sessions.length = 0;
|
||||
cwd = mkdtempSync(join(tmpdir(), "subagents-mention-clone-"));
|
||||
faux = registerFauxProvider({ provider: "faux", models: [{ id: "faux-1", contextWindow: 200_000 }] });
|
||||
});
|
||||
afterEach(() => {
|
||||
faux.unregister();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("the clone's Agent tool is live on the real session, and it is the only one", async () => {
|
||||
const model = faux.getModel();
|
||||
const backend = fauxModelBackend(model);
|
||||
const ctx: any = {
|
||||
cwd,
|
||||
model,
|
||||
getSystemPrompt: () => "PARENT",
|
||||
// mention-clone reads the runtime off the registry facade, the same shim
|
||||
// agent-runner carries for Pi >= 0.80.8.
|
||||
modelRegistry: { ...backend.modelRegistry, runtime: backend.modelRuntime },
|
||||
sessionManager: { getEntries: () => [], getLeafId: () => undefined },
|
||||
};
|
||||
|
||||
// Never called: the assertion is on what the session exposes, not on the
|
||||
// faux model deciding to use it.
|
||||
const agentTool = { name: "Agent", execute: vi.fn() } as any;
|
||||
|
||||
// Never rejects by contract; a faux turn that cannot complete is fine,
|
||||
// because the tool set is fixed at construction.
|
||||
await runMentionClone({ ctx, type: "Explore", message: "go", agentTool });
|
||||
|
||||
expect(sessions).toHaveLength(1);
|
||||
// The bug this file exists for: with an empty allowlist this is `[]`.
|
||||
expect(sessions[0].getActiveToolNames()).toEqual(["Agent"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* tool-veto-reachability.e2e.test.ts — reachability guard for the `ext:` turn-1
|
||||
* tool veto (issue #125).
|
||||
*
|
||||
* `installExtensionToolScope` enforces `ext:` narrowing two ways. Re-narrowing the
|
||||
* ACTIVE set on `turn_end` is built entirely on public API (`getAllTools`,
|
||||
* `getActiveToolNames`, `setActiveToolsByName`) and is covered by the unit tests.
|
||||
* The second half is not: turn 1 cannot be narrowed at all — `before_agent_start`
|
||||
* fires INSIDE `prompt()` and may widen the tool set, but `createContextSnapshot()`
|
||||
* freezes that turn's tools immediately after, leaving no window — so out-of-scope
|
||||
* calls are vetoed at call time by wrapping `session.agent.beforeToolCall`.
|
||||
*
|
||||
* That wrap is the one place this extension reaches past the documented surface:
|
||||
* - `ExtensionBindings` has no tool_call hook, so there is no SDK-level way to
|
||||
* inject a veto into a session we construct. Pi exposes the veto to EXTENSIONS
|
||||
* as `pi.on("tool_call") -> { block, reason }`, but we are the SDK caller here,
|
||||
* not an extension bound to the child session.
|
||||
* - So we wrap the property Pi itself installs in the AgentSession constructor
|
||||
* (`_installAgentToolHooks`), chaining to the prior hook so Pi's own `tool_call`
|
||||
* dispatch still runs.
|
||||
*
|
||||
* The unit tests assert our wrapper's behavior against a MOCK session whose `agent`
|
||||
* is a hand-written `{ beforeToolCall: undefined }`. That mock cannot catch the one
|
||||
* thing that would silently break the veto: if a future Pi renames `beforeToolCall`,
|
||||
* stops installing it, makes `agent` non-enumerable/private, or moves the veto
|
||||
* elsewhere, our assignment lands on a property nothing reads. Every test still
|
||||
* passes, and out-of-scope tools become callable on turn 1 with no failing test.
|
||||
*
|
||||
* This guard closes exactly that gap and nothing else. It asserts against a REAL
|
||||
* session that:
|
||||
* 1. Pi installs its own `beforeToolCall` (so there IS a prior hook to chain), and
|
||||
* 2. after `runAgent`, ours is installed and vetoes an out-of-scope tool in the
|
||||
* `{ block, reason }` shape Pi honors.
|
||||
*
|
||||
* No network/LLM: a faux Model satisfies `createAgentSession`, and the veto is
|
||||
* invoked directly rather than through a model turn.
|
||||
*/
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { runAgent } from "../../src/agent-runner.js";
|
||||
import { registerAgents } from "../../src/agent-types.js";
|
||||
import type { AgentConfig } from "../../src/types.js";
|
||||
import { registerFauxProvider } from "../helpers/pi-ai.js";
|
||||
|
||||
// Real pi-mono (loader + dynamic extension import + session construction).
|
||||
vi.setConfig({ testTimeout: 30_000 });
|
||||
|
||||
/** Registers `alpha_read` / `alpha_write`; reused so no new fixture is needed. */
|
||||
const ALPHA = resolve(fileURLToPath(new URL("../fixtures/ext-alpha.mjs", import.meta.url)));
|
||||
/** Registers `beta_tool` — loaded but NOT selected by the `ext:` selector below. */
|
||||
const BETA = resolve(fileURLToPath(new URL("../fixtures/ext-beta.mjs", import.meta.url)));
|
||||
|
||||
function makePi() {
|
||||
return { exec: async () => ({ code: 1, stdout: "", stderr: "" }) } as any;
|
||||
}
|
||||
|
||||
describe("tool veto reachability against real pi-mono", () => {
|
||||
let cwd: string;
|
||||
let faux: ReturnType<typeof registerFauxProvider>;
|
||||
|
||||
beforeEach(() => {
|
||||
cwd = mkdtempSync(join(tmpdir(), "subagents-veto-"));
|
||||
faux = registerFauxProvider({
|
||||
provider: "faux",
|
||||
models: [{ id: "faux-1", contextWindow: 200_000 }],
|
||||
});
|
||||
});
|
||||
afterEach(() => {
|
||||
faux.unregister();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("pi installs a chainable beforeToolCall, and runAgent's veto blocks out-of-scope tools", async () => {
|
||||
registerAgents(
|
||||
new Map([
|
||||
[
|
||||
"veto",
|
||||
{
|
||||
name: "veto",
|
||||
description: "veto guard",
|
||||
builtinToolNames: ["read"],
|
||||
// Select alpha only — beta loads (its handlers run) but is muted.
|
||||
extensions: [ALPHA, BETA],
|
||||
extSelectors: ["ext:ext-alpha.mjs"],
|
||||
skills: false,
|
||||
systemPrompt: "You are veto.",
|
||||
promptMode: "replace",
|
||||
inheritContext: false,
|
||||
runInBackground: false,
|
||||
isolated: false,
|
||||
} as AgentConfig,
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
const model = faux.getModel();
|
||||
const modelRegistry: any = {
|
||||
find: () => model,
|
||||
getAll: () => [model],
|
||||
getAvailable: () => [model],
|
||||
hasConfiguredAuth: () => true,
|
||||
isUsingOAuth: () => false,
|
||||
getApiKeyAndHeaders: async () => ({ apiKey: "faux", headers: {} }),
|
||||
registerProvider: () => {},
|
||||
unregisterProvider: () => {},
|
||||
};
|
||||
const ctx: any = { cwd, getSystemPrompt: () => "PARENT", model, modelRegistry };
|
||||
|
||||
let priorIsFunction: boolean | undefined;
|
||||
let session: any;
|
||||
try {
|
||||
await runAgent(ctx, "veto", "go", {
|
||||
pi: makePi(),
|
||||
model,
|
||||
onSessionCreated: (s: any) => {
|
||||
session = s;
|
||||
// By onSessionCreated our wrapper is already installed, so this being a
|
||||
// function proves the property is reachable and writable. Pi installing
|
||||
// its own in the constructor is what gives us something to chain to —
|
||||
// asserted below via the in-scope path returning undefined rather than
|
||||
// throwing on a missing prior hook.
|
||||
priorIsFunction = typeof s.agent?.beforeToolCall === "function";
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// A faux-model turn may not complete; the veto is fixed at construction.
|
||||
}
|
||||
|
||||
expect(priorIsFunction).toBe(true);
|
||||
|
||||
// Out of scope: beta loaded but the ext: flip did not select it.
|
||||
await expect(
|
||||
session.agent.beforeToolCall({ toolCall: { name: "beta_tool" }, args: {} }),
|
||||
).resolves.toMatchObject({ block: true, reason: expect.any(String) });
|
||||
|
||||
// In scope: must NOT be blocked. Reaching Pi's own prior hook without throwing
|
||||
// also proves the chain is intact (a clobbered/absent prior would surface here).
|
||||
await expect(
|
||||
session.agent.beforeToolCall({ toolCall: { name: "alpha_read" }, args: {} }),
|
||||
).resolves.toSatisfy((r: any) => !r?.block);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* usage-reaches-session-stats.e2e.test.ts — the premise #193 rests on, checked
|
||||
* against the REAL pi runtime.
|
||||
*
|
||||
* Every unit test for usage reporting asserts that our tool results carry a
|
||||
* `usage` field. None of them can establish the thing that makes carrying it
|
||||
* worth doing: that pi picks it up. That happens entirely inside pi —
|
||||
* `createToolResultMessage` copies `AgentToolResult.usage` onto the persisted
|
||||
* message, and `getSessionStats()` folds `toolResult.usage` into the tokens and
|
||||
* cost the footer, the statusline and `/cost` read. Mock pi, and a release that
|
||||
* stopped doing either would leave the whole feature reporting into a void with
|
||||
* a green suite.
|
||||
*
|
||||
* So this drives a real `AgentSession` and reads its real `getSessionStats()`,
|
||||
* with the exact object `PendingUsagePool.drain()` produces — including the
|
||||
* `cacheRead` our own display total drops but this report must carry, and the
|
||||
* cost breakdown whose `total` pi reads with no guard at all.
|
||||
*
|
||||
* No network/LLM and no model turn: the message is appended through pi's own
|
||||
* `sessionManager.appendMessage`, because what is under test is the accounting,
|
||||
* not the streaming that would normally produce the message.
|
||||
*
|
||||
* This test is also what set the peer floor. Pi began folding `toolResult.usage`
|
||||
* into `getSessionStats()` in 0.81.0, when the computation moved to walking
|
||||
* session entries through `addUsageToTotals`; every 0.80.x sums assistant
|
||||
* messages alone and drops the field. Running unconditionally is the point —
|
||||
* against a Pi that does not aggregate, this fails rather than skipping, which
|
||||
* is how the range stays honest. `peerDependencies` moved to `>=0.81.0` for
|
||||
* exactly this reason, so the CI floor job runs it too.
|
||||
*/
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { createAgentSession, SessionManager } from "@earendil-works/pi-coding-agent";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { PendingUsagePool } from "../../src/usage.js";
|
||||
import { fauxModelBackend } from "../helpers/faux-model-backend.js";
|
||||
import { registerFauxProvider } from "../helpers/pi-ai.js";
|
||||
|
||||
// Real pi session construction; a cold first run under full-suite CPU
|
||||
// contention can exceed vitest's 5s default.
|
||||
vi.setConfig({ testTimeout: 30_000 });
|
||||
|
||||
describe("subagent usage reaches the parent session's stats (real pi)", () => {
|
||||
let cwd: string;
|
||||
let faux: ReturnType<typeof registerFauxProvider>;
|
||||
|
||||
beforeEach(() => {
|
||||
cwd = mkdtempSync(join(tmpdir(), "subagents-usage-e2e-"));
|
||||
faux = registerFauxProvider({ provider: "faux", models: [{ id: "faux-1", contextWindow: 200_000 }] });
|
||||
});
|
||||
afterEach(() => {
|
||||
faux.unregister();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/** A real session, in memory, on a faux model. */
|
||||
async function realSession() {
|
||||
const model = faux.getModel();
|
||||
const backend = fauxModelBackend(model);
|
||||
const { session } = await createAgentSession({
|
||||
cwd,
|
||||
sessionManager: SessionManager.inMemory(cwd),
|
||||
model: model as any,
|
||||
modelRegistry: backend.modelRegistry,
|
||||
modelRuntime: backend.modelRuntime,
|
||||
tools: [],
|
||||
} as any);
|
||||
return session;
|
||||
}
|
||||
|
||||
/** The tool result our `Agent` tool returns, as pi would persist it. */
|
||||
function toolResultCarrying(usage: unknown) {
|
||||
return {
|
||||
role: "toolResult" as const,
|
||||
toolCallId: "tc-1",
|
||||
toolName: "Agent",
|
||||
content: [{ type: "text" as const, text: "Agent completed." }],
|
||||
isError: false,
|
||||
timestamp: 1,
|
||||
usage,
|
||||
};
|
||||
}
|
||||
|
||||
it("pi adds our reported tokens and cost to getSessionStats()", async () => {
|
||||
const session = await realSession();
|
||||
try {
|
||||
const before = session.getSessionStats();
|
||||
|
||||
const pool = new PendingUsagePool();
|
||||
pool.add({ input: 1000, output: 400, cacheWrite: 100, cacheRead: 9000, cost: 0.0123 });
|
||||
pool.add({ input: 2000, output: 600, cacheWrite: 200, cacheRead: 18_000, cost: 0.0077 });
|
||||
const usage = pool.drain();
|
||||
|
||||
session.sessionManager.appendMessage(toolResultCarrying(usage) as any);
|
||||
const after = session.getSessionStats();
|
||||
|
||||
// Exactly what we reported, on every component pi tracks — cacheRead
|
||||
// included, which is the one pi counts for its own messages and our own
|
||||
// display total leaves out.
|
||||
expect(after.tokens.input - before.tokens.input).toBe(3000);
|
||||
expect(after.tokens.output - before.tokens.output).toBe(1000);
|
||||
expect(after.tokens.cacheWrite - before.tokens.cacheWrite).toBe(300);
|
||||
expect(after.tokens.cacheRead - before.tokens.cacheRead).toBe(27_000);
|
||||
|
||||
// The cost: the whole point of the feature for anyone watching a
|
||||
// statusline. `addUsageToTotals` reads `usage.cost.total` with no guard,
|
||||
// so an incomplete object would have thrown before reaching here.
|
||||
expect(after.cost - before.cost).toBeCloseTo(0.02, 10);
|
||||
} finally {
|
||||
session.dispose?.();
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves the context-window percentage alone", async () => {
|
||||
// pi derives context usage from assistant messages only. If that ever
|
||||
// changed, a delegating session would look like it was filling its context
|
||||
// with work that happened somewhere else entirely — and users would compact
|
||||
// for no reason.
|
||||
const session = await realSession();
|
||||
try {
|
||||
const before = session.getSessionStats().contextUsage?.percent ?? null;
|
||||
|
||||
const pool = new PendingUsagePool();
|
||||
pool.add({ input: 150_000, output: 400, cacheWrite: 100, cost: 1.5 });
|
||||
session.sessionManager.appendMessage(toolResultCarrying(pool.drain()) as any);
|
||||
|
||||
expect(session.getSessionStats().contextUsage?.percent ?? null).toBe(before);
|
||||
} finally {
|
||||
session.dispose?.();
|
||||
}
|
||||
});
|
||||
|
||||
it("counts nothing for a tool result that carries no usage", async () => {
|
||||
// The `reportUsage: false` shape, and every other tool in the session.
|
||||
const session = await realSession();
|
||||
try {
|
||||
const before = session.getSessionStats();
|
||||
session.sessionManager.appendMessage(toolResultCarrying(undefined) as any);
|
||||
const after = session.getSessionStats();
|
||||
|
||||
expect(after.tokens.input).toBe(before.tokens.input);
|
||||
expect(after.cost).toBe(before.cost);
|
||||
} finally {
|
||||
session.dispose?.();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user