From 572d1f8fe0d600789abf8314516dfe036cd7ae99 Mon Sep 17 00:00:00 2001 From: wassname2 Date: Mon, 21 Sep 2026 12:00:45 +0800 Subject: [PATCH] Discard plan-review intent after interrupted or superseding input --- src/index.ts | 8 ++++---- src/prompts.ts | 2 +- test/goals.test.ts | 7 +++++++ 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/index.ts b/src/index.ts index ab3a4c9..7f41a0e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -620,14 +620,14 @@ export default function mainSupervisor(pi: ExtensionAPI) { if (state.child && state.parent) pi.appendEntry(RUN, { plan: state.plan, parent: state.parent, session: identity(ctx) }); if (clearCheckIn) { clearTimeout(clearCheckIn.deadline); clearCheckIn.deadline = undefined; } }); - let requestedPlanReview: { generation: number; digest: string } | undefined; + let requestedPlanReview: { generation: number; digest: string; signal?: AbortSignal } | undefined; pi.on("agent_settled", async (_e, ctx) => { agentRunActive = false; clearCheckIn?.startDeadline?.(); reconcileReports(ctx); remindReports(ctx); const requested = requestedPlanReview; requestedPlanReview = undefined; - if (!requested || requested.generation !== generation || state.child || state.mode !== "planning" || !ctx.hasUI) return; + if (!requested || requested.signal?.aborted || requested.generation !== generation || state.child || state.mode !== "planning" || !ctx.hasUI) return; const snapshot = readPlan(); if (snapshot.text === undefined || digest(snapshot.text) !== requested.digest) return; await ready(ctx, true); @@ -667,7 +667,7 @@ export default function mainSupervisor(pi: ExtensionAPI) { return { systemPrompt: `${event.systemPrompt}\n\n${role}${pending.length ? `\n${pendingReportReviews(pending.map(reportLabel))}` : ""}`, ...(message ? { message } : {}) }; }); pi.on("input", (event, ctx) => { - if (event.source !== "extension") { pauseCheckIn = false; return; } + if (event.source !== "extension") { requestedPlanReview = undefined; pauseCheckIn = false; return; } const wake = /^\[Scheduled task ([a-zA-Z0-9_-]+) fired\]\nName: ([^\n]+)\nAction: prompt\n/.exec(event.text); if (!wake || wake[2] !== `goals-${ctx.sessionManager.getSessionId()}` || !ownedCheckInIds(ctx).has(wake[1])) return; const snapshot = readPlan(); @@ -709,7 +709,7 @@ export default function mainSupervisor(pi: ExtensionAPI) { parameters: Type.Object({}), async execute(_id, _params, signal, _update, ctx) { if (signal?.aborted || state.child || state.mode !== "planning" || !ctx.hasUI) return result(planReviewResult.unavailable); - requestedPlanReview = { generation, digest: digest(planText()) }; + requestedPlanReview = { generation, digest: digest(planText()), signal }; return result(planReviewResult.queued); }, }); diff --git a/src/prompts.ts b/src/prompts.ts index 011b587..f4492b9 100644 --- a/src/prompts.ts +++ b/src/prompts.ts @@ -141,7 +141,7 @@ export const planDocument = (objective: string) => `# ${objective.split("\n")[0] export const requestPlanReviewDescription = "Intentionally present the settled goal draft for human acceptance through the existing Ready/Discuss/Edit/Cancel menu. Planning parent only. Not for provisional drafts, redrafting, interview or Log updates; resolve consequential open questions first unless the user explicitly requests a shortcut. Only human Ready authorizes execution."; export const planReviewResult = { unavailable: "Plan review requires a planning parent and an interactive UI. No execution authorized.", - queued: "Intentional review queued for the end of this turn. Finish without repeating the full plan; the interface will present it. No execution authorized before human Ready. Further edits, a failed/aborted turn, Discuss/Edit/Cancel or a mode change cancel this request; request again only when settled.", + queued: "Intentional review queued for the end of this turn. Finish without repeating the full plan; the interface will present it. No execution authorized before human Ready. Further edits, new user input, a failed/aborted turn, Discuss/Edit/Cancel or a mode change cancel this request; request again only when settled.", }; export const discuss = "Type your changes in chat; the draft stays open."; diff --git a/test/goals.test.ts b/test/goals.test.ts index 676f5a9..15a6171 100644 --- a/test/goals.test.ts +++ b/test/goals.test.ts @@ -337,6 +337,13 @@ it("keeps provisional drafts and interview updates separate from intentional acc f.hooks.get("agent_end")({ messages: [{ role: "assistant", content: [], stopReason }] }, f.ctx); await f.hooks.get("agent_settled")({}, f.ctx); } + const interrupted = new AbortController(); + await f.tools.get("RequestPlanReview").execute("interrupted", {}, interrupted.signal, undefined, f.ctx); + interrupted.abort(); // can arrive after the tool returned, without a new assistant error + await f.hooks.get("agent_settled")({}, f.ctx); + await f.tools.get("RequestPlanReview").execute("superseded", {}, undefined, undefined, f.ctx); + f.hooks.get("input")({ source: "interactive", text: "Wait, another question" }, f.ctx); + await f.hooks.get("agent_settled")({}, f.ctx); await f.tools.get("RequestPlanReview").execute("discussed", {}, undefined, undefined, f.ctx); await f.command("discuss"); await f.hooks.get("agent_settled")({}, f.ctx);