Просмотр исходного кода

Fix duplicate rehydrate during reasoning; centralize rehydrate and preserve cancel metadata (#8171)

Co-authored-by: daniel-lxs <[email protected]>
Hannes Rudolph 3 месяцев назад
Родитель
Сommit
d956cdb727

+ 21 - 29
src/core/task/Task.ts

@@ -212,6 +212,7 @@ export class Task extends EventEmitter<TaskEvents> 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<TaskEvents> 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
@@ -1884,28 +1895,10 @@ export class Task extends EventEmitter<TaskEvents> 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"
-											? "Response interrupted by API Error"
-											: "Response interrupted by user"
-									}]`,
-							},
-						],
-					})
-
 					// 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()
 
@@ -2187,24 +2180,23 @@ export class Task extends EventEmitter<TaskEvents> 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

+ 60 - 7
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.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,24 @@ export class ClineProvider
 
 		console.log(`[cancelTask] cancelling task ${task.taskId}.${task.instanceId}`)
 
-		const { historyItem } = 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
 		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 original instance as abandoned to prevent any residual activity
+		task.abandoned = true
+
 		await pWaitFor(
 			() =>
 				this.getCurrentTask()! === undefined ||
@@ -2549,11 +2589,24 @@ 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 rehydrate: current instance ${current.instanceId} != original ${originalInstanceId}`,
+			)
+			return
+		}
+
+		// Final race check before rehydrate to avoid duplicate rehydration
+		{
+			const currentAfterCheck = this.getCurrentTask()
+			if (currentAfterCheck && currentAfterCheck.instanceId !== originalInstanceId) {
+				this.log(
+					`[cancelTask] Skipping rehydrate after final check: current instance ${currentAfterCheck.instanceId} != original ${originalInstanceId}`,
+				)
+				return
+			}
 		}
 
 		// Clears task again, so we need to abortTask manually above.

+ 4 - 0
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",

+ 4 - 0
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",

+ 4 - 0
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",

+ 4 - 0
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",

+ 4 - 0
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",

+ 4 - 0
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",

+ 4 - 0
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",

+ 4 - 0
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",

+ 4 - 0
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",

+ 4 - 0
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",

+ 4 - 0
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",

+ 4 - 0
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",

+ 4 - 0
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",

+ 4 - 0
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",

+ 4 - 0
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",

+ 4 - 0
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",

+ 4 - 0
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",

+ 4 - 0
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",