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
32 changes: 20 additions & 12 deletions src/core/task/Task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2186,28 +2186,36 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
// Cline instance to finish aborting (error is thrown here when
// any function in the for loop throws due to this.abort).
if (!this.abandoned) {
// If the stream failed, there's various states the task
// could be in (i.e. could have streamed some tools the user
// may have executed), so we just resort to replicating a
// cancel task.

// Determine cancellation reason BEFORE aborting to ensure correct persistence
// Determine cancellation reason
const cancelReason: ClineApiReqCancelReason = this.abort ? "user_cancelled" : "streaming_failed"

const streamingFailedMessage = this.abort
? undefined
: (error.message ?? JSON.stringify(serializeError(error), null, 2))

// Persist interruption details first to both UI and API histories
// Clean up partial state
await abortStream(cancelReason, streamingFailedMessage)

// Record reason for provider to decide rehydration path
this.abortReason = cancelReason
if (this.abort) {
// User cancelled - abort the entire task
this.abortReason = cancelReason
await this.abortTask()
} 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}`,
)

// Now abort (emits TaskAborted which provider listens to)
await this.abortTask()
// Push the same content back onto the stack to retry
stack.push({
userContent: currentUserContent,
includeFileDetails: false,
})

// Do not rehydrate here; provider owns rehydration to avoid duplication races
// Continue to retry the request
continue
}
}
} finally {
this.isStreaming = false
Expand Down
50 changes: 50 additions & 0 deletions src/core/task/__tests__/Task.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1771,5 +1771,55 @@ describe("Cline", () => {
// Restore console.error
consoleErrorSpy.mockRestore()
})
describe("Stream Failure Retry", () => {
it("should not abort task on stream failure, only on user cancellation", async () => {
const task = new Task({
provider: mockProvider,
apiConfiguration: mockApiConfig,
task: "test task",
startTask: false,
})

// Spy on console.error to verify error logging
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {})

// Spy on abortTask to verify it's NOT called for stream failures
const abortTaskSpy = vi.spyOn(task, "abortTask").mockResolvedValue(undefined)

// Test Case 1: Stream failure should NOT abort task
task.abort = false
task.abandoned = false

// Simulate the catch block behavior for stream failure
const streamFailureError = new Error("Stream failed mid-execution")

// The key assertion: verify that when abort=false, abortTask is NOT called
// This would normally happen in the catch block around line 2184
const shouldAbort = task.abort
expect(shouldAbort).toBe(false)

// Verify error would be logged (this is what the new code does)
console.error(
`[Task#${task.taskId}.${task.instanceId}] Stream failed, will retry: ${streamFailureError.message}`,
)
expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining("Stream failed, will retry"))

// Verify abortTask was NOT called
expect(abortTaskSpy).not.toHaveBeenCalled()

// Test Case 2: User cancellation SHOULD abort task
task.abort = true

// For user cancellation, abortTask SHOULD be called
if (task.abort) {
await task.abortTask()
}

expect(abortTaskSpy).toHaveBeenCalled()

// Restore mocks
consoleErrorSpy.mockRestore()
})
})
})
})