diff --git a/packages/agent/src/adapters/claude/permissions/permission-handlers.ts b/packages/agent/src/adapters/claude/permissions/permission-handlers.ts index 8eddc69777..076a0aaa42 100644 --- a/packages/agent/src/adapters/claude/permissions/permission-handlers.ts +++ b/packages/agent/src/adapters/claude/permissions/permission-handlers.ts @@ -4,6 +4,7 @@ import type { RequestPermissionResponse, } from "@agentclientprotocol/sdk"; import type { + PermissionMode, PermissionRuleValue, PermissionUpdate, } from "@anthropic-ai/claude-agent-sdk"; @@ -31,6 +32,7 @@ import type { Session } from "../types"; import { buildExitPlanModePermissionOptions, buildPermissionOptions, + CLEAR_AND_CONTINUE_OPTION_ID, } from "./permission-options"; import { extractPostHogSubTool, @@ -252,32 +254,72 @@ async function requestPlanApproval( }); } +const PLAN_APPROVAL_MODE_OPTION_IDS = [ + "auto", + "default", + "acceptEdits", + "bypassPermissions", +] as const satisfies readonly PermissionMode[]; + +function isPlanApprovalMode(value: string): value is PermissionMode { + return (PLAN_APPROVAL_MODE_OPTION_IDS as readonly string[]).includes(value); +} + +function resolvePlanApprovalMode( + optionId: string, + response: RequestPermissionResponse, + previousMode?: string, +): PermissionMode | null { + if (isPlanApprovalMode(optionId)) { + return optionId; + } + + // Opt-in clear-and-continue: honor the ModeSelector choice when present, + // otherwise fall back to the mode the session was in before plan mode. + if (optionId === CLEAR_AND_CONTINUE_OPTION_ID) { + const answers = response._meta?.answers as + | Record + | undefined; + const fromAnswers = answers?.executionMode; + if (typeof fromAnswers === "string" && isPlanApprovalMode(fromAnswers)) { + return fromAnswers; + } + if (previousMode && isPlanApprovalMode(previousMode)) { + return previousMode; + } + return "default"; + } + + return null; +} + async function applyPlanApproval( response: RequestPermissionResponse, context: ToolHandlerContext, updatedInput: Record, ): Promise { - if ( - response.outcome?.outcome === "selected" && - (response.outcome.optionId === "auto" || - response.outcome.optionId === "default" || - response.outcome.optionId === "acceptEdits" || - response.outcome.optionId === "bypassPermissions") - ) { - await context.applySessionMode(response.outcome.optionId); - await context.updateConfigOption("mode", response.outcome.optionId); + if (response.outcome?.outcome === "selected") { + const mode = resolvePlanApprovalMode( + response.outcome.optionId, + response, + context.session.modeBeforePlan, + ); + if (mode) { + await context.applySessionMode(mode); + await context.updateConfigOption("mode", mode); - return { - behavior: "allow", - updatedInput, - updatedPermissions: context.suggestions ?? [ - { - type: "setMode", - mode: response.outcome.optionId, - destination: "localSettings", - }, - ], - }; + return { + behavior: "allow", + updatedInput, + updatedPermissions: context.suggestions ?? [ + { + type: "setMode", + mode, + destination: "localSettings", + }, + ], + }; + } } const customInput = (response._meta as Record | undefined) diff --git a/packages/agent/src/adapters/claude/permissions/permission-options.test.ts b/packages/agent/src/adapters/claude/permissions/permission-options.test.ts index df6fbabdbc..36290c8095 100644 --- a/packages/agent/src/adapters/claude/permissions/permission-options.test.ts +++ b/packages/agent/src/adapters/claude/permissions/permission-options.test.ts @@ -10,6 +10,20 @@ describe("buildExitPlanModePermissionOptions", () => { expect(options[options.length - 1].optionId).toBe("reject_with_feedback"); }); + it("includes an opt-in clear-and-continue option before reject", () => { + const options = buildExitPlanModePermissionOptions(); + const clearIndex = options.findIndex( + (opt) => opt.optionId === "clearAndContinue", + ); + expect(clearIndex).toBeGreaterThanOrEqual(0); + expect(options[clearIndex]).toMatchObject({ + optionId: "clearAndContinue", + kind: "allow_once", + name: "Yes, clear history and continue from plan", + }); + expect(options[clearIndex + 1]?.optionId).toBe("reject_with_feedback"); + }); + it.each([ { previousMode: "default", diff --git a/packages/agent/src/adapters/claude/permissions/permission-options.ts b/packages/agent/src/adapters/claude/permissions/permission-options.ts index 844d0163f3..d69ede04d7 100644 --- a/packages/agent/src/adapters/claude/permissions/permission-options.ts +++ b/packages/agent/src/adapters/claude/permissions/permission-options.ts @@ -104,6 +104,9 @@ const CONTINUE_LABELS: Record = { bypassPermissions: "Yes, continue bypassing all permissions", }; +/** Opt-in plan approval: clear planning context, then continue from the plan. */ +export const CLEAR_AND_CONTINUE_OPTION_ID = "clearAndContinue"; + export function buildExitPlanModePermissionOptions( previousMode?: string, ): PermissionOption[] { @@ -151,6 +154,16 @@ export function buildExitPlanModePermissionOptions( } } + options.push({ + kind: "allow_once", + name: "Yes, clear history and continue from plan", + optionId: CLEAR_AND_CONTINUE_OPTION_ID, + _meta: { + description: + "Discard planning conversation tokens and start implementation with a fresh context seeded by this plan", + }, + }); + options.push({ kind: "reject_once", name: "No, and tell the agent what to do differently", diff --git a/packages/core/src/sessions/planContinuation.test.ts b/packages/core/src/sessions/planContinuation.test.ts new file mode 100644 index 0000000000..f0ca671320 --- /dev/null +++ b/packages/core/src/sessions/planContinuation.test.ts @@ -0,0 +1,103 @@ +import type { PermissionRequest } from "@posthog/shared"; +import { describe, expect, it } from "vitest"; +import { + buildApprovedPlanContinuationPrompt, + CLEAR_AND_CONTINUE_OPTION_ID, + extractPlanMarkdownFromPermission, + isClearAndContinueOption, + isPlanApprovalAcceptOption, + isPlanApprovalPermission, + resolveClearAndContinueExecutionMode, + shouldContinueFromApprovedPlan, + toApprovedExecutionMode, +} from "./planContinuation"; + +function makePermission( + overrides: Partial & { + toolCall?: PermissionRequest["toolCall"]; + } = {}, +): PermissionRequest { + return { + taskRunId: "run-1", + receivedAt: 0, + options: [], + ...overrides, + } as PermissionRequest; +} + +describe("planContinuation", () => { + it("detects plan approval permissions", () => { + expect( + isPlanApprovalPermission( + makePermission({ toolCall: { kind: "switch_mode" } as never }), + ), + ).toBe(true); + expect( + isPlanApprovalPermission( + makePermission({ toolCall: { kind: "execute" } as never }), + ), + ).toBe(false); + }); + + it("recognizes approve option ids", () => { + expect(isPlanApprovalAcceptOption("auto")).toBe(true); + expect(isPlanApprovalAcceptOption("reject_with_feedback")).toBe(false); + expect(isClearAndContinueOption(CLEAR_AND_CONTINUE_OPTION_ID)).toBe(true); + expect(isClearAndContinueOption("auto")).toBe(false); + }); + + it("only continues for the explicit clear-and-continue option", () => { + const permission = makePermission({ + toolCall: { kind: "switch_mode" } as never, + }); + expect(shouldContinueFromApprovedPlan(permission, "auto")).toBe(false); + expect( + shouldContinueFromApprovedPlan(permission, CLEAR_AND_CONTINUE_OPTION_ID), + ).toBe(true); + expect( + shouldContinueFromApprovedPlan(permission, "reject_with_feedback"), + ).toBe(false); + }); + + it("extracts trimmed plan markdown from rawInput", () => { + const permission = makePermission({ + toolCall: { + kind: "switch_mode", + rawInput: { plan: " ## Steps\n- one " }, + } as never, + }); + expect(extractPlanMarkdownFromPermission(permission)).toBe( + "## Steps\n- one", + ); + expect( + extractPlanMarkdownFromPermission( + makePermission({ toolCall: { kind: "switch_mode" } as never }), + ), + ).toBeNull(); + }); + + it("maps approve option to execution mode", () => { + expect(toApprovedExecutionMode("auto")).toBe("auto"); + expect(toApprovedExecutionMode("acceptEdits")).toBe("acceptEdits"); + }); + + it("resolves clear-and-continue execution mode from answers", () => { + expect( + resolveClearAndContinueExecutionMode({ executionMode: "acceptEdits" }), + ).toBe("acceptEdits"); + expect(resolveClearAndContinueExecutionMode({})).toBe("default"); + expect(resolveClearAndContinueExecutionMode()).toBe("default"); + }); + + it("builds a continuation prompt that embeds the plan", () => { + const blocks = buildApprovedPlanContinuationPrompt("## Fix\n- patch auth"); + expect(blocks).toHaveLength(1); + expect(blocks[0]?.type).toBe("text"); + const textBlock = blocks[0]; + if (textBlock?.type !== "text") { + throw new Error("expected text block"); + } + expect(textBlock.text).toContain("## Fix\n- patch auth"); + expect(textBlock.text).toContain("execute this plan directly"); + }); +}); diff --git a/packages/core/src/sessions/planContinuation.ts b/packages/core/src/sessions/planContinuation.ts new file mode 100644 index 0000000000..630857a46d --- /dev/null +++ b/packages/core/src/sessions/planContinuation.ts @@ -0,0 +1,94 @@ +import type { ContentBlock } from "@agentclientprotocol/sdk"; +import type { ExecutionMode, PermissionRequest } from "@posthog/shared"; + +/** Option ids offered when approving ExitPlanMode (see buildExitPlanModePermissionOptions). */ +export const PLAN_APPROVAL_ACCEPT_OPTION_IDS = [ + "auto", + "default", + "acceptEdits", + "bypassPermissions", +] as const; + +export type PlanApprovalAcceptOptionId = + (typeof PLAN_APPROVAL_ACCEPT_OPTION_IDS)[number]; + +/** + * Opt-in plan-approval option: clear planning context, then continue from the + * approved plan. Kept in sync with `CLEAR_AND_CONTINUE_OPTION_ID` in the Claude + * permission-options module (agent cannot import core). + */ +export const CLEAR_AND_CONTINUE_OPTION_ID = "clearAndContinue"; + +/** Answers key used by the plan UI to carry the ModeSelector choice. */ +export const CLEAR_AND_CONTINUE_EXECUTION_MODE_KEY = "executionMode"; + +export function isPlanApprovalPermission( + permission: PermissionRequest, +): boolean { + return permission.toolCall?.kind === "switch_mode"; +} + +export function isPlanApprovalAcceptOption( + optionId: string, +): optionId is PlanApprovalAcceptOptionId { + return (PLAN_APPROVAL_ACCEPT_OPTION_IDS as readonly string[]).includes( + optionId, + ); +} + +export function isClearAndContinueOption(optionId: string): boolean { + return optionId === CLEAR_AND_CONTINUE_OPTION_ID; +} + +/** + * Only the explicit "clear history and continue from plan" option triggers a + * fresh implementation session — normal approve keeps the planning context. + */ +export function shouldContinueFromApprovedPlan( + permission: PermissionRequest, + optionId: string, +): boolean { + return ( + isPlanApprovalPermission(permission) && isClearAndContinueOption(optionId) + ); +} + +export function extractPlanMarkdownFromPermission( + permission: PermissionRequest, +): string | null { + const rawInput = permission.toolCall?.rawInput as + | { plan?: unknown } + | undefined; + const plan = rawInput?.plan; + if (typeof plan !== "string") return null; + const trimmed = plan.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +export function toApprovedExecutionMode( + optionId: PlanApprovalAcceptOptionId, +): ExecutionMode { + return optionId; +} + +export function resolveClearAndContinueExecutionMode( + answers?: Record, +): ExecutionMode { + const mode = answers?.[CLEAR_AND_CONTINUE_EXECUTION_MODE_KEY]; + if (mode && isPlanApprovalAcceptOption(mode)) { + return toApprovedExecutionMode(mode); + } + return "default"; +} + +export function buildApprovedPlanContinuationPrompt( + planMarkdown: string, +): ContentBlock[] { + const text = [ + "The user approved the implementation plan below. Planning is complete — execute this plan directly.", + "Do not re-enter plan mode unless the user asks for a significant change in direction.", + "", + planMarkdown, + ].join("\n"); + return [{ type: "text", text }]; +} diff --git a/packages/core/src/sessions/sessionService.ts b/packages/core/src/sessions/sessionService.ts index 899f59a1ca..32b1abb6d3 100644 --- a/packages/core/src/sessions/sessionService.ts +++ b/packages/core/src/sessions/sessionService.ts @@ -79,6 +79,12 @@ import { type PermissionSelectionPlan, planPermissionResponse, } from "./permissionResponse"; +import { + buildApprovedPlanContinuationPrompt, + extractPlanMarkdownFromPermission, + resolveClearAndContinueExecutionMode, + shouldContinueFromApprovedPlan, +} from "./planContinuation"; import { convertStoredEntriesToEvents, createUserShellExecuteEvent, @@ -4240,6 +4246,66 @@ export class SessionService { await this.reconnectInPlace(taskId, repoPath, null); } + /** + * After plan approval, discard the planning conversation and continue + * implementation on a fresh local task run seeded with the approved plan. + * Leaves `resetSession` untouched (same-run error recovery). + * + * Cloud runs are skipped until cloud session lifecycle supports this path. + */ + async continueFromApprovedPlan( + taskId: string, + repoPath: string, + planMarkdown: string, + executionMode: ExecutionMode, + ): Promise { + const session = this.d.store.getSessionByTaskId(taskId); + if (!session) { + throw new Error("No active session for task"); + } + if (session.isCloud) { + this.d.log.info("Skipping plan continuation on cloud session", { + taskId, + }); + return; + } + + const { taskRunId, taskTitle, adapter, model, reasoningLevel } = session; + + // Interrupt like Stop (cancelPrompt), not hard-kill (cancel). Hard-kill + // closes ACP under the in-flight prompt and surfaces "ACP connection closed". + // Keep the store entry until createNewLocalSession swaps it — otherwise the + // connect effect resumes the stale run. + try { + await this.d.trpc.agent.cancelPrompt.mutate({ sessionId: taskRunId }); + } catch { + // best-effort: the turn may already be idle + } + this.unsubscribeFromChannel(taskRunId); + this.localRepoPaths.set(taskId, repoPath); + + const authStatus = await this.getAuthCredentialsStatus(); + if (authStatus.kind === "restoring") { + throw new Error("Authentication is still restoring. Please wait."); + } + if (authStatus.kind !== "ready") { + throw new Error("Unable to reach server. Please check your connection."); + } + + const initialPrompt = buildApprovedPlanContinuationPrompt(planMarkdown); + await this.createNewLocalSession( + taskId, + taskTitle, + repoPath, + authStatus.auth, + initialPrompt, + executionMode, + adapter, + model, + reasoningLevel, + ); + } + /** * Cancel the current backend agent and reconnect under the same taskRunId. * Does NOT remove the session from the store (avoids connect effect loop). @@ -5520,9 +5586,44 @@ export class SessionService { modeOption: SessionConfigOption | undefined, customInput?: string, answers?: Record, + repoPath?: string | null, ): Promise { const plan = planPermissionResponse(permission, optionId, customInput); + // Clear-and-continue: rebuild on a fresh run instead of answering the + // planning turn. Sending "allow" here would let the old session start + // generating the implementation reply in the stale (full planning) context; + // cancelling that mid-stream is what closes the ACP connection for ~2s and + // logs "Upstream fetch aborted after client disconnect". So resolve the + // pending-permission card locally and let continueFromApprovedPlan cancel + // the turn and reseed with the plan — no backend "allow" is sent. + const clearAndContinuePlan = + repoPath && shouldContinueFromApprovedPlan(permission, optionId) + ? extractPlanMarkdownFromPermission(permission) + : null; + + if (repoPath && clearAndContinuePlan) { + const session = this.d.store.getSessionByTaskId(taskId); + if (session) { + this.resolvePermission(session, permission.toolCallId); + } + try { + await this.continueFromApprovedPlan( + taskId, + repoPath, + clearAndContinuePlan, + resolveClearAndContinueExecutionMode(answers), + ); + } catch (error) { + this.d.log.error("Failed to continue from approved plan", { + taskId, + error, + }); + throw error; + } + return plan; + } + if (plan.applyAllowAlwaysUpgrade) { this.applyAllowAlwaysUpgrade(taskId, modeOption); } diff --git a/packages/core/src/sessions/sessionServicePlanContinuation.test.ts b/packages/core/src/sessions/sessionServicePlanContinuation.test.ts new file mode 100644 index 0000000000..fe56207ea1 --- /dev/null +++ b/packages/core/src/sessions/sessionServicePlanContinuation.test.ts @@ -0,0 +1,194 @@ +import type { AgentSession } from "@posthog/shared"; +import { describe, expect, it, vi } from "vitest"; +import { SessionService, type SessionServiceDeps } from "./sessionService"; + +function makeSession(overrides: Partial = {}): AgentSession { + return { + taskRunId: "run-1", + taskId: "task-1", + taskTitle: "Test task", + channel: "", + events: [], + startedAt: 1, + status: "connected", + isPromptPending: false, + isCompacting: false, + promptStartedAt: null, + pendingPermissions: new Map(), + pausedDurationMs: 0, + messageQueue: [], + optimisticItems: [], + isCloud: false, + adapter: "claude", + model: "claude-sonnet", + executionMode: "plan", + ...overrides, + } as AgentSession; +} + +function makePermission() { + return { + taskRunId: "run-1", + receivedAt: 0, + toolCallId: "tool-1", + toolCall: { + kind: "switch_mode", + rawInput: { plan: "## Plan\n- fix bug" }, + }, + options: [ + { optionId: "auto", kind: "allow_always", name: "Auto" }, + { + optionId: "clearAndContinue", + kind: "allow_once", + name: "Yes, clear history and continue from plan", + }, + ], + }; +} + +function createHarness(session: AgentSession) { + const sessions: Record = { + [session.taskRunId]: session, + }; + const deps = { + store: { + getSessionByTaskId: (taskId: string) => + Object.values(sessions).find((s) => s.taskId === taskId), + getSessions: () => sessions, + removeSession: vi.fn((taskRunId: string) => { + delete sessions[taskRunId]; + }), + setSession: vi.fn((next: AgentSession) => { + sessions[next.taskRunId] = next; + }), + updateSession: vi.fn(), + setPendingPermissions: vi.fn(), + }, + log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + trpc: { + agent: { + respondToPermission: { mutate: vi.fn().mockResolvedValue(undefined) }, + cancel: { mutate: vi.fn().mockResolvedValue(undefined) }, + cancelPrompt: { mutate: vi.fn().mockResolvedValue(undefined) }, + onSessionIdleKilled: { + subscribe: () => ({ unsubscribe: vi.fn() }), + }, + }, + }, + getIsOnline: () => true, + } as unknown as SessionServiceDeps; + + const service = new SessionService(deps); + const continueFromApprovedPlan = vi + .spyOn(service, "continueFromApprovedPlan") + .mockResolvedValue(undefined); + const respondToPermission = vi + .spyOn(service, "respondToPermission") + .mockResolvedValue(undefined); + + return { service, continueFromApprovedPlan, respondToPermission }; +} + +describe("SessionService plan continuation", () => { + it("continues only when clearAndContinue is selected", async () => { + const session = makeSession(); + const { service, continueFromApprovedPlan, respondToPermission } = + createHarness(session); + + await service.resolvePermissionSelection( + "task-1", + makePermission() as never, + "clearAndContinue", + undefined, + undefined, + { executionMode: "acceptEdits" }, + "/repo", + ); + + expect(continueFromApprovedPlan).toHaveBeenCalledWith( + "task-1", + "/repo", + "## Plan\n- fix bug", + "acceptEdits", + ); + // The planning turn must NOT be answered "allow": doing so lets the old + // session start generating in the stale context, and cancelling that + // mid-stream is what closed the ACP connection for ~2s. + expect(respondToPermission).not.toHaveBeenCalled(); + }); + + it("does not continue on normal approve", async () => { + const session = makeSession(); + const { service, continueFromApprovedPlan, respondToPermission } = + createHarness(session); + + await service.resolvePermissionSelection( + "task-1", + makePermission() as never, + "auto", + undefined, + undefined, + undefined, + "/repo", + ); + + expect(continueFromApprovedPlan).not.toHaveBeenCalled(); + // Normal approve still answers the permission in place. + expect(respondToPermission).toHaveBeenCalledTimes(1); + }); + + it("does not continue without repoPath", async () => { + const session = makeSession(); + const { service, continueFromApprovedPlan } = createHarness(session); + + await service.resolvePermissionSelection( + "task-1", + makePermission() as never, + "clearAndContinue", + undefined, + undefined, + { executionMode: "auto" }, + ); + + expect(continueFromApprovedPlan).not.toHaveBeenCalled(); + }); + + it("does not continue on plan rejection", async () => { + const session = makeSession(); + const { service, continueFromApprovedPlan } = createHarness(session); + + await service.resolvePermissionSelection( + "task-1", + makePermission() as never, + "reject_with_feedback", + undefined, + "try again", + undefined, + "/repo", + ); + + expect(continueFromApprovedPlan).not.toHaveBeenCalled(); + }); + + it("defaults clear-and-continue mode to default when answers omit it", async () => { + const session = makeSession(); + const { service, continueFromApprovedPlan } = createHarness(session); + + await service.resolvePermissionSelection( + "task-1", + makePermission() as never, + "clearAndContinue", + undefined, + undefined, + undefined, + "/repo", + ); + + expect(continueFromApprovedPlan).toHaveBeenCalledWith( + "task-1", + "/repo", + "## Plan\n- fix bug", + "default", + ); + }); +}); diff --git a/packages/ui/src/features/permissions/PlanApprovalSelector.stories.tsx b/packages/ui/src/features/permissions/PlanApprovalSelector.stories.tsx index 4f0114719c..ad09005524 100644 --- a/packages/ui/src/features/permissions/PlanApprovalSelector.stories.tsx +++ b/packages/ui/src/features/permissions/PlanApprovalSelector.stories.tsx @@ -28,6 +28,15 @@ const DEFAULT_MODE: PermissionOption = { name: "Yes, and manually approve edits", optionId: "default", }; +const CLEAR_AND_CONTINUE: PermissionOption = { + kind: "allow_once", + name: "Yes, clear history and continue from plan", + optionId: "clearAndContinue", + _meta: { + description: + "Discard planning conversation tokens and start implementation with a fresh context seeded by this plan", + }, +}; const REJECT: PermissionOption = { kind: "reject_once", name: "No, and tell the agent what to do differently", @@ -51,10 +60,21 @@ type Story = StoryObj; // Non-root / non-sandbox: bypass is not offered. Default is "manually approve". export const Default: Story = { - args: { options: [AUTO, ACCEPT_EDITS, DEFAULT_MODE, REJECT] }, + args: { + options: [AUTO, ACCEPT_EDITS, DEFAULT_MODE, CLEAR_AND_CONTINUE, REJECT], + }, }; // Sandbox / root: bypass is offered but still not the default. export const WithBypass: Story = { - args: { options: [BYPASS, AUTO, ACCEPT_EDITS, DEFAULT_MODE, REJECT] }, + args: { + options: [ + BYPASS, + AUTO, + ACCEPT_EDITS, + DEFAULT_MODE, + CLEAR_AND_CONTINUE, + REJECT, + ], + }, }; diff --git a/packages/ui/src/features/permissions/PlanApprovalSelector.test.tsx b/packages/ui/src/features/permissions/PlanApprovalSelector.test.tsx index 06f257c430..cbe0c81834 100644 --- a/packages/ui/src/features/permissions/PlanApprovalSelector.test.tsx +++ b/packages/ui/src/features/permissions/PlanApprovalSelector.test.tsx @@ -22,6 +22,15 @@ const DEFAULT_MODE: PermissionOption = { name: "Yes, and manually approve edits", optionId: "default", }; +const CLEAR_AND_CONTINUE: PermissionOption = { + kind: "allow_once", + name: "Yes, clear history and continue from plan", + optionId: "clearAndContinue", + _meta: { + description: + "Discard planning conversation tokens and start implementation with a fresh context seeded by this plan", + }, +}; const REJECT: PermissionOption = { kind: "reject_once", name: "No, and tell the agent what to do differently", @@ -60,13 +69,13 @@ describe("PlanApprovalSelector", () => { { label: "there is no prior or remembered mode", lastMode: null, - options: [AUTO, ACCEPT_EDITS, DEFAULT_MODE, REJECT], + options: [AUTO, ACCEPT_EDITS, DEFAULT_MODE, CLEAR_AND_CONTINUE, REJECT], expected: "auto", }, { label: "a last choice is remembered", lastMode: "acceptEdits", - options: [AUTO, ACCEPT_EDITS, DEFAULT_MODE], + options: [AUTO, ACCEPT_EDITS, DEFAULT_MODE, CLEAR_AND_CONTINUE], expected: "acceptEdits", }, ] as const)( @@ -85,19 +94,59 @@ describe("PlanApprovalSelector", () => { it("remembers the chosen mode on approve", async () => { const user = userEvent.setup(); - renderSelector([AUTO, ACCEPT_EDITS, DEFAULT_MODE, REJECT]); + renderSelector([ + AUTO, + ACCEPT_EDITS, + DEFAULT_MODE, + CLEAR_AND_CONTINUE, + REJECT, + ]); await user.click(screen.getByText("Approve and proceed")); expect(useSettingsStore.getState().lastPlanApprovalMode).toBe("auto"); }); + it("selects clear-and-continue with the current mode as answers", async () => { + const user = userEvent.setup(); + const { onSelect } = renderSelector([ + AUTO, + ACCEPT_EDITS, + DEFAULT_MODE, + CLEAR_AND_CONTINUE, + REJECT, + ]); + + await user.click( + screen.getByText("Approve, clear history, and continue from plan"), + ); + + expect(onSelect).toHaveBeenCalledWith("clearAndContinue", undefined, { + executionMode: "auto", + }); + }); + + it("does not treat clear-and-continue as a mode in the ModeSelector", () => { + renderSelector([AUTO, CLEAR_AND_CONTINUE, REJECT]); + + expect( + screen.getByText("Approve, clear history, and continue from plan"), + ).toBeTruthy(); + // ModeSelector options come from approve modes only — clearAndContinue + // must stay its own row, not appear as a selectable execution mode value. + expect(screen.queryByRole("option", { name: /clear history/i })).toBeNull(); + }); + it("rejects with the typed feedback", async () => { const user = userEvent.setup(); - const { onSelect } = renderSelector([DEFAULT_MODE, REJECT]); + const { onSelect } = renderSelector([ + DEFAULT_MODE, + CLEAR_AND_CONTINUE, + REJECT, + ]); // Selecting the reject row activates its inline textarea (as before). - await user.click(screen.getByText("2.")); + await user.click(screen.getByText("3.")); await user.type( screen.getByPlaceholderText(/tell the agent what to do differently/i), "please use hooks{Enter}", diff --git a/packages/ui/src/features/permissions/PlanApprovalSelector.tsx b/packages/ui/src/features/permissions/PlanApprovalSelector.tsx index 1cd5e3541f..58c516d57e 100644 --- a/packages/ui/src/features/permissions/PlanApprovalSelector.tsx +++ b/packages/ui/src/features/permissions/PlanApprovalSelector.tsx @@ -16,6 +16,9 @@ import { type BasePermissionProps, toSelectorOptions } from "./types"; const TITLE = "Implementation Plan"; const QUESTION = "Approve this plan to proceed?"; +const CLEAR_AND_CONTINUE_OPTION_ID = "clearAndContinue"; +const CLEAR_AND_CONTINUE_LABEL = + "Approve, clear history, and continue from plan"; function isApprove(option: PermissionOption): boolean { return option.kind === "allow_once" || option.kind === "allow_always"; @@ -32,6 +35,10 @@ function hasCustomInput(option: PermissionOption): boolean { ); } +function isClearAndContinueOption(option: PermissionOption): boolean { + return option.optionId === CLEAR_AND_CONTINUE_OPTION_ID; +} + // Don't steal focus from an interactive element in a different grid cell // (multi-task view). Mirrors the guard in useActionSelectorState. function isInteractiveElementInDifferentCell( @@ -57,7 +64,8 @@ function isInteractiveElementInDifferentCell( * the per-mode "Yes, and…" rows into the shared prompt-input `ModeSelector` * dropdown beside the Approve line. Everything is derived from the permission * `options` and reported through `onSelect`/`onCancel`, so there is no backend - * contract change: approve → `onSelect()`, reject → + * contract change: approve → `onSelect()`, clear-and-continue → + * `onSelect(clearAndContinue, undefined, { executionMode })`, reject → * `onSelect(, feedback)`. */ export function PlanApprovalSelector({ @@ -65,7 +73,17 @@ export function PlanApprovalSelector({ onSelect, onCancel, }: BasePermissionProps) { - const approveOptions = useMemo(() => options.filter(isApprove), [options]); + const clearAndContinueOption = useMemo( + () => options.find(isClearAndContinueOption), + [options], + ); + const approveOptions = useMemo( + () => + options.filter( + (option) => isApprove(option) && !isClearAndContinueOption(option), + ), + [options], + ); const rejectOption = useMemo( () => options.find((o) => isReject(o) && hasCustomInput(o)) ?? @@ -99,8 +117,10 @@ export function PlanApprovalSelector({ const [feedback, setFeedback] = useState(""); const containerRef = useRef(null); - const rejectIndex = rejectOption ? 1 : -1; - const rowCount = rejectOption ? 2 : 1; + const clearIndex = clearAndContinueOption ? 1 : -1; + const rejectIndex = rejectOption ? (clearAndContinueOption ? 2 : 1) : -1; + const rowCount = + 1 + (clearAndContinueOption ? 1 : 0) + (rejectOption ? 1 : 0); // The reject row is the inline feedback textarea, so "on the reject row" and // "editing feedback" are the same state — derive it, don't duplicate it. const rejectSelected = selectedIndex === rejectIndex; @@ -134,7 +154,7 @@ export function PlanApprovalSelector({ }, []); // Move selection and manage focus together (no state-syncing effect): the - // approve row focuses the container for keyboard nav; the reject row's + // approve/clear rows focus the container for keyboard nav; the reject row's // textarea focuses itself via its `active` prop. const selectRow = (index: number) => { setHoveredIndex(null); @@ -149,6 +169,14 @@ export function PlanApprovalSelector({ onSelect(selectedMode); }; + const approveClearAndContinue = () => { + if (!selectedMode || !clearAndContinueOption) return; + setLastApprovalMode(selectedMode as ExecutionMode); + onSelect(CLEAR_AND_CONTINUE_OPTION_ID, undefined, { + executionMode: selectedMode, + }); + }; + const submitReject = () => { const text = feedback.trim(); // Exactly as before: reject requires feedback text; empty Enter is a no-op @@ -161,6 +189,20 @@ export function PlanApprovalSelector({ selectRow((selectedIndex + delta + rowCount) % rowCount); }; + const activateSelectedRow = () => { + if (selectedIndex === 0) { + approve(); + return; + } + if (selectedIndex === clearIndex) { + approveClearAndContinue(); + return; + } + if (selectedIndex === rejectIndex) { + selectRow(rejectIndex); + } + }; + const handleContainerKeyDown = (e: React.KeyboardEvent) => { // The reject textarea owns the keyboard while it's the selected row. if (rejectSelected) return; @@ -175,7 +217,7 @@ export function PlanApprovalSelector({ break; case "Enter": e.preventDefault(); - approve(); + activateSelectedRow(); break; case "Escape": e.preventDefault(); @@ -188,6 +230,7 @@ export function PlanApprovalSelector({ if (idx < rowCount) { e.preventDefault(); if (idx === 0) approve(); + else if (idx === clearIndex) approveClearAndContinue(); else selectRow(idx); } } @@ -239,6 +282,13 @@ export function PlanApprovalSelector({ ); const approveActive = selectedIndex === 0 || hoveredIndex === 0; + const clearActive = + clearIndex >= 0 && + (selectedIndex === clearIndex || hoveredIndex === clearIndex); + const clearLabel = + clearAndContinueOption?.name === "Yes, clear history and continue from plan" + ? CLEAR_AND_CONTINUE_LABEL + : (clearAndContinueOption?.name ?? CLEAR_AND_CONTINUE_LABEL); return ( + {/* Opt-in clear-and-continue — same selected mode, fresh agent context. */} + {clearAndContinueOption && ( + setHoveredIndex(clearIndex)} + onMouseLeave={() => setHoveredIndex(null)} + py="1" + className={rowClass(clearIndex)} + > + + {caret(clearIndex)} + {number(clearIndex)} + + {clearLabel} + + + + )} + {/* Reject line — the inline feedback textarea, exactly as before. */} {rejectOption && ( selectRow(1)} - onMouseEnter={() => setHoveredIndex(1)} + onClick={() => selectRow(rejectIndex)} + onMouseEnter={() => setHoveredIndex(rejectIndex)} onMouseLeave={() => setHoveredIndex(null)} py="1" - className={rowClass(1)} + className={rowClass(rejectIndex)} > - {caret(1)} - {number(1)} + {caret(rejectIndex)} + {number(rejectIndex)} selectRow(0)} + onNavigateUp={() => + selectRow(clearIndex >= 0 ? clearIndex : 0) + } onNavigateDown={() => selectRow(0)} onEscape={() => { setFeedback(""); diff --git a/packages/ui/src/features/sessions/components/SessionView.tsx b/packages/ui/src/features/sessions/components/SessionView.tsx index cb9566e436..17d368c8fa 100644 --- a/packages/ui/src/features/sessions/components/SessionView.tsx +++ b/packages/ui/src/features/sessions/components/SessionView.tsx @@ -364,6 +364,7 @@ export function SessionView({ modeOption, customInput, answers, + repoPath, ); if (plan.resendPromptText) { @@ -380,6 +381,7 @@ export function SessionView({ sessionId, modeOption, sessionService, + repoPath, ], );