Files
backnotprop__plannotator/apps/pi-extension/tool-scope.test.ts
Peete d26289060d fix(pi): preserve extension tool selections (#1124)
* fix(pi): preserve extension tool selections

* fix(pi): release phase tools when the plan completes

The agent_end plan-completion path dropped back to idle via
restoreSavedState but never called releaseAddedPhaseTools(), so tools
Plannotator activated for the executing phase stayed active in idle and
a stale phaseAddedTools list was persisted. Mirror exitToIdle.

Claude-Session: https://claude.ai/code/session_01H5KQWqXqjrPxyxUNso1QHS

* fix(pi): keep grep/find/ls in the default planning tool set

Dropping them from the planning phase profile silently removed native
search from default planning sessions. Restore them as default planning
phase tools so they now flow through the new tracking mechanism: they
are recorded in phaseAddedTools and released on phase exit, and a
discovery tool another extension already activated is neither claimed
nor removed. A user's own planning.activeTools still overrides them.

Claude-Session: https://claude.ai/code/session_01H5KQWqXqjrPxyxUNso1QHS

* fix(pi): always keep the submit tool active during planning

Planning tools now come purely from the resolved profile's activeTools,
which uses replace semantics: a phases.planning.activeTools in
~/.pi/agent/plannotator.json or cwd/.pi/plannotator.json supersedes the
built-in list wholesale. Pi's setActiveToolsByName then replaces the
session's tool list outright, so such a config silently dropped
plannotator_submit_plan while the planning system prompt kept telling the
model to call it - a dead end with no way to submit a plan.

Union PLAN_SUBMIT_TOOL into the configured list for the planning phase
only (deduped if the config already lists it). It flows through
phaseAddedTools like any other addition, so it is released normally on
phase exit. Other tools are left to the user: overriding
planning.activeTools is a deliberate choice of tool set, and only the
submit tool is contractually required for the phase to function.

Also corrects the README's "extra tools ... additive" wording, which
described the old merge behavior.

Claude-Session: https://claude.ai/code/session_01H5KQWqXqjrPxyxUNso1QHS

---------

Co-authored-by: Michael Ramos <support@plannotator.ai>
Co-authored-by: Michael Ramos <mdramos8@gmail.com>
2026-07-26 22:28:11 -07:00

81 lines
2.6 KiB
TypeScript

import { describe, expect, test } from "bun:test";
import {
applyPhaseTools,
isPlanWritePathAllowed,
releasePhaseTools,
} from "./tool-scope.ts";
describe("pi plan tool scoping", () => {
test("adds configured phase tools without replacing the active tools", () => {
expect(
applyPhaseTools(["inspect", "search"], [], ["search", "submit_plan"]),
).toEqual({
activeTools: ["inspect", "search", "submit_plan"],
addedTools: ["submit_plan"],
});
});
test("changes phases without restoring tools removed by another extension", () => {
expect(
applyPhaseTools(
["inspect", "external_new", "submit_plan"],
["submit_plan", "missing_phase_tool"],
["execution_progress"],
),
).toEqual({
activeTools: ["inspect", "external_new", "execution_progress"],
addedTools: ["execution_progress"],
});
});
test("releases only tools added by the phase", () => {
expect(
releasePhaseTools(
["inspect", "external_new", "execution_progress"],
["execution_progress", "already_removed"],
),
).toEqual(["inspect", "external_new"]);
});
});
describe("plan write path gate", () => {
const cwd = "/r";
test("allows markdown files anywhere inside cwd", () => {
expect(isPlanWritePathAllowed("PLAN.md", cwd)).toBe(true);
expect(isPlanWritePathAllowed("plans/auth.md", cwd)).toBe(true);
expect(isPlanWritePathAllowed("deeply/nested/dir/notes.mdx", cwd)).toBe(true);
});
test("rejects non-markdown extensions", () => {
expect(isPlanWritePathAllowed("src/app.ts", cwd)).toBe(false);
expect(isPlanWritePathAllowed("notes.txt", cwd)).toBe(false);
expect(isPlanWritePathAllowed("config.json", cwd)).toBe(false);
});
test("rejects files with no extension or bare directories", () => {
expect(isPlanWritePathAllowed("plans", cwd)).toBe(false);
expect(isPlanWritePathAllowed("PLAN", cwd)).toBe(false);
});
test("rejects traversal and absolute paths outside cwd", () => {
expect(isPlanWritePathAllowed("../escape.md", cwd)).toBe(false);
expect(isPlanWritePathAllowed("../../etc/passwd.md", cwd)).toBe(false);
expect(isPlanWritePathAllowed("/tmp/leak.md", cwd)).toBe(false);
});
test("allows absolute paths that resolve inside cwd", () => {
expect(isPlanWritePathAllowed("/r/plans/foo.md", cwd)).toBe(true);
});
test("rejects empty path and the cwd itself", () => {
expect(isPlanWritePathAllowed("", cwd)).toBe(false);
expect(isPlanWritePathAllowed(".", cwd)).toBe(false);
});
test("extension check is case-insensitive", () => {
expect(isPlanWritePathAllowed("PLAN.MD", cwd)).toBe(true);
expect(isPlanWritePathAllowed("notes.MdX", cwd)).toBe(true);
});
});