diff --git a/packages/telemetry/src/TelemetryService.ts b/packages/telemetry/src/TelemetryService.ts index 7a11e3d388..7d59f2a417 100644 --- a/packages/telemetry/src/TelemetryService.ts +++ b/packages/telemetry/src/TelemetryService.ts @@ -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 diff --git a/packages/types/src/telemetry.ts b/packages/types/src/telemetry.ts index 6ad2eb3a7a..b714d16509 100644 --- a/packages/types/src/telemetry.ts +++ b/packages/types/src/telemetry.ts @@ -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", } /** @@ -157,6 +158,7 @@ export const rooCodeTelemetryEventSchema = z.discriminatedUnion("type", [ TelemetryEventName.TAB_SHOWN, TelemetryEventName.MODE_SETTINGS_CHANGED, TelemetryEventName.CUSTOM_MODE_CREATED, + TelemetryEventName.TOOL_TIMEOUT, ]), properties: telemetryPropertiesSchema, }), diff --git a/src/core/prompts/__tests__/timeout-fallback-responses.spec.ts b/src/core/prompts/__tests__/timeout-fallback-responses.spec.ts index 30b53f1581..3899df3923 100644 --- a/src/core/prompts/__tests__/timeout-fallback-responses.spec.ts +++ b/src/core/prompts/__tests__/timeout-fallback-responses.spec.ts @@ -2,6 +2,47 @@ 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("") + 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("") + }) + }) + describe("generateContextualSuggestions", () => { it("should generate execute_command suggestions", () => { const suggestions = formatResponse.timeoutFallbackSuggestions.generateContextualSuggestions( @@ -11,7 +52,8 @@ describe("timeout fallback responses", () => { expect(suggestions).toHaveLength(4) expect(suggestions[0].text).toContain("npm install") - expect(suggestions[0].text).toContain("smaller, sequential steps") + expect(suggestions[0].text).toContain("still running in the background") + expect(suggestions[1].text).toContain("smaller, sequential steps") }) it("should generate read_file suggestions", () => { @@ -42,8 +84,9 @@ describe("timeout fallback responses", () => { ) expect(suggestions).toHaveLength(4) - expect(suggestions[0].text).toContain("click") - expect(suggestions[0].text).toContain("smaller, more targeted steps") + 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", () => { @@ -73,6 +116,7 @@ describe("timeout fallback responses", () => { 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", () => { @@ -94,6 +138,7 @@ describe("timeout fallback responses", () => { 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", () => { diff --git a/src/core/prompts/responses.ts b/src/core/prompts/responses.ts index f3c629101b..37282e13c1 100644 --- a/src/core/prompts/responses.ts +++ b/src/core/prompts/responses.ts @@ -16,17 +16,26 @@ export const formatResponse = { toolError: (error?: string) => `The tool execution failed with the following error:\n\n${error}\n`, - toolTimeout: (toolName: string, timeoutMs: number, executionTimeMs: number) => - `The ${toolName} operation timed out after ${Math.round(timeoutMs / 1000)} seconds and was automatically canceled. + toolTimeout: (toolName: string, timeoutMs: number, executionTimeMs: number) => { + // Determine if this tool might continue running in the background + const backgroundOperationTools = ["execute_command", "browser_action"] + const mightContinueInBackground = backgroundOperationTools.includes(toolName) + + const backgroundWarning = mightContinueInBackground + ? `\n\n**Important**: The ${toolName} operation may still be running in the background. You might see output or effects from this operation later.` + : "" + + return `The ${toolName} operation timed out after ${Math.round(timeoutMs / 1000)} seconds and was automatically canceled. Tool: ${toolName} Configured Timeout: ${Math.round(timeoutMs / 1000)}s Execution Time: ${Math.round(executionTimeMs / 1000)}s Status: Canceled - +${backgroundWarning} -The operation has been terminated to prevent system resource issues. Please consider one of the following approaches to complete your task.`, +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.`, @@ -215,6 +224,9 @@ Otherwise, if you have not completed the task and do not need additional informa const command = params?.command || "the command" return [ + { + text: `Check if "${command}" is still running in the background and wait for it to complete`, + }, { text: `Break "${command}" into smaller, sequential steps that can complete faster`, }, @@ -224,9 +236,6 @@ Otherwise, if you have not completed the task and do not need additional informa { text: `Try an alternative approach or tool to accomplish the same goal`, }, - { - text: `Increase the timeout setting if this operation legitimately needs more time`, - }, ] }, @@ -272,15 +281,15 @@ Otherwise, if you have not completed the task and do not need additional informa const action = params?.action || "browser action" return [ + { + text: `Check if the browser action is still processing in the background`, + }, { 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`, }, diff --git a/src/core/timeout/TimeoutFallbackHandler.ts b/src/core/timeout/TimeoutFallbackHandler.ts index 5aaa2c62f1..6c9e09dc97 100644 --- a/src/core/timeout/TimeoutFallbackHandler.ts +++ b/src/core/timeout/TimeoutFallbackHandler.ts @@ -25,6 +25,18 @@ export interface TimeoutFallbackResult { * and creates timeout responses in a single optimized flow */ export class TimeoutFallbackHandler { + private static readonly BACKGROUND_OPERATION_TOOLS = ["execute_command", "browser_action"] + + /** + * Helper method to generate the timeout question message + */ + private static generateTimeoutQuestion(toolName: ToolName, timeoutMs: number): string { + const mightContinueInBackground = this.BACKGROUND_OPERATION_TOOLS.includes(toolName) + const baseMessage = `The ${toolName} operation timed out after ${Math.round(timeoutMs / 1000)} seconds` + const backgroundSuffix = mightContinueInBackground ? " but may still be running in the background" : "" + return `${baseMessage}${backgroundSuffix}. How would you like to proceed?` + } + /** * Create a timeout response with AI-generated fallback question in a single optimized query */ @@ -39,7 +51,11 @@ export class TimeoutFallbackHandler { // Create a timeout message for display in the chat if (task) { - await task.say("tool_timeout", "", undefined, false, undefined, undefined, { + const mightContinueInBackground = this.BACKGROUND_OPERATION_TOOLS.includes(toolName) + + // Pass a JSON string with the tool info so the UI can determine if it should show a background warning + const timeoutInfo = JSON.stringify({ toolName, mightContinueInBackground }) + await task.say("tool_timeout", timeoutInfo, undefined, false, undefined, undefined, { isNonInteractive: true, }) } @@ -93,10 +109,17 @@ This is required to help the user decide how to proceed after the timeout.` // 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) + // Pass the timeout from context to use exact tool timeout + const aiResult = await this.generateAiSuggestions( + context, + task.api as SingleCompletionHandler, + context.timeoutMs, + ) if (aiResult.success) { + console.log(`[TimeoutFallbackHandler] AI suggestions generated successfully`) return aiResult } + console.error(`[TimeoutFallbackHandler] AI suggestions failed:`, aiResult.error) } catch (error) { // AI failed, fall through to static suggestions } @@ -117,10 +140,20 @@ This is required to help the user decide how to proceed after the timeout.` private static async generateAiSuggestions( context: TimeoutFallbackContext, apiHandler: SingleCompletionHandler, + timeoutMs: number, ): Promise { try { const prompt = createTimeoutFallbackPrompt(context) - const aiResponse = await apiHandler.completePrompt(prompt) + + // Create a timeout promise using exactly the same timeout as the tool + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => { + reject(new Error(`AI fallback generation timed out after ${timeoutMs / 1000} seconds`)) + }, timeoutMs) + }) + + // Race between AI completion and timeout + const aiResponse = await Promise.race([apiHandler.completePrompt(prompt), timeoutPromise]) // Parse the AI response to extract suggestions const suggestions = parseTimeoutFallbackResponse(aiResponse) @@ -129,7 +162,7 @@ This is required to help the user decide how to proceed after the timeout.` 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 question = this.generateTimeoutQuestion(context.toolName, context.timeoutMs) const followUpXml = suggestions .map((suggestion) => @@ -150,9 +183,12 @@ This is required to help the user decide how to proceed after the timeout.` }, } } catch (error) { + // If it's a timeout error, include that in the error message + const errorMessage = error instanceof Error ? error.message : "Unknown error generating AI suggestions" + return { success: false, - error: error instanceof Error ? error.message : "Unknown error generating AI suggestions", + error: errorMessage, } } } @@ -166,7 +202,7 @@ This is required to help the user decide how to proceed after the timeout.` context.toolParams, ) - const question = `The ${context.toolName} operation timed out after ${Math.round(context.timeoutMs / 1000)} seconds. How would you like to proceed?` + const question = this.generateTimeoutQuestion(context.toolName, context.timeoutMs) const followUpXml = suggestions .map((suggestion) => diff --git a/src/core/timeout/TimeoutManager.ts b/src/core/timeout/TimeoutManager.ts index 805b46dc3e..fe1ae3c458 100644 --- a/src/core/timeout/TimeoutManager.ts +++ b/src/core/timeout/TimeoutManager.ts @@ -1,5 +1,6 @@ import { EventEmitter } from "events" import type { ToolName } from "@roo-code/types" +import { TelemetryService } from "@roo-code/telemetry" export interface TimeoutConfig { toolName: ToolName @@ -36,14 +37,16 @@ export class TimeoutManager extends EventEmitter { * like "Proceed While Running" */ private lastTimeoutEvent: TimeoutEvent | null = null + private logger: (...args: any[]) => void - private constructor() { + private constructor(logger?: (...args: any[]) => void) { super() + this.logger = logger || console.log } - public static getInstance(): TimeoutManager { + public static getInstance(logger?: (...args: any[]) => void): TimeoutManager { if (!TimeoutManager.instance) { - TimeoutManager.instance = new TimeoutManager() + TimeoutManager.instance = new TimeoutManager(logger) } return TimeoutManager.instance } @@ -59,6 +62,8 @@ export class TimeoutManager extends EventEmitter { const controller = new AbortController() const startTime = Date.now() + this.logger(`[TimeoutManager] Starting operation ${operationId} with timeout ${config.timeoutMs}ms`) + // Store the controller for potential cancellation this.activeOperations.set(operationId, controller) @@ -81,6 +86,8 @@ export class TimeoutManager extends EventEmitter { const executionTimeMs = Date.now() - startTime + this.logger(`[TimeoutManager] Operation ${operationId} completed successfully in ${executionTimeMs}ms`) + return { success: true, result, @@ -105,6 +112,21 @@ export class TimeoutManager extends EventEmitter { this.lastTimeoutEvent = timeoutEvent this.emit("timeout", timeoutEvent) + // Log timeout event + this.logger( + `[TimeoutManager] Operation ${operationId} timed out after ${executionTimeMs}ms (limit: ${config.timeoutMs}ms)`, + ) + + // Capture telemetry if TelemetryService is available + if (TelemetryService.hasInstance() && config.taskId) { + TelemetryService.instance.captureToolTimeout( + config.taskId, + config.toolName, + config.timeoutMs, + executionTimeMs, + ) + } + return { success: false, timedOut: true, @@ -114,6 +136,11 @@ export class TimeoutManager extends EventEmitter { } } + // Log non-timeout errors + this.logger( + `[TimeoutManager] Operation ${operationId} failed after ${executionTimeMs}ms: ${error instanceof Error ? error.message : String(error)}`, + ) + return { success: false, timedOut: false, @@ -186,6 +213,21 @@ export class TimeoutManager extends EventEmitter { return `${toolName}:${taskId || "default"}` } + /** + * Get timeout statistics for monitoring + */ + public getTimeoutStats(): { + lastTimeout: TimeoutEvent | null + activeOperations: number + operationIds: string[] + } { + return { + lastTimeout: this.lastTimeoutEvent, + activeOperations: this.activeOperations.size, + operationIds: Array.from(this.activeOperations.keys()), + } + } + /** * Cleanup method for graceful shutdown */ diff --git a/src/core/timeout/__tests__/timeout-fallback.spec.ts b/src/core/timeout/__tests__/timeout-fallback.spec.ts index d835bbf6ac..ee98890f2a 100644 --- a/src/core/timeout/__tests__/timeout-fallback.spec.ts +++ b/src/core/timeout/__tests__/timeout-fallback.spec.ts @@ -59,6 +59,7 @@ describe("TimeoutFallbackHandler", () => { expect(result.toolCall?.name).toBe("ask_followup_question") expect(result.toolCall?.params.question).toContain("execute_command") expect(result.toolCall?.params.question).toContain("30 seconds") + expect(result.toolCall?.params.question).toContain("may still be running in the background") // Check that AI-generated suggestions are included const followUp = result.toolCall?.params.follow_up || "" @@ -93,12 +94,55 @@ describe("TimeoutFallbackHandler", () => { // Should contain static fallback suggestions const followUp = result.toolCall?.params.follow_up || "" + expect(followUp).toContain("still running in the background") 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 timeout AI fallback generation using exact tool timeout", async () => { + // Mock AI handler that takes longer than the timeout + ;(mockApiHandler.completePrompt as any).mockImplementation(() => { + return new Promise((resolve) => { + // This would resolve after 5 seconds, but should timeout before that + setTimeout(() => { + resolve("This response should never be used") + }, 5000) + }) + }) + + const context = { + toolName: "execute_command" as const, + timeoutMs: 2000, // 2 second timeout for faster testing + executionTimeMs: 2500, + toolParams: { command: "npm install" }, + } + + const startTime = Date.now() + // The timeout is now taken from context.timeoutMs + const result = await TimeoutFallbackHandler.generateAiFallback(context, mockTask as Task) + const elapsedTime = Date.now() - startTime + + // Should timeout and fallback to static suggestions + expect(result.success).toBe(true) + expect(result.toolCall).toBeDefined() + expect(result.toolCall?.name).toBe("ask_followup_question") + + // Should have timed out around 2 seconds (exact tool timeout) + expect(elapsedTime).toBeGreaterThanOrEqual(1900) + expect(elapsedTime).toBeLessThan(2500) + + // Should contain static fallback suggestions since AI timed out + const followUp = result.toolCall?.params.follow_up || "" + expect(followUp).toContain("still running in the background") + expect(followUp).toContain('Break "npm install" into smaller') + expect(followUp).toContain("background using") + expect(followUp).toContain("alternative approach") + + // Verify AI was called + expect(mockApiHandler.completePrompt).toHaveBeenCalled() + }, 5000) // Test timeout of 5 seconds + test("should fallback to static suggestions when API handler is unavailable", async () => { // Task without API handler const taskWithoutApi = {} @@ -400,9 +444,9 @@ Based on the search_files timeout, here are my recommendations: // Should contain static fallback suggestions for browser_action const followUp = result.toolCall?.params.follow_up || "" + expect(followUp).toContain("still processing in the background") 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") }) }) diff --git a/src/core/timeout/__tests__/timeout-telemetry.spec.ts b/src/core/timeout/__tests__/timeout-telemetry.spec.ts new file mode 100644 index 0000000000..a8fd4effc9 --- /dev/null +++ b/src/core/timeout/__tests__/timeout-telemetry.spec.ts @@ -0,0 +1,256 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" +import { TimeoutManager } from "../TimeoutManager" +import { TelemetryService } from "@roo-code/telemetry" + +// Mock TelemetryService +vi.mock("@roo-code/telemetry", () => ({ + TelemetryService: { + hasInstance: vi.fn(), + instance: { + captureToolTimeout: vi.fn(), + }, + }, +})) + +describe("TimeoutManager Telemetry and Logging", () => { + let timeoutManager: TimeoutManager + let mockLogger: ReturnType + let originalInstance: TimeoutManager | undefined + + beforeEach(() => { + // Store the original instance + originalInstance = (TimeoutManager as any).instance + // Reset the singleton instance + ;(TimeoutManager as any).instance = undefined + + // Create mock logger + mockLogger = vi.fn() + + // Create new instance with mock logger + timeoutManager = TimeoutManager.getInstance(mockLogger) + + // Setup TelemetryService mock + vi.mocked(TelemetryService.hasInstance).mockReturnValue(true) + vi.mocked(TelemetryService.instance.captureToolTimeout).mockClear() + }) + + afterEach(() => { + // Restore the original instance + ;(TimeoutManager as any).instance = originalInstance + vi.clearAllMocks() + }) + + describe("Logging", () => { + it("should log operation start", async () => { + const operation = vi.fn().mockResolvedValue("result") + + await timeoutManager.executeWithTimeout(operation, { + toolName: "execute_command", + timeoutMs: 1000, + enableFallback: false, + taskId: "task-123", + }) + + expect(mockLogger).toHaveBeenCalledWith( + expect.stringContaining( + "[TimeoutManager] Starting operation execute_command:task-123 with timeout 1000ms", + ), + ) + }) + + it("should log successful operation completion", async () => { + const operation = vi.fn().mockResolvedValue("result") + + await timeoutManager.executeWithTimeout(operation, { + toolName: "execute_command", + timeoutMs: 1000, + enableFallback: false, + taskId: "task-123", + }) + + expect(mockLogger).toHaveBeenCalledWith( + expect.stringMatching( + /\[TimeoutManager\] Operation execute_command:task-123 completed successfully in \d+ms/, + ), + ) + }) + + it("should log timeout events", async () => { + const operation = vi.fn().mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 2000))) + + await timeoutManager.executeWithTimeout(operation, { + toolName: "execute_command", + timeoutMs: 100, + enableFallback: false, + taskId: "task-123", + }) + + expect(mockLogger).toHaveBeenCalledWith( + expect.stringMatching( + /\[TimeoutManager\] Operation execute_command:task-123 timed out after \d+ms \(limit: 100ms\)/, + ), + ) + }) + + it("should log non-timeout errors", async () => { + const error = new Error("Test error") + const operation = vi.fn().mockRejectedValue(error) + + await timeoutManager.executeWithTimeout(operation, { + toolName: "execute_command", + timeoutMs: 1000, + enableFallback: false, + taskId: "task-123", + }) + + expect(mockLogger).toHaveBeenCalledWith( + expect.stringMatching( + /\[TimeoutManager\] Operation execute_command:task-123 failed after \d+ms: Test error/, + ), + ) + }) + }) + + describe("Telemetry", () => { + it("should capture telemetry for timeout events when TelemetryService is available", async () => { + const operation = vi.fn().mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 2000))) + + const result = await timeoutManager.executeWithTimeout(operation, { + toolName: "execute_command", + timeoutMs: 100, + enableFallback: false, + taskId: "task-123", + }) + + expect(result.timedOut).toBe(true) + expect(TelemetryService.instance.captureToolTimeout).toHaveBeenCalledWith( + "task-123", + "execute_command", + 100, + expect.any(Number), + ) + }) + + it("should not capture telemetry when taskId is not provided", async () => { + const operation = vi.fn().mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 2000))) + + await timeoutManager.executeWithTimeout(operation, { + toolName: "execute_command", + timeoutMs: 100, + enableFallback: false, + }) + + expect(TelemetryService.instance.captureToolTimeout).not.toHaveBeenCalled() + }) + + it("should not capture telemetry when TelemetryService is not available", async () => { + vi.mocked(TelemetryService.hasInstance).mockReturnValue(false) + + const operation = vi.fn().mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 2000))) + + await timeoutManager.executeWithTimeout(operation, { + toolName: "execute_command", + timeoutMs: 100, + enableFallback: false, + taskId: "task-123", + }) + + expect(TelemetryService.instance.captureToolTimeout).not.toHaveBeenCalled() + }) + + it("should not capture telemetry for successful operations", async () => { + const operation = vi.fn().mockResolvedValue("result") + + await timeoutManager.executeWithTimeout(operation, { + toolName: "execute_command", + timeoutMs: 1000, + enableFallback: false, + taskId: "task-123", + }) + + expect(TelemetryService.instance.captureToolTimeout).not.toHaveBeenCalled() + }) + + it("should not capture telemetry for non-timeout errors", async () => { + const operation = vi.fn().mockRejectedValue(new Error("Test error")) + + await timeoutManager.executeWithTimeout(operation, { + toolName: "execute_command", + timeoutMs: 1000, + enableFallback: false, + taskId: "task-123", + }) + + expect(TelemetryService.instance.captureToolTimeout).not.toHaveBeenCalled() + }) + }) + + describe("getTimeoutStats", () => { + it("should return timeout statistics", async () => { + // Execute an operation that will timeout + const operation = vi.fn().mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 2000))) + + await timeoutManager.executeWithTimeout(operation, { + toolName: "execute_command", + timeoutMs: 100, + enableFallback: false, + taskId: "task-123", + }) + + const stats = timeoutManager.getTimeoutStats() + + expect(stats.lastTimeout).toBeTruthy() + expect(stats.lastTimeout?.toolName).toBe("execute_command") + expect(stats.lastTimeout?.taskId).toBe("task-123") + expect(stats.lastTimeout?.timeoutMs).toBe(100) + expect(stats.activeOperations).toBe(0) // Should be 0 after operation completes + }) + + it("should track active operations", async () => { + // Start a long-running operation + const operation = vi.fn().mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 5000))) + + const promise = timeoutManager.executeWithTimeout(operation, { + toolName: "execute_command", + timeoutMs: 10000, + enableFallback: false, + taskId: "task-123", + }) + + // Check stats while operation is running + const stats = timeoutManager.getTimeoutStats() + expect(stats.activeOperations).toBe(1) + expect(stats.operationIds).toContain("execute_command:task-123") + + // Cancel to clean up + timeoutManager.cancelOperation("execute_command", "task-123") + await promise.catch(() => {}) // Ignore cancellation error + }) + }) + + describe("Timeout event emission", () => { + it("should emit timeout event with correct data", async () => { + const timeoutHandler = vi.fn() + timeoutManager.on("timeout", timeoutHandler) + + const operation = vi.fn().mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 2000))) + + await timeoutManager.executeWithTimeout(operation, { + toolName: "execute_command", + timeoutMs: 100, + enableFallback: true, + taskId: "task-123", + }) + + expect(timeoutHandler).toHaveBeenCalledWith({ + toolName: "execute_command", + timeoutMs: 100, + executionTimeMs: expect.any(Number), + taskId: "task-123", + timestamp: expect.any(Number), + }) + + timeoutManager.off("timeout", timeoutHandler) + }) + }) +}) diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index ae19d3bb2a..397cc0c8da 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -259,9 +259,27 @@ export const ChatRowContent = ({ {t("chat:questions.hasQuestion")}, ] case "tool_timeout": + let showBackgroundWarning = false + try { + const timeoutInfo = message.text ? JSON.parse(message.text) : null + showBackgroundWarning = timeoutInfo?.mightContinueInBackground || false + } catch (_e) { + // If parsing fails, don't show the warning + } return [ , - {t("chat:toolTimeout")}, + + {t("chat:toolTimeout.title")}{" "} + {t("chat:toolTimeout.subtitle")} + {showBackgroundWarning && ( + <> +
+ + {t("chat:toolTimeout.backgroundWarning")} + + + )} +
, ] default: return [null, null] @@ -1115,6 +1133,16 @@ export const ChatRowContent = ({ case "user_edit_todos": return {}} /> default: + // Don't render message text for tool_timeout as it contains JSON metadata + if (message.say === "tool_timeout") { + return title ? ( +
+ {icon} + {title} +
+ ) : null + } + return ( <> {title && ( diff --git a/webview-ui/src/i18n/locales/ca/chat.json b/webview-ui/src/i18n/locales/ca/chat.json index ac8c69a153..8c429bbb20 100644 --- a/webview-ui/src/i18n/locales/ca/chat.json +++ b/webview-ui/src/i18n/locales/ca/chat.json @@ -215,7 +215,6 @@ "hasQuestion": "Roo té una pregunta:" }, "taskCompleted": "Tasca completada", - "toolTimeout": "Temps d'espera de l'eina esgotat: Suggerint alternatives...", "powershell": { "issues": "Sembla que estàs tenint problemes amb Windows PowerShell, si us plau consulta aquesta documentació per a més informació." }, @@ -239,6 +238,11 @@ "autoSelectCountdown": "Selecció automàtica en {{count}}s", "countdownDisplay": "{{count}}s" }, + "toolTimeout": { + "title": "Temps d'espera d'eina configurat per l'usuari:", + "subtitle": "Suggerint alternatives...", + "backgroundWarning": "Nota: l'operació pot seguir executant-se en segon pla" + }, "announcement": { "title": "🎉 Roo Code {{version}} Llançat", "description": "Roo Code {{version}} porta noves funcions potents i millores significatives per millorar el vostre flux de treball de desenvolupament.", diff --git a/webview-ui/src/i18n/locales/de/chat.json b/webview-ui/src/i18n/locales/de/chat.json index c98ca8a3b6..8b3ae34dc1 100644 --- a/webview-ui/src/i18n/locales/de/chat.json +++ b/webview-ui/src/i18n/locales/de/chat.json @@ -215,7 +215,6 @@ "hasQuestion": "Roo hat eine Frage:" }, "taskCompleted": "Aufgabe abgeschlossen", - "toolTimeout": "Automatische Tool-Zeitüberschreitung: Schlage Alternativen vor...", "powershell": { "issues": "Es scheint, dass du Probleme mit Windows PowerShell hast, bitte sieh dir dies an" }, @@ -239,6 +238,11 @@ "autoSelectCountdown": "Automatische Auswahl in {{count}}s", "countdownDisplay": "{{count}}s" }, + "toolTimeout": { + "title": "Benutzerdefiniertes Tool-Timeout:", + "subtitle": "Alternativen werden vorgeschlagen...", + "backgroundWarning": "Hinweis: Der Vorgang läuft möglicherweise noch im Hintergrund" + }, "announcement": { "title": "🎉 Roo Code {{version}} veröffentlicht", "description": "Roo Code {{version}} bringt mächtige neue Funktionen und bedeutende Verbesserungen, um deinen Entwicklungsworkflow zu verbessern.", diff --git a/webview-ui/src/i18n/locales/en/chat.json b/webview-ui/src/i18n/locales/en/chat.json index 99b267bc6c..0add37cab5 100644 --- a/webview-ui/src/i18n/locales/en/chat.json +++ b/webview-ui/src/i18n/locales/en/chat.json @@ -230,7 +230,11 @@ "hasQuestion": "Roo has a question:" }, "taskCompleted": "Task Completed", - "toolTimeout": "Automatic Tool Timeout: Suggesting alternatives...", + "toolTimeout": { + "title": "User Configured Tool Timeout:", + "subtitle": "Suggesting alternatives...", + "backgroundWarning": "Note operation may still be running in the background" + }, "error": "Error", "diffError": { "title": "Edit Unsuccessful" diff --git a/webview-ui/src/i18n/locales/es/chat.json b/webview-ui/src/i18n/locales/es/chat.json index 53cd277f3a..6b8a3a34eb 100644 --- a/webview-ui/src/i18n/locales/es/chat.json +++ b/webview-ui/src/i18n/locales/es/chat.json @@ -1,5 +1,4 @@ { - "toolTimeout": "Tiempo de espera de herramienta agotado: Sugiriendo alternativas...", "greeting": "Bienvenido a Roo Code", "task": { "title": "Tarea", @@ -239,6 +238,11 @@ "autoSelectCountdown": "Selección automática en {{count}}s", "countdownDisplay": "{{count}}s" }, + "toolTimeout": { + "title": "Tiempo de espera de herramienta configurado por el usuario:", + "subtitle": "Sugiriendo alternativas...", + "backgroundWarning": "Nota: la operación puede seguir ejecutándose en segundo plano" + }, "announcement": { "title": "🎉 Roo Code {{version}} publicado", "description": "Roo Code {{version}} trae poderosas nuevas funcionalidades y mejoras significativas para mejorar tu flujo de trabajo de desarrollo.", diff --git a/webview-ui/src/i18n/locales/fr/chat.json b/webview-ui/src/i18n/locales/fr/chat.json index e57501c176..032a6cc54b 100644 --- a/webview-ui/src/i18n/locales/fr/chat.json +++ b/webview-ui/src/i18n/locales/fr/chat.json @@ -1,5 +1,4 @@ { - "toolTimeout": "Délai d'attente de l'outil dépassé : Suggestion d'alternatives...", "greeting": "Bienvenue sur Roo Code", "task": { "title": "Tâche", @@ -239,6 +238,11 @@ "autoSelectCountdown": "Sélection automatique dans {{count}}s", "countdownDisplay": "{{count}}s" }, + "toolTimeout": { + "title": "Délai d'expiration d'outil configuré par l'utilisateur :", + "subtitle": "Suggestion d'alternatives...", + "backgroundWarning": "Note : l'opération peut encore s'exécuter en arrière-plan" + }, "announcement": { "title": "🎉 Roo Code {{version}} est sortie", "description": "Roo Code {{version}} apporte de puissantes nouvelles fonctionnalités et des améliorations significatives pour améliorer ton flux de travail de développement.", diff --git a/webview-ui/src/i18n/locales/hi/chat.json b/webview-ui/src/i18n/locales/hi/chat.json index dc7d82c1f2..95c7621605 100644 --- a/webview-ui/src/i18n/locales/hi/chat.json +++ b/webview-ui/src/i18n/locales/hi/chat.json @@ -1,5 +1,4 @@ { - "toolTimeout": "टूल टाइमआउट: विकल्प सुझा रहा हूं...", "greeting": "Roo Code में आपका स्वागत है", "task": { "title": "कार्य", @@ -239,6 +238,11 @@ "autoSelectCountdown": "{{count}}s में स्वचालित रूप से चयन हो रहा है", "countdownDisplay": "{{count}}सेकंड" }, + "toolTimeout": { + "title": "उपयोगकर्ता द्वारा कॉन्फ़िगर किया गया टूल टाइमआउट:", + "subtitle": "विकल्प सुझा रहा है...", + "backgroundWarning": "नोट: ऑपरेशन अभी भी बैकग्राउंड में चल रहा हो सकता है" + }, "announcement": { "title": "🎉 Roo Code {{version}} रिलीज़ हुआ", "description": "Roo Code {{version}} आपके विकास वर्कफ़्लो को बेहतर बनाने के लिए शक्तिशाली नई सुविधाएं और महत्वपूर्ण सुधार लेकर आया है।", diff --git a/webview-ui/src/i18n/locales/id/chat.json b/webview-ui/src/i18n/locales/id/chat.json index a86456313a..7bf8671a79 100644 --- a/webview-ui/src/i18n/locales/id/chat.json +++ b/webview-ui/src/i18n/locales/id/chat.json @@ -1,5 +1,4 @@ { - "toolTimeout": "Timeout Tool Otomatis: Menyarankan alternatif...", "greeting": "Selamat datang di Roo Code", "task": { "title": "Tugas", @@ -236,6 +235,11 @@ "questions": { "hasQuestion": "Roo punya pertanyaan:" }, + "toolTimeout": { + "title": "Timeout Tool yang Dikonfigurasi Pengguna:", + "subtitle": "Menyarankan alternatif...", + "backgroundWarning": "Catatan: operasi mungkin masih berjalan di latar belakang" + }, "taskCompleted": "Tugas Selesai", "error": "Error", "diffError": { diff --git a/webview-ui/src/i18n/locales/it/chat.json b/webview-ui/src/i18n/locales/it/chat.json index c3ccfe1f0f..c30123c0eb 100644 --- a/webview-ui/src/i18n/locales/it/chat.json +++ b/webview-ui/src/i18n/locales/it/chat.json @@ -1,5 +1,4 @@ { - "toolTimeout": "Timeout automatico dello strumento: Suggerendo alternative...", "greeting": "Benvenuto a Roo Code", "task": { "title": "Attività", @@ -239,6 +238,11 @@ "autoSelectCountdown": "Selezione automatica in {{count}}s", "countdownDisplay": "{{count}}s" }, + "toolTimeout": { + "title": "Timeout Strumento Configurato dall'Utente:", + "subtitle": "Suggerendo alternative...", + "backgroundWarning": "Nota: l'operazione potrebbe essere ancora in esecuzione in background" + }, "announcement": { "title": "🎉 Rilasciato Roo Code {{version}}", "description": "Roo Code {{version}} porta nuove potenti funzionalità e miglioramenti significativi per potenziare il tuo flusso di lavoro di sviluppo.", diff --git a/webview-ui/src/i18n/locales/ja/chat.json b/webview-ui/src/i18n/locales/ja/chat.json index 443c63eb1a..1787fcec72 100644 --- a/webview-ui/src/i18n/locales/ja/chat.json +++ b/webview-ui/src/i18n/locales/ja/chat.json @@ -1,5 +1,4 @@ { - "toolTimeout": "自動ツールタイムアウト:代替案を提案しています...", "greeting": "Roo Code へようこそ", "task": { "title": "タスク", @@ -239,6 +238,11 @@ "autoSelectCountdown": "{{count}}秒後に自動選択します", "countdownDisplay": "{{count}}秒" }, + "toolTimeout": { + "title": "ユーザー設定ツールタイムアウト:", + "subtitle": "代替案を提案中...", + "backgroundWarning": "注意: 操作がバックグラウンドで実行中の可能性があります" + }, "announcement": { "title": "🎉 Roo Code {{version}} リリース", "description": "Roo Code {{version}}は、開発ワークフローを向上させる強力な新機能と重要な改善をもたらします。", diff --git a/webview-ui/src/i18n/locales/ko/chat.json b/webview-ui/src/i18n/locales/ko/chat.json index 5ca6075ec3..1234935b29 100644 --- a/webview-ui/src/i18n/locales/ko/chat.json +++ b/webview-ui/src/i18n/locales/ko/chat.json @@ -1,5 +1,4 @@ { - "toolTimeout": "자동 도구 타임아웃: 대안을 제안하고 있습니다...", "greeting": "Roo Code에 오신 것을 환영합니다", "task": { "title": "작업", @@ -239,6 +238,11 @@ "autoSelectCountdown": "{{count}}초 후 자동 선택", "countdownDisplay": "{{count}}초" }, + "toolTimeout": { + "title": "사용자 구성 도구 타임아웃:", + "subtitle": "대안을 제안하는 중...", + "backgroundWarning": "참고: 작업이 백그라운드에서 계속 실행 중일 수 있습니다" + }, "announcement": { "title": "🎉 Roo Code {{version}} 출시", "description": "Roo Code {{version}}은 개발 워크플로우를 향상시키는 강력한 새 기능과 중요한 개선사항을 제공합니다.", diff --git a/webview-ui/src/i18n/locales/nl/chat.json b/webview-ui/src/i18n/locales/nl/chat.json index 71ebaf2c28..146e27576f 100644 --- a/webview-ui/src/i18n/locales/nl/chat.json +++ b/webview-ui/src/i18n/locales/nl/chat.json @@ -1,5 +1,4 @@ { - "toolTimeout": "Automatische tool timeout: Alternatieven voorstellen...", "greeting": "Welkom bij Roo Code", "task": { "title": "Taak", @@ -244,6 +243,11 @@ "errorHeader": "Context samenvatten mislukt", "tokens": "tokens" }, + "toolTimeout": { + "title": "Door gebruiker geconfigureerde tool timeout:", + "subtitle": "Alternatieven voorstellen...", + "backgroundWarning": "Let op: de bewerking kan nog steeds op de achtergrond worden uitgevoerd" + }, "followUpSuggest": { "copyToInput": "Kopiëren naar invoer (zelfde als shift + klik)", "autoSelectCountdown": "Automatische selectie in {{count}}s", diff --git a/webview-ui/src/i18n/locales/pl/chat.json b/webview-ui/src/i18n/locales/pl/chat.json index 35ee26990c..894f06f5eb 100644 --- a/webview-ui/src/i18n/locales/pl/chat.json +++ b/webview-ui/src/i18n/locales/pl/chat.json @@ -1,5 +1,4 @@ { - "toolTimeout": "Automatyczny timeout narzędzia: Sugerowanie alternatyw...", "greeting": "Witamy w Roo Code", "task": { "title": "Zadanie", @@ -239,6 +238,11 @@ "autoSelectCountdown": "Automatyczny wybór za {{count}}s", "countdownDisplay": "{{count}}s" }, + "toolTimeout": { + "title": "Skonfigurowany przez użytkownika timeout narzędzia:", + "subtitle": "Sugerowanie alternatyw...", + "backgroundWarning": "Uwaga: operacja może nadal działać w tle" + }, "announcement": { "title": "🎉 Roo Code {{version}} wydany", "description": "Roo Code {{version}} wprowadza potężne nowe funkcje i znaczące ulepszenia, aby ulepszyć Twój przepływ pracy programistycznej.", diff --git a/webview-ui/src/i18n/locales/pt-BR/chat.json b/webview-ui/src/i18n/locales/pt-BR/chat.json index 892ed6c49d..cc7b86eafd 100644 --- a/webview-ui/src/i18n/locales/pt-BR/chat.json +++ b/webview-ui/src/i18n/locales/pt-BR/chat.json @@ -1,5 +1,4 @@ { - "toolTimeout": "Timeout automático da ferramenta: Sugerindo alternativas...", "greeting": "Bem-vindo ao Roo Code", "task": { "title": "Tarefa", @@ -239,6 +238,11 @@ "autoSelectCountdown": "Seleção automática em {{count}}s", "countdownDisplay": "{{count}}s" }, + "toolTimeout": { + "title": "Timeout de Ferramenta Configurado pelo Usuário:", + "subtitle": "Sugerindo alternativas...", + "backgroundWarning": "Nota: a operação pode ainda estar executando em segundo plano" + }, "announcement": { "title": "🎉 Roo Code {{version}} Lançado", "description": "Roo Code {{version}} traz novos recursos poderosos e melhorias significativas para aprimorar seu fluxo de trabalho de desenvolvimento.", diff --git a/webview-ui/src/i18n/locales/ru/chat.json b/webview-ui/src/i18n/locales/ru/chat.json index d63264259d..64eed7bdfa 100644 --- a/webview-ui/src/i18n/locales/ru/chat.json +++ b/webview-ui/src/i18n/locales/ru/chat.json @@ -1,5 +1,4 @@ { - "toolTimeout": "Автоматический тайм-аут инструмента: Предлагаю альтернативы...", "greeting": "Добро пожаловать в Roo Code", "task": { "title": "Задача", @@ -244,6 +243,11 @@ "errorHeader": "Не удалось сжать контекст", "tokens": "токены" }, + "toolTimeout": { + "title": "Настроенный пользователем тайм-аут инструмента:", + "subtitle": "Предлагаем альтернативы...", + "backgroundWarning": "Примечание: операция может все еще выполняться в фоновом режиме" + }, "followUpSuggest": { "copyToInput": "Скопировать во ввод (то же, что shift + клик)", "autoSelectCountdown": "Автовыбор через {{count}}с", diff --git a/webview-ui/src/i18n/locales/tr/chat.json b/webview-ui/src/i18n/locales/tr/chat.json index 3377c8e70f..bc44e17c00 100644 --- a/webview-ui/src/i18n/locales/tr/chat.json +++ b/webview-ui/src/i18n/locales/tr/chat.json @@ -1,5 +1,4 @@ { - "toolTimeout": "Otomatik araç zaman aşımı: Alternatifler öneriliyor...", "greeting": "Roo Code'a Hoş Geldiniz", "task": { "title": "Görev", @@ -239,6 +238,11 @@ "autoSelectCountdown": "{{count}}s içinde otomatik seçilecek", "countdownDisplay": "{{count}}sn" }, + "toolTimeout": { + "title": "Kullanıcı Tarafından Yapılandırılan Araç Zaman Aşımı:", + "subtitle": "Alternatifler öneriliyor...", + "backgroundWarning": "Not: işlem hala arka planda çalışıyor olabilir" + }, "announcement": { "title": "🎉 Roo Code {{version}} Yayınlandı", "description": "Roo Code {{version}}, geliştirme iş akışınızı geliştirmek için güçlü yeni özellikler ve önemli iyileştirmeler getiriyor.", diff --git a/webview-ui/src/i18n/locales/vi/chat.json b/webview-ui/src/i18n/locales/vi/chat.json index 2aa199d056..e8ceb8a9d1 100644 --- a/webview-ui/src/i18n/locales/vi/chat.json +++ b/webview-ui/src/i18n/locales/vi/chat.json @@ -238,6 +238,11 @@ "autoSelectCountdown": "Tự động chọn sau {{count}}s", "countdownDisplay": "{{count}}s" }, + "toolTimeout": { + "title": "Thời gian chờ công cụ do người dùng cấu hình:", + "subtitle": "Đang đề xuất các lựa chọn thay thế...", + "backgroundWarning": "Lưu ý: thao tác có thể vẫn đang chạy trong nền" + }, "announcement": { "title": "🎉 Roo Code {{version}} Đã phát hành", "description": "Roo Code {{version}} mang đến các tính năng mạnh mẽ mới và cải tiến đáng kể để nâng cao quy trình phát triển của bạn.", @@ -316,6 +321,5 @@ }, "versionIndicator": { "ariaLabel": "Phiên bản {{version}} - Nhấp để xem ghi chú phát hành" - }, - "toolTimeout": "Tự động hết thời gian chờ công cụ: Đang đề xuất các lựa chọn thay thế..." + } } diff --git a/webview-ui/src/i18n/locales/zh-CN/chat.json b/webview-ui/src/i18n/locales/zh-CN/chat.json index de30f5e693..e5b3ec283e 100644 --- a/webview-ui/src/i18n/locales/zh-CN/chat.json +++ b/webview-ui/src/i18n/locales/zh-CN/chat.json @@ -238,6 +238,11 @@ "autoSelectCountdown": "{{count}}秒后自动选择", "countdownDisplay": "{{count}}秒" }, + "toolTimeout": { + "title": "用户配置的工具超时:", + "subtitle": "正在建议替代方案...", + "backgroundWarning": "注意: 操作可能仍在后台运行" + }, "announcement": { "title": "🎉 Roo Code {{version}} 已发布", "description": "Roo Code {{version}} 带来强大的新功能和重大改进,提升您的开发工作流程。", @@ -316,6 +321,5 @@ }, "versionIndicator": { "ariaLabel": "版本 {{version}} - 点击查看发布说明" - }, - "toolTimeout": "自动工具超时:正在建议替代方案..." + } } diff --git a/webview-ui/src/i18n/locales/zh-TW/chat.json b/webview-ui/src/i18n/locales/zh-TW/chat.json index 0ece02f426..b198111910 100644 --- a/webview-ui/src/i18n/locales/zh-TW/chat.json +++ b/webview-ui/src/i18n/locales/zh-TW/chat.json @@ -238,6 +238,11 @@ "autoSelectCountdown": "{{count}}秒後自動選擇", "countdownDisplay": "{{count}}秒" }, + "toolTimeout": { + "title": "使用者設定的工具逾時:", + "subtitle": "正在建議替代方案...", + "backgroundWarning": "注意: 操作可能仍在背景執行" + }, "announcement": { "title": "🎉 Roo Code {{version}} 已發布", "description": "Roo Code {{version}} 帶來強大的新功能和重大改進,提升您的開發工作流程。", @@ -316,6 +321,5 @@ }, "versionIndicator": { "ariaLabel": "版本 {{version}} - 點擊查看發布說明" - }, - "toolTimeout": "自動工具逾時:正在建議替代方案..." + } }