feat(coding-agent/plan-mode): add "Approve and compact context" approval choice

A fifth ExitPlanMode approval choice — sits between the existing
"Approve and execute" (purge session) and "Approve and keep context"
(full transcript). Runs `handleCompactCommand` against the plan-mode
transcript with a planning-specific custom instruction rendered from
`plan-mode-compact-instructions.md`, then dispatches the plan-approved
synthetic prompt so it lands as the first entry in the freshly-
summarized transcript — giving execution a fresh cache anchor with
the rationale carried over.

Cancel/fail contract:
- ok       → bookkeeping runs, plan-approved synthetic prompt dispatched.
- cancelled → bookkeeping runs (tools restored, plan reference path
              recorded), warning surfaced, dispatch skipped.
              `markPlanReferenceSent` is intentionally deferred past
              the cancel guard so `AgentSession.#buildPlanReferenceMessage`
              re-injects the plan on the operator's next prompt() call.
              If we marked it sent on cancel, the executor's first turn
              would have no plan context.
- failed   → bookkeeping runs, error already surfaced by executeCompaction,
             dispatch proceeds best-effort. Approval intent stands.

Cancel vs. fail is discriminated via `instanceof CompactionCancelledError`
at the session/compaction error boundary (introduced in the previous
commit), so any abort source — operator Esc, extension hook, programmatic
abort — classifies uniformly without input-modality or message-string
coupling.

Op: extend
This commit is contained in:
cognitive
2026-05-12 23:01:06 +00:00
parent bad4c133e7
commit 0eb8d0fdcd
3 changed files with 74 additions and 4 deletions
@@ -41,6 +41,9 @@ import { resolveLocalUrlToPath } from "../internal-urls";
import { LSP_STARTUP_EVENT_CHANNEL, type LspStartupEvent } from "../lsp/startup-events";
import { renameApprovedPlanFile } from "../plan-mode/approved-plan";
import planModeApprovedPrompt from "../prompts/system/plan-mode-approved.md" with { type: "text" };
import planModeCompactInstructionsPrompt from "../prompts/system/plan-mode-compact-instructions.md" with {
type: "text",
};
import type { AgentSession, AgentSessionEvent } from "../session/agent-session";
import type { CompactionOutcome } from "../session/compaction";
import { HistoryStorage } from "../session/history-storage";
@@ -1110,7 +1113,12 @@ export class InteractiveMode implements InteractiveModeContext {
async #approvePlan(
planContent: string,
options: { planFilePath: string; finalPlanFilePath: string; preserveContext?: boolean },
options: {
planFilePath: string;
finalPlanFilePath: string;
preserveContext?: boolean;
compactBeforeExecute?: boolean;
},
): Promise<void> {
await renameApprovedPlanFile({
planFilePath: options.planFilePath,
@@ -1120,6 +1128,8 @@ export class InteractiveMode implements InteractiveModeContext {
});
const previousTools = this.#planModePreviousTools ?? this.session.getActiveToolNames();
await this.#exitPlanMode({ silent: true, paused: false });
let compactOutcome: CompactionOutcome | undefined;
if (!options.preserveContext) {
await this.handleClearCommand();
// The new session has a fresh local:// root — persist the approved plan there
@@ -1129,11 +1139,43 @@ export class InteractiveMode implements InteractiveModeContext {
getSessionId: () => this.sessionManager.getSessionId(),
});
await Bun.write(newLocalPath, planContent);
} else if (options.compactBeforeExecute) {
// Distill the plan-mode transcript before the execution turn is queued so
// the plan-approved synthetic prompt lands as a fresh cache anchor.
// Outcome is consumed after tool-restoration and plan-reference-path
// bookkeeping below; `markPlanReferenceSent` is intentionally deferred
// past the cancel guard — see the comment at the cancel branch.
// Cancellation skips the synthetic-prompt dispatch (operator's explicit
// abort is honored); failure proceeds best-effort — approval intent stands.
const compactionPrompt = prompt.render(planModeCompactInstructionsPrompt, {
planFilePath: options.finalPlanFilePath,
});
compactOutcome = await this.handleCompactCommand(compactionPrompt);
}
// Tool restoration runs on every path — the plan mode tools must be
// retired regardless of whether the synthetic prompt fires.
if (previousTools.length > 0) {
await this.session.setActiveToolsByName(previousTools);
}
this.session.setPlanReferencePath(options.finalPlanFilePath);
if (compactOutcome === "cancelled") {
// Explicit abort: honor it. `executeCompaction` already surfaced
// `showError("Compaction cancelled")` to the operator; we add the
// deferred-dispatch warning and exit. `markPlanReferenceSent` is
// intentionally skipped here: `#planReferenceSent` stays false, so
// `AgentSession.#buildPlanReferenceMessage` will inject the plan
// reference on the operator's next `prompt()` call. If we marked it
// sent here, the executor's first turn would have no plan context.
this.showWarning(
"Plan approved, but compaction was cancelled — execution not dispatched. Submit a turn to continue.",
);
return;
}
// markPlanReferenceSent fires only on the dispatch path so the synthetic
// plan-approved prompt is the source of the reference injection.
this.session.markPlanReferenceSent();
const planModePrompt = prompt.render(planModeApprovedPrompt, {
planContent,
@@ -1186,14 +1228,24 @@ export class InteractiveMode implements InteractiveModeContext {
this.#renderPlanPreview(planContent, { append: true });
const choice = await this.showHookSelector(
"Plan mode - next step",
["Approve and execute", "Approve and keep context", "Refine plan", "Stay in plan mode"],
[
"Approve and execute",
"Approve and compact context",
"Approve and keep context",
"Refine plan",
"Stay in plan mode",
],
{
helpText: this.#getPlanReviewHelpText(),
onExternalEditor: () => void this.#openPlanInExternalEditor(planFilePath),
},
);
if (choice === "Approve and execute" || choice === "Approve and keep context") {
if (
choice === "Approve and execute" ||
choice === "Approve and compact context" ||
choice === "Approve and keep context"
) {
const finalPlanFilePath = details.finalPlanFilePath || planFilePath;
try {
const latestPlanContent = await this.#readPlanFile(planFilePath);
@@ -1204,7 +1256,8 @@ export class InteractiveMode implements InteractiveModeContext {
await this.#approvePlan(latestPlanContent, {
planFilePath,
finalPlanFilePath,
preserveContext: choice === "Approve and keep context",
preserveContext: choice !== "Approve and execute",
compactBeforeExecute: choice === "Approve and compact context",
});
} catch (error) {
this.showError(
@@ -24,6 +24,7 @@ You **MUST** use `{{editToolName}}` for incremental updates; use `{{writeToolNam
<caution>
The approval selector includes:
- **Approve and execute**: starts execution in fresh context (session cleared).
- **Approve and compact context**: distills the plan-mode discussion into a summary, then starts execution in this session.
- **Approve and keep context**: starts execution in this session, preserving exploration history.
You **MUST** still make the plan file self-contained: include requirements, decisions, key findings, and remaining todos.
@@ -0,0 +1,16 @@
Preparing to execute the approved plan.
You **MUST** distill the plan-mode discussion. Preserve:
- The plan rationale and the alternatives explicitly rejected.
- Key decisions and the constraints that drove them.
- Discovered files, symbols, and code paths the executor will need.
- Explicit user preferences expressed during planning.
You **MUST** drop:
- Tool-call noise (file reads, searches) where the result is already captured in the plan or above.
- Superseded plan drafts.
- Restated context already present in the plan file.
{{#if planFilePath}}
The approved plan file is at `{{planFilePath}}`; it is the authoritative source of truth and need not be re-summarized in detail.
{{/if}}