feat(plan): support plan mode in non-interactive mode (#22670)

This commit is contained in:
ruomeng
2026-03-18 16:00:26 -04:00
committed by GitHub
parent c12fc340c1
commit 1725ec346b
13 changed files with 343 additions and 40 deletions

View File

@@ -33,6 +33,13 @@
toolName = "enter_plan_mode"
decision = "ask_user"
priority = 50
interactive = true
[[rule]]
toolName = "enter_plan_mode"
decision = "allow"
priority = 50
interactive = false
[[rule]]
toolName = "enter_plan_mode"
@@ -46,6 +53,13 @@ toolName = "exit_plan_mode"
decision = "ask_user"
priority = 70
modes = ["plan"]
interactive = true
[[rule]]
toolName = "exit_plan_mode"
decision = "allow"
priority = 70
interactive = false
[[rule]]
toolName = "exit_plan_mode"

View File

@@ -45,6 +45,7 @@ toolName = ["enter_plan_mode", "exit_plan_mode"]
decision = "deny"
priority = 999
modes = ["yolo"]
interactive = true
# Allow everything else in YOLO mode
[[rule]]

View File

@@ -3343,4 +3343,121 @@ describe('PolicyEngine', () => {
expect(excluded.has('test-tool')).toBe(false);
});
});
describe('interactive matching', () => {
it('should ignore interactive rules in non-interactive mode', async () => {
const engine = new PolicyEngine({
rules: [
{
toolName: 'my_tool',
decision: PolicyDecision.ALLOW,
interactive: true,
},
],
nonInteractive: true,
defaultDecision: PolicyDecision.DENY,
});
const result = await engine.check(
{ name: 'my_tool', args: {} },
undefined,
);
expect(result.decision).toBe(PolicyDecision.DENY);
});
it('should allow interactive rules in interactive mode', async () => {
const engine = new PolicyEngine({
rules: [
{
toolName: 'my_tool',
decision: PolicyDecision.ALLOW,
interactive: true,
},
],
nonInteractive: false,
defaultDecision: PolicyDecision.DENY,
});
const result = await engine.check(
{ name: 'my_tool', args: {} },
undefined,
);
expect(result.decision).toBe(PolicyDecision.ALLOW);
});
it('should ignore non-interactive rules in interactive mode', async () => {
const engine = new PolicyEngine({
rules: [
{
toolName: 'my_tool',
decision: PolicyDecision.ALLOW,
interactive: false,
},
],
nonInteractive: false,
defaultDecision: PolicyDecision.DENY,
});
const result = await engine.check(
{ name: 'my_tool', args: {} },
undefined,
);
expect(result.decision).toBe(PolicyDecision.DENY);
});
it('should allow non-interactive rules in non-interactive mode', async () => {
const engine = new PolicyEngine({
rules: [
{
toolName: 'my_tool',
decision: PolicyDecision.ALLOW,
interactive: false,
},
],
nonInteractive: true,
defaultDecision: PolicyDecision.DENY,
});
const result = await engine.check(
{ name: 'my_tool', args: {} },
undefined,
);
expect(result.decision).toBe(PolicyDecision.ALLOW);
});
it('should apply rules without interactive flag to both', async () => {
const rule: PolicyRule = {
toolName: 'my_tool',
decision: PolicyDecision.ALLOW,
};
const engineInteractive = new PolicyEngine({
rules: [rule],
nonInteractive: false,
defaultDecision: PolicyDecision.DENY,
});
const engineNonInteractive = new PolicyEngine({
rules: [rule],
nonInteractive: true,
defaultDecision: PolicyDecision.DENY,
});
expect(
(
await engineInteractive.check(
{ name: 'my_tool', args: {} },
undefined,
)
).decision,
).toBe(PolicyDecision.ALLOW);
expect(
(
await engineNonInteractive.check(
{ name: 'my_tool', args: {} },
undefined,
)
).decision,
).toBe(PolicyDecision.ALLOW);
});
});
});

View File

@@ -74,6 +74,7 @@ function ruleMatches(
stringifiedArgs: string | undefined,
serverName: string | undefined,
currentApprovalMode: ApprovalMode,
nonInteractive: boolean,
toolAnnotations?: Record<string, unknown>,
subagent?: string,
): boolean {
@@ -146,6 +147,16 @@ function ruleMatches(
}
}
// Check interactive if specified
if ('interactive' in rule && rule.interactive !== undefined) {
if (rule.interactive && nonInteractive) {
return false;
}
if (!rule.interactive && !nonInteractive) {
return false;
}
}
return true;
}
@@ -443,6 +454,7 @@ export class PolicyEngine {
stringifiedArgs,
serverName,
this.approvalMode,
this.nonInteractive,
toolAnnotations,
subagent,
),
@@ -521,6 +533,7 @@ export class PolicyEngine {
stringifiedArgs,
serverName,
this.approvalMode,
this.nonInteractive,
toolAnnotations,
subagent,
)
@@ -713,6 +726,7 @@ export class PolicyEngine {
undefined, // stringifiedArgs
serverName,
this.approvalMode,
this.nonInteractive,
annotations,
);

View File

@@ -61,6 +61,7 @@ const PolicyRuleSchema = z.object({
'priority must be <= 999 to prevent tier overflow. Priorities >= 1000 would jump to the next tier.',
}),
modes: z.array(z.nativeEnum(ApprovalMode)).optional(),
interactive: z.boolean().optional(),
toolAnnotations: z.record(z.any()).optional(),
allow_redirection: z.boolean().optional(),
deny_message: z.string().optional(),
@@ -475,6 +476,7 @@ export async function loadPoliciesFromToml(
decision: rule.decision,
priority: transformPriority(rule.priority, tier),
modes: rule.modes,
interactive: rule.interactive,
toolAnnotations: rule.toolAnnotations,
allowRedirection: rule.allow_redirection,
source: `${tierName.charAt(0).toUpperCase() + tierName.slice(1)}: ${file}`,

View File

@@ -152,6 +152,13 @@ export interface PolicyRule {
*/
modes?: ApprovalMode[];
/**
* If true, this rule only applies to interactive environments.
* If false, this rule only applies to non-interactive environments.
* If undefined, it applies to both interactive and non-interactive environments.
*/
interactive?: boolean;
/**
* If true, allows command redirection even if the policy engine would normally
* downgrade ALLOW to ASK_USER for redirected commands.

View File

@@ -175,6 +175,7 @@ export class PromptProvider {
planningWorkflow: this.withSection(
'planningWorkflow',
() => ({
interactive: interactiveMode,
planModeToolsList,
plansDir: context.config.storage.getPlansDir(),
approvedPlanPath: context.config.getApprovedPlanPath(),

View File

@@ -88,6 +88,7 @@ export interface GitRepoOptions {
}
export interface PlanningWorkflowOptions {
interactive: boolean;
planModeToolsList: string;
plansDir: string;
approvedPlanPath?: string;
@@ -513,7 +514,7 @@ export function renderPlanningWorkflow(
return `
# Active Approval Mode: Plan
You are operating in **Plan Mode**. Your goal is to produce an implementation plan in \`${options.plansDir}/\` and get user approval before editing source code.
You are operating in **Plan Mode**. Your goal is to produce an implementation plan in \`${options.plansDir}/\` and ${options.interactive ? 'get user approval before editing source code.' : 'create a design document before proceeding autonomously.'}
## Available Tools
The following tools are available in Plan Mode:
@@ -550,7 +551,7 @@ Write the implementation plan to \`${options.plansDir}/\`. The plan's structure
- **Complex Tasks:** Include **Background & Motivation**, **Scope & Impact**, **Proposed Solution**, **Alternatives Considered**, a phased **Implementation Plan**, **Verification**, and **Migration & Rollback** strategies.
### 4. Review & Approval
Use the ${formatToolName(EXIT_PLAN_MODE_TOOL_NAME)} tool to present the plan and formally request approval.
Use the ${formatToolName(EXIT_PLAN_MODE_TOOL_NAME)} tool to present the plan and ${options.interactive ? 'formally request approval.' : 'begin implementation.'}
${renderApprovedPlanSection(options.approvedPlanPath)}`.trim();
}
@@ -711,7 +712,7 @@ function newApplicationSteps(options: PrimaryWorkflowsOptions): string {
// standard 'Execution' loop handle implementation once the plan is approved.
if (options.enableEnterPlanModeTool) {
return `
1. **Mandatory Planning:** You MUST use the ${formatToolName(ENTER_PLAN_MODE_TOOL_NAME)} tool to draft a comprehensive design document and obtain user approval before writing any code.
1. **Mandatory Planning:** You MUST use the ${formatToolName(ENTER_PLAN_MODE_TOOL_NAME)} tool to draft a comprehensive design document${options.interactive ? ' and obtain user approval' : ''} before writing any code.
2. **Design Constraints:** When drafting your plan, adhere to these defaults unless explicitly overridden by the user:
- **Goal:** Autonomously design a visually appealing, substantially complete, and functional prototype with rich aesthetics. Users judge applications by their visual impact; ensure they feel modern, "alive," and polished through consistent spacing, typography, and interactive feedback.
- **Visuals:** Describe your strategy for sourcing or generating placeholders (e.g., stylized CSS shapes, gradients, procedurally generated patterns) to ensure a visually complete prototype. Never plan for assets that cannot be locally generated.

View File

@@ -47,6 +47,7 @@ describe('ExitPlanModeTool', () => {
storage: {
getPlansDir: vi.fn().mockReturnValue(mockPlansDir),
} as unknown as Config['storage'],
isInteractive: vi.fn().mockReturnValue(true),
};
tool = new ExitPlanModeTool(
mockConfig as Config,
@@ -359,6 +360,36 @@ Ask the user for specific feedback on how to improve the plan.`,
});
});
describe('getAllowApprovalMode (internal)', () => {
it('should return YOLO when config.isInteractive() is false', async () => {
mockConfig.isInteractive = vi.fn().mockReturnValue(false);
const planRelativePath = createPlanFile('test.md', '# Content');
const invocation = tool.build({ plan_path: planRelativePath });
// Directly call execute to trigger the internal getAllowApprovalMode
const result = await invocation.execute(new AbortController().signal);
expect(result.llmContent).toContain('YOLO mode');
expect(mockConfig.setApprovalMode).toHaveBeenCalledWith(
ApprovalMode.YOLO,
);
});
it('should return DEFAULT when config.isInteractive() is true', async () => {
mockConfig.isInteractive = vi.fn().mockReturnValue(true);
const planRelativePath = createPlanFile('test.md', '# Content');
const invocation = tool.build({ plan_path: planRelativePath });
// Directly call execute to trigger the internal getAllowApprovalMode
const result = await invocation.execute(new AbortController().signal);
expect(result.llmContent).toContain('Default mode');
expect(mockConfig.setApprovalMode).toHaveBeenCalledWith(
ApprovalMode.DEFAULT,
);
});
});
describe('getApprovalModeDescription (internal)', () => {
it('should handle all valid approval modes', async () => {
const planRelativePath = createPlanFile('test.md', '# Content');
@@ -387,6 +418,10 @@ Ask the user for specific feedback on how to improve the plan.`,
ApprovalMode.DEFAULT,
'Default mode (edits will require confirmation)',
);
await testMode(
ApprovalMode.YOLO,
'YOLO mode (all tool calls auto-approved)',
);
});
it('should throw for invalid post-planning modes', async () => {
@@ -409,7 +444,6 @@ Ask the user for specific feedback on how to improve the plan.`,
).rejects.toThrow(/Unexpected approval mode/);
};
await testInvalidMode(ApprovalMode.YOLO);
await testInvalidMode(ApprovalMode.PLAN);
});
});

View File

@@ -7,12 +7,12 @@
import {
BaseDeclarativeTool,
BaseToolInvocation,
type ToolResult,
Kind,
type ToolExitPlanModeConfirmationDetails,
type ToolConfirmationPayload,
type ToolExitPlanModeConfirmationPayload,
ToolConfirmationOutcome,
type ToolConfirmationPayload,
type ToolExitPlanModeConfirmationDetails,
type ToolExitPlanModeConfirmationPayload,
type ToolResult,
} from './tools.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
import path from 'node:path';
@@ -151,7 +151,7 @@ export class ExitPlanModeInvocation extends BaseToolInvocation<
this.confirmationOutcome = ToolConfirmationOutcome.ProceedOnce;
this.approvalPayload = {
approved: true,
approvalMode: ApprovalMode.DEFAULT,
approvalMode: this.getAllowApprovalMode(),
};
return false;
}
@@ -205,17 +205,15 @@ export class ExitPlanModeInvocation extends BaseToolInvocation<
// When a user policy grants `allow` for exit_plan_mode, the scheduler
// skips the confirmation phase entirely and shouldConfirmExecute is never
// called, leaving approvalPayload null. Treat that as an approval with
// the default mode — consistent with the ALLOW branch inside
// shouldConfirmExecute.
// called, leaving approvalPayload null.
const payload = this.approvalPayload ?? {
approved: true,
approvalMode: ApprovalMode.DEFAULT,
approvalMode: this.getAllowApprovalMode(),
};
if (payload.approved) {
const newMode = payload.approvalMode ?? ApprovalMode.DEFAULT;
if (newMode === ApprovalMode.PLAN || newMode === ApprovalMode.YOLO) {
if (newMode === ApprovalMode.PLAN) {
throw new Error(`Unexpected approval mode: ${newMode}`);
}
@@ -254,4 +252,18 @@ Ask the user for specific feedback on how to improve the plan.`,
}
}
}
/**
* Determines the approval mode to switch to when plan mode is exited via a policy ALLOW.
* In non-interactive environments, this defaults to YOLO to allow automated execution.
*/
private getAllowApprovalMode(): ApprovalMode {
if (!this.config.isInteractive()) {
// For non-interactive environment requires minimal user action, exit as YOLO mode for plan implementation.
return ApprovalMode.YOLO;
}
// By default, YOLO mode in interactive environment cannot enter/exit plan mode.
// Always exit plan mode and move to default approval mode if exit_plan_mode tool is configured with allow decision.
return ApprovalMode.DEFAULT;
}
}