diff --git a/README.md b/README.md index 4e49a0e..276f9a6 100644 --- a/README.md +++ b/README.md @@ -83,8 +83,15 @@ pi -e . 3. Work. Ready is the only review action that starts work. The agent ticks subtasks, appends to `## Log` and `## Learnings`, fills `evidence:`, and calls `CompleteGoal` when a discriminator is satisfied. Every human reply in plan mode is saved verbatim under `## Interview`. - After eight turns without a change above `## Log`, the working set is sent back with a short upkeep - reminder. + After eight turns without a change above `## Log`, the next natural prompt includes a saved + extension message with the working set and a short upkeep reminder. + +Plan reminders are saved in session history before they reach the model, not appended only to an +outgoing request. In working mode, after startup or compaction, the next natural prompt refreshes +the whole plan, including its appendix, from disk. In planning mode, it refreshes the planning-policy +snapshot and plan path instead. Automatic compaction and tool-loop continuations do not start +an extra turn for a reminder: the refresh waits until the next `before_agent_start` (normally the +next user prompt). There is no fresh plan reminder during that ongoing automatic continuation. ## Plan supervisor and auto-continue diff --git a/src/index.ts b/src/index.ts index f769e68..bd48790 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,9 +11,9 @@ * The v1 lesson: the parser existed so TypeScript could read the plan, but almost every reader is a * model. So v2 has NO parser and no schema. The harness does exactly three things for a * cooperative-but-confused model: - * 1. memory — a transient re-send of the plan, never persisted, on two triggers: the plan went - * stale for STALE_TURNS turns (send the working set above ## Log), or the session - * started / compacted (send the whole file, appendix included). v2 sent the whole + * 1. memory — a saved extension message on two triggers: the plan went stale for STALE_TURNS + * turns (send the working set above ## Log), or the session started / compacted + * (send the whole file, appendix included). v2 sent the whole * file every turn; pi-tasks tried that and deleted it as "wallpaper noise that * trains the model to ignore the task block" (tintinweb/pi-tasks CHANGELOG.md:149), * and the always-present CompleteGoal description carries the contract instead. @@ -38,6 +38,7 @@ */ import { spawn } from "node:child_process"; +import { randomUUID } from "node:crypto"; import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { basename, join, resolve } from "node:path"; import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; @@ -63,6 +64,7 @@ const STATE = "pi-goals-state"; const STATUS_KEY = "pi-goals"; const WIDGET_KEY = "pi-goals-widget"; const PLANNING_CONTEXT = "pi-goals-planning-context"; +const PLAN_REMINDER = "pi-goals-plan-reminder"; const PLAN_DIR = ".pi/plan"; // For static text (the /goals description) where there is no ctx to resolve the session id. const PLAN_SHAPE = `${PLAN_DIR}/-vN.md`; @@ -180,9 +182,10 @@ export default function piGoalsExtension(pi: ExtensionAPI): void { let autoLastWorkingSet = ""; let autoImmediateUsed = false; let runStartedBackgroundWork = false; - // Set on session start and after a compaction; drained by the next LLM call, which then carries - // the WHOLE file (appendix included) instead of just the working set. + // Drained only after a saved reminder is observed. Auto-compaction skips before_agent_start: + // defer refresh until the next natural prompt rather than injecting unsaved context or a turn. let resyncReason: string | null = "New session."; + let pendingReminder: { id: string; planning: boolean; reason: string | null } | null = null; const planRel = (ctx: ExtensionContext) => (state.planVersion === null ? PLAN_SHAPE : `${PLAN_DIR}/${ctx.sessionManager.getSessionId()}-v${state.planVersion}.md`); const planPath = (ctx: ExtensionContext) => { @@ -490,6 +493,7 @@ export default function piGoalsExtension(pi: ExtensionAPI): void { }; planningContextPending = true; resyncReason = null; + pendingReminder = null; writePlan(ctx, ""); persist(); updateWidget(ctx); @@ -505,17 +509,11 @@ export default function piGoalsExtension(pi: ExtensionAPI): void { // --- hooks -------------------------------------------------------------------------------------- - /** What this LLM call should carry, if anything: a one-shot resync, or a staleness reminder. */ - function dueInjection(ctx: ExtensionContext, plan: string): string | null { - const drainResync = (): string | null => { - const why = resyncReason; - resyncReason = null; - return why; - }; + /** What the next saved reminder should carry: a one-shot resync, or a staleness reminder. */ + function dueReminder(ctx: ExtensionContext, plan: string): string | null { if (state.phase === "planning" || state.phase === "starting") return null; if (!plan.trim()) return null; - const why = drainResync(); - if (why) return resync(plan, planRel(ctx), why); + if (resyncReason) return resync(plan, planRel(ctx), resyncReason); if (turnsStale < STALE_TURNS) return null; const goals = scanGoals(plan); if (goals.length === 0) { @@ -527,28 +525,42 @@ export default function piGoalsExtension(pi: ExtensionAPI): void { return reminder(foldPlan(plan), planRel(ctx)); } - // The phase snapshot enters context only when planning starts or context was lost. + function confirmReminder(ctx: ExtensionContext): void { + if (!pendingReminder) return; + const id = pendingReminder.id; + const saved = ctx.sessionManager.getBranch().some(entry => entry.type === "custom_message" && (entry.details as { reminderId?: string } | undefined)?.reminderId === id); + if (!saved) return; + if (pendingReminder.planning) planningContextPending = false; + else { + if (resyncReason === pendingReminder.reason) resyncReason = null; + turnsStale = 0; + } + pendingReminder = null; + } + + // Only call at before_agent_start: Pi persists the returned message before model context. + // If an earlier delivery failed, retain the due state and retry with fresh file text. + function reminderMessage(ctx: ExtensionContext) { + confirmReminder(ctx); + const planning = state.phase === "planning" || state.phase === "starting"; + const content = planning ? planningContextPending ? planningState(planPath(ctx), state.questionsWaived) : null : dueReminder(ctx, readPlan(ctx)); + if (!content) return; + const id = randomUUID(); + pendingReminder = { id, planning, reason: resyncReason }; + return { customType: planning ? PLANNING_CONTEXT : PLAN_REMINDER, content, display: false, details: { reminderId: id } }; + } + pi.on("before_agent_start", async (_event, ctx) => { - if ((state.phase !== "planning" && state.phase !== "starting") || !planningContextPending) return; - planningContextPending = false; - const content = planningState(planPath(ctx), state.questionsWaived); - return { message: { customType: PLANNING_CONTEXT, content, display: false } }; + const message = reminderMessage(ctx); + if (message) return { message }; }); - // PI: Working turns never see an obsolete planning snapshot. Auto-compaction retries skip - // before_agent_start, so context restores the planning snapshot exactly once in that path. + // PI: Working turns never see an obsolete planning snapshot. Never add ephemeral messages: + // provider replay must see exactly the reminders saved in the session, including after compact. pi.on("context", async (event, ctx) => { - const inPlanGate = state.phase === "planning" || state.phase === "starting"; - const messages = inPlanGate ? event.messages : event.messages.filter((message) => (message as { customType?: string }).customType !== PLANNING_CONTEXT); - if (inPlanGate && planningContextPending) { - planningContextPending = false; - const text = planningState(planPath(ctx), state.questionsWaived); - return { messages: [...messages, { role: "user" as const, content: [{ type: "text" as const, text }], timestamp: Date.now() }] }; - } - const text = dueInjection(ctx, readPlan(ctx)); - if (!text) return messages === event.messages ? undefined : { messages }; - turnsStale = 0; - return { messages: [...messages, { role: "user" as const, content: [{ type: "text" as const, text }], timestamp: Date.now() }] }; + confirmReminder(ctx); + if (state.phase === "planning" || state.phase === "starting") return; + return { messages: event.messages.filter((message) => (message as { customType?: string }).customType !== PLANNING_CONTEXT) }; }); // PI: Human plan-mode replies are durable evidence of the interview, not model summaries. @@ -568,6 +580,7 @@ export default function piGoalsExtension(pi: ExtensionAPI): void { // The staleness clock sees only the working set. Log updates are durable evidence, not progress. pi.on("turn_end", async (_event, ctx) => { + confirmReminder(ctx); const workingSet = foldPlan(readPlan(ctx)); if (workingSet === lastSeenWorkingSet) { turnsStale++; @@ -600,7 +613,8 @@ export default function piGoalsExtension(pi: ExtensionAPI): void { }); // A compaction loses context, so restore either the planning snapshot or the working plan once. - pi.on("session_compact", async () => { + pi.on("session_compact", async (_event, ctx) => { + confirmReminder(ctx); if (state.phase === "planning" || state.phase === "starting") planningContextPending = true; else resyncReason = "The session was just compacted."; }); @@ -717,6 +731,7 @@ export default function piGoalsExtension(pi: ExtensionAPI): void { autoLastWorkingSet = lastSeenWorkingSet; planningContextPending = state.phase === "planning" || state.phase === "starting"; resyncReason = state.phase === "working" ? "New session." : null; + pendingReminder = null; updateWidget(ctx); if (state.phase && !await models.enter(state.phase === "working" || state.modelRecovery ? "worker" : "planning", ctx)) return; scheduleAutoContinue(ctx); diff --git a/src/prompts.ts b/src/prompts.ts index bf6aa26..585ec41 100644 --- a/src/prompts.ts +++ b/src/prompts.ts @@ -4,7 +4,7 @@ * Design: the plan file is for LLMs and the human, not for TypeScript. No parser and no schema; * the skeleton below is a convention the drafting prompt teaches, the working agent maintains with * its normal Edit tool, and the judge reads natively. The harness does three things for a - * cooperative-but-confused model: memory (a transient re-send of the plan when it goes stale), + * cooperative-but-confused model: memory (a saved reminder of the plan when it goes stale), * format guidance (the skeleton), and fresh eyes (the read-only judge in CompleteGoal). * * THE FOLD: everything above "## Log" is the working set (title, user voice, goals, @@ -149,7 +149,7 @@ Conventions: After the alignment answers are incorporated, present the final plan and call RequestPlanReview. Do not begin execution.`; /* ───────────────────────────────────────────────────────────────────────── - * 3. reminder — EXEC. Transient, never persisted, and only when the plan went stale for a couple of + * 3. reminder — EXEC. Saved at the next natural prompt after the plan goes stale for several * turns. pi-tasks tried a per-turn injection and deleted it: "wallpaper noise that trains the * model to ignore the task block" (tintinweb/pi-tasks CHANGELOG.md:149). Carries the folded plan * (above ## Log), because a nudge with no plan in it makes the model go read the file anyway. diff --git a/test/goals-flow.test.ts b/test/goals-flow.test.ts index 46e8a59..c1c9308 100644 --- a/test/goals-flow.test.ts +++ b/test/goals-flow.test.ts @@ -16,7 +16,7 @@ function setup( const commands = new Map(); const hooks = new Map(); const tools = new Map(); - const entries: Array<{ type: string; customType: string; data: unknown }> = []; + const entries: Array<{ type: string; customType: string; data?: unknown; details?: unknown; content?: string }> = []; const events: string[] = []; const messages: Array<{ content: string; display?: boolean; customType?: string }> = []; const busHandlers = new Map unknown>>(); @@ -73,6 +73,13 @@ function setup( return { pi, bus, commands, ctx, cwd, entries, events, hooks, messages, tools }; } +async function promptReminder(flow: ReturnType) { + const result = await flow.hooks.get("before_agent_start")({}, flow.ctx); + if (result?.message) flow.entries.push({ type: "custom_message", ...result.message }); + await flow.hooks.get("context")({ messages: [] }, flow.ctx); + return result?.message; +} + async function settleDraft(flow: ReturnType) { await flow.tools.get("RequestPlanReview").execute("", {}, undefined, undefined, flow.ctx); await flow.hooks.get("agent_settled")({}, flow.ctx); @@ -241,7 +248,8 @@ describe("/goals draft flow", () => { expect(flow.messages.filter((message) => !message.display)).toHaveLength(2); expect(flow.messages.at(-1)?.content).toContain("Work the goals"); await flow.hooks.get("session_start")({}, flow.ctx); - expect(await flow.hooks.get("before_agent_start")({}, flow.ctx)).toBeUndefined(); + expect((await promptReminder(flow)).content).toContain("New session."); + expect(await promptReminder(flow)).toBeUndefined(); } finally { rmSync(flow.cwd, { recursive: true, force: true }); } @@ -296,20 +304,45 @@ describe("/goals draft flow", () => { writeFileSync(planPath, "# Plan\n\n## Goals\n\n1. [/] goal: make the output\n\n## Log\n- checked input\n"); for (let turn = 0; turn < 5; turn++) await flow.hooks.get("turn_end")({}, flow.ctx); - const reminder = await flow.hooks.get("context")({ messages: [] }, flow.ctx); - expect(reminder.messages.at(-1).content[0].text).toContain(".pi/plan/session-a-v1.md"); + expect((await flow.hooks.get("context")({ messages: [] }, flow.ctx)).messages).toHaveLength(0); + const reminder = await promptReminder(flow); + expect(reminder.content).toContain(".pi/plan/session-a-v1.md"); + expect(reminder.content).not.toContain("checked input"); + expect(await promptReminder(flow)).toBeUndefined(); writeFileSync(planPath, "# Plan\n\n## Goals\n\n1. [/] goal: make the output\n - [x] inspect input\n\n## Log\n- checked input\n"); await flow.hooks.get("turn_end")({}, flow.ctx); for (let turn = 0; turn < 7; turn++) await flow.hooks.get("turn_end")({}, flow.ctx); - expect((await flow.hooks.get("context")({ messages: [] }, flow.ctx)).messages).toHaveLength(0); + expect(await promptReminder(flow)).toBeUndefined(); await flow.hooks.get("turn_end")({}, flow.ctx); - expect((await flow.hooks.get("context")({ messages: [] }, flow.ctx)).messages.at(-1).content[0].text).toContain("make the output"); + expect((await promptReminder(flow)).content).toContain("make the output"); } finally { rmSync(flow.cwd, { recursive: true, force: true }); } }); + it("does not acknowledge an unpersisted reminder; retries fresh on the next natural prompt", async () => { + const flow = setup([]); + try { + flow.entries.push({ type: "custom", customType: "pi-goals-state", data: { phase: "working", planVersion: 1, stewardEnabled: false, autoIntervalMs: null } }); + const planPath = join(flow.cwd, ".pi/plan/session-a-v1.md"); + mkdirSync(join(flow.cwd, ".pi/plan"), { recursive: true }); + writeFileSync(planPath, "1. [ ] goal: old plan\n"); + await flow.hooks.get("session_start")({}, flow.ctx); + await flow.hooks.get("session_compact")({}, flow.ctx); + const unsaved = await flow.hooks.get("before_agent_start")({}, flow.ctx); + expect(unsaved.message.content).toContain("old plan"); + expect((await flow.hooks.get("context")({ messages: [] }, flow.ctx)).messages).toEqual([]); + writeFileSync(planPath, "1. [ ] goal: fresh plan\n"); + const saved = await promptReminder(flow); + expect(saved.content).toContain("The session was just compacted."); + expect(saved.content).toContain("fresh plan"); + expect(saved.details.reminderId).not.toBe(unsaved.message.details.reminderId); + expect(await promptReminder(flow)).toBeUndefined(); + expect(flow.messages).toEqual([]); // no queued sendMessage/sendUserMessage delivery + } finally { await flow.hooks.get("session_shutdown")({}, flow.ctx); rmSync(flow.cwd, { recursive: true, force: true }); } + }); + it("auto-continues once on stop, then pauses after two no-progress wakes", async () => { vi.useFakeTimers(); const flow = setup(["Ready"]); @@ -381,7 +414,8 @@ describe("/goals draft flow", () => { const pythonWrite = await flow.hooks.get("tool_call")({ toolName: "bash", input: { command: "python -c \"open('README.md', 'w')\"" } }, flow.ctx); const signoff = await flow.tools.get("CompleteGoal").execute("", { goal: "work" }, undefined, undefined, flow.ctx); await flow.hooks.get("session_compact")({}, flow.ctx); - const compacted = await flow.hooks.get("context")({ messages: [] }, flow.ctx); + expect(await flow.hooks.get("context")({ messages: [] }, flow.ctx)).toBeUndefined(); + const compacted = await promptReminder(flow); expect(writePlan).toBeUndefined(); expect(writeCode?.block).toBe(true); @@ -390,7 +424,8 @@ describe("/goals draft flow", () => { expect(pipeShell?.block).toBe(true); expect(pythonWrite?.block).toBe(true); expect(signoff.isError).toBe(true); - expect(compacted.messages.at(-1).content[0].text).toContain("[PLANNING MODE]"); + expect(compacted.content).toContain("[PLANNING MODE]"); + expect(await promptReminder(flow)).toBeUndefined(); } finally { rmSync(flow.cwd, { recursive: true, force: true }); } diff --git a/test/reminder-lifecycle.test.ts b/test/reminder-lifecycle.test.ts new file mode 100644 index 0000000..41cc7b4 --- /dev/null +++ b/test/reminder-lifecycle.test.ts @@ -0,0 +1,185 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { describe, expect, it } from "vitest"; +import piGoalsExtension from "../src/index.js"; + +// Point at the installed Pi package to exercise its real lifecycle, not the API mock. +const sdkRoot = process.env.PI_GOALS_TEST_SDK_ROOT ?? resolve("node_modules/@earendil-works/pi-coding-agent"); +const sdk = await import(pathToFileURL(join(sdkRoot, "dist/index.js")).href); +const { loadExtensionFromFactory } = await import(pathToFileURL(join(sdkRoot, "dist/core/extensions/loader.js")).href); +const requireSdk = createRequire(join(sdkRoot, "package.json")); +const aiRoot = requireSdk.resolve.paths("@earendil-works/pi-ai")!.map(path => join(path, "@earendil-works/pi-ai")).find(path => existsSync(join(path, "package.json")))!; +const { convertResponsesMessages } = await import(pathToFileURL(join(aiRoot, "dist/api/openai-responses-shared.js")).href); +const sdkVersion = JSON.parse(readFileSync(join(sdkRoot, "package.json"), "utf8")).version; +// Optional read-only check against the real guard; no native endpoint or private checkpoint used. +const replay = process.env.PI_GOALS_TEST_REPLAY_ROOT + ? await import(pathToFileURL(join(process.env.PI_GOALS_TEST_REPLAY_ROOT, "src/payload-rewrite.ts")).href) : undefined; +const zeroCost = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }; +const plan = (revision: string) => `# Plan ${revision}\n\n1. [/] goal: test saved reminders\n\n## Log\n- fixture log ${revision}\n\n## Appendix\nfixture appendix ${revision}\n`; +const compactReminder = (message: any) => typeof message.content === "string" && message.content.includes("The session was just compacted."); + +async function setup(phase = "working") { + sdk.initTheme("dark", false); + const cwd = mkdtempSync(join(tmpdir(), "goals-reminder-sdk-")); + const sm = sdk.SessionManager.create(cwd, join(cwd, "sessions")); + const events: string[] = []; + const requests: any[][] = []; + const errors: unknown[] = []; + let responses: Array<{ tool?: boolean; high?: boolean; overflow?: boolean; queue?: boolean }> = []; + let compactions = 0; + const settingsManager = sdk.SettingsManager.inMemory({ compaction: { enabled: false, reserveTokens: 1000, keepRecentTokens: 100 }, retry: { enabled: false } }); + const runtime = sdk.createExtensionRuntime(); + const bus = sdk.createEventBus(); + const extensions = []; + extensions.push(await loadExtensionFromFactory(piGoalsExtension, cwd, bus, runtime)); + let session: any; + extensions.push(await loadExtensionFromFactory((pi: any) => { + pi.on("before_agent_start", () => { events.push("before_agent_start"); }); + pi.on("session_compact", (event: any) => { events.push(`compact:${event.reason}:${event.willRetry}`); }); + pi.on("session_before_compact", (event: any) => { + compactions++; + return { compaction: { summary: `Offline checkpoint ${compactions}`, firstKeptEntryId: event.preparation.firstKeptEntryId, tokensBefore: event.preparation.tokensBefore, details: { compactedWindow: [{ type: "compaction", encrypted_content: "offline-fixture-only" }] } } }; + }); + pi.registerTool({ name: "fixture_tool", label: "fixture", description: "Offline no-op", parameters: { type: "object", properties: {} }, execute: async () => ({ content: [{ type: "text", text: "fixture result" }], details: {} }) }); + }, cwd, bus, runtime)); + const resourceLoader = { + getExtensions: () => ({ extensions, errors: [], runtime }), + getSkills: () => ({ skills: [], diagnostics: [] }), getPrompts: () => ({ prompts: [], diagnostics: [] }), getThemes: () => ({ themes: [], diagnostics: [] }), + getAgentsFiles: () => ({ agentsFiles: [] }), getSystemPrompt: () => "Offline reminder test", getSystemPromptSource: () => undefined, + getAppendSystemPrompt: () => [], getAppendSystemPromptSources: () => [], extendResources: () => {}, reload: async () => {}, + }; + const modelRuntime = await sdk.ModelRuntime.create({ authPath: join(cwd, "auth.json"), modelsPath: join(cwd, "models.json"), modelsStorePath: join(cwd, "models-store.json"), allowModelNetwork: false }); + await modelRuntime.setRuntimeApiKey("openai", "offline-fixture-key"); + const model = { ...modelRuntime.getModel("openai", "gpt-4.1"), contextWindow: 10000 }; + expect(model.id).toBe("gpt-4.1"); + const assistant = (content: any[], stopReason = "stop", input = 100) => ({ role: "assistant", content, api: model.api, provider: model.provider, model: model.id, stopReason, usage: { input, output: 1, cacheRead: 0, cacheWrite: 0, totalTokens: input + 1, cost: zeroCost }, timestamp: Date.now() }); + sm.appendMessage({ role: "user", content: "Fixture history", timestamp: Date.now() - 1000 }); + sm.appendMessage({ ...assistant([{ type: "text", text: "Fixture history response" }]), timestamp: Date.now() - 900 }); + sm.appendCustomEntry("pi-goals-state", { defaultsVersion: 1, phase, planVersion: 1, stewardEnabled: false, autoIntervalMs: null, reviewRequested: false }); + const planPath = join(cwd, ".pi/plan", `${sm.getSessionId()}-v1.md`); + mkdirSync(dirname(planPath), { recursive: true }); writeFileSync(planPath, plan("initial")); + ({ session } = await sdk.createAgentSession({ cwd, agentDir: process.env.PI_CODING_AGENT_DIR, model, modelRuntime, sessionManager: sm, settingsManager, resourceLoader, tools: ["fixture_tool"] })); + session.subscribe((event: any) => { + if (event.type === "message_end" && event.message.role === "assistant" && event.message.errorMessage && event.message.errorMessage !== "maximum context length exceeded") errors.push(event.message.errorMessage); + }); + const serialize = (messages: any[]) => convertResponsesMessages(model, { messages: sdk.convertToLlm(messages) }, new Set(["openai", "openai-codex", "opencode"])); + session.agent.streamFunction = async (_model: any, context: any) => { + const actual = serialize(context.messages); + const saved = serialize(sm.buildSessionContext().messages); + expect(actual).toEqual(saved); // Provider-visible history matches saved history AT request time. + const disk = sdk.SessionManager.open(sm.getSessionFile()); + expect(serialize(disk.buildSessionContext().messages)).toEqual(saved); + const checkpoint = sm.getBranch().findLast((entry: any) => entry.type === "compaction"); + if (replay && checkpoint) { + const args = { model, payload: { model: model.id, instructions: "Offline reminder test", input: actual }, branchEntries: sm.getBranch(), compactionEntry: checkpoint }; + expect(replay.rewriteResponsesPayloadWithNativeReplay(args).ok).toBe(true); + // Negative control: the original ephemeral suffix must still be rejected by the guard. + expect(replay.rewriteResponsesPayloadWithNativeReplay({ ...args, payload: { ...args.payload, input: [...actual, { role: "user", content: [{ type: "input_text", text: "unsaved plan" }] }] } })).toMatchObject({ ok: false, reason: "expected-pi-replay-mismatch" }); + } + requests.push(structuredClone(context.messages)); + const next = responses.shift() ?? {}; + if (next.queue) await session.steer("Queued user must retain order"); + const message = assistant(next.tool ? [{ type: "toolCall", id: `fixture-${requests.length}`, name: "fixture_tool", arguments: {} }] : [{ type: "text", text: "Offline answer" }], next.overflow ? "error" : next.tool ? "toolUse" : "stop", next.high ? 9500 : 100); + if (next.overflow) { message.errorMessage = "maximum context length exceeded"; message.usage.input = 0; } + return { async *[Symbol.asyncIterator]() { yield next.overflow ? { type: "error", reason: "error", error: message } : { type: "done", reason: message.stopReason, message }; }, result: async () => message }; + }; + await session.bindExtensions({ onError: (error: unknown) => errors.push(error) }); + return { + session, sm, events, requests, errors, planPath, settingsManager, + respond: (...next: typeof responses) => { responses = next; }, + reminders: () => sm.getBranch().filter((entry: any) => entry.type === "custom_message" && compactReminder(entry)), + close: () => { session.dispose(); rmSync(cwd, { recursive: true, force: true }); }, + }; +} + +describe(`saved goal reminders (Pi SDK ${sdkVersion}${replay ? ", real replay guard" : ""})`, () => { + it.each(["working", "planning"])("manual compact in %s: fresh saved reminder once on next natural prompt", async (phase) => { + const flow = await setup(phase); + try { + await flow.session.prompt("Initial request"); + const priorSnapshotIds = new Set(flow.sm.getBranch().filter((entry: any) => entry.type === "custom_message").map((entry: any) => entry.id)); + await flow.session.compact(); + const checkpointId = flow.sm.getBranch().findLast((entry: any) => entry.type === "compaction").id; + expect(flow.events).toContain("compact:manual:false"); + expect(flow.requests).toHaveLength(1); + writeFileSync(flow.planPath, plan("fresh-after-compact")); + await flow.session.prompt("Natural request"); + const snapshots = flow.sm.getBranch().filter((entry: any) => entry.type === "custom_message" && entry.customType === (phase === "working" ? "pi-goals-plan-reminder" : "pi-goals-planning-context")); + const count = snapshots.length; + const latest = snapshots.at(-1); + expect(latest.display).toBe(false); + expect(priorSnapshotIds.has(latest.id)).toBe(false); + const branch = flow.sm.getBranch(); + expect(branch.findIndex((entry: any) => entry.id === latest.id)).toBeGreaterThan(branch.findIndex((entry: any) => entry.id === checkpointId)); + expect(flow.requests[1].some((message: any) => message.role === "user" && Array.isArray(message.content) && message.content.some((part: any) => part.type === "text" && part.text === latest.content))).toBe(true); + if (phase === "working") { expect(flow.reminders()).toHaveLength(1); expect(flow.reminders()[0].content).toContain("fixture appendix fresh-after-compact"); } + await flow.session.prompt("Another natural request"); + expect(flow.sm.getBranch().filter((entry: any) => entry.type === "custom_message" && entry.customType === snapshots[0].customType)).toHaveLength(count); + expect(flow.requests).toHaveLength(3); + expect(flow.errors).toEqual([]); + } finally { flow.close(); } + }); + + it.each(["threshold", "overflow"])("post-run %s compact: no extra run, refresh at next natural prompt", async (reason) => { + const flow = await setup(); + try { + flow.settingsManager.applyOverrides({ compaction: { enabled: true } }); + flow.respond(reason === "threshold" ? { high: true } : { overflow: true }, {}); + await flow.session.prompt("Run and compact"); + expect(flow.events).toContain(`compact:${reason}:${reason === "overflow"}`); + expect(flow.requests).toHaveLength(reason === "threshold" ? 1 : 2); + expect(flow.reminders()).toHaveLength(0); + expect(flow.events.filter(event => event === "before_agent_start")).toHaveLength(1); + writeFileSync(flow.planPath, plan("next-natural")); + await flow.session.prompt("Next natural prompt"); + expect(flow.reminders()).toHaveLength(1); + expect(flow.reminders()[0].content).toContain("fixture appendix next-natural"); + expect(flow.errors).toEqual([]); + } finally { flow.close(); } + }); + + it("stale tool-loop reminder waits for a natural prompt and persists only the working set", async () => { + const flow = await setup(); + try { + flow.respond(...Array.from({ length: 8 }, () => ({ tool: true })), {}); + await flow.session.prompt("Long tool run"); + expect(flow.requests).toHaveLength(9); + const count = () => flow.sm.getBranch().filter((entry: any) => entry.type === "custom_message" && entry.customType === "pi-goals-plan-reminder").length; + expect(count()).toBe(1); // startup only; no turn_end/ephemeral reminders + await flow.session.prompt("Natural prompt after staleness"); + expect(count()).toBe(2); + const text = JSON.stringify(flow.requests.at(-1).at(-1)); + expect(text).toContain("test saved reminders"); + expect(text).not.toContain("fixture appendix"); + await flow.session.prompt("No duplicate"); + expect(count()).toBe(2); + expect(flow.errors).toEqual([]); + } finally { flow.close(); } + }); + + it.skipIf(Number(sdkVersion.split(".")[1]) < 85).each([false, true])("mid-run threshold, queued user=%s: defer without drops, duplicates, or extra response", async (queue) => { + const flow = await setup(); + try { + flow.settingsManager.applyOverrides({ compaction: { enabled: true } }); + flow.respond({ tool: true, high: true, queue }, {}); + await flow.session.prompt("Use fixture tool"); + expect(flow.events).toContain("compact:threshold:false"); + expect(flow.requests).toHaveLength(2); + expect(JSON.stringify(flow.requests[1][0])).toContain("Offline checkpoint"); // compacted before the tool continuation, not just after the run + expect(flow.events.filter(event => event === "before_agent_start")).toHaveLength(1); + expect(flow.reminders()).toHaveLength(0); + if (queue) expect(JSON.stringify(flow.requests[1].at(-1))).toContain("Queued user must retain order"); + writeFileSync(flow.planPath, plan("after-auto")); + await flow.session.prompt("Next natural prompt"); + expect(flow.reminders()).toHaveLength(1); + expect(flow.reminders()[0].content).toContain("fixture appendix after-auto"); + await flow.session.prompt("No duplicate"); + expect(flow.reminders()).toHaveLength(1); + expect(flow.requests).toHaveLength(4); + expect(flow.errors).toEqual([]); + } finally { flow.close(); } + }); +});