Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
0575a35
fix: prevent agent loop stall from WriteToFileTool filesystem errors …
awschmeder Jun 26, 2026
75b52e3
fix: clear stuck UI spinner and duplicate/repeated errors on write_to…
awschmeder Jun 26, 2026
0966556
fix(write-to-file): address partial filesystem error review
easonliang28 Jul 30, 2026
16c4d48
fix(write-to-file): guard diff reset cleanup
easonliang28 Jul 30, 2026
be0e154
fix(write-to-file): guard partial ask finalization
easonliang28 Jul 30, 2026
224690b
fix(write-to-file): clean partial state on task abort
easonliang28 Jul 30, 2026
dd31f44
Merge branch 'main' into fix/pr-727-review
easonLiangWorldedtech Aug 12, 2026
c17b15b
fix(write-to-file): address remaining review cleanup
easonliang28 Aug 12, 2026
c011322
Merge branch 'main' into fix/pr-727-review
easonLiangWorldedtech Aug 12, 2026
44f9aa9
Merge branch 'main' into fix/pr-727-review
easonLiangWorldedtech Aug 15, 2026
6f99772
fix(write-to-file): clean up partial state on rooignore denial
easonliang28 Aug 19, 2026
4189f88
test(task): cover finalizePartialToolAsk updateClineMessage rejection…
easonliang28 Aug 23, 2026
f3e4d40
fix(tools): finalize open partial tool ask on args parse failure
easonliang28 Aug 29, 2026
c47e3c7
fix(write-to-file): address review findings on partial-stream cleanup
easonliang28 Aug 29, 2026
a03ac8c
Merge branch 'main' into fix/pr-727-review
easonLiangWorldedtech Sep 2, 2026
d7f8038
test(write-to-file): cover partial-ask finalize rejection, revert fai…
easonliang28 Sep 2, 2026
26ad729
Merge branch 'main' into fix/pr-727-review
edelauna Sep 3, 2026
f8b6b35
fix(mutation): match direct spec files case-insensitively
easonliang28 Sep 4, 2026
5d32001
test(task): cover finalizePartialToolAsk predicate clause mismatches
easonliang28 Sep 4, 2026
26dda51
test(tools): cover write_to_file stabilization and cleanup branches
easonliang28 Sep 4, 2026
4bd7d88
Merge branch 'main' into fix/pr-727-review
easonLiangWorldedtech Sep 4, 2026
4fd1a9f
fix(core): address CodeRabbit review findings on write_to_file and ta…
easonliang28 Sep 4, 2026
3228643
fix(task): report save stages independently in saveClineMessages
easonliang28 Sep 4, 2026
9c26889
Merge upstream/main into fix/pr-727-review
easonliang28 Sep 15, 2026
3d23d9d
test(core): harden finalizePartialToolAsk and write_to_file cleanup t…
easonliang28 Sep 15, 2026
ef7a941
chore(coverage): update extension coverage population baseline after …
easonliang28 Sep 15, 2026
b9e2bda
Merge remote-tracking branch 'upstream/main' into fix/pr-727-review
easonliang28 Sep 16, 2026
a704b15
fix(write_to_file): report captured streaming error on parse failure;…
easonliang28 Sep 16, 2026
7b6835f
fix(write_to_file): run diff cleanup when handleError rejects; tighte…
easonliang28 Sep 16, 2026
9d75b10
test(write_to_file): prove revert-then-reset ordering when handleErro…
easonliang28 Sep 16, 2026
8a5bf2d
fix(write_to_file): restore diff document on parse-failure teardown
easonliang28 Sep 16, 2026
9f8e91c
Merge branch 'main' into fix/pr-727-review
easonLiangWorldedtech Sep 23, 2026
ac673f9
test(write-to-file): assert the no-argument finalize call on parse fa…
Sep 28, 2026
6db4039
Merge branch 'main' into fix/pr-727-review
easonLiangWorldedtech Oct 3, 2026
ce20e1b
Assert the exact listener reference that off removes
Oct 4, 2026
c6e9590
Re-trigger review at head: all required checks green and every review…
Oct 4, 2026
b8353a2
Release per-task stream state on direct disposal
Oct 4, 2026
eef03b5
Drop the out-of-scope comment from the throttle test
Oct 4, 2026
d5c4960
Drop out-of-scope Stryker tooling changes
Oct 4, 2026
46d1d21
Restore the Stryker tooling files to the PR base
Oct 4, 2026
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
33 changes: 33 additions & 0 deletions src/core/task/Task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1831,6 +1831,39 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
return formatResponse.toolError(formatResponse.missingToolParameterError(paramName))
}

/**
* Finalize a partial "tool" ask message without blocking for user input.
* Call this in error paths where a partial tool message was opened during streaming
* but execution failed before the normal approval flow could close it, so the webview
* spinner does not get stuck in a loading state.
*
* The matching partial message may no longer be the final entry if another asynchronous
* message was inserted between the partial ask and the error handler, so search backward
* instead of relying on clineMessages.at(-1).
*/
async finalizePartialToolAsk(text?: string): Promise<void> {
const partialToolAsk = this.clineMessages
.slice()
.reverse()
.find(
(message) =>
message.partial === true &&
message.type === "ask" &&
message.ask === "tool" &&
(text === undefined || message.text === text),
)
Comment thread
easonLiangWorldedtech marked this conversation as resolved.
Outdated

if (!partialToolAsk) {
return
}

partialToolAsk.partial = false
await this.saveClineMessages()
Comment thread
easonLiangWorldedtech marked this conversation as resolved.
Outdated
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
await this.updateClineMessage(partialToolAsk).catch((error) => {
console.error("[Task#finalizePartialToolAsk] updateClineMessage failed:", error)
})
}

// Lifecycle
// Start / Resume / Abort / Dispose

Expand Down
72 changes: 72 additions & 0 deletions src/core/task/__tests__/Task.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2857,6 +2857,78 @@ describe("Cline", () => {
saveSpy.mockRestore()
})

it("finalizePartialToolAsk persists and updates a non-last partial tool ask", async () => {
const updateSpy = vi
.spyOn(getTaskTestAccess(Task.prototype), "updateClineMessage")
.mockResolvedValue(undefined)
const saveSpy = vi.spyOn(getTaskTestAccess(Task.prototype), "saveClineMessages").mockResolvedValue(true)

const task = new Task({
provider: mockProvider,
apiConfiguration: mockApiConfig,
task: "test task",
startTask: false,
})

const partialToolAsk = {
ts: Date.now() - 2,
type: "ask" as const,
ask: "tool" as const,
text: "partial tool message",
partial: true,
}

task.clineMessages.push(partialToolAsk)
task.clineMessages.push({
ts: Date.now() - 1,
type: "say",
say: "error",
text: "intervening async message",
})

await task.finalizePartialToolAsk("partial tool message")
await flushMicrotasks()

expect(partialToolAsk.partial).toBe(false)
expect(saveSpy).toHaveBeenCalled()
expect(updateSpy).toHaveBeenCalledWith(partialToolAsk)
Comment thread
easonLiangWorldedtech marked this conversation as resolved.

updateSpy.mockRestore()
saveSpy.mockRestore()
})

it("finalizePartialToolAsk ignores non-matching partial tool asks when text is provided", async () => {
const updateSpy = vi
.spyOn(getTaskTestAccess(Task.prototype), "updateClineMessage")
.mockResolvedValue(undefined)
const saveSpy = vi.spyOn(getTaskTestAccess(Task.prototype), "saveClineMessages").mockResolvedValue(true)

const task = new Task({
provider: mockProvider,
apiConfiguration: mockApiConfig,
task: "test task",
startTask: false,
})

task.clineMessages.push({
ts: Date.now() - 1,
type: "ask",
ask: "tool",
text: "other partial tool message",
partial: true,
})

await task.finalizePartialToolAsk("target partial tool message")
await flushMicrotasks()

expect(task.clineMessages[0].partial).toBe(true)
expect(saveSpy).not.toHaveBeenCalled()
expect(updateSpy).not.toHaveBeenCalled()
Comment thread
easonLiangWorldedtech marked this conversation as resolved.

updateSpy.mockRestore()
saveSpy.mockRestore()
})

it("logs (instead of crashing) when updateClineMessage rejects from the ask() ignore-partial path", async () => {
// Pins the .catch arm on the fire-and-forget updateClineMessage call
// in ask() when a new partial ask arrives while the previous partial
Expand Down
3 changes: 3 additions & 0 deletions src/core/task/__tests__/Task.throttle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,12 @@ describe("Task token usage throttling", () => {
let mockProvider: any
let mockApiConfiguration: ProviderSettings
let task: Task
let consoleLogSpy: ReturnType<typeof vi.spyOn>

beforeEach(() => {
// Reset all mocks
vi.clearAllMocks()
consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
vi.useFakeTimers()

// Mock provider
Expand Down Expand Up @@ -101,6 +103,7 @@ describe("Task token usage throttling", () => {
if (task && !task.abort) {
task.dispose()
}
consoleLogSpy.mockRestore()
})

test("should emit TaskTokenUsageUpdated immediately on first change", async () => {
Expand Down
110 changes: 88 additions & 22 deletions src/core/tools/WriteToFileTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,48 @@ interface WriteToFileParams {
export class WriteToFileTool extends BaseTool<"write_to_file"> {
readonly name = "write_to_file" as const

/**
* Tracks filesystem failures from diff-view streaming by task id. Tool instances are
* singletons, so this state must be keyed per task to avoid one task's failing partial
* stream suppressing another task's streaming deltas.
*/
private partialStreamFailuresByTaskId = new Set<string>()
Comment thread
easonLiangWorldedtech marked this conversation as resolved.
Outdated

/**
* Tracks partial path stabilization by task id. The tool is a singleton, so using the
* BaseTool singleton path state lets concurrent tasks incorrectly stabilize each other.
*/
private lastSeenPartialPathByTaskId = new Map<string, string | undefined>()
Comment thread
easonLiangWorldedtech marked this conversation as resolved.
Outdated

private getPartialStreamFailureKey(task: Task): string {
return `${task.taskId}.${task.instanceId}`
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

private hasPathStabilizedForTask(task: Task, partialPath: string | undefined): boolean {
const key = this.getPartialStreamFailureKey(task)
const lastSeenPath = this.lastSeenPartialPathByTaskId.get(key)
const pathHasStabilized = lastSeenPath !== undefined && lastSeenPath === partialPath
this.lastSeenPartialPathByTaskId.set(key, partialPath)
return pathHasStabilized && !!partialPath
}

private resetTaskPartialState(task: Task): void {
const key = this.getPartialStreamFailureKey(task)
this.lastSeenPartialPathByTaskId.delete(key)
this.partialStreamFailuresByTaskId.delete(key)
}

override resetPartialState(): void {
super.resetPartialState()
this.partialStreamFailuresByTaskId.clear()
this.lastSeenPartialPathByTaskId.clear()
}

async execute(params: WriteToFileParams, task: Task, callbacks: ToolCallbacks): Promise<void> {
const { pushToolResult, handleError, askApproval } = callbacks
const relPath = params.path
let newContent = params.content
const partialStreamFailureKey = this.getPartialStreamFailureKey(task)

if (!relPath) {
task.consecutiveMistakeCount++
Expand Down Expand Up @@ -67,12 +105,6 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> {
task.diffViewProvider.editType = fileExists ? "modify" : "create"
}

// Create parent directories early for new files to prevent ENOENT errors
// in subsequent operations (e.g., diffViewProvider.open, fs.readFile)
if (!fileExists) {
await createDirectoriesForFile(absolutePath)
}

if (newContent.startsWith("```")) {
newContent = newContent.split("\n").slice(1).join("\n")
}
Expand All @@ -97,6 +129,13 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> {
}

try {
// Create parent directories for new files inside the try block so filesystem
// errors (EROFS, EACCES, etc.) route through handleError with proper cleanup
// and consecutive-mistake counting, rather than escaping unhandled.
if (!fileExists) {
await createDirectoriesForFile(absolutePath)
}

task.consecutiveMistakeCount = 0

const provider = task.providerRef.deref()
Expand Down Expand Up @@ -180,15 +219,20 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> {
pushToolResult(message)

await task.diffViewProvider.reset()
this.resetPartialState()
this.resetTaskPartialState(task)

task.processQueuedMessages()

return
} catch (error) {
// Finalize any open partial tool message so the UI spinner doesn't get stuck.
// The partial ask fired during streaming (handlePartial) or early in execute sets
// partial: true on the webview message; without this, the spinner persists even
// after the error bubble appears.
await task.finalizePartialToolAsk()
await handleError("writing file", error as Error)
await task.diffViewProvider.reset()
this.resetPartialState()
this.resetTaskPartialState(task)
return
}
}
Expand All @@ -197,8 +241,17 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> {
const relPath: string | undefined = block.params.path
const newContent: string | undefined = block.params.content

const partialStreamFailureKey = this.getPartialStreamFailureKey(task)

// A prior streaming delta for this task already hit a fatal filesystem error.
// Skip further streaming work so we don't create a new partial tool message on every
// subsequent delta. execute() will report the error once when the block completes.
if (this.partialStreamFailuresByTaskId.has(partialStreamFailureKey)) {
return
}

// Wait for path to stabilize before showing UI (prevents truncated paths)
if (!this.hasPathStabilized(relPath) || newContent === undefined) {
if (!this.hasPathStabilizedForTask(task, relPath) || newContent === undefined) {
return
}

Expand All @@ -224,12 +277,6 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> {
task.diffViewProvider.editType = fileExists ? "modify" : "create"
}

// Create parent directories early for new files to prevent ENOENT errors
// in subsequent operations (e.g., diffViewProvider.open)
if (!fileExists) {
await createDirectoriesForFile(absolutePath)
}

const isWriteProtected = task.rooProtectedController?.isWriteProtected(relPath!) || false
const isOutsideWorkspace = isPathOutsideWorkspace(absolutePath)

Expand All @@ -245,14 +292,33 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> {
await task.ask("tool", partialMessage, block.partial).catch(() => {})

if (newContent) {
if (!task.diffViewProvider.isEditing) {
await task.diffViewProvider.open(relPath!)
}
try {
if (!task.diffViewProvider.isEditing) {
await task.diffViewProvider.open(relPath!)
}

await task.diffViewProvider.update(
everyLineHasLineNumbers(newContent) ? stripLineNumbers(newContent) : newContent,
false,
)
await task.diffViewProvider.update(
everyLineHasLineNumbers(newContent) ? stripLineNumbers(newContent) : newContent,
false,
)
} catch (error) {
// Opening or updating the diff view can throw on filesystem errors
// (EACCES/EROFS on read-only paths). Finalize the partial tool message
// so the UI spinner doesn't get stuck and reset the diff view. Do NOT
// rethrow: the same filesystem operation is retried in execute() once the
// block completes, and that authoritative non-partial path reports the
// error to the user. Surfacing it here too would show the same error twice.
// Swallowing it here is safe because the agent loop advances naturally when
// the non-partial block arrives (it does not depend on this throw).
console.error(`Error streaming write_to_file diff view:`, error)
// Mark the stream as failed so later deltas don't re-attempt and spawn a new
// partial tool message each time.
this.partialStreamFailuresByTaskId.add(partialStreamFailureKey)
await task.finalizePartialToolAsk(partialMessage)
await task.diffViewProvider.reset().catch((resetError) => {
console.error("Error resetting write_to_file diff view after partial failure:", resetError)
})
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
}
Expand Down
Loading
Loading