Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/design/explicit-plan-exit-approval.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ ACP sends no mode update when permission is pending or when confirmation, hooks,

## Failure behavior

- Calls outside Plan mode are denied before a confirmation is constructed.
- Calls outside Plan mode fail safely with actionable state guidance at whichever boundary observes the mode change. `execute()` returns a guidance error when the session is outside Plan mode and there is no approval snapshot. `getConfirmationDetails()` throws the same guidance when called outside Plan mode (e.g. via a PM `ask` rule or a Plan-to-non-Plan switch between permission evaluation and confirmation construction). The default permission is `allow` — this is a state issue, not a security issue.
- Invalid confirmation outcomes, cancellation, aborts, stale revisions, and transition failures leave Plan mode active.
- Two exits approved against the same revision cannot both succeed.
- If an ACP host cannot present `switch_mode`, Plan mode remains active and the error directs the user to the host mode selector or `/plan exit`.
Expand Down
115 changes: 115 additions & 0 deletions packages/core/src/core/coreToolScheduler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { pathToFileURL } from 'node:url';
import { SkillTool } from '../tools/skill.js';
import { StructuredToolError } from '../tools/priorReadEnforcement.js';
import { ToolNames, ToolNamesMigration } from '../tools/tool-names.js';
import { ExitPlanModeTool } from '../tools/exitPlanMode.js';
import type {
CompletedToolCall,
ExecutingToolCall,
Expand Down Expand Up @@ -1171,6 +1172,120 @@ describe('CoreToolScheduler', () => {
expect(execute).toHaveBeenCalledWith({ plan: 'Original plan' });
});

it('returns guidance error through the scheduler when a PM ask rule hits exit_plan_mode outside plan mode (#7671)', async () => {
const exitPlanConfig = {
getApprovalMode: () => ApprovalMode.DEFAULT,
getApprovalModeRevision: () => 7,
getPrePlanMode: () => ApprovalMode.DEFAULT,
setApprovalMode: vi.fn(),
savePlan: vi.fn(),
getTeamManager: () => undefined,
} as unknown as Config;
const realTool = new ExitPlanModeTool(exitPlanConfig);
const permissionManager = {
isToolEnabled: vi.fn().mockResolvedValue(true),
hasRelevantRules: vi.fn().mockReturnValue(true),
evaluate: vi.fn().mockResolvedValue('ask'),
hasMatchingAskRule: vi.fn().mockReturnValue(true),
findMatchingDenyRule: vi.fn(),
};
const onAllToolCallsComplete = vi.fn();
const onToolCallsUpdate = vi.fn();
const { scheduler } = createSchedulerForLegacyToolTests({
toolsByName: new Map([
[ToolNames.EXIT_PLAN_MODE, realTool as unknown as MockTool],
]),
approvalMode: ApprovalMode.DEFAULT,
onAllToolCallsComplete,
onToolCallsUpdate,
});
Object.assign(
(scheduler as unknown as { config: Record<string, unknown> }).config,
{
getPermissionManager: () => permissionManager,
getTargetDir: () => '/repo',
getConditionalRulesRegistry: () => undefined,
getSkillManager: () => undefined,
},
);

await scheduler.schedule(
[
{
callId: 'pm-ask-exit-plan',
name: ToolNames.EXIT_PLAN_MODE,
args: { plan: 'My plan' },
isClientInitiated: false,
prompt_id: 'prompt-pm-ask-exit-plan',
},
],
new AbortController().signal,
);

await vi.waitFor(() => expect(onAllToolCallsComplete).toHaveBeenCalled());
const completedCalls = onAllToolCallsComplete.mock
.calls[0][0] as CompletedToolCall[];
expect(completedCalls[0].status).toBe('error');
const errorJson = JSON.stringify(completedCalls[0].response);
expect(errorJson).toContain('not in plan mode');
expect(errorJson).toContain('Do not call exit_plan_mode again');
});

it('returns guidance error through the scheduler on Plan-to-non-Plan timing boundary (#7671)', async () => {
// Simulate: permission evaluation sees PLAN (requiresUserInteraction
// returns true, forcing ask), then mode switches before
// getConfirmationDetails is called.
let approvalModeCallCount = 0;
const exitPlanConfig = {
getApprovalMode: () => {
approvalModeCallCount++;
// The first call is requiresUserInteraction() during
// evaluatePermissionFlow; subsequent calls (inside
// getConfirmationDetails) see DEFAULT.
return approvalModeCallCount <= 1
? ApprovalMode.PLAN
: ApprovalMode.DEFAULT;
},
getApprovalModeRevision: () => 7,
getPrePlanMode: () => ApprovalMode.DEFAULT,
setApprovalMode: vi.fn(),
savePlan: vi.fn(),
getTeamManager: () => undefined,
} as unknown as Config;
const realTool = new ExitPlanModeTool(exitPlanConfig);
const onAllToolCallsComplete = vi.fn();
const onToolCallsUpdate = vi.fn();
const { scheduler } = createSchedulerForLegacyToolTests({
toolsByName: new Map([
[ToolNames.EXIT_PLAN_MODE, realTool as unknown as MockTool],
]),
approvalMode: ApprovalMode.PLAN,
onAllToolCallsComplete,
onToolCallsUpdate,
});

await scheduler.schedule(
[
{
callId: 'timing-boundary-exit-plan',
name: ToolNames.EXIT_PLAN_MODE,
args: { plan: 'My plan' },
isClientInitiated: false,
prompt_id: 'prompt-timing-boundary-exit-plan',
},
],
new AbortController().signal,
);

await vi.waitFor(() => expect(onAllToolCallsComplete).toHaveBeenCalled());
const completedCalls = onAllToolCallsComplete.mock
.calls[0][0] as CompletedToolCall[];
expect(completedCalls[0].status).toBe('error');
const errorJson = JSON.stringify(completedCalls[0].response);
expect(errorJson).toContain('not in plan mode');
expect(errorJson).toContain('Do not call exit_plan_mode again');
});

it('does not let AUTO_EDIT approve an interaction-required info tool', async () => {
const execute = vi.fn().mockResolvedValue({
llmContent: 'executed',
Expand Down
29 changes: 25 additions & 4 deletions packages/core/src/tools/exitPlanMode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,17 +75,38 @@ describe('ExitPlanModeTool', () => {
const invocation = tool.build({ plan: 'Plan' });

expect(invocation.requiresUserInteraction?.()).toBe(true);
await expect(invocation.getDefaultPermission()).resolves.toBe('ask');
// getDefaultPermission always returns 'allow'; requiresUserInteraction
// forces 'ask' via permissionFlow when in plan mode (#7671).
await expect(invocation.getDefaultPermission()).resolves.toBe('allow');
});

it('denies outside plan mode without constructing a misleading prompt', async () => {
it('allows outside plan mode but execute returns guidance error (#7671)', async () => {
approvalMode = ApprovalMode.DEFAULT;
const invocation = tool.build({ plan: 'Plan' });

await expect(invocation.getDefaultPermission()).resolves.toBe('deny');
// Permission layer allows — not a security concern, just wrong state
await expect(invocation.getDefaultPermission()).resolves.toBe('allow');

// No user interaction required outside plan mode — execute() handles it
expect(invocation.requiresUserInteraction?.()).toBe(false);

// Execute layer returns a helpful error
const result = await invocation.execute(new AbortController().signal);
expect(result.error).toBeDefined();
expect(result.llmContent).toContain('not in plan mode');
expect(result.llmContent).toContain('Do not call exit_plan_mode again');
});

it('getConfirmationDetails throws guidance outside plan mode (#7671)', async () => {
approvalMode = ApprovalMode.DEFAULT;
const invocation = tool.build({ plan: 'Plan' });

await expect(
invocation.getConfirmationDetails(new AbortController().signal),
).rejects.toThrow('not in plan mode');
await expect(
invocation.getConfirmationDetails(new AbortController().signal),
).rejects.toThrow('outside plan mode');
).rejects.toThrow('Do not call exit_plan_mode again');
});

it.each([
Expand Down
36 changes: 28 additions & 8 deletions packages/core/src/tools/exitPlanMode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,20 +115,23 @@ class ExitPlanModeToolInvocation extends BaseToolInvocation<
}

override requiresUserInteraction(): boolean {
// Outside plan mode, no user interaction is needed — execute() will
// return a guidance error directly (#7671).
if (this.config.getApprovalMode() !== ApprovalMode.PLAN) {
return false;
}
return (
!isPlanRequiredTeammateContext() &&
!isPlanLifecycleToolUnavailableInSubagent(ToolNames.EXIT_PLAN_MODE)
);
}

override async getDefaultPermission(): Promise<PermissionDecision> {
if (
isPlanRequiredTeammateContext() ||
isPlanLifecycleToolUnavailableInSubagent(ToolNames.EXIT_PLAN_MODE)
) {
return 'allow';
}
return this.config.getApprovalMode() === ApprovalMode.PLAN ? 'ask' : 'deny';
// Always allow at the permission layer. Plan-mode gating lives in
// requiresUserInteraction() (forces 'ask' via permissionFlow) and
// execute() (returns guidance error outside plan mode). A single
// source of truth avoids duplicated conditionals (#7671).
return 'allow';
}

override async getConfirmationDetails(
Expand All @@ -141,7 +144,7 @@ class ExitPlanModeToolInvocation extends BaseToolInvocation<
return super.getConfirmationDetails(abortSignal);
}
if (this.config.getApprovalMode() !== ApprovalMode.PLAN) {
throw new Error('Cannot request plan approval outside plan mode.');
throw new Error(this.outsidePlanGuidanceMessage());
Comment thread
qwen-code-dev-bot marked this conversation as resolved.
Outdated
}

const snapshot: ExitApprovalSnapshot = {
Expand Down Expand Up @@ -201,6 +204,14 @@ class ExitPlanModeToolInvocation extends BaseToolInvocation<
);
}

// Not in plan mode and no approval snapshot — the user may have
// manually switched modes. Return a guidance error instead of a
// permission deny (#7671). If there IS an approval snapshot, let the
// stale-revision check below handle it (concurrent exit scenario).
if (this.config.getApprovalMode() !== ApprovalMode.PLAN && !this.approval) {
Comment thread
qwen-code-dev-bot marked this conversation as resolved.
return this.errorResult(this.outsidePlanGuidanceMessage());
}
Comment thread
qwen-code-dev-bot marked this conversation as resolved.

const { plan, originalRequest, researchSummary } = this.params;
if (isPlanRequiredTeammateContext()) {
return this.executePlanRequiredTeammate(
Expand Down Expand Up @@ -352,6 +363,15 @@ class ExitPlanModeToolInvocation extends BaseToolInvocation<
};
}

private outsidePlanGuidanceMessage(): string {
const currentMode = this.config.getApprovalMode();
return (
`You are not in plan mode (current mode: ${currentMode}). ` +
`The user may have manually switched modes via Shift+Tab or /approval-mode. ` +
`Do not call exit_plan_mode again. Continue working in the current mode.`
);
}

private savePlanBestEffort(plan: string): void {
try {
this.config.savePlan(plan);
Expand Down
Loading