Roo-Code/apps/cli/src/agent/ask-dispatcher.ts

664 lines
20 KiB
TypeScript

/**
* 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<number>()
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<AskHandleResult>
*/
async handleAsk(message: ClineMessage): Promise<AskHandleResult> {
// 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<AskHandleResult> {
// 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<AskHandleResult> {
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<AskHandleResult> {
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<AskHandleResult> {
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<AskHandleResult> {
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<AskHandleResult> {
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<AskHandleResult> {
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<AskHandleResult> {
let toolName = "unknown"
let toolInfo: Record<string, unknown> = {}
try {
toolInfo = JSON.parse(text) as Record<string, unknown>
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<AskHandleResult> {
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<AskHandleResult> {
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<AskHandleResult> {
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<AskHandleResult> {
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<AskHandleResult> {
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<AskHandleResult> {
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
}
}