Skip to content
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 PrivateClineProviderMethods = {
removeClineFromStack: (this: ClineProvider) => ReturnType<ClineProvider["removeClineFromStack"]>
Expand Down Expand Up @@ -202,6 +203,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
21 changes: 20 additions & 1 deletion src/core/assistant-message/presentAssistantMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -577,15 +577,34 @@ async function presentAssistantMessageBlock(cline: Task, policySnapshot: Request
// Best-effort only
}

// A streaming delta may have captured a fatal filesystem error for this task.
// handle() never runs on this path, and clearTaskState() (below) releases the
// state holding it, so the model must learn about it from this single result.
const retainedStreamError =
block.name === "write_to_file" ? writeToFileTool.getRetainedStreamError(cline) : undefined
const reportedMessage = retainedStreamError
? `${retainedStreamError.message}\n${errorMessage}`
: errorMessage

// Push tool_result directly without setting didAlreadyUseTool so streaming can
// continue gracefully.
cline.pushToolResultToUserContent({
type: "tool_result",
tool_use_id: sanitizeToolUseId(toolCallId),
content: formatResponse.toolError(errorMessage),
content: formatResponse.toolError(reportedMessage),
is_error: true,
})

// handle() is never reached on this path, so the parse-failure teardown it
// performs must run here instead: a streaming delta may already have opened a
// partial write_to_file ask and left per-task failure state behind.
if (block.name === "write_to_file") {
writeToFileTool.clearTaskState(cline)
await cline.finalizePartialToolAsk().catch((finalizeError) => {
console.error("Error finalizing write_to_file partial ask:", finalizeError)
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}

break
}
}
Expand Down
43 changes: 40 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)
Comment thread
easonLiangWorldedtech marked this conversation as resolved.
Resolved
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,27 @@ 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