fix(tui): auto-approve plan mode in YOLO (#1345)

This commit is contained in:
jnjpng
2026-03-10 21:56:48 -06:00
committed by GitHub
parent a387724b05
commit 13d86fbd7c
2 changed files with 128 additions and 59 deletions

View File

@@ -12451,13 +12451,23 @@ ${SYSTEM_REMINDER_CLOSE}
const hasUsablePlan = planFileExists(fallbackPlanPath);
if (mode !== "plan") {
if (hasUsablePlan) {
if (mode === "bypassPermissions") {
// User cycled to YOLO mode — auto-approve ExitPlanMode
// so they don't need to manually click through the approval.
if (mode === "bypassPermissions") {
if (hasUsablePlan) {
// YOLO mode with a plan file — auto-approve ExitPlanMode.
handlePlanApprove();
return;
}
// YOLO mode but no plan file yet — tell agent to write it first.
const planFilePath = activePlanPath ?? fallbackPlanPath;
const plansDir = join(homedir(), ".letta", "plans");
handlePlanKeepPlanning(
`You must write your plan to a plan file before exiting plan mode.\n` +
(planFilePath ? `Plan file path: ${planFilePath}\n` : "") +
`Use a write tool to create your plan in ${plansDir}, then use ExitPlanMode to present the plan to the user.`,
);
return;
}
if (hasUsablePlan) {
// Other modes: keep approval flow alive and let user manually approve.
return;
}
@@ -12580,28 +12590,33 @@ ${SYSTEM_REMINDER_CLOSE}
[pendingApprovals, approvalResults, sendAllResults, refreshDerived],
);
const handleEnterPlanModeApprove = useCallback(async () => {
const currentIndex = approvalResults.length;
const approval = pendingApprovals[currentIndex];
if (!approval) return;
const handleEnterPlanModeApprove = useCallback(
async (preserveMode: boolean = false) => {
const currentIndex = approvalResults.length;
const approval = pendingApprovals[currentIndex];
if (!approval) return;
const isLast = currentIndex + 1 >= pendingApprovals.length;
const isLast = currentIndex + 1 >= pendingApprovals.length;
// Generate plan file path
const planFilePath = generatePlanFilePath();
const applyPatchRelativePath = relative(
process.cwd(),
planFilePath,
).replace(/\\/g, "/");
// Generate plan file path
const planFilePath = generatePlanFilePath();
const applyPatchRelativePath = relative(
process.cwd(),
planFilePath,
).replace(/\\/g, "/");
// Toggle plan mode on and store plan file path
permissionMode.setMode("plan");
permissionMode.setPlanFilePath(planFilePath);
cacheLastPlanFilePath(planFilePath);
setUiPermissionMode("plan");
// Store plan file path
permissionMode.setPlanFilePath(planFilePath);
cacheLastPlanFilePath(planFilePath);
// Get the tool return message from the implementation
const toolReturn = `Entered plan mode. You should now focus on exploring the codebase and designing an implementation approach.
if (!preserveMode) {
// Normal flow: switch to plan mode
permissionMode.setMode("plan");
setUiPermissionMode("plan");
}
// Get the tool return message from the implementation
const toolReturn = `Entered plan mode. You should now focus on exploring the codebase and designing an implementation approach.
In plan mode, you should:
1. Thoroughly explore the codebase to understand existing patterns
@@ -12616,46 +12631,48 @@ Remember: DO NOT write or edit any files yet. This is a read-only exploration an
Plan file path: ${planFilePath}
If using apply_patch, use this exact relative patch path: ${applyPatchRelativePath}`;
const precomputedResult: ToolExecutionResult = {
toolReturn,
status: "success",
};
const precomputedResult: ToolExecutionResult = {
toolReturn,
status: "success",
};
// Update buffers with tool return
onChunk(buffersRef.current, {
message_type: "tool_return_message",
id: "dummy",
date: new Date().toISOString(),
tool_call_id: approval.toolCallId,
tool_return: toolReturn,
status: "success",
stdout: null,
stderr: null,
});
// Update buffers with tool return
onChunk(buffersRef.current, {
message_type: "tool_return_message",
id: "dummy",
date: new Date().toISOString(),
tool_call_id: approval.toolCallId,
tool_return: toolReturn,
status: "success",
stdout: null,
stderr: null,
});
setThinkingMessage(getRandomThinkingVerb());
refreshDerived();
setThinkingMessage(getRandomThinkingVerb());
refreshDerived();
const decision = {
type: "approve" as const,
approval,
precomputedResult,
};
const decision = {
type: "approve" as const,
approval,
precomputedResult,
};
if (isLast) {
setIsExecutingTool(true);
await sendAllResults(decision);
} else {
setApprovalResults((prev) => [...prev, decision]);
}
}, [
pendingApprovals,
approvalResults,
sendAllResults,
refreshDerived,
setUiPermissionMode,
cacheLastPlanFilePath,
]);
if (isLast) {
setIsExecutingTool(true);
await sendAllResults(decision);
} else {
setApprovalResults((prev) => [...prev, decision]);
}
},
[
pendingApprovals,
approvalResults,
sendAllResults,
refreshDerived,
setUiPermissionMode,
cacheLastPlanFilePath,
],
);
const handleEnterPlanModeReject = useCallback(async () => {
const currentIndex = approvalResults.length;
@@ -12681,6 +12698,20 @@ If using apply_patch, use this exact relative patch path: ${applyPatchRelativePa
}
}, [pendingApprovals, approvalResults, sendAllResults]);
// Guard EnterPlanMode:
// When in bypassPermissions (YOLO) mode, auto-approve EnterPlanMode and stay
// in YOLO — the agent gets plan instructions but keeps full permissions.
// The existing ExitPlanMode guard then auto-approves the exit too.
useEffect(() => {
const currentIndex = approvalResults.length;
const approval = pendingApprovals[currentIndex];
if (approval?.toolName === "EnterPlanMode") {
if (permissionMode.getMode() === "bypassPermissions") {
handleEnterPlanModeApprove(true);
}
}
}, [pendingApprovals, approvalResults.length, handleEnterPlanModeApprove]);
// Live area shows only in-progress items
// biome-ignore lint/correctness/useExhaustiveDependencies: staticItems.length and deferredCommitAt are intentional triggers to recompute when items are promoted to static or deferred commits complete
const liveItems = useMemo(() => {

View File

@@ -60,7 +60,7 @@ describe("permission mode retry wiring", () => {
);
const enterPlanStart = source.indexOf(
"const handleEnterPlanModeApprove = useCallback(async () => {",
"const handleEnterPlanModeApprove = useCallback(",
);
const enterPlanEnd = source.indexOf(
"const handleEnterPlanModeReject = useCallback(async () => {",
@@ -133,6 +133,44 @@ describe("permission mode retry wiring", () => {
expect(segment).toContain("continue;");
});
test("handleEnterPlanModeApprove supports preserveMode to stay in YOLO", () => {
const source = readAppSource();
const start = source.indexOf(
"const handleEnterPlanModeApprove = useCallback(",
);
const end = source.indexOf(
"const handleEnterPlanModeReject = useCallback(async () => {",
);
expect(start).toBeGreaterThan(-1);
expect(end).toBeGreaterThan(start);
const segment = source.slice(start, end);
expect(segment).toContain("preserveMode: boolean = false");
expect(segment).toContain("if (!preserveMode)");
expect(segment).toContain('permissionMode.setMode("plan")');
});
test("auto-approves EnterPlanMode in bypassPermissions mode", () => {
const source = readAppSource();
const guardStart = source.indexOf("Guard EnterPlanMode:");
expect(guardStart).toBeGreaterThan(-1);
const guardEnd = source.indexOf(
"// Live area shows only in-progress items",
guardStart,
);
expect(guardEnd).toBeGreaterThan(guardStart);
const segment = source.slice(guardStart, guardEnd);
expect(segment).toContain('approval?.toolName === "EnterPlanMode"');
expect(segment).toContain(
'permissionMode.getMode() === "bypassPermissions"',
);
expect(segment).toContain("handleEnterPlanModeApprove(true)");
});
test("preserves saved plan path when approving ExitPlanMode after mode cycling", () => {
const source = readAppSource();