@@ -68,7 +68,7 @@ import { maybeRemoveImageBlocks } from "../../api/transform/image-cleaning"
6868import { OutputTokenLimitError } from "../../api/providers/utils/output-token-limit-error"
6969
7070// shared
71- import { findLastIndex } from "../../shared/array"
71+ import { findLast , findLastIndex } from "../../shared/array"
7272import { combineApiRequests } from "../../shared/combineApiRequests"
7373import { combineCommandSequences } from "../../shared/combineCommandSequences"
7474import { t } from "../../i18n"
@@ -1678,7 +1678,19 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
16781678 }
16791679 }
16801680
1681- /** Persists Cline messages and updates task metadata in the history store. Returns false on failure. */
1681+ /**
1682+ * Persist the message array, then refresh the derived metadata / task-history entries.
1683+ *
1684+ * The returned boolean reflects the message write only: `saveTaskMessages` failure
1685+ * leaves the on-disk record stale, so callers gating UI updates on durable state must
1686+ * skip them. Metadata / task-history stage failures are logged and swallowed — the
1687+ * message array is already persisted, and the next save recomputes and re-emits the
1688+ * metadata.
1689+ *
1690+ * `merge` (default `true`) is passed through to `saveTaskMessages`: the in-memory
1691+ * snapshot is merged with the on-disk record. `overwriteClineMessages` passes `false`
1692+ * to replace the stored messages outright.
1693+ */
16821694 private async saveClineMessages ( merge = true ) : Promise < boolean > {
16831695 try {
16841696 await saveTaskMessages ( {
@@ -1687,7 +1699,12 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
16871699 globalStoragePath : this . globalStoragePath ,
16881700 merge,
16891701 } )
1702+ } catch ( error ) {
1703+ console . error ( "Failed to save Roo messages:" , error )
1704+ return false
1705+ }
16901706
1707+ try {
16911708 if ( this . _taskApiConfigName === undefined ) {
16921709 await this . taskApiConfigReady
16931710 }
@@ -1715,11 +1732,14 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
17151732 const provider = this . providerRef . deref ( )
17161733 const existingStatus = provider ?. taskHistoryStore . get ( this . taskId ) ?. status
17171734 await provider ?. updateTaskHistory ( existingStatus ? { ...historyItem , status : existingStatus } : historyItem )
1718- return true
17191735 } catch ( error ) {
1720- console . error ( "Failed to save Roo messages:" , error )
1721- return false
1736+ // The message array was persisted above; a metadata or task-history failure must
1737+ // not mask that write (see the method docs). The next saveClineMessages() call
1738+ // recomputes and re-emits the metadata update.
1739+ console . error ( "Failed to save task metadata:" , error )
17221740 }
1741+
1742+ return true
17231743 }
17241744
17251745 private findMessageByTimestamp ( ts : number ) : ClineMessage | undefined {
@@ -2712,6 +2732,56 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
27122732 return formatResponse . toolError ( formatResponse . missingToolParameterError ( paramName ) )
27132733 }
27142734
2735+ /**
2736+ * Finalize a partial "tool" ask message without blocking for user input.
2737+ * Call this in error paths where a partial tool message was opened during streaming
2738+ * but execution failed before the normal approval flow could close it, so the webview
2739+ * spinner does not get stuck in a loading state.
2740+ *
2741+ * The matching partial message may no longer be the final entry if another asynchronous
2742+ * message was inserted between the partial ask and the error handler, so search backward
2743+ * instead of relying on clineMessages.at(-1).
2744+ *
2745+ * Any in-progress `progressStatus` on the message is cleared as well: the ask is being
2746+ * finalized because it will NOT complete, so a stale "in progress" indicator would be
2747+ * misleading (the normal completion path overwrites it with the final status instead).
2748+ *
2749+ * `isAnswered` is stamped true because the ask is resolved by the system rather than
2750+ * by the user: ChatView only shows ask buttons for unanswered messages, so leaving it
2751+ * unset would keep Save/Reject armed for a write that already failed.
2752+ */
2753+ async finalizePartialToolAsk ( text ?: string ) : Promise < void > {
2754+ const partialToolAsk = findLast (
2755+ this . clineMessages ,
2756+ ( message ) =>
2757+ message . partial === true &&
2758+ message . type === "ask" &&
2759+ message . ask === "tool" &&
2760+ ( text === undefined || message . text === text ) ,
2761+ )
2762+
2763+ if ( ! partialToolAsk ) {
2764+ return
2765+ }
2766+
2767+ partialToolAsk . partial = false
2768+ partialToolAsk . progressStatus = undefined
2769+ partialToolAsk . isAnswered = true
2770+ const saved = await this . saveClineMessages ( )
2771+ if ( ! saved ) {
2772+ // The persistence write failed: the on-disk record still carries `partial: true`
2773+ // while the in-memory message is finalized. Skip the webview-only update so the
2774+ // two views do not diverge (a later state resync or restart reload would flip the
2775+ // spinner back on from the stale disk record). The next saveClineMessages() call
2776+ // re-persists the full message array and repairs the disk record.
2777+ console . error ( "[Task#finalizePartialToolAsk] saveClineMessages failed; skipping webview update" )
2778+ return
2779+ }
2780+ await this . updateClineMessage ( partialToolAsk ) . catch ( ( error ) => {
2781+ console . error ( "[Task#finalizePartialToolAsk] updateClineMessage failed:" , error )
2782+ } )
2783+ }
2784+
27152785 // Lifecycle
27162786 // Start / Resume / Abort / Dispose
27172787
0 commit comments