/** * AskDispatcher - Routes ask messages to appropriate handlers * * This dispatcher is responsible for: * - Categorizing ask types using type guards from client module * - Routing to the appropriate handler based on ask category * - Coordinating between OutputManager and PromptManager * - Tracking which asks have been handled (to avoid duplicates) * * Design notes: * - Uses isIdleAsk, isInteractiveAsk, isResumableAsk, isNonBlockingAsk type guards * - Single responsibility: Ask routing and handling only * - Delegates output to OutputManager, input to PromptManager * - Sends responses back through a provided callback */ import { type WebviewMessage, type ClineMessage, type ClineAsk, type ClineAskResponse, isIdleAsk, isInteractiveAsk, isResumableAsk, isNonBlockingAsk, } from "@roo-code/types" import { debugLog } from "@roo-code/core/cli" import { FOLLOWUP_TIMEOUT_SECONDS } from "@/types/index.js" import type { OutputManager } from "./output-manager.js" import type { PromptManager } from "./prompt-manager.js" // ============================================================================= // Types // ============================================================================= /** * Configuration for AskDispatcher. */ export interface AskDispatcherOptions { /** * OutputManager for displaying ask-related output. */ outputManager: OutputManager /** * PromptManager for collecting user input. */ promptManager: PromptManager /** * Callback to send responses to the extension. */ sendMessage: (message: WebviewMessage) => void /** * Whether running in non-interactive mode (auto-approve). */ nonInteractive?: boolean /** * Whether to exit on API request errors instead of retrying. */ exitOnError?: boolean /** * Whether to disable ask handling (for TUI mode). * In TUI mode, the TUI handles asks directly. */ disabled?: boolean } /** * Result of handling an ask. */ export interface AskHandleResult { /** Whether the ask was handled */ handled: boolean /** The response sent (if any) */ response?: ClineAskResponse /** Any error that occurred */ error?: Error } // ============================================================================= // AskDispatcher Class // ============================================================================= export class AskDispatcher { private outputManager: OutputManager private promptManager: PromptManager private sendMessage: (message: WebviewMessage) => void private nonInteractive: boolean private exitOnError: boolean private disabled: boolean /** * Track which asks have been handled to avoid duplicates. * Key: message ts */ private handledAsks = new Set() constructor(options: AskDispatcherOptions) { this.outputManager = options.outputManager this.promptManager = options.promptManager this.sendMessage = options.sendMessage this.nonInteractive = options.nonInteractive ?? false this.exitOnError = options.exitOnError ?? false this.disabled = options.disabled ?? false } // =========================================================================== // Public API // =========================================================================== /** * Handle an ask message. * Routes to the appropriate handler based on ask type. * * @param message - The ClineMessage with type="ask" * @returns Promise */ async handleAsk(message: ClineMessage): Promise { // Disabled in TUI mode - TUI handles asks directly if (this.disabled) { return { handled: false } } const ts = message.ts const ask = message.ask const text = message.text || "" // Check if already handled if (this.handledAsks.has(ts)) { return { handled: true } } // Must be an ask message if (message.type !== "ask" || !ask) { return { handled: false } } // Skip partial messages (wait for complete) if (message.partial) { return { handled: false } } // Mark as being handled this.handledAsks.add(ts) try { // Route based on ask category if (isNonBlockingAsk(ask)) { return await this.handleNonBlockingAsk(ts, ask, text) } if (isIdleAsk(ask)) { return await this.handleIdleAsk(ts, ask, text) } if (isResumableAsk(ask)) { return await this.handleResumableAsk(ts, ask, text) } if (isInteractiveAsk(ask)) { return await this.handleInteractiveAsk(ts, ask, text) } // Unknown ask type - log and handle generically debugLog("[AskDispatcher] Unknown ask type", { ask, ts }) return await this.handleUnknownAsk(ts, ask, text) } catch (error) { // Re-allow handling on error this.handledAsks.delete(ts) return { handled: false, error: error instanceof Error ? error : new Error(String(error)), } } } /** * Check if an ask has been handled. */ isHandled(ts: number): boolean { return this.handledAsks.has(ts) } /** * Clear handled asks (call when starting new task). */ clear(): void { this.handledAsks.clear() } // =========================================================================== // Category Handlers // =========================================================================== /** * Handle non-blocking asks (command_output). * These don't actually block the agent - just need acknowledgment. */ private async handleNonBlockingAsk(_ts: number, _ask: ClineAsk, _text: string): Promise { // command_output - output is handled by OutputManager // Just send approval to continue this.sendApprovalResponse(true) return { handled: true, response: "yesButtonClicked" } } /** * Handle idle asks (completion_result, api_req_failed, etc.). * These indicate the task has stopped. */ private async handleIdleAsk(ts: number, ask: ClineAsk, text: string): Promise { switch (ask) { case "completion_result": // Task complete - nothing to do here, TaskCompleted event handles it return { handled: true } case "api_req_failed": return await this.handleApiFailedRetry(ts, text) case "mistake_limit_reached": return await this.handleMistakeLimitReached(ts, text) case "resume_completed_task": return await this.handleResumeTask(ts, ask, text) case "auto_approval_max_req_reached": return await this.handleAutoApprovalMaxReached(ts, text) default: return { handled: false } } } /** * Handle resumable asks (resume_task). */ private async handleResumableAsk(ts: number, ask: ClineAsk, text: string): Promise { return await this.handleResumeTask(ts, ask, text) } /** * Handle interactive asks (followup, command, tool, use_mcp_server). * These require user approval or input. */ private async handleInteractiveAsk(ts: number, ask: ClineAsk, text: string): Promise { switch (ask) { case "followup": return await this.handleFollowupQuestion(ts, text) case "command": return await this.handleCommandApproval(ts, text) case "tool": return await this.handleToolApproval(ts, text) case "use_mcp_server": return await this.handleMcpApproval(ts, text) default: return { handled: false } } } /** * Handle unknown ask types. */ private async handleUnknownAsk(ts: number, ask: ClineAsk, text: string): Promise { if (this.nonInteractive) { if (text) { this.outputManager.output(`\n[${ask}]`, text) } return { handled: true } } return await this.handleGenericApproval(ts, ask, text) } // =========================================================================== // Specific Ask Handlers // =========================================================================== /** * Handle followup questions - prompt for text input with suggestions. */ private async handleFollowupQuestion(ts: number, text: string): Promise { let question = text let suggestions: Array<{ answer: string; mode?: string | null }> = [] try { const data = JSON.parse(text) question = data.question || text suggestions = Array.isArray(data.suggest) ? data.suggest : [] } catch { // Use raw text if not JSON } this.outputManager.output("\n[question]", question) if (suggestions.length > 0) { this.outputManager.output("\nSuggested answers:") suggestions.forEach((suggestion, index) => { const suggestionText = suggestion.answer || String(suggestion) const modeHint = suggestion.mode ? ` (mode: ${suggestion.mode})` : "" this.outputManager.output(` ${index + 1}. ${suggestionText}${modeHint}`) }) this.outputManager.output("") } const firstSuggestion = suggestions.length > 0 ? suggestions[0] : null const defaultAnswer = firstSuggestion?.answer ?? "" if (this.nonInteractive) { // Use timeout prompt in non-interactive mode const timeoutMs = FOLLOWUP_TIMEOUT_SECONDS * 1000 const result = await this.promptManager.promptWithTimeout( suggestions.length > 0 ? `Enter number (1-${suggestions.length}) or type your answer (auto-select in ${Math.round(timeoutMs / 1000)}s): ` : `Your answer (auto-select in ${Math.round(timeoutMs / 1000)}s): `, timeoutMs, defaultAnswer, ) let responseText = result.value.trim() responseText = this.resolveNumberedSuggestion(responseText, suggestions) if (result.timedOut || result.cancelled) { this.outputManager.output(`[Using default: ${defaultAnswer || "(empty)"}]`) } this.sendFollowupResponse(responseText) return { handled: true, response: "messageResponse" } } // Interactive mode try { const answer = await this.promptManager.promptForInput( suggestions.length > 0 ? `Enter number (1-${suggestions.length}) or type your answer: ` : "Your answer: ", ) let responseText = answer.trim() responseText = this.resolveNumberedSuggestion(responseText, suggestions) this.sendFollowupResponse(responseText) return { handled: true, response: "messageResponse" } } catch { this.outputManager.output(`[Using default: ${defaultAnswer || "(empty)"}]`) this.sendFollowupResponse(defaultAnswer) return { handled: true, response: "messageResponse" } } } /** * Handle command execution approval. */ private async handleCommandApproval(ts: number, text: string): Promise { this.outputManager.output("\n[command request]") this.outputManager.output(` Command: ${text || "(no command specified)"}`) this.outputManager.markDisplayed(ts, text || "", false) if (this.nonInteractive) { // Auto-approved by extension settings return { handled: true } } try { const approved = await this.promptManager.promptForYesNo("Execute this command? (y/n): ") this.sendApprovalResponse(approved) return { handled: true, response: approved ? "yesButtonClicked" : "noButtonClicked" } } catch { this.outputManager.output("[Defaulting to: no]") this.sendApprovalResponse(false) return { handled: true, response: "noButtonClicked" } } } /** * Handle tool execution approval. */ private async handleToolApproval(ts: number, text: string): Promise { let toolName = "unknown" let toolInfo: Record = {} try { toolInfo = JSON.parse(text) as Record toolName = (toolInfo.tool as string) || "unknown" } catch { // Use raw text if not JSON } const isProtected = toolInfo.isProtected === true if (isProtected) { this.outputManager.output(`\n[Tool Request] ${toolName} [PROTECTED CONFIGURATION FILE]`) this.outputManager.output(`⚠️ WARNING: This tool wants to modify a protected configuration file.`) this.outputManager.output( ` Protected files include .rooignore, .roo/*, and other sensitive config files.`, ) } else { this.outputManager.output(`\n[Tool Request] ${toolName}`) } // Display tool details for (const [key, value] of Object.entries(toolInfo)) { if (key === "tool" || key === "isProtected") continue let displayValue: string if (typeof value === "string") { displayValue = value.length > 200 ? value.substring(0, 200) + "..." : value } else if (typeof value === "object" && value !== null) { const json = JSON.stringify(value) displayValue = json.length > 200 ? json.substring(0, 200) + "..." : json } else { displayValue = String(value) } this.outputManager.output(` ${key}: ${displayValue}`) } this.outputManager.markDisplayed(ts, text || "", false) if (this.nonInteractive) { // Auto-approved by extension settings (unless protected) return { handled: true } } try { const approved = await this.promptManager.promptForYesNo("Approve this action? (y/n): ") this.sendApprovalResponse(approved) return { handled: true, response: approved ? "yesButtonClicked" : "noButtonClicked" } } catch { this.outputManager.output("[Defaulting to: no]") this.sendApprovalResponse(false) return { handled: true, response: "noButtonClicked" } } } /** * Handle MCP server access approval. */ private async handleMcpApproval(ts: number, text: string): Promise { let serverName = "unknown" let toolName = "" let resourceUri = "" try { const mcpInfo = JSON.parse(text) serverName = mcpInfo.server_name || "unknown" if (mcpInfo.type === "use_mcp_tool") { toolName = mcpInfo.tool_name || "" } else if (mcpInfo.type === "access_mcp_resource") { resourceUri = mcpInfo.uri || "" } } catch { // Use raw text if not JSON } this.outputManager.output("\n[mcp request]") this.outputManager.output(` Server: ${serverName}`) if (toolName) { this.outputManager.output(` Tool: ${toolName}`) } if (resourceUri) { this.outputManager.output(` Resource: ${resourceUri}`) } this.outputManager.markDisplayed(ts, text || "", false) if (this.nonInteractive) { // Auto-approved by extension settings return { handled: true } } try { const approved = await this.promptManager.promptForYesNo("Allow MCP access? (y/n): ") this.sendApprovalResponse(approved) return { handled: true, response: approved ? "yesButtonClicked" : "noButtonClicked" } } catch { this.outputManager.output("[Defaulting to: no]") this.sendApprovalResponse(false) return { handled: true, response: "noButtonClicked" } } } /** * Handle API request failed - retry prompt. */ private async handleApiFailedRetry(ts: number, text: string): Promise { this.outputManager.output("\n[api request failed]") this.outputManager.output(` Error: ${text || "Unknown error"}`) this.outputManager.markDisplayed(ts, text || "", false) if (this.exitOnError) { console.error(`[CLI] API request failed: ${text || "Unknown error"}`) process.exit(1) } if (this.nonInteractive) { this.outputManager.output("\n[retrying api request]") // Auto-retry in non-interactive mode return { handled: true } } try { const retry = await this.promptManager.promptForYesNo("Retry the request? (y/n): ") this.sendApprovalResponse(retry) return { handled: true, response: retry ? "yesButtonClicked" : "noButtonClicked" } } catch { this.outputManager.output("[Defaulting to: no]") this.sendApprovalResponse(false) return { handled: true, response: "noButtonClicked" } } } /** * Handle mistake limit reached. */ private async handleMistakeLimitReached(ts: number, text: string): Promise { this.outputManager.output("\n[mistake limit reached]") if (text) { this.outputManager.output(` Details: ${text}`) } this.outputManager.markDisplayed(ts, text || "", false) if (this.nonInteractive) { // Auto-proceed in non-interactive mode this.sendApprovalResponse(true) return { handled: true, response: "yesButtonClicked" } } try { const proceed = await this.promptManager.promptForYesNo("Continue anyway? (y/n): ") this.sendApprovalResponse(proceed) return { handled: true, response: proceed ? "yesButtonClicked" : "noButtonClicked" } } catch { this.outputManager.output("[Defaulting to: no]") this.sendApprovalResponse(false) return { handled: true, response: "noButtonClicked" } } } /** * Handle auto-approval max reached. */ private async handleAutoApprovalMaxReached(ts: number, text: string): Promise { this.outputManager.output("\n[auto-approval limit reached]") if (text) { this.outputManager.output(` Details: ${text}`) } this.outputManager.markDisplayed(ts, text || "", false) if (this.nonInteractive) { // Auto-proceed in non-interactive mode this.sendApprovalResponse(true) return { handled: true, response: "yesButtonClicked" } } try { const proceed = await this.promptManager.promptForYesNo("Continue with manual approval? (y/n): ") this.sendApprovalResponse(proceed) return { handled: true, response: proceed ? "yesButtonClicked" : "noButtonClicked" } } catch { this.outputManager.output("[Defaulting to: no]") this.sendApprovalResponse(false) return { handled: true, response: "noButtonClicked" } } } /** * Handle task resume prompt. */ private async handleResumeTask(ts: number, ask: ClineAsk, text: string): Promise { const isCompleted = ask === "resume_completed_task" this.outputManager.output(`\n[Resume ${isCompleted ? "Completed " : ""}Task]`) if (text) { this.outputManager.output(` ${text}`) } this.outputManager.markDisplayed(ts, text || "", false) if (this.nonInteractive) { this.outputManager.output("\n[continuing task]") // Auto-resume in non-interactive mode this.sendApprovalResponse(true) return { handled: true, response: "yesButtonClicked" } } try { const resume = await this.promptManager.promptForYesNo("Continue with this task? (y/n): ") this.sendApprovalResponse(resume) return { handled: true, response: resume ? "yesButtonClicked" : "noButtonClicked" } } catch { this.outputManager.output("[Defaulting to: no]") this.sendApprovalResponse(false) return { handled: true, response: "noButtonClicked" } } } /** * Handle generic approval prompts for unknown ask types. */ private async handleGenericApproval(ts: number, ask: ClineAsk, text: string): Promise { this.outputManager.output(`\n[${ask}]`) if (text) { this.outputManager.output(` ${text}`) } this.outputManager.markDisplayed(ts, text || "", false) try { const approved = await this.promptManager.promptForYesNo("Approve? (y/n): ") this.sendApprovalResponse(approved) return { handled: true, response: approved ? "yesButtonClicked" : "noButtonClicked" } } catch { this.outputManager.output("[Defaulting to: no]") this.sendApprovalResponse(false) return { handled: true, response: "noButtonClicked" } } } // =========================================================================== // Response Helpers // =========================================================================== /** * Send a followup response (text answer) to the extension. */ private sendFollowupResponse(text: string): void { this.sendMessage({ type: "askResponse", askResponse: "messageResponse", text }) } /** * Send an approval response (yes/no) to the extension. */ private sendApprovalResponse(approved: boolean): void { this.sendMessage({ type: "askResponse", askResponse: approved ? "yesButtonClicked" : "noButtonClicked", }) } /** * Resolve a numbered suggestion selection. */ private resolveNumberedSuggestion( input: string, suggestions: Array<{ answer: string; mode?: string | null }>, ): string { const num = parseInt(input, 10) if (!isNaN(num) && num >= 1 && num <= suggestions.length) { const selectedSuggestion = suggestions[num - 1] if (selectedSuggestion) { const selected = selectedSuggestion.answer || String(selectedSuggestion) this.outputManager.output(`Selected: ${selected}`) return selected } } return input } }