Skip to content
Open
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
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