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
1 change: 1 addition & 0 deletions src/core/task/Task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3236,6 +3236,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
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
Expand Down
93 changes: 93 additions & 0 deletions src/core/task/__tests__/Task.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ vi.mock("delay", () => ({
}))

import delay from "delay"
import pWaitFor from "p-wait-for"

vi.mock("uuid", async (importOriginal) => {
const actual = await importOriginal<typeof import("uuid")>()
Expand Down Expand Up @@ -713,6 +714,98 @@ describe("Cline", () => {
])
})

it("clears didFinishAbortingStream on retry so cancelTask waits for the new stream (#1801)", async () => {
const task = new Task({
provider: mockProvider,
apiConfiguration: mockApiConfig,
task: "abort flag reset test",
startTask: false,
})

vi.spyOn(task.diffViewProvider, "reset").mockResolvedValue(undefined)
vi.spyOn(getTaskTestAccess(task), "safeEnsureModelFetched").mockResolvedValue(stubModelInfo)
vi.spyOn(getTaskTestAccess(task), "presentAssistantMessageSafe").mockImplementation(() => {})

const failingStream = async function* (): AsyncGenerator<ApiStreamChunk> {
yield { type: "text", text: "partial" }
throw new Error("simulated mid-stream failure")
}

let releaseRetryStream: (() => void) | undefined
let markRetryStreamPaused: (() => void) | undefined
const retryStreamRelease = new Promise<void>((resolve) => {
releaseRetryStream = resolve
})
const retryStreamPaused = new Promise<void>((resolve) => {
markRetryStreamPaused = resolve
})
const pausedRetryStream = async function* (): AsyncGenerator<ApiStreamChunk> {
yield { type: "text", text: "retry" }
markRetryStreamPaused?.()
await retryStreamRelease
}

const attemptApiRequestSpy = vi
.spyOn(task, "attemptApiRequest")
.mockImplementationOnce(() => failingStream())
.mockImplementationOnce(() => pausedRetryStream())
.mockImplementation(() => {
throw new Error("stop after retry response")
})

const { default: realPWaitFor } = await vi.importActual<typeof import("p-wait-for")>("p-wait-for")
vi.mocked(pWaitFor).mockImplementation(realPWaitFor)
let request: Promise<boolean> | undefined
let cancel: Promise<void> | undefined
try {
await mockProvider.addClineToStack(task)
request = task.recursivelyMakeClineRequests([{ type: "text", text: "abort flag reset test" }])
await Promise.race([
retryStreamPaused,
request.then(() => {
throw new Error("request settled before the retry stream paused")
}),
])

const firstApiReq = task.clineMessages.find((message) => message.say === "api_req_started")
expect(JSON.parse(firstApiReq?.text ?? "{}")).toMatchObject({ cancelReason: "streaming_failed" })
expect(attemptApiRequestSpy).toHaveBeenCalledTimes(2)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Can we replace this fixed 200 ms sleep with a condition-based wait (e.g. poll until cancelTask has entered its internal pWaitFor)? On a stalled CI machine, 200 ms may not be enough for cancelTask to reach the wait for reasons unrelated to the fix, which would make the pending assertion below pass vacuously. A condition-based wait removes that blind spot entirely.

let markCancelWaiting: (() => void) | undefined
const cancelWaiting = new Promise<void>((resolve) => {
markCancelWaiting = resolve
})
vi.mocked(pWaitFor).mockImplementation((condition, options) =>
realPWaitFor(async () => {
const done = await condition()
if (!done) markCancelWaiting?.()
return done
}, options),
)

vi.spyOn(mockProvider, "createTaskWithHistoryItem").mockImplementation((historyItem) =>
ClineProvider.prototype.createTaskWithHistoryItem.call(mockProvider, historyItem, {
startTask: false,
}),
)

let cancelSettled = false
cancel = mockProvider.cancelTask().then(() => {
cancelSettled = true
})
await Promise.race([cancelWaiting, cancel])
expect(cancelSettled, "cancelTask must wait while the retry stream is live").toBe(false)

releaseRetryStream?.()
await cancel
await expect(request).resolves.toBe(true)
} finally {
releaseRetryStream?.()
await Promise.allSettled([request, cancel])
vi.mocked(pWaitFor).mockImplementation(async () => {})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
})

it("finalizes MCP tool call using the request-scoped parser state", async () => {
const task = new Task({
provider: mockProvider,
Expand Down
Loading