Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
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),
)

if (!partialToolAsk) {
return
}

partialToolAsk.partial = false
await this.saveClineMessages()
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)

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()

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(() => {})
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
Loading
Loading