mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 16:45:22 +00:00
146 lines
6.2 KiB
TypeScript
146 lines
6.2 KiB
TypeScript
/**
|
|
* 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);
|
|
});
|
|
});
|