From acffdd3090476e7e9846b1f3c1ed872331db49f6 Mon Sep 17 00:00:00 2001 From: igennova Date: Wed, 15 Jul 2026 13:03:28 +0530 Subject: [PATCH 1/3] feat(sessions): add opt-in clear-and-continue on plan approval Closes #704. Lets users discard planning context and start a fresh local run seeded with the approved plan, without changing resetSession. --- .../claude/permissions/permission-handlers.ts | 82 ++++++-- .../permissions/permission-options.test.ts | 14 ++ .../claude/permissions/permission-options.ts | 13 ++ .../src/sessions/planContinuation.test.ts | 103 ++++++++++ .../core/src/sessions/planContinuation.ts | 94 +++++++++ .../core/src/sessions/sessionService.ts | 101 +++++++++ .../sessionServicePlanContinuation.test.ts | 194 ++++++++++++++++++ .../PlanApprovalSelector.stories.tsx | 24 ++- .../permissions/PlanApprovalSelector.test.tsx | 59 +++++- .../permissions/PlanApprovalSelector.tsx | 100 +++++++-- .../sessions/components/SessionView.tsx | 2 + 11 files changed, 746 insertions(+), 40 deletions(-) create mode 100644 products/desktop/packages/core/src/sessions/planContinuation.test.ts create mode 100644 products/desktop/packages/core/src/sessions/planContinuation.ts create mode 100644 products/desktop/packages/core/src/sessions/sessionServicePlanContinuation.test.ts 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 928074dd039c..8f4172d10918 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 @@ -4,6 +4,7 @@ import type { RequestPermissionResponse, } from "@agentclientprotocol/sdk"; import type { + PermissionMode, PermissionRuleValue, PermissionUpdate, } from "@anthropic-ai/claude-agent-sdk"; @@ -38,6 +39,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); @@ -256,32 +258,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..f0ca67132070 --- /dev/null +++ b/products/desktop/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/products/desktop/packages/core/src/sessions/planContinuation.ts b/products/desktop/packages/core/src/sessions/planContinuation.ts new file mode 100644 index 000000000000..630857a46d3f --- /dev/null +++ b/products/desktop/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/products/desktop/packages/core/src/sessions/sessionService.ts b/products/desktop/packages/core/src/sessions/sessionService.ts index 34818d7e8881..fd34a27cc99e 100644 --- a/products/desktop/packages/core/src/sessions/sessionService.ts +++ b/products/desktop/packages/core/src/sessions/sessionService.ts @@ -89,6 +89,12 @@ import { planPermissionResponse, resolveAllowAlwaysUpgradeMode, } from "./permissionResponse"; +import { + buildApprovedPlanContinuationPrompt, + extractPlanMarkdownFromPermission, + resolveClearAndContinueExecutionMode, + shouldContinueFromApprovedPlan, +} from "./planContinuation"; import { convertStoredEntriesToEvents, createUserShellExecuteEvent, @@ -5389,6 +5395,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). @@ -7160,9 +7226,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/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..fe56207ea1b5 --- /dev/null +++ b/products/desktop/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/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} diff --git a/products/desktop/packages/ui/src/features/sessions/components/SessionView.tsx b/products/desktop/packages/ui/src/features/sessions/components/SessionView.tsx index 32538925c1a7..e3cdb89b54d8 100644 --- a/products/desktop/packages/ui/src/features/sessions/components/SessionView.tsx +++ b/products/desktop/packages/ui/src/features/sessions/components/SessionView.tsx @@ -511,6 +511,7 @@ export function SessionView({ modeOption, customInput, answers, + repoPath, ); if (plan.resendPromptText) { @@ -527,6 +528,7 @@ export function SessionView({ sessionId, modeOption, sessionService, + repoPath, ], ); From b7b1ec05888bc5ebc547f9b54485826189c2214f Mon Sep 17 00:00:00 2001 From: igennova Date: Sat, 8 Aug 2026 04:19:23 +0530 Subject: [PATCH 2/3] feat(desktop): continue from an approved plan via /clear Clear-and-continue built its own clearing: it cancelled the turn and started a fresh task run. That reset the chat thread, and it did nothing at all on cloud sessions. Use the adapter's /clear primitive instead. End the planning turn, clear the conversation, then send the approved plan into the fresh context. The task run, the ACP session and the visible thread all survive, and the thread keeps a "Conversation cleared" boundary above the implementation. Gate on the agent advertising conversationClear rather than skipping cloud sessions. An agent predating the capability ignores the boundary and rebuilds the conversation it was meant to retire, so fall back to a normal approve instead of showing a clear that did not happen. Extract the plan markdown before the clear and re-send it afterwards. /clear deliberately wipes the adapter's plan state, so the host's copy is the only thing that survives the boundary. --- .../core/src/sessions/planContinuation.ts | 6 ++ .../core/src/sessions/sessionService.ts | 95 ++++++---------- .../sessionServicePlanContinuation.test.ts | 102 +++++++++++------- .../sessions/components/SessionView.tsx | 2 - 4 files changed, 103 insertions(+), 102 deletions(-) diff --git a/products/desktop/packages/core/src/sessions/planContinuation.ts b/products/desktop/packages/core/src/sessions/planContinuation.ts index 630857a46d3f..2581f004ed5e 100644 --- a/products/desktop/packages/core/src/sessions/planContinuation.ts +++ b/products/desktop/packages/core/src/sessions/planContinuation.ts @@ -22,6 +22,12 @@ 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 { diff --git a/products/desktop/packages/core/src/sessions/sessionService.ts b/products/desktop/packages/core/src/sessions/sessionService.ts index fd34a27cc99e..9f724ebaf80c 100644 --- a/products/desktop/packages/core/src/sessions/sessionService.ts +++ b/products/desktop/packages/core/src/sessions/sessionService.ts @@ -91,6 +91,7 @@ import { } from "./permissionResponse"; import { buildApprovedPlanContinuationPrompt, + CLEAR_CONVERSATION_COMMAND, extractPlanMarkdownFromPermission, resolveClearAndContinueExecutionMode, shouldContinueFromApprovedPlan, @@ -5396,15 +5397,15 @@ export class SessionService { } /** - * 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). + * 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. * - * Cloud runs are skipped until cloud session lifecycle supports this path. + * Callers gate on the agent advertising `conversationClear`. */ async continueFromApprovedPlan( taskId: string, - repoPath: string, + toolCallId: string, planMarkdown: string, executionMode: ExecutionMode, ): Promise { @@ -5412,46 +5413,18 @@ export class SessionService { 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); + // 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); - 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."); - } + await this.setSessionConfigOptionByCategory(taskId, "mode", executionMode); - const initialPrompt = buildApprovedPlanContinuationPrompt(planMarkdown); - await this.createNewLocalSession( + await this.sendPrompt(taskId, CLEAR_CONVERSATION_COMMAND); + await this.sendPrompt( taskId, - taskTitle, - repoPath, - authStatus.auth, - initialPrompt, - executionMode, - adapter, - model, - reasoningLevel, + buildApprovedPlanContinuationPrompt(planMarkdown), ); } @@ -7226,31 +7199,28 @@ 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); - } + // 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, - repoPath, + permission.toolCallId, clearAndContinuePlan, resolveClearAndContinueExecutionMode(answers), ); @@ -7263,6 +7233,11 @@ export class SessionService { } 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 index fe56207ea1b5..55f1a10239b7 100644 --- a/products/desktop/packages/core/src/sessions/sessionServicePlanContinuation.test.ts +++ b/products/desktop/packages/core/src/sessions/sessionServicePlanContinuation.test.ts @@ -1,4 +1,4 @@ -import type { AgentSession } from "@posthog/shared"; +import type { AgentSession, ExecutionMode } from "@posthog/shared"; import { describe, expect, it, vi } from "vitest"; import { SessionService, type SessionServiceDeps } from "./sessionService"; @@ -22,6 +22,7 @@ function makeSession(overrides: Partial = {}): AgentSession { adapter: "claude", model: "claude-sonnet", executionMode: "plan", + conversationClear: true, ...overrides, } as AgentSession; } @@ -46,7 +47,10 @@ function makePermission() { }; } -function createHarness(session: AgentSession) { +function createHarness( + session: AgentSession, + { stubContinuation = true }: { stubContinuation?: boolean } = {}, +) { const sessions: Record = { [session.taskRunId]: session, }; @@ -79,9 +83,11 @@ function createHarness(session: AgentSession) { } as unknown as SessionServiceDeps; const service = new SessionService(deps); - const continueFromApprovedPlan = vi - .spyOn(service, "continueFromApprovedPlan") - .mockResolvedValue(undefined); + const continueFromApprovedPlan = vi.spyOn( + service, + "continueFromApprovedPlan", + ); + if (stubContinuation) continueFromApprovedPlan.mockResolvedValue(undefined); const respondToPermission = vi .spyOn(service, "respondToPermission") .mockResolvedValue(undefined); @@ -90,10 +96,17 @@ function createHarness(session: AgentSession) { } describe("SessionService plan continuation", () => { - it("continues only when clearAndContinue is selected", async () => { - const session = makeSession(); + 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(session); + createHarness(makeSession()); await service.resolvePermissionSelection( "task-1", @@ -101,26 +114,23 @@ describe("SessionService plan continuation", () => { "clearAndContinue", undefined, undefined, - { executionMode: "acceptEdits" }, - "/repo", + answers, ); expect(continueFromApprovedPlan).toHaveBeenCalledWith( "task-1", - "/repo", + "tool-1", "## Plan\n- fix bug", - "acceptEdits", + expected, ); - // 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. + // 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 session = makeSession(); const { service, continueFromApprovedPlan, respondToPermission } = - createHarness(session); + createHarness(makeSession()); await service.resolvePermissionSelection( "task-1", @@ -129,17 +139,15 @@ describe("SessionService plan continuation", () => { 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); + it("approves without clearing when the agent cannot clear", async () => { + const { service, continueFromApprovedPlan, respondToPermission } = + createHarness(makeSession({ conversationClear: false })); await service.resolvePermissionSelection( "task-1", @@ -150,12 +158,14 @@ describe("SessionService plan continuation", () => { { 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 session = makeSession(); - const { service, continueFromApprovedPlan } = createHarness(session); + const { service, continueFromApprovedPlan } = createHarness(makeSession()); await service.resolvePermissionSelection( "task-1", @@ -164,31 +174,43 @@ describe("SessionService plan continuation", () => { 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( + 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", - makePermission() as never, - "clearAndContinue", - undefined, - undefined, - undefined, - "/repo", + "tool-1", + "## Plan\n- fix bug", + "acceptEdits", ); - expect(continueFromApprovedPlan).toHaveBeenCalledWith( + expect(cancelPermissionAndPrompt).toHaveBeenCalledWith("task-1", "tool-1"); + expect(setConfigOption).toHaveBeenCalledWith( "task-1", - "/repo", - "## Plan\n- fix bug", - "default", + "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/sessions/components/SessionView.tsx b/products/desktop/packages/ui/src/features/sessions/components/SessionView.tsx index e3cdb89b54d8..32538925c1a7 100644 --- a/products/desktop/packages/ui/src/features/sessions/components/SessionView.tsx +++ b/products/desktop/packages/ui/src/features/sessions/components/SessionView.tsx @@ -511,7 +511,6 @@ export function SessionView({ modeOption, customInput, answers, - repoPath, ); if (plan.resendPromptText) { @@ -528,7 +527,6 @@ export function SessionView({ sessionId, modeOption, sessionService, - repoPath, ], ); From c8056262a818fa0f6afa3802503703f1f3eb44f4 Mon Sep 17 00:00:00 2001 From: igennova Date: Sat, 8 Aug 2026 04:36:30 +0530 Subject: [PATCH 3/3] chore(desktop): validate the ExitPlanMode plan input with zod Reading rawInput through an `as { plan?: unknown }` cast asserted a shape nothing checked, so an upstream change would surface as a silent null and quietly disable clear-and-continue. AGENTS.md asks for Zod-validated metadata over raw SDK input. Behavior is unchanged for well-formed and missing input alike. Cover the wrong-typed plan the schema now rejects. Co-Authored-By: Claude Opus 5 --- .../core/src/sessions/planContinuation.test.ts | 8 ++++++++ .../packages/core/src/sessions/planContinuation.ts | 13 +++++++------ 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/products/desktop/packages/core/src/sessions/planContinuation.test.ts b/products/desktop/packages/core/src/sessions/planContinuation.test.ts index f0ca67132070..7f56315a2af3 100644 --- a/products/desktop/packages/core/src/sessions/planContinuation.test.ts +++ b/products/desktop/packages/core/src/sessions/planContinuation.test.ts @@ -74,6 +74,14 @@ describe("planContinuation", () => { 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", () => { diff --git a/products/desktop/packages/core/src/sessions/planContinuation.ts b/products/desktop/packages/core/src/sessions/planContinuation.ts index 2581f004ed5e..946a28b575f5 100644 --- a/products/desktop/packages/core/src/sessions/planContinuation.ts +++ b/products/desktop/packages/core/src/sessions/planContinuation.ts @@ -1,5 +1,6 @@ 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 = [ @@ -59,15 +60,15 @@ export function shouldContinueFromApprovedPlan( ); } +/** 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 rawInput = permission.toolCall?.rawInput as - | { plan?: unknown } - | undefined; - const plan = rawInput?.plan; - if (typeof plan !== "string") return null; - const trimmed = plan.trim(); + const parsed = planToolInputSchema.safeParse(permission.toolCall?.rawInput); + if (!parsed.success) return null; + const trimmed = parsed.data.plan.trim(); return trimmed.length > 0 ? trimmed : null; }