import { describe, expect, spyOn, test } from "bun:test"; import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { isAbsolute, join } from "node:path"; import { type BuildSystemPromptOptions, createEventBus, type ExtensionAPI, } from "@earendil-works/pi-coding-agent"; import { runPromptHandlers } from "../../../test/support/prompt-handlers"; import { getAgentRuntimeComposition } from "../../shared/agent-runtime-composition"; import { getToolsetRuntime } from "../../shared/toolsets/runtime"; import systemPrompt from "./index"; const AGENT_DIR_ENV = "PI_CODING_AGENT_DIR"; const AGENT_SUITE_DIR_ENV = "PI_AGENT_SUITE_DIR"; const DATE_LINE_PATTERN = /Date: \d{4}-\d{2}-\d{2}/; interface RegisteredHandler { readonly eventName: string; readonly handler: unknown; } interface Notification { readonly message: string; readonly type: string | undefined; } interface ExtensionApiFake extends ExtensionAPI { readonly handlers: RegisteredHandler[]; } interface ContextFake { readonly notifications: Notification[]; readonly ctx: { readonly cwd: string; readonly hasUI?: boolean; readonly ui: { notify(message: string, type: string | undefined): void; }; }; } /** Creates an ExtensionAPI fake that records lifecycle handlers registered by system-prompt. */ function createExtensionApiFake( initialActiveTools: readonly string[] = [], ): ExtensionApiFake { const handlers: RegisteredHandler[] = []; let activeTools = [...initialActiveTools]; return { handlers, events: createEventBus(), on(eventName: string, handler: unknown): void { handlers.push({ eventName, handler }); }, registerTool(): void {}, getActiveTools(): readonly string[] { return activeTools; }, setActiveTools(toolNames: readonly string[]): void { activeTools = [...toolNames]; }, } as unknown as ExtensionApiFake; } /** Creates the session context fake needed to observe startup warnings. */ function createContextFake(cwd = "/tmp/project", hasUI?: boolean): ContextFake { const notifications: Notification[] = []; return { notifications, ctx: { cwd, ...(hasUI !== undefined ? { hasUI } : {}), ui: { notify(message: string, type: string | undefined): void { notifications.push({ message, type }); }, }, }, }; } /** Runs a test with isolated pi agent and suite directories so real user config is never read. */ async function withIsolatedAgentDir( action: (paths: { readonly agentDir: string; readonly suiteDir: string; }) => Promise, ): Promise { const previousAgentDir = process.env[AGENT_DIR_ENV]; const previousAgentSuiteDir = process.env[AGENT_SUITE_DIR_ENV]; const agentDir = await mkdtemp(join(tmpdir(), "pi-system-prompt-agent-")); const suiteDir = await mkdtemp(join(tmpdir(), "pi-system-prompt-suite-")); process.env[AGENT_DIR_ENV] = agentDir; process.env[AGENT_SUITE_DIR_ENV] = suiteDir; try { return await action({ agentDir, suiteDir }); } finally { if (previousAgentDir === undefined) { delete process.env[AGENT_DIR_ENV]; } else { process.env[AGENT_DIR_ENV] = previousAgentDir; } if (previousAgentSuiteDir === undefined) { delete process.env[AGENT_SUITE_DIR_ENV]; } else { process.env[AGENT_SUITE_DIR_ENV] = previousAgentSuiteDir; } await rm(agentDir, { recursive: true, force: true }); await rm(suiteDir, { recursive: true, force: true }); } } /** Writes a suite-owned config file for system-prompt in the isolated suite directory. */ async function writeSuiteConfig( suiteDir: string, config: unknown, ): Promise { const configDir = join(suiteDir, "system-prompt"); await mkdir(configDir, { recursive: true }); await writeFile(join(configDir, "config.json"), JSON.stringify(config)); } /** Writes the legacy config path that system-prompt must ignore. */ async function writeLegacyConfig( agentDir: string, config: unknown, ): Promise { const configDir = join(agentDir, "config"); await mkdir(configDir, { recursive: true }); await writeFile( join(configDir, "system-prompt.json"), JSON.stringify(config), ); } /** Returns one registered event handler from the extension fake. */ function getRegisteredHandler( pi: ExtensionApiFake, eventName: string, ): (event: unknown, ctx: unknown) => Promise | unknown { const handler = pi.handlers.find( (item) => item.eventName === eventName, )?.handler; if (typeof handler !== "function") { throw new Error(`expected ${eventName} handler to be registered`); } return handler as ( event: unknown, ctx: unknown, ) => Promise | unknown; } /** Runs registered before_agent_start handlers in the same chained order as pi. */ async function runBeforeAgentStartHandlers( pi: ExtensionApiFake, event: BeforeAgentStartEventFake, ctx: ContextFake["ctx"], ): Promise { const handlers = pi.handlers .filter((item) => item.eventName === "before_agent_start") .map((item) => item.handler) .filter( (handler): handler is (event: unknown, ctx: unknown) => unknown => typeof handler === "function", ); return ( (await runPromptHandlers(handlers, event, ctx))?.systemPrompt ?? event.systemPrompt ); } /** Creates a before_agent_start event fixture with every dynamic prompt input populated. */ function createBeforeAgentStartEvent( overrides: Partial = {}, ): BeforeAgentStartEventFake { return { type: "before_agent_start", prompt: "work", images: [], systemPrompt: "Original pi prompt", systemPromptOptions: { sections: {}, cwd: "/tmp/project", selectedTools: ["read", "bash", "hidden"], toolSnippets: { read: "Read file contents", bash: "Execute shell commands", }, promptGuidelines: [ "Use read before editing files.", " ", " Use read before editing files. ", "Use bash only when command output is needed.", ], appendSystemPrompt: "Append text", contextFiles: [ { path: "AGENTS.md", content: "Project rules" }, { path: "CLAUDE.md", content: "Extra rules" }, ], skills: [ { name: "typescript", description: "TypeScript rules", filePath: "/skills/typescript/SKILL.md", baseDir: "/skills/typescript", sourceInfo: { path: "/skills/typescript/SKILL.md", source: "test", scope: "temporary", origin: "top-level", }, disableModelInvocation: false, }, ], ...overrides, }, }; } interface BeforeAgentStartEventFake { readonly type: "before_agent_start"; readonly prompt: string; readonly images: readonly unknown[]; readonly systemPrompt: string; readonly systemPromptOptions: BuildSystemPromptOptions; } describe("system-prompt", () => { for (const child of [false, true]) { for (const fixture of [ { name: "repeated inline sections", input: "A{{#mainAgent}}M{{/mainAgent}}B{{#childAgent}}C{{/childAgent}}D{{#mainAgent}}N{{/mainAgent}}E", output: child ? "ABCDE" : "AMBDNE", }, ...(["\n", "\r\n"] as const).map((newline) => ({ name: `marker-only lines ${JSON.stringify(newline)}`, input: [ "A", " \t{{#mainAgent}} ", "M", "{{/mainAgent}}", "{{#childAgent}}", "C", "{{/childAgent}}", "Z", ].join(newline), output: ["A", child ? "C" : "M", "Z"].join(newline), })), { name: "selection before substitution", input: "{{#mainAgent}}{{cwd}}{{/mainAgent}}{{#childAgent}}{{appendSystemPrompt}}{{/childAgent}}", output: child ? "{{#mainAgent}}runtime{{/mainAgent}}" : "/tmp/{{#childAgent}}runtime{{/childAgent}}", }, { name: "section-free bytes and ordinary variables", input: " \r\n{{cwd}}\n{{unsupported}}tail\r\n", output: " \r\n/tmp/{{#childAgent}}runtime{{/childAgent}}\ntail\r\n", }, { name: "inline surrounding spaces and final marker-only line", input: "a {{#mainAgent}} M {{/mainAgent}} z\n{{#childAgent}}\nC\n{{/childAgent}}", output: child ? "a z\nC\n" : "a M z\n", }, ]) { test(`renders ${fixture.name} for ${child ? "child" : "main"} with role captured at extension load`, async () => { await withIsolatedAgentDir(async ({ suiteDir }) => { const templateFile = join(suiteDir, "sections.md"); await writeFile(templateFile, fixture.input); await writeSuiteConfig(suiteDir, { templateFile }); const pi = createExtensionApiFake(); const context = createContextFake(); const environment = await import("./environment"); const role = spyOn( environment, "isSystemPromptChildProcess", ).mockReturnValue(child); try { systemPrompt(pi); role.mockReturnValue(!child); await getRegisteredHandler(pi, "session_start")({}, context.ctx); for (let turn = 0; turn < 2; turn++) { expect( await runBeforeAgentStartHandlers( pi, createBeforeAgentStartEvent({ cwd: "/tmp/{{#childAgent}}runtime{{/childAgent}}", appendSystemPrompt: "{{#mainAgent}}runtime{{/mainAgent}}", }), context.ctx, ), ).toBe(fixture.output); } expect(context.notifications.length).toBe( fixture.input.includes("unsupported") ? 1 : 0, ); } finally { role.mockRestore(); } }); }); } } for (const child of [false, true]) { for (const workflows of [false, true]) { const role = child ? "child" : "main"; const agent = `${role} agent ${workflows ? "with" : "without"} workflows`; for (const fixture of [ { name: "a top-level workflow section", input: "C{{#workflowAgent}}X{{/workflowAgent}}", output: workflows ? "CX" : "C", }, { name: "a workflow section inside a role section", input: "{{#mainAgent}}M{{#workflowAgent}}X{{/workflowAgent}}{{/mainAgent}}{{#childAgent}}S{{/childAgent}}", output: child ? "S" : `M${workflows ? "X" : ""}`, }, ...(["\n", "\r\n"] as const).map((newline) => ({ name: `marker-only workflow lines ${JSON.stringify(newline)}`, input: [ "A", "{{#mainAgent}}", "M", " \t{{#workflowAgent}} ", "X", "{{/workflowAgent}}", "{{/mainAgent}}", "{{#workflowAgent}}", "W", "{{/workflowAgent}}", ].join(newline), output: [ "A", ...(child ? [] : ["M"]), ...(workflows && !child ? ["X"] : []), ...(workflows ? ["W"] : []), "", ].join(newline), })), ]) { test(`renders ${fixture.name} for a ${agent}`, async () => { await withIsolatedAgentDir(async ({ suiteDir }) => { const templateFile = join(suiteDir, "sections.md"); await writeFile(templateFile, fixture.input); await writeSuiteConfig(suiteDir, { templateFile }); const pi = createExtensionApiFake( workflows ? ["read", "workflow_activate"] : ["read"], ); const context = createContextFake(); const environment = await import("./environment"); const childMarker = spyOn( environment, "isSystemPromptChildProcess", ).mockReturnValue(child); try { systemPrompt(pi); await getRegisteredHandler(pi, "session_start")({}, context.ctx); expect( await runBeforeAgentStartHandlers( pi, createBeforeAgentStartEvent(), context.ctx, ), ).toBe(fixture.output); expect(context.notifications).toHaveLength(0); } finally { childMarker.mockRestore(); } }); }); } } } test("omits workflow sections on the next run after workflow_activate leaves the active tools", async () => { await withIsolatedAgentDir(async ({ suiteDir }) => { const templateFile = join(suiteDir, "sections.md"); await writeFile( templateFile, "{{#mainAgent}}M{{#workflowAgent}}X{{/workflowAgent}}{{/mainAgent}}", ); await writeSuiteConfig(suiteDir, { templateFile }); const pi = createExtensionApiFake(["read", "workflow_activate"]); const context = createContextFake(); systemPrompt(pi); await getRegisteredHandler(pi, "session_start")({}, context.ctx); expect( await runBeforeAgentStartHandlers( pi, createBeforeAgentStartEvent(), context.ctx, ), ).toBe("MX"); // The workflow extension removes the tool through this filter when the agent loses its workflows. getAgentRuntimeComposition(pi).setRestrictiveToolFilter( "fixture", (names) => names.filter((name) => name !== "workflow_activate"), ); expect( await runBeforeAgentStartHandlers( pi, createBeforeAgentStartEvent(), context.ctx, ), ).toBe("M"); }); }); for (const launched of [false, true]) { test(`renders ${launched ? "child" : "main"} sections when the composition ${launched ? "reports" : "does not report"} a launched subagent`, async () => { await withIsolatedAgentDir(async ({ suiteDir }) => { const templateFile = join(suiteDir, "sections.md"); await writeFile( templateFile, "{{#mainAgent}}M{{/mainAgent}}{{#childAgent}}C{{/childAgent}}", ); await writeSuiteConfig(suiteDir, { templateFile }); const pi = createExtensionApiFake(); const context = createContextFake(); systemPrompt(pi); // The owner of the flag registers the resolver after system-prompt has loaded. getAgentRuntimeComposition(pi).setLaunchedSubagentResolver(() => launched ? "Extractor" : undefined, ); await getRegisteredHandler(pi, "session_start")({}, context.ctx); expect( await runBeforeAgentStartHandlers( pi, createBeforeAgentStartEvent(), context.ctx, ), ).toBe(launched ? "C" : "M"); }); }); } for (const input of [ "{{#mainAgent}}open", "{{/childAgent}}", "{{#mainAgent}}m{{/childAgent}}", "{{#mainAgent}}{{#childAgent}}c{{/childAgent}}{{/mainAgent}}", "{{#mainAgent}}{{#mainAgent}}m{{/mainAgent}}{{/mainAgent}}", "{{#other}}x{{/other}}", "{{/other}}", "{{#workflowAgent}}{{#workflowAgent}}X{{/workflowAgent}}{{/workflowAgent}}", "{{#mainAgent}}{{#workflowAgent}}X{{/mainAgent}}{{/workflowAgent}}", "{{#workflowAgent}}{{#mainAgent}}X{{/mainAgent}}{{/workflowAgent}}", "{{#workflowAgent}}X", ]) { test(`retains Pi prompt and reports invalid sections: ${input}`, async () => { await withIsolatedAgentDir(async ({ suiteDir }) => { const templateFile = join(suiteDir, "invalid-sections.md"); await writeFile(templateFile, input); await writeSuiteConfig(suiteDir, { templateFile }); const pi = createExtensionApiFake(); const context = createContextFake(); systemPrompt(pi); await getRegisteredHandler(pi, "session_start")({}, context.ctx); expect(context.notifications).toHaveLength(1); expect(context.notifications[0]?.message).toContain("section"); expect( await runBeforeAgentStartHandlers( pi, createBeforeAgentStartEvent(), context.ctx, ), ).toBe("Original pi prompt"); }); }); } for (const templateSource of ["bundled", "configured"] as const) { test(`preserves custom prompts with a ${templateSource} template while reconciling tools and composing agent contributions`, async () => { await withIsolatedAgentDir(async ({ suiteDir }) => { if (templateSource === "configured") { const templateFile = join(suiteDir, "template.md"); await writeFile(templateFile, "Fixture template: {{cwd}}"); await writeSuiteConfig(suiteDir, { templateFile }); } const pi = createExtensionApiFake(["read", "bash"]); const context = createContextFake(); systemPrompt(pi); await getRegisteredHandler(pi, "session_start")( { type: "session_start", reason: "startup" }, context.ctx, ); const composition = getAgentRuntimeComposition(pi); composition.setSelectedAgentContribution({ prompt: "Fixture selected-agent contribution", tools: ["read", "bash"], }); let allowedTools = ["read", "bash"]; composition.setRestrictiveToolFilter("fixture", () => allowedTools); allowedTools = ["read"]; const event = createBeforeAgentStartEvent({ customPrompt: "Fixture custom prompt", }); expect( await getRegisteredHandler(pi, "before_agent_start")( event, context.ctx, ), ).toBeUndefined(); expect(pi.getActiveTools()).toEqual(["read"]); expect(await runBeforeAgentStartHandlers(pi, event, context.ctx)).toBe( `${event.systemPrompt}\n\nFixture selected-agent contribution`, ); }); }); } for (const customPrompt of [undefined, ""]) { test(`renders a fixture template when customPrompt is ${customPrompt === undefined ? "absent" : "empty"}`, async () => { await withIsolatedAgentDir(async ({ suiteDir }) => { const templateFile = join(suiteDir, "template.md"); await writeFile(templateFile, "Fixture directory: {{cwd}}"); await writeSuiteConfig(suiteDir, { templateFile }); const pi = createExtensionApiFake(["read"]); const context = createContextFake(); systemPrompt(pi); await getRegisteredHandler(pi, "session_start")( { type: "session_start", reason: "startup" }, context.ctx, ); const event = createBeforeAgentStartEvent( customPrompt === undefined ? {} : { customPrompt }, ); expect(await runBeforeAgentStartHandlers(pi, event, context.ctx)).toBe( `Fixture directory: ${event.systemPromptOptions.cwd}`, ); }); }); } test("maps visible active tool guidelines before normalized global contributions", async () => { await withIsolatedAgentDir(async ({ suiteDir }) => { const templateFile = join(suiteDir, "guidelines.md"); await writeFile(templateFile, "{{toolGuidelines}}"); await writeSuiteConfig(suiteDir, { templateFile }); const pi = createExtensionApiFake([ "beta", "alpha", "concealed", "empty", ]); const context = createContextFake(); systemPrompt(pi); await getRegisteredHandler(pi, "session_start")( { type: "session_start", reason: "startup" }, context.ctx, ); const prompt = await runBeforeAgentStartHandlers( pi, createBeforeAgentStartEvent({ selectedTools: ["inactive"], hiddenTools: ["concealed"], toolSnippets: {}, toolGuidelines: { alpha: [" ALPHA_MARKER ", "SHARED_MARKER"], beta: [" BETA_MARKER ", " ", " SHARED_MARKER "], concealed: ["HIDDEN_MARKER"], inactive: ["INACTIVE_MARKER"], }, promptGuidelines: [ "SHARED_MARKER", " GLOBAL_MARKER ", "", "GLOBAL_MARKER", ], }), context.ctx, ); expect(prompt.split("\n")).toEqual([ "- BETA_MARKER", "- SHARED_MARKER", "- ALPHA_MARKER", "- GLOBAL_MARKER", ]); }); }); test("renders only requested dynamic variables from the Markdown template", async () => { // Purpose: a template must own all static prose while variables inject only runtime values. // Input and expected output: a custom template references every supported variable plus one unknown placeholder. // Edge case: blank and normalized duplicate tool guidelines plus unknown placeholders are omitted. // Dependencies: this test uses isolated temp config/template files and in-memory pi lifecycle fakes. await withIsolatedAgentDir(async ({ suiteDir }) => { const templateFile = join(suiteDir, "template.md"); await writeFile( templateFile, [ "Static header", "Date: {{date}}", "CWD: {{cwd}}", "Tool guidelines:", "{{toolGuidelines}}", "Append:", "{{appendSystemPrompt}}", "Context:", "{{contextFiles}}", "Skills:", "{{skills}}", "Unknown: {{typo}}", ].join("\n"), ); await writeSuiteConfig(suiteDir, { templateFile }); const pi = createExtensionApiFake(["read", "bash", "hidden"]); const context = createContextFake(); systemPrompt(pi); await getRegisteredHandler(pi, "session_start")( { type: "session_start", reason: "startup" }, context.ctx, ); expect(context.notifications).toEqual([ { message: "[system-prompt] removed unsupported template variable(s): typo", type: "warning", }, ]); const prompt = await runBeforeAgentStartHandlers( pi, createBeforeAgentStartEvent(), context.ctx, ); expect(prompt).toContain("Static header"); expect(prompt).toMatch(DATE_LINE_PATTERN); expect(prompt).toContain("CWD: /tmp/project"); expect(prompt.match(/- Use read before editing files\./g)).toHaveLength( 1, ); expect(prompt).toContain( "- Use bash only when command output is needed.", ); expect(prompt).toContain("Append text"); expect(prompt).toContain(""); expect(prompt).toContain( '\nProject rules\n ', ); expect(prompt).toContain( '\nExtra rules\n ', ); expect(prompt).toContain(""); expect(prompt).not.toContain("## AGENTS.md"); expect(prompt).toContain("typescript"); expect(prompt).not.toContain("{{typo}}"); expect(prompt).toContain("Unknown: "); await getRegisteredHandler(pi, "session_start")( { type: "session_start", reason: "reload" }, context.ctx, ); expect(context.notifications).toEqual([ { message: "[system-prompt] removed unsupported template variable(s): typo", type: "warning", }, { message: "[system-prompt] removed unsupported template variable(s): typo", type: "warning", }, ]); }); }); test("removes unsupported placeholder names with non-alphanumeric characters", async () => { // Purpose: every unsupported {{...}} placeholder must be removed and reported, not only simple identifier-like names. // Input and expected output: underscore and hyphen placeholder names warn at startup and render as empty strings. // Edge case: supported variables still render next to unsupported placeholder forms. // Dependencies: this test uses isolated temp config/template files and in-memory pi lifecycle fakes. await withIsolatedAgentDir(async ({ suiteDir }) => { const templateFile = join(suiteDir, "template.md"); await writeFile(templateFile, "{{date}} {{foo_bar}} {{tool-guidelines}}"); await writeSuiteConfig(suiteDir, { templateFile }); const pi = createExtensionApiFake(); const context = createContextFake(); systemPrompt(pi); await getRegisteredHandler(pi, "session_start")( { type: "session_start", reason: "startup" }, context.ctx, ); expect(context.notifications).toEqual([ { message: "[system-prompt] removed unsupported template variable(s): foo_bar, tool-guidelines", type: "warning", }, ]); expect( await runBeforeAgentStartHandlers( pi, createBeforeAgentStartEvent(), context.ctx, ), ).not.toContain("{{"); }); }); test("renders eligible deferred toolsets in runtime order with escaped attributes", async () => { // Purpose: {{toolsets}} must expose only runtime-eligible deferred toolsets in its required XML form. // Input and expected output: two eligible toolsets include Unicode and every XML-sensitive attribute character. // Edge case: runtime order and literal Unicode survive while XML delimiters are escaped. // Dependencies: shared runtime/composition and isolated system-prompt lifecycle fakes. await withIsolatedAgentDir(async ({ suiteDir }) => { const templateFile = join(suiteDir, "template.md"); await writeFile(templateFile, "{{toolsets}}"); await writeSuiteConfig(suiteDir, { templateFile }); const pi = createExtensionApiFake(["alpha_tool", "beta_tool"]); systemPrompt(pi); const runtime = getToolsetRuntime(pi); runtime.replaceProvider("mcp", [ { providerId: "mcp", name: `alpha & <β> "'`, description: `first > & < "' 工具`, toolNames: ["alpha_tool"], activate: async () => ["alpha_tool"], }, { providerId: "mcp", name: "second", description: "second suite", toolNames: ["beta_tool"], activate: async () => ["beta_tool"], }, ]); const context = createContextFake(); await getRegisteredHandler(pi, "session_start")( { type: "session_start", reason: "startup" }, context.ctx, ); expect( await runBeforeAgentStartHandlers( pi, createBeforeAgentStartEvent(), context.ctx, ), ).toBe( '\n\n\n', ); }); }); test("renders model-invocable skills as escaped skill entries only", async () => { // Purpose: {{skills}} must expose only skill data so that the template owns all skill rules and wrappers. // Input and expected output: two visible skills render as entries in runtime order, with no text from pi. // Edge case: a skill with disableModelInvocation is omitted, and XML-sensitive characters are escaped. // Dependencies: this test uses isolated temp config/template files and in-memory pi lifecycle fakes. await withIsolatedAgentDir(async ({ suiteDir }) => { const templateFile = join(suiteDir, "template.md"); await writeFile(templateFile, "{{skills}}"); await writeSuiteConfig(suiteDir, { templateFile }); const pi = createExtensionApiFake(["read"]); const context = createContextFake(); const createSkill = ( name: string, description: string, disableModelInvocation: boolean, ) => ({ name, description, filePath: `/skills/${name}/SKILL.md`, baseDir: `/skills/${name}`, sourceInfo: { path: `/skills/${name}/SKILL.md`, source: "test", scope: "temporary" as const, origin: "top-level" as const, }, disableModelInvocation, }); systemPrompt(pi); await getRegisteredHandler(pi, "session_start")( { type: "session_start", reason: "startup" }, context.ctx, ); expect( await runBeforeAgentStartHandlers( pi, createBeforeAgentStartEvent({ selectedTools: ["read"], skills: [ createSkill("alpha", `first & <β> "'`, false), createSkill("hidden", "manual only", true), createSkill("beta", "second", false), ], }), context.ctx, ), ).toBe( [ "", " ", " alpha", " first & <β> "'", " /skills/alpha/SKILL.md", " ", " ", " beta", " second", " /skills/beta/SKILL.md", " ", "", ].join("\n"), ); }); }); test("preserves catalog rendering while activation availability follows branch state", async () => { // Purpose: prompt discovery must follow the runtime's active branch state. // Input and expected output: one eligible toolset activates, then an empty history branch restores it. // Edge case: activate_toolset is removed with the final trigger and restored on rewind. // Dependencies: shared runtime history handler, composition, and isolated system-prompt lifecycle fakes. await withIsolatedAgentDir(async ({ suiteDir }) => { const templateFile = join(suiteDir, "template.md"); await writeFile(templateFile, "{{toolsets}}"); await writeSuiteConfig(suiteDir, { templateFile }); const pi = createExtensionApiFake(["files_read"]); systemPrompt(pi); const runtime = getToolsetRuntime(pi); runtime.replaceProvider("mcp", [ { providerId: "mcp", name: "files", description: "Read files", toolNames: ["files_read"], activate: async () => ["files_read"], }, ]); const context = createContextFake(); await getRegisteredHandler(pi, "session_start")( { type: "session_start", reason: "startup" }, context.ctx, ); const initial = await runBeforeAgentStartHandlers( pi, createBeforeAgentStartEvent(), context.ctx, ); expect(pi.getActiveTools()).toContain("activate_toolset"); await runtime.activate("files"); expect( await runBeforeAgentStartHandlers( pi, createBeforeAgentStartEvent(), context.ctx, ), ).toBe(initial); expect(pi.getActiveTools()).not.toContain("activate_toolset"); await getRegisteredHandler(pi, "session_tree")( { type: "session_tree" }, { hasUI: false, sessionManager: { getBranch: () => [] }, }, ); expect( await runBeforeAgentStartHandlers( pi, createBeforeAgentStartEvent(), context.ctx, ), ).toBe(initial); expect(pi.getActiveTools()).toContain("activate_toolset"); }); }); test("inserts dynamic toolsets between fixture-defined surrounding text", async () => { // Purpose: the renderer must insert dynamic toolsets at the fixture-defined position. // Input and expected output: a synthetic template surrounds one runtime toolset with fixed text. // Edge case: the renderer must preserve fixture text before and after the insertion. // Dependencies: isolated template/config helpers and the shared runtime lifecycle fake. await withIsolatedAgentDir(async ({ suiteDir }) => { const templateFile = join(suiteDir, "template.md"); await writeFile( templateFile, "Before catalog\n{{toolsets}}\nAfter catalog", ); await writeSuiteConfig(suiteDir, { templateFile }); const pi = createExtensionApiFake(["files_read"]); systemPrompt(pi); getToolsetRuntime(pi).replaceProvider("mcp", [ { providerId: "mcp", name: "files", description: "Read files", toolNames: ["files_read"], activate: async () => ["files_read"], }, ]); const context = createContextFake(); await getRegisteredHandler(pi, "session_start")( { type: "session_start", reason: "startup" }, context.ctx, ); const prompt = await runBeforeAgentStartHandlers( pi, createBeforeAgentStartEvent(), context.ctx, ); expect(prompt).toBe( 'Before catalog\n\n\n\nAfter catalog', ); }); }); test("does not append dynamic values whose variables are absent", async () => { // Purpose: absent variables must not recreate pi's automatic prompt sections behind the template author's back. // Input and expected output: a static-only template replaces the base prompt without tools, context, skills, append text, date, or cwd. // Edge case: every runtime source is populated but no variable references it. // Dependencies: this test uses isolated temp config/template files and in-memory pi lifecycle fakes. await withIsolatedAgentDir(async ({ suiteDir }) => { const templateFile = join(suiteDir, "template.md"); await writeFile(templateFile, "Static prompt only"); await writeSuiteConfig(suiteDir, { templateFile }); const pi = createExtensionApiFake(); const context = createContextFake(); systemPrompt(pi); await getRegisteredHandler(pi, "session_start")( { type: "session_start", reason: "startup" }, context.ctx, ); expect( await runBeforeAgentStartHandlers( pi, createBeforeAgentStartEvent(), context.ctx, ), ).toBe("Static prompt only"); expect(context.notifications).toEqual([]); }); }); test("keeps the original prompt when disabled, invalid, or unreadable", async () => { // Purpose: config/template failures must not replace pi's original system prompt with partial output. // Input and expected output: disabled config returns the original prompt silently; invalid or missing template returns it with a warning. // Edge case: relative templateFile is rejected even though it could be resolved from the config directory. // Dependencies: this test uses isolated suite config files and in-memory pi lifecycle fakes. await withIsolatedAgentDir(async ({ suiteDir }) => { const disabledPi = createExtensionApiFake(); const disabledContext = createContextFake(); await writeSuiteConfig(suiteDir, { enabled: false }); systemPrompt(disabledPi); await getRegisteredHandler(disabledPi, "session_start")( { type: "session_start", reason: "startup" }, disabledContext.ctx, ); expect( await runBeforeAgentStartHandlers( disabledPi, createBeforeAgentStartEvent(), disabledContext.ctx, ), ).toBe("Original pi prompt"); expect(disabledContext.notifications).toEqual([]); const relativePi = createExtensionApiFake(); const relativeContext = createContextFake(); expect(isAbsolute("relative.md")).toBe(false); await writeSuiteConfig(suiteDir, { templateFile: "relative.md" }); systemPrompt(relativePi); await getRegisteredHandler(relativePi, "session_start")( { type: "session_start", reason: "startup" }, relativeContext.ctx, ); expect( await runBeforeAgentStartHandlers( relativePi, createBeforeAgentStartEvent(), relativeContext.ctx, ), ).toBe("Original pi prompt"); expect(relativeContext.notifications).toEqual([ { message: "[system-prompt] templateFile must be an absolute path", type: "warning", }, ]); const missingPi = createExtensionApiFake(); const missingContext = createContextFake(); await writeSuiteConfig(suiteDir, { templateFile: join(suiteDir, "missing.md"), }); systemPrompt(missingPi); await getRegisteredHandler(missingPi, "session_start")( { type: "session_start", reason: "startup" }, missingContext.ctx, ); expect( await runBeforeAgentStartHandlers( missingPi, createBeforeAgentStartEvent(), missingContext.ctx, ), ).toBe("Original pi prompt"); expect(missingContext.notifications).toHaveLength(1); expect(missingContext.notifications[0]?.type).toBe("warning"); expect( missingContext.notifications[0]?.message.startsWith( "[system-prompt] failed to read template:", ), ).toBe(true); }); }); test("ignores legacy config and publishes the bundled default template", async () => { // Purpose: missing suite config must enable the bundled template independently of legacy config. // Input and expected output: an invalid legacy template is ignored and the default rendering is published as a native section. // Edge case: default template wording and placeholders can change without changing config precedence. // Dependencies: this test uses isolated agent/suite directories and in-memory pi lifecycle fakes. await withIsolatedAgentDir(async ({ agentDir, suiteDir }) => { const legacyTemplateFile = join(agentDir, "legacy-template.md"); await writeFile(legacyTemplateFile, "{{#legacyOnly}}"); await writeLegacyConfig(agentDir, { templateFile: legacyTemplateFile }); const pi = createExtensionApiFake(); const context = createContextFake(); systemPrompt(pi); await getRegisteredHandler(pi, "session_start")( { type: "session_start", reason: "startup" }, context.ctx, ); const event = createBeforeAgentStartEvent({ cwd: suiteDir }); await runBeforeAgentStartHandlers(pi, event, context.ctx); expect( Object.keys(event.systemPromptOptions.sections ?? {}), ).toHaveLength(1); expect(context.notifications).toEqual([]); }); }); test("preserves later runtime prompt contributions in chained handler order", async () => { // Purpose: system-prompt may replace only the base prompt layer, not later package-owned runtime contributions. // Input and expected output: a later before_agent_start handler appends agent guidance after the template is rendered. // Edge case: the template handler must run before the contribution handler in registration order. // Dependencies: this test uses in-memory lifecycle fakes and a temp Markdown template. await withIsolatedAgentDir(async ({ suiteDir }) => { const templateFile = join(suiteDir, "template.md"); await writeFile(templateFile, "Template base"); await writeSuiteConfig(suiteDir, { templateFile }); const pi = createExtensionApiFake(); const context = createContextFake(); systemPrompt(pi); pi.on("before_agent_start", (event: unknown) => { const currentPrompt = (event as { readonly systemPrompt: string }) .systemPrompt; return { systemPrompt: `${currentPrompt}\n\nAgent contribution` }; }); await getRegisteredHandler(pi, "session_start")( { type: "session_start", reason: "startup" }, context.ctx, ); expect( await runBeforeAgentStartHandlers( pi, createBeforeAgentStartEvent(), context.ctx, ), ).toBe("Template base\n\nAgent contribution"); }); }); });