Skip to content

Commit 8f87946

Browse files
committed
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.
1 parent 6fca46a commit 8f87946

11 files changed

Lines changed: 744 additions & 39 deletions

File tree

packages/agent/src/adapters/claude/permissions/permission-handlers.ts

Lines changed: 62 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type {
33
RequestPermissionResponse,
44
} from "@agentclientprotocol/sdk";
55
import type {
6+
PermissionMode,
67
PermissionRuleValue,
78
PermissionUpdate,
89
} from "@anthropic-ai/claude-agent-sdk";
@@ -30,6 +31,7 @@ import type { Session } from "../types";
3031
import {
3132
buildExitPlanModePermissionOptions,
3233
buildPermissionOptions,
34+
CLEAR_AND_CONTINUE_OPTION_ID,
3335
} from "./permission-options";
3436
import {
3537
extractPostHogSubTool,
@@ -180,32 +182,72 @@ async function requestPlanApproval(
180182
});
181183
}
182184

185+
const PLAN_APPROVAL_MODE_OPTION_IDS = [
186+
"auto",
187+
"default",
188+
"acceptEdits",
189+
"bypassPermissions",
190+
] as const satisfies readonly PermissionMode[];
191+
192+
function isPlanApprovalMode(value: string): value is PermissionMode {
193+
return (PLAN_APPROVAL_MODE_OPTION_IDS as readonly string[]).includes(value);
194+
}
195+
196+
function resolvePlanApprovalMode(
197+
optionId: string,
198+
response: RequestPermissionResponse,
199+
previousMode?: string,
200+
): PermissionMode | null {
201+
if (isPlanApprovalMode(optionId)) {
202+
return optionId;
203+
}
204+
205+
// Opt-in clear-and-continue: honor the ModeSelector choice when present,
206+
// otherwise fall back to the mode the session was in before plan mode.
207+
if (optionId === CLEAR_AND_CONTINUE_OPTION_ID) {
208+
const answers = response._meta?.answers as
209+
| Record<string, string>
210+
| undefined;
211+
const fromAnswers = answers?.executionMode;
212+
if (typeof fromAnswers === "string" && isPlanApprovalMode(fromAnswers)) {
213+
return fromAnswers;
214+
}
215+
if (previousMode && isPlanApprovalMode(previousMode)) {
216+
return previousMode;
217+
}
218+
return "default";
219+
}
220+
221+
return null;
222+
}
223+
183224
async function applyPlanApproval(
184225
response: RequestPermissionResponse,
185226
context: ToolHandlerContext,
186227
updatedInput: Record<string, unknown>,
187228
): Promise<ToolPermissionResult> {
188-
if (
189-
response.outcome?.outcome === "selected" &&
190-
(response.outcome.optionId === "auto" ||
191-
response.outcome.optionId === "default" ||
192-
response.outcome.optionId === "acceptEdits" ||
193-
response.outcome.optionId === "bypassPermissions")
194-
) {
195-
await context.applySessionMode(response.outcome.optionId);
196-
await context.updateConfigOption("mode", response.outcome.optionId);
229+
if (response.outcome?.outcome === "selected") {
230+
const mode = resolvePlanApprovalMode(
231+
response.outcome.optionId,
232+
response,
233+
context.session.modeBeforePlan,
234+
);
235+
if (mode) {
236+
await context.applySessionMode(mode);
237+
await context.updateConfigOption("mode", mode);
197238

198-
return {
199-
behavior: "allow",
200-
updatedInput,
201-
updatedPermissions: context.suggestions ?? [
202-
{
203-
type: "setMode",
204-
mode: response.outcome.optionId,
205-
destination: "localSettings",
206-
},
207-
],
208-
};
239+
return {
240+
behavior: "allow",
241+
updatedInput,
242+
updatedPermissions: context.suggestions ?? [
243+
{
244+
type: "setMode",
245+
mode,
246+
destination: "localSettings",
247+
},
248+
],
249+
};
250+
}
209251
}
210252

211253
const customInput = (response._meta as Record<string, unknown> | undefined)

packages/agent/src/adapters/claude/permissions/permission-options.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,20 @@ describe("buildExitPlanModePermissionOptions", () => {
1010
expect(options[options.length - 1].optionId).toBe("reject_with_feedback");
1111
});
1212

13+
it("includes an opt-in clear-and-continue option before reject", () => {
14+
const options = buildExitPlanModePermissionOptions();
15+
const clearIndex = options.findIndex(
16+
(opt) => opt.optionId === "clearAndContinue",
17+
);
18+
expect(clearIndex).toBeGreaterThanOrEqual(0);
19+
expect(options[clearIndex]).toMatchObject({
20+
optionId: "clearAndContinue",
21+
kind: "allow_once",
22+
name: "Yes, clear history and continue from plan",
23+
});
24+
expect(options[clearIndex + 1]?.optionId).toBe("reject_with_feedback");
25+
});
26+
1327
it.each([
1428
{
1529
previousMode: "default",

packages/agent/src/adapters/claude/permissions/permission-options.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,9 @@ const CONTINUE_LABELS: Record<string, string> = {
104104
bypassPermissions: "Yes, continue bypassing all permissions",
105105
};
106106

107+
/** Opt-in plan approval: clear planning context, then continue from the plan. */
108+
export const CLEAR_AND_CONTINUE_OPTION_ID = "clearAndContinue";
109+
107110
export function buildExitPlanModePermissionOptions(
108111
previousMode?: string,
109112
): PermissionOption[] {
@@ -151,6 +154,16 @@ export function buildExitPlanModePermissionOptions(
151154
}
152155
}
153156

157+
options.push({
158+
kind: "allow_once",
159+
name: "Yes, clear history and continue from plan",
160+
optionId: CLEAR_AND_CONTINUE_OPTION_ID,
161+
_meta: {
162+
description:
163+
"Discard planning conversation tokens and start implementation with a fresh context seeded by this plan",
164+
},
165+
});
166+
154167
options.push({
155168
kind: "reject_once",
156169
name: "No, and tell the agent what to do differently",
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import type { PermissionRequest } from "@posthog/shared";
2+
import { describe, expect, it } from "vitest";
3+
import {
4+
buildApprovedPlanContinuationPrompt,
5+
CLEAR_AND_CONTINUE_OPTION_ID,
6+
extractPlanMarkdownFromPermission,
7+
isClearAndContinueOption,
8+
isPlanApprovalAcceptOption,
9+
isPlanApprovalPermission,
10+
resolveClearAndContinueExecutionMode,
11+
shouldContinueFromApprovedPlan,
12+
toApprovedExecutionMode,
13+
} from "./planContinuation";
14+
15+
function makePermission(
16+
overrides: Partial<PermissionRequest> & {
17+
toolCall?: PermissionRequest["toolCall"];
18+
} = {},
19+
): PermissionRequest {
20+
return {
21+
taskRunId: "run-1",
22+
receivedAt: 0,
23+
options: [],
24+
...overrides,
25+
} as PermissionRequest;
26+
}
27+
28+
describe("planContinuation", () => {
29+
it("detects plan approval permissions", () => {
30+
expect(
31+
isPlanApprovalPermission(
32+
makePermission({ toolCall: { kind: "switch_mode" } as never }),
33+
),
34+
).toBe(true);
35+
expect(
36+
isPlanApprovalPermission(
37+
makePermission({ toolCall: { kind: "execute" } as never }),
38+
),
39+
).toBe(false);
40+
});
41+
42+
it("recognizes approve option ids", () => {
43+
expect(isPlanApprovalAcceptOption("auto")).toBe(true);
44+
expect(isPlanApprovalAcceptOption("reject_with_feedback")).toBe(false);
45+
expect(isClearAndContinueOption(CLEAR_AND_CONTINUE_OPTION_ID)).toBe(true);
46+
expect(isClearAndContinueOption("auto")).toBe(false);
47+
});
48+
49+
it("only continues for the explicit clear-and-continue option", () => {
50+
const permission = makePermission({
51+
toolCall: { kind: "switch_mode" } as never,
52+
});
53+
expect(shouldContinueFromApprovedPlan(permission, "auto")).toBe(false);
54+
expect(
55+
shouldContinueFromApprovedPlan(permission, CLEAR_AND_CONTINUE_OPTION_ID),
56+
).toBe(true);
57+
expect(
58+
shouldContinueFromApprovedPlan(permission, "reject_with_feedback"),
59+
).toBe(false);
60+
});
61+
62+
it("extracts trimmed plan markdown from rawInput", () => {
63+
const permission = makePermission({
64+
toolCall: {
65+
kind: "switch_mode",
66+
rawInput: { plan: " ## Steps\n- one " },
67+
} as never,
68+
});
69+
expect(extractPlanMarkdownFromPermission(permission)).toBe(
70+
"## Steps\n- one",
71+
);
72+
expect(
73+
extractPlanMarkdownFromPermission(
74+
makePermission({ toolCall: { kind: "switch_mode" } as never }),
75+
),
76+
).toBeNull();
77+
});
78+
79+
it("maps approve option to execution mode", () => {
80+
expect(toApprovedExecutionMode("auto")).toBe("auto");
81+
expect(toApprovedExecutionMode("acceptEdits")).toBe("acceptEdits");
82+
});
83+
84+
it("resolves clear-and-continue execution mode from answers", () => {
85+
expect(
86+
resolveClearAndContinueExecutionMode({ executionMode: "acceptEdits" }),
87+
).toBe("acceptEdits");
88+
expect(resolveClearAndContinueExecutionMode({})).toBe("default");
89+
expect(resolveClearAndContinueExecutionMode()).toBe("default");
90+
});
91+
92+
it("builds a continuation prompt that embeds the plan", () => {
93+
const blocks = buildApprovedPlanContinuationPrompt("## Fix\n- patch auth");
94+
expect(blocks).toHaveLength(1);
95+
expect(blocks[0]?.type).toBe("text");
96+
const textBlock = blocks[0];
97+
if (textBlock?.type !== "text") {
98+
throw new Error("expected text block");
99+
}
100+
expect(textBlock.text).toContain("## Fix\n- patch auth");
101+
expect(textBlock.text).toContain("execute this plan directly");
102+
});
103+
});
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import type { ContentBlock } from "@agentclientprotocol/sdk";
2+
import type { ExecutionMode, PermissionRequest } from "@posthog/shared";
3+
4+
/** Option ids offered when approving ExitPlanMode (see buildExitPlanModePermissionOptions). */
5+
export const PLAN_APPROVAL_ACCEPT_OPTION_IDS = [
6+
"auto",
7+
"default",
8+
"acceptEdits",
9+
"bypassPermissions",
10+
] as const;
11+
12+
export type PlanApprovalAcceptOptionId =
13+
(typeof PLAN_APPROVAL_ACCEPT_OPTION_IDS)[number];
14+
15+
/**
16+
* Opt-in plan-approval option: clear planning context, then continue from the
17+
* approved plan. Kept in sync with `CLEAR_AND_CONTINUE_OPTION_ID` in the Claude
18+
* permission-options module (agent cannot import core).
19+
*/
20+
export const CLEAR_AND_CONTINUE_OPTION_ID = "clearAndContinue";
21+
22+
/** Answers key used by the plan UI to carry the ModeSelector choice. */
23+
export const CLEAR_AND_CONTINUE_EXECUTION_MODE_KEY = "executionMode";
24+
25+
export function isPlanApprovalPermission(
26+
permission: PermissionRequest,
27+
): boolean {
28+
return permission.toolCall?.kind === "switch_mode";
29+
}
30+
31+
export function isPlanApprovalAcceptOption(
32+
optionId: string,
33+
): optionId is PlanApprovalAcceptOptionId {
34+
return (PLAN_APPROVAL_ACCEPT_OPTION_IDS as readonly string[]).includes(
35+
optionId,
36+
);
37+
}
38+
39+
export function isClearAndContinueOption(optionId: string): boolean {
40+
return optionId === CLEAR_AND_CONTINUE_OPTION_ID;
41+
}
42+
43+
/**
44+
* Only the explicit "clear history and continue from plan" option triggers a
45+
* fresh implementation session — normal approve keeps the planning context.
46+
*/
47+
export function shouldContinueFromApprovedPlan(
48+
permission: PermissionRequest,
49+
optionId: string,
50+
): boolean {
51+
return (
52+
isPlanApprovalPermission(permission) && isClearAndContinueOption(optionId)
53+
);
54+
}
55+
56+
export function extractPlanMarkdownFromPermission(
57+
permission: PermissionRequest,
58+
): string | null {
59+
const rawInput = permission.toolCall?.rawInput as
60+
| { plan?: unknown }
61+
| undefined;
62+
const plan = rawInput?.plan;
63+
if (typeof plan !== "string") return null;
64+
const trimmed = plan.trim();
65+
return trimmed.length > 0 ? trimmed : null;
66+
}
67+
68+
export function toApprovedExecutionMode(
69+
optionId: PlanApprovalAcceptOptionId,
70+
): ExecutionMode {
71+
return optionId;
72+
}
73+
74+
export function resolveClearAndContinueExecutionMode(
75+
answers?: Record<string, string>,
76+
): ExecutionMode {
77+
const mode = answers?.[CLEAR_AND_CONTINUE_EXECUTION_MODE_KEY];
78+
if (mode && isPlanApprovalAcceptOption(mode)) {
79+
return toApprovedExecutionMode(mode);
80+
}
81+
return "default";
82+
}
83+
84+
export function buildApprovedPlanContinuationPrompt(
85+
planMarkdown: string,
86+
): ContentBlock[] {
87+
const text = [
88+
"The user approved the implementation plan below. Planning is complete — execute this plan directly.",
89+
"Do not re-enter plan mode unless the user asks for a significant change in direction.",
90+
"",
91+
planMarkdown,
92+
].join("\n");
93+
return [{ type: "text", text }];
94+
}

0 commit comments

Comments
 (0)