From bcf224167e6233921aa3678b028051245f1a0e4a Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Thu, 18 Sep 2025 17:54:21 -0600 Subject: [PATCH 1/6] fix(stream): eliminate duplicate rehydrate during reasoning; centralize rehydrate in provider and preserve cancel metadata - Remove Task-side rehydrate on stream error/cancel; provider owns rehydrate - Set abandoned early on user cancel to prevent Task catch from rehydrating - Provider writes cancelReason to last api_req_started and appends assistant interruption - Trim orphan reasoning-only UI rows on resume - Add defensive guards to avoid rehydrate-after-rehydrate --- src/core/task/Task.ts | 30 +++++--- src/core/webview/ClineProvider.ts | 118 ++++++++++++++++++++++++++++-- 2 files changed, 131 insertions(+), 17 deletions(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index cf16df8dcc..e7dfb96e0f 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -212,6 +212,7 @@ export class Task extends EventEmitter implements TaskLike { didFinishAbortingStream = false abandoned = false + abortReason?: ClineApiReqCancelReason isInitialized = false isPaused: boolean = false pausedModeSlug: string = defaultModeSlug @@ -1264,6 +1265,16 @@ export class Task extends EventEmitter implements TaskLike { modifiedClineMessages.splice(lastRelevantMessageIndex + 1) } + // Remove any trailing reasoning-only UI messages that were not part of the persisted API conversation + while (modifiedClineMessages.length > 0) { + const last = modifiedClineMessages[modifiedClineMessages.length - 1] + if (last.type === "say" && last.say === "reasoning") { + 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 @@ -2187,24 +2198,23 @@ export class Task extends EventEmitter implements TaskLike { // may have executed), so we just resort to replicating a // cancel task. - // Check if this was a user-initiated cancellation BEFORE calling abortTask - // If this.abort is already true, it means the user clicked cancel, so we should - // treat this as "user_cancelled" rather than "streaming_failed" - const cancelReason = this.abort ? "user_cancelled" : "streaming_failed" + // Determine cancellation reason BEFORE aborting to ensure correct persistence + const cancelReason: ClineApiReqCancelReason = this.abort ? "user_cancelled" : "streaming_failed" const streamingFailedMessage = this.abort ? undefined : (error.message ?? JSON.stringify(serializeError(error), null, 2)) - // Now call abortTask after determining the cancel reason. - await this.abortTask() + // Persist interruption details first to both UI and API histories await abortStream(cancelReason, streamingFailedMessage) - const history = await provider?.getTaskWithId(this.taskId) + // Record reason for provider to decide rehydration path + this.abortReason = cancelReason - if (history) { - await provider?.createTaskWithHistoryItem(history.historyItem) - } + // Now abort (emits TaskAborted which provider listens to) + await this.abortTask() + + // Do not rehydrate here; provider owns rehydration to avoid duplication races } } finally { this.isStreaming = false diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 9abddc6d96..79dad113c5 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -89,6 +89,8 @@ import { Task } from "../task/Task" import { getSystemPromptFilePath } from "../prompts/sections/custom-system-prompt" import { webviewMessageHandler } from "./webviewMessageHandler" +import type { ClineMessage } from "@roo-code/types" +import { readApiMessages, saveApiMessages, saveTaskMessages } from "../task-persistence" import { getNonce } from "./getNonce" import { getUri } from "./getUri" @@ -196,7 +198,35 @@ export class ClineProvider const onTaskStarted = () => this.emit(RooCodeEventName.TaskStarted, instance.taskId) const onTaskCompleted = (taskId: string, tokenUsage: any, toolUsage: any) => this.emit(RooCodeEventName.TaskCompleted, taskId, tokenUsage, toolUsage) - const onTaskAborted = () => this.emit(RooCodeEventName.TaskAborted, instance.taskId) + const onTaskAborted = async () => { + this.emit(RooCodeEventName.TaskAborted, instance.taskId) + + try { + // Only rehydrate on genuine streaming failures. + // User-initiated cancels are handled by cancelTask(). + if ((instance as any).abortReason === "streaming_failed") { + // Defensive safeguard: if another path already replaced this instance, skip + const current = this.getCurrentTask() + if (current && current.instanceId !== instance.instanceId) { + this.log( + `[onTaskAborted] Skipping rehydrate: current instance ${current.instanceId} != aborted ${instance.instanceId}`, + ) + return + } + + const { historyItem } = await this.getTaskWithId(instance.taskId) + const rootTask = instance.rootTask + const parentTask = instance.parentTask + await this.createTaskWithHistoryItem({ ...historyItem, rootTask, parentTask }) + } + } catch (error) { + this.log( + `[onTaskAborted] Failed to rehydrate after streaming failure: ${ + error instanceof Error ? error.message : String(error) + }`, + ) + } + } const onTaskFocused = () => this.emit(RooCodeEventName.TaskFocused, instance.taskId) const onTaskUnfocused = () => this.emit(RooCodeEventName.TaskUnfocused, instance.taskId) const onTaskActive = (taskId: string) => this.emit(RooCodeEventName.TaskActive, taskId) @@ -2525,14 +2555,28 @@ export class ClineProvider console.log(`[cancelTask] cancelling task ${task.taskId}.${task.instanceId}`) - const { historyItem } = await this.getTaskWithId(task.taskId) + const { historyItem, uiMessagesFilePath, apiConversationHistoryFilePath } = await this.getTaskWithId( + task.taskId, + ) // Preserve parent and root task information for history item. const rootTask = task.rootTask const parentTask = task.parentTask + // Mark this as a user-initiated cancellation so provider-only rehydration can occur + task.abortReason = "user_cancelled" + + // Capture the current instance to detect if rehydrate already occurred elsewhere + const originalInstanceId = task.instanceId + + // Begin abort (non-blocking) task.abortTask() + // Immediately mark the current instance as abandoned to prevent any residual activity + if (this.getCurrentTask()) { + this.getCurrentTask()!.abandoned = true + } + await pWaitFor( () => this.getCurrentTask()! === undefined || @@ -2549,11 +2593,71 @@ export class ClineProvider console.error("Failed to abort task") }) - if (this.getCurrentTask()) { - // 'abandoned' will prevent this Cline instance from affecting - // future Cline instances. This may happen if its hanging on a - // streaming request. - this.getCurrentTask()!.abandoned = true + // Defensive safeguard: if current instance already changed, skip rehydrate + const current = this.getCurrentTask() + if (current && current.instanceId !== originalInstanceId) { + this.log( + `[cancelTask] Skipping cancel bookkeeping and rehydrate: current instance ${current.instanceId} != original ${originalInstanceId}`, + ) + return + } + + // Provider-side cancel bookkeeping to mirror abortStream effects for user_cancelled + try { + // Update ui_messages: add cancelReason to last api_req_started + const messagesJson = await fs.readFile(uiMessagesFilePath, "utf8").catch(() => undefined) + if (messagesJson) { + const uiMsgs = JSON.parse(messagesJson) as ClineMessage[] + if (Array.isArray(uiMsgs)) { + const revIdx = uiMsgs + .slice() + .reverse() + .findIndex((m) => m?.type === "say" && (m as any)?.say === "api_req_started") + if (revIdx !== -1) { + const idx = uiMsgs.length - 1 - revIdx + try { + const existing = uiMsgs[idx]?.text ? JSON.parse(uiMsgs[idx].text as string) : {} + uiMsgs[idx].text = JSON.stringify({ ...existing, cancelReason: "user_cancelled" }) + await saveTaskMessages({ + messages: uiMsgs as any, + taskId: task.taskId, + globalStoragePath: this.contextProxy.globalStorageUri.fsPath, + }) + } catch { + // non-fatal + } + } + } + } + + // Update api_conversation_history: append assistant interruption if last isn't assistant + try { + const apiMsgs = await readApiMessages({ + taskId: task.taskId, + globalStoragePath: this.contextProxy.globalStorageUri.fsPath, + }) + const last = apiMsgs.at(-1) + if (!last || last.role !== "assistant") { + apiMsgs.push({ + role: "assistant", + content: [{ type: "text", text: "[Response interrupted by user]" }], + ts: Date.now(), + } as any) + await saveApiMessages({ + messages: apiMsgs as any, + taskId: task.taskId, + globalStoragePath: this.contextProxy.globalStorageUri.fsPath, + }) + } + } catch (e) { + this.log( + `[cancelTask] Failed to update API history for user_cancelled: ${ + e instanceof Error ? e.message : String(e) + }`, + ) + } + } catch (e) { + this.log(`[cancelTask] Cancel bookkeeping failed: ${e instanceof Error ? e.message : String(e)}`) } // Clears task again, so we need to abortTask manually above. From 3bbf98412e83824e0541f888edfb21e8f6b0d809 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Thu, 18 Sep 2025 18:29:13 -0600 Subject: [PATCH 2/6] fix(webview/task): harden cancel/rehydrate flow and type-safety - Mark original task instance abandoned instead of getCurrentTask() - Add parse guards and centralize UI/API cancel bookkeeping helpers - Replace any-cast with typed instance.abortReason - Centralize interruption strings and reuse in Task/Provider - Add final instanceId race-check before rehydrate - Remove unused variable in cancelTask() --- .../task-persistence/cancelBookkeeping.ts | 73 +++++++++++++++ src/core/task/Task.ts | 5 +- src/core/webview/ClineProvider.ts | 89 +++++++------------ src/shared/messages.ts | 9 ++ 4 files changed, 116 insertions(+), 60 deletions(-) create mode 100644 src/core/task-persistence/cancelBookkeeping.ts create mode 100644 src/shared/messages.ts diff --git a/src/core/task-persistence/cancelBookkeeping.ts b/src/core/task-persistence/cancelBookkeeping.ts new file mode 100644 index 0000000000..0eb7f2a7ea --- /dev/null +++ b/src/core/task-persistence/cancelBookkeeping.ts @@ -0,0 +1,73 @@ +import type { ClineMessage } from "@roo-code/types" +import { readTaskMessages, saveTaskMessages } from "./taskMessages" +import { readApiMessages, saveApiMessages } from "./apiMessages" +import type { ClineApiReqCancelReason } from "../../shared/ExtensionMessage" + +// Safely add cancelReason to the last api_req_started UI message +export async function addCancelReasonToLastApiReqStarted(args: { + taskId: string + globalStoragePath: string + reason: ClineApiReqCancelReason +}): Promise { + const { taskId, globalStoragePath, reason } = args + + try { + const uiMsgs = (await readTaskMessages({ taskId, globalStoragePath })) as ClineMessage[] + + if (!Array.isArray(uiMsgs) || uiMsgs.length === 0) { + return + } + + // Find last api_req_started + const revIdx = uiMsgs + .slice() + .reverse() + .findIndex((m) => m?.type === "say" && (m as any)?.say === "api_req_started") + + if (revIdx === -1) { + return + } + + const idx = uiMsgs.length - 1 - revIdx + + try { + const existing = uiMsgs[idx]?.text ? JSON.parse(uiMsgs[idx].text as string) : {} + uiMsgs[idx].text = JSON.stringify({ ...existing, cancelReason: reason }) + await saveTaskMessages({ messages: uiMsgs as any, taskId, globalStoragePath }) + } catch { + // Non-fatal parse or write failure + return + } + } catch { + // Non-fatal read failure + return + } +} + +// Append an assistant interruption marker to API conversation history +// only if the last message isn't already an assistant. +export async function appendAssistantInterruptionIfNeeded(args: { + taskId: string + globalStoragePath: string + text: string +}): Promise { + const { taskId, globalStoragePath, text } = args + + try { + const apiMsgs = await readApiMessages({ taskId, globalStoragePath }) + const last = apiMsgs.at(-1) + + if (!last || last.role !== "assistant") { + apiMsgs.push({ + role: "assistant", + content: [{ type: "text", text }], + ts: Date.now(), + } as any) + + await saveApiMessages({ messages: apiMsgs as any, taskId, globalStoragePath }) + } + } catch { + // Non-fatal read/write failure + return + } +} diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index e7dfb96e0f..9bee30825b 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -88,6 +88,7 @@ import { type AssistantMessageContent, presentAssistantMessage } from "../assist import { AssistantMessageParser } from "../assistant-message/AssistantMessageParser" import { truncateConversationIfNeeded } from "../sliding-window" import { ClineProvider } from "../webview/ClineProvider" +import { RESPONSE_INTERRUPTED_BY_API_ERROR, RESPONSE_INTERRUPTED_BY_USER } from "../../shared/messages" import { MultiSearchReplaceDiffStrategy } from "../diff/strategies/multi-search-replace" import { MultiFileSearchReplaceDiffStrategy } from "../diff/strategies/multi-file-search-replace" import { @@ -1908,8 +1909,8 @@ export class Task extends EventEmitter implements TaskLike { assistantMessage + `\n\n[${ cancelReason === "streaming_failed" - ? "Response interrupted by API Error" - : "Response interrupted by user" + ? RESPONSE_INTERRUPTED_BY_API_ERROR + : RESPONSE_INTERRUPTED_BY_USER }]`, }, ], diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 79dad113c5..1ff51b70d1 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -94,6 +94,11 @@ import { readApiMessages, saveApiMessages, saveTaskMessages } from "../task-pers import { getNonce } from "./getNonce" import { getUri } from "./getUri" +import { + addCancelReasonToLastApiReqStarted, + appendAssistantInterruptionIfNeeded, +} from "../task-persistence/cancelBookkeeping" +import { RESPONSE_INTERRUPTED_BY_USER } from "../../shared/messages" /** * https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts * https://github.com/KumarVariable/vscode-extension-sidebar-html/blob/master/src/customSidebarViewProvider.ts @@ -204,7 +209,7 @@ export class ClineProvider try { // Only rehydrate on genuine streaming failures. // User-initiated cancels are handled by cancelTask(). - if ((instance as any).abortReason === "streaming_failed") { + if (instance.abortReason === "streaming_failed") { // Defensive safeguard: if another path already replaced this instance, skip const current = this.getCurrentTask() if (current && current.instanceId !== instance.instanceId) { @@ -2555,9 +2560,7 @@ export class ClineProvider console.log(`[cancelTask] cancelling task ${task.taskId}.${task.instanceId}`) - const { historyItem, uiMessagesFilePath, apiConversationHistoryFilePath } = await this.getTaskWithId( - task.taskId, - ) + const { historyItem, uiMessagesFilePath } = await this.getTaskWithId(task.taskId) // Preserve parent and root task information for history item. const rootTask = task.rootTask @@ -2572,10 +2575,8 @@ export class ClineProvider // Begin abort (non-blocking) task.abortTask() - // Immediately mark the current instance as abandoned to prevent any residual activity - if (this.getCurrentTask()) { - this.getCurrentTask()!.abandoned = true - } + // Immediately mark the original instance as abandoned to prevent any residual activity + task.abandoned = true await pWaitFor( () => @@ -2604,60 +2605,32 @@ export class ClineProvider // Provider-side cancel bookkeeping to mirror abortStream effects for user_cancelled try { - // Update ui_messages: add cancelReason to last api_req_started - const messagesJson = await fs.readFile(uiMessagesFilePath, "utf8").catch(() => undefined) - if (messagesJson) { - const uiMsgs = JSON.parse(messagesJson) as ClineMessage[] - if (Array.isArray(uiMsgs)) { - const revIdx = uiMsgs - .slice() - .reverse() - .findIndex((m) => m?.type === "say" && (m as any)?.say === "api_req_started") - if (revIdx !== -1) { - const idx = uiMsgs.length - 1 - revIdx - try { - const existing = uiMsgs[idx]?.text ? JSON.parse(uiMsgs[idx].text as string) : {} - uiMsgs[idx].text = JSON.stringify({ ...existing, cancelReason: "user_cancelled" }) - await saveTaskMessages({ - messages: uiMsgs as any, - taskId: task.taskId, - globalStoragePath: this.contextProxy.globalStorageUri.fsPath, - }) - } catch { - // non-fatal - } - } - } - } + // Persist cancelReason to last api_req_started in UI messages + await addCancelReasonToLastApiReqStarted({ + taskId: task.taskId, + globalStoragePath: this.contextProxy.globalStorageUri.fsPath, + reason: "user_cancelled", + }) - // Update api_conversation_history: append assistant interruption if last isn't assistant - try { - const apiMsgs = await readApiMessages({ - taskId: task.taskId, - globalStoragePath: this.contextProxy.globalStorageUri.fsPath, - }) - const last = apiMsgs.at(-1) - if (!last || last.role !== "assistant") { - apiMsgs.push({ - role: "assistant", - content: [{ type: "text", text: "[Response interrupted by user]" }], - ts: Date.now(), - } as any) - await saveApiMessages({ - messages: apiMsgs as any, - taskId: task.taskId, - globalStoragePath: this.contextProxy.globalStorageUri.fsPath, - }) - } - } catch (e) { + // Append assistant interruption marker to API conversation history if needed + await appendAssistantInterruptionIfNeeded({ + taskId: task.taskId, + globalStoragePath: this.contextProxy.globalStorageUri.fsPath, + text: `[${RESPONSE_INTERRUPTED_BY_USER}]`, + }) + } catch (e) { + this.log(`[cancelTask] Cancel bookkeeping failed: ${e instanceof Error ? e.message : String(e)}`) + } + + // Final race check before rehydrate to avoid duplicate rehydration + { + const currentAfterBookkeeping = this.getCurrentTask() + if (currentAfterBookkeeping && currentAfterBookkeeping.instanceId !== originalInstanceId) { this.log( - `[cancelTask] Failed to update API history for user_cancelled: ${ - e instanceof Error ? e.message : String(e) - }`, + `[cancelTask] Skipping rehydrate after bookkeeping: current instance ${currentAfterBookkeeping.instanceId} != original ${originalInstanceId}`, ) + return } - } catch (e) { - this.log(`[cancelTask] Cancel bookkeeping failed: ${e instanceof Error ? e.message : String(e)}`) } // Clears task again, so we need to abortTask manually above. diff --git a/src/shared/messages.ts b/src/shared/messages.ts new file mode 100644 index 0000000000..6089e03edb --- /dev/null +++ b/src/shared/messages.ts @@ -0,0 +1,9 @@ +/** + * Centralized user-facing message constants for interruption labels. + * TODO: Consider moving these to i18n JSON in src/i18n/locales/* and wiring through t() + * so they can be localized consistently across the UI. + * + * Note: These are plain phrases (no surrounding brackets). Call sites add any desired decoration. + */ +export const RESPONSE_INTERRUPTED_BY_USER = "Response interrupted by user" +export const RESPONSE_INTERRUPTED_BY_API_ERROR = "Response interrupted by API Error" From e5be214147130a3fb90005dfc72af91f46f169cc Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Fri, 19 Sep 2025 10:25:10 -0600 Subject: [PATCH 3/6] refactor(interruption): replace hardcoded response messages with localized strings and remove unused message constants --- src/core/task/Task.ts | 5 ++--- src/core/webview/ClineProvider.ts | 3 +-- src/i18n/locales/ca/common.json | 4 ++++ src/i18n/locales/de/common.json | 4 ++++ src/i18n/locales/en/common.json | 4 ++++ src/i18n/locales/es/common.json | 4 ++++ src/i18n/locales/fr/common.json | 4 ++++ src/i18n/locales/hi/common.json | 4 ++++ src/i18n/locales/id/common.json | 4 ++++ src/i18n/locales/it/common.json | 4 ++++ src/i18n/locales/ja/common.json | 4 ++++ src/i18n/locales/ko/common.json | 4 ++++ src/i18n/locales/nl/common.json | 4 ++++ src/i18n/locales/pl/common.json | 4 ++++ src/i18n/locales/pt-BR/common.json | 4 ++++ src/i18n/locales/ru/common.json | 4 ++++ src/i18n/locales/tr/common.json | 4 ++++ src/i18n/locales/vi/common.json | 4 ++++ src/i18n/locales/zh-CN/common.json | 4 ++++ src/i18n/locales/zh-TW/common.json | 4 ++++ src/shared/messages.ts | 9 --------- 21 files changed, 75 insertions(+), 14 deletions(-) delete mode 100644 src/shared/messages.ts diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 9bee30825b..4d73c397de 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -88,7 +88,6 @@ import { type AssistantMessageContent, presentAssistantMessage } from "../assist import { AssistantMessageParser } from "../assistant-message/AssistantMessageParser" import { truncateConversationIfNeeded } from "../sliding-window" import { ClineProvider } from "../webview/ClineProvider" -import { RESPONSE_INTERRUPTED_BY_API_ERROR, RESPONSE_INTERRUPTED_BY_USER } from "../../shared/messages" import { MultiSearchReplaceDiffStrategy } from "../diff/strategies/multi-search-replace" import { MultiFileSearchReplaceDiffStrategy } from "../diff/strategies/multi-file-search-replace" import { @@ -1909,8 +1908,8 @@ export class Task extends EventEmitter implements TaskLike { assistantMessage + `\n\n[${ cancelReason === "streaming_failed" - ? RESPONSE_INTERRUPTED_BY_API_ERROR - : RESPONSE_INTERRUPTED_BY_USER + ? t("common:interruption.responseInterruptedByApiError") + : t("common:interruption.responseInterruptedByUser") }]`, }, ], diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 1ff51b70d1..c62961e19b 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -98,7 +98,6 @@ import { addCancelReasonToLastApiReqStarted, appendAssistantInterruptionIfNeeded, } from "../task-persistence/cancelBookkeeping" -import { RESPONSE_INTERRUPTED_BY_USER } from "../../shared/messages" /** * https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts * https://github.com/KumarVariable/vscode-extension-sidebar-html/blob/master/src/customSidebarViewProvider.ts @@ -2616,7 +2615,7 @@ export class ClineProvider await appendAssistantInterruptionIfNeeded({ taskId: task.taskId, globalStoragePath: this.contextProxy.globalStorageUri.fsPath, - text: `[${RESPONSE_INTERRUPTED_BY_USER}]`, + text: `[${t("common:interruption.responseInterruptedByUser")}]`, }) } catch (e) { this.log(`[cancelTask] Cancel bookkeeping failed: ${e instanceof Error ? e.message : String(e)}`) diff --git a/src/i18n/locales/ca/common.json b/src/i18n/locales/ca/common.json index b71b7eb913..a1f528ef97 100644 --- a/src/i18n/locales/ca/common.json +++ b/src/i18n/locales/ca/common.json @@ -165,6 +165,10 @@ "incomplete": "Tasca #{{taskNumber}} (Incompleta)", "no_messages": "Tasca #{{taskNumber}} (Sense missatges)" }, + "interruption": { + "responseInterruptedByUser": "Resposta interrompuda per l'usuari", + "responseInterruptedByApiError": "Resposta interrompuda per error d'API" + }, "storage": { "prompt_custom_path": "Introdueix una ruta d'emmagatzematge personalitzada per a l'historial de converses o deixa-ho buit per utilitzar la ubicació predeterminada", "path_placeholder": "D:\\RooCodeStorage", diff --git a/src/i18n/locales/de/common.json b/src/i18n/locales/de/common.json index 6577d460d1..dbd9452e60 100644 --- a/src/i18n/locales/de/common.json +++ b/src/i18n/locales/de/common.json @@ -161,6 +161,10 @@ "incomplete": "Aufgabe #{{taskNumber}} (Unvollständig)", "no_messages": "Aufgabe #{{taskNumber}} (Keine Nachrichten)" }, + "interruption": { + "responseInterruptedByUser": "Antwort vom Benutzer unterbrochen", + "responseInterruptedByApiError": "Antwort durch API-Fehler unterbrochen" + }, "storage": { "prompt_custom_path": "Gib den benutzerdefinierten Speicherpfad für den Gesprächsverlauf ein, leer lassen für Standardspeicherort", "path_placeholder": "D:\\RooCodeStorage", diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index e8c264ba68..3a613cc1c2 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -161,6 +161,10 @@ "incomplete": "Task #{{taskNumber}} (Incomplete)", "no_messages": "Task #{{taskNumber}} (No messages)" }, + "interruption": { + "responseInterruptedByUser": "Response interrupted by user", + "responseInterruptedByApiError": "Response interrupted by API error" + }, "storage": { "prompt_custom_path": "Enter custom conversation history storage path, leave empty to use default location", "path_placeholder": "D:\\RooCodeStorage", diff --git a/src/i18n/locales/es/common.json b/src/i18n/locales/es/common.json index 5cfa3c5749..49dcfe98c5 100644 --- a/src/i18n/locales/es/common.json +++ b/src/i18n/locales/es/common.json @@ -161,6 +161,10 @@ "incomplete": "Tarea #{{taskNumber}} (Incompleta)", "no_messages": "Tarea #{{taskNumber}} (Sin mensajes)" }, + "interruption": { + "responseInterruptedByUser": "Respuesta interrumpida por el usuario", + "responseInterruptedByApiError": "Respuesta interrumpida por error de API" + }, "storage": { "prompt_custom_path": "Ingresa la ruta de almacenamiento personalizada para el historial de conversaciones, déjala vacía para usar la ubicación predeterminada", "path_placeholder": "D:\\RooCodeStorage", diff --git a/src/i18n/locales/fr/common.json b/src/i18n/locales/fr/common.json index 5a11c874a7..260bbbf13b 100644 --- a/src/i18n/locales/fr/common.json +++ b/src/i18n/locales/fr/common.json @@ -161,6 +161,10 @@ "incomplete": "Tâche #{{taskNumber}} (Incomplète)", "no_messages": "Tâche #{{taskNumber}} (Aucun message)" }, + "interruption": { + "responseInterruptedByUser": "Réponse interrompue par l'utilisateur", + "responseInterruptedByApiError": "Réponse interrompue par une erreur d'API" + }, "storage": { "prompt_custom_path": "Entrez le chemin de stockage personnalisé pour l'historique des conversations, laissez vide pour utiliser l'emplacement par défaut", "path_placeholder": "D:\\RooCodeStorage", diff --git a/src/i18n/locales/hi/common.json b/src/i18n/locales/hi/common.json index e89c16cbd0..ab7d594e8f 100644 --- a/src/i18n/locales/hi/common.json +++ b/src/i18n/locales/hi/common.json @@ -161,6 +161,10 @@ "incomplete": "टास्क #{{taskNumber}} (अधूरा)", "no_messages": "टास्क #{{taskNumber}} (कोई संदेश नहीं)" }, + "interruption": { + "responseInterruptedByUser": "उपयोगकर्ता द्वारा प्रतिक्रिया बाधित", + "responseInterruptedByApiError": "API त्रुटि द्वारा प्रतिक्रिया बाधित" + }, "storage": { "prompt_custom_path": "वार्तालाप इतिहास के लिए कस्टम स्टोरेज पाथ दर्ज करें, डिफ़ॉल्ट स्थान का उपयोग करने के लिए खाली छोड़ दें", "path_placeholder": "D:\\RooCodeStorage", diff --git a/src/i18n/locales/id/common.json b/src/i18n/locales/id/common.json index ae1662eb37..ddd549b6f0 100644 --- a/src/i18n/locales/id/common.json +++ b/src/i18n/locales/id/common.json @@ -161,6 +161,10 @@ "incomplete": "Tugas #{{taskNumber}} (Tidak lengkap)", "no_messages": "Tugas #{{taskNumber}} (Tidak ada pesan)" }, + "interruption": { + "responseInterruptedByUser": "Respons diinterupsi oleh pengguna", + "responseInterruptedByApiError": "Respons diinterupsi oleh error API" + }, "storage": { "prompt_custom_path": "Masukkan path penyimpanan riwayat percakapan kustom, biarkan kosong untuk menggunakan lokasi default", "path_placeholder": "D:\\RooCodeStorage", diff --git a/src/i18n/locales/it/common.json b/src/i18n/locales/it/common.json index aeaec11d0d..80e8e0633b 100644 --- a/src/i18n/locales/it/common.json +++ b/src/i18n/locales/it/common.json @@ -161,6 +161,10 @@ "incomplete": "Attività #{{taskNumber}} (Incompleta)", "no_messages": "Attività #{{taskNumber}} (Nessun messaggio)" }, + "interruption": { + "responseInterruptedByUser": "Risposta interrotta dall'utente", + "responseInterruptedByApiError": "Risposta interrotta da errore API" + }, "storage": { "prompt_custom_path": "Inserisci il percorso di archiviazione personalizzato per la cronologia delle conversazioni, lascia vuoto per utilizzare la posizione predefinita", "path_placeholder": "D:\\RooCodeStorage", diff --git a/src/i18n/locales/ja/common.json b/src/i18n/locales/ja/common.json index a607dbffd5..accba790a2 100644 --- a/src/i18n/locales/ja/common.json +++ b/src/i18n/locales/ja/common.json @@ -161,6 +161,10 @@ "incomplete": "タスク #{{taskNumber}} (未完了)", "no_messages": "タスク #{{taskNumber}} (メッセージなし)" }, + "interruption": { + "responseInterruptedByUser": "ユーザーによって応答が中断されました", + "responseInterruptedByApiError": "APIエラーによって応答が中断されました" + }, "storage": { "prompt_custom_path": "会話履歴のカスタムストレージパスを入力してください。デフォルトの場所を使用する場合は空のままにしてください", "path_placeholder": "D:\\RooCodeStorage", diff --git a/src/i18n/locales/ko/common.json b/src/i18n/locales/ko/common.json index e48b84fe20..acb7bd47d7 100644 --- a/src/i18n/locales/ko/common.json +++ b/src/i18n/locales/ko/common.json @@ -161,6 +161,10 @@ "incomplete": "작업 #{{taskNumber}} (미완료)", "no_messages": "작업 #{{taskNumber}} (메시지 없음)" }, + "interruption": { + "responseInterruptedByUser": "사용자에 의해 응답이 중단됨", + "responseInterruptedByApiError": "API 오류로 인해 응답이 중단됨" + }, "storage": { "prompt_custom_path": "대화 내역을 위한 사용자 지정 저장 경로를 입력하세요. 기본 위치를 사용하려면 비워두세요", "path_placeholder": "D:\\RooCodeStorage", diff --git a/src/i18n/locales/nl/common.json b/src/i18n/locales/nl/common.json index 0e3e2459a0..d43690c435 100644 --- a/src/i18n/locales/nl/common.json +++ b/src/i18n/locales/nl/common.json @@ -161,6 +161,10 @@ "incomplete": "Taak #{{taskNumber}} (Onvolledig)", "no_messages": "Taak #{{taskNumber}} (Geen berichten)" }, + "interruption": { + "responseInterruptedByUser": "Reactie onderbroken door gebruiker", + "responseInterruptedByApiError": "Reactie onderbroken door API-fout" + }, "storage": { "prompt_custom_path": "Voer een aangepast opslagpad voor gespreksgeschiedenis in, laat leeg voor standaardlocatie", "path_placeholder": "D:\\RooCodeStorage", diff --git a/src/i18n/locales/pl/common.json b/src/i18n/locales/pl/common.json index 1d48b0f9cc..56c076f785 100644 --- a/src/i18n/locales/pl/common.json +++ b/src/i18n/locales/pl/common.json @@ -161,6 +161,10 @@ "incomplete": "Zadanie #{{taskNumber}} (Niekompletne)", "no_messages": "Zadanie #{{taskNumber}} (Brak wiadomości)" }, + "interruption": { + "responseInterruptedByUser": "Odpowiedź przerwana przez użytkownika", + "responseInterruptedByApiError": "Odpowiedź przerwana przez błąd API" + }, "storage": { "prompt_custom_path": "Wprowadź niestandardową ścieżkę przechowywania dla historii konwersacji lub pozostaw puste, aby użyć lokalizacji domyślnej", "path_placeholder": "D:\\RooCodeStorage", diff --git a/src/i18n/locales/pt-BR/common.json b/src/i18n/locales/pt-BR/common.json index 093ef7b0bf..c2cd63255f 100644 --- a/src/i18n/locales/pt-BR/common.json +++ b/src/i18n/locales/pt-BR/common.json @@ -165,6 +165,10 @@ "incomplete": "Tarefa #{{taskNumber}} (Incompleta)", "no_messages": "Tarefa #{{taskNumber}} (Sem mensagens)" }, + "interruption": { + "responseInterruptedByUser": "Resposta interrompida pelo usuário", + "responseInterruptedByApiError": "Resposta interrompida por erro da API" + }, "storage": { "prompt_custom_path": "Digite o caminho de armazenamento personalizado para o histórico de conversas, deixe em branco para usar o local padrão", "path_placeholder": "D:\\RooCodeStorage", diff --git a/src/i18n/locales/ru/common.json b/src/i18n/locales/ru/common.json index 7edd656d8c..9595f1f276 100644 --- a/src/i18n/locales/ru/common.json +++ b/src/i18n/locales/ru/common.json @@ -161,6 +161,10 @@ "incomplete": "Задача #{{taskNumber}} (Незавершенная)", "no_messages": "Задача #{{taskNumber}} (Нет сообщений)" }, + "interruption": { + "responseInterruptedByUser": "Ответ прерван пользователем", + "responseInterruptedByApiError": "Ответ прерван ошибкой API" + }, "storage": { "prompt_custom_path": "Введите пользовательский путь хранения истории разговоров, оставьте пустым для использования расположения по умолчанию", "path_placeholder": "D:\\RooCodeStorage", diff --git a/src/i18n/locales/tr/common.json b/src/i18n/locales/tr/common.json index 20b2824b98..aa11041110 100644 --- a/src/i18n/locales/tr/common.json +++ b/src/i18n/locales/tr/common.json @@ -161,6 +161,10 @@ "incomplete": "Görev #{{taskNumber}} (Tamamlanmamış)", "no_messages": "Görev #{{taskNumber}} (Mesaj yok)" }, + "interruption": { + "responseInterruptedByUser": "Yanıt kullanıcı tarafından kesildi", + "responseInterruptedByApiError": "Yanıt API hatası nedeniyle kesildi" + }, "storage": { "prompt_custom_path": "Konuşma geçmişi için özel depolama yolunu girin, varsayılan konumu kullanmak için boş bırakın", "path_placeholder": "D:\\RooCodeStorage", diff --git a/src/i18n/locales/vi/common.json b/src/i18n/locales/vi/common.json index f4755162fe..b4b92b373e 100644 --- a/src/i18n/locales/vi/common.json +++ b/src/i18n/locales/vi/common.json @@ -161,6 +161,10 @@ "incomplete": "Nhiệm vụ #{{taskNumber}} (Chưa hoàn thành)", "no_messages": "Nhiệm vụ #{{taskNumber}} (Không có tin nhắn)" }, + "interruption": { + "responseInterruptedByUser": "Phản hồi bị gián đoạn bởi người dùng", + "responseInterruptedByApiError": "Phản hồi bị gián đoạn bởi lỗi API" + }, "storage": { "prompt_custom_path": "Nhập đường dẫn lưu trữ tùy chỉnh cho lịch sử hội thoại, để trống để sử dụng vị trí mặc định", "path_placeholder": "D:\\RooCodeStorage", diff --git a/src/i18n/locales/zh-CN/common.json b/src/i18n/locales/zh-CN/common.json index 787c5c8ae9..8c42744484 100644 --- a/src/i18n/locales/zh-CN/common.json +++ b/src/i18n/locales/zh-CN/common.json @@ -166,6 +166,10 @@ "incomplete": "任务 #{{taskNumber}} (未完成)", "no_messages": "任务 #{{taskNumber}} (无消息)" }, + "interruption": { + "responseInterruptedByUser": "响应被用户中断", + "responseInterruptedByApiError": "响应被 API 错误中断" + }, "storage": { "prompt_custom_path": "输入自定义会话历史存储路径,留空以使用默认位置", "path_placeholder": "D:\\RooCodeStorage", diff --git a/src/i18n/locales/zh-TW/common.json b/src/i18n/locales/zh-TW/common.json index 0ae3549d3e..5a31d601b4 100644 --- a/src/i18n/locales/zh-TW/common.json +++ b/src/i18n/locales/zh-TW/common.json @@ -161,6 +161,10 @@ "incomplete": "工作 #{{taskNumber}} (未完成)", "no_messages": "工作 #{{taskNumber}} (無訊息)" }, + "interruption": { + "responseInterruptedByUser": "回應被使用者中斷", + "responseInterruptedByApiError": "回應被 API 錯誤中斷" + }, "storage": { "prompt_custom_path": "輸入自訂會話歷史儲存路徑,留空以使用預設位置", "path_placeholder": "D:\\RooCodeStorage", diff --git a/src/shared/messages.ts b/src/shared/messages.ts deleted file mode 100644 index 6089e03edb..0000000000 --- a/src/shared/messages.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * Centralized user-facing message constants for interruption labels. - * TODO: Consider moving these to i18n JSON in src/i18n/locales/* and wiring through t() - * so they can be localized consistently across the UI. - * - * Note: These are plain phrases (no surrounding brackets). Call sites add any desired decoration. - */ -export const RESPONSE_INTERRUPTED_BY_USER = "Response interrupted by user" -export const RESPONSE_INTERRUPTED_BY_API_ERROR = "Response interrupted by API Error" From 054764cb18023ba2474a611ece80543fd93cbe4b Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Fri, 19 Sep 2025 11:37:56 -0500 Subject: [PATCH 4/6] fix(cancelBookkeeping): check for existing cancelReason before overwriting - Add conditional check to preserve existing cancelReason values - Only set cancelReason if it doesn't already exist - Addresses review feedback from daniel-lxs --- src/core/task-persistence/cancelBookkeeping.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/core/task-persistence/cancelBookkeeping.ts b/src/core/task-persistence/cancelBookkeeping.ts index 0eb7f2a7ea..e59b5dc71d 100644 --- a/src/core/task-persistence/cancelBookkeeping.ts +++ b/src/core/task-persistence/cancelBookkeeping.ts @@ -32,8 +32,12 @@ export async function addCancelReasonToLastApiReqStarted(args: { try { const existing = uiMsgs[idx]?.text ? JSON.parse(uiMsgs[idx].text as string) : {} - uiMsgs[idx].text = JSON.stringify({ ...existing, cancelReason: reason }) - await saveTaskMessages({ messages: uiMsgs as any, taskId, globalStoragePath }) + // Only set cancelReason if it doesn't already exist + if (!existing.cancelReason) { + uiMsgs[idx].text = JSON.stringify({ ...existing, cancelReason: reason }) + await saveTaskMessages({ messages: uiMsgs as any, taskId, globalStoragePath }) + } + // If cancelReason already exists, preserve the original reason } catch { // Non-fatal parse or write failure return From b357f4cf8692965795d09870779e9ab6f54cee43 Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Fri, 19 Sep 2025 17:51:58 -0500 Subject: [PATCH 5/6] refactor: remove cancelBookkeeping module and simplify cancellation flow - Delete cancelBookkeeping.ts entirely - Remove bookkeeping imports and calls from ClineProvider - Simplify abortStream in Task.ts - Keep core fixes: centralized rehydration, abortReason tracking, abandoned flag - Let UI handle cancellation display without modifying persisted data --- .../task-persistence/cancelBookkeeping.ts | 77 ------------------- src/core/task/Task.ts | 20 +---- src/core/webview/ClineProvider.ts | 23 ------ 3 files changed, 1 insertion(+), 119 deletions(-) delete mode 100644 src/core/task-persistence/cancelBookkeeping.ts diff --git a/src/core/task-persistence/cancelBookkeeping.ts b/src/core/task-persistence/cancelBookkeeping.ts deleted file mode 100644 index e59b5dc71d..0000000000 --- a/src/core/task-persistence/cancelBookkeeping.ts +++ /dev/null @@ -1,77 +0,0 @@ -import type { ClineMessage } from "@roo-code/types" -import { readTaskMessages, saveTaskMessages } from "./taskMessages" -import { readApiMessages, saveApiMessages } from "./apiMessages" -import type { ClineApiReqCancelReason } from "../../shared/ExtensionMessage" - -// Safely add cancelReason to the last api_req_started UI message -export async function addCancelReasonToLastApiReqStarted(args: { - taskId: string - globalStoragePath: string - reason: ClineApiReqCancelReason -}): Promise { - const { taskId, globalStoragePath, reason } = args - - try { - const uiMsgs = (await readTaskMessages({ taskId, globalStoragePath })) as ClineMessage[] - - if (!Array.isArray(uiMsgs) || uiMsgs.length === 0) { - return - } - - // Find last api_req_started - const revIdx = uiMsgs - .slice() - .reverse() - .findIndex((m) => m?.type === "say" && (m as any)?.say === "api_req_started") - - if (revIdx === -1) { - return - } - - const idx = uiMsgs.length - 1 - revIdx - - try { - const existing = uiMsgs[idx]?.text ? JSON.parse(uiMsgs[idx].text as string) : {} - // Only set cancelReason if it doesn't already exist - if (!existing.cancelReason) { - uiMsgs[idx].text = JSON.stringify({ ...existing, cancelReason: reason }) - await saveTaskMessages({ messages: uiMsgs as any, taskId, globalStoragePath }) - } - // If cancelReason already exists, preserve the original reason - } catch { - // Non-fatal parse or write failure - return - } - } catch { - // Non-fatal read failure - return - } -} - -// Append an assistant interruption marker to API conversation history -// only if the last message isn't already an assistant. -export async function appendAssistantInterruptionIfNeeded(args: { - taskId: string - globalStoragePath: string - text: string -}): Promise { - const { taskId, globalStoragePath, text } = args - - try { - const apiMsgs = await readApiMessages({ taskId, globalStoragePath }) - const last = apiMsgs.at(-1) - - if (!last || last.role !== "assistant") { - apiMsgs.push({ - role: "assistant", - content: [{ type: "text", text }], - ts: Date.now(), - } as any) - - await saveApiMessages({ messages: apiMsgs as any, taskId, globalStoragePath }) - } - } catch { - // Non-fatal read/write failure - return - } -} diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 4d73c397de..ec7c07999f 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -1895,28 +1895,10 @@ export class Task extends EventEmitter implements TaskLike { lastMessage.partial = false // instead of streaming partialMessage events, we do a save and post like normal to persist to disk console.log("updating partial message", lastMessage) - // await this.saveClineMessages() } - // Let assistant know their response was interrupted for when task is resumed - await this.addToApiConversationHistory({ - role: "assistant", - content: [ - { - type: "text", - text: - assistantMessage + - `\n\n[${ - cancelReason === "streaming_failed" - ? t("common:interruption.responseInterruptedByApiError") - : t("common:interruption.responseInterruptedByUser") - }]`, - }, - ], - }) - // Update `api_req_started` to have cancelled and cost, so that - // we can display the cost of the partial stream. + // we can display the cost of the partial stream and the cancellation reason updateApiReqMsg(cancelReason, streamingFailedMessage) await this.saveClineMessages() diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index c62961e19b..572483e407 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -94,10 +94,6 @@ import { readApiMessages, saveApiMessages, saveTaskMessages } from "../task-pers import { getNonce } from "./getNonce" import { getUri } from "./getUri" -import { - addCancelReasonToLastApiReqStarted, - appendAssistantInterruptionIfNeeded, -} from "../task-persistence/cancelBookkeeping" /** * https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts * https://github.com/KumarVariable/vscode-extension-sidebar-html/blob/master/src/customSidebarViewProvider.ts @@ -2602,25 +2598,6 @@ export class ClineProvider return } - // Provider-side cancel bookkeeping to mirror abortStream effects for user_cancelled - try { - // Persist cancelReason to last api_req_started in UI messages - await addCancelReasonToLastApiReqStarted({ - taskId: task.taskId, - globalStoragePath: this.contextProxy.globalStorageUri.fsPath, - reason: "user_cancelled", - }) - - // Append assistant interruption marker to API conversation history if needed - await appendAssistantInterruptionIfNeeded({ - taskId: task.taskId, - globalStoragePath: this.contextProxy.globalStorageUri.fsPath, - text: `[${t("common:interruption.responseInterruptedByUser")}]`, - }) - } catch (e) { - this.log(`[cancelTask] Cancel bookkeeping failed: ${e instanceof Error ? e.message : String(e)}`) - } - // Final race check before rehydrate to avoid duplicate rehydration { const currentAfterBookkeeping = this.getCurrentTask() From b1958e873da1235371892b5db5f9d613e6e00553 Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Fri, 19 Sep 2025 18:20:25 -0500 Subject: [PATCH 6/6] fix: remove misleading bookkeeping references from log messages - Update log messages to reflect that we're no longer doing bookkeeping - Rename variable from currentAfterBookkeeping to currentAfterCheck - Make the code accurately describe what it's doing (race condition checks) --- src/core/webview/ClineProvider.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 572483e407..9d987db16f 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2593,17 +2593,17 @@ export class ClineProvider const current = this.getCurrentTask() if (current && current.instanceId !== originalInstanceId) { this.log( - `[cancelTask] Skipping cancel bookkeeping and rehydrate: current instance ${current.instanceId} != original ${originalInstanceId}`, + `[cancelTask] Skipping rehydrate: current instance ${current.instanceId} != original ${originalInstanceId}`, ) return } // Final race check before rehydrate to avoid duplicate rehydration { - const currentAfterBookkeeping = this.getCurrentTask() - if (currentAfterBookkeeping && currentAfterBookkeeping.instanceId !== originalInstanceId) { + const currentAfterCheck = this.getCurrentTask() + if (currentAfterCheck && currentAfterCheck.instanceId !== originalInstanceId) { this.log( - `[cancelTask] Skipping rehydrate after bookkeeping: current instance ${currentAfterBookkeeping.instanceId} != original ${originalInstanceId}`, + `[cancelTask] Skipping rehydrate after final check: current instance ${currentAfterCheck.instanceId} != original ${originalInstanceId}`, ) return }