Skip to content
17 changes: 17 additions & 0 deletions packages/telemetry/src/TelemetryService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,23 @@ export class TelemetryService {
this.captureEvent(TelemetryEventName.CONSECUTIVE_MISTAKE_ERROR, { taskId })
}

/**
* Captures when a tool execution times out
* @param taskId The task ID where the timeout occurred
* @param toolName The name of the tool that timed out
* @param timeoutMs The timeout duration in milliseconds
* @param executionTimeMs The actual execution time before timeout
*/
public captureToolTimeout(taskId: string, toolName: string, timeoutMs: number, executionTimeMs: number): void {
this.captureEvent(TelemetryEventName.TOOL_TIMEOUT, {
taskId,
toolName,
timeoutMs,
executionTimeMs,
timeoutRatio: executionTimeMs / timeoutMs,
})
}

/**
* Captures when a tab is shown due to user action
* @param tab The tab that was shown
Expand Down
8 changes: 8 additions & 0 deletions packages/types/src/global-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,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(),
Expand Down Expand Up @@ -227,6 +231,10 @@ export const EVALS_SETTINGS: RooCodeSettings = {

enableCheckpoints: false,

// Timeout settings
toolExecutionTimeoutMs: 60000, // 1 minute default
timeoutFallbackEnabled: false,

rateLimitSeconds: 0,
maxOpenTabsContext: 20,
maxWorkspaceFiles: 200,
Expand Down
2 changes: 2 additions & 0 deletions packages/types/src/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ export type ClineAsk = z.infer<typeof clineAskSchema>
* - `condense_context`: Context condensation/summarization has started
* - `condense_context_error`: Error occurred during context condensation
* - `codebase_search_result`: Results from searching the codebase
* - `tool_timeout`: Indicates a tool operation has timed out
*/
export const clineSays = [
"error",
Expand Down Expand Up @@ -107,6 +108,7 @@ export const clineSays = [
"condense_context_error",
"codebase_search_result",
"user_edit_todos",
"tool_timeout",
] as const

export const clineSaySchema = z.enum(clineSays)
Expand Down
2 changes: 2 additions & 0 deletions packages/types/src/telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ export enum TelemetryEventName {
DIFF_APPLICATION_ERROR = "Diff Application Error",
SHELL_INTEGRATION_ERROR = "Shell Integration Error",
CONSECUTIVE_MISTAKE_ERROR = "Consecutive Mistake Error",
TOOL_TIMEOUT = "Tool Timeout",
CODE_INDEX_ERROR = "Code Index Error",
}

Expand Down Expand Up @@ -167,6 +168,7 @@ export const rooCodeTelemetryEventSchema = z.discriminatedUnion("type", [
TelemetryEventName.TAB_SHOWN,
TelemetryEventName.MODE_SETTINGS_CHANGED,
TelemetryEventName.CUSTOM_MODE_CREATED,
TelemetryEventName.TOOL_TIMEOUT,
]),
properties: telemetryPropertiesSchema,
}),
Expand Down
180 changes: 180 additions & 0 deletions src/core/prompts/__tests__/timeout-fallback-responses.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import { describe, it, expect } from "vitest"
import { formatResponse } from "../responses"

describe("timeout fallback responses", () => {
describe("toolTimeout", () => {
it("should include background operation warning for execute_command", () => {
const message = formatResponse.toolTimeout("execute_command", 30000, 35000)

expect(message).toContain("timed out after 30 seconds")
expect(message).toContain(
"**Important**: The execute_command operation may still be running in the background",
)
expect(message).toContain("You might see output or effects from this operation later")
})

it("should include background operation warning for browser_action", () => {
const message = formatResponse.toolTimeout("browser_action", 15000, 20000)

expect(message).toContain("timed out after 15 seconds")
expect(message).toContain(
"**Important**: The browser_action operation may still be running in the background",
)
expect(message).toContain("You might see output or effects from this operation later")
})

it("should not include background operation warning for other tools", () => {
const message = formatResponse.toolTimeout("read_file", 10000, 12000)

expect(message).toContain("timed out after 10 seconds")
expect(message).not.toContain("may still be running in the background")
expect(message).not.toContain("**Important**")
})

it("should format timeout details correctly", () => {
const message = formatResponse.toolTimeout("write_to_file", 5000, 6000)

expect(message).toContain("<timeout_details>")
expect(message).toContain("Tool: write_to_file")
expect(message).toContain("Configured Timeout: 5s")
expect(message).toContain("Execution Time: 6s")
expect(message).toContain("Status: Canceled")
expect(message).toContain("</timeout_details>")
})
})

describe("generateContextualSuggestions", () => {
it("should generate execute_command suggestions", () => {
const suggestions = formatResponse.timeoutFallbackSuggestions.generateContextualSuggestions(
"execute_command",
{ command: "npm install" },
)

expect(suggestions).toHaveLength(4)
expect(suggestions[0].text).toContain("npm install")
expect(suggestions[0].text).toContain("still running in the background")
expect(suggestions[1].text).toContain("smaller, sequential steps")
})

it("should generate read_file suggestions", () => {
const suggestions = formatResponse.timeoutFallbackSuggestions.generateContextualSuggestions("read_file", {
path: "/large/file.txt",
})

expect(suggestions).toHaveLength(4)
expect(suggestions[0].text).toContain("/large/file.txt")
expect(suggestions[0].text).toContain("smaller chunks")
})

it("should generate write_to_file suggestions", () => {
const suggestions = formatResponse.timeoutFallbackSuggestions.generateContextualSuggestions(
"write_to_file",
{ path: "/output/file.js" },
)

expect(suggestions).toHaveLength(4)
expect(suggestions[0].text).toContain("/output/file.js")
expect(suggestions[0].text).toContain("insert_content")
})

it("should generate browser_action suggestions", () => {
const suggestions = formatResponse.timeoutFallbackSuggestions.generateContextualSuggestions(
"browser_action",
{ action: "click" },
)

expect(suggestions).toHaveLength(4)
expect(suggestions[0].text).toContain("browser action")
expect(suggestions[0].text).toContain("still processing in the background")
expect(suggestions[1].text).toContain("smaller, more targeted steps")
})

it("should generate search_files suggestions", () => {
const suggestions = formatResponse.timeoutFallbackSuggestions.generateContextualSuggestions(
"search_files",
{ regex: "complex.*pattern" },
)

expect(suggestions).toHaveLength(4)
expect(suggestions[0].text).toContain("Narrow the search scope")
})

it("should generate generic suggestions for unknown tools", () => {
const suggestions = formatResponse.timeoutFallbackSuggestions.generateContextualSuggestions(
"unknown_tool" as any,
)

expect(suggestions).toHaveLength(4)
expect(suggestions[0].text).toContain("unknown_tool operation")
expect(suggestions[0].text).toContain("smaller steps")
})
})

describe("individual suggestion generators", () => {
it("should generate command suggestions with default command name", () => {
const suggestions = formatResponse.timeoutFallbackSuggestions.generateCommandSuggestions()

expect(suggestions).toHaveLength(4)
expect(suggestions[0].text).toContain("the command")
expect(suggestions[0].text).toContain("still running in the background")
})

it("should generate read file suggestions with default file name", () => {
const suggestions = formatResponse.timeoutFallbackSuggestions.generateReadFileSuggestions()

expect(suggestions).toHaveLength(4)
expect(suggestions[0].text).toContain("the file")
})

it("should generate write file suggestions with default file name", () => {
const suggestions = formatResponse.timeoutFallbackSuggestions.generateWriteFileSuggestions()

expect(suggestions).toHaveLength(4)
expect(suggestions[0].text).toContain("the file")
})

it("should generate browser suggestions with default action name", () => {
const suggestions = formatResponse.timeoutFallbackSuggestions.generateBrowserSuggestions()

expect(suggestions).toHaveLength(4)
expect(suggestions[0].text).toContain("browser action")
expect(suggestions[0].text).toContain("still processing in the background")
})

it("should generate search suggestions", () => {
const suggestions = formatResponse.timeoutFallbackSuggestions.generateSearchSuggestions()

expect(suggestions).toHaveLength(4)
expect(suggestions[0].text).toContain("Narrow the search scope")
})

it("should generate generic suggestions", () => {
const suggestions = formatResponse.timeoutFallbackSuggestions.generateGenericSuggestions("new_task")

expect(suggestions).toHaveLength(4)
expect(suggestions[0].text).toContain("new_task operation")
})
})

describe("suggestion structure", () => {
it("should return suggestions with text property", () => {
const suggestions = formatResponse.timeoutFallbackSuggestions.generateGenericSuggestions("new_task")

suggestions.forEach((suggestion) => {
expect(suggestion).toHaveProperty("text")
expect(typeof suggestion.text).toBe("string")
expect(suggestion.text.length).toBeGreaterThan(0)
})
})

it("should optionally include mode property", () => {
const suggestions = formatResponse.timeoutFallbackSuggestions.generateGenericSuggestions("new_task")

suggestions.forEach((suggestion) => {
if (suggestion.mode) {
expect(typeof suggestion.mode).toBe("string")
}
})
})
})
})
134 changes: 134 additions & 0 deletions src/core/prompts/instructions/__tests__/timeout-fallback.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { describe, it, expect } from "vitest"
import {
createTimeoutFallbackPrompt,
parseTimeoutFallbackResponse,
type TimeoutFallbackContext,
} from "../timeout-fallback"

describe("timeout-fallback", () => {
describe("createTimeoutFallbackPrompt", () => {
it("should create a basic prompt with required context", () => {
const context: TimeoutFallbackContext = {
toolName: "execute_command",
timeoutMs: 30000,
executionTimeMs: 32000,
}

const prompt = createTimeoutFallbackPrompt(context)

expect(prompt).toContain("execute_command operation has timed out after 30 seconds")
expect(prompt).toContain("actual execution time: 32 seconds")
expect(prompt).toContain("Tool: execute_command")
expect(prompt).toContain("Generate exactly 3-4 specific, actionable suggestions")
})

it("should include tool parameters when provided", () => {
const context: TimeoutFallbackContext = {
toolName: "read_file",
timeoutMs: 15000,
executionTimeMs: 16000,
toolParams: {
path: "/large/file.txt",
line_range: "1-10000",
},
}

const prompt = createTimeoutFallbackPrompt(context)

expect(prompt).toContain("Parameters:")
expect(prompt).toContain("/large/file.txt")
expect(prompt).toContain("1-10000")
})

it("should include task context when provided", () => {
const context: TimeoutFallbackContext = {
toolName: "write_to_file",
timeoutMs: 20000,
executionTimeMs: 22000,
taskContext: {
currentStep: "Creating configuration file",
workingDirectory: "/project/config",
},
}

const prompt = createTimeoutFallbackPrompt(context)

expect(prompt).toContain("Current step: Creating configuration file")
expect(prompt).toContain("Working directory: /project/config")
})
})

describe("parseTimeoutFallbackResponse", () => {
it("should parse numbered list responses", () => {
const response = `Here are the suggestions:
1. Break the command into smaller parts
2. Use background execution with nohup
3. Try an alternative approach
4. Increase the timeout setting`

const suggestions = parseTimeoutFallbackResponse(response)

expect(suggestions).toHaveLength(4)
expect(suggestions[0].text).toBe("Break the command into smaller parts")
expect(suggestions[1].text).toBe("Use background execution with nohup")
expect(suggestions[2].text).toBe("Try an alternative approach")
expect(suggestions[3].text).toBe("Increase the timeout setting")
})

it("should parse numbered list with parentheses", () => {
const response = `Suggestions:
1) Check file permissions
2) Use smaller chunks
3) Try a different tool`

const suggestions = parseTimeoutFallbackResponse(response)

expect(suggestions).toHaveLength(3)
expect(suggestions[0].text).toBe("Check file permissions")
expect(suggestions[1].text).toBe("Use smaller chunks")
expect(suggestions[2].text).toBe("Try a different tool")
})

it("should fallback to sentence parsing when no numbered list found", () => {
const response = `You should try breaking the operation into smaller parts. Consider using an alternative approach. Check system resources and try again.`

const suggestions = parseTimeoutFallbackResponse(response)

expect(suggestions.length).toBeGreaterThan(0)
expect(suggestions[0].text).toBe("You should try breaking the operation into smaller parts")
})

it("should limit suggestions to 4 items", () => {
const response = `1. First suggestion
2. Second suggestion
3. Third suggestion
4. Fourth suggestion
5. Fifth suggestion
6. Sixth suggestion`

const suggestions = parseTimeoutFallbackResponse(response)

expect(suggestions).toHaveLength(4)
})

it("should filter out suggestions that are too long", () => {
const response = `1. Good suggestion
2. This is a very long suggestion that exceeds the maximum character limit and should be filtered out because it's too verbose
3. Another good suggestion`

const suggestions = parseTimeoutFallbackResponse(response)

expect(suggestions).toHaveLength(2)
expect(suggestions[0].text).toBe("Good suggestion")
expect(suggestions[1].text).toBe("Another good suggestion")
})

it("should return empty array for invalid responses", () => {
const response = ""

const suggestions = parseTimeoutFallbackResponse(response)

expect(suggestions).toHaveLength(0)
})
})
})
Loading