From 82b739bb0b4e1a2baa1278dfaeddef0ab0aac92a Mon Sep 17 00:00:00 2001 From: wassname2 Date: Fri, 11 Sep 2026 13:37:22 +0800 Subject: [PATCH] Defer passive goal context until ordinary prompt preparation --- README.md | 6 +++++ src/index.ts | 32 ++++++++++++++++------- test/goals.test.ts | 63 ++++++++++++++++++++++++++++++++++++++-------- 3 files changed, 82 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 8c910ed..a9a080a 100644 --- a/README.md +++ b/README.md @@ -146,6 +146,12 @@ pi -e ./src/index.ts `/goals` opens the action menu. New plan enters plan mode and starts a conversation; +## Context delivery + +Startup and successful compaction mark the plan for a fresh read at the next ordinary prompt (`before_agent_start`). Upkeep becomes due after eight unchanged turns, but waits for that same prompt boundary. A full plan refresh replaces pending upkeep; edits, pause, exit and session navigation invalidate obsolete reminders. Failed or cancelled compaction does not schedule another refresh or consume pending upkeep. Missing plans are retried without discarding progress. + +This is deliberately passive on Pi 0.85.1: tool-loop continuations, overflow retries and already-queued user messages keep Pi's existing role and compacted context, without an extra model turn just to repeat the plan. They do **not** receive a newly read plan until ordinary prompt preparation. Pi's `triggerTurn: false` mid-run path can save a message absent from the live request snapshot; steering can instead force an unwanted turn. We use neither path for upkeep. Passive pause/exit notices use `nextTurn`, with immediate UI feedback; stopping remains local and remote termination is unconfirmed. + ## Prompts You can read all the prompts in conversation order in [`src/prompts.ts`](src/prompts.ts). diff --git a/src/index.ts b/src/index.ts index 1c78e3d..b21e238 100644 --- a/src/index.ts +++ b/src/index.ts @@ -91,6 +91,7 @@ export default function mainSupervisor(pi: ExtensionAPI) { }; let turnsStale = 0; let lastWorkingSet = ""; + let pendingUpkeep: { generation: number; workingSet: string } | undefined; const checkIn = (ctx: ExtensionContext) => scheduleCheckIn(ctx.sessionManager.getSessionId(), state.plan ?? ""); const hasScheduleTool = () => pi.getAllTools().some((tool) => tool.name === "schedule_prompt"); const notedPlanValue = (prefix: string) => { @@ -169,6 +170,7 @@ export default function mainSupervisor(pi: ExtensionAPI) { notice = true; turnsStale = 0; lastWorkingSet = ""; + pendingUpkeep = undefined; refresh(ctx); watchPlan(ctx); } @@ -181,7 +183,7 @@ export default function mainSupervisor(pi: ExtensionAPI) { // sendMessage(triggerTurn:true) bypasses before_agent_start in Pi 0.85.1. // A normal saved prompt prepares the current role before starting the turn. if (triggerTurn) pi.sendUserMessage(`[pi-goals]\n${content}`, { deliverAs: "followUp" }); - else pi.sendMessage({ customType: "pi-goals-supervision", content, display: true }, { deliverAs: "followUp", triggerTurn: false }); + else pi.sendMessage({ customType: "pi-goals-supervision", content, display: true }, { deliverAs: "nextTurn" }); } async function confirmOwnership(ctx: ExtensionContext, target: string, text: string, solo = true): Promise { if (pendingLaunches > 0) { ctx.ui.notify("A worker launch/resume is still pending; inspect its result before takeover.", "warning"); return false; } @@ -227,6 +229,8 @@ export default function mainSupervisor(pi: ExtensionAPI) { pi.on("session_start", (_e, ctx) => restore(ctx)); pi.on("session_tree", (_e, ctx) => restore(ctx)); pi.on("session_shutdown", () => { generation++; planWatcher?.close(); planWatcher = undefined; clearTimeout(planEditTimer); planEditTimer = undefined; }); + // Only successful compaction needs resync; failed/cancelled attempts leave pending context alone. + // Defer to prompt preparation: same-run continuation retains Pi's current role/context. pi.on("session_compact", () => { notice = true; }); pi.on("turn_end", (_event, ctx) => { if (!["supervising", "solo"].includes(state.mode)) return; @@ -237,9 +241,9 @@ export default function mainSupervisor(pi: ExtensionAPI) { lastWorkingSet = workingSet; refresh(ctx); if (turnsStale === 8 && goals(snapshot.text).some(g => g.status === "open" || g.status === "active")) { - // Pi queues context-only messages until tool results are appended at turn_end. - // This reaches the next model call in a long run without triggering another run. - pi.sendMessage({ customType: "pi-goals-upkeep", content: upkeep(state.plan!), display: false }, { triggerTurn: false }); + // In Pi 0.85.1 triggerTurn:false updates saved history, not the live loop snapshot. + // Queue intent locally until ordinary prompt preparation, never force another turn. + pendingUpkeep = { generation, workingSet }; } }); pi.on("agent_end", (_e, ctx) => { refresh(ctx); if (!planWatcher && state.mode === "supervising") watchPlan(ctx); }); @@ -268,11 +272,18 @@ export default function mainSupervisor(pi: ExtensionAPI) { const role = state.child ? childPlanRole : state.mode === "supervising" ? supervisor(WORKER, state.plan!, ctx.sessionManager.getSessionId()) : state.mode === "planning" ? planning(state.plan!) : state.mode === "paused" ? pausedRole : soloRole; - const content = notice ? planContext(state.child ? "worker" : state.mode, state.plan, snapshot.text) - : undefined; - if (content) turnsStale = 0; + // Returned messages enter both Pi's prompt snapshot and saved history together. + // Unlike nextTurn, retaining intent here lets a fresh plan resync supersede upkeep, + // and drops obsolete reminders after edits, takeover, pause or session navigation. + const message = notice + ? { customType: "pi-goals-plan", content: planContext(state.child ? "worker" : state.mode, state.plan, snapshot.text), display: false } + : pendingUpkeep?.generation === generation && pendingUpkeep.workingSet === foldPlan(snapshot.text) + && ["supervising", "solo"].includes(state.mode) && goals(snapshot.text).some(g => g.status === "open" || g.status === "active") + ? { customType: "pi-goals-upkeep", content: upkeep(state.plan!), display: false } : undefined; + if (notice) turnsStale = 0; notice = false; - return { systemPrompt: `${event.systemPrompt}\n\n${role}`, ...(content ? { message: { customType: "pi-goals-plan", content, display: false } } : {}) }; + pendingUpkeep = undefined; + return { systemPrompt: `${event.systemPrompt}\n\n${role}`, ...(message ? { message } : {}) }; }); pi.on("tool_call", (event, ctx) => { if (event.toolName === "subagent" && event.input) { @@ -382,7 +393,10 @@ export default function mainSupervisor(pi: ExtensionAPI) { return; } state.mode = command === "stop" ? "paused" : "chat"; generation++; notice = true; save(); refresh(ctx); watchPlan(ctx); - send(`${removeGoalSchedule(ctx.sessionManager.getSessionId())}\n\n${pauseExitNotice(state.worker, command === "exit")}`, Boolean(state.worker) || hasScheduleTool()); + const pause = pauseExitNotice(state.worker, command === "exit"); + const requestCleanup = Boolean(state.worker) || hasScheduleTool(); + if (!requestCleanup) ctx.ui.notify(pause, "info"); // Visible now; passive model context waits for a prompt. + send(`${removeGoalSchedule(ctx.sessionManager.getSessionId())}\n\n${pause}`, requestCleanup); return; } if (command === "resume") { diff --git a/test/goals.test.ts b/test/goals.test.ts index 9d97e5a..dfdb8ab 100644 --- a/test/goals.test.ts +++ b/test/goals.test.ts @@ -525,7 +525,7 @@ it.each(["solo", "supervising"])("%s widget omits long tasks without altering th expect(readFileSync(f.path, "utf8")).toBe(text); }); -it.each(["solo", "supervising"])("%s upkeep is turn-driven, folds Log, resets on working-set edits, and never starts a turn", async mode => { +it.each(["solo", "supervising"])("%s upkeep is turn-driven, folds Log, and joins the next ordinary prompt once", async mode => { const f = fixture(); await f.draft(); if (mode === "solo") { f.ctx.ui.select.mockResolvedValueOnce("Worker confirmed stopped"); await f.command("solo"); } else await f.command("ready"); @@ -538,20 +538,21 @@ it.each(["solo", "supervising"])("%s upkeep is turn-driven, folds Log, resets on } expect(reminders()).toHaveLength(0); f.hooks.get("turn_end")({}, f.ctx); - expect(reminders()).toHaveLength(1); - expect(reminders()[0].options).toEqual({ triggerTurn: false }); - expect(reminders()[0].message.content).toContain(f.path); - expect(reminders()[0].message.content).not.toContain("first output"); for (let i = 0; i < 16; i++) f.hooks.get("turn_end")({}, f.ctx); - expect(reminders()).toHaveLength(1); - expect(reminders()[0].message.content).not.toContain("historical recap"); - for (let i = 0; i < 7; i++) f.hooks.get("turn_end")({}, f.ctx); + expect(reminders()).toHaveLength(0); // No direct send, even after the run would finish. + const reminder = f.hooks.get("before_agent_start")({ systemPrompt: "base" }, f.ctx).message; + expect(reminder.customType).toBe("pi-goals-upkeep"); + expect(reminder.content).toContain(f.path); + expect(reminder.content).not.toContain("first output"); + expect(reminder.content).not.toContain("historical recap"); + expect(f.hooks.get("before_agent_start")({ systemPrompt: "base" }, f.ctx).message).toBeUndefined(); writeFileSync(f.path, f.plan.replace("first output", "refined output")); f.hooks.get("turn_end")({}, f.ctx); - expect(reminders()).toHaveLength(1); + for (let i = 0; i < 7; i++) f.hooks.get("turn_end")({}, f.ctx); + expect(f.hooks.get("before_agent_start")({ systemPrompt: "base" }, f.ctx).message).toBeUndefined(); await f.command("stop"); for (let i = 0; i < 10; i++) f.hooks.get("turn_end")({}, f.ctx); - expect(reminders()).toHaveLength(1); + expect(reminders()).toHaveLength(0); }); it("extra subagent launches are recorded as helpers and never steal the implementation identity", async () => { @@ -651,3 +652,45 @@ it("keeps interactive workers open and supplies the supervisor identity for Inte expect(role).toContain("stop workers before /reload"); expect(role).not.toContain("Reports arrive automatically"); }); + +it.each(["stop", "exit", "edit", "session_tree"])("discards pending upkeep after %s instead of reviving stale work", async change => { + const f = fixture(); await f.draft(); + f.ctx.ui.select.mockResolvedValueOnce("Worker confirmed stopped"); await f.command("solo"); + f.hooks.get("before_agent_start")({ systemPrompt: "base" }, f.ctx); + for (let i = 0; i < 9; i++) f.hooks.get("turn_end")({}, f.ctx); + if (change === "edit") writeFileSync(f.path, f.plan.replace("first output", "changed requirement")); + else if (change === "session_tree") f.hooks.get("session_tree")({}, f.ctx); + else await f.command(change); + const prepared = f.hooks.get("before_agent_start")({ systemPrompt: "base" }, f.ctx); + expect(prepared?.message?.customType).not.toBe("pi-goals-upkeep"); + if (change === "stop") expect(prepared.systemPrompt).toContain("Goal work is paused"); + if (change === "exit") expect(prepared).toBeUndefined(); + expect(f.messages.filter(m => m.message.customType === "pi-goals-upkeep")).toHaveLength(0); +}); + +it("coalesces pending upkeep with a repaired post-compaction plan, retaining the user's latest requirements", async () => { + const f = fixture(); await f.draft(); + f.ctx.ui.select.mockResolvedValueOnce("Worker confirmed stopped"); await f.command("solo"); + f.hooks.get("before_agent_start")({ systemPrompt: "base" }, f.ctx); + for (let i = 0; i < 9; i++) f.hooks.get("turn_end")({}, f.ctx); + f.hooks.get("session_compact")(); + rmSync(f.path); + const unavailable = f.hooks.get("before_agent_start")({ systemPrompt: "base" }, f.ctx); + expect(unavailable.message).toBeUndefined(); + expect(unavailable.systemPrompt).toContain("unavailable"); + const repaired = f.plan.replace("first output", "the human's latest exact result"); + writeFileSync(f.path, repaired); + const ready = f.hooks.get("before_agent_start")({ systemPrompt: "base" }, f.ctx); + expect(ready.message).toMatchObject({ customType: "pi-goals-plan" }); + expect(ready.message.content).toContain(repaired); + expect(ready.message.content).not.toContain("Plan upkeep:"); + expect(f.hooks.get("before_agent_start")({ systemPrompt: "base" }, f.ctx).message).toBeUndefined(); +}); + +it.each(["stop", "exit"])("passive %s is visible immediately while its model notice waits safely for the next prompt", async command => { + const f = fixture(); await f.draft(); + f.ctx.ui.select.mockResolvedValueOnce("Worker confirmed stopped"); await f.command("solo"); + await f.command(command); + expect(f.messages.at(-1).options).toEqual({ deliverAs: "nextTurn" }); + expect(f.ctx.ui.notify).toHaveBeenLastCalledWith(expect.stringContaining("Remote stop is NOT yet confirmed"), "info"); +});