fix: persist goal reminders before agent runs

This commit is contained in:
wassname2
2026-09-09 14:57:55 +08:00
parent d5729ac106
commit b386efd8ea
5 changed files with 287 additions and 45 deletions
+9 -2
View File
@@ -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
+48 -33
View File
@@ -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}/<session_id>-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);
+2 -2
View File
@@ -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.
+43 -8
View File
@@ -16,7 +16,7 @@ function setup(
const commands = new Map<string, any>();
const hooks = new Map<string, any>();
const tools = new Map<string, any>();
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<string, Set<(value: unknown) => unknown>>();
@@ -73,6 +73,13 @@ function setup(
return { pi, bus, commands, ctx, cwd, entries, events, hooks, messages, tools };
}
async function promptReminder(flow: ReturnType<typeof setup>) {
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<typeof setup>) {
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 });
}
+185
View File
@@ -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: "<system-reminder>unsaved plan</system-reminder>" }] }] } })).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(); }
});
});