From 36b8d33cfecf16b7350037f6ddc4dc659d962efd Mon Sep 17 00:00:00 2001 From: Ston Jia Date: Sat, 10 Oct 2026 11:15:41 +0800 Subject: [PATCH 1/2] fix(pi-plugin): inject system prompt via sections API when the host has one On Pi, returning { systemPrompt } from before_agent_start makes the host treat the whole prompt as forced opaque text: sections set by extensions registered AFTER magic-context are still recorded in the transcript but never sent to the provider, so any later extension silently loses its system-prompt section (issue #649). Reproduced with a memory-index extension whose snapshot section reached the transcript but never the provider request whenever it loaded after magic-context in Pi's packages list; moving it earlier in the list restored it. Inject the guidance block through systemPromptOptions.sections instead - the API Pi's docs explicitly recommend. Oh My Pi's before_agent_start event has no systemPromptOptions at all (maintainer host matrix in #649), so the injection is capability-detected: hosts with a sections API get a composable section, hosts without one (Oh My Pi) keep the forced prompt return, which is their only injection path. On Pi this costs nothing for cache stability: the block is byte-stable within a session, so Pi's section diffing leaves the provider-visible prefix identical on unchanged turns, and Pi's prompt has no 'Today''s date:' line for processSystemPromptForCache to freeze, so the forced return was never rewriting anything there. Hash detection and the refresh signals are unchanged. Tests: a source contract pins sections-first injection and requires every forced return to be guarded by the no-sections branch; runtime tests dispatch before_agent_start through the real extension on both host shapes - with a sections API (asserting a later-registered extension's section coexists with ours) and without (asserting the forced fallback still carries the block). --- .../src/index-in-process-latch.test.ts | 88 ++++++++++++++++++- packages/pi-plugin/src/index.ts | 44 ++++++++-- .../pi-plugin/src/signal-peek-drain.test.ts | 25 ++++++ 3 files changed, 147 insertions(+), 10 deletions(-) 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..24a22a2eb 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 + // (issue #649, maintainer host matrix): 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..6b367ec0b 100644 --- a/packages/pi-plugin/src/index.ts +++ b/packages/pi-plugin/src/index.ts @@ -2428,21 +2428,37 @@ 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 (issue #649). Oh My Pi's + // before_agent_start event has no systemPromptOptions at all + // (host matrix in #649), so the forced prompt return below + // remains the only injection path there. + const hostSections = event.systemPromptOptions?.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 +2491,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 (issue #649). 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..3b0415e19 100644 --- a/packages/pi-plugin/src/signal-peek-drain.test.ts +++ b/packages/pi-plugin/src/signal-peek-drain.test.ts @@ -298,6 +298,31 @@ 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 + // (issue #649, maintainer host matrix). 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?.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 From c103c31765160f4f1b54f677a2e24e7aba275fda Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sat, 10 Oct 2026 10:53:43 +0200 Subject: [PATCH 2/2] Type the Pi sections field for hosts newer than the compiled Pi types, and drop issue references from comments The plugin compiles against Pi 0.83 types, which predate systemPromptOptions.sections, so the PR failed typecheck. A locally widened type describes newer hosts without claiming the field always exists. Comments now state the verified host behaviour directly. Co-authored-by: Alfonso --- .../src/index-in-process-latch.test.ts | 2 +- packages/pi-plugin/src/index.ts | 20 +++++++++++++------ .../pi-plugin/src/signal-peek-drain.test.ts | 5 +++-- 3 files changed, 18 insertions(+), 9 deletions(-) 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 24a22a2eb..c6b532ec1 100644 --- a/packages/pi-plugin/src/index-in-process-latch.test.ts +++ b/packages/pi-plugin/src/index-in-process-latch.test.ts @@ -1027,7 +1027,7 @@ describe("Pi system-prompt section injection (#649)", () => { await runtime.emitPiEvent("session_start", {}, ctx); // Oh My Pi's before_agent_start event has no systemPromptOptions - // (issue #649, maintainer host matrix): the handler must not throw + // (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 = { diff --git a/packages/pi-plugin/src/index.ts b/packages/pi-plugin/src/index.ts index 6b367ec0b..7d266fb82 100644 --- a/packages/pi-plugin/src/index.ts +++ b/packages/pi-plugin/src/index.ts @@ -2445,11 +2445,19 @@ async function startPiMagicContextRuntime( // 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 (issue #649). Oh My Pi's - // before_agent_start event has no systemPromptOptions at all - // (host matrix in #649), so the forced prompt return below - // remains the only injection path there. - const hostSections = event.systemPromptOptions?.sections; + // 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; } @@ -2494,7 +2502,7 @@ async function startPiMagicContextRuntime( // 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 (issue #649). Only hosts without that API + // 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. diff --git a/packages/pi-plugin/src/signal-peek-drain.test.ts b/packages/pi-plugin/src/signal-peek-drain.test.ts index 3b0415e19..d91e29967 100644 --- a/packages/pi-plugin/src/signal-peek-drain.test.ts +++ b/packages/pi-plugin/src/signal-peek-drain.test.ts @@ -301,13 +301,14 @@ describe("source contract: peek-then-drain in before_agent_start (system prompt) 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 - // (issue #649, maintainer host matrix). Injection must prefer the + // (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?.sections"); + 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