import * as path from "path" import * as vscode from "vscode" import os from "os" import crypto from "crypto" import { v7 as uuidv7 } from "uuid" import EventEmitter from "events" import { AskIgnoredError } from "./AskIgnoredError" import { RateLimitClock, createRateLimitClock } from "./RateLimitClock" import { Anthropic } from "@anthropic-ai/sdk" import OpenAI from "openai" import debounce from "lodash.debounce" import delay from "delay" import pWaitFor from "p-wait-for" import { serializeError } from "serialize-error" import { Package } from "../../shared/package" import { type TaskLike, type TaskMetadata, type TaskEvents, type ProviderSettings, type TokenUsage, type ToolUsage, type ToolName, type ContextCondense, type ContextTruncation, type ClineMessage, type ClineSay, type ClineAsk, type ExtensionState, type ToolProgressStatus, type HistoryItem, type PendingTaskAction, type CreateTaskOptions, type ModelInfo, type ClineApiReqCancelReason, type ClineApiReqInfo, RooCodeEventName, TelemetryEventName, TaskStatus, TodoItem, getApiProtocol, getModelId, isRetiredProvider, isIdleAsk, isInteractiveAsk, isResumableAsk, QueuedMessage, DEFAULT_CONSECUTIVE_MISTAKE_LIMIT, DEFAULT_TOOL_REPETITION_SOFT_LIMIT, normalizeToolRepetitionSoftLimit, DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, MAX_CHECKPOINT_TIMEOUT_SECONDS, MIN_CHECKPOINT_TIMEOUT_SECONDS, MAX_MCP_TOOLS_THRESHOLD, countEnabledMcpTools, providerIdentifiers, } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" import { CloudService } from "@roo-code/cloud" // api import { ApiHandler, ApiHandlerCreateMessageMetadata, buildApiHandler } from "../../api" import { ApiStream, GroundingSource } from "../../api/transform/stream" import { maybeRemoveImageBlocks } from "../../api/transform/image-cleaning" import { OutputTokenLimitError } from "../../api/providers/utils/output-token-limit-error" // shared import { findLast, findLastIndex } from "../../shared/array" import { combineApiRequests } from "../../shared/combineApiRequests" import { combineCommandSequences } from "../../shared/combineCommandSequences" import { t } from "../../i18n" import { getApiMetrics, hasTokenUsageChanged, hasToolUsageChanged } from "../../shared/getApiMetrics" import { ClineAskResponse } from "../../shared/WebviewMessage" import { defaultModeSlug, getModeBySlug } from "../../shared/modes" import { DiffStrategy, type AutoApprovalContext, type ToolUse } from "../../shared/tools" import { getModelMaxOutputTokens } from "../../shared/api" // services import { McpHub } from "../../services/mcp/McpHub" import { McpServerManager } from "../../services/mcp/McpServerManager" import { RepoPerTaskCheckpointService } from "../../services/checkpoints" // integrations import { DiffViewProvider } from "../../integrations/editor/DiffViewProvider" import { RooTerminalProcess } from "../../integrations/terminal/types" import { TerminalRegistry } from "../../integrations/terminal/TerminalRegistry" import { OutputInterceptor } from "../../integrations/terminal/OutputInterceptor" // utils import { calculateApiCostAnthropic, calculateApiCostOpenAI } from "../../shared/cost" import { getWorkspacePath } from "../../utils/path" import { sanitizeToolUseId } from "../../utils/tool-id" import { getTaskDirectoryPath } from "../../utils/storage" // prompts import { formatResponse } from "../prompts/responses" import { SYSTEM_PROMPT } from "../prompts/system" import { buildNativeToolsArrayWithRestrictions } from "./build-tools" // core modules import { ToolRepetitionDetector } from "../tools/ToolRepetitionDetector" import { restoreTodoListForTask } from "../tools/UpdateTodoListTool" import { FileContextTracker } from "../context-tracking/FileContextTracker" import { RooIgnoreController } from "../ignore/RooIgnoreController" import { RooProtectedController } from "../protect/RooProtectedController" import { type AssistantMessageContent, presentAssistantMessage } from "../assistant-message" import { type RequestPolicySnapshot } from "../prompts/tools/effective-tool-policy" import { NativeToolCallParser } from "../assistant-message/NativeToolCallParser" import { manageContext, willManageContext } from "../context-management" import { ClineProvider } from "../webview/ClineProvider" import { MultiSearchReplaceDiffStrategy } from "../diff/strategies/multi-search-replace" import { type ApiMessage, ensureMessageIdentifiers, readApiMessages, saveApiMessages, readTaskMessages, saveTaskMessages, taskMetadata, } from "../task-persistence" import { getEnvironmentDetails } from "../environment/getEnvironmentDetails" import { checkContextWindowExceededError } from "../context/context-management/context-error-handling" import { type CheckpointDiffOptions, type CheckpointRestoreOptions, getCheckpointService, checkpointSave, checkpointRestore, checkpointDiff, } from "../checkpoints" import { processUserContentMentions } from "../mentions/processUserContentMentions" import { getMessagesSinceLastSummary, summarizeConversation, getEffectiveApiHistory } from "../condense" import { MessageQueueService } from "../message-queue/MessageQueueService" import { type AutoDenyDetail, AutoApprovalHandler, checkAutoApproval } from "../auto-approval" import { MessageManager } from "../message-manager" import { validateAndFixToolResultIds } from "./validateToolResultIds" import { mergeConsecutiveApiMessages } from "./mergeConsecutiveApiMessages" import { prepareApiConversationMessage } from "./apiConversationHistory" import { shouldAddUserMessageToHistory } from "./messageCounting" import { type TaskExecutionContext } from "./providerHandoff" import { initialRunState, type Ask, type RunEvent, type RunState } from "./run-state/runState" import { applyRunEvent, type RunRejection } from "./run-state/runStateShadow" const MAX_EXPONENTIAL_BACKOFF_SECONDS = 600 // 10 minutes const DEFAULT_USAGE_COLLECTION_TIMEOUT_MS = 5000 // 5 seconds // Upper bound on awaiting lazily loaded model metadata. Some model-catalog // fetchers (e.g. OpenRouter's bare axios GET) have no request timeout, so a // hung endpoint must not stall streaming, condense, or context-window // handling. On expiry the caller aborts its per-call AbortSignal (detaching // the provider-side waiter) and falls back to the handler's existing // getModel().info metadata — the same degradation a rejected fetch produces. // Kept in sync with PREVIEW_MODEL_FETCH_TIMEOUT_MS in the preview path // (src/core/webview/generateSystemPrompt.ts). Deliberately duplicated, not // shared: importing from the webview layer would close a // Task -> generateSystemPrompt -> ClineProvider -> Task circular import. export const MODEL_FETCH_TIMEOUT_MS = 5_000 const QUEUED_FEEDBACK_SAVE_RETRY_DELAYS_MS = [250, 1_000, 4_000] as const type QueuedAskResolution = { response: ClineAskResponse; requiresDurableAck: boolean } /** * Denial kinds the command policy produces only while blanket auto-deny is on * (`checkAutoApproval` returns `ask` for these when the setting is off — see * the command branch of `src/core/auto-approval/index.ts`), so a denial of one * of these kinds is blanket-caused. `denylist` and `guard_unavailable` are * excluded: they deny independently of the blanket setting. */ export const BLANKET_DENY_AUTO_DENY_KINDS: ReadonlySet = new Set([ "dcg", "not_allowlisted", "dangerous_substitution", "malformed_command", ]) /** * What to do with a claimed queued message about to answer a command ask, * decided against the auto-approval policy as it stands NOW. */ type QueuedCommandPolicyAction = | { action: "consume" } | { action: "approve" } | { action: "deny"; detail?: AutoDenyDetail } | { action: "release" } /** * Whether blanket auto-deny currently engages. The three settings act as a * conjunction: blanket deny only means anything while auto-approval is on and * command auto-approval is on — without those two an unapproved command is * prompted rather than auto-approved, so there is nothing to deny. * * Single source of truth for the derivation: the ask-time snapshot, the * consume-site re-reads, and the tool-level execute-time re-check must not * drift apart, or a flip landing between two of them decides execution or * consumption on stale policy. */ export function isBlanketDenyEngaged( state?: Pick, ): boolean { return ( state?.alwaysDenyUnapprovedCommands === true && state?.autoApprovalEnabled === true && state?.alwaysAllowExecute === true ) } function queuedResponseForAsk(type: ClineAsk, text?: string): QueuedAskResolution | undefined { if (type === "command_output") { return undefined } if (type === "tool") { try { const tool = JSON.parse(text || "{}") as { tool?: string } if (tool.tool === "newTask" || tool.tool === "finishTask") { return { response: "messageResponse", requiresDurableAck: true } } } catch { // Malformed tool asks retain the existing approve-with-feedback behavior. } return { response: "yesButtonClicked", requiresDurableAck: false } } if (type === "command" || type === "use_mcp_server") { return { response: "yesButtonClicked", requiresDurableAck: false } } return { response: "messageResponse", requiresDurableAck: type === "completion_result" } } const FORCED_CONTEXT_REDUCTION_PERCENT = 75 // Keep 75% of context (remove 25%) on context window errors const MAX_CONTEXT_WINDOW_RETRIES = 3 // Maximum retries for context window errors export interface TaskOptions extends CreateTaskOptions { provider: ClineProvider apiConfiguration: ProviderSettings enableCheckpoints?: boolean checkpointTimeout?: number consecutiveMistakeLimit?: number task?: string images?: string[] historyItem?: HistoryItem experiments?: Record startTask?: boolean rootTask?: Task parentTask?: Task taskNumber?: number onCreated?: (task: Task) => void initialTodos?: TodoItem[] workspacePath?: string /** Initial status for the task's history item (e.g., "active" for child tasks) */ initialStatus?: "active" | "delegated" | "completed" | "interrupted" rateLimitClock?: RateLimitClock diffFuzzyThreshold?: number /** Explicit task-local execution context for a delegated child. */ handoffExecutionContext?: TaskExecutionContext } type AssistantMessagePersistenceResult = boolean type AssistantMessagePersistenceCancellation = { cancelled: boolean promise: Promise resolve: () => void } export class PendingActionSettlementError extends Error { constructor(message: string, options?: ErrorOptions) { super(message, options) this.name = "PendingActionSettlementError" } } export class Task extends EventEmitter implements TaskLike { readonly taskId: string readonly rootTaskId?: string readonly parentTaskId?: string childTaskId?: string pendingNewTaskToolCallId?: string readonly instanceId: string readonly metadata: TaskMetadata todoList?: TodoItem[] readonly rootTask: Task | undefined = undefined readonly parentTask: Task | undefined = undefined readonly taskNumber: number readonly workspacePath: string /** * The mode associated with this task. Persisted across sessions * to maintain user context when reopening tasks from history. * * ## Lifecycle * * ### For new tasks: * 1. Initially `undefined` during construction * 2. Asynchronously initialized from provider state via `initializeTaskMode()` * 3. Falls back to `defaultModeSlug` if provider state is unavailable * * ### For history items: * 1. Immediately set from `historyItem.mode` during construction * 2. Falls back to `defaultModeSlug` if mode is not stored in history * * ## Important * This property should NOT be accessed directly until `taskModeReady` promise resolves. * Use `getTaskMode()` for async access or `taskMode` getter for sync access after initialization. * * @private * @see {@link getTaskMode} - For safe async access * @see {@link taskMode} - For sync access after initialization * @see {@link waitForModeInitialization} - To ensure initialization is complete */ private _taskMode: string | undefined /** * Promise that resolves when the task mode has been initialized. * This ensures async mode initialization completes before the task is used. * * ## Purpose * - Prevents race conditions when accessing task mode * - Ensures provider state is properly loaded before mode-dependent operations * - Provides a synchronization point for async initialization * * ## Resolution timing * - For history items: Resolves immediately (sync initialization) * - For new tasks: Resolves after provider state is fetched (async initialization) * * @private * @see {@link waitForModeInitialization} - Public method to await this promise */ private taskModeReady: Promise /** * The API configuration name (provider profile) associated with this task. * Persisted across sessions to maintain the provider profile when reopening tasks from history. * * ## Lifecycle * * ### For new tasks: * 1. Initially `undefined` during construction * 2. Asynchronously initialized from provider state via `initializeTaskApiConfigName()` * 3. Falls back to "default" if provider state is unavailable * * ### For history items: * 1. Immediately set from `historyItem.apiConfigName` during construction * 2. Falls back to undefined if not stored in history (for backward compatibility) * * ## Important * If you need a non-`undefined` provider profile (e.g., for profile-dependent operations), * wait for `taskApiConfigReady` first (or use `getTaskApiConfigName()`). * The sync `taskApiConfigName` getter may return `undefined` for backward compatibility. * * @private * @see {@link getTaskApiConfigName} - For safe async access * @see {@link taskApiConfigName} - For sync access after initialization */ private _taskApiConfigName: string | undefined /** * Promise that resolves when the task API config name has been initialized. * This ensures async API config name initialization completes before the task is used. * * ## Purpose * - Prevents race conditions when accessing task API config name * - Ensures provider state is properly loaded before profile-dependent operations * - Provides a synchronization point for async initialization * * ## Resolution timing * - For history items: Resolves immediately (sync initialization) * - For new tasks: Resolves after provider state is fetched (async initialization) * * @private */ private taskApiConfigReady: Promise providerRef: WeakRef private readonly globalStoragePath: string abort: boolean = false currentRequestAbortController?: AbortController /** * Controller for the waiter on an in-flight `ensureModelFetched()` call (see * safeEnsureModelFetched). Aborting it detaches this task from the provider-side * metadata fetch; it is aborted when the bounded wait expires and when the task's * current request is cancelled (cancel/dispose path in cancelCurrentRequest). */ metadataFetchAbortController?: AbortController skipPrevResponseIdOnce: boolean = false // TaskStatus idleAsk?: ClineMessage resumableAsk?: ClineMessage interactiveAsk?: ClineMessage didFinishAbortingStream = false abandoned = false abortReason?: ClineApiReqCancelReason isInitialized = false runState: RunState = initialRunState readonly runRejections: RunRejection[] = [] // API apiConfiguration: ProviderSettings api: ApiHandler private rateLimitClock: RateLimitClock private autoApprovalHandler: AutoApprovalHandler toolRepetitionDetector: ToolRepetitionDetector rooIgnoreController?: RooIgnoreController rooProtectedController?: RooProtectedController fileContextTracker: FileContextTracker terminalProcess?: RooTerminalProcess // Editing diffViewProvider: DiffViewProvider diffStrategy?: DiffStrategy didEditFile: boolean = false // LLM Messages & Chat Messages apiConversationHistory: ApiMessage[] = [] clineMessages: ClineMessage[] = [] // Ask private askResponse?: ClineAskResponse private askResponseText?: string private askResponseImages?: string[] /** * Structured detail of the automatic denial that resolved the current ask, * set when `checkAutoApproval` denies via policy (denylist, blanket * auto-deny, or a DCG block in blanket mode) and consumed (cleared) by the * `ask()` result. System-generated, unlike `askResponseText` which carries * user feedback and triggers a `user_feedback` say row. */ private pendingAutoDenyDetail?: AutoDenyDetail /** * Set while the current turn has blanket-denied a command ask. The queued * messages such a denial deliberately leaves in place were typed in response * to that denial, not as approval — so a later ask in the same turn must not * consume one as `yesButtonClicked` (which would silently approve it with the * interactive prompt suppressed). Read at the queued-message consume sites; * cleared by the per-turn reset beside `didToolFailInCurrentTurn`. */ private blanketDeniedCommandThisTurn = false public lastMessageTs?: number private autoApprovalTimeoutRef?: NodeJS.Timeout // Tool Use consecutiveMistakeCount: number = 0 consecutiveMistakeLimit: number toolRepetitionSoftLimit: number consecutiveMistakeCountForApplyDiff: Map = new Map() consecutiveMistakeCountForEditFile: Map = new Map() consecutiveNoToolUseCount: number = 0 consecutiveNoAssistantMessagesCount: number = 0 toolUsage: ToolUsage = {} // Conversation message counts, summarized once per Task Completed // installment instead of emitting a separate telemetry event per turn. messageCounts: { user: number; assistant: number } = { user: 0, assistant: 0 } // Idle/shutdown telemetry flush: reports toolUsage/messageCounts for tasks that // go quiet or get torn down without the model ever calling attempt_completion // (or without the user accepting it), so long-running/abandoned tasks aren't // invisible to telemetry. Each flush reports only what changed since the previous // one, tracked via telemetryToolUsageBaseline/telemetryMessageCountsBaseline -- // task.toolUsage/messageCounts themselves are never mutated by this, since they're // also read as running totals by the public TaskCompleted API event and the UI. // Checked on an interval rather than hooked into every say()/ask() call site. private static readonly IDLE_TELEMETRY_CHECK_INTERVAL_MS = 5 * 60 * 1000 private static readonly IDLE_TELEMETRY_THRESHOLD_MS = 30 * 60 * 1000 private idleTelemetryCheckInterval?: NodeJS.Timeout private lastTelemetryFlushAt: number = Date.now() private telemetryToolUsageBaseline: ToolUsage = {} private telemetryMessageCountsBaseline: { user: number; assistant: number } = { user: 0, assistant: 0 } private abortPromise?: Promise private disposalPromise?: Promise private diffReversionPromise: Promise = Promise.resolve() // Checkpoints enableCheckpoints: boolean checkpointTimeout: number checkpointService?: RepoPerTaskCheckpointService checkpointServiceInitializing = false // Message Queue Service public readonly messageQueueService: MessageQueueService private messageQueueStateChangedHandler: (() => void) | undefined // Streaming isWaitingForFirstChunk = false isStreaming = false currentStreamingContentIndex = 0 currentStreamingDidCheckpoint = false assistantMessageContent: AssistantMessageContent[] = [] presentAssistantMessageLocked = false presentAssistantMessageHasPendingUpdates = false userMessageContent: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam | Anthropic.ToolResultBlockParam)[] = [] userMessageContentReady = false /** * Flag indicating whether the assistant message for the current streaming session * has been saved to API conversation history. * * This is critical for parallel tool calling: tools should NOT execute until * the assistant message is saved. Otherwise, if a tool like `new_task` triggers * `flushPendingToolResultsToHistory()`, the user message with tool_results would * appear BEFORE the assistant message with tool_uses, causing API errors. * * Reset to `false` at the start of each API request. * Set to `true` only after the assistant message is durably saved. */ assistantMessageSavedToHistory = false private assistantMessagePersistencePromise!: Promise private resolveAssistantMessagePersistence!: (result: AssistantMessagePersistenceResult) => void private assistantMessagePersistenceCancellation?: AssistantMessagePersistenceCancellation private completionPersistenceReadyPromise?: Promise /** * The tool-policy inputs captured for the API request currently being built * (see `attemptApiRequest`). The streaming presenter reads this field at call * time and binds it to `presentAssistantMessage`'s required parameter, so * every tool call of a request validates against the same frozen policy its * prompt was generated from. Re-assigned per API attempt — including retries, * whose generator-body re-execution re-captures before any new presentation * can fire. */ private requestPolicySnapshot?: RequestPolicySnapshot /** * Fire-and-forget wrapper around `presentAssistantMessage` that swallows the * expected cancellation rejection (the presenter throws when `this.abort` is set) * and logs any other failure. Keeping it non-blocking preserves the streaming * presenter's self-locking semantics while preventing unhandled promise rejections * from crashing the extension host. */ private presentAssistantMessageSafe(): void { void presentAssistantMessage(this, this.requestPolicySnapshot!).catch((error) => { // Discriminate on the error message rather than `this.abort` state, // which can flip between the throw and the catch microtask running: // a real failure followed by an abort flip would otherwise be // silently swallowed, and a stale abort error logged as a failure. // The abort throw site in presentAssistantMessage emits a message // ending in "aborted" (matching the other abort-throw contracts in // this file), so we suppress exactly that. if (error instanceof Error && error.message.endsWith("aborted")) { return } console.error(`[Task#presentAssistantMessage] task ${this.taskId}.${this.instanceId} failed:`, error) }) } /** * Push a tool_result block to userMessageContent, preventing duplicates. * Duplicate tool_use_ids cause API errors. * * @param toolResult - The tool_result block to add * @returns true if added, false if duplicate was skipped */ public pushToolResultToUserContent(toolResult: Anthropic.ToolResultBlockParam): boolean { const existingResult = this.userMessageContent.find( (block): block is Anthropic.ToolResultBlockParam => block.type === "tool_result" && block.tool_use_id === toolResult.tool_use_id, ) if (existingResult) { console.warn( `[Task#pushToolResultToUserContent] Skipping duplicate tool_result for tool_use_id: ${toolResult.tool_use_id}`, ) return false } this.userMessageContent.push(toolResult) return true } /** * Derives terminal tool-turn readiness from the protocol state instead of * relying exclusively on the presenter's one-shot boolean latch. * * A re-entrant presenter can lose the latch update after every tool has * already completed. At that point the assistant turn is safe to continue * when the stream is closed, presentation is idle, every content block is * final, and every tool call has its matching result. */ private hasCompleteToolResultsForCurrentTurn(): boolean { // Do not require currentStreamingContentIndex to reach the end here. // A non-abort presenter rejection releases the lock and is logged, but // nothing schedules another presentation pass. Gating on the index would // strand this wait after exactly that failure. if (!this.didCompleteReadingStream || this.presentAssistantMessageLocked) { return false } const toolResultIds = new Set( this.userMessageContent .filter((block): block is Anthropic.ToolResultBlockParam => block.type === "tool_result") .map((block) => block.tool_use_id), ) let toolUseCount = 0 for (const block of this.assistantMessageContent) { if (block.partial) { return false } if (block.type !== "tool_use" && block.type !== "mcp_tool_use") { continue } toolUseCount++ if (!block.id || !toolResultIds.has(sanitizeToolUseId(block.id))) { return false } } return toolUseCount > 0 } didRejectTool = false didAlreadyUseTool = false didToolFailInCurrentTurn = false didCompleteReadingStream = false private _started = false private _runPromise: Promise | undefined private readonly _isHistoryTask: boolean // No streaming parser is required. assistantMessageParser?: undefined // Native tool call streaming state (track which index each tool is at) private streamingToolCallIndices: Map = new Map() // Cached model info for current streaming session (set at start of each API request) // This prevents excessive getModel() calls during tool execution cachedStreamingModel?: { id: string; info: ModelInfo } // Token Usage Cache private tokenUsageSnapshot?: TokenUsage private tokenUsageSnapshotAt?: number // Tool Usage Cache private toolUsageSnapshot?: ToolUsage // Token Usage Throttling - Debounced emit function private readonly TOKEN_USAGE_EMIT_INTERVAL_MS = 2000 // 2 seconds private debouncedEmitTokenUsage: ReturnType // Historical cloud sync tracking retained only to avoid task resume churn. private cloudSyncedMessageTimestamps: Set = new Set() // Initial status for the task's history item (set at creation time to avoid race conditions) private readonly initialStatus?: "active" | "delegated" | "completed" | "interrupted" private pendingAction?: PendingTaskAction // MessageManager for high-level message operations (lazy initialized) private _messageManager?: MessageManager constructor({ provider, apiConfiguration, enableCheckpoints = true, checkpointTimeout = DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, consecutiveMistakeLimit = DEFAULT_CONSECUTIVE_MISTAKE_LIMIT, toolRepetitionSoftLimit = DEFAULT_TOOL_REPETITION_SOFT_LIMIT, taskId, task, images, historyItem, experiments: _experimentsConfig, startTask = true, rootTask, parentTask, taskNumber = -1, onCreated, initialTodos, workspacePath, initialStatus, rateLimitClock, diffFuzzyThreshold, handoffExecutionContext, }: TaskOptions) { super() this.resetAssistantMessagePersistence() if (startTask && !task && !images && !historyItem) { throw new Error("Either historyItem or task/images must be provided") } if ( !checkpointTimeout || checkpointTimeout > MAX_CHECKPOINT_TIMEOUT_SECONDS || checkpointTimeout < MIN_CHECKPOINT_TIMEOUT_SECONDS ) { throw new Error( "checkpointTimeout must be between " + MIN_CHECKPOINT_TIMEOUT_SECONDS + " and " + MAX_CHECKPOINT_TIMEOUT_SECONDS + " seconds", ) } this.taskId = historyItem ? historyItem.id : (taskId ?? uuidv7()) this.rootTaskId = historyItem ? historyItem.rootTaskId : rootTask?.taskId this.parentTaskId = historyItem ? historyItem.parentTaskId : parentTask?.taskId this.childTaskId = undefined this.metadata = { task: historyItem ? historyItem.task : task, images: historyItem ? [] : images, } this._isHistoryTask = !!historyItem && !task && !images // Normal use-case is usually retry similar history task with new workspace. this.workspacePath = parentTask ? parentTask.workspacePath : (workspacePath ?? getWorkspacePath(path.join(os.homedir(), "Desktop"))) this.instanceId = crypto.randomUUID().slice(0, 8) this.taskNumber = -1 this.rooIgnoreController = new RooIgnoreController(this.cwd) this.rooProtectedController = new RooProtectedController(this.cwd) this.fileContextTracker = new FileContextTracker(provider, this.taskId) this.rooIgnoreController.initialize().catch((error) => { console.error("Failed to initialize RooIgnoreController:", error) }) this.apiConfiguration = handoffExecutionContext?.apiConfiguration ?? apiConfiguration this.api = buildApiHandler(this.apiConfiguration) this.rateLimitClock = rateLimitClock ?? createRateLimitClock() this.autoApprovalHandler = new AutoApprovalHandler() this.consecutiveMistakeLimit = consecutiveMistakeLimit ?? DEFAULT_CONSECUTIVE_MISTAKE_LIMIT this.toolRepetitionSoftLimit = normalizeToolRepetitionSoftLimit( toolRepetitionSoftLimit ?? DEFAULT_TOOL_REPETITION_SOFT_LIMIT, this.consecutiveMistakeLimit, ) this.providerRef = new WeakRef(provider) this.globalStoragePath = provider.context.globalStorageUri.fsPath this.diffViewProvider = new DiffViewProvider(this.cwd, this) this.enableCheckpoints = enableCheckpoints this.checkpointTimeout = checkpointTimeout this.parentTask = parentTask this.taskNumber = taskNumber this.initialStatus = initialStatus ?? historyItem?.status this.pendingAction = historyItem?.pendingAction // Store the task's mode and API config name when it's created. // For history items, use the stored values; for new tasks, we'll set them // after getting state. if (handoffExecutionContext) { this._taskMode = handoffExecutionContext.mode this._taskApiConfigName = handoffExecutionContext.apiConfigName this.taskModeReady = Promise.resolve() this.taskApiConfigReady = Promise.resolve() TelemetryService.instance.captureTaskCreated(this.taskId) } else if (historyItem) { this._taskMode = historyItem.mode || defaultModeSlug this._taskApiConfigName = historyItem.apiConfigName this.taskModeReady = Promise.resolve() this.taskApiConfigReady = Promise.resolve() TelemetryService.instance.captureTaskRestarted(this.taskId) } else { // For new tasks, don't set the mode/apiConfigName yet - wait for async initialization. this._taskMode = undefined this._taskApiConfigName = undefined this.taskModeReady = this.initializeTaskMode(provider) this.taskApiConfigReady = this.initializeTaskApiConfigName(provider) TelemetryService.instance.captureTaskCreated(this.taskId) } this.assistantMessageParser = undefined this.messageQueueService = new MessageQueueService() this.messageQueueStateChangedHandler = () => { this.emit(RooCodeEventName.TaskUserMessage, this.taskId) this.emit(RooCodeEventName.QueuedMessagesUpdated, this.taskId, this.messageQueueService.messages) void this.providerRef .deref() ?.postStateToWebviewThrottled() .catch((error) => { console.error("[Task#messageQueueStateChangedHandler] postStateToWebviewThrottled failed:", error) }) } this.messageQueueService.on("stateChanged", this.messageQueueStateChangedHandler) // Set up diff strategy this.diffStrategy = new MultiSearchReplaceDiffStrategy(diffFuzzyThreshold) this.toolRepetitionDetector = new ToolRepetitionDetector( this.toolRepetitionSoftLimit, this.consecutiveMistakeLimit, ) // Initialize todo list if provided if (initialTodos && initialTodos.length > 0) { this.todoList = initialTodos } // Initialize debounced token usage emit function // Uses debounce with maxWait to achieve throttle-like behavior: // - leading: true - Emit immediately on first call // - trailing: true - Emit final state when updates stop // - maxWait - Ensures at most one emit per interval during rapid updates (throttle behavior) this.debouncedEmitTokenUsage = debounce( (tokenUsage: TokenUsage, toolUsage: ToolUsage) => { const tokenChanged = hasTokenUsageChanged(tokenUsage, this.tokenUsageSnapshot) const toolChanged = hasToolUsageChanged(toolUsage, this.toolUsageSnapshot) if (tokenChanged || toolChanged) { this.emit(RooCodeEventName.TaskTokenUsageUpdated, this.taskId, tokenUsage, toolUsage) this.tokenUsageSnapshot = tokenUsage this.tokenUsageSnapshotAt = this.clineMessages.at(-1)?.ts // Deep copy tool usage for snapshot this.toolUsageSnapshot = JSON.parse(JSON.stringify(toolUsage)) } }, this.TOKEN_USAGE_EMIT_INTERVAL_MS, { leading: true, trailing: true, maxWait: this.TOKEN_USAGE_EMIT_INTERVAL_MS }, ) onCreated?.(this) if (startTask) { this._started = true this.startIdleTelemetryCheck() if (task || images) { void this.startTask(task, images).catch((error) => { console.error("[Task#constructor] startTask failed:", error) }) } else if (historyItem) { void this.resumeTaskFromHistory().catch((error) => { console.error("[Task#constructor] resumeTaskFromHistory failed:", error) }) } else { throw new Error("Either historyItem or task/images must be provided") } } } /** * Initialize the task mode from the provider state. * This method handles async initialization with proper error handling. * * ## Flow * 1. Attempts to fetch the current mode from provider state * 2. Sets `_taskMode` to the fetched mode or `defaultModeSlug` if unavailable * 3. Handles errors gracefully by falling back to default mode * 4. Logs any initialization errors for debugging * * ## Error handling * - Network failures when fetching provider state * - Provider not yet initialized * - Invalid state structure * * All errors result in fallback to `defaultModeSlug` to ensure task can proceed. * * @private * @param provider - The ClineProvider instance to fetch state from * @returns Promise that resolves when initialization is complete */ private async initializeTaskMode(provider: ClineProvider): Promise { try { const state = await provider.getState() this._taskMode = state?.mode || defaultModeSlug } catch (error) { // If there's an error getting state, use the default mode this._taskMode = defaultModeSlug // Use the provider's log method for better error visibility const errorMessage = `Failed to initialize task mode: ${error instanceof Error ? error.message : String(error)}` provider.log(errorMessage) } } /** * Initialize the task API config name from the provider state. * This method handles async initialization with proper error handling. * * ## Flow * 1. Attempts to fetch the current API config name from provider state * 2. Sets `_taskApiConfigName` to the fetched name or "default" if unavailable * 3. Handles errors gracefully by falling back to "default" * 4. Logs any initialization errors for debugging * * ## Error handling * - Network failures when fetching provider state * - Provider not yet initialized * - Invalid state structure * * All errors result in fallback to "default" to ensure task can proceed. * * @private * @param provider - The ClineProvider instance to fetch state from * @returns Promise that resolves when initialization is complete */ private async initializeTaskApiConfigName(provider: ClineProvider): Promise { try { const state = await provider.getState() // Avoid clobbering a newer value that may have been set while awaiting provider state // (e.g., user switches provider profile immediately after task creation). if (this._taskApiConfigName === undefined) { this._taskApiConfigName = state?.currentApiConfigName ?? "default" } } catch (error) { // If there's an error getting state, use the default profile (unless a newer value was set). if (this._taskApiConfigName === undefined) { this._taskApiConfigName = "default" } // Use the provider's log method for better error visibility const errorMessage = `Failed to initialize task API config name: ${error instanceof Error ? error.message : String(error)}` provider.log(errorMessage) } } /** * Wait for the task mode to be initialized before proceeding. * This method ensures that any operations depending on the task mode * will have access to the correct mode value. * * ## When to use * - Before accessing mode-specific configurations * - When switching between tasks with different modes * - Before operations that depend on mode-based permissions * * ## Example usage * ```typescript * // Wait for mode initialization before mode-dependent operations * await task.waitForModeInitialization(); * const mode = task.taskMode; // Now safe to access synchronously * * // Or use with getTaskMode() for a one-liner * const mode = await task.getTaskMode(); // Internally waits for initialization * ``` * * @returns Promise that resolves when the task mode is initialized * @public */ public async waitForModeInitialization(): Promise { return this.taskModeReady } /** * Get the task mode asynchronously, ensuring it's properly initialized. * This is the recommended way to access the task mode as it guarantees * the mode is available before returning. * * ## Async behavior * - Internally waits for `taskModeReady` promise to resolve * - Returns the initialized mode or `defaultModeSlug` as fallback * - Safe to call multiple times - subsequent calls return immediately if already initialized * * ## Example usage * ```typescript * // Safe async access * const mode = await task.getTaskMode(); * console.log(`Task is running in ${mode} mode`); * * // Use in conditional logic * if (await task.getTaskMode() === 'architect') { * // Perform architect-specific operations * } * ``` * * @returns Promise resolving to the task mode string * @public */ public async getTaskMode(): Promise { await this.taskModeReady return this._taskMode || defaultModeSlug } /** * Get the task mode synchronously. This should only be used when you're certain * that the mode has already been initialized (e.g., after waitForModeInitialization). * * ## When to use * - In synchronous contexts where async/await is not available * - After explicitly waiting for initialization via `waitForModeInitialization()` * - In event handlers or callbacks where mode is guaranteed to be initialized * * ## Example usage * ```typescript * // After ensuring initialization * await task.waitForModeInitialization(); * const mode = task.taskMode; // Safe synchronous access * * // In an event handler after task is started * task.on('taskStarted', () => { * console.log(`Task started in ${task.taskMode} mode`); // Safe here * }); * ``` * * @throws {Error} If the mode hasn't been initialized yet * @returns The task mode string * @public */ public get taskMode(): string { if (this._taskMode === undefined) { throw new Error("Task mode accessed before initialization. Use getTaskMode() or wait for taskModeReady.") } return this._taskMode } /** * Wait for the task API config name to be initialized before proceeding. * This method ensures that any operations depending on the task's provider profile * will have access to the correct value. * * ## When to use * - Before accessing provider profile-specific configurations * - When switching between tasks with different provider profiles * - Before operations that depend on the provider profile * * @returns Promise that resolves when the task API config name is initialized * @public */ public async waitForApiConfigInitialization(): Promise { return this.taskApiConfigReady } /** * Get the task API config name asynchronously, ensuring it's properly initialized. * This is the recommended way to access the task's provider profile as it guarantees * the value is available before returning. * * ## Async behavior * - Internally waits for `taskApiConfigReady` promise to resolve * - Returns the initialized API config name or undefined as fallback * - Safe to call multiple times - subsequent calls return immediately if already initialized * * @returns Promise resolving to the task API config name string or undefined * @public */ public async getTaskApiConfigName(): Promise { await this.taskApiConfigReady return this._taskApiConfigName } /** * Get the task API config name synchronously. This should only be used when you're certain * that the value has already been initialized (e.g., after waitForApiConfigInitialization). * * ## When to use * - In synchronous contexts where async/await is not available * - After explicitly waiting for initialization via `waitForApiConfigInitialization()` * - In event handlers or callbacks where API config name is guaranteed to be initialized * * Note: Unlike taskMode, this getter does not throw if uninitialized since the API config * name can legitimately be undefined (backward compatibility with tasks created before * this feature was added). * * @returns The task API config name string or undefined * @public */ public get taskApiConfigName(): string | undefined { return this._taskApiConfigName } /** * Update the task's API config name. This is called when the user switches * provider profiles while a task is active, allowing the task to remember * its new provider profile. * * @param apiConfigName - The new API config name to set * @internal */ public setTaskApiConfigName(apiConfigName: string | undefined): void { this._taskApiConfigName = apiConfigName } public setPendingTaskAction(pendingAction: PendingTaskAction): void { this.pendingAction = pendingAction } public async persistQueuedFeedbackAndAcknowledge( messageId: string, text?: string, images?: string[], ): Promise { await this.say("user_feedback", text ?? "", images) for (let attempt = 0; attempt <= QUEUED_FEEDBACK_SAVE_RETRY_DELAYS_MS.length; attempt++) { if (this.abort) { this.messageQueueService.releaseMessage(messageId) return false } if (await this.saveClineMessages()) { return this.messageQueueService.removeMessage(messageId) } if (attempt < QUEUED_FEEDBACK_SAVE_RETRY_DELAYS_MS.length) { await delay(QUEUED_FEEDBACK_SAVE_RETRY_DELAYS_MS[attempt]) } } console.error( `[Task#persistQueuedFeedbackAndAcknowledge] Failed to durably save queued feedback ${messageId} after ${QUEUED_FEEDBACK_SAVE_RETRY_DELAYS_MS.length + 1} attempts`, ) this.messageQueueService.releaseMessage(messageId) return false } /** * Clears the pending action metadata after its durable result is saved. * Reconciles in-memory state with the task history store to avoid clearing a newer action. */ private async clearPendingActionAfterDurableResult(actionId: string): Promise { if (this.pendingAction?.actionId !== actionId) { return } const provider = this.providerRef.deref() const cleared = await provider?.clearPendingTaskAction(this.taskId, actionId) if (cleared) { if (this.pendingAction?.actionId === actionId) { this.pendingAction = undefined } return } const storedPendingAction = provider?.taskHistoryStore.get(this.taskId)?.pendingAction if (this.pendingAction?.actionId !== actionId) { return } if (!storedPendingAction) { this.pendingAction = undefined } else if (storedPendingAction.actionId !== actionId) { this.pendingAction = storedPendingAction } } /** * An interrupted task cannot legally delegate, so a staged create-subtask * action is a durable rejection marker rather than replayable work. The * constructor-injected history item can be stale, so the persisted record * is refreshed first and the refreshed action is the one settled. A failed * refresh, lookup, or settlement stops replay instead of risking another * doomed child. A refreshed action kept for replay must also appear in the * loaded conversation, because a replayed acknowledgment without a matching * tool use could answer the wrong tool call. */ private async settleInterruptedCreateSubtaskBeforeReplay(): Promise { if (this.initialStatus !== "interrupted") { return } const provider = this.providerRef.deref() if (!provider) { throw new PendingActionSettlementError( `[Task#settleInterruptedCreateSubtaskBeforeReplay] Provider unavailable for task ${this.taskId}`, ) } try { await provider.taskHistoryStore.reconcile({ forceRefresh: true }) } catch (error) { throw new PendingActionSettlementError( `[Task#settleInterruptedCreateSubtaskBeforeReplay] Failed to refresh task history for task ${this.taskId}`, { cause: error }, ) } const refreshedItem = provider.taskHistoryStore.get(this.taskId) if (!refreshedItem) { throw new PendingActionSettlementError( `[Task#settleInterruptedCreateSubtaskBeforeReplay] Task ${this.taskId} not found in refreshed task history`, ) } this.pendingAction = refreshedItem.pendingAction const action = refreshedItem.pendingAction if (action?.kind === "create_subtask") { let authoritative: HistoryItem try { authoritative = await provider.taskHistoryStore.clearPendingActionIfMatching( this.taskId, action.actionId, ) } catch (error) { throw new PendingActionSettlementError( `[Task#settleInterruptedCreateSubtaskBeforeReplay] Failed to settle rejected action for task ${this.taskId}`, { cause: error }, ) } this.pendingAction = authoritative.pendingAction } const replayAction = this.pendingAction if (replayAction && !this.loadedConversationContainsToolUse(replayAction.actionId)) { throw new PendingActionSettlementError( `[Task#settleInterruptedCreateSubtaskBeforeReplay] Refreshed action ${replayAction.actionId} for task ${this.taskId} is missing from the loaded conversation`, ) } } /** * A replayed acknowledgment is safe only when its tool call is part of the * conversation the task just loaded. A tool result for an unmatched action * id could otherwise answer a tool call the model never made here. */ private loadedConversationContainsToolUse(actionId: string): boolean { return this.apiConversationHistory.some( (message) => message.role === "assistant" && Array.isArray(message.content) && message.content.some((block) => block.type === "tool_use" && block.id === actionId), ) } private handleQueuedAskResponse(message: QueuedMessage, resolution: QueuedAskResolution): string | undefined { this.handleWebviewAskResponse(resolution.response, message.text, message.images) if (resolution.requiresDurableAck) { return message.id } this.messageQueueService.removeMessage(message.id) return undefined } /** * Re-read the auto-approval policy immediately before a queued message would * stand in for a command ask's approval, and consult the full command policy * under the fresh state if blanket deny now engages. * * The queued-answer shortcut skips `checkAutoApproval` and the ask's decision * otherwise rides on a single settings snapshot taken before the prompt was * shown. A blanket-deny flip landing between the snapshot and the consume is * invisible to that frozen gate, and the queued message would auto-approve an * unallowlisted command — fail-OPEN through a window as wide as the prompt * dwell. Re-reading here closes it: while blanket deny engages, the message * is never consumed as approval and the ask gets the structured denial policy * would have produced without a queued message. * * With `abortSignal`, the re-check settles on the abort itself instead of * merely losing a race against it: the fresh-state read ends in uncancellable * provider work, so a post-await check alone would leave this promise pending * whenever the read never lands, retaining the task and running policy work * after the abort. An aborted re-check resolves to the `release` no-op, * leaving the claim and the pending ask to the caller's release path. */ private async recheckQueuedCommandPolicy( { text, isProtected, dcgDecision, }: { text?: string isProtected?: boolean dcgDecision?: AutoApprovalContext["dcgDecision"] }, abortSignal?: AbortSignal, ): Promise { // Resolving a sentinel rather than rejecting keeps a late rejection from // the uncancellable read unhandled once the abort has won the race, and // routes the abort through the same release early-return as the checks. const signal = abortSignal const abortPromise = signal ? new Promise<"aborted">((resolve) => { if (signal.aborted) { resolve("aborted") } else { signal.addEventListener("abort", () => resolve("aborted"), { once: true }) } }) : undefined const freshState = abortPromise ? await Promise.race([this.providerRef.deref()?.getState(), abortPromise]) : await this.providerRef.deref()?.getState() // The abort either won the race (sentinel) or landed while the read was // still resolving; both settle the re-check before any policy work. if (freshState === "aborted" || signal?.aborted) { return { action: "release" } } if (!isBlanketDenyEngaged(freshState)) { // Disengaged: the queued answer is a legitimate approval, as before. return { action: "consume" } } // Engaged: the full policy decides, with the fresh state. `cwd` and the // forwarded DCG verdict match the ask-time `checkAutoApproval` call. const approval = await checkAutoApproval({ state: freshState, cwd: this.cwd, ask: "command", text, isProtected, dcgDecision, }) if (signal?.aborted) { return { action: "release" } } if (approval.decision === "deny") { return { action: "deny", detail: approval.autoDeny } } if (approval.decision === "approve") { return { action: "approve" } } return { action: "release" } } /** * Apply a queued-command policy re-check outcome to a claimed message: the * claim is released on every path except the legitimate consume, and an * engaged-policy outcome resolves the ask the same way the no-queued-message * path would (structured denial, approval, or — for a plain `ask` decision — * left pending for the user). */ private applyQueuedCommandPolicyAction( action: QueuedCommandPolicyAction, message: QueuedMessage, resolution: QueuedAskResolution, ): string | undefined { if (action.action !== "consume") { this.messageQueueService.releaseMessage(message.id) } switch (action.action) { case "consume": return this.handleQueuedAskResponse(message, resolution) case "deny": if (action.detail && BLANKET_DENY_AUTO_DENY_KINDS.has(action.detail.kind)) { this.blanketDeniedCommandThisTurn = true } this.pendingAutoDenyDetail = action.detail this.denyAsk() return undefined case "approve": this.approveAsk() return undefined case "release": return undefined } } /** * Shared claim-gate for the two queued-message consume sites. * * The latch blocks a message left in the queue by a command this turn already * blanket-denied (it answers that denial, not the current ask), and * `hasUnclaimed()` replaces the length-only `isEmpty()`, which reports a * queue containing nothing but claims as available for a new consumer. * `isMessageQueued`/`isStatusMutable` keep `isEmpty()` semantics on purpose: * flipping those would re-enable interactive prompt timers whenever a claim * is outstanding. */ private mayDrainQueuedMessageForAsk(): boolean { return !this.blanketDeniedCommandThisTurn && this.messageQueueService.hasUnclaimed() } /** * Latch the turn after a blanket-caused command denial detected outside * the ask path (the tool's execute-time policy re-check). A message left * in the queue by that denial answers the denial, not the next ask, so * without the latch a later non-command ask would consume it as an * approval. Deliberately not cleared mid-turn: the per-turn reset owns * clearing. */ public recordBlanketCommandDenial(): void { this.blanketDeniedCommandThisTurn = true } static create(options: TaskOptions): [Task, Promise] { const instance = new Task({ ...options, startTask: false }) const { images, task, historyItem } = options let promise instance.startIdleTelemetryCheck() if (images || task) { promise = instance.startTask(task, images) } else if (historyItem) { promise = instance.resumeTaskFromHistory() } else { throw new Error("Either historyItem or task/images must be provided") } return [instance, promise] } // API Messages private async getSavedApiConversationHistory(): Promise { const messages = await readApiMessages({ taskId: this.taskId, globalStoragePath: this.globalStoragePath }) return ensureMessageIdentifiers(messages) } /** * Appends an API turn and records whether an assistant turn reached persistent storage. * If the message resolves a pending action, retries the save on initial failure before clearing the action. */ private async addToApiConversationHistory(message: Anthropic.MessageParam, reasoning?: string): Promise { const resolvesPendingAction = this.pendingAction && message.role === "user" && Array.isArray(message.content) && message.content.some( (block) => block.type === "tool_result" && block.tool_use_id === this.pendingAction?.actionId, ) this.apiConversationHistory.push( prepareApiConversationMessage({ message, reasoning, api: this.api, apiConfiguration: this.apiConfiguration, apiConversationHistory: this.apiConversationHistory, }), ) let saved = await this.saveApiConversationHistory() if (!saved && resolvesPendingAction) { saved = await this.retrySaveApiConversationHistory() } if (saved && resolvesPendingAction && this.pendingAction) { try { await this.clearPendingActionAfterDurableResult(this.pendingAction.actionId) } catch (error) { console.error( `[Task#addToApiConversationHistory] Failed to clear pending action for ${this.taskId}:`, error, ) } } if (message.role === "assistant") { this.assistantMessageSavedToHistory = saved this.resolveAssistantMessagePersistence(saved) } } /** Cancels the current persistence generation before creating the next assistant-turn boundary. */ private resetAssistantMessagePersistence(): void { this.cancelAssistantMessagePersistence() this.assistantMessagePersistencePromise = new Promise((resolve) => { this.resolveAssistantMessagePersistence = resolve }) let resolveCancellation!: () => void const cancellation: AssistantMessagePersistenceCancellation = { cancelled: false, promise: new Promise((resolve) => { resolveCancellation = resolve }), resolve: () => { cancellation.cancelled = true resolveCancellation() }, } this.assistantMessagePersistenceCancellation = cancellation this.completionPersistenceReadyPromise = undefined } /** Settles persistence waiters when the task or current stream generation ends. */ private cancelAssistantMessagePersistence(): void { this.assistantMessagePersistenceCancellation?.resolve() } /** * Waits until the current assistant turn is visible to a fresh extension host. * A public completion event must not be emitted before this boundary succeeds. */ public waitForCurrentAssistantMessagePersistence(): Promise { const currentCancellation = this.assistantMessagePersistenceCancellation! if (!this.completionPersistenceReadyPromise) { const currentPersistence = this.assistantMessagePersistencePromise this.completionPersistenceReadyPromise = (async () => { const result = await Promise.race([currentPersistence, currentCancellation.promise]) if (result) return const retrySaved = await this.retrySaveApiConversationHistoryWithCancellation(currentCancellation) if (!retrySaved) { if (!currentCancellation.cancelled) { throw new Error("Failed to persist API conversation history before task completion") } return } this.assistantMessageSavedToHistory = true })() } return this.completionPersistenceReadyPromise.then(() => !currentCancellation.cancelled) } // NOTE: We intentionally do NOT mutate stored messages to merge consecutive user turns. // For API requests, consecutive same-role messages are merged via mergeConsecutiveApiMessages() // so rewind/edit behavior can still reference original message boundaries. /** Replaces the entire API conversation history and persists the new state. */ async overwriteApiConversationHistory(newHistory: ApiMessage[], persist = true) { this.hydrateApiConversationHistory(newHistory) if (persist) { await this.saveApiConversationHistory(false) } } /** * Flush any pending tool results to the API conversation history. * * This is critical when the task is about to be * delegated (e.g., via new_task). Before delegation, if other tools were * called in the same turn before new_task, their tool_result blocks are * accumulated in `userMessageContent` but haven't been saved to the API * history yet. If we don't flush them before the parent is disposed, * the API conversation will be incomplete and cause 400 errors when * the parent resumes (missing tool_result for tool_use blocks). * * NOTE: The assistant message is typically already in history by the time * tools execute (added in recursivelyMakeClineRequests after streaming completes). * So we usually only need to flush the pending user message with tool_results. */ public async flushPendingToolResultsToHistory(): Promise { // Only flush if there's actually pending content to save if (this.userMessageContent.length === 0) { return true } if (this.abort) { return false } // CRITICAL: Wait for the assistant message to be saved to API history first. // Without this, tool_result blocks would appear BEFORE tool_use blocks in the // conversation history, causing API errors like: // "unexpected `tool_use_id` found in `tool_result` blocks" // // This can happen when parallel tools are called (e.g., update_todo_list + new_task). // Tools execute during streaming via presentAssistantMessage, BEFORE the assistant // message is saved. When new_task triggers delegation, it calls this method to // flush pending results - but the assistant message hasn't been saved yet. // // The assistantMessageSavedToHistory flag is: // - Reset to false at the start of each API request // - Set to true after the initial write or a bounded persistence retry succeeds if (!this.assistantMessageSavedToHistory) { try { if (!(await this.waitForCurrentAssistantMessagePersistence())) { return false } } catch (error) { console.warn( `[Task#${this.taskId}] flushPendingToolResultsToHistory: failed to persist assistant message`, error, ) return false } } // If task was aborted while waiting, don't flush if (this.abort) { return false } // Save the user message with tool_result blocks const userMessage: Anthropic.MessageParam = { role: "user", content: this.userMessageContent, } // Validate and fix tool_result IDs when the previous *effective* message is an assistant message. const effectiveHistoryForValidation = getEffectiveApiHistory(this.apiConversationHistory) const lastEffective = effectiveHistoryForValidation[effectiveHistoryForValidation.length - 1] const historyForValidation = lastEffective?.role === "assistant" ? effectiveHistoryForValidation : [] const validatedMessage = validateAndFixToolResultIds(userMessage, historyForValidation) const userMessageWithTs = { ...validatedMessage, messageId: crypto.randomUUID(), ts: Date.now() } this.apiConversationHistory.push(userMessageWithTs as ApiMessage) const saved = await this.saveApiConversationHistory() if (saved) { // Clear the pending content since it's now saved this.userMessageContent = [] } else { console.warn( `[Task#${this.taskId}] flushPendingToolResultsToHistory: save failed, retaining pending tool results in memory`, ) } return saved } /** Persists the current API conversation history to disk, returning false on I/O errors. */ private async saveApiConversationHistory(merge = true): Promise { try { await saveApiMessages({ messages: structuredClone(this.apiConversationHistory), taskId: this.taskId, globalStoragePath: this.globalStoragePath, merge, }) return true } catch (error) { console.error("Failed to save API conversation history:", error) return false } } /** * Public wrapper to retry saving the API conversation history. * Uses exponential backoff: up to 3 attempts with delays of 100 ms, 500 ms, 1500 ms. * Used by delegation flow when flushPendingToolResultsToHistory reports failure. */ public async retrySaveApiConversationHistory(): Promise { return this.retrySaveApiConversationHistoryWithCancellation() } /** Retries API-history persistence while allowing the active assistant generation to cancel backoff. */ private async retrySaveApiConversationHistoryWithCancellation( cancellation?: AssistantMessagePersistenceCancellation, ): Promise { const delays = [100, 500, 1500] for (let attempt = 0; attempt < delays.length; attempt++) { if (cancellation) { await new Promise((resolve) => { const timer = setTimeout(resolve, delays[attempt]) void cancellation.promise.then(() => { clearTimeout(timer) resolve() }) }) } else { await new Promise((resolve) => setTimeout(resolve, delays[attempt])) } // Check cancellation before each save attempt if (cancellation?.cancelled) return false console.warn( `[Task#${this.taskId}] retrySaveApiConversationHistory: retry attempt ${attempt + 1}/${delays.length}`, ) const success = await this.saveApiConversationHistory() if (cancellation?.cancelled) return false if (success) { return true } } return false } // Cline Messages private async getSavedClineMessages(): Promise { return readTaskMessages({ taskId: this.taskId, globalStoragePath: this.globalStoragePath }) } private async addToClineMessages(message: ClineMessage) { message.messageId ??= crypto.randomUUID() this.clineMessages.push(message) const provider = this.providerRef.deref() // Unanswered asks must reach the webview before Message listeners can respond against its state. // // Partial `say` messages deliberately do NOT flush. `flushPostStateToWebviewThrottled()` // invoked right after a leading-edge debounce call has no pending trailing invocation to // run, so it only cancels the trailing timer — which makes the *next* call hit the leading // edge and post immediately. Flushing on every partial therefore defeated the debounce // entirely (one full-state post per message, the very behaviour #1078 set out to remove). // They are safe on the throttled path: the trailing/maxWait post carries the message's // current text, so a `messageUpdated` dropped for a not-yet-known `ts` is superseded // rather than lost. // // Partial *asks* still flush, via the clause below — `Task#ask` adds them without // `isAnswered`, so they keep the ordering guarantee that unanswered asks depend on. const requiresImmediateState = message.type === "ask" && message.isAnswered !== true try { await provider?.postStateToWebviewThrottled() } catch (error) { console.error("[Task#addToClineMessages] postStateToWebviewThrottled failed:", error) } if (requiresImmediateState) { try { await provider?.flushPostStateToWebviewThrottled() } catch (error) { console.error("[Task#addToClineMessages] flushPostStateToWebviewThrottled failed:", error) } } this.emit(RooCodeEventName.Message, { action: "created", message }) await this.saveClineMessages() const shouldCaptureMessage = message.partial !== true && CloudService.isEnabled() if (shouldCaptureMessage) { CloudService.instance.captureEvent({ event: TelemetryEventName.TASK_MESSAGE, properties: { taskId: this.taskId, message }, }) // Track that this message has been synced to cloud this.cloudSyncedMessageTimestamps.add(message.ts) } } /** * Replaces the entire Cline message history, restores todo state, and persists. * Also resets cloud sync tracking to avoid re-syncing previously synced messages. */ public async overwriteClineMessages(newMessages: ClineMessage[], persist = true) { this.hydrateClineMessages(newMessages) if (persist) { await this.saveClineMessages(false) } } private hydrateClineMessages(messages: ClineMessage[]) { this.clineMessages = ensureMessageIdentifiers(messages) restoreTodoListForTask(this) // When hydrating or overwriting messages, repopulate the cloud sync tracking Set // with timestamps from all non-partial messages to prevent re-syncing previously synced messages this.cloudSyncedMessageTimestamps.clear() for (const msg of messages) { if (msg.partial !== true) { this.cloudSyncedMessageTimestamps.add(msg.ts) } } } private hydrateApiConversationHistory(messages: ApiMessage[]) { this.apiConversationHistory = ensureMessageIdentifiers(messages) } /** * Updates a Cline message in the webview and emits an event. * Non-partial messages are synced to cloud telemetry if not already synced. */ private async updateClineMessage(message: ClineMessage) { const provider = this.providerRef.deref() await provider?.postMessageToWebview({ type: "messageUpdated", clineMessage: message }) this.emit(RooCodeEventName.Message, { action: "updated", message }) // Check if we should sync to cloud and haven't already synced this message const shouldCaptureMessage = message.partial !== true && CloudService.isEnabled() const hasNotBeenSynced = !this.cloudSyncedMessageTimestamps.has(message.ts) if (shouldCaptureMessage && hasNotBeenSynced) { CloudService.instance.captureEvent({ event: TelemetryEventName.TASK_MESSAGE, properties: { taskId: this.taskId, message }, }) // Track that this message has been synced to cloud this.cloudSyncedMessageTimestamps.add(message.ts) } } /** * Persist the message array, then refresh the derived metadata / task-history entries. * * The returned boolean reflects the message write only: `saveTaskMessages` failure * leaves the on-disk record stale, so callers gating UI updates on durable state must * skip them. Metadata / task-history stage failures are logged and swallowed — the * message array is already persisted, and the next save recomputes and re-emits the * metadata. * * `merge` (default `true`) is passed through to `saveTaskMessages`: the in-memory * snapshot is merged with the on-disk record. `overwriteClineMessages` passes `false` * to replace the stored messages outright. */ private async saveClineMessages(merge = true): Promise { try { await saveTaskMessages({ messages: structuredClone(this.clineMessages), taskId: this.taskId, globalStoragePath: this.globalStoragePath, merge, }) } catch (error) { console.error("Failed to save Roo messages:", error) return false } try { if (this._taskApiConfigName === undefined) { await this.taskApiConfigReady } const { historyItem, tokenUsage } = await taskMetadata({ taskId: this.taskId, rootTaskId: this.rootTaskId, parentTaskId: this.parentTaskId, taskNumber: this.taskNumber, messages: this.clineMessages, globalStoragePath: this.globalStoragePath, workspace: this.cwd, mode: this._taskMode || defaultModeSlug, // Use the task's own mode, not the current provider mode. apiConfigName: this._taskApiConfigName, // Use the task's own provider profile, not the current provider profile. initialStatus: this.initialStatus, }) // Emit token/tool usage updates using debounced function // The debounce with maxWait ensures: // - Immediate first emit (leading: true) // - At most one emit per interval during rapid updates (maxWait) // - Final state is emitted when updates stop (trailing: true) this.debouncedEmitTokenUsage(tokenUsage, this.toolUsage) const provider = this.providerRef.deref() const existingStatus = provider?.taskHistoryStore.get(this.taskId)?.status await provider?.updateTaskHistory(existingStatus ? { ...historyItem, status: existingStatus } : historyItem) } catch (error) { // The message array was persisted above; a metadata or task-history failure must // not mask that write (see the method docs). The next saveClineMessages() call // recomputes and re-emits the metadata update. console.error("Failed to save task metadata:", error) } return true } private findMessageByTimestamp(ts: number): ClineMessage | undefined { for (let i = this.clineMessages.length - 1; i >= 0; i--) { if (this.clineMessages[i].ts === ts) { return this.clineMessages[i] } } return undefined } // Note that `partial` has three valid states true (partial message), // false (completion of partial message), undefined (individual complete // message). async ask( type: ClineAsk, text?: string, partial?: boolean, progressStatus?: ToolProgressStatus, isProtected?: boolean, autoApprovalContext?: AutoApprovalContext, ): Promise<{ response: ClineAskResponse text?: string images?: string[] queuedMessageId?: string /** * Present when the ask was resolved by an automatic (policy) denial * rather than a user click. Consumers must not treat this as a user * rejection: the denial is scoped to its own tool call. */ autoDenyDetail?: AutoDenyDetail }> { // If this Cline instance was aborted by the provider, then the only // thing keeping us alive is a promise still running in the background, // in which case we don't want to send its result to the webview as it // is attached to a new instance of Cline now. So we can safely ignore // the result of any active promises, and this class will be // deallocated. (Although we set Cline = undefined in provider, that // simply removes the reference to this instance, but the instance is // still alive until this promise resolves or rejects.) if (this.abort) { throw new Error(`[RooCode#ask] task ${this.taskId}.${this.instanceId} aborted`) } let askTs: number // Resolve auto-approval before adding the message so the state snapshot // sent to the webview already carries isAnswered:true when the ask will // be immediately resolved. This eliminates the race between the state // update (which shows approval buttons) and the former separate // clearApprovalButtons message (which could arrive before buttons were // rendered, leaving them stuck on-screen). const provider = this.providerRef.deref() const state = provider ? await provider.getState() : undefined // The blanket auto-deny setting only engages while command auto-approval // is on; while it is disengaged, the queued-message shortcut below is // unaffected. const blanketDenyEngaged = isBlanketDenyEngaged(state) // A queued message normally answers the pending ask, which for command asks // means an unconditional auto-approve. That shortcut must never bypass // blanket deny: while it is engaged, a command ask keeps its policy // decision and the queued message is left in place for a later turn // instead of being consumed as approval. The shared claim gate adds the // per-turn latch: a message a blanket denial left behind answers that // denial, not this ask, and a claim would force the `ask` decision below // while the policy would have auto-answered it — holding a prompt no user // has to see and stalling a hands-free session. Leaving the message // unclaimed lets the policy decision stand and keeps the message queued. const queueMayAnswerThisAsk = !(blanketDenyEngaged && type === "command") // A queued message must not short-circuit protected asks (e.g. // DCG-blocked commands): claiming one here skips checkAutoApproval, and // the drain below would auto-approve what protection intentionally // leaves pending for explicit user approval. const queuedMessage = partial === true || type === "command_output" || isProtected || !queueMayAnswerThisAsk || !this.mayDrainQueuedMessageForAsk() ? undefined : this.messageQueueService.claimNextMessage() const queuedAskResolution = queuedMessage ? queuedResponseForAsk(type, text) : undefined // `this.cwd`, not `provider.cwd`: // The path inside `text` was made relative to this task's workspace, // which for a resumed or child task need not be the one the provider // currently reports. const approval = queuedAskResolution ? ({ decision: "ask" } as const) : await checkAutoApproval({ state, cwd: this.cwd, ask: type, text, isProtected, dcgDecision: autoApprovalContext?.dcgDecision, }) const isAutoAnswered = approval.decision === "approve" || approval.decision === "deny" const autoApprovalDecision = isAutoAnswered ? approval.decision : undefined // Re-check: an abort during the getState/checkAutoApproval awaits must not post an ask row. if (this.abort) { if (queuedMessage) { this.messageQueueService.releaseMessage(queuedMessage.id) } throw new Error(`[RooCode#ask] task ${this.taskId}.${this.instanceId} aborted`) } if (partial !== undefined) { const lastMessage = this.clineMessages.at(-1) const isUpdatingPreviousPartial = lastMessage && lastMessage.partial && lastMessage.type === "ask" && lastMessage.ask === type if (partial) { if (isUpdatingPreviousPartial) { // Existing partial message, so update it. lastMessage.text = text lastMessage.partial = partial lastMessage.progressStatus = progressStatus lastMessage.isProtected = isProtected // TODO: Be more efficient about saving and posting only new // data or one whole message at a time so ignore partial for // saves, and only post parts of partial message instead of // whole array in new listener. // Fire-and-forget: the webview post is internally guarded, but // the `RooCodeEventName.Message` emit can synchronously throw // if any consumer-attached listener does, which would surface // here as an unhandled rejection. Log it instead. this.updateClineMessage(lastMessage).catch((error) => { console.error("[Task#ask] updateClineMessage failed:", error) }) // console.log("Task#ask: current ask promise was ignored (#1)") throw new AskIgnoredError("updating existing partial") } else { // This is a new partial message, so add it with partial // state. askTs = Date.now() this.lastMessageTs = askTs await this.addToClineMessages({ ts: askTs, type: "ask", ask: type, text, partial, isProtected }) // console.log("Task#ask: current ask promise was ignored (#2)") throw new AskIgnoredError("new partial") } } else { if (isUpdatingPreviousPartial) { // This is the complete version of a previously partial // message, so replace the partial with the complete version. this.askResponse = undefined this.askResponseText = undefined this.askResponseImages = undefined // Bug for the history books: // In the webview we use the ts as the chatrow key for the // virtuoso list. Since we would update this ts right at the // end of streaming, it would cause the view to flicker. The // key prop has to be stable otherwise react has trouble // reconciling items between renders, causing unmounting and // remounting of components (flickering). // The lesson here is if you see flickering when rendering // lists, it's likely because the key prop is not stable. // So in this case we must make sure that the message ts is // never altered after first setting it. askTs = lastMessage.ts this.lastMessageTs = askTs lastMessage.text = text lastMessage.partial = false lastMessage.progressStatus = progressStatus lastMessage.isProtected = isProtected if (isAutoAnswered) { lastMessage.isAnswered = true lastMessage.autoApprovalDecision = autoApprovalDecision } await this.saveClineMessages() // Fire-and-forget: see updateClineMessage call above for the // rationale on the .catch arm. this.updateClineMessage(lastMessage).catch((error) => { console.error("[Task#ask] updateClineMessage failed:", error) }) } else { // This is a new and complete message, so add it like normal. this.askResponse = undefined this.askResponseText = undefined this.askResponseImages = undefined askTs = Date.now() this.lastMessageTs = askTs await this.addToClineMessages({ ts: askTs, type: "ask", ask: type, text, isProtected, isAnswered: isAutoAnswered || undefined, autoApprovalDecision, }) } } } else { // This is a new non-partial message, so add it like normal. this.askResponse = undefined this.askResponseText = undefined this.askResponseImages = undefined askTs = Date.now() this.lastMessageTs = askTs await this.addToClineMessages({ ts: askTs, type: "ask", ask: type, text, isProtected, isAnswered: isAutoAnswered || undefined, autoApprovalDecision, }) } const timeouts: NodeJS.Timeout[] = [] // Record the structured detail of an automatic (policy) denial so the // `ask()` result can hand it to the caller. Assigned unconditionally so // a stale detail from a previous ask can never leak into this result. // Deliberately not routed through `askResponseText`: that field means // *user feedback* and triggers a `user_feedback` say row, while this // reason is system-generated. this.pendingAutoDenyDetail = approval.decision === "deny" ? approval.autoDeny : undefined // A blanket-caused denial leaves the queued messages this turn for later // turns; latch so no ask in this turn consumes one as approval. Set only on // blanket-caused denials — here and at the consume-site re-check // (`applyQueuedCommandPolicyAction`) — and never cleared mid-turn: the // per-turn reset owns clearing, so a later non-denied ask cannot drop the // latch prematurely. if ( type === "command" && approval.decision === "deny" && approval.autoDeny && BLANKET_DENY_AUTO_DENY_KINDS.has(approval.autoDeny.kind) ) { this.blanketDeniedCommandThisTurn = true } if (approval.decision === "approve") { this.approveAsk() } else if (approval.decision === "deny") { this.denyAsk() } else if (approval.decision === "timeout") { // Store the auto-approval timeout so it can be cancelled if user interacts this.autoApprovalTimeoutRef = setTimeout(() => { const { askResponse, text, images } = approval.fn() this.handleWebviewAskResponse(askResponse, text, images) this.autoApprovalTimeoutRef = undefined }, approval.timeout) timeouts.push(this.autoApprovalTimeoutRef) } // The state is mutable if the message is complete and the task will // block (via the `pWaitFor`). const isBlocking = !(this.askResponse !== undefined || this.lastMessageTs !== askTs) const isMessageQueued = !this.messageQueueService.isEmpty() // Keep queued user messages intact during command_output asks. Those asks // are terminal flow-control, not conversational turns. const shouldDrainQueuedMessageForAsk = type !== "command_output" const isStatusMutable = !partial && isBlocking && !isMessageQueued && approval.decision === "ask" let queuedMessageId: string | undefined // Arm the interactive/resumable/idle status timers for this ask: the // single source of that arm, shared between the queue-free case and the // claim-gated and queued-release paths below. A gated or released claim // keeps the message in the queue, so `isMessageQueued` stays true and // `isStatusMutable` — which requires an empty queue — stays false while // the ask waits for the user; arming only from `isStatusMutable` would // leave hands-free/API consumers seeing `Running` with no // `TaskInteractive`/`interactionRequired` for a prompt that is in fact // pending. Idempotent: several arm sites can fire for one ask (e.g. the // queue-free arm, then a drain-site release), and a second arm would // double-emit the event. The `timeouts` array is the arm ledger, and a // `finally` around the wait owns the `clearTimeout` teardown on every // settle path, including the abort and supersession throws. The // callbacks still re-check liveness at fire time: an already-due timer // can outrun that teardown, and a status transition published after // abort would describe a task that no longer runs. const armAskStatusTimers = (): void => { const askStillPending = this.askResponse === undefined && this.lastMessageTs === askTs if (!askStillPending || this.abort || partial || approval.decision !== "ask" || timeouts.length > 0) { return } const statusMutationTimeout = 2_000 // Fire-time liveness check for the timers armed below. Clearing a due // timer does not retract it: when abort lands between the wait's last // poll tick and the timer's due time, the callback runs before // `ask()`'s continuation reaches the teardown sweep, so liveness has // to be re-checked here. Read-only: the `timeouts` ledger is never // touched from the callbacks. const statusTimerStillLive = (): boolean => !this.abort && this.askResponse === undefined && this.lastMessageTs === askTs if (isInteractiveAsk(type)) { timeouts.push( setTimeout(() => { if (!statusTimerStillLive()) { return } const message = this.findMessageByTimestamp(askTs) if (message) { this.interactiveAsk = message this.emit(RooCodeEventName.TaskInteractive, this.taskId) /* v8 ignore next 3 -- fires inside 2s timer after ask() resolves; not reachable in unit tests */ void provider?.postMessageToWebview({ type: "interactionRequired" }).catch((error) => { console.error("[Task#ask] postMessageToWebview interactionRequired failed:", error) }) } }, statusMutationTimeout), ) } else if (isResumableAsk(type)) { timeouts.push( setTimeout(() => { if (!statusTimerStillLive()) { return } const message = this.findMessageByTimestamp(askTs) if (message) { this.resumableAsk = message this.emit(RooCodeEventName.TaskResumable, this.taskId) } }, statusMutationTimeout), ) } else if (isIdleAsk(type)) { timeouts.push( setTimeout(() => { if (!statusTimerStillLive()) { return } const message = this.findMessageByTimestamp(askTs) if (message) { this.idleAsk = message this.emit(RooCodeEventName.TaskIdle, this.taskId) } }, statusMutationTimeout), ) } } if (isStatusMutable) { armAskStatusTimers() } else if (queuedMessage && queuedAskResolution) { if (type === "command") { // The snapshot gate is frozen; blanket deny may have engaged since the // ask began. Re-read policy before the message stands in for approval. // Drain-site parity for cancellation and cleanup: the fresh policy // read ends in an uncancellable provider read, so an abort poller // aborts a signal the re-check itself awaits — settling it on the // abort instead of leaving a pending promise that retains this task // and runs policy post-abort — and one `finally` releases the claim // on the abort, throw, supersession, and "release" outcomes alike. const recheckAbort = new AbortController() const checkAbort = () => { if (this.abort) { recheckAbort.abort() } } checkAbort() const abortWatcher = setInterval(checkAbort, 100) try { const action = await this.recheckQueuedCommandPolicy( { text, isProtected, dcgDecision: autoApprovalContext?.dcgDecision, }, recheckAbort.signal, ) if (!this.abort && this.askResponse === undefined && this.lastMessageTs === askTs) { // Any outcome on a still-live ask — the user answered or the // ask was superseded — leaves the message for the next // consumer; the `finally` below releases the claim and the ask // resolves with the user's own response via the pWaitFor. queuedMessageId = this.applyQueuedCommandPolicyAction( action, queuedMessage, queuedAskResolution, ) // A "release" outcome leaves the ask pending; consume/deny/approve // resolved it, and the arm's pending check declines to arm then. armAskStatusTimers() } } catch (error) { // Drain-site parity: a failed re-check must not reject ask() nor // strand the claim; the prompt stays pending for the user. console.error("[Task#ask] queued command policy re-check failed:", error) armAskStatusTimers() } finally { clearInterval(abortWatcher) // One release path for abort, throw, supersession, and "release". // `releaseMessage` is idempotent, so it stays safe beside the // helper-internal release. The durable consume is the one outcome // that must keep its claim until persistence removes the message — // it is the only path that assigned `queuedMessageId` here. if (queuedMessageId !== queuedMessage.id) { this.messageQueueService.releaseMessage(queuedMessage.id) } } } else { queuedMessageId = this.handleQueuedAskResponse(queuedMessage, queuedAskResolution) } } else if (shouldDrainQueuedMessageForAsk && isMessageQueued) { // The claim gate (per-turn latch, or blanket deny engaged for a command // ask) left the queued message untouched. If the policy still leaves the // prompt pending, the non-empty queue keeps `isStatusMutable` false, so // the interactive arm must run from here — the same reason a release // re-arms. For an auto-answered ask the arm's pending check declines. armAskStatusTimers() } // At most one drain-site policy re-check is in flight per ask; the // pWaitFor predicate is synchronous, so its await runs out here. let queuedCommandPolicyCheck: Promise | undefined const verifyDrainedCommandMessage = async ( message: QueuedMessage, resolution: QueuedAskResolution, ): Promise => { // The fresh policy read ends in `provider.getState()`, which awaits // custom-mode file work that cannot be cancelled from here. An abort // poller aborts a signal the re-check itself awaits, settling it within // one poll of the abort instead of leaving `ask()` blocked on the pending // read or a lost race retaining this task. The re-check's own race // attaches handlers to the read, so it rejecting after the abort settles // is already considered handled — no extra `.catch` needed. const recheckAbort = new AbortController() const checkAbort = () => { if (this.abort) { recheckAbort.abort() } } checkAbort() const abortWatcher = setInterval(checkAbort, 100) try { const action = await this.recheckQueuedCommandPolicy( { text, isProtected, dcgDecision: autoApprovalContext?.dcgDecision, }, recheckAbort.signal, ) if (!this.abort && this.askResponse === undefined && this.lastMessageTs === askTs) { queuedMessageId = this.applyQueuedCommandPolicyAction(action, message, resolution) // A "release" outcome leaves the ask pending while the queue stays // non-empty, so the arm that `isStatusMutable` gates — computed // once, before the claim — must run here. armAskStatusTimers() } } finally { clearInterval(abortWatcher) // One release path for abort, throw, supersession, and "release". // `releaseMessage` is idempotent, so it stays safe beside the // branch-local releases. The durable consume is the one outcome // that must keep its claim until persistence removes the message — // it is the only path that assigned `queuedMessageId`. if (queuedMessageId !== message.id) { this.messageQueueService.releaseMessage(message.id) } } } // Wait for askResponse to be set try { await pWaitFor( () => { if (this.abort || this.askResponse !== undefined || this.lastMessageTs !== askTs) { return true } // If a queued message arrives while we're blocked on an ask (e.g. a follow-up // suggestion click that was incorrectly queued due to UI state), consume it // immediately so the task doesn't hang. Command asks under blanket deny are // excluded (`queueMayAnswerThisAsk`): a queued message must never stand in // for the explicit approval the policy withheld. Protected asks are exempt // too: like the pre-block drain above, they must wait for explicit user // approval. if ( queueMayAnswerThisAsk && shouldDrainQueuedMessageForAsk && !isProtected && !queuedCommandPolicyCheck && this.mayDrainQueuedMessageForAsk() ) { const message = this.messageQueueService.claimNextMessage() const resolution = message ? queuedResponseForAsk(type, text) : undefined if (message && resolution) { if (type === "command") { // Claim first, then verify the policy off-predicate: a // blanket-deny flip landing during the prompt dwell is // invisible to the frozen snapshot gate, so the claim is // provisional until the fresh check clears it. queuedCommandPolicyCheck = verifyDrainedCommandMessage(message, resolution).catch( (error) => { // The background check must never reject unhandled; // on failure the claim is released so the message // stays available to a later consumer. console.error("[Task#ask] queued command policy re-check failed:", error) this.messageQueueService.releaseMessage(message.id) armAskStatusTimers() }, ) } else { queuedMessageId = this.handleQueuedAskResponse(message, resolution) } } } return false }, { interval: 100 }, ) // Let a policy re-check that was in flight when the wait resolved run to // completion: its bail-out path releases the claim, and leaving it // unresolved would let the consume race the result below. On abort, detach // instead: the re-check settles on the abort and its `finally` releases the // claim either way, while awaiting past the abort could stall on an // uncancellable read instead of letting the throw below settle the ask. if (queuedCommandPolicyCheck && !this.abort) { await queuedCommandPolicyCheck } /* v8 ignore next 3 -- abort-while-waiting path; covered by e2e standalone-resume test */ if (this.abort) { if (queuedMessageId) { this.messageQueueService.releaseMessage(queuedMessageId) } throw new Error(`[ZooCode#ask] task ${this.taskId}.${this.instanceId} aborted`) } if (this.lastMessageTs !== askTs) { // Could happen if we send multiple asks in a row i.e. with // command_output. It's important that when we know an ask could // fail, it is handled gracefully. if (queuedMessageId) { this.messageQueueService.releaseMessage(queuedMessageId) } throw new AskIgnoredError("superseded") } const result = { response: this.askResponse!, text: this.askResponseText, images: this.askResponseImages, queuedMessageId, autoDenyDetail: this.pendingAutoDenyDetail, } this.askResponse = undefined this.askResponseText = undefined this.askResponseImages = undefined this.pendingAutoDenyDetail = undefined // Switch back to an active state. if (this.idleAsk || this.resumableAsk || this.interactiveAsk) { this.idleAsk = undefined this.resumableAsk = undefined this.interactiveAsk = undefined this.emit(RooCodeEventName.TaskActive, this.taskId) } this.emit(RooCodeEventName.TaskAskResponded) return result } finally { // Teardown for every settle path: the normal resolve and the // abort/supersession throws that never reach the result handling // above. The fire-time guard in the armed callbacks covers the // sub-tick window where an already-due timer fires before this // sweep runs. timeouts.forEach((timeout) => clearTimeout(timeout)) } } handleWebviewAskResponse(askResponse: ClineAskResponse, text?: string, images?: string[]) { // Clear any pending auto-approval timeout when user responds this.cancelAutoApprovalTimeout() this.askResponse = askResponse this.askResponseText = text this.askResponseImages = images // Create a checkpoint whenever the user sends a message. // Use allowEmpty=true to ensure a checkpoint is recorded even if there are no file changes. // Suppress the checkpoint_saved chat row for this particular checkpoint to keep the timeline clean. if (askResponse === "messageResponse") { void this.checkpointSave(false, true) } // Mark the last follow-up question as answered if (askResponse === "messageResponse" || askResponse === "yesButtonClicked") { // Find the last unanswered follow-up message using findLastIndex const lastFollowUpIndex = findLastIndex( this.clineMessages, (msg) => msg.type === "ask" && msg.ask === "followup" && !msg.isAnswered, ) if (lastFollowUpIndex !== -1) { // Mark this follow-up as answered this.clineMessages[lastFollowUpIndex].isAnswered = true // Save the updated messages this.saveClineMessages().catch((error) => { console.error("Failed to save answered follow-up state:", error) }) } } // Mark the last tool-approval ask as answered when user approves (or auto-approval) if (askResponse === "yesButtonClicked") { const lastToolAskIndex = findLastIndex( this.clineMessages, (msg) => msg.type === "ask" && msg.ask === "tool" && !msg.isAnswered, ) if (lastToolAskIndex !== -1) { this.clineMessages[lastToolAskIndex].isAnswered = true void this.updateClineMessage(this.clineMessages[lastToolAskIndex]).catch((error) => { console.error("[Task#handleWebviewAskResponse] updateClineMessage failed:", error) }) this.saveClineMessages().catch((error) => { console.error("Failed to save answered tool-ask state:", error) }) } } } /** * Cancel any pending auto-approval timeout. * Called when user interacts (types, clicks buttons, etc.) to prevent the timeout from firing. */ public cancelAutoApprovalTimeout(): void { if (this.autoApprovalTimeoutRef) { clearTimeout(this.autoApprovalTimeoutRef) this.autoApprovalTimeoutRef = undefined } } public approveAsk({ text, images }: { text?: string; images?: string[] } = {}) { this.handleWebviewAskResponse("yesButtonClicked", text, images) } public denyAsk({ text, images }: { text?: string; images?: string[] } = {}) { this.handleWebviewAskResponse("noButtonClicked", text, images) } public supersedePendingAsk(): void { this.lastMessageTs = Date.now() } /** * Updates the API configuration and rebuilds the API handler. * There is no tool-protocol switching or tool parser swapping. * * @param newApiConfiguration - The new API configuration to use */ public updateApiConfiguration(newApiConfiguration: ProviderSettings): void { // Update the configuration and rebuild the API handler this.apiConfiguration = newApiConfiguration this.api = buildApiHandler(this.apiConfiguration) // Refresh only the soft tool-repetition limit from the new profile so an // active task immediately enforces it. The task-level // `consecutiveMistakeLimit` (the hard stop) is intentionally preserved: // it may have been set explicitly at task creation (e.g. the extension // API creates tasks with `Number.MAX_SAFE_INTEGER`), and a profile switch // must not silently override it. The detector's in-progress counting // state is preserved. this.toolRepetitionSoftLimit = normalizeToolRepetitionSoftLimit( newApiConfiguration.toolRepetitionSoftLimit ?? DEFAULT_TOOL_REPETITION_SOFT_LIMIT, this.consecutiveMistakeLimit, ) this.toolRepetitionDetector.updateLimits(this.toolRepetitionSoftLimit, this.consecutiveMistakeLimit) } public async submitUserMessage( text: string, images?: string[], mode?: string, providerProfile?: string, ): Promise { try { text = (text ?? "").trim() images = images ?? [] if (text.length === 0 && images.length === 0) { return } const provider = this.providerRef.deref() if (provider) { if (mode) { await provider.setMode(mode) this._taskMode = mode } if (providerProfile) { await provider.setProviderProfile(providerProfile) // Update this task's API configuration to match the new profile // This ensures the parser state is synchronized with the selected model const newState = await provider.getState() this.setTaskApiConfigName(newState?.currentApiConfigName ?? providerProfile) if (newState?.apiConfiguration) { this.updateApiConfiguration(newState.apiConfiguration) } } this.emit(RooCodeEventName.TaskUserMessage, this.taskId) // Handle the message directly instead of routing through the webview. // This avoids a race condition where the webview's message state hasn't // hydrated yet, causing it to interpret the message as a new task request. this.handleWebviewAskResponse("messageResponse", text, images) } else { console.error("[Task#submitUserMessage] Provider reference lost") } } catch (error) { console.error("[Task#submitUserMessage] Failed to submit user message:", error) } } async handleTerminalOperation(terminalOperation: "continue" | "abort") { if (terminalOperation === "continue") { this.terminalProcess?.continue() } else if (terminalOperation === "abort") { this.terminalProcess?.abort() } } private async getFilesReadByRooSafely(context: string): Promise { try { return await this.fileContextTracker.getFilesReadByRoo() } catch (error) { console.error(`[Task#${context}] Failed to get files read by Roo:`, error) return undefined } } public async condenseContext(): Promise { // CRITICAL: Flush any pending tool results before condensing // to ensure tool_use/tool_result pairs are complete in history await this.flushPendingToolResultsToHistory() // Capture provider state and one model-info snapshot once and thread them into // getSystemPrompt so the prompt and the condensing tool array below resolve // from one snapshot. const state = await this.providerRef.deref()?.getState() const requestModelInfo = await this.safeEnsureModelFetched() // A cancellation landing during the bounded metadata wait must stop // manual condensation before any prompt build or summarization request. if (this.abort || this.abandoned) { return } const systemPrompt = await this.getSystemPrompt(state, requestModelInfo) // A cancellation landing during the prompt build's bounded MCP wait must // stop manual condensation before any summarization request is issued. if (this.abort || this.abandoned) { return } // Get condensing configuration const customCondensingPrompt = state?.customSupportPrompts?.CONDENSE // Use task-local values, not provider state, to prevent cross-task configuration leaks. const mode = await this.getTaskMode() const apiConfiguration = this.apiConfiguration const { contextTokens: prevContextTokens } = this.getTokenUsage() // Build tools for condensing metadata (same tools used for normal API calls) const provider = this.providerRef.deref() let allTools: import("openai").default.Chat.ChatCompletionTool[] = [] if (provider) { const toolsResult = await buildNativeToolsArrayWithRestrictions({ provider, cwd: this.cwd, mode, customModes: state?.customModes, experiments: state?.experiments, apiConfiguration, disabledTools: state?.disabledTools, modelInfo: requestModelInfo, includeAllToolsWithRestrictions: false, }) allTools = toolsResult.tools } // Build metadata with tools and taskId for the condensing API call const metadata: ApiHandlerCreateMessageMetadata = { mode, taskId: this.taskId, ...(this.currentRequestAbortController?.signal ? { abortSignal: this.currentRequestAbortController.signal, } : {}), ...(allTools.length > 0 ? { tools: allTools, tool_choice: "auto", parallelToolCalls: true, } : {}), } // Generate environment details to include in the condensed summary const environmentDetails = await getEnvironmentDetails(this, true) const filesReadByRoo = await this.getFilesReadByRooSafely("condenseContext") // A cancellation landing while the summarization inputs are gathered must // stop manual condensation before any summarization request is issued. if (this.abort || this.abandoned) { return } const { messages, summary, cost, newContextTokens = 0, error, condenseId, } = await summarizeConversation({ messages: this.apiConversationHistory, apiHandler: this.api, systemPrompt, taskId: this.taskId, isAutomaticTrigger: false, customCondensingPrompt, metadata, environmentDetails, filesReadByRoo, cwd: this.cwd, rooIgnoreController: this.rooIgnoreController, }) // A cancellation landing during the summarization request must stop // manual condensation before it replaces and persists the history. if (this.abort || this.abandoned) { return } if (error) { await this.say( "condense_context_error", error, undefined /* images */, false /* partial */, undefined /* checkpoint */, undefined /* progressStatus */, { isNonInteractive: true } /* options */, ) return } await this.overwriteApiConversationHistory(messages) const contextCondense: ContextCondense = { summary, cost, newContextTokens, prevContextTokens, condenseId: condenseId!, } await this.say( "condense_context", undefined /* text */, undefined /* images */, false /* partial */, undefined /* checkpoint */, undefined /* progressStatus */, { isNonInteractive: true } /* options */, contextCondense, ) // Process any queued messages after condensing completes this.processQueuedMessages() } async say( type: ClineSay, text?: string, images?: string[], partial?: boolean, checkpoint?: Record, progressStatus?: ToolProgressStatus, options: { isNonInteractive?: boolean } = {}, contextCondense?: ContextCondense, contextTruncation?: ContextTruncation, ): Promise { if (this.abort) { throw new Error(`[RooCode#say] task ${this.taskId}.${this.instanceId} aborted`) } if (partial !== undefined) { const lastMessage = this.clineMessages.at(-1) const isUpdatingPreviousPartial = lastMessage && lastMessage.partial && lastMessage.type === "say" && lastMessage.say === type if (partial) { if (isUpdatingPreviousPartial) { // Existing partial message, so update it. lastMessage.text = text lastMessage.images = images lastMessage.partial = partial lastMessage.progressStatus = progressStatus // Fire-and-forget: webview post is internally guarded, but the // `RooCodeEventName.Message` emit can synchronously throw via a // consumer-attached listener. Surface that as a log, not an // unhandled rejection. this.updateClineMessage(lastMessage).catch((error) => { console.error("[Task#say] updateClineMessage failed:", error) }) } else { // This is a new partial message, so add it with partial state. const sayTs = Date.now() if (!options.isNonInteractive) { this.lastMessageTs = sayTs } await this.addToClineMessages({ ts: sayTs, type: "say", say: type, text, images, partial, contextCondense, contextTruncation, }) } } else { // New now have a complete version of a previously partial message. // This is the complete version of a previously partial // message, so replace the partial with the complete version. if (isUpdatingPreviousPartial) { if (!options.isNonInteractive) { this.lastMessageTs = lastMessage.ts } lastMessage.text = text lastMessage.images = images lastMessage.partial = false lastMessage.progressStatus = progressStatus // Instead of streaming partialMessage events, we do a save // and post like normal to persist to disk. await this.saveClineMessages() // More performant than an entire `postStateToWebview`. // Fire-and-forget: see updateClineMessage call above for the // rationale on the .catch arm. this.updateClineMessage(lastMessage).catch((error) => { console.error("[Task#say] updateClineMessage failed:", error) }) } else { // This is a new and complete message, so add it like normal. const sayTs = Date.now() if (!options.isNonInteractive) { this.lastMessageTs = sayTs } await this.addToClineMessages({ ts: sayTs, type: "say", say: type, text, images, contextCondense, contextTruncation, }) } } } else { // This is a new non-partial message, so add it like normal. const sayTs = Date.now() // A "non-interactive" message is a message is one that the user // does not need to respond to. We don't want these message types // to trigger an update to `lastMessageTs` since they can be created // asynchronously and could interrupt a pending ask. if (!options.isNonInteractive) { this.lastMessageTs = sayTs } await this.addToClineMessages({ ts: sayTs, type: "say", say: type, text, images, checkpoint, contextCondense, contextTruncation, }) } } async sayAndCreateMissingParamError(toolName: ToolName, paramName: string, relPath?: string) { await this.say( "error", relPath ? t("tools:missingToolParameterWithPath", { toolName, relPath: relPath.toPosix(), paramName, }) : t("tools:missingToolParameter", { toolName, paramName }), ) return formatResponse.toolError(formatResponse.missingToolParameterError(paramName)) } /** * Finalize a partial "tool" ask message without blocking for user input. * Call this in error paths where a partial tool message was opened during streaming * but execution failed before the normal approval flow could close it, so the webview * spinner does not get stuck in a loading state. * * The matching partial message may no longer be the final entry if another asynchronous * message was inserted between the partial ask and the error handler, so search backward * instead of relying on clineMessages.at(-1). * * Any in-progress `progressStatus` on the message is cleared as well: the ask is being * finalized because it will NOT complete, so a stale "in progress" indicator would be * misleading (the normal completion path overwrites it with the final status instead). * * `isAnswered` is stamped true because the ask is resolved by the system rather than * by the user: ChatView only shows ask buttons for unanswered messages, so leaving it * unset would keep Save/Reject armed for a write that already failed. */ async finalizePartialToolAsk(text?: string): Promise { const partialToolAsk = findLast( this.clineMessages, (message) => message.partial === true && message.type === "ask" && message.ask === "tool" && (text === undefined || message.text === text), ) if (!partialToolAsk) { return } partialToolAsk.partial = false partialToolAsk.progressStatus = undefined partialToolAsk.isAnswered = true const saved = await this.saveClineMessages() if (!saved) { // The persistence write failed: the on-disk record still carries `partial: true` // while the in-memory message is finalized. Skip the webview-only update so the // two views do not diverge (a later state resync or restart reload would flip the // spinner back on from the stale disk record). The next saveClineMessages() call // re-persists the full message array and repairs the disk record. console.error("[Task#finalizePartialToolAsk] saveClineMessages failed; skipping webview update") return } await this.updateClineMessage(partialToolAsk).catch((error) => { console.error("[Task#finalizePartialToolAsk] updateClineMessage failed:", error) }) } // Lifecycle // Start / Resume / Abort / Dispose /** * Get enabled MCP tools count for this task. * Returns the count along with the number of servers contributing. * * @returns Object with enabledToolCount and enabledServerCount */ private async getEnabledMcpToolsCount(): Promise<{ enabledToolCount: number; enabledServerCount: number }> { try { const provider = this.providerRef.deref() if (!provider) { return { enabledToolCount: 0, enabledServerCount: 0 } } const { mcpEnabled } = (await provider.getState()) ?? {} if (!(mcpEnabled ?? true)) { return { enabledToolCount: 0, enabledServerCount: 0 } } const mcpHub = await McpServerManager.getInstance(provider.context, provider) if (!mcpHub) { return { enabledToolCount: 0, enabledServerCount: 0 } } const servers = mcpHub.getServers() return countEnabledMcpTools(servers) } catch (error) { console.error("[Task#getEnabledMcpToolsCount] Error counting MCP tools:", error) return { enabledToolCount: 0, enabledServerCount: 0 } } } /** * Manually start a **new** task when it was created with `startTask: false`. * * This fires task startup as a background async operation after the provider * has installed the task in the stack and wired listeners. The primary * use-case is delegation/rehydration flow where metadata and stack state * must be in place before the task begins writing history or emitting asks. */ public start(): void { if (this._started) { return } this._started = true this.startIdleTelemetryCheck() const { task, images } = this.metadata if (task || images) { void this.startTask(task ?? undefined, images ?? undefined).catch((error) => { console.error("[Task#start] startTask failed:", error) }) } } /** * Like `start()`, but returns the underlying promise so callers (e.g. * `TaskScheduler`) can await task completion and gate concurrency. * Idempotent: subsequent calls return the same in-flight promise. */ public run(): Promise { if (this._runPromise !== undefined) { return this._runPromise } if (this._started) { // Already launched via constructor or start() — no promise to return. return Promise.resolve() } this._started = true this.startIdleTelemetryCheck() const { task, images } = this.metadata this._runPromise = this._isHistoryTask ? this.resumeTaskFromHistory() : task || images ? this.startTask(task ?? undefined, images ?? undefined) : Promise.resolve() return this._runPromise } private async startTask(task?: string, images?: string[]): Promise { try { // `conversationHistory` (for API) and `clineMessages` (for webview) // need to be in sync. // If the extension process were killed, then on restart the // `clineMessages` might not be empty, so we need to set it to [] when // we create a new Cline client (otherwise webview would show stale // messages from previous session). this.clineMessages = [] this.apiConversationHistory = [] // The todo list is already set in the constructor if initialTodos were provided // No need to add any messages - the todoList property is already set await this.providerRef.deref()?.postStateToWebviewWithoutTaskHistory() await this.say("text", task, images) // Check for too many MCP tools and warn the user const { enabledToolCount, enabledServerCount } = await this.getEnabledMcpToolsCount() if (enabledToolCount > MAX_MCP_TOOLS_THRESHOLD) { await this.say( "too_many_tools_warning", JSON.stringify({ toolCount: enabledToolCount, serverCount: enabledServerCount, threshold: MAX_MCP_TOOLS_THRESHOLD, }), undefined, undefined, undefined, undefined, { isNonInteractive: true }, ) } this.isInitialized = true const imageBlocks: Anthropic.ImageBlockParam[] = formatResponse.imageBlocks(images) // Task starting await this.initiateTaskLoop([ { type: "text", text: `\n${task}\n`, }, ...imageBlocks, ]).catch((error) => { // Swallow loop rejection when the task was intentionally abandoned/aborted // during delegation or user cancellation to prevent unhandled rejections. if (this.abandoned === true || this.abortReason === "user_cancelled") { return } throw error }) } catch (error) { // In tests and some UX flows, tasks can be aborted while `startTask` is still // initializing. Treat abort/abandon as expected and avoid unhandled rejections. if (this.abandoned === true || this.abort === true || this.abortReason === "user_cancelled") { return } throw error } } private async resumeTaskFromHistory() { try { const modifiedClineMessages = [...(await this.getSavedClineMessages())] if (this.abort || this.abandoned) { return } // Remove any resume messages that may have been added before. const lastRelevantMessageIndex = findLastIndex( modifiedClineMessages, (m) => !(m.ask === "resume_task" || m.ask === "resume_completed_task"), ) if (lastRelevantMessageIndex !== -1) { modifiedClineMessages.splice(lastRelevantMessageIndex + 1) } if (this.pendingAction) { const pendingAskIndex = findLastIndex( modifiedClineMessages, (message) => message.type === "ask" && message.ask === "tool" && message.isAnswered !== true && message.text === this.pendingAction?.approvalText, ) if (pendingAskIndex !== -1) { modifiedClineMessages.splice(pendingAskIndex, 1) } } // Incomplete reasoning has no matching API-history entry and would become // an orphaned bubble when the resumed request starts fresh reasoning. while (modifiedClineMessages.length > 0) { const lastMessage = modifiedClineMessages[modifiedClineMessages.length - 1] if (lastMessage.type === "say" && lastMessage.say === "reasoning" && lastMessage.partial === true) { modifiedClineMessages.pop() } else { break } } // Since we don't use `api_req_finished` anymore, we need to check if the // last `api_req_started` has a cost value, if it doesn't and no // cancellation reason to present, then we remove it since it indicates // an api request without any partial content streamed. const lastApiReqStartedIndex = findLastIndex( modifiedClineMessages, (m) => m.type === "say" && m.say === "api_req_started", ) if (lastApiReqStartedIndex !== -1) { const lastApiReqStarted = modifiedClineMessages[lastApiReqStartedIndex] const { cost, cancelReason }: ClineApiReqInfo = JSON.parse(lastApiReqStarted.text || "{}") if (cost === undefined && cancelReason === undefined) { modifiedClineMessages.splice(lastApiReqStartedIndex, 1) } } // Read API history before hydrating either side. If the task is aborted // or abandoned after the UI read completes but before this point, the // abort guard below will fire and neither history will be written. const savedApiConversationHistory = await this.getSavedApiConversationHistory() if (this.abort || this.abandoned) { return } // Avoid a standalone write during hydration. The resume ask will persist only // after all history reads succeed and the task is still active. this.hydrateClineMessages(modifiedClineMessages) // Now present the cline messages to the user and ask if they want to // resume (NOTE: we ran into a bug before where the // apiConversationHistory wouldn't be initialized when opening a old // task, and it was because we were waiting for resume). // This is important in case the user deletes messages without resuming // the task first. this.hydrateApiConversationHistory(savedApiConversationHistory) await this.settleInterruptedCreateSubtaskBeforeReplay() if ( this.pendingAction && this.apiConversationHistory.some( (message) => message.role === "user" && Array.isArray(message.content) && message.content.some( (block) => block.type === "tool_result" && block.tool_use_id === this.pendingAction?.actionId, ), ) ) { await this.clearPendingActionAfterDurableResult(this.pendingAction.actionId) } if (this.pendingAction) { this.isInitialized = true await this.resumePendingTaskAction(this.pendingAction) return } if (this.abort || this.abandoned) { return } const lastClineMessage = this.clineMessages .slice() .reverse() .find((m) => !(m.ask === "resume_task" || m.ask === "resume_completed_task")) // Could be multiple resume tasks. let askType: ClineAsk if (this.initialStatus === "completed" || lastClineMessage?.ask === "completion_result") { askType = "resume_completed_task" } else { askType = "resume_task" } this.isInitialized = true const { response, text, images } = await this.ask(askType) // Calls `postStateToWebview`. let responseText: string | undefined let responseImages: string[] | undefined if (response === "messageResponse") { await this.say("user_feedback", text, images) responseText = text responseImages = images } // Make sure that the api conversation history can be resumed by the API, // even if it goes out of sync with cline messages. const existingApiConversationHistory: ApiMessage[] = await this.getSavedApiConversationHistory() // Tool blocks are always preserved; native tool calling only. // if the last message is an assistant message, we need to check if there's tool use since every tool use has to have a tool response // if there's no tool use and only a text block, then we can just add a user message // (note this isn't relevant anymore since we use custom tool prompts instead of tool use blocks, but this is here for legacy purposes in case users resume old tasks) // if the last message is a user message, we can need to get the assistant message before it to see if it made tool calls, and if so, fill in the remaining tool responses with 'interrupted' let modifiedOldUserContent: Anthropic.Messages.ContentBlockParam[] // either the last message if its user message, or the user message before the last (assistant) message let modifiedApiConversationHistory: ApiMessage[] // need to remove the last user message to replace with new modified user message if (existingApiConversationHistory.length > 0) { const lastMessage = existingApiConversationHistory[existingApiConversationHistory.length - 1] if (lastMessage.isSummary) { // IMPORTANT: If the last message is a condensation summary, we must preserve it // intact. The summary message carries critical metadata (isSummary, condenseId) // that getEffectiveApiHistory() uses to filter out condensed messages. // Removing or merging it would destroy this metadata, causing all condensed // messages to become "orphaned" and restored to active status — effectively // undoing the condensation and sending the full history to the API. // See: https://github.com/RooCodeInc/Roo-Code/issues/11487 modifiedApiConversationHistory = [...existingApiConversationHistory] modifiedOldUserContent = [] } else if (lastMessage.role === "assistant") { const content = Array.isArray(lastMessage.content) ? lastMessage.content : [{ type: "text", text: lastMessage.content }] const hasToolUse = content.some((block) => block.type === "tool_use") if (hasToolUse) { const toolUseBlocks = content.filter( (block) => block.type === "tool_use", ) as Anthropic.Messages.ToolUseBlock[] // Mark the synthetic results as errors (is_error: true) so the persisted // history cannot be misread as a successful completion of the interrupted // tool calls (e.g. attempt_completion). The task list already records the // task as interrupted; the API history must agree. // See: https://github.com/Zoo-Code-Org/Zoo-Code/issues/1283 const toolResponses: Anthropic.ToolResultBlockParam[] = toolUseBlocks.map((block) => ({ type: "tool_result", tool_use_id: block.id, content: "Task was interrupted before this tool call could be completed.", is_error: true, })) modifiedApiConversationHistory = [...existingApiConversationHistory] // no changes modifiedOldUserContent = [...toolResponses] } else { modifiedApiConversationHistory = [...existingApiConversationHistory] modifiedOldUserContent = [] } } else if (lastMessage.role === "user") { const previousAssistantMessage: ApiMessage | undefined = existingApiConversationHistory[existingApiConversationHistory.length - 2] const existingUserContent: Anthropic.Messages.ContentBlockParam[] = Array.isArray( lastMessage.content, ) ? lastMessage.content : [{ type: "text", text: lastMessage.content }] if (previousAssistantMessage && previousAssistantMessage.role === "assistant") { const assistantContent = Array.isArray(previousAssistantMessage.content) ? previousAssistantMessage.content : [{ type: "text", text: previousAssistantMessage.content }] const toolUseBlocks = assistantContent.filter( (block) => block.type === "tool_use", ) as Anthropic.Messages.ToolUseBlock[] if (toolUseBlocks.length > 0) { const existingToolResults = existingUserContent.filter( (block) => block.type === "tool_result", ) as Anthropic.ToolResultBlockParam[] const missingToolResponses: Anthropic.ToolResultBlockParam[] = toolUseBlocks .filter( (toolUse) => !existingToolResults.some((result) => result.tool_use_id === toolUse.id), ) // is_error: true — same rationale as the assistant-last case above. // See: https://github.com/Zoo-Code-Org/Zoo-Code/issues/1283 .map((toolUse) => ({ type: "tool_result", tool_use_id: toolUse.id, content: "Task was interrupted before this tool call could be completed.", is_error: true, })) modifiedApiConversationHistory = existingApiConversationHistory.slice(0, -1) // removes the last user message modifiedOldUserContent = [...existingUserContent, ...missingToolResponses] } else { modifiedApiConversationHistory = existingApiConversationHistory.slice(0, -1) modifiedOldUserContent = [...existingUserContent] } } else { modifiedApiConversationHistory = existingApiConversationHistory.slice(0, -1) modifiedOldUserContent = [...existingUserContent] } } else { throw new Error("Unexpected: Last message is not a user or assistant message") } } else { throw new Error("Unexpected: No existing API conversation history") } const newUserContent: Anthropic.Messages.ContentBlockParam[] = [...modifiedOldUserContent] if (responseText) { newUserContent.push({ type: "text", text: `\n${responseText}\n`, }) } if (responseImages && responseImages.length > 0) { newUserContent.push(...formatResponse.imageBlocks(responseImages)) } // Ensure we have at least some content to send to the API. // If newUserContent is empty, add a minimal resumption message. if (newUserContent.length === 0) { newUserContent.push({ type: "text", text: "[TASK RESUMPTION] Resuming task...", }) } await this.overwriteApiConversationHistory(modifiedApiConversationHistory) // Task resuming from history item. await this.initiateTaskLoop(newUserContent) } catch (error) { // Resume and cancellation can race when users issue repeated cancels. // Treat intentional abort/abandon flows as expected and avoid process-level crashes. if (this.abandoned === true || this.abort === true || this.abortReason === "user_cancelled") { return } throw error } } private async resumePendingTaskAction(action: PendingTaskAction): Promise { const provider = this.providerRef.deref() if (!provider) { throw new Error(`[Task#resumePendingTaskAction] Provider unavailable for task ${this.taskId}`) } let { response, text, images, queuedMessageId } = await this.ask("tool", action.approvalText, false) if (response === "yesButtonClicked") { if (action.kind === "create_subtask") { await provider.delegateParentAndOpenChild({ parentTaskId: this.taskId, message: action.message, initialTodos: action.todos, mode: action.mode, pendingActionId: action.actionId, }) return } const didReopen = await provider.reopenParentFromDelegation({ parentTaskId: action.parentTaskId, childTaskId: this.taskId, completionResultSummary: action.result, pendingActionId: action.actionId, }) if (didReopen) { return } await this.clearPendingActionAfterDurableResult(action.actionId) if (this.pendingAction) { await this.resumePendingTaskAction(this.pendingAction) return } ;({ response, text, images, queuedMessageId } = await this.ask("completion_result", "", false)) if (response === "yesButtonClicked") { return } } if (queuedMessageId) { const persisted = await this.persistQueuedFeedbackAndAcknowledge(queuedMessageId, text, images) if (!persisted) { throw new Error( `[Task#resumePendingTaskAction] Failed to persist queued feedback ${queuedMessageId}; task loop was not resumed`, ) } } else if (text || images?.length) { await this.say("user_feedback", text ?? "", images) } const deniedContent = text ? formatResponse.toolDeniedWithFeedback(text) : formatResponse.toolDenied() await this.initiateTaskLoop([ { type: "tool_result", tool_use_id: action.actionId, content: formatResponse.toolResult(deniedContent, images), }, ]) } /** * Cancels the current HTTP request if one is in progress. * This immediately aborts the underlying stream rather than waiting for the next chunk. */ public cancelCurrentRequest(): void { if (this.currentRequestAbortController) { console.log(`[Task#${this.taskId}.${this.instanceId}] Aborting current HTTP request`) this.currentRequestAbortController.abort() this.currentRequestAbortController = undefined } // A metadata-fetch waiter still in flight is as stale as an abandoned // stream: detach it from the provider-side fetch on cancel/dispose. if (this.metadataFetchAbortController) { this.metadataFetchAbortController.abort() this.metadataFetchAbortController = undefined } } /** * Force emit a final token usage update, ignoring throttle. * Called before task completion or abort to ensure final stats are captured. * Triggers the debounce with current values and immediately flushes to ensure emit. */ public emitFinalTokenUsageUpdate(): void { const tokenUsage = this.getTokenUsage() this.debouncedEmitTokenUsage(tokenUsage, this.toolUsage) this.debouncedEmitTokenUsage.flush() } markInitialized(): void { this.isInitialized = true this.driveRunEvent({ tag: "initialized" }) } markStreamStarted(): void { this.isStreaming = true this.driveRunEvent({ tag: "streamStarted" }) } markStreamCleanupFinished(): void { this.didFinishAbortingStream = true this.driveRunEvent({ tag: "streamCleanupFinished" }) } markStreamEnded(): void { this.isStreaming = false this.driveRunEvent({ tag: "streamEnded" }) } markAskStarted(ask: Exclude): void { this.driveRunEvent({ tag: "askStarted", ask }) } markAskSettled(askTs: number): void { this.driveRunEvent({ tag: "askSettled", askTs }) } markCompletionAccepted(askTs: number): void { this.driveRunEvent({ tag: "completionAccepted", askTs }) } setAbortReason(reason: ClineApiReqCancelReason): void { this.abortReason ??= reason this.driveRunEvent({ tag: "reasonSet", reason }) } requestAbort(isAbandoned: boolean): void { if (isAbandoned) { this.abandoned = true } this.abort = true this.driveRunEvent({ tag: "abortRequested", abandoned: isAbandoned }) } requestAbandon(): void { this.abandoned = true this.driveRunEvent({ tag: "abandonRequested" }) } requestDispose(): void { this.abort = true this.driveRunEvent({ tag: "disposeRequested" }) } private driveRunEvent(event: RunEvent): void { this.runState = applyRunEvent(this.runState, event, this.runRejections) } public abortTask(isAbandoned = false): Promise { if (isAbandoned) { this.abandoned = true } this.abort = true this.cancelAssistantMessagePersistence() this.abortPromise ??= this.abortTaskOnce() return this.abortPromise } private async abortTaskOnce(): Promise { // Aborting task // Reset consecutive error counters on abort (manual intervention) this.consecutiveNoToolUseCount = 0 this.consecutiveNoAssistantMessagesCount = 0 // Force final token usage update before abort event this.emitFinalTokenUsageUpdate() try { await this.providerRef.deref()?.flushPostStateToWebviewThrottled() } catch (error) { console.error( `[Task#abortTask] flushPostStateToWebviewThrottled failed for ${this.taskId}.${this.instanceId}:`, error, ) } this.emit(RooCodeEventName.TaskAborted) try { void this.dispose().catch((error) => { console.error(`Error during task ${this.taskId}.${this.instanceId} disposal:`, error) }) // Reversion affects the user's workspace and must finish before the // final task state is saved. Artifact deletion is drained separately. await this.diffReversionPromise } catch (error) { console.error(`Error during task ${this.taskId}.${this.instanceId} disposal:`, error) // Don't rethrow - we want abort to always succeed } // Guard: a history task whose message load has not finished yet has // clineMessages = []. Saving now would call taskMetadata() with an // empty array, which writes the "no messages" placeholder as the // title and permanently clobbers the real title in the history store // (the "Work #1 (no message)" / "工作 #1 (無訊息)" bug, v3.76.0). // The on-disk data is still correct at this point, so skip the save. if (this._isHistoryTask && this.clineMessages.length === 0) { return } try { await this.saveClineMessages() } catch (error) { console.error(`Error saving messages during abort for task ${this.taskId}.${this.instanceId}:`, error) } } public dispose(): Promise { if (this.disposalPromise) { return this.disposalPromise } this.disposalPromise = this.disposeOnce() return this.disposalPromise } private async disposeOnce(): Promise { console.log(`[Task#dispose] disposing task ${this.taskId}.${this.instanceId}`) this.cancelAssistantMessagePersistence() // Stop the idle telemetry check and report any unflushed activity as a // shutdown installment, so a task torn down mid-work (panel closed, task // switched, extension deactivated) isn't invisible to telemetry. try { clearInterval(this.idleTelemetryCheckInterval) this.idleTelemetryCheckInterval = undefined this.flushTelemetryInstallment("shutdown") } catch (error) { console.error("Error flushing shutdown telemetry:", error) } // A task being disposed is no longer serving requests: set the same // cancellation state `abortTask()` sets, synchronously before the aborts // below, so the request-construction guard (`abort || abandoned` in // attemptApiRequest) and the outer loop's `abort` checks observe disposal // even when it lands before any explicit cancel. Without this, only the // signals below are cancelled and a request already past those checks // could still build tools and call `createMessage()`. this.abort = true // Cancel any in-progress HTTP request try { this.cancelCurrentRequest() } catch (error) { console.error("Error cancelling current request:", error) } // Dispose message queue and remove event listeners. try { if (this.messageQueueStateChangedHandler) { this.messageQueueService.removeListener("stateChanged", this.messageQueueStateChangedHandler) this.messageQueueStateChangedHandler = undefined } this.messageQueueService.dispose() } catch (error) { console.error("Error disposing message queue:", error) } // Remove all event listeners to prevent memory leaks. try { this.removeAllListeners() } catch (error) { console.error("Error removing event listeners:", error) } // Release any terminals associated with this task. try { // Release any terminals associated with this task. TerminalRegistry.releaseTerminalsForTask(this.taskId) } catch (error) { console.error("Error releasing terminals:", error) } // Cleanup command output artifacts const pendingCleanup = getTaskDirectoryPath(this.globalStoragePath, this.taskId) .then((taskDir) => { const outputDir = path.join(taskDir, "command-output") return OutputInterceptor.cleanup(outputDir) }) .catch((error) => { console.error("Error cleaning up command output artifacts:", error) }) try { if (this.rooIgnoreController) { this.rooIgnoreController.dispose() this.rooIgnoreController = undefined } } catch (error) { console.error("Error disposing RooIgnoreController:", error) // This is the critical one for the leak fix. } try { this.fileContextTracker.dispose() } catch (error) { console.error("Error disposing file context tracker:", error) } try { // If we're not streaming then `abortStream` won't be called. if (this.isStreaming && this.diffViewProvider.isEditing) { this.diffReversionPromise = this.diffViewProvider.revertChanges().catch(console.error) } } catch (error) { console.error("Error reverting diff changes:", error) } await pendingCleanup await this.diffReversionPromise } // Subtasks // Spawn / Wait / Complete public async startSubtask(message: string, initialTodos: TodoItem[], mode: string) { const provider = this.providerRef.deref() if (!provider) { throw new Error("Provider not available") } const child = await (provider as any).delegateParentAndOpenChild({ parentTaskId: this.taskId, message, initialTodos, mode, }) return child } /** * Resume parent task after delegation completion without showing resume ask. * Used in metadata-driven subtask flow. * * This method: * - Clears any pending ask states * - Resets abort and streaming flags * - Ensures next API call includes full context * - Immediately continues task loop without user interaction */ public async resumeAfterDelegation(): Promise { // Clear any ask states that might have been set during history load this.idleAsk = undefined this.resumableAsk = undefined this.interactiveAsk = undefined // Reset abort and streaming state to ensure clean continuation this.abort = false this.abandoned = false this.abortReason = undefined this.didFinishAbortingStream = false this.isStreaming = false this.isWaitingForFirstChunk = false // Ensure next API call includes full context after delegation this.skipPrevResponseIdOnce = true // Mark as initialized and active this.isInitialized = true this.emit(RooCodeEventName.TaskActive, this.taskId) // Load conversation history if not already loaded if (this.apiConversationHistory.length === 0) { this.hydrateApiConversationHistory(await this.getSavedApiConversationHistory()) } // Add environment details to the existing last user message (which contains the tool_result) // This avoids creating a new user message which would cause consecutive user messages const environmentDetails = await getEnvironmentDetails(this, true) let lastUserMsgIndex = -1 for (let i = this.apiConversationHistory.length - 1; i >= 0; i--) { if (this.apiConversationHistory[i].role === "user") { lastUserMsgIndex = i break } } if (lastUserMsgIndex >= 0) { const lastUserMsg = this.apiConversationHistory[lastUserMsgIndex] if (Array.isArray(lastUserMsg.content)) { // Remove any existing environment_details blocks before adding fresh ones const contentWithoutEnvDetails = lastUserMsg.content.filter( (block: Anthropic.Messages.ContentBlockParam) => { if (block.type === "text" && typeof block.text === "string") { const isEnvironmentDetailsBlock = block.text.trim().startsWith("") && block.text.trim().endsWith("") return !isEnvironmentDetailsBlock } return true }, ) // Add fresh environment details lastUserMsg.content = [...contentWithoutEnvDetails, { type: "text" as const, text: environmentDetails }] } } // Save the updated history await this.saveApiConversationHistory() // Continue task loop - pass empty array to signal no new user content needed // The initiateTaskLoop will handle this by skipping user message addition await this.initiateTaskLoop([]) } // Task Loop private async initiateTaskLoop(userContent: Anthropic.Messages.ContentBlockParam[]): Promise { // Kicks off the checkpoints initialization process in the background. // `getCheckpointService` wraps its full body in a try/catch and returns // `undefined` on failure (see src/core/checkpoints/index.ts), so the // returned promise cannot reject. `void` is sufficient — no `.catch` // arm needed. void getCheckpointService(this) let nextUserContent = userContent let includeFileDetails = true this.emit(RooCodeEventName.TaskStarted) while (!this.abort) { const didEndLoop = await this.recursivelyMakeClineRequests(nextUserContent, includeFileDetails) includeFileDetails = false // We only need file details the first time. // The way this agentic loop works is that cline will be given a // task that he then calls tools to complete. Unless there's an // attempt_completion call, we keep responding back to him with his // tool's responses until he either attempt_completion or does not // use anymore tools. If he does not use anymore tools, we ask him // to consider if he's completed the task and then call // attempt_completion, otherwise proceed with completing the task. // There is a MAX_REQUESTS_PER_TASK limit to prevent infinite // requests, but Cline is prompted to finish the task as efficiently // as he can. if (didEndLoop) { // For now a task never 'completes'. This will only happen if // the user hits max requests and denies resetting the count. break } else { nextUserContent = [{ type: "text", text: formatResponse.noToolsUsed() }] } } } public async recursivelyMakeClineRequests( userContent: Anthropic.Messages.ContentBlockParam[], includeFileDetails: boolean = false, ): Promise { interface StackItem { userContent: Anthropic.Messages.ContentBlockParam[] includeFileDetails: boolean retryAttempt?: number userMessageWasRemoved?: boolean // Track if user message was removed due to empty response } const stack: StackItem[] = [{ userContent, includeFileDetails, retryAttempt: 0 }] while (stack.length > 0) { const currentItem = stack.pop()! const currentUserContent = currentItem.userContent const currentIncludeFileDetails = currentItem.includeFileDetails if (this.abort) { throw new Error(`[RooCode#recursivelyMakeRooRequests] task ${this.taskId}.${this.instanceId} aborted`) } if (this.consecutiveMistakeLimit > 0 && this.consecutiveMistakeCount >= this.consecutiveMistakeLimit) { // Track consecutive mistake errors in telemetry via a named event. // The reason is "unknown" because many paths increment consecutiveMistakeCount // (no tool use, tool validation and tool execution failures) and none records its cause. TelemetryService.instance.captureConsecutiveMistakeError( this.taskId, "unknown", this.consecutiveMistakeCount, this.consecutiveMistakeLimit, ) const { response, text, images } = await this.ask( "mistake_limit_reached", t("common:errors.mistake_limit_guidance"), ) if (response === "messageResponse") { currentUserContent.push( ...[ { type: "text" as const, text: formatResponse.tooManyMistakes(text) }, ...formatResponse.imageBlocks(images), ], ) await this.say("user_feedback", text, images) } this.consecutiveMistakeCount = 0 } // Getting verbose details is an expensive operation, it uses ripgrep to // top-down build file structure of project which for large projects can // take a few seconds. For the best UX we show a placeholder api_req_started // message with a loading spinner as this happens. // Determine API protocol based on provider and model const modelId = getModelId(this.apiConfiguration) const apiProvider = this.apiConfiguration.apiProvider const apiProtocol = getApiProtocol( apiProvider && !isRetiredProvider(apiProvider) ? apiProvider : undefined, modelId, ) // Respect user-configured provider rate limiting BEFORE we emit api_req_started. // This prevents the UI from showing an "API Request..." spinner while we are // intentionally waiting due to the rate limit slider. // // NOTE: We also record the request time here to reserve this slot before // we build environment details (which can take time). This ensures // subsequent requests (including subtasks) still honour the // provider rate-limit window. await this.maybeWaitForProviderRateLimit(currentItem.retryAttempt ?? 0) this.rateLimitClock.recordRequest() await this.say( "api_req_started", JSON.stringify({ apiProtocol, }), ) const provider = this.providerRef.deref() const state = provider ? await provider.getState() : undefined const showRooIgnoredFiles = state?.showRooIgnoredFiles ?? false const includeDiagnosticMessages = state?.includeDiagnosticMessages ?? true const maxDiagnosticMessages = state?.maxDiagnosticMessages ?? 50 const currentMode = await this.getTaskMode() const { content: parsedUserContent, mode: slashCommandMode } = await processUserContentMentions({ userContent: currentUserContent, cwd: this.cwd, fileContextTracker: this.fileContextTracker, rooIgnoreController: this.rooIgnoreController, showRooIgnoredFiles, includeDiagnosticMessages, maxDiagnosticMessages, skillsManager: provider?.getSkillsManager(), currentMode, }) // Switch mode if specified in a slash command's frontmatter if (slashCommandMode) { const provider = this.providerRef.deref() if (provider) { const state = await provider.getState() const targetMode = getModeBySlug(slashCommandMode, state?.customModes) if (targetMode) { await provider.handleModeSwitch(slashCommandMode, null) } } } const environmentDetails = await getEnvironmentDetails(this, currentIncludeFileDetails) // Remove any existing environment_details blocks before adding fresh ones. // This prevents duplicate environment details when resuming tasks, // where the old user message content may already contain environment details from the previous session. // We check for both opening and closing tags to ensure we're matching complete environment detail blocks, // not just mentions of the tag in regular content. const contentWithoutEnvDetails = parsedUserContent.filter((block) => { if (block.type === "text" && typeof block.text === "string") { // Check if this text block is a complete environment_details block // by verifying it starts with the opening tag and ends with the closing tag const isEnvironmentDetailsBlock = block.text.trim().startsWith("") && block.text.trim().endsWith("") return !isEnvironmentDetailsBlock } return true }) // Add environment details as its own text block, separate from tool // results. const finalUserContent = [...contentWithoutEnvDetails, { type: "text" as const, text: environmentDetails }] // See shouldAddUserMessageToHistory for the full add/skip rules (retry/empty/removed). const isEmptyUserContent = currentUserContent.length === 0 const shouldAddUserMessage = shouldAddUserMessageToHistory({ retryAttempt: currentItem.retryAttempt, isEmptyUserContent, userMessageWasRemoved: currentItem.userMessageWasRemoved, }) if (shouldAddUserMessage) { await this.addToApiConversationHistory({ role: "user", content: finalUserContent }) this.messageCounts.user++ } // Since we sent off a placeholder api_req_started message to update the // webview while waiting to actually start the API request (to load // potential details for example), we need to update the text of that // message. const lastApiReqIndex = findLastIndex(this.clineMessages, (m) => m.say === "api_req_started") this.clineMessages[lastApiReqIndex].text = JSON.stringify({ apiProtocol, } satisfies ClineApiReqInfo) await this.saveClineMessages() await this.providerRef.deref()?.postStateToWebviewWithoutTaskHistory() try { let cacheWriteTokens = 0 let cacheReadTokens = 0 let inputTokens = 0 let outputTokens = 0 let totalCost: number | undefined // We can't use `api_req_finished` anymore since it's a unique case // where it could come after a streaming message (i.e. in the middle // of being updated or executed). // Fortunately `api_req_finished` was always parsed out for the GUI // anyways, so it remains solely for legacy purposes to keep track // of prices in tasks from history (it's worth removing a few months // from now). const updateApiReqMsg = (cancelReason?: ClineApiReqCancelReason, streamingFailedMessage?: string) => { if (lastApiReqIndex < 0 || !this.clineMessages[lastApiReqIndex]) { return } const existingData = JSON.parse(this.clineMessages[lastApiReqIndex].text || "{}") // Calculate total tokens and cost using provider-aware function const modelId = getModelId(this.apiConfiguration) const apiProvider = this.apiConfiguration.apiProvider const apiProtocol = getApiProtocol( apiProvider && !isRetiredProvider(apiProvider) ? apiProvider : undefined, modelId, ) const costResult = apiProtocol === "anthropic" ? calculateApiCostAnthropic( streamModelInfo, inputTokens, outputTokens, cacheWriteTokens, cacheReadTokens, ) : calculateApiCostOpenAI( streamModelInfo, inputTokens, outputTokens, cacheWriteTokens, cacheReadTokens, ) this.clineMessages[lastApiReqIndex].text = JSON.stringify({ ...existingData, tokensIn: costResult.totalInputTokens, tokensOut: costResult.totalOutputTokens, cacheWrites: cacheWriteTokens, cacheReads: cacheReadTokens, cost: totalCost ?? costResult.totalCost, cancelReason, streamingFailedMessage, } satisfies ClineApiReqInfo) } const abortStream = async (cancelReason: ClineApiReqCancelReason, streamingFailedMessage?: string) => { if (this.diffViewProvider.isEditing) { await this.diffViewProvider.revertChanges() // closes diff view } // if last message is a partial we need to update and save it const lastMessage = this.clineMessages.at(-1) if (lastMessage && lastMessage.partial) { // lastMessage.ts = Date.now() DO NOT update ts since it is used as a key for virtuoso list lastMessage.partial = false // instead of streaming partialMessage events, we do a save and post like normal to persist to disk } // Update `api_req_started` to have cancelled and cost, so that // we can display the cost of the partial stream and the cancellation reason updateApiReqMsg(cancelReason, streamingFailedMessage) await this.saveClineMessages() // Signals to provider that it can retrieve the saved messages // from disk, as abortTask can not be awaited on in nature. this.didFinishAbortingStream = true } // Reset streaming state for each new API request this.currentStreamingContentIndex = 0 this.currentStreamingDidCheckpoint = false this.assistantMessageContent = [] this.didCompleteReadingStream = false this.userMessageContent = [] this.userMessageContentReady = false this.didRejectTool = false this.didAlreadyUseTool = false this.assistantMessageSavedToHistory = false this.didFinishAbortingStream = false this.resetAssistantMessagePersistence() // Reset tool failure flag for each new assistant turn - this ensures that tool failures // only prevent attempt_completion within the same assistant message, not across turns // (e.g., if a tool fails, then user sends a message saying "just complete anyway") this.didToolFailInCurrentTurn = false // A blanket command denial only suppresses queued-message approval // for the turn that earned it: a message the user queues afterward // (a new turn) is fair game for the next ask to answer. this.blanketDeniedCommandThisTurn = false this.presentAssistantMessageLocked = false this.presentAssistantMessageHasPendingUpdates = false // No legacy text-stream tool parser. this.streamingToolCallIndices.clear() const nativeToolCallParserScope = NativeToolCallParser.createScope() await this.diffViewProvider.reset() await this.safeEnsureModelFetched() // Cache model info once per API request to avoid repeated calls during streaming // This is especially important for tools and background usage collection this.cachedStreamingModel = this.api.getModel() const streamModelInfo = this.cachedStreamingModel.info // Yields only if the first chunk is successful, otherwise will // allow the user to retry the request (most likely due to rate // limit error, which gets thrown on the first chunk). const stream = this.attemptApiRequest(currentItem.retryAttempt ?? 0, { skipProviderRateLimit: true, requestModelInfo: streamModelInfo, }) let assistantMessage = "" let reasoningMessage = "" const pendingGroundingSources: GroundingSource[] = [] this.isStreaming = true try { const iterator = stream[Symbol.asyncIterator]() // Helper to race iterator.next() with abort signal const nextChunkWithAbort = async () => { const nextPromise = iterator.next() // If we have an abort controller, race it with the next chunk if (this.currentRequestAbortController) { const abortPromise = new Promise((_, reject) => { const signal = this.currentRequestAbortController!.signal if (signal.aborted) { reject(new Error("Request cancelled by user")) } else { signal.addEventListener( "abort", () => { reject(new Error("Request cancelled by user")) }, { once: true }, ) } }) return await Promise.race([nextPromise, abortPromise]) } // No abort controller, just return the next chunk normally return await nextPromise } let item = await nextChunkWithAbort() while (!item.done) { const chunk = item.value item = await nextChunkWithAbort() if (!chunk) { // Sometimes chunk is undefined, no idea that can cause // it, but this workaround seems to fix it. continue } switch (chunk.type) { case "reasoning": { reasoningMessage += chunk.text // Only apply formatting if the message contains sentence-ending punctuation followed by ** let formattedReasoning = reasoningMessage if (reasoningMessage.includes("**")) { // Add line breaks before **Title** patterns that appear after sentence endings // This targets section headers like "...end of sentence.**Title Here**" // Handles periods, exclamation marks, and question marks formattedReasoning = reasoningMessage.replace( /([.!?])\*\*([^*\n]+)\*\*/g, "$1\n\n**$2**", ) } await this.say("reasoning", formattedReasoning, undefined, true) break } case "usage": inputTokens += chunk.inputTokens outputTokens += chunk.outputTokens cacheWriteTokens += chunk.cacheWriteTokens ?? 0 cacheReadTokens += chunk.cacheReadTokens ?? 0 totalCost = chunk.totalCost break case "grounding": // Handle grounding sources separately from regular content // to prevent state persistence issues - store them separately if (chunk.sources && chunk.sources.length > 0) { pendingGroundingSources.push(...chunk.sources) } break case "tool_call_partial": { // Process raw tool call chunk through NativeToolCallParser // which handles tracking, buffering, and emits events const events = NativeToolCallParser.processRawChunk( { index: chunk.index, id: chunk.id, name: chunk.name, arguments: chunk.arguments, }, nativeToolCallParserScope, ) for (const event of events) { if (event.type === "tool_call_start") { // Guard against duplicate tool_call_start events for the same tool ID. // This can occur due to stream retry, reconnection, or API quirks. // Without this check, duplicate tool_use blocks with the same ID would // be added to assistantMessageContent, causing API 400 errors: // "tool_use ids must be unique" // Use compound key (id, name) to distinguish different tools with the same call ID. const dedupKey = NativeToolCallParser.makeStreamingKey(event.id, event.name) if (this.streamingToolCallIndices.has(dedupKey)) { console.warn( `[Task#${this.taskId}] Ignoring duplicate tool_call_start for ID: ${event.id} (tool: ${event.name})`, ) continue } // A call ID that already appears in assistantMessageContent cannot be // round-tripped: the API history builder dedupes tool_use blocks by ID and // results are matched by tool_use_id, so a second entry with the same ID // would be orphaned. The compound-key check above already rejects an // identical (id, name) start in this stream, so reject any remaining start // that reuses an ID, regardless of the entry's displayed name: alias // resolution renames a streaming entry to its canonical tool (the streamed // name is kept in originalName), which would otherwise let a same-ID start // under the canonical name slip past a name comparison. The first call wins. const idAlreadyUsed = this.assistantMessageContent.some( (entry) => // Stryker disable next-line ConditionalExpression: only tool_use and mcp_tool_use entries ever carry an id — the text block pushed in the text case has no id field and the start events reaching this guard always carry the string id locked in by processRawChunk — so the type check cannot change the outcome of entry.id === event.id for any entry this pipeline produces; the false replacement is additionally killed by the rejection tests. (entry.type === "tool_use" || entry.type === "mcp_tool_use") && (entry as { id?: string }).id === event.id, ) if (idAlreadyUsed) { console.warn( `[Task#${this.taskId}] Ignoring tool_call_start reusing call ID ${event.id} (tool: ${event.name})`, ) continue } // Initialize streaming in NativeToolCallParser NativeToolCallParser.startStreamingToolCall( event.id, event.name as ToolName, nativeToolCallParserScope, ) // Before adding a new tool, finalize any preceding text block // This prevents the text block from blocking tool presentation const lastBlock = this.assistantMessageContent[this.assistantMessageContent.length - 1] if (lastBlock?.type === "text" && lastBlock.partial) { lastBlock.partial = false } // Track the index where this tool will be stored const toolUseIndex = this.assistantMessageContent.length this.streamingToolCallIndices.set(dedupKey, toolUseIndex) // Create initial partial tool use const partialToolUse: ToolUse = { type: "tool_use", name: event.name as ToolName, params: {}, partial: true, } // Store the ID for native protocol ;(partialToolUse as any).id = event.id // Add to content and present this.assistantMessageContent.push(partialToolUse) this.userMessageContentReady = false /* v8 ignore next -- streaming presenter; .catch lives in presentAssistantMessageSafe (covered) */ this.presentAssistantMessageSafe() } else if (event.type === "tool_call_delta") { // Deltas from the raw-chunk path carry the tracked name: build the // compound key directly so same-ID calls cannot share an // accumulator. Deltas without a name (legacy provider streams) // keep the deprecated single-entry-per-ID lookup. let streamingKey: string | undefined // Stryker disable next-line ConditionalExpression: event.name is always a string on this path — the only delta events reaching this handler are emitted by processRawChunk with the tracked start name (never undefined), so the legacy nameless branch below is unreachable defensive code and the conditional always takes the named side. if (event.name !== undefined) { streamingKey = NativeToolCallParser.makeStreamingKey(event.id, event.name) } else { const legacyEntry = NativeToolCallParser.getStreamingToolCallById( event.id, nativeToolCallParserScope, ) streamingKey = legacyEntry ? NativeToolCallParser.makeStreamingKey( legacyEntry.id, legacyEntry.name, ) : undefined } const partialToolUse = // Stryker disable next-line ConditionalExpression: streamingKey can never be undefined here — makeStreamingKey always returns a string and the legacy lookup branch above is unreachable (see the directive on the event.name check) — so the null side is unreachable defensive code. streamingKey === undefined ? null : NativeToolCallParser.processStreamingChunk( streamingKey, event.delta, nativeToolCallParserScope, ) // Stryker disable next-line ConditionalExpression, EqualityOperator: streamingKey is always a defined string on this path (makeStreamingKey never returns undefined; the legacy branch is unreachable per the directives above), and processStreamingChunk returns a partial ToolUse for a known key, so both defensive conditions reduce to the partialToolUse check and cannot change the observed behavior. if (partialToolUse && streamingKey !== undefined) { // Reuse the compound key built above: it is exactly the key the start event // registered under, so the delta lookup needs no second encoding. const toolUseIndex = this.streamingToolCallIndices.get(streamingKey) if (toolUseIndex !== undefined) { // Store the ID for native protocol ;(partialToolUse as any).id = event.id // Update the existing tool use with new partial data this.assistantMessageContent[toolUseIndex] = partialToolUse // Present updated tool use /* v8 ignore next -- streaming presenter; .catch lives in presentAssistantMessageSafe (covered) */ this.presentAssistantMessageSafe() } } } } break } case "tool_call": { // Legacy: Handle complete tool calls (for backward compatibility) // Convert native tool call to ToolUse format const toolUse = NativeToolCallParser.parseToolCall({ id: chunk.id, name: chunk.name as ToolName, arguments: chunk.arguments, }) if (!toolUse) { console.error(`Failed to parse tool call for task ${this.taskId}:`, chunk) break } // Store the tool call ID on the ToolUse object for later reference // This is needed to create tool_result blocks that reference the correct tool_use_id toolUse.id = chunk.id // Add the tool use to assistant message content this.assistantMessageContent.push(toolUse) // Mark that we have new content to process this.userMessageContentReady = false // Present the tool call to user - presentAssistantMessage will execute // tools sequentially and accumulate all results in userMessageContent /* v8 ignore next -- streaming presenter; .catch lives in presentAssistantMessageSafe (covered) */ this.presentAssistantMessageSafe() break } case "text": { assistantMessage += chunk.text // Native tool calling: text chunks are plain text. // Create or update a text content block directly const lastBlock = this.assistantMessageContent[this.assistantMessageContent.length - 1] if (lastBlock?.type === "text" && lastBlock.partial) { lastBlock.content = assistantMessage } else { this.assistantMessageContent.push({ type: "text", content: assistantMessage, partial: true, }) this.userMessageContentReady = false } /* v8 ignore next -- streaming presenter; .catch lives in presentAssistantMessageSafe (covered) */ this.presentAssistantMessageSafe() break } } if (this.abort) { console.log(`aborting stream, this.abandoned = ${this.abandoned}`) if (!this.abandoned) { // Only need to gracefully abort if this instance // isn't abandoned (sometimes OpenRouter stream // hangs, in which case this would affect future // instances of Cline). await abortStream("user_cancelled") } break // Aborts the stream. } if (this.didRejectTool) { // `userContent` has a tool rejection, so interrupt the // assistant's response to present the user's feedback. assistantMessage += "\n\n[Response interrupted by user feedback]" // Instead of setting this preemptively, we allow the // present iterator to finish and set // userMessageContentReady when its ready. // this.userMessageContentReady = true break } if (this.didAlreadyUseTool) { assistantMessage += "\n\n[Response interrupted by a tool use result. Only one tool may be used at a time and should be placed at the end of the message.]" break } } // Create a copy of current token values to avoid race conditions const currentTokens = { input: inputTokens, output: outputTokens, cacheWrite: cacheWriteTokens, cacheRead: cacheReadTokens, total: totalCost, } const drainStreamInBackgroundToFindAllUsage = async ( apiReqIndex: number, status: "completed" | "cancelled" = "completed", ) => { const timeoutMs = DEFAULT_USAGE_COLLECTION_TIMEOUT_MS const startTime = performance.now() const modelId = getModelId(this.apiConfiguration) // Local variables to accumulate usage data without affecting the main flow let bgInputTokens = currentTokens.input let bgOutputTokens = currentTokens.output let bgCacheWriteTokens = currentTokens.cacheWrite let bgCacheReadTokens = currentTokens.cacheRead let bgTotalCost = currentTokens.total // Helper function to capture telemetry and update messages const captureUsageData = async ( tokens: { input: number output: number cacheWrite: number cacheRead: number total?: number }, messageIndex: number = apiReqIndex, _status: "completed" | "cancelled" = "completed", ) => { if ( tokens.input > 0 || tokens.output > 0 || tokens.cacheWrite > 0 || tokens.cacheRead > 0 ) { // Update the shared variables atomically inputTokens = tokens.input outputTokens = tokens.output cacheWriteTokens = tokens.cacheWrite cacheReadTokens = tokens.cacheRead totalCost = tokens.total // Update the API request message with the latest usage data updateApiReqMsg() await this.saveClineMessages() // Update the specific message in the webview const apiReqMessage = this.clineMessages[messageIndex] if (apiReqMessage) { await this.updateClineMessage(apiReqMessage) } // Capture telemetry with provider-aware cost calculation const modelId = getModelId(this.apiConfiguration) const apiProvider = this.apiConfiguration.apiProvider const apiProtocol = getApiProtocol( apiProvider && !isRetiredProvider(apiProvider) ? apiProvider : undefined, modelId, ) // Use the appropriate cost function based on the API protocol const costResult = apiProtocol === "anthropic" ? calculateApiCostAnthropic( streamModelInfo, tokens.input, tokens.output, tokens.cacheWrite, tokens.cacheRead, ) : calculateApiCostOpenAI( streamModelInfo, tokens.input, tokens.output, tokens.cacheWrite, tokens.cacheRead, ) TelemetryService.instance.captureLlmCompletion(this.taskId, { inputTokens: costResult.totalInputTokens, outputTokens: costResult.totalOutputTokens, cacheWriteTokens: tokens.cacheWrite, cacheReadTokens: tokens.cacheRead, cost: tokens.total ?? costResult.totalCost, }) } } try { // Continue processing the original stream from where the main loop left off let usageFound = false let chunkCount = 0 // Use the same iterator that the main loop was using while (!item.done) { // Check for timeout if (performance.now() - startTime > timeoutMs) { console.warn( `[Background Usage Collection] Timed out after ${timeoutMs}ms for model: ${modelId}, processed ${chunkCount} chunks`, ) // Clean up the iterator before breaking if (iterator.return) { await iterator.return(undefined) } break } const chunk = item.value item = await iterator.next() chunkCount++ if (chunk && chunk.type === "usage") { usageFound = true bgInputTokens += chunk.inputTokens bgOutputTokens += chunk.outputTokens bgCacheWriteTokens += chunk.cacheWriteTokens ?? 0 bgCacheReadTokens += chunk.cacheReadTokens ?? 0 bgTotalCost = chunk.totalCost } } if ( usageFound || bgInputTokens > 0 || bgOutputTokens > 0 || bgCacheWriteTokens > 0 || bgCacheReadTokens > 0 ) { // We have usage data either from a usage chunk or accumulated tokens await captureUsageData( { input: bgInputTokens, output: bgOutputTokens, cacheWrite: bgCacheWriteTokens, cacheRead: bgCacheReadTokens, total: bgTotalCost, }, lastApiReqIndex, status, ) } else { console.warn( `[Background Usage Collection] Suspicious: request ${apiReqIndex} is complete, but no usage info was found. Model: ${modelId}`, ) } } catch (error) { console.error("Error draining stream for usage data:", error) // Still try to capture whatever usage data we have collected so far if ( bgInputTokens > 0 || bgOutputTokens > 0 || bgCacheWriteTokens > 0 || bgCacheReadTokens > 0 ) { await captureUsageData( { input: bgInputTokens, output: bgOutputTokens, cacheWrite: bgCacheWriteTokens, cacheRead: bgCacheReadTokens, total: bgTotalCost, }, lastApiReqIndex, status, ) } } } // Start the background task and handle any errors // Pass "cancelled" status if the task was aborted by the user drainStreamInBackgroundToFindAllUsage( lastApiReqIndex, this.abort ? "cancelled" : "completed", ).catch((error) => { console.error("Background usage collection failed:", error) }) } catch (error) { // Abandoned happens when extension is no longer waiting for the // Cline instance to finish aborting (error is thrown here when // any function in the for loop throws due to this.abort). if (!this.abandoned) { // Determine cancellation reason const cancelReason: ClineApiReqCancelReason = this.abort ? "user_cancelled" : "streaming_failed" const rawErrorMessage = error.message ?? JSON.stringify(serializeError(error), null, 2) const streamingFailedMessage = this.abort ? undefined : `${t("common:interruption.streamTerminatedByProvider")}: ${rawErrorMessage}` // Clean up partial state await abortStream(cancelReason, streamingFailedMessage) if (this.abort) { // ??= keeps the first reason; a cancel can land during abortStream after cancelReason was already computed. this.abortReason ??= "user_cancelled" await this.abortTask() } else if (error instanceof OutputTokenLimitError) { // Truncation repeats on an identical request, so never auto-retry it // (even with auto-approval); let the user decide once. const { response } = await this.ask("api_req_failed", rawErrorMessage) if (response !== "yesButtonClicked") { throw new Error("API request failed") } await this.say("api_req_retried") stack.push({ userContent: currentUserContent, includeFileDetails: false, retryAttempt: 0, }) continue } else { // Stream failed - log the error and retry with the same content // The existing rate limiting will prevent rapid retries console.error( `[Task#${this.taskId}.${this.instanceId}] Stream failed, will retry: ${streamingFailedMessage}`, ) // Apply exponential backoff similar to first-chunk errors when auto-resubmit is enabled const stateForBackoff = await this.providerRef.deref()?.getState() if (stateForBackoff?.autoApprovalEnabled) { await this.backoffAndAnnounce(currentItem.retryAttempt ?? 0, error) // Check if task was aborted during the backoff if (this.abort) { console.log( `[Task#${this.taskId}.${this.instanceId}] Task aborted during mid-stream retry backoff`, ) this.abortReason ??= "user_cancelled" await this.abortTask() break } } // Push the same content back onto the stack to retry, incrementing the retry attempt counter stack.push({ userContent: currentUserContent, includeFileDetails: false, retryAttempt: (currentItem.retryAttempt ?? 0) + 1, }) // Continue to retry the request continue } } } finally { this.isStreaming = false // Clean up the abort controller when streaming completes this.currentRequestAbortController = undefined } // Need to call here in case the stream was aborted. if (this.abort || this.abandoned) { throw new Error( `[RooCode#recursivelyMakeRooRequests] task ${this.taskId}.${this.instanceId} aborted`, ) } this.didCompleteReadingStream = true // Set any blocks to be complete to allow `presentAssistantMessage` // to finish and set `userMessageContentReady` to true. // (Could be a text block that had no subsequent tool uses, or a // text block at the very end, or an invalid tool use, etc. Whatever // the case, `presentAssistantMessage` relies on these blocks either // to be completed or the user to reject a block in order to proceed // and eventually set userMessageContentReady to true.) // Finalize any remaining streaming tool calls that weren't explicitly ended // This is critical for MCP tools which need tool_call_end events to be properly // converted from ToolUse to McpToolUse via finalizeStreamingToolCall() const finalizeEvents = NativeToolCallParser.finalizeRawChunks(nativeToolCallParserScope) for (const event of finalizeEvents) { if (event.type === "tool_call_end") { // Create compound key for deduplication (same pattern as streaming handler). // End events carry the tool name; fall back to the id for events that don't. // Stryker disable next-line LogicalOperator: event.name is always a string on this path — finalizeRawChunks emits end events with the tracked start name (a string, set before the tracker can start), so the ?? event.id fallback guards a nameless end event this pipeline cannot produce. let eventName = event.name ?? event.id let dedupKey = NativeToolCallParser.makeStreamingKey(event.id, eventName) // Finalize the streaming tool call let finalToolUse = NativeToolCallParser.finalizeStreamingToolCall( dedupKey, nativeToolCallParserScope, ) // Get the index for this tool call using compound key let toolUseIndex = this.streamingToolCallIndices.get(dedupKey) // Defensive resolution: if the end event's name does not match the start // name (provider quirk), the compound-key lookup above misses and both the // parser entry and the dedup tracking would stay stale. Resolve the tracked // entry by id and target the real compound key for finalization and cleanup. // Stryker disable next-line ConditionalExpression, LogicalOperator, EqualityOperator: the both-miss case is unreachable via the raw-chunk pipeline — end events carry the locked start name so the compound key resolves the registered entry, and a same-ID rejected-start end is always processed after the accepted start's end has already removed that entry; this defensive path protects against a cross-name end event the pipeline cannot produce. if (finalToolUse === null && toolUseIndex === undefined) { const resolved = NativeToolCallParser.getStreamingToolCallById( event.id, nativeToolCallParserScope, ) // Stryker disable next-line ConditionalExpression: unreachable defensive region — getStreamingToolCallById can only resolve when the both-miss case above is reachable, which the raw-chunk pipeline cannot produce (see the directive there). if (resolved) { eventName = resolved.name dedupKey = NativeToolCallParser.makeStreamingKey(resolved.id, resolved.name) finalToolUse = NativeToolCallParser.finalizeStreamingToolCall( dedupKey, nativeToolCallParserScope, ) toolUseIndex = this.streamingToolCallIndices.get(dedupKey) } } if (finalToolUse) { // Store the tool call ID ;(finalToolUse as any).id = event.id // Get the index and replace partial with final if (toolUseIndex !== undefined) { this.assistantMessageContent[toolUseIndex] = finalToolUse } // Clean up tracking using compound key this.streamingToolCallIndices.delete(dedupKey) // Mark that we have new content to process this.userMessageContentReady = false // Present the finalized tool call /* v8 ignore next -- streaming presenter; .catch lives in presentAssistantMessageSafe (covered) */ this.presentAssistantMessageSafe() } else if (toolUseIndex !== undefined) { // finalizeStreamingToolCall returned null (malformed JSON or missing args). // existingToolUse is the same object the streaming phase was mutating in // place, so it still carries nativeArgs AND params built from the incomplete // partial parse (e.g. a truncated write_to_file `content` string) - both were // only ever meant for live progress display, never for execution or for // ending up in conversation history. Mark the tool as non-partial so it's // presented as complete, and clear both so presentAssistantMessage's // `!block.nativeArgs` guard short-circuits with a structured tool_result // instead of executing the truncated value, and so the toolUse.nativeArgs || // toolUse.params fallback used when recording history doesn't fall through to // the same truncated data under a different name. const existingToolUse = this.assistantMessageContent[toolUseIndex] if (existingToolUse && existingToolUse.type === "tool_use") { existingToolUse.partial = false existingToolUse.nativeArgs = undefined existingToolUse.params = {} // Ensure it has the ID for native protocol ;(existingToolUse as any).id = event.id } // Clean up tracking using compound key this.streamingToolCallIndices.delete(dedupKey) // Mark that we have new content to process this.userMessageContentReady = false // Present the tool call - validation will handle missing params /* v8 ignore next -- streaming presenter; .catch lives in presentAssistantMessageSafe (covered) */ this.presentAssistantMessageSafe() } } } // IMPORTANT: Capture partialBlocks AFTER finalizeRawChunks() to avoid double-presentation. // Tools finalized above are already presented, so we only want blocks still partial after finalization. const partialBlocks = this.assistantMessageContent.filter((block) => block.partial) partialBlocks.forEach((block) => (block.partial = false)) // Can't just do this b/c a tool could be in the middle of executing. // this.assistantMessageContent.forEach((e) => (e.partial = false)) // No legacy streaming parser to finalize. // Note: updateApiReqMsg() is now called from within drainStreamInBackgroundToFindAllUsage // to ensure usage data is captured even when the stream is interrupted. The background task // uses local variables to accumulate usage data before atomically updating the shared state. // Complete the reasoning message if it exists // We can't use say() here because the reasoning message may not be the last message // (other messages like text blocks or tool uses may have been added after it during streaming) if (reasoningMessage) { const lastReasoningIndex = findLastIndex( this.clineMessages, (m) => m.type === "say" && m.say === "reasoning", ) if (lastReasoningIndex !== -1 && this.clineMessages[lastReasoningIndex].partial) { this.clineMessages[lastReasoningIndex].partial = false await this.updateClineMessage(this.clineMessages[lastReasoningIndex]) } } await this.saveClineMessages() await this.providerRef.deref()?.postStateToWebviewWithoutTaskHistory() // No legacy text-stream tool parser state to reset. // CRITICAL: Save assistant message to API history BEFORE executing tools. // This ensures that when new_task triggers delegation and calls flushPendingToolResultsToHistory(), // the assistant message is already in history. Otherwise, tool_result blocks would appear // BEFORE their corresponding tool_use blocks, causing API errors. // Check if we have any content to process (text or tool uses) const hasTextContent = assistantMessage.length > 0 const hasToolUses = this.assistantMessageContent.some( (block) => block.type === "tool_use" || block.type === "mcp_tool_use", ) if (hasTextContent || hasToolUses) { // Reset counter when we get a successful response with content this.consecutiveNoAssistantMessagesCount = 0 // Display grounding sources to the user if they exist if (pendingGroundingSources.length > 0) { const citationLinks = pendingGroundingSources.map((source, i) => `[${i + 1}](${source.url})`) const sourcesText = `${t("common:gemini.sources")} ${citationLinks.join(", ")}` await this.say("text", sourcesText, undefined, false, undefined, undefined, { isNonInteractive: true, }) } // Build the assistant message content array const assistantContent: Array = [] // Add text content if present if (assistantMessage) { assistantContent.push({ type: "text" as const, text: assistantMessage, }) } // Add tool_use blocks with their IDs for native protocol // This handles both regular ToolUse and McpToolUse types // IMPORTANT: Track seen IDs to prevent duplicates in the API request. // Duplicate tool_use IDs cause Anthropic API 400 errors: // "tool_use ids must be unique" const seenToolUseIds = new Set() const toolUseBlocks = this.assistantMessageContent.filter( (block) => block.type === "tool_use" || block.type === "mcp_tool_use", ) for (const block of toolUseBlocks) { if (block.type === "mcp_tool_use") { // McpToolUse already has the original tool name (e.g., "mcp_serverName_toolName") // The arguments are the raw tool arguments (matching the simplified schema) const mcpBlock = block as import("../../shared/tools").McpToolUse if (mcpBlock.id) { const sanitizedId = sanitizeToolUseId(mcpBlock.id) // Pre-flight deduplication: Skip if we've already added this ID if (seenToolUseIds.has(sanitizedId)) { console.warn( `[Task#${this.taskId}] Pre-flight deduplication: Skipping duplicate MCP tool_use ID: ${sanitizedId} (tool: ${mcpBlock.name})`, ) continue } seenToolUseIds.add(sanitizedId) assistantContent.push({ type: "tool_use" as const, id: sanitizedId, name: mcpBlock.name, // Original dynamic name input: mcpBlock.arguments, // Direct tool arguments }) } } else { // Regular ToolUse const toolUse = block as import("../../shared/tools").ToolUse const toolCallId = toolUse.id if (toolCallId) { const sanitizedId = sanitizeToolUseId(toolCallId) // Pre-flight deduplication: Skip if we've already added this ID if (seenToolUseIds.has(sanitizedId)) { console.warn( `[Task#${this.taskId}] Pre-flight deduplication: Skipping duplicate tool_use ID: ${sanitizedId} (tool: ${toolUse.name})`, ) continue } seenToolUseIds.add(sanitizedId) // nativeArgs is already in the correct API format for all tools const input = toolUse.nativeArgs || toolUse.params // Use originalName (alias) if present for API history consistency. // When tool aliases are used (e.g., "edit_file" -> "search_and_replace" -> "edit" (current canonical name)), // we want the alias name in the conversation history to match what the model // was told the tool was named, preventing confusion in multi-turn conversations. const toolNameForHistory = toolUse.originalName ?? toolUse.name assistantContent.push({ type: "tool_use" as const, id: sanitizedId, name: toolNameForHistory, input, }) } } } // Enforce new_task isolation: if new_task is called alongside other tools, // truncate any tools that come after it and inject error tool_results. // This prevents orphaned tools when delegation disposes the parent task. const newTaskIndex = assistantContent.findIndex( (block) => block.type === "tool_use" && block.name === "new_task", ) if (newTaskIndex !== -1 && newTaskIndex < assistantContent.length - 1) { // new_task found but not last - truncate subsequent tools const truncatedTools = assistantContent.slice(newTaskIndex + 1) assistantContent.length = newTaskIndex + 1 // Truncate API history array // ALSO truncate the execution array (assistantMessageContent) to prevent // tools after new_task from being executed by presentAssistantMessage(). // Find new_task index in assistantMessageContent (may differ from assistantContent // due to text blocks being structured differently). const executionNewTaskIndex = this.assistantMessageContent.findIndex( (block) => block.type === "tool_use" && block.name === "new_task", ) if (executionNewTaskIndex !== -1) { this.assistantMessageContent.length = executionNewTaskIndex + 1 } // Pre-inject error tool_results for truncated tools for (const tool of truncatedTools) { if (tool.type === "tool_use" && (tool as Anthropic.ToolUseBlockParam).id) { this.pushToolResultToUserContent({ type: "tool_result", tool_use_id: (tool as Anthropic.ToolUseBlockParam).id, content: "This tool was not executed because new_task was called in the same message turn. The new_task tool must be the last tool in a message.", is_error: true, }) } } } // Save assistant message BEFORE executing tools // This is critical for new_task: when it triggers delegation, flushPendingToolResultsToHistory() // will save the user message with tool_results. The assistant message must already be in history // so that tool_result blocks appear AFTER their corresponding tool_use blocks. await this.addToApiConversationHistory( { role: "assistant", content: assistantContent }, reasoningMessage || undefined, ) this.messageCounts.assistant++ } // Present any partial blocks that were just completed. // Tool calls are typically presented during streaming via tool_call_partial events, // but we still present here if any partial blocks remain (e.g., malformed streams). // NOTE: This MUST happen AFTER saving the assistant message to API history. // When new_task is in the batch, it triggers delegation which calls flushPendingToolResultsToHistory(). // If the assistant message isn't saved yet, tool_results would appear before tool_use blocks. if (partialBlocks.length > 0) { // If there is content to update then it will complete and // update `this.userMessageContentReady` to true, which we // `pWaitFor` before making the next request. /* v8 ignore next -- streaming presenter; .catch lives in presentAssistantMessageSafe (covered) */ this.presentAssistantMessageSafe() } if (hasTextContent || hasToolUses) { // NOTE: This comment is here for future reference - this was a // workaround for `userMessageContent` not getting set to true. // It was due to it not recursively calling for partial blocks // when `didRejectTool`, so it would get stuck waiting for a // partial block to complete before it could continue. // In case the content blocks finished it may be the api stream // finished after the last parsed content block was executed, so // we are able to detect out of bounds and set // `userMessageContentReady` to true (note you should not call // `presentAssistantMessage` since if the last block i // completed it will be presented again). // const completeBlocks = this.assistantMessageContent.filter((block) => !block.partial) // If there are any partial blocks after the stream ended we can consider them invalid. // if (this.currentStreamingContentIndex >= completeBlocks.length) { // this.userMessageContentReady = true // } await pWaitFor( () => this.userMessageContentReady || this.hasCompleteToolResultsForCurrentTurn() || this.abort || this.abandoned, ) if (this.abort || this.abandoned) { throw new Error( `[RooCode#recursivelyMakeRooRequests] task ${this.taskId}.${this.instanceId} aborted`, ) } // If the model did not tool use, then we need to tell it to // either use a tool or attempt_completion. const didToolUse = this.assistantMessageContent.some( (block) => block.type === "tool_use" || block.type === "mcp_tool_use", ) if (!didToolUse) { // Increment consecutive no-tool-use counter this.consecutiveNoToolUseCount++ // Only show error and count toward mistake limit after 2 consecutive failures if (this.consecutiveNoToolUseCount >= 2) { await this.say("error", "MODEL_NO_TOOLS_USED") // Only count toward mistake limit after second consecutive failure this.consecutiveMistakeCount++ } // Use the task's locked protocol for consistent behavior this.userMessageContent.push({ type: "text", text: formatResponse.noToolsUsed(), }) } else { // Reset counter when tools are used successfully this.consecutiveNoToolUseCount = 0 } if (this.userMessageContent.length > 0) { stack.push({ userContent: [...this.userMessageContent], // Create a copy to avoid mutation issues includeFileDetails: false, // Subsequent iterations don't need file details }) // Add periodic yielding to prevent blocking await new Promise((resolve) => setImmediate(resolve)) } continue } else { // If there's no assistant_responses, that means we got no text // or tool_use content blocks from API which we should assume is // an error. // Increment consecutive no-assistant-messages counter this.consecutiveNoAssistantMessagesCount++ // Only show error and count toward mistake limit after 2 consecutive failures // This provides a "grace retry" - first failure retries silently if (this.consecutiveNoAssistantMessagesCount >= 2) { await this.say("error", "MODEL_NO_ASSISTANT_MESSAGES") } // IMPORTANT: We already added the user message to // apiConversationHistory at line 1876. Since the assistant failed to respond, // we need to remove that message before retrying to avoid having two consecutive // user messages (which would cause tool_result validation errors). const state = await this.providerRef.deref()?.getState() // Only pop the user message that this iteration added. When // shouldAddUserMessage is false (empty continuation, resumed history, // or flushPendingToolResultsToHistory message) there is nothing to // remove, and popping would corrupt history. let removedCurrentUserMessage = false if (shouldAddUserMessage && this.apiConversationHistory.length > 0) { const lastMessage = this.apiConversationHistory[this.apiConversationHistory.length - 1] if (lastMessage.role === "user") { this.apiConversationHistory.pop() this.messageCounts.user-- removedCurrentUserMessage = true } } // Check if we should auto-retry or prompt the user // Reuse the state variable from above if (state?.autoApprovalEnabled) { // Auto-retry with backoff - don't persist failure message when retrying await this.backoffAndAnnounce( currentItem.retryAttempt ?? 0, new Error( "Unexpected API Response: The language model did not provide any assistant messages. This may indicate an issue with the API or the model's output.", ), ) // Check if task was aborted during the backoff if (this.abort) { console.log( `[Task#${this.taskId}.${this.instanceId}] Task aborted during empty-assistant retry backoff`, ) break } // Push the same content back onto the stack to retry, incrementing the retry attempt counter. // Only mark userMessageWasRemoved when we actually removed one -- the // restore branch in shouldAddUserMessageToHistory must only fire once. stack.push({ userContent: currentUserContent, includeFileDetails: false, retryAttempt: (currentItem.retryAttempt ?? 0) + 1, userMessageWasRemoved: removedCurrentUserMessage, }) // Continue to retry the request continue } else { // Prompt the user for retry decision const { response } = await this.ask( "api_req_failed", "The model returned no assistant messages. This may indicate an issue with the API or the model's output.", ) if (response === "yesButtonClicked") { await this.say("api_req_retried") // Push the same content back to retry. Only mark userMessageWasRemoved // when we actually removed one so the restore fires exactly once. stack.push({ userContent: currentUserContent, includeFileDetails: false, retryAttempt: (currentItem.retryAttempt ?? 0) + 1, userMessageWasRemoved: removedCurrentUserMessage, }) // Continue to retry the request continue } else { // User declined to retry. Re-add the user message only if this // iteration removed one, so the history and counter stay consistent. if (removedCurrentUserMessage) { await this.addToApiConversationHistory({ role: "user", content: currentUserContent, }) this.messageCounts.user++ } await this.say( "error", "Unexpected API Response: The language model did not provide any assistant messages. This may indicate an issue with the API or the model's output.", ) // Synthetic assistant message recording the failure -- increment // messageCounts.assistant to match, same as the normal // assistant-message-saved path. await this.addToApiConversationHistory({ role: "assistant", content: [{ type: "text", text: "Failure: I did not provide a response." }], }) this.messageCounts.assistant++ } } } // If we reach here without continuing, return false (will always be false for now) return false } catch { // This should never happen since the only thing that can throw an // error is the attemptApiRequest, which is wrapped in a try catch // that sends an ask where if noButtonClicked, will clear current // task and destroy this instance. However to avoid unhandled // promise rejection, we will end this loop which will end execution // of this instance (see `startTask`). return true // Needs to be true so parent loop knows to end task. } } // If we exit the while loop normally (stack is empty), return false return false } /** * Builds the SYSTEM_PROMPT from the caller's provider-state snapshot. This * method never reads provider state itself: callers that also construct * runtime tools for the same request (attemptApiRequest, condenseContext, * handleContextWindowExceededError) must thread the very snapshot they build * those tools from, so the prompt and the runtime tool array resolve from one * consistent set of values — otherwise a settings change during the MCP wait * can make the prompt advertise a tool the runtime rejects, or hide a * callable tool. An `undefined` snapshot declares that the caller's own read * came back empty because the provider was already gone; the prompt then * resolves from defaults. Pass `requestModelInfo` (captured via * safeEnsureModelFetched) in the same situation so the prompt's tool * guidance and the request's tool arrays resolve from one model-metadata * snapshot. */ private async getSystemPrompt( requestState: Awaited> | undefined, requestModelInfo?: ModelInfo, ): Promise { const { mcpEnabled } = requestState ?? {} let mcpHub: McpHub | undefined if (mcpEnabled ?? true) { const provider = this.providerRef.deref() if (!provider) { throw new Error("Provider reference lost during view transition") } // Wait for MCP hub initialization through McpServerManager mcpHub = await McpServerManager.getInstance(provider.context, provider) if (!mcpHub) { throw new Error("Failed to get MCP hub from server manager") } // Wait for MCP servers to be connected before generating system prompt await pWaitFor(() => !mcpHub!.isConnecting, { timeout: 10_000 }).catch(() => { console.error("MCP servers failed to connect in time") }) } const rooIgnoreInstructions = this.rooIgnoreController?.getInstructions() const { customModes, customModePrompts, customInstructions, experiments, language, enableSubfolderRules } = requestState ?? {} // Use task-local values, not provider state, to prevent cross-task configuration leaks. const mode = await this.getTaskMode() const apiConfiguration = this.apiConfiguration return await (async () => { const provider = this.providerRef.deref() if (!provider) { throw new Error("Provider not available") } // Load dynamically discovered model metadata (router providers) before // reading it, so the prompt's included/excluded tool guidance matches // the runtime path; prefer the caller's per-request snapshot when threaded. const modelInfo = requestModelInfo ?? (await this.safeEnsureModelFetched()) return SYSTEM_PROMPT( provider.context, this.cwd, false, mcpHub, this.diffStrategy, mode ?? defaultModeSlug, customModePrompts, customModes, customInstructions, experiments, language, rooIgnoreInstructions, { todoListEnabled: apiConfiguration?.todoListEnabled ?? true, useAgentRules: vscode.workspace.getConfiguration(Package.name).get("useAgentRules") ?? true, enableSubfolderRules: enableSubfolderRules ?? false, newTaskRequireTodos: vscode.workspace .getConfiguration(Package.name) .get("newTaskRequireTodos", false), isStealthModel: modelInfo?.isStealthModel, }, undefined, // todoList this.api.getModel().id, provider.getSkillsManager(), requestState?.disabledTools, modelInfo, ) })() } private getCurrentProfileId(state: any): string { return ( state?.listApiConfigMeta?.find((profile: any) => profile.name === state?.currentApiConfigName)?.id ?? "default" ) } /** * Ensures router-provider model metadata is loaded before getModel() is used for * context management or streaming. Failures fall back to hardcoded defaults rather * than aborting the task; the wait is bounded by MODEL_FETCH_TIMEOUT_MS (see the * constant for the degradation semantics). On expiry or cancellation the per-call * AbortSignal detaches this task's waiter from the provider-side fetch instead of * leaving a handler-side promise waiting on it indefinitely. * * The return value is the settled post-wait read of getModel().info: request * entry points (attemptApiRequest, condenseContext) await this once before * prompt generation and share the returned snapshot between getSystemPrompt * and every tool-array build of the same request, so a fetch that resolves after * the bounded wait was abandoned cannot move model-specific tool policy between * prompt time and request time. Callers that do not thread a snapshot keep * awaiting this immediately before their own getModel() read; that per-site guard * remains the standalone/fallback read path, and repeat awaits stay cheap once a * fetch has succeeded because the provider caches successes. RouterProvider * already negative-caches catalog misses with a TTL (`missingModelRefreshAt`). */ private async safeEnsureModelFetched(): Promise { // Per-call controller: its signal makes ensureModelFetched() settle on // abort, so neither this race nor the provider-side waiter outlives the // bounded wait. cancel/dispose aborts it early via cancelCurrentRequest. const controller = new AbortController() this.metadataFetchAbortController = controller // Promise.race attaches handlers to both inputs, so the fetch rejecting // after the abort is already considered handled — no extra .catch needed. let timeoutId: ReturnType | undefined let timedOut = false try { await Promise.race([ this.api.ensureModelFetched?.(controller.signal), new Promise((resolve) => { timeoutId = setTimeout(() => { timedOut = true resolve() }, MODEL_FETCH_TIMEOUT_MS) }), ]) if (timedOut) { console.warn( `[Task#${this.taskId}] Timed out after ${MODEL_FETCH_TIMEOUT_MS}ms fetching model metadata; using fallback model info.`, ) } } catch (error) { console.error( `[Task#${this.taskId}] Failed to fetch model metadata:`, error instanceof Error ? error.message : error, ) } finally { if (timeoutId) { clearTimeout(timeoutId) } if (this.metadataFetchAbortController === controller) { this.metadataFetchAbortController = undefined } // Unconditional: settles any waiter still attached to this call. controller.abort() } // The post-wait read is the settled snapshot callers must share per request. return this.api.getModel().info } private async handleContextWindowExceededError(requestModelInfo: ModelInfo): Promise { const state = await this.providerRef.deref()?.getState() const { profileThresholds = {} } = state ?? {} // Use task-local values, not provider state, to prevent cross-task configuration leaks. const mode = await this.getTaskMode() const apiConfiguration = this.apiConfiguration const { contextTokens } = this.getTokenUsage() // Truncation permanently rewrites apiConversationHistory, and the retry // hop that consumes the result builds from the caller's snapshot; sizing // against a fresh read here could discard history the retry would still // have fit, so recovery shares the caller's snapshot instead of re-fetching. const modelInfo = requestModelInfo const maxTokens = getModelMaxOutputTokens({ modelId: this.api.getModel().id, model: modelInfo, settings: this.apiConfiguration, }) // vscode-lm condenses against its static-table maxInputTokens (not the inflated live window); // only it implements getCondenseContextWindow, so others fall back to the full contextWindow. const contextWindow = this.api.getCondenseContextWindow?.() ?? modelInfo.contextWindow const useAvailableInputForContextPercent = typeof this.api.getCondenseContextWindow === "function" // Get the current profile ID using the helper method const currentProfileId = this.getCurrentProfileId(state) // Log the context window error for debugging console.warn( `[Task#${this.taskId}] Context window exceeded for model ${this.api.getModel().id}. ` + `Current tokens: ${contextTokens}, Context window: ${contextWindow}. ` + `Forcing truncation to ${FORCED_CONTEXT_REDUCTION_PERCENT}% of current context.`, ) // Send condenseTaskContextStarted to show in-progress indicator await this.providerRef.deref()?.postMessageToWebview({ type: "condenseTaskContextStarted", text: this.taskId }) // Build tools for condensing metadata (same tools used for normal API calls) const provider = this.providerRef.deref() let allTools: import("openai").default.Chat.ChatCompletionTool[] = [] if (provider) { const toolsResult = await buildNativeToolsArrayWithRestrictions({ provider, cwd: this.cwd, mode, customModes: state?.customModes, experiments: state?.experiments, apiConfiguration, disabledTools: state?.disabledTools, modelInfo, includeAllToolsWithRestrictions: false, }) allTools = toolsResult.tools } // Build metadata with tools and taskId for the condensing API call const metadata: ApiHandlerCreateMessageMetadata = { mode, taskId: this.taskId, ...(this.currentRequestAbortController?.signal ? { abortSignal: this.currentRequestAbortController.signal, } : {}), ...(allTools.length > 0 ? { tools: allTools, tool_choice: "auto", parallelToolCalls: true, } : {}), } try { // Generate environment details to include in the condensed summary const environmentDetails = await getEnvironmentDetails(this, true) // Force aggressive truncation by keeping only 75% of the conversation history const truncateResult = await manageContext({ messages: this.apiConversationHistory, totalTokens: contextTokens || 0, maxTokens, contextWindow, apiHandler: this.api, autoCondenseContext: true, autoCondenseContextPercent: FORCED_CONTEXT_REDUCTION_PERCENT, systemPrompt: await this.getSystemPrompt(state, modelInfo), taskId: this.taskId, profileThresholds, currentProfileId, metadata, environmentDetails, useAvailableInputForContextPercent, }) if (truncateResult.messages !== this.apiConversationHistory) { await this.overwriteApiConversationHistory(truncateResult.messages) } if (truncateResult.summary) { const { summary, cost, prevContextTokens, newContextTokens = 0 } = truncateResult const contextCondense: ContextCondense = { summary, cost, newContextTokens, prevContextTokens } await this.say( "condense_context", undefined /* text */, undefined /* images */, false /* partial */, undefined /* checkpoint */, undefined /* progressStatus */, { isNonInteractive: true } /* options */, contextCondense, ) } else if (truncateResult.truncationId) { // Sliding window truncation occurred (fallback when condensing fails or is disabled) const contextTruncation: ContextTruncation = { truncationId: truncateResult.truncationId, messagesRemoved: truncateResult.messagesRemoved ?? 0, prevContextTokens: truncateResult.prevContextTokens, newContextTokens: truncateResult.newContextTokensAfterTruncation ?? 0, } await this.say( "sliding_window_truncation", undefined /* text */, undefined /* images */, false /* partial */, undefined /* checkpoint */, undefined /* progressStatus */, { isNonInteractive: true } /* options */, undefined /* contextCondense */, contextTruncation, ) } } finally { // Notify webview that context management is complete (removes in-progress spinner) // IMPORTANT: Must always be sent to dismiss the spinner, even on error await this.providerRef .deref() ?.postMessageToWebview({ type: "condenseTaskContextResponse", text: this.taskId }) } } /** * Enforce the user-configured provider rate limit. * * NOTE: This is intentionally treated as expected behavior and is surfaced via * the `api_req_rate_limit_wait` say type (not an error). */ private async maybeWaitForProviderRateLimit(retryAttempt: number): Promise { const rateLimitSeconds = this.apiConfiguration?.rateLimitSeconds ?? 0 const lastRequestTime = this.rateLimitClock.getLastRequestTime() if (rateLimitSeconds <= 0 || !lastRequestTime) { return } const now = performance.now() const timeSinceLastRequest = now - lastRequestTime const rateLimitDelay = Math.ceil( Math.min(rateLimitSeconds, Math.max(0, rateLimitSeconds * 1000 - timeSinceLastRequest) / 1000), ) // Only show the countdown UX on the first attempt. Retry flows have their own delay messaging. if (rateLimitDelay > 0 && retryAttempt === 0) { for (let i = rateLimitDelay; i > 0; i--) { // Send structured JSON data for i18n-safe transport const delayMessage = JSON.stringify({ seconds: i }) await this.say("api_req_rate_limit_wait", delayMessage, undefined, true) await delay(1000) } // Finalize the partial message so the UI doesn't keep rendering an in-progress spinner. await this.say("api_req_rate_limit_wait", undefined, undefined, false) } } public async *attemptApiRequest( retryAttempt: number = 0, options: { skipProviderRateLimit?: boolean; requestModelInfo?: ModelInfo } = {}, ): ApiStream { const state = await this.providerRef.deref()?.getState() const { autoApprovalEnabled, autoCondenseContext = true, autoCondenseContextPercent = 100, profileThresholds = {}, } = state ?? {} // Use task-local values, not provider state, to prevent cross-task configuration leaks. const mode = await this.getTaskMode() const apiConfiguration = this.apiConfiguration // Get condensing configuration for automatic triggers. const customCondensingPrompt = state?.customSupportPrompts?.CONDENSE if (!options.skipProviderRateLimit) { await this.maybeWaitForProviderRateLimit(retryAttempt) } // Update last request time right before making the request so that subsequent // requests — even from new subtasks — will honour the provider's rate-limit. // // NOTE: When recursivelyMakeClineRequests handles rate limiting, it sets the // timestamp earlier to include the environment details build. We still set it // here for direct callers (tests) and for the case where we didn't rate-limit // in the caller. this.rateLimitClock.recordRequest() // Thread the request state snapshot into prompt generation so the prompt // and the runtime tools (built below from the same `state`) stay aligned // even if settings change while this method waits on MCP or rate limits. // Capture one bounded-wait model-info snapshot per request, shared by the // prompt and every tool array built below; prefer the caller's snapshot // when one was threaded. const requestModelInfo = options.requestModelInfo ?? (await this.safeEnsureModelFetched()) // Retry recursions must reuse this snapshot instead of re-deriving it: a // metadata fetch landing between attempts would otherwise move // model-specific tool policy or `preserveReasoning` mid-request. When the // caller threaded a snapshot its options object is forwarded unchanged — // same reference, and never mutated. const retryOptions = options.requestModelInfo === undefined ? { ...options, requestModelInfo } : options // Publish this attempt's tool-policy snapshot from the same `state` read // the prompt is built from: the snapshot's fields are read off that held // local at the store itself, with no await between those reads and the // assignment, and no policy input is re-read from live state across the // awaits above, so the streaming presenter of this request validates // against exactly the policy its prompt advertised. The generator body // re-executes per retry, so each attempt re-captures before its stream // can present anything. this.requestPolicySnapshot = { disabledTools: state?.disabledTools, experiments: state?.experiments, customModes: state?.customModes, modelInfo: requestModelInfo, } const systemPrompt = await this.getSystemPrompt(state, requestModelInfo) // A cancellation landing during the rate-limit countdown, the bounded metadata // wait, or the MCP wait inside getSystemPrompt must stop this request before any // tool array, AbortController, or createMessage call is issued for it. if (this.abort || this.abandoned) { throw new Error( `[Task#attemptApiRequest] task ${this.taskId}.${this.instanceId} aborted during request construction`, ) } const { contextTokens } = this.getTokenUsage() if (contextTokens) { // Context sizing resolves from the same model-info snapshot as the prompt and // every tool array of this request, not from a fresh getModel() re-read. const modelInfo = requestModelInfo const maxTokens = getModelMaxOutputTokens({ modelId: this.api.getModel().id, model: modelInfo, settings: this.apiConfiguration, }) // vscode-lm condenses against its static-table maxInputTokens (not the inflated live window); // only it implements getCondenseContextWindow, so others fall back to the full contextWindow. const contextWindow = this.api.getCondenseContextWindow?.() ?? modelInfo.contextWindow const useAvailableInputForContextPercent = typeof this.api.getCondenseContextWindow === "function" // Get the current profile ID using the helper method const currentProfileId = this.getCurrentProfileId(state) // Check if context management will likely run (threshold check) // This allows us to show an in-progress indicator to the user // We use the centralized willManageContext helper to avoid duplicating threshold logic const lastMessage = this.apiConversationHistory[this.apiConversationHistory.length - 1] const lastMessageContent = lastMessage?.content let lastMessageTokens = 0 if (lastMessageContent) { lastMessageTokens = Array.isArray(lastMessageContent) ? await this.api.countTokens(lastMessageContent) : await this.api.countTokens([{ type: "text", text: lastMessageContent as string }]) } const contextManagementWillRun = willManageContext({ totalTokens: contextTokens, contextWindow, maxTokens, autoCondenseContext, autoCondenseContextPercent, profileThresholds, currentProfileId, lastMessageTokens, useAvailableInputForContextPercent, }) // Send condenseTaskContextStarted BEFORE manageContext to show in-progress indicator // This notification must be sent here (not earlier) because the early check uses stale token count // (before user message is added to history), which could incorrectly skip showing the indicator if (contextManagementWillRun && autoCondenseContext) { await this.providerRef .deref() ?.postMessageToWebview({ type: "condenseTaskContextStarted", text: this.taskId }) } // Build tools for condensing metadata (same tools used for normal API calls) // This ensures the condensing API call includes tool definitions for providers that need them let contextMgmtTools: import("openai").default.Chat.ChatCompletionTool[] = [] { const provider = this.providerRef.deref() if (provider) { const toolsResult = await buildNativeToolsArrayWithRestrictions({ provider, cwd: this.cwd, mode, customModes: state?.customModes, experiments: state?.experiments, apiConfiguration, disabledTools: state?.disabledTools, modelInfo: requestModelInfo, includeAllToolsWithRestrictions: false, }) contextMgmtTools = toolsResult.tools } } // Build metadata with tools and taskId for the condensing API call const contextMgmtMetadata: ApiHandlerCreateMessageMetadata = { mode, taskId: this.taskId, ...(this.currentRequestAbortController?.signal ? { abortSignal: this.currentRequestAbortController.signal, } : {}), ...(contextMgmtTools.length > 0 ? { tools: contextMgmtTools, tool_choice: "auto", parallelToolCalls: true, } : {}), } // Only generate environment details when context management will actually run. // getEnvironmentDetails(this, true) triggers a recursive workspace listing which // adds overhead - avoid this for the common case where context is below threshold. const contextMgmtEnvironmentDetails = contextManagementWillRun ? await getEnvironmentDetails(this, true) : undefined // Get files read by Roo for code folding - only when context management will run const contextMgmtFilesReadByRoo = contextManagementWillRun && autoCondenseContext ? await this.getFilesReadByRooSafely("attemptApiRequest") : undefined try { const truncateResult = await manageContext({ messages: this.apiConversationHistory, totalTokens: contextTokens, maxTokens, contextWindow, apiHandler: this.api, autoCondenseContext, autoCondenseContextPercent, systemPrompt, taskId: this.taskId, customCondensingPrompt, profileThresholds, currentProfileId, metadata: contextMgmtMetadata, environmentDetails: contextMgmtEnvironmentDetails, filesReadByRoo: contextMgmtFilesReadByRoo, cwd: this.cwd, rooIgnoreController: this.rooIgnoreController, useAvailableInputForContextPercent, }) if (truncateResult.messages !== this.apiConversationHistory) { await this.overwriteApiConversationHistory(truncateResult.messages) } if (truncateResult.error) { await this.say("condense_context_error", truncateResult.error) } if (truncateResult.summary) { const { summary, cost, prevContextTokens, newContextTokens = 0, condenseId } = truncateResult const contextCondense: ContextCondense = { summary, cost, newContextTokens, prevContextTokens, condenseId, } await this.say( "condense_context", undefined /* text */, undefined /* images */, false /* partial */, undefined /* checkpoint */, undefined /* progressStatus */, { isNonInteractive: true } /* options */, contextCondense, ) } else if (truncateResult.truncationId) { // Sliding window truncation occurred (fallback when condensing fails or is disabled) const contextTruncation: ContextTruncation = { truncationId: truncateResult.truncationId, messagesRemoved: truncateResult.messagesRemoved ?? 0, prevContextTokens: truncateResult.prevContextTokens, newContextTokens: truncateResult.newContextTokensAfterTruncation ?? 0, } await this.say( "sliding_window_truncation", undefined /* text */, undefined /* images */, false /* partial */, undefined /* checkpoint */, undefined /* progressStatus */, { isNonInteractive: true } /* options */, undefined /* contextCondense */, contextTruncation, ) } } finally { // Notify webview that context management is complete (sets isCondensing = false) // This removes the in-progress spinner and allows the completed result to show // IMPORTANT: Must always be sent to dismiss the spinner, even on error if (contextManagementWillRun && autoCondenseContext) { await this.providerRef .deref() ?.postMessageToWebview({ type: "condenseTaskContextResponse", text: this.taskId }) } } } // Get the effective API history by filtering out condensed messages // This allows non-destructive condensing where messages are tagged but not deleted, // enabling accurate rewind operations while still sending condensed history to the API. const effectiveHistory = getEffectiveApiHistory(this.apiConversationHistory) const messagesSinceLastSummary = getMessagesSinceLastSummary(effectiveHistory) // For API only: merge consecutive user messages (excludes summary messages per // mergeConsecutiveApiMessages implementation) without mutating stored history. const mergedForApi = mergeConsecutiveApiMessages(messagesSinceLastSummary, { roles: ["user"] }) const messagesWithoutImages = maybeRemoveImageBlocks(mergedForApi, this.api) const cleanConversationHistory = this.buildCleanConversationHistory( messagesWithoutImages as ApiMessage[], requestModelInfo, ) // Check auto-approval limits const approvalResult = await this.autoApprovalHandler.checkAutoApprovalLimits( state, this.combineMessages(this.clineMessages.slice(1)), async (type, data) => this.ask(type, data), ) if (!approvalResult.shouldProceed) { // User did not approve, task should be aborted throw new Error("Auto-approval limit reached and user did not approve continuation") } // Tool policy resolves from the same model-info snapshot as the system prompt // built earlier in this request, not from a fresh getModel() re-read. const modelInfo = requestModelInfo // Build complete tools array: native tools + dynamic MCP tools // When includeAllToolsWithRestrictions is true, returns all tools but provides // allowedFunctionNames for providers (like Gemini) that need to see all tool // definitions in history while restricting callable tools for the current mode. // Only Gemini currently supports this - other providers filter tools normally. let allTools: OpenAI.Chat.ChatCompletionTool[] = [] let allowedFunctionNames: string[] | undefined // Gemini requires all tool definitions to be present for history compatibility, // but uses allowedFunctionNames to restrict which tools can be called. // Other providers (Anthropic, OpenAI, etc.) don't support this feature yet, // so they continue to receive only the filtered tools for the current mode. const supportsAllowedFunctionNames = apiConfiguration?.apiProvider === providerIdentifiers.gemini { const provider = this.providerRef.deref() if (!provider) { throw new Error("Provider reference lost during tool building") } const toolsResult = await buildNativeToolsArrayWithRestrictions({ provider, cwd: this.cwd, mode, customModes: state?.customModes, experiments: state?.experiments, apiConfiguration, disabledTools: state?.disabledTools, modelInfo, includeAllToolsWithRestrictions: supportsAllowedFunctionNames, }) allTools = toolsResult.tools allowedFunctionNames = toolsResult.allowedFunctionNames } const shouldIncludeTools = allTools.length > 0 // Create an AbortController to allow cancelling the request mid-stream this.currentRequestAbortController = new AbortController() const abortSignal = this.currentRequestAbortController.signal const metadata: ApiHandlerCreateMessageMetadata = { mode: mode, taskId: this.taskId, suppressPreviousResponseId: this.skipPrevResponseIdOnce, abortSignal, // Include tools whenever they are present. ...(shouldIncludeTools ? { tools: allTools, tool_choice: "auto", parallelToolCalls: true, // When mode restricts tools, provide allowedFunctionNames so providers // like Gemini can see all tools in history but only call allowed ones ...(allowedFunctionNames ? { allowedFunctionNames } : {}), } : {}), } // Reset the flag after using it this.skipPrevResponseIdOnce = false // The provider accepts reasoning items alongside standard messages; cast to the expected parameter type. const stream = this.api.createMessage( systemPrompt, cleanConversationHistory as unknown as Anthropic.Messages.MessageParam[], metadata, ) const iterator = stream[Symbol.asyncIterator]() // Set up abort handling - when the signal is aborted, clean up the controller reference abortSignal.addEventListener( "abort", () => { console.log(`[Task#${this.taskId}.${this.instanceId}] AbortSignal triggered for current request`) this.currentRequestAbortController = undefined }, { once: true }, ) try { // Awaiting first chunk to see if it will throw an error. this.isWaitingForFirstChunk = true // Race between the first chunk and the abort signal const firstChunkPromise = iterator.next() const abortPromise = new Promise((_, reject) => { if (abortSignal.aborted) { reject(new Error("Request cancelled by user")) } else { abortSignal.addEventListener( "abort", () => { reject(new Error("Request cancelled by user")) }, { once: true }, ) } }) const firstChunk = await Promise.race([firstChunkPromise, abortPromise]) yield firstChunk.value this.isWaitingForFirstChunk = false } catch (error) { this.isWaitingForFirstChunk = false const isContextWindowExceededError = checkContextWindowExceededError(error) if (!isContextWindowExceededError) { this.currentRequestAbortController = undefined } // If it's a context window error and we haven't exceeded max retries for this error type if (isContextWindowExceededError && retryAttempt < MAX_CONTEXT_WINDOW_RETRIES) { console.warn( `[Task#${this.taskId}] Context window exceeded for model ${this.api.getModel().id}. ` + `Retry attempt ${retryAttempt + 1}/${MAX_CONTEXT_WINDOW_RETRIES}. ` + `Attempting automatic truncation...`, ) await this.handleContextWindowExceededError(requestModelInfo) // Retry the request after handling the context window error yield* this.attemptApiRequest(retryAttempt + 1, retryOptions) return } // note that this api_req_failed ask is unique in that we only present this option if the api hasn't streamed any content yet (ie it fails on the first chunk due), as it would allow them to hit a retry button. However if the api failed mid-stream, it could be in any arbitrary state where some tools may have executed, so that error is handled differently and requires cancelling the task entirely. if (autoApprovalEnabled) { // Apply shared exponential backoff and countdown UX await this.backoffAndAnnounce(retryAttempt, error) // CRITICAL: Check if task was aborted during the backoff countdown // This prevents infinite loops when users cancel during auto-retry // Without this check, the recursive call below would continue even after abort if (this.abort) { throw new Error( `[Task#attemptApiRequest] task ${this.taskId}.${this.instanceId} aborted during retry`, ) } // Delegate generator output from the recursive call with // incremented retry count. yield* this.attemptApiRequest(retryAttempt + 1, retryOptions) return } else { const { response } = await this.ask( "api_req_failed", error.message ?? JSON.stringify(serializeError(error), null, 2), ) if (response !== "yesButtonClicked") { // This will never happen since if noButtonClicked, we will // clear current task, aborting this instance. throw new Error("API request failed") } await this.say("api_req_retried") // Delegate generator output from the recursive call. yield* this.attemptApiRequest(0, retryOptions) return } } // No error, so we can continue to yield all remaining chunks. // (Needs to be placed outside of try/catch since it we want caller to // handle errors not with api_req_failed as that is reserved for first // chunk failures only.) // This delegates to another generator or iterable object. In this case, // it's saying "yield all remaining values from this iterator". This // effectively passes along all subsequent chunks from the original // stream. yield* iterator } // Shared exponential backoff for retries (first-chunk and mid-stream) private async backoffAndAnnounce(retryAttempt: number, error: any): Promise { try { const state = await this.providerRef.deref()?.getState() const baseDelay = state?.requestDelaySeconds || 5 let exponentialDelay = Math.min( Math.ceil(baseDelay * Math.pow(2, retryAttempt)), MAX_EXPONENTIAL_BACKOFF_SECONDS, ) // Respect provider rate limit window let rateLimitDelay = 0 const rateLimit = this.apiConfiguration?.rateLimitSeconds ?? 0 const lastRequestTime = this.rateLimitClock.getLastRequestTime() if (lastRequestTime && rateLimit > 0) { const elapsed = performance.now() - lastRequestTime rateLimitDelay = Math.ceil(Math.min(rateLimit, Math.max(0, rateLimit * 1000 - elapsed) / 1000)) } // Prefer RetryInfo on 429 if present if (error?.status === 429) { const retryInfo = error?.errorDetails?.find( (d: any) => d["@type"] === "type.googleapis.com/google.rpc.RetryInfo", ) const match = retryInfo?.retryDelay?.match?.(/^(\d+)s$/) if (match) { exponentialDelay = Number(match[1]) + 1 } } const finalDelay = Math.max(exponentialDelay, rateLimitDelay) if (finalDelay <= 0) { return } // Build header text; fall back to error message if none provided let headerText if (error.status) { // Include both status code (for ChatRow parsing) and detailed message (for error details) // Format: "\n" allows ChatRow to extract status via parseInt(text.substring(0,3)) // while preserving the full error message in errorDetails for debugging const errorMessage = error?.message || "Unknown error" headerText = `${error.status}\n${errorMessage}` } else if (error?.message) { headerText = error.message } else { headerText = "Unknown error" } headerText = headerText ? `${headerText}\n` : "" // Show countdown timer with exponential backoff for (let i = finalDelay; i > 0; i--) { // Check abort flag during countdown to allow early exit if (this.abort) { throw new Error(`[Task#${this.taskId}] Aborted during retry countdown`) } await this.say("api_req_retry_delayed", `${headerText}${i}`, undefined, true) await delay(1000) } await this.say("api_req_retry_delayed", headerText, undefined, false) } catch (err) { const message = err instanceof Error ? err.message : String(err) if (this.abort && message.includes("Aborted during retry countdown")) { return } console.error("Exponential backoff failed:", err) } } // Checkpoints public async checkpointSave(force: boolean = false, suppressMessage: boolean = false) { return checkpointSave(this, force, suppressMessage) } private buildCleanConversationHistory( messages: ApiMessage[], requestModelInfo: ModelInfo, ): Array< Anthropic.Messages.MessageParam | { type: "reasoning"; encrypted_content: string; id?: string; summary?: any[] } > { type ReasoningItemForRequest = { type: "reasoning" encrypted_content: string id?: string summary?: any[] } const cleanConversationHistory: (Anthropic.Messages.MessageParam | ReasoningItemForRequest)[] = [] for (const msg of messages) { // Standalone reasoning: send encrypted, skip plain text if (msg.type === "reasoning") { if (msg.encrypted_content) { cleanConversationHistory.push({ type: "reasoning", summary: msg.summary, encrypted_content: msg.encrypted_content!, ...(msg.id ? { id: msg.id } : {}), }) } continue } // Preferred path: assistant message with embedded reasoning as first content block if (msg.role === "assistant") { const rawContent = msg.content const contentArray: Anthropic.Messages.ContentBlockParam[] = Array.isArray(rawContent) ? (rawContent as Anthropic.Messages.ContentBlockParam[]) : rawContent !== undefined ? ([ { type: "text", text: rawContent } satisfies Anthropic.Messages.TextBlockParam, ] as Anthropic.Messages.ContentBlockParam[]) : [] const [first, ...rest] = contentArray // Check if this message has reasoning_details (OpenRouter format for Gemini 3, etc.) const msgWithDetails = msg if (msgWithDetails.reasoning_details && Array.isArray(msgWithDetails.reasoning_details)) { // Build the assistant message with reasoning_details let assistantContent: Anthropic.Messages.MessageParam["content"] if (contentArray.length === 0) { assistantContent = "" } else if (contentArray.length === 1 && contentArray[0].type === "text") { assistantContent = (contentArray[0] as Anthropic.Messages.TextBlockParam).text } else { assistantContent = contentArray } // Create message with reasoning_details property cleanConversationHistory.push({ role: "assistant", content: assistantContent, reasoning_details: msgWithDetails.reasoning_details, } as any) continue } // Embedded reasoning: encrypted (send) or plain text (skip) const hasEncryptedReasoning = first && (first as any).type === "reasoning" && typeof (first as any).encrypted_content === "string" const hasPlainTextReasoning = first && (first as any).type === "reasoning" && typeof (first as any).text === "string" if (hasEncryptedReasoning) { const reasoningBlock = first as any // Send as separate reasoning item (OpenAI Native) cleanConversationHistory.push({ type: "reasoning", summary: reasoningBlock.summary ?? [], encrypted_content: reasoningBlock.encrypted_content, ...(reasoningBlock.id ? { id: reasoningBlock.id } : {}), }) // Send assistant message without reasoning let assistantContent: Anthropic.Messages.MessageParam["content"] if (rest.length === 0) { assistantContent = "" } else if (rest.length === 1 && rest[0].type === "text") { assistantContent = (rest[0] as Anthropic.Messages.TextBlockParam).text } else { assistantContent = rest } cleanConversationHistory.push({ role: "assistant", content: assistantContent, } satisfies Anthropic.Messages.MessageParam) continue } else if (hasPlainTextReasoning) { // Check if the model's preserveReasoning flag is set, resolved from // the request's threaded model snapshot (same per-request source as // the prompt and tool arrays) rather than a fresh getModel() re-read, // so a mid-request metadata refresh cannot change what this request sends. // If true, include the reasoning block in API requests // If false/undefined, strip it out (stored for history only, not sent back to API) const shouldPreserveForApi = requestModelInfo.preserveReasoning === true let assistantContent: Anthropic.Messages.MessageParam["content"] if (shouldPreserveForApi) { // Include reasoning block in the content sent to API assistantContent = contentArray } else { // Strip reasoning out - stored for history only, not sent back to API if (rest.length === 0) { assistantContent = "" } else if (rest.length === 1 && rest[0].type === "text") { assistantContent = (rest[0] as Anthropic.Messages.TextBlockParam).text } else { assistantContent = rest } } cleanConversationHistory.push({ role: "assistant", content: assistantContent, } satisfies Anthropic.Messages.MessageParam) continue } } // Default path for regular messages (no embedded reasoning) if (msg.role) { cleanConversationHistory.push({ role: msg.role, content: msg.content as Anthropic.Messages.ContentBlockParam[] | string, }) } } return cleanConversationHistory } public async checkpointRestore(options: CheckpointRestoreOptions) { return checkpointRestore(this, options) } public async checkpointDiff(options: CheckpointDiffOptions) { return checkpointDiff(this, options) } // Metrics public combineMessages(messages: ClineMessage[]) { return combineApiRequests(combineCommandSequences(messages)) } public getTokenUsage(): TokenUsage { return getApiMetrics(this.combineMessages(this.clineMessages.slice(1))) } public recordToolUsage(toolName: ToolName) { if (!this.toolUsage[toolName]) { this.toolUsage[toolName] = { attempts: 0, failures: 0 } } this.toolUsage[toolName].attempts++ } public recordToolError(toolName: ToolName, error?: string) { if (!this.toolUsage[toolName]) { this.toolUsage[toolName] = { attempts: 0, failures: 0 } } this.toolUsage[toolName].failures++ if (error) { this.emit(RooCodeEventName.TaskToolFailed, this.taskId, toolName, error) } } /** * Emits a Task Completed installment for whatever toolUsage/messageCounts have * changed since the previous installment (from any reason), then advances the * telemetry baseline so a later installment reports only its own delta. Does * NOT touch task.toolUsage/messageCounts themselves -- those stay running totals * for the public TaskCompleted API event and the UI. No-ops if nothing changed * since the last installment, so idle/shutdown checks don't emit empty events * for tasks that were already fully reported (e.g. right after attempt_completion). */ public flushTelemetryInstallment(reason: "attempt_completion" | "idle" | "shutdown"): void { const toolUsageDelta: ToolUsage = {} for (const [toolName, usage] of Object.entries(this.toolUsage) as [ToolName, ToolUsage[ToolName]][]) { if (!usage) { continue } const baseline = this.telemetryToolUsageBaseline[toolName] const attempts = usage.attempts - (baseline?.attempts ?? 0) const failures = usage.failures - (baseline?.failures ?? 0) if (attempts > 0 || failures > 0) { toolUsageDelta[toolName] = { attempts, failures } } } const messageCountDelta = { user: Math.max(0, this.messageCounts.user - this.telemetryMessageCountsBaseline.user), assistant: Math.max(0, this.messageCounts.assistant - this.telemetryMessageCountsBaseline.assistant), } const hasToolUsageDelta = Object.keys(toolUsageDelta).length > 0 const hasMessageDelta = messageCountDelta.user > 0 || messageCountDelta.assistant > 0 if (!hasToolUsageDelta && !hasMessageDelta) { return } // Advance the baseline before emitting so a synchronous throw from an // EventEmitter listener or TelemetryService client cannot leave the baseline // behind the running totals, which would cause the same delta to re-appear // on the next flush. The delta values are already captured in locals above. this.telemetryToolUsageBaseline = JSON.parse(JSON.stringify(this.toolUsage)) this.telemetryMessageCountsBaseline = { ...this.messageCounts } this.lastTelemetryFlushAt = Date.now() this.emitFinalTokenUsageUpdate() TelemetryService.instance.captureTaskCompleted(this.taskId, toolUsageDelta, messageCountDelta, reason) } startIdleTelemetryCheck(): void { if (this.idleTelemetryCheckInterval !== undefined) { return } this.idleTelemetryCheckInterval = setInterval(() => { // Measure idleness from the later of the last activity and the last flush. // Using lastMessageTs alone would keep the condition true forever after the // first idle flush, re-running the empty-delta check on every interval tick. const lastEventAt = Math.max(this.lastMessageTs ?? 0, this.lastTelemetryFlushAt) const idleForMs = Date.now() - lastEventAt if (idleForMs >= Task.IDLE_TELEMETRY_THRESHOLD_MS) { this.flushTelemetryInstallment("idle") } }, Task.IDLE_TELEMETRY_CHECK_INTERVAL_MS) // Don't hold the process open just for this timer. this.idleTelemetryCheckInterval?.unref?.() } // Getters public get taskStatus(): TaskStatus { if (this.interactiveAsk) { return TaskStatus.Interactive } if (this.resumableAsk) { return TaskStatus.Resumable } if (this.idleAsk) { return TaskStatus.Idle } return TaskStatus.Running } public get taskAsk(): ClineMessage | undefined { return this.idleAsk || this.resumableAsk || this.interactiveAsk } public get queuedMessages(): QueuedMessage[] { return this.messageQueueService.messages } public get tokenUsage(): TokenUsage | undefined { if (this.tokenUsageSnapshot && this.tokenUsageSnapshotAt) { return this.tokenUsageSnapshot } this.tokenUsageSnapshot = this.getTokenUsage() this.tokenUsageSnapshotAt = this.clineMessages.at(-1)?.ts return this.tokenUsageSnapshot } public get cwd() { return this.workspacePath } /** * Provides convenient access to high-level message operations. * Uses lazy initialization - the MessageManager is only created when first accessed. * Subsequent accesses return the same cached instance. * * ## Important: Single Coordination Point * * **All MessageManager operations must go through this getter** rather than * instantiating `new MessageManager(task)` directly. This ensures: * - A single shared instance for consistent behavior * - Centralized coordination of all rewind/message operations * - Ability to add internal state or instrumentation in the future * * @example * ```typescript * // Correct: Use the getter * await task.messageManager.rewindToTimestamp(ts) * * // Incorrect: Do NOT create new instances directly * // const manager = new MessageManager(task) // Don't do this! * ``` */ get messageManager(): MessageManager { if (!this._messageManager) { this._messageManager = new MessageManager(this) } return this._messageManager } /** * Process any queued messages by dequeuing and submitting them. * This ensures that queued user messages are sent when appropriate, * preventing them from getting stuck in the queue. * * @param context - Context string for logging (e.g., the calling tool name) */ public processQueuedMessages(): void { try { if (!this.messageQueueService.isEmpty()) { const queued = this.messageQueueService.dequeueMessage() if (queued) { setTimeout(() => { this.submitUserMessage(queued.text, queued.images).catch((err) => console.error(`[Task] Failed to submit queued message:`, err), ) }, 0) } } } catch (e) { console.error(`[Task] Queue processing error:`, e) } } }