Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
21 changes: 15 additions & 6 deletions packages/core/src/tools/exitPlanMode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,17 +75,26 @@ 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');
await expect(
invocation.getConfirmationDetails(new AbortController().signal),
).rejects.toThrow('outside plan mode');
// 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.each([
Expand Down
30 changes: 23 additions & 7 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 Down Expand Up @@ -201,6 +204,19 @@ 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.
const currentMode = this.config.getApprovalMode();
return this.errorResult(
`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.`,
);
}
Comment thread
qwen-code-dev-bot marked this conversation as resolved.

const { plan, originalRequest, researchSummary } = this.params;
if (isPlanRequiredTeammateContext()) {
return this.executePlanRequiredTeammate(
Expand Down
Loading