working first version

This commit is contained in:
Will Li 2025-07-08 01:16:18 -07:00
parent 7645aad435
commit c0c7878497
21 changed files with 1736 additions and 3 deletions

View file

@ -84,6 +84,10 @@ export const globalSettingsSchema = z.object({
terminalZdotdir: z.boolean().optional(),
terminalCompressProgressBar: z.boolean().optional(),
// Timeout settings
toolExecutionTimeoutMs: z.number().min(1000).max(1800000).optional(), // 1s to 30min
timeoutFallbackEnabled: z.boolean().optional(),
rateLimitSeconds: z.number().optional(),
diffEnabled: z.boolean().optional(),
fuzzyMatchThreshold: z.number().optional(),
@ -226,6 +230,10 @@ export const EVALS_SETTINGS: RooCodeSettings = {
enableCheckpoints: false,
// Timeout settings
toolExecutionTimeoutMs: 300000, // 5 minutes default
timeoutFallbackEnabled: false,
rateLimitSeconds: 0,
maxOpenTabsContext: 20,
maxWorkspaceFiles: 200,

View file

@ -15,6 +15,18 @@ export const formatResponse = {
toolError: (error?: string) => `The tool execution failed with the following error:\n<error>\n${error}\n</error>`,
toolTimeout: (toolName: string, timeoutMs: number, executionTimeMs: number) =>
`The ${toolName} operation timed out after ${Math.round(timeoutMs / 1000)} seconds and was automatically canceled.
<timeout_details>
Tool: ${toolName}
Configured Timeout: ${Math.round(timeoutMs / 1000)}s
Execution Time: ${Math.round(executionTimeMs / 1000)}s
Status: Canceled
</timeout_details>
The operation has been terminated to prevent system resource issues. Please consider one of the following approaches to complete your task.`,
rooIgnoreError: (path: string) =>
`Access to ${path} is blocked by the .rooignore file settings. You must try to continue in the task without using this file, or ask the user to update the .rooignore file.`,

View file

@ -0,0 +1,358 @@
import type { ToolName } from "@roo-code/types"
import type { Task } from "../task/Task"
import type { SingleCompletionHandler } from "../../api"
export interface TimeoutFallbackContext {
toolName: ToolName
timeoutMs: number
executionTimeMs: number
toolParams?: Record<string, any>
errorMessage?: string
taskContext?: {
currentStep?: string
previousActions?: string[]
workingDirectory?: string
}
}
export interface TimeoutFallbackResult {
success: boolean
toolCall?: {
name: "ask_followup_question"
params: {
question: string
follow_up: string
}
}
error?: string
}
/**
* Generates AI-powered fallback suggestions for timeout scenarios
*/
export class TimeoutFallbackGenerator {
/**
* Generate an AI-powered ask_followup_question tool call for timeout scenarios
*/
public static async generateAiFallback(
context: TimeoutFallbackContext,
task?: Task,
): Promise<TimeoutFallbackResult> {
// Try to use AI to generate contextual suggestions
if (task?.api && "completePrompt" in task.api) {
try {
const aiResult = await this.generateAiSuggestions(context, task.api as SingleCompletionHandler)
if (aiResult.success) {
return aiResult
}
} catch (error) {
// AI failed, fall through to static suggestions
}
}
// Fallback to static suggestions if AI fails or is unavailable
const toolCall = this.generateStaticToolCall(context)
return {
success: true,
toolCall,
}
}
/**
* Generate AI-powered suggestions using the task's API handler
*/
private static async generateAiSuggestions(
context: TimeoutFallbackContext,
apiHandler: SingleCompletionHandler,
): Promise<TimeoutFallbackResult> {
try {
const prompt = this.createAiPrompt(context)
const aiResponse = await apiHandler.completePrompt(prompt)
// Parse the AI response to extract suggestions
const suggestions = this.parseAiResponse(aiResponse)
if (suggestions.length === 0) {
throw new Error("No valid suggestions generated by AI")
}
const question = `The ${context.toolName} operation timed out after ${Math.round(context.timeoutMs / 1000)} seconds. How would you like to proceed?`
const followUpXml = suggestions
.map((suggestion) =>
suggestion.mode
? `<suggest mode="${suggestion.mode}">${suggestion.text}</suggest>`
: `<suggest>${suggestion.text}</suggest>`,
)
.join("\n")
return {
success: true,
toolCall: {
name: "ask_followup_question",
params: {
question,
follow_up: followUpXml,
},
},
}
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error generating AI suggestions",
}
}
}
/**
* Create a prompt for the AI to generate contextual timeout fallback suggestions
*/
private static createAiPrompt(context: TimeoutFallbackContext): string {
const { toolName, timeoutMs, executionTimeMs, toolParams, taskContext } = context
const timeoutSeconds = Math.round(timeoutMs / 1000)
const executionSeconds = Math.round(executionTimeMs / 1000)
let prompt = `A ${toolName} operation has timed out after ${timeoutSeconds} seconds (actual execution time: ${executionSeconds} seconds).
Context:
- Tool: ${toolName}
- Timeout limit: ${timeoutSeconds}s
- Actual execution time: ${executionSeconds}s`
// Add tool-specific context
if (toolParams) {
prompt += `\n- Parameters: ${JSON.stringify(toolParams, null, 2)}`
}
// Add task context if available
if (taskContext) {
if (taskContext.currentStep) {
prompt += `\n- Current step: ${taskContext.currentStep}`
}
if (taskContext.workingDirectory) {
prompt += `\n- Working directory: ${taskContext.workingDirectory}`
}
}
prompt += `
Generate exactly 3-4 specific, actionable suggestions for how to proceed after this timeout. Each suggestion should be:
1. Contextually relevant to the specific ${toolName} operation that timed out
2. Actionable and specific (not generic advice)
3. Focused on solving the immediate problem
4. Ordered by likelihood of success
Format your response as a simple numbered list:
1. [First suggestion]
2. [Second suggestion]
3. [Third suggestion]
4. [Fourth suggestion (optional)]
Focus on practical solutions like:
- Breaking the operation into smaller parts
- Using alternative tools or methods
- Adjusting parameters or settings
- Checking for underlying issues
- Optimizing the approach
Keep each suggestion concise (under 80 characters) and actionable.`
return prompt
}
/**
* Parse AI response to extract suggestions
*/
private static parseAiResponse(response: string): Array<{ text: string; mode?: string }> {
const suggestions: Array<{ text: string; mode?: string }> = []
// Look for numbered list items
const lines = response.split("\n")
for (const line of lines) {
const trimmed = line.trim()
// Match patterns like "1. suggestion", "2) suggestion", etc.
const match = trimmed.match(/^(\d+)[.)]\s*(.+)$/)
if (match && match[2]) {
const suggestionText = match[2].trim()
if (suggestionText.length > 0 && suggestionText.length <= 120) {
suggestions.push({ text: suggestionText })
}
}
}
// If no numbered list found, try to extract sentences
if (suggestions.length === 0) {
const sentences = response
.split(/[.!?]+/)
.map((s) => s.trim())
.filter((s) => s.length > 10 && s.length <= 120)
for (let i = 0; i < Math.min(4, sentences.length); i++) {
suggestions.push({ text: sentences[i] })
}
}
return suggestions.slice(0, 4) // Limit to 4 suggestions
}
/**
* Generate static fallback suggestions when AI is unavailable
*/
private static generateStaticToolCall(context: TimeoutFallbackContext): TimeoutFallbackResult["toolCall"] {
const suggestions = this.generateContextualSuggestions(context)
const question = `The ${context.toolName} operation timed out after ${Math.round(context.timeoutMs / 1000)} seconds. How would you like to proceed?`
const followUpXml = suggestions
.map((suggestion) =>
suggestion.mode
? `<suggest mode="${suggestion.mode}">${suggestion.text}</suggest>`
: `<suggest>${suggestion.text}</suggest>`,
)
.join("\n")
return {
name: "ask_followup_question",
params: {
question,
follow_up: followUpXml,
},
}
}
/**
* Generate contextual suggestions based on tool type and parameters
*/
private static generateContextualSuggestions(
context: TimeoutFallbackContext,
): Array<{ text: string; mode?: string }> {
const { toolName, toolParams } = context
switch (toolName) {
case "execute_command":
return this.generateCommandSuggestions(toolParams)
case "read_file":
return this.generateReadFileSuggestions(toolParams)
case "write_to_file":
return this.generateWriteFileSuggestions(toolParams)
case "browser_action":
return this.generateBrowserSuggestions(toolParams)
case "search_files":
return this.generateSearchSuggestions(toolParams)
default:
return this.generateGenericSuggestions(context)
}
}
private static generateCommandSuggestions(params?: Record<string, any>): Array<{ text: string; mode?: string }> {
const command = params?.command || "the command"
return [
{
text: `Break "${command}" into smaller, sequential steps that can complete faster`,
},
{
text: `Run "${command}" in the background using '&' or 'nohup' to avoid blocking`,
},
{
text: `Try an alternative approach or tool to accomplish the same goal`,
},
{
text: `Increase the timeout setting if this operation legitimately needs more time`,
},
]
}
private static generateReadFileSuggestions(params?: Record<string, any>): Array<{ text: string; mode?: string }> {
const filePath = params?.path || "the file"
return [
{
text: `Read "${filePath}" in smaller chunks using line ranges`,
},
{
text: `Check if "${filePath}" is accessible and not locked by another process`,
},
{
text: `Use a different approach to access the file content`,
},
{
text: `Increase the timeout if this is a legitimately large file`,
},
]
}
private static generateWriteFileSuggestions(params?: Record<string, any>): Array<{ text: string; mode?: string }> {
const filePath = params?.path || "the file"
return [
{
text: `Write to "${filePath}" incrementally using insert_content instead`,
},
{
text: `Check if "${filePath}" is writable and not locked`,
},
{
text: `Use apply_diff for targeted changes instead of full file replacement`,
},
{
text: `Break the content into smaller write operations`,
},
]
}
private static generateBrowserSuggestions(params?: Record<string, any>): Array<{ text: string; mode?: string }> {
const action = params?.action || "browser action"
return [
{
text: `Simplify the "${action}" into smaller, more targeted steps`,
},
{
text: `Wait for specific elements to load before proceeding`,
},
{
text: `Use direct API calls instead of browser automation if possible`,
},
{
text: `Reset the browser session and try again`,
},
]
}
private static generateSearchSuggestions(params?: Record<string, any>): Array<{ text: string; mode?: string }> {
return [
{
text: `Narrow the search scope to specific directories`,
},
{
text: `Use simpler search patterns or literal strings`,
},
{
text: `Apply file type filters to reduce search space`,
},
{
text: `Search incrementally in smaller batches`,
},
]
}
private static generateGenericSuggestions(context: TimeoutFallbackContext): Array<{ text: string; mode?: string }> {
return [
{
text: `Break the ${context.toolName} operation into smaller steps`,
},
{
text: `Try an alternative approach to accomplish the same goal`,
},
{
text: `Check system resources and try again`,
},
{
text: `Increase the timeout setting for this operation`,
},
]
}
}

View file

@ -0,0 +1,75 @@
import type { ToolName } from "@roo-code/types"
import { formatResponse } from "../prompts/responses"
import {
TimeoutFallbackGenerator,
type TimeoutFallbackContext,
type TimeoutFallbackResult,
} from "./TimeoutFallbackGenerator"
import type { Task } from "../task/Task"
import { parseAssistantMessage } from "../assistant-message/parseAssistantMessage"
/**
* Generates AI-powered fallback suggestions for timeout scenarios
*/
export class TimeoutFallbackHandler {
/**
* Create a timeout response with AI-generated fallback question
*/
public static async createTimeoutResponse(
toolName: ToolName,
timeoutMs: number,
executionTimeMs: number,
context?: any,
task?: Task,
): Promise<string> {
const baseResponse = formatResponse.toolTimeout(toolName, timeoutMs, executionTimeMs)
// Create context for AI fallback generation
const aiContext: TimeoutFallbackContext = {
toolName,
timeoutMs,
executionTimeMs,
toolParams: context,
taskContext: task
? {
workingDirectory: task.cwd,
}
: undefined,
}
// Generate AI-powered fallback (with static fallback if AI fails)
const aiResult = await TimeoutFallbackGenerator.generateAiFallback(aiContext, task)
if (aiResult.success && aiResult.toolCall && task) {
// Inject the tool call directly into the assistant message content for proper execution
this.injectToolCallIntoMessageContent(aiResult.toolCall, task)
return baseResponse
}
// This should rarely happen since generateAiFallback always provides static fallback
return `${baseResponse}\n\nThe operation timed out. Please consider breaking this into smaller steps or trying a different approach.`
}
/**
* Inject a tool call directly into the assistant message content for proper parsing and execution
*/
private static injectToolCallIntoMessageContent(toolCall: TimeoutFallbackResult["toolCall"], task: Task): void {
if (toolCall?.name === "ask_followup_question" && toolCall.params) {
const { question, follow_up } = toolCall.params
// Create the XML tool call string
const toolCallXml = `<ask_followup_question>
<question>${question}</question>
<follow_up>
${follow_up}
</follow_up>
</ask_followup_question>`
// Parse the tool call XML to create proper assistant message content
const parsedContent = parseAssistantMessage(toolCallXml)
// Add the parsed tool call to the assistant message content
task.assistantMessageContent.push(...parsedContent)
}
}
}

View file

@ -0,0 +1,199 @@
import { EventEmitter } from "events"
import type { ToolName } from "@roo-code/types"
export interface TimeoutConfig {
toolName: ToolName
timeoutMs: number
enableFallback: boolean
taskId?: string
}
export interface TimeoutResult<T> {
success: boolean
result?: T
timedOut: boolean
fallbackTriggered: boolean
error?: Error
executionTimeMs: number
}
export interface TimeoutEvent {
toolName: ToolName
timeoutMs: number
executionTimeMs: number
taskId?: string
timestamp: number
}
/**
* Manages timeouts for all tool executions with configurable fallback mechanisms
*/
export class TimeoutManager extends EventEmitter {
private static instance: TimeoutManager | undefined
private activeOperations = new Map<string, AbortController>()
/**
* Multiple timeout events can be run at once
* eg. running a command while reading a file
*/
private timeoutEvents: TimeoutEvent[] = []
private constructor() {
super()
}
public static getInstance(): TimeoutManager {
if (!TimeoutManager.instance) {
TimeoutManager.instance = new TimeoutManager()
}
return TimeoutManager.instance
}
/**
* Execute a function with timeout protection
*/
public async executeWithTimeout<T>(
operation: (signal: AbortSignal) => Promise<T>,
config: TimeoutConfig,
): Promise<TimeoutResult<T>> {
const operationId = this.generateOperationId(config.toolName, config.taskId)
const controller = new AbortController()
const startTime = Date.now()
// Store the controller for potential cancellation
this.activeOperations.set(operationId, controller)
try {
// Create timeout promise
const timeoutPromise = new Promise<never>((_, reject) => {
const timeoutId = setTimeout(() => {
controller.abort()
reject(new Error(`Operation timed out after ${config.timeoutMs}ms`))
}, config.timeoutMs)
// Clean up timeout if operation completes
controller.signal.addEventListener("abort", () => {
clearTimeout(timeoutId)
})
})
// Race between operation and timeout
const result = await Promise.race([operation(controller.signal), timeoutPromise])
const executionTimeMs = Date.now() - startTime
return {
success: true,
result,
timedOut: false,
fallbackTriggered: false,
executionTimeMs,
}
} catch (error) {
const executionTimeMs = Date.now() - startTime
const timedOut = controller.signal.aborted
if (timedOut) {
// Log timeout event
const timeoutEvent: TimeoutEvent = {
toolName: config.toolName,
timeoutMs: config.timeoutMs,
executionTimeMs,
taskId: config.taskId,
timestamp: Date.now(),
}
this.timeoutEvents.push(timeoutEvent)
this.emit("timeout", timeoutEvent)
return {
success: false,
timedOut: true,
fallbackTriggered: config.enableFallback,
error: error as Error,
executionTimeMs,
}
}
return {
success: false,
timedOut: false,
fallbackTriggered: false,
error: error as Error,
executionTimeMs,
}
} finally {
// Clean up
this.activeOperations.delete(operationId)
}
}
/**
* Cancel a specific operation by tool name and task ID
*/
public cancelOperation(toolName: ToolName, taskId?: string): boolean {
const operationId = this.generateOperationId(toolName, taskId)
const controller = this.activeOperations.get(operationId)
if (controller) {
controller.abort()
this.activeOperations.delete(operationId)
return true
}
return false
}
/**
* Cancel all active operations
*/
public cancelAllOperations(): void {
for (const controller of this.activeOperations.values()) {
controller.abort()
}
this.activeOperations.clear()
}
/**
* Get timeout events for debugging/monitoring
*/
public getTimeoutEvents(limit = 100): TimeoutEvent[] {
return this.timeoutEvents.slice(-limit)
}
/**
* Clear timeout event history
*/
public clearTimeoutEvents(): void {
this.timeoutEvents = []
}
/**
* Get active operation count
*/
public getActiveOperationCount(): number {
return this.activeOperations.size
}
/**
* Check if a specific operation is active
*/
public isOperationActive(toolName: ToolName, taskId?: string): boolean {
const operationId = this.generateOperationId(toolName, taskId)
return this.activeOperations.has(operationId)
}
private generateOperationId(toolName: ToolName, taskId?: string): string {
return `${toolName}:${taskId || "default"}:${Date.now()}`
}
/**
* Cleanup method for graceful shutdown
*/
public dispose(): void {
this.cancelAllOperations()
this.removeAllListeners()
this.timeoutEvents = []
}
}
export const timeoutManager = TimeoutManager.getInstance()

View file

@ -0,0 +1,163 @@
import type { ToolName } from "@roo-code/types"
import { timeoutManager, type TimeoutConfig, type TimeoutResult } from "./TimeoutManager"
export interface ToolExecutionOptions {
toolName: ToolName
taskId?: string
timeoutMs?: number
enableFallback?: boolean
}
/**
* Wrapper for executing tools with timeout protection
*/
export class ToolExecutionWrapper {
/**
* Execute a tool operation with timeout protection
*/
public static async execute<T>(
operation: (signal: AbortSignal) => Promise<T>,
options: ToolExecutionOptions,
defaultTimeoutMs = 300000, // 5 minutes default
): Promise<TimeoutResult<T>> {
const config: TimeoutConfig = {
toolName: options.toolName,
timeoutMs: options.timeoutMs ?? defaultTimeoutMs,
enableFallback: options.enableFallback ?? true,
taskId: options.taskId,
}
return timeoutManager.executeWithTimeout(operation, config)
}
/**
* Wrap a promise-based operation to support AbortSignal
*/
public static wrapPromise<T>(promiseFactory: () => Promise<T>, signal: AbortSignal): Promise<T> {
return new Promise<T>((resolve, reject) => {
// Check if already aborted
if (signal.aborted) {
reject(new Error("Operation was aborted before starting"))
return
}
// Set up abort listener
const abortListener = () => {
reject(new Error("Operation was aborted"))
}
signal.addEventListener("abort", abortListener)
// Execute the operation
promiseFactory()
.then((result) => {
signal.removeEventListener("abort", abortListener)
resolve(result)
})
.catch((error) => {
signal.removeEventListener("abort", abortListener)
reject(error)
})
})
}
/**
* Wrap a callback-based operation to support AbortSignal
*/
public static wrapCallback<T>(
operation: (callback: (error: Error | null, result?: T) => void, signal: AbortSignal) => void,
signal: AbortSignal,
): Promise<T> {
return new Promise<T>((resolve, reject) => {
// Check if already aborted
if (signal.aborted) {
reject(new Error("Operation was aborted before starting"))
return
}
// Set up abort listener
const abortListener = () => {
reject(new Error("Operation was aborted"))
}
signal.addEventListener("abort", abortListener)
// Execute the operation
operation((error, result) => {
signal.removeEventListener("abort", abortListener)
if (error) {
reject(error)
} else {
resolve(result!)
}
}, signal)
})
}
/**
* Create an abortable delay
*/
public static delay(ms: number, signal: AbortSignal): Promise<void> {
return new Promise<void>((resolve, reject) => {
if (signal.aborted) {
reject(new Error("Delay was aborted before starting"))
return
}
const timeoutId = setTimeout(() => {
signal.removeEventListener("abort", abortListener)
resolve()
}, ms)
const abortListener = () => {
clearTimeout(timeoutId)
reject(new Error("Delay was aborted"))
}
signal.addEventListener("abort", abortListener)
})
}
/**
* Execute multiple operations in parallel with timeout protection
*/
public static async executeParallel<T>(
operations: Array<{
operation: (signal: AbortSignal) => Promise<T>
options: ToolExecutionOptions
}>,
defaultTimeoutMs = 300000,
): Promise<TimeoutResult<T>[]> {
const promises = operations.map(({ operation, options }) =>
ToolExecutionWrapper.execute(operation, options, defaultTimeoutMs),
)
return Promise.all(promises)
}
/**
* Execute operations in sequence with timeout protection
*/
public static async executeSequential<T>(
operations: Array<{
operation: (signal: AbortSignal) => Promise<T>
options: ToolExecutionOptions
}>,
defaultTimeoutMs = 300000,
): Promise<TimeoutResult<T>[]> {
const results: TimeoutResult<T>[] = []
for (const { operation, options } of operations) {
const result = await ToolExecutionWrapper.execute(operation, options, defaultTimeoutMs)
results.push(result)
// Stop execution if any operation fails or times out
if (!result.success) {
break
}
}
return results
}
}

View file

@ -0,0 +1,234 @@
// npx vitest run src/core/timeout/__tests__/ai-fallback-real.spec.ts
import { describe, test, expect, beforeEach, vitest } from "vitest"
import { TimeoutFallbackGenerator } from "../TimeoutFallbackGenerator"
import type { ApiHandler, SingleCompletionHandler } from "../../../api"
import type { Task } from "../../task/Task"
// Create a mock API handler that extends ApiHandler and includes completePrompt
interface MockApiHandler extends ApiHandler, SingleCompletionHandler {}
describe("TimeoutFallbackGenerator - Real AI Implementation", () => {
let mockApiHandler: MockApiHandler
let mockTask: Partial<Task>
beforeEach(() => {
vitest.clearAllMocks()
// Mock API handler that simulates real AI responses
mockApiHandler = {
createMessage: vitest.fn(),
getModel: vitest.fn().mockReturnValue({ id: "test-model", info: { maxTokens: 4096 } }),
countTokens: vitest.fn().mockResolvedValue(100),
completePrompt: vitest.fn(),
}
// Mock task with API handler
mockTask = {
api: mockApiHandler,
}
})
test("should use AI to generate contextual suggestions when available", async () => {
// Mock AI response with numbered suggestions
const mockAiResponse = `Here are some suggestions for the timeout:
1. Break the npm install command into smaller package installations
2. Clear npm cache and try again with npm cache clean --force
3. Use npm install --no-optional to skip optional dependencies
4. Check network connectivity and try with different registry`
;(mockApiHandler.completePrompt as any).mockResolvedValueOnce(mockAiResponse)
const context = {
toolName: "execute_command" as const,
timeoutMs: 30000,
executionTimeMs: 35000,
toolParams: { command: "npm install" },
}
const result = await TimeoutFallbackGenerator.generateAiFallback(context, mockTask as Task)
expect(result.success).toBe(true)
expect(result.toolCall).toBeDefined()
expect(result.toolCall?.name).toBe("ask_followup_question")
expect(result.toolCall?.params.question).toContain("execute_command")
expect(result.toolCall?.params.question).toContain("30 seconds")
// Check that AI-generated suggestions are included
const followUp = result.toolCall?.params.follow_up || ""
expect(followUp).toContain("Break the npm install command into smaller package installations")
expect(followUp).toContain("Clear npm cache and try again")
expect(followUp).toContain("Use npm install --no-optional")
expect(followUp).toContain("Check network connectivity")
// Verify AI was called with proper prompt
expect(mockApiHandler.completePrompt).toHaveBeenCalledWith(
expect.stringContaining("execute_command operation has timed out"),
)
expect(mockApiHandler.completePrompt).toHaveBeenCalledWith(expect.stringContaining("npm install"))
})
test("should fallback to static suggestions when AI fails", async () => {
// Mock AI failure
;(mockApiHandler.completePrompt as any).mockRejectedValueOnce(new Error("API Error"))
const context = {
toolName: "execute_command" as const,
timeoutMs: 30000,
executionTimeMs: 35000,
toolParams: { command: "npm test" },
}
const result = await TimeoutFallbackGenerator.generateAiFallback(context, mockTask as Task)
expect(result.success).toBe(true)
expect(result.toolCall).toBeDefined()
expect(result.toolCall?.name).toBe("ask_followup_question")
// Should contain static fallback suggestions
const followUp = result.toolCall?.params.follow_up || ""
expect(followUp).toContain('Break "npm test" into smaller')
expect(followUp).toContain("background using")
expect(followUp).toContain("alternative approach")
expect(followUp).toContain("Increase the timeout")
})
test("should fallback to static suggestions when API handler is unavailable", async () => {
// Task without API handler
const taskWithoutApi = {}
const context = {
toolName: "read_file" as const,
timeoutMs: 5000,
executionTimeMs: 6000,
toolParams: { path: "/large/file.txt" },
}
const result = await TimeoutFallbackGenerator.generateAiFallback(context, taskWithoutApi as Task)
expect(result.success).toBe(true)
expect(result.toolCall).toBeDefined()
// Should contain static fallback suggestions for read_file
const followUp = result.toolCall?.params.follow_up || ""
expect(followUp).toContain('Read "/large/file.txt" in smaller chunks')
expect(followUp).toContain("accessible and not locked")
})
test("should parse AI response with different numbering formats", async () => {
// Test different numbering formats
const mockAiResponse = `Here are the suggestions:
1) Try breaking the command into parts
2. Use a different approach
3) Check system resources
4. Increase timeout duration`
;(mockApiHandler.completePrompt as any).mockResolvedValueOnce(mockAiResponse)
const context = {
toolName: "execute_command" as const,
timeoutMs: 10000,
executionTimeMs: 12000,
toolParams: { command: "build script" },
}
const result = await TimeoutFallbackGenerator.generateAiFallback(context, mockTask as Task)
expect(result.success).toBe(true)
const followUp = result.toolCall?.params.follow_up || ""
expect(followUp).toContain("Try breaking the command into parts")
expect(followUp).toContain("Use a different approach")
expect(followUp).toContain("Check system resources")
expect(followUp).toContain("Increase timeout duration")
})
test("should handle AI response without numbered list", async () => {
// AI response without clear numbering
const mockAiResponse = `You could try splitting the operation. Another option is to check the network. Maybe increase the timeout. Consider using a different tool.`
;(mockApiHandler.completePrompt as any).mockResolvedValueOnce(mockAiResponse)
const context = {
toolName: "browser_action" as const,
timeoutMs: 15000,
executionTimeMs: 16000,
toolParams: { action: "click" },
}
const result = await TimeoutFallbackGenerator.generateAiFallback(context, mockTask as Task)
expect(result.success).toBe(true)
const followUp = result.toolCall?.params.follow_up || ""
// Should extract sentences as suggestions
expect(followUp).toContain("You could try splitting the operation")
expect(followUp).toContain("Another option is to check the network")
})
test("should include task context in AI prompt when available", async () => {
const mockAiResponse = `1. Try a different approach\n2. Check the working directory\n3. Break into steps`
;(mockApiHandler.completePrompt as any).mockResolvedValueOnce(mockAiResponse)
const context = {
toolName: "search_files" as const,
timeoutMs: 20000,
executionTimeMs: 22000,
toolParams: { path: "/project", regex: ".*\\.ts$" },
taskContext: {
currentStep: "Finding TypeScript files",
workingDirectory: "/project/src",
previousActions: ["read package.json", "list files"],
},
}
const result = await TimeoutFallbackGenerator.generateAiFallback(context, mockTask as Task)
expect(result.success).toBe(true)
// Verify the prompt included task context
const calledPrompt = (mockApiHandler.completePrompt as any).mock.calls[0][0]
expect(calledPrompt).toContain("Current step: Finding TypeScript files")
expect(calledPrompt).toContain("Working directory: /project/src")
expect(calledPrompt).toContain("search_files")
expect(calledPrompt).toContain("ts$") // Just check for the pattern ending
})
test("should limit suggestions to maximum of 4", async () => {
// AI response with many suggestions
const mockAiResponse = `Here are many suggestions:
1. First suggestion
2. Second suggestion
3. Third suggestion
4. Fourth suggestion
5. Fifth suggestion
6. Sixth suggestion
7. Seventh suggestion`
;(mockApiHandler.completePrompt as any).mockResolvedValueOnce(mockAiResponse)
const context = {
toolName: "write_to_file" as const,
timeoutMs: 8000,
executionTimeMs: 9000,
toolParams: { path: "/output.txt" },
}
const result = await TimeoutFallbackGenerator.generateAiFallback(context, mockTask as Task)
expect(result.success).toBe(true)
const followUp = result.toolCall?.params.follow_up || ""
// Count the number of <suggest> tags
const suggestCount = (followUp.match(/<suggest>/g) || []).length
expect(suggestCount).toBeLessThanOrEqual(4)
// Should include first 4 suggestions
expect(followUp).toContain("First suggestion")
expect(followUp).toContain("Fourth suggestion")
// Should not include 5th and beyond
expect(followUp).not.toContain("Fifth suggestion")
})
})

View file

@ -0,0 +1,179 @@
// End-to-end test to verify AI fallback generation works
// npx vitest run src/core/timeout/__tests__/e2e-ai-test.spec.ts
import { describe, test, expect, beforeEach, vitest } from "vitest"
import { TimeoutFallbackGenerator } from "../TimeoutFallbackGenerator"
import type { ApiHandler, SingleCompletionHandler } from "../../../api"
import type { Task } from "../../task/Task"
// Mock API handler that simulates a real AI provider
interface TestApiHandler extends ApiHandler, SingleCompletionHandler {}
describe("TimeoutFallbackGenerator - End-to-End AI Test", () => {
test("should generate realistic AI suggestions for execute_command timeout", async () => {
// Create a realistic mock API handler
const mockApiHandler: TestApiHandler = {
createMessage: vitest.fn(),
getModel: vitest.fn().mockReturnValue({ id: "claude-3-sonnet", info: { maxTokens: 4096 } }),
countTokens: vitest.fn().mockResolvedValue(150),
completePrompt: vitest.fn().mockResolvedValue(
`
Here are some suggestions for the npm install timeout:
1. Clear npm cache with "npm cache clean --force" and retry
2. Break installation into smaller chunks by installing packages individually
3. Use "npm install --no-optional" to skip optional dependencies
4. Check network connectivity and try with a different registry
`.trim(),
),
}
const mockTask: Partial<Task> = {
api: mockApiHandler,
}
const context = {
toolName: "execute_command" as const,
timeoutMs: 60000,
executionTimeMs: 65000,
toolParams: {
command: "npm install",
cwd: "/project",
},
}
const result = await TimeoutFallbackGenerator.generateAiFallback(context, mockTask as Task)
// Verify the result structure
expect(result.success).toBe(true)
expect(result.toolCall).toBeDefined()
expect(result.toolCall?.name).toBe("ask_followup_question")
expect(result.toolCall?.params.question).toContain("execute_command")
expect(result.toolCall?.params.question).toContain("60 seconds")
// Verify AI-generated suggestions are included
const followUp = result.toolCall?.params.follow_up || ""
expect(followUp).toContain("Clear npm cache")
expect(followUp).toContain("Break installation into smaller chunks")
expect(followUp).toContain("no-optional")
expect(followUp).toContain("network connectivity")
// Verify the AI was called with a proper prompt
expect(mockApiHandler.completePrompt).toHaveBeenCalledWith(
expect.stringContaining("execute_command operation has timed out"),
)
expect(mockApiHandler.completePrompt).toHaveBeenCalledWith(expect.stringContaining("npm install"))
expect(mockApiHandler.completePrompt).toHaveBeenCalledWith(expect.stringContaining("60 seconds"))
})
test("should handle AI response with different formatting", async () => {
// Mock AI response with different numbering style
const mockApiHandler: TestApiHandler = {
createMessage: vitest.fn(),
getModel: vitest.fn().mockReturnValue({ id: "gpt-4", info: { maxTokens: 8192 } }),
countTokens: vitest.fn().mockResolvedValue(200),
completePrompt: vitest.fn().mockResolvedValue(
`
Based on the search_files timeout, here are my recommendations:
Limit search to specific subdirectories instead of entire project
Use more specific regex patterns to reduce matches
Try list_files first to understand directory structure
Consider breaking search into multiple smaller operations
`.trim(),
),
}
const mockTask: Partial<Task> = {
api: mockApiHandler,
}
const context = {
toolName: "search_files" as const,
timeoutMs: 30000,
executionTimeMs: 32000,
toolParams: {
path: "/large-project",
regex: ".*",
file_pattern: "*.ts",
},
}
const result = await TimeoutFallbackGenerator.generateAiFallback(context, mockTask as Task)
expect(result.success).toBe(true)
expect(result.toolCall).toBeDefined()
// Should extract suggestions even with bullet points
const followUp = result.toolCall?.params.follow_up || ""
expect(followUp).toContain("Narrow the search scope")
expect(followUp).toContain("simpler search patterns")
expect(followUp).toContain("file type filters")
expect(followUp).toContain("incrementally in smaller batches")
})
test("should gracefully handle AI failure and use static fallback", async () => {
// Mock API handler that fails
const mockApiHandler: TestApiHandler = {
createMessage: vitest.fn(),
getModel: vitest.fn().mockReturnValue({ id: "test-model", info: { maxTokens: 4096 } }),
countTokens: vitest.fn().mockResolvedValue(100),
completePrompt: vitest.fn().mockRejectedValue(new Error("API rate limit exceeded")),
}
const mockTask: Partial<Task> = {
api: mockApiHandler,
}
const context = {
toolName: "read_file" as const,
timeoutMs: 10000,
executionTimeMs: 12000,
toolParams: {
path: "/very/large/file.log",
},
}
const result = await TimeoutFallbackGenerator.generateAiFallback(context, mockTask as Task)
expect(result.success).toBe(true)
expect(result.toolCall).toBeDefined()
// Should contain static fallback suggestions for read_file
const followUp = result.toolCall?.params.follow_up || ""
expect(followUp).toContain('Read "/very/large/file.log" in smaller chunks')
expect(followUp).toContain("accessible and not locked")
expect(followUp).toContain("different approach")
expect(followUp).toContain("Increase the timeout")
// Verify AI was attempted but failed gracefully
expect(mockApiHandler.completePrompt).toHaveBeenCalled()
})
test("should work without task API handler", async () => {
// Task without API handler
const mockTask: Partial<Task> = {}
const context = {
toolName: "browser_action" as const,
timeoutMs: 15000,
executionTimeMs: 16500,
toolParams: {
action: "click",
coordinate: "450,300",
},
}
const result = await TimeoutFallbackGenerator.generateAiFallback(context, mockTask as Task)
expect(result.success).toBe(true)
expect(result.toolCall).toBeDefined()
// Should contain static fallback suggestions for browser_action
const followUp = result.toolCall?.params.follow_up || ""
expect(followUp).toContain('Simplify the "click"')
expect(followUp).toContain("Wait for specific elements")
expect(followUp).toContain("direct API calls")
expect(followUp).toContain("Reset the browser session")
})
})

View file

@ -0,0 +1,167 @@
// npx vitest run src/core/timeout/__tests__/timeout-integration.spec.ts
import { describe, test, expect, beforeEach, vitest } from "vitest"
import { TimeoutManager } from "../TimeoutManager"
import { ToolExecutionWrapper } from "../ToolExecutionWrapper"
import { TimeoutFallbackHandler } from "../TimeoutFallbackHandler"
import type { Task } from "../../task/Task"
describe("Timeout Integration Tests", () => {
beforeEach(() => {
vitest.clearAllMocks()
})
test("TimeoutManager should handle basic timeout operations", async () => {
const manager = TimeoutManager.getInstance()
// Test successful operation within timeout
const result = await manager.executeWithTimeout(
async () => {
await new Promise((resolve) => setTimeout(resolve, 10))
return "success"
},
{
toolName: "execute_command",
timeoutMs: 100,
enableFallback: true,
},
)
expect(result.success).toBe(true)
expect(result.result).toBe("success")
expect(result.timedOut).toBe(false)
})
test("TimeoutManager should handle timeout scenarios", async () => {
const manager = TimeoutManager.getInstance()
// Test operation that times out
const result = await manager.executeWithTimeout(
async () => {
await new Promise((resolve) => setTimeout(resolve, 200))
return "should not reach here"
},
{
toolName: "execute_command",
timeoutMs: 50,
enableFallback: true,
},
)
expect(result.success).toBe(false)
expect(result.timedOut).toBe(true)
expect(result.error?.message).toContain("Operation timed out")
})
test("ToolExecutionWrapper should wrap operations correctly", async () => {
const mockOperation = vitest.fn().mockImplementation(async (signal: AbortSignal) => {
// Simulate checking abort signal
if (signal.aborted) {
throw new Error("Operation was aborted")
}
await new Promise((resolve) => setTimeout(resolve, 10))
return [false, "test result"]
})
const result = await ToolExecutionWrapper.execute(
mockOperation,
{
toolName: "execute_command",
taskId: "test-task",
timeoutMs: 100,
enableFallback: true,
},
100,
)
expect(result.success).toBe(true)
expect(result.result).toEqual([false, "test result"])
expect(mockOperation).toHaveBeenCalledWith(expect.any(AbortSignal))
})
test("ToolExecutionWrapper should handle timeout with fallback", async () => {
const mockOperation = vitest.fn().mockImplementation(async () => {
await new Promise((resolve) => setTimeout(resolve, 200))
return [false, "should not reach here"]
})
const result = await ToolExecutionWrapper.execute(
mockOperation,
{
toolName: "execute_command",
taskId: "test-task",
timeoutMs: 50,
enableFallback: true,
},
50,
)
expect(result.success).toBe(false)
expect(result.timedOut).toBe(true)
expect(result.fallbackTriggered).toBe(true)
})
test("TimeoutFallbackHandler should create AI-powered responses", async () => {
// Create a mock task to test tool injection
const mockTask = {
assistantMessageContent: [],
cwd: "/test/dir",
} as unknown as Task
const response = await TimeoutFallbackHandler.createTimeoutResponse(
"execute_command",
5000,
6000,
{ command: "npm install" },
mockTask,
)
// The response should contain the basic timeout information
expect(response).toContain("execute_command")
expect(response).toContain("5 seconds")
expect(response).toContain("6s")
expect(response.length).toBeGreaterThan(50)
// The AI-generated tool call should be injected into the task's assistant message content
const toolUseBlock = mockTask.assistantMessageContent.find((block) => block.type === "tool_use")
expect(toolUseBlock).toBeDefined()
if (toolUseBlock?.type === "tool_use") {
expect(toolUseBlock.name).toBe("ask_followup_question")
expect(toolUseBlock.params?.question).toContain("timed out")
}
})
test("AbortSignal should be properly handled", async () => {
const mockOperation = vitest.fn().mockImplementation(async (signal: AbortSignal) => {
// Simulate a long-running operation that checks abort signal
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
if (signal.aborted) {
reject(new Error("Operation was aborted"))
} else {
resolve("success")
}
}, 100)
signal.addEventListener("abort", () => {
clearTimeout(timeout)
reject(new Error("Operation was aborted"))
})
})
})
const result = await ToolExecutionWrapper.execute(
mockOperation,
{
toolName: "execute_command",
taskId: "test-task",
timeoutMs: 50, // Shorter timeout to trigger abort
enableFallback: false,
},
50,
)
expect(result.success).toBe(false)
expect(result.timedOut).toBe(true)
})
})

View file

@ -0,0 +1,62 @@
import { describe, test, expect, vi, beforeEach } from "vitest"
import { TimeoutFallbackHandler } from "../TimeoutFallbackHandler"
import { Task } from "../../task/Task"
describe("Tool Call Injection Test", () => {
let mockTask: Task
beforeEach(() => {
// Create a minimal mock task with assistantMessageContent array
mockTask = {
assistantMessageContent: [],
cwd: "/test/dir",
} as unknown as Task
})
test("should inject ask_followup_question tool call into assistant message content", async () => {
// Mock the TimeoutFallbackGenerator to return a successful AI result
const mockAiResult = {
success: true,
toolCall: {
name: "ask_followup_question",
params: {
question: "What would you like to do next?",
follow_up: "<suggest>Try a different approach</suggest><suggest>Break into smaller steps</suggest>",
},
},
}
// Mock the generateAiFallback method
vi.doMock("../TimeoutFallbackGenerator", () => ({
TimeoutFallbackGenerator: {
generateAiFallback: vi.fn().mockResolvedValue(mockAiResult),
},
}))
// Call createTimeoutResponse
const response = await TimeoutFallbackHandler.createTimeoutResponse(
"execute_command",
5000,
6000,
{ command: "npm install" },
mockTask,
)
// Check that the response is just the base timeout message
expect(response).toContain("timed out after 5 seconds")
expect(response).toContain("Execution Time: 6s")
// Check that the tool call was injected into assistantMessageContent
// The XML parser might create multiple blocks (text + tool_use), so find the tool_use block
const toolUseBlock = mockTask.assistantMessageContent.find((block) => block.type === "tool_use")
expect(toolUseBlock).toBeDefined()
expect(toolUseBlock?.type).toBe("tool_use")
expect(toolUseBlock?.name).toBe("ask_followup_question")
expect(toolUseBlock?.params?.question).toBeDefined()
expect(toolUseBlock?.params?.follow_up).toBeDefined()
// Verify the question contains timeout information
expect(toolUseBlock?.params?.question).toContain("timed out")
expect(toolUseBlock?.params?.question).toContain("5 seconds")
})
})

View file

@ -0,0 +1,71 @@
import { describe, it, expect, vi } from "vitest"
import { TimeoutFallbackGenerator } from "../TimeoutFallbackGenerator"
import { TimeoutFallbackHandler } from "../TimeoutFallbackHandler"
describe("UI Integration - AI Timeout Fallbacks", () => {
it("should generate AI fallbacks using static method", async () => {
const context = {
toolName: "execute_command" as const,
timeoutMs: 30000,
executionTimeMs: 25000,
toolParams: { command: "npm install" },
}
const result = await TimeoutFallbackGenerator.generateAiFallback(context)
expect(result.success).toBe(true)
expect(result.toolCall).toBeDefined()
expect(result.toolCall?.name).toBe("ask_followup_question")
expect(result.toolCall?.params.question).toContain("execute_command")
})
it("should create timeout response with AI fallbacks", async () => {
const response = await TimeoutFallbackHandler.createTimeoutResponse("execute_command", 30000, 25000, {
command: "npm install",
})
expect(response).toContain("execute_command")
expect(response).toContain("timed out")
expect(response.length).toBeGreaterThan(100) // Should contain substantial content
})
it("should create timeout response when AI fallbacks fail", async () => {
const response = await TimeoutFallbackHandler.createTimeoutResponse("execute_command", 30000, 25000, {
command: "npm install",
})
expect(response).toContain("execute_command")
expect(response).toContain("timed out")
expect(response.length).toBeGreaterThan(50) // Should contain basic timeout message
})
it("should validate UI setting flow", () => {
// This test validates that timeout settings can be toggled
const settings = {
timeoutFallbackEnabled: true,
toolExecutionTimeoutMs: 30000,
}
// Simulate UI toggle
settings.timeoutFallbackEnabled = false
expect(settings.timeoutFallbackEnabled).toBe(false)
// Simulate timeout duration change
settings.toolExecutionTimeoutMs = 60000
expect(settings.toolExecutionTimeoutMs).toBe(60000)
})
it("should handle different tool types with AI fallbacks", async () => {
const commandResponse = await TimeoutFallbackHandler.createTimeoutResponse("execute_command", 30000, 25000, {
command: "npm test",
})
const browserResponse = await TimeoutFallbackHandler.createTimeoutResponse("browser_action", 30000, 25000, {
action: "click",
})
expect(commandResponse).toContain("execute_command")
expect(browserResponse).toContain("browser_action")
expect(commandResponse).not.toEqual(browserResponse)
})
})

View file

@ -0,0 +1,8 @@
export { TimeoutManager, timeoutManager } from "./TimeoutManager"
export { ToolExecutionWrapper } from "./ToolExecutionWrapper"
export { TimeoutFallbackHandler } from "./TimeoutFallbackHandler"
export { TimeoutFallbackGenerator } from "./TimeoutFallbackGenerator"
export type { TimeoutConfig, TimeoutResult, TimeoutEvent } from "./TimeoutManager"
export type { ToolExecutionOptions } from "./ToolExecutionWrapper"
export type { TimeoutFallbackContext, TimeoutFallbackResult } from "./TimeoutFallbackGenerator"

View file

@ -14,6 +14,7 @@ import { unescapeHtmlEntities } from "../../utils/text-normalization"
import { ExitCodeDetails, RooTerminalCallbacks, RooTerminalProcess } from "../../integrations/terminal/types"
import { TerminalRegistry } from "../../integrations/terminal/TerminalRegistry"
import { Terminal } from "../../integrations/terminal/Terminal"
import { ToolExecutionWrapper, TimeoutFallbackHandler } from "../timeout"
class ShellIntegrationError extends Error {}
@ -60,7 +61,11 @@ export async function executeCommandTool(
const executionId = cline.lastMessageTs?.toString() ?? Date.now().toString()
const clineProvider = await cline.providerRef.deref()
const clineProviderState = await clineProvider?.getState()
const { terminalOutputLineLimit = 500, terminalShellIntegrationDisabled = false } = clineProviderState ?? {}
const {
terminalOutputLineLimit = 500,
terminalShellIntegrationDisabled = false,
toolExecutionTimeoutMs = 300000, // 5 minutes default
} = clineProviderState ?? {}
const options: ExecuteCommandOptions = {
executionId,
@ -68,6 +73,7 @@ export async function executeCommandTool(
customCwd,
terminalShellIntegrationDisabled,
terminalOutputLineLimit,
timeoutMs: toolExecutionTimeoutMs,
}
try {
@ -113,6 +119,7 @@ export type ExecuteCommandOptions = {
customCwd?: string
terminalShellIntegrationDisabled?: boolean
terminalOutputLineLimit?: number
timeoutMs?: number
}
export async function executeCommand(
@ -123,7 +130,66 @@ export async function executeCommand(
customCwd,
terminalShellIntegrationDisabled = false,
terminalOutputLineLimit = 500,
timeoutMs,
}: ExecuteCommandOptions,
): Promise<[boolean, ToolResponse]> {
// Get timeout from settings if not provided
const clineProvider = await cline.providerRef.deref()
const clineProviderState = await clineProvider?.getState()
const defaultTimeoutMs = clineProviderState?.toolExecutionTimeoutMs ?? 300000 // 5 minutes default
const actualTimeoutMs = timeoutMs ?? defaultTimeoutMs
const timeoutFallbackEnabled = clineProviderState?.timeoutFallbackEnabled ?? true
// Wrap the command execution with timeout
const timeoutResult = await ToolExecutionWrapper.execute(
async (signal: AbortSignal) => {
return executeCommandInternal(cline, {
executionId,
command,
customCwd,
terminalShellIntegrationDisabled,
terminalOutputLineLimit,
signal,
})
},
{
toolName: "execute_command",
taskId: cline.taskId,
timeoutMs: actualTimeoutMs,
enableFallback: timeoutFallbackEnabled,
},
actualTimeoutMs,
)
// Handle timeout result
if (timeoutResult.timedOut && timeoutResult.fallbackTriggered) {
const fallbackResponse = await TimeoutFallbackHandler.createTimeoutResponse(
"execute_command",
actualTimeoutMs,
timeoutResult.executionTimeMs,
{ command },
cline,
)
return [false, fallbackResponse]
}
if (!timeoutResult.success) {
return [false, formatResponse.toolError(timeoutResult.error?.message)]
}
return timeoutResult.result!
}
async function executeCommandInternal(
cline: Task,
{
executionId,
command,
customCwd,
terminalShellIntegrationDisabled = false,
terminalOutputLineLimit = 500,
signal,
}: ExecuteCommandOptions & { signal: AbortSignal },
): Promise<[boolean, ToolResponse]> {
let workingDir: string
@ -211,8 +277,27 @@ export async function executeCommand(
const process = terminal.runCommand(command, callbacks)
cline.terminalProcess = process
await process
cline.terminalProcess = undefined
// Handle abort signal for timeout cancellation
const abortHandler = () => {
if (process && typeof process.abort === "function") {
process.abort()
}
cline.terminalProcess = undefined
}
if (signal.aborted) {
abortHandler()
throw new Error("Command execution was cancelled due to timeout")
}
signal.addEventListener("abort", abortHandler)
try {
await process
} finally {
signal.removeEventListener("abort", abortHandler)
cline.terminalProcess = undefined
}
if (shellIntegrationError) {
throw new ShellIntegrationError(shellIntegrationError)

View file

@ -1409,6 +1409,8 @@ export class ClineProvider
profileThresholds,
alwaysAllowFollowupQuestions,
followupAutoApproveTimeoutMs,
toolExecutionTimeoutMs,
timeoutFallbackEnabled,
} = await this.getState()
const telemetryKey = process.env.POSTHOG_API_KEY
@ -1522,6 +1524,8 @@ export class ClineProvider
hasOpenedModeSelector: this.getGlobalState("hasOpenedModeSelector") ?? false,
alwaysAllowFollowupQuestions: alwaysAllowFollowupQuestions ?? false,
followupAutoApproveTimeoutMs: followupAutoApproveTimeoutMs ?? 60000,
toolExecutionTimeoutMs: toolExecutionTimeoutMs ?? 300000,
timeoutFallbackEnabled: timeoutFallbackEnabled ?? false,
}
}
@ -1634,6 +1638,8 @@ export class ClineProvider
terminalZshP10k: stateValues.terminalZshP10k ?? false,
terminalZdotdir: stateValues.terminalZdotdir ?? false,
terminalCompressProgressBar: stateValues.terminalCompressProgressBar ?? true,
toolExecutionTimeoutMs: stateValues.toolExecutionTimeoutMs ?? 300000, // 5 minutes default
timeoutFallbackEnabled: stateValues.timeoutFallbackEnabled ?? false,
mode: stateValues.mode ?? defaultModeSlug,
language: stateValues.language ?? formatLanguage(vscode.env.language),
mcpEnabled: stateValues.mcpEnabled ?? true,

View file

@ -877,6 +877,14 @@ export const webviewMessageHandler = async (
await updateGlobalState("terminalOutputLineLimit", message.value)
await provider.postStateToWebview()
break
case "toolExecutionTimeoutMs":
await updateGlobalState("toolExecutionTimeoutMs", message.value)
await provider.postStateToWebview()
break
case "timeoutFallbackEnabled":
await updateGlobalState("timeoutFallbackEnabled", message.bool)
await provider.postStateToWebview()
break
case "terminalShellIntegrationTimeout":
await updateGlobalState("terminalShellIntegrationTimeout", message.value)
await provider.postStateToWebview()

View file

@ -227,6 +227,8 @@ export type ExtensionState = Pick<
| "codebaseIndexConfig"
| "codebaseIndexModels"
| "profileThresholds"
| "toolExecutionTimeoutMs"
| "timeoutFallbackEnabled"
> & {
version: string
clineMessages: ClineMessage[]

View file

@ -120,6 +120,8 @@ export interface WebviewMessage {
| "terminalZshP10k"
| "terminalZdotdir"
| "terminalCompressProgressBar"
| "toolExecutionTimeoutMs"
| "timeoutFallbackEnabled"
| "mcpEnabled"
| "enableMcpServerCreation"
| "searchCommits"

View file

@ -28,6 +28,8 @@ type AutoApproveSettingsProps = HTMLAttributes<HTMLDivElement> & {
alwaysAllowFollowupQuestions?: boolean
followupAutoApproveTimeoutMs?: number
allowedCommands?: string[]
timeoutFallbackEnabled?: boolean
toolExecutionTimeoutMs?: number
setCachedStateField: SetCachedStateField<
| "alwaysAllowReadOnly"
| "alwaysAllowReadOnlyOutsideWorkspace"
@ -46,6 +48,8 @@ type AutoApproveSettingsProps = HTMLAttributes<HTMLDivElement> & {
| "followupAutoApproveTimeoutMs"
| "allowedCommands"
| "alwaysAllowUpdateTodoList"
| "timeoutFallbackEnabled"
| "toolExecutionTimeoutMs"
>
}
@ -66,6 +70,8 @@ export const AutoApproveSettings = ({
alwaysAllowFollowupQuestions,
followupAutoApproveTimeoutMs = 60000,
allowedCommands,
timeoutFallbackEnabled,
toolExecutionTimeoutMs,
setCachedStateField,
...props
}: AutoApproveSettingsProps) => {
@ -292,6 +298,50 @@ export const AutoApproveSettings = ({
</div>
</div>
)}
{/* TIMEOUT SETTINGS */}
<div className="flex flex-col gap-3 pl-3 border-l-2 border-vscode-button-background">
<div className="flex items-center gap-4 font-bold">
<span className="codicon codicon-clock" />
<div>{t("settings:autoApprove.timeout.label")}</div>
</div>
{/* Enable timeout handling */}
<div>
<VSCodeCheckbox
checked={timeoutFallbackEnabled}
onChange={(e: any) => setCachedStateField("timeoutFallbackEnabled", e.target.checked)}
data-testid="timeout-fallback-enabled-checkbox">
<span className="font-medium">
{t("settings:autoApprove.timeout.timeoutFallbackEnabled.label")}
</span>
</VSCodeCheckbox>
<div className="text-vscode-descriptionForeground text-sm mt-1">
{t("settings:autoApprove.timeout.timeoutFallbackEnabled.description")}
</div>
</div>
{/* Tool execution timeout duration */}
<div>
<div className="font-medium mb-2">
{t("settings:autoApprove.timeout.toolExecutionTimeoutMs.label")}
</div>
<Input
type="number"
min="1000"
max="1800000"
step="1000"
value={toolExecutionTimeoutMs || 300000}
onChange={(e) => setCachedStateField("toolExecutionTimeoutMs", parseInt(e.target.value))}
disabled={!timeoutFallbackEnabled}
className="w-32"
data-testid="tool-execution-timeout-input"
/>
<div className="text-vscode-descriptionForeground text-sm mt-1">
{t("settings:autoApprove.timeout.toolExecutionTimeoutMs.description")}
</div>
</div>
</div>
</Section>
</div>
)

View file

@ -176,6 +176,8 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
profileThresholds,
alwaysAllowFollowupQuestions,
followupAutoApproveTimeoutMs,
timeoutFallbackEnabled,
toolExecutionTimeoutMs,
} = cachedState
const apiConfiguration = useMemo(() => cachedState.apiConfiguration ?? {}, [cachedState.apiConfiguration])
@ -315,6 +317,8 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
vscode.postMessage({ type: "alwaysAllowSubtasks", bool: alwaysAllowSubtasks })
vscode.postMessage({ type: "alwaysAllowFollowupQuestions", bool: alwaysAllowFollowupQuestions })
vscode.postMessage({ type: "followupAutoApproveTimeoutMs", value: followupAutoApproveTimeoutMs })
vscode.postMessage({ type: "timeoutFallbackEnabled", bool: timeoutFallbackEnabled })
vscode.postMessage({ type: "toolExecutionTimeoutMs", value: toolExecutionTimeoutMs })
vscode.postMessage({ type: "condensingApiConfigId", text: condensingApiConfigId || "" })
vscode.postMessage({ type: "updateCondensingPrompt", text: customCondensingPrompt || "" })
vscode.postMessage({ type: "updateSupportPrompt", values: customSupportPrompts || {} })
@ -608,6 +612,8 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
alwaysAllowFollowupQuestions={alwaysAllowFollowupQuestions}
followupAutoApproveTimeoutMs={followupAutoApproveTimeoutMs}
allowedCommands={allowedCommands}
timeoutFallbackEnabled={timeoutFallbackEnabled}
toolExecutionTimeoutMs={toolExecutionTimeoutMs}
setCachedStateField={setCachedStateField}
/>
)}

View file

@ -44,6 +44,10 @@ export interface ExtensionStateContextType extends ExtensionState {
setAlwaysAllowFollowupQuestions: (value: boolean) => void // Setter for the new property
followupAutoApproveTimeoutMs: number | undefined // Timeout in ms for auto-approving follow-up questions
setFollowupAutoApproveTimeoutMs: (value: number) => void // Setter for the timeout
timeoutFallbackEnabled?: boolean // New property for timeout fallback enabled
setTimeoutFallbackEnabled: (value: boolean) => void // Setter for timeout fallback enabled
toolExecutionTimeoutMs?: number // New property for tool execution timeout
setToolExecutionTimeoutMs: (value: number) => void // Setter for tool execution timeout
condensingApiConfigId?: string
setCondensingApiConfigId: (value: string) => void
customCondensingPrompt?: string
@ -224,6 +228,9 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
},
codebaseIndexModels: { ollama: {}, openai: {} },
alwaysAllowUpdateTodoList: true,
// Timeout settings
timeoutFallbackEnabled: false, // Default to disabled
toolExecutionTimeoutMs: 300000, // 5 minutes default
})
const [didHydrateState, setDidHydrateState] = useState(false)
@ -237,6 +244,8 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
const [marketplaceItems, setMarketplaceItems] = useState<any[]>([])
const [alwaysAllowFollowupQuestions, setAlwaysAllowFollowupQuestions] = useState(false) // Add state for follow-up questions auto-approve
const [followupAutoApproveTimeoutMs, setFollowupAutoApproveTimeoutMs] = useState<number | undefined>(undefined) // Will be set from global settings
const [timeoutFallbackEnabled, setTimeoutFallbackEnabledState] = useState(false) // Add state for timeout fallback enabled
const [toolExecutionTimeoutMs, setToolExecutionTimeoutMsState] = useState<number>(300000) // Add state for tool execution timeout (5 minutes default)
const [marketplaceInstalledMetadata, setMarketplaceInstalledMetadata] = useState<MarketplaceInstalledMetadata>({
project: {},
global: {},
@ -274,6 +283,13 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
if ((newState as any).followupAutoApproveTimeoutMs !== undefined) {
setFollowupAutoApproveTimeoutMs((newState as any).followupAutoApproveTimeoutMs)
}
// Update timeout settings if present in state message
if ((newState as any).timeoutFallbackEnabled !== undefined) {
setTimeoutFallbackEnabledState((newState as any).timeoutFallbackEnabled)
}
if ((newState as any).toolExecutionTimeoutMs !== undefined) {
setToolExecutionTimeoutMsState((newState as any).toolExecutionTimeoutMs)
}
// Handle marketplace data if present in state message
if (newState.marketplaceItems !== undefined) {
setMarketplaceItems(newState.marketplaceItems)
@ -373,6 +389,8 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
profileThresholds: state.profileThresholds ?? {},
alwaysAllowFollowupQuestions,
followupAutoApproveTimeoutMs,
timeoutFallbackEnabled,
toolExecutionTimeoutMs,
setExperimentEnabled: (id, enabled) =>
setState((prevState) => ({ ...prevState, experiments: { ...prevState.experiments, [id]: enabled } })),
setApiConfiguration,
@ -466,6 +484,14 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
setAlwaysAllowUpdateTodoList: (value) => {
setState((prevState) => ({ ...prevState, alwaysAllowUpdateTodoList: value }))
},
setTimeoutFallbackEnabled: (value) => {
setState((prevState) => ({ ...prevState, timeoutFallbackEnabled: value }))
setTimeoutFallbackEnabledState(value)
},
setToolExecutionTimeoutMs: (value) => {
setState((prevState) => ({ ...prevState, toolExecutionTimeoutMs: value }))
setToolExecutionTimeoutMsState(value)
},
}
return <ExtensionStateContext.Provider value={contextValue}>{children}</ExtensionStateContext.Provider>

View file

@ -170,6 +170,18 @@
"title": "Max Requests",
"description": "Automatically make this many API requests before asking for approval to continue with the task.",
"unlimited": "Unlimited"
},
"timeout": {
"label": "Timeout",
"description": "Configure how Roo handles tool operations that exceed their timeout limits",
"timeoutFallbackEnabled": {
"label": "Enable timeout handling",
"description": "Enable automatic timeout detection and fallback suggestions for long-running operations"
},
"toolExecutionTimeoutMs": {
"label": "Tool execution timeout (ms)",
"description": "Maximum time to wait for tool operations before triggering timeout handling (1000-1800000ms)"
}
}
},
"providers": {