diff --git a/products/desktop/packages/agent/src/adapters/claude/permissions/permission-handlers.ts b/products/desktop/packages/agent/src/adapters/claude/permissions/permission-handlers.ts index c6aad314abbb..066a13fc6a8d 100644 --- a/products/desktop/packages/agent/src/adapters/claude/permissions/permission-handlers.ts +++ b/products/desktop/packages/agent/src/adapters/claude/permissions/permission-handlers.ts @@ -5,6 +5,7 @@ import type { ToolCallContent, } from "@agentclientprotocol/sdk"; import type { + PermissionMode, PermissionRuleValue, PermissionUpdate, } from "@anthropic-ai/claude-agent-sdk"; @@ -39,6 +40,7 @@ import type { Session } from "../types"; import { buildExitPlanModePermissionOptions, buildPermissionOptions, + CLEAR_AND_CONTINUE_OPTION_ID, } from "./permission-options"; const SPEAK_TOOL_ID = qualifiedLocalToolName(SPEAK_TOOL_NAME); @@ -284,32 +286,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/products/desktop/packages/agent/src/adapters/claude/permissions/permission-options.test.ts b/products/desktop/packages/agent/src/adapters/claude/permissions/permission-options.test.ts index df6fbabdbc2d..36290c809595 100644 --- a/products/desktop/packages/agent/src/adapters/claude/permissions/permission-options.test.ts +++ b/products/desktop/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/products/desktop/packages/agent/src/adapters/claude/permissions/permission-options.ts b/products/desktop/packages/agent/src/adapters/claude/permissions/permission-options.ts index 844d0163f335..d69ede04d7d7 100644 --- a/products/desktop/packages/agent/src/adapters/claude/permissions/permission-options.ts +++ b/products/desktop/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/products/desktop/packages/core/src/sessions/planContinuation.test.ts b/products/desktop/packages/core/src/sessions/planContinuation.test.ts new file mode 100644 index 000000000000..7f56315a2af3 --- /dev/null +++ b/products/desktop/packages/core/src/sessions/planContinuation.test.ts @@ -0,0 +1,111 @@ +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(); + // A non-string plan must not reach the continuation prompt. + expect( + extractPlanMarkdownFromPermission( + makePermission({ + toolCall: { kind: "switch_mode", rawInput: { plan: 42 } } 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/products/desktop/packages/core/src/sessions/planContinuation.ts b/products/desktop/packages/core/src/sessions/planContinuation.ts new file mode 100644 index 000000000000..946a28b575f5 --- /dev/null +++ b/products/desktop/packages/core/src/sessions/planContinuation.ts @@ -0,0 +1,101 @@ +import type { ContentBlock } from "@agentclientprotocol/sdk"; +import type { ExecutionMode, PermissionRequest } from "@posthog/shared"; +import { z } from "zod"; + +/** 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"; + +/** + * The adapter intercepts this before the SDK conversion and swaps in a fresh + * SDK session, so it is how core asks for a clear. + */ +export const CLEAR_CONVERSATION_COMMAND = "/clear"; + +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) + ); +} + +/** ExitPlanMode carries the approved plan as markdown on its tool input. */ +const planToolInputSchema = z.object({ plan: z.string() }); + +export function extractPlanMarkdownFromPermission( + permission: PermissionRequest, +): string | null { + const parsed = planToolInputSchema.safeParse(permission.toolCall?.rawInput); + if (!parsed.success) return null; + const trimmed = parsed.data.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/products/desktop/packages/core/src/sessions/sessionService.ts b/products/desktop/packages/core/src/sessions/sessionService.ts index a54b72357492..c335087871e1 100644 --- a/products/desktop/packages/core/src/sessions/sessionService.ts +++ b/products/desktop/packages/core/src/sessions/sessionService.ts @@ -95,6 +95,13 @@ import { planPermissionResponse, resolveAllowAlwaysUpgradeMode, } from "./permissionResponse"; +import { + buildApprovedPlanContinuationPrompt, + CLEAR_CONVERSATION_COMMAND, + extractPlanMarkdownFromPermission, + resolveClearAndContinueExecutionMode, + shouldContinueFromApprovedPlan, +} from "./planContinuation"; import { convertStoredEntriesToEvents, createUserShellExecuteEvent, @@ -5516,6 +5523,38 @@ export class SessionService { await this.reconnectInPlace(taskId, repoPath, null); } + /** + * After plan approval, clear the conversation and continue implementation in + * the same run, seeded with the approved plan. The task run, the ACP session + * and the visible thread all survive; only the SDK conversation is retired. + * + * Callers gate on the agent advertising `conversationClear`. + */ + async continueFromApprovedPlan( + taskId: string, + toolCallId: string, + planMarkdown: string, + executionMode: ExecutionMode, + ): Promise { + const session = this.d.store.getSessionByTaskId(taskId); + if (!session) { + throw new Error("No active session for task"); + } + + // The turn is parked on the ExitPlanMode permission and /clear only lands + // on an idle input stream. Cancel both, which stops the turn without the + // hard-kill that closes ACP. + await this.cancelPermissionAndPrompt(taskId, toolCallId); + + await this.setSessionConfigOptionByCategory(taskId, "mode", executionMode); + + await this.sendPrompt(taskId, CLEAR_CONVERSATION_COMMAND); + await this.sendPrompt( + taskId, + buildApprovedPlanContinuationPrompt(planMarkdown), + ); + } + /** * Cancel the current backend agent and reconnect under the same taskRunId. * Does NOT remove the session from the store (avoids connect effect loop). @@ -7290,6 +7329,43 @@ export class SessionService { ): Promise { const plan = planPermissionResponse(permission, optionId, customInput); + // Clear-and-continue: retire the planning conversation with /clear instead + // of answering the planning turn. Sending "allow" here would let the agent + // start the implementation reply in the stale (full planning) context. + const clearAndContinuePlan = shouldContinueFromApprovedPlan( + permission, + optionId, + ) + ? extractPlanMarkdownFromPermission(permission) + : null; + + // An agent older than the /clear capability ignores the boundary and + // rebuilds the conversation it was meant to retire, so fall through to a + // normal approve rather than show a clear that did not happen. + const session = this.d.store.getSessionByTaskId(taskId); + if (clearAndContinuePlan && session?.conversationClear) { + try { + await this.continueFromApprovedPlan( + taskId, + permission.toolCallId, + clearAndContinuePlan, + resolveClearAndContinueExecutionMode(answers), + ); + } catch (error) { + this.d.log.error("Failed to continue from approved plan", { + taskId, + error, + }); + throw error; + } + return plan; + } + if (clearAndContinuePlan) { + this.d.log.info("Agent lacks /clear; approving plan without clearing", { + taskId, + }); + } + if (plan.applyAllowAlwaysUpgrade) { this.applyAllowAlwaysUpgrade(taskId, modeOption); } diff --git a/products/desktop/packages/core/src/sessions/sessionServicePlanContinuation.test.ts b/products/desktop/packages/core/src/sessions/sessionServicePlanContinuation.test.ts new file mode 100644 index 000000000000..55f1a10239b7 --- /dev/null +++ b/products/desktop/packages/core/src/sessions/sessionServicePlanContinuation.test.ts @@ -0,0 +1,216 @@ +import type { AgentSession, ExecutionMode } 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", + conversationClear: true, + ...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, + { stubContinuation = true }: { stubContinuation?: boolean } = {}, +) { + 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", + ); + if (stubContinuation) continueFromApprovedPlan.mockResolvedValue(undefined); + const respondToPermission = vi + .spyOn(service, "respondToPermission") + .mockResolvedValue(undefined); + + return { service, continueFromApprovedPlan, respondToPermission }; +} + +describe("SessionService plan continuation", () => { + it.each<[string, Record | undefined, ExecutionMode]>([ + ["the selected mode", { executionMode: "acceptEdits" }, "acceptEdits"], + ["default when answers omit it", undefined, "default"], + [ + "default when answers carry an unknown mode", + { executionMode: "x" }, + "default", + ], + ])("continues from the plan with %s", async (_label, answers, expected) => { + const { service, continueFromApprovedPlan, respondToPermission } = + createHarness(makeSession()); + + await service.resolvePermissionSelection( + "task-1", + makePermission() as never, + "clearAndContinue", + undefined, + undefined, + answers, + ); + + expect(continueFromApprovedPlan).toHaveBeenCalledWith( + "task-1", + "tool-1", + "## Plan\n- fix bug", + expected, + ); + // Answering "allow" would let the agent start implementing in the stale + // planning context before the clear lands. + expect(respondToPermission).not.toHaveBeenCalled(); + }); + + it("does not continue on normal approve", async () => { + const { service, continueFromApprovedPlan, respondToPermission } = + createHarness(makeSession()); + + await service.resolvePermissionSelection( + "task-1", + makePermission() as never, + "auto", + undefined, + undefined, + undefined, + ); + + expect(continueFromApprovedPlan).not.toHaveBeenCalled(); + expect(respondToPermission).toHaveBeenCalledTimes(1); + }); + + it("approves without clearing when the agent cannot clear", async () => { + const { service, continueFromApprovedPlan, respondToPermission } = + createHarness(makeSession({ conversationClear: false })); + + await service.resolvePermissionSelection( + "task-1", + makePermission() as never, + "clearAndContinue", + undefined, + undefined, + { executionMode: "auto" }, + ); + + // An agent predating the capability ignores the boundary and rebuilds the + // conversation, so a clear here would be shown but not real. + expect(continueFromApprovedPlan).not.toHaveBeenCalled(); + expect(respondToPermission).toHaveBeenCalledTimes(1); + }); + + it("does not continue on plan rejection", async () => { + const { service, continueFromApprovedPlan } = createHarness(makeSession()); + + await service.resolvePermissionSelection( + "task-1", + makePermission() as never, + "reject_with_feedback", + undefined, + "try again", + undefined, + ); + + expect(continueFromApprovedPlan).not.toHaveBeenCalled(); + }); + + it("ends the planning turn and clears before sending the plan", async () => { + const { service } = createHarness(makeSession(), { + stubContinuation: false, + }); + const cancelPermissionAndPrompt = vi + .spyOn(service, "cancelPermissionAndPrompt") + .mockResolvedValue(undefined); + const setConfigOption = vi + .spyOn(service, "setSessionConfigOptionByCategory") + .mockResolvedValue(undefined); + const sendPrompt = vi + .spyOn(service, "sendPrompt") + .mockResolvedValue({ stopReason: "end_turn" }); + + await service.continueFromApprovedPlan( + "task-1", + "tool-1", + "## Plan\n- fix bug", + "acceptEdits", + ); + + expect(cancelPermissionAndPrompt).toHaveBeenCalledWith("task-1", "tool-1"); + expect(setConfigOption).toHaveBeenCalledWith( + "task-1", + "mode", + "acceptEdits", + ); + // Order is the whole feature: a plan sent before the clear lands in the + // planning context the clear was meant to retire. + expect(sendPrompt.mock.calls.map((call) => call[1])).toEqual([ + "/clear", + [{ type: "text", text: expect.stringContaining("## Plan\n- fix bug") }], + ]); + }); +}); diff --git a/products/desktop/packages/ui/src/features/permissions/PlanApprovalSelector.stories.tsx b/products/desktop/packages/ui/src/features/permissions/PlanApprovalSelector.stories.tsx index 4f0114719c11..ad0900552444 100644 --- a/products/desktop/packages/ui/src/features/permissions/PlanApprovalSelector.stories.tsx +++ b/products/desktop/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/products/desktop/packages/ui/src/features/permissions/PlanApprovalSelector.test.tsx b/products/desktop/packages/ui/src/features/permissions/PlanApprovalSelector.test.tsx index a606ff03fb05..44ada2bfe3a2 100644 --- a/products/desktop/packages/ui/src/features/permissions/PlanApprovalSelector.test.tsx +++ b/products/desktop/packages/ui/src/features/permissions/PlanApprovalSelector.test.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", @@ -66,13 +75,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)( @@ -91,7 +100,13 @@ 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")); @@ -183,12 +198,46 @@ describe("PlanApprovalSelector", () => { expect(onSelect).toHaveBeenCalledWith("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/products/desktop/packages/ui/src/features/permissions/PlanApprovalSelector.tsx b/products/desktop/packages/ui/src/features/permissions/PlanApprovalSelector.tsx index 35bacec515bf..64cbcda9cb5e 100644 --- a/products/desktop/packages/ui/src/features/permissions/PlanApprovalSelector.tsx +++ b/products/desktop/packages/ui/src/features/permissions/PlanApprovalSelector.tsx @@ -1,4 +1,7 @@ -import type { SessionConfigOption } from "@agentclientprotocol/sdk"; +import type { + PermissionOption, + SessionConfigOption, +} from "@agentclientprotocol/sdk"; import { resolveInitialPlanApprovalOption, selectPlanPermissionOptions, @@ -17,6 +20,13 @@ 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 isClearAndContinueOption(option: PermissionOption): boolean { + return option.optionId === CLEAR_AND_CONTINUE_OPTION_ID; +} // Don't steal focus from a different task in multi-task view. function isElementInDifferentCell( @@ -41,7 +51,8 @@ function isElementInDifferentCell( * 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({ @@ -50,10 +61,18 @@ export function PlanApprovalSelector({ onSelect, onCancel, }: BasePermissionProps) { - const { approvals: approveOptions, rejection: rejectOption } = useMemo( + const clearAndContinueOption = useMemo( + () => options.find(isClearAndContinueOption), + [options], + ); + const { approvals, rejection: rejectOption } = useMemo( () => selectPlanPermissionOptions(options), [options], ); + const approveOptions = useMemo( + () => approvals.filter((option) => !isClearAndContinueOption(option)), + [approvals], + ); const lastApprovalMode = useSettingsStore((s) => s.lastPlanApprovalMode); const setLastApprovalMode = useSettingsStore( @@ -93,8 +112,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; @@ -128,7 +149,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); @@ -143,6 +164,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 @@ -155,6 +184,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) => { if (e.nativeEvent.isComposing || e.keyCode === 229) return; // The reject textarea owns the keyboard while it's the selected row. @@ -170,7 +213,7 @@ export function PlanApprovalSelector({ break; case "Enter": e.preventDefault(); - approve(); + activateSelectedRow(); break; case "Escape": e.preventDefault(); @@ -183,6 +226,7 @@ export function PlanApprovalSelector({ if (idx < rowCount) { e.preventDefault(); if (idx === 0) approve(); + else if (idx === clearIndex) approveClearAndContinue(); else selectRow(idx); } } @@ -234,6 +278,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={onCancel} onSubmit={submitReject}