diff --git a/src/adapters/pi/extension.ts b/src/adapters/pi/extension.ts index bd5d4d98..0df36a72 100644 --- a/src/adapters/pi/extension.ts +++ b/src/adapters/pi/extension.ts @@ -160,10 +160,6 @@ let _buildAutoInjection: | null | undefined = undefined; -// Pending context to inject via the 'context' hook (avoiding systemPrompt mutation -// which breaks prefix prompt cache on DeepSeek/Anthropic/OpenAI). -// See: https://github.com/mksglu/context-mode/issues/598 -let _pendingContext = ""; async function getAutoInjection( pluginRoot: string, ): Promise<((events: Array<{ category: string; data: string }>) => string) | null> { @@ -606,7 +602,6 @@ export default function piExtension(pi: any): void { pi.on("before_agent_start", async (event: any, ctx: any) => { try { - _pendingContext = ""; // Reset — will be filled below if events exist // Lazily start and await the MCP bridge only when Pi is about to // dispatch a real agent turn. This is the non-brittle #534/#809 guard: // help/version/package/config CLI paths may load the extension, but they @@ -715,43 +710,15 @@ export default function piExtension(pi: any): void { db.markResumeConsumed(_sessionId); } - // Store extra context (routing anchor, active_memory, resume, behavioralDirective) - // for injection via the 'context' hook as a message, NOT as a systemPrompt - // modification. Mutating systemPrompt breaks prefix prompt caching on - // DeepSeek/Anthropic/OpenAI because the system message sits at messages[0] - // and any change invalidates the entire cache chain. - const baseLen = existingPrompt ? 1 : 0; - if (parts.length > baseLen) { - const extraParts = parts.slice(baseLen); - _pendingContext = extraParts.join("\n\n"); - } else { - _pendingContext = ""; - } + // Keep runtime instructions at Pi's host-owned model boundary. Injecting + // them as user/custom messages makes automation appear as user-authored + // chat in API consumers even when the TUI honors display: false. + return { systemPrompt: parts.join("\n\n") }; } catch { - _pendingContext = ""; // Reset — ensure no stale data escapes // best effort — never break agent start } }); - // ── 4a2. context — Inject active_memory + resume + behavioralDirective as message ── - // Uses the 'context' hook (like hindsight does) to append context at the END of - // messages rather than mutating systemPrompt at the beginning. This preserves - // prefix prompt cache for DeepSeek, Anthropic, and OpenAI. - pi.on("context", (event: any) => { - try { - if (!_pendingContext) return; - const ctx = _pendingContext; - _pendingContext = ""; - event.messages.push({ - role: "user", - content: ctx, - }); - return { messages: event.messages }; - } catch { - // best effort — never break context assembly - } - }); - // ── 4b. before_provider_response — capture response metadata ─── // Pi-2: Register the missing event so providers can record latency, // model, and token usage when Pi exposes them. Best-effort only; diff --git a/tests/pi-extension.test.ts b/tests/pi-extension.test.ts index 69f70bf4..611e10e2 100644 --- a/tests/pi-extension.test.ts +++ b/tests/pi-extension.test.ts @@ -593,15 +593,13 @@ describe("Pi Extension", () => { { sessionManager: { getSessionFile: () => sessionFile } }, // ctx (2nd arg) ); - // Verify the session was initialised with the file-derived ID by checking - // that before_agent_start doesn't blow up (it needs a valid _sessionId). - // In the new behavior, before_agent_start no longer returns systemPrompt; - // it stores context in _pendingContext for the context hook to inject. + // Verify the session was initialised with the file-derived ID and that + // routing instructions use Pi's model-only system boundary. const result = await api._trigger("before_agent_start", { systemPrompt: "Base.", }); - // before_agent_start may or may not return a value — the key is it doesn't throw - expect(result?.systemPrompt ?? null).toBe(null); // systemPrompt is no longer returned + expect(result?.systemPrompt).toContain("Base."); + expect(result?.systemPrompt).toContain("context-mode active"); }); it("handles session lifecycle in correct order", async () => { @@ -637,7 +635,7 @@ describe("Pi Extension", () => { // ═══════════════════════════════════════════════════════════ describe("Slice 5: Resume injection", () => { - it("delivers resume snapshot through context hook, not systemPrompt", async () => { + it("delivers resume snapshot through the model-only system prompt", async () => { await registerPiExtension(api); // Build up session state: capture events → compact → build resume @@ -662,28 +660,16 @@ describe("Pi Extension", () => { await api._trigger("session_before_compact", {}); await api._trigger("session_compact", {}); - // before_agent_start should prepare context without mutating systemPrompt + // before_agent_start should deliver the resume at the model-only system boundary. const result = await api._trigger("before_agent_start", { systemPrompt: "You are a helpful assistant.", }); - expect(result?.systemPrompt ?? null).toBe(null); - // The context hook should deliver the resume as a trailing user message. - const messages = [{ role: "system", content: "You are a helpful assistant." }]; - const ctxResult = await api._trigger("context", { messages }); - - expect(ctxResult?.messages).toBe(messages); - expect(messages).toHaveLength(2); - expect(messages[0]).toEqual({ - role: "system", - content: "You are a helpful assistant.", - }); - expect(messages[1].role).toBe("user"); - expect(String(messages[1].content)).toContain("session_resume"); - expect(String(messages[1].content)).not.toContain("You are a helpful assistant."); + expect(result?.systemPrompt).toContain("You are a helpful assistant."); + expect(result?.systemPrompt).toContain("session_resume"); }); - it("appends resume and active context after existing messages", async () => { + it("appends resume and active context after the existing system prompt", async () => { await registerPiExtension(api); await api._trigger("session_start", { @@ -710,25 +696,11 @@ describe("Pi Extension", () => { systemPrompt: "Stable system prompt.", prompt: "Continue with the refactor and avoid lodash.", }); - expect(result?.systemPrompt ?? null).toBe(null); - - const messages = [ - { role: "system", content: "Stable system prompt." }, - { role: "user", content: "Continue with the refactor." }, - ]; - const ctxResult = await api._trigger("context", { messages }); - - expect(ctxResult?.messages).toBe(messages); - expect(messages).toHaveLength(3); - expect(messages[0]).toEqual({ role: "system", content: "Stable system prompt." }); - expect(messages[1]).toEqual({ role: "user", content: "Continue with the refactor." }); - expect(messages[2].role).toBe("user"); - - const trailing = String(messages[2].content); - expect(trailing).toContain("context-mode active"); - expect(trailing).toContain("session_resume"); - expect(trailing).toContain("how_to_search"); - expect(trailing).not.toContain("Stable system prompt."); + + expect(result?.systemPrompt).toContain("Stable system prompt."); + expect(result?.systemPrompt).toContain("context-mode active"); + expect(result?.systemPrompt).toContain("session_resume"); + expect(result?.systemPrompt).toContain("how_to_search"); }); it("returns nothing when no resume exists", async () => { @@ -901,29 +873,23 @@ describe("Pi Extension", () => { // ═══════════════════════════════════════════════════════════ describe("Slice 7: Routing block injection", () => { - it("injects lightweight routing anchor via context hook on first before_agent_start", async () => { + it("injects lightweight routing anchor at the model-only system boundary", async () => { await registerPiExtension(api); await api._trigger("session_start", {}, { sessionManager: { getSessionFile: () => `routing-1-${Date.now()}-${Math.random()}` }, }); - // before_agent_start sets _pendingContext (was previously modifying systemPrompt) - await api._trigger("before_agent_start", { + const result = await api._trigger("before_agent_start", { systemPrompt: "Base prompt.", }); - // context hook now injects the routing anchor as a user message at message end - const messages: any[] = []; - const ctxResult = await api._trigger("context", { messages }); - - expect(ctxResult?.messages).toBeDefined(); - expect(ctxResult.messages.length).toBe(1); - expect(ctxResult.messages[0].role).toBe("user"); - expect(ctxResult.messages[0].content).toContain("context-mode active"); - expect(ctxResult.messages[0].content).toContain("ctx_batch_execute > ctx_execute > ctx_execute_file"); + expect(result?.systemPrompt).toContain("Base prompt."); + expect(result?.systemPrompt).toContain("context-mode active"); + expect(result?.systemPrompt).toContain("ctx_batch_execute > ctx_execute > ctx_execute_file"); + expect(api._handlers.context).toBeUndefined(); }); - it("re-injects the anchor via context hook on every subsequent call", async () => { + it("re-injects the anchor on every subsequent call", async () => { await registerPiExtension(api); await api._trigger("session_start", {}, { sessionManager: { getSessionFile: () => `routing-2-${Date.now()}-${Math.random()}` }, @@ -935,10 +901,10 @@ describe("Pi Extension", () => { const ANCHOR = "context-mode active"; for (let call = 0; call < 3; call++) { - await api._trigger("before_agent_start", { systemPrompt: "Base." }); - const ctxResult = await api._trigger("context", { messages: [] }); - expect(ctxResult?.messages).toBeDefined(); - expect(ctxResult.messages[0]?.content).toContain(ANCHOR); + const result = await api._trigger("before_agent_start", { + systemPrompt: "Base.", + }); + expect(result?.systemPrompt).toContain(ANCHOR); } }); }); @@ -984,7 +950,7 @@ describe("Pi Extension", () => { // ═══════════════════════════════════════════════════════════ describe("Slice 9: active_memory injection", () => { - it("injects context every turn via context hook even when compact_count is 0", async () => { + it("injects context every turn even when compact_count is 0", async () => { await registerPiExtension(api); await api._trigger("session_start", { sessionManager: { getSessionFile: () => `active-mem-1-${Date.now()}-${Math.random()}` }, @@ -997,25 +963,19 @@ describe("Pi Extension", () => { }); // Second call rebuilds context (always-on, not just post-compaction). - await api._trigger("before_agent_start", { + const result = await api._trigger("before_agent_start", { systemPrompt: "Base 2.", }); - // context hook injects the pending context as a user message - const ctxResult = await api._trigger("context", { messages: [] }); - - expect(ctxResult?.messages).toBeDefined(); - expect(ctxResult.messages.length).toBe(1); - expect(ctxResult.messages[0].role).toBe("user"); // The always-on injection path fires every turn — the routing anchor // proves context reaches the model even with compact_count 0. - const content = String(ctxResult.messages[0].content); + const content = String(result?.systemPrompt ?? ""); expect(content).toContain("context-mode active"); // Issue #856 — the role MUST NOT be pinned as a standing directive. expect(content).not.toContain(""); }); - it("caps active_memory at ≤ 2000 characters (via context hook)", async () => { + it("caps active_memory at ≤ 2000 characters", async () => { await registerPiExtension(api); await api._trigger("session_start", { sessionManager: { getSessionFile: () => `active-mem-2-${Date.now()}-${Math.random()}` }, @@ -1030,12 +990,11 @@ describe("Pi Extension", () => { }); } - await api._trigger("before_agent_start", { + const result = await api._trigger("before_agent_start", { systemPrompt: "Base final.", }); - const ctxResult = await api._trigger("context", { messages: [] }); - const content = String(ctxResult?.messages?.[0]?.content ?? ""); + const content = String(result?.systemPrompt ?? ""); // Issue #856 — flooding with role prompts must NOT accumulate any // behavioral_directive, and the per-turn injection must stay bounded // (it is now just the routing anchor; roles are filtered out entirely). @@ -1068,9 +1027,10 @@ describe("Pi Extension", () => { }); // Subsequent turn rebuilds context. - await api._trigger("before_agent_start", { systemPrompt: "Base 2." }); - const ctxResult = await api._trigger("context", { messages: [] }); - const content = String(ctxResult?.messages?.[0]?.content ?? ""); + const result = await api._trigger("before_agent_start", { + systemPrompt: "Base 2.", + }); + const content = String(result?.systemPrompt ?? ""); // The stale role must NOT be pinned as a standing behavioral_directive. expect(content).not.toContain(""); @@ -1087,9 +1047,10 @@ describe("Pi Extension", () => { prompt: "You are a senior staff engineer reviewing this codebase.", systemPrompt: "Base.", }); - await api._trigger("before_agent_start", { systemPrompt: "Base 2." }); - const ctxResult = await api._trigger("context", { messages: [] }); - const content = String(ctxResult?.messages?.[0]?.content ?? ""); + const result = await api._trigger("before_agent_start", { + systemPrompt: "Base 2.", + }); + const content = String(result?.systemPrompt ?? ""); // Role filtered out… expect(content).not.toContain("");