Skip to content
Merged
19 changes: 19 additions & 0 deletions packages/cli/src/acp-integration/session/Session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2876,6 +2876,7 @@ export class Session implements SessionContext {
const isTodoWriteTool = tool.name === ToolNames.TODO_WRITE;
const isAgentTool = tool.name === ToolNames.AGENT;
const isExitPlanModeTool = tool.name === ToolNames.EXIT_PLAN_MODE;
const isEnterPlanModeTool = tool.name === ToolNames.ENTER_PLAN_MODE;

// Track cleanup functions for sub-agent event listeners
let subAgentCleanupFunctions: Array<() => void> = [];
Expand Down Expand Up @@ -3110,6 +3111,7 @@ export class Session implements SessionContext {
isExitPlanModeTool,
isAskUserQuestionTool,
confirmationDetails,
isEnterPlanModeTool,
)
) {
return earlyErrorResponse(
Expand Down Expand Up @@ -3364,6 +3366,23 @@ export class Session implements SessionContext {
// Clean up event listeners
subAgentCleanupFunctions.forEach((cleanup) => cleanup());

// enter_plan_mode and the AUTO/YOLO gate path of exit_plan_mode change the
// approval mode inside execute() without going through the user-confirmation
// branch above, so notify the client of the current mode explicitly.
// Only send when the mode actually changed (a gate "blocked" result keeps
// the mode at PLAN, and a redundant notification would be misleading).
if (
(isEnterPlanModeTool || isExitPlanModeTool) &&
!didRequestPermission &&
!toolResult.error &&
this.config.getApprovalMode() !== approvalMode
) {
await this.sendUpdate({
sessionUpdate: 'current_mode_update',
currentModeId: this.config.getApprovalMode() as ApprovalModeValue,
});
}

// Create response parts first (needed for emitResult and recordToolResult)
const responseParts = convertToFunctionResponse(
toolName,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,12 @@ describe('ToolCallEmitter', () => {
);
});

it('should map enter_plan_mode tool to switch_mode kind', () => {
expect(emitter.mapToolKind(Kind.Think, 'enter_plan_mode')).toBe(
'switch_mode',
);
});

it('should not affect other tools with Kind.Think', () => {
// Other tools with Kind.Think should still map to think
expect(emitter.mapToolKind(Kind.Think, 'todo_write')).toBe('think');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,13 @@ export class ToolCallEmitter extends BaseEmitter {
return toolName === ToolNames.EXIT_PLAN_MODE;
}

/**
* Checks if a tool name is the EnterPlanModeTool.
*/
isEnterPlanModeTool(toolName: string): boolean {
return toolName === ToolNames.ENTER_PLAN_MODE;
}

/**
* Resolves tool metadata from the registry.
* Falls back to defaults if tool not found or build fails.
Expand Down Expand Up @@ -315,7 +322,11 @@ export class ToolCallEmitter extends BaseEmitter {
* @param toolName - Optional tool name to handle special cases like exit_plan_mode
*/
mapToolKind(kind: Kind, toolName?: string): ToolKind {
if (toolName && this.isExitPlanModeTool(toolName)) {
// Special case: enter/exit_plan_mode use 'switch_mode' kind per ACP spec
if (
toolName &&
(this.isExitPlanModeTool(toolName) || this.isEnterPlanModeTool(toolName))
) {
return 'switch_mode';
}
return KIND_MAP[kind] ?? 'other';
Expand Down
6 changes: 5 additions & 1 deletion packages/cli/src/ui/utils/export/normalize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,11 @@ function resolveToolMetadata(
* Maps tool kind to allowed export kinds.
*/
function mapToolKind(kind: Kind | undefined, toolName?: string): string {
if (toolName && toolName === ToolNames.EXIT_PLAN_MODE) {
if (
toolName &&
(toolName === ToolNames.EXIT_PLAN_MODE ||
toolName === ToolNames.ENTER_PLAN_MODE)
) {
return 'switch_mode';
}

Expand Down
21 changes: 21 additions & 0 deletions packages/core/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ import {
createDenialState,
resetDenialState,
} from '../permissions/denialTracking.js';
import { type PlanGateState, createPlanGateState } from '../plan-gate/state.js';
import { SubagentManager } from '../subagents/subagent-manager.js';
import type { SubagentConfig } from '../subagents/types.js';
import { BackgroundTaskRegistry } from '../agents/background-tasks.js';
Expand Down Expand Up @@ -1139,6 +1140,8 @@ export class Config {
private readonly contextRuleExcludes: string[];
private approvalMode: ApprovalMode;
private prePlanMode?: ApprovalMode;
private planGateState?: PlanGateState;
private planGateEntryCounter = 0;
private autoModeDenialState: AutoModeDenialState = createDenialState();
private readonly accessibility: AccessibilitySettings;
private readonly telemetrySettings: TelemetrySettings;
Expand Down Expand Up @@ -3394,6 +3397,15 @@ export class Config {
return this.prePlanMode ?? ApprovalMode.DEFAULT;
}

/**
* Returns the Plan Approval Gate state for the current Plan Mode Entry, or
* undefined when not in plan mode. The returned object is mutable; callers
* may update its fields directly (e.g. review count, gate mode).
*/
getPlanGateState(): PlanGateState | undefined {
return this.planGateState;
}

setApprovalMode(mode: ApprovalMode): void {
if (
!this.isTrustedFolder() &&
Expand All @@ -3407,11 +3419,16 @@ export class Config {
// Track the mode before entering plan mode so it can be restored later
if (mode === ApprovalMode.PLAN && this.approvalMode !== ApprovalMode.PLAN) {
this.prePlanMode = this.approvalMode;
// Begin a fresh Plan Mode Entry for the Plan Approval Gate.
this.planGateState = createPlanGateState(++this.planGateEntryCounter);
} else if (
mode !== ApprovalMode.PLAN &&
this.approvalMode === ApprovalMode.PLAN
) {
this.prePlanMode = undefined;
// Successfully leaving PLAN clears all gate state (including any
// user_takeover marker, which only lives for the duration of PLAN).
this.planGateState = undefined;
}
// Strip over-broad allow rules (Bash interpreter wildcards, any Agent /
// Skill allow) on AUTO entry; restore them on AUTO exit. Settings on
Expand Down Expand Up @@ -4631,6 +4648,10 @@ export class Config {
const { ExitPlanModeTool } = await import('../tools/exitPlanMode.js');
return new ExitPlanModeTool(this);
});
await registerLazy(ToolNames.ENTER_PLAN_MODE, async () => {
const { EnterPlanModeTool } = await import('../tools/enterPlanMode.js');
return new EnterPlanModeTool(this);
});
}
await registerLazy(ToolNames.ENTER_WORKTREE, async () => {
const { EnterWorktreeTool } = await import('../tools/enter-worktree.js');
Expand Down
Loading
Loading