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

@@ -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;
}
}