Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 21 additions & 29 deletions src/core/task/Task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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
Expand Down
67 changes: 60 additions & 7 deletions src/core/webview/ClineProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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"
Copy link

Copilot AI Sep 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Direct property assignment to abortReason suggests this property may not be part of the Task interface. Consider adding this property to the Task interface or using a setter method to ensure type safety.

Copilot uses AI. Check for mistakes.

// 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 ||
Expand All @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions src/i18n/locales/ca/common.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions src/i18n/locales/de/common.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions src/i18n/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions src/i18n/locales/es/common.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions src/i18n/locales/fr/common.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions src/i18n/locales/hi/common.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions src/i18n/locales/id/common.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions src/i18n/locales/it/common.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions src/i18n/locales/ja/common.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions src/i18n/locales/ko/common.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions src/i18n/locales/nl/common.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions src/i18n/locales/pl/common.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions src/i18n/locales/pt-BR/common.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions src/i18n/locales/ru/common.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions src/i18n/locales/tr/common.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions src/i18n/locales/vi/common.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions src/i18n/locales/zh-CN/common.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions src/i18n/locales/zh-TW/common.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading