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
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ describe("presentAssistantMessage - Custom Tool Recording", () => {
},
say: vi.fn().mockResolvedValue(undefined),
ask: vi.fn().mockResolvedValue({ response: "yesButtonClicked" }),
finalizePartialToolAsk: vi.fn().mockResolvedValue(undefined),
}

// Add pushToolResultToUserContent method after mockTask is created so it can reference mockTask
Expand Down
80 changes: 75 additions & 5 deletions src/core/task/Task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ import { maybeRemoveImageBlocks } from "../../api/transform/image-cleaning"
import { OutputTokenLimitError } from "../../api/providers/utils/output-token-limit-error"

// shared
import { findLastIndex } from "../../shared/array"
import { findLast, findLastIndex } from "../../shared/array"
import { combineApiRequests } from "../../shared/combineApiRequests"
import { combineCommandSequences } from "../../shared/combineCommandSequences"
import { t } from "../../i18n"
Expand Down Expand Up @@ -1678,7 +1678,19 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
}
}

/** Persists Cline messages and updates task metadata in the history store. Returns false on failure. */
/**
* Persist the message array, then refresh the derived metadata / task-history entries.
*
* The returned boolean reflects the message write only: `saveTaskMessages` failure
* leaves the on-disk record stale, so callers gating UI updates on durable state must
* skip them. Metadata / task-history stage failures are logged and swallowed — the
* message array is already persisted, and the next save recomputes and re-emits the
* metadata.
*
* `merge` (default `true`) is passed through to `saveTaskMessages`: the in-memory
* snapshot is merged with the on-disk record. `overwriteClineMessages` passes `false`
* to replace the stored messages outright.
*/
private async saveClineMessages(merge = true): Promise<boolean> {
try {
await saveTaskMessages({
Expand All @@ -1687,7 +1699,12 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
globalStoragePath: this.globalStoragePath,
merge,
})
} catch (error) {
console.error("Failed to save Roo messages:", error)
return false
}

try {
if (this._taskApiConfigName === undefined) {
await this.taskApiConfigReady
}
Expand Down Expand Up @@ -1715,11 +1732,14 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
const provider = this.providerRef.deref()
const existingStatus = provider?.taskHistoryStore.get(this.taskId)?.status
await provider?.updateTaskHistory(existingStatus ? { ...historyItem, status: existingStatus } : historyItem)
return true
} catch (error) {
console.error("Failed to save Roo messages:", error)
return false
// The message array was persisted above; a metadata or task-history failure must
// not mask that write (see the method docs). The next saveClineMessages() call
// recomputes and re-emits the metadata update.
console.error("Failed to save task metadata:", error)
}

return true
}

private findMessageByTimestamp(ts: number): ClineMessage | undefined {
Expand Down Expand Up @@ -2712,6 +2732,56 @@ 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).
*
* Any in-progress `progressStatus` on the message is cleared as well: the ask is being
* finalized because it will NOT complete, so a stale "in progress" indicator would be
* misleading (the normal completion path overwrites it with the final status instead).
*
* `isAnswered` is stamped true because the ask is resolved by the system rather than
* by the user: ChatView only shows ask buttons for unanswered messages, so leaving it
* unset would keep Save/Reject armed for a write that already failed.
*/
async finalizePartialToolAsk(text?: string): Promise<void> {
const partialToolAsk = findLast(
this.clineMessages,
(message) =>
message.partial === true &&
message.type === "ask" &&
message.ask === "tool" &&
(text === undefined || message.text === text),
)

if (!partialToolAsk) {
return
}

partialToolAsk.partial = false
partialToolAsk.progressStatus = undefined
partialToolAsk.isAnswered = true
const saved = await this.saveClineMessages()
if (!saved) {
// The persistence write failed: the on-disk record still carries `partial: true`
// while the in-memory message is finalized. Skip the webview-only update so the
// two views do not diverge (a later state resync or restart reload would flip the
// spinner back on from the stale disk record). The next saveClineMessages() call
// re-persists the full message array and repairs the disk record.
console.error("[Task#finalizePartialToolAsk] saveClineMessages failed; skipping webview update")
return
}
await this.updateClineMessage(partialToolAsk).catch((error) => {
console.error("[Task#finalizePartialToolAsk] updateClineMessage failed:", error)
})
}

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

Expand Down
Loading
Loading