From 2a95931ba82efa0b49280e0861157020338261e2 Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Sun, 23 Aug 2026 07:31:47 -0600 Subject: [PATCH 01/15] 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 cdb495ea090148703808509943780b99e15d11b4 Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Sun, 23 Aug 2026 07:34:42 -0600 Subject: [PATCH 02/15] 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 37483ec2e752ad74d8eeb8feb366ac862edda3a9 Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Sun, 23 Aug 2026 12:52:16 -0600 Subject: [PATCH 03/15] 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 349d9c51d3..02ac6a7365 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -1034,20 +1034,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() @@ -1077,11 +1067,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 @@ -1174,7 +1165,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 @@ -1203,10 +1194,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 @@ -1465,6 +1454,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) @@ -1940,7 +1932,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) @@ -2057,7 +2049,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 @@ -2688,7 +2680,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 @@ -2759,12 +2751,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 @@ -3442,7 +3438,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 3b1f3f709a..7247be7873 100644 --- a/src/core/task/__tests__/Task.persistence.spec.ts +++ b/src/core/task/__tests__/Task.persistence.spec.ts @@ -283,6 +283,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 37e228f887..f39b705a3b 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", }) }) }) @@ -3246,7 +3236,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, @@ -3257,16 +3247,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, @@ -3276,11 +3264,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 4621cb3fc4..1c9649873c 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -204,10 +204,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: ${ @@ -295,12 +300,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 = "aug-2026-v3.80.0-allowlists-models-reliability" // v3.80.0 file allowlists, models, and workflow reliability @@ -571,6 +570,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) { @@ -629,6 +630,8 @@ export class ClineProvider // garbage collected. task = undefined } + + await this.syncFocusedTaskToWebview() } /** @@ -1342,6 +1345,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)`, @@ -1413,6 +1417,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 { @@ -1420,6 +1430,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")) @@ -2364,8 +2520,6 @@ export class ClineProvider async postStateToWebview() { const state = await this.getStateToPostToWebview() - this.clineMessagesSeq++ - state.clineMessagesSeq = this.clineMessagesSeq await this.postMessageToWebview({ type: "state", state }) } @@ -2379,10 +2533,8 @@ export class ClineProvider */ async postStateToWebviewWithoutTaskHistory(): Promise { const state = await this.getStateToPostToWebview({ includeTaskHistory: false }) - this.clineMessagesSeq++ - state.clineMessagesSeq = this.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 }) } /** @@ -2408,7 +2560,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 @@ -2420,7 +2574,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 731124cccc..e6c036678c 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -756,7 +756,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 () => { @@ -858,6 +859,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) => { @@ -947,7 +1031,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() @@ -963,7 +1049,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) @@ -984,7 +1072,7 @@ describe("ClineProvider", () => { releasePost = resolve }) const postStateSpy = vi - .spyOn(provider, "postStateToWebviewWithoutTaskHistory") + .spyOn(provider, "postStateToWebviewWithoutClineMessages") .mockResolvedValueOnce(undefined) .mockReturnValueOnce(pendingPost) @@ -1011,7 +1099,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() @@ -1023,7 +1113,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( @@ -1033,7 +1123,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( @@ -1045,7 +1135,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) @@ -1060,7 +1150,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 bdbd202830..92c91ad94d 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" @@ -7,20 +7,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" @@ -87,13 +73,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 { @@ -237,25 +216,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 377c8eb721..d7e47fcbd6 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -1,4 +1,4 @@ -import React, { createContext, useCallback, useEffect, useState } from "react" +import React, { createContext, useCallback, useEffect, useRef, useState } from "react" import { type ProviderSettings, @@ -12,6 +12,7 @@ import { type CloudOrganizationMembership, type ExtensionMessage, type ExtensionState, + type ClineMessage, type MarketplaceInstalledMetadata, type SkillMetadata, type RuleMetadata, @@ -155,6 +156,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 @@ -170,21 +179,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 { @@ -286,6 +280,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) @@ -335,13 +334,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 @@ -403,26 +487,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": { @@ -503,7 +703,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 23ac911585..a1553d7d0f 100644 --- a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx +++ b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx @@ -5,6 +5,7 @@ import { type ProviderSettings, type ExperimentId, type ExtensionState, + type ExtensionMessage, type ClineMessage, type MarketplaceItem, type MarketplaceInstalledMetadata, @@ -14,6 +15,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 } = @@ -102,6 +110,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( @@ -396,6 +414,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", () => { @@ -468,152 +616,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 e1b14102da8931174ba527803502a94d9642bca7 Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Sun, 23 Aug 2026 12:53:19 -0600 Subject: [PATCH 04/15] 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 5613423224c1416ac3ac1909148a321937eb2251 Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Sun, 23 Aug 2026 12:54:32 -0600 Subject: [PATCH 05/15] 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 96107aa3f3d9ae5f1420d5fc157304d3120a9875 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/15] 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 c00db16ca671e90e6802344d2cf42ff464c49418 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/15] 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 a0812250a83ce3662e67d5511957e9530ec4ffe7 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/15] 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 012af474a37e865d3d887aa564c74dc20280a1aa 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/15] 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 18dab74a35ae9c38e5835a4317b0771d89953ff3 Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Sun, 23 Aug 2026 20:59:33 -0600 Subject: [PATCH 10/15] 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 f39b705a3b..43c8331514 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, @@ -2370,6 +2394,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, @@ -3700,6 +3788,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 1c9649873c..f56dee366b 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -1417,8 +1417,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 } } @@ -1456,6 +1458,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 @@ -1477,6 +1482,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 @@ -1502,6 +1510,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 e6c036678c..54576e6643 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -445,6 +445,7 @@ describe("ClineProvider", () => { beforeEach(() => { vi.clearAllMocks() + delete process.env.ROO_CLI_RUNTIME if (!TelemetryService.hasInstance()) { TelemetryService.createInstance([]) @@ -882,6 +883,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) => ({ @@ -909,36 +1026,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 d7e47fcbd6..3a10ea81f1 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -164,6 +164,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 @@ -285,6 +287,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) @@ -334,11 +337,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, @@ -347,6 +362,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 @@ -367,7 +390,7 @@ export const ExtensionStateContextProvider: React.FC<{ return } activeSnapshotRef.current = null - requestClineMessagesResync(seq) + retryClineMessagesResync(seq) return } if (seq <= clineMessagesSeqRef.current) { @@ -399,7 +422,7 @@ export const ExtensionStateContextProvider: React.FC<{ clineMessagesSeq: seq, })) }, - [requestClineMessagesResync], + [requestClineMessagesResync, retryClineMessagesResync], ) const handleMessage = useCallback( @@ -420,7 +443,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) @@ -495,7 +518,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) { @@ -505,7 +528,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 } @@ -535,19 +558,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 } @@ -563,7 +586,7 @@ export const ExtensionStateContextProvider: React.FC<{ snapshot.messages.length + chunk.length > snapshot.total ) { activeSnapshotRef.current = null - requestClineMessagesResync(seq) + retryClineMessagesResync(seq) break } @@ -579,30 +602,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) => ({ @@ -703,15 +726,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 a1553d7d0f..e6b89927d1 100644 --- a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx +++ b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx @@ -1,4 +1,4 @@ -import { render, screen, act } from "@/utils/test-utils" +import { render, screen, act, appendClineMessage, hydrateExtensionState } from "@/utils/test-utils" import React from "react" import { @@ -417,6 +417,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( @@ -543,6 +549,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 e04231c70c0c93c53130e278b713e5a193704d2b Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Sun, 23 Aug 2026 21:14:54 -0600 Subject: [PATCH 11/15] 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 f56dee366b..fd1ce3d966 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -606,6 +606,7 @@ export class ClineProvider } if (task) { + this.clineMessagesSeqByTaskId.delete(task.taskId) task.emit(RooCodeEventName.TaskUnfocused) try { @@ -2477,6 +2478,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 @@ -2519,6 +2523,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 54576e6643..52f32a9f92 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -1133,6 +1133,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 b57b5134d4c89196187a23e645f7276e5f769fa4 Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Sun, 23 Aug 2026 21:24:03 -0600 Subject: [PATCH 12/15] 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 1fc4e7040caeea716a7e2223a0137aea7da286bb Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Sun, 23 Aug 2026 21:39:46 -0600 Subject: [PATCH 13/15] 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 f9f45d673e85823b98a8b67e9b22ece26224d819 Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Sun, 23 Aug 2026 21:59:24 -0600 Subject: [PATCH 14/15] 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 cb80ca80867fd2334a2448e20b8428e990d546ed Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Sun, 23 Aug 2026 23:38:12 -0600 Subject: [PATCH 15/15] 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 fd1ce3d966..903b276485 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2872,7 +2872,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 52f32a9f92..9b8a46535e 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -1258,6 +1258,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 3a10ea81f1..ea2ddf8870 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -282,7 +282,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) @@ -436,9 +436,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 = [] @@ -447,8 +450,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 e6b89927d1..0d414ed859 100644 --- a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx +++ b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx @@ -111,11 +111,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, + })}
) } @@ -417,6 +433,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( @@ -470,7 +490,7 @@ describe("ExtensionStateContext", () => { }) }) - expect(readTranscript()).toEqual({ + expect(readTranscriptFields()).toEqual({ currentTaskId: "task-1", clineMessages: [first, { ...second, text: "updated" }], clineMessagesSeq: 6, @@ -505,7 +525,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", () => { @@ -621,7 +726,7 @@ describe("ExtensionStateContext", () => { }) }) - expect(readTranscript()).toEqual({ + expect(readTranscriptFields()).toEqual({ currentTaskId: "task-1", clineMessages: [first, recovered, makeMessage(4, "after recovery")], clineMessagesSeq: 4, @@ -736,7 +841,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, @@ -895,7 +1000,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() } @@ -936,7 +1045,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, @@ -945,7 +1054,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({