Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
cf5abe6
fix(task): stage-independent saveClineMessages + finalize open partia…
easonLiangWorldedtech Oct 5, 2026
d172c95
feat(write-to-file): per-task partial stream state + cleanup primitives
easonLiangWorldedtech Oct 5, 2026
52699c6
test(write-to-file): cover partial-state cleanup primitives directly
easonLiangWorldedtech Oct 5, 2026
4b23b6a
fix(write-to-file): capture streaming failure once, report it once
easonLiangWorldedtech Oct 5, 2026
e3c1040
feat(tools): onParameterParseFailure teardown boundary
easonLiangWorldedtech Oct 5, 2026
445c2f5
chore(ci): re-trigger the unit suite after the flaky writeToFileTool …
Oct 6, 2026
1e0f1c4
test(write-to-file): align the partial-path case with handlePartial's…
Oct 6, 2026
b04e224
fix(tools): scope the write_to_file stream teardown to one task and f…
Oct 6, 2026
0dc92ca
fix(tools): do not report a completed teardown when the diff revert f…
Oct 6, 2026
58c1a2b
test(tools): cover rollback failure with a retained stream error, and…
Oct 6, 2026
af51675
fix(tools): report the rollback hazard from the failed-partial-stream…
Oct 6, 2026
1311c90
fix(tools): tear down the per-task stream state when a write is rejected
Oct 6, 2026
5d713e5
chore: trigger a fresh review pass at this head
Oct 7, 2026
41ae456
fix(tools): release the stream state on execute()'s early returns too
Oct 8, 2026
7c11c26
fix(write-to-file): stop a cancelled partial stream at every provider…
Oct 8, 2026
6626914
fix(write-to-file): let the cancellation teardown own a rejected open…
Oct 8, 2026
bcd801e
fix(write-to-file): release the partial stream state when the preview…
Oct 9, 2026
c1a9f1d
Merge org main (036245c5e, U1 #1927) into p1066/u3-parse-failure-boun…
Oct 9, 2026
41ccb42
fix(write-to-file): guard every setup step and roll a failed diff-ope…
easonLiangWorldedtech Oct 9, 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
41 changes: 41 additions & 0 deletions src/__tests__/removeClineFromStack-delegation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { ClineProvider } from "../core/webview/ClineProvider"
import { TaskRegistry } from "../core/task/TaskRegistry"
import { PendingActionSettlementError, type Task } from "../core/task/Task"
import { makeProviderStub } from "./helpers/provider-stub"
import { writeToFileTool } from "../core/tools/WriteToFileTool"

type MockTask = Pick<Task, "taskId" | "instanceId"> &
Partial<Pick<Task, "parentTaskId" | "abort" | "abandoned">> & {
Expand Down Expand Up @@ -208,6 +209,46 @@ describe("ClineProvider failed history restoration cleanup", () => {
expect(task.dispose).toHaveBeenCalledOnce()
})

it("releases the tool's per-task state before a directly disposed task loses its listeners", async () => {
const key = "failed-history-task.inst-1"
const order: string[] = []
const task = {
taskId: "failed-history-task",
instanceId: "inst-1",
emit: vi.fn(),
once: vi.fn(),
off: vi.fn(),
dispose: vi.fn().mockImplementation(() => {
// Dispose removes every listener, so this is the last moment the abort
// cleanup could still have run; the entry must already be gone here.
order.push(writeToFileTool["taskPartialStreamState"].has(key) ? "state-retained" : "state-cleared")
order.push("dispose")
return Promise.resolve()
}),
} as unknown as Task
// Fixture: the state entry the tool would have created during a partial stream.
writeToFileTool["taskPartialStreamState"].set(key, {
lastSeenPartialPath: undefined,
streamFailed: false,
streamError: undefined,
task,
abortCleanup: () => {},
})
const taskEventListeners = new Map([[task, [vi.fn()]]])
const taskRegistry = new TaskRegistry()
taskRegistry.push(task)
const provider = { taskRegistry, taskEventListeners, log: vi.fn() } as unknown as ClineProvider

await privateClineProvider.cleanupFailedHistoryTask.call(
provider,
task,
new PendingActionSettlementError("settlement failed"),
)

expect(order).toEqual(["state-cleared", "dispose"])
expect(writeToFileTool["taskPartialStreamState"].size).toBe(0)
})

it("keeps the task active after an unrelated history resume failure", async () => {
const cleanupListener = vi.fn()
const task = {
Expand Down
39 changes: 36 additions & 3 deletions src/core/tools/BaseTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,9 +155,23 @@ export abstract class BaseTool<TName extends ToolName> {
throw new Error("Tool call is missing native arguments (nativeArgs).")
}
} catch (error) {
console.error(`Error parsing parameters:`, error)
const errorMessage = `Failed to parse ${this.name} parameters: ${error instanceof Error ? error.message : String(error)}`
await callbacks.handleError(`parsing ${this.name} args`, new Error(errorMessage))
const parseError = error instanceof Error ? error : new Error(String(error))
console.error(`Error parsing parameters:`, parseError)
// Final args could not be parsed (e.g. the model's tool call was truncated
// mid-JSON by the output token limit), so execute() will never run. If a
// streaming delta already opened a partial "tool" ask (partial: true),
// finalize it here or the webview spinner stays stuck indefinitely.
await task.finalizePartialToolAsk().catch((finalizeError) => {
console.error(`Error finalizing ${this.name} partial tool ask:`, finalizeError)
})
// execute() never runs on this path, so tools that keep per-task state
// outside execute() (streaming failure marks, abort listeners) get their
// one remaining teardown boundary here.
const reportedStreamingFailure = await this.onParameterParseFailure(task, callbacks, parseError)
if (!reportedStreamingFailure) {
const errorMessage = `Failed to parse ${this.name} parameters: ${parseError.message}`
await callbacks.handleError(`parsing ${this.name} args`, new Error(errorMessage))
}
// Note: handleError already emits a tool_result via formatResponse.toolError in the caller.
// Do NOT call pushToolResult here to avoid duplicate tool_result payloads.
return
Expand All @@ -166,4 +180,23 @@ export abstract class BaseTool<TName extends ToolName> {
// Execute with typed parameters
await this.execute(params, task, callbacks)
}

/**
* Teardown boundary for the native-argument parse-failure path in handle().
*
* When nativeArgs are missing or malformed, execute() never runs, so per-task
* state a tool registered outside execute() (streaming failure marks, abort
* listeners) is never torn down there. Streaming tools override this to tear
* that state down and, when a streaming delta already failed, to report the
* captured streaming error instead of the generic parse error.
*
* @param task - Task instance
* @param callbacks - Tool execution callbacks
* @param parseError - The native-argument parse error
* @returns true when the override already reported the failure to the user,
* so handle() suppresses the generic parse error
*/
protected async onParameterParseFailure(task: Task, callbacks: ToolCallbacks, parseError: Error): Promise<boolean> {
return false
}
}
Loading
Loading