diff --git a/packages/pi-plugin/src/index-in-process-latch.test.ts b/packages/pi-plugin/src/index-in-process-latch.test.ts index 40ae93765..c6b532ec1 100644 --- a/packages/pi-plugin/src/index-in-process-latch.test.ts +++ b/packages/pi-plugin/src/index-in-process-latch.test.ts @@ -151,9 +151,11 @@ function createCountingPi() { for (const handler of eventBusHandlers.get(channel) ?? []) handler(data); }, async emitPiEvent(event: string, data: unknown = {}, ctx: unknown = {}) { + const results: unknown[] = []; for (const handler of piEventHandlers.get(event) ?? []) { - await handler(data, ctx); + results.push(await handler(data, ctx)); } + return results; }, }; } @@ -965,6 +967,90 @@ describe("Pi in-process child guard (#247)", () => { }, 15_000); }); +describe("Pi system-prompt section injection (#649)", () => { + it("injects the guidance block as a structured section that composes with later extensions", async () => { + isolateXdgEnv(); + delete process.env[MAGIC_CONTEXT_PI_SUBAGENT_ENV]; + const runtime = createCountingPi(); + await magicContextPiExtension(runtime.pi); + + // Register a second extension's handler AFTER magic-context, mirroring + // Pi's in-order before_agent_start dispatch: a later extension writing + // its own section into the same shared, mutable options map. + runtime.pi.on("before_agent_start", (event) => { + event.systemPromptOptions.sections.later_ext = + "snapshot"; + }); + + const ctx = { + cwd: process.cwd(), + hasUI: false, + sessionManager: { getSessionId: () => "ses-section-injection" }, + ui: { setStatus() {} }, + }; + await runtime.emitPiEvent("session_start", {}, ctx); + + // Mirror Pi's BeforeAgentStartEvent: systemPromptOptions.sections is + // the normalized, mutable map every extension's handler writes into. + const event = { + type: "before_agent_start", + prompt: "hello", + systemPrompt: "BASE PROMPT", + systemPromptOptions: { sections: {} as Record }, + }; + await runtime.emitPiEvent("before_agent_start", event, ctx); + + // The guidance block must land in the shared sections map — not in a + // forced prompt, which Pi would render INSTEAD of the sections and + // thereby hide the later extension's section from the provider. + expect(event.systemPromptOptions.sections.magic_context).toContain( + "## Magic Context", + ); + expect(event.systemPromptOptions.sections.later_ext).toBe( + "snapshot", + ); + + await runtime.emitPiEvent("session_shutdown", {}, ctx); + }, 20_000); + + it("keeps the forced-prompt fallback on hosts without a sections API (Oh My Pi)", async () => { + isolateXdgEnv(); + delete process.env[MAGIC_CONTEXT_PI_SUBAGENT_ENV]; + const runtime = createCountingPi(); + await magicContextPiExtension(runtime.pi); + const ctx = { + cwd: process.cwd(), + hasUI: false, + sessionManager: { getSessionId: () => "ses-omp-fallback" }, + ui: { setStatus() {} }, + }; + await runtime.emitPiEvent("session_start", {}, ctx); + + // Oh My Pi's before_agent_start event has no systemPromptOptions + // (verified on Oh My Pi 18.2.6 and 18.8.7): the handler must not throw + // and must keep injecting via a forced prompt return, or requests + // would go out with no guidance block at all. + const event = { + type: "before_agent_start", + prompt: "hello", + systemPrompt: "BASE PROMPT", + }; + const results = await runtime.emitPiEvent( + "before_agent_start", + event, + ctx, + ); + const forced = results.find( + (r): r is { systemPrompt: string } => + typeof r === "object" && r !== null && "systemPrompt" in r, + ); + expect(forced?.systemPrompt).toContain("BASE PROMPT"); + expect(forced?.systemPrompt).toContain("## Magic Context"); + + await runtime.emitPiEvent("session_shutdown", {}, ctx); + }, 20_000); +}); + beforeEach(async () => { await observeProducerModelsForTest(["test/historian"]); }); diff --git a/packages/pi-plugin/src/index.ts b/packages/pi-plugin/src/index.ts index 96ab7dbcd..7d266fb82 100644 --- a/packages/pi-plugin/src/index.ts +++ b/packages/pi-plugin/src/index.ts @@ -2428,21 +2428,45 @@ async function startPiMagicContextRuntime( existingSystemPrompt: event.systemPrompt, }); - // Compose the final system prompt: base prompt from Pi + our - // magic-context block. We always run hash detection on the - // composed string so even sessions with no data block (e.g. - // memories disabled, no docs, no key files) still get - // sticky-date freezing and hash-change tracking. + // Compose the reference system prompt (base prompt from Pi + + // our magic-context block) for hash detection. We always run + // hash detection on the composed string so even sessions with + // no data block (e.g. memories disabled, no docs, no key + // files) still get hash-change tracking. const composedPrompt = composeMagicContextSystemPrompt( event.systemPrompt, block, ); + // Inject through the host's structured sections API when it has + // one. On Pi, returning `systemPrompt` from before_agent_start + // makes the host replace the WHOLE prompt with opaque text: + // sections set by later-registered extensions are then still + // recorded in the transcript but never sent to the provider + // (Pi renders forceSystemPrompt instead of the structured + // sections), so any extension registered after us silently + // loses its system-prompt section. Oh My Pi's before_agent_start + // event has no systemPromptOptions at all, and Pi releases + // before 0.87 have no `sections` field, so the forced prompt + // return below remains the only injection path on those hosts. + // The plugin compiles against Pi 0.83 types, which predate + // `sections`; the widened type below describes newer hosts + // without claiming the field always exists. + const promptOptions: + | (NonNullable & { + sections?: Record; + }) + | undefined = event.systemPromptOptions; + const hostSections = promptOptions?.sections; + if (block && hostSections) { + hostSections.magic_context = block; + } + if (!sessionId) { - // No session id yet — return the composed prompt without - // cache logic. The next turn (with a session id) will - // compute the first hash and set sticky date. - if (block) return { systemPrompt: composedPrompt }; + // No session id yet — inject without cache logic. The + // next turn (with a session id) will compute the first + // hash and set sticky date. + if (block && !hostSections) return { systemPrompt: composedPrompt }; return; } @@ -2475,6 +2499,16 @@ async function startPiMagicContextRuntime( clearSystemPromptRefresh(sessionId); } + // On hosts with a sections API (Pi) the block already went + // out as a structured section above; returning a forced + // prompt here would hide later extensions' sections from the + // provider. Only hosts without that API + // (Oh My Pi) still need the forced return. Pi's prompt has + // no `Today's date:` line for processSystemPromptForCache + // to freeze, so dropping the force there loses nothing. + if (hostSections) { + return; + } return { systemPrompt: result.systemPrompt }; } catch (error) { warn("failed to build magic-context block:", error); diff --git a/packages/pi-plugin/src/signal-peek-drain.test.ts b/packages/pi-plugin/src/signal-peek-drain.test.ts index 278205234..d91e29967 100644 --- a/packages/pi-plugin/src/signal-peek-drain.test.ts +++ b/packages/pi-plugin/src/signal-peek-drain.test.ts @@ -298,6 +298,32 @@ describe("source contract: peek-then-drain in before_agent_start (system prompt) expect(window).toMatch(/if\s*\(\s*isCacheBusting\s*\)/); }); + test("system-prompt injection prefers sections, with a guarded forced fallback for hosts without them", () => { + // Pi exposes systemPromptOptions.sections; Oh My Pi's + // before_agent_start event has no systemPromptOptions at all + // (verified on Oh My Pi 18.2.6 and 18.8.7). Injection must prefer the + // shared sections map — an unconditional forced return hides later + // extensions' sections on Pi — and fall back to a forced prompt + // only when the host has no sections API, because an unconditional + // sections assignment throws on Oh My Pi and the handler's catch + // would then drop our guidance block entirely. + expect(code).toContain("= event.systemPromptOptions;"); + expect(code).toContain("const hostSections = promptOptions?.sections;"); + expect(code).toContain("hostSections.magic_context = block"); + // Exactly two forced fallbacks exist, each on its explicit + // no-sections guard: an unguarded forced return adds a third match + // or breaks the guard patterns (hiding later Pi sections), and + // deleting a fallback drops the count (breaking Oh My Pi). + const forcedReturns = [...code.matchAll(/return\s*\{\s*systemPrompt\b/g)]; + expect(forcedReturns).toHaveLength(2); + expect(code).toMatch( + /if\s*\(\s*block\s*&&\s*!hostSections\s*\)\s*return\s*\{\s*systemPrompt\b/, + ); + expect(code).toMatch( + /if\s*\(\s*hostSections\s*\)\s*\{\s*return;?\s*\}\s*return\s*\{\s*systemPrompt\b/, + ); + }); + test("system-prompt injection supports global disable, skip signatures, and existing prompt dedup", () => { // Council #4 (project-config bleed on /cd): these decisions use // effectiveConfig — the config re-resolved from the CURRENT checkout's