import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { mkdtemp, readFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import test from "node:test"; import { fileURLToPath } from "node:url"; import { applyToolCatalog, isSubagentCustomPrompt, loadRoutingRules, loadSectionsConfig, loadToolCatalog, orderSections, readPreamble, renderRouting, resolvePreamblePath, spliceCover, stripJsoncComments, validateRoutingConfig, validateSectionsConfig, type MutablePromptOptions, type RoutingRule, } from "../extensions/system-prompt-config.ts"; import systemPromptExtension, { createSystemPromptComposer, getSystemPromptDumpPath, } from "../extensions/system-prompt.ts"; import { guardExtensionApi } from "../extensions/prompt-guard.ts"; import { BUNDLE_GROUP_DEFINITIONS } from "../pi-tool-search/extensions/bundle-groups.ts"; import { BUNDLE_TOOL_SEARCH_DEFAULTS } from "../pi-tool-search/extensions/config.ts"; const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const promptConfigDir = join(repositoryRoot, "config", "prompt"); const BUNDLE_PREAMBLE = "You are an expert coding assistant operating inside pi, a coding agent harness. " + "You help users by reading files, executing commands, editing code, and writing new files."; // ── JSONC parsing ─────────────────────────────────────────────────────────── test("stripJsoncComments removes comments but keeps // inside strings", () => { const source = `{ // line comment "url": "https://example.com//path", /* block comment */ "escaped": "a\\"//not a comment" }`; assert.deepEqual(JSON.parse(stripJsoncComments(source)), { url: "https://example.com//path", escaped: 'a"//not a comment', }); }); // ── Config files load and validate ────────────────────────────────────────── test("config/prompt files load with the expected shapes", () => { const catalog = loadToolCatalog(); assert.equal(catalog.version, 1); assert.ok(Object.keys(catalog.tools).length > 0); const routing = loadRoutingRules(); assert.equal(routing.version, 1); assert.ok(routing.rules.length > 0); for (const rule of routing.rules) assert.equal(typeof rule.text, "string"); const sections = loadSectionsConfig(); assert.equal(sections.version, 1); assert.deepEqual(sections.order, [ "memory_policy", "remote_ssh", "chrome_profile_bridge", "tool_routing", ]); assert.deepEqual(sections.subagentExclude, ["memory_policy", "chrome_profile_bridge"]); }); test("routing scope validation: main/subagent accepted, other values rejected", () => { assert.doesNotThrow(() => validateRoutingConfig({ version: 1, rules: [ { text: "both" }, { scope: "main", text: "m" }, { scope: "subagent", text: "s" }, ], }), ); assert.throws( () => validateRoutingConfig({ version: 1, rules: [{ scope: "everyone", text: "x" }] }), /scope must be "main" or "subagent"/, ); }); test("sections.jsonc validation: subagentExclude defaults to [] and rejects bad shapes", () => { assert.deepEqual(validateSectionsConfig({ version: 1, order: ["tool_routing"] }), { version: 1, order: ["tool_routing"], subagentExclude: [], }); assert.throws( () => validateSectionsConfig({ version: 1, order: [], subagentExclude: "memory_policy" }), /subagentExclude.*array of section names/, ); assert.throws( () => validateSectionsConfig({ version: 1, order: [], subagentExclude: ["Bad Name"] }), /not a valid section name/, ); }); test("readPreamble reads the bundle preamble from config/prompt/", () => { assert.equal( resolvePreamblePath(), join(promptConfigDir, "preamble.md"), ); assert.equal(readPreamble(), BUNDLE_PREAMBLE); }); // ── Catalog completeness ──────────────────────────────────────────────────── const CORE_TOOL_NAMES = [ "read", "bash", "edit", "write", "grep", "find", "ls", "powershell", "tool_search", ]; function requiredToolNames(): Set { const required = new Set(CORE_TOOL_NAMES); for (const group of BUNDLE_GROUP_DEFINITIONS) { for (const name of group.tools) required.add(name); } for (const name of BUNDLE_TOOL_SEARCH_DEFAULTS.alwaysEnabled) required.add(name); return required; } test("tools.jsonc covers every tool the bundle can expose, and nothing else", () => { const required = requiredToolNames(); const catalog = loadToolCatalog(); for (const name of required) { assert.ok(name in catalog.tools, `tool "${name}" is missing from config/prompt/tools.jsonc`); } for (const name of Object.keys(catalog.tools)) { assert.ok(required.has(name), `tools.jsonc entry "${name}" is not a bundle-exposed tool`); } }); // ── Forbidden phrases ─────────────────────────────────────────────────────── test("prompt config carries no retired guidance phrasing", () => { const forbidden = [ "ls, grep, find", "read, grep, or edit", "read/grep", "replace_text", "Read/edit files", ]; for (const file of ["preamble.md", "tools.jsonc", "routing.jsonc"]) { const source = readFileSync(join(promptConfigDir, file), "utf8"); for (const phrase of forbidden) { assert.ok(!source.includes(phrase), `config/prompt/${file} must not contain "${phrase}"`); } } }); // ── applyToolCatalog ──────────────────────────────────────────────────────── test("applyToolCatalog applies snippet/guideline overrides and reports unregistered tools", () => { const catalog = { version: 1 as const, tools: { bash: { snippet: "custom bash line" }, edit: { guidelines: ["rule one", "rule two"] }, read: { inherit: true as const }, write: { snippet: null }, }, }; const options: MutablePromptOptions = { selectedTools: ["bash", "edit", "read", "write", "mystery_tool"], toolSnippets: { bash: "orig bash", write: "orig write", read: "orig read" }, toolGuidelines: { edit: ["old rule"] }, }; const missing = applyToolCatalog(options, catalog); assert.deepEqual(missing, ["mystery_tool"]); assert.equal(options.toolSnippets?.bash, "custom bash line"); assert.equal(options.toolSnippets?.read, "orig read", "inherit entries keep upstream text"); assert.ok(!("write" in (options.toolSnippets ?? {})), "snippet: null hides the tools entry"); assert.deepEqual(options.toolGuidelines?.edit, ["rule one", "rule two"]); }); // ── renderRouting ─────────────────────────────────────────────────────────── test("renderRouting honours whenAll, whenAny and unconditional rules", () => { const rules: RoutingRule[] = [ { whenAll: ["a", "b"], text: "all" }, { whenAny: ["x", "y"], text: "any" }, { text: "always" }, { whenAll: ["a"], whenAny: ["b", "z"], text: "both" }, ]; assert.equal(renderRouting(rules, ["a", "b", "x"]), "- all\n- any\n- always\n- both"); assert.equal(renderRouting(rules, ["a"]), "- always", "whenAny must match at least one"); assert.equal(renderRouting(rules, []), "- always"); assert.equal( renderRouting(rules, undefined), "- all\n- any\n- always\n- both", "absent selectedTools means every tool is active", ); assert.equal(renderRouting([], ["a"]), ""); }); test("renderRouting filters scoped rules by audience, unscoped rules render for both", () => { const rules: RoutingRule[] = [ { text: "both audiences" }, { scope: "main", text: "main only" }, { scope: "subagent", text: "subagent only" }, ]; assert.equal(renderRouting(rules, undefined, "main"), "- both audiences\n- main only"); assert.equal(renderRouting(rules, undefined, "subagent"), "- both audiences\n- subagent only"); // The omitted-audience default stays "main" for callers that predate scopes. assert.equal(renderRouting(rules, undefined), "- both audiences\n- main only"); }); test("isSubagentCustomPrompt matches the contract only on customPrompt", () => { assert.ok(isSubagentCustomPrompt('a\n')); assert.ok(isSubagentCustomPrompt("")); assert.ok(!isSubagentCustomPrompt("plain main-session prompt")); assert.ok(!isSubagentCustomPrompt(undefined)); assert.ok(!isSubagentCustomPrompt(42)); }); test("bundled ssh_connect rules differ per audience", () => { const routing = loadRoutingRules(); const tools = ["ssh_connect", "ssh_bash"]; const main = renderRouting(routing.rules, tools, "main"); assert.match(main, /the user explicitly names an imported host/); assert.doesNotMatch(main, /the delegated task explicitly names/); const subagent = renderRouting(routing.rules, tools, "subagent"); assert.match(subagent, /the delegated task explicitly names an imported host/); assert.doesNotMatch(subagent, /the user explicitly names an imported host/); }); test("bundled routing rules select the same guidance the retired tool-routing extension emitted", () => { const routing = loadRoutingRules(); const literal = renderRouting(routing.rules, ["find", "grep", "read"]); assert.match(literal, /Use find to identify likely files or directories/); assert.match(literal, /narrow the path, glob, or pattern instead of increasing the limit/); const ssh = renderRouting(routing.rules, ["ssh_connect", "ssh_cd", "ssh_bash"]); assert.match(ssh, /only when the user explicitly names an imported host/); assert.match(ssh, /command-local directory change/); const chrome = renderRouting(routing.rules, ["chrome_snapshot", "overflow_query"]); assert.match(chrome, /return the page snapshot JSON directly/); const minimal = renderRouting(routing.rules, ["read"]); assert.doesNotMatch(minimal, /codegraph_explore first/); assert.doesNotMatch(minimal, /For literal search/); assert.doesNotMatch(minimal, /ssh_connect only when/); assert.match(minimal, /Stop searching once the current evidence is sufficient/); }); // ── orderSections ─────────────────────────────────────────────────────────── test("orderSections applies the configured order and keeps unlisted sections last", () => { const sections = { late_unlisted: "L", tool_routing: "R", memory_policy: "M", early_unlisted: "E", }; assert.deepEqual( Object.keys(orderSections(sections, ["memory_policy", "tool_routing"])), ["memory_policy", "tool_routing", "late_unlisted", "early_unlisted"], ); assert.deepEqual(orderSections({}, ["memory_policy"]), {}); }); // ── spliceCover (moved from the retired cover extension) ──────────────────── function renderedPrompt(preamble = "PI DEFAULT PREAMBLE") { return [ preamble, "", "- read: reads files", "", "", "- Be concise", "", "", '', "Nested content with a -looking line is not a tag.", "", "", "", "/work", "", ].join("\n"); } function tagsOf(text: string): string[] { return [...text.matchAll(/^<([a-z][a-z0-9_-]*)>$/gm)].map((match) => match[1]); } test("spliceCover replaces only the preamble and preserves the remaining bytes", () => { const rendered = renderedPrompt(); const spliced = spliceCover(rendered, "MY CUSTOM COVER"); assert.ok(spliced.startsWith("MY CUSTOM COVER\n")); const originalTail = rendered.slice(rendered.indexOf("\n")); assert.ok(spliced.endsWith(originalTail)); assert.deepEqual(tagsOf(spliced), tagsOf(rendered)); assert.deepEqual(tagsOf(spliced), ["tools", "rules", "project_context", "cwd"]); }); test("spliceCover tolerates a trailing newline and a whitespace-only preamble", () => { const rendered = renderedPrompt(); assert.equal(spliceCover(rendered, "COVER\n\n"), spliceCover(rendered, "COVER")); const blank = spliceCover(rendered, " \n"); assert.equal(blank, rendered.slice(rendered.indexOf("\n"))); }); test("spliceCover degenerates to the preamble when the prompt has no sections", () => { assert.equal(spliceCover("just a preamble", "NEW"), "NEW"); assert.equal(spliceCover("", "NEW"), "NEW"); }); // ── Composer: modern structured path ──────────────────────────────────────── interface FakePromptOptions extends MutablePromptOptions { forceSystemPrompt?: string; customPrompt?: string; } /** Simplified re-render of Pi's prompt from the mutable options object. */ function renderPiPrompt(options: FakePromptOptions): string { if (options.forceSystemPrompt !== undefined) return options.forceSystemPrompt; // Like the real host: a non-empty customPrompt owns the preamble section. const preamble = typeof options.customPrompt === "string" && options.customPrompt.length > 0 ? options.customPrompt : "PI DEFAULT PREAMBLE"; const parts = [preamble]; const snippets = Object.entries(options.toolSnippets ?? {}); if (snippets.length > 0) { parts.push(`\n${snippets.map(([n, s]) => `- ${n}: ${s}`).join("\n")}\n`); } const rules = [ ...(options.promptGuidelines ?? []), ...Object.entries(options.toolGuidelines ?? {}).flatMap(([name, lines]) => lines.map((line) => `${name}: ${line}`), ), ]; if (rules.length > 0) parts.push(`\n${rules.join("\n")}\n`); parts.push("\n/work\n"); for (const [name, text] of Object.entries(options.sections ?? {})) { parts.push(`<${name}>\n${text}\n`); } return parts.join("\n"); } function makeEvent(options: FakePromptOptions | undefined) { return { systemPromptOptions: options, get systemPrompt() { return options ? renderPiPrompt(options) : "PI DEFAULT PREAMBLE\n\n- x\n"; }, }; } function makeUiCtx() { const notices: string[] = []; const ctx = { hasUI: true, ui: { notify: (message: string) => notices.push(message) }, }; return { notices, ctx: ctx as never }; } test("composer splices the preamble, applies the catalog, and orders sections", () => { const composer = createSystemPromptComposer(); const options: FakePromptOptions = { selectedTools: ["read", "bash", "edit", "codegraph_explore", "tool_search"], toolSnippets: { read: "reads files", bash: "runs commands", edit: "edits", codegraph_explore: "explores", tool_search: "searches", }, toolGuidelines: { edit: ["upstream edit rule"] }, sections: { memory_policy: "policy text" }, }; const { notices, ctx } = makeUiCtx(); const result = composer.handleBeforeAgentStart(makeEvent(options) as never, ctx); const output = result?.systemPrompt; assert.ok(output); assert.ok( output.startsWith(BUNDLE_PREAMBLE), "the bundle preamble replaces Pi's default preamble", ); assert.match(output, /\n- Use codegraph_explore first/); assert.match(output, /search files with the find\/grep tools, not bash/); assert.match(output, /LINE#HASH anchors/); assert.ok( !output.includes("upstream edit rule"), "the edit guidelines entry replaces upstream rules", ); assert.ok( output.indexOf("") < output.indexOf(""), "sections.jsonc orders memory_policy before tool_routing", ); assert.deepEqual(notices, []); assert.equal(composer.getLastComposed(), output); }); test("composer degrades to text splicing when an earlier extension froze the prompt", () => { const composer = createSystemPromptComposer(); const options: FakePromptOptions = { selectedTools: ["read", "bash"], forceSystemPrompt: "FROZEN PROMPT\n\nkept\n", }; const { notices, ctx } = makeUiCtx(); const event = makeEvent(options); const first = composer.handleBeforeAgentStart(event as never, ctx); composer.handleBeforeAgentStart(event as never, ctx); assert.match(notices[0] ?? "", /单一入口规则/); assert.equal(notices.length, 1, "the violation warning is reported once per process"); assert.match(first?.systemPrompt ?? "", //); assert.match(first?.systemPrompt ?? "", /\nkept\n<\/frozen>/); assert.ok( (first?.systemPrompt ?? "").startsWith(BUNDLE_PREAMBLE), "the preamble is still spliced onto the frozen prompt", ); }); test("composer keeps a customPrompt-owned preamble (subagent / SYSTEM.md) intact", () => { const composer = createSystemPromptComposer(); const customPrompt = 'You are the Explore agent.\n'; const options: FakePromptOptions = { customPrompt, selectedTools: ["read"], toolSnippets: { read: "reads files" }, }; const { ctx } = makeUiCtx(); const event = { systemPromptOptions: options, systemPrompt: `${customPrompt}\n\n\n- read: reads files\n`, }; const result = composer.handleBeforeAgentStart(event as never, ctx); const output = result?.systemPrompt ?? ""; assert.ok(output.startsWith(customPrompt), "the agent's own preamble is kept verbatim"); assert.ok( output.includes(''), "the identity the permission system reads survives", ); assert.ok( !output.includes(BUNDLE_PREAMBLE), "the bundle preamble must not be spliced over a customPrompt-owned preamble", ); assert.match(output, //, "routing still degrades to an appended section"); }); test("composer treats an customPrompt as a subagent session", () => { const composer = createSystemPromptComposer(); const customPrompt = 'You are a subagent.\n\n# Environment\nWorking directory: /work'; const options: FakePromptOptions = { customPrompt, selectedTools: ["read", "ssh_connect"], toolSnippets: { read: "reads files", ssh_connect: "connects" }, sections: { memory_policy: "memory guidance for main sessions", chrome_profile_bridge: "chrome bridge preamble", remote_ssh: "remote ssh preamble", }, }; const { ctx } = makeUiCtx(); const result = composer.handleBeforeAgentStart(makeEvent(options) as never, ctx); const output = result?.systemPrompt ?? ""; assert.ok(output.startsWith(customPrompt), "the child preamble stays untouched"); assert.doesNotMatch(output, //, "subagentExclude removes memory_policy"); assert.doesNotMatch(output, //, "subagentExclude removes chrome_profile_bridge"); assert.match(output, //, "unexcluded sections are kept"); assert.match(output, /the delegated task explicitly names an imported host/, "subagent-scope routing renders"); assert.doesNotMatch(output, /the user explicitly names an imported host/, "main-scope routing stays out"); }); test("a main session keeps excluded sections and main-scope routing even with a literal tag", () => { const composer = createSystemPromptComposer(); // The tag appears inside a contributed section (project docs can carry the // literal text) but there is no customPrompt — this is a main session. const options: FakePromptOptions = { selectedTools: ["read", "ssh_connect"], toolSnippets: { read: "reads files", ssh_connect: "connects" }, sections: { memory_policy: 'docs mention ', chrome_profile_bridge: "chrome bridge preamble", }, }; const { ctx } = makeUiCtx(); const result = composer.handleBeforeAgentStart(makeEvent(options) as never, ctx); const output = result?.systemPrompt ?? ""; assert.ok(output.startsWith(BUNDLE_PREAMBLE), "the bundle preamble still splices for a main session"); assert.match(output, //, "main sessions are unaffected by subagentExclude"); assert.match(output, //); assert.match(output, /the user explicitly names an imported host/, "main-scope routing renders"); assert.doesNotMatch(output, /the delegated task explicitly names/, "subagent-scope routing stays out"); }); test("composer falls back to text appends when the host exposes no prompt options", () => { const composer = createSystemPromptComposer(); const { ctx } = makeUiCtx(); const event = { systemPrompt: "PI DEFAULT PREAMBLE\n\n- x\n" }; const result = composer.handleBeforeAgentStart(event as never, ctx); const output = result?.systemPrompt ?? ""; assert.ok(output.startsWith(BUNDLE_PREAMBLE)); assert.match(output, //); assert.match(output, /\n- x\n<\/tools>/); }); test("composer reports unregistered tools once per distinct name set", () => { const composer = createSystemPromptComposer(); const { notices, ctx } = makeUiCtx(); const options = (): FakePromptOptions => ({ selectedTools: ["read", "ghost_tool"], toolSnippets: { read: "r" }, }); composer.handleBeforeAgentStart(makeEvent(options()) as never, ctx); composer.handleBeforeAgentStart(makeEvent(options()) as never, ctx); assert.equal(notices.length, 1); assert.match(notices[0], /未在 config\/prompt\/tools\.jsonc 登记:.*ghost_tool/); composer.handleBeforeAgentStart( makeEvent({ selectedTools: ["other_tool"], toolSnippets: {} }) as never, ctx, ); assert.equal(notices.length, 2, "a different missing-tool set notifies again"); }); test("composer warns once and returns nothing when the prompt config fails to load", () => { const composer = createSystemPromptComposer({ loadToolCatalog: () => { throw new Error("invalid jsonc"); }, }); const { notices, ctx } = makeUiCtx(); const options: FakePromptOptions = { selectedTools: ["read"], toolSnippets: { read: "r" } }; assert.equal(composer.handleBeforeAgentStart(makeEvent(options) as never, ctx), undefined); assert.equal(composer.handleBeforeAgentStart(makeEvent(options) as never, ctx), undefined); assert.equal(notices.length, 1); assert.match(notices[0], /invalid jsonc/); }); // ── prompt guard ──────────────────────────────────────────────────────────── function makeRecordingPi() { const tools: Array<{ name: string; description?: string }> = []; const handlers = new Map unknown>(); const pi = { registerTool(def: { name: string; description?: string }) { tools.push(def); }, on(name: string, handler: (event: unknown, ctx: unknown) => unknown) { handlers.set(name, handler); }, someOtherApi: () => "passthrough", }; return { pi, tools, handlers }; } test("prompt guard applies catalog description overrides at registration", () => { const { pi, tools } = makeRecordingPi(); const guarded = guardExtensionApi(pi as unknown as ExtensionAPI); guarded.registerTool({ name: "edit", description: "upstream edit" } as never); guarded.registerTool({ name: "grep", description: "upstream grep" } as never); const override = loadToolCatalog().tools.edit; assert.ok(override && !("inherit" in override) && typeof override.description === "string"); assert.equal(tools[0].description, override.description); assert.equal(tools[1].description, "upstream grep", "inherit entries keep the upstream description"); }); test("prompt guard strips a returned systemPrompt but keeps other fields", async () => { const { pi, handlers } = makeRecordingPi(); const guarded = guardExtensionApi(pi as unknown as ExtensionAPI); guarded.on("before_agent_start", (async () => ({ systemPrompt: "forced by third party", message: "kept", })) as never); const result = (await handlers.get("before_agent_start")?.({}, {})) as Record; assert.deepEqual(result, { message: "kept" }); }); test("prompt guard runs context results through the provided contextFilter", async () => { const { pi, handlers } = makeRecordingPi(); const guarded = guardExtensionApi(pi as unknown as ExtensionAPI, { contextFilter: (before, after) => after.slice(0, before.length), }); guarded.on("context", (async (event: { messages: Array<{ role: string; content: string }> }) => { event.messages.push({ role: "user", content: "injected" }); return { messages: event.messages }; }) as never); const event = { messages: [{ role: "user", content: "real" }] }; const result = (await handlers.get("context")?.(event, {})) as { messages: Array<{ content: string }>; }; assert.deepEqual(result.messages.map((m) => m.content), ["real"]); }); test("prompt guard returns the handler result untouched when the filter changes nothing", async () => { const { pi, handlers } = makeRecordingPi(); const guarded = guardExtensionApi(pi as unknown as ExtensionAPI, { contextFilter: (_before, after) => after, }); const expected = { messages: [{ role: "user", content: "plain" }] }; guarded.on("context", (async () => expected) as never); const result = await handlers.get("context")?.({ messages: expected.messages.slice() }, {}); assert.equal(result, expected, "a no-op filter must preserve result identity"); }); test("prompt guard passes unrelated API members through to the real pi object", () => { const { pi } = makeRecordingPi(); const guarded = guardExtensionApi(pi as unknown as ExtensionAPI); assert.equal((guarded as unknown as { someOtherApi: () => string }).someOtherApi(), "passthrough"); }); // ── /dump-system-prompt ───────────────────────────────────────────────────── test("dump-system-prompt writes the last composed prompt under .pi-debug", async () => { let beforeAgentStart: ((event: unknown, ctx: unknown) => unknown) | undefined; let command: { handler: (args: string, ctx: unknown) => Promise } | undefined; const pi = { on(name: string, handler: typeof beforeAgentStart) { if (name === "before_agent_start") beforeAgentStart = handler; }, registerCommand(name: string, definition: typeof command) { if (name === "dump-system-prompt") command = definition; }, } as unknown as ExtensionAPI; systemPromptExtension(pi); assert.ok(beforeAgentStart); assert.ok(command); const { ctx } = makeUiCtx(); beforeAgentStart( makeEvent({ selectedTools: ["read"], toolSnippets: { read: "r" } }) as never, ctx, ); const cwd = await mkdtemp(join(tmpdir(), "my-pi-system-prompt-")); try { await command.handler("", { cwd, getSystemPrompt: () => "fallback prompt", ui: { notify() {} }, }); const dumped = await readFile(getSystemPromptDumpPath(cwd), "utf8"); assert.ok(dumped.startsWith(BUNDLE_PREAMBLE)); assert.match(dumped, //); } finally { await rm(cwd, { recursive: true, force: true }); } });