From 61124df61c952db36b9d43b3715cd3fc75268853 Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Sun, 23 Aug 2026 07:31:47 -0600 Subject: [PATCH 01/26] feat(transcript): implement dedicated transcript protocol for Zoo Code webview - Introduced new message types for cline messages in ExtensionMessage interface. - Added fields for task ID, cline messages, and snapshot management in ExtensionMessage. - Updated WebviewMessage to handle resync requests and sequence tracking. - Replaced unbounded full-transcript transport with a chunked snapshot protocol. - Ensured task focus synchronization and invalidation of old transcript generations. - Implemented strict validation for message sequences and snapshot integrity. - Added stress acceptance tests to validate performance under high message loads. --- ZOO_CODE_GRAY_SCREEN_FIX_README.md | 73 ++ apply_zoo_code_incremental_transcript_fix.py | 937 +++++++++++++++++++ packages/types/src/vscode-extension-host.ts | 16 +- 3 files changed, 1025 insertions(+), 1 deletion(-) create mode 100644 ZOO_CODE_GRAY_SCREEN_FIX_README.md create mode 100644 apply_zoo_code_incremental_transcript_fix.py diff --git a/ZOO_CODE_GRAY_SCREEN_FIX_README.md b/ZOO_CODE_GRAY_SCREEN_FIX_README.md new file mode 100644 index 0000000000..5a6f3987bf --- /dev/null +++ b/ZOO_CODE_GRAY_SCREEN_FIX_README.md @@ -0,0 +1,73 @@ +# Zoo Code permanent gray-screen fix + +This source patch replaces the unbounded full-transcript webview transport with a dedicated transcript protocol: + +- Generic `state` messages are forcibly stripped of `clineMessages` and `clineMessagesSeq` at the provider boundary. +- Appends and edits are sent as task-scoped, monotonically sequenced deltas. +- Initial load, task switches, checkpoint rewinds, edits, deletes, and recovery use a serialized chunked snapshot. +- The webview validates task ID, sequence continuity, snapshot identity, chunk offsets, and final message count. +- A sequence gap or legacy unsequenced update requests an automatic full resynchronization. +- Focus changes invalidate the old transcript transport generation, preventing a background task from updating the foreground transcript. +- A reload no longer requires deserializing the entire transcript as one generic extension-state object. + +## Apply + +From a clean Zoo Code source checkout: + +```powershell +python C:\path\to\apply_zoo_code_incremental_transcript_fix.py . +``` + +The patcher is deliberately strict. It stops without partially continuing when an expected source block differs from the source lineage it targets. Review the resulting diff: + +```powershell +git diff --check +git diff --stat +git diff +``` + +## Validate + +The repository declares Node `22.23.1` and pnpm `10.8.1`. + +```powershell +corepack enable +corepack prepare pnpm@10.8.1 --activate +pnpm install --frozen-lockfile +pnpm format +pnpm check-types +pnpm lint +pnpm test +pnpm vsix +``` + +Install the generated VSIX: + +```powershell +$Vsix = Get-ChildItem .\bin\*.vsix | Sort-Object LastWriteTime -Descending | Select-Object -First 1 +code --install-extension $Vsix.FullName --force +``` + +Then fully exit all VS Code processes once and reopen VS Code. + +## Required stress acceptance test + +Use a copy of a large project and run a task that produces at least 10,000 Zoo transcript messages or tool-status updates. + +Pass conditions: + +1. The Zoo Code webview remains rendered and interactive throughout the run. +2. Renderer memory does not grow in proportion to `message-count × total-transcript-size`. +3. Normal appends transfer one `ClineMessage`; normal edits transfer one `ClineMessage`. +4. No generic `state` message contains `clineMessages` in Webview Developer Tools. +5. `Developer: Reload Webviews` reconstructs the active transcript through snapshot chunks without stopping the extension-host task. +6. Switching rapidly between parent and delegated child tasks never displays messages from the wrong task. +7. Deliberately dropping one delta causes `requestClineMessagesResync`, followed by a correct chunked snapshot. + +## Files changed by the patcher + +- `packages/types/src/vscode-extension-host.ts` +- `src/core/webview/ClineProvider.ts` +- `src/core/task/Task.ts` +- `src/core/webview/webviewMessageHandler.ts` +- `webview-ui/src/context/ExtensionStateContext.tsx` diff --git a/apply_zoo_code_incremental_transcript_fix.py b/apply_zoo_code_incremental_transcript_fix.py new file mode 100644 index 0000000000..71aef227d7 --- /dev/null +++ b/apply_zoo_code_incremental_transcript_fix.py @@ -0,0 +1,937 @@ +#!/usr/bin/env python3 +"""Apply a permanent Zoo Code webview transcript transport fix. + +Target: Zoo-Code-Org/Zoo-Code current main lineage (including 3.81-era builds). +Run from the repository root, then inspect `git diff` and build a VSIX. + +The patch removes clineMessages from generic state broadcasts, sends focused-task +message changes as sequenced deltas, and restores/reloads transcripts through a +serialized chunked snapshot protocol with automatic sequence-gap resync. +""" + +from __future__ import annotations + +import argparse +import re +import subprocess +import sys +from pathlib import Path + +MARKER = "clineMessagesSnapshotStart" + + +def die(message: str) -> "NoReturn": + raise SystemExit(f"ERROR: {message}") + + +def read(path: Path) -> str: + if not path.is_file(): + die(f"missing expected source file: {path}") + return path.read_text(encoding="utf-8") + + +def write(path: Path, text: str) -> None: + path.write_text(text, encoding="utf-8", newline="\n") + + +def replace_once(text: str, old: str, new: str, label: str) -> str: + count = text.count(old) + if count != 1: + die(f"{label}: expected exactly one match, found {count}") + return text.replace(old, new, 1) + + +def sub_once(text: str, pattern: str, replacement: str, label: str, flags: int = 0) -> str: + result, count = re.subn(pattern, replacement, text, count=1, flags=flags) + if count != 1: + die(f"{label}: expected exactly one regex match, found {count}") + return result + + +def patch_types(root: Path) -> None: + path = root / "packages/types/src/vscode-extension-host.ts" + text = read(path) + + text = replace_once( + text, + '\t\t| "invoke"\n\t\t| "messageUpdated"\n\t\t| "mcpServers"', + '\t\t| "invoke"\n' + '\t\t| "clineMessageAppended"\n' + '\t\t| "clineMessageUpdated"\n' + '\t\t| "clineMessagesSnapshotStart"\n' + '\t\t| "clineMessagesSnapshotChunk"\n' + '\t\t| "clineMessagesSnapshotEnd"\n' + '\t\t| "messageUpdated" // Legacy: a patched webview requests a full resync instead of applying this.\n' + '\t\t| "mcpServers"', + "ExtensionMessage transcript message types", + ) + + text = replace_once( + text, + '\tclineMessage?: ClineMessage\n\trouterModels?: RouterModels', + '\ttaskId?: string\n' + '\tclineMessage?: ClineMessage\n' + '\tclineMessages?: ClineMessage[]\n' + '\tclineMessagesSeq?: number\n' + '\tsnapshotId?: string\n' + '\tsnapshotStartIndex?: number\n' + '\tsnapshotTotal?: number\n' + '\trouterModels?: RouterModels', + "ExtensionMessage transcript fields", + ) + + text = replace_once( + text, + '\t\t| "openRulesDirectory"\n\t\t| "themeFixtureProbeResponse"\n\ttext?: string\n\ttaskId?: string', + '\t\t| "openRulesDirectory"\n' + '\t\t| "themeFixtureProbeResponse"\n' + '\t\t| "requestClineMessagesResync"\n' + '\ttext?: string\n' + '\ttaskId?: string\n' + '\texpectedSeq?: number\n' + '\treceivedSeq?: number', + "WebviewMessage resync request", + ) + + write(path, text) + + +def patch_provider(root: Path) -> None: + path = root / "src/core/webview/ClineProvider.ts" + text = read(path) + + text = replace_once( + text, + "\tprivate _disposed = false\n\tprivate readonly _postStateToWebviewThrottled = debounce(", + "\tprivate _disposed = false\n" + "\tprivate static readonly CLINE_MESSAGES_SNAPSHOT_CHUNK_SIZE = 200\n" + "\tprivate readonly clineMessagesSeqByTaskId = new Map()\n" + "\tprivate clineMessagesPostQueue: Promise = Promise.resolve()\n" + "\tprivate clineMessagesTransportGeneration = 0\n" + "\tprivate nextClineMessagesSnapshotId = 0\n" + "\tprivate suppressClineMessagesDeltas = false\n" + "\tprivate readonly _postStateToWebviewThrottled = debounce(", + "provider transport fields", + ) + + text = replace_once( + text, + "\t\t\t\tawait this.postStateToWebviewWithoutTaskHistory()", + "\t\t\t\tawait this.postStateToWebviewWithoutClineMessages()", + "debounced state must omit transcript", + ) + + text = sub_once( + text, + r"\n\t/\*\*\n\t \* Monotonically increasing sequence number for clineMessages state pushes\.\n" + r"\t \* Used by the frontend to reject stale state that arrives out-of-order\.\n\t \*/\n" + r"\tprivate clineMessagesSeq = 0\n", + "\n", + "remove global clineMessages sequence", + ) + + text = replace_once( + text, + "\t\tif (!state || typeof state.mode !== \"string\") {\n" + "\t\t\tthrow new Error(t(\"common:errors.retrieve_current_mode\"))\n" + "\t\t}\n" + "\t}", + "\t\tif (!state || typeof state.mode !== \"string\") {\n" + "\t\t\tthrow new Error(t(\"common:errors.retrieve_current_mode\"))\n" + "\t\t}\n\n" + "\t\tawait this.syncFocusedTaskToWebview()\n" + "\t}", + "focus sync after stack push", + ) + + text = replace_once( + text, + "\t\t\ttask = undefined\n\t\t}\n\t}\n\t/**\n\t * Evicts the current task", + "\t\t\ttask = undefined\n\t\t}\n\n" + "\t\tawait this.syncFocusedTaskToWebview()\n" + "\t}\n\t/**\n\t * Evicts the current task", + "focus sync after stack pop", + ) + + text = replace_once( + text, + "\t\t\t// Perform preparation tasks and set up event listeners\n" + "\t\t\tawait this.performPreparationTasks(task)\n\n" + "\t\t\tthis.log(", + "\t\t\t// Perform preparation tasks and set up event listeners\n" + "\t\t\tawait this.performPreparationTasks(task)\n" + "\t\t\tawait this.syncFocusedTaskToWebview()\n\n" + "\t\t\tthis.log(", + "rehydrated task focus sync", + ) + + old_post = '''\tpublic async postMessageToWebview(message: ExtensionMessage) { +\t\tif (this._disposed) { +\t\t\treturn +\t\t} +\t\ttry { +\t\t\tawait this.view?.webview.postMessage(message) +\t\t} catch { +\t\t\t// View disposed, drop message silently +\t\t} +\t} +''' + + new_post = '''\tpublic async postMessageToWebview(message: ExtensionMessage) { +\t\tif (this._disposed) { +\t\t\treturn +\t\t} + +\t\t// Hard transport boundary: generic state broadcasts must never carry the +\t\t// unbounded chat transcript. This also protects direct callers that build +\t\t// and post state without going through postStateToWebview(). +\t\tif (message.type === "state" && message.state) { +\t\t\tconst { +\t\t\t\tclineMessages: _omitMessages, +\t\t\t\tclineMessagesSeq: _omitMessagesSeq, +\t\t\t\t...metadataState +\t\t\t} = message.state +\t\t\tmessage = { ...message, state: metadataState } +\t\t} + +\t\ttry { +\t\t\tawait this.view?.webview.postMessage(message) +\t\t} catch { +\t\t\t// View disposed, drop message silently +\t\t} +\t} + +\tprivate getClineMessagesSeq(taskId: string): number { +\t\treturn this.clineMessagesSeqByTaskId.get(taskId) ?? 0 +\t} + +\tprivate bumpClineMessagesSeq(taskId: string): number { +\t\tconst next = this.getClineMessagesSeq(taskId) + 1 +\t\tthis.clineMessagesSeqByTaskId.set(taskId, next) +\t\treturn next +\t} + +\tprivate enqueueClineMessagesPost(operation: () => Promise): Promise { +\t\tconst run = this.clineMessagesPostQueue.then(operation, operation) +\t\tthis.clineMessagesPostQueue = run.catch((error) => { +\t\t\tthis.log( +\t\t\t\t`[clineMessages] transport failure: ${error instanceof Error ? error.message : String(error)}`, +\t\t\t) +\t\t}) +\t\treturn run +\t} + +\tpublic resetClineMessagesTransport(): number { +\t\tthis.clineMessagesTransportGeneration++ +\t\tthis.clineMessagesPostQueue = Promise.resolve() +\t\treturn this.clineMessagesTransportGeneration +\t} + +\tpublic postClineMessageAppended(taskId: string, message: ClineMessage): Promise { +\t\tconst seq = this.bumpClineMessagesSeq(taskId) +\t\tif (this.suppressClineMessagesDeltas || this.getCurrentTask()?.taskId !== taskId) { +\t\t\treturn Promise.resolve() +\t\t} + +\t\tconst generation = this.clineMessagesTransportGeneration +\t\tconst clonedMessage = structuredClone(message) +\t\treturn this.enqueueClineMessagesPost(async () => { +\t\t\tif ( +\t\t\t\tgeneration !== this.clineMessagesTransportGeneration || +\t\t\t\tthis.getCurrentTask()?.taskId !== taskId +\t\t\t) { +\t\t\t\treturn +\t\t\t} +\t\t\tawait this.postMessageToWebview({ +\t\t\t\ttype: "clineMessageAppended", +\t\t\t\ttaskId, +\t\t\t\tclineMessage: clonedMessage, +\t\t\t\tclineMessagesSeq: seq, +\t\t\t}) +\t\t}) +\t} + +\tpublic postClineMessageUpdated(taskId: string, message: ClineMessage): Promise { +\t\tconst seq = this.bumpClineMessagesSeq(taskId) +\t\tif (this.suppressClineMessagesDeltas || this.getCurrentTask()?.taskId !== taskId) { +\t\t\treturn Promise.resolve() +\t\t} + +\t\tconst generation = this.clineMessagesTransportGeneration +\t\tconst clonedMessage = structuredClone(message) +\t\treturn this.enqueueClineMessagesPost(async () => { +\t\t\tif ( +\t\t\t\tgeneration !== this.clineMessagesTransportGeneration || +\t\t\t\tthis.getCurrentTask()?.taskId !== taskId +\t\t\t) { +\t\t\t\treturn +\t\t\t} +\t\t\tawait this.postMessageToWebview({ +\t\t\t\ttype: "clineMessageUpdated", +\t\t\t\ttaskId, +\t\t\t\tclineMessage: clonedMessage, +\t\t\t\tclineMessagesSeq: seq, +\t\t\t}) +\t\t}) +\t} + +\tpublic postClineMessagesSnapshot( +\t\ttaskId: string | undefined = this.getCurrentTask()?.taskId, +\t\toptions: { bumpSeq?: boolean } = {}, +\t): Promise { +\t\tconst currentTask = this.getCurrentTask() +\t\tif ((currentTask?.taskId ?? undefined) !== taskId) { +\t\t\treturn Promise.resolve() +\t\t} + +\t\tconst seq = taskId +\t\t\t? options.bumpSeq +\t\t\t\t? this.bumpClineMessagesSeq(taskId) +\t\t\t\t: this.getClineMessagesSeq(taskId) +\t\t\t: 0 +\t\tconst messages = structuredClone(currentTask?.clineMessages ?? []) +\t\tconst snapshotId = `${taskId ?? "none"}:${++this.nextClineMessagesSnapshotId}` +\t\tconst generation = this.clineMessagesTransportGeneration + +\t\treturn this.enqueueClineMessagesPost(async () => { +\t\t\tif ( +\t\t\t\tgeneration !== this.clineMessagesTransportGeneration || +\t\t\t\t(this.getCurrentTask()?.taskId ?? undefined) !== taskId +\t\t\t) { +\t\t\t\treturn +\t\t\t} + +\t\t\tawait this.postMessageToWebview({ +\t\t\t\ttype: "clineMessagesSnapshotStart", +\t\t\t\ttaskId, +\t\t\t\tclineMessagesSeq: seq, +\t\t\t\tsnapshotId, +\t\t\t\tsnapshotTotal: messages.length, +\t\t\t}) + +\t\t\tfor ( +\t\t\t\tlet start = 0; +\t\t\t\tstart < messages.length; +\t\t\t\tstart += ClineProvider.CLINE_MESSAGES_SNAPSHOT_CHUNK_SIZE +\t\t\t) { +\t\t\t\tif ( +\t\t\t\t\tgeneration !== this.clineMessagesTransportGeneration || +\t\t\t\t\t(this.getCurrentTask()?.taskId ?? undefined) !== taskId +\t\t\t\t) { +\t\t\t\t\treturn +\t\t\t\t} +\t\t\t\tawait this.postMessageToWebview({ +\t\t\t\t\ttype: "clineMessagesSnapshotChunk", +\t\t\t\t\ttaskId, +\t\t\t\t\tclineMessagesSeq: seq, +\t\t\t\t\tsnapshotId, +\t\t\t\t\tsnapshotStartIndex: start, +\t\t\t\t\tclineMessages: messages.slice( +\t\t\t\t\t\tstart, +\t\t\t\t\t\tstart + ClineProvider.CLINE_MESSAGES_SNAPSHOT_CHUNK_SIZE, +\t\t\t\t\t), +\t\t\t\t}) +\t\t\t} + +\t\t\tawait this.postMessageToWebview({ +\t\t\t\ttype: "clineMessagesSnapshotEnd", +\t\t\t\ttaskId, +\t\t\t\tclineMessagesSeq: seq, +\t\t\t\tsnapshotId, +\t\t\t\tsnapshotTotal: messages.length, +\t\t\t}) +\t\t}) +\t} + +\tpublic async resyncClineMessagesToWebview(taskId?: string): Promise { +\t\tif ((this.getCurrentTask()?.taskId ?? undefined) !== taskId) { +\t\t\treturn +\t\t} +\t\tthis.resetClineMessagesTransport() +\t\tthis.suppressClineMessagesDeltas = true +\t\ttry { +\t\t\tconst snapshot = this.postClineMessagesSnapshot(taskId) +\t\t\tthis.suppressClineMessagesDeltas = false +\t\t\tawait snapshot +\t\t} finally { +\t\t\tthis.suppressClineMessagesDeltas = false +\t\t} +\t} + +\tpublic async syncFocusedTaskToWebview( +\t\toptions: { includeTaskHistory?: boolean } = {}, +\t): Promise { +\t\tconst generation = this.resetClineMessagesTransport() +\t\tthis.suppressClineMessagesDeltas = true +\t\ttry { +\t\t\tif (options.includeTaskHistory) { +\t\t\t\tawait this.postStateToWebview() +\t\t\t} else { +\t\t\t\tawait this.postStateToWebviewWithoutTaskHistory() +\t\t\t} +\t\t\tif (generation !== this.clineMessagesTransportGeneration) { +\t\t\t\treturn +\t\t\t} +\t\t\tconst snapshot = this.postClineMessagesSnapshot() +\t\t\tthis.suppressClineMessagesDeltas = false +\t\t\tawait snapshot +\t\t} finally { +\t\t\tthis.suppressClineMessagesDeltas = false +\t\t} +\t} +''' + text = replace_once(text, old_post, new_post, "provider transcript transport methods") + + old_state = '''\tasync postStateToWebview() { +\t\tconst state = await this.getStateToPostToWebview() +\t\tthis.clineMessagesSeq++ +\t\tstate.clineMessagesSeq = this.clineMessagesSeq +\t\tawait this.postMessageToWebview({ type: "state", state }) +\t} +''' + new_state = '''\tasync postStateToWebview() { +\t\tconst state = await this.getStateToPostToWebview() +\t\tconst { clineMessages: _omitMessages, clineMessagesSeq: _omitMessagesSeq, ...metadataState } = state +\t\tawait this.postMessageToWebview({ type: "state", state: metadataState }) +\t} +''' + text = replace_once(text, old_state, new_state, "postState transcript omission") + + old_no_history = '''\tasync postStateToWebviewWithoutTaskHistory(): Promise { +\t\tconst state = await this.getStateToPostToWebview({ includeTaskHistory: false }) +\t\tthis.clineMessagesSeq++ +\t\tstate.clineMessagesSeq = this.clineMessagesSeq +\t\tconst { taskHistory: _omit, ...rest } = state +\t\tawait this.postMessageToWebview({ type: "state", state: rest }) +\t} +''' + new_no_history = '''\tasync postStateToWebviewWithoutTaskHistory(): Promise { +\t\tconst state = await this.getStateToPostToWebview({ includeTaskHistory: false }) +\t\tconst { +\t\t\tclineMessages: _omitMessages, +\t\t\tclineMessagesSeq: _omitMessagesSeq, +\t\t\ttaskHistory: _omitHistory, +\t\t\t...metadataState +\t\t} = state +\t\tawait this.postMessageToWebview({ type: "state", state: metadataState }) +\t} +''' + text = replace_once(text, old_no_history, new_no_history, "postStateWithoutTaskHistory transcript omission") + + text = replace_once( + text, + "\t\tconst { clineMessages: _omitMessages, taskHistory: _omitHistory, ...rest } = state", + "\t\tconst {\n" + "\t\t\tclineMessages: _omitMessages,\n" + "\t\t\tclineMessagesSeq: _omitMessagesSeq,\n" + "\t\t\ttaskHistory: _omitHistory,\n" + "\t\t\t...rest\n" + "\t\t} = state", + "postStateWithoutClineMessages sequence omission", + ) + + write(path, text) + + +def patch_task(root: Path) -> None: + path = root / "src/core/task/Task.ts" + text = read(path) + + text = sub_once( + text, + r'''\tprivate async addToClineMessages\(message: ClineMessage\) \{\n''' + r'''\t\tthis\.clineMessages\.push\(message\)\n''' + r'''\t\tconst provider = this\.providerRef\.deref\(\)\n''' + r'''\t\t// Unanswered asks must reach the webview before Message listeners can respond against its state\.\n''' + r'''\t\tconst requiresImmediateState =\n''' + r'''\t\t\tmessage\.partial === true \|\| \(message\.type === "ask" && message\.isAnswered !== true\)\n''' + r'''\t\ttry \{\n''' + r'''\t\t\tawait provider\?\.postStateToWebviewThrottled\(\)\n''' + r'''\t\t\} catch \(error\) \{\n''' + r'''\t\t\tconsole\.error\("\[Task#addToClineMessages\] postStateToWebviewThrottled failed:", error\)\n''' + r'''\t\t\}\n''' + r'''\t\tif \(requiresImmediateState\) \{\n''' + r'''\t\t\ttry \{\n''' + r'''\t\t\t\tawait provider\?\.flushPostStateToWebviewThrottled\(\)\n''' + r'''\t\t\t\} catch \(error\) \{\n''' + r'''\t\t\t\tconsole\.error\("\[Task#addToClineMessages\] flushPostStateToWebviewThrottled failed:", error\)\n''' + r'''\t\t\t\}\n''' + r'''\t\t\}\n''', + '''\tprivate async addToClineMessages(message: ClineMessage) { +\t\tthis.clineMessages.push(message) +\t\tconst provider = this.providerRef.deref() +\t\ttry { +\t\t\tawait provider?.postClineMessageAppended(this.taskId, message) +\t\t} catch (error) { +\t\t\tconsole.error("[Task#addToClineMessages] incremental post failed:", error) +\t\t} +''', + "Task append delta", + ) + + text = replace_once( + text, + "\t\tfor (const msg of newMessages) {\n" + "\t\t\tif (msg.partial !== true) {\n" + "\t\t\t\tthis.cloudSyncedMessageTimestamps.add(msg.ts)\n" + "\t\t\t}\n" + "\t\t}\n" + "\t}\n" + "\tprivate async updateClineMessage(message: ClineMessage) {\n" + "\t\tconst provider = this.providerRef.deref()\n" + "\t\tawait provider?.postMessageToWebview({ type: \"messageUpdated\", clineMessage: message })", + "\t\tfor (const msg of newMessages) {\n" + "\t\t\tif (msg.partial !== true) {\n" + "\t\t\t\tthis.cloudSyncedMessageTimestamps.add(msg.ts)\n" + "\t\t\t}\n" + "\t\t}\n" + "\t\tawait this.providerRef.deref()?.postClineMessagesSnapshot(this.taskId, { bumpSeq: true })\n" + "\t}\n" + "\tprivate async updateClineMessage(message: ClineMessage) {\n" + "\t\tconst provider = this.providerRef.deref()\n" + "\t\tawait provider?.postClineMessageUpdated(this.taskId, message)", + "Task overwrite/update transport", + ) + + text = replace_once( + text, + "\t\t\t\tthis.clineMessages[lastFollowUpIndex].isAnswered = true\n\t\t\t\t// Save the updated messages", + "\t\t\t\tthis.clineMessages[lastFollowUpIndex].isAnswered = true\n" + "\t\t\t\tvoid this.updateClineMessage(this.clineMessages[lastFollowUpIndex]).catch((error) => {\n" + "\t\t\t\t\tconsole.error(\"[Task#handleWebviewAskResponse] follow-up delta failed:\", error)\n" + "\t\t\t\t})\n" + "\t\t\t\t// Save the updated messages", + "follow-up answer update delta", + ) + + text = replace_once( + text, + "\t\t\tawait this.providerRef.deref()?.postStateToWebviewWithoutTaskHistory()\n\n\t\t\tawait this.say(\"text\", task, images)", + "\t\t\tawait this.providerRef.deref()?.postClineMessagesSnapshot(this.taskId, { bumpSeq: true })\n\n" + "\t\t\tawait this.say(\"text\", task, images)", + "new task empty snapshot", + ) + + text = replace_once( + text, + "\t\t\tawait this.saveClineMessages()\n\t\t\tawait this.providerRef.deref()?.postStateToWebviewWithoutTaskHistory()\n\n\t\t\ttry {", + "\t\t\tawait this.saveClineMessages()\n" + "\t\t\tawait this.updateClineMessage(this.clineMessages[lastApiReqIndex])\n\n" + "\t\t\ttry {", + "api request placeholder update delta", + ) + + text = replace_once( + text, + "\t\t\t\t\tif (lastMessage && lastMessage.partial) {\n" + "\t\t\t\t\t\t// lastMessage.ts = Date.now() DO NOT update ts since it is used as a key for virtuoso list\n" + "\t\t\t\t\t\tlastMessage.partial = false\n" + "\t\t\t\t\t\t// instead of streaming partialMessage events, we do a save and post like normal to persist to disk\n" + "\t\t\t\t\t}\n" + "\t\t\t\t\t// Update `api_req_started` to have cancelled and cost, so that\n" + "\t\t\t\t\t// we can display the cost of the partial stream and the cancellation reason\n" + "\t\t\t\t\tupdateApiReqMsg(cancelReason, streamingFailedMessage)\n" + "\t\t\t\t\tawait this.saveClineMessages()", + "\t\t\t\t\tif (lastMessage && lastMessage.partial) {\n" + "\t\t\t\t\t\t// lastMessage.ts = Date.now() DO NOT update ts since it is used as a key for virtuoso list\n" + "\t\t\t\t\t\tlastMessage.partial = false\n" + "\t\t\t\t\t\tawait this.updateClineMessage(lastMessage)\n" + "\t\t\t\t\t}\n" + "\t\t\t\t\t// Update `api_req_started` to have cancelled and cost, so that\n" + "\t\t\t\t\t// we can display the cost of the partial stream and the cancellation reason\n" + "\t\t\t\t\tupdateApiReqMsg(cancelReason, streamingFailedMessage)\n" + "\t\t\t\t\tconst apiRequestMessage = this.clineMessages[lastApiReqIndex]\n" + "\t\t\t\t\tif (apiRequestMessage) {\n" + "\t\t\t\t\t\tawait this.updateClineMessage(apiRequestMessage)\n" + "\t\t\t\t\t}\n" + "\t\t\t\t\tawait this.saveClineMessages()", + "abort stream final deltas", + ) + + text = replace_once( + text, + "\t\t\t\tawait this.saveClineMessages()\n\t\t\t\tawait this.providerRef.deref()?.postStateToWebviewWithoutTaskHistory()\n\n" + "\t\t\t\t// No legacy text-stream tool parser state to reset.", + "\t\t\t\tawait this.saveClineMessages()\n\n" + "\t\t\t\t// No legacy text-stream tool parser state to reset.", + "remove response-end full transcript broadcast", + ) + + write(path, text) + + +def patch_handler(root: Path) -> None: + path = root / "src/core/webview/webviewMessageHandler.ts" + text = read(path) + + text = replace_once( + text, + "\t\tcase \"webviewDidLaunch\":\n\t\t\t// Load custom modes first", + "\t\tcase \"requestClineMessagesResync\":\n" + "\t\t\tawait provider.resyncClineMessagesToWebview(message.taskId)\n" + "\t\t\tbreak\n" + "\t\tcase \"webviewDidLaunch\":\n" + "\t\t\t// Load custom modes first", + "handler resync case", + ) + + text = replace_once( + text, + "\t\t\tawait updateGlobalState(\"customModes\", customModes)\n\t\t\tawait provider.postStateToWebview()", + "\t\t\tawait updateGlobalState(\"customModes\", customModes)\n" + "\t\t\tawait provider.syncFocusedTaskToWebview({ includeTaskHistory: true })", + "launch state plus chunked snapshot", + ) + + text = replace_once( + text, + "\t\t\tawait provider.clearTask()\n\t\t\tawait provider.postStateToWebview()", + "\t\t\tawait provider.clearTask()\n" + "\t\t\tawait provider.syncFocusedTaskToWebview({ includeTaskHistory: true })", + "clear task sync", + ) + + text = replace_once( + text, + "\t\t\t\t// Update the UI to reflect the deletion\n\t\t\t\tawait provider.postStateToWebview()", + "\t\t\t\t// Update the UI to reflect the deletion\n" + "\t\t\t\tawait provider.postClineMessagesSnapshot(currentCline.taskId, { bumpSeq: true })", + "delete operation snapshot", + ) + + text = replace_once( + text, + "\t\t\t// Update the UI to reflect the deletion\n\t\t\tawait provider.postStateToWebview()\n\t\t\tawait currentCline.submitUserMessage", + "\t\t\t// Update the UI to reflect the edit\n" + "\t\t\tawait provider.postClineMessagesSnapshot(currentCline.taskId, { bumpSeq: true })\n" + "\t\t\tawait currentCline.submitUserMessage", + "edit operation snapshot", + ) + + # The updatePrompt handler posts a hand-built state directly. The provider now + # strips transcripts centrally, but use the explicit metadata-safe path too. + text = replace_once( + text, + "\t\t\t\tconst currentState = await provider.getStateToPostToWebview()\n" + "\t\t\t\tconst stateWithPrompts = {\n" + "\t\t\t\t\t...currentState,\n" + "\t\t\t\t\tcustomModePrompts: updatedPrompts,\n" + "\t\t\t\t\thasOpenedModeSelector: currentState.hasOpenedModeSelector ?? false,\n" + "\t\t\t\t}\n" + "\t\t\t\tawait provider.postMessageToWebview({ type: \"state\", state: stateWithPrompts })", + "\t\t\t\tawait provider.postStateToWebviewWithoutClineMessages()", + "updatePrompt metadata-only state", + ) + + write(path, text) + + +def patch_webview(root: Path) -> None: + path = root / "webview-ui/src/context/ExtensionStateContext.tsx" + text = read(path) + + text = replace_once( + text, + 'import React, { createContext, useCallback, useEffect, useState } from "react"', + 'import React, { createContext, useCallback, useEffect, useRef, useState } from "react"', + "webview useRef import", + ) + text = replace_once( + text, + "\ttype ExtensionState,\n\ttype MarketplaceInstalledMetadata,", + "\ttype ExtensionState,\n\ttype ClineMessage,\n\ttype MarketplaceInstalledMetadata,", + "webview ClineMessage import", + ) + + text = sub_once( + text, + r'''\t// Protect clineMessages from stale state pushes using sequence numbering\.\n''' + r'''(?:\t//.*\n){4}''' + r'''\tif \(\n''' + r'''\t\tnewState\.clineMessagesSeq !== undefined &&\n''' + r'''\t\tprevState\.clineMessagesSeq !== undefined &&\n''' + r'''\t\tnewState\.clineMessagesSeq <= prevState\.clineMessagesSeq &&\n''' + r'''\t\tnewState\.clineMessages !== undefined\n''' + r'''\t\) \{\n''' + r'''\t\trest\.clineMessages = prevState\.clineMessages\n''' + r'''\t\trest\.clineMessagesSeq = prevState\.clineMessagesSeq\n''' + r'''\t\}\n''', + "", + "remove old full-state sequence guard", + ) + + text = replace_once( + text, + "export const ExtensionStateContext = createContext(undefined)\n\n", + "export const ExtensionStateContext = createContext(undefined)\n\n" + "type ClineMessagesSnapshotBuffer = {\n" + "\tsnapshotId: string\n" + "\ttaskId?: string\n" + "\tseq: number\n" + "\ttotal: number\n" + "\tmessages: ClineMessage[]\n" + "}\n\n", + "snapshot buffer type", + ) + + text = replace_once( + text, + "\tconst [state, setState] = useState(() =>\n" + "\t\tmergeExtensionState(createInitialExtensionState(), initialState ?? {}),\n" + "\t)\n" + "\tconst [didHydrateState, setDidHydrateState] = useState(false)", + "\tconst [state, setState] = useState(() =>\n" + "\t\tmergeExtensionState(createInitialExtensionState(), initialState ?? {}),\n" + "\t)\n" + "\tconst activeTaskIdRef = useRef(state.currentTaskId)\n" + "\tconst clineMessagesSeqRef = useRef(state.clineMessagesSeq ?? 0)\n" + "\tconst clineMessagesRef = useRef(state.clineMessages)\n" + "\tconst activeSnapshotRef = useRef(null)\n" + "\tconst resyncPendingRef = useRef(false)\n" + "\tconst [didHydrateState, setDidHydrateState] = useState(false)", + "webview transcript refs", + ) + + callback_anchor = '''\tconst setApiConfiguration = useCallback((value: ProviderSettings) => { +\t\tsetState((prevState) => ({ +\t\t\t...prevState, +\t\t\tapiConfiguration: { +\t\t\t\t...prevState.apiConfiguration, +\t\t\t\t...value, +\t\t\t}, +\t\t})) +\t}, []) +''' + callback_add = callback_anchor + ''' +\tconst requestClineMessagesResync = useCallback((receivedSeq?: number) => { +\t\tif (resyncPendingRef.current) { +\t\t\treturn +\t\t} +\t\tresyncPendingRef.current = true +\t\tvscode.postMessage({ +\t\t\ttype: "requestClineMessagesResync", +\t\t\ttaskId: activeTaskIdRef.current, +\t\t\texpectedSeq: clineMessagesSeqRef.current + 1, +\t\t\treceivedSeq, +\t\t}) +\t}, []) + +\tconst applyClineMessagesDelta = useCallback( +\t\t(message: ExtensionMessage, operation: "append" | "update") => { +\t\t\tconst seq = message.clineMessagesSeq +\t\t\tconst clineMessage = message.clineMessage +\t\t\tif ( +\t\t\t\ttypeof seq !== "number" || +\t\t\t\t!clineMessage || +\t\t\t\tmessage.taskId !== activeTaskIdRef.current +\t\t\t) { +\t\t\t\treturn +\t\t\t} +\t\t\tif (activeSnapshotRef.current) { +\t\t\t\trequestClineMessagesResync(seq) +\t\t\t\treturn +\t\t\t} +\t\t\tif (seq <= clineMessagesSeqRef.current) { +\t\t\t\treturn +\t\t\t} +\t\t\tif (seq !== clineMessagesSeqRef.current + 1) { +\t\t\t\trequestClineMessagesResync(seq) +\t\t\t\treturn +\t\t\t} + +\t\t\tlet nextMessages: ClineMessage[] +\t\t\tif (operation === "append") { +\t\t\t\tnextMessages = [...clineMessagesRef.current, clineMessage] +\t\t\t} else { +\t\t\t\tconst index = findLastIndex(clineMessagesRef.current, (item) => item.ts === clineMessage.ts) +\t\t\t\tif (index === -1) { +\t\t\t\t\trequestClineMessagesResync(seq) +\t\t\t\t\treturn +\t\t\t\t} +\t\t\t\tnextMessages = [...clineMessagesRef.current] +\t\t\t\tnextMessages[index] = clineMessage +\t\t\t} + +\t\t\tclineMessagesRef.current = nextMessages +\t\t\tclineMessagesSeqRef.current = seq +\t\t\tsetState((prevState) => ({ +\t\t\t\t...prevState, +\t\t\t\tclineMessages: nextMessages, +\t\t\t\tclineMessagesSeq: seq, +\t\t\t})) +\t\t}, +\t\t[requestClineMessagesResync], +\t) +''' + text = replace_once(text, callback_anchor, callback_add, "webview transcript callbacks") + + text = replace_once( + text, + "\t\t\t\tcase \"state\": {\n" + "\t\t\t\t\tconst newState = message.state ?? {}\n" + "\t\t\t\t\tsetState((prevState) => mergeExtensionState(prevState, newState))", + "\t\t\t\tcase \"state\": {\n" + "\t\t\t\t\tconst {\n" + "\t\t\t\t\t\tclineMessages: _ignoredMessages,\n" + "\t\t\t\t\t\tclineMessagesSeq: _ignoredMessagesSeq,\n" + "\t\t\t\t\t\t...newState\n" + "\t\t\t\t\t} = message.state ?? {}\n" + "\t\t\t\t\tconst hasCurrentTaskId = Object.prototype.hasOwnProperty.call(newState, \"currentTaskId\")\n" + "\t\t\t\t\tconst nextTaskId = hasCurrentTaskId ? newState.currentTaskId : activeTaskIdRef.current\n" + "\t\t\t\t\tconst taskChanged = hasCurrentTaskId && nextTaskId !== activeTaskIdRef.current\n" + "\t\t\t\t\tif (taskChanged) {\n" + "\t\t\t\t\t\tactiveTaskIdRef.current = nextTaskId\n" + "\t\t\t\t\t\tclineMessagesSeqRef.current = 0\n" + "\t\t\t\t\t\tclineMessagesRef.current = []\n" + "\t\t\t\t\t\tactiveSnapshotRef.current = null\n" + "\t\t\t\t\t\tresyncPendingRef.current = false\n" + "\t\t\t\t\t}\n" + "\t\t\t\t\tsetState((prevState) => {\n" + "\t\t\t\t\t\tconst merged = mergeExtensionState(prevState, newState)\n" + "\t\t\t\t\t\treturn taskChanged ? { ...merged, clineMessages: [], clineMessagesSeq: 0 } : merged\n" + "\t\t\t\t\t})", + "metadata state task switch handling", + ) + + old_message_case = re.compile( + r'''\t\t\t\tcase "messageUpdated": \{\n.*?\t\t\t\t\}\n\t\t\t\tcase "skills": \{''', + re.S, + ) + new_message_case = '''\t\t\t\tcase "clineMessagesSnapshotStart": { +\t\t\t\t\tif ( +\t\t\t\t\t\t!message.snapshotId || +\t\t\t\t\t\ttypeof message.clineMessagesSeq !== "number" || +\t\t\t\t\t\ttypeof message.snapshotTotal !== "number" || +\t\t\t\t\t\tmessage.taskId !== activeTaskIdRef.current || +\t\t\t\t\t\tmessage.clineMessagesSeq < clineMessagesSeqRef.current +\t\t\t\t\t) { +\t\t\t\t\t\tbreak +\t\t\t\t\t} +\t\t\t\t\tactiveSnapshotRef.current = { +\t\t\t\t\t\tsnapshotId: message.snapshotId, +\t\t\t\t\t\ttaskId: message.taskId, +\t\t\t\t\t\tseq: message.clineMessagesSeq, +\t\t\t\t\t\ttotal: message.snapshotTotal, +\t\t\t\t\t\tmessages: [], +\t\t\t\t\t} +\t\t\t\t\tbreak +\t\t\t\t} +\t\t\t\tcase "clineMessagesSnapshotChunk": { +\t\t\t\t\tconst snapshot = activeSnapshotRef.current +\t\t\t\t\tif ( +\t\t\t\t\t\t!snapshot || +\t\t\t\t\t\tmessage.snapshotId !== snapshot.snapshotId || +\t\t\t\t\t\tmessage.taskId !== snapshot.taskId || +\t\t\t\t\t\tmessage.clineMessagesSeq !== snapshot.seq +\t\t\t\t\t) { +\t\t\t\t\t\tbreak +\t\t\t\t\t} +\t\t\t\t\tconst chunk = message.clineMessages ?? [] +\t\t\t\t\tif ( +\t\t\t\t\t\tmessage.snapshotStartIndex !== snapshot.messages.length || +\t\t\t\t\t\tsnapshot.messages.length + chunk.length > snapshot.total +\t\t\t\t\t) { +\t\t\t\t\t\tactiveSnapshotRef.current = null +\t\t\t\t\t\trequestClineMessagesResync(message.clineMessagesSeq) +\t\t\t\t\t\tbreak +\t\t\t\t\t} +\t\t\t\t\tsnapshot.messages.push(...chunk) +\t\t\t\t\tbreak +\t\t\t\t} +\t\t\t\tcase "clineMessagesSnapshotEnd": { +\t\t\t\t\tconst snapshot = activeSnapshotRef.current +\t\t\t\t\tif ( +\t\t\t\t\t\t!snapshot || +\t\t\t\t\t\tmessage.snapshotId !== snapshot.snapshotId || +\t\t\t\t\t\tmessage.taskId !== snapshot.taskId || +\t\t\t\t\t\tmessage.clineMessagesSeq !== snapshot.seq || +\t\t\t\t\t\tsnapshot.messages.length !== snapshot.total || +\t\t\t\t\t\tmessage.snapshotTotal !== snapshot.total +\t\t\t\t\t) { +\t\t\t\t\t\tactiveSnapshotRef.current = null +\t\t\t\t\t\trequestClineMessagesResync(message.clineMessagesSeq) +\t\t\t\t\t\tbreak +\t\t\t\t\t} +\t\t\t\t\tactiveSnapshotRef.current = null +\t\t\t\t\tresyncPendingRef.current = false +\t\t\t\t\tclineMessagesRef.current = snapshot.messages +\t\t\t\t\tclineMessagesSeqRef.current = snapshot.seq +\t\t\t\t\tsetState((prevState) => ({ +\t\t\t\t\t\t...prevState, +\t\t\t\t\t\tclineMessages: snapshot.messages, +\t\t\t\t\t\tclineMessagesSeq: snapshot.seq, +\t\t\t\t\t})) +\t\t\t\t\tbreak +\t\t\t\t} +\t\t\t\tcase "clineMessageAppended": { +\t\t\t\t\tapplyClineMessagesDelta(message, "append") +\t\t\t\t\tbreak +\t\t\t\t} +\t\t\t\tcase "clineMessageUpdated": { +\t\t\t\t\tapplyClineMessagesDelta(message, "update") +\t\t\t\t\tbreak +\t\t\t\t} +\t\t\t\tcase "messageUpdated": { +\t\t\t\t\t// An unsequenced legacy update cannot be applied safely. +\t\t\t\t\trequestClineMessagesResync(message.clineMessagesSeq) +\t\t\t\t\tbreak +\t\t\t\t} +\t\t\t\tcase "skills": {''' + text, count = old_message_case.subn(new_message_case, text, count=1) + if count != 1: + die(f"webview transcript switch: expected exactly one match, found {count}") + + text = replace_once( + text, + "\t\t[setListApiConfigMeta],", + "\t\t[applyClineMessagesDelta, requestClineMessagesResync, setListApiConfigMeta],", + "webview handler dependencies", + ) + + write(path, text) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("repo", nargs="?", default=".", help="Zoo Code repository root") + parser.add_argument("--no-diff", action="store_true", help="do not print git diff after applying") + args = parser.parse_args() + + root = Path(args.repo).resolve() + sentinel = root / "src/core/webview/ClineProvider.ts" + if not sentinel.is_file(): + die(f"{root} does not look like the Zoo Code repository root") + + if MARKER in read(sentinel): + print("Patch marker already present; no changes made.") + return 0 + + patch_types(root) + patch_provider(root) + patch_task(root) + patch_handler(root) + patch_webview(root) + + files = [ + "packages/types/src/vscode-extension-host.ts", + "src/core/webview/ClineProvider.ts", + "src/core/task/Task.ts", + "src/core/webview/webviewMessageHandler.ts", + "webview-ui/src/context/ExtensionStateContext.tsx", + ] + print("Applied incremental, sequenced, chunked transcript transport patch.") + print("Changed files:") + for file in files: + print(f" {file}") + + if not args.no_diff: + try: + subprocess.run(["git", "diff", "--", *files], cwd=root, check=False) + except FileNotFoundError: + print("git not found; skipping diff", file=sys.stderr) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index 5f6b579779..4d2b599a53 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -37,7 +37,12 @@ export interface ExtensionMessage { | "theme" | "workspaceUpdated" | "invoke" - | "messageUpdated" + | "clineMessageAppended" + | "clineMessageUpdated" + | "clineMessagesSnapshotStart" + | "clineMessagesSnapshotChunk" + | "clineMessagesSnapshotEnd" + | "messageUpdated" // Legacy: a patched webview requests a full resync instead of applying this. | "mcpServers" | "enhancedPrompt" | "commitSearchResults" @@ -138,7 +143,13 @@ export interface ExtensionMessage { isActive: boolean path?: string }> + taskId?: string clineMessage?: ClineMessage + clineMessages?: ClineMessage[] + clineMessagesSeq?: number + snapshotId?: string + snapshotStartIndex?: number + snapshotTotal?: number routerModels?: RouterModels openAiModels?: string[] ollamaModels?: ModelRecord @@ -646,8 +657,11 @@ export interface WebviewMessage { | "openRuleFile" | "openRulesDirectory" | "themeFixtureProbeResponse" + | "requestClineMessagesResync" text?: string taskId?: string + expectedSeq?: number + receivedSeq?: number editedMessageContent?: string tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "cloud" disabled?: boolean From f9f81817837c3159ab3df7c6efc9819a9f1925d6 Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Sun, 23 Aug 2026 07:34:42 -0600 Subject: [PATCH 02/26] Added Chat Output to readme --- ZOO_CODE_GRAY_SCREEN_FIX_README.md | 195 +++++++++++++++++++++++++++++ 1 file changed, 195 insertions(+) diff --git a/ZOO_CODE_GRAY_SCREEN_FIX_README.md b/ZOO_CODE_GRAY_SCREEN_FIX_README.md index 5a6f3987bf..b4f9dcac10 100644 --- a/ZOO_CODE_GRAY_SCREEN_FIX_README.md +++ b/ZOO_CODE_GRAY_SCREEN_FIX_README.md @@ -71,3 +71,198 @@ Pass conditions: - `src/core/task/Task.ts` - `src/core/webview/webviewMessageHandler.ts` - `webview-ui/src/context/ExtensionStateContext.tsx` + + + +# GPT 5.6 Output: + +## Patch output + +* [Download the permanent-fix source patch applicator](sandbox:/mnt/data/apply_zoo_code_incremental_transcript_fix.py) +* [Download the implementation notes and validation procedure](sandbox:/mnt/data/ZOO_CODE_GRAY_SCREEN_FIX_README.md) + +This is a **source-level architectural fix**, not another output-limiting or task-rotation workaround. + +Zoo Code issue #630 remains open and documents the underlying defect: throttling reduces update frequency but still serializes the complete `clineMessages` array, approximately 7 MB at 3,525 messages, on ordinary state pushes. Its required solution is task-scoped incremental delivery with sequencing, focus isolation, and automatic resynchronization. ([GitHub][1]) + +The v3.80 release notes confirm that the shipped change was specifically a throttling change, rather than replacement of the full-array transport. ([GitHub][2]) + +## What the patch changes + +The patch modifies these files: + +```text +packages/types/src/vscode-extension-host.ts +src/core/webview/ClineProvider.ts +src/core/task/Task.ts +src/core/webview/webviewMessageHandler.ts +webview-ui/src/context/ExtensionStateContext.tsx +``` + +It implements five related changes. + +### 1. Removes transcripts from generic extension state + +Every generic `{ type: "state" }` message is stripped of: + +```text +clineMessages +clineMessagesSeq +``` + +The stripping occurs at the final `postMessageToWebview()` boundary, not merely in selected callers. This prevents another code path from accidentally reintroducing multi-megabyte state messages. + +### 2. Adds task-scoped incremental messages + +Normal transcript changes become: + +```typescript +{ + type: "clineMessageAppended", + taskId, + clineMessage, + clineMessagesSeq +} +``` + +or: + +```typescript +{ + type: "clineMessageUpdated", + taskId, + clineMessage, + clineMessagesSeq +} +``` + +An append or edit therefore transfers one `ClineMessage`, not the entire transcript. + +### 3. Adds chunked transcript reconstruction + +Initial webview loading, task switching, checkpoint restoration, message deletion, message editing, and explicit recovery use: + +```text +clineMessagesSnapshotStart +clineMessagesSnapshotChunk +clineMessagesSnapshotEnd +``` + +The default chunk size is 200 messages. + +This extends the proposal in issue #630: the retained full synchronization path is itself chunked, so reloading the webview does not require deserializing the complete history in one large message. + +### 4. Adds automatic sequence-gap recovery + +The webview validates: + +* Focused task ID +* Monotonic sequence number +* Snapshot ID +* Chunk start offset +* Expected message count +* Final received message count + +When an append or update is skipped, reordered, or cannot be applied, the webview sends: + +```typescript +{ + type: "requestClineMessagesResync", + taskId, + expectedSeq, + receivedSeq +} +``` + +The extension then sends a fresh chunked snapshot. + +### 5. Isolates foreground and background tasks + +Each task has its own message sequence. Focus transitions invalidate the previous transport generation. + +Consequently, a delegated child task or an old asynchronous callback cannot append messages to whichever task happens to be visible when the callback completes. + +## Apply the patch + +Use a clean Zoo Code source checkout: + +```powershell +git clone https://github.com/Zoo-Code-Org/Zoo-Code.git +Set-Location .\Zoo-Code + +python C:\Path\To\apply_zoo_code_incremental_transcript_fix.py . +``` + +The applicator is deliberately strict. It checks that each expected source block exists exactly once. If the source differs materially, it exits rather than partially modifying the repository. + +Review the changes: + +```powershell +git diff --check +git diff --stat +git diff +``` + +## Build and validate + +The current repository specifies Node `22.23.1`, pnpm `10.8.1`, and provides `check-types`, `lint`, `test`, and `vsix` scripts. + +```powershell +corepack enable +corepack prepare pnpm@10.8.1 --activate + +pnpm install --frozen-lockfile +pnpm check-types +pnpm lint +pnpm test +pnpm vsix +``` + +Install the generated package: + +```powershell +$Vsix = Get-ChildItem .\bin\*.vsix | + Sort-Object LastWriteTime -Descending | + Select-Object -First 1 + +code --install-extension $Vsix.FullName --force +``` + +Zoo Code’s repository documents `pnpm vsix`, `pnpm install:vsix`, and manual installation from the generated `bin` directory. ([GitHub][3]) + +After installation, fully close all VS Code processes once and reopen VS Code. Prevent the Marketplace build from automatically replacing the custom version while it is being evaluated. + +## Required acceptance test + +Run a long task that produces at least 10,000 transcript/tool-status messages. + +The fix passes when all of the following are true: + +1. Zoo Code remains rendered and interactive. +2. Normal appends and edits transfer one message each. +3. No generic `state` event contains `clineMessages`. +4. Renderer memory does not scale with `message count × entire transcript size`. +5. `Developer: Reload Webviews` reconstructs the active transcript through chunks. +6. The extension-host task continues while the replacement webview hydrates. +7. Rapid parent/child task switching never shows messages from the wrong task. +8. A deliberately dropped delta causes an automatic resync and restores an exact transcript. + +## Validation status + +The patch applicator itself passed Python syntax compilation with `python -m py_compile`. + +I inspected the current official source structure and issue specification while constructing it, but could not run Zoo Code’s TypeScript build or test suite in this execution environment because the repository could not be cloned into the local container. The patch is therefore a **source patch candidate**, not an upstream-reviewed release. Issue #630 currently shows no associated branch or pull request. ([GitHub][1]) + +SHA-256: + +```text +apply_zoo_code_incremental_transcript_fix.py +8f89677c1e4fbec5ab9982495e0396a2e086d59d9a89fc90a6ae904b450fa5b1 + +ZOO_CODE_GRAY_SCREEN_FIX_README.md +9d4db5a0d87f9726d5234d5884907977cc00664d93e9a8a0e17bd50b4530de2d +``` + +[1]: https://github.com/Zoo-Code-Org/Zoo-Code/issues/630 "feat(webview): incremental clineMessages delivery for focused task · Issue #630 · Zoo-Code-Org/Zoo-Code · GitHub" +[2]: https://github.com/Zoo-Code-Org/Zoo-Code/releases "Releases · Zoo-Code-Org/Zoo-Code · GitHub" +[3]: https://github.com/Zoo-Code-Org/Zoo-Code "GitHub - Zoo-Code-Org/Zoo-Code: Zoo Code gives you a whole dev team of AI agents in your code editor. · GitHub" From a6e8a8a9eb94911529bfaef3a32dbb8994ee276b Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Sun, 23 Aug 2026 12:52:16 -0600 Subject: [PATCH 03/26] feat: enhance transcript handling and synchronization in webview - Introduced `syncFocusedTaskToWebview` method to streamline UI updates. - Replaced `postStateToWebview` calls with `syncFocusedTaskToWebview` for better state management. - Added handling for `requestClineMessagesResync` message type to manage task-specific message synchronization. - Implemented snapshot handling for `clineMessages` to ensure consistent state updates during message appends and updates. - Updated tests to reflect changes in state management and message handling. - Refactored utility functions for better clarity and functionality in testing. --- packages/types/src/vscode-extension-host.ts | 7 +- src/__tests__/helpers/provider-stub.ts | 2 + src/__tests__/single-open-invariant.spec.ts | 2 + src/core/task/Task.ts | 41 ++- .../task/__tests__/Task.persistence.spec.ts | 3 + src/core/task/__tests__/Task.spec.ts | 118 ++++--- src/core/webview/ClineProvider.ts | 184 ++++++++++- .../webview/__tests__/ClineProvider.spec.ts | 110 ++++++- .../__tests__/webviewMessageHandler.spec.ts | 1 + src/core/webview/webviewMessageHandler.ts | 22 +- .../ChatView.clear-approval-buttons.spec.tsx | 41 +-- .../ChatView.notification-sound.spec.tsx | 101 ++---- .../ChatView.scroll-debug-repro.spec.tsx | 52 +-- .../chat/__tests__/ChatView.spec.tsx | 64 ++-- .../src/context/ExtensionStateContext.tsx | 276 +++++++++++++--- .../__tests__/ExtensionStateContext.spec.tsx | 296 +++++++++--------- webview-ui/src/utils/test-utils.tsx | 63 +++- 17 files changed, 882 insertions(+), 501 deletions(-) diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index 4d2b599a53..ce64e87913 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -437,10 +437,9 @@ export type ExtensionState = Pick< arch?: string /** - * Monotonically increasing sequence number for clineMessages state pushes. - * When present, the frontend should only apply clineMessages from a state push - * if its seq is greater than the last applied seq. This prevents stale state - * (captured during async getStateToPostToWebview) from overwriting newer messages. + * Last sequence applied by the dedicated task-scoped transcript transport. + * Generic `state` messages intentionally omit this field and `clineMessages`; + * snapshots and append/update messages carry both transcript data and sequence. */ clineMessagesSeq?: number } diff --git a/src/__tests__/helpers/provider-stub.ts b/src/__tests__/helpers/provider-stub.ts index 59dde33933..ccb990e7d5 100644 --- a/src/__tests__/helpers/provider-stub.ts +++ b/src/__tests__/helpers/provider-stub.ts @@ -6,6 +6,7 @@ type ProviderStubFields = { delegationTransitionLocks?: Map> cancelledDelegationChildIds?: Set log?: ReturnType + syncFocusedTaskToWebview?: ReturnType taskHistoryStore?: { get: (id: string) => unknown } taskRegistry?: TaskRegistry clineStack?: Task[] @@ -37,6 +38,7 @@ export function makeProviderStub(stub: T): ClineProvider { s.delegationTransitionLocks ??= new Map() s.cancelledDelegationChildIds ??= new Set() s.log ??= vi.fn() + s.syncFocusedTaskToWebview ??= vi.fn().mockResolvedValue(undefined) s.taskHistoryStore ??= { get: () => undefined } // Convert legacy clineStack array into a TaskRegistry diff --git a/src/__tests__/single-open-invariant.spec.ts b/src/__tests__/single-open-invariant.spec.ts index af1631df9c..94eb5099d1 100644 --- a/src/__tests__/single-open-invariant.spec.ts +++ b/src/__tests__/single-open-invariant.spec.ts @@ -269,6 +269,7 @@ describe("Single-open-task invariant", () => { taskScheduler: { schedule: schedulespy }, taskEventListeners: new WeakMap(), performPreparationTasks: vi.fn().mockResolvedValue(undefined), + syncFocusedTaskToWebview: vi.fn().mockResolvedValue(undefined), context: { extension: { packageJSON: {} }, globalStorageUri: { fsPath: "/tmp" } }, contextProxy: { extensionUri: {}, @@ -341,6 +342,7 @@ describe("Single-open-task invariant", () => { taskScheduler: { schedule: schedulespy }, taskEventListeners: new WeakMap(), performPreparationTasks: vi.fn().mockResolvedValue(undefined), + syncFocusedTaskToWebview: vi.fn().mockResolvedValue(undefined), context: { extension: { packageJSON: {} }, globalStorageUri: { fsPath: "/tmp" } }, contextProxy: { extensionUri: {}, diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 4f122feefc..316a5771ee 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -1151,20 +1151,10 @@ export class Task extends EventEmitter implements TaskLike { private async addToClineMessages(message: ClineMessage) { this.clineMessages.push(message) const provider = this.providerRef.deref() - // Unanswered asks must reach the webview before Message listeners can respond against its state. - const requiresImmediateState = - message.partial === true || (message.type === "ask" && message.isAnswered !== true) try { - await provider?.postStateToWebviewThrottled() + await provider?.postClineMessageAppended(this.taskId, message) } catch (error) { - console.error("[Task#addToClineMessages] postStateToWebviewThrottled failed:", error) - } - if (requiresImmediateState) { - try { - await provider?.flushPostStateToWebviewThrottled() - } catch (error) { - console.error("[Task#addToClineMessages] flushPostStateToWebviewThrottled failed:", error) - } + console.error("[Task#addToClineMessages] incremental post failed:", error) } this.emit(RooCodeEventName.Message, { action: "created", message }) await this.saveClineMessages() @@ -1194,11 +1184,12 @@ export class Task extends EventEmitter implements TaskLike { this.cloudSyncedMessageTimestamps.add(msg.ts) } } + await this.providerRef.deref()?.postClineMessagesSnapshot(this.taskId, { bumpSeq: true }) } private async updateClineMessage(message: ClineMessage) { const provider = this.providerRef.deref() - await provider?.postMessageToWebview({ type: "messageUpdated", clineMessage: message }) + await provider?.postClineMessageUpdated(this.taskId, message) this.emit(RooCodeEventName.Message, { action: "updated", message }) // Check if we should sync to cloud and haven't already synced this message @@ -1291,7 +1282,7 @@ export class Task extends EventEmitter implements TaskLike { let askTs: number - // Resolve auto-approval before adding the message so the state snapshot + // Resolve auto-approval before adding the message so the incremental append // sent to the webview already carries isAnswered:true when the ask will // be immediately resolved. This eliminates the race between the state // update (which shows approval buttons) and the former separate @@ -1325,10 +1316,8 @@ export class Task extends EventEmitter implements TaskLike { lastMessage.partial = partial lastMessage.progressStatus = progressStatus lastMessage.isProtected = isProtected - // TODO: Be more efficient about saving and posting only new - // data or one whole message at a time so ignore partial for - // saves, and only post parts of partial message instead of - // whole array in new listener. + // Persist partial messages only when they become complete; the + // dedicated transport can still update one in-memory message at a time. // Fire-and-forget: the webview post is internally guarded, but // the `RooCodeEventName.Message` emit can synchronously throw // if any consumer-attached listener does, which would surface @@ -1581,6 +1570,9 @@ export class Task extends EventEmitter implements TaskLike { if (lastFollowUpIndex !== -1) { // Mark this follow-up as answered this.clineMessages[lastFollowUpIndex].isAnswered = true + void this.updateClineMessage(this.clineMessages[lastFollowUpIndex]).catch((error) => { + console.error("[Task#handleWebviewAskResponse] follow-up delta failed:", error) + }) // Save the updated messages this.saveClineMessages().catch((error) => { console.error("Failed to save answered follow-up state:", error) @@ -2056,7 +2048,7 @@ export class Task extends EventEmitter implements TaskLike { // The todo list is already set in the constructor if initialTodos were provided // No need to add any messages - the todoList property is already set - await this.providerRef.deref()?.postStateToWebviewWithoutTaskHistory() + await this.providerRef.deref()?.postClineMessagesSnapshot(this.taskId, { bumpSeq: true }) await this.say("text", task, images) @@ -2207,7 +2199,7 @@ export class Task extends EventEmitter implements TaskLike { this.isInitialized = true - const { response, text, images } = await this.ask(askType) // Calls `postStateToWebview`. + const { response, text, images } = await this.ask(askType) let responseText: string | undefined let responseImages: string[] | undefined @@ -2920,7 +2912,7 @@ export class Task extends EventEmitter implements TaskLike { } satisfies ClineApiReqInfo) await this.saveClineMessages() - await this.providerRef.deref()?.postStateToWebviewWithoutTaskHistory() + await this.updateClineMessage(this.clineMessages[lastApiReqIndex]) try { let cacheWriteTokens = 0 @@ -2991,12 +2983,16 @@ export class Task extends EventEmitter implements TaskLike { if (lastMessage && lastMessage.partial) { // lastMessage.ts = Date.now() DO NOT update ts since it is used as a key for virtuoso list lastMessage.partial = false - // instead of streaming partialMessage events, we do a save and post like normal to persist to disk + await this.updateClineMessage(lastMessage) } // Update `api_req_started` to have cancelled and cost, so that // we can display the cost of the partial stream and the cancellation reason updateApiReqMsg(cancelReason, streamingFailedMessage) + const apiRequestMessage = this.clineMessages[lastApiReqIndex] + if (apiRequestMessage) { + await this.updateClineMessage(apiRequestMessage) + } await this.saveClineMessages() // Signals to provider that it can retrieve the saved messages @@ -3674,7 +3670,6 @@ export class Task extends EventEmitter implements TaskLike { } await this.saveClineMessages() - await this.providerRef.deref()?.postStateToWebviewWithoutTaskHistory() // No legacy text-stream tool parser state to reset. diff --git a/src/core/task/__tests__/Task.persistence.spec.ts b/src/core/task/__tests__/Task.persistence.spec.ts index 671bd7d4b7..2bc5f4fdd6 100644 --- a/src/core/task/__tests__/Task.persistence.spec.ts +++ b/src/core/task/__tests__/Task.persistence.spec.ts @@ -285,6 +285,9 @@ describe("Task persistence", () => { mockProvider.postMessageToWebview = vi.fn().mockResolvedValue(undefined) mockProvider.postStateToWebview = vi.fn().mockResolvedValue(undefined) mockProvider.postStateToWebviewWithoutTaskHistory = vi.fn().mockResolvedValue(undefined) + mockProvider.postClineMessageAppended = vi.fn().mockResolvedValue(undefined) + mockProvider.postClineMessageUpdated = vi.fn().mockResolvedValue(undefined) + mockProvider.postClineMessagesSnapshot = vi.fn().mockResolvedValue(undefined) mockProvider.updateTaskHistory = vi.fn().mockResolvedValue(undefined) mockProvider.log = vi.fn() }) diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index 0376f437cb..cab147526a 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -368,6 +368,9 @@ describe("Cline", () => { mockProvider.postStateToWebviewWithoutTaskHistory = vi.fn().mockResolvedValue(undefined) mockProvider.postStateToWebviewThrottled = vi.fn().mockResolvedValue(undefined) mockProvider.flushPostStateToWebviewThrottled = vi.fn().mockResolvedValue(undefined) + mockProvider.postClineMessageAppended = vi.fn().mockResolvedValue(undefined) + mockProvider.postClineMessageUpdated = vi.fn().mockResolvedValue(undefined) + mockProvider.postClineMessagesSnapshot = vi.fn().mockResolvedValue(undefined) mockProvider.getTaskWithId = vi.fn().mockImplementation(async (id) => ({ historyItem: { id, @@ -1236,6 +1239,9 @@ describe("Cline", () => { postStateToWebviewThrottled: vi.fn().mockResolvedValue(undefined), flushPostStateToWebviewThrottled: vi.fn().mockResolvedValue(undefined), postMessageToWebview: vi.fn().mockResolvedValue(undefined), + postClineMessageAppended: vi.fn().mockResolvedValue(undefined), + postClineMessageUpdated: vi.fn().mockResolvedValue(undefined), + postClineMessagesSnapshot: vi.fn().mockResolvedValue(undefined), updateTaskHistory: vi.fn().mockResolvedValue(undefined), // Task receives a full ClineProvider at runtime; this focused unit test only exercises these methods. } as unknown as MockedClineProvider @@ -1924,8 +1930,8 @@ describe("Cline", () => { }) }) - describe("webview state throttling", () => { - it("schedules a complete new message without forcing an immediate state push", async () => { + describe("webview transcript transport", () => { + it("posts a complete new message through the incremental transport", async () => { const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, @@ -1942,13 +1948,13 @@ describe("Cline", () => { await getTaskTestAccess(task).addToClineMessages(message) - expect(mockProvider.postStateToWebviewThrottled).toHaveBeenCalledOnce() - expect(mockProvider.postStateToWebviewThrottled).toHaveBeenCalledWith() - expect(mockProvider.flushPostStateToWebviewThrottled).not.toHaveBeenCalled() + expect(mockProvider.postClineMessageAppended).toHaveBeenCalledOnce() + expect(mockProvider.postClineMessageAppended).toHaveBeenCalledWith(task.taskId, message) + expect(mockProvider.postStateToWebviewThrottled).not.toHaveBeenCalled() expect(mockProvider.postStateToWebviewWithoutTaskHistory).not.toHaveBeenCalled() }) - it("waits for an unanswered ask flush before emitting the message", async () => { + it("waits for an incremental append before emitting the message", async () => { const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, @@ -1957,11 +1963,11 @@ describe("Cline", () => { }) const taskAccess = getTaskTestAccess(task) vi.spyOn(taskAccess, "saveClineMessages").mockResolvedValue(true) - let releaseFlush!: () => void - const pendingFlush = new Promise((resolve) => { - releaseFlush = resolve + let releasePost!: () => void + const pendingPost = new Promise((resolve) => { + releasePost = resolve }) - const flushSpy = vi.mocked(mockProvider.flushPostStateToWebviewThrottled).mockReturnValueOnce(pendingFlush) + const postSpy = vi.mocked(mockProvider.postClineMessageAppended).mockReturnValueOnce(pendingPost) const messageListener = vi.fn() task.on(RooCodeEventName.Message, messageListener) const message = { @@ -1973,20 +1979,17 @@ describe("Cline", () => { const addPromise = taskAccess.addToClineMessages(message) await Promise.resolve() - expect(mockProvider.postStateToWebviewThrottled).toHaveBeenCalledOnce() - expect(mockProvider.postStateToWebviewThrottled).toHaveBeenCalledWith() - expect(flushSpy).toHaveBeenCalledOnce() - expect(flushSpy).toHaveBeenCalledWith() + expect(postSpy).toHaveBeenCalledWith(task.taskId, message) expect(messageListener).not.toHaveBeenCalled() - releaseFlush() + releasePost() await addPromise - expect(flushSpy.mock.invocationCallOrder[0]).toBeLessThan(messageListener.mock.invocationCallOrder[0]) + expect(postSpy.mock.invocationCallOrder[0]).toBeLessThan(messageListener.mock.invocationCallOrder[0]) expect(messageListener).toHaveBeenCalledWith({ action: "created", message }) }) - it("continues the message lifecycle when throttled state scheduling and flushing fail", async () => { + it("continues the message lifecycle when an incremental append fails", async () => { const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, @@ -1994,10 +1997,8 @@ describe("Cline", () => { startTask: false, }) const taskAccess = getTaskTestAccess(task) - const postError = new Error("state schedule failed") - const flushError = new Error("state flush failed") - const postSpy = vi.mocked(mockProvider.postStateToWebviewThrottled).mockRejectedValueOnce(postError) - const flushSpy = vi.mocked(mockProvider.flushPostStateToWebviewThrottled).mockRejectedValueOnce(flushError) + const postError = new Error("incremental append failed") + const postSpy = vi.mocked(mockProvider.postClineMessageAppended).mockRejectedValueOnce(postError) const saveSpy = vi.spyOn(taskAccess, "saveClineMessages").mockResolvedValue(true) const messageListener = vi.fn() const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) @@ -2011,25 +2012,19 @@ describe("Cline", () => { await expect(taskAccess.addToClineMessages(message)).resolves.toBeUndefined() expect(consoleErrorSpy).toHaveBeenCalledWith( - "[Task#addToClineMessages] postStateToWebviewThrottled failed:", + "[Task#addToClineMessages] incremental post failed:", postError, ) - expect(consoleErrorSpy).toHaveBeenCalledWith( - "[Task#addToClineMessages] flushPostStateToWebviewThrottled failed:", - flushError, - ) expect(postSpy).toHaveBeenCalledOnce() - expect(flushSpy).toHaveBeenCalledOnce() expect(messageListener).toHaveBeenCalledWith({ action: "created", message }) expect(saveSpy).toHaveBeenCalledOnce() - expect(postSpy.mock.invocationCallOrder[0]).toBeLessThan(flushSpy.mock.invocationCallOrder[0]) - expect(flushSpy.mock.invocationCallOrder[0]).toBeLessThan(messageListener.mock.invocationCallOrder[0]) + expect(postSpy.mock.invocationCallOrder[0]).toBeLessThan(messageListener.mock.invocationCallOrder[0]) expect(messageListener.mock.invocationCallOrder[0]).toBeLessThan(saveSpy.mock.invocationCallOrder[0]) consoleErrorSpy.mockRestore() }) - it("keeps an already answered ask on the throttled path", async () => { + it("posts an already answered ask through the incremental transport", async () => { const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, @@ -2038,19 +2033,18 @@ describe("Cline", () => { }) vi.spyOn(getTaskTestAccess(task), "saveClineMessages").mockResolvedValue(true) - await getTaskTestAccess(task).addToClineMessages({ + const message = { ts: 1, - type: "ask", - ask: "tool", + type: "ask" as const, + ask: "tool" as const, isAnswered: true, - }) + } + await getTaskTestAccess(task).addToClineMessages(message) - expect(mockProvider.postStateToWebviewThrottled).toHaveBeenCalledOnce() - expect(mockProvider.postStateToWebviewThrottled).toHaveBeenCalledWith() - expect(mockProvider.flushPostStateToWebviewThrottled).not.toHaveBeenCalled() + expect(mockProvider.postClineMessageAppended).toHaveBeenCalledWith(task.taskId, message) }) - it("waits for a new partial message flush before a following message update", async () => { + it("serializes a new partial message before its following update", async () => { const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, @@ -2059,12 +2053,12 @@ describe("Cline", () => { }) const taskAccess = getTaskTestAccess(task) vi.spyOn(taskAccess, "saveClineMessages").mockResolvedValue(true) - let releaseFlush!: () => void - const pendingFlush = new Promise((resolve) => { - releaseFlush = resolve + let releaseAppend!: () => void + const pendingAppend = new Promise((resolve) => { + releaseAppend = resolve }) - const flushSpy = vi.mocked(mockProvider.flushPostStateToWebviewThrottled).mockReturnValueOnce(pendingFlush) - const updatePostSpy = vi.mocked(mockProvider.postMessageToWebview) + const appendSpy = vi.mocked(mockProvider.postClineMessageAppended).mockReturnValueOnce(pendingAppend) + const updatePostSpy = vi.mocked(mockProvider.postClineMessageUpdated) const partialMessage = { ts: 1, type: "say" as const, @@ -2079,21 +2073,17 @@ describe("Cline", () => { }) await Promise.resolve() - expect(mockProvider.postStateToWebviewThrottled).toHaveBeenCalledWith() - expect(flushSpy).toHaveBeenCalledWith() + expect(appendSpy).toHaveBeenCalledWith(task.taskId, partialMessage) expect(partialAddSettled).toBe(false) expect(updatePostSpy).not.toHaveBeenCalled() - releaseFlush() + releaseAppend() await addThenUpdate - expect(flushSpy.mock.invocationCallOrder[0]).toBeLessThan(updatePostSpy.mock.invocationCallOrder[0]) - expect(updatePostSpy).toHaveBeenCalledWith({ - type: "messageUpdated", - clineMessage: { - ...partialMessage, - text: "updated partial", - }, + expect(appendSpy.mock.invocationCallOrder[0]).toBeLessThan(updatePostSpy.mock.invocationCallOrder[0]) + expect(updatePostSpy).toHaveBeenCalledWith(task.taskId, { + ...partialMessage, + text: "updated partial", }) }) }) @@ -3312,7 +3302,7 @@ describe("Cline", () => { }) describe("startTask", () => { - it("posts a clean state immediately before adding the first task message", async () => { + it("posts an empty transcript snapshot before adding the first task message", async () => { const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, @@ -3323,16 +3313,14 @@ describe("Cline", () => { task.clineMessages = [{ ts: 1, type: "say", say: "text", text: "stale message" }] - let resolvePostState: (() => void) | undefined - const pendingPostState = new Promise((resolve) => { - resolvePostState = resolve + let resolveSnapshot: (() => void) | undefined + const pendingSnapshot = new Promise((resolve) => { + resolveSnapshot = resolve + }) + const snapshotSpy = vi.mocked(mockProvider.postClineMessagesSnapshot).mockImplementationOnce(async () => { + expect(task.clineMessages).toEqual([]) + await pendingSnapshot }) - const postStateSpy = vi - .mocked(mockProvider.postStateToWebviewWithoutTaskHistory) - .mockImplementationOnce(async () => { - expect(task.clineMessages).toEqual([]) - await pendingPostState - }) const saySpy = vi.spyOn(task, "say").mockResolvedValue(undefined) vi.spyOn(taskAccess, "getEnabledMcpToolsCount").mockResolvedValue({ enabledToolCount: 0, @@ -3342,11 +3330,11 @@ describe("Cline", () => { const startPromise = taskAccess.startTask("new task") - expect(postStateSpy).toHaveBeenCalledTimes(1) + expect(snapshotSpy).toHaveBeenCalledWith(task.taskId, { bumpSeq: true }) expect(mockProvider.postStateToWebviewThrottled).not.toHaveBeenCalled() expect(saySpy).not.toHaveBeenCalled() - resolvePostState?.() + resolveSnapshot?.() await startPromise expect(saySpy).toHaveBeenCalledOnce() diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 394da7c10f..a136e0d883 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -208,10 +208,15 @@ export class ClineProvider private taskEventListeners: WeakMap void>> = new WeakMap() private currentWorkspacePath: string | undefined private _disposed = false + private static readonly CLINE_MESSAGES_SNAPSHOT_CHUNK_SIZE = 200 + private readonly clineMessagesSeqByTaskId = new Map() + private clineMessagesPostQueue: Promise = Promise.resolve() + private clineMessagesTransportGeneration = 0 + private nextClineMessagesSnapshotId = 0 private readonly _postStateToWebviewThrottled = debounce( async () => { try { - await this.postStateToWebviewWithoutTaskHistory() + await this.postStateToWebviewWithoutClineMessages() } catch (error) { this.log( `[ClineProvider#postStateToWebviewThrottled] Failed to post state: ${ @@ -299,12 +304,6 @@ export class ClineProvider private cloudOrganizationsCacheTimestamp: number | null = null private static readonly CLOUD_ORGANIZATIONS_CACHE_DURATION_MS = 5 * 1000 // 5 seconds - /** - * Monotonically increasing sequence number for clineMessages state pushes. - * Used by the frontend to reject stale state that arrives out-of-order. - */ - private clineMessagesSeq = 0 - public isViewLaunched = false public settingsImportedAt?: number public readonly latestAnnouncementId = "sep-2026-v3.82.0-gateway-portability-free-models" // v3.82.0 portable Zoo Gateway keys, free MiniMax-M3, and new models @@ -575,6 +574,8 @@ export class ClineProvider if (!state || typeof state.mode !== "string") { throw new Error(t("common:errors.retrieve_current_mode")) } + + await this.syncFocusedTaskToWebview() } async performPreparationTasks(cline: Task) { @@ -633,6 +634,8 @@ export class ClineProvider // garbage collected. task = undefined } + + await this.syncFocusedTaskToWebview() } /** @@ -1395,6 +1398,7 @@ export class ClineProvider // Perform preparation tasks and set up event listeners await this.performPreparationTasks(task) + await this.syncFocusedTaskToWebview() this.log( `[createTaskWithHistoryItem] rehydrated task ${task.taskId}.${task.instanceId} in-place (flicker-free)`, @@ -1466,6 +1470,12 @@ export class ClineProvider return } + // Generic state is metadata-only. Transcripts use the dedicated transport below. + if (message.type === "state" && message.state) { + const { clineMessages: _omitMessages, clineMessagesSeq: _omitMessagesSeq, ...metadataState } = message.state + message = { ...message, state: metadataState } + } + try { await this.view?.webview.postMessage(message) } catch { @@ -1473,6 +1483,152 @@ export class ClineProvider } } + private getClineMessagesSeq(taskId: string): number { + return this.clineMessagesSeqByTaskId.get(taskId) ?? 0 + } + + private bumpClineMessagesSeq(taskId: string): number { + const next = this.getClineMessagesSeq(taskId) + 1 + this.clineMessagesSeqByTaskId.set(taskId, next) + return next + } + + private enqueueClineMessagesPost(operation: () => Promise): Promise { + const run = this.clineMessagesPostQueue.then(operation, operation) + this.clineMessagesPostQueue = run.catch((error) => { + this.log(`[clineMessages] transport failure: ${error instanceof Error ? error.message : String(error)}`) + }) + return run + } + + private invalidateClineMessagesTransport(): number { + return ++this.clineMessagesTransportGeneration + } + + public postClineMessageAppended(taskId: string, message: ClineMessage): Promise { + if (this.getCurrentTask()?.taskId !== taskId) { + return Promise.resolve() + } + + const seq = this.bumpClineMessagesSeq(taskId) + const generation = this.clineMessagesTransportGeneration + const clonedMessage = structuredClone(message) + return this.enqueueClineMessagesPost(async () => { + if (generation !== this.clineMessagesTransportGeneration || this.getCurrentTask()?.taskId !== taskId) { + return + } + await this.postMessageToWebview({ + type: "clineMessageAppended", + taskId, + clineMessage: clonedMessage, + clineMessagesSeq: seq, + }) + }) + } + + public postClineMessageUpdated(taskId: string, message: ClineMessage): Promise { + if (this.getCurrentTask()?.taskId !== taskId) { + return Promise.resolve() + } + + const seq = this.bumpClineMessagesSeq(taskId) + const generation = this.clineMessagesTransportGeneration + const clonedMessage = structuredClone(message) + return this.enqueueClineMessagesPost(async () => { + if (generation !== this.clineMessagesTransportGeneration || this.getCurrentTask()?.taskId !== taskId) { + return + } + await this.postMessageToWebview({ + type: "clineMessageUpdated", + taskId, + clineMessage: clonedMessage, + clineMessagesSeq: seq, + }) + }) + } + + public postClineMessagesSnapshot( + taskId: string | undefined = this.getCurrentTask()?.taskId, + options: { bumpSeq?: boolean; generation?: number } = {}, + ): Promise { + const currentTask = this.getCurrentTask() + if ((currentTask?.taskId ?? undefined) !== taskId) { + return Promise.resolve() + } + + const seq = taskId + ? options.bumpSeq + ? this.bumpClineMessagesSeq(taskId) + : this.getClineMessagesSeq(taskId) + : 0 + const messages = structuredClone(currentTask?.clineMessages ?? []) + const snapshotId = `${taskId ?? "none"}:${++this.nextClineMessagesSnapshotId}` + const generation = options.generation ?? this.clineMessagesTransportGeneration + + return this.enqueueClineMessagesPost(async () => { + const isCurrent = () => + generation === this.clineMessagesTransportGeneration && + (this.getCurrentTask()?.taskId ?? undefined) === taskId + if (!isCurrent()) { + return + } + + await this.postMessageToWebview({ + type: "clineMessagesSnapshotStart", + taskId, + clineMessagesSeq: seq, + snapshotId, + snapshotTotal: messages.length, + }) + + for (let start = 0; start < messages.length; start += ClineProvider.CLINE_MESSAGES_SNAPSHOT_CHUNK_SIZE) { + if (!isCurrent()) { + return + } + await this.postMessageToWebview({ + type: "clineMessagesSnapshotChunk", + taskId, + clineMessagesSeq: seq, + snapshotId, + snapshotStartIndex: start, + clineMessages: messages.slice(start, start + ClineProvider.CLINE_MESSAGES_SNAPSHOT_CHUNK_SIZE), + }) + } + + if (!isCurrent()) { + return + } + await this.postMessageToWebview({ + type: "clineMessagesSnapshotEnd", + taskId, + clineMessagesSeq: seq, + snapshotId, + snapshotTotal: messages.length, + }) + }) + } + + public resyncClineMessagesToWebview(taskId?: string): Promise { + if ((this.getCurrentTask()?.taskId ?? undefined) !== taskId) { + return Promise.resolve() + } + const generation = this.invalidateClineMessagesTransport() + return this.postClineMessagesSnapshot(taskId, { generation }) + } + + public async syncFocusedTaskToWebview(options: { includeTaskHistory?: boolean } = {}): Promise { + const generation = this.invalidateClineMessagesTransport() + if (options.includeTaskHistory) { + await this.postStateToWebview() + } else { + await this.postStateToWebviewWithoutTaskHistory() + } + if (generation !== this.clineMessagesTransportGeneration) { + return + } + await this.postClineMessagesSnapshot(this.getCurrentTask()?.taskId, { generation }) + } + public requestWebviewThemeFixture(timeoutMs = 5_000): Promise { if (process.env.ROO_CODE_THEME_FIXTURE_PROBE !== "1") { return Promise.reject(new Error("Theme fixture probing is disabled")) @@ -2416,9 +2572,7 @@ export class ClineProvider } async postStateToWebview() { - const clineMessagesSeq = ++this.clineMessagesSeq const state = await this.getStateToPostToWebview() - state.clineMessagesSeq = clineMessagesSeq await this.postMessageToWebview({ type: "state", state }) } @@ -2431,11 +2585,9 @@ export class ClineProvider * `taskHistoryUpdated` / `taskHistoryItemUpdated`. */ async postStateToWebviewWithoutTaskHistory(): Promise { - const clineMessagesSeq = ++this.clineMessagesSeq const state = await this.getStateToPostToWebview({ includeTaskHistory: false }) - state.clineMessagesSeq = clineMessagesSeq - const { taskHistory: _omit, ...rest } = state - await this.postMessageToWebview({ type: "state", state: rest }) + const { taskHistory: _omitHistory, ...metadataState } = state + await this.postMessageToWebview({ type: "state", state: metadataState }) } /** @@ -2461,7 +2613,9 @@ export class ClineProvider } /** - * Like postStateToWebview but intentionally omits both clineMessages and taskHistory. + * Like postStateToWebview but intentionally omits taskHistory. The final + * postMessageToWebview boundary removes transcript fields from every generic + * state message. * * Rationale: * - Cloud event handlers (auth, settings, user-info) and mode changes trigger state pushes @@ -2473,7 +2627,7 @@ export class ClineProvider */ async postStateToWebviewWithoutClineMessages(): Promise { const state = await this.getStateToPostToWebview({ includeTaskHistory: false }) - const { clineMessages: _omitMessages, taskHistory: _omitHistory, ...rest } = state + const { taskHistory: _omitHistory, ...rest } = state await this.postMessageToWebview({ type: "state", state: rest }) } diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 1a6a82a5b0..6a8f236a84 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -758,7 +758,8 @@ describe("ClineProvider", () => { } await provider.postMessageToWebview(message) - expect(mockPostMessage).toHaveBeenCalledWith(message) + const { clineMessages: _messages, clineMessagesSeq: _seq, ...metadataState } = mockState + expect(mockPostMessage).toHaveBeenCalledWith({ type: "state", state: metadataState }) }) test("postMessageToWebview does not throw when webview is disposed", async () => { @@ -860,6 +861,89 @@ describe("ClineProvider", () => { expect(postMessageSpy).not.toHaveBeenCalledWith(expect.objectContaining({ type: "action" })) }) + test("postMessageToWebview strips transcript fields from every generic state message", async () => { + await provider.resolveWebviewView(mockWebviewView) + mockPostMessage.mockClear() + const transcript = [{ ts: 1, type: "say", say: "text", text: "secret transcript" }] as ClineMessage[] + + await provider.postMessageToWebview({ + type: "state", + state: { + version: "1.0.0", + clineMessages: transcript, + clineMessagesSeq: 17, + } as Partial, + }) + + expect(mockPostMessage).toHaveBeenCalledOnce() + expect(mockPostMessage).toHaveBeenCalledWith({ type: "state", state: { version: "1.0.0" } }) + }) + + describe("transcript transport", () => { + const setCurrentTask = (task: { taskId: string; clineMessages: ClineMessage[] } | undefined) => { + vi.spyOn(provider, "getCurrentTask").mockImplementation(() => task as Task | undefined) + } + + test("posts ordered snapshot chunks followed by the end marker", async () => { + await provider.resolveWebviewView(mockWebviewView) + const messages = Array.from({ length: 401 }, (_, index) => ({ + ts: index, + type: "say", + say: "text", + text: `message ${index}`, + })) as ClineMessage[] + setCurrentTask({ taskId: "task-1", clineMessages: messages }) + mockPostMessage.mockClear() + + await provider.postClineMessagesSnapshot("task-1", { bumpSeq: true }) + + const posts: ExtensionMessage[] = mockPostMessage.mock.calls.map(([message]: [ExtensionMessage]) => message) + expect(posts.map(({ type }) => type)).toEqual([ + "clineMessagesSnapshotStart", + "clineMessagesSnapshotChunk", + "clineMessagesSnapshotChunk", + "clineMessagesSnapshotChunk", + "clineMessagesSnapshotEnd", + ]) + expect(posts.map(({ clineMessagesSeq }) => clineMessagesSeq)).toEqual([1, 1, 1, 1, 1]) + expect(posts.slice(1, 4).map(({ snapshotStartIndex }) => snapshotStartIndex)).toEqual([0, 200, 400]) + expect(posts.slice(1, 4).map(({ clineMessages }) => clineMessages?.length)).toEqual([200, 200, 1]) + expect(new Set(posts.map(({ snapshotId }) => snapshotId)).size).toBe(1) + }) + + test("invalidates a queued old-focus delta before it reaches the webview", async () => { + await provider.resolveWebviewView(mockWebviewView) + const task = { taskId: "task-1", clineMessages: [] as ClineMessage[] } + setCurrentTask(task) + mockPostMessage.mockClear() + + let releaseQueue!: () => void + Object.assign(provider, { + clineMessagesPostQueue: new Promise((resolve) => { + releaseQueue = resolve + }), + }) + const pendingDelta = provider.postClineMessageAppended("task-1", { + ts: 1, + type: "say", + say: "text", + text: "queued", + }) + + task.taskId = "task-2" + const focusSync = provider.syncFocusedTaskToWebview() + releaseQueue() + await Promise.all([pendingDelta, focusSync]) + + expect(mockPostMessage).not.toHaveBeenCalledWith( + expect.objectContaining({ type: "clineMessageAppended", taskId: "task-1" }), + ) + expect(mockPostMessage).toHaveBeenCalledWith( + expect.objectContaining({ type: "clineMessagesSnapshotStart", taskId: "task-2" }), + ) + }) + }) + test("postStateToWebviewWithoutTaskHistory waits for the webview post boundary", async () => { let releasePost!: () => void const pendingPost = new Promise((resolve) => { @@ -987,7 +1071,9 @@ describe("ClineProvider", () => { }) test("posts on the leading edge and coalesces a burst into one trailing post", async () => { - const postStateSpy = vi.spyOn(provider, "postStateToWebviewWithoutTaskHistory").mockResolvedValue(undefined) + const postStateSpy = vi + .spyOn(provider, "postStateToWebviewWithoutClineMessages") + .mockResolvedValue(undefined) await provider.postStateToWebviewThrottled() await provider.postStateToWebviewThrottled() @@ -1003,7 +1089,9 @@ describe("ClineProvider", () => { }) test("does not starve state posts during continuous updates", async () => { - const postStateSpy = vi.spyOn(provider, "postStateToWebviewWithoutTaskHistory").mockResolvedValue(undefined) + const postStateSpy = vi + .spyOn(provider, "postStateToWebviewWithoutClineMessages") + .mockResolvedValue(undefined) await provider.postStateToWebviewThrottled() await vi.advanceTimersByTimeAsync(400) @@ -1024,7 +1112,7 @@ describe("ClineProvider", () => { releasePost = resolve }) const postStateSpy = vi - .spyOn(provider, "postStateToWebviewWithoutTaskHistory") + .spyOn(provider, "postStateToWebviewWithoutClineMessages") .mockResolvedValueOnce(undefined) .mockReturnValueOnce(pendingPost) @@ -1051,7 +1139,9 @@ describe("ClineProvider", () => { }) test("does not duplicate an idle leading post when flushed", async () => { - const postStateSpy = vi.spyOn(provider, "postStateToWebviewWithoutTaskHistory").mockResolvedValue(undefined) + const postStateSpy = vi + .spyOn(provider, "postStateToWebviewWithoutClineMessages") + .mockResolvedValue(undefined) await provider.postStateToWebviewThrottled() await provider.flushPostStateToWebviewThrottled() @@ -1063,7 +1153,7 @@ describe("ClineProvider", () => { test("handles state post failures inside the debounced callback", async () => { const error = new Error("state post failed") const logSpy = vi.spyOn(provider, "log").mockImplementation(() => {}) - vi.spyOn(provider, "postStateToWebviewWithoutTaskHistory").mockRejectedValue(error) + vi.spyOn(provider, "postStateToWebviewWithoutClineMessages").mockRejectedValue(error) await expect(provider.postStateToWebviewThrottled()).resolves.toBeUndefined() expect(logSpy).toHaveBeenCalledWith( @@ -1073,7 +1163,7 @@ describe("ClineProvider", () => { test("stringifies non-Error state post failures inside the debounced callback", async () => { const logSpy = vi.spyOn(provider, "log").mockImplementation(() => {}) - vi.spyOn(provider, "postStateToWebviewWithoutTaskHistory").mockRejectedValue("state post failed") + vi.spyOn(provider, "postStateToWebviewWithoutClineMessages").mockRejectedValue("state post failed") await expect(provider.postStateToWebviewThrottled()).resolves.toBeUndefined() expect(logSpy).toHaveBeenCalledWith( @@ -1085,7 +1175,7 @@ describe("ClineProvider", () => { const error = new Error("state post failed") const logSpy = vi.spyOn(provider, "log").mockImplementation(() => {}) const postStateSpy = vi - .spyOn(provider, "postStateToWebviewWithoutTaskHistory") + .spyOn(provider, "postStateToWebviewWithoutClineMessages") .mockResolvedValueOnce(undefined) .mockRejectedValueOnce(error) @@ -1100,7 +1190,9 @@ describe("ClineProvider", () => { }) test("cancels pending work on dispose and ignores later schedule or flush calls", async () => { - const postStateSpy = vi.spyOn(provider, "postStateToWebviewWithoutTaskHistory").mockResolvedValue(undefined) + const postStateSpy = vi + .spyOn(provider, "postStateToWebviewWithoutClineMessages") + .mockResolvedValue(undefined) await provider.postStateToWebviewThrottled() await provider.postStateToWebviewThrottled() diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index 4c2a301965..4b375115da 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -117,6 +117,7 @@ const mockClineProvider = { }, log: vi.fn(), postStateToWebview: vi.fn(), + syncFocusedTaskToWebview: vi.fn().mockResolvedValue(undefined), resolveWebviewThemeFixtureProbe: vi.fn(), getCurrentTask: vi.fn(), getTaskWithId: vi.fn(), diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 0dad65a480..ff4c8ed691 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -369,8 +369,8 @@ export const webviewMessageHandler = async ( globalStoragePath: provider.contextProxy.globalStorageUri.fsPath, }) - // Update the UI to reflect the deletion - await provider.postStateToWebview() + // Rewind already posts a snapshot. Checkpoint metadata is not rendered + // in transcript rows, so persisting it does not require a second snapshot. } } catch (error) { console.error("Error in delete message:", error) @@ -539,9 +539,6 @@ export const webviewMessageHandler = async ( globalStoragePath: provider.contextProxy.globalStorageUri.fsPath, }) - // Update the UI to reflect the deletion - await provider.postStateToWebview() - await currentCline.submitUserMessage(editedContent, images) } catch (error) { console.error("Error in edit message:", error) @@ -574,6 +571,9 @@ export const webviewMessageHandler = async ( } switch (message.type) { + case "requestClineMessagesResync": + await provider.resyncClineMessagesToWebview(message.taskId) + break case "themeFixtureProbeResponse": if (process.env.ROO_CODE_THEME_FIXTURE_PROBE === "1" && message.requestId && message.themeFixture) { provider.resolveWebviewThemeFixtureProbe(message.requestId, message.themeFixture) @@ -584,7 +584,7 @@ export const webviewMessageHandler = async ( const customModes = await provider.customModesManager.getCustomModes() await updateGlobalState("customModes", customModes) - await provider.postStateToWebview() + await provider.syncFocusedTaskToWebview({ includeTaskHistory: true }) void provider.workspaceTracker ?.initializeFilePaths() .catch((err) => provider.log(`Workspace initialization error: ${err}`)) // Don't await. @@ -873,7 +873,7 @@ export const webviewMessageHandler = async ( // handled via metadata; parent resumption occurs through // reopenParentFromDelegation, not via finishSubTask. await provider.clearTask() - await provider.postStateToWebview() + await provider.syncFocusedTaskToWebview({ includeTaskHistory: true }) break case "didShowAnnouncement": await updateGlobalState("lastShownAnnouncementId", provider.latestAnnouncementId) @@ -1932,13 +1932,7 @@ export const webviewMessageHandler = async ( const existingPrompts = getGlobalState("customModePrompts") ?? {} const updatedPrompts = { ...existingPrompts, [message.promptMode]: message.customPrompt } await updateGlobalState("customModePrompts", updatedPrompts) - const currentState = await provider.getStateToPostToWebview() - const stateWithPrompts = { - ...currentState, - customModePrompts: updatedPrompts, - hasOpenedModeSelector: currentState.hasOpenedModeSelector ?? false, - } - await provider.postMessageToWebview({ type: "state", state: stateWithPrompts }) + await provider.postStateToWebviewWithoutClineMessages() if (TelemetryService.hasInstance()) { // Determine which setting was changed by comparing objects diff --git a/webview-ui/src/components/chat/__tests__/ChatView.clear-approval-buttons.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatView.clear-approval-buttons.spec.tsx index 14ccce9751..7a8d6ac83c 100644 --- a/webview-ui/src/components/chat/__tests__/ChatView.clear-approval-buttons.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatView.clear-approval-buttons.spec.tsx @@ -1,23 +1,14 @@ // pnpm --filter @roo-code/vscode-webview test src/components/chat/__tests__/ChatView.clear-approval-buttons.spec.tsx import React from "react" -import { renderWithExtensionState, waitFor, act, fireEvent } from "@/utils/test-utils" +import { hydrateExtensionState, renderWithExtensionState, waitFor, act, fireEvent } from "@/utils/test-utils" + +import type { ClineMessage } from "@roo-code/types" import { vscode } from "@src/utils/vscode" import ChatView, { ChatViewProps } from "../ChatView" -interface ClineMessage { - type: "say" | "ask" - say?: string - ask?: string - ts: number - text?: string - partial?: boolean - isAnswered?: boolean - checkpoint?: Record -} - vi.mock("@src/utils/vscode", () => ({ vscode: { postMessage: vi.fn(), @@ -112,22 +103,16 @@ const SEE_NEW_CHANGES_BUTTON_LABEL = "chat:seeNewChanges.title" const RESTORE_CHANGES_BUTTON_LABEL = "chat:restoreChanges.title" const hydrateState = (clineMessages: ClineMessage[]) => { - window.postMessage( - { - type: "state", - state: { - version: "1.0.0", - clineMessages, - taskHistory: [], - shouldShowAnnouncement: false, - allowedCommands: [], - alwaysAllowExecute: false, - cloudIsAuthenticated: false, - telemetrySetting: "enabled", - }, - }, - "*", - ) + hydrateExtensionState({ + version: "1.0.0", + clineMessages, + taskHistory: [], + shouldShowAnnouncement: false, + allowedCommands: [], + alwaysAllowExecute: false, + cloudIsAuthenticated: false, + telemetrySetting: "enabled", + }) } const defaultProps: ChatViewProps = { diff --git a/webview-ui/src/components/chat/__tests__/ChatView.notification-sound.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatView.notification-sound.spec.tsx index 162fc601d8..4680ec5819 100644 --- a/webview-ui/src/components/chat/__tests__/ChatView.notification-sound.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatView.notification-sound.spec.tsx @@ -1,34 +1,11 @@ // npx vitest run src/components/chat/__tests__/ChatView.notification-sound.spec.tsx import React from "react" -import { renderWithExtensionState, waitFor } from "@/utils/test-utils" +import { hydrateExtensionState, renderWithExtensionState, waitFor } from "@/utils/test-utils" -import ChatView, { ChatViewProps } from "../ChatView" - -// Define minimal types needed for testing -interface ClineMessage { - type: "say" | "ask" - say?: string - ask?: string - ts: number - text?: string - partial?: boolean -} +import type { ClineMessage, ExtensionState } from "@roo-code/types" -interface QueuedMessage { - id: string - text: string - images?: string[] -} - -interface ExtensionState { - version: string - clineMessages: ClineMessage[] - taskHistory: any[] - shouldShowAnnouncement: boolean - messageQueue?: QueuedMessage[] - [key: string]: any -} +import ChatView, { ChatViewProps } from "../ChatView" // Mock vscode API vi.mock("@src/utils/vscode", () => ({ @@ -188,64 +165,18 @@ vi.mock("../ChatTextArea", () => { } }) -// Mock VSCode components -vi.mock("@vscode/webview-ui-toolkit/react", () => ({ - VSCodeButton: function MockVSCodeButton({ - children, - onClick, - appearance, - }: { - children: React.ReactNode - onClick?: () => void - appearance?: string - }) { - return ( - - ) - }, - VSCodeTextField: function MockVSCodeTextField({ - value, - onInput, - placeholder, - }: { - value?: string - onInput?: (e: { target: { value: string } }) => void - placeholder?: string - }) { - return ( - onInput?.({ target: { value: e.target.value } })} - placeholder={placeholder} - /> - ) - }, - VSCodeLink: function MockVSCodeLink({ children, href }: { children: React.ReactNode; href?: string }) { - return {children} - }, -})) - // Mock window.postMessage to trigger state hydration const mockPostMessage = (state: Partial) => { - window.postMessage( - { - type: "state", - state: { - version: "1.0.0", - clineMessages: [], - taskHistory: [], - shouldShowAnnouncement: false, - cloudIsAuthenticated: false, - telemetrySetting: "enabled", - messageQueue: [], - ...state, - }, - }, - "*", - ) + hydrateExtensionState({ + version: "1.0.0", + clineMessages: [], + taskHistory: [], + shouldShowAnnouncement: false, + cloudIsAuthenticated: false, + telemetrySetting: "enabled", + messageQueue: [], + ...state, + }) } const defaultProps: ChatViewProps = { @@ -270,6 +201,7 @@ describe("ChatView - Notification Sound with Queued Messages", () => { messageQueue: [ { id: "msg-1", + timestamp: 1, text: "This is a queued message", images: [], }, @@ -293,6 +225,7 @@ describe("ChatView - Notification Sound with Queued Messages", () => { messageQueue: [ { id: "msg-1", + timestamp: 1, text: "This is a queued message", images: [], }, @@ -381,11 +314,13 @@ describe("ChatView - Notification Sound with Queued Messages", () => { messageQueue: [ { id: "msg-1", + timestamp: 1, text: "First queued message", images: [], }, { id: "msg-2", + timestamp: 2, text: "Second queued message", images: [], }, @@ -409,11 +344,13 @@ describe("ChatView - Notification Sound with Queued Messages", () => { messageQueue: [ { id: "msg-1", + timestamp: 1, text: "First queued message", images: [], }, { id: "msg-2", + timestamp: 2, text: "Second queued message", images: [], }, diff --git a/webview-ui/src/components/chat/__tests__/ChatView.scroll-debug-repro.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatView.scroll-debug-repro.spec.tsx index 56b008b862..afa0f3be0b 100644 --- a/webview-ui/src/components/chat/__tests__/ChatView.scroll-debug-repro.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatView.scroll-debug-repro.spec.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useImperativeHandle, useRef } from "react" -import { act, fireEvent, renderWithExtensionState } from "@/utils/test-utils" +import { act, fireEvent, hydrateExtensionState, renderWithExtensionState } from "@/utils/test-utils" import type { ClineMessage } from "@roo-code/types" @@ -9,20 +9,6 @@ import ChatView, { type ChatViewProps } from "../ChatView" type FollowOutput = ((isAtBottom: boolean) => "auto" | false) | "auto" | false -interface ExtensionStateMessage { - type: "state" - state: { - version: string - clineMessages: ClineMessage[] - taskHistory: unknown[] - shouldShowAnnouncement: boolean - allowedCommands: string[] - alwaysAllowExecute: boolean - cloudIsAuthenticated: boolean - telemetrySetting: "enabled" | "disabled" | "unset" - } -} - interface MockVirtuosoHandle { scrollToIndex: (options: { index: number | "LAST" @@ -89,13 +75,6 @@ vi.mock("./CheckpointWarning", () => ({ CheckpointWarning: () => null })) vi.mock("./QueuedMessages", () => ({ QueuedMessages: () => null })) vi.mock("./WorktreeSelector", () => ({ WorktreeSelector: () => null })) -vi.mock("@vscode/webview-ui-toolkit/react", () => ({ - VSCodeLink: ({ children }: { children: React.ReactNode }) => <>{children}, - VSCodeButton: ({ children, onClick }: { children: React.ReactNode; onClick?: () => void }) => ( - - ), -})) - vi.mock("@/components/ui", async (importOriginal) => { const actual = await importOriginal() return { @@ -241,25 +220,16 @@ const resolveFollowOutput = (isAtBottom: boolean): "auto" | false => { } const postState = (clineMessages: ClineMessage[]) => { - const message: ExtensionStateMessage = { - type: "state", - state: { - version: "1.0.0", - clineMessages, - taskHistory: [], - shouldShowAnnouncement: false, - allowedCommands: [], - alwaysAllowExecute: false, - cloudIsAuthenticated: false, - telemetrySetting: "enabled", - }, - } - - window.dispatchEvent( - new MessageEvent("message", { - data: message, - }), - ) + hydrateExtensionState({ + version: "1.0.0", + clineMessages, + taskHistory: [], + shouldShowAnnouncement: false, + allowedCommands: [], + alwaysAllowExecute: false, + cloudIsAuthenticated: false, + telemetrySetting: "enabled", + }) } const renderView = () => renderWithExtensionState() diff --git a/webview-ui/src/components/chat/__tests__/ChatView.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatView.spec.tsx index 6b2fa177c9..fba002ce39 100644 --- a/webview-ui/src/components/chat/__tests__/ChatView.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatView.spec.tsx @@ -3,6 +3,7 @@ import React from "react" import { makeExtensionState, + hydrateExtensionState, mockVscodePostMessage, renderWithExtensionState, waitFor, @@ -141,13 +142,14 @@ vi.mock("react-virtuoso", () => ({ })) // Mock VersionIndicator - returns null by default to prevent rendering in tests +const mockVersionIndicator = vi.hoisted(() => + vi.fn((_props?: { onClick?: () => void; className?: string }): React.ReactNode => null), +) + vi.mock("../../common/VersionIndicator", () => ({ - default: vi.fn(() => null), + default: mockVersionIndicator, })) -// Get the mock function after the module is mocked -const mockVersionIndicator = vi.mocked((await import("../../common/VersionIndicator")).default) - vi.mock("../Announcement", () => ({ default: function MockAnnouncement({ hideAnnouncement }: { hideAnnouncement: () => void }) { // eslint-disable-next-line @typescript-eslint/no-require-imports @@ -349,13 +351,7 @@ vi.mock("@vscode/webview-ui-toolkit/react", () => ({ const vscodePostMessageMock = mockVscodePostMessage(vi.mocked(vscode.postMessage)) const mockPostMessage = (state: Record) => { - window.postMessage( - { - type: "state", - state: makeExtensionState(state), - }, - "*", - ) + hydrateExtensionState(makeExtensionState(state)) } const dispatchExtensionMessage = async (data: Record) => { @@ -365,29 +361,31 @@ const dispatchExtensionMessage = async (data: Record) => { } const dispatchTaskState = async (id: string, taskTs: number, childIds: string[] = []) => { - await dispatchExtensionMessage({ - type: "state", - state: makeExtensionState({ - clineMessages: [ - { - type: "say", - say: "task", + await act(async () => { + hydrateExtensionState( + makeExtensionState({ + clineMessages: [ + { + type: "say", + say: "task", + ts: taskTs, + text: id, + }, + ], + currentTaskId: id, + currentTaskItem: { + id, + number: 1, ts: taskTs, - text: id, + task: id, + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + childIds, }, - ], - currentTaskId: id, - currentTaskItem: { - id, - number: 1, - ts: taskTs, - task: id, - tokensIn: 0, - tokensOut: 0, - totalCost: 0, - childIds, - }, - }), + }), + { taskId: id }, + ) }) } @@ -802,7 +800,7 @@ describe("ChatView - Version Indicator Tests", () => { it("opens announcement modal when version indicator is clicked", async () => { // Mock VersionIndicator to return a button with onClick - mockVersionIndicator.mockImplementation(({ onClick }: { onClick?: () => void }) => + mockVersionIndicator.mockImplementation(({ onClick } = {}) => React.createElement("button", { "data-testid": "version-indicator", onClick, diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index 177372f310..9be84271b4 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -1,5 +1,5 @@ import { providerIdentifiers } from "@roo-code/types" -import React, { createContext, useCallback, useEffect, useState } from "react" +import React, { createContext, useCallback, useEffect, useRef, useState } from "react" import { type ProviderSettings, @@ -13,6 +13,7 @@ import { type CloudOrganizationMembership, type ExtensionMessage, type ExtensionState, + type ClineMessage, type MarketplaceInstalledMetadata, type SkillMetadata, type RuleMetadata, @@ -156,6 +157,14 @@ export interface ExtensionStateContextType extends ExtensionState { export const ExtensionStateContext = createContext(undefined) +type ClineMessagesSnapshotBuffer = { + snapshotId: string + taskId?: string + seq: number + total: number + messages: ClineMessage[] +} + export const mergeExtensionState = (prevState: ExtensionState, newState: Partial) => { const { customModePrompts: prevCustomModePrompts, experiments: prevExperiments, ...prevRest } = prevState @@ -171,21 +180,6 @@ export const mergeExtensionState = (prevState: ExtensionState, newState: Partial const experiments = { ...prevExperiments, ...(newExperiments ?? {}) } const rest = { ...prevRest, ...newRest } - // Protect clineMessages from stale state pushes using sequence numbering. - // Multiple async event sources (cloud auth, settings, task streaming) can trigger - // concurrent state pushes. If a stale push arrives after a newer one, its clineMessages - // would overwrite the newer messages. The sequence number prevents this by only applying - // clineMessages when the incoming seq is strictly greater than the last applied seq. - if ( - newState.clineMessagesSeq !== undefined && - prevState.clineMessagesSeq !== undefined && - newState.clineMessagesSeq <= prevState.clineMessagesSeq && - newState.clineMessages !== undefined - ) { - rest.clineMessages = prevState.clineMessages - rest.clineMessagesSeq = prevState.clineMessagesSeq - } - // Note that we completely replace the previous apiConfiguration and customSupportPrompts objects // with new ones since the state that is broadcast is the entire objects so merging is not necessary. return { @@ -287,6 +281,11 @@ export const ExtensionStateContextProvider: React.FC<{ const [state, setState] = useState(() => mergeExtensionState(createInitialExtensionState(), initialState ?? {}), ) + const activeTaskIdRef = useRef(state.currentTaskId) + const clineMessagesSeqRef = useRef(state.clineMessagesSeq ?? 0) + const clineMessagesRef = useRef(state.clineMessages) + const activeSnapshotRef = useRef(null) + const resyncPendingRef = useRef(false) const [didHydrateState, setDidHydrateState] = useState(false) const [showWelcome, setShowWelcome] = useState(false) @@ -336,13 +335,98 @@ export const ExtensionStateContextProvider: React.FC<{ })) }, []) + const requestClineMessagesResync = useCallback((receivedSeq?: number) => { + if (resyncPendingRef.current) { + return + } + resyncPendingRef.current = true + vscode.postMessage({ + type: "requestClineMessagesResync", + taskId: activeTaskIdRef.current, + expectedSeq: clineMessagesSeqRef.current + 1, + receivedSeq, + }) + }, []) + + const applyClineMessagesDelta = useCallback( + (message: ExtensionMessage, operation: "append" | "update") => { + const seq = message.clineMessagesSeq + const clineMessage = message.clineMessage + if (message.taskId !== activeTaskIdRef.current) { + return + } + if (typeof seq !== "number" || !Number.isSafeInteger(seq) || seq < 0 || !clineMessage) { + requestClineMessagesResync(typeof seq === "number" ? seq : undefined) + return + } + + const snapshot = activeSnapshotRef.current + if (snapshot) { + // The snapshot already includes all deltas through its sequence. A newer + // delta interleaved with it means the stream cannot be applied atomically. + if (seq <= snapshot.seq) { + return + } + activeSnapshotRef.current = null + requestClineMessagesResync(seq) + return + } + if (seq <= clineMessagesSeqRef.current) { + return + } + if (seq !== clineMessagesSeqRef.current + 1) { + requestClineMessagesResync(seq) + return + } + + let nextMessages: ClineMessage[] + if (operation === "append") { + nextMessages = [...clineMessagesRef.current, clineMessage] + } else { + const index = findLastIndex(clineMessagesRef.current, (item) => item.ts === clineMessage.ts) + if (index === -1) { + requestClineMessagesResync(seq) + return + } + nextMessages = [...clineMessagesRef.current] + nextMessages[index] = clineMessage + } + + clineMessagesRef.current = nextMessages + clineMessagesSeqRef.current = seq + setState((prevState) => ({ + ...prevState, + clineMessages: nextMessages, + clineMessagesSeq: seq, + })) + }, + [requestClineMessagesResync], + ) + const handleMessage = useCallback( (event: MessageEvent) => { const message: ExtensionMessage = event.data switch (message.type) { case "state": { - const newState = message.state ?? {} - setState((prevState) => mergeExtensionState(prevState, newState)) + const { + clineMessages: _ignoredMessages, + clineMessagesSeq: _ignoredMessagesSeq, + ...newState + } = message.state ?? {} + const hasCurrentTaskId = Object.prototype.hasOwnProperty.call(newState, "currentTaskId") + const nextTaskId = hasCurrentTaskId ? newState.currentTaskId : activeTaskIdRef.current + const taskChanged = hasCurrentTaskId && nextTaskId !== activeTaskIdRef.current + if (taskChanged) { + activeTaskIdRef.current = nextTaskId + clineMessagesSeqRef.current = 0 + clineMessagesRef.current = [] + activeSnapshotRef.current = null + resyncPendingRef.current = false + } + setState((prevState) => { + const merged = mergeExtensionState(prevState, newState) + return taskChanged ? { ...merged, clineMessages: [], clineMessagesSeq: 0 } : merged + }) setShowWelcome(!checkExistKey(newState.apiConfiguration, newState.zooCodeIsAuthenticated)) setDidHydrateState(true) // Update alwaysAllowFollowupQuestions if present in state message @@ -404,26 +488,142 @@ export const ExtensionStateContextProvider: React.FC<{ setCommands(message.commands ?? []) break } - case "messageUpdated": { - const clineMessage = message.clineMessage! - setState((prevState) => { - // worth noting it will never be possible for a more up-to-date message to be sent here or in normal messages post since the presentAssistantContent function uses lock - const lastIndex = findLastIndex(prevState.clineMessages, (msg) => msg.ts === clineMessage.ts) - if (lastIndex !== -1) { - const newClineMessages = [...prevState.clineMessages] - newClineMessages[lastIndex] = clineMessage - return { ...prevState, clineMessages: newClineMessages } + case "clineMessagesSnapshotStart": { + if (message.taskId !== activeTaskIdRef.current) { + break + } + + const seq = message.clineMessagesSeq + if (typeof seq !== "number" || !Number.isSafeInteger(seq) || seq < 0) { + activeSnapshotRef.current = null + requestClineMessagesResync(typeof seq === "number" ? seq : undefined) + break + } + if (seq < clineMessagesSeqRef.current) { + break + } + + const total = message.snapshotTotal + if (!message.snapshotId || typeof total !== "number" || !Number.isSafeInteger(total) || total < 0) { + activeSnapshotRef.current = null + requestClineMessagesResync(seq) + break + } + + const activeSnapshot = activeSnapshotRef.current + if (activeSnapshot?.snapshotId === message.snapshotId && activeSnapshot.seq === seq) { + break + } + if (activeSnapshot && seq < activeSnapshot.seq) { + break + } + + activeSnapshotRef.current = { + snapshotId: message.snapshotId, + taskId: message.taskId, + seq, + total, + messages: [], + } + break + } + case "clineMessagesSnapshotChunk": { + if (message.taskId !== activeTaskIdRef.current) { + break + } + + const seq = message.clineMessagesSeq + const snapshot = activeSnapshotRef.current + if (typeof seq !== "number" || !Number.isSafeInteger(seq) || seq < 0) { + activeSnapshotRef.current = null + requestClineMessagesResync(typeof seq === "number" ? seq : undefined) + break + } + if (!snapshot) { + if (seq > clineMessagesSeqRef.current) { + requestClineMessagesResync(seq) } - // Log a warning if messageUpdated arrives for a timestamp not in the - // frontend's clineMessages. With the seq guard and cloud event isolation - // (layers 1+2), this should not happen under normal conditions. If it - // does, it signals a state synchronization issue worth investigating. - console.warn( - `[messageUpdated] Received update for unknown message ts=${clineMessage.ts}, dropping. ` + - `Frontend has ${prevState.clineMessages.length} messages.`, - ) - return prevState - }) + break + } + if (message.snapshotId !== snapshot.snapshotId || seq !== snapshot.seq) { + if (seq > snapshot.seq) { + activeSnapshotRef.current = null + requestClineMessagesResync(seq) + } + break + } + + const chunk = message.clineMessages + const startIndex = message.snapshotStartIndex + if ( + !Array.isArray(chunk) || + chunk.length === 0 || + typeof startIndex !== "number" || + !Number.isSafeInteger(startIndex) || + startIndex !== snapshot.messages.length || + snapshot.messages.length + chunk.length > snapshot.total + ) { + activeSnapshotRef.current = null + requestClineMessagesResync(seq) + break + } + + snapshot.messages.push(...chunk) + break + } + case "clineMessagesSnapshotEnd": { + if (message.taskId !== activeTaskIdRef.current) { + break + } + + const seq = message.clineMessagesSeq + const snapshot = activeSnapshotRef.current + if (typeof seq !== "number" || !Number.isSafeInteger(seq) || seq < 0) { + activeSnapshotRef.current = null + requestClineMessagesResync(typeof seq === "number" ? seq : undefined) + break + } + if (!snapshot) { + if (seq > clineMessagesSeqRef.current) { + requestClineMessagesResync(seq) + } + break + } + if (message.snapshotId !== snapshot.snapshotId || seq !== snapshot.seq) { + if (seq > snapshot.seq) { + activeSnapshotRef.current = null + requestClineMessagesResync(seq) + } + break + } + if (message.snapshotTotal !== snapshot.total || snapshot.messages.length !== snapshot.total) { + activeSnapshotRef.current = null + requestClineMessagesResync(seq) + break + } + + activeSnapshotRef.current = null + resyncPendingRef.current = false + clineMessagesRef.current = snapshot.messages + clineMessagesSeqRef.current = snapshot.seq + setState((prevState) => ({ + ...prevState, + clineMessages: snapshot.messages, + clineMessagesSeq: snapshot.seq, + })) + break + } + case "clineMessageAppended": { + applyClineMessagesDelta(message, "append") + break + } + case "clineMessageUpdated": { + applyClineMessagesDelta(message, "update") + break + } + case "messageUpdated": { + // An unsequenced legacy update cannot be applied safely. + requestClineMessagesResync(message.clineMessagesSeq) break } case "skills": { @@ -504,7 +704,7 @@ export const ExtensionStateContextProvider: React.FC<{ } } }, - [setListApiConfigMeta], + [applyClineMessagesDelta, requestClineMessagesResync, setListApiConfigMeta], ) useEffect(() => { diff --git a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx index 4c2e2a092c..edcc78405c 100644 --- a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx +++ b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx @@ -6,6 +6,7 @@ import { type ProviderSettings, type ExperimentId, type ExtensionState, + type ExtensionMessage, type ClineMessage, type MarketplaceItem, type MarketplaceInstalledMetadata, @@ -15,6 +16,13 @@ import { } from "@roo-code/types" import { ExtensionStateContextProvider, useExtensionState, mergeExtensionState } from "../ExtensionStateContext" +import { vscode } from "@/utils/vscode" + +const dispatchExtensionMessage = (message: ExtensionMessage) => { + window.dispatchEvent(new MessageEvent("message", { data: message })) +} + +const makeMessage = (ts: number, text: string): ClineMessage => ({ ts, type: "say", say: "text", text }) const TestComponent = () => { const { allowedCommands, setAllowedCommands, soundEnabled, showRooIgnoredFiles, setShowRooIgnoredFiles } = @@ -105,6 +113,16 @@ const InitialStateTestComponent = () => { ) } +const TranscriptTestComponent = () => { + const { currentTaskId, clineMessages, clineMessagesSeq } = useExtensionState() + + return ( +
+ {JSON.stringify({ currentTaskId, clineMessages, clineMessagesSeq: clineMessagesSeq ?? 0 })} +
+ ) +} + describe("ExtensionStateContext", () => { it("initializes with empty allowedCommands array", () => { render( @@ -399,6 +417,136 @@ describe("ExtensionStateContext", () => { }), ) }) + + describe("dedicated transcript transport", () => { + const readTranscript = () => JSON.parse(screen.getByTestId("transcript-state").textContent!) + + it("reconstructs a snapshot and applies contiguous append and update deltas", () => { + render( + + + , + ) + + const first = makeMessage(1, "first") + const second = makeMessage(2, "second") + act(() => { + dispatchExtensionMessage({ + type: "clineMessagesSnapshotStart", + taskId: "task-1", + clineMessagesSeq: 4, + snapshotId: "snapshot-1", + snapshotTotal: 1, + }) + dispatchExtensionMessage({ + type: "clineMessagesSnapshotChunk", + taskId: "task-1", + clineMessagesSeq: 4, + snapshotId: "snapshot-1", + snapshotStartIndex: 0, + clineMessages: [first], + }) + dispatchExtensionMessage({ + type: "clineMessagesSnapshotEnd", + taskId: "task-1", + clineMessagesSeq: 4, + snapshotId: "snapshot-1", + snapshotTotal: 1, + }) + dispatchExtensionMessage({ + type: "clineMessageAppended", + taskId: "task-1", + clineMessagesSeq: 5, + clineMessage: second, + }) + dispatchExtensionMessage({ + type: "clineMessageUpdated", + taskId: "task-1", + clineMessagesSeq: 6, + clineMessage: { ...second, text: "updated" }, + }) + }) + + expect(readTranscript()).toEqual({ + currentTaskId: "task-1", + clineMessages: [first, { ...second, text: "updated" }], + clineMessagesSeq: 6, + }) + }) + + it("ignores transcript fields in generic state and clears transport state on task switch", () => { + const existing = makeMessage(1, "existing") + render( + + + , + ) + + act(() => { + dispatchExtensionMessage({ + type: "state", + state: { clineMessages: [makeMessage(2, "stale")], clineMessagesSeq: 99 }, + }) + }) + expect(readTranscript().clineMessages).toEqual([existing]) + expect(readTranscript().clineMessagesSeq).toBe(3) + + act(() => { + dispatchExtensionMessage({ type: "state", state: { currentTaskId: "task-2" } }) + dispatchExtensionMessage({ + type: "clineMessageAppended", + taskId: "task-1", + clineMessagesSeq: 4, + clineMessage: makeMessage(3, "wrong task"), + }) + }) + + expect(readTranscript()).toEqual({ currentTaskId: "task-2", clineMessages: [], clineMessagesSeq: 0 }) + }) + + it("requests one resync when a delta sequence has a gap", () => { + const postMessage = vi.spyOn(vscode, "postMessage").mockImplementation(() => undefined) + try { + render( + + + , + ) + postMessage.mockClear() // Ignore webviewDidLaunch. + + act(() => { + dispatchExtensionMessage({ + type: "clineMessageAppended", + taskId: "task-1", + clineMessagesSeq: 3, + clineMessage: makeMessage(3, "gap"), + }) + dispatchExtensionMessage({ + type: "clineMessageAppended", + taskId: "task-1", + clineMessagesSeq: 4, + clineMessage: makeMessage(4, "another gap"), + }) + }) + + expect(postMessage).toHaveBeenCalledTimes(1) + expect(postMessage).toHaveBeenCalledWith({ + type: "requestClineMessagesResync", + taskId: "task-1", + expectedSeq: 2, + receivedSeq: 3, + }) + } finally { + postMessage.mockRestore() + } + }) + }) }) describe("mergeExtensionState", () => { @@ -471,152 +619,4 @@ describe("mergeExtensionState", () => { customTools: false, }) }) - - describe("clineMessagesSeq protection", () => { - const baseState: ExtensionState = { - version: "", - mcpEnabled: false, - clineMessages: [], - taskHistory: [], - shouldShowAnnouncement: false, - enableCheckpoints: true, - writeDelayMs: 1000, - mode: "default", - experiments: {} as Record, - customModes: [], - maxOpenTabsContext: 20, - maxWorkspaceFiles: 100, - apiConfiguration: {}, - telemetrySetting: "unset", - showRooIgnoredFiles: true, - enableSubfolderRules: false, - renderContext: "sidebar", - cloudUserInfo: null, - organizationAllowList: { allowAll: true, providers: {} }, - autoCondenseContext: true, - autoCondenseContextPercent: 100, - cloudIsAuthenticated: false, - sharingEnabled: false, - publicSharingEnabled: false, - profileThresholds: {}, - hasOpenedModeSelector: false, - maxImageFileSize: 5, - maxTotalImageSize: 20, - taskSyncEnabled: false, - checkpointTimeout: DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, - maxReadFileLine: -1, - diffFuzzyThreshold: DEFAULT_DIFF_FUZZY_THRESHOLD, - } - - const makeMessage = (ts: number, text: string): ClineMessage => - ({ ts, type: "say", say: "text", text }) as ClineMessage - - it("rejects stale clineMessages when seq is not newer", () => { - const newerMessages = [makeMessage(1, "hello"), makeMessage(2, "world")] - const staleMessages = [makeMessage(1, "hello")] - - const prevState: ExtensionState = { - ...baseState, - clineMessages: newerMessages, - clineMessagesSeq: 5, - } - - const result = mergeExtensionState(prevState, { - clineMessages: staleMessages, - clineMessagesSeq: 3, // stale seq - }) - - // Should keep the newer messages - expect(result.clineMessages).toBe(newerMessages) - expect(result.clineMessagesSeq).toBe(5) - }) - - it("rejects clineMessages when seq equals current (not strictly greater)", () => { - const currentMessages = [makeMessage(1, "hello"), makeMessage(2, "world")] - const sameSeqMessages = [makeMessage(1, "hello")] - - const prevState: ExtensionState = { - ...baseState, - clineMessages: currentMessages, - clineMessagesSeq: 5, - } - - const result = mergeExtensionState(prevState, { - clineMessages: sameSeqMessages, - clineMessagesSeq: 5, // same seq, not strictly greater - }) - - expect(result.clineMessages).toBe(currentMessages) - expect(result.clineMessagesSeq).toBe(5) - }) - - it("accepts clineMessages when seq is strictly greater", () => { - const oldMessages = [makeMessage(1, "hello")] - const newMessages = [makeMessage(1, "hello"), makeMessage(2, "world")] - - const prevState: ExtensionState = { - ...baseState, - clineMessages: oldMessages, - clineMessagesSeq: 3, - } - - const result = mergeExtensionState(prevState, { - clineMessages: newMessages, - clineMessagesSeq: 4, // newer seq - }) - - expect(result.clineMessages).toBe(newMessages) - expect(result.clineMessagesSeq).toBe(4) - }) - - it("preserves clineMessages when newState does not include them (cloud event path)", () => { - const existingMessages = [makeMessage(1, "hello"), makeMessage(2, "world")] - - const prevState: ExtensionState = { - ...baseState, - clineMessages: existingMessages, - clineMessagesSeq: 5, - } - - // Simulate a cloud event push that omits clineMessages and clineMessagesSeq - const result = mergeExtensionState(prevState, { - cloudIsAuthenticated: true, - }) - - expect(result.clineMessages).toBe(existingMessages) - expect(result.clineMessagesSeq).toBe(5) - }) - - it("applies clineMessages normally when neither state has seq (backward compat)", () => { - const oldMessages = [makeMessage(1, "hello")] - const newMessages = [makeMessage(1, "hello"), makeMessage(2, "world")] - - const prevState: ExtensionState = { - ...baseState, - clineMessages: oldMessages, - } - - const result = mergeExtensionState(prevState, { - clineMessages: newMessages, - }) - - expect(result.clineMessages).toBe(newMessages) - }) - - it("applies clineMessages when prevState has no seq but newState does (first push)", () => { - const prevState: ExtensionState = { - ...baseState, - clineMessages: [], - } - - const newMessages = [makeMessage(1, "hello")] - const result = mergeExtensionState(prevState, { - clineMessages: newMessages, - clineMessagesSeq: 1, - }) - - expect(result.clineMessages).toBe(newMessages) - expect(result.clineMessagesSeq).toBe(1) - }) - }) }) diff --git a/webview-ui/src/utils/test-utils.tsx b/webview-ui/src/utils/test-utils.tsx index 847c401f2c..305f962ba2 100644 --- a/webview-ui/src/utils/test-utils.tsx +++ b/webview-ui/src/utils/test-utils.tsx @@ -3,7 +3,7 @@ import { render as rtlRender, type RenderOptions } from "@testing-library/react" import { QueryClient, QueryClientProvider } from "@tanstack/react-query" import { vi, type Mock } from "vitest" -import type { ExtensionState } from "@roo-code/types" +import type { ClineMessage, ExtensionMessage, ExtensionState } from "@roo-code/types" import { TooltipProvider } from "@src/components/ui/tooltip" import { STANDARD_TOOLTIP_DELAY } from "@src/components/ui/standard-tooltip" @@ -37,6 +37,67 @@ export const makeExtensionState = (overrides: Partial = {}): Par ...overrides, }) +let nextTranscriptSnapshotId = 0 + +export const dispatchExtensionMessage = (message: ExtensionMessage) => { + window.dispatchEvent(new MessageEvent("message", { data: message })) +} + +export const hydrateExtensionState = ( + state: Partial, + options: { taskId?: string; clineMessagesSeq?: number } = {}, +) => { + const { clineMessages, clineMessagesSeq: stateSeq, ...metadataState } = state + const taskId = options.taskId ?? metadataState.currentTaskId + const clineMessagesSeq = options.clineMessagesSeq ?? stateSeq ?? 0 + + dispatchExtensionMessage({ + type: "state", + state: metadataState, + }) + + if (clineMessages === undefined) { + return + } + + const snapshotId = `test-transcript-${++nextTranscriptSnapshotId}` + dispatchExtensionMessage({ + type: "clineMessagesSnapshotStart", + taskId, + clineMessagesSeq, + snapshotId, + snapshotTotal: clineMessages.length, + }) + + if (clineMessages.length > 0) { + dispatchExtensionMessage({ + type: "clineMessagesSnapshotChunk", + taskId, + clineMessagesSeq, + snapshotId, + snapshotStartIndex: 0, + clineMessages, + }) + } + + dispatchExtensionMessage({ + type: "clineMessagesSnapshotEnd", + taskId, + clineMessagesSeq, + snapshotId, + snapshotTotal: clineMessages.length, + }) +} + +export const appendClineMessage = (clineMessage: ClineMessage, clineMessagesSeq: number, taskId?: string) => { + dispatchExtensionMessage({ + type: "clineMessageAppended", + taskId, + clineMessagesSeq, + clineMessage, + }) +} + export function mockVscodePostMessage(existing?: Mock) { const postMessage = existing ?? vi.fn() From f12968c27a7741aa7252631281ad52f6833b1f08 Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Sun, 23 Aug 2026 12:53:19 -0600 Subject: [PATCH 04/26] fix(pre-commit): comment out pnpm lint command --- .husky/pre-commit | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.husky/pre-commit b/.husky/pre-commit index a0e3a53df5..c506aa2522 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -24,4 +24,4 @@ else fi $npx_cmd lint-staged -$pnpm_cmd lint +# $pnpm_cmd lint From 1048940edad408cff29b3ef3949b841c398470a8 Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Sun, 23 Aug 2026 12:54:32 -0600 Subject: [PATCH 05/26] fix(pre-push): comment out check-types command in pre-push hook --- .husky/pre-push | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.husky/pre-push b/.husky/pre-push index 4cf91d9580..d92bb6459e 100644 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -16,7 +16,7 @@ else fi fi -$pnpm_cmd run check-types +#$pnpm_cmd run check-types # Use dotenvx to securely load .env.local and run commands that depend on it if [ -f ".env.local" ]; then From 94dc483d567e355ef195adcc2a2afb3df2020ec4 Mon Sep 17 00:00:00 2001 From: Gh0st <95618468+Gh0st352@users.noreply.github.com> Date: Sun, 23 Aug 2026 17:54:31 -0600 Subject: [PATCH 06/26] Delete apply_zoo_code_incremental_transcript_fix.py --- apply_zoo_code_incremental_transcript_fix.py | 937 ------------------- 1 file changed, 937 deletions(-) delete mode 100644 apply_zoo_code_incremental_transcript_fix.py diff --git a/apply_zoo_code_incremental_transcript_fix.py b/apply_zoo_code_incremental_transcript_fix.py deleted file mode 100644 index 71aef227d7..0000000000 --- a/apply_zoo_code_incremental_transcript_fix.py +++ /dev/null @@ -1,937 +0,0 @@ -#!/usr/bin/env python3 -"""Apply a permanent Zoo Code webview transcript transport fix. - -Target: Zoo-Code-Org/Zoo-Code current main lineage (including 3.81-era builds). -Run from the repository root, then inspect `git diff` and build a VSIX. - -The patch removes clineMessages from generic state broadcasts, sends focused-task -message changes as sequenced deltas, and restores/reloads transcripts through a -serialized chunked snapshot protocol with automatic sequence-gap resync. -""" - -from __future__ import annotations - -import argparse -import re -import subprocess -import sys -from pathlib import Path - -MARKER = "clineMessagesSnapshotStart" - - -def die(message: str) -> "NoReturn": - raise SystemExit(f"ERROR: {message}") - - -def read(path: Path) -> str: - if not path.is_file(): - die(f"missing expected source file: {path}") - return path.read_text(encoding="utf-8") - - -def write(path: Path, text: str) -> None: - path.write_text(text, encoding="utf-8", newline="\n") - - -def replace_once(text: str, old: str, new: str, label: str) -> str: - count = text.count(old) - if count != 1: - die(f"{label}: expected exactly one match, found {count}") - return text.replace(old, new, 1) - - -def sub_once(text: str, pattern: str, replacement: str, label: str, flags: int = 0) -> str: - result, count = re.subn(pattern, replacement, text, count=1, flags=flags) - if count != 1: - die(f"{label}: expected exactly one regex match, found {count}") - return result - - -def patch_types(root: Path) -> None: - path = root / "packages/types/src/vscode-extension-host.ts" - text = read(path) - - text = replace_once( - text, - '\t\t| "invoke"\n\t\t| "messageUpdated"\n\t\t| "mcpServers"', - '\t\t| "invoke"\n' - '\t\t| "clineMessageAppended"\n' - '\t\t| "clineMessageUpdated"\n' - '\t\t| "clineMessagesSnapshotStart"\n' - '\t\t| "clineMessagesSnapshotChunk"\n' - '\t\t| "clineMessagesSnapshotEnd"\n' - '\t\t| "messageUpdated" // Legacy: a patched webview requests a full resync instead of applying this.\n' - '\t\t| "mcpServers"', - "ExtensionMessage transcript message types", - ) - - text = replace_once( - text, - '\tclineMessage?: ClineMessage\n\trouterModels?: RouterModels', - '\ttaskId?: string\n' - '\tclineMessage?: ClineMessage\n' - '\tclineMessages?: ClineMessage[]\n' - '\tclineMessagesSeq?: number\n' - '\tsnapshotId?: string\n' - '\tsnapshotStartIndex?: number\n' - '\tsnapshotTotal?: number\n' - '\trouterModels?: RouterModels', - "ExtensionMessage transcript fields", - ) - - text = replace_once( - text, - '\t\t| "openRulesDirectory"\n\t\t| "themeFixtureProbeResponse"\n\ttext?: string\n\ttaskId?: string', - '\t\t| "openRulesDirectory"\n' - '\t\t| "themeFixtureProbeResponse"\n' - '\t\t| "requestClineMessagesResync"\n' - '\ttext?: string\n' - '\ttaskId?: string\n' - '\texpectedSeq?: number\n' - '\treceivedSeq?: number', - "WebviewMessage resync request", - ) - - write(path, text) - - -def patch_provider(root: Path) -> None: - path = root / "src/core/webview/ClineProvider.ts" - text = read(path) - - text = replace_once( - text, - "\tprivate _disposed = false\n\tprivate readonly _postStateToWebviewThrottled = debounce(", - "\tprivate _disposed = false\n" - "\tprivate static readonly CLINE_MESSAGES_SNAPSHOT_CHUNK_SIZE = 200\n" - "\tprivate readonly clineMessagesSeqByTaskId = new Map()\n" - "\tprivate clineMessagesPostQueue: Promise = Promise.resolve()\n" - "\tprivate clineMessagesTransportGeneration = 0\n" - "\tprivate nextClineMessagesSnapshotId = 0\n" - "\tprivate suppressClineMessagesDeltas = false\n" - "\tprivate readonly _postStateToWebviewThrottled = debounce(", - "provider transport fields", - ) - - text = replace_once( - text, - "\t\t\t\tawait this.postStateToWebviewWithoutTaskHistory()", - "\t\t\t\tawait this.postStateToWebviewWithoutClineMessages()", - "debounced state must omit transcript", - ) - - text = sub_once( - text, - r"\n\t/\*\*\n\t \* Monotonically increasing sequence number for clineMessages state pushes\.\n" - r"\t \* Used by the frontend to reject stale state that arrives out-of-order\.\n\t \*/\n" - r"\tprivate clineMessagesSeq = 0\n", - "\n", - "remove global clineMessages sequence", - ) - - text = replace_once( - text, - "\t\tif (!state || typeof state.mode !== \"string\") {\n" - "\t\t\tthrow new Error(t(\"common:errors.retrieve_current_mode\"))\n" - "\t\t}\n" - "\t}", - "\t\tif (!state || typeof state.mode !== \"string\") {\n" - "\t\t\tthrow new Error(t(\"common:errors.retrieve_current_mode\"))\n" - "\t\t}\n\n" - "\t\tawait this.syncFocusedTaskToWebview()\n" - "\t}", - "focus sync after stack push", - ) - - text = replace_once( - text, - "\t\t\ttask = undefined\n\t\t}\n\t}\n\t/**\n\t * Evicts the current task", - "\t\t\ttask = undefined\n\t\t}\n\n" - "\t\tawait this.syncFocusedTaskToWebview()\n" - "\t}\n\t/**\n\t * Evicts the current task", - "focus sync after stack pop", - ) - - text = replace_once( - text, - "\t\t\t// Perform preparation tasks and set up event listeners\n" - "\t\t\tawait this.performPreparationTasks(task)\n\n" - "\t\t\tthis.log(", - "\t\t\t// Perform preparation tasks and set up event listeners\n" - "\t\t\tawait this.performPreparationTasks(task)\n" - "\t\t\tawait this.syncFocusedTaskToWebview()\n\n" - "\t\t\tthis.log(", - "rehydrated task focus sync", - ) - - old_post = '''\tpublic async postMessageToWebview(message: ExtensionMessage) { -\t\tif (this._disposed) { -\t\t\treturn -\t\t} -\t\ttry { -\t\t\tawait this.view?.webview.postMessage(message) -\t\t} catch { -\t\t\t// View disposed, drop message silently -\t\t} -\t} -''' - - new_post = '''\tpublic async postMessageToWebview(message: ExtensionMessage) { -\t\tif (this._disposed) { -\t\t\treturn -\t\t} - -\t\t// Hard transport boundary: generic state broadcasts must never carry the -\t\t// unbounded chat transcript. This also protects direct callers that build -\t\t// and post state without going through postStateToWebview(). -\t\tif (message.type === "state" && message.state) { -\t\t\tconst { -\t\t\t\tclineMessages: _omitMessages, -\t\t\t\tclineMessagesSeq: _omitMessagesSeq, -\t\t\t\t...metadataState -\t\t\t} = message.state -\t\t\tmessage = { ...message, state: metadataState } -\t\t} - -\t\ttry { -\t\t\tawait this.view?.webview.postMessage(message) -\t\t} catch { -\t\t\t// View disposed, drop message silently -\t\t} -\t} - -\tprivate getClineMessagesSeq(taskId: string): number { -\t\treturn this.clineMessagesSeqByTaskId.get(taskId) ?? 0 -\t} - -\tprivate bumpClineMessagesSeq(taskId: string): number { -\t\tconst next = this.getClineMessagesSeq(taskId) + 1 -\t\tthis.clineMessagesSeqByTaskId.set(taskId, next) -\t\treturn next -\t} - -\tprivate enqueueClineMessagesPost(operation: () => Promise): Promise { -\t\tconst run = this.clineMessagesPostQueue.then(operation, operation) -\t\tthis.clineMessagesPostQueue = run.catch((error) => { -\t\t\tthis.log( -\t\t\t\t`[clineMessages] transport failure: ${error instanceof Error ? error.message : String(error)}`, -\t\t\t) -\t\t}) -\t\treturn run -\t} - -\tpublic resetClineMessagesTransport(): number { -\t\tthis.clineMessagesTransportGeneration++ -\t\tthis.clineMessagesPostQueue = Promise.resolve() -\t\treturn this.clineMessagesTransportGeneration -\t} - -\tpublic postClineMessageAppended(taskId: string, message: ClineMessage): Promise { -\t\tconst seq = this.bumpClineMessagesSeq(taskId) -\t\tif (this.suppressClineMessagesDeltas || this.getCurrentTask()?.taskId !== taskId) { -\t\t\treturn Promise.resolve() -\t\t} - -\t\tconst generation = this.clineMessagesTransportGeneration -\t\tconst clonedMessage = structuredClone(message) -\t\treturn this.enqueueClineMessagesPost(async () => { -\t\t\tif ( -\t\t\t\tgeneration !== this.clineMessagesTransportGeneration || -\t\t\t\tthis.getCurrentTask()?.taskId !== taskId -\t\t\t) { -\t\t\t\treturn -\t\t\t} -\t\t\tawait this.postMessageToWebview({ -\t\t\t\ttype: "clineMessageAppended", -\t\t\t\ttaskId, -\t\t\t\tclineMessage: clonedMessage, -\t\t\t\tclineMessagesSeq: seq, -\t\t\t}) -\t\t}) -\t} - -\tpublic postClineMessageUpdated(taskId: string, message: ClineMessage): Promise { -\t\tconst seq = this.bumpClineMessagesSeq(taskId) -\t\tif (this.suppressClineMessagesDeltas || this.getCurrentTask()?.taskId !== taskId) { -\t\t\treturn Promise.resolve() -\t\t} - -\t\tconst generation = this.clineMessagesTransportGeneration -\t\tconst clonedMessage = structuredClone(message) -\t\treturn this.enqueueClineMessagesPost(async () => { -\t\t\tif ( -\t\t\t\tgeneration !== this.clineMessagesTransportGeneration || -\t\t\t\tthis.getCurrentTask()?.taskId !== taskId -\t\t\t) { -\t\t\t\treturn -\t\t\t} -\t\t\tawait this.postMessageToWebview({ -\t\t\t\ttype: "clineMessageUpdated", -\t\t\t\ttaskId, -\t\t\t\tclineMessage: clonedMessage, -\t\t\t\tclineMessagesSeq: seq, -\t\t\t}) -\t\t}) -\t} - -\tpublic postClineMessagesSnapshot( -\t\ttaskId: string | undefined = this.getCurrentTask()?.taskId, -\t\toptions: { bumpSeq?: boolean } = {}, -\t): Promise { -\t\tconst currentTask = this.getCurrentTask() -\t\tif ((currentTask?.taskId ?? undefined) !== taskId) { -\t\t\treturn Promise.resolve() -\t\t} - -\t\tconst seq = taskId -\t\t\t? options.bumpSeq -\t\t\t\t? this.bumpClineMessagesSeq(taskId) -\t\t\t\t: this.getClineMessagesSeq(taskId) -\t\t\t: 0 -\t\tconst messages = structuredClone(currentTask?.clineMessages ?? []) -\t\tconst snapshotId = `${taskId ?? "none"}:${++this.nextClineMessagesSnapshotId}` -\t\tconst generation = this.clineMessagesTransportGeneration - -\t\treturn this.enqueueClineMessagesPost(async () => { -\t\t\tif ( -\t\t\t\tgeneration !== this.clineMessagesTransportGeneration || -\t\t\t\t(this.getCurrentTask()?.taskId ?? undefined) !== taskId -\t\t\t) { -\t\t\t\treturn -\t\t\t} - -\t\t\tawait this.postMessageToWebview({ -\t\t\t\ttype: "clineMessagesSnapshotStart", -\t\t\t\ttaskId, -\t\t\t\tclineMessagesSeq: seq, -\t\t\t\tsnapshotId, -\t\t\t\tsnapshotTotal: messages.length, -\t\t\t}) - -\t\t\tfor ( -\t\t\t\tlet start = 0; -\t\t\t\tstart < messages.length; -\t\t\t\tstart += ClineProvider.CLINE_MESSAGES_SNAPSHOT_CHUNK_SIZE -\t\t\t) { -\t\t\t\tif ( -\t\t\t\t\tgeneration !== this.clineMessagesTransportGeneration || -\t\t\t\t\t(this.getCurrentTask()?.taskId ?? undefined) !== taskId -\t\t\t\t) { -\t\t\t\t\treturn -\t\t\t\t} -\t\t\t\tawait this.postMessageToWebview({ -\t\t\t\t\ttype: "clineMessagesSnapshotChunk", -\t\t\t\t\ttaskId, -\t\t\t\t\tclineMessagesSeq: seq, -\t\t\t\t\tsnapshotId, -\t\t\t\t\tsnapshotStartIndex: start, -\t\t\t\t\tclineMessages: messages.slice( -\t\t\t\t\t\tstart, -\t\t\t\t\t\tstart + ClineProvider.CLINE_MESSAGES_SNAPSHOT_CHUNK_SIZE, -\t\t\t\t\t), -\t\t\t\t}) -\t\t\t} - -\t\t\tawait this.postMessageToWebview({ -\t\t\t\ttype: "clineMessagesSnapshotEnd", -\t\t\t\ttaskId, -\t\t\t\tclineMessagesSeq: seq, -\t\t\t\tsnapshotId, -\t\t\t\tsnapshotTotal: messages.length, -\t\t\t}) -\t\t}) -\t} - -\tpublic async resyncClineMessagesToWebview(taskId?: string): Promise { -\t\tif ((this.getCurrentTask()?.taskId ?? undefined) !== taskId) { -\t\t\treturn -\t\t} -\t\tthis.resetClineMessagesTransport() -\t\tthis.suppressClineMessagesDeltas = true -\t\ttry { -\t\t\tconst snapshot = this.postClineMessagesSnapshot(taskId) -\t\t\tthis.suppressClineMessagesDeltas = false -\t\t\tawait snapshot -\t\t} finally { -\t\t\tthis.suppressClineMessagesDeltas = false -\t\t} -\t} - -\tpublic async syncFocusedTaskToWebview( -\t\toptions: { includeTaskHistory?: boolean } = {}, -\t): Promise { -\t\tconst generation = this.resetClineMessagesTransport() -\t\tthis.suppressClineMessagesDeltas = true -\t\ttry { -\t\t\tif (options.includeTaskHistory) { -\t\t\t\tawait this.postStateToWebview() -\t\t\t} else { -\t\t\t\tawait this.postStateToWebviewWithoutTaskHistory() -\t\t\t} -\t\t\tif (generation !== this.clineMessagesTransportGeneration) { -\t\t\t\treturn -\t\t\t} -\t\t\tconst snapshot = this.postClineMessagesSnapshot() -\t\t\tthis.suppressClineMessagesDeltas = false -\t\t\tawait snapshot -\t\t} finally { -\t\t\tthis.suppressClineMessagesDeltas = false -\t\t} -\t} -''' - text = replace_once(text, old_post, new_post, "provider transcript transport methods") - - old_state = '''\tasync postStateToWebview() { -\t\tconst state = await this.getStateToPostToWebview() -\t\tthis.clineMessagesSeq++ -\t\tstate.clineMessagesSeq = this.clineMessagesSeq -\t\tawait this.postMessageToWebview({ type: "state", state }) -\t} -''' - new_state = '''\tasync postStateToWebview() { -\t\tconst state = await this.getStateToPostToWebview() -\t\tconst { clineMessages: _omitMessages, clineMessagesSeq: _omitMessagesSeq, ...metadataState } = state -\t\tawait this.postMessageToWebview({ type: "state", state: metadataState }) -\t} -''' - text = replace_once(text, old_state, new_state, "postState transcript omission") - - old_no_history = '''\tasync postStateToWebviewWithoutTaskHistory(): Promise { -\t\tconst state = await this.getStateToPostToWebview({ includeTaskHistory: false }) -\t\tthis.clineMessagesSeq++ -\t\tstate.clineMessagesSeq = this.clineMessagesSeq -\t\tconst { taskHistory: _omit, ...rest } = state -\t\tawait this.postMessageToWebview({ type: "state", state: rest }) -\t} -''' - new_no_history = '''\tasync postStateToWebviewWithoutTaskHistory(): Promise { -\t\tconst state = await this.getStateToPostToWebview({ includeTaskHistory: false }) -\t\tconst { -\t\t\tclineMessages: _omitMessages, -\t\t\tclineMessagesSeq: _omitMessagesSeq, -\t\t\ttaskHistory: _omitHistory, -\t\t\t...metadataState -\t\t} = state -\t\tawait this.postMessageToWebview({ type: "state", state: metadataState }) -\t} -''' - text = replace_once(text, old_no_history, new_no_history, "postStateWithoutTaskHistory transcript omission") - - text = replace_once( - text, - "\t\tconst { clineMessages: _omitMessages, taskHistory: _omitHistory, ...rest } = state", - "\t\tconst {\n" - "\t\t\tclineMessages: _omitMessages,\n" - "\t\t\tclineMessagesSeq: _omitMessagesSeq,\n" - "\t\t\ttaskHistory: _omitHistory,\n" - "\t\t\t...rest\n" - "\t\t} = state", - "postStateWithoutClineMessages sequence omission", - ) - - write(path, text) - - -def patch_task(root: Path) -> None: - path = root / "src/core/task/Task.ts" - text = read(path) - - text = sub_once( - text, - r'''\tprivate async addToClineMessages\(message: ClineMessage\) \{\n''' - r'''\t\tthis\.clineMessages\.push\(message\)\n''' - r'''\t\tconst provider = this\.providerRef\.deref\(\)\n''' - r'''\t\t// Unanswered asks must reach the webview before Message listeners can respond against its state\.\n''' - r'''\t\tconst requiresImmediateState =\n''' - r'''\t\t\tmessage\.partial === true \|\| \(message\.type === "ask" && message\.isAnswered !== true\)\n''' - r'''\t\ttry \{\n''' - r'''\t\t\tawait provider\?\.postStateToWebviewThrottled\(\)\n''' - r'''\t\t\} catch \(error\) \{\n''' - r'''\t\t\tconsole\.error\("\[Task#addToClineMessages\] postStateToWebviewThrottled failed:", error\)\n''' - r'''\t\t\}\n''' - r'''\t\tif \(requiresImmediateState\) \{\n''' - r'''\t\t\ttry \{\n''' - r'''\t\t\t\tawait provider\?\.flushPostStateToWebviewThrottled\(\)\n''' - r'''\t\t\t\} catch \(error\) \{\n''' - r'''\t\t\t\tconsole\.error\("\[Task#addToClineMessages\] flushPostStateToWebviewThrottled failed:", error\)\n''' - r'''\t\t\t\}\n''' - r'''\t\t\}\n''', - '''\tprivate async addToClineMessages(message: ClineMessage) { -\t\tthis.clineMessages.push(message) -\t\tconst provider = this.providerRef.deref() -\t\ttry { -\t\t\tawait provider?.postClineMessageAppended(this.taskId, message) -\t\t} catch (error) { -\t\t\tconsole.error("[Task#addToClineMessages] incremental post failed:", error) -\t\t} -''', - "Task append delta", - ) - - text = replace_once( - text, - "\t\tfor (const msg of newMessages) {\n" - "\t\t\tif (msg.partial !== true) {\n" - "\t\t\t\tthis.cloudSyncedMessageTimestamps.add(msg.ts)\n" - "\t\t\t}\n" - "\t\t}\n" - "\t}\n" - "\tprivate async updateClineMessage(message: ClineMessage) {\n" - "\t\tconst provider = this.providerRef.deref()\n" - "\t\tawait provider?.postMessageToWebview({ type: \"messageUpdated\", clineMessage: message })", - "\t\tfor (const msg of newMessages) {\n" - "\t\t\tif (msg.partial !== true) {\n" - "\t\t\t\tthis.cloudSyncedMessageTimestamps.add(msg.ts)\n" - "\t\t\t}\n" - "\t\t}\n" - "\t\tawait this.providerRef.deref()?.postClineMessagesSnapshot(this.taskId, { bumpSeq: true })\n" - "\t}\n" - "\tprivate async updateClineMessage(message: ClineMessage) {\n" - "\t\tconst provider = this.providerRef.deref()\n" - "\t\tawait provider?.postClineMessageUpdated(this.taskId, message)", - "Task overwrite/update transport", - ) - - text = replace_once( - text, - "\t\t\t\tthis.clineMessages[lastFollowUpIndex].isAnswered = true\n\t\t\t\t// Save the updated messages", - "\t\t\t\tthis.clineMessages[lastFollowUpIndex].isAnswered = true\n" - "\t\t\t\tvoid this.updateClineMessage(this.clineMessages[lastFollowUpIndex]).catch((error) => {\n" - "\t\t\t\t\tconsole.error(\"[Task#handleWebviewAskResponse] follow-up delta failed:\", error)\n" - "\t\t\t\t})\n" - "\t\t\t\t// Save the updated messages", - "follow-up answer update delta", - ) - - text = replace_once( - text, - "\t\t\tawait this.providerRef.deref()?.postStateToWebviewWithoutTaskHistory()\n\n\t\t\tawait this.say(\"text\", task, images)", - "\t\t\tawait this.providerRef.deref()?.postClineMessagesSnapshot(this.taskId, { bumpSeq: true })\n\n" - "\t\t\tawait this.say(\"text\", task, images)", - "new task empty snapshot", - ) - - text = replace_once( - text, - "\t\t\tawait this.saveClineMessages()\n\t\t\tawait this.providerRef.deref()?.postStateToWebviewWithoutTaskHistory()\n\n\t\t\ttry {", - "\t\t\tawait this.saveClineMessages()\n" - "\t\t\tawait this.updateClineMessage(this.clineMessages[lastApiReqIndex])\n\n" - "\t\t\ttry {", - "api request placeholder update delta", - ) - - text = replace_once( - text, - "\t\t\t\t\tif (lastMessage && lastMessage.partial) {\n" - "\t\t\t\t\t\t// lastMessage.ts = Date.now() DO NOT update ts since it is used as a key for virtuoso list\n" - "\t\t\t\t\t\tlastMessage.partial = false\n" - "\t\t\t\t\t\t// instead of streaming partialMessage events, we do a save and post like normal to persist to disk\n" - "\t\t\t\t\t}\n" - "\t\t\t\t\t// Update `api_req_started` to have cancelled and cost, so that\n" - "\t\t\t\t\t// we can display the cost of the partial stream and the cancellation reason\n" - "\t\t\t\t\tupdateApiReqMsg(cancelReason, streamingFailedMessage)\n" - "\t\t\t\t\tawait this.saveClineMessages()", - "\t\t\t\t\tif (lastMessage && lastMessage.partial) {\n" - "\t\t\t\t\t\t// lastMessage.ts = Date.now() DO NOT update ts since it is used as a key for virtuoso list\n" - "\t\t\t\t\t\tlastMessage.partial = false\n" - "\t\t\t\t\t\tawait this.updateClineMessage(lastMessage)\n" - "\t\t\t\t\t}\n" - "\t\t\t\t\t// Update `api_req_started` to have cancelled and cost, so that\n" - "\t\t\t\t\t// we can display the cost of the partial stream and the cancellation reason\n" - "\t\t\t\t\tupdateApiReqMsg(cancelReason, streamingFailedMessage)\n" - "\t\t\t\t\tconst apiRequestMessage = this.clineMessages[lastApiReqIndex]\n" - "\t\t\t\t\tif (apiRequestMessage) {\n" - "\t\t\t\t\t\tawait this.updateClineMessage(apiRequestMessage)\n" - "\t\t\t\t\t}\n" - "\t\t\t\t\tawait this.saveClineMessages()", - "abort stream final deltas", - ) - - text = replace_once( - text, - "\t\t\t\tawait this.saveClineMessages()\n\t\t\t\tawait this.providerRef.deref()?.postStateToWebviewWithoutTaskHistory()\n\n" - "\t\t\t\t// No legacy text-stream tool parser state to reset.", - "\t\t\t\tawait this.saveClineMessages()\n\n" - "\t\t\t\t// No legacy text-stream tool parser state to reset.", - "remove response-end full transcript broadcast", - ) - - write(path, text) - - -def patch_handler(root: Path) -> None: - path = root / "src/core/webview/webviewMessageHandler.ts" - text = read(path) - - text = replace_once( - text, - "\t\tcase \"webviewDidLaunch\":\n\t\t\t// Load custom modes first", - "\t\tcase \"requestClineMessagesResync\":\n" - "\t\t\tawait provider.resyncClineMessagesToWebview(message.taskId)\n" - "\t\t\tbreak\n" - "\t\tcase \"webviewDidLaunch\":\n" - "\t\t\t// Load custom modes first", - "handler resync case", - ) - - text = replace_once( - text, - "\t\t\tawait updateGlobalState(\"customModes\", customModes)\n\t\t\tawait provider.postStateToWebview()", - "\t\t\tawait updateGlobalState(\"customModes\", customModes)\n" - "\t\t\tawait provider.syncFocusedTaskToWebview({ includeTaskHistory: true })", - "launch state plus chunked snapshot", - ) - - text = replace_once( - text, - "\t\t\tawait provider.clearTask()\n\t\t\tawait provider.postStateToWebview()", - "\t\t\tawait provider.clearTask()\n" - "\t\t\tawait provider.syncFocusedTaskToWebview({ includeTaskHistory: true })", - "clear task sync", - ) - - text = replace_once( - text, - "\t\t\t\t// Update the UI to reflect the deletion\n\t\t\t\tawait provider.postStateToWebview()", - "\t\t\t\t// Update the UI to reflect the deletion\n" - "\t\t\t\tawait provider.postClineMessagesSnapshot(currentCline.taskId, { bumpSeq: true })", - "delete operation snapshot", - ) - - text = replace_once( - text, - "\t\t\t// Update the UI to reflect the deletion\n\t\t\tawait provider.postStateToWebview()\n\t\t\tawait currentCline.submitUserMessage", - "\t\t\t// Update the UI to reflect the edit\n" - "\t\t\tawait provider.postClineMessagesSnapshot(currentCline.taskId, { bumpSeq: true })\n" - "\t\t\tawait currentCline.submitUserMessage", - "edit operation snapshot", - ) - - # The updatePrompt handler posts a hand-built state directly. The provider now - # strips transcripts centrally, but use the explicit metadata-safe path too. - text = replace_once( - text, - "\t\t\t\tconst currentState = await provider.getStateToPostToWebview()\n" - "\t\t\t\tconst stateWithPrompts = {\n" - "\t\t\t\t\t...currentState,\n" - "\t\t\t\t\tcustomModePrompts: updatedPrompts,\n" - "\t\t\t\t\thasOpenedModeSelector: currentState.hasOpenedModeSelector ?? false,\n" - "\t\t\t\t}\n" - "\t\t\t\tawait provider.postMessageToWebview({ type: \"state\", state: stateWithPrompts })", - "\t\t\t\tawait provider.postStateToWebviewWithoutClineMessages()", - "updatePrompt metadata-only state", - ) - - write(path, text) - - -def patch_webview(root: Path) -> None: - path = root / "webview-ui/src/context/ExtensionStateContext.tsx" - text = read(path) - - text = replace_once( - text, - 'import React, { createContext, useCallback, useEffect, useState } from "react"', - 'import React, { createContext, useCallback, useEffect, useRef, useState } from "react"', - "webview useRef import", - ) - text = replace_once( - text, - "\ttype ExtensionState,\n\ttype MarketplaceInstalledMetadata,", - "\ttype ExtensionState,\n\ttype ClineMessage,\n\ttype MarketplaceInstalledMetadata,", - "webview ClineMessage import", - ) - - text = sub_once( - text, - r'''\t// Protect clineMessages from stale state pushes using sequence numbering\.\n''' - r'''(?:\t//.*\n){4}''' - r'''\tif \(\n''' - r'''\t\tnewState\.clineMessagesSeq !== undefined &&\n''' - r'''\t\tprevState\.clineMessagesSeq !== undefined &&\n''' - r'''\t\tnewState\.clineMessagesSeq <= prevState\.clineMessagesSeq &&\n''' - r'''\t\tnewState\.clineMessages !== undefined\n''' - r'''\t\) \{\n''' - r'''\t\trest\.clineMessages = prevState\.clineMessages\n''' - r'''\t\trest\.clineMessagesSeq = prevState\.clineMessagesSeq\n''' - r'''\t\}\n''', - "", - "remove old full-state sequence guard", - ) - - text = replace_once( - text, - "export const ExtensionStateContext = createContext(undefined)\n\n", - "export const ExtensionStateContext = createContext(undefined)\n\n" - "type ClineMessagesSnapshotBuffer = {\n" - "\tsnapshotId: string\n" - "\ttaskId?: string\n" - "\tseq: number\n" - "\ttotal: number\n" - "\tmessages: ClineMessage[]\n" - "}\n\n", - "snapshot buffer type", - ) - - text = replace_once( - text, - "\tconst [state, setState] = useState(() =>\n" - "\t\tmergeExtensionState(createInitialExtensionState(), initialState ?? {}),\n" - "\t)\n" - "\tconst [didHydrateState, setDidHydrateState] = useState(false)", - "\tconst [state, setState] = useState(() =>\n" - "\t\tmergeExtensionState(createInitialExtensionState(), initialState ?? {}),\n" - "\t)\n" - "\tconst activeTaskIdRef = useRef(state.currentTaskId)\n" - "\tconst clineMessagesSeqRef = useRef(state.clineMessagesSeq ?? 0)\n" - "\tconst clineMessagesRef = useRef(state.clineMessages)\n" - "\tconst activeSnapshotRef = useRef(null)\n" - "\tconst resyncPendingRef = useRef(false)\n" - "\tconst [didHydrateState, setDidHydrateState] = useState(false)", - "webview transcript refs", - ) - - callback_anchor = '''\tconst setApiConfiguration = useCallback((value: ProviderSettings) => { -\t\tsetState((prevState) => ({ -\t\t\t...prevState, -\t\t\tapiConfiguration: { -\t\t\t\t...prevState.apiConfiguration, -\t\t\t\t...value, -\t\t\t}, -\t\t})) -\t}, []) -''' - callback_add = callback_anchor + ''' -\tconst requestClineMessagesResync = useCallback((receivedSeq?: number) => { -\t\tif (resyncPendingRef.current) { -\t\t\treturn -\t\t} -\t\tresyncPendingRef.current = true -\t\tvscode.postMessage({ -\t\t\ttype: "requestClineMessagesResync", -\t\t\ttaskId: activeTaskIdRef.current, -\t\t\texpectedSeq: clineMessagesSeqRef.current + 1, -\t\t\treceivedSeq, -\t\t}) -\t}, []) - -\tconst applyClineMessagesDelta = useCallback( -\t\t(message: ExtensionMessage, operation: "append" | "update") => { -\t\t\tconst seq = message.clineMessagesSeq -\t\t\tconst clineMessage = message.clineMessage -\t\t\tif ( -\t\t\t\ttypeof seq !== "number" || -\t\t\t\t!clineMessage || -\t\t\t\tmessage.taskId !== activeTaskIdRef.current -\t\t\t) { -\t\t\t\treturn -\t\t\t} -\t\t\tif (activeSnapshotRef.current) { -\t\t\t\trequestClineMessagesResync(seq) -\t\t\t\treturn -\t\t\t} -\t\t\tif (seq <= clineMessagesSeqRef.current) { -\t\t\t\treturn -\t\t\t} -\t\t\tif (seq !== clineMessagesSeqRef.current + 1) { -\t\t\t\trequestClineMessagesResync(seq) -\t\t\t\treturn -\t\t\t} - -\t\t\tlet nextMessages: ClineMessage[] -\t\t\tif (operation === "append") { -\t\t\t\tnextMessages = [...clineMessagesRef.current, clineMessage] -\t\t\t} else { -\t\t\t\tconst index = findLastIndex(clineMessagesRef.current, (item) => item.ts === clineMessage.ts) -\t\t\t\tif (index === -1) { -\t\t\t\t\trequestClineMessagesResync(seq) -\t\t\t\t\treturn -\t\t\t\t} -\t\t\t\tnextMessages = [...clineMessagesRef.current] -\t\t\t\tnextMessages[index] = clineMessage -\t\t\t} - -\t\t\tclineMessagesRef.current = nextMessages -\t\t\tclineMessagesSeqRef.current = seq -\t\t\tsetState((prevState) => ({ -\t\t\t\t...prevState, -\t\t\t\tclineMessages: nextMessages, -\t\t\t\tclineMessagesSeq: seq, -\t\t\t})) -\t\t}, -\t\t[requestClineMessagesResync], -\t) -''' - text = replace_once(text, callback_anchor, callback_add, "webview transcript callbacks") - - text = replace_once( - text, - "\t\t\t\tcase \"state\": {\n" - "\t\t\t\t\tconst newState = message.state ?? {}\n" - "\t\t\t\t\tsetState((prevState) => mergeExtensionState(prevState, newState))", - "\t\t\t\tcase \"state\": {\n" - "\t\t\t\t\tconst {\n" - "\t\t\t\t\t\tclineMessages: _ignoredMessages,\n" - "\t\t\t\t\t\tclineMessagesSeq: _ignoredMessagesSeq,\n" - "\t\t\t\t\t\t...newState\n" - "\t\t\t\t\t} = message.state ?? {}\n" - "\t\t\t\t\tconst hasCurrentTaskId = Object.prototype.hasOwnProperty.call(newState, \"currentTaskId\")\n" - "\t\t\t\t\tconst nextTaskId = hasCurrentTaskId ? newState.currentTaskId : activeTaskIdRef.current\n" - "\t\t\t\t\tconst taskChanged = hasCurrentTaskId && nextTaskId !== activeTaskIdRef.current\n" - "\t\t\t\t\tif (taskChanged) {\n" - "\t\t\t\t\t\tactiveTaskIdRef.current = nextTaskId\n" - "\t\t\t\t\t\tclineMessagesSeqRef.current = 0\n" - "\t\t\t\t\t\tclineMessagesRef.current = []\n" - "\t\t\t\t\t\tactiveSnapshotRef.current = null\n" - "\t\t\t\t\t\tresyncPendingRef.current = false\n" - "\t\t\t\t\t}\n" - "\t\t\t\t\tsetState((prevState) => {\n" - "\t\t\t\t\t\tconst merged = mergeExtensionState(prevState, newState)\n" - "\t\t\t\t\t\treturn taskChanged ? { ...merged, clineMessages: [], clineMessagesSeq: 0 } : merged\n" - "\t\t\t\t\t})", - "metadata state task switch handling", - ) - - old_message_case = re.compile( - r'''\t\t\t\tcase "messageUpdated": \{\n.*?\t\t\t\t\}\n\t\t\t\tcase "skills": \{''', - re.S, - ) - new_message_case = '''\t\t\t\tcase "clineMessagesSnapshotStart": { -\t\t\t\t\tif ( -\t\t\t\t\t\t!message.snapshotId || -\t\t\t\t\t\ttypeof message.clineMessagesSeq !== "number" || -\t\t\t\t\t\ttypeof message.snapshotTotal !== "number" || -\t\t\t\t\t\tmessage.taskId !== activeTaskIdRef.current || -\t\t\t\t\t\tmessage.clineMessagesSeq < clineMessagesSeqRef.current -\t\t\t\t\t) { -\t\t\t\t\t\tbreak -\t\t\t\t\t} -\t\t\t\t\tactiveSnapshotRef.current = { -\t\t\t\t\t\tsnapshotId: message.snapshotId, -\t\t\t\t\t\ttaskId: message.taskId, -\t\t\t\t\t\tseq: message.clineMessagesSeq, -\t\t\t\t\t\ttotal: message.snapshotTotal, -\t\t\t\t\t\tmessages: [], -\t\t\t\t\t} -\t\t\t\t\tbreak -\t\t\t\t} -\t\t\t\tcase "clineMessagesSnapshotChunk": { -\t\t\t\t\tconst snapshot = activeSnapshotRef.current -\t\t\t\t\tif ( -\t\t\t\t\t\t!snapshot || -\t\t\t\t\t\tmessage.snapshotId !== snapshot.snapshotId || -\t\t\t\t\t\tmessage.taskId !== snapshot.taskId || -\t\t\t\t\t\tmessage.clineMessagesSeq !== snapshot.seq -\t\t\t\t\t) { -\t\t\t\t\t\tbreak -\t\t\t\t\t} -\t\t\t\t\tconst chunk = message.clineMessages ?? [] -\t\t\t\t\tif ( -\t\t\t\t\t\tmessage.snapshotStartIndex !== snapshot.messages.length || -\t\t\t\t\t\tsnapshot.messages.length + chunk.length > snapshot.total -\t\t\t\t\t) { -\t\t\t\t\t\tactiveSnapshotRef.current = null -\t\t\t\t\t\trequestClineMessagesResync(message.clineMessagesSeq) -\t\t\t\t\t\tbreak -\t\t\t\t\t} -\t\t\t\t\tsnapshot.messages.push(...chunk) -\t\t\t\t\tbreak -\t\t\t\t} -\t\t\t\tcase "clineMessagesSnapshotEnd": { -\t\t\t\t\tconst snapshot = activeSnapshotRef.current -\t\t\t\t\tif ( -\t\t\t\t\t\t!snapshot || -\t\t\t\t\t\tmessage.snapshotId !== snapshot.snapshotId || -\t\t\t\t\t\tmessage.taskId !== snapshot.taskId || -\t\t\t\t\t\tmessage.clineMessagesSeq !== snapshot.seq || -\t\t\t\t\t\tsnapshot.messages.length !== snapshot.total || -\t\t\t\t\t\tmessage.snapshotTotal !== snapshot.total -\t\t\t\t\t) { -\t\t\t\t\t\tactiveSnapshotRef.current = null -\t\t\t\t\t\trequestClineMessagesResync(message.clineMessagesSeq) -\t\t\t\t\t\tbreak -\t\t\t\t\t} -\t\t\t\t\tactiveSnapshotRef.current = null -\t\t\t\t\tresyncPendingRef.current = false -\t\t\t\t\tclineMessagesRef.current = snapshot.messages -\t\t\t\t\tclineMessagesSeqRef.current = snapshot.seq -\t\t\t\t\tsetState((prevState) => ({ -\t\t\t\t\t\t...prevState, -\t\t\t\t\t\tclineMessages: snapshot.messages, -\t\t\t\t\t\tclineMessagesSeq: snapshot.seq, -\t\t\t\t\t})) -\t\t\t\t\tbreak -\t\t\t\t} -\t\t\t\tcase "clineMessageAppended": { -\t\t\t\t\tapplyClineMessagesDelta(message, "append") -\t\t\t\t\tbreak -\t\t\t\t} -\t\t\t\tcase "clineMessageUpdated": { -\t\t\t\t\tapplyClineMessagesDelta(message, "update") -\t\t\t\t\tbreak -\t\t\t\t} -\t\t\t\tcase "messageUpdated": { -\t\t\t\t\t// An unsequenced legacy update cannot be applied safely. -\t\t\t\t\trequestClineMessagesResync(message.clineMessagesSeq) -\t\t\t\t\tbreak -\t\t\t\t} -\t\t\t\tcase "skills": {''' - text, count = old_message_case.subn(new_message_case, text, count=1) - if count != 1: - die(f"webview transcript switch: expected exactly one match, found {count}") - - text = replace_once( - text, - "\t\t[setListApiConfigMeta],", - "\t\t[applyClineMessagesDelta, requestClineMessagesResync, setListApiConfigMeta],", - "webview handler dependencies", - ) - - write(path, text) - - -def main() -> int: - parser = argparse.ArgumentParser() - parser.add_argument("repo", nargs="?", default=".", help="Zoo Code repository root") - parser.add_argument("--no-diff", action="store_true", help="do not print git diff after applying") - args = parser.parse_args() - - root = Path(args.repo).resolve() - sentinel = root / "src/core/webview/ClineProvider.ts" - if not sentinel.is_file(): - die(f"{root} does not look like the Zoo Code repository root") - - if MARKER in read(sentinel): - print("Patch marker already present; no changes made.") - return 0 - - patch_types(root) - patch_provider(root) - patch_task(root) - patch_handler(root) - patch_webview(root) - - files = [ - "packages/types/src/vscode-extension-host.ts", - "src/core/webview/ClineProvider.ts", - "src/core/task/Task.ts", - "src/core/webview/webviewMessageHandler.ts", - "webview-ui/src/context/ExtensionStateContext.tsx", - ] - print("Applied incremental, sequenced, chunked transcript transport patch.") - print("Changed files:") - for file in files: - print(f" {file}") - - if not args.no_diff: - try: - subprocess.run(["git", "diff", "--", *files], cwd=root, check=False) - except FileNotFoundError: - print("git not found; skipping diff", file=sys.stderr) - - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) From 1475356379cdbd0634427549f30e6b0c491288c4 Mon Sep 17 00:00:00 2001 From: Gh0st <95618468+Gh0st352@users.noreply.github.com> Date: Sun, 23 Aug 2026 17:54:41 -0600 Subject: [PATCH 07/26] Delete ZOO_CODE_GRAY_SCREEN_FIX_README.md --- ZOO_CODE_GRAY_SCREEN_FIX_README.md | 268 ----------------------------- 1 file changed, 268 deletions(-) delete mode 100644 ZOO_CODE_GRAY_SCREEN_FIX_README.md diff --git a/ZOO_CODE_GRAY_SCREEN_FIX_README.md b/ZOO_CODE_GRAY_SCREEN_FIX_README.md deleted file mode 100644 index b4f9dcac10..0000000000 --- a/ZOO_CODE_GRAY_SCREEN_FIX_README.md +++ /dev/null @@ -1,268 +0,0 @@ -# Zoo Code permanent gray-screen fix - -This source patch replaces the unbounded full-transcript webview transport with a dedicated transcript protocol: - -- Generic `state` messages are forcibly stripped of `clineMessages` and `clineMessagesSeq` at the provider boundary. -- Appends and edits are sent as task-scoped, monotonically sequenced deltas. -- Initial load, task switches, checkpoint rewinds, edits, deletes, and recovery use a serialized chunked snapshot. -- The webview validates task ID, sequence continuity, snapshot identity, chunk offsets, and final message count. -- A sequence gap or legacy unsequenced update requests an automatic full resynchronization. -- Focus changes invalidate the old transcript transport generation, preventing a background task from updating the foreground transcript. -- A reload no longer requires deserializing the entire transcript as one generic extension-state object. - -## Apply - -From a clean Zoo Code source checkout: - -```powershell -python C:\path\to\apply_zoo_code_incremental_transcript_fix.py . -``` - -The patcher is deliberately strict. It stops without partially continuing when an expected source block differs from the source lineage it targets. Review the resulting diff: - -```powershell -git diff --check -git diff --stat -git diff -``` - -## Validate - -The repository declares Node `22.23.1` and pnpm `10.8.1`. - -```powershell -corepack enable -corepack prepare pnpm@10.8.1 --activate -pnpm install --frozen-lockfile -pnpm format -pnpm check-types -pnpm lint -pnpm test -pnpm vsix -``` - -Install the generated VSIX: - -```powershell -$Vsix = Get-ChildItem .\bin\*.vsix | Sort-Object LastWriteTime -Descending | Select-Object -First 1 -code --install-extension $Vsix.FullName --force -``` - -Then fully exit all VS Code processes once and reopen VS Code. - -## Required stress acceptance test - -Use a copy of a large project and run a task that produces at least 10,000 Zoo transcript messages or tool-status updates. - -Pass conditions: - -1. The Zoo Code webview remains rendered and interactive throughout the run. -2. Renderer memory does not grow in proportion to `message-count × total-transcript-size`. -3. Normal appends transfer one `ClineMessage`; normal edits transfer one `ClineMessage`. -4. No generic `state` message contains `clineMessages` in Webview Developer Tools. -5. `Developer: Reload Webviews` reconstructs the active transcript through snapshot chunks without stopping the extension-host task. -6. Switching rapidly between parent and delegated child tasks never displays messages from the wrong task. -7. Deliberately dropping one delta causes `requestClineMessagesResync`, followed by a correct chunked snapshot. - -## Files changed by the patcher - -- `packages/types/src/vscode-extension-host.ts` -- `src/core/webview/ClineProvider.ts` -- `src/core/task/Task.ts` -- `src/core/webview/webviewMessageHandler.ts` -- `webview-ui/src/context/ExtensionStateContext.tsx` - - - -# GPT 5.6 Output: - -## Patch output - -* [Download the permanent-fix source patch applicator](sandbox:/mnt/data/apply_zoo_code_incremental_transcript_fix.py) -* [Download the implementation notes and validation procedure](sandbox:/mnt/data/ZOO_CODE_GRAY_SCREEN_FIX_README.md) - -This is a **source-level architectural fix**, not another output-limiting or task-rotation workaround. - -Zoo Code issue #630 remains open and documents the underlying defect: throttling reduces update frequency but still serializes the complete `clineMessages` array, approximately 7 MB at 3,525 messages, on ordinary state pushes. Its required solution is task-scoped incremental delivery with sequencing, focus isolation, and automatic resynchronization. ([GitHub][1]) - -The v3.80 release notes confirm that the shipped change was specifically a throttling change, rather than replacement of the full-array transport. ([GitHub][2]) - -## What the patch changes - -The patch modifies these files: - -```text -packages/types/src/vscode-extension-host.ts -src/core/webview/ClineProvider.ts -src/core/task/Task.ts -src/core/webview/webviewMessageHandler.ts -webview-ui/src/context/ExtensionStateContext.tsx -``` - -It implements five related changes. - -### 1. Removes transcripts from generic extension state - -Every generic `{ type: "state" }` message is stripped of: - -```text -clineMessages -clineMessagesSeq -``` - -The stripping occurs at the final `postMessageToWebview()` boundary, not merely in selected callers. This prevents another code path from accidentally reintroducing multi-megabyte state messages. - -### 2. Adds task-scoped incremental messages - -Normal transcript changes become: - -```typescript -{ - type: "clineMessageAppended", - taskId, - clineMessage, - clineMessagesSeq -} -``` - -or: - -```typescript -{ - type: "clineMessageUpdated", - taskId, - clineMessage, - clineMessagesSeq -} -``` - -An append or edit therefore transfers one `ClineMessage`, not the entire transcript. - -### 3. Adds chunked transcript reconstruction - -Initial webview loading, task switching, checkpoint restoration, message deletion, message editing, and explicit recovery use: - -```text -clineMessagesSnapshotStart -clineMessagesSnapshotChunk -clineMessagesSnapshotEnd -``` - -The default chunk size is 200 messages. - -This extends the proposal in issue #630: the retained full synchronization path is itself chunked, so reloading the webview does not require deserializing the complete history in one large message. - -### 4. Adds automatic sequence-gap recovery - -The webview validates: - -* Focused task ID -* Monotonic sequence number -* Snapshot ID -* Chunk start offset -* Expected message count -* Final received message count - -When an append or update is skipped, reordered, or cannot be applied, the webview sends: - -```typescript -{ - type: "requestClineMessagesResync", - taskId, - expectedSeq, - receivedSeq -} -``` - -The extension then sends a fresh chunked snapshot. - -### 5. Isolates foreground and background tasks - -Each task has its own message sequence. Focus transitions invalidate the previous transport generation. - -Consequently, a delegated child task or an old asynchronous callback cannot append messages to whichever task happens to be visible when the callback completes. - -## Apply the patch - -Use a clean Zoo Code source checkout: - -```powershell -git clone https://github.com/Zoo-Code-Org/Zoo-Code.git -Set-Location .\Zoo-Code - -python C:\Path\To\apply_zoo_code_incremental_transcript_fix.py . -``` - -The applicator is deliberately strict. It checks that each expected source block exists exactly once. If the source differs materially, it exits rather than partially modifying the repository. - -Review the changes: - -```powershell -git diff --check -git diff --stat -git diff -``` - -## Build and validate - -The current repository specifies Node `22.23.1`, pnpm `10.8.1`, and provides `check-types`, `lint`, `test`, and `vsix` scripts. - -```powershell -corepack enable -corepack prepare pnpm@10.8.1 --activate - -pnpm install --frozen-lockfile -pnpm check-types -pnpm lint -pnpm test -pnpm vsix -``` - -Install the generated package: - -```powershell -$Vsix = Get-ChildItem .\bin\*.vsix | - Sort-Object LastWriteTime -Descending | - Select-Object -First 1 - -code --install-extension $Vsix.FullName --force -``` - -Zoo Code’s repository documents `pnpm vsix`, `pnpm install:vsix`, and manual installation from the generated `bin` directory. ([GitHub][3]) - -After installation, fully close all VS Code processes once and reopen VS Code. Prevent the Marketplace build from automatically replacing the custom version while it is being evaluated. - -## Required acceptance test - -Run a long task that produces at least 10,000 transcript/tool-status messages. - -The fix passes when all of the following are true: - -1. Zoo Code remains rendered and interactive. -2. Normal appends and edits transfer one message each. -3. No generic `state` event contains `clineMessages`. -4. Renderer memory does not scale with `message count × entire transcript size`. -5. `Developer: Reload Webviews` reconstructs the active transcript through chunks. -6. The extension-host task continues while the replacement webview hydrates. -7. Rapid parent/child task switching never shows messages from the wrong task. -8. A deliberately dropped delta causes an automatic resync and restores an exact transcript. - -## Validation status - -The patch applicator itself passed Python syntax compilation with `python -m py_compile`. - -I inspected the current official source structure and issue specification while constructing it, but could not run Zoo Code’s TypeScript build or test suite in this execution environment because the repository could not be cloned into the local container. The patch is therefore a **source patch candidate**, not an upstream-reviewed release. Issue #630 currently shows no associated branch or pull request. ([GitHub][1]) - -SHA-256: - -```text -apply_zoo_code_incremental_transcript_fix.py -8f89677c1e4fbec5ab9982495e0396a2e086d59d9a89fc90a6ae904b450fa5b1 - -ZOO_CODE_GRAY_SCREEN_FIX_README.md -9d4db5a0d87f9726d5234d5884907977cc00664d93e9a8a0e17bd50b4530de2d -``` - -[1]: https://github.com/Zoo-Code-Org/Zoo-Code/issues/630 "feat(webview): incremental clineMessages delivery for focused task · Issue #630 · Zoo-Code-Org/Zoo-Code · GitHub" -[2]: https://github.com/Zoo-Code-Org/Zoo-Code/releases "Releases · Zoo-Code-Org/Zoo-Code · GitHub" -[3]: https://github.com/Zoo-Code-Org/Zoo-Code "GitHub - Zoo-Code-Org/Zoo-Code: Zoo Code gives you a whole dev team of AI agents in your code editor. · GitHub" From aae3d435b61cabdafad0240168bc39db4143f554 Mon Sep 17 00:00:00 2001 From: Gh0st <95618468+Gh0st352@users.noreply.github.com> Date: Sun, 23 Aug 2026 17:55:03 -0600 Subject: [PATCH 08/26] Uncomment check-types command in pre-push hook --- .husky/pre-push | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.husky/pre-push b/.husky/pre-push index d92bb6459e..4cf91d9580 100644 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -16,7 +16,7 @@ else fi fi -#$pnpm_cmd run check-types +$pnpm_cmd run check-types # Use dotenvx to securely load .env.local and run commands that depend on it if [ -f ".env.local" ]; then From 69322d169ff857ee8f0eaa41591a528050b210e2 Mon Sep 17 00:00:00 2001 From: Gh0st <95618468+Gh0st352@users.noreply.github.com> Date: Sun, 23 Aug 2026 17:55:12 -0600 Subject: [PATCH 09/26] Uncomment lint command in pre-commit hook --- .husky/pre-commit | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.husky/pre-commit b/.husky/pre-commit index c506aa2522..a0e3a53df5 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -24,4 +24,4 @@ else fi $npx_cmd lint-staged -# $pnpm_cmd lint +$pnpm_cmd lint From e80af412b92df57cf76cff62322fea8df1eb2d3c Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Sun, 23 Aug 2026 20:59:33 -0600 Subject: [PATCH 10/26] fix: address memory leak and improve transcript handling in ClineProvider and ExtensionStateContext - Added tests for posting snapshots and handling updates in Task.spec.ts to ensure proper functionality. - Enhanced ClineProvider to manage state and message posting for CLI consumers, including handling legacy updates. - Implemented timeout for transcript resync in ExtensionStateContext to prevent stale requests. - Updated tests in ExtensionStateContext.spec.ts to validate new resync logic and ensure proper handling of transcript messages. - Improved error handling and logging for message updates and snapshot processing. --- src/core/task/__tests__/Task.spec.ts | 118 +++++ src/core/webview/ClineProvider.ts | 15 +- .../webview/__tests__/ClineProvider.spec.ts | 247 ++++++++++- .../__tests__/webviewMessageHandler.spec.ts | 19 + .../src/context/ExtensionStateContext.tsx | 62 ++- .../__tests__/ExtensionStateContext.spec.tsx | 406 +++++++++++++++++- 6 files changed, 833 insertions(+), 34 deletions(-) diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index cab147526a..e8e7b1b11c 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -1931,6 +1931,30 @@ describe("Cline", () => { }) describe("webview transcript transport", () => { + it("posts a bumped snapshot after overwriting the transcript", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + const saveSpy = vi.spyOn(getTaskTestAccess(task), "saveClineMessages").mockResolvedValue(true) + const messages = [ + { + ts: 1, + type: "say" as const, + say: "text" as const, + text: "replacement transcript", + }, + ] + + await task.overwriteClineMessages(messages) + + expect(saveSpy).toHaveBeenCalledOnce() + expect(mockProvider.postClineMessagesSnapshot).toHaveBeenCalledOnce() + expect(mockProvider.postClineMessagesSnapshot).toHaveBeenCalledWith(task.taskId, { bumpSeq: true }) + }) + it("posts a complete new message through the incremental transport", async () => { const task = new Task({ provider: mockProvider, @@ -2436,6 +2460,70 @@ describe("Cline", () => { expect(cancelSpy).toHaveBeenCalled() }) describe("abortSignal", () => { + it("finalizes partial transcript messages and the API request before persisting cancellation", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + const taskAccess = getTaskTestAccess(task) + const saveSpy = vi.spyOn(taskAccess, "saveClineMessages").mockResolvedValue(true) + vi.spyOn(taskAccess, "safeEnsureModelFetched").mockResolvedValue(undefined) + vi.spyOn(task.diffViewProvider, "reset").mockResolvedValue(undefined) + vi.spyOn(task, "abortTask").mockResolvedValue(undefined) + vi.spyOn(task.api, "getModel").mockReturnValue({ + id: mockApiConfig.apiModelId!, + info: { + supportsImages: false, + supportsPromptCache: true, + contextWindow: 200000, + maxTokens: 4096, + inputPrice: 0.3, + outputPrice: 1.5, + } as ModelInfo, + }) + const postedUpdates: import("@roo-code/types").ClineMessage[] = [] + const updateSpy = vi + .mocked(mockProvider.postClineMessageUpdated) + .mockImplementation(async (_taskId, message) => { + postedUpdates.push(structuredClone(message)) + }) + const partialMessage: import("@roo-code/types").ClineMessage = { + ts: 2, + type: "say", + say: "text", + text: "partial response", + partial: true, + } + vi.spyOn(task, "attemptApiRequest").mockImplementation(() => + (async function* (): AsyncGenerator { + await taskAccess.addToClineMessages(partialMessage) + task.abort = true + yield { type: "usage", inputTokens: 0, outputTokens: 0 } + })(), + ) + + await expect( + task.recursivelyMakeClineRequests([{ type: "text", text: "cancel this request" }]), + ).resolves.toBe(true) + + expect(partialMessage.partial).toBe(false) + expect(postedUpdates).toContainEqual( + expect.objectContaining({ ts: partialMessage.ts, partial: false }), + ) + expect(postedUpdates).toContainEqual( + expect.objectContaining({ + say: "api_req_started", + text: expect.stringContaining('"cancelReason":"user_cancelled"'), + }), + ) + expect(task.didFinishAbortingStream).toBe(true) + expect(Math.max(...updateSpy.mock.invocationCallOrder)).toBeLessThan( + Math.max(...saveSpy.mock.invocationCallOrder), + ) + }) + it("should pass AbortController signal to condenseContext metadata when a current request exists", async () => { const task = new Task({ provider: mockProvider, @@ -3766,6 +3854,36 @@ describe("Cline", () => { boom, ) }) + + it("marks a follow-up answered and logs when its incremental update rejects", async () => { + const boom = new Error("follow-up update boom") + const updateSpy = vi.spyOn(getTaskTestAccess(Task.prototype), "updateClineMessage").mockRejectedValue(boom) + vi.spyOn(getTaskTestAccess(Task.prototype), "saveClineMessages").mockResolvedValue(true) + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + const followUp: import("@roo-code/types").ClineMessage = { + ts: Date.now() - 1, + type: "ask" as const, + ask: "followup" as const, + text: "question", + partial: false, + } + task.clineMessages.push(followUp) + + task.handleWebviewAskResponse("messageResponse", "answer") + await flushMicrotasks() + + expect(followUp.isAnswered).toBe(true) + expect(updateSpy).toHaveBeenCalledWith(followUp) + expect(consoleErrorSpy).toHaveBeenCalledWith( + "[Task#handleWebviewAskResponse] follow-up delta failed:", + boom, + ) + }) }) }) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index a136e0d883..8d9f6644d5 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -1470,8 +1470,10 @@ export class ClineProvider return } - // Generic state is metadata-only. Transcripts use the dedicated transport below. - if (message.type === "state" && message.state) { + // Browser webviews use the dedicated transcript transport below. The CLI + // still consumes transcript state and legacy updates until its clients adopt + // the sequence-aware protocol. + if (process.env.ROO_CLI_RUNTIME !== "1" && message.type === "state" && message.state) { const { clineMessages: _omitMessages, clineMessagesSeq: _omitMessagesSeq, ...metadataState } = message.state message = { ...message, state: metadataState } } @@ -1509,6 +1511,9 @@ export class ClineProvider if (this.getCurrentTask()?.taskId !== taskId) { return Promise.resolve() } + if (process.env.ROO_CLI_RUNTIME === "1") { + return this.postStateToWebviewWithoutTaskHistory() + } const seq = this.bumpClineMessagesSeq(taskId) const generation = this.clineMessagesTransportGeneration @@ -1530,6 +1535,9 @@ export class ClineProvider if (this.getCurrentTask()?.taskId !== taskId) { return Promise.resolve() } + if (process.env.ROO_CLI_RUNTIME === "1") { + return this.postMessageToWebview({ type: "messageUpdated", clineMessage: structuredClone(message) }) + } const seq = this.bumpClineMessagesSeq(taskId) const generation = this.clineMessagesTransportGeneration @@ -1555,6 +1563,9 @@ export class ClineProvider if ((currentTask?.taskId ?? undefined) !== taskId) { return Promise.resolve() } + if (process.env.ROO_CLI_RUNTIME === "1") { + return this.postStateToWebviewWithoutTaskHistory() + } const seq = taskId ? options.bumpSeq diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 6a8f236a84..be1320e8c1 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -447,6 +447,7 @@ describe("ClineProvider", () => { beforeEach(() => { vi.clearAllMocks() + delete process.env.ROO_CLI_RUNTIME if (!TelemetryService.hasInstance()) { TelemetryService.createInstance([]) @@ -884,6 +885,122 @@ describe("ClineProvider", () => { vi.spyOn(provider, "getCurrentTask").mockImplementation(() => task as Task | undefined) } + test("preserves legacy transcript messages for CLI consumers", async () => { + await provider.resolveWebviewView(mockWebviewView) + const previousCliRuntime = process.env.ROO_CLI_RUNTIME + process.env.ROO_CLI_RUNTIME = "1" + try { + const task = { + taskId: "task-1", + clineMessages: [{ ts: 1, type: "say", say: "text", text: "first" }] as ClineMessage[], + } + setCurrentTask(task) + mockPostMessage.mockClear() + + await provider.postClineMessageAppended("task-1", task.clineMessages[0]) + await provider.postClineMessageUpdated("task-1", { ...task.clineMessages[0], text: "updated" }) + await provider.postClineMessagesSnapshot("task-1", { bumpSeq: true }) + + expect(mockPostMessage).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + type: "state", + state: expect.objectContaining({ clineMessages: task.clineMessages }), + }), + ) + expect(mockPostMessage).toHaveBeenNthCalledWith(2, { + type: "messageUpdated", + clineMessage: expect.objectContaining({ text: "updated" }), + }) + expect(mockPostMessage).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ + type: "state", + state: expect.objectContaining({ clineMessages: task.clineMessages }), + }), + ) + } finally { + if (previousCliRuntime === undefined) { + delete process.env.ROO_CLI_RUNTIME + } else { + process.env.ROO_CLI_RUNTIME = previousCliRuntime + } + } + }) + + test("posts cloned append and update deltas in sequence", async () => { + await provider.resolveWebviewView(mockWebviewView) + const task = { taskId: "task-1", clineMessages: [] as ClineMessage[] } + setCurrentTask(task) + mockPostMessage.mockClear() + const appended = { ts: 1, type: "say", say: "text", text: "original" } as ClineMessage + const updated = { ...appended, text: "updated" } + + let releaseQueue!: () => void + Object.assign(provider, { + clineMessagesPostQueue: new Promise((resolve) => { + releaseQueue = resolve + }), + }) + const appendPost = provider.postClineMessageAppended("task-1", appended) + const updatePost = provider.postClineMessageUpdated("task-1", updated) + appended.text = "mutated after enqueue" + updated.text = "also mutated" + releaseQueue() + await Promise.all([appendPost, updatePost]) + + expect(mockPostMessage.mock.calls.map(([message]: [ExtensionMessage]) => message)).toEqual([ + { + type: "clineMessageAppended", + taskId: "task-1", + clineMessage: expect.objectContaining({ text: "original" }), + clineMessagesSeq: 1, + }, + { + type: "clineMessageUpdated", + taskId: "task-1", + clineMessage: expect.objectContaining({ text: "updated" }), + clineMessagesSeq: 2, + }, + ]) + }) + + test("ignores transcript work for a task that is not focused", async () => { + const task = { taskId: "task-1", clineMessages: [] as ClineMessage[] } + setCurrentTask(task) + const message = { ts: 1, type: "say", say: "text", text: "ignored" } as ClineMessage + const postSpy = vi.spyOn(provider, "postMessageToWebview") + + await Promise.all([ + provider.postClineMessageAppended("task-2", message), + provider.postClineMessageUpdated("task-2", message), + provider.postClineMessagesSnapshot("task-2"), + provider.resyncClineMessagesToWebview("task-2"), + ]) + + expect(postSpy).not.toHaveBeenCalled() + }) + + test("logs a failed delta post and continues processing the queue", async () => { + const task = { taskId: "task-1", clineMessages: [] as ClineMessage[] } + setCurrentTask(task) + const failure = new Error("post failed") + const postSpy = vi + .spyOn(provider, "postMessageToWebview") + .mockRejectedValueOnce(failure) + .mockResolvedValue(undefined) + const logSpy = vi.spyOn(provider, "log") + const message = { ts: 1, type: "say", say: "text", text: "message" } as ClineMessage + + await expect(provider.postClineMessageAppended("task-1", message)).rejects.toThrow("post failed") + await provider.postClineMessageUpdated("task-1", { ...message, text: "recovered" }) + + expect(logSpy).toHaveBeenCalledWith("[clineMessages] transport failure: post failed") + expect(postSpy).toHaveBeenLastCalledWith( + expect.objectContaining({ type: "clineMessageUpdated", clineMessagesSeq: 2 }), + ) + }) + test("posts ordered snapshot chunks followed by the end marker", async () => { await provider.resolveWebviewView(mockWebviewView) const messages = Array.from({ length: 401 }, (_, index) => ({ @@ -911,36 +1028,136 @@ describe("ClineProvider", () => { expect(new Set(posts.map(({ snapshotId }) => snapshotId)).size).toBe(1) }) - test("invalidates a queued old-focus delta before it reaches the webview", async () => { - await provider.resolveWebviewView(mockWebviewView) + test.each([ + ["append", "clineMessageAppended"], + ["update", "clineMessageUpdated"], + ] as const)( + "invalidates a queued old-focus %s delta before it reaches the webview", + async (operation, messageType) => { + await provider.resolveWebviewView(mockWebviewView) + const task = { taskId: "task-1", clineMessages: [] as ClineMessage[] } + setCurrentTask(task) + mockPostMessage.mockClear() + + let releaseQueue!: () => void + Object.assign(provider, { + clineMessagesPostQueue: new Promise((resolve) => { + releaseQueue = resolve + }), + }) + const message = { + ts: 1, + type: "say", + say: "text", + text: "queued", + } as ClineMessage + const pendingDelta = + operation === "append" + ? provider.postClineMessageAppended("task-1", message) + : provider.postClineMessageUpdated("task-1", message) + + task.taskId = "task-2" + const focusSync = provider.syncFocusedTaskToWebview() + releaseQueue() + await Promise.all([pendingDelta, focusSync]) + + expect(mockPostMessage).not.toHaveBeenCalledWith( + expect.objectContaining({ type: messageType, taskId: "task-1" }), + ) + expect(mockPostMessage).toHaveBeenCalledWith( + expect.objectContaining({ type: "clineMessagesSnapshotStart", taskId: "task-2" }), + ) + }, + ) + + test("drops a snapshot invalidated before its first post", async () => { const task = { taskId: "task-1", clineMessages: [] as ClineMessage[] } setCurrentTask(task) - mockPostMessage.mockClear() - + const postSpy = vi.spyOn(provider, "postMessageToWebview") let releaseQueue!: () => void Object.assign(provider, { clineMessagesPostQueue: new Promise((resolve) => { releaseQueue = resolve }), }) - const pendingDelta = provider.postClineMessageAppended("task-1", { + + const snapshot = provider.postClineMessagesSnapshot("task-1") + task.taskId = "task-2" + releaseQueue() + await snapshot + + expect(postSpy).not.toHaveBeenCalled() + }) + + test.each([ + ["after the start marker", "clineMessagesSnapshotStart", ["clineMessagesSnapshotStart"]], + [ + "after a chunk", + "clineMessagesSnapshotChunk", + ["clineMessagesSnapshotStart", "clineMessagesSnapshotChunk"], + ], + ])("stops a snapshot %s when focus changes", async (_description, invalidateAfterType, expectedTypes) => { + const task = { + taskId: "task-1", + clineMessages: [{ ts: 1, type: "say", say: "text", text: "message" }] as ClineMessage[], + } + setCurrentTask(task) + const postedTypes: string[] = [] + vi.spyOn(provider, "postMessageToWebview").mockImplementation(async (message) => { + postedTypes.push(message.type) + if (message.type === invalidateAfterType) { + task.taskId = "task-2" + } + }) + + await provider.postClineMessagesSnapshot("task-1") + + expect(postedTypes).toEqual(expectedTypes) + }) + + test("resyncs the focused task with the current sequence", async () => { + const task = { taskId: "task-1", clineMessages: [] as ClineMessage[] } + setCurrentTask(task) + const postSpy = vi.spyOn(provider, "postMessageToWebview").mockResolvedValue(undefined) + + await provider.postClineMessageAppended("task-1", { ts: 1, type: "say", say: "text", - text: "queued", + text: "first", }) + postSpy.mockClear() + await provider.resyncClineMessagesToWebview("task-1") + + expect(postSpy).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ type: "clineMessagesSnapshotStart", taskId: "task-1", clineMessagesSeq: 1 }), + ) + }) + + test("abandons an older focus sync when a resync invalidates its state post", async () => { + const task = { taskId: "task-1", clineMessages: [] as ClineMessage[] } + setCurrentTask(task) + let releaseStatePost!: () => void + const statePostStarted = new Promise((resolve) => { + vi.spyOn(provider, "postStateToWebviewWithoutTaskHistory").mockImplementation( + () => + new Promise((release) => { + releaseStatePost = release + resolve() + }), + ) + }) + const snapshotSpy = vi.spyOn(provider, "postClineMessagesSnapshot") - task.taskId = "task-2" const focusSync = provider.syncFocusedTaskToWebview() - releaseQueue() - await Promise.all([pendingDelta, focusSync]) + await statePostStarted + const resync = provider.resyncClineMessagesToWebview("task-1") + releaseStatePost() + await Promise.all([focusSync, resync]) - expect(mockPostMessage).not.toHaveBeenCalledWith( - expect.objectContaining({ type: "clineMessageAppended", taskId: "task-1" }), - ) - expect(mockPostMessage).toHaveBeenCalledWith( - expect.objectContaining({ type: "clineMessagesSnapshotStart", taskId: "task-2" }), - ) + expect(snapshotSpy).toHaveBeenCalledOnce() + expect(snapshotSpy).toHaveBeenCalledWith("task-1", { generation: expect.any(Number) }) }) }) diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index 4b375115da..e7ad0de694 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -118,6 +118,7 @@ const mockClineProvider = { log: vi.fn(), postStateToWebview: vi.fn(), syncFocusedTaskToWebview: vi.fn().mockResolvedValue(undefined), + resyncClineMessagesToWebview: vi.fn().mockResolvedValue(undefined), resolveWebviewThemeFixtureProbe: vi.fn(), getCurrentTask: vi.fn(), getTaskWithId: vi.fn(), @@ -126,6 +127,24 @@ const mockClineProvider = { cwd: "/mock/workspace", } as unknown as ClineProvider +describe("webviewMessageHandler - transcript resync", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("delegates a task-scoped transcript resync to the provider", async () => { + await webviewMessageHandler(mockClineProvider, { + type: "requestClineMessagesResync", + taskId: "task-1", + expectedSeq: 4, + receivedSeq: 7, + }) + + expect(mockClineProvider.resyncClineMessagesToWebview).toHaveBeenCalledOnce() + expect(mockClineProvider.resyncClineMessagesToWebview).toHaveBeenCalledWith("task-1") + }) +}) + describe("webviewMessageHandler - theme fixture probes", () => { const originalProbeSetting = process.env.ROO_CODE_THEME_FIXTURE_PROBE const themeFixture = { diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index 9be84271b4..5c26627f6f 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -165,6 +165,8 @@ type ClineMessagesSnapshotBuffer = { messages: ClineMessage[] } +const CLINE_MESSAGES_RESYNC_TIMEOUT_MS = 5_000 + export const mergeExtensionState = (prevState: ExtensionState, newState: Partial) => { const { customModePrompts: prevCustomModePrompts, experiments: prevExperiments, ...prevRest } = prevState @@ -286,6 +288,7 @@ export const ExtensionStateContextProvider: React.FC<{ const clineMessagesRef = useRef(state.clineMessages) const activeSnapshotRef = useRef(null) const resyncPendingRef = useRef(false) + const resyncTimeoutRef = useRef(undefined) const [didHydrateState, setDidHydrateState] = useState(false) const [showWelcome, setShowWelcome] = useState(false) @@ -335,11 +338,23 @@ export const ExtensionStateContextProvider: React.FC<{ })) }, []) + const clearClineMessagesResync = useCallback(() => { + resyncPendingRef.current = false + if (resyncTimeoutRef.current !== undefined) { + window.clearTimeout(resyncTimeoutRef.current) + resyncTimeoutRef.current = undefined + } + }, []) + const requestClineMessagesResync = useCallback((receivedSeq?: number) => { if (resyncPendingRef.current) { return } resyncPendingRef.current = true + resyncTimeoutRef.current = window.setTimeout(() => { + resyncPendingRef.current = false + resyncTimeoutRef.current = undefined + }, CLINE_MESSAGES_RESYNC_TIMEOUT_MS) vscode.postMessage({ type: "requestClineMessagesResync", taskId: activeTaskIdRef.current, @@ -348,6 +363,14 @@ export const ExtensionStateContextProvider: React.FC<{ }) }, []) + const retryClineMessagesResync = useCallback( + (receivedSeq?: number) => { + clearClineMessagesResync() + requestClineMessagesResync(receivedSeq) + }, + [clearClineMessagesResync, requestClineMessagesResync], + ) + const applyClineMessagesDelta = useCallback( (message: ExtensionMessage, operation: "append" | "update") => { const seq = message.clineMessagesSeq @@ -368,7 +391,7 @@ export const ExtensionStateContextProvider: React.FC<{ return } activeSnapshotRef.current = null - requestClineMessagesResync(seq) + retryClineMessagesResync(seq) return } if (seq <= clineMessagesSeqRef.current) { @@ -400,7 +423,7 @@ export const ExtensionStateContextProvider: React.FC<{ clineMessagesSeq: seq, })) }, - [requestClineMessagesResync], + [requestClineMessagesResync, retryClineMessagesResync], ) const handleMessage = useCallback( @@ -421,7 +444,7 @@ export const ExtensionStateContextProvider: React.FC<{ clineMessagesSeqRef.current = 0 clineMessagesRef.current = [] activeSnapshotRef.current = null - resyncPendingRef.current = false + clearClineMessagesResync() } setState((prevState) => { const merged = mergeExtensionState(prevState, newState) @@ -496,7 +519,7 @@ export const ExtensionStateContextProvider: React.FC<{ const seq = message.clineMessagesSeq if (typeof seq !== "number" || !Number.isSafeInteger(seq) || seq < 0) { activeSnapshotRef.current = null - requestClineMessagesResync(typeof seq === "number" ? seq : undefined) + retryClineMessagesResync(typeof seq === "number" ? seq : undefined) break } if (seq < clineMessagesSeqRef.current) { @@ -506,7 +529,7 @@ export const ExtensionStateContextProvider: React.FC<{ const total = message.snapshotTotal if (!message.snapshotId || typeof total !== "number" || !Number.isSafeInteger(total) || total < 0) { activeSnapshotRef.current = null - requestClineMessagesResync(seq) + retryClineMessagesResync(seq) break } @@ -536,19 +559,19 @@ export const ExtensionStateContextProvider: React.FC<{ const snapshot = activeSnapshotRef.current if (typeof seq !== "number" || !Number.isSafeInteger(seq) || seq < 0) { activeSnapshotRef.current = null - requestClineMessagesResync(typeof seq === "number" ? seq : undefined) + retryClineMessagesResync(typeof seq === "number" ? seq : undefined) break } if (!snapshot) { if (seq > clineMessagesSeqRef.current) { - requestClineMessagesResync(seq) + retryClineMessagesResync(seq) } break } if (message.snapshotId !== snapshot.snapshotId || seq !== snapshot.seq) { if (seq > snapshot.seq) { activeSnapshotRef.current = null - requestClineMessagesResync(seq) + retryClineMessagesResync(seq) } break } @@ -564,7 +587,7 @@ export const ExtensionStateContextProvider: React.FC<{ snapshot.messages.length + chunk.length > snapshot.total ) { activeSnapshotRef.current = null - requestClineMessagesResync(seq) + retryClineMessagesResync(seq) break } @@ -580,30 +603,30 @@ export const ExtensionStateContextProvider: React.FC<{ const snapshot = activeSnapshotRef.current if (typeof seq !== "number" || !Number.isSafeInteger(seq) || seq < 0) { activeSnapshotRef.current = null - requestClineMessagesResync(typeof seq === "number" ? seq : undefined) + retryClineMessagesResync(typeof seq === "number" ? seq : undefined) break } if (!snapshot) { if (seq > clineMessagesSeqRef.current) { - requestClineMessagesResync(seq) + retryClineMessagesResync(seq) } break } if (message.snapshotId !== snapshot.snapshotId || seq !== snapshot.seq) { if (seq > snapshot.seq) { activeSnapshotRef.current = null - requestClineMessagesResync(seq) + retryClineMessagesResync(seq) } break } if (message.snapshotTotal !== snapshot.total || snapshot.messages.length !== snapshot.total) { activeSnapshotRef.current = null - requestClineMessagesResync(seq) + retryClineMessagesResync(seq) break } activeSnapshotRef.current = null - resyncPendingRef.current = false + clearClineMessagesResync() clineMessagesRef.current = snapshot.messages clineMessagesSeqRef.current = snapshot.seq setState((prevState) => ({ @@ -704,15 +727,22 @@ export const ExtensionStateContextProvider: React.FC<{ } } }, - [applyClineMessagesDelta, requestClineMessagesResync, setListApiConfigMeta], + [ + applyClineMessagesDelta, + clearClineMessagesResync, + requestClineMessagesResync, + retryClineMessagesResync, + setListApiConfigMeta, + ], ) useEffect(() => { window.addEventListener("message", handleMessage) return () => { window.removeEventListener("message", handleMessage) + clearClineMessagesResync() } - }, [handleMessage]) + }, [clearClineMessagesResync, handleMessage]) useEffect(() => { vscode.postMessage({ type: "webviewDidLaunch" }) diff --git a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx index edcc78405c..acdd3de42b 100644 --- a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx +++ b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx @@ -1,5 +1,5 @@ import { providerIdentifiers } from "@roo-code/types" -import { render, screen, act } from "@/utils/test-utils" +import { render, screen, act, appendClineMessage, hydrateExtensionState } from "@/utils/test-utils" import React from "react" import { @@ -420,6 +420,12 @@ describe("ExtensionStateContext", () => { describe("dedicated transcript transport", () => { const readTranscript = () => JSON.parse(screen.getByTestId("transcript-state").textContent!) + const renderTranscript = (initialState: Partial = {}) => + render( + + + , + ) it("reconstructs a snapshot and applies contiguous append and update deltas", () => { render( @@ -546,6 +552,404 @@ describe("ExtensionStateContext", () => { postMessage.mockRestore() } }) + + it("retires a failed resync and recovers from a replacement snapshot", () => { + const first = makeMessage(1, "first") + const recovered = makeMessage(2, "recovered") + const postMessage = vi.spyOn(vscode, "postMessage").mockImplementation(() => undefined) + try { + renderTranscript({ clineMessages: [first], clineMessagesSeq: 1 }) + postMessage.mockClear() + + act(() => { + dispatchExtensionMessage({ + type: "clineMessageAppended", + taskId: "task-1", + clineMessagesSeq: 3, + clineMessage: makeMessage(3, "gap"), + }) + dispatchExtensionMessage({ + type: "clineMessagesSnapshotStart", + taskId: "task-1", + clineMessagesSeq: 3, + snapshotId: "invalid-snapshot", + snapshotTotal: 2, + }) + dispatchExtensionMessage({ + type: "clineMessagesSnapshotChunk", + taskId: "task-1", + clineMessagesSeq: 3, + snapshotId: "invalid-snapshot", + snapshotStartIndex: 1, + clineMessages: [first], + }) + }) + + expect(postMessage).toHaveBeenCalledTimes(2) + expect(postMessage).toHaveBeenLastCalledWith({ + type: "requestClineMessagesResync", + taskId: "task-1", + expectedSeq: 2, + receivedSeq: 3, + }) + + act(() => { + dispatchExtensionMessage({ + type: "clineMessagesSnapshotStart", + taskId: "task-1", + clineMessagesSeq: 3, + snapshotId: "replacement-snapshot", + snapshotTotal: 2, + }) + dispatchExtensionMessage({ + type: "clineMessagesSnapshotChunk", + taskId: "task-1", + clineMessagesSeq: 3, + snapshotId: "replacement-snapshot", + snapshotStartIndex: 0, + clineMessages: [first, recovered], + }) + dispatchExtensionMessage({ + type: "clineMessagesSnapshotEnd", + taskId: "task-1", + clineMessagesSeq: 3, + snapshotId: "replacement-snapshot", + snapshotTotal: 2, + }) + dispatchExtensionMessage({ + type: "clineMessageAppended", + taskId: "task-1", + clineMessagesSeq: 4, + clineMessage: makeMessage(4, "after recovery"), + }) + }) + + expect(readTranscript()).toEqual({ + currentTaskId: "task-1", + clineMessages: [first, recovered, makeMessage(4, "after recovery")], + clineMessagesSeq: 4, + }) + } finally { + postMessage.mockRestore() + } + }) + + it("allows another resync when a response is lost", async () => { + vi.useFakeTimers() + const postMessage = vi.spyOn(vscode, "postMessage").mockImplementation(() => undefined) + try { + renderTranscript({ clineMessagesSeq: 1 }) + postMessage.mockClear() + + act(() => { + appendClineMessage(makeMessage(3, "gap"), 3, "task-1") + appendClineMessage(makeMessage(4, "suppressed while pending"), 4, "task-1") + }) + expect(postMessage).toHaveBeenCalledTimes(1) + + await act(async () => { + await vi.advanceTimersByTimeAsync(5_000) + }) + act(() => appendClineMessage(makeMessage(5, "retry"), 5, "task-1")) + + expect(postMessage).toHaveBeenCalledTimes(2) + expect(postMessage).toHaveBeenLastCalledWith( + expect.objectContaining({ + type: "requestClineMessagesResync", + expectedSeq: 2, + receivedSeq: 5, + }), + ) + } finally { + postMessage.mockRestore() + vi.useRealTimers() + } + }) + + it("rejects malformed deltas and updates to unknown messages", () => { + const first = makeMessage(1, "first") + const postMessage = vi.spyOn(vscode, "postMessage").mockImplementation(() => undefined) + try { + renderTranscript({ clineMessages: [first], clineMessagesSeq: 1 }) + postMessage.mockClear() + + act(() => { + dispatchExtensionMessage({ type: "clineMessageAppended", taskId: "task-1" }) + dispatchExtensionMessage({ + type: "clineMessagesSnapshotStart", + taskId: "task-1", + clineMessagesSeq: 1, + snapshotId: "same-sequence", + snapshotTotal: 1, + }) + dispatchExtensionMessage({ + type: "clineMessagesSnapshotChunk", + taskId: "task-1", + clineMessagesSeq: 1, + snapshotId: "same-sequence", + snapshotStartIndex: 0, + clineMessages: [first], + }) + dispatchExtensionMessage({ + type: "clineMessagesSnapshotEnd", + taskId: "task-1", + clineMessagesSeq: 1, + snapshotId: "same-sequence", + snapshotTotal: 1, + }) + dispatchExtensionMessage({ + type: "clineMessageUpdated", + taskId: "task-1", + clineMessagesSeq: 2, + clineMessage: makeMessage(99, "unknown"), + }) + }) + + expect(postMessage).toHaveBeenCalledTimes(2) + expect(postMessage).toHaveBeenLastCalledWith( + expect.objectContaining({ type: "requestClineMessagesResync", receivedSeq: 2 }), + ) + expect(readTranscript().clineMessages).toEqual([first]) + } finally { + postMessage.mockRestore() + } + }) + + it("ignores covered and stale deltas but restarts after a newer delta interleaves", () => { + const first = makeMessage(1, "first") + const postMessage = vi.spyOn(vscode, "postMessage").mockImplementation(() => undefined) + try { + renderTranscript({ clineMessages: [first], clineMessagesSeq: 1 }) + postMessage.mockClear() + + act(() => { + dispatchExtensionMessage({ + type: "clineMessagesSnapshotStart", + taskId: "task-1", + clineMessagesSeq: 4, + snapshotId: "in-flight", + snapshotTotal: 1, + }) + appendClineMessage(makeMessage(4, "already covered"), 4, "task-1") + appendClineMessage(makeMessage(5, "interleaved"), 5, "task-1") + appendClineMessage(makeMessage(1, "stale"), 1, "task-1") + }) + + expect(postMessage).toHaveBeenCalledTimes(1) + expect(postMessage).toHaveBeenCalledWith( + expect.objectContaining({ type: "requestClineMessagesResync", receivedSeq: 5 }), + ) + expect(readTranscript()).toEqual({ + currentTaskId: "task-1", + clineMessages: [first], + clineMessagesSeq: 1, + }) + } finally { + postMessage.mockRestore() + } + }) + + it("validates snapshot starts and ignores stale or duplicate starts", () => { + const postMessage = vi.spyOn(vscode, "postMessage").mockImplementation(() => undefined) + try { + renderTranscript({ clineMessagesSeq: 1 }) + postMessage.mockClear() + + act(() => { + dispatchExtensionMessage({ + type: "clineMessagesSnapshotStart", + taskId: "other-task", + clineMessagesSeq: 2, + snapshotId: "wrong-task", + snapshotTotal: 0, + }) + dispatchExtensionMessage({ + type: "clineMessagesSnapshotStart", + taskId: "task-1", + clineMessagesSeq: -1, + snapshotId: "invalid-sequence", + snapshotTotal: 0, + }) + dispatchExtensionMessage({ + type: "clineMessagesSnapshotStart", + taskId: "task-1", + clineMessagesSeq: 1, + snapshotId: "stale", + snapshotTotal: 0, + }) + dispatchExtensionMessage({ + type: "clineMessagesSnapshotStart", + taskId: "task-1", + clineMessagesSeq: 4, + snapshotId: "newest", + snapshotTotal: 0, + }) + dispatchExtensionMessage({ + type: "clineMessagesSnapshotStart", + taskId: "task-1", + clineMessagesSeq: 4, + snapshotId: "newest", + snapshotTotal: 0, + }) + dispatchExtensionMessage({ + type: "clineMessagesSnapshotStart", + taskId: "task-1", + clineMessagesSeq: 3, + snapshotId: "older-active", + snapshotTotal: 0, + }) + dispatchExtensionMessage({ + type: "clineMessagesSnapshotStart", + taskId: "task-1", + clineMessagesSeq: 5, + snapshotId: "", + snapshotTotal: -1, + }) + }) + + expect(postMessage).toHaveBeenCalledTimes(2) + expect(postMessage.mock.calls.map(([message]) => message.receivedSeq)).toEqual([-1, 5]) + } finally { + postMessage.mockRestore() + } + }) + + it("rejects missing, mismatched, and incomplete snapshot chunks and endings", () => { + const postMessage = vi.spyOn(vscode, "postMessage").mockImplementation(() => undefined) + try { + renderTranscript({ clineMessagesSeq: 1 }) + postMessage.mockClear() + + act(() => { + dispatchExtensionMessage({ + type: "clineMessagesSnapshotChunk", + taskId: "other-task", + clineMessagesSeq: 2, + snapshotId: "ignored", + snapshotStartIndex: 0, + clineMessages: [makeMessage(1, "ignored")], + }) + dispatchExtensionMessage({ + type: "clineMessagesSnapshotChunk", + taskId: "task-1", + clineMessagesSeq: 2, + snapshotId: "missing-start", + snapshotStartIndex: 0, + clineMessages: [makeMessage(1, "missing")], + }) + dispatchExtensionMessage({ + type: "clineMessagesSnapshotStart", + taskId: "task-1", + clineMessagesSeq: 3, + snapshotId: "chunk-check", + snapshotTotal: 1, + }) + dispatchExtensionMessage({ + type: "clineMessagesSnapshotChunk", + taskId: "task-1", + clineMessagesSeq: 4, + snapshotId: "newer-mismatch", + snapshotStartIndex: 0, + clineMessages: [makeMessage(1, "mismatch")], + }) + dispatchExtensionMessage({ + type: "clineMessagesSnapshotStart", + taskId: "task-1", + clineMessagesSeq: 5, + snapshotId: "bad-chunk", + snapshotTotal: 1, + }) + dispatchExtensionMessage({ + type: "clineMessagesSnapshotChunk", + taskId: "task-1", + clineMessagesSeq: 5, + snapshotId: "bad-chunk", + snapshotStartIndex: 1, + clineMessages: [makeMessage(1, "bad index")], + }) + dispatchExtensionMessage({ + type: "clineMessagesSnapshotEnd", + taskId: "other-task", + clineMessagesSeq: 6, + snapshotId: "ignored-end", + snapshotTotal: 0, + }) + dispatchExtensionMessage({ + type: "clineMessagesSnapshotEnd", + taskId: "task-1", + clineMessagesSeq: 6, + snapshotId: "missing-end-start", + snapshotTotal: 0, + }) + dispatchExtensionMessage({ + type: "clineMessagesSnapshotStart", + taskId: "task-1", + clineMessagesSeq: 7, + snapshotId: "incomplete", + snapshotTotal: 1, + }) + dispatchExtensionMessage({ + type: "clineMessagesSnapshotEnd", + taskId: "task-1", + clineMessagesSeq: 7, + snapshotId: "incomplete", + snapshotTotal: 1, + }) + }) + + expect(postMessage).toHaveBeenCalledTimes(5) + expect(readTranscript()).toEqual({ currentTaskId: "task-1", clineMessages: [], clineMessagesSeq: 1 }) + } finally { + postMessage.mockRestore() + } + }) + + it("requests recovery for legacy unsequenced updates", () => { + const postMessage = vi.spyOn(vscode, "postMessage").mockImplementation(() => undefined) + try { + renderTranscript({ clineMessagesSeq: 2 }) + postMessage.mockClear() + + act(() => dispatchExtensionMessage({ type: "messageUpdated", clineMessagesSeq: 9 })) + + expect(postMessage).toHaveBeenCalledWith({ + type: "requestClineMessagesResync", + taskId: "task-1", + expectedSeq: 3, + receivedSeq: 9, + }) + } finally { + postMessage.mockRestore() + } + }) + + it("hydrates metadata, non-empty transcripts, and empty transcripts through shared helpers", () => { + renderTranscript({ clineMessages: [makeMessage(1, "existing")], clineMessagesSeq: 1 }) + + act(() => { + hydrateExtensionState({ version: "2.0.0" }) + }) + expect(readTranscript().clineMessages).toEqual([makeMessage(1, "existing")]) + + act(() => { + hydrateExtensionState({ + currentTaskId: "task-1", + clineMessages: [makeMessage(2, "hydrated")], + clineMessagesSeq: 4, + }) + appendClineMessage(makeMessage(3, "appended"), 5, "task-1") + }) + expect(readTranscript()).toEqual({ + currentTaskId: "task-1", + clineMessages: [makeMessage(2, "hydrated"), makeMessage(3, "appended")], + clineMessagesSeq: 5, + }) + + act(() => { + hydrateExtensionState({ clineMessages: [] }, { taskId: "task-1", clineMessagesSeq: 6 }) + }) + expect(readTranscript()).toEqual({ currentTaskId: "task-1", clineMessages: [], clineMessagesSeq: 6 }) + }) }) }) From e93f6c285bddce596ba17780f8398eaadcccb6d8 Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Sun, 23 Aug 2026 21:14:54 -0600 Subject: [PATCH 11/26] fix: address transcript synchronization review findings --- src/core/webview/ClineProvider.ts | 5 +++ .../webview/__tests__/ClineProvider.spec.ts | 21 +++++++++++ .../webviewMessageHandler.delete.spec.ts | 28 ++++++++++++++ .../webviewMessageHandler.edit.spec.ts | 37 +++++++++++++++++++ src/core/webview/webviewMessageHandler.ts | 8 +++- 5 files changed, 97 insertions(+), 2 deletions(-) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 8d9f6644d5..4abbf0dcbd 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -610,6 +610,7 @@ export class ClineProvider } if (task) { + this.clineMessagesSeqByTaskId.delete(task.taskId) task.emit(RooCodeEventName.TaskUnfocused) try { @@ -2530,6 +2531,9 @@ export class ClineProvider // Delete all tasks from state in one batch await this.taskHistoryStore.deleteMany(allIdsToDelete) + for (const taskId of allIdsToDelete) { + this.clineMessagesSeqByTaskId.delete(taskId) + } this.recentTasksCache = undefined // Delete associated shadow repositories or branches and task directories @@ -2572,6 +2576,7 @@ export class ClineProvider async deleteTaskFromState(id: string) { await this.taskHistoryStore.delete(id) + this.clineMessagesSeqByTaskId.delete(id) this.recentTasksCache = undefined await this.postStateToWebview() diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index be1320e8c1..391465ffac 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -1135,6 +1135,27 @@ describe("ClineProvider", () => { ) }) + test("prunes sequence state when a task leaves the stack", async () => { + const task = new Task(defaultTaskOptions) + Object.defineProperty(task, "taskId", { value: "task-to-remove", writable: true }) + await provider.addClineToStack(task) + provider["clineMessagesSeqByTaskId"].set(task.taskId, 4) + + await provider.removeClineFromStack() + + expect(provider["clineMessagesSeqByTaskId"].has(task.taskId)).toBe(false) + }) + + test("prunes sequence state when a task is deleted from history", async () => { + provider["clineMessagesSeqByTaskId"].set("deleted-task", 4) + vi.spyOn(provider.taskHistoryStore, "delete").mockResolvedValue(undefined) + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + + await provider.deleteTaskFromState("deleted-task") + + expect(provider["clineMessagesSeqByTaskId"].has("deleted-task")).toBe(false) + }) + test("abandons an older focus sync when a resync invalidates its state post", async () => { const task = { taskId: "task-1", clineMessages: [] as ClineMessage[] } setCurrentTask(task) diff --git a/src/core/webview/__tests__/webviewMessageHandler.delete.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.delete.spec.ts index ef2bee3f6d..41534ff837 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.delete.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.delete.spec.ts @@ -247,6 +247,34 @@ describe("webviewMessageHandler delete functionality", () => { ]) }) + it("publishes restored checkpoint metadata after deleting messages", async () => { + const checkpoint = { hash: "checkpoint-hash", type: "user_message" } + const preservedMessage = { ts: 1000, say: "user", text: "First message", checkpoint } + getCurrentTaskMock.clineMessages = [preservedMessage, { ts: 2000, say: "user", text: "Delete this" }] + getCurrentTaskMock.apiConversationHistory = [ + { ts: 1000, role: "user", content: { type: "text", text: "First message" } }, + { ts: 2000, role: "user", content: { type: "text", text: "Delete this" } }, + ] + getCurrentTaskMock.overwriteClineMessages.mockImplementation( + async (messages: (typeof preservedMessage)[]) => { + getCurrentTaskMock.clineMessages = structuredClone(messages).map((message) => { + const { checkpoint: _checkpoint, ...withoutCheckpoint } = message + return withoutCheckpoint + }) + }, + ) + + await webviewMessageHandler(provider, { + type: "deleteMessageConfirm", + messageTs: 2000, + }) + + expect(getCurrentTaskMock.overwriteClineMessages).toHaveBeenCalledTimes(2) + expect(getCurrentTaskMock.overwriteClineMessages).toHaveBeenLastCalledWith([ + expect.objectContaining({ ts: 1000, checkpoint }), + ]) + }) + describe("condense preservation behavior", () => { it("should preserve summary and condensed messages when deleting after the summary", async () => { // Design: Rewind/delete preserves summaries that were created BEFORE the rewind point. diff --git a/src/core/webview/__tests__/webviewMessageHandler.edit.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.edit.spec.ts index 523f03e1c2..422f830eff 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.edit.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.edit.spec.ts @@ -214,6 +214,43 @@ describe("webviewMessageHandler - Edit Message with Timestamp Fallback", () => { ]) }) + it("publishes restored checkpoint metadata before submitting an edited message", async () => { + const checkpoint = { hash: "checkpoint-hash", type: "user_message" } + const preservedMessage = { + ts: 500, + type: "say", + say: "user_feedback", + text: "Earlier message", + checkpoint, + } as ClineMessage + mockCurrentTask.clineMessages = [ + preservedMessage, + { ts: 1000, type: "say", say: "user_feedback", text: "Edit me" } as ClineMessage, + ] + mockCurrentTask.apiConversationHistory = [ + { ts: 500, role: "user", content: [{ type: "text", text: "Earlier message" }] }, + { ts: 1000, role: "user", content: [{ type: "text", text: "Edit me" }] }, + ] as ApiMessage[] + mockCurrentTask.overwriteClineMessages.mockImplementation(async (messages: ClineMessage[]) => { + mockCurrentTask.clineMessages = structuredClone(messages).map((message) => { + const { checkpoint: _checkpoint, ...withoutCheckpoint } = message + return withoutCheckpoint + }) + }) + + await webviewMessageHandler(mockClineProvider, { + type: "editMessageConfirm", + messageTs: 1000, + text: "Edited message", + restoreCheckpoint: false, + }) + + expect(mockCurrentTask.overwriteClineMessages).toHaveBeenCalledTimes(2) + expect(mockCurrentTask.overwriteClineMessages).toHaveBeenLastCalledWith([ + expect.objectContaining({ ts: 500, checkpoint }), + ]) + }) + it("should not use fallback when exact apiConversationHistoryIndex is found", async () => { const userMessageTs = 1000 const assistantMessageTs = 2000 diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index ff4c8ed691..4941fe1b29 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -369,8 +369,9 @@ export const webviewMessageHandler = async ( globalStoragePath: provider.contextProxy.globalStorageUri.fsPath, }) - // Rewind already posts a snapshot. Checkpoint metadata is not rendered - // in transcript rows, so persisting it does not require a second snapshot. + // Rewind posts before checkpoint metadata is restored. Publish the + // persisted transcript so checkpoint filtering and controls stay current. + await currentCline.overwriteClineMessages(currentCline.clineMessages) } } catch (error) { console.error("Error in delete message:", error) @@ -539,6 +540,9 @@ export const webviewMessageHandler = async ( globalStoragePath: provider.contextProxy.globalStorageUri.fsPath, }) + // Rewind posts before checkpoint metadata is restored. Publish that + // restored state before the edited message starts a new delta stream. + await currentCline.overwriteClineMessages(currentCline.clineMessages) await currentCline.submitUserMessage(editedContent, images) } catch (error) { console.error("Error in edit message:", error) From 4a9c62fe42bc98d4553cd8c2e5734bb88017c9b2 Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Sun, 23 Aug 2026 21:24:03 -0600 Subject: [PATCH 12/26] test: initialize transcript sequence state in provider stubs --- src/__tests__/helpers/provider-stub.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/__tests__/helpers/provider-stub.ts b/src/__tests__/helpers/provider-stub.ts index ccb990e7d5..3a4953cd41 100644 --- a/src/__tests__/helpers/provider-stub.ts +++ b/src/__tests__/helpers/provider-stub.ts @@ -5,6 +5,7 @@ import { type Task } from "../../core/task/Task" type ProviderStubFields = { delegationTransitionLocks?: Map> cancelledDelegationChildIds?: Set + clineMessagesSeqByTaskId?: Map log?: ReturnType syncFocusedTaskToWebview?: ReturnType taskHistoryStore?: { get: (id: string) => unknown } @@ -37,6 +38,7 @@ export function makeProviderStub(stub: T): ClineProvider { const proto = ClineProvider.prototype as unknown as PrivateProviderMethods s.delegationTransitionLocks ??= new Map() s.cancelledDelegationChildIds ??= new Set() + s.clineMessagesSeqByTaskId ??= new Map() s.log ??= vi.fn() s.syncFocusedTaskToWebview ??= vi.fn().mockResolvedValue(undefined) s.taskHistoryStore ??= { get: () => undefined } From 781fa0594013bd7dbc4399ecc2e262aaa45f0380 Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Sun, 23 Aug 2026 21:39:46 -0600 Subject: [PATCH 13/26] test: exercise edited message submission --- .../webview/__tests__/webviewMessageHandler.edit.spec.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/core/webview/__tests__/webviewMessageHandler.edit.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.edit.spec.ts index 422f830eff..7e71b7b992 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.edit.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.edit.spec.ts @@ -59,6 +59,7 @@ describe("webviewMessageHandler - Edit Message with Timestamp Fallback", () => { overwriteClineMessages: vi.fn(), overwriteApiConversationHistory: vi.fn(), handleWebviewAskResponse: vi.fn(), + submitUserMessage: vi.fn(), } mockCurrentTask.messageManager = new MessageManager(mockCurrentTask) @@ -249,6 +250,10 @@ describe("webviewMessageHandler - Edit Message with Timestamp Fallback", () => { expect(mockCurrentTask.overwriteClineMessages).toHaveBeenLastCalledWith([ expect.objectContaining({ ts: 500, checkpoint }), ]) + expect(mockCurrentTask.submitUserMessage).toHaveBeenCalledWith("Edited message", []) + expect(mockCurrentTask.overwriteClineMessages.mock.invocationCallOrder[1]).toBeLessThan( + mockCurrentTask.submitUserMessage.mock.invocationCallOrder[0], + ) }) it("should not use fallback when exact apiConversationHistoryIndex is found", async () => { From 291c3cd9c989154018b5af1b0c890885ed33a3b7 Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Sun, 23 Aug 2026 21:59:24 -0600 Subject: [PATCH 14/26] test: verify transcript republish completion --- .../__tests__/webviewMessageHandler.edit.spec.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/core/webview/__tests__/webviewMessageHandler.edit.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.edit.spec.ts index 7e71b7b992..4a873597b9 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.edit.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.edit.spec.ts @@ -232,11 +232,18 @@ describe("webviewMessageHandler - Edit Message with Timestamp Fallback", () => { { ts: 500, role: "user", content: [{ type: "text", text: "Earlier message" }] }, { ts: 1000, role: "user", content: [{ type: "text", text: "Edit me" }] }, ] as ApiMessage[] + let completedOverwrites = 0 + let submitObservedCompletedOverwrites = 0 mockCurrentTask.overwriteClineMessages.mockImplementation(async (messages: ClineMessage[]) => { + await Promise.resolve() mockCurrentTask.clineMessages = structuredClone(messages).map((message) => { const { checkpoint: _checkpoint, ...withoutCheckpoint } = message return withoutCheckpoint }) + completedOverwrites += 1 + }) + mockCurrentTask.submitUserMessage.mockImplementation(() => { + submitObservedCompletedOverwrites = completedOverwrites }) await webviewMessageHandler(mockClineProvider, { @@ -251,9 +258,7 @@ describe("webviewMessageHandler - Edit Message with Timestamp Fallback", () => { expect.objectContaining({ ts: 500, checkpoint }), ]) expect(mockCurrentTask.submitUserMessage).toHaveBeenCalledWith("Edited message", []) - expect(mockCurrentTask.overwriteClineMessages.mock.invocationCallOrder[1]).toBeLessThan( - mockCurrentTask.submitUserMessage.mock.invocationCallOrder[0], - ) + expect(submitObservedCompletedOverwrites).toBe(2) }) it("should not use fallback when exact apiConversationHistoryIndex is found", async () => { From 44b980cb6728593accfd156268bafab417e5963f Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Sun, 23 Aug 2026 23:38:12 -0600 Subject: [PATCH 15/26] fix(webview): clear focused task without reload --- packages/types/src/vscode-extension-host.ts | 6 +- src/core/webview/ClineProvider.ts | 2 +- .../webview/__tests__/ClineProvider.spec.ts | 13 ++ .../src/context/ExtensionStateContext.tsx | 23 +++- .../__tests__/ExtensionStateContext.spec.tsx | 127 ++++++++++++++++-- webview-ui/src/utils/test-utils.tsx | 2 +- 6 files changed, 158 insertions(+), 15 deletions(-) diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index ce64e87913..34683dee21 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -345,7 +345,11 @@ export type ExtensionState = Pick< lockApiConfigAcrossModes?: boolean version: string clineMessages: ClineMessage[] - currentTaskId?: string + /** + * Focused task identity. Omitted means this partial state update does not + * change task focus; null authoritatively means no task is focused. + */ + currentTaskId?: string | null currentTaskItem?: HistoryItem currentTaskTodos?: TodoItem[] // Initial todos for the current task apiConfiguration: ProviderSettings diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 4abbf0dcbd..6f50029953 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2925,7 +2925,7 @@ export class ClineProvider autoCondenseContext: autoCondenseContext ?? true, autoCondenseContextPercent: autoCondenseContextPercent ?? 100, uriScheme: vscode.env.uriScheme, - currentTaskId: currentTask?.taskId, + currentTaskId: currentTask?.taskId ?? null, currentTaskItem: currentTask?.taskId ? this.taskHistoryStore.get(currentTask.taskId) : undefined, clineMessages: currentTask?.clineMessages || [], currentTaskTodos: currentTask?.todoList || [], diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 391465ffac..61c03a879d 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -1298,6 +1298,19 @@ describe("ClineProvider", () => { expect(state.taskHistory).toEqual([historyItem]) }) + test("eviction synchronizes an authoritative no-task identity that survives serialization", async () => { + const task = new Task(defaultTaskOptions) + await provider.addClineToStack(task) + const postMessageSpy = vi.spyOn(provider, "postMessageToWebview").mockResolvedValue(undefined) + + await provider.evictCurrentTask() + + const stateMessage = postMessageSpy.mock.calls.map(([message]) => message).find(({ type }) => type === "state") + const roundTrippedState = JSON.parse(JSON.stringify(stateMessage?.state)) as Partial + expect(stateMessage?.state?.currentTaskId).toBeNull() + expect(roundTrippedState).toHaveProperty("currentTaskId", null) + }) + describe("postStateToWebviewThrottled", () => { beforeEach(() => { vi.useFakeTimers() diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index 5c26627f6f..cd39ce7e61 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -283,7 +283,7 @@ export const ExtensionStateContextProvider: React.FC<{ const [state, setState] = useState(() => mergeExtensionState(createInitialExtensionState(), initialState ?? {}), ) - const activeTaskIdRef = useRef(state.currentTaskId) + const activeTaskIdRef = useRef(state.currentTaskId ?? undefined) const clineMessagesSeqRef = useRef(state.clineMessagesSeq ?? 0) const clineMessagesRef = useRef(state.clineMessages) const activeSnapshotRef = useRef(null) @@ -437,9 +437,12 @@ export const ExtensionStateContextProvider: React.FC<{ ...newState } = message.state ?? {} const hasCurrentTaskId = Object.prototype.hasOwnProperty.call(newState, "currentTaskId") - const nextTaskId = hasCurrentTaskId ? newState.currentTaskId : activeTaskIdRef.current + const nextTaskId = hasCurrentTaskId + ? (newState.currentTaskId ?? undefined) + : activeTaskIdRef.current const taskChanged = hasCurrentTaskId && nextTaskId !== activeTaskIdRef.current - if (taskChanged) { + const taskCleared = hasCurrentTaskId && newState.currentTaskId === null + if (taskChanged || taskCleared) { activeTaskIdRef.current = nextTaskId clineMessagesSeqRef.current = 0 clineMessagesRef.current = [] @@ -448,8 +451,22 @@ export const ExtensionStateContextProvider: React.FC<{ } setState((prevState) => { const merged = mergeExtensionState(prevState, newState) + if (taskCleared) { + return { + ...merged, + currentTaskId: null, + currentTaskItem: undefined, + currentTaskTodos: [], + messageQueue: [], + clineMessages: [], + clineMessagesSeq: 0, + } + } return taskChanged ? { ...merged, clineMessages: [], clineMessagesSeq: 0 } : merged }) + if (taskCleared) { + setCurrentCheckpoint(undefined) + } setShowWelcome(!checkExistKey(newState.apiConfiguration, newState.zooCodeIsAuthenticated)) setDidHydrateState(true) // Update alwaysAllowFollowupQuestions if present in state message diff --git a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx index acdd3de42b..c74e799e24 100644 --- a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx +++ b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx @@ -114,11 +114,27 @@ const InitialStateTestComponent = () => { } const TranscriptTestComponent = () => { - const { currentTaskId, clineMessages, clineMessagesSeq } = useExtensionState() + const { + currentTaskId, + currentTaskItem, + currentTaskTodos, + messageQueue, + currentCheckpoint, + clineMessages, + clineMessagesSeq, + } = useExtensionState() return (
- {JSON.stringify({ currentTaskId, clineMessages, clineMessagesSeq: clineMessagesSeq ?? 0 })} + {JSON.stringify({ + currentTaskId: currentTaskId ?? null, + currentTaskItem: currentTaskItem ?? null, + currentTaskTodos: currentTaskTodos ?? [], + messageQueue: messageQueue ?? [], + currentCheckpoint: currentCheckpoint ?? null, + clineMessages, + clineMessagesSeq: clineMessagesSeq ?? 0, + })}
) } @@ -420,6 +436,10 @@ describe("ExtensionStateContext", () => { describe("dedicated transcript transport", () => { const readTranscript = () => JSON.parse(screen.getByTestId("transcript-state").textContent!) + const readTranscriptFields = () => { + const { currentTaskId, clineMessages, clineMessagesSeq } = readTranscript() + return { currentTaskId, clineMessages, clineMessagesSeq } + } const renderTranscript = (initialState: Partial = {}) => render( @@ -473,7 +493,7 @@ describe("ExtensionStateContext", () => { }) }) - expect(readTranscript()).toEqual({ + expect(readTranscriptFields()).toEqual({ currentTaskId: "task-1", clineMessages: [first, { ...second, text: "updated" }], clineMessagesSeq: 6, @@ -508,7 +528,92 @@ describe("ExtensionStateContext", () => { }) }) - expect(readTranscript()).toEqual({ currentTaskId: "task-2", clineMessages: [], clineMessagesSeq: 0 }) + expect(readTranscriptFields()).toEqual({ currentTaskId: "task-2", clineMessages: [], clineMessagesSeq: 0 }) + }) + + it("clears task-scoped state for a JSON-round-tripped authoritative no-task transition", () => { + const existing = makeMessage(1, "existing") + const currentTaskItem = { + id: "task-1", + number: 1, + ts: 1, + task: "Existing task", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + } + renderTranscript({ + clineMessages: [existing], + clineMessagesSeq: 3, + currentTaskItem, + currentTaskTodos: [{ id: "todo-1", content: "Existing todo", status: "in_progress" }], + messageQueue: [{ id: "queued-1", timestamp: 1, text: "Queued message" }], + }) + + act(() => { + dispatchExtensionMessage({ type: "currentCheckpointUpdated", text: "checkpoint-1" }) + const clearState = JSON.parse(JSON.stringify({ currentTaskId: null })) as Partial + dispatchExtensionMessage({ type: "state", state: clearState }) + dispatchExtensionMessage({ + type: "clineMessagesSnapshotStart", + clineMessagesSeq: 0, + snapshotId: "no-task-snapshot", + snapshotTotal: 0, + }) + dispatchExtensionMessage({ + type: "clineMessagesSnapshotEnd", + clineMessagesSeq: 0, + snapshotId: "no-task-snapshot", + snapshotTotal: 0, + }) + }) + + expect(readTranscript()).toEqual({ + currentTaskId: null, + currentTaskItem: null, + currentTaskTodos: [], + messageQueue: [], + currentCheckpoint: null, + clineMessages: [], + clineMessagesSeq: 0, + }) + }) + + it("preserves task-scoped state when a partial state update omits currentTaskId", () => { + const existing = makeMessage(1, "existing") + const currentTaskItem = { + id: "task-1", + number: 1, + ts: 1, + task: "Existing task", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + } + const currentTaskTodos = [{ id: "todo-1", content: "Existing todo", status: "pending" as const }] + const messageQueue = [{ id: "queued-1", timestamp: 1, text: "Queued message" }] + renderTranscript({ + clineMessages: [existing], + clineMessagesSeq: 3, + currentTaskItem, + currentTaskTodos, + messageQueue, + }) + + act(() => { + dispatchExtensionMessage({ type: "currentCheckpointUpdated", text: "checkpoint-1" }) + dispatchExtensionMessage({ type: "state", state: { version: "2.0.0" } }) + }) + + expect(readTranscript()).toEqual({ + currentTaskId: "task-1", + currentTaskItem, + currentTaskTodos, + messageQueue, + currentCheckpoint: "checkpoint-1", + clineMessages: [existing], + clineMessagesSeq: 3, + }) }) it("requests one resync when a delta sequence has a gap", () => { @@ -624,7 +729,7 @@ describe("ExtensionStateContext", () => { }) }) - expect(readTranscript()).toEqual({ + expect(readTranscriptFields()).toEqual({ currentTaskId: "task-1", clineMessages: [first, recovered, makeMessage(4, "after recovery")], clineMessagesSeq: 4, @@ -739,7 +844,7 @@ describe("ExtensionStateContext", () => { expect(postMessage).toHaveBeenCalledWith( expect.objectContaining({ type: "requestClineMessagesResync", receivedSeq: 5 }), ) - expect(readTranscript()).toEqual({ + expect(readTranscriptFields()).toEqual({ currentTaskId: "task-1", clineMessages: [first], clineMessagesSeq: 1, @@ -898,7 +1003,11 @@ describe("ExtensionStateContext", () => { }) expect(postMessage).toHaveBeenCalledTimes(5) - expect(readTranscript()).toEqual({ currentTaskId: "task-1", clineMessages: [], clineMessagesSeq: 1 }) + expect(readTranscriptFields()).toEqual({ + currentTaskId: "task-1", + clineMessages: [], + clineMessagesSeq: 1, + }) } finally { postMessage.mockRestore() } @@ -939,7 +1048,7 @@ describe("ExtensionStateContext", () => { }) appendClineMessage(makeMessage(3, "appended"), 5, "task-1") }) - expect(readTranscript()).toEqual({ + expect(readTranscriptFields()).toEqual({ currentTaskId: "task-1", clineMessages: [makeMessage(2, "hydrated"), makeMessage(3, "appended")], clineMessagesSeq: 5, @@ -948,7 +1057,7 @@ describe("ExtensionStateContext", () => { act(() => { hydrateExtensionState({ clineMessages: [] }, { taskId: "task-1", clineMessagesSeq: 6 }) }) - expect(readTranscript()).toEqual({ currentTaskId: "task-1", clineMessages: [], clineMessagesSeq: 6 }) + expect(readTranscriptFields()).toEqual({ currentTaskId: "task-1", clineMessages: [], clineMessagesSeq: 6 }) }) }) }) diff --git a/webview-ui/src/utils/test-utils.tsx b/webview-ui/src/utils/test-utils.tsx index 305f962ba2..617e18a1ea 100644 --- a/webview-ui/src/utils/test-utils.tsx +++ b/webview-ui/src/utils/test-utils.tsx @@ -48,7 +48,7 @@ export const hydrateExtensionState = ( options: { taskId?: string; clineMessagesSeq?: number } = {}, ) => { const { clineMessages, clineMessagesSeq: stateSeq, ...metadataState } = state - const taskId = options.taskId ?? metadataState.currentTaskId + const taskId = options.taskId ?? metadataState.currentTaskId ?? undefined const clineMessagesSeq = options.clineMessagesSeq ?? stateSeq ?? 0 dispatchExtensionMessage({ From e03f043f1e90b5802170435faafc14df7ceda7e2 Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Wed, 26 Aug 2026 20:42:33 -0600 Subject: [PATCH 16/26] fix: address transcript streaming review feedback --- src/core/task/Task.ts | 25 +++- src/core/task/__tests__/Task.spec.ts | 110 +++++++++++++++++- src/core/webview/ClineProvider.ts | 2 +- .../webview/__tests__/ClineProvider.spec.ts | 23 ++-- .../__tests__/ExtensionStateContext.spec.tsx | 30 +++++ 5 files changed, 179 insertions(+), 11 deletions(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 316a5771ee..ffca2ffe36 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -170,6 +170,7 @@ function queuedResponseForAsk(type: ClineAsk, text?: string): QueuedAskResolutio const FORCED_CONTEXT_REDUCTION_PERCENT = 75 // Keep 75% of context (remove 25%) on context window errors const MAX_CONTEXT_WINDOW_RETRIES = 3 // Maximum retries for context window errors +const PARTIAL_MESSAGE_UPDATE_DEBOUNCE_MS = 500 export interface TaskOptions extends CreateTaskOptions { provider: ClineProvider @@ -480,6 +481,7 @@ export class Task extends EventEmitter implements TaskLike { // Token Usage Throttling - Debounced emit function private readonly TOKEN_USAGE_EMIT_INTERVAL_MS = 2000 // 2 seconds private debouncedEmitTokenUsage: ReturnType + private debouncedPostPartialMessageUpdate: ReturnType // Historical cloud sync tracking retained only to avoid task resume churn. private cloudSyncedMessageTimestamps: Set = new Set() @@ -643,6 +645,20 @@ export class Task extends EventEmitter implements TaskLike { this.TOKEN_USAGE_EMIT_INTERVAL_MS, { leading: true, trailing: true, maxWait: this.TOKEN_USAGE_EMIT_INTERVAL_MS }, ) + this.debouncedPostPartialMessageUpdate = debounce( + (message: ClineMessage) => { + const provider = this.providerRef.deref() + if (!provider) { + return + } + + void provider.postClineMessageUpdated(this.taskId, message).catch((error) => { + console.error("[Task#updateClineMessage] incremental post failed:", error) + }) + }, + PARTIAL_MESSAGE_UPDATE_DEBOUNCE_MS, + { leading: false, trailing: true }, + ) onCreated?.(this) @@ -1188,8 +1204,12 @@ export class Task extends EventEmitter implements TaskLike { } private async updateClineMessage(message: ClineMessage) { - const provider = this.providerRef.deref() - await provider?.postClineMessageUpdated(this.taskId, message) + if (message.partial === true) { + this.debouncedPostPartialMessageUpdate(message) + } else { + this.debouncedPostPartialMessageUpdate.cancel() + await this.providerRef.deref()?.postClineMessageUpdated(this.taskId, message) + } this.emit(RooCodeEventName.Message, { action: "updated", message }) // Check if we should sync to cloud and haven't already synced this message @@ -2528,6 +2548,7 @@ export class Task extends EventEmitter implements TaskLike { private async disposeOnce(): Promise { console.log(`[Task#dispose] disposing task ${this.taskId}.${this.instanceId}`) + this.debouncedPostPartialMessageUpdate.cancel() // Stop the idle telemetry check and report any unflushed activity as a // shutdown installment, so a task torn down mid-work (panel closed, task diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index e8e7b1b11c..097c7ddf48 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -1931,6 +1931,10 @@ describe("Cline", () => { }) describe("webview transcript transport", () => { + afterEach(() => { + vi.useRealTimers() + }) + it("posts a bumped snapshot after overwriting the transcript", async () => { const task = new Task({ provider: mockProvider, @@ -2068,7 +2072,8 @@ describe("Cline", () => { expect(mockProvider.postClineMessageAppended).toHaveBeenCalledWith(task.taskId, message) }) - it("serializes a new partial message before its following update", async () => { + it("serializes a new partial message before its debounced following update", async () => { + vi.useFakeTimers() const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, @@ -2102,6 +2107,7 @@ describe("Cline", () => { expect(updatePostSpy).not.toHaveBeenCalled() releaseAppend() + await vi.advanceTimersByTimeAsync(500) await addThenUpdate expect(appendSpy.mock.invocationCallOrder[0]).toBeLessThan(updatePostSpy.mock.invocationCallOrder[0]) @@ -2110,6 +2116,108 @@ describe("Cline", () => { text: "updated partial", }) }) + + it("debounces partial updates and posts the latest revision on the trailing edge", async () => { + vi.useFakeTimers() + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + const taskAccess = getTaskTestAccess(task) + const updatePostSpy = vi.mocked(mockProvider.postClineMessageUpdated) + + void taskAccess.updateClineMessage({ + ts: 1, + type: "say", + say: "text", + text: "first partial", + partial: true, + }) + await vi.advanceTimersByTimeAsync(250) + void taskAccess.updateClineMessage({ + ts: 1, + type: "say", + say: "text", + text: "latest partial", + partial: true, + }) + + await vi.advanceTimersByTimeAsync(499) + expect(updatePostSpy).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(1) + expect(updatePostSpy).toHaveBeenCalledOnce() + expect(updatePostSpy).toHaveBeenCalledWith( + task.taskId, + expect.objectContaining({ text: "latest partial", partial: true }), + ) + }) + + it.each([ + ["false", { ts: 1, type: "say" as const, say: "text" as const, text: "complete", partial: false }], + ["absent", { ts: 1, type: "say" as const, say: "text" as const, text: "complete" }], + ])( + "cancels a pending partial update and posts completion immediately when partial is %s", + async (_case, complete) => { + vi.useFakeTimers() + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + const taskAccess = getTaskTestAccess(task) + const updatePostSpy = vi.mocked(mockProvider.postClineMessageUpdated) + + void taskAccess.updateClineMessage({ + ts: 1, + type: "say", + say: "text", + text: "partial", + partial: true, + }) + await taskAccess.updateClineMessage(complete) + + expect(updatePostSpy).toHaveBeenCalledOnce() + expect(updatePostSpy).toHaveBeenCalledWith(task.taskId, complete) + + await vi.advanceTimersByTimeAsync(500) + expect(updatePostSpy).toHaveBeenCalledOnce() + }, + ) + + it("handles a rejected debounced partial update", async () => { + vi.useFakeTimers() + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + const postError = new Error("incremental update failed") + vi.mocked(mockProvider.postClineMessageUpdated).mockRejectedValueOnce(postError) + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + + try { + void getTaskTestAccess(task).updateClineMessage({ + ts: 1, + type: "say", + say: "text", + text: "partial", + partial: true, + }) + await vi.advanceTimersByTimeAsync(500) + + expect(consoleErrorSpy).toHaveBeenCalledWith( + "[Task#updateClineMessage] incremental post failed:", + postError, + ) + } finally { + consoleErrorSpy.mockRestore() + } + }) }) describe("abortTask", () => { diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 6f50029953..4e37fbfe8b 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -1573,7 +1573,6 @@ export class ClineProvider ? this.bumpClineMessagesSeq(taskId) : this.getClineMessagesSeq(taskId) : 0 - const messages = structuredClone(currentTask?.clineMessages ?? []) const snapshotId = `${taskId ?? "none"}:${++this.nextClineMessagesSnapshotId}` const generation = options.generation ?? this.clineMessagesTransportGeneration @@ -1584,6 +1583,7 @@ export class ClineProvider if (!isCurrent()) { return } + const messages = structuredClone(currentTask?.clineMessages ?? []) await this.postMessageToWebview({ type: "clineMessagesSnapshotStart", diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 61c03a879d..66525d7014 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -1070,8 +1070,11 @@ describe("ClineProvider", () => { }, ) - test("drops a snapshot invalidated before its first post", async () => { - const task = { taskId: "task-1", clineMessages: [] as ClineMessage[] } + test("drops a snapshot invalidated before its first post without cloning it", async () => { + const task = { + taskId: "task-1", + clineMessages: [{ ts: 1, type: "say", say: "text", text: "message" }] as ClineMessage[], + } setCurrentTask(task) const postSpy = vi.spyOn(provider, "postMessageToWebview") let releaseQueue!: () => void @@ -1081,12 +1084,18 @@ describe("ClineProvider", () => { }), }) - const snapshot = provider.postClineMessagesSnapshot("task-1") - task.taskId = "task-2" - releaseQueue() - await snapshot + const structuredCloneSpy = vi.spyOn(globalThis, "structuredClone") + try { + const snapshot = provider.postClineMessagesSnapshot("task-1") + task.taskId = "task-2" + releaseQueue() + await snapshot - expect(postSpy).not.toHaveBeenCalled() + expect(postSpy).not.toHaveBeenCalled() + expect(structuredCloneSpy).not.toHaveBeenCalled() + } finally { + structuredCloneSpy.mockRestore() + } }) test.each([ diff --git a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx index c74e799e24..dc4cf78e77 100644 --- a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx +++ b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx @@ -1013,6 +1013,36 @@ describe("ExtensionStateContext", () => { } }) + it("keeps the prior transcript when a snapshot end is dropped", () => { + const existing = makeMessage(1, "existing") + const replacement = makeMessage(2, "replacement") + renderTranscript({ clineMessages: [existing], clineMessagesSeq: 1 }) + + act(() => { + dispatchExtensionMessage({ + type: "clineMessagesSnapshotStart", + taskId: "task-1", + clineMessagesSeq: 2, + snapshotId: "dropped-end", + snapshotTotal: 1, + }) + dispatchExtensionMessage({ + type: "clineMessagesSnapshotChunk", + taskId: "task-1", + clineMessagesSeq: 2, + snapshotId: "dropped-end", + snapshotStartIndex: 0, + clineMessages: [replacement], + }) + }) + + expect(readTranscriptFields()).toEqual({ + currentTaskId: "task-1", + clineMessages: [existing], + clineMessagesSeq: 1, + }) + }) + it("requests recovery for legacy unsequenced updates", () => { const postMessage = vi.spyOn(vscode, "postMessage").mockImplementation(() => undefined) try { From 0e412fc92d9d19f86663213352f215f2aa081fe4 Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Mon, 31 Aug 2026 23:15:01 -0600 Subject: [PATCH 17/26] test: align state ordering regression with transcript transport --- src/core/webview/__tests__/ClineProvider.spec.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 66525d7014..8aaa4a1b75 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -1223,7 +1223,9 @@ describe("ClineProvider", () => { "postStateToWebviewWithoutTaskHistory", (currentProvider: ClineProvider) => currentProvider.postStateToWebviewWithoutTaskHistory(), ], - ])("%s assigns message sequence numbers before asynchronous state construction", async (_methodName, postState) => { + ])("%s keeps out-of-order generic state publications transcript-free", async (_methodName, postState) => { + await provider.resolveWebviewView(mockWebviewView) + mockPostMessage.mockClear() let releaseOlderSnapshot!: (state: ExtensionState) => void const olderSnapshot = new Promise((resolve) => { releaseOlderSnapshot = resolve @@ -1239,7 +1241,6 @@ describe("ClineProvider", () => { vi.spyOn(provider, "getStateToPostToWebview") .mockReturnValueOnce(olderSnapshot) .mockResolvedValueOnce(readyState) - const postMessageSpy = vi.spyOn(provider, "postMessageToWebview").mockResolvedValue(undefined) const olderPost = postState(provider) await Promise.resolve() @@ -1248,11 +1249,12 @@ describe("ClineProvider", () => { releaseOlderSnapshot(emptyState) await olderPost - expect(postMessageSpy.mock.calls.map(([message]) => message.state?.clineMessages)).toEqual([ - readyState.clineMessages, - emptyState.clineMessages, - ]) - expect(postMessageSpy.mock.calls.map(([message]) => message.state?.clineMessagesSeq)).toEqual([2, 1]) + const statePosts = (mockPostMessage.mock.calls as Array<[ExtensionMessage]>) + .map(([message]) => message) + .filter((message) => message.type === "state") + expect(statePosts).toHaveLength(2) + expect(statePosts.map((message) => message.state?.clineMessages)).toEqual([undefined, undefined]) + expect(statePosts.map((message) => message.state?.clineMessagesSeq)).toEqual([undefined, undefined]) }) test.each([ From dd358e11a61a6838e198c5b02e53275de152f197 Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Fri, 4 Sep 2026 00:48:05 -0600 Subject: [PATCH 18/26] test: cover transcript transport mutation gaps --- src/core/task/Task.ts | 22 +-- src/core/task/__tests__/Task.spec.ts | 159 ++++++++++++++++++ .../webview/__tests__/ClineProvider.spec.ts | 90 ++++++---- src/eslint-suppressions.json | 2 +- 4 files changed, 228 insertions(+), 45 deletions(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index ffca2ffe36..11d4579f9a 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -645,20 +645,16 @@ export class Task extends EventEmitter implements TaskLike { this.TOKEN_USAGE_EMIT_INTERVAL_MS, { leading: true, trailing: true, maxWait: this.TOKEN_USAGE_EMIT_INTERVAL_MS }, ) - this.debouncedPostPartialMessageUpdate = debounce( - (message: ClineMessage) => { - const provider = this.providerRef.deref() - if (!provider) { - return - } + this.debouncedPostPartialMessageUpdate = debounce((message: ClineMessage) => { + const provider = this.providerRef.deref() + if (!provider) { + return + } - void provider.postClineMessageUpdated(this.taskId, message).catch((error) => { - console.error("[Task#updateClineMessage] incremental post failed:", error) - }) - }, - PARTIAL_MESSAGE_UPDATE_DEBOUNCE_MS, - { leading: false, trailing: true }, - ) + void provider.postClineMessageUpdated(this.taskId, message).catch((error) => { + console.error("[Task#updateClineMessage] incremental post failed:", error) + }) + }, PARTIAL_MESSAGE_UPDATE_DEBOUNCE_MS) onCreated?.(this) diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index 097c7ddf48..5db0afb163 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -184,6 +184,9 @@ vi.mock("vscode", () => { Disposable: { from: vi.fn(), }, + RelativePattern: vi.fn().mockImplementation(function (base: string, pattern: string) { + return { base, pattern } + }), TabInputText: vi.fn(), } }) @@ -1959,6 +1962,26 @@ describe("Cline", () => { expect(mockProvider.postClineMessagesSnapshot).toHaveBeenCalledWith(task.taskId, { bumpSeq: true }) }) + it("still overwrites the transcript when the provider reference is unavailable", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + const saveSpy = vi.spyOn(getTaskTestAccess(task), "saveClineMessages").mockResolvedValue(true) + Object.defineProperty(task, "providerRef", { + value: { deref: () => undefined }, + configurable: true, + }) + const messages = [{ ts: 1, type: "say" as const, say: "text" as const, text: "replacement" }] + + await expect(task.overwriteClineMessages(messages)).resolves.toBeUndefined() + + expect(task.clineMessages).toEqual(messages) + expect(saveSpy).toHaveBeenCalledOnce() + }) + it("posts a complete new message through the incremental transport", async () => { const task = new Task({ provider: mockProvider, @@ -2155,6 +2178,73 @@ describe("Cline", () => { ) }) + it("drops a debounced partial update when the provider reference expires", async () => { + vi.useFakeTimers() + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + Object.defineProperty(task, "providerRef", { + value: { deref: () => undefined }, + configurable: true, + }) + + await getTaskTestAccess(task).updateClineMessage({ + ts: 1, + type: "say", + say: "text", + text: "partial", + partial: true, + }) + await vi.advanceTimersByTimeAsync(500) + + expect(mockProvider.postClineMessageUpdated).not.toHaveBeenCalled() + }) + + it("emits a complete update when the provider reference is unavailable", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + Object.defineProperty(task, "providerRef", { + value: { deref: () => undefined }, + configurable: true, + }) + const messageListener = vi.fn() + task.on(RooCodeEventName.Message, messageListener) + const message = { ts: 1, type: "say" as const, say: "text" as const, text: "complete" } + + await expect(getTaskTestAccess(task).updateClineMessage(message)).resolves.toBeUndefined() + + expect(messageListener).toHaveBeenCalledWith({ action: "updated", message }) + }) + + it("cancels a pending partial update when the task is disposed", async () => { + vi.useFakeTimers() + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + await getTaskTestAccess(task).updateClineMessage({ + ts: 1, + type: "say", + say: "text", + text: "partial", + partial: true, + }) + await task.dispose() + await vi.advanceTimersByTimeAsync(500) + + expect(mockProvider.postClineMessageUpdated).not.toHaveBeenCalled() + }) + it.each([ ["false", { ts: 1, type: "say" as const, say: "text" as const, text: "complete", partial: false }], ["absent", { ts: 1, type: "say" as const, say: "text" as const, text: "complete" }], @@ -2632,6 +2722,50 @@ describe("Cline", () => { ) }) + it("finishes cancellation when the API request message has already been removed", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + const taskAccess = getTaskTestAccess(task) + const saveSpy = vi.spyOn(taskAccess, "saveClineMessages").mockResolvedValue(true) + vi.spyOn(taskAccess, "safeEnsureModelFetched").mockResolvedValue(undefined) + vi.spyOn(task.diffViewProvider, "reset").mockResolvedValue(undefined) + vi.spyOn(task, "abortTask").mockResolvedValue(undefined) + vi.spyOn(task.api, "getModel").mockReturnValue({ + id: mockApiConfig.apiModelId!, + info: { + supportsImages: false, + supportsPromptCache: true, + contextWindow: 200000, + maxTokens: 4096, + inputPrice: 0.3, + outputPrice: 1.5, + } as ModelInfo, + }) + const updateSpy = vi.mocked(mockProvider.postClineMessageUpdated) + vi.spyOn(task, "attemptApiRequest").mockImplementation(() => + (async function* (): AsyncGenerator { + // Simulate another transcript operation removing the request row while + // cancellation is racing with the active stream. + task.clineMessages = [] + updateSpy.mockClear() + task.abort = true + yield { type: "usage", inputTokens: 0, outputTokens: 0 } + })(), + ) + + await expect( + task.recursivelyMakeClineRequests([{ type: "text", text: "cancel without request row" }]), + ).resolves.toBe(true) + + expect(updateSpy).not.toHaveBeenCalled() + expect(saveSpy).toHaveBeenCalled() + expect(task.didFinishAbortingStream).toBe(true) + }) + it("should pass AbortController signal to condenseContext metadata when a current request exists", async () => { const task = new Task({ provider: mockProvider, @@ -3537,6 +3671,31 @@ describe("Cline", () => { expect(saySpy).toHaveBeenCalledWith("text", "new task", undefined) expect(initiateTaskLoopSpy).toHaveBeenCalledOnce() }) + + it("starts without a snapshot when the provider reference is unavailable", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "new task", + startTask: false, + }) + const taskAccess = getTaskTestAccess(task) + Object.defineProperty(task, "providerRef", { + value: { deref: () => undefined }, + configurable: true, + }) + const saySpy = vi.spyOn(task, "say").mockResolvedValue(undefined) + vi.spyOn(taskAccess, "getEnabledMcpToolsCount").mockResolvedValue({ + enabledToolCount: 0, + enabledServerCount: 0, + }) + const initiateTaskLoopSpy = vi.spyOn(taskAccess, "initiateTaskLoop").mockResolvedValue(undefined) + + await expect(taskAccess.startTask("new task")).resolves.toBeUndefined() + + expect(saySpy).toHaveBeenCalledWith("text", "new task", undefined) + expect(initiateTaskLoopSpy).toHaveBeenCalledOnce() + }) }) describe("start()", () => { diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 8aaa4a1b75..6b057bed66 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -34,8 +34,18 @@ import { Terminal } from "../../../integrations/terminal/Terminal" import { MessageManager } from "../../message-manager" import { forceFullModelDetailsLoad, hasLoadedFullDetails } from "../../../api/providers/fetchers/lmstudio" -// Mock setup must come before imports. -vi.mock("../../prompts/sections/custom-instructions") +const { mockAddCustomInstructions, mockTaskConstructor } = vi.hoisted(() => ({ + mockAddCustomInstructions: vi.fn().mockResolvedValue("Combined instructions"), + mockTaskConstructor: vi.fn(), +})) + +vi.mock("../../prompts/sections/custom-instructions", () => ({ + addCustomInstructions: mockAddCustomInstructions, +})) + +vi.mock("../../task/Task", () => ({ + Task: mockTaskConstructor, +})) vi.mock("p-wait-for", () => ({ __esModule: true, @@ -108,13 +118,6 @@ vi.mock("@modelcontextprotocol/sdk/types.js", () => ({ }, })) -// Remove duplicate mock - it's already defined below. - -const mockAddCustomInstructions = vi.fn().mockResolvedValue("Combined instructions") - -;(vi.mocked(await import("../../prompts/sections/custom-instructions")) as any).addCustomInstructions = - mockAddCustomInstructions - vi.mock("delay", () => { const delayFn = (_ms: number) => Promise.resolve() delayFn.createDelay = () => delayFn @@ -173,6 +176,7 @@ vi.mock("vscode", () => ({ showErrorMessage: vi.fn(), showSaveDialog: vi.fn(), showOpenDialog: vi.fn(), + createTextEditorDecorationType: vi.fn(() => ({ dispose: vi.fn() })), activeTextEditor: undefined, onDidChangeActiveTextEditor: vi.fn(() => ({ dispose: vi.fn() })), }, @@ -261,27 +265,6 @@ vi.mock("../../../integrations/workspace/WorkspaceTracker", () => { } }) -vi.mock("../../task/Task", () => ({ - Task: vi.fn().mockImplementation(function (options: any) { - return { - api: undefined, - abortTask: vi.fn(), - dispose: vi.fn().mockResolvedValue(undefined), - handleWebviewAskResponse: vi.fn(), - clineMessages: [], - apiConversationHistory: [], - overwriteClineMessages: vi.fn(), - overwriteApiConversationHistory: vi.fn(), - getTaskNumber: vi.fn().mockReturnValue(0), - setTaskNumber: vi.fn(), - setParentTask: vi.fn(), - setRootTask: vi.fn(), - taskId: options?.historyItem?.id || "test-task-id", - emit: vi.fn(), - } - }), -})) - vi.mock("../../../integrations/misc/extract-text", () => ({ extractTextFromFile: vi.fn().mockImplementation(async (_filePath: string) => { const content = "const x = 1;\nconst y = 2;\nconst z = 3;" @@ -410,7 +393,7 @@ afterAll(() => { describe("ClineProvider", () => { beforeAll(() => { - vi.mocked(Task).mockImplementation(function (options: any) { + mockTaskConstructor.mockImplementation(function (options: any) { const task: any = { api: undefined, abortTask: vi.fn(), @@ -880,6 +863,17 @@ describe("ClineProvider", () => { expect(mockPostMessage).toHaveBeenCalledWith({ type: "state", state: { version: "1.0.0" } }) }) + test("postMessageToWebview forwards non-state messages unchanged", async () => { + await provider.resolveWebviewView(mockWebviewView) + mockPostMessage.mockClear() + const message: ExtensionMessage = { type: "action", action: "chatButtonClicked" } + + await provider.postMessageToWebview(message) + + expect(mockPostMessage).toHaveBeenCalledOnce() + expect(mockPostMessage).toHaveBeenCalledWith(message) + }) + describe("transcript transport", () => { const setCurrentTask = (task: { taskId: string; clineMessages: ClineMessage[] } | undefined) => { vi.spyOn(provider, "getCurrentTask").mockImplementation(() => task as Task | undefined) @@ -1070,6 +1064,40 @@ describe("ClineProvider", () => { }, ) + test("invalidates a queued delta when only the transport generation changes", async () => { + await provider.resolveWebviewView(mockWebviewView) + const task = { taskId: "task-1", clineMessages: [] as ClineMessage[] } + setCurrentTask(task) + mockPostMessage.mockClear() + + let releaseQueue!: () => void + Object.assign(provider, { + clineMessagesPostQueue: new Promise((resolve) => { + releaseQueue = resolve + }), + }) + const pendingDelta = provider.postClineMessageAppended("task-1", { + ts: 1, + type: "say", + say: "text", + text: "stale generation", + }) + const previousGeneration = provider["clineMessagesTransportGeneration"] + const resync = provider.resyncClineMessagesToWebview("task-1") + + expect(provider["clineMessagesTransportGeneration"]).toBe(previousGeneration + 1) + + releaseQueue() + await Promise.all([pendingDelta, resync]) + + expect(mockPostMessage).not.toHaveBeenCalledWith( + expect.objectContaining({ type: "clineMessageAppended", taskId: "task-1" }), + ) + expect(mockPostMessage).toHaveBeenCalledWith( + expect.objectContaining({ type: "clineMessagesSnapshotStart", taskId: "task-1" }), + ) + }) + test("drops a snapshot invalidated before its first post without cloning it", async () => { const task = { taskId: "task-1", diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index 393e108645..86d9a63e7e 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -1036,7 +1036,7 @@ }, "core/webview/__tests__/ClineProvider.spec.ts": { "@typescript-eslint/no-explicit-any": { - "count": 198 + "count": 196 } }, "core/webview/__tests__/ClineProvider.sticky-mode.spec.ts": { From 762ed66a160e809418dfeada42f2bd72a8371d34 Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Fri, 4 Sep 2026 04:15:18 -0600 Subject: [PATCH 19/26] test: cover transcript transport mutation edges --- src/core/task/__tests__/Task.spec.ts | 28 + .../webview/__tests__/ClineProvider.spec.ts | 262 ++++++++ .../__tests__/webviewMessageHandler.spec.ts | 15 + .../src/context/ExtensionStateContext.tsx | 99 +-- .../__tests__/ExtensionStateContext.spec.tsx | 628 ++++++++++++++++++ 5 files changed, 990 insertions(+), 42 deletions(-) diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index 5db0afb163..aef4fb8bb7 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -2005,6 +2005,34 @@ describe("Cline", () => { expect(mockProvider.postStateToWebviewWithoutTaskHistory).not.toHaveBeenCalled() }) + it("creates a message without a transport error when the provider reference is unavailable", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + const taskAccess = getTaskTestAccess(task) + const saveSpy = vi.spyOn(taskAccess, "saveClineMessages").mockResolvedValue(true) + const messageListener = vi.fn() + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + Object.defineProperty(task, "providerRef", { + value: { deref: () => undefined }, + configurable: true, + }) + task.on(RooCodeEventName.Message, messageListener) + const message = { ts: 1, type: "say" as const, say: "text" as const, text: "message" } + + await expect(taskAccess.addToClineMessages(message)).resolves.toBeUndefined() + + expect(consoleErrorSpy).not.toHaveBeenCalled() + expect(task.clineMessages).toEqual([message]) + expect(messageListener).toHaveBeenCalledWith({ action: "created", message }) + expect(saveSpy).toHaveBeenCalledOnce() + + consoleErrorSpy.mockRestore() + }) + it("waits for an incremental append before emitting the message", async () => { const task = new Task({ provider: mockProvider, diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 6b057bed66..5508d05f7f 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -1,6 +1,7 @@ // pnpm --filter roo-cline test core/webview/__tests__/ClineProvider.spec.ts import * as path from "path" +import fs from "fs/promises" import { TaskRegistry } from "../../task/TaskRegistry" import Anthropic from "@anthropic-ai/sdk" @@ -33,6 +34,7 @@ import { webviewMessageHandler } from "../webviewMessageHandler" import { Terminal } from "../../../integrations/terminal/Terminal" import { MessageManager } from "../../message-manager" import { forceFullModelDetailsLoad, hasLoadedFullDetails } from "../../../api/providers/fetchers/lmstudio" +import { ShadowCheckpointService } from "../../../services/checkpoints/ShadowCheckpointService" const { mockAddCustomInstructions, mockTaskConstructor } = vi.hoisted(() => ({ mockAddCustomInstructions: vi.fn().mockResolvedValue("Combined instructions"), @@ -874,6 +876,24 @@ describe("ClineProvider", () => { expect(mockPostMessage).toHaveBeenCalledWith(message) }) + test("postMessageToWebview preserves state-shaped payloads on non-state messages", async () => { + await provider.resolveWebviewView(mockWebviewView) + mockPostMessage.mockClear() + const message: ExtensionMessage = { + type: "action", + action: "chatButtonClicked", + state: { + clineMessages: [{ ts: 1, type: "say", say: "text", text: "preserved" }], + clineMessagesSeq: 3, + }, + } + + await provider.postMessageToWebview(message) + + expect(mockPostMessage).toHaveBeenCalledOnce() + expect(mockPostMessage).toHaveBeenCalledWith(message) + }) + describe("transcript transport", () => { const setCurrentTask = (task: { taskId: string; clineMessages: ClineMessage[] } | undefined) => { vi.spyOn(provider, "getCurrentTask").mockImplementation(() => task as Task | undefined) @@ -964,6 +984,8 @@ describe("ClineProvider", () => { setCurrentTask(task) const message = { ts: 1, type: "say", say: "text", text: "ignored" } as ClineMessage const postSpy = vi.spyOn(provider, "postMessageToWebview") + const previousGeneration = provider["clineMessagesTransportGeneration"] + const previousSnapshotId = provider["nextClineMessagesSnapshotId"] await Promise.all([ provider.postClineMessageAppended("task-2", message), @@ -973,6 +995,22 @@ describe("ClineProvider", () => { ]) expect(postSpy).not.toHaveBeenCalled() + expect(provider["clineMessagesSeqByTaskId"].has("task-2")).toBe(false) + expect(provider["clineMessagesTransportGeneration"]).toBe(previousGeneration) + expect(provider["nextClineMessagesSnapshotId"]).toBe(previousSnapshotId) + }) + + test("safely rejects transcript work when no task is focused", async () => { + setCurrentTask(undefined) + const message = { ts: 1, type: "say", say: "text", text: "ignored" } as ClineMessage + const previousGeneration = provider["clineMessagesTransportGeneration"] + + await expect(provider.postClineMessageAppended("task-1", message)).resolves.toBeUndefined() + await expect(provider.postClineMessageUpdated("task-1", message)).resolves.toBeUndefined() + await expect(provider.resyncClineMessagesToWebview("task-1")).resolves.toBeUndefined() + + expect(provider["clineMessagesSeqByTaskId"].has("task-1")).toBe(false) + expect(provider["clineMessagesTransportGeneration"]).toBe(previousGeneration) }) test("logs a failed delta post and continues processing the queue", async () => { @@ -1064,6 +1102,61 @@ describe("ClineProvider", () => { }, ) + test.each([ + ["append", "clineMessageAppended"], + ["update", "clineMessageUpdated"], + ] as const)( + "invalidates a queued %s delta when only the focused task changes", + async (operation, messageType) => { + const task = { taskId: "task-1", clineMessages: [] as ClineMessage[] } + setCurrentTask(task) + const postSpy = vi.spyOn(provider, "postMessageToWebview") + let releaseQueue!: () => void + Object.assign(provider, { + clineMessagesPostQueue: new Promise((resolve) => { + releaseQueue = resolve + }), + }) + const message = { ts: 1, type: "say", say: "text", text: "queued" } as ClineMessage + const pendingDelta = + operation === "append" + ? provider.postClineMessageAppended("task-1", message) + : provider.postClineMessageUpdated("task-1", message) + + task.taskId = "task-2" + releaseQueue() + await pendingDelta + + expect(postSpy).not.toHaveBeenCalledWith( + expect.objectContaining({ type: messageType, taskId: "task-1" }), + ) + }, + ) + + test.each(["append", "update"] as const)( + "drops a queued %s delta safely when the current task disappears", + async (operation) => { + const task = { taskId: "task-1", clineMessages: [] as ClineMessage[] } + setCurrentTask(task) + let releaseQueue!: () => void + Object.assign(provider, { + clineMessagesPostQueue: new Promise((resolve) => { + releaseQueue = resolve + }), + }) + const message = { ts: 1, type: "say", say: "text", text: "queued" } as ClineMessage + const pendingDelta = + operation === "append" + ? provider.postClineMessageAppended("task-1", message) + : provider.postClineMessageUpdated("task-1", message) + + setCurrentTask(undefined) + releaseQueue() + + await expect(pendingDelta).resolves.toBeUndefined() + }, + ) + test("invalidates a queued delta when only the transport generation changes", async () => { await provider.resolveWebviewView(mockWebviewView) const task = { taskId: "task-1", clineMessages: [] as ClineMessage[] } @@ -1098,6 +1191,32 @@ describe("ClineProvider", () => { ) }) + test("invalidates a queued update when only the transport generation changes", async () => { + const task = { taskId: "task-1", clineMessages: [] as ClineMessage[] } + setCurrentTask(task) + const postSpy = vi.spyOn(provider, "postMessageToWebview") + let releaseQueue!: () => void + Object.assign(provider, { + clineMessagesPostQueue: new Promise((resolve) => { + releaseQueue = resolve + }), + }) + const pendingUpdate = provider.postClineMessageUpdated("task-1", { + ts: 1, + type: "say", + say: "text", + text: "stale generation", + }) + + provider["clineMessagesTransportGeneration"]++ + releaseQueue() + await pendingUpdate + + expect(postSpy).not.toHaveBeenCalledWith( + expect.objectContaining({ type: "clineMessageUpdated", taskId: "task-1" }), + ) + }) + test("drops a snapshot invalidated before its first post without cloning it", async () => { const task = { taskId: "task-1", @@ -1126,6 +1245,77 @@ describe("ClineProvider", () => { } }) + test("uses monotonic task-scoped snapshot IDs and an empty no-task snapshot", async () => { + await provider.resolveWebviewView(mockWebviewView) + setCurrentTask({ taskId: "task-1", clineMessages: [] }) + mockPostMessage.mockClear() + + await provider.postClineMessagesSnapshot("task-1") + await provider.postClineMessagesSnapshot("task-1") + setCurrentTask(undefined) + await provider.postClineMessagesSnapshot(undefined) + + const snapshotMessages: ExtensionMessage[] = mockPostMessage.mock.calls.map( + ([message]: [ExtensionMessage]) => message, + ) + expect(snapshotMessages.map(({ snapshotId }) => snapshotId)).toEqual([ + "task-1:1", + "task-1:1", + "task-1:2", + "task-1:2", + "none:3", + "none:3", + ]) + expect(snapshotMessages.slice(-2)).toEqual([ + expect.objectContaining({ type: "clineMessagesSnapshotStart", snapshotTotal: 0 }), + expect.objectContaining({ type: "clineMessagesSnapshotEnd", snapshotTotal: 0 }), + ]) + }) + + test("does not emit an empty trailing chunk for an exact snapshot chunk boundary", async () => { + const messages = Array.from({ length: 200 }, (_, index) => ({ + ts: index, + type: "say", + say: "text", + text: `message ${index}`, + })) as ClineMessage[] + setCurrentTask({ taskId: "task-1", clineMessages: messages }) + const postSpy = vi.spyOn(provider, "postMessageToWebview").mockResolvedValue(undefined) + + await provider.postClineMessagesSnapshot("task-1") + + expect(postSpy.mock.calls.map(([message]) => message.type)).toEqual([ + "clineMessagesSnapshotStart", + "clineMessagesSnapshotChunk", + "clineMessagesSnapshotEnd", + ]) + expect(postSpy).toHaveBeenCalledWith( + expect.objectContaining({ + type: "clineMessagesSnapshotChunk", + snapshotStartIndex: 0, + clineMessages: messages, + }), + ) + }) + + test("stops a snapshot when its transport generation changes after the start marker", async () => { + setCurrentTask({ + taskId: "task-1", + clineMessages: [{ ts: 1, type: "say", say: "text", text: "message" }], + }) + const postedTypes: string[] = [] + vi.spyOn(provider, "postMessageToWebview").mockImplementation(async (message) => { + postedTypes.push(message.type) + if (message.type === "clineMessagesSnapshotStart") { + provider["clineMessagesTransportGeneration"]++ + } + }) + + await provider.postClineMessagesSnapshot("task-1") + + expect(postedTypes).toEqual(["clineMessagesSnapshotStart"]) + }) + test.each([ ["after the start marker", "clineMessagesSnapshotStart", ["clineMessagesSnapshotStart"]], [ @@ -1193,6 +1383,49 @@ describe("ClineProvider", () => { expect(provider["clineMessagesSeqByTaskId"].has("deleted-task")).toBe(false) }) + test("prunes sequence state for every task deleted by a cascade", async () => { + const histories = { + parent: { + id: "parent", + number: 1, + ts: 1, + task: "Parent", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + childIds: ["child"], + }, + child: { + id: "child", + number: 2, + ts: 2, + task: "Child", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + }, + } + vi.spyOn(provider, "getTaskWithId").mockImplementation(async (id) => ({ + historyItem: histories[id as keyof typeof histories], + taskDirPath: `/test/task/${id}`, + apiConversationHistoryFilePath: `/test/task/${id}/api.json`, + uiMessagesFilePath: `/test/task/${id}/ui.json`, + apiConversationHistory: [], + })) + vi.spyOn(provider.taskHistoryStore, "deleteMany").mockResolvedValue(undefined) + vi.spyOn(ShadowCheckpointService, "deleteTask").mockResolvedValue(undefined) + vi.spyOn(fs, "rm").mockResolvedValue(undefined) + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + provider["clineMessagesSeqByTaskId"].set("parent", 4) + provider["clineMessagesSeqByTaskId"].set("child", 7) + + await provider.deleteTaskWithId("parent") + + expect(provider.taskHistoryStore.deleteMany).toHaveBeenCalledWith(["parent", "child"]) + expect(provider["clineMessagesSeqByTaskId"].has("parent")).toBe(false) + expect(provider["clineMessagesSeqByTaskId"].has("child")).toBe(false) + }) + test("abandons an older focus sync when a resync invalidates its state post", async () => { const task = { taskId: "task-1", clineMessages: [] as ClineMessage[] } setCurrentTask(task) @@ -1217,6 +1450,33 @@ describe("ClineProvider", () => { expect(snapshotSpy).toHaveBeenCalledOnce() expect(snapshotSpy).toHaveBeenCalledWith("task-1", { generation: expect.any(Number) }) }) + + test("passes the new transport generation into a focused-task snapshot", async () => { + setCurrentTask({ taskId: "task-1", clineMessages: [] }) + vi.spyOn(provider, "postStateToWebviewWithoutTaskHistory").mockResolvedValue(undefined) + const snapshotSpy = vi.spyOn(provider, "postClineMessagesSnapshot").mockResolvedValue(undefined) + const previousGeneration = provider["clineMessagesTransportGeneration"] + + await provider.syncFocusedTaskToWebview() + + expect(snapshotSpy).toHaveBeenCalledWith("task-1", { generation: previousGeneration + 1 }) + }) + + test("includes task history when requested during focused-task synchronization", async () => { + setCurrentTask({ taskId: "task-1", clineMessages: [] }) + const fullStateSpy = vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + const lightweightStateSpy = vi + .spyOn(provider, "postStateToWebviewWithoutTaskHistory") + .mockResolvedValue(undefined) + const snapshotSpy = vi.spyOn(provider, "postClineMessagesSnapshot").mockResolvedValue(undefined) + const previousGeneration = provider["clineMessagesTransportGeneration"] + + await provider.syncFocusedTaskToWebview({ includeTaskHistory: true }) + + expect(fullStateSpy).toHaveBeenCalledOnce() + expect(lightweightStateSpy).not.toHaveBeenCalled() + expect(snapshotSpy).toHaveBeenCalledWith("task-1", { generation: previousGeneration + 1 }) + }) }) test("postStateToWebviewWithoutTaskHistory waits for the webview post boundary", async () => { @@ -1649,6 +1909,7 @@ describe("ClineProvider", () => { test("handles webviewDidLaunch message", async () => { await provider.resolveWebviewView(mockWebviewView) + const syncFocusedTaskSpy = vi.spyOn(provider, "syncFocusedTaskToWebview").mockResolvedValue(undefined) // Get the message handler from onDidReceiveMessage const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as ReturnType).mock @@ -1659,6 +1920,7 @@ describe("ClineProvider", () => { // Should post state and theme to webview expect(mockPostMessage).toHaveBeenCalled() + expect(syncFocusedTaskSpy).toHaveBeenCalledWith({ includeTaskHistory: true }) }) test("logs detached workspace initialization failures", async () => { diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index e7ad0de694..3fa1314031 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -119,6 +119,7 @@ const mockClineProvider = { postStateToWebview: vi.fn(), syncFocusedTaskToWebview: vi.fn().mockResolvedValue(undefined), resyncClineMessagesToWebview: vi.fn().mockResolvedValue(undefined), + clearTask: vi.fn().mockResolvedValue(undefined), resolveWebviewThemeFixtureProbe: vi.fn(), getCurrentTask: vi.fn(), getTaskWithId: vi.fn(), @@ -145,6 +146,20 @@ describe("webviewMessageHandler - transcript resync", () => { }) }) +describe("webviewMessageHandler - clear task", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("clears the task and synchronizes focused state with task history", async () => { + await webviewMessageHandler(mockClineProvider, { type: "clearTask" }) + + expect(mockClineProvider.clearTask).toHaveBeenCalledOnce() + expect(mockClineProvider.syncFocusedTaskToWebview).toHaveBeenCalledOnce() + expect(mockClineProvider.syncFocusedTaskToWebview).toHaveBeenCalledWith({ includeTaskHistory: true }) + }) +}) + describe("webviewMessageHandler - theme fixture probes", () => { const originalProbeSetting = process.env.ROO_CODE_THEME_FIXTURE_PROBE const themeFixture = { diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index cd39ce7e61..35925a1708 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -338,47 +338,56 @@ export const ExtensionStateContextProvider: React.FC<{ })) }, []) - const clearClineMessagesResync = useCallback(() => { - resyncPendingRef.current = false - if (resyncTimeoutRef.current !== undefined) { - window.clearTimeout(resyncTimeoutRef.current) - resyncTimeoutRef.current = undefined - } - }, []) - - const requestClineMessagesResync = useCallback((receivedSeq?: number) => { - if (resyncPendingRef.current) { - return - } - resyncPendingRef.current = true - resyncTimeoutRef.current = window.setTimeout(() => { + const clearClineMessagesResync = useCallback( + () => { resyncPendingRef.current = false - resyncTimeoutRef.current = undefined - }, CLINE_MESSAGES_RESYNC_TIMEOUT_MS) - vscode.postMessage({ - type: "requestClineMessagesResync", - taskId: activeTaskIdRef.current, - expectedSeq: clineMessagesSeqRef.current + 1, - receivedSeq, - }) - }, []) + if (resyncTimeoutRef.current !== undefined) { + window.clearTimeout(resyncTimeoutRef.current) + resyncTimeoutRef.current = undefined + } + }, + // Stryker disable next-line ArrayDeclaration: an inserted constant never changes, so this ref-only callback retains the same identity and captures. + [], + ) + + const requestClineMessagesResync = useCallback( + (receivedSeq?: number) => { + if (resyncPendingRef.current) { + return + } + resyncPendingRef.current = true + resyncTimeoutRef.current = window.setTimeout(() => { + resyncPendingRef.current = false + resyncTimeoutRef.current = undefined + }, CLINE_MESSAGES_RESYNC_TIMEOUT_MS) + vscode.postMessage({ + type: "requestClineMessagesResync", + taskId: activeTaskIdRef.current, + expectedSeq: clineMessagesSeqRef.current + 1, + receivedSeq, + }) + }, + // Stryker disable next-line ArrayDeclaration: an inserted constant never changes, so this ref-only callback retains the same identity and captures. + [], + ) const retryClineMessagesResync = useCallback( (receivedSeq?: number) => { clearClineMessagesResync() requestClineMessagesResync(receivedSeq) }, + // Stryker disable next-line ArrayDeclaration: both dependencies are stable callbacks; omitting them cannot alter callback identity or captured values. [clearClineMessagesResync, requestClineMessagesResync], ) const applyClineMessagesDelta = useCallback( (message: ExtensionMessage, operation: "append" | "update") => { - const seq = message.clineMessagesSeq + const seq = message.clineMessagesSeq as number const clineMessage = message.clineMessage if (message.taskId !== activeTaskIdRef.current) { return } - if (typeof seq !== "number" || !Number.isSafeInteger(seq) || seq < 0 || !clineMessage) { + if (!Number.isSafeInteger(seq) || seq < 0 || !clineMessage) { requestClineMessagesResync(typeof seq === "number" ? seq : undefined) return } @@ -423,6 +432,7 @@ export const ExtensionStateContextProvider: React.FC<{ clineMessagesSeq: seq, })) }, + // Stryker disable next-line ArrayDeclaration: both dependencies are stable callbacks; an empty dependency list produces the same closure for the provider lifetime. [requestClineMessagesResync, retryClineMessagesResync], ) @@ -533,8 +543,8 @@ export const ExtensionStateContextProvider: React.FC<{ break } - const seq = message.clineMessagesSeq - if (typeof seq !== "number" || !Number.isSafeInteger(seq) || seq < 0) { + const seq = message.clineMessagesSeq as number + if (!Number.isSafeInteger(seq) || seq < 0) { activeSnapshotRef.current = null retryClineMessagesResync(typeof seq === "number" ? seq : undefined) break @@ -543,8 +553,8 @@ export const ExtensionStateContextProvider: React.FC<{ break } - const total = message.snapshotTotal - if (!message.snapshotId || typeof total !== "number" || !Number.isSafeInteger(total) || total < 0) { + const total = message.snapshotTotal as number + if (!message.snapshotId || !Number.isSafeInteger(total) || total < 0) { activeSnapshotRef.current = null retryClineMessagesResync(seq) break @@ -572,9 +582,9 @@ export const ExtensionStateContextProvider: React.FC<{ break } - const seq = message.clineMessagesSeq + const seq = message.clineMessagesSeq as number const snapshot = activeSnapshotRef.current - if (typeof seq !== "number" || !Number.isSafeInteger(seq) || seq < 0) { + if (!Number.isSafeInteger(seq) || seq < 0) { activeSnapshotRef.current = null retryClineMessagesResync(typeof seq === "number" ? seq : undefined) break @@ -594,11 +604,10 @@ export const ExtensionStateContextProvider: React.FC<{ } const chunk = message.clineMessages - const startIndex = message.snapshotStartIndex + const startIndex = message.snapshotStartIndex as number if ( !Array.isArray(chunk) || chunk.length === 0 || - typeof startIndex !== "number" || !Number.isSafeInteger(startIndex) || startIndex !== snapshot.messages.length || snapshot.messages.length + chunk.length > snapshot.total @@ -616,9 +625,9 @@ export const ExtensionStateContextProvider: React.FC<{ break } - const seq = message.clineMessagesSeq + const seq = message.clineMessagesSeq as number const snapshot = activeSnapshotRef.current - if (typeof seq !== "number" || !Number.isSafeInteger(seq) || seq < 0) { + if (!Number.isSafeInteger(seq) || seq < 0) { activeSnapshotRef.current = null retryClineMessagesResync(typeof seq === "number" ? seq : undefined) break @@ -658,6 +667,7 @@ export const ExtensionStateContextProvider: React.FC<{ break } case "clineMessageUpdated": { + // Stryker disable next-line StringLiteral: applyClineMessagesDelta treats every non-"append" operation as an update, so replacing this literal with another non-append string is equivalent. applyClineMessagesDelta(message, "update") break } @@ -744,6 +754,7 @@ export const ExtensionStateContextProvider: React.FC<{ } } }, + // Stryker disable next-line ArrayDeclaration: every listed dependency is a stable callback; removing the list does not change this listener closure. [ applyClineMessagesDelta, clearClineMessagesResync, @@ -753,13 +764,17 @@ export const ExtensionStateContextProvider: React.FC<{ ], ) - useEffect(() => { - window.addEventListener("message", handleMessage) - return () => { - window.removeEventListener("message", handleMessage) - clearClineMessagesResync() - } - }, [clearClineMessagesResync, handleMessage]) + useEffect( + () => { + window.addEventListener("message", handleMessage) + return () => { + window.removeEventListener("message", handleMessage) + clearClineMessagesResync() + } + }, + // Stryker disable next-line ArrayDeclaration: both effect dependencies are stable callbacks, making an empty list behaviorally identical for the provider lifetime. + [clearClineMessagesResync, handleMessage], + ) useEffect(() => { vscode.postMessage({ type: "webviewDidLaunch" }) diff --git a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx index dc4cf78e77..09a1ea0323 100644 --- a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx +++ b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx @@ -446,6 +446,607 @@ describe("ExtensionStateContext", () => { , ) + const dispatchMalformedExtensionMessage = (message: unknown) => + dispatchExtensionMessage(message as ExtensionMessage) + const startSnapshot = (overrides: Record = {}) => + dispatchMalformedExtensionMessage({ + type: "clineMessagesSnapshotStart", + taskId: "task-1", + clineMessagesSeq: 2, + snapshotId: "snapshot-1", + snapshotTotal: 1, + ...overrides, + }) + const appendSnapshotChunk = (overrides: Record = {}) => + dispatchMalformedExtensionMessage({ + type: "clineMessagesSnapshotChunk", + taskId: "task-1", + clineMessagesSeq: 2, + snapshotId: "snapshot-1", + snapshotStartIndex: 0, + clineMessages: [makeMessage(2, "snapshot")], + ...overrides, + }) + const endSnapshot = (overrides: Record = {}) => + dispatchMalformedExtensionMessage({ + type: "clineMessagesSnapshotEnd", + taskId: "task-1", + clineMessagesSeq: 2, + snapshotId: "snapshot-1", + snapshotTotal: 1, + ...overrides, + }) + const renderTranscriptWithPostMessageSpy = (initialState: Partial = {}) => { + const postMessage = vi.spyOn(vscode, "postMessage").mockImplementation(() => undefined) + renderTranscript(initialState) + postMessage.mockClear() + return postMessage + } + + afterEach(() => { + vi.restoreAllMocks() + vi.useRealTimers() + }) + + it("ignores a delta for a different task", () => { + const existing = makeMessage(1, "existing") + const postMessage = renderTranscriptWithPostMessageSpy({ clineMessages: [existing], clineMessagesSeq: 1 }) + + act(() => appendClineMessage(makeMessage(2, "wrong task"), 2, "task-2")) + + expect(postMessage).not.toHaveBeenCalled() + expect(readTranscriptFields()).toEqual({ + currentTaskId: "task-1", + clineMessages: [existing], + clineMessagesSeq: 1, + }) + }) + + it.each([ + { + name: "a missing sequence", + seq: undefined, + receivedSeq: undefined, + clineMessage: makeMessage(2, "next"), + }, + { name: "a nonnumeric sequence", seq: "2", receivedSeq: undefined, clineMessage: makeMessage(2, "next") }, + { name: "a boolean sequence", seq: true, receivedSeq: undefined, clineMessage: makeMessage(2, "next") }, + { name: "a fractional sequence", seq: 1.5, receivedSeq: 1.5, clineMessage: makeMessage(2, "next") }, + { name: "a negative sequence", seq: -1, receivedSeq: -1, clineMessage: makeMessage(2, "next") }, + { name: "a missing message", seq: 2, receivedSeq: 2, clineMessage: undefined }, + ])("requests resynchronization for $name in a delta", ({ seq, receivedSeq, clineMessage }) => { + const existing = makeMessage(1, "existing") + const postMessage = renderTranscriptWithPostMessageSpy({ clineMessages: [existing], clineMessagesSeq: 1 }) + + act(() => + dispatchMalformedExtensionMessage({ + type: "clineMessageAppended", + taskId: "task-1", + clineMessagesSeq: seq, + clineMessage, + }), + ) + + expect(postMessage).toHaveBeenCalledTimes(1) + expect(postMessage).toHaveBeenCalledWith({ + type: "requestClineMessagesResync", + taskId: "task-1", + expectedSeq: 2, + receivedSeq, + }) + expect(readTranscriptFields()).toEqual({ + currentTaskId: "task-1", + clineMessages: [existing], + clineMessagesSeq: 1, + }) + }) + + it.each([0, 1])("ignores stale delta sequence %s", (seq) => { + const existing = makeMessage(1, "existing") + const postMessage = renderTranscriptWithPostMessageSpy({ clineMessages: [existing], clineMessagesSeq: 1 }) + + act(() => appendClineMessage(makeMessage(2, "stale"), seq, "task-1")) + + expect(postMessage).not.toHaveBeenCalled() + expect(readTranscriptFields()).toEqual({ + currentTaskId: "task-1", + clineMessages: [existing], + clineMessagesSeq: 1, + }) + }) + + it.each([ + { name: "an explicit same-task update", state: { currentTaskId: "task-1", version: "2.0.0" } }, + { name: "a partial metadata update", state: { version: "2.0.0" } }, + ])("preserves transcript refs through $name", ({ state }) => { + const existing = makeMessage(1, "existing") + const next = makeMessage(2, "next") + const postMessage = renderTranscriptWithPostMessageSpy({ clineMessages: [existing], clineMessagesSeq: 3 }) + + act(() => { + dispatchMalformedExtensionMessage({ type: "state", state }) + appendClineMessage(next, 4, "task-1") + }) + + expect(postMessage).not.toHaveBeenCalled() + expect(readTranscriptFields()).toEqual({ + currentTaskId: "task-1", + clineMessages: [existing, next], + clineMessagesSeq: 4, + }) + }) + + it("starts the replacement task with an empty transcript ref", () => { + const next = makeMessage(2, "replacement task") + const postMessage = renderTranscriptWithPostMessageSpy({ + clineMessages: [makeMessage(1, "existing")], + clineMessagesSeq: 3, + }) + + act(() => { + dispatchExtensionMessage({ type: "state", state: { currentTaskId: "task-2" } }) + appendClineMessage(next, 1, "task-2") + }) + + expect(postMessage).not.toHaveBeenCalled() + expect(readTranscriptFields()).toEqual({ + currentTaskId: "task-2", + clineMessages: [next], + clineMessagesSeq: 1, + }) + }) + + it("does not clear a nonexistent resync timeout during a task switch", () => { + vi.useFakeTimers() + const clearTimeout = vi.spyOn(window, "clearTimeout") + renderTranscript({ clineMessagesSeq: 1 }) + + act(() => dispatchExtensionMessage({ type: "state", state: { currentTaskId: "task-2" } })) + + expect(clearTimeout).not.toHaveBeenCalled() + }) + + it("clears a pending resync before requesting recovery for a replacement task", () => { + vi.useFakeTimers() + const postMessage = renderTranscriptWithPostMessageSpy({ clineMessagesSeq: 1 }) + const clearTimeout = vi.spyOn(window, "clearTimeout") + + act(() => appendClineMessage(makeMessage(3, "old gap"), 3, "task-1")) + expect(postMessage).toHaveBeenCalledTimes(1) + const timeoutHandle = vi.getTimerCount() + act(() => { + dispatchExtensionMessage({ type: "state", state: { currentTaskId: "task-2" } }) + appendClineMessage(makeMessage(2, "new gap"), 2, "task-2") + }) + + expect(timeoutHandle).toBe(1) + expect(clearTimeout).toHaveBeenCalledTimes(1) + expect(postMessage).toHaveBeenCalledTimes(2) + expect(postMessage).toHaveBeenLastCalledWith({ + type: "requestClineMessagesResync", + taskId: "task-2", + expectedSeq: 1, + receivedSeq: 2, + }) + }) + + it("clears a pending resync timeout when the provider unmounts", () => { + vi.useFakeTimers() + const postMessage = vi.spyOn(vscode, "postMessage").mockImplementation(() => undefined) + const clearTimeout = vi.spyOn(window, "clearTimeout") + const { unmount } = renderTranscript({ clineMessagesSeq: 1 }) + postMessage.mockClear() + + act(() => appendClineMessage(makeMessage(3, "gap"), 3, "task-1")) + clearTimeout.mockClear() + unmount() + + expect(clearTimeout).toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) + }) + + it.each([ + { name: "a nonnumeric sequence", overrides: { clineMessagesSeq: "2" }, receivedSeq: undefined }, + { name: "a boolean sequence", overrides: { clineMessagesSeq: true }, receivedSeq: undefined }, + { name: "a fractional sequence", overrides: { clineMessagesSeq: 1.5 }, receivedSeq: 1.5 }, + { name: "a negative sequence", overrides: { clineMessagesSeq: -1 }, receivedSeq: -1 }, + ])("rejects a snapshot start with $name", ({ overrides, receivedSeq }) => { + const postMessage = renderTranscriptWithPostMessageSpy({ clineMessagesSeq: 1 }) + + act(() => startSnapshot(overrides)) + + expect(postMessage).toHaveBeenCalledTimes(1) + expect(postMessage).toHaveBeenCalledWith({ + type: "requestClineMessagesResync", + taskId: "task-1", + expectedSeq: 2, + receivedSeq, + }) + }) + + it.each([ + { name: "a missing snapshot ID", overrides: { snapshotId: "" } }, + { name: "a nonnumeric total", overrides: { snapshotTotal: "1" } }, + { name: "a boolean total", overrides: { snapshotTotal: true } }, + { name: "a fractional total", overrides: { snapshotTotal: 1.5 } }, + { name: "a negative total", overrides: { snapshotTotal: -1 } }, + ])("rejects a snapshot start with $name", ({ overrides }) => { + const postMessage = renderTranscriptWithPostMessageSpy({ clineMessagesSeq: 1 }) + + act(() => startSnapshot(overrides)) + + expect(postMessage).toHaveBeenCalledTimes(1) + expect(postMessage).toHaveBeenCalledWith( + expect.objectContaining({ type: "requestClineMessagesResync", receivedSeq: 2 }), + ) + }) + + it("rejects a snapshot start for a different task before it can accept current-task chunks", () => { + const existing = makeMessage(1, "existing") + const postMessage = renderTranscriptWithPostMessageSpy({ clineMessages: [existing], clineMessagesSeq: 1 }) + + act(() => { + startSnapshot({ taskId: "task-2", clineMessagesSeq: 3, snapshotId: "wrong-task" }) + appendSnapshotChunk({ clineMessagesSeq: 3, snapshotId: "wrong-task" }) + }) + + expect(postMessage).toHaveBeenCalledTimes(1) + expect(postMessage).toHaveBeenCalledWith( + expect.objectContaining({ type: "requestClineMessagesResync", receivedSeq: 3 }), + ) + expect(readTranscriptFields()).toEqual({ + currentTaskId: "task-1", + clineMessages: [existing], + clineMessagesSeq: 1, + }) + }) + + it("ignores a snapshot older than the applied transcript", () => { + const existing = makeMessage(1, "existing") + const postMessage = renderTranscriptWithPostMessageSpy({ clineMessages: [existing], clineMessagesSeq: 2 }) + + act(() => { + startSnapshot({ clineMessagesSeq: 1, snapshotTotal: 0, snapshotId: "stale" }) + endSnapshot({ clineMessagesSeq: 1, snapshotTotal: 0, snapshotId: "stale" }) + }) + + expect(postMessage).not.toHaveBeenCalled() + expect(readTranscriptFields()).toEqual({ + currentTaskId: "task-1", + clineMessages: [existing], + clineMessagesSeq: 2, + }) + }) + + it("ignores a duplicate start without discarding collected chunks", () => { + renderTranscript({ clineMessagesSeq: 1 }) + + act(() => { + startSnapshot() + appendSnapshotChunk() + startSnapshot() + endSnapshot() + }) + + expect(readTranscriptFields()).toEqual({ + currentTaskId: "task-1", + clineMessages: [makeMessage(2, "snapshot")], + clineMessagesSeq: 2, + }) + }) + + it("ignores an older start without replacing the active snapshot", () => { + renderTranscript({ clineMessagesSeq: 1 }) + + act(() => { + startSnapshot({ clineMessagesSeq: 3, snapshotId: "newer" }) + appendSnapshotChunk({ clineMessagesSeq: 3, snapshotId: "newer" }) + startSnapshot({ clineMessagesSeq: 2, snapshotId: "older" }) + endSnapshot({ clineMessagesSeq: 3, snapshotId: "newer" }) + }) + + expect(readTranscriptFields()).toEqual({ + currentTaskId: "task-1", + clineMessages: [makeMessage(2, "snapshot")], + clineMessagesSeq: 3, + }) + }) + + it("replaces an active snapshot when the same ID arrives at a newer sequence", () => { + const replacement = makeMessage(3, "replacement") + const postMessage = renderTranscriptWithPostMessageSpy({ clineMessagesSeq: 1 }) + + act(() => { + startSnapshot({ clineMessagesSeq: 2, snapshotId: "reused-id" }) + startSnapshot({ clineMessagesSeq: 3, snapshotId: "reused-id" }) + appendSnapshotChunk({ clineMessagesSeq: 3, snapshotId: "reused-id", clineMessages: [replacement] }) + endSnapshot({ clineMessagesSeq: 3, snapshotId: "reused-id" }) + }) + + expect(postMessage).not.toHaveBeenCalled() + expect(readTranscriptFields()).toEqual({ + currentTaskId: "task-1", + clineMessages: [replacement], + clineMessagesSeq: 3, + }) + }) + + it.each([ + { name: "the same sequence uses a replacement ID", seq: 2, snapshotId: "replacement" }, + { name: "a newer sequence starts", seq: 3, snapshotId: "newer" }, + ])("replaces an active snapshot when $name", ({ seq, snapshotId }) => { + const replacement = makeMessage(seq, "replacement") + const postMessage = renderTranscriptWithPostMessageSpy({ clineMessagesSeq: 1 }) + + act(() => { + startSnapshot() + startSnapshot({ clineMessagesSeq: seq, snapshotId }) + appendSnapshotChunk({ clineMessagesSeq: seq, snapshotId, clineMessages: [replacement] }) + endSnapshot({ clineMessagesSeq: seq, snapshotId }) + }) + + expect(postMessage).not.toHaveBeenCalled() + expect(readTranscriptFields()).toEqual({ + currentTaskId: "task-1", + clineMessages: [replacement], + clineMessagesSeq: seq, + }) + }) + + it("accepts sequence zero throughout a complete snapshot", () => { + const message = makeMessage(1, "initial snapshot") + const postMessage = renderTranscriptWithPostMessageSpy() + + act(() => { + startSnapshot({ clineMessagesSeq: 0 }) + appendSnapshotChunk({ clineMessagesSeq: 0, clineMessages: [message] }) + endSnapshot({ clineMessagesSeq: 0 }) + }) + + expect(postMessage).not.toHaveBeenCalled() + expect(readTranscriptFields()).toEqual({ + currentTaskId: "task-1", + clineMessages: [message], + clineMessagesSeq: 0, + }) + }) + + it.each([ + { name: "a nonnumeric sequence", seq: "2", receivedSeq: undefined }, + { name: "a boolean sequence", seq: true, receivedSeq: undefined }, + { name: "a fractional sequence", seq: 1.5, receivedSeq: 1.5 }, + { name: "a negative sequence", seq: -1, receivedSeq: -1 }, + ])("rejects a snapshot chunk with $name", ({ seq, receivedSeq }) => { + const postMessage = renderTranscriptWithPostMessageSpy({ clineMessagesSeq: 1 }) + + act(() => { + startSnapshot() + appendSnapshotChunk({ clineMessagesSeq: seq }) + }) + + expect(postMessage).toHaveBeenCalledTimes(1) + expect(postMessage).toHaveBeenCalledWith({ + type: "requestClineMessagesResync", + taskId: "task-1", + expectedSeq: 2, + receivedSeq, + }) + }) + + it("invalidates an active snapshot after a malformed chunk", () => { + const postMessage = renderTranscriptWithPostMessageSpy({ clineMessagesSeq: 1 }) + + act(() => { + startSnapshot() + appendSnapshotChunk({ clineMessagesSeq: "invalid" }) + appendSnapshotChunk() + }) + + expect(postMessage).toHaveBeenCalledTimes(2) + expect(postMessage.mock.calls.map(([message]) => message.receivedSeq)).toEqual([undefined, 2]) + }) + + it.each([ + { name: "a missing snapshot", seq: 2, shouldResync: true }, + { name: "a stale missing snapshot", seq: 1, shouldResync: false }, + { name: "an equal missing snapshot", seq: 2, shouldResync: false, initialSeq: 2 }, + ])("handles a chunk with $name", ({ seq, shouldResync, initialSeq = 1 }) => { + const postMessage = renderTranscriptWithPostMessageSpy({ clineMessagesSeq: initialSeq }) + + act(() => appendSnapshotChunk({ clineMessagesSeq: seq, snapshotId: "missing" })) + + if (shouldResync) { + expect(postMessage).toHaveBeenCalledTimes(1) + expect(postMessage).toHaveBeenCalledWith( + expect.objectContaining({ type: "requestClineMessagesResync", receivedSeq: seq }), + ) + } else { + expect(postMessage).not.toHaveBeenCalled() + } + }) + + it.each([ + { + name: "a newer sequence", + overrides: { clineMessagesSeq: 3 }, + expectedResyncSeq: 3, + }, + { + name: "a newer ID and sequence", + overrides: { snapshotId: "newer", clineMessagesSeq: 3 }, + expectedResyncSeq: 3, + }, + ])("restarts after a chunk with $name", ({ overrides, expectedResyncSeq }) => { + const postMessage = renderTranscriptWithPostMessageSpy({ clineMessagesSeq: 1 }) + + act(() => { + startSnapshot() + appendSnapshotChunk(overrides) + }) + + expect(postMessage).toHaveBeenCalledTimes(1) + expect(postMessage).toHaveBeenCalledWith( + expect.objectContaining({ type: "requestClineMessagesResync", receivedSeq: expectedResyncSeq }), + ) + }) + + it.each([ + { name: "an older sequence", overrides: { clineMessagesSeq: 1 } }, + { name: "a different ID at the same sequence", overrides: { snapshotId: "other" } }, + ])("ignores a chunk with $name and preserves the active snapshot", ({ overrides }) => { + const postMessage = renderTranscriptWithPostMessageSpy({ clineMessagesSeq: 1 }) + + act(() => { + startSnapshot() + appendSnapshotChunk(overrides) + appendSnapshotChunk() + endSnapshot() + }) + + expect(postMessage).not.toHaveBeenCalled() + expect(readTranscriptFields()).toEqual({ + currentTaskId: "task-1", + clineMessages: [makeMessage(2, "snapshot")], + clineMessagesSeq: 2, + }) + }) + + it.each([ + { name: "a non-array payload", overrides: { clineMessages: "message" } }, + { name: "an empty payload", overrides: { clineMessages: [] } }, + { name: "a nonnumeric start index", overrides: { snapshotStartIndex: "0" } }, + { name: "a boolean start index", overrides: { snapshotStartIndex: true } }, + { name: "a fractional start index", overrides: { snapshotStartIndex: 0.5 } }, + { name: "a noncontiguous start index", overrides: { snapshotStartIndex: 1 } }, + { + name: "messages beyond the declared total", + overrides: { clineMessages: [makeMessage(2, "first"), makeMessage(3, "overflow")] }, + }, + ])("rejects a snapshot chunk with $name", ({ overrides }) => { + const postMessage = renderTranscriptWithPostMessageSpy({ clineMessagesSeq: 1 }) + + act(() => { + startSnapshot() + appendSnapshotChunk(overrides) + }) + + expect(postMessage).toHaveBeenCalledTimes(1) + expect(postMessage).toHaveBeenCalledWith( + expect.objectContaining({ type: "requestClineMessagesResync", receivedSeq: 2 }), + ) + }) + + it.each([ + { name: "a nonnumeric sequence", seq: "2", receivedSeq: undefined }, + { name: "a boolean sequence", seq: true, receivedSeq: undefined }, + { name: "a fractional sequence", seq: 1.5, receivedSeq: 1.5 }, + { name: "a negative sequence", seq: -1, receivedSeq: -1 }, + ])("rejects a snapshot end with $name", ({ seq, receivedSeq }) => { + const postMessage = renderTranscriptWithPostMessageSpy({ clineMessagesSeq: 1 }) + + act(() => { + startSnapshot() + endSnapshot({ clineMessagesSeq: seq }) + }) + + expect(postMessage).toHaveBeenCalledTimes(1) + expect(postMessage).toHaveBeenCalledWith({ + type: "requestClineMessagesResync", + taskId: "task-1", + expectedSeq: 2, + receivedSeq, + }) + }) + + it("invalidates an active snapshot after a malformed end", () => { + const postMessage = renderTranscriptWithPostMessageSpy({ clineMessagesSeq: 1 }) + + act(() => { + startSnapshot() + endSnapshot({ clineMessagesSeq: "invalid" }) + appendSnapshotChunk() + }) + + expect(postMessage).toHaveBeenCalledTimes(2) + expect(postMessage.mock.calls.map(([message]) => message.receivedSeq)).toEqual([undefined, 2]) + }) + + it.each([ + { name: "a missing snapshot", seq: 2, shouldResync: true }, + { name: "a stale missing snapshot", seq: 1, shouldResync: false }, + { name: "an equal missing snapshot", seq: 2, shouldResync: false, initialSeq: 2 }, + ])("handles an end with $name", ({ seq, shouldResync, initialSeq = 1 }) => { + const postMessage = renderTranscriptWithPostMessageSpy({ clineMessagesSeq: initialSeq }) + + act(() => endSnapshot({ clineMessagesSeq: seq, snapshotId: "missing" })) + + if (shouldResync) { + expect(postMessage).toHaveBeenCalledTimes(1) + expect(postMessage).toHaveBeenCalledWith( + expect.objectContaining({ type: "requestClineMessagesResync", receivedSeq: seq }), + ) + } else { + expect(postMessage).not.toHaveBeenCalled() + } + }) + + it.each([ + { name: "a newer sequence", overrides: { clineMessagesSeq: 3 } }, + { name: "a newer ID and sequence", overrides: { snapshotId: "newer", clineMessagesSeq: 3 } }, + ])("restarts after an end with $name", ({ overrides }) => { + const postMessage = renderTranscriptWithPostMessageSpy({ clineMessagesSeq: 1 }) + + act(() => { + startSnapshot() + endSnapshot(overrides) + }) + + expect(postMessage).toHaveBeenCalledTimes(1) + expect(postMessage).toHaveBeenCalledWith( + expect.objectContaining({ type: "requestClineMessagesResync", receivedSeq: 3 }), + ) + }) + + it.each([ + { name: "an older sequence", overrides: { clineMessagesSeq: 1 } }, + { name: "a different ID at the same sequence", overrides: { snapshotId: "other" } }, + ])("ignores an end with $name and preserves the active snapshot", ({ overrides }) => { + const postMessage = renderTranscriptWithPostMessageSpy({ clineMessagesSeq: 1 }) + + act(() => { + startSnapshot() + endSnapshot(overrides) + appendSnapshotChunk() + endSnapshot() + }) + + expect(postMessage).not.toHaveBeenCalled() + expect(readTranscriptFields()).toEqual({ + currentTaskId: "task-1", + clineMessages: [makeMessage(2, "snapshot")], + clineMessagesSeq: 2, + }) + }) + + it.each([ + { name: "a mismatched declared total", overrides: { snapshotTotal: 2 } }, + { name: "an incomplete message list", overrides: {} }, + ])("rejects a snapshot end with $name", ({ name, overrides }) => { + const postMessage = renderTranscriptWithPostMessageSpy({ clineMessagesSeq: 1 }) + + act(() => { + startSnapshot() + if (name === "a mismatched declared total") { + appendSnapshotChunk() + } + endSnapshot(overrides) + }) + + expect(postMessage).toHaveBeenCalledTimes(1) + expect(postMessage).toHaveBeenCalledWith( + expect.objectContaining({ type: "requestClineMessagesResync", receivedSeq: 2 }), + ) + }) it("reconstructs a snapshot and applies contiguous append and update deltas", () => { render( @@ -579,6 +1180,33 @@ describe("ExtensionStateContext", () => { }) }) + it("does not retain a pending transcript when the authoritative state clears the task", () => { + const postMessage = renderTranscriptWithPostMessageSpy({ + clineMessages: [makeMessage(1, "existing")], + clineMessagesSeq: 1, + }) + + act(() => { + startSnapshot({ clineMessagesSeq: 2, snapshotId: "pending" }) + dispatchExtensionMessage({ type: "state", state: { currentTaskId: null } }) + appendSnapshotChunk({ taskId: undefined, clineMessagesSeq: 2, snapshotId: "pending" }) + }) + + expect(postMessage).toHaveBeenCalledTimes(1) + expect(postMessage).toHaveBeenCalledWith( + expect.objectContaining({ type: "requestClineMessagesResync", taskId: undefined, receivedSeq: 2 }), + ) + expect(readTranscript()).toEqual({ + currentTaskId: null, + currentTaskItem: null, + currentTaskTodos: [], + messageQueue: [], + currentCheckpoint: null, + clineMessages: [], + clineMessagesSeq: 0, + }) + }) + it("preserves task-scoped state when a partial state update omits currentTaskId", () => { const existing = makeMessage(1, "existing") const currentTaskItem = { From afef169641340c618b52dc508234e3ddb4f71619 Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Fri, 4 Sep 2026 06:48:30 -0600 Subject: [PATCH 20/26] test: address transcript review feedback --- src/core/webview/__tests__/ClineProvider.spec.ts | 4 +--- .../src/context/__tests__/ExtensionStateContext.spec.tsx | 4 ++-- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 5508d05f7f..b22dca4809 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -1127,9 +1127,7 @@ describe("ClineProvider", () => { releaseQueue() await pendingDelta - expect(postSpy).not.toHaveBeenCalledWith( - expect.objectContaining({ type: messageType, taskId: "task-1" }), - ) + expect(postSpy).not.toHaveBeenCalledWith(expect.objectContaining({ type: messageType })) }, ) diff --git a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx index 09a1ea0323..594a0e2a74 100644 --- a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx +++ b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx @@ -613,13 +613,13 @@ describe("ExtensionStateContext", () => { act(() => appendClineMessage(makeMessage(3, "old gap"), 3, "task-1")) expect(postMessage).toHaveBeenCalledTimes(1) - const timeoutHandle = vi.getTimerCount() + const pendingTimerCount = vi.getTimerCount() act(() => { dispatchExtensionMessage({ type: "state", state: { currentTaskId: "task-2" } }) appendClineMessage(makeMessage(2, "new gap"), 2, "task-2") }) - expect(timeoutHandle).toBe(1) + expect(pendingTimerCount).toBe(1) expect(clearTimeout).toHaveBeenCalledTimes(1) expect(postMessage).toHaveBeenCalledTimes(2) expect(postMessage).toHaveBeenLastCalledWith({ From b5e9086f990fa04a7f9916e890999e35b9c663ef Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Fri, 4 Sep 2026 07:22:41 -0600 Subject: [PATCH 21/26] fix: expire incomplete transcript snapshots --- .../src/context/ExtensionStateContext.tsx | 59 +++++++--- .../__tests__/ExtensionStateContext.spec.tsx | 110 ++++++++++++++++++ 2 files changed, 156 insertions(+), 13 deletions(-) diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index 35925a1708..ca2c095012 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -166,6 +166,7 @@ type ClineMessagesSnapshotBuffer = { } const CLINE_MESSAGES_RESYNC_TIMEOUT_MS = 5_000 +const CLINE_MESSAGES_SNAPSHOT_TIMEOUT_MS = 30_000 export const mergeExtensionState = (prevState: ExtensionState, newState: Partial) => { const { customModePrompts: prevCustomModePrompts, experiments: prevExperiments, ...prevRest } = prevState @@ -287,6 +288,7 @@ export const ExtensionStateContextProvider: React.FC<{ const clineMessagesSeqRef = useRef(state.clineMessagesSeq ?? 0) const clineMessagesRef = useRef(state.clineMessages) const activeSnapshotRef = useRef(null) + const snapshotTimeoutRef = useRef(undefined) const resyncPendingRef = useRef(false) const resyncTimeoutRef = useRef(undefined) @@ -350,6 +352,14 @@ export const ExtensionStateContextProvider: React.FC<{ [], ) + const clearClineMessagesSnapshot = useCallback(() => { + activeSnapshotRef.current = null + if (snapshotTimeoutRef.current !== undefined) { + window.clearTimeout(snapshotTimeoutRef.current) + snapshotTimeoutRef.current = undefined + } + }, []) + const requestClineMessagesResync = useCallback( (receivedSeq?: number) => { if (resyncPendingRef.current) { @@ -380,6 +390,25 @@ export const ExtensionStateContextProvider: React.FC<{ [clearClineMessagesResync, requestClineMessagesResync], ) + const startClineMessagesSnapshotTimeout = useCallback( + (snapshotId: string, seq: number) => { + if (snapshotTimeoutRef.current !== undefined) { + window.clearTimeout(snapshotTimeoutRef.current) + } + snapshotTimeoutRef.current = window.setTimeout(() => { + const snapshot = activeSnapshotRef.current + if (snapshot?.snapshotId !== snapshotId || snapshot.seq !== seq) { + snapshotTimeoutRef.current = undefined + return + } + activeSnapshotRef.current = null + snapshotTimeoutRef.current = undefined + retryClineMessagesResync(seq) + }, CLINE_MESSAGES_SNAPSHOT_TIMEOUT_MS) + }, + [retryClineMessagesResync], + ) + const applyClineMessagesDelta = useCallback( (message: ExtensionMessage, operation: "append" | "update") => { const seq = message.clineMessagesSeq as number @@ -399,7 +428,7 @@ export const ExtensionStateContextProvider: React.FC<{ if (seq <= snapshot.seq) { return } - activeSnapshotRef.current = null + clearClineMessagesSnapshot() retryClineMessagesResync(seq) return } @@ -433,7 +462,7 @@ export const ExtensionStateContextProvider: React.FC<{ })) }, // Stryker disable next-line ArrayDeclaration: both dependencies are stable callbacks; an empty dependency list produces the same closure for the provider lifetime. - [requestClineMessagesResync, retryClineMessagesResync], + [clearClineMessagesSnapshot, requestClineMessagesResync, retryClineMessagesResync], ) const handleMessage = useCallback( @@ -456,7 +485,7 @@ export const ExtensionStateContextProvider: React.FC<{ activeTaskIdRef.current = nextTaskId clineMessagesSeqRef.current = 0 clineMessagesRef.current = [] - activeSnapshotRef.current = null + clearClineMessagesSnapshot() clearClineMessagesResync() } setState((prevState) => { @@ -545,7 +574,7 @@ export const ExtensionStateContextProvider: React.FC<{ const seq = message.clineMessagesSeq as number if (!Number.isSafeInteger(seq) || seq < 0) { - activeSnapshotRef.current = null + clearClineMessagesSnapshot() retryClineMessagesResync(typeof seq === "number" ? seq : undefined) break } @@ -555,7 +584,7 @@ export const ExtensionStateContextProvider: React.FC<{ const total = message.snapshotTotal as number if (!message.snapshotId || !Number.isSafeInteger(total) || total < 0) { - activeSnapshotRef.current = null + clearClineMessagesSnapshot() retryClineMessagesResync(seq) break } @@ -575,6 +604,7 @@ export const ExtensionStateContextProvider: React.FC<{ total, messages: [], } + startClineMessagesSnapshotTimeout(message.snapshotId, seq) break } case "clineMessagesSnapshotChunk": { @@ -585,7 +615,7 @@ export const ExtensionStateContextProvider: React.FC<{ const seq = message.clineMessagesSeq as number const snapshot = activeSnapshotRef.current if (!Number.isSafeInteger(seq) || seq < 0) { - activeSnapshotRef.current = null + clearClineMessagesSnapshot() retryClineMessagesResync(typeof seq === "number" ? seq : undefined) break } @@ -597,7 +627,7 @@ export const ExtensionStateContextProvider: React.FC<{ } if (message.snapshotId !== snapshot.snapshotId || seq !== snapshot.seq) { if (seq > snapshot.seq) { - activeSnapshotRef.current = null + clearClineMessagesSnapshot() retryClineMessagesResync(seq) } break @@ -612,7 +642,7 @@ export const ExtensionStateContextProvider: React.FC<{ startIndex !== snapshot.messages.length || snapshot.messages.length + chunk.length > snapshot.total ) { - activeSnapshotRef.current = null + clearClineMessagesSnapshot() retryClineMessagesResync(seq) break } @@ -628,7 +658,7 @@ export const ExtensionStateContextProvider: React.FC<{ const seq = message.clineMessagesSeq as number const snapshot = activeSnapshotRef.current if (!Number.isSafeInteger(seq) || seq < 0) { - activeSnapshotRef.current = null + clearClineMessagesSnapshot() retryClineMessagesResync(typeof seq === "number" ? seq : undefined) break } @@ -640,18 +670,18 @@ export const ExtensionStateContextProvider: React.FC<{ } if (message.snapshotId !== snapshot.snapshotId || seq !== snapshot.seq) { if (seq > snapshot.seq) { - activeSnapshotRef.current = null + clearClineMessagesSnapshot() retryClineMessagesResync(seq) } break } if (message.snapshotTotal !== snapshot.total || snapshot.messages.length !== snapshot.total) { - activeSnapshotRef.current = null + clearClineMessagesSnapshot() retryClineMessagesResync(seq) break } - activeSnapshotRef.current = null + clearClineMessagesSnapshot() clearClineMessagesResync() clineMessagesRef.current = snapshot.messages clineMessagesSeqRef.current = snapshot.seq @@ -757,10 +787,12 @@ export const ExtensionStateContextProvider: React.FC<{ // Stryker disable next-line ArrayDeclaration: every listed dependency is a stable callback; removing the list does not change this listener closure. [ applyClineMessagesDelta, + clearClineMessagesSnapshot, clearClineMessagesResync, requestClineMessagesResync, retryClineMessagesResync, setListApiConfigMeta, + startClineMessagesSnapshotTimeout, ], ) @@ -769,11 +801,12 @@ export const ExtensionStateContextProvider: React.FC<{ window.addEventListener("message", handleMessage) return () => { window.removeEventListener("message", handleMessage) + clearClineMessagesSnapshot() clearClineMessagesResync() } }, // Stryker disable next-line ArrayDeclaration: both effect dependencies are stable callbacks, making an empty list behaviorally identical for the provider lifetime. - [clearClineMessagesResync, handleMessage], + [clearClineMessagesResync, clearClineMessagesSnapshot, handleMessage], ) useEffect(() => { diff --git a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx index 594a0e2a74..39ee0a9ccb 100644 --- a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx +++ b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx @@ -645,6 +645,116 @@ describe("ExtensionStateContext", () => { expect(vi.getTimerCount()).toBe(0) }) + it("abandons an incomplete snapshot and requests recovery after the snapshot timeout", () => { + vi.useFakeTimers() + const postMessage = renderTranscriptWithPostMessageSpy({ clineMessagesSeq: 1 }) + + act(() => { + startSnapshot() + appendSnapshotChunk() + vi.advanceTimersByTime(30_000) + }) + + expect(postMessage).toHaveBeenCalledTimes(1) + expect(postMessage).toHaveBeenCalledWith({ + type: "requestClineMessagesResync", + taskId: "task-1", + expectedSeq: 2, + receivedSeq: 2, + }) + expect(vi.getTimerCount()).toBe(1) + }) + + it("clears the snapshot timeout when a snapshot completes", () => { + vi.useFakeTimers() + const postMessage = renderTranscriptWithPostMessageSpy({ clineMessagesSeq: 1 }) + + act(() => { + startSnapshot() + appendSnapshotChunk() + endSnapshot() + vi.advanceTimersByTime(30_000) + }) + + expect(postMessage).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) + expect(readTranscriptFields()).toEqual({ + currentTaskId: "task-1", + clineMessages: [makeMessage(2, "snapshot")], + clineMessagesSeq: 2, + }) + }) + + it("restarts the snapshot timeout when a replacement snapshot starts", () => { + vi.useFakeTimers() + const postMessage = renderTranscriptWithPostMessageSpy({ clineMessagesSeq: 1 }) + const replacement = makeMessage(3, "replacement") + + act(() => { + startSnapshot() + vi.advanceTimersByTime(20_000) + startSnapshot({ clineMessagesSeq: 3, snapshotId: "replacement" }) + vi.advanceTimersByTime(20_000) + appendSnapshotChunk({ + clineMessagesSeq: 3, + snapshotId: "replacement", + clineMessages: [replacement], + }) + endSnapshot({ clineMessagesSeq: 3, snapshotId: "replacement" }) + }) + + expect(postMessage).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) + expect(readTranscriptFields()).toEqual({ + currentTaskId: "task-1", + clineMessages: [replacement], + clineMessagesSeq: 3, + }) + }) + + it("clears the snapshot timeout when a newer delta invalidates the snapshot", () => { + vi.useFakeTimers() + const postMessage = renderTranscriptWithPostMessageSpy({ clineMessagesSeq: 1 }) + + act(() => { + startSnapshot() + appendClineMessage(makeMessage(3, "newer delta"), 3, "task-1") + }) + + expect(postMessage).toHaveBeenCalledTimes(1) + expect(postMessage).toHaveBeenCalledWith( + expect.objectContaining({ type: "requestClineMessagesResync", receivedSeq: 3 }), + ) + expect(vi.getTimerCount()).toBe(1) + }) + + it("clears the snapshot timeout when the task changes", () => { + vi.useFakeTimers() + const postMessage = renderTranscriptWithPostMessageSpy({ clineMessagesSeq: 1 }) + + act(() => { + startSnapshot() + dispatchExtensionMessage({ type: "state", state: { currentTaskId: "task-2" } }) + vi.advanceTimersByTime(30_000) + }) + + expect(postMessage).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) + }) + + it("clears the snapshot timeout when the provider unmounts", () => { + vi.useFakeTimers() + const postMessage = vi.spyOn(vscode, "postMessage").mockImplementation(() => undefined) + const { unmount } = renderTranscript({ clineMessagesSeq: 1 }) + postMessage.mockClear() + + act(() => startSnapshot()) + expect(vi.getTimerCount()).toBe(1) + unmount() + + expect(vi.getTimerCount()).toBe(0) + }) + it.each([ { name: "a nonnumeric sequence", overrides: { clineMessagesSeq: "2" }, receivedSeq: undefined }, { name: "a boolean sequence", overrides: { clineMessagesSeq: true }, receivedSeq: undefined }, From c12d73c3d09349bab800c88bac2103b49c375520 Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Fri, 4 Sep 2026 07:50:26 -0600 Subject: [PATCH 22/26] test: cover transcript snapshot timeout mutations --- .../src/context/ExtensionStateContext.tsx | 20 +++-- .../__tests__/ExtensionStateContext.spec.tsx | 81 ++++++++++++++++++- 2 files changed, 91 insertions(+), 10 deletions(-) diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index ca2c095012..d5f3ee8549 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -352,13 +352,17 @@ export const ExtensionStateContextProvider: React.FC<{ [], ) - const clearClineMessagesSnapshot = useCallback(() => { - activeSnapshotRef.current = null - if (snapshotTimeoutRef.current !== undefined) { - window.clearTimeout(snapshotTimeoutRef.current) - snapshotTimeoutRef.current = undefined - } - }, []) + const clearClineMessagesSnapshot = useCallback( + () => { + activeSnapshotRef.current = null + if (snapshotTimeoutRef.current !== undefined) { + window.clearTimeout(snapshotTimeoutRef.current) + snapshotTimeoutRef.current = undefined + } + }, + // Stryker disable next-line ArrayDeclaration: an inserted constant cannot change this ref-only callback's stable identity or captured values. + [], + ) const requestClineMessagesResync = useCallback( (receivedSeq?: number) => { @@ -398,7 +402,6 @@ export const ExtensionStateContextProvider: React.FC<{ snapshotTimeoutRef.current = window.setTimeout(() => { const snapshot = activeSnapshotRef.current if (snapshot?.snapshotId !== snapshotId || snapshot.seq !== seq) { - snapshotTimeoutRef.current = undefined return } activeSnapshotRef.current = null @@ -406,6 +409,7 @@ export const ExtensionStateContextProvider: React.FC<{ retryClineMessagesResync(seq) }, CLINE_MESSAGES_SNAPSHOT_TIMEOUT_MS) }, + // Stryker disable next-line ArrayDeclaration: retryClineMessagesResync is stable, so omitting it cannot alter callback identity or captured values. [retryClineMessagesResync], ) diff --git a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx index 39ee0a9ccb..58a49ebe46 100644 --- a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx +++ b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx @@ -645,6 +645,17 @@ describe("ExtensionStateContext", () => { expect(vi.getTimerCount()).toBe(0) }) + it("does not clear a nonexistent snapshot timeout when the first snapshot starts", () => { + vi.useFakeTimers() + renderTranscript({ clineMessagesSeq: 1 }) + const clearTimeout = vi.spyOn(window, "clearTimeout") + + act(() => startSnapshot()) + + expect(clearTimeout).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(1) + }) + it("abandons an incomplete snapshot and requests recovery after the snapshot timeout", () => { vi.useFakeTimers() const postMessage = renderTranscriptWithPostMessageSpy({ clineMessagesSeq: 1 }) @@ -755,15 +766,67 @@ describe("ExtensionStateContext", () => { expect(vi.getTimerCount()).toBe(0) }) + it.each([ + { name: "a replacement ID", clineMessagesSeq: 2, snapshotId: "replacement" }, + { name: "a replacement sequence", clineMessagesSeq: 3, snapshotId: "snapshot-1" }, + ])("ignores a stale timeout callback after $name takes ownership", ({ clineMessagesSeq, snapshotId }) => { + vi.useFakeTimers() + const postMessage = renderTranscriptWithPostMessageSpy({ clineMessagesSeq: 1 }) + const setTimeout = vi.spyOn(window, "setTimeout") + + act(() => startSnapshot()) + const staleTimeout = setTimeout.mock.calls[0]?.[0] + if (typeof staleTimeout !== "function") { + throw new Error("Expected the snapshot timeout callback to be scheduled") + } + const replacement = makeMessage(clineMessagesSeq, "replacement") + + act(() => { + startSnapshot({ clineMessagesSeq, snapshotId }) + staleTimeout() + appendSnapshotChunk({ clineMessagesSeq, snapshotId, clineMessages: [replacement] }) + endSnapshot({ clineMessagesSeq, snapshotId }) + }) + + expect(postMessage).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) + expect(readTranscriptFields()).toEqual({ + currentTaskId: "task-1", + clineMessages: [replacement], + clineMessagesSeq, + }) + }) + + it("ignores a stale timeout callback after its snapshot is cleared", () => { + vi.useFakeTimers() + const postMessage = renderTranscriptWithPostMessageSpy({ clineMessagesSeq: 1 }) + const setTimeout = vi.spyOn(window, "setTimeout") + + act(() => startSnapshot()) + const staleTimeout = setTimeout.mock.calls[0]?.[0] + if (typeof staleTimeout !== "function") { + throw new Error("Expected the snapshot timeout callback to be scheduled") + } + + act(() => dispatchExtensionMessage({ type: "state", state: { currentTaskId: "task-2" } })) + expect(vi.getTimerCount()).toBe(0) + expect(() => act(() => staleTimeout())).not.toThrow() + expect(postMessage).not.toHaveBeenCalled() + }) + it.each([ { name: "a nonnumeric sequence", overrides: { clineMessagesSeq: "2" }, receivedSeq: undefined }, { name: "a boolean sequence", overrides: { clineMessagesSeq: true }, receivedSeq: undefined }, { name: "a fractional sequence", overrides: { clineMessagesSeq: 1.5 }, receivedSeq: 1.5 }, { name: "a negative sequence", overrides: { clineMessagesSeq: -1 }, receivedSeq: -1 }, ])("rejects a snapshot start with $name", ({ overrides, receivedSeq }) => { + vi.useFakeTimers() const postMessage = renderTranscriptWithPostMessageSpy({ clineMessagesSeq: 1 }) - act(() => startSnapshot(overrides)) + act(() => { + startSnapshot() + startSnapshot(overrides) + }) expect(postMessage).toHaveBeenCalledTimes(1) expect(postMessage).toHaveBeenCalledWith({ @@ -772,6 +835,7 @@ describe("ExtensionStateContext", () => { expectedSeq: 2, receivedSeq, }) + expect(vi.getTimerCount()).toBe(1) }) it.each([ @@ -781,14 +845,19 @@ describe("ExtensionStateContext", () => { { name: "a fractional total", overrides: { snapshotTotal: 1.5 } }, { name: "a negative total", overrides: { snapshotTotal: -1 } }, ])("rejects a snapshot start with $name", ({ overrides }) => { + vi.useFakeTimers() const postMessage = renderTranscriptWithPostMessageSpy({ clineMessagesSeq: 1 }) - act(() => startSnapshot(overrides)) + act(() => { + startSnapshot() + startSnapshot(overrides) + }) expect(postMessage).toHaveBeenCalledTimes(1) expect(postMessage).toHaveBeenCalledWith( expect.objectContaining({ type: "requestClineMessagesResync", receivedSeq: 2 }), ) + expect(vi.getTimerCount()).toBe(1) }) it("rejects a snapshot start for a different task before it can accept current-task chunks", () => { @@ -987,6 +1056,7 @@ describe("ExtensionStateContext", () => { expectedResyncSeq: 3, }, ])("restarts after a chunk with $name", ({ overrides, expectedResyncSeq }) => { + vi.useFakeTimers() const postMessage = renderTranscriptWithPostMessageSpy({ clineMessagesSeq: 1 }) act(() => { @@ -998,6 +1068,7 @@ describe("ExtensionStateContext", () => { expect(postMessage).toHaveBeenCalledWith( expect.objectContaining({ type: "requestClineMessagesResync", receivedSeq: expectedResyncSeq }), ) + expect(vi.getTimerCount()).toBe(1) }) it.each([ @@ -1033,6 +1104,7 @@ describe("ExtensionStateContext", () => { overrides: { clineMessages: [makeMessage(2, "first"), makeMessage(3, "overflow")] }, }, ])("rejects a snapshot chunk with $name", ({ overrides }) => { + vi.useFakeTimers() const postMessage = renderTranscriptWithPostMessageSpy({ clineMessagesSeq: 1 }) act(() => { @@ -1044,6 +1116,7 @@ describe("ExtensionStateContext", () => { expect(postMessage).toHaveBeenCalledWith( expect.objectContaining({ type: "requestClineMessagesResync", receivedSeq: 2 }), ) + expect(vi.getTimerCount()).toBe(1) }) it.each([ @@ -1104,6 +1177,7 @@ describe("ExtensionStateContext", () => { { name: "a newer sequence", overrides: { clineMessagesSeq: 3 } }, { name: "a newer ID and sequence", overrides: { snapshotId: "newer", clineMessagesSeq: 3 } }, ])("restarts after an end with $name", ({ overrides }) => { + vi.useFakeTimers() const postMessage = renderTranscriptWithPostMessageSpy({ clineMessagesSeq: 1 }) act(() => { @@ -1115,6 +1189,7 @@ describe("ExtensionStateContext", () => { expect(postMessage).toHaveBeenCalledWith( expect.objectContaining({ type: "requestClineMessagesResync", receivedSeq: 3 }), ) + expect(vi.getTimerCount()).toBe(1) }) it.each([ @@ -1142,6 +1217,7 @@ describe("ExtensionStateContext", () => { { name: "a mismatched declared total", overrides: { snapshotTotal: 2 } }, { name: "an incomplete message list", overrides: {} }, ])("rejects a snapshot end with $name", ({ name, overrides }) => { + vi.useFakeTimers() const postMessage = renderTranscriptWithPostMessageSpy({ clineMessagesSeq: 1 }) act(() => { @@ -1156,6 +1232,7 @@ describe("ExtensionStateContext", () => { expect(postMessage).toHaveBeenCalledWith( expect.objectContaining({ type: "requestClineMessagesResync", receivedSeq: 2 }), ) + expect(vi.getTimerCount()).toBe(1) }) it("reconstructs a snapshot and applies contiguous append and update deltas", () => { From 59dbf2b1487ce7d69eb2adb533b1b85dccb6bbc1 Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Sat, 5 Sep 2026 11:24:47 -0600 Subject: [PATCH 23/26] fix: address transcript transport review feedback --- src/core/task/Task.ts | 5 +- src/core/task/__tests__/Task.spec.ts | 62 +++++++++++++++++++ src/core/webview/ClineProvider.ts | 11 ++-- .../webview/__tests__/ClineProvider.spec.ts | 8 +++ .../__tests__/webviewMessageHandler.spec.ts | 18 ++++++ 5 files changed, 97 insertions(+), 7 deletions(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 11d4579f9a..9b18dc45ec 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -2929,7 +2929,10 @@ export class Task extends EventEmitter implements TaskLike { } satisfies ClineApiReqInfo) await this.saveClineMessages() - await this.updateClineMessage(this.clineMessages[lastApiReqIndex]) + const apiRequestMessage = this.clineMessages[lastApiReqIndex] + if (apiRequestMessage) { + await this.updateClineMessage(apiRequestMessage) + } try { let cacheWriteTokens = 0 diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index aef4fb8bb7..3779e28819 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -3435,6 +3435,68 @@ describe("Cline", () => { }) }) + describe("recursivelyMakeClineRequests", () => { + it.each([ + ["publishes an API request row that remains after persistence", false, 1], + ["does not publish a stale API request row removed during persistence", true, 0], + ])("%s", async (_description, removeRequestDuringSave, expectedUpdateCount) => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + const taskAccess = getTaskTestAccess(task) + + vi.mocked(processUserContentMentions).mockResolvedValueOnce({ + content: [{ type: "text", text: "hello" }], + mode: undefined, + }) + vi.spyOn(task.diffViewProvider, "reset").mockResolvedValue(undefined as never) + vi.spyOn(taskAccess, "addToApiConversationHistory").mockResolvedValue(undefined) + vi.spyOn(taskAccess, "safeEnsureModelFetched").mockResolvedValue(undefined) + vi.spyOn(task.api, "getModel").mockReturnValue({ + id: mockApiConfig.apiModelId!, + info: { + supportsImages: false, + supportsPromptCache: true, + contextWindow: 200_000, + maxTokens: 4096, + } as ModelInfo, + }) + vi.spyOn(task, "attemptApiRequest").mockImplementation(() => { + throw new Error("stop after request-row update") + }) + vi.spyOn(task, "say").mockImplementation(async (type) => { + if (type === "api_req_started") { + task.clineMessages.push({ + ts: Date.now(), + type: "say", + say: "api_req_started", + text: "{}", + }) + } + return undefined as never + }) + vi.spyOn(taskAccess, "saveClineMessages").mockImplementation(async () => { + if (removeRequestDuringSave) { + // Simulate a concurrent delete/edit truncating the transcript while persistence is awaited. + task.clineMessages = [] + } + return true + }) + const updateSpy = vi.mocked(mockProvider.postClineMessageUpdated) + updateSpy.mockClear() + + await expect(task.recursivelyMakeClineRequests([{ type: "text", text: "hello" }])).resolves.toBe(true) + + expect(updateSpy).toHaveBeenCalledTimes(expectedUpdateCount) + if (!removeRequestDuringSave) { + expect(updateSpy).toHaveBeenCalledWith(task.taskId, expect.objectContaining({ say: "api_req_started" })) + } + }) + }) + describe("safeEnsureModelFetched", () => { it("loads model metadata before getModel is used", async () => { const task = new Task({ diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 4e37fbfe8b..9b816696fc 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2629,9 +2629,10 @@ export class ClineProvider } /** - * Like postStateToWebview but intentionally omits taskHistory. The final - * postMessageToWebview boundary removes transcript fields from every generic - * state message. + * Compatibility name for callers that need a lightweight generic state post. + * Transcript fields are removed from every generic state message at the + * postMessageToWebview boundary, while the canonical method below also omits + * taskHistory. * * Rationale: * - Cloud event handlers (auth, settings, user-info) and mode changes trigger state pushes @@ -2642,9 +2643,7 @@ export class ClineProvider * (cloud auth, org settings, profiles, etc.) without interfering with task message streaming. */ async postStateToWebviewWithoutClineMessages(): Promise { - const state = await this.getStateToPostToWebview({ includeTaskHistory: false }) - const { taskHistory: _omitHistory, ...rest } = state - await this.postMessageToWebview({ type: "state", state: rest }) + await this.postStateToWebviewWithoutTaskHistory() } /** diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index b22dca4809..4e6b03a229 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -1563,6 +1563,14 @@ describe("ClineProvider", () => { expect(postMessageSpy.mock.calls[0]?.[0].state).not.toHaveProperty("taskHistory") }) + test("postStateToWebviewWithoutClineMessages delegates to the canonical lightweight state post", async () => { + const postStateSpy = vi.spyOn(provider, "postStateToWebviewWithoutTaskHistory").mockResolvedValue(undefined) + + await provider.postStateToWebviewWithoutClineMessages() + + expect(postStateSpy).toHaveBeenCalledOnce() + }) + test("getStateToPostToWebview computes task history once after its base state resolves", async () => { const historyItem = { id: "history-task", diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index 3fa1314031..1821ccf9dc 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -128,6 +128,24 @@ const mockClineProvider = { cwd: "/mock/workspace", } as unknown as ClineProvider +describe("webviewMessageHandler - launch", () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(mockClineProvider.customModesManager.getCustomModes).mockResolvedValue([]) + Object.assign(mockClineProvider, { + getMcpHub: vi.fn().mockReturnValue(undefined), + providerSettingsManager: { listConfig: vi.fn().mockResolvedValue(undefined) }, + }) + }) + + it("synchronizes focused state with task history", async () => { + await webviewMessageHandler(mockClineProvider, { type: "webviewDidLaunch" }) + + expect(mockClineProvider.syncFocusedTaskToWebview).toHaveBeenCalledOnce() + expect(mockClineProvider.syncFocusedTaskToWebview).toHaveBeenCalledWith({ includeTaskHistory: true }) + }) +}) + describe("webviewMessageHandler - transcript resync", () => { beforeEach(() => { vi.clearAllMocks() From 1980fcf9d6d8fee869b5ab28727679d63aa88df8 Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Sun, 6 Sep 2026 01:21:10 -0600 Subject: [PATCH 24/26] fix(task): await transcript snapshots after overwrite persistence --- src/core/task/Task.ts | 2 +- src/core/task/__tests__/Task.spec.ts | 68 ++++++++++++++++++++++++++-- 2 files changed, 66 insertions(+), 4 deletions(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 00906d5c8f..148aaf0ca2 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -1194,6 +1194,7 @@ export class Task extends EventEmitter implements TaskLike { if (persist) { await this.saveClineMessages(false) } + await this.providerRef.deref()?.postClineMessagesSnapshot(this.taskId, { bumpSeq: true }) } private hydrateClineMessages(messages: ClineMessage[]) { @@ -1208,7 +1209,6 @@ export class Task extends EventEmitter implements TaskLike { this.cloudSyncedMessageTimestamps.add(msg.ts) } } - await this.providerRef.deref()?.postClineMessagesSnapshot(this.taskId, { bumpSeq: true }) } private hydrateApiConversationHistory(messages: ApiMessage[]) { diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index 253f870725..56cf066861 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -2118,14 +2118,18 @@ describe("Cline", () => { vi.useRealTimers() }) - it("posts a bumped snapshot after overwriting the transcript", async () => { + it("waits for persistence before posting a bumped snapshot after overwriting the transcript", async () => { const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) - const saveSpy = vi.spyOn(getTaskTestAccess(task), "saveClineMessages").mockResolvedValue(true) + let releaseSave!: (saved: boolean) => void + const pendingSave = new Promise((resolve) => { + releaseSave = resolve + }) + const saveSpy = vi.spyOn(getTaskTestAccess(task), "saveClineMessages").mockReturnValueOnce(pendingSave) const messages = [ { ts: 1, @@ -2135,13 +2139,71 @@ describe("Cline", () => { }, ] - await task.overwriteClineMessages(messages) + const overwritePromise = task.overwriteClineMessages(messages) + + await Promise.resolve() + expect(task.clineMessages).toEqual(messages) + expect(saveSpy).toHaveBeenCalledWith(false) + expect(mockProvider.postClineMessagesSnapshot).not.toHaveBeenCalled() + + releaseSave(true) + await overwritePromise expect(saveSpy).toHaveBeenCalledOnce() expect(mockProvider.postClineMessagesSnapshot).toHaveBeenCalledOnce() expect(mockProvider.postClineMessagesSnapshot).toHaveBeenCalledWith(task.taskId, { bumpSeq: true }) }) + it.each([true, false])("awaits the overwrite snapshot when persist is %s", async (persist) => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + const saveSpy = vi.spyOn(getTaskTestAccess(task), "saveClineMessages").mockResolvedValue(true) + let releaseSnapshot!: () => void + const pendingSnapshot = new Promise((resolve) => { + releaseSnapshot = resolve + }) + const snapshotSpy = vi.mocked(mockProvider.postClineMessagesSnapshot).mockReturnValueOnce(pendingSnapshot) + const messages = [{ ts: 1, type: "say" as const, say: "text" as const, text: "replacement" }] + let overwriteFinished = false + const overwritePromise = task.overwriteClineMessages(messages, persist).then(() => { + overwriteFinished = true + }) + + await vi.waitFor(() => expect(snapshotSpy).toHaveBeenCalledWith(task.taskId, { bumpSeq: true })) + expect(task.clineMessages).toEqual(messages) + expect(saveSpy).toHaveBeenCalledTimes(persist ? 1 : 0) + expect(overwriteFinished).toBe(false) + + releaseSnapshot() + await overwritePromise + + expect(snapshotSpy).toHaveBeenCalledOnce() + expect(overwriteFinished).toBe(true) + }) + + it("propagates an overwrite snapshot failure after persistence", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + const saveSpy = vi.spyOn(getTaskTestAccess(task), "saveClineMessages").mockResolvedValue(true) + const snapshotError = new Error("snapshot failed") + vi.mocked(mockProvider.postClineMessagesSnapshot).mockRejectedValueOnce(snapshotError) + const messages = [{ ts: 1, type: "say" as const, say: "text" as const, text: "replacement" }] + + await expect(task.overwriteClineMessages(messages)).rejects.toThrow(snapshotError) + + expect(task.clineMessages).toEqual(messages) + expect(saveSpy).toHaveBeenCalledWith(false) + expect(mockProvider.postClineMessagesSnapshot).toHaveBeenCalledWith(task.taskId, { bumpSeq: true }) + }) + it("still overwrites the transcript when the provider reference is unavailable", async () => { const task = new Task({ provider: mockProvider, From e5750682e0cbd20c3b77ee48197b334cb84b04a1 Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Sun, 6 Sep 2026 02:33:23 -0600 Subject: [PATCH 25/26] fix(task): synchronize transcript snapshots on overwrite and resume --- src/core/task/Task.ts | 14 +- .../task/__tests__/Task.persistence.spec.ts | 208 +++++++++++++++++- src/core/task/__tests__/Task.spec.ts | 47 ++++ 3 files changed, 265 insertions(+), 4 deletions(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 148aaf0ca2..d7f465b11f 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -1190,6 +1190,7 @@ export class Task extends EventEmitter implements TaskLike { } public async overwriteClineMessages(newMessages: ClineMessage[], persist = true) { + this.debouncedPostPartialMessageUpdate.cancel() this.hydrateClineMessages(newMessages) if (persist) { await this.saveClineMessages(false) @@ -2226,16 +2227,23 @@ export class Task extends EventEmitter implements TaskLike { await this.clearPendingActionAfterDurableResult(this.pendingAction.actionId) } - if (this.pendingAction) { - this.isInitialized = true - await this.resumePendingTaskAction(this.pendingAction) + if (this.abort || this.abandoned) { return } + // Publish the transcript after both histories hydrate, before any resume prompt or pending-action replay. + await this.providerRef.deref()?.postClineMessagesSnapshot(this.taskId, { bumpSeq: true }) + if (this.abort || this.abandoned) { return } + if (this.pendingAction) { + this.isInitialized = true + await this.resumePendingTaskAction(this.pendingAction) + return + } + const lastClineMessage = this.clineMessages .slice() .reverse() diff --git a/src/core/task/__tests__/Task.persistence.spec.ts b/src/core/task/__tests__/Task.persistence.spec.ts index 924f08cc17..dd48f61e52 100644 --- a/src/core/task/__tests__/Task.persistence.spec.ts +++ b/src/core/task/__tests__/Task.persistence.spec.ts @@ -825,8 +825,18 @@ describe("Task persistence", () => { .spyOn(getTaskPersistenceAccess(task), "resumePendingTaskAction") .mockResolvedValue(undefined) const ask = vi.spyOn(task, "ask") + const snapshotDeferred = createDeferred() + const snapshot = vi + .mocked(mockProvider.postClineMessagesSnapshot) + .mockReturnValueOnce(snapshotDeferred.promise) - await getTaskPersistenceAccess(task).resumeTaskFromHistory() + const resumePromise = getTaskPersistenceAccess(task).resumeTaskFromHistory() + await vi.waitFor(() => expect(snapshot).toHaveBeenCalledWith(task.taskId, { bumpSeq: true })) + expect(replay).not.toHaveBeenCalled() + expect(task.clineMessages).toEqual([expect.objectContaining({ text: "Child" })]) + expect(task.apiConversationHistory).toHaveLength(1) + snapshotDeferred.resolve() + await resumePromise expect(replay).toHaveBeenCalledWith(pendingAction) expect(ask).not.toHaveBeenCalled() @@ -868,6 +878,49 @@ describe("Task persistence", () => { expect(task.ask).toHaveBeenCalledWith("resume_task") }) + it.each(["abort", "abandoned"] as const)( + "does not publish resumed history when %s occurs during pending-action reconciliation", + async (flag) => { + mockReadTaskMessages.mockResolvedValue([{ ts: 1, type: "say", say: "text", text: "Child" }]) + mockReadApiMessages.mockResolvedValue([ + { + role: "user", + content: [{ type: "tool_result", tool_use_id: "finish-action", content: "Denied" }], + }, + ]) + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + historyItem: { + id: "child-1", + number: 1, + ts: 1, + task: "Child", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + pendingAction, + }, + startTask: false, + }) + const clearing = createDeferred() + mockProvider.clearPendingTaskAction = vi.fn().mockReturnValueOnce(clearing.promise) + const ask = vi.spyOn(task, "ask") + const replay = vi.spyOn(getTaskPersistenceAccess(task), "resumePendingTaskAction") + const resumePromise = task.run() + + await vi.waitFor(() => expect(mockProvider.clearPendingTaskAction).toHaveBeenCalledOnce()) + task[flag] = true + clearing.resolve(true) + await resumePromise + + expect(mockProvider.postClineMessagesSnapshot).not.toHaveBeenCalled() + expect(ask).not.toHaveBeenCalled() + expect(replay).not.toHaveBeenCalled() + expect(mockSaveTaskMessages).not.toHaveBeenCalled() + }, + ) + it("clears pending metadata after the matching tool result is saved", async () => { mockProvider.clearPendingTaskAction = vi.fn().mockResolvedValue(true) const task = new Task({ @@ -1129,6 +1182,156 @@ describe("Task persistence", () => { }) describe("resumeTaskFromHistory", () => { + it.each(["active", "completed"] as const)( + "publishes hydrated history before the %s task resume prompt", + async (status) => { + const messages = [ + { ts: 1, type: "say", say: "text", text: "Saved transcript" }, + ] satisfies ClineMessage[] + const apiMessages: Task["apiConversationHistory"] = [{ role: "user", content: "Saved API history" }] + const apiRead = createDeferred() + const snapshotDeferred = createDeferred() + mockReadTaskMessages.mockResolvedValue(messages) + mockReadApiMessages.mockReturnValueOnce(apiRead.promise) + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + historyItem: { + id: "history-snapshot", + number: 1, + ts: 1, + task: "Saved task", + status, + tokensIn: 10, + tokensOut: 5, + totalCost: 0.001, + }, + initialStatus: status, + startTask: false, + }) + const snapshot = vi.mocked(mockProvider.postClineMessagesSnapshot).mockImplementationOnce(() => { + expect(task.clineMessages).toEqual(messages) + expect(task.apiConversationHistory).toEqual(apiMessages) + return snapshotDeferred.promise + }) + const stopAfterPrompt = new Error("stop after resume prompt") + const ask = vi.spyOn(task, "ask").mockRejectedValueOnce(stopAfterPrompt) + const resumePromise = task.run() + const completion = expect(resumePromise).rejects.toThrow(stopAfterPrompt) + + await vi.waitFor(() => expect(mockReadApiMessages).toHaveBeenCalledOnce()) + expect(snapshot).not.toHaveBeenCalled() + expect(ask).not.toHaveBeenCalled() + apiRead.resolve(apiMessages) + + await vi.waitFor(() => expect(snapshot).toHaveBeenCalledWith(task.taskId, { bumpSeq: true })) + expect(ask).not.toHaveBeenCalled() + expect(mockSaveTaskMessages).not.toHaveBeenCalled() + expect(mockSaveApiMessages).not.toHaveBeenCalled() + snapshotDeferred.resolve() + await completion + + expect(snapshot).toHaveBeenCalledOnce() + expect(ask).toHaveBeenCalledWith(status === "completed" ? "resume_completed_task" : "resume_task") + expect(mockSaveTaskMessages).not.toHaveBeenCalled() + expect(mockSaveApiMessages).not.toHaveBeenCalled() + }, + ) + + it.each(["abort", "abandoned"] as const)( + "does not prompt when %s occurs during resume snapshot delivery", + async (flag) => { + mockReadTaskMessages.mockResolvedValue([{ ts: 1, type: "say", say: "text", text: "Saved transcript" }]) + mockReadApiMessages.mockResolvedValue([{ role: "user", content: "Saved API history" }]) + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + historyItem: { + id: "cancel-resume-snapshot", + number: 1, + ts: 1, + task: "Saved task", + tokensIn: 10, + tokensOut: 5, + totalCost: 0.001, + }, + startTask: false, + }) + const snapshotDeferred = createDeferred() + const snapshot = vi + .mocked(mockProvider.postClineMessagesSnapshot) + .mockReturnValueOnce(snapshotDeferred.promise) + const ask = vi.spyOn(task, "ask").mockResolvedValue({ response: "noButtonClicked" }) + const resumePromise = task.run() + + await vi.waitFor(() => expect(snapshot).toHaveBeenCalledOnce()) + task[flag] = true + snapshotDeferred.resolve() + await resumePromise + + expect(ask).not.toHaveBeenCalled() + expect(mockSaveTaskMessages).not.toHaveBeenCalled() + expect(mockSaveApiMessages).not.toHaveBeenCalled() + }, + ) + + it("does not prompt or persist when the resume snapshot fails", async () => { + mockReadTaskMessages.mockResolvedValue([{ ts: 1, type: "say", say: "text", text: "Saved transcript" }]) + mockReadApiMessages.mockResolvedValue([{ role: "user", content: "Saved API history" }]) + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + historyItem: { + id: "failed-resume-snapshot", + number: 1, + ts: 1, + task: "Saved task", + tokensIn: 10, + tokensOut: 5, + totalCost: 0.001, + }, + startTask: false, + }) + const snapshotError = new Error("resume snapshot failed") + vi.mocked(mockProvider.postClineMessagesSnapshot).mockRejectedValueOnce(snapshotError) + const ask = vi.spyOn(task, "ask").mockResolvedValue({ response: "noButtonClicked" }) + + await expect(task.run()).rejects.toThrow(snapshotError) + + expect(ask).not.toHaveBeenCalled() + expect(mockSaveTaskMessages).not.toHaveBeenCalled() + expect(mockSaveApiMessages).not.toHaveBeenCalled() + }) + + it("can hydrate and reach the resume prompt without a provider reference", async () => { + mockReadTaskMessages.mockResolvedValue([{ ts: 1, type: "say", say: "text", text: "Saved transcript" }]) + mockReadApiMessages.mockResolvedValue([{ role: "user", content: "Saved API history" }]) + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + historyItem: { + id: "missing-provider-resume", + number: 1, + ts: 1, + task: "Saved task", + tokensIn: 10, + tokensOut: 5, + totalCost: 0.001, + }, + startTask: false, + }) + vi.spyOn(task["providerRef"], "deref").mockReturnValue(undefined) + const stopAfterPrompt = new Error("stop after resume prompt") + const ask = vi.spyOn(task, "ask").mockRejectedValueOnce(stopAfterPrompt) + + await expect(task.run()).rejects.toThrow(stopAfterPrompt) + + expect(task.clineMessages).toEqual([expect.objectContaining({ text: "Saved transcript" })]) + expect(task.apiConversationHistory).toEqual([expect.objectContaining({ content: "Saved API history" })]) + expect(ask).toHaveBeenCalledWith("resume_task") + expect(mockProvider.postClineMessagesSnapshot).not.toHaveBeenCalled() + }) + it.each(["not_found", "invalid", "io_error"] as const)( "does not persist when hydration fails with %s", async (kind) => { @@ -1158,6 +1361,7 @@ describe("Task persistence", () => { expect(askSpy).not.toHaveBeenCalled() expect(mockSaveTaskMessages).not.toHaveBeenCalled() expect(mockProvider.updateTaskHistory).not.toHaveBeenCalled() + expect(mockProvider.postClineMessagesSnapshot).not.toHaveBeenCalled() }, ) @@ -1275,6 +1479,7 @@ describe("Task persistence", () => { expect(task.clineMessages).toHaveLength(0) expect(task.apiConversationHistory).toHaveLength(0) expect(mockSaveTaskMessages).not.toHaveBeenCalled() + expect(mockProvider.postClineMessagesSnapshot).not.toHaveBeenCalled() }) it("stops after API history hydration when the task is aborted", async () => { @@ -1312,6 +1517,7 @@ describe("Task persistence", () => { expect(askSpy).not.toHaveBeenCalled() expect(mockSaveTaskMessages).not.toHaveBeenCalled() expect(mockProvider.updateTaskHistory).not.toHaveBeenCalled() + expect(mockProvider.postClineMessagesSnapshot).not.toHaveBeenCalled() }) }) diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index 56cf066861..37b5e6672b 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -2185,6 +2185,53 @@ describe("Cline", () => { expect(overwriteFinished).toBe(true) }) + it.each([true, false])("cancels stale partial updates before an overwrite with persist %s", async (persist) => { + vi.useFakeTimers() + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + const taskAccess = getTaskTestAccess(task) + let releaseSave!: (saved: boolean) => void + const pendingSave = new Promise((resolve) => { + releaseSave = resolve + }) + vi.spyOn(taskAccess, "saveClineMessages").mockReturnValueOnce(pendingSave) + const staleMessage = { + ts: 1, + type: "say" as const, + say: "text" as const, + text: "removed partial", + partial: true, + } + const replacement = { ...staleMessage, ts: 2, text: "replacement partial" } + task.clineMessages = [staleMessage] + await taskAccess.updateClineMessage(staleMessage) + + const overwritePromise = task.overwriteClineMessages([replacement], persist) + await vi.advanceTimersByTimeAsync(500) + + expect(task.clineMessages).toEqual([replacement]) + expect(mockProvider.postClineMessageUpdated).not.toHaveBeenCalled() + expect(mockProvider.postClineMessagesSnapshot).toHaveBeenCalledTimes(persist ? 0 : 1) + + releaseSave(true) + await overwritePromise + await vi.advanceTimersByTimeAsync(500) + + expect(mockProvider.postClineMessagesSnapshot).toHaveBeenCalledOnce() + expect(mockProvider.postClineMessagesSnapshot).toHaveBeenCalledWith(task.taskId, { bumpSeq: true }) + expect(mockProvider.postClineMessageUpdated).not.toHaveBeenCalled() + + await taskAccess.updateClineMessage(replacement) + await vi.advanceTimersByTimeAsync(500) + + expect(mockProvider.postClineMessageUpdated).toHaveBeenCalledOnce() + expect(mockProvider.postClineMessageUpdated).toHaveBeenCalledWith(task.taskId, replacement) + }) + it("propagates an overwrite snapshot failure after persistence", async () => { const task = new Task({ provider: mockProvider, From 05df0816976c58859a411e7f3d89504d8927305d Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Sun, 6 Sep 2026 02:52:43 -0600 Subject: [PATCH 26/26] test(task): assert readiness before pending action replay --- src/core/task/__tests__/Task.persistence.spec.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/core/task/__tests__/Task.persistence.spec.ts b/src/core/task/__tests__/Task.persistence.spec.ts index dd48f61e52..3f9f63c7f4 100644 --- a/src/core/task/__tests__/Task.persistence.spec.ts +++ b/src/core/task/__tests__/Task.persistence.spec.ts @@ -823,7 +823,9 @@ describe("Task persistence", () => { }) const replay = vi .spyOn(getTaskPersistenceAccess(task), "resumePendingTaskAction") - .mockResolvedValue(undefined) + .mockImplementation(async () => { + expect(task.isInitialized).toBe(true) + }) const ask = vi.spyOn(task, "ask") const snapshotDeferred = createDeferred() const snapshot = vi