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:
@@ -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}}
|
||||
Reference in New Issue
Block a user