feat: add auto-approve during todo execution setting

- Add new setting alwaysAllowDuringTodoExecution to GlobalSettings
- Update auto-approval logic to automatically approve write operations
  while todos are in progress (pending or in_progress status)
- Add UI toggle for the new setting in Settings and AutoApproveDropdown
- Add comprehensive tests for the new auto-approval behavior

This addresses the request to allow automatic continuation of modifications
while a todo list is being executed, reducing the need for manual approval
during task completion.

Closes #10123
This commit is contained in:
Roo Code 2025-12-16 09:56:48 +00:00
parent 596783d365
commit 4c1a8c3b50
13 changed files with 241 additions and 2 deletions

View file

@ -80,6 +80,7 @@ export const globalSettingsSchema = z.object({
alwaysAllowSubtasks: z.boolean().optional(),
alwaysAllowExecute: z.boolean().optional(),
alwaysAllowFollowupQuestions: z.boolean().optional(),
alwaysAllowDuringTodoExecution: z.boolean().optional(),
followupAutoApproveTimeoutMs: z.number().optional(),
allowedCommands: z.array(z.string()).optional(),
deniedCommands: z.array(z.string()).optional(),
@ -311,6 +312,7 @@ export const EVALS_SETTINGS: RooCodeSettings = {
alwaysAllowSubtasks: true,
alwaysAllowExecute: true,
alwaysAllowFollowupQuestions: true,
alwaysAllowDuringTodoExecution: false,
followupAutoApproveTimeoutMs: 0,
allowedCommands: ["*"],
commandExecutionTimeout: 20,

View file

@ -0,0 +1,178 @@
import { checkAutoApproval } from "../index"
import type { TodoItem } from "@roo-code/types"
describe("checkAutoApproval", () => {
describe("alwaysAllowDuringTodoExecution", () => {
const baseState = {
autoApprovalEnabled: true,
alwaysAllowDuringTodoExecution: true,
alwaysAllowWrite: false, // Intentionally false to test that todo execution takes precedence
}
// Tool names used in auto-approval are: editedExistingFile, appliedDiff, newFileCreated
const writeToolText = JSON.stringify({
tool: "editedExistingFile",
path: "test.ts",
content: "test content",
})
it("should auto-approve write operations when todos are in progress", async () => {
const todoList: TodoItem[] = [
{ id: "1", content: "Task 1", status: "completed" },
{ id: "2", content: "Task 2", status: "in_progress" },
{ id: "3", content: "Task 3", status: "pending" },
]
const result = await checkAutoApproval({
state: baseState,
ask: "tool",
text: writeToolText,
todoList,
})
expect(result.decision).toBe("approve")
})
it("should auto-approve when todos are pending", async () => {
const todoList: TodoItem[] = [{ id: "1", content: "Task 1", status: "pending" }]
const result = await checkAutoApproval({
state: baseState,
ask: "tool",
text: writeToolText,
todoList,
})
expect(result.decision).toBe("approve")
})
it("should not auto-approve when all todos are completed", async () => {
const todoList: TodoItem[] = [
{ id: "1", content: "Task 1", status: "completed" },
{ id: "2", content: "Task 2", status: "completed" },
]
const result = await checkAutoApproval({
state: baseState,
ask: "tool",
text: writeToolText,
todoList,
})
// Should fall back to normal behavior - since alwaysAllowWrite is false, should ask
expect(result.decision).toBe("ask")
})
it("should not auto-approve when todo list is empty", async () => {
const result = await checkAutoApproval({
state: baseState,
ask: "tool",
text: writeToolText,
todoList: [],
})
expect(result.decision).toBe("ask")
})
it("should not auto-approve when todo list is undefined", async () => {
const result = await checkAutoApproval({
state: baseState,
ask: "tool",
text: writeToolText,
todoList: undefined,
})
expect(result.decision).toBe("ask")
})
it("should not auto-approve when alwaysAllowDuringTodoExecution is false", async () => {
const state = {
...baseState,
alwaysAllowDuringTodoExecution: false,
}
const todoList: TodoItem[] = [{ id: "1", content: "Task 1", status: "in_progress" }]
const result = await checkAutoApproval({
state,
ask: "tool",
text: writeToolText,
todoList,
})
expect(result.decision).toBe("ask")
})
it("should not auto-approve protected files during todo execution", async () => {
const todoList: TodoItem[] = [{ id: "1", content: "Task 1", status: "in_progress" }]
const result = await checkAutoApproval({
state: baseState,
ask: "tool",
text: writeToolText,
isProtected: true,
todoList,
})
expect(result.decision).toBe("ask")
})
it("should not auto-approve files outside workspace during todo execution", async () => {
const todoList: TodoItem[] = [{ id: "1", content: "Task 1", status: "in_progress" }]
const outsideWorkspaceWriteToolText = JSON.stringify({
tool: "editedExistingFile",
path: "/outside/workspace/test.ts",
content: "test content",
isOutsideWorkspace: true,
})
const result = await checkAutoApproval({
state: baseState,
ask: "tool",
text: outsideWorkspaceWriteToolText,
todoList,
})
expect(result.decision).toBe("ask")
})
it("should fall back to regular alwaysAllowWrite when todos are complete", async () => {
const state = {
...baseState,
alwaysAllowWrite: true, // Enable regular write auto-approval
}
const todoList: TodoItem[] = [{ id: "1", content: "Task 1", status: "completed" }]
const result = await checkAutoApproval({
state,
ask: "tool",
text: writeToolText,
todoList,
})
// Should fall back to alwaysAllowWrite behavior
expect(result.decision).toBe("approve")
})
it("should auto-approve appliedDiff tool during todo execution", async () => {
const todoList: TodoItem[] = [{ id: "1", content: "Task 1", status: "in_progress" }]
const applyDiffToolText = JSON.stringify({
tool: "appliedDiff",
path: "test.ts",
diff: "some diff content",
})
const result = await checkAutoApproval({
state: baseState,
ask: "tool",
text: applyDiffToolText,
todoList,
})
expect(result.decision).toBe("approve")
})
})
})

View file

@ -1,4 +1,4 @@
import { type ClineAsk, type McpServerUse, type FollowUpData, isNonBlockingAsk } from "@roo-code/types"
import { type ClineAsk, type McpServerUse, type FollowUpData, type TodoItem, isNonBlockingAsk } from "@roo-code/types"
import type { ClineSayTool, ExtensionState } from "../../shared/ExtensionMessage"
import { ClineAskResponse } from "../../shared/WebviewMessage"
@ -17,6 +17,7 @@ export type AutoApprovalState =
| "alwaysAllowSubtasks"
| "alwaysAllowExecute"
| "alwaysAllowFollowupQuestions"
| "alwaysAllowDuringTodoExecution"
// Some of these actions have additional settings associated with them.
export type AutoApprovalStateOptions =
@ -29,6 +30,17 @@ export type AutoApprovalStateOptions =
| "allowedCommands" // For `alwaysAllowExecute`.
| "deniedCommands"
/**
* Check if there are incomplete todo items in the list.
* A todo is considered incomplete if its status is "pending" or "in_progress".
*/
function hasTodosInProgress(todoList?: TodoItem[]): boolean {
if (!todoList || todoList.length === 0) {
return false
}
return todoList.some((todo) => todo.status === "pending" || todo.status === "in_progress")
}
export type CheckAutoApprovalResult =
| { decision: "approve" }
| { decision: "deny" }
@ -44,11 +56,13 @@ export async function checkAutoApproval({
ask,
text,
isProtected,
todoList,
}: {
state?: Pick<ExtensionState, AutoApprovalState | AutoApprovalStateOptions>
ask: ClineAsk
text?: string
isProtected?: boolean
todoList?: TodoItem[]
}): Promise<CheckAutoApprovalResult> {
if (isNonBlockingAsk(ask)) {
return { decision: "approve" }
@ -173,6 +187,19 @@ export async function checkAutoApproval({
}
if (isWriteToolAction(tool)) {
// Check if auto-approve during todo execution is enabled and todos are in progress
const todosInProgress = hasTodosInProgress(todoList)
const autoApproveDuringTodo =
state.alwaysAllowDuringTodoExecution === true &&
todosInProgress &&
!isOutsideWorkspace && // Never auto-approve outside workspace during todo execution
!isProtected // Never auto-approve protected files during todo execution
if (autoApproveDuringTodo) {
return { decision: "approve" }
}
// Fall back to regular write auto-approval logic
return state.alwaysAllowWrite === true &&
(!isOutsideWorkspace || state.alwaysAllowWriteOutsideWorkspace === true) &&
(!isProtected || state.alwaysAllowWriteProtected === true)

View file

@ -1089,7 +1089,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
// Automatically approve if the ask according to the user's settings.
const provider = this.providerRef.deref()
const state = provider ? await provider.getState() : undefined
const approval = await checkAutoApproval({ state, ask: type, text, isProtected })
const approval = await checkAutoApproval({ state, ask: type, text, isProtected, todoList: this.todoList })
if (approval.decision === "approve") {
this.approveAsk()

View file

@ -238,6 +238,7 @@ export type ExtensionState = Pick<
| "alwaysAllowSubtasks"
| "alwaysAllowFollowupQuestions"
| "alwaysAllowExecute"
| "alwaysAllowDuringTodoExecution"
| "followupAutoApproveTimeoutMs"
| "allowedCommands"
| "deniedCommands"

View file

@ -39,6 +39,7 @@ export const AutoApproveDropdown = ({ disabled = false, triggerClassName = "" }:
setAlwaysAllowModeSwitch,
setAlwaysAllowSubtasks,
setAlwaysAllowFollowupQuestions,
setAlwaysAllowDuringTodoExecution,
} = useExtensionState()
const toggles = useAutoApprovalToggles()
@ -72,6 +73,9 @@ export const AutoApproveDropdown = ({ disabled = false, triggerClassName = "" }:
case "alwaysAllowFollowupQuestions":
setAlwaysAllowFollowupQuestions(value)
break
case "alwaysAllowDuringTodoExecution":
setAlwaysAllowDuringTodoExecution(value)
break
}
// If enabling any option, ensure autoApprovalEnabled is true.
@ -90,6 +94,7 @@ export const AutoApproveDropdown = ({ disabled = false, triggerClassName = "" }:
setAlwaysAllowModeSwitch,
setAlwaysAllowSubtasks,
setAlwaysAllowFollowupQuestions,
setAlwaysAllowDuringTodoExecution,
setAutoApprovalEnabled,
],
)

View file

@ -29,6 +29,7 @@ type AutoApproveSettingsProps = HTMLAttributes<HTMLDivElement> & {
alwaysAllowSubtasks?: boolean
alwaysAllowExecute?: boolean
alwaysAllowFollowupQuestions?: boolean
alwaysAllowDuringTodoExecution?: boolean
followupAutoApproveTimeoutMs?: number
allowedCommands?: string[]
allowedMaxRequests?: number | undefined
@ -46,6 +47,7 @@ type AutoApproveSettingsProps = HTMLAttributes<HTMLDivElement> & {
| "alwaysAllowSubtasks"
| "alwaysAllowExecute"
| "alwaysAllowFollowupQuestions"
| "alwaysAllowDuringTodoExecution"
| "followupAutoApproveTimeoutMs"
| "allowedCommands"
| "allowedMaxRequests"
@ -66,6 +68,7 @@ export const AutoApproveSettings = ({
alwaysAllowSubtasks,
alwaysAllowExecute,
alwaysAllowFollowupQuestions,
alwaysAllowDuringTodoExecution,
followupAutoApproveTimeoutMs = 60000,
allowedCommands,
allowedMaxRequests,
@ -160,6 +163,7 @@ export const AutoApproveSettings = ({
alwaysAllowSubtasks={alwaysAllowSubtasks}
alwaysAllowExecute={alwaysAllowExecute}
alwaysAllowFollowupQuestions={alwaysAllowFollowupQuestions}
alwaysAllowDuringTodoExecution={alwaysAllowDuringTodoExecution}
onToggle={(key, value) => setCachedStateField(key, value)}
/>

View file

@ -14,6 +14,7 @@ type AutoApproveToggles = Pick<
| "alwaysAllowSubtasks"
| "alwaysAllowExecute"
| "alwaysAllowFollowupQuestions"
| "alwaysAllowDuringTodoExecution"
>
export type AutoApproveSetting = keyof AutoApproveToggles
@ -83,6 +84,13 @@ export const autoApproveSettingsConfig: Record<AutoApproveSetting, AutoApproveCo
icon: "question",
testId: "always-allow-followup-questions-toggle",
},
alwaysAllowDuringTodoExecution: {
key: "alwaysAllowDuringTodoExecution",
labelKey: "settings:autoApprove.todoExecution.label",
descriptionKey: "settings:autoApprove.todoExecution.description",
icon: "checklist",
testId: "always-allow-todo-execution-toggle",
},
}
type AutoApproveToggleProps = AutoApproveToggles & {

View file

@ -199,6 +199,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
customSupportPrompts,
profileThresholds,
alwaysAllowFollowupQuestions,
alwaysAllowDuringTodoExecution,
followupAutoApproveTimeoutMs,
includeDiagnosticMessages,
maxDiagnosticMessages,
@ -404,6 +405,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
maxDiagnosticMessages: maxDiagnosticMessages ?? 50,
alwaysAllowSubtasks,
alwaysAllowFollowupQuestions: alwaysAllowFollowupQuestions ?? false,
alwaysAllowDuringTodoExecution: alwaysAllowDuringTodoExecution ?? false,
followupAutoApproveTimeoutMs,
condensingApiConfigId: condensingApiConfigId || "",
includeTaskHistoryInEnhance: includeTaskHistoryInEnhance ?? true,
@ -718,6 +720,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
alwaysAllowSubtasks={alwaysAllowSubtasks}
alwaysAllowExecute={alwaysAllowExecute}
alwaysAllowFollowupQuestions={alwaysAllowFollowupQuestions}
alwaysAllowDuringTodoExecution={alwaysAllowDuringTodoExecution}
followupAutoApproveTimeoutMs={followupAutoApproveTimeoutMs}
allowedCommands={allowedCommands}
allowedMaxRequests={allowedMaxRequests ?? undefined}

View file

@ -25,6 +25,7 @@ describe("AutoApproveToggle", () => {
alwaysAllowSubtasks: false,
alwaysAllowExecute: true,
alwaysAllowFollowupQuestions: false,
alwaysAllowDuringTodoExecution: false,
onToggle: mockOnToggle,
}

View file

@ -71,6 +71,7 @@ export interface ExtensionStateContextType extends ExtensionState {
setAlwaysAllowMcp: (value: boolean) => void
setAlwaysAllowModeSwitch: (value: boolean) => void
setAlwaysAllowSubtasks: (value: boolean) => void
setAlwaysAllowDuringTodoExecution: (value: boolean) => void
setBrowserToolEnabled: (value: boolean) => void
setShowRooIgnoredFiles: (value: boolean) => void
setShowAnnouncement: (value: boolean) => void
@ -488,6 +489,8 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
setAlwaysAllowMcp: (value) => setState((prevState) => ({ ...prevState, alwaysAllowMcp: value })),
setAlwaysAllowModeSwitch: (value) => setState((prevState) => ({ ...prevState, alwaysAllowModeSwitch: value })),
setAlwaysAllowSubtasks: (value) => setState((prevState) => ({ ...prevState, alwaysAllowSubtasks: value })),
setAlwaysAllowDuringTodoExecution: (value) =>
setState((prevState) => ({ ...prevState, alwaysAllowDuringTodoExecution: value })),
setAlwaysAllowFollowupQuestions,
setFollowupAutoApproveTimeoutMs: (value) =>
setState((prevState) => ({ ...prevState, followupAutoApproveTimeoutMs: value })),

View file

@ -15,6 +15,7 @@ export function useAutoApprovalToggles() {
alwaysAllowModeSwitch,
alwaysAllowSubtasks,
alwaysAllowFollowupQuestions,
alwaysAllowDuringTodoExecution,
} = useExtensionState()
const toggles = useMemo(
@ -27,6 +28,7 @@ export function useAutoApprovalToggles() {
alwaysAllowModeSwitch,
alwaysAllowSubtasks,
alwaysAllowFollowupQuestions,
alwaysAllowDuringTodoExecution,
}),
[
alwaysAllowReadOnly,
@ -37,6 +39,7 @@ export function useAutoApprovalToggles() {
alwaysAllowModeSwitch,
alwaysAllowSubtasks,
alwaysAllowFollowupQuestions,
alwaysAllowDuringTodoExecution,
],
)

View file

@ -227,6 +227,10 @@
"description": "Automatically select the first suggested answer for follow-up questions after the configured timeout",
"timeoutLabel": "Time to wait before auto-selecting the first answer"
},
"todoExecution": {
"label": "Todo",
"description": "Automatically approve file writes while a todo list is being executed. Helps Roo complete todo items without interruption."
},
"execute": {
"label": "Execute",
"description": "Automatically execute allowed terminal commands without requiring approval",