From 0c837d4d0c8054afd74b71211b14b84bd60715a8 Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Sun, 23 Aug 2026 07:31:47 -0600 Subject: [PATCH 01/38] 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 a68150c96b6401292b2cccd610cdb56af8f244f2 Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Sun, 23 Aug 2026 07:34:42 -0600 Subject: [PATCH 02/38] 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 4abfcf66f398246d968ba35680662000197d0ae7 Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Sun, 23 Aug 2026 12:52:16 -0600 Subject: [PATCH 03/38] 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 852e2f5a67..d77316e6ea 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 = { cancelledDelegationChildIds?: Set log?: ReturnType + syncFocusedTaskToWebview?: ReturnType taskHistoryStore?: { get: (id: string) => unknown; invalidate?: (id: string) => Promise } taskScheduler?: { schedule: (task: Task, run: () => Promise) => Promise } taskRegistry?: TaskRegistry @@ -36,6 +37,7 @@ export function makeProviderStub(stub: T): ClineProvider { const proto = ClineProvider.prototype as unknown as PrivateProviderMethods s.cancelledDelegationChildIds ??= new Set() s.log ??= vi.fn() + s.syncFocusedTaskToWebview ??= vi.fn().mockResolvedValue(undefined) s.taskHistoryStore ??= { get: () => undefined } s.taskHistoryStore.invalidate ??= async () => {} s.taskScheduler ??= { schedule: async (_task, run) => run() } 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 92ee8184d6..ef0842fa94 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -1272,20 +1272,10 @@ export class Task extends EventEmitter implements TaskLike { message.messageId ??= crypto.randomUUID() 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() @@ -1325,6 +1315,7 @@ export class Task extends EventEmitter implements TaskLike { this.cloudSyncedMessageTimestamps.add(msg.ts) } } + await this.providerRef.deref()?.postClineMessagesSnapshot(this.taskId, { bumpSeq: true }) } private hydrateApiConversationHistory(messages: ApiMessage[]) { @@ -1337,7 +1328,7 @@ export class Task extends EventEmitter implements TaskLike { */ 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 @@ -1432,7 +1423,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 @@ -1466,10 +1457,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 @@ -1722,6 +1711,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) @@ -2197,7 +2189,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) @@ -2366,7 +2358,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 @@ -3081,7 +3073,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 @@ -3152,12 +3144,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 @@ -3799,7 +3795,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 8d3314a9a6..d4428e8bf8 100644 --- a/src/core/task/__tests__/Task.persistence.spec.ts +++ b/src/core/task/__tests__/Task.persistence.spec.ts @@ -296,6 +296,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 1bcacd459c..1dc2d9275b 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -371,6 +371,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, @@ -1476,6 +1479,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 @@ -2164,8 +2170,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, @@ -2182,13 +2188,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, @@ -2197,11 +2203,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 = { @@ -2213,20 +2219,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, @@ -2234,10 +2237,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(() => {}) @@ -2251,25 +2252,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, @@ -2278,19 +2273,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, @@ -2299,12 +2293,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, @@ -2319,21 +2313,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", }) }) }) @@ -3556,7 +3546,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, @@ -3567,16 +3557,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, @@ -3586,11 +3574,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 495fe454b7..45990c0f41 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -222,10 +222,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: ${ @@ -312,12 +317,6 @@ export class ClineProvider private cloudOrganizationsCacheTimestamp: number | null = null private static readonly CLOUD_ORGANIZATIONS_CACHE_DURATION_MS = 5 * 1000 // 5 seconds - /** - * Monotonically increasing sequence number for clineMessages state pushes. - * Used by the frontend to reject stale state that arrives out-of-order. - */ - private clineMessagesSeq = 0 - public isViewLaunched = false public settingsImportedAt?: number public readonly latestAnnouncementId = "sep-2026-v3.82.0-gateway-portability-free-models" // v3.82.0 portable Zoo Gateway keys, free MiniMax-M3, and new models @@ -588,6 +587,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) { @@ -646,6 +647,8 @@ export class ClineProvider // garbage collected. task = undefined } + + await this.syncFocusedTaskToWebview() } /** @@ -1407,6 +1410,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)`, @@ -1478,6 +1482,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 { @@ -1485,6 +1495,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")) @@ -2428,9 +2584,7 @@ export class ClineProvider } async postStateToWebview() { - const clineMessagesSeq = ++this.clineMessagesSeq const state = await this.getStateToPostToWebview() - state.clineMessagesSeq = clineMessagesSeq await this.postMessageToWebview({ type: "state", state }) } @@ -2443,11 +2597,9 @@ export class ClineProvider * `taskHistoryUpdated` / `taskHistoryItemUpdated`. */ async postStateToWebviewWithoutTaskHistory(): Promise { - const clineMessagesSeq = ++this.clineMessagesSeq const state = await this.getStateToPostToWebview({ includeTaskHistory: false }) - state.clineMessagesSeq = clineMessagesSeq - const { taskHistory: _omit, ...rest } = state - await this.postMessageToWebview({ type: "state", state: rest }) + const { taskHistory: _omitHistory, ...metadataState } = state + await this.postMessageToWebview({ type: "state", state: metadataState }) } /** @@ -2473,7 +2625,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 @@ -2485,7 +2639,7 @@ export class ClineProvider */ async postStateToWebviewWithoutClineMessages(): Promise { const state = await this.getStateToPostToWebview({ includeTaskHistory: false }) - const { clineMessages: _omitMessages, taskHistory: _omitHistory, ...rest } = state + const { taskHistory: _omitHistory, ...rest } = state await this.postMessageToWebview({ type: "state", state: rest }) } diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 1a6a82a5b0..6a8f236a84 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -758,7 +758,8 @@ describe("ClineProvider", () => { } await provider.postMessageToWebview(message) - expect(mockPostMessage).toHaveBeenCalledWith(message) + const { clineMessages: _messages, clineMessagesSeq: _seq, ...metadataState } = mockState + expect(mockPostMessage).toHaveBeenCalledWith({ type: "state", state: metadataState }) }) test("postMessageToWebview does not throw when webview is disposed", async () => { @@ -860,6 +861,89 @@ describe("ClineProvider", () => { expect(postMessageSpy).not.toHaveBeenCalledWith(expect.objectContaining({ type: "action" })) }) + test("postMessageToWebview strips transcript fields from every generic state message", async () => { + await provider.resolveWebviewView(mockWebviewView) + mockPostMessage.mockClear() + const transcript = [{ ts: 1, type: "say", say: "text", text: "secret transcript" }] as ClineMessage[] + + await provider.postMessageToWebview({ + type: "state", + state: { + version: "1.0.0", + clineMessages: transcript, + clineMessagesSeq: 17, + } as Partial, + }) + + expect(mockPostMessage).toHaveBeenCalledOnce() + expect(mockPostMessage).toHaveBeenCalledWith({ type: "state", state: { version: "1.0.0" } }) + }) + + describe("transcript transport", () => { + const setCurrentTask = (task: { taskId: string; clineMessages: ClineMessage[] } | undefined) => { + vi.spyOn(provider, "getCurrentTask").mockImplementation(() => task as Task | undefined) + } + + test("posts ordered snapshot chunks followed by the end marker", async () => { + await provider.resolveWebviewView(mockWebviewView) + const messages = Array.from({ length: 401 }, (_, index) => ({ + ts: index, + type: "say", + say: "text", + text: `message ${index}`, + })) as ClineMessage[] + setCurrentTask({ taskId: "task-1", clineMessages: messages }) + mockPostMessage.mockClear() + + await provider.postClineMessagesSnapshot("task-1", { bumpSeq: true }) + + const posts: ExtensionMessage[] = mockPostMessage.mock.calls.map(([message]: [ExtensionMessage]) => message) + expect(posts.map(({ type }) => type)).toEqual([ + "clineMessagesSnapshotStart", + "clineMessagesSnapshotChunk", + "clineMessagesSnapshotChunk", + "clineMessagesSnapshotChunk", + "clineMessagesSnapshotEnd", + ]) + expect(posts.map(({ clineMessagesSeq }) => clineMessagesSeq)).toEqual([1, 1, 1, 1, 1]) + expect(posts.slice(1, 4).map(({ snapshotStartIndex }) => snapshotStartIndex)).toEqual([0, 200, 400]) + expect(posts.slice(1, 4).map(({ clineMessages }) => clineMessages?.length)).toEqual([200, 200, 1]) + expect(new Set(posts.map(({ snapshotId }) => snapshotId)).size).toBe(1) + }) + + test("invalidates a queued old-focus delta before it reaches the webview", async () => { + await provider.resolveWebviewView(mockWebviewView) + const task = { taskId: "task-1", clineMessages: [] as ClineMessage[] } + setCurrentTask(task) + mockPostMessage.mockClear() + + let releaseQueue!: () => void + Object.assign(provider, { + clineMessagesPostQueue: new Promise((resolve) => { + releaseQueue = resolve + }), + }) + const pendingDelta = provider.postClineMessageAppended("task-1", { + ts: 1, + type: "say", + say: "text", + text: "queued", + }) + + task.taskId = "task-2" + const focusSync = provider.syncFocusedTaskToWebview() + releaseQueue() + await Promise.all([pendingDelta, focusSync]) + + expect(mockPostMessage).not.toHaveBeenCalledWith( + expect.objectContaining({ type: "clineMessageAppended", taskId: "task-1" }), + ) + expect(mockPostMessage).toHaveBeenCalledWith( + expect.objectContaining({ type: "clineMessagesSnapshotStart", taskId: "task-2" }), + ) + }) + }) + test("postStateToWebviewWithoutTaskHistory waits for the webview post boundary", async () => { let releasePost!: () => void const pendingPost = new Promise((resolve) => { @@ -987,7 +1071,9 @@ describe("ClineProvider", () => { }) test("posts on the leading edge and coalesces a burst into one trailing post", async () => { - const postStateSpy = vi.spyOn(provider, "postStateToWebviewWithoutTaskHistory").mockResolvedValue(undefined) + const postStateSpy = vi + .spyOn(provider, "postStateToWebviewWithoutClineMessages") + .mockResolvedValue(undefined) await provider.postStateToWebviewThrottled() await provider.postStateToWebviewThrottled() @@ -1003,7 +1089,9 @@ describe("ClineProvider", () => { }) test("does not starve state posts during continuous updates", async () => { - const postStateSpy = vi.spyOn(provider, "postStateToWebviewWithoutTaskHistory").mockResolvedValue(undefined) + const postStateSpy = vi + .spyOn(provider, "postStateToWebviewWithoutClineMessages") + .mockResolvedValue(undefined) await provider.postStateToWebviewThrottled() await vi.advanceTimersByTimeAsync(400) @@ -1024,7 +1112,7 @@ describe("ClineProvider", () => { releasePost = resolve }) const postStateSpy = vi - .spyOn(provider, "postStateToWebviewWithoutTaskHistory") + .spyOn(provider, "postStateToWebviewWithoutClineMessages") .mockResolvedValueOnce(undefined) .mockReturnValueOnce(pendingPost) @@ -1051,7 +1139,9 @@ describe("ClineProvider", () => { }) test("does not duplicate an idle leading post when flushed", async () => { - const postStateSpy = vi.spyOn(provider, "postStateToWebviewWithoutTaskHistory").mockResolvedValue(undefined) + const postStateSpy = vi + .spyOn(provider, "postStateToWebviewWithoutClineMessages") + .mockResolvedValue(undefined) await provider.postStateToWebviewThrottled() await provider.flushPostStateToWebviewThrottled() @@ -1063,7 +1153,7 @@ describe("ClineProvider", () => { test("handles state post failures inside the debounced callback", async () => { const error = new Error("state post failed") const logSpy = vi.spyOn(provider, "log").mockImplementation(() => {}) - vi.spyOn(provider, "postStateToWebviewWithoutTaskHistory").mockRejectedValue(error) + vi.spyOn(provider, "postStateToWebviewWithoutClineMessages").mockRejectedValue(error) await expect(provider.postStateToWebviewThrottled()).resolves.toBeUndefined() expect(logSpy).toHaveBeenCalledWith( @@ -1073,7 +1163,7 @@ describe("ClineProvider", () => { test("stringifies non-Error state post failures inside the debounced callback", async () => { const logSpy = vi.spyOn(provider, "log").mockImplementation(() => {}) - vi.spyOn(provider, "postStateToWebviewWithoutTaskHistory").mockRejectedValue("state post failed") + vi.spyOn(provider, "postStateToWebviewWithoutClineMessages").mockRejectedValue("state post failed") await expect(provider.postStateToWebviewThrottled()).resolves.toBeUndefined() expect(logSpy).toHaveBeenCalledWith( @@ -1085,7 +1175,7 @@ describe("ClineProvider", () => { const error = new Error("state post failed") const logSpy = vi.spyOn(provider, "log").mockImplementation(() => {}) const postStateSpy = vi - .spyOn(provider, "postStateToWebviewWithoutTaskHistory") + .spyOn(provider, "postStateToWebviewWithoutClineMessages") .mockResolvedValueOnce(undefined) .mockRejectedValueOnce(error) @@ -1100,7 +1190,9 @@ describe("ClineProvider", () => { }) test("cancels pending work on dispose and ignores later schedule or flush calls", async () => { - const postStateSpy = vi.spyOn(provider, "postStateToWebviewWithoutTaskHistory").mockResolvedValue(undefined) + const postStateSpy = vi + .spyOn(provider, "postStateToWebviewWithoutClineMessages") + .mockResolvedValue(undefined) await provider.postStateToWebviewThrottled() await provider.postStateToWebviewThrottled() diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index 4c2a301965..4b375115da 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -117,6 +117,7 @@ const mockClineProvider = { }, log: vi.fn(), postStateToWebview: vi.fn(), + syncFocusedTaskToWebview: vi.fn().mockResolvedValue(undefined), resolveWebviewThemeFixtureProbe: vi.fn(), getCurrentTask: vi.fn(), getTaskWithId: vi.fn(), diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 0dad65a480..ff4c8ed691 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -369,8 +369,8 @@ export const webviewMessageHandler = async ( globalStoragePath: provider.contextProxy.globalStorageUri.fsPath, }) - // Update the UI to reflect the deletion - await provider.postStateToWebview() + // Rewind already posts a snapshot. Checkpoint metadata is not rendered + // in transcript rows, so persisting it does not require a second snapshot. } } catch (error) { console.error("Error in delete message:", error) @@ -539,9 +539,6 @@ export const webviewMessageHandler = async ( globalStoragePath: provider.contextProxy.globalStorageUri.fsPath, }) - // Update the UI to reflect the deletion - await provider.postStateToWebview() - await currentCline.submitUserMessage(editedContent, images) } catch (error) { console.error("Error in edit message:", error) @@ -574,6 +571,9 @@ export const webviewMessageHandler = async ( } switch (message.type) { + case "requestClineMessagesResync": + await provider.resyncClineMessagesToWebview(message.taskId) + break case "themeFixtureProbeResponse": if (process.env.ROO_CODE_THEME_FIXTURE_PROBE === "1" && message.requestId && message.themeFixture) { provider.resolveWebviewThemeFixtureProbe(message.requestId, message.themeFixture) @@ -584,7 +584,7 @@ export const webviewMessageHandler = async ( const customModes = await provider.customModesManager.getCustomModes() await updateGlobalState("customModes", customModes) - await provider.postStateToWebview() + await provider.syncFocusedTaskToWebview({ includeTaskHistory: true }) void provider.workspaceTracker ?.initializeFilePaths() .catch((err) => provider.log(`Workspace initialization error: ${err}`)) // Don't await. @@ -873,7 +873,7 @@ export const webviewMessageHandler = async ( // handled via metadata; parent resumption occurs through // reopenParentFromDelegation, not via finishSubTask. await provider.clearTask() - await provider.postStateToWebview() + await provider.syncFocusedTaskToWebview({ includeTaskHistory: true }) break case "didShowAnnouncement": await updateGlobalState("lastShownAnnouncementId", provider.latestAnnouncementId) @@ -1932,13 +1932,7 @@ export const webviewMessageHandler = async ( const existingPrompts = getGlobalState("customModePrompts") ?? {} const updatedPrompts = { ...existingPrompts, [message.promptMode]: message.customPrompt } await updateGlobalState("customModePrompts", updatedPrompts) - const currentState = await provider.getStateToPostToWebview() - const stateWithPrompts = { - ...currentState, - customModePrompts: updatedPrompts, - hasOpenedModeSelector: currentState.hasOpenedModeSelector ?? false, - } - await provider.postMessageToWebview({ type: "state", state: stateWithPrompts }) + await provider.postStateToWebviewWithoutClineMessages() if (TelemetryService.hasInstance()) { // Determine which setting was changed by comparing objects diff --git a/webview-ui/src/components/chat/__tests__/ChatView.clear-approval-buttons.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatView.clear-approval-buttons.spec.tsx index 14ccce9751..7a8d6ac83c 100644 --- a/webview-ui/src/components/chat/__tests__/ChatView.clear-approval-buttons.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatView.clear-approval-buttons.spec.tsx @@ -1,23 +1,14 @@ // pnpm --filter @roo-code/vscode-webview test src/components/chat/__tests__/ChatView.clear-approval-buttons.spec.tsx import React from "react" -import { renderWithExtensionState, waitFor, act, fireEvent } from "@/utils/test-utils" +import { hydrateExtensionState, renderWithExtensionState, waitFor, act, fireEvent } from "@/utils/test-utils" + +import type { ClineMessage } from "@roo-code/types" import { vscode } from "@src/utils/vscode" import ChatView, { ChatViewProps } from "../ChatView" -interface ClineMessage { - type: "say" | "ask" - say?: string - ask?: string - ts: number - text?: string - partial?: boolean - isAnswered?: boolean - checkpoint?: Record -} - vi.mock("@src/utils/vscode", () => ({ vscode: { postMessage: vi.fn(), @@ -112,22 +103,16 @@ const SEE_NEW_CHANGES_BUTTON_LABEL = "chat:seeNewChanges.title" const RESTORE_CHANGES_BUTTON_LABEL = "chat:restoreChanges.title" const hydrateState = (clineMessages: ClineMessage[]) => { - window.postMessage( - { - type: "state", - state: { - version: "1.0.0", - clineMessages, - taskHistory: [], - shouldShowAnnouncement: false, - allowedCommands: [], - alwaysAllowExecute: false, - cloudIsAuthenticated: false, - telemetrySetting: "enabled", - }, - }, - "*", - ) + hydrateExtensionState({ + version: "1.0.0", + clineMessages, + taskHistory: [], + shouldShowAnnouncement: false, + allowedCommands: [], + alwaysAllowExecute: false, + cloudIsAuthenticated: false, + telemetrySetting: "enabled", + }) } const defaultProps: ChatViewProps = { diff --git a/webview-ui/src/components/chat/__tests__/ChatView.notification-sound.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatView.notification-sound.spec.tsx index 162fc601d8..4680ec5819 100644 --- a/webview-ui/src/components/chat/__tests__/ChatView.notification-sound.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatView.notification-sound.spec.tsx @@ -1,34 +1,11 @@ // npx vitest run src/components/chat/__tests__/ChatView.notification-sound.spec.tsx import React from "react" -import { renderWithExtensionState, waitFor } from "@/utils/test-utils" +import { hydrateExtensionState, renderWithExtensionState, waitFor } from "@/utils/test-utils" -import ChatView, { ChatViewProps } from "../ChatView" - -// Define minimal types needed for testing -interface ClineMessage { - type: "say" | "ask" - say?: string - ask?: string - ts: number - text?: string - partial?: boolean -} +import type { ClineMessage, ExtensionState } from "@roo-code/types" -interface QueuedMessage { - id: string - text: string - images?: string[] -} - -interface ExtensionState { - version: string - clineMessages: ClineMessage[] - taskHistory: any[] - shouldShowAnnouncement: boolean - messageQueue?: QueuedMessage[] - [key: string]: any -} +import ChatView, { ChatViewProps } from "../ChatView" // Mock vscode API vi.mock("@src/utils/vscode", () => ({ @@ -188,64 +165,18 @@ vi.mock("../ChatTextArea", () => { } }) -// Mock VSCode components -vi.mock("@vscode/webview-ui-toolkit/react", () => ({ - VSCodeButton: function MockVSCodeButton({ - children, - onClick, - appearance, - }: { - children: React.ReactNode - onClick?: () => void - appearance?: string - }) { - return ( - - ) - }, - VSCodeTextField: function MockVSCodeTextField({ - value, - onInput, - placeholder, - }: { - value?: string - onInput?: (e: { target: { value: string } }) => void - placeholder?: string - }) { - return ( - onInput?.({ target: { value: e.target.value } })} - placeholder={placeholder} - /> - ) - }, - VSCodeLink: function MockVSCodeLink({ children, href }: { children: React.ReactNode; href?: string }) { - return {children} - }, -})) - // Mock window.postMessage to trigger state hydration const mockPostMessage = (state: Partial) => { - window.postMessage( - { - type: "state", - state: { - version: "1.0.0", - clineMessages: [], - taskHistory: [], - shouldShowAnnouncement: false, - cloudIsAuthenticated: false, - telemetrySetting: "enabled", - messageQueue: [], - ...state, - }, - }, - "*", - ) + hydrateExtensionState({ + version: "1.0.0", + clineMessages: [], + taskHistory: [], + shouldShowAnnouncement: false, + cloudIsAuthenticated: false, + telemetrySetting: "enabled", + messageQueue: [], + ...state, + }) } const defaultProps: ChatViewProps = { @@ -270,6 +201,7 @@ describe("ChatView - Notification Sound with Queued Messages", () => { messageQueue: [ { id: "msg-1", + timestamp: 1, text: "This is a queued message", images: [], }, @@ -293,6 +225,7 @@ describe("ChatView - Notification Sound with Queued Messages", () => { messageQueue: [ { id: "msg-1", + timestamp: 1, text: "This is a queued message", images: [], }, @@ -381,11 +314,13 @@ describe("ChatView - Notification Sound with Queued Messages", () => { messageQueue: [ { id: "msg-1", + timestamp: 1, text: "First queued message", images: [], }, { id: "msg-2", + timestamp: 2, text: "Second queued message", images: [], }, @@ -409,11 +344,13 @@ describe("ChatView - Notification Sound with Queued Messages", () => { messageQueue: [ { id: "msg-1", + timestamp: 1, text: "First queued message", images: [], }, { id: "msg-2", + timestamp: 2, text: "Second queued message", images: [], }, diff --git a/webview-ui/src/components/chat/__tests__/ChatView.scroll-debug-repro.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatView.scroll-debug-repro.spec.tsx index 56b008b862..afa0f3be0b 100644 --- a/webview-ui/src/components/chat/__tests__/ChatView.scroll-debug-repro.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatView.scroll-debug-repro.spec.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useImperativeHandle, useRef } from "react" -import { act, fireEvent, renderWithExtensionState } from "@/utils/test-utils" +import { act, fireEvent, hydrateExtensionState, renderWithExtensionState } from "@/utils/test-utils" import type { ClineMessage } from "@roo-code/types" @@ -9,20 +9,6 @@ import ChatView, { type ChatViewProps } from "../ChatView" type FollowOutput = ((isAtBottom: boolean) => "auto" | false) | "auto" | false -interface ExtensionStateMessage { - type: "state" - state: { - version: string - clineMessages: ClineMessage[] - taskHistory: unknown[] - shouldShowAnnouncement: boolean - allowedCommands: string[] - alwaysAllowExecute: boolean - cloudIsAuthenticated: boolean - telemetrySetting: "enabled" | "disabled" | "unset" - } -} - interface MockVirtuosoHandle { scrollToIndex: (options: { index: number | "LAST" @@ -89,13 +75,6 @@ vi.mock("./CheckpointWarning", () => ({ CheckpointWarning: () => null })) vi.mock("./QueuedMessages", () => ({ QueuedMessages: () => null })) vi.mock("./WorktreeSelector", () => ({ WorktreeSelector: () => null })) -vi.mock("@vscode/webview-ui-toolkit/react", () => ({ - VSCodeLink: ({ children }: { children: React.ReactNode }) => <>{children}, - VSCodeButton: ({ children, onClick }: { children: React.ReactNode; onClick?: () => void }) => ( - - ), -})) - vi.mock("@/components/ui", async (importOriginal) => { const actual = await importOriginal() return { @@ -241,25 +220,16 @@ const resolveFollowOutput = (isAtBottom: boolean): "auto" | false => { } const postState = (clineMessages: ClineMessage[]) => { - const message: ExtensionStateMessage = { - type: "state", - state: { - version: "1.0.0", - clineMessages, - taskHistory: [], - shouldShowAnnouncement: false, - allowedCommands: [], - alwaysAllowExecute: false, - cloudIsAuthenticated: false, - telemetrySetting: "enabled", - }, - } - - window.dispatchEvent( - new MessageEvent("message", { - data: message, - }), - ) + hydrateExtensionState({ + version: "1.0.0", + clineMessages, + taskHistory: [], + shouldShowAnnouncement: false, + allowedCommands: [], + alwaysAllowExecute: false, + cloudIsAuthenticated: false, + telemetrySetting: "enabled", + }) } const renderView = () => renderWithExtensionState() diff --git a/webview-ui/src/components/chat/__tests__/ChatView.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatView.spec.tsx index 8f7de5c459..ad6ffc3cfd 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, @@ -144,13 +145,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 @@ -352,13 +354,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) => { @@ -368,29 +364,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 }, + ) }) } @@ -805,7 +803,7 @@ describe("ChatView - Version Indicator Tests", () => { it("opens announcement modal when version indicator is clicked", async () => { // Mock VersionIndicator to return a button with onClick - mockVersionIndicator.mockImplementation(({ onClick }: { onClick?: () => void }) => + mockVersionIndicator.mockImplementation(({ onClick } = {}) => React.createElement("button", { "data-testid": "version-indicator", onClick, diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index 177372f310..9be84271b4 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -1,5 +1,5 @@ import { providerIdentifiers } from "@roo-code/types" -import React, { createContext, useCallback, useEffect, useState } from "react" +import React, { createContext, useCallback, useEffect, useRef, useState } from "react" import { type ProviderSettings, @@ -13,6 +13,7 @@ import { type CloudOrganizationMembership, type ExtensionMessage, type ExtensionState, + type ClineMessage, type MarketplaceInstalledMetadata, type SkillMetadata, type RuleMetadata, @@ -156,6 +157,14 @@ export interface ExtensionStateContextType extends ExtensionState { export const ExtensionStateContext = createContext(undefined) +type ClineMessagesSnapshotBuffer = { + snapshotId: string + taskId?: string + seq: number + total: number + messages: ClineMessage[] +} + export const mergeExtensionState = (prevState: ExtensionState, newState: Partial) => { const { customModePrompts: prevCustomModePrompts, experiments: prevExperiments, ...prevRest } = prevState @@ -171,21 +180,6 @@ export const mergeExtensionState = (prevState: ExtensionState, newState: Partial const experiments = { ...prevExperiments, ...(newExperiments ?? {}) } const rest = { ...prevRest, ...newRest } - // Protect clineMessages from stale state pushes using sequence numbering. - // Multiple async event sources (cloud auth, settings, task streaming) can trigger - // concurrent state pushes. If a stale push arrives after a newer one, its clineMessages - // would overwrite the newer messages. The sequence number prevents this by only applying - // clineMessages when the incoming seq is strictly greater than the last applied seq. - if ( - newState.clineMessagesSeq !== undefined && - prevState.clineMessagesSeq !== undefined && - newState.clineMessagesSeq <= prevState.clineMessagesSeq && - newState.clineMessages !== undefined - ) { - rest.clineMessages = prevState.clineMessages - rest.clineMessagesSeq = prevState.clineMessagesSeq - } - // Note that we completely replace the previous apiConfiguration and customSupportPrompts objects // with new ones since the state that is broadcast is the entire objects so merging is not necessary. return { @@ -287,6 +281,11 @@ export const ExtensionStateContextProvider: React.FC<{ const [state, setState] = useState(() => mergeExtensionState(createInitialExtensionState(), initialState ?? {}), ) + const activeTaskIdRef = useRef(state.currentTaskId) + const clineMessagesSeqRef = useRef(state.clineMessagesSeq ?? 0) + const clineMessagesRef = useRef(state.clineMessages) + const activeSnapshotRef = useRef(null) + const resyncPendingRef = useRef(false) const [didHydrateState, setDidHydrateState] = useState(false) const [showWelcome, setShowWelcome] = useState(false) @@ -336,13 +335,98 @@ export const ExtensionStateContextProvider: React.FC<{ })) }, []) + const requestClineMessagesResync = useCallback((receivedSeq?: number) => { + if (resyncPendingRef.current) { + return + } + resyncPendingRef.current = true + vscode.postMessage({ + type: "requestClineMessagesResync", + taskId: activeTaskIdRef.current, + expectedSeq: clineMessagesSeqRef.current + 1, + receivedSeq, + }) + }, []) + + const applyClineMessagesDelta = useCallback( + (message: ExtensionMessage, operation: "append" | "update") => { + const seq = message.clineMessagesSeq + const clineMessage = message.clineMessage + if (message.taskId !== activeTaskIdRef.current) { + return + } + if (typeof seq !== "number" || !Number.isSafeInteger(seq) || seq < 0 || !clineMessage) { + requestClineMessagesResync(typeof seq === "number" ? seq : undefined) + return + } + + const snapshot = activeSnapshotRef.current + if (snapshot) { + // The snapshot already includes all deltas through its sequence. A newer + // delta interleaved with it means the stream cannot be applied atomically. + if (seq <= snapshot.seq) { + return + } + activeSnapshotRef.current = null + requestClineMessagesResync(seq) + return + } + if (seq <= clineMessagesSeqRef.current) { + return + } + if (seq !== clineMessagesSeqRef.current + 1) { + requestClineMessagesResync(seq) + return + } + + let nextMessages: ClineMessage[] + if (operation === "append") { + nextMessages = [...clineMessagesRef.current, clineMessage] + } else { + const index = findLastIndex(clineMessagesRef.current, (item) => item.ts === clineMessage.ts) + if (index === -1) { + requestClineMessagesResync(seq) + return + } + nextMessages = [...clineMessagesRef.current] + nextMessages[index] = clineMessage + } + + clineMessagesRef.current = nextMessages + clineMessagesSeqRef.current = seq + setState((prevState) => ({ + ...prevState, + clineMessages: nextMessages, + clineMessagesSeq: seq, + })) + }, + [requestClineMessagesResync], + ) + const handleMessage = useCallback( (event: MessageEvent) => { const message: ExtensionMessage = event.data switch (message.type) { case "state": { - const newState = message.state ?? {} - setState((prevState) => mergeExtensionState(prevState, newState)) + const { + clineMessages: _ignoredMessages, + clineMessagesSeq: _ignoredMessagesSeq, + ...newState + } = message.state ?? {} + const hasCurrentTaskId = Object.prototype.hasOwnProperty.call(newState, "currentTaskId") + const nextTaskId = hasCurrentTaskId ? newState.currentTaskId : activeTaskIdRef.current + const taskChanged = hasCurrentTaskId && nextTaskId !== activeTaskIdRef.current + if (taskChanged) { + activeTaskIdRef.current = nextTaskId + clineMessagesSeqRef.current = 0 + clineMessagesRef.current = [] + activeSnapshotRef.current = null + resyncPendingRef.current = false + } + setState((prevState) => { + const merged = mergeExtensionState(prevState, newState) + return taskChanged ? { ...merged, clineMessages: [], clineMessagesSeq: 0 } : merged + }) setShowWelcome(!checkExistKey(newState.apiConfiguration, newState.zooCodeIsAuthenticated)) setDidHydrateState(true) // Update alwaysAllowFollowupQuestions if present in state message @@ -404,26 +488,142 @@ export const ExtensionStateContextProvider: React.FC<{ setCommands(message.commands ?? []) break } - case "messageUpdated": { - const clineMessage = message.clineMessage! - setState((prevState) => { - // worth noting it will never be possible for a more up-to-date message to be sent here or in normal messages post since the presentAssistantContent function uses lock - const lastIndex = findLastIndex(prevState.clineMessages, (msg) => msg.ts === clineMessage.ts) - if (lastIndex !== -1) { - const newClineMessages = [...prevState.clineMessages] - newClineMessages[lastIndex] = clineMessage - return { ...prevState, clineMessages: newClineMessages } + case "clineMessagesSnapshotStart": { + if (message.taskId !== activeTaskIdRef.current) { + break + } + + const seq = message.clineMessagesSeq + if (typeof seq !== "number" || !Number.isSafeInteger(seq) || seq < 0) { + activeSnapshotRef.current = null + requestClineMessagesResync(typeof seq === "number" ? seq : undefined) + break + } + if (seq < clineMessagesSeqRef.current) { + break + } + + const total = message.snapshotTotal + if (!message.snapshotId || typeof total !== "number" || !Number.isSafeInteger(total) || total < 0) { + activeSnapshotRef.current = null + requestClineMessagesResync(seq) + break + } + + const activeSnapshot = activeSnapshotRef.current + if (activeSnapshot?.snapshotId === message.snapshotId && activeSnapshot.seq === seq) { + break + } + if (activeSnapshot && seq < activeSnapshot.seq) { + break + } + + activeSnapshotRef.current = { + snapshotId: message.snapshotId, + taskId: message.taskId, + seq, + total, + messages: [], + } + break + } + case "clineMessagesSnapshotChunk": { + if (message.taskId !== activeTaskIdRef.current) { + break + } + + const seq = message.clineMessagesSeq + const snapshot = activeSnapshotRef.current + if (typeof seq !== "number" || !Number.isSafeInteger(seq) || seq < 0) { + activeSnapshotRef.current = null + requestClineMessagesResync(typeof seq === "number" ? seq : undefined) + break + } + if (!snapshot) { + if (seq > clineMessagesSeqRef.current) { + requestClineMessagesResync(seq) } - // Log a warning if messageUpdated arrives for a timestamp not in the - // frontend's clineMessages. With the seq guard and cloud event isolation - // (layers 1+2), this should not happen under normal conditions. If it - // does, it signals a state synchronization issue worth investigating. - console.warn( - `[messageUpdated] Received update for unknown message ts=${clineMessage.ts}, dropping. ` + - `Frontend has ${prevState.clineMessages.length} messages.`, - ) - return prevState - }) + break + } + if (message.snapshotId !== snapshot.snapshotId || seq !== snapshot.seq) { + if (seq > snapshot.seq) { + activeSnapshotRef.current = null + requestClineMessagesResync(seq) + } + break + } + + const chunk = message.clineMessages + const startIndex = message.snapshotStartIndex + if ( + !Array.isArray(chunk) || + chunk.length === 0 || + typeof startIndex !== "number" || + !Number.isSafeInteger(startIndex) || + startIndex !== snapshot.messages.length || + snapshot.messages.length + chunk.length > snapshot.total + ) { + activeSnapshotRef.current = null + requestClineMessagesResync(seq) + break + } + + snapshot.messages.push(...chunk) + break + } + case "clineMessagesSnapshotEnd": { + if (message.taskId !== activeTaskIdRef.current) { + break + } + + const seq = message.clineMessagesSeq + const snapshot = activeSnapshotRef.current + if (typeof seq !== "number" || !Number.isSafeInteger(seq) || seq < 0) { + activeSnapshotRef.current = null + requestClineMessagesResync(typeof seq === "number" ? seq : undefined) + break + } + if (!snapshot) { + if (seq > clineMessagesSeqRef.current) { + requestClineMessagesResync(seq) + } + break + } + if (message.snapshotId !== snapshot.snapshotId || seq !== snapshot.seq) { + if (seq > snapshot.seq) { + activeSnapshotRef.current = null + requestClineMessagesResync(seq) + } + break + } + if (message.snapshotTotal !== snapshot.total || snapshot.messages.length !== snapshot.total) { + activeSnapshotRef.current = null + requestClineMessagesResync(seq) + break + } + + activeSnapshotRef.current = null + resyncPendingRef.current = false + clineMessagesRef.current = snapshot.messages + clineMessagesSeqRef.current = snapshot.seq + setState((prevState) => ({ + ...prevState, + clineMessages: snapshot.messages, + clineMessagesSeq: snapshot.seq, + })) + break + } + case "clineMessageAppended": { + applyClineMessagesDelta(message, "append") + break + } + case "clineMessageUpdated": { + applyClineMessagesDelta(message, "update") + break + } + case "messageUpdated": { + // An unsequenced legacy update cannot be applied safely. + requestClineMessagesResync(message.clineMessagesSeq) break } case "skills": { @@ -504,7 +704,7 @@ export const ExtensionStateContextProvider: React.FC<{ } } }, - [setListApiConfigMeta], + [applyClineMessagesDelta, requestClineMessagesResync, setListApiConfigMeta], ) useEffect(() => { diff --git a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx index 4c2e2a092c..edcc78405c 100644 --- a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx +++ b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx @@ -6,6 +6,7 @@ import { type ProviderSettings, type ExperimentId, type ExtensionState, + type ExtensionMessage, type ClineMessage, type MarketplaceItem, type MarketplaceInstalledMetadata, @@ -15,6 +16,13 @@ import { } from "@roo-code/types" import { ExtensionStateContextProvider, useExtensionState, mergeExtensionState } from "../ExtensionStateContext" +import { vscode } from "@/utils/vscode" + +const dispatchExtensionMessage = (message: ExtensionMessage) => { + window.dispatchEvent(new MessageEvent("message", { data: message })) +} + +const makeMessage = (ts: number, text: string): ClineMessage => ({ ts, type: "say", say: "text", text }) const TestComponent = () => { const { allowedCommands, setAllowedCommands, soundEnabled, showRooIgnoredFiles, setShowRooIgnoredFiles } = @@ -105,6 +113,16 @@ const InitialStateTestComponent = () => { ) } +const TranscriptTestComponent = () => { + const { currentTaskId, clineMessages, clineMessagesSeq } = useExtensionState() + + return ( +
+ {JSON.stringify({ currentTaskId, clineMessages, clineMessagesSeq: clineMessagesSeq ?? 0 })} +
+ ) +} + describe("ExtensionStateContext", () => { it("initializes with empty allowedCommands array", () => { render( @@ -399,6 +417,136 @@ describe("ExtensionStateContext", () => { }), ) }) + + describe("dedicated transcript transport", () => { + const readTranscript = () => JSON.parse(screen.getByTestId("transcript-state").textContent!) + + it("reconstructs a snapshot and applies contiguous append and update deltas", () => { + render( + + + , + ) + + const first = makeMessage(1, "first") + const second = makeMessage(2, "second") + act(() => { + dispatchExtensionMessage({ + type: "clineMessagesSnapshotStart", + taskId: "task-1", + clineMessagesSeq: 4, + snapshotId: "snapshot-1", + snapshotTotal: 1, + }) + dispatchExtensionMessage({ + type: "clineMessagesSnapshotChunk", + taskId: "task-1", + clineMessagesSeq: 4, + snapshotId: "snapshot-1", + snapshotStartIndex: 0, + clineMessages: [first], + }) + dispatchExtensionMessage({ + type: "clineMessagesSnapshotEnd", + taskId: "task-1", + clineMessagesSeq: 4, + snapshotId: "snapshot-1", + snapshotTotal: 1, + }) + dispatchExtensionMessage({ + type: "clineMessageAppended", + taskId: "task-1", + clineMessagesSeq: 5, + clineMessage: second, + }) + dispatchExtensionMessage({ + type: "clineMessageUpdated", + taskId: "task-1", + clineMessagesSeq: 6, + clineMessage: { ...second, text: "updated" }, + }) + }) + + expect(readTranscript()).toEqual({ + currentTaskId: "task-1", + clineMessages: [first, { ...second, text: "updated" }], + clineMessagesSeq: 6, + }) + }) + + it("ignores transcript fields in generic state and clears transport state on task switch", () => { + const existing = makeMessage(1, "existing") + render( + + + , + ) + + act(() => { + dispatchExtensionMessage({ + type: "state", + state: { clineMessages: [makeMessage(2, "stale")], clineMessagesSeq: 99 }, + }) + }) + expect(readTranscript().clineMessages).toEqual([existing]) + expect(readTranscript().clineMessagesSeq).toBe(3) + + act(() => { + dispatchExtensionMessage({ type: "state", state: { currentTaskId: "task-2" } }) + dispatchExtensionMessage({ + type: "clineMessageAppended", + taskId: "task-1", + clineMessagesSeq: 4, + clineMessage: makeMessage(3, "wrong task"), + }) + }) + + expect(readTranscript()).toEqual({ currentTaskId: "task-2", clineMessages: [], clineMessagesSeq: 0 }) + }) + + it("requests one resync when a delta sequence has a gap", () => { + const postMessage = vi.spyOn(vscode, "postMessage").mockImplementation(() => undefined) + try { + render( + + + , + ) + postMessage.mockClear() // Ignore webviewDidLaunch. + + act(() => { + dispatchExtensionMessage({ + type: "clineMessageAppended", + taskId: "task-1", + clineMessagesSeq: 3, + clineMessage: makeMessage(3, "gap"), + }) + dispatchExtensionMessage({ + type: "clineMessageAppended", + taskId: "task-1", + clineMessagesSeq: 4, + clineMessage: makeMessage(4, "another gap"), + }) + }) + + expect(postMessage).toHaveBeenCalledTimes(1) + expect(postMessage).toHaveBeenCalledWith({ + type: "requestClineMessagesResync", + taskId: "task-1", + expectedSeq: 2, + receivedSeq: 3, + }) + } finally { + postMessage.mockRestore() + } + }) + }) }) describe("mergeExtensionState", () => { @@ -471,152 +619,4 @@ describe("mergeExtensionState", () => { customTools: false, }) }) - - describe("clineMessagesSeq protection", () => { - const baseState: ExtensionState = { - version: "", - mcpEnabled: false, - clineMessages: [], - taskHistory: [], - shouldShowAnnouncement: false, - enableCheckpoints: true, - writeDelayMs: 1000, - mode: "default", - experiments: {} as Record, - customModes: [], - maxOpenTabsContext: 20, - maxWorkspaceFiles: 100, - apiConfiguration: {}, - telemetrySetting: "unset", - showRooIgnoredFiles: true, - enableSubfolderRules: false, - renderContext: "sidebar", - cloudUserInfo: null, - organizationAllowList: { allowAll: true, providers: {} }, - autoCondenseContext: true, - autoCondenseContextPercent: 100, - cloudIsAuthenticated: false, - sharingEnabled: false, - publicSharingEnabled: false, - profileThresholds: {}, - hasOpenedModeSelector: false, - maxImageFileSize: 5, - maxTotalImageSize: 20, - taskSyncEnabled: false, - checkpointTimeout: DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, - maxReadFileLine: -1, - diffFuzzyThreshold: DEFAULT_DIFF_FUZZY_THRESHOLD, - } - - const makeMessage = (ts: number, text: string): ClineMessage => - ({ ts, type: "say", say: "text", text }) as ClineMessage - - it("rejects stale clineMessages when seq is not newer", () => { - const newerMessages = [makeMessage(1, "hello"), makeMessage(2, "world")] - const staleMessages = [makeMessage(1, "hello")] - - const prevState: ExtensionState = { - ...baseState, - clineMessages: newerMessages, - clineMessagesSeq: 5, - } - - const result = mergeExtensionState(prevState, { - clineMessages: staleMessages, - clineMessagesSeq: 3, // stale seq - }) - - // Should keep the newer messages - expect(result.clineMessages).toBe(newerMessages) - expect(result.clineMessagesSeq).toBe(5) - }) - - it("rejects clineMessages when seq equals current (not strictly greater)", () => { - const currentMessages = [makeMessage(1, "hello"), makeMessage(2, "world")] - const sameSeqMessages = [makeMessage(1, "hello")] - - const prevState: ExtensionState = { - ...baseState, - clineMessages: currentMessages, - clineMessagesSeq: 5, - } - - const result = mergeExtensionState(prevState, { - clineMessages: sameSeqMessages, - clineMessagesSeq: 5, // same seq, not strictly greater - }) - - expect(result.clineMessages).toBe(currentMessages) - expect(result.clineMessagesSeq).toBe(5) - }) - - it("accepts clineMessages when seq is strictly greater", () => { - const oldMessages = [makeMessage(1, "hello")] - const newMessages = [makeMessage(1, "hello"), makeMessage(2, "world")] - - const prevState: ExtensionState = { - ...baseState, - clineMessages: oldMessages, - clineMessagesSeq: 3, - } - - const result = mergeExtensionState(prevState, { - clineMessages: newMessages, - clineMessagesSeq: 4, // newer seq - }) - - expect(result.clineMessages).toBe(newMessages) - expect(result.clineMessagesSeq).toBe(4) - }) - - it("preserves clineMessages when newState does not include them (cloud event path)", () => { - const existingMessages = [makeMessage(1, "hello"), makeMessage(2, "world")] - - const prevState: ExtensionState = { - ...baseState, - clineMessages: existingMessages, - clineMessagesSeq: 5, - } - - // Simulate a cloud event push that omits clineMessages and clineMessagesSeq - const result = mergeExtensionState(prevState, { - cloudIsAuthenticated: true, - }) - - expect(result.clineMessages).toBe(existingMessages) - expect(result.clineMessagesSeq).toBe(5) - }) - - it("applies clineMessages normally when neither state has seq (backward compat)", () => { - const oldMessages = [makeMessage(1, "hello")] - const newMessages = [makeMessage(1, "hello"), makeMessage(2, "world")] - - const prevState: ExtensionState = { - ...baseState, - clineMessages: oldMessages, - } - - const result = mergeExtensionState(prevState, { - clineMessages: newMessages, - }) - - expect(result.clineMessages).toBe(newMessages) - }) - - it("applies clineMessages when prevState has no seq but newState does (first push)", () => { - const prevState: ExtensionState = { - ...baseState, - clineMessages: [], - } - - const newMessages = [makeMessage(1, "hello")] - const result = mergeExtensionState(prevState, { - clineMessages: newMessages, - clineMessagesSeq: 1, - }) - - expect(result.clineMessages).toBe(newMessages) - expect(result.clineMessagesSeq).toBe(1) - }) - }) }) diff --git a/webview-ui/src/utils/test-utils.tsx b/webview-ui/src/utils/test-utils.tsx index 847c401f2c..305f962ba2 100644 --- a/webview-ui/src/utils/test-utils.tsx +++ b/webview-ui/src/utils/test-utils.tsx @@ -3,7 +3,7 @@ import { render as rtlRender, type RenderOptions } from "@testing-library/react" import { QueryClient, QueryClientProvider } from "@tanstack/react-query" import { vi, type Mock } from "vitest" -import type { ExtensionState } from "@roo-code/types" +import type { ClineMessage, ExtensionMessage, ExtensionState } from "@roo-code/types" import { TooltipProvider } from "@src/components/ui/tooltip" import { STANDARD_TOOLTIP_DELAY } from "@src/components/ui/standard-tooltip" @@ -37,6 +37,67 @@ export const makeExtensionState = (overrides: Partial = {}): Par ...overrides, }) +let nextTranscriptSnapshotId = 0 + +export const dispatchExtensionMessage = (message: ExtensionMessage) => { + window.dispatchEvent(new MessageEvent("message", { data: message })) +} + +export const hydrateExtensionState = ( + state: Partial, + options: { taskId?: string; clineMessagesSeq?: number } = {}, +) => { + const { clineMessages, clineMessagesSeq: stateSeq, ...metadataState } = state + const taskId = options.taskId ?? metadataState.currentTaskId + const clineMessagesSeq = options.clineMessagesSeq ?? stateSeq ?? 0 + + dispatchExtensionMessage({ + type: "state", + state: metadataState, + }) + + if (clineMessages === undefined) { + return + } + + const snapshotId = `test-transcript-${++nextTranscriptSnapshotId}` + dispatchExtensionMessage({ + type: "clineMessagesSnapshotStart", + taskId, + clineMessagesSeq, + snapshotId, + snapshotTotal: clineMessages.length, + }) + + if (clineMessages.length > 0) { + dispatchExtensionMessage({ + type: "clineMessagesSnapshotChunk", + taskId, + clineMessagesSeq, + snapshotId, + snapshotStartIndex: 0, + clineMessages, + }) + } + + dispatchExtensionMessage({ + type: "clineMessagesSnapshotEnd", + taskId, + clineMessagesSeq, + snapshotId, + snapshotTotal: clineMessages.length, + }) +} + +export const appendClineMessage = (clineMessage: ClineMessage, clineMessagesSeq: number, taskId?: string) => { + dispatchExtensionMessage({ + type: "clineMessageAppended", + taskId, + clineMessagesSeq, + clineMessage, + }) +} + export function mockVscodePostMessage(existing?: Mock) { const postMessage = existing ?? vi.fn() From ab44bdf565dd2057e264cc5cbbf11247b581758b Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Sun, 23 Aug 2026 12:53:19 -0600 Subject: [PATCH 04/38] 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 ffdddcdaea5114481dd804fc50fe915b8c6447e6 Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Sun, 23 Aug 2026 12:54:32 -0600 Subject: [PATCH 05/38] 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 89194c0b6274455a3202fd4e63a09dc4977fcc60 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/38] 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 564b6ced50f21ae0da044ad657d3c2188b6aa672 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/38] 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 6677497302a0a101c2da45c04efcc51549910aed 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/38] 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 bc65542aefa0f44747965334b1183deefeb58fc9 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/38] 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 36a4c9e14cae1fd0fbfde1e403307478c4853ffe Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Sun, 23 Aug 2026 20:59:33 -0600 Subject: [PATCH 10/38] 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 1dc2d9275b..a8275bb481 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -2171,6 +2171,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, @@ -2678,6 +2702,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, @@ -4010,6 +4098,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 45990c0f41..4f2c293235 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -1482,8 +1482,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 } } @@ -1521,6 +1523,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 @@ -1542,6 +1547,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 @@ -1567,6 +1575,9 @@ export class ClineProvider if ((currentTask?.taskId ?? undefined) !== taskId) { return Promise.resolve() } + if (process.env.ROO_CLI_RUNTIME === "1") { + return this.postStateToWebviewWithoutTaskHistory() + } const seq = taskId ? options.bumpSeq diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 6a8f236a84..be1320e8c1 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -447,6 +447,7 @@ describe("ClineProvider", () => { beforeEach(() => { vi.clearAllMocks() + delete process.env.ROO_CLI_RUNTIME if (!TelemetryService.hasInstance()) { TelemetryService.createInstance([]) @@ -884,6 +885,122 @@ describe("ClineProvider", () => { vi.spyOn(provider, "getCurrentTask").mockImplementation(() => task as Task | undefined) } + test("preserves legacy transcript messages for CLI consumers", async () => { + await provider.resolveWebviewView(mockWebviewView) + const previousCliRuntime = process.env.ROO_CLI_RUNTIME + process.env.ROO_CLI_RUNTIME = "1" + try { + const task = { + taskId: "task-1", + clineMessages: [{ ts: 1, type: "say", say: "text", text: "first" }] as ClineMessage[], + } + setCurrentTask(task) + mockPostMessage.mockClear() + + await provider.postClineMessageAppended("task-1", task.clineMessages[0]) + await provider.postClineMessageUpdated("task-1", { ...task.clineMessages[0], text: "updated" }) + await provider.postClineMessagesSnapshot("task-1", { bumpSeq: true }) + + expect(mockPostMessage).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + type: "state", + state: expect.objectContaining({ clineMessages: task.clineMessages }), + }), + ) + expect(mockPostMessage).toHaveBeenNthCalledWith(2, { + type: "messageUpdated", + clineMessage: expect.objectContaining({ text: "updated" }), + }) + expect(mockPostMessage).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ + type: "state", + state: expect.objectContaining({ clineMessages: task.clineMessages }), + }), + ) + } finally { + if (previousCliRuntime === undefined) { + delete process.env.ROO_CLI_RUNTIME + } else { + process.env.ROO_CLI_RUNTIME = previousCliRuntime + } + } + }) + + test("posts cloned append and update deltas in sequence", async () => { + await provider.resolveWebviewView(mockWebviewView) + const task = { taskId: "task-1", clineMessages: [] as ClineMessage[] } + setCurrentTask(task) + mockPostMessage.mockClear() + const appended = { ts: 1, type: "say", say: "text", text: "original" } as ClineMessage + const updated = { ...appended, text: "updated" } + + let releaseQueue!: () => void + Object.assign(provider, { + clineMessagesPostQueue: new Promise((resolve) => { + releaseQueue = resolve + }), + }) + const appendPost = provider.postClineMessageAppended("task-1", appended) + const updatePost = provider.postClineMessageUpdated("task-1", updated) + appended.text = "mutated after enqueue" + updated.text = "also mutated" + releaseQueue() + await Promise.all([appendPost, updatePost]) + + expect(mockPostMessage.mock.calls.map(([message]: [ExtensionMessage]) => message)).toEqual([ + { + type: "clineMessageAppended", + taskId: "task-1", + clineMessage: expect.objectContaining({ text: "original" }), + clineMessagesSeq: 1, + }, + { + type: "clineMessageUpdated", + taskId: "task-1", + clineMessage: expect.objectContaining({ text: "updated" }), + clineMessagesSeq: 2, + }, + ]) + }) + + test("ignores transcript work for a task that is not focused", async () => { + const task = { taskId: "task-1", clineMessages: [] as ClineMessage[] } + setCurrentTask(task) + const message = { ts: 1, type: "say", say: "text", text: "ignored" } as ClineMessage + const postSpy = vi.spyOn(provider, "postMessageToWebview") + + await Promise.all([ + provider.postClineMessageAppended("task-2", message), + provider.postClineMessageUpdated("task-2", message), + provider.postClineMessagesSnapshot("task-2"), + provider.resyncClineMessagesToWebview("task-2"), + ]) + + expect(postSpy).not.toHaveBeenCalled() + }) + + test("logs a failed delta post and continues processing the queue", async () => { + const task = { taskId: "task-1", clineMessages: [] as ClineMessage[] } + setCurrentTask(task) + const failure = new Error("post failed") + const postSpy = vi + .spyOn(provider, "postMessageToWebview") + .mockRejectedValueOnce(failure) + .mockResolvedValue(undefined) + const logSpy = vi.spyOn(provider, "log") + const message = { ts: 1, type: "say", say: "text", text: "message" } as ClineMessage + + await expect(provider.postClineMessageAppended("task-1", message)).rejects.toThrow("post failed") + await provider.postClineMessageUpdated("task-1", { ...message, text: "recovered" }) + + expect(logSpy).toHaveBeenCalledWith("[clineMessages] transport failure: post failed") + expect(postSpy).toHaveBeenLastCalledWith( + expect.objectContaining({ type: "clineMessageUpdated", clineMessagesSeq: 2 }), + ) + }) + test("posts ordered snapshot chunks followed by the end marker", async () => { await provider.resolveWebviewView(mockWebviewView) const messages = Array.from({ length: 401 }, (_, index) => ({ @@ -911,36 +1028,136 @@ describe("ClineProvider", () => { expect(new Set(posts.map(({ snapshotId }) => snapshotId)).size).toBe(1) }) - test("invalidates a queued old-focus delta before it reaches the webview", async () => { - await provider.resolveWebviewView(mockWebviewView) + test.each([ + ["append", "clineMessageAppended"], + ["update", "clineMessageUpdated"], + ] as const)( + "invalidates a queued old-focus %s delta before it reaches the webview", + async (operation, messageType) => { + await provider.resolveWebviewView(mockWebviewView) + const task = { taskId: "task-1", clineMessages: [] as ClineMessage[] } + setCurrentTask(task) + mockPostMessage.mockClear() + + let releaseQueue!: () => void + Object.assign(provider, { + clineMessagesPostQueue: new Promise((resolve) => { + releaseQueue = resolve + }), + }) + const message = { + ts: 1, + type: "say", + say: "text", + text: "queued", + } as ClineMessage + const pendingDelta = + operation === "append" + ? provider.postClineMessageAppended("task-1", message) + : provider.postClineMessageUpdated("task-1", message) + + task.taskId = "task-2" + const focusSync = provider.syncFocusedTaskToWebview() + releaseQueue() + await Promise.all([pendingDelta, focusSync]) + + expect(mockPostMessage).not.toHaveBeenCalledWith( + expect.objectContaining({ type: messageType, taskId: "task-1" }), + ) + expect(mockPostMessage).toHaveBeenCalledWith( + expect.objectContaining({ type: "clineMessagesSnapshotStart", taskId: "task-2" }), + ) + }, + ) + + test("drops a snapshot invalidated before its first post", async () => { const task = { taskId: "task-1", clineMessages: [] as ClineMessage[] } setCurrentTask(task) - mockPostMessage.mockClear() - + const postSpy = vi.spyOn(provider, "postMessageToWebview") let releaseQueue!: () => void Object.assign(provider, { clineMessagesPostQueue: new Promise((resolve) => { releaseQueue = resolve }), }) - const pendingDelta = provider.postClineMessageAppended("task-1", { + + const snapshot = provider.postClineMessagesSnapshot("task-1") + task.taskId = "task-2" + releaseQueue() + await snapshot + + expect(postSpy).not.toHaveBeenCalled() + }) + + test.each([ + ["after the start marker", "clineMessagesSnapshotStart", ["clineMessagesSnapshotStart"]], + [ + "after a chunk", + "clineMessagesSnapshotChunk", + ["clineMessagesSnapshotStart", "clineMessagesSnapshotChunk"], + ], + ])("stops a snapshot %s when focus changes", async (_description, invalidateAfterType, expectedTypes) => { + const task = { + taskId: "task-1", + clineMessages: [{ ts: 1, type: "say", say: "text", text: "message" }] as ClineMessage[], + } + setCurrentTask(task) + const postedTypes: string[] = [] + vi.spyOn(provider, "postMessageToWebview").mockImplementation(async (message) => { + postedTypes.push(message.type) + if (message.type === invalidateAfterType) { + task.taskId = "task-2" + } + }) + + await provider.postClineMessagesSnapshot("task-1") + + expect(postedTypes).toEqual(expectedTypes) + }) + + test("resyncs the focused task with the current sequence", async () => { + const task = { taskId: "task-1", clineMessages: [] as ClineMessage[] } + setCurrentTask(task) + const postSpy = vi.spyOn(provider, "postMessageToWebview").mockResolvedValue(undefined) + + await provider.postClineMessageAppended("task-1", { ts: 1, type: "say", say: "text", - text: "queued", + text: "first", }) + postSpy.mockClear() + await provider.resyncClineMessagesToWebview("task-1") + + expect(postSpy).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ type: "clineMessagesSnapshotStart", taskId: "task-1", clineMessagesSeq: 1 }), + ) + }) + + test("abandons an older focus sync when a resync invalidates its state post", async () => { + const task = { taskId: "task-1", clineMessages: [] as ClineMessage[] } + setCurrentTask(task) + let releaseStatePost!: () => void + const statePostStarted = new Promise((resolve) => { + vi.spyOn(provider, "postStateToWebviewWithoutTaskHistory").mockImplementation( + () => + new Promise((release) => { + releaseStatePost = release + resolve() + }), + ) + }) + const snapshotSpy = vi.spyOn(provider, "postClineMessagesSnapshot") - task.taskId = "task-2" const focusSync = provider.syncFocusedTaskToWebview() - releaseQueue() - await Promise.all([pendingDelta, focusSync]) + await statePostStarted + const resync = provider.resyncClineMessagesToWebview("task-1") + releaseStatePost() + await Promise.all([focusSync, resync]) - expect(mockPostMessage).not.toHaveBeenCalledWith( - expect.objectContaining({ type: "clineMessageAppended", taskId: "task-1" }), - ) - expect(mockPostMessage).toHaveBeenCalledWith( - expect.objectContaining({ type: "clineMessagesSnapshotStart", taskId: "task-2" }), - ) + expect(snapshotSpy).toHaveBeenCalledOnce() + expect(snapshotSpy).toHaveBeenCalledWith("task-1", { generation: expect.any(Number) }) }) }) diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index 4b375115da..e7ad0de694 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -118,6 +118,7 @@ const mockClineProvider = { log: vi.fn(), postStateToWebview: vi.fn(), syncFocusedTaskToWebview: vi.fn().mockResolvedValue(undefined), + resyncClineMessagesToWebview: vi.fn().mockResolvedValue(undefined), resolveWebviewThemeFixtureProbe: vi.fn(), getCurrentTask: vi.fn(), getTaskWithId: vi.fn(), @@ -126,6 +127,24 @@ const mockClineProvider = { cwd: "/mock/workspace", } as unknown as ClineProvider +describe("webviewMessageHandler - transcript resync", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("delegates a task-scoped transcript resync to the provider", async () => { + await webviewMessageHandler(mockClineProvider, { + type: "requestClineMessagesResync", + taskId: "task-1", + expectedSeq: 4, + receivedSeq: 7, + }) + + expect(mockClineProvider.resyncClineMessagesToWebview).toHaveBeenCalledOnce() + expect(mockClineProvider.resyncClineMessagesToWebview).toHaveBeenCalledWith("task-1") + }) +}) + describe("webviewMessageHandler - theme fixture probes", () => { const originalProbeSetting = process.env.ROO_CODE_THEME_FIXTURE_PROBE const themeFixture = { diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index 9be84271b4..5c26627f6f 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -165,6 +165,8 @@ type ClineMessagesSnapshotBuffer = { messages: ClineMessage[] } +const CLINE_MESSAGES_RESYNC_TIMEOUT_MS = 5_000 + export const mergeExtensionState = (prevState: ExtensionState, newState: Partial) => { const { customModePrompts: prevCustomModePrompts, experiments: prevExperiments, ...prevRest } = prevState @@ -286,6 +288,7 @@ export const ExtensionStateContextProvider: React.FC<{ const clineMessagesRef = useRef(state.clineMessages) const activeSnapshotRef = useRef(null) const resyncPendingRef = useRef(false) + const resyncTimeoutRef = useRef(undefined) const [didHydrateState, setDidHydrateState] = useState(false) const [showWelcome, setShowWelcome] = useState(false) @@ -335,11 +338,23 @@ export const ExtensionStateContextProvider: React.FC<{ })) }, []) + const clearClineMessagesResync = useCallback(() => { + resyncPendingRef.current = false + if (resyncTimeoutRef.current !== undefined) { + window.clearTimeout(resyncTimeoutRef.current) + resyncTimeoutRef.current = undefined + } + }, []) + const requestClineMessagesResync = useCallback((receivedSeq?: number) => { if (resyncPendingRef.current) { return } resyncPendingRef.current = true + resyncTimeoutRef.current = window.setTimeout(() => { + resyncPendingRef.current = false + resyncTimeoutRef.current = undefined + }, CLINE_MESSAGES_RESYNC_TIMEOUT_MS) vscode.postMessage({ type: "requestClineMessagesResync", taskId: activeTaskIdRef.current, @@ -348,6 +363,14 @@ export const ExtensionStateContextProvider: React.FC<{ }) }, []) + const retryClineMessagesResync = useCallback( + (receivedSeq?: number) => { + clearClineMessagesResync() + requestClineMessagesResync(receivedSeq) + }, + [clearClineMessagesResync, requestClineMessagesResync], + ) + const applyClineMessagesDelta = useCallback( (message: ExtensionMessage, operation: "append" | "update") => { const seq = message.clineMessagesSeq @@ -368,7 +391,7 @@ export const ExtensionStateContextProvider: React.FC<{ return } activeSnapshotRef.current = null - requestClineMessagesResync(seq) + retryClineMessagesResync(seq) return } if (seq <= clineMessagesSeqRef.current) { @@ -400,7 +423,7 @@ export const ExtensionStateContextProvider: React.FC<{ clineMessagesSeq: seq, })) }, - [requestClineMessagesResync], + [requestClineMessagesResync, retryClineMessagesResync], ) const handleMessage = useCallback( @@ -421,7 +444,7 @@ export const ExtensionStateContextProvider: React.FC<{ clineMessagesSeqRef.current = 0 clineMessagesRef.current = [] activeSnapshotRef.current = null - resyncPendingRef.current = false + clearClineMessagesResync() } setState((prevState) => { const merged = mergeExtensionState(prevState, newState) @@ -496,7 +519,7 @@ export const ExtensionStateContextProvider: React.FC<{ const seq = message.clineMessagesSeq if (typeof seq !== "number" || !Number.isSafeInteger(seq) || seq < 0) { activeSnapshotRef.current = null - requestClineMessagesResync(typeof seq === "number" ? seq : undefined) + retryClineMessagesResync(typeof seq === "number" ? seq : undefined) break } if (seq < clineMessagesSeqRef.current) { @@ -506,7 +529,7 @@ export const ExtensionStateContextProvider: React.FC<{ const total = message.snapshotTotal if (!message.snapshotId || typeof total !== "number" || !Number.isSafeInteger(total) || total < 0) { activeSnapshotRef.current = null - requestClineMessagesResync(seq) + retryClineMessagesResync(seq) break } @@ -536,19 +559,19 @@ export const ExtensionStateContextProvider: React.FC<{ const snapshot = activeSnapshotRef.current if (typeof seq !== "number" || !Number.isSafeInteger(seq) || seq < 0) { activeSnapshotRef.current = null - requestClineMessagesResync(typeof seq === "number" ? seq : undefined) + retryClineMessagesResync(typeof seq === "number" ? seq : undefined) break } if (!snapshot) { if (seq > clineMessagesSeqRef.current) { - requestClineMessagesResync(seq) + retryClineMessagesResync(seq) } break } if (message.snapshotId !== snapshot.snapshotId || seq !== snapshot.seq) { if (seq > snapshot.seq) { activeSnapshotRef.current = null - requestClineMessagesResync(seq) + retryClineMessagesResync(seq) } break } @@ -564,7 +587,7 @@ export const ExtensionStateContextProvider: React.FC<{ snapshot.messages.length + chunk.length > snapshot.total ) { activeSnapshotRef.current = null - requestClineMessagesResync(seq) + retryClineMessagesResync(seq) break } @@ -580,30 +603,30 @@ export const ExtensionStateContextProvider: React.FC<{ const snapshot = activeSnapshotRef.current if (typeof seq !== "number" || !Number.isSafeInteger(seq) || seq < 0) { activeSnapshotRef.current = null - requestClineMessagesResync(typeof seq === "number" ? seq : undefined) + retryClineMessagesResync(typeof seq === "number" ? seq : undefined) break } if (!snapshot) { if (seq > clineMessagesSeqRef.current) { - requestClineMessagesResync(seq) + retryClineMessagesResync(seq) } break } if (message.snapshotId !== snapshot.snapshotId || seq !== snapshot.seq) { if (seq > snapshot.seq) { activeSnapshotRef.current = null - requestClineMessagesResync(seq) + retryClineMessagesResync(seq) } break } if (message.snapshotTotal !== snapshot.total || snapshot.messages.length !== snapshot.total) { activeSnapshotRef.current = null - requestClineMessagesResync(seq) + retryClineMessagesResync(seq) break } activeSnapshotRef.current = null - resyncPendingRef.current = false + clearClineMessagesResync() clineMessagesRef.current = snapshot.messages clineMessagesSeqRef.current = snapshot.seq setState((prevState) => ({ @@ -704,15 +727,22 @@ export const ExtensionStateContextProvider: React.FC<{ } } }, - [applyClineMessagesDelta, requestClineMessagesResync, setListApiConfigMeta], + [ + applyClineMessagesDelta, + clearClineMessagesResync, + requestClineMessagesResync, + retryClineMessagesResync, + setListApiConfigMeta, + ], ) useEffect(() => { window.addEventListener("message", handleMessage) return () => { window.removeEventListener("message", handleMessage) + clearClineMessagesResync() } - }, [handleMessage]) + }, [clearClineMessagesResync, handleMessage]) useEffect(() => { vscode.postMessage({ type: "webviewDidLaunch" }) diff --git a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx index edcc78405c..acdd3de42b 100644 --- a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx +++ b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx @@ -1,5 +1,5 @@ import { providerIdentifiers } from "@roo-code/types" -import { render, screen, act } from "@/utils/test-utils" +import { render, screen, act, appendClineMessage, hydrateExtensionState } from "@/utils/test-utils" import React from "react" import { @@ -420,6 +420,12 @@ describe("ExtensionStateContext", () => { describe("dedicated transcript transport", () => { const readTranscript = () => JSON.parse(screen.getByTestId("transcript-state").textContent!) + const renderTranscript = (initialState: Partial = {}) => + render( + + + , + ) it("reconstructs a snapshot and applies contiguous append and update deltas", () => { render( @@ -546,6 +552,404 @@ describe("ExtensionStateContext", () => { postMessage.mockRestore() } }) + + it("retires a failed resync and recovers from a replacement snapshot", () => { + const first = makeMessage(1, "first") + const recovered = makeMessage(2, "recovered") + const postMessage = vi.spyOn(vscode, "postMessage").mockImplementation(() => undefined) + try { + renderTranscript({ clineMessages: [first], clineMessagesSeq: 1 }) + postMessage.mockClear() + + act(() => { + dispatchExtensionMessage({ + type: "clineMessageAppended", + taskId: "task-1", + clineMessagesSeq: 3, + clineMessage: makeMessage(3, "gap"), + }) + dispatchExtensionMessage({ + type: "clineMessagesSnapshotStart", + taskId: "task-1", + clineMessagesSeq: 3, + snapshotId: "invalid-snapshot", + snapshotTotal: 2, + }) + dispatchExtensionMessage({ + type: "clineMessagesSnapshotChunk", + taskId: "task-1", + clineMessagesSeq: 3, + snapshotId: "invalid-snapshot", + snapshotStartIndex: 1, + clineMessages: [first], + }) + }) + + expect(postMessage).toHaveBeenCalledTimes(2) + expect(postMessage).toHaveBeenLastCalledWith({ + type: "requestClineMessagesResync", + taskId: "task-1", + expectedSeq: 2, + receivedSeq: 3, + }) + + act(() => { + dispatchExtensionMessage({ + type: "clineMessagesSnapshotStart", + taskId: "task-1", + clineMessagesSeq: 3, + snapshotId: "replacement-snapshot", + snapshotTotal: 2, + }) + dispatchExtensionMessage({ + type: "clineMessagesSnapshotChunk", + taskId: "task-1", + clineMessagesSeq: 3, + snapshotId: "replacement-snapshot", + snapshotStartIndex: 0, + clineMessages: [first, recovered], + }) + dispatchExtensionMessage({ + type: "clineMessagesSnapshotEnd", + taskId: "task-1", + clineMessagesSeq: 3, + snapshotId: "replacement-snapshot", + snapshotTotal: 2, + }) + dispatchExtensionMessage({ + type: "clineMessageAppended", + taskId: "task-1", + clineMessagesSeq: 4, + clineMessage: makeMessage(4, "after recovery"), + }) + }) + + expect(readTranscript()).toEqual({ + currentTaskId: "task-1", + clineMessages: [first, recovered, makeMessage(4, "after recovery")], + clineMessagesSeq: 4, + }) + } finally { + postMessage.mockRestore() + } + }) + + it("allows another resync when a response is lost", async () => { + vi.useFakeTimers() + const postMessage = vi.spyOn(vscode, "postMessage").mockImplementation(() => undefined) + try { + renderTranscript({ clineMessagesSeq: 1 }) + postMessage.mockClear() + + act(() => { + appendClineMessage(makeMessage(3, "gap"), 3, "task-1") + appendClineMessage(makeMessage(4, "suppressed while pending"), 4, "task-1") + }) + expect(postMessage).toHaveBeenCalledTimes(1) + + await act(async () => { + await vi.advanceTimersByTimeAsync(5_000) + }) + act(() => appendClineMessage(makeMessage(5, "retry"), 5, "task-1")) + + expect(postMessage).toHaveBeenCalledTimes(2) + expect(postMessage).toHaveBeenLastCalledWith( + expect.objectContaining({ + type: "requestClineMessagesResync", + expectedSeq: 2, + receivedSeq: 5, + }), + ) + } finally { + postMessage.mockRestore() + vi.useRealTimers() + } + }) + + it("rejects malformed deltas and updates to unknown messages", () => { + const first = makeMessage(1, "first") + const postMessage = vi.spyOn(vscode, "postMessage").mockImplementation(() => undefined) + try { + renderTranscript({ clineMessages: [first], clineMessagesSeq: 1 }) + postMessage.mockClear() + + act(() => { + dispatchExtensionMessage({ type: "clineMessageAppended", taskId: "task-1" }) + dispatchExtensionMessage({ + type: "clineMessagesSnapshotStart", + taskId: "task-1", + clineMessagesSeq: 1, + snapshotId: "same-sequence", + snapshotTotal: 1, + }) + dispatchExtensionMessage({ + type: "clineMessagesSnapshotChunk", + taskId: "task-1", + clineMessagesSeq: 1, + snapshotId: "same-sequence", + snapshotStartIndex: 0, + clineMessages: [first], + }) + dispatchExtensionMessage({ + type: "clineMessagesSnapshotEnd", + taskId: "task-1", + clineMessagesSeq: 1, + snapshotId: "same-sequence", + snapshotTotal: 1, + }) + dispatchExtensionMessage({ + type: "clineMessageUpdated", + taskId: "task-1", + clineMessagesSeq: 2, + clineMessage: makeMessage(99, "unknown"), + }) + }) + + expect(postMessage).toHaveBeenCalledTimes(2) + expect(postMessage).toHaveBeenLastCalledWith( + expect.objectContaining({ type: "requestClineMessagesResync", receivedSeq: 2 }), + ) + expect(readTranscript().clineMessages).toEqual([first]) + } finally { + postMessage.mockRestore() + } + }) + + it("ignores covered and stale deltas but restarts after a newer delta interleaves", () => { + const first = makeMessage(1, "first") + const postMessage = vi.spyOn(vscode, "postMessage").mockImplementation(() => undefined) + try { + renderTranscript({ clineMessages: [first], clineMessagesSeq: 1 }) + postMessage.mockClear() + + act(() => { + dispatchExtensionMessage({ + type: "clineMessagesSnapshotStart", + taskId: "task-1", + clineMessagesSeq: 4, + snapshotId: "in-flight", + snapshotTotal: 1, + }) + appendClineMessage(makeMessage(4, "already covered"), 4, "task-1") + appendClineMessage(makeMessage(5, "interleaved"), 5, "task-1") + appendClineMessage(makeMessage(1, "stale"), 1, "task-1") + }) + + expect(postMessage).toHaveBeenCalledTimes(1) + expect(postMessage).toHaveBeenCalledWith( + expect.objectContaining({ type: "requestClineMessagesResync", receivedSeq: 5 }), + ) + expect(readTranscript()).toEqual({ + currentTaskId: "task-1", + clineMessages: [first], + clineMessagesSeq: 1, + }) + } finally { + postMessage.mockRestore() + } + }) + + it("validates snapshot starts and ignores stale or duplicate starts", () => { + const postMessage = vi.spyOn(vscode, "postMessage").mockImplementation(() => undefined) + try { + renderTranscript({ clineMessagesSeq: 1 }) + postMessage.mockClear() + + act(() => { + dispatchExtensionMessage({ + type: "clineMessagesSnapshotStart", + taskId: "other-task", + clineMessagesSeq: 2, + snapshotId: "wrong-task", + snapshotTotal: 0, + }) + dispatchExtensionMessage({ + type: "clineMessagesSnapshotStart", + taskId: "task-1", + clineMessagesSeq: -1, + snapshotId: "invalid-sequence", + snapshotTotal: 0, + }) + dispatchExtensionMessage({ + type: "clineMessagesSnapshotStart", + taskId: "task-1", + clineMessagesSeq: 1, + snapshotId: "stale", + snapshotTotal: 0, + }) + dispatchExtensionMessage({ + type: "clineMessagesSnapshotStart", + taskId: "task-1", + clineMessagesSeq: 4, + snapshotId: "newest", + snapshotTotal: 0, + }) + dispatchExtensionMessage({ + type: "clineMessagesSnapshotStart", + taskId: "task-1", + clineMessagesSeq: 4, + snapshotId: "newest", + snapshotTotal: 0, + }) + dispatchExtensionMessage({ + type: "clineMessagesSnapshotStart", + taskId: "task-1", + clineMessagesSeq: 3, + snapshotId: "older-active", + snapshotTotal: 0, + }) + dispatchExtensionMessage({ + type: "clineMessagesSnapshotStart", + taskId: "task-1", + clineMessagesSeq: 5, + snapshotId: "", + snapshotTotal: -1, + }) + }) + + expect(postMessage).toHaveBeenCalledTimes(2) + expect(postMessage.mock.calls.map(([message]) => message.receivedSeq)).toEqual([-1, 5]) + } finally { + postMessage.mockRestore() + } + }) + + it("rejects missing, mismatched, and incomplete snapshot chunks and endings", () => { + const postMessage = vi.spyOn(vscode, "postMessage").mockImplementation(() => undefined) + try { + renderTranscript({ clineMessagesSeq: 1 }) + postMessage.mockClear() + + act(() => { + dispatchExtensionMessage({ + type: "clineMessagesSnapshotChunk", + taskId: "other-task", + clineMessagesSeq: 2, + snapshotId: "ignored", + snapshotStartIndex: 0, + clineMessages: [makeMessage(1, "ignored")], + }) + dispatchExtensionMessage({ + type: "clineMessagesSnapshotChunk", + taskId: "task-1", + clineMessagesSeq: 2, + snapshotId: "missing-start", + snapshotStartIndex: 0, + clineMessages: [makeMessage(1, "missing")], + }) + dispatchExtensionMessage({ + type: "clineMessagesSnapshotStart", + taskId: "task-1", + clineMessagesSeq: 3, + snapshotId: "chunk-check", + snapshotTotal: 1, + }) + dispatchExtensionMessage({ + type: "clineMessagesSnapshotChunk", + taskId: "task-1", + clineMessagesSeq: 4, + snapshotId: "newer-mismatch", + snapshotStartIndex: 0, + clineMessages: [makeMessage(1, "mismatch")], + }) + dispatchExtensionMessage({ + type: "clineMessagesSnapshotStart", + taskId: "task-1", + clineMessagesSeq: 5, + snapshotId: "bad-chunk", + snapshotTotal: 1, + }) + dispatchExtensionMessage({ + type: "clineMessagesSnapshotChunk", + taskId: "task-1", + clineMessagesSeq: 5, + snapshotId: "bad-chunk", + snapshotStartIndex: 1, + clineMessages: [makeMessage(1, "bad index")], + }) + dispatchExtensionMessage({ + type: "clineMessagesSnapshotEnd", + taskId: "other-task", + clineMessagesSeq: 6, + snapshotId: "ignored-end", + snapshotTotal: 0, + }) + dispatchExtensionMessage({ + type: "clineMessagesSnapshotEnd", + taskId: "task-1", + clineMessagesSeq: 6, + snapshotId: "missing-end-start", + snapshotTotal: 0, + }) + dispatchExtensionMessage({ + type: "clineMessagesSnapshotStart", + taskId: "task-1", + clineMessagesSeq: 7, + snapshotId: "incomplete", + snapshotTotal: 1, + }) + dispatchExtensionMessage({ + type: "clineMessagesSnapshotEnd", + taskId: "task-1", + clineMessagesSeq: 7, + snapshotId: "incomplete", + snapshotTotal: 1, + }) + }) + + expect(postMessage).toHaveBeenCalledTimes(5) + expect(readTranscript()).toEqual({ currentTaskId: "task-1", clineMessages: [], clineMessagesSeq: 1 }) + } finally { + postMessage.mockRestore() + } + }) + + it("requests recovery for legacy unsequenced updates", () => { + const postMessage = vi.spyOn(vscode, "postMessage").mockImplementation(() => undefined) + try { + renderTranscript({ clineMessagesSeq: 2 }) + postMessage.mockClear() + + act(() => dispatchExtensionMessage({ type: "messageUpdated", clineMessagesSeq: 9 })) + + expect(postMessage).toHaveBeenCalledWith({ + type: "requestClineMessagesResync", + taskId: "task-1", + expectedSeq: 3, + receivedSeq: 9, + }) + } finally { + postMessage.mockRestore() + } + }) + + it("hydrates metadata, non-empty transcripts, and empty transcripts through shared helpers", () => { + renderTranscript({ clineMessages: [makeMessage(1, "existing")], clineMessagesSeq: 1 }) + + act(() => { + hydrateExtensionState({ version: "2.0.0" }) + }) + expect(readTranscript().clineMessages).toEqual([makeMessage(1, "existing")]) + + act(() => { + hydrateExtensionState({ + currentTaskId: "task-1", + clineMessages: [makeMessage(2, "hydrated")], + clineMessagesSeq: 4, + }) + appendClineMessage(makeMessage(3, "appended"), 5, "task-1") + }) + expect(readTranscript()).toEqual({ + currentTaskId: "task-1", + clineMessages: [makeMessage(2, "hydrated"), makeMessage(3, "appended")], + clineMessagesSeq: 5, + }) + + act(() => { + hydrateExtensionState({ clineMessages: [] }, { taskId: "task-1", clineMessagesSeq: 6 }) + }) + expect(readTranscript()).toEqual({ currentTaskId: "task-1", clineMessages: [], clineMessagesSeq: 6 }) + }) }) }) From 861c6be755ce39182abc41f3146346e944060141 Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Sun, 23 Aug 2026 21:14:54 -0600 Subject: [PATCH 11/38] 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 4f2c293235..b96f737415 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -623,6 +623,7 @@ export class ClineProvider } if (task) { + this.clineMessagesSeqByTaskId.delete(task.taskId) task.emit(RooCodeEventName.TaskUnfocused) try { @@ -2542,6 +2543,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 @@ -2584,6 +2588,7 @@ export class ClineProvider async deleteTaskFromState(id: string) { await this.taskHistoryStore.delete(id) + this.clineMessagesSeqByTaskId.delete(id) this.recentTasksCache = undefined await this.postStateToWebview() diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index be1320e8c1..391465ffac 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -1135,6 +1135,27 @@ describe("ClineProvider", () => { ) }) + test("prunes sequence state when a task leaves the stack", async () => { + const task = new Task(defaultTaskOptions) + Object.defineProperty(task, "taskId", { value: "task-to-remove", writable: true }) + await provider.addClineToStack(task) + provider["clineMessagesSeqByTaskId"].set(task.taskId, 4) + + await provider.removeClineFromStack() + + expect(provider["clineMessagesSeqByTaskId"].has(task.taskId)).toBe(false) + }) + + test("prunes sequence state when a task is deleted from history", async () => { + provider["clineMessagesSeqByTaskId"].set("deleted-task", 4) + vi.spyOn(provider.taskHistoryStore, "delete").mockResolvedValue(undefined) + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + + await provider.deleteTaskFromState("deleted-task") + + expect(provider["clineMessagesSeqByTaskId"].has("deleted-task")).toBe(false) + }) + test("abandons an older focus sync when a resync invalidates its state post", async () => { const task = { taskId: "task-1", clineMessages: [] as ClineMessage[] } setCurrentTask(task) diff --git a/src/core/webview/__tests__/webviewMessageHandler.delete.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.delete.spec.ts index ef2bee3f6d..41534ff837 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.delete.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.delete.spec.ts @@ -247,6 +247,34 @@ describe("webviewMessageHandler delete functionality", () => { ]) }) + it("publishes restored checkpoint metadata after deleting messages", async () => { + const checkpoint = { hash: "checkpoint-hash", type: "user_message" } + const preservedMessage = { ts: 1000, say: "user", text: "First message", checkpoint } + getCurrentTaskMock.clineMessages = [preservedMessage, { ts: 2000, say: "user", text: "Delete this" }] + getCurrentTaskMock.apiConversationHistory = [ + { ts: 1000, role: "user", content: { type: "text", text: "First message" } }, + { ts: 2000, role: "user", content: { type: "text", text: "Delete this" } }, + ] + getCurrentTaskMock.overwriteClineMessages.mockImplementation( + async (messages: (typeof preservedMessage)[]) => { + getCurrentTaskMock.clineMessages = structuredClone(messages).map((message) => { + const { checkpoint: _checkpoint, ...withoutCheckpoint } = message + return withoutCheckpoint + }) + }, + ) + + await webviewMessageHandler(provider, { + type: "deleteMessageConfirm", + messageTs: 2000, + }) + + expect(getCurrentTaskMock.overwriteClineMessages).toHaveBeenCalledTimes(2) + expect(getCurrentTaskMock.overwriteClineMessages).toHaveBeenLastCalledWith([ + expect.objectContaining({ ts: 1000, checkpoint }), + ]) + }) + describe("condense preservation behavior", () => { it("should preserve summary and condensed messages when deleting after the summary", async () => { // Design: Rewind/delete preserves summaries that were created BEFORE the rewind point. diff --git a/src/core/webview/__tests__/webviewMessageHandler.edit.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.edit.spec.ts index 523f03e1c2..422f830eff 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.edit.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.edit.spec.ts @@ -214,6 +214,43 @@ describe("webviewMessageHandler - Edit Message with Timestamp Fallback", () => { ]) }) + it("publishes restored checkpoint metadata before submitting an edited message", async () => { + const checkpoint = { hash: "checkpoint-hash", type: "user_message" } + const preservedMessage = { + ts: 500, + type: "say", + say: "user_feedback", + text: "Earlier message", + checkpoint, + } as ClineMessage + mockCurrentTask.clineMessages = [ + preservedMessage, + { ts: 1000, type: "say", say: "user_feedback", text: "Edit me" } as ClineMessage, + ] + mockCurrentTask.apiConversationHistory = [ + { ts: 500, role: "user", content: [{ type: "text", text: "Earlier message" }] }, + { ts: 1000, role: "user", content: [{ type: "text", text: "Edit me" }] }, + ] as ApiMessage[] + mockCurrentTask.overwriteClineMessages.mockImplementation(async (messages: ClineMessage[]) => { + mockCurrentTask.clineMessages = structuredClone(messages).map((message) => { + const { checkpoint: _checkpoint, ...withoutCheckpoint } = message + return withoutCheckpoint + }) + }) + + await webviewMessageHandler(mockClineProvider, { + type: "editMessageConfirm", + messageTs: 1000, + text: "Edited message", + restoreCheckpoint: false, + }) + + expect(mockCurrentTask.overwriteClineMessages).toHaveBeenCalledTimes(2) + expect(mockCurrentTask.overwriteClineMessages).toHaveBeenLastCalledWith([ + expect.objectContaining({ ts: 500, checkpoint }), + ]) + }) + it("should not use fallback when exact apiConversationHistoryIndex is found", async () => { const userMessageTs = 1000 const assistantMessageTs = 2000 diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index ff4c8ed691..4941fe1b29 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -369,8 +369,9 @@ export const webviewMessageHandler = async ( globalStoragePath: provider.contextProxy.globalStorageUri.fsPath, }) - // Rewind already posts a snapshot. Checkpoint metadata is not rendered - // in transcript rows, so persisting it does not require a second snapshot. + // Rewind posts before checkpoint metadata is restored. Publish the + // persisted transcript so checkpoint filtering and controls stay current. + await currentCline.overwriteClineMessages(currentCline.clineMessages) } } catch (error) { console.error("Error in delete message:", error) @@ -539,6 +540,9 @@ export const webviewMessageHandler = async ( globalStoragePath: provider.contextProxy.globalStorageUri.fsPath, }) + // Rewind posts before checkpoint metadata is restored. Publish that + // restored state before the edited message starts a new delta stream. + await currentCline.overwriteClineMessages(currentCline.clineMessages) await currentCline.submitUserMessage(editedContent, images) } catch (error) { console.error("Error in edit message:", error) From 27be21ee800f484c9bf1b2f5f7e22f5593a00b7c Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Sun, 23 Aug 2026 21:24:03 -0600 Subject: [PATCH 12/38] 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 d77316e6ea..2ad4257348 100644 --- a/src/__tests__/helpers/provider-stub.ts +++ b/src/__tests__/helpers/provider-stub.ts @@ -4,6 +4,7 @@ import { type Task } from "../../core/task/Task" type ProviderStubFields = { cancelledDelegationChildIds?: Set + clineMessagesSeqByTaskId?: Map log?: ReturnType syncFocusedTaskToWebview?: ReturnType taskHistoryStore?: { get: (id: string) => unknown; invalidate?: (id: string) => Promise } @@ -36,6 +37,7 @@ export function makeProviderStub(stub: T): ClineProvider { const s = stub as T & ProviderStubFields const proto = ClineProvider.prototype as unknown as PrivateProviderMethods s.cancelledDelegationChildIds ??= new Set() + s.clineMessagesSeqByTaskId ??= new Map() s.log ??= vi.fn() s.syncFocusedTaskToWebview ??= vi.fn().mockResolvedValue(undefined) s.taskHistoryStore ??= { get: () => undefined } From 6c26083c807d011b5d8a00acff74927b39c629cd Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Sun, 23 Aug 2026 21:39:46 -0600 Subject: [PATCH 13/38] 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 736433aef276c9f5f8b11aad94c54d48582351e3 Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Sun, 23 Aug 2026 21:59:24 -0600 Subject: [PATCH 14/38] 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 7b6cb9895f3e8b34f12779dd2cdaf010c35d43ba Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Sun, 23 Aug 2026 23:38:12 -0600 Subject: [PATCH 15/38] 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 b96f737415..af1648ff75 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2945,7 +2945,7 @@ export class ClineProvider autoCondenseContext: autoCondenseContext ?? true, autoCondenseContextPercent: autoCondenseContextPercent ?? 100, uriScheme: vscode.env.uriScheme, - currentTaskId: currentTask?.taskId, + currentTaskId: currentTask?.taskId ?? null, currentTaskItem: currentTask?.taskId ? this.taskHistoryStore.get(currentTask.taskId) : undefined, clineMessages: currentTask?.clineMessages || [], currentTaskTodos: currentTask?.todoList || [], diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 391465ffac..61c03a879d 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -1298,6 +1298,19 @@ describe("ClineProvider", () => { expect(state.taskHistory).toEqual([historyItem]) }) + test("eviction synchronizes an authoritative no-task identity that survives serialization", async () => { + const task = new Task(defaultTaskOptions) + await provider.addClineToStack(task) + const postMessageSpy = vi.spyOn(provider, "postMessageToWebview").mockResolvedValue(undefined) + + await provider.evictCurrentTask() + + const stateMessage = postMessageSpy.mock.calls.map(([message]) => message).find(({ type }) => type === "state") + const roundTrippedState = JSON.parse(JSON.stringify(stateMessage?.state)) as Partial + expect(stateMessage?.state?.currentTaskId).toBeNull() + expect(roundTrippedState).toHaveProperty("currentTaskId", null) + }) + describe("postStateToWebviewThrottled", () => { beforeEach(() => { vi.useFakeTimers() diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index 5c26627f6f..cd39ce7e61 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -283,7 +283,7 @@ export const ExtensionStateContextProvider: React.FC<{ const [state, setState] = useState(() => mergeExtensionState(createInitialExtensionState(), initialState ?? {}), ) - const activeTaskIdRef = useRef(state.currentTaskId) + const activeTaskIdRef = useRef(state.currentTaskId ?? undefined) const clineMessagesSeqRef = useRef(state.clineMessagesSeq ?? 0) const clineMessagesRef = useRef(state.clineMessages) const activeSnapshotRef = useRef(null) @@ -437,9 +437,12 @@ export const ExtensionStateContextProvider: React.FC<{ ...newState } = message.state ?? {} const hasCurrentTaskId = Object.prototype.hasOwnProperty.call(newState, "currentTaskId") - const nextTaskId = hasCurrentTaskId ? newState.currentTaskId : activeTaskIdRef.current + const nextTaskId = hasCurrentTaskId + ? (newState.currentTaskId ?? undefined) + : activeTaskIdRef.current const taskChanged = hasCurrentTaskId && nextTaskId !== activeTaskIdRef.current - if (taskChanged) { + const taskCleared = hasCurrentTaskId && newState.currentTaskId === null + if (taskChanged || taskCleared) { activeTaskIdRef.current = nextTaskId clineMessagesSeqRef.current = 0 clineMessagesRef.current = [] @@ -448,8 +451,22 @@ export const ExtensionStateContextProvider: React.FC<{ } setState((prevState) => { const merged = mergeExtensionState(prevState, newState) + if (taskCleared) { + return { + ...merged, + currentTaskId: null, + currentTaskItem: undefined, + currentTaskTodos: [], + messageQueue: [], + clineMessages: [], + clineMessagesSeq: 0, + } + } return taskChanged ? { ...merged, clineMessages: [], clineMessagesSeq: 0 } : merged }) + if (taskCleared) { + setCurrentCheckpoint(undefined) + } setShowWelcome(!checkExistKey(newState.apiConfiguration, newState.zooCodeIsAuthenticated)) setDidHydrateState(true) // Update alwaysAllowFollowupQuestions if present in state message diff --git a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx index acdd3de42b..c74e799e24 100644 --- a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx +++ b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx @@ -114,11 +114,27 @@ const InitialStateTestComponent = () => { } const TranscriptTestComponent = () => { - const { currentTaskId, clineMessages, clineMessagesSeq } = useExtensionState() + const { + currentTaskId, + currentTaskItem, + currentTaskTodos, + messageQueue, + currentCheckpoint, + clineMessages, + clineMessagesSeq, + } = useExtensionState() return (
- {JSON.stringify({ currentTaskId, clineMessages, clineMessagesSeq: clineMessagesSeq ?? 0 })} + {JSON.stringify({ + currentTaskId: currentTaskId ?? null, + currentTaskItem: currentTaskItem ?? null, + currentTaskTodos: currentTaskTodos ?? [], + messageQueue: messageQueue ?? [], + currentCheckpoint: currentCheckpoint ?? null, + clineMessages, + clineMessagesSeq: clineMessagesSeq ?? 0, + })}
) } @@ -420,6 +436,10 @@ describe("ExtensionStateContext", () => { describe("dedicated transcript transport", () => { const readTranscript = () => JSON.parse(screen.getByTestId("transcript-state").textContent!) + const readTranscriptFields = () => { + const { currentTaskId, clineMessages, clineMessagesSeq } = readTranscript() + return { currentTaskId, clineMessages, clineMessagesSeq } + } const renderTranscript = (initialState: Partial = {}) => render( @@ -473,7 +493,7 @@ describe("ExtensionStateContext", () => { }) }) - expect(readTranscript()).toEqual({ + expect(readTranscriptFields()).toEqual({ currentTaskId: "task-1", clineMessages: [first, { ...second, text: "updated" }], clineMessagesSeq: 6, @@ -508,7 +528,92 @@ describe("ExtensionStateContext", () => { }) }) - expect(readTranscript()).toEqual({ currentTaskId: "task-2", clineMessages: [], clineMessagesSeq: 0 }) + expect(readTranscriptFields()).toEqual({ currentTaskId: "task-2", clineMessages: [], clineMessagesSeq: 0 }) + }) + + it("clears task-scoped state for a JSON-round-tripped authoritative no-task transition", () => { + const existing = makeMessage(1, "existing") + const currentTaskItem = { + id: "task-1", + number: 1, + ts: 1, + task: "Existing task", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + } + renderTranscript({ + clineMessages: [existing], + clineMessagesSeq: 3, + currentTaskItem, + currentTaskTodos: [{ id: "todo-1", content: "Existing todo", status: "in_progress" }], + messageQueue: [{ id: "queued-1", timestamp: 1, text: "Queued message" }], + }) + + act(() => { + dispatchExtensionMessage({ type: "currentCheckpointUpdated", text: "checkpoint-1" }) + const clearState = JSON.parse(JSON.stringify({ currentTaskId: null })) as Partial + dispatchExtensionMessage({ type: "state", state: clearState }) + dispatchExtensionMessage({ + type: "clineMessagesSnapshotStart", + clineMessagesSeq: 0, + snapshotId: "no-task-snapshot", + snapshotTotal: 0, + }) + dispatchExtensionMessage({ + type: "clineMessagesSnapshotEnd", + clineMessagesSeq: 0, + snapshotId: "no-task-snapshot", + snapshotTotal: 0, + }) + }) + + expect(readTranscript()).toEqual({ + currentTaskId: null, + currentTaskItem: null, + currentTaskTodos: [], + messageQueue: [], + currentCheckpoint: null, + clineMessages: [], + clineMessagesSeq: 0, + }) + }) + + it("preserves task-scoped state when a partial state update omits currentTaskId", () => { + const existing = makeMessage(1, "existing") + const currentTaskItem = { + id: "task-1", + number: 1, + ts: 1, + task: "Existing task", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + } + const currentTaskTodos = [{ id: "todo-1", content: "Existing todo", status: "pending" as const }] + const messageQueue = [{ id: "queued-1", timestamp: 1, text: "Queued message" }] + renderTranscript({ + clineMessages: [existing], + clineMessagesSeq: 3, + currentTaskItem, + currentTaskTodos, + messageQueue, + }) + + act(() => { + dispatchExtensionMessage({ type: "currentCheckpointUpdated", text: "checkpoint-1" }) + dispatchExtensionMessage({ type: "state", state: { version: "2.0.0" } }) + }) + + expect(readTranscript()).toEqual({ + currentTaskId: "task-1", + currentTaskItem, + currentTaskTodos, + messageQueue, + currentCheckpoint: "checkpoint-1", + clineMessages: [existing], + clineMessagesSeq: 3, + }) }) it("requests one resync when a delta sequence has a gap", () => { @@ -624,7 +729,7 @@ describe("ExtensionStateContext", () => { }) }) - expect(readTranscript()).toEqual({ + expect(readTranscriptFields()).toEqual({ currentTaskId: "task-1", clineMessages: [first, recovered, makeMessage(4, "after recovery")], clineMessagesSeq: 4, @@ -739,7 +844,7 @@ describe("ExtensionStateContext", () => { expect(postMessage).toHaveBeenCalledWith( expect.objectContaining({ type: "requestClineMessagesResync", receivedSeq: 5 }), ) - expect(readTranscript()).toEqual({ + expect(readTranscriptFields()).toEqual({ currentTaskId: "task-1", clineMessages: [first], clineMessagesSeq: 1, @@ -898,7 +1003,11 @@ describe("ExtensionStateContext", () => { }) expect(postMessage).toHaveBeenCalledTimes(5) - expect(readTranscript()).toEqual({ currentTaskId: "task-1", clineMessages: [], clineMessagesSeq: 1 }) + expect(readTranscriptFields()).toEqual({ + currentTaskId: "task-1", + clineMessages: [], + clineMessagesSeq: 1, + }) } finally { postMessage.mockRestore() } @@ -939,7 +1048,7 @@ describe("ExtensionStateContext", () => { }) appendClineMessage(makeMessage(3, "appended"), 5, "task-1") }) - expect(readTranscript()).toEqual({ + expect(readTranscriptFields()).toEqual({ currentTaskId: "task-1", clineMessages: [makeMessage(2, "hydrated"), makeMessage(3, "appended")], clineMessagesSeq: 5, @@ -948,7 +1057,7 @@ describe("ExtensionStateContext", () => { act(() => { hydrateExtensionState({ clineMessages: [] }, { taskId: "task-1", clineMessagesSeq: 6 }) }) - expect(readTranscript()).toEqual({ currentTaskId: "task-1", clineMessages: [], clineMessagesSeq: 6 }) + expect(readTranscriptFields()).toEqual({ currentTaskId: "task-1", clineMessages: [], clineMessagesSeq: 6 }) }) }) }) diff --git a/webview-ui/src/utils/test-utils.tsx b/webview-ui/src/utils/test-utils.tsx index 305f962ba2..617e18a1ea 100644 --- a/webview-ui/src/utils/test-utils.tsx +++ b/webview-ui/src/utils/test-utils.tsx @@ -48,7 +48,7 @@ export const hydrateExtensionState = ( options: { taskId?: string; clineMessagesSeq?: number } = {}, ) => { const { clineMessages, clineMessagesSeq: stateSeq, ...metadataState } = state - const taskId = options.taskId ?? metadataState.currentTaskId + const taskId = options.taskId ?? metadataState.currentTaskId ?? undefined const clineMessagesSeq = options.clineMessagesSeq ?? stateSeq ?? 0 dispatchExtensionMessage({ From 540c3dba1be8fccd08047b76570ad791c9bfa7d5 Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Wed, 26 Aug 2026 20:42:33 -0600 Subject: [PATCH 16/38] fix: address transcript streaming review feedback --- src/core/task/Task.ts | 25 +++- src/core/task/__tests__/Task.spec.ts | 110 +++++++++++++++++- src/core/webview/ClineProvider.ts | 2 +- .../webview/__tests__/ClineProvider.spec.ts | 23 ++-- .../__tests__/ExtensionStateContext.spec.tsx | 30 +++++ 5 files changed, 179 insertions(+), 11 deletions(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index ef0842fa94..b5677358ca 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -172,6 +172,7 @@ function queuedResponseForAsk(type: ClineAsk, text?: string): QueuedAskResolutio const FORCED_CONTEXT_REDUCTION_PERCENT = 75 // Keep 75% of context (remove 25%) on context window errors const MAX_CONTEXT_WINDOW_RETRIES = 3 // Maximum retries for context window errors +const PARTIAL_MESSAGE_UPDATE_DEBOUNCE_MS = 500 export interface TaskOptions extends CreateTaskOptions { provider: ClineProvider @@ -495,6 +496,7 @@ export class Task extends EventEmitter implements TaskLike { // Token Usage Throttling - Debounced emit function private readonly TOKEN_USAGE_EMIT_INTERVAL_MS = 2000 // 2 seconds private debouncedEmitTokenUsage: ReturnType + private debouncedPostPartialMessageUpdate: ReturnType // Historical cloud sync tracking retained only to avoid task resume churn. private cloudSyncedMessageTimestamps: Set = new Set() @@ -666,6 +668,20 @@ export class Task extends EventEmitter implements TaskLike { this.TOKEN_USAGE_EMIT_INTERVAL_MS, { leading: true, trailing: true, maxWait: this.TOKEN_USAGE_EMIT_INTERVAL_MS }, ) + this.debouncedPostPartialMessageUpdate = debounce( + (message: ClineMessage) => { + const provider = this.providerRef.deref() + if (!provider) { + return + } + + void provider.postClineMessageUpdated(this.taskId, message).catch((error) => { + console.error("[Task#updateClineMessage] incremental post failed:", error) + }) + }, + PARTIAL_MESSAGE_UPDATE_DEBOUNCE_MS, + { leading: false, trailing: true }, + ) onCreated?.(this) @@ -1327,8 +1343,12 @@ export class Task extends EventEmitter implements TaskLike { * Non-partial messages are synced to cloud telemetry if not already synced. */ private async updateClineMessage(message: ClineMessage) { - const provider = this.providerRef.deref() - await provider?.postClineMessageUpdated(this.taskId, message) + if (message.partial === true) { + this.debouncedPostPartialMessageUpdate(message) + } else { + this.debouncedPostPartialMessageUpdate.cancel() + await this.providerRef.deref()?.postClineMessageUpdated(this.taskId, message) + } this.emit(RooCodeEventName.Message, { action: "updated", message }) // Check if we should sync to cloud and haven't already synced this message @@ -2689,6 +2709,7 @@ export class Task extends EventEmitter implements TaskLike { private async disposeOnce(): Promise { console.log(`[Task#dispose] disposing task ${this.taskId}.${this.instanceId}`) this.cancelAssistantMessagePersistence() + this.debouncedPostPartialMessageUpdate.cancel() // Stop the idle telemetry check and report any unflushed activity as a // shutdown installment, so a task torn down mid-work (panel closed, task diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index a8275bb481..54dec890a4 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -2171,6 +2171,10 @@ describe("Cline", () => { }) describe("webview transcript transport", () => { + afterEach(() => { + vi.useRealTimers() + }) + it("posts a bumped snapshot after overwriting the transcript", async () => { const task = new Task({ provider: mockProvider, @@ -2308,7 +2312,8 @@ describe("Cline", () => { expect(mockProvider.postClineMessageAppended).toHaveBeenCalledWith(task.taskId, message) }) - it("serializes a new partial message before its following update", async () => { + it("serializes a new partial message before its debounced following update", async () => { + vi.useFakeTimers() const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, @@ -2342,6 +2347,7 @@ describe("Cline", () => { expect(updatePostSpy).not.toHaveBeenCalled() releaseAppend() + await vi.advanceTimersByTimeAsync(500) await addThenUpdate expect(appendSpy.mock.invocationCallOrder[0]).toBeLessThan(updatePostSpy.mock.invocationCallOrder[0]) @@ -2350,6 +2356,108 @@ describe("Cline", () => { text: "updated partial", }) }) + + it("debounces partial updates and posts the latest revision on the trailing edge", async () => { + vi.useFakeTimers() + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + const taskAccess = getTaskTestAccess(task) + const updatePostSpy = vi.mocked(mockProvider.postClineMessageUpdated) + + void taskAccess.updateClineMessage({ + ts: 1, + type: "say", + say: "text", + text: "first partial", + partial: true, + }) + await vi.advanceTimersByTimeAsync(250) + void taskAccess.updateClineMessage({ + ts: 1, + type: "say", + say: "text", + text: "latest partial", + partial: true, + }) + + await vi.advanceTimersByTimeAsync(499) + expect(updatePostSpy).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(1) + expect(updatePostSpy).toHaveBeenCalledOnce() + expect(updatePostSpy).toHaveBeenCalledWith( + task.taskId, + expect.objectContaining({ text: "latest partial", partial: true }), + ) + }) + + it.each([ + ["false", { ts: 1, type: "say" as const, say: "text" as const, text: "complete", partial: false }], + ["absent", { ts: 1, type: "say" as const, say: "text" as const, text: "complete" }], + ])( + "cancels a pending partial update and posts completion immediately when partial is %s", + async (_case, complete) => { + vi.useFakeTimers() + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + const taskAccess = getTaskTestAccess(task) + const updatePostSpy = vi.mocked(mockProvider.postClineMessageUpdated) + + void taskAccess.updateClineMessage({ + ts: 1, + type: "say", + say: "text", + text: "partial", + partial: true, + }) + await taskAccess.updateClineMessage(complete) + + expect(updatePostSpy).toHaveBeenCalledOnce() + expect(updatePostSpy).toHaveBeenCalledWith(task.taskId, complete) + + await vi.advanceTimersByTimeAsync(500) + expect(updatePostSpy).toHaveBeenCalledOnce() + }, + ) + + it("handles a rejected debounced partial update", async () => { + vi.useFakeTimers() + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + const postError = new Error("incremental update failed") + vi.mocked(mockProvider.postClineMessageUpdated).mockRejectedValueOnce(postError) + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + + try { + void getTaskTestAccess(task).updateClineMessage({ + ts: 1, + type: "say", + say: "text", + text: "partial", + partial: true, + }) + await vi.advanceTimersByTimeAsync(500) + + expect(consoleErrorSpy).toHaveBeenCalledWith( + "[Task#updateClineMessage] incremental post failed:", + postError, + ) + } finally { + consoleErrorSpy.mockRestore() + } + }) }) describe("abortTask", () => { diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index af1648ff75..415ba63af4 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -1585,7 +1585,6 @@ export class ClineProvider ? this.bumpClineMessagesSeq(taskId) : this.getClineMessagesSeq(taskId) : 0 - const messages = structuredClone(currentTask?.clineMessages ?? []) const snapshotId = `${taskId ?? "none"}:${++this.nextClineMessagesSnapshotId}` const generation = options.generation ?? this.clineMessagesTransportGeneration @@ -1596,6 +1595,7 @@ export class ClineProvider if (!isCurrent()) { return } + const messages = structuredClone(currentTask?.clineMessages ?? []) await this.postMessageToWebview({ type: "clineMessagesSnapshotStart", diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 61c03a879d..66525d7014 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -1070,8 +1070,11 @@ describe("ClineProvider", () => { }, ) - test("drops a snapshot invalidated before its first post", async () => { - const task = { taskId: "task-1", clineMessages: [] as ClineMessage[] } + test("drops a snapshot invalidated before its first post without cloning it", async () => { + const task = { + taskId: "task-1", + clineMessages: [{ ts: 1, type: "say", say: "text", text: "message" }] as ClineMessage[], + } setCurrentTask(task) const postSpy = vi.spyOn(provider, "postMessageToWebview") let releaseQueue!: () => void @@ -1081,12 +1084,18 @@ describe("ClineProvider", () => { }), }) - const snapshot = provider.postClineMessagesSnapshot("task-1") - task.taskId = "task-2" - releaseQueue() - await snapshot + const structuredCloneSpy = vi.spyOn(globalThis, "structuredClone") + try { + const snapshot = provider.postClineMessagesSnapshot("task-1") + task.taskId = "task-2" + releaseQueue() + await snapshot - expect(postSpy).not.toHaveBeenCalled() + expect(postSpy).not.toHaveBeenCalled() + expect(structuredCloneSpy).not.toHaveBeenCalled() + } finally { + structuredCloneSpy.mockRestore() + } }) test.each([ diff --git a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx index c74e799e24..dc4cf78e77 100644 --- a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx +++ b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx @@ -1013,6 +1013,36 @@ describe("ExtensionStateContext", () => { } }) + it("keeps the prior transcript when a snapshot end is dropped", () => { + const existing = makeMessage(1, "existing") + const replacement = makeMessage(2, "replacement") + renderTranscript({ clineMessages: [existing], clineMessagesSeq: 1 }) + + act(() => { + dispatchExtensionMessage({ + type: "clineMessagesSnapshotStart", + taskId: "task-1", + clineMessagesSeq: 2, + snapshotId: "dropped-end", + snapshotTotal: 1, + }) + dispatchExtensionMessage({ + type: "clineMessagesSnapshotChunk", + taskId: "task-1", + clineMessagesSeq: 2, + snapshotId: "dropped-end", + snapshotStartIndex: 0, + clineMessages: [replacement], + }) + }) + + expect(readTranscriptFields()).toEqual({ + currentTaskId: "task-1", + clineMessages: [existing], + clineMessagesSeq: 1, + }) + }) + it("requests recovery for legacy unsequenced updates", () => { const postMessage = vi.spyOn(vscode, "postMessage").mockImplementation(() => undefined) try { From 5e2d9515c65699b65bd07b307344b9d8b1f52586 Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Mon, 31 Aug 2026 23:15:01 -0600 Subject: [PATCH 17/38] test: align state ordering regression with transcript transport --- src/core/webview/__tests__/ClineProvider.spec.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 66525d7014..8aaa4a1b75 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -1223,7 +1223,9 @@ describe("ClineProvider", () => { "postStateToWebviewWithoutTaskHistory", (currentProvider: ClineProvider) => currentProvider.postStateToWebviewWithoutTaskHistory(), ], - ])("%s assigns message sequence numbers before asynchronous state construction", async (_methodName, postState) => { + ])("%s keeps out-of-order generic state publications transcript-free", async (_methodName, postState) => { + await provider.resolveWebviewView(mockWebviewView) + mockPostMessage.mockClear() let releaseOlderSnapshot!: (state: ExtensionState) => void const olderSnapshot = new Promise((resolve) => { releaseOlderSnapshot = resolve @@ -1239,7 +1241,6 @@ describe("ClineProvider", () => { vi.spyOn(provider, "getStateToPostToWebview") .mockReturnValueOnce(olderSnapshot) .mockResolvedValueOnce(readyState) - const postMessageSpy = vi.spyOn(provider, "postMessageToWebview").mockResolvedValue(undefined) const olderPost = postState(provider) await Promise.resolve() @@ -1248,11 +1249,12 @@ describe("ClineProvider", () => { releaseOlderSnapshot(emptyState) await olderPost - expect(postMessageSpy.mock.calls.map(([message]) => message.state?.clineMessages)).toEqual([ - readyState.clineMessages, - emptyState.clineMessages, - ]) - expect(postMessageSpy.mock.calls.map(([message]) => message.state?.clineMessagesSeq)).toEqual([2, 1]) + const statePosts = (mockPostMessage.mock.calls as Array<[ExtensionMessage]>) + .map(([message]) => message) + .filter((message) => message.type === "state") + expect(statePosts).toHaveLength(2) + expect(statePosts.map((message) => message.state?.clineMessages)).toEqual([undefined, undefined]) + expect(statePosts.map((message) => message.state?.clineMessagesSeq)).toEqual([undefined, undefined]) }) test.each([ From 107cb13196b10de3a32a2276f39a5cf2a029ef21 Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Fri, 4 Sep 2026 00:48:05 -0600 Subject: [PATCH 18/38] test: cover transcript transport mutation gaps --- src/core/task/Task.ts | 22 +-- src/core/task/__tests__/Task.spec.ts | 159 ++++++++++++++++++ .../webview/__tests__/ClineProvider.spec.ts | 90 ++++++---- src/eslint-suppressions.json | 2 +- 4 files changed, 228 insertions(+), 45 deletions(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index b5677358ca..0cbe647046 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -668,20 +668,16 @@ export class Task extends EventEmitter implements TaskLike { this.TOKEN_USAGE_EMIT_INTERVAL_MS, { leading: true, trailing: true, maxWait: this.TOKEN_USAGE_EMIT_INTERVAL_MS }, ) - this.debouncedPostPartialMessageUpdate = debounce( - (message: ClineMessage) => { - const provider = this.providerRef.deref() - if (!provider) { - return - } + this.debouncedPostPartialMessageUpdate = debounce((message: ClineMessage) => { + const provider = this.providerRef.deref() + if (!provider) { + return + } - void provider.postClineMessageUpdated(this.taskId, message).catch((error) => { - console.error("[Task#updateClineMessage] incremental post failed:", error) - }) - }, - PARTIAL_MESSAGE_UPDATE_DEBOUNCE_MS, - { leading: false, trailing: true }, - ) + void provider.postClineMessageUpdated(this.taskId, message).catch((error) => { + console.error("[Task#updateClineMessage] incremental post failed:", error) + }) + }, PARTIAL_MESSAGE_UPDATE_DEBOUNCE_MS) onCreated?.(this) diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index 54dec890a4..b247601590 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -187,6 +187,9 @@ vi.mock("vscode", () => { Disposable: { from: vi.fn(), }, + RelativePattern: vi.fn().mockImplementation(function (base: string, pattern: string) { + return { base, pattern } + }), TabInputText: vi.fn(), } }) @@ -2199,6 +2202,26 @@ describe("Cline", () => { expect(mockProvider.postClineMessagesSnapshot).toHaveBeenCalledWith(task.taskId, { bumpSeq: true }) }) + it("still overwrites the transcript when the provider reference is unavailable", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + const saveSpy = vi.spyOn(getTaskTestAccess(task), "saveClineMessages").mockResolvedValue(true) + Object.defineProperty(task, "providerRef", { + value: { deref: () => undefined }, + configurable: true, + }) + const messages = [{ ts: 1, type: "say" as const, say: "text" as const, text: "replacement" }] + + await expect(task.overwriteClineMessages(messages)).resolves.toBeUndefined() + + expect(task.clineMessages).toEqual(messages) + expect(saveSpy).toHaveBeenCalledOnce() + }) + it("posts a complete new message through the incremental transport", async () => { const task = new Task({ provider: mockProvider, @@ -2395,6 +2418,73 @@ describe("Cline", () => { ) }) + it("drops a debounced partial update when the provider reference expires", async () => { + vi.useFakeTimers() + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + Object.defineProperty(task, "providerRef", { + value: { deref: () => undefined }, + configurable: true, + }) + + await getTaskTestAccess(task).updateClineMessage({ + ts: 1, + type: "say", + say: "text", + text: "partial", + partial: true, + }) + await vi.advanceTimersByTimeAsync(500) + + expect(mockProvider.postClineMessageUpdated).not.toHaveBeenCalled() + }) + + it("emits a complete update when the provider reference is unavailable", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + Object.defineProperty(task, "providerRef", { + value: { deref: () => undefined }, + configurable: true, + }) + const messageListener = vi.fn() + task.on(RooCodeEventName.Message, messageListener) + const message = { ts: 1, type: "say" as const, say: "text" as const, text: "complete" } + + await expect(getTaskTestAccess(task).updateClineMessage(message)).resolves.toBeUndefined() + + expect(messageListener).toHaveBeenCalledWith({ action: "updated", message }) + }) + + it("cancels a pending partial update when the task is disposed", async () => { + vi.useFakeTimers() + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + await getTaskTestAccess(task).updateClineMessage({ + ts: 1, + type: "say", + say: "text", + text: "partial", + partial: true, + }) + await task.dispose() + await vi.advanceTimersByTimeAsync(500) + + expect(mockProvider.postClineMessageUpdated).not.toHaveBeenCalled() + }) + it.each([ ["false", { ts: 1, type: "say" as const, say: "text" as const, text: "complete", partial: false }], ["absent", { ts: 1, type: "say" as const, say: "text" as const, text: "complete" }], @@ -2874,6 +2964,50 @@ describe("Cline", () => { ) }) + it("finishes cancellation when the API request message has already been removed", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + const taskAccess = getTaskTestAccess(task) + const saveSpy = vi.spyOn(taskAccess, "saveClineMessages").mockResolvedValue(true) + vi.spyOn(taskAccess, "safeEnsureModelFetched").mockResolvedValue(undefined) + vi.spyOn(task.diffViewProvider, "reset").mockResolvedValue(undefined) + vi.spyOn(task, "abortTask").mockResolvedValue(undefined) + vi.spyOn(task.api, "getModel").mockReturnValue({ + id: mockApiConfig.apiModelId!, + info: { + supportsImages: false, + supportsPromptCache: true, + contextWindow: 200000, + maxTokens: 4096, + inputPrice: 0.3, + outputPrice: 1.5, + } as ModelInfo, + }) + const updateSpy = vi.mocked(mockProvider.postClineMessageUpdated) + vi.spyOn(task, "attemptApiRequest").mockImplementation(() => + (async function* (): AsyncGenerator { + // Simulate another transcript operation removing the request row while + // cancellation is racing with the active stream. + task.clineMessages = [] + updateSpy.mockClear() + task.abort = true + yield { type: "usage", inputTokens: 0, outputTokens: 0 } + })(), + ) + + await expect( + task.recursivelyMakeClineRequests([{ type: "text", text: "cancel without request row" }]), + ).resolves.toBe(true) + + expect(updateSpy).not.toHaveBeenCalled() + expect(saveSpy).toHaveBeenCalled() + expect(task.didFinishAbortingStream).toBe(true) + }) + it("should pass AbortController signal to condenseContext metadata when a current request exists", async () => { const task = new Task({ provider: mockProvider, @@ -3781,6 +3915,31 @@ describe("Cline", () => { expect(saySpy).toHaveBeenCalledWith("text", "new task", undefined) expect(initiateTaskLoopSpy).toHaveBeenCalledOnce() }) + + it("starts without a snapshot when the provider reference is unavailable", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "new task", + startTask: false, + }) + const taskAccess = getTaskTestAccess(task) + Object.defineProperty(task, "providerRef", { + value: { deref: () => undefined }, + configurable: true, + }) + const saySpy = vi.spyOn(task, "say").mockResolvedValue(undefined) + vi.spyOn(taskAccess, "getEnabledMcpToolsCount").mockResolvedValue({ + enabledToolCount: 0, + enabledServerCount: 0, + }) + const initiateTaskLoopSpy = vi.spyOn(taskAccess, "initiateTaskLoop").mockResolvedValue(undefined) + + await expect(taskAccess.startTask("new task")).resolves.toBeUndefined() + + expect(saySpy).toHaveBeenCalledWith("text", "new task", undefined) + expect(initiateTaskLoopSpy).toHaveBeenCalledOnce() + }) }) describe("start()", () => { diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 8aaa4a1b75..6b057bed66 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -34,8 +34,18 @@ import { Terminal } from "../../../integrations/terminal/Terminal" import { MessageManager } from "../../message-manager" import { forceFullModelDetailsLoad, hasLoadedFullDetails } from "../../../api/providers/fetchers/lmstudio" -// Mock setup must come before imports. -vi.mock("../../prompts/sections/custom-instructions") +const { mockAddCustomInstructions, mockTaskConstructor } = vi.hoisted(() => ({ + mockAddCustomInstructions: vi.fn().mockResolvedValue("Combined instructions"), + mockTaskConstructor: vi.fn(), +})) + +vi.mock("../../prompts/sections/custom-instructions", () => ({ + addCustomInstructions: mockAddCustomInstructions, +})) + +vi.mock("../../task/Task", () => ({ + Task: mockTaskConstructor, +})) vi.mock("p-wait-for", () => ({ __esModule: true, @@ -108,13 +118,6 @@ vi.mock("@modelcontextprotocol/sdk/types.js", () => ({ }, })) -// Remove duplicate mock - it's already defined below. - -const mockAddCustomInstructions = vi.fn().mockResolvedValue("Combined instructions") - -;(vi.mocked(await import("../../prompts/sections/custom-instructions")) as any).addCustomInstructions = - mockAddCustomInstructions - vi.mock("delay", () => { const delayFn = (_ms: number) => Promise.resolve() delayFn.createDelay = () => delayFn @@ -173,6 +176,7 @@ vi.mock("vscode", () => ({ showErrorMessage: vi.fn(), showSaveDialog: vi.fn(), showOpenDialog: vi.fn(), + createTextEditorDecorationType: vi.fn(() => ({ dispose: vi.fn() })), activeTextEditor: undefined, onDidChangeActiveTextEditor: vi.fn(() => ({ dispose: vi.fn() })), }, @@ -261,27 +265,6 @@ vi.mock("../../../integrations/workspace/WorkspaceTracker", () => { } }) -vi.mock("../../task/Task", () => ({ - Task: vi.fn().mockImplementation(function (options: any) { - return { - api: undefined, - abortTask: vi.fn(), - dispose: vi.fn().mockResolvedValue(undefined), - handleWebviewAskResponse: vi.fn(), - clineMessages: [], - apiConversationHistory: [], - overwriteClineMessages: vi.fn(), - overwriteApiConversationHistory: vi.fn(), - getTaskNumber: vi.fn().mockReturnValue(0), - setTaskNumber: vi.fn(), - setParentTask: vi.fn(), - setRootTask: vi.fn(), - taskId: options?.historyItem?.id || "test-task-id", - emit: vi.fn(), - } - }), -})) - vi.mock("../../../integrations/misc/extract-text", () => ({ extractTextFromFile: vi.fn().mockImplementation(async (_filePath: string) => { const content = "const x = 1;\nconst y = 2;\nconst z = 3;" @@ -410,7 +393,7 @@ afterAll(() => { describe("ClineProvider", () => { beforeAll(() => { - vi.mocked(Task).mockImplementation(function (options: any) { + mockTaskConstructor.mockImplementation(function (options: any) { const task: any = { api: undefined, abortTask: vi.fn(), @@ -880,6 +863,17 @@ describe("ClineProvider", () => { expect(mockPostMessage).toHaveBeenCalledWith({ type: "state", state: { version: "1.0.0" } }) }) + test("postMessageToWebview forwards non-state messages unchanged", async () => { + await provider.resolveWebviewView(mockWebviewView) + mockPostMessage.mockClear() + const message: ExtensionMessage = { type: "action", action: "chatButtonClicked" } + + await provider.postMessageToWebview(message) + + expect(mockPostMessage).toHaveBeenCalledOnce() + expect(mockPostMessage).toHaveBeenCalledWith(message) + }) + describe("transcript transport", () => { const setCurrentTask = (task: { taskId: string; clineMessages: ClineMessage[] } | undefined) => { vi.spyOn(provider, "getCurrentTask").mockImplementation(() => task as Task | undefined) @@ -1070,6 +1064,40 @@ describe("ClineProvider", () => { }, ) + test("invalidates a queued delta when only the transport generation changes", async () => { + await provider.resolveWebviewView(mockWebviewView) + const task = { taskId: "task-1", clineMessages: [] as ClineMessage[] } + setCurrentTask(task) + mockPostMessage.mockClear() + + let releaseQueue!: () => void + Object.assign(provider, { + clineMessagesPostQueue: new Promise((resolve) => { + releaseQueue = resolve + }), + }) + const pendingDelta = provider.postClineMessageAppended("task-1", { + ts: 1, + type: "say", + say: "text", + text: "stale generation", + }) + const previousGeneration = provider["clineMessagesTransportGeneration"] + const resync = provider.resyncClineMessagesToWebview("task-1") + + expect(provider["clineMessagesTransportGeneration"]).toBe(previousGeneration + 1) + + releaseQueue() + await Promise.all([pendingDelta, resync]) + + expect(mockPostMessage).not.toHaveBeenCalledWith( + expect.objectContaining({ type: "clineMessageAppended", taskId: "task-1" }), + ) + expect(mockPostMessage).toHaveBeenCalledWith( + expect.objectContaining({ type: "clineMessagesSnapshotStart", taskId: "task-1" }), + ) + }) + test("drops a snapshot invalidated before its first post without cloning it", async () => { const task = { taskId: "task-1", diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index d90272962b..5700774d48 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -1036,7 +1036,7 @@ }, "core/webview/__tests__/ClineProvider.spec.ts": { "@typescript-eslint/no-explicit-any": { - "count": 198 + "count": 196 } }, "core/webview/__tests__/ClineProvider.sticky-mode.spec.ts": { From 5afa2c09fafc587325dbd0f40a77d30c989b090b Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Fri, 4 Sep 2026 04:15:18 -0600 Subject: [PATCH 19/38] test: cover transcript transport mutation edges --- src/core/task/__tests__/Task.spec.ts | 28 + .../webview/__tests__/ClineProvider.spec.ts | 262 ++++++++ .../__tests__/webviewMessageHandler.spec.ts | 15 + .../src/context/ExtensionStateContext.tsx | 99 +-- .../__tests__/ExtensionStateContext.spec.tsx | 628 ++++++++++++++++++ 5 files changed, 990 insertions(+), 42 deletions(-) diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index b247601590..70b50e7104 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -2245,6 +2245,34 @@ describe("Cline", () => { expect(mockProvider.postStateToWebviewWithoutTaskHistory).not.toHaveBeenCalled() }) + it("creates a message without a transport error when the provider reference is unavailable", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + const taskAccess = getTaskTestAccess(task) + const saveSpy = vi.spyOn(taskAccess, "saveClineMessages").mockResolvedValue(true) + const messageListener = vi.fn() + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + Object.defineProperty(task, "providerRef", { + value: { deref: () => undefined }, + configurable: true, + }) + task.on(RooCodeEventName.Message, messageListener) + const message = { ts: 1, type: "say" as const, say: "text" as const, text: "message" } + + await expect(taskAccess.addToClineMessages(message)).resolves.toBeUndefined() + + expect(consoleErrorSpy).not.toHaveBeenCalled() + expect(task.clineMessages).toEqual([message]) + expect(messageListener).toHaveBeenCalledWith({ action: "created", message }) + expect(saveSpy).toHaveBeenCalledOnce() + + consoleErrorSpy.mockRestore() + }) + it("waits for an incremental append before emitting the message", async () => { const task = new Task({ provider: mockProvider, diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 6b057bed66..5508d05f7f 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -1,6 +1,7 @@ // pnpm --filter roo-cline test core/webview/__tests__/ClineProvider.spec.ts import * as path from "path" +import fs from "fs/promises" import { TaskRegistry } from "../../task/TaskRegistry" import Anthropic from "@anthropic-ai/sdk" @@ -33,6 +34,7 @@ import { webviewMessageHandler } from "../webviewMessageHandler" import { Terminal } from "../../../integrations/terminal/Terminal" import { MessageManager } from "../../message-manager" import { forceFullModelDetailsLoad, hasLoadedFullDetails } from "../../../api/providers/fetchers/lmstudio" +import { ShadowCheckpointService } from "../../../services/checkpoints/ShadowCheckpointService" const { mockAddCustomInstructions, mockTaskConstructor } = vi.hoisted(() => ({ mockAddCustomInstructions: vi.fn().mockResolvedValue("Combined instructions"), @@ -874,6 +876,24 @@ describe("ClineProvider", () => { expect(mockPostMessage).toHaveBeenCalledWith(message) }) + test("postMessageToWebview preserves state-shaped payloads on non-state messages", async () => { + await provider.resolveWebviewView(mockWebviewView) + mockPostMessage.mockClear() + const message: ExtensionMessage = { + type: "action", + action: "chatButtonClicked", + state: { + clineMessages: [{ ts: 1, type: "say", say: "text", text: "preserved" }], + clineMessagesSeq: 3, + }, + } + + await provider.postMessageToWebview(message) + + expect(mockPostMessage).toHaveBeenCalledOnce() + expect(mockPostMessage).toHaveBeenCalledWith(message) + }) + describe("transcript transport", () => { const setCurrentTask = (task: { taskId: string; clineMessages: ClineMessage[] } | undefined) => { vi.spyOn(provider, "getCurrentTask").mockImplementation(() => task as Task | undefined) @@ -964,6 +984,8 @@ describe("ClineProvider", () => { setCurrentTask(task) const message = { ts: 1, type: "say", say: "text", text: "ignored" } as ClineMessage const postSpy = vi.spyOn(provider, "postMessageToWebview") + const previousGeneration = provider["clineMessagesTransportGeneration"] + const previousSnapshotId = provider["nextClineMessagesSnapshotId"] await Promise.all([ provider.postClineMessageAppended("task-2", message), @@ -973,6 +995,22 @@ describe("ClineProvider", () => { ]) expect(postSpy).not.toHaveBeenCalled() + expect(provider["clineMessagesSeqByTaskId"].has("task-2")).toBe(false) + expect(provider["clineMessagesTransportGeneration"]).toBe(previousGeneration) + expect(provider["nextClineMessagesSnapshotId"]).toBe(previousSnapshotId) + }) + + test("safely rejects transcript work when no task is focused", async () => { + setCurrentTask(undefined) + const message = { ts: 1, type: "say", say: "text", text: "ignored" } as ClineMessage + const previousGeneration = provider["clineMessagesTransportGeneration"] + + await expect(provider.postClineMessageAppended("task-1", message)).resolves.toBeUndefined() + await expect(provider.postClineMessageUpdated("task-1", message)).resolves.toBeUndefined() + await expect(provider.resyncClineMessagesToWebview("task-1")).resolves.toBeUndefined() + + expect(provider["clineMessagesSeqByTaskId"].has("task-1")).toBe(false) + expect(provider["clineMessagesTransportGeneration"]).toBe(previousGeneration) }) test("logs a failed delta post and continues processing the queue", async () => { @@ -1064,6 +1102,61 @@ describe("ClineProvider", () => { }, ) + test.each([ + ["append", "clineMessageAppended"], + ["update", "clineMessageUpdated"], + ] as const)( + "invalidates a queued %s delta when only the focused task changes", + async (operation, messageType) => { + const task = { taskId: "task-1", clineMessages: [] as ClineMessage[] } + setCurrentTask(task) + const postSpy = vi.spyOn(provider, "postMessageToWebview") + let releaseQueue!: () => void + Object.assign(provider, { + clineMessagesPostQueue: new Promise((resolve) => { + releaseQueue = resolve + }), + }) + const message = { ts: 1, type: "say", say: "text", text: "queued" } as ClineMessage + const pendingDelta = + operation === "append" + ? provider.postClineMessageAppended("task-1", message) + : provider.postClineMessageUpdated("task-1", message) + + task.taskId = "task-2" + releaseQueue() + await pendingDelta + + expect(postSpy).not.toHaveBeenCalledWith( + expect.objectContaining({ type: messageType, taskId: "task-1" }), + ) + }, + ) + + test.each(["append", "update"] as const)( + "drops a queued %s delta safely when the current task disappears", + async (operation) => { + const task = { taskId: "task-1", clineMessages: [] as ClineMessage[] } + setCurrentTask(task) + let releaseQueue!: () => void + Object.assign(provider, { + clineMessagesPostQueue: new Promise((resolve) => { + releaseQueue = resolve + }), + }) + const message = { ts: 1, type: "say", say: "text", text: "queued" } as ClineMessage + const pendingDelta = + operation === "append" + ? provider.postClineMessageAppended("task-1", message) + : provider.postClineMessageUpdated("task-1", message) + + setCurrentTask(undefined) + releaseQueue() + + await expect(pendingDelta).resolves.toBeUndefined() + }, + ) + test("invalidates a queued delta when only the transport generation changes", async () => { await provider.resolveWebviewView(mockWebviewView) const task = { taskId: "task-1", clineMessages: [] as ClineMessage[] } @@ -1098,6 +1191,32 @@ describe("ClineProvider", () => { ) }) + test("invalidates a queued update when only the transport generation changes", async () => { + const task = { taskId: "task-1", clineMessages: [] as ClineMessage[] } + setCurrentTask(task) + const postSpy = vi.spyOn(provider, "postMessageToWebview") + let releaseQueue!: () => void + Object.assign(provider, { + clineMessagesPostQueue: new Promise((resolve) => { + releaseQueue = resolve + }), + }) + const pendingUpdate = provider.postClineMessageUpdated("task-1", { + ts: 1, + type: "say", + say: "text", + text: "stale generation", + }) + + provider["clineMessagesTransportGeneration"]++ + releaseQueue() + await pendingUpdate + + expect(postSpy).not.toHaveBeenCalledWith( + expect.objectContaining({ type: "clineMessageUpdated", taskId: "task-1" }), + ) + }) + test("drops a snapshot invalidated before its first post without cloning it", async () => { const task = { taskId: "task-1", @@ -1126,6 +1245,77 @@ describe("ClineProvider", () => { } }) + test("uses monotonic task-scoped snapshot IDs and an empty no-task snapshot", async () => { + await provider.resolveWebviewView(mockWebviewView) + setCurrentTask({ taskId: "task-1", clineMessages: [] }) + mockPostMessage.mockClear() + + await provider.postClineMessagesSnapshot("task-1") + await provider.postClineMessagesSnapshot("task-1") + setCurrentTask(undefined) + await provider.postClineMessagesSnapshot(undefined) + + const snapshotMessages: ExtensionMessage[] = mockPostMessage.mock.calls.map( + ([message]: [ExtensionMessage]) => message, + ) + expect(snapshotMessages.map(({ snapshotId }) => snapshotId)).toEqual([ + "task-1:1", + "task-1:1", + "task-1:2", + "task-1:2", + "none:3", + "none:3", + ]) + expect(snapshotMessages.slice(-2)).toEqual([ + expect.objectContaining({ type: "clineMessagesSnapshotStart", snapshotTotal: 0 }), + expect.objectContaining({ type: "clineMessagesSnapshotEnd", snapshotTotal: 0 }), + ]) + }) + + test("does not emit an empty trailing chunk for an exact snapshot chunk boundary", async () => { + const messages = Array.from({ length: 200 }, (_, index) => ({ + ts: index, + type: "say", + say: "text", + text: `message ${index}`, + })) as ClineMessage[] + setCurrentTask({ taskId: "task-1", clineMessages: messages }) + const postSpy = vi.spyOn(provider, "postMessageToWebview").mockResolvedValue(undefined) + + await provider.postClineMessagesSnapshot("task-1") + + expect(postSpy.mock.calls.map(([message]) => message.type)).toEqual([ + "clineMessagesSnapshotStart", + "clineMessagesSnapshotChunk", + "clineMessagesSnapshotEnd", + ]) + expect(postSpy).toHaveBeenCalledWith( + expect.objectContaining({ + type: "clineMessagesSnapshotChunk", + snapshotStartIndex: 0, + clineMessages: messages, + }), + ) + }) + + test("stops a snapshot when its transport generation changes after the start marker", async () => { + setCurrentTask({ + taskId: "task-1", + clineMessages: [{ ts: 1, type: "say", say: "text", text: "message" }], + }) + const postedTypes: string[] = [] + vi.spyOn(provider, "postMessageToWebview").mockImplementation(async (message) => { + postedTypes.push(message.type) + if (message.type === "clineMessagesSnapshotStart") { + provider["clineMessagesTransportGeneration"]++ + } + }) + + await provider.postClineMessagesSnapshot("task-1") + + expect(postedTypes).toEqual(["clineMessagesSnapshotStart"]) + }) + test.each([ ["after the start marker", "clineMessagesSnapshotStart", ["clineMessagesSnapshotStart"]], [ @@ -1193,6 +1383,49 @@ describe("ClineProvider", () => { expect(provider["clineMessagesSeqByTaskId"].has("deleted-task")).toBe(false) }) + test("prunes sequence state for every task deleted by a cascade", async () => { + const histories = { + parent: { + id: "parent", + number: 1, + ts: 1, + task: "Parent", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + childIds: ["child"], + }, + child: { + id: "child", + number: 2, + ts: 2, + task: "Child", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + }, + } + vi.spyOn(provider, "getTaskWithId").mockImplementation(async (id) => ({ + historyItem: histories[id as keyof typeof histories], + taskDirPath: `/test/task/${id}`, + apiConversationHistoryFilePath: `/test/task/${id}/api.json`, + uiMessagesFilePath: `/test/task/${id}/ui.json`, + apiConversationHistory: [], + })) + vi.spyOn(provider.taskHistoryStore, "deleteMany").mockResolvedValue(undefined) + vi.spyOn(ShadowCheckpointService, "deleteTask").mockResolvedValue(undefined) + vi.spyOn(fs, "rm").mockResolvedValue(undefined) + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + provider["clineMessagesSeqByTaskId"].set("parent", 4) + provider["clineMessagesSeqByTaskId"].set("child", 7) + + await provider.deleteTaskWithId("parent") + + expect(provider.taskHistoryStore.deleteMany).toHaveBeenCalledWith(["parent", "child"]) + expect(provider["clineMessagesSeqByTaskId"].has("parent")).toBe(false) + expect(provider["clineMessagesSeqByTaskId"].has("child")).toBe(false) + }) + test("abandons an older focus sync when a resync invalidates its state post", async () => { const task = { taskId: "task-1", clineMessages: [] as ClineMessage[] } setCurrentTask(task) @@ -1217,6 +1450,33 @@ describe("ClineProvider", () => { expect(snapshotSpy).toHaveBeenCalledOnce() expect(snapshotSpy).toHaveBeenCalledWith("task-1", { generation: expect.any(Number) }) }) + + test("passes the new transport generation into a focused-task snapshot", async () => { + setCurrentTask({ taskId: "task-1", clineMessages: [] }) + vi.spyOn(provider, "postStateToWebviewWithoutTaskHistory").mockResolvedValue(undefined) + const snapshotSpy = vi.spyOn(provider, "postClineMessagesSnapshot").mockResolvedValue(undefined) + const previousGeneration = provider["clineMessagesTransportGeneration"] + + await provider.syncFocusedTaskToWebview() + + expect(snapshotSpy).toHaveBeenCalledWith("task-1", { generation: previousGeneration + 1 }) + }) + + test("includes task history when requested during focused-task synchronization", async () => { + setCurrentTask({ taskId: "task-1", clineMessages: [] }) + const fullStateSpy = vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + const lightweightStateSpy = vi + .spyOn(provider, "postStateToWebviewWithoutTaskHistory") + .mockResolvedValue(undefined) + const snapshotSpy = vi.spyOn(provider, "postClineMessagesSnapshot").mockResolvedValue(undefined) + const previousGeneration = provider["clineMessagesTransportGeneration"] + + await provider.syncFocusedTaskToWebview({ includeTaskHistory: true }) + + expect(fullStateSpy).toHaveBeenCalledOnce() + expect(lightweightStateSpy).not.toHaveBeenCalled() + expect(snapshotSpy).toHaveBeenCalledWith("task-1", { generation: previousGeneration + 1 }) + }) }) test("postStateToWebviewWithoutTaskHistory waits for the webview post boundary", async () => { @@ -1649,6 +1909,7 @@ describe("ClineProvider", () => { test("handles webviewDidLaunch message", async () => { await provider.resolveWebviewView(mockWebviewView) + const syncFocusedTaskSpy = vi.spyOn(provider, "syncFocusedTaskToWebview").mockResolvedValue(undefined) // Get the message handler from onDidReceiveMessage const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as ReturnType).mock @@ -1659,6 +1920,7 @@ describe("ClineProvider", () => { // Should post state and theme to webview expect(mockPostMessage).toHaveBeenCalled() + expect(syncFocusedTaskSpy).toHaveBeenCalledWith({ includeTaskHistory: true }) }) test("logs detached workspace initialization failures", async () => { diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index e7ad0de694..3fa1314031 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -119,6 +119,7 @@ const mockClineProvider = { postStateToWebview: vi.fn(), syncFocusedTaskToWebview: vi.fn().mockResolvedValue(undefined), resyncClineMessagesToWebview: vi.fn().mockResolvedValue(undefined), + clearTask: vi.fn().mockResolvedValue(undefined), resolveWebviewThemeFixtureProbe: vi.fn(), getCurrentTask: vi.fn(), getTaskWithId: vi.fn(), @@ -145,6 +146,20 @@ describe("webviewMessageHandler - transcript resync", () => { }) }) +describe("webviewMessageHandler - clear task", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("clears the task and synchronizes focused state with task history", async () => { + await webviewMessageHandler(mockClineProvider, { type: "clearTask" }) + + expect(mockClineProvider.clearTask).toHaveBeenCalledOnce() + expect(mockClineProvider.syncFocusedTaskToWebview).toHaveBeenCalledOnce() + expect(mockClineProvider.syncFocusedTaskToWebview).toHaveBeenCalledWith({ includeTaskHistory: true }) + }) +}) + describe("webviewMessageHandler - theme fixture probes", () => { const originalProbeSetting = process.env.ROO_CODE_THEME_FIXTURE_PROBE const themeFixture = { diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index cd39ce7e61..35925a1708 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -338,47 +338,56 @@ export const ExtensionStateContextProvider: React.FC<{ })) }, []) - const clearClineMessagesResync = useCallback(() => { - resyncPendingRef.current = false - if (resyncTimeoutRef.current !== undefined) { - window.clearTimeout(resyncTimeoutRef.current) - resyncTimeoutRef.current = undefined - } - }, []) - - const requestClineMessagesResync = useCallback((receivedSeq?: number) => { - if (resyncPendingRef.current) { - return - } - resyncPendingRef.current = true - resyncTimeoutRef.current = window.setTimeout(() => { + const clearClineMessagesResync = useCallback( + () => { resyncPendingRef.current = false - resyncTimeoutRef.current = undefined - }, CLINE_MESSAGES_RESYNC_TIMEOUT_MS) - vscode.postMessage({ - type: "requestClineMessagesResync", - taskId: activeTaskIdRef.current, - expectedSeq: clineMessagesSeqRef.current + 1, - receivedSeq, - }) - }, []) + if (resyncTimeoutRef.current !== undefined) { + window.clearTimeout(resyncTimeoutRef.current) + resyncTimeoutRef.current = undefined + } + }, + // Stryker disable next-line ArrayDeclaration: an inserted constant never changes, so this ref-only callback retains the same identity and captures. + [], + ) + + const requestClineMessagesResync = useCallback( + (receivedSeq?: number) => { + if (resyncPendingRef.current) { + return + } + resyncPendingRef.current = true + resyncTimeoutRef.current = window.setTimeout(() => { + resyncPendingRef.current = false + resyncTimeoutRef.current = undefined + }, CLINE_MESSAGES_RESYNC_TIMEOUT_MS) + vscode.postMessage({ + type: "requestClineMessagesResync", + taskId: activeTaskIdRef.current, + expectedSeq: clineMessagesSeqRef.current + 1, + receivedSeq, + }) + }, + // Stryker disable next-line ArrayDeclaration: an inserted constant never changes, so this ref-only callback retains the same identity and captures. + [], + ) const retryClineMessagesResync = useCallback( (receivedSeq?: number) => { clearClineMessagesResync() requestClineMessagesResync(receivedSeq) }, + // Stryker disable next-line ArrayDeclaration: both dependencies are stable callbacks; omitting them cannot alter callback identity or captured values. [clearClineMessagesResync, requestClineMessagesResync], ) const applyClineMessagesDelta = useCallback( (message: ExtensionMessage, operation: "append" | "update") => { - const seq = message.clineMessagesSeq + const seq = message.clineMessagesSeq as number const clineMessage = message.clineMessage if (message.taskId !== activeTaskIdRef.current) { return } - if (typeof seq !== "number" || !Number.isSafeInteger(seq) || seq < 0 || !clineMessage) { + if (!Number.isSafeInteger(seq) || seq < 0 || !clineMessage) { requestClineMessagesResync(typeof seq === "number" ? seq : undefined) return } @@ -423,6 +432,7 @@ export const ExtensionStateContextProvider: React.FC<{ clineMessagesSeq: seq, })) }, + // Stryker disable next-line ArrayDeclaration: both dependencies are stable callbacks; an empty dependency list produces the same closure for the provider lifetime. [requestClineMessagesResync, retryClineMessagesResync], ) @@ -533,8 +543,8 @@ export const ExtensionStateContextProvider: React.FC<{ break } - const seq = message.clineMessagesSeq - if (typeof seq !== "number" || !Number.isSafeInteger(seq) || seq < 0) { + const seq = message.clineMessagesSeq as number + if (!Number.isSafeInteger(seq) || seq < 0) { activeSnapshotRef.current = null retryClineMessagesResync(typeof seq === "number" ? seq : undefined) break @@ -543,8 +553,8 @@ export const ExtensionStateContextProvider: React.FC<{ break } - const total = message.snapshotTotal - if (!message.snapshotId || typeof total !== "number" || !Number.isSafeInteger(total) || total < 0) { + const total = message.snapshotTotal as number + if (!message.snapshotId || !Number.isSafeInteger(total) || total < 0) { activeSnapshotRef.current = null retryClineMessagesResync(seq) break @@ -572,9 +582,9 @@ export const ExtensionStateContextProvider: React.FC<{ break } - const seq = message.clineMessagesSeq + const seq = message.clineMessagesSeq as number const snapshot = activeSnapshotRef.current - if (typeof seq !== "number" || !Number.isSafeInteger(seq) || seq < 0) { + if (!Number.isSafeInteger(seq) || seq < 0) { activeSnapshotRef.current = null retryClineMessagesResync(typeof seq === "number" ? seq : undefined) break @@ -594,11 +604,10 @@ export const ExtensionStateContextProvider: React.FC<{ } const chunk = message.clineMessages - const startIndex = message.snapshotStartIndex + const startIndex = message.snapshotStartIndex as number if ( !Array.isArray(chunk) || chunk.length === 0 || - typeof startIndex !== "number" || !Number.isSafeInteger(startIndex) || startIndex !== snapshot.messages.length || snapshot.messages.length + chunk.length > snapshot.total @@ -616,9 +625,9 @@ export const ExtensionStateContextProvider: React.FC<{ break } - const seq = message.clineMessagesSeq + const seq = message.clineMessagesSeq as number const snapshot = activeSnapshotRef.current - if (typeof seq !== "number" || !Number.isSafeInteger(seq) || seq < 0) { + if (!Number.isSafeInteger(seq) || seq < 0) { activeSnapshotRef.current = null retryClineMessagesResync(typeof seq === "number" ? seq : undefined) break @@ -658,6 +667,7 @@ export const ExtensionStateContextProvider: React.FC<{ break } case "clineMessageUpdated": { + // Stryker disable next-line StringLiteral: applyClineMessagesDelta treats every non-"append" operation as an update, so replacing this literal with another non-append string is equivalent. applyClineMessagesDelta(message, "update") break } @@ -744,6 +754,7 @@ export const ExtensionStateContextProvider: React.FC<{ } } }, + // Stryker disable next-line ArrayDeclaration: every listed dependency is a stable callback; removing the list does not change this listener closure. [ applyClineMessagesDelta, clearClineMessagesResync, @@ -753,13 +764,17 @@ export const ExtensionStateContextProvider: React.FC<{ ], ) - useEffect(() => { - window.addEventListener("message", handleMessage) - return () => { - window.removeEventListener("message", handleMessage) - clearClineMessagesResync() - } - }, [clearClineMessagesResync, handleMessage]) + useEffect( + () => { + window.addEventListener("message", handleMessage) + return () => { + window.removeEventListener("message", handleMessage) + clearClineMessagesResync() + } + }, + // Stryker disable next-line ArrayDeclaration: both effect dependencies are stable callbacks, making an empty list behaviorally identical for the provider lifetime. + [clearClineMessagesResync, handleMessage], + ) useEffect(() => { vscode.postMessage({ type: "webviewDidLaunch" }) diff --git a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx index dc4cf78e77..09a1ea0323 100644 --- a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx +++ b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx @@ -446,6 +446,607 @@ describe("ExtensionStateContext", () => { , ) + const dispatchMalformedExtensionMessage = (message: unknown) => + dispatchExtensionMessage(message as ExtensionMessage) + const startSnapshot = (overrides: Record = {}) => + dispatchMalformedExtensionMessage({ + type: "clineMessagesSnapshotStart", + taskId: "task-1", + clineMessagesSeq: 2, + snapshotId: "snapshot-1", + snapshotTotal: 1, + ...overrides, + }) + const appendSnapshotChunk = (overrides: Record = {}) => + dispatchMalformedExtensionMessage({ + type: "clineMessagesSnapshotChunk", + taskId: "task-1", + clineMessagesSeq: 2, + snapshotId: "snapshot-1", + snapshotStartIndex: 0, + clineMessages: [makeMessage(2, "snapshot")], + ...overrides, + }) + const endSnapshot = (overrides: Record = {}) => + dispatchMalformedExtensionMessage({ + type: "clineMessagesSnapshotEnd", + taskId: "task-1", + clineMessagesSeq: 2, + snapshotId: "snapshot-1", + snapshotTotal: 1, + ...overrides, + }) + const renderTranscriptWithPostMessageSpy = (initialState: Partial = {}) => { + const postMessage = vi.spyOn(vscode, "postMessage").mockImplementation(() => undefined) + renderTranscript(initialState) + postMessage.mockClear() + return postMessage + } + + afterEach(() => { + vi.restoreAllMocks() + vi.useRealTimers() + }) + + it("ignores a delta for a different task", () => { + const existing = makeMessage(1, "existing") + const postMessage = renderTranscriptWithPostMessageSpy({ clineMessages: [existing], clineMessagesSeq: 1 }) + + act(() => appendClineMessage(makeMessage(2, "wrong task"), 2, "task-2")) + + expect(postMessage).not.toHaveBeenCalled() + expect(readTranscriptFields()).toEqual({ + currentTaskId: "task-1", + clineMessages: [existing], + clineMessagesSeq: 1, + }) + }) + + it.each([ + { + name: "a missing sequence", + seq: undefined, + receivedSeq: undefined, + clineMessage: makeMessage(2, "next"), + }, + { name: "a nonnumeric sequence", seq: "2", receivedSeq: undefined, clineMessage: makeMessage(2, "next") }, + { name: "a boolean sequence", seq: true, receivedSeq: undefined, clineMessage: makeMessage(2, "next") }, + { name: "a fractional sequence", seq: 1.5, receivedSeq: 1.5, clineMessage: makeMessage(2, "next") }, + { name: "a negative sequence", seq: -1, receivedSeq: -1, clineMessage: makeMessage(2, "next") }, + { name: "a missing message", seq: 2, receivedSeq: 2, clineMessage: undefined }, + ])("requests resynchronization for $name in a delta", ({ seq, receivedSeq, clineMessage }) => { + const existing = makeMessage(1, "existing") + const postMessage = renderTranscriptWithPostMessageSpy({ clineMessages: [existing], clineMessagesSeq: 1 }) + + act(() => + dispatchMalformedExtensionMessage({ + type: "clineMessageAppended", + taskId: "task-1", + clineMessagesSeq: seq, + clineMessage, + }), + ) + + expect(postMessage).toHaveBeenCalledTimes(1) + expect(postMessage).toHaveBeenCalledWith({ + type: "requestClineMessagesResync", + taskId: "task-1", + expectedSeq: 2, + receivedSeq, + }) + expect(readTranscriptFields()).toEqual({ + currentTaskId: "task-1", + clineMessages: [existing], + clineMessagesSeq: 1, + }) + }) + + it.each([0, 1])("ignores stale delta sequence %s", (seq) => { + const existing = makeMessage(1, "existing") + const postMessage = renderTranscriptWithPostMessageSpy({ clineMessages: [existing], clineMessagesSeq: 1 }) + + act(() => appendClineMessage(makeMessage(2, "stale"), seq, "task-1")) + + expect(postMessage).not.toHaveBeenCalled() + expect(readTranscriptFields()).toEqual({ + currentTaskId: "task-1", + clineMessages: [existing], + clineMessagesSeq: 1, + }) + }) + + it.each([ + { name: "an explicit same-task update", state: { currentTaskId: "task-1", version: "2.0.0" } }, + { name: "a partial metadata update", state: { version: "2.0.0" } }, + ])("preserves transcript refs through $name", ({ state }) => { + const existing = makeMessage(1, "existing") + const next = makeMessage(2, "next") + const postMessage = renderTranscriptWithPostMessageSpy({ clineMessages: [existing], clineMessagesSeq: 3 }) + + act(() => { + dispatchMalformedExtensionMessage({ type: "state", state }) + appendClineMessage(next, 4, "task-1") + }) + + expect(postMessage).not.toHaveBeenCalled() + expect(readTranscriptFields()).toEqual({ + currentTaskId: "task-1", + clineMessages: [existing, next], + clineMessagesSeq: 4, + }) + }) + + it("starts the replacement task with an empty transcript ref", () => { + const next = makeMessage(2, "replacement task") + const postMessage = renderTranscriptWithPostMessageSpy({ + clineMessages: [makeMessage(1, "existing")], + clineMessagesSeq: 3, + }) + + act(() => { + dispatchExtensionMessage({ type: "state", state: { currentTaskId: "task-2" } }) + appendClineMessage(next, 1, "task-2") + }) + + expect(postMessage).not.toHaveBeenCalled() + expect(readTranscriptFields()).toEqual({ + currentTaskId: "task-2", + clineMessages: [next], + clineMessagesSeq: 1, + }) + }) + + it("does not clear a nonexistent resync timeout during a task switch", () => { + vi.useFakeTimers() + const clearTimeout = vi.spyOn(window, "clearTimeout") + renderTranscript({ clineMessagesSeq: 1 }) + + act(() => dispatchExtensionMessage({ type: "state", state: { currentTaskId: "task-2" } })) + + expect(clearTimeout).not.toHaveBeenCalled() + }) + + it("clears a pending resync before requesting recovery for a replacement task", () => { + vi.useFakeTimers() + const postMessage = renderTranscriptWithPostMessageSpy({ clineMessagesSeq: 1 }) + const clearTimeout = vi.spyOn(window, "clearTimeout") + + act(() => appendClineMessage(makeMessage(3, "old gap"), 3, "task-1")) + expect(postMessage).toHaveBeenCalledTimes(1) + const timeoutHandle = vi.getTimerCount() + act(() => { + dispatchExtensionMessage({ type: "state", state: { currentTaskId: "task-2" } }) + appendClineMessage(makeMessage(2, "new gap"), 2, "task-2") + }) + + expect(timeoutHandle).toBe(1) + expect(clearTimeout).toHaveBeenCalledTimes(1) + expect(postMessage).toHaveBeenCalledTimes(2) + expect(postMessage).toHaveBeenLastCalledWith({ + type: "requestClineMessagesResync", + taskId: "task-2", + expectedSeq: 1, + receivedSeq: 2, + }) + }) + + it("clears a pending resync timeout when the provider unmounts", () => { + vi.useFakeTimers() + const postMessage = vi.spyOn(vscode, "postMessage").mockImplementation(() => undefined) + const clearTimeout = vi.spyOn(window, "clearTimeout") + const { unmount } = renderTranscript({ clineMessagesSeq: 1 }) + postMessage.mockClear() + + act(() => appendClineMessage(makeMessage(3, "gap"), 3, "task-1")) + clearTimeout.mockClear() + unmount() + + expect(clearTimeout).toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) + }) + + it.each([ + { name: "a nonnumeric sequence", overrides: { clineMessagesSeq: "2" }, receivedSeq: undefined }, + { name: "a boolean sequence", overrides: { clineMessagesSeq: true }, receivedSeq: undefined }, + { name: "a fractional sequence", overrides: { clineMessagesSeq: 1.5 }, receivedSeq: 1.5 }, + { name: "a negative sequence", overrides: { clineMessagesSeq: -1 }, receivedSeq: -1 }, + ])("rejects a snapshot start with $name", ({ overrides, receivedSeq }) => { + const postMessage = renderTranscriptWithPostMessageSpy({ clineMessagesSeq: 1 }) + + act(() => startSnapshot(overrides)) + + expect(postMessage).toHaveBeenCalledTimes(1) + expect(postMessage).toHaveBeenCalledWith({ + type: "requestClineMessagesResync", + taskId: "task-1", + expectedSeq: 2, + receivedSeq, + }) + }) + + it.each([ + { name: "a missing snapshot ID", overrides: { snapshotId: "" } }, + { name: "a nonnumeric total", overrides: { snapshotTotal: "1" } }, + { name: "a boolean total", overrides: { snapshotTotal: true } }, + { name: "a fractional total", overrides: { snapshotTotal: 1.5 } }, + { name: "a negative total", overrides: { snapshotTotal: -1 } }, + ])("rejects a snapshot start with $name", ({ overrides }) => { + const postMessage = renderTranscriptWithPostMessageSpy({ clineMessagesSeq: 1 }) + + act(() => startSnapshot(overrides)) + + expect(postMessage).toHaveBeenCalledTimes(1) + expect(postMessage).toHaveBeenCalledWith( + expect.objectContaining({ type: "requestClineMessagesResync", receivedSeq: 2 }), + ) + }) + + it("rejects a snapshot start for a different task before it can accept current-task chunks", () => { + const existing = makeMessage(1, "existing") + const postMessage = renderTranscriptWithPostMessageSpy({ clineMessages: [existing], clineMessagesSeq: 1 }) + + act(() => { + startSnapshot({ taskId: "task-2", clineMessagesSeq: 3, snapshotId: "wrong-task" }) + appendSnapshotChunk({ clineMessagesSeq: 3, snapshotId: "wrong-task" }) + }) + + expect(postMessage).toHaveBeenCalledTimes(1) + expect(postMessage).toHaveBeenCalledWith( + expect.objectContaining({ type: "requestClineMessagesResync", receivedSeq: 3 }), + ) + expect(readTranscriptFields()).toEqual({ + currentTaskId: "task-1", + clineMessages: [existing], + clineMessagesSeq: 1, + }) + }) + + it("ignores a snapshot older than the applied transcript", () => { + const existing = makeMessage(1, "existing") + const postMessage = renderTranscriptWithPostMessageSpy({ clineMessages: [existing], clineMessagesSeq: 2 }) + + act(() => { + startSnapshot({ clineMessagesSeq: 1, snapshotTotal: 0, snapshotId: "stale" }) + endSnapshot({ clineMessagesSeq: 1, snapshotTotal: 0, snapshotId: "stale" }) + }) + + expect(postMessage).not.toHaveBeenCalled() + expect(readTranscriptFields()).toEqual({ + currentTaskId: "task-1", + clineMessages: [existing], + clineMessagesSeq: 2, + }) + }) + + it("ignores a duplicate start without discarding collected chunks", () => { + renderTranscript({ clineMessagesSeq: 1 }) + + act(() => { + startSnapshot() + appendSnapshotChunk() + startSnapshot() + endSnapshot() + }) + + expect(readTranscriptFields()).toEqual({ + currentTaskId: "task-1", + clineMessages: [makeMessage(2, "snapshot")], + clineMessagesSeq: 2, + }) + }) + + it("ignores an older start without replacing the active snapshot", () => { + renderTranscript({ clineMessagesSeq: 1 }) + + act(() => { + startSnapshot({ clineMessagesSeq: 3, snapshotId: "newer" }) + appendSnapshotChunk({ clineMessagesSeq: 3, snapshotId: "newer" }) + startSnapshot({ clineMessagesSeq: 2, snapshotId: "older" }) + endSnapshot({ clineMessagesSeq: 3, snapshotId: "newer" }) + }) + + expect(readTranscriptFields()).toEqual({ + currentTaskId: "task-1", + clineMessages: [makeMessage(2, "snapshot")], + clineMessagesSeq: 3, + }) + }) + + it("replaces an active snapshot when the same ID arrives at a newer sequence", () => { + const replacement = makeMessage(3, "replacement") + const postMessage = renderTranscriptWithPostMessageSpy({ clineMessagesSeq: 1 }) + + act(() => { + startSnapshot({ clineMessagesSeq: 2, snapshotId: "reused-id" }) + startSnapshot({ clineMessagesSeq: 3, snapshotId: "reused-id" }) + appendSnapshotChunk({ clineMessagesSeq: 3, snapshotId: "reused-id", clineMessages: [replacement] }) + endSnapshot({ clineMessagesSeq: 3, snapshotId: "reused-id" }) + }) + + expect(postMessage).not.toHaveBeenCalled() + expect(readTranscriptFields()).toEqual({ + currentTaskId: "task-1", + clineMessages: [replacement], + clineMessagesSeq: 3, + }) + }) + + it.each([ + { name: "the same sequence uses a replacement ID", seq: 2, snapshotId: "replacement" }, + { name: "a newer sequence starts", seq: 3, snapshotId: "newer" }, + ])("replaces an active snapshot when $name", ({ seq, snapshotId }) => { + const replacement = makeMessage(seq, "replacement") + const postMessage = renderTranscriptWithPostMessageSpy({ clineMessagesSeq: 1 }) + + act(() => { + startSnapshot() + startSnapshot({ clineMessagesSeq: seq, snapshotId }) + appendSnapshotChunk({ clineMessagesSeq: seq, snapshotId, clineMessages: [replacement] }) + endSnapshot({ clineMessagesSeq: seq, snapshotId }) + }) + + expect(postMessage).not.toHaveBeenCalled() + expect(readTranscriptFields()).toEqual({ + currentTaskId: "task-1", + clineMessages: [replacement], + clineMessagesSeq: seq, + }) + }) + + it("accepts sequence zero throughout a complete snapshot", () => { + const message = makeMessage(1, "initial snapshot") + const postMessage = renderTranscriptWithPostMessageSpy() + + act(() => { + startSnapshot({ clineMessagesSeq: 0 }) + appendSnapshotChunk({ clineMessagesSeq: 0, clineMessages: [message] }) + endSnapshot({ clineMessagesSeq: 0 }) + }) + + expect(postMessage).not.toHaveBeenCalled() + expect(readTranscriptFields()).toEqual({ + currentTaskId: "task-1", + clineMessages: [message], + clineMessagesSeq: 0, + }) + }) + + it.each([ + { name: "a nonnumeric sequence", seq: "2", receivedSeq: undefined }, + { name: "a boolean sequence", seq: true, receivedSeq: undefined }, + { name: "a fractional sequence", seq: 1.5, receivedSeq: 1.5 }, + { name: "a negative sequence", seq: -1, receivedSeq: -1 }, + ])("rejects a snapshot chunk with $name", ({ seq, receivedSeq }) => { + const postMessage = renderTranscriptWithPostMessageSpy({ clineMessagesSeq: 1 }) + + act(() => { + startSnapshot() + appendSnapshotChunk({ clineMessagesSeq: seq }) + }) + + expect(postMessage).toHaveBeenCalledTimes(1) + expect(postMessage).toHaveBeenCalledWith({ + type: "requestClineMessagesResync", + taskId: "task-1", + expectedSeq: 2, + receivedSeq, + }) + }) + + it("invalidates an active snapshot after a malformed chunk", () => { + const postMessage = renderTranscriptWithPostMessageSpy({ clineMessagesSeq: 1 }) + + act(() => { + startSnapshot() + appendSnapshotChunk({ clineMessagesSeq: "invalid" }) + appendSnapshotChunk() + }) + + expect(postMessage).toHaveBeenCalledTimes(2) + expect(postMessage.mock.calls.map(([message]) => message.receivedSeq)).toEqual([undefined, 2]) + }) + + it.each([ + { name: "a missing snapshot", seq: 2, shouldResync: true }, + { name: "a stale missing snapshot", seq: 1, shouldResync: false }, + { name: "an equal missing snapshot", seq: 2, shouldResync: false, initialSeq: 2 }, + ])("handles a chunk with $name", ({ seq, shouldResync, initialSeq = 1 }) => { + const postMessage = renderTranscriptWithPostMessageSpy({ clineMessagesSeq: initialSeq }) + + act(() => appendSnapshotChunk({ clineMessagesSeq: seq, snapshotId: "missing" })) + + if (shouldResync) { + expect(postMessage).toHaveBeenCalledTimes(1) + expect(postMessage).toHaveBeenCalledWith( + expect.objectContaining({ type: "requestClineMessagesResync", receivedSeq: seq }), + ) + } else { + expect(postMessage).not.toHaveBeenCalled() + } + }) + + it.each([ + { + name: "a newer sequence", + overrides: { clineMessagesSeq: 3 }, + expectedResyncSeq: 3, + }, + { + name: "a newer ID and sequence", + overrides: { snapshotId: "newer", clineMessagesSeq: 3 }, + expectedResyncSeq: 3, + }, + ])("restarts after a chunk with $name", ({ overrides, expectedResyncSeq }) => { + const postMessage = renderTranscriptWithPostMessageSpy({ clineMessagesSeq: 1 }) + + act(() => { + startSnapshot() + appendSnapshotChunk(overrides) + }) + + expect(postMessage).toHaveBeenCalledTimes(1) + expect(postMessage).toHaveBeenCalledWith( + expect.objectContaining({ type: "requestClineMessagesResync", receivedSeq: expectedResyncSeq }), + ) + }) + + it.each([ + { name: "an older sequence", overrides: { clineMessagesSeq: 1 } }, + { name: "a different ID at the same sequence", overrides: { snapshotId: "other" } }, + ])("ignores a chunk with $name and preserves the active snapshot", ({ overrides }) => { + const postMessage = renderTranscriptWithPostMessageSpy({ clineMessagesSeq: 1 }) + + act(() => { + startSnapshot() + appendSnapshotChunk(overrides) + appendSnapshotChunk() + endSnapshot() + }) + + expect(postMessage).not.toHaveBeenCalled() + expect(readTranscriptFields()).toEqual({ + currentTaskId: "task-1", + clineMessages: [makeMessage(2, "snapshot")], + clineMessagesSeq: 2, + }) + }) + + it.each([ + { name: "a non-array payload", overrides: { clineMessages: "message" } }, + { name: "an empty payload", overrides: { clineMessages: [] } }, + { name: "a nonnumeric start index", overrides: { snapshotStartIndex: "0" } }, + { name: "a boolean start index", overrides: { snapshotStartIndex: true } }, + { name: "a fractional start index", overrides: { snapshotStartIndex: 0.5 } }, + { name: "a noncontiguous start index", overrides: { snapshotStartIndex: 1 } }, + { + name: "messages beyond the declared total", + overrides: { clineMessages: [makeMessage(2, "first"), makeMessage(3, "overflow")] }, + }, + ])("rejects a snapshot chunk with $name", ({ overrides }) => { + const postMessage = renderTranscriptWithPostMessageSpy({ clineMessagesSeq: 1 }) + + act(() => { + startSnapshot() + appendSnapshotChunk(overrides) + }) + + expect(postMessage).toHaveBeenCalledTimes(1) + expect(postMessage).toHaveBeenCalledWith( + expect.objectContaining({ type: "requestClineMessagesResync", receivedSeq: 2 }), + ) + }) + + it.each([ + { name: "a nonnumeric sequence", seq: "2", receivedSeq: undefined }, + { name: "a boolean sequence", seq: true, receivedSeq: undefined }, + { name: "a fractional sequence", seq: 1.5, receivedSeq: 1.5 }, + { name: "a negative sequence", seq: -1, receivedSeq: -1 }, + ])("rejects a snapshot end with $name", ({ seq, receivedSeq }) => { + const postMessage = renderTranscriptWithPostMessageSpy({ clineMessagesSeq: 1 }) + + act(() => { + startSnapshot() + endSnapshot({ clineMessagesSeq: seq }) + }) + + expect(postMessage).toHaveBeenCalledTimes(1) + expect(postMessage).toHaveBeenCalledWith({ + type: "requestClineMessagesResync", + taskId: "task-1", + expectedSeq: 2, + receivedSeq, + }) + }) + + it("invalidates an active snapshot after a malformed end", () => { + const postMessage = renderTranscriptWithPostMessageSpy({ clineMessagesSeq: 1 }) + + act(() => { + startSnapshot() + endSnapshot({ clineMessagesSeq: "invalid" }) + appendSnapshotChunk() + }) + + expect(postMessage).toHaveBeenCalledTimes(2) + expect(postMessage.mock.calls.map(([message]) => message.receivedSeq)).toEqual([undefined, 2]) + }) + + it.each([ + { name: "a missing snapshot", seq: 2, shouldResync: true }, + { name: "a stale missing snapshot", seq: 1, shouldResync: false }, + { name: "an equal missing snapshot", seq: 2, shouldResync: false, initialSeq: 2 }, + ])("handles an end with $name", ({ seq, shouldResync, initialSeq = 1 }) => { + const postMessage = renderTranscriptWithPostMessageSpy({ clineMessagesSeq: initialSeq }) + + act(() => endSnapshot({ clineMessagesSeq: seq, snapshotId: "missing" })) + + if (shouldResync) { + expect(postMessage).toHaveBeenCalledTimes(1) + expect(postMessage).toHaveBeenCalledWith( + expect.objectContaining({ type: "requestClineMessagesResync", receivedSeq: seq }), + ) + } else { + expect(postMessage).not.toHaveBeenCalled() + } + }) + + it.each([ + { name: "a newer sequence", overrides: { clineMessagesSeq: 3 } }, + { name: "a newer ID and sequence", overrides: { snapshotId: "newer", clineMessagesSeq: 3 } }, + ])("restarts after an end with $name", ({ overrides }) => { + const postMessage = renderTranscriptWithPostMessageSpy({ clineMessagesSeq: 1 }) + + act(() => { + startSnapshot() + endSnapshot(overrides) + }) + + expect(postMessage).toHaveBeenCalledTimes(1) + expect(postMessage).toHaveBeenCalledWith( + expect.objectContaining({ type: "requestClineMessagesResync", receivedSeq: 3 }), + ) + }) + + it.each([ + { name: "an older sequence", overrides: { clineMessagesSeq: 1 } }, + { name: "a different ID at the same sequence", overrides: { snapshotId: "other" } }, + ])("ignores an end with $name and preserves the active snapshot", ({ overrides }) => { + const postMessage = renderTranscriptWithPostMessageSpy({ clineMessagesSeq: 1 }) + + act(() => { + startSnapshot() + endSnapshot(overrides) + appendSnapshotChunk() + endSnapshot() + }) + + expect(postMessage).not.toHaveBeenCalled() + expect(readTranscriptFields()).toEqual({ + currentTaskId: "task-1", + clineMessages: [makeMessage(2, "snapshot")], + clineMessagesSeq: 2, + }) + }) + + it.each([ + { name: "a mismatched declared total", overrides: { snapshotTotal: 2 } }, + { name: "an incomplete message list", overrides: {} }, + ])("rejects a snapshot end with $name", ({ name, overrides }) => { + const postMessage = renderTranscriptWithPostMessageSpy({ clineMessagesSeq: 1 }) + + act(() => { + startSnapshot() + if (name === "a mismatched declared total") { + appendSnapshotChunk() + } + endSnapshot(overrides) + }) + + expect(postMessage).toHaveBeenCalledTimes(1) + expect(postMessage).toHaveBeenCalledWith( + expect.objectContaining({ type: "requestClineMessagesResync", receivedSeq: 2 }), + ) + }) it("reconstructs a snapshot and applies contiguous append and update deltas", () => { render( @@ -579,6 +1180,33 @@ describe("ExtensionStateContext", () => { }) }) + it("does not retain a pending transcript when the authoritative state clears the task", () => { + const postMessage = renderTranscriptWithPostMessageSpy({ + clineMessages: [makeMessage(1, "existing")], + clineMessagesSeq: 1, + }) + + act(() => { + startSnapshot({ clineMessagesSeq: 2, snapshotId: "pending" }) + dispatchExtensionMessage({ type: "state", state: { currentTaskId: null } }) + appendSnapshotChunk({ taskId: undefined, clineMessagesSeq: 2, snapshotId: "pending" }) + }) + + expect(postMessage).toHaveBeenCalledTimes(1) + expect(postMessage).toHaveBeenCalledWith( + expect.objectContaining({ type: "requestClineMessagesResync", taskId: undefined, receivedSeq: 2 }), + ) + expect(readTranscript()).toEqual({ + currentTaskId: null, + currentTaskItem: null, + currentTaskTodos: [], + messageQueue: [], + currentCheckpoint: null, + clineMessages: [], + clineMessagesSeq: 0, + }) + }) + it("preserves task-scoped state when a partial state update omits currentTaskId", () => { const existing = makeMessage(1, "existing") const currentTaskItem = { From 5feab4849a9a4ec128be40fd037a858248245c48 Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Fri, 4 Sep 2026 06:48:30 -0600 Subject: [PATCH 20/38] test: address transcript review feedback --- src/core/webview/__tests__/ClineProvider.spec.ts | 4 +--- .../src/context/__tests__/ExtensionStateContext.spec.tsx | 4 ++-- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 5508d05f7f..b22dca4809 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -1127,9 +1127,7 @@ describe("ClineProvider", () => { releaseQueue() await pendingDelta - expect(postSpy).not.toHaveBeenCalledWith( - expect.objectContaining({ type: messageType, taskId: "task-1" }), - ) + expect(postSpy).not.toHaveBeenCalledWith(expect.objectContaining({ type: messageType })) }, ) diff --git a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx index 09a1ea0323..594a0e2a74 100644 --- a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx +++ b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx @@ -613,13 +613,13 @@ describe("ExtensionStateContext", () => { act(() => appendClineMessage(makeMessage(3, "old gap"), 3, "task-1")) expect(postMessage).toHaveBeenCalledTimes(1) - const timeoutHandle = vi.getTimerCount() + const pendingTimerCount = vi.getTimerCount() act(() => { dispatchExtensionMessage({ type: "state", state: { currentTaskId: "task-2" } }) appendClineMessage(makeMessage(2, "new gap"), 2, "task-2") }) - expect(timeoutHandle).toBe(1) + expect(pendingTimerCount).toBe(1) expect(clearTimeout).toHaveBeenCalledTimes(1) expect(postMessage).toHaveBeenCalledTimes(2) expect(postMessage).toHaveBeenLastCalledWith({ From 5aa33599a1afae96a7ee8ebedbbb070dca253bfa Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Fri, 4 Sep 2026 07:22:41 -0600 Subject: [PATCH 21/38] fix: expire incomplete transcript snapshots --- .../src/context/ExtensionStateContext.tsx | 59 +++++++--- .../__tests__/ExtensionStateContext.spec.tsx | 110 ++++++++++++++++++ 2 files changed, 156 insertions(+), 13 deletions(-) diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index 35925a1708..ca2c095012 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -166,6 +166,7 @@ type ClineMessagesSnapshotBuffer = { } const CLINE_MESSAGES_RESYNC_TIMEOUT_MS = 5_000 +const CLINE_MESSAGES_SNAPSHOT_TIMEOUT_MS = 30_000 export const mergeExtensionState = (prevState: ExtensionState, newState: Partial) => { const { customModePrompts: prevCustomModePrompts, experiments: prevExperiments, ...prevRest } = prevState @@ -287,6 +288,7 @@ export const ExtensionStateContextProvider: React.FC<{ const clineMessagesSeqRef = useRef(state.clineMessagesSeq ?? 0) const clineMessagesRef = useRef(state.clineMessages) const activeSnapshotRef = useRef(null) + const snapshotTimeoutRef = useRef(undefined) const resyncPendingRef = useRef(false) const resyncTimeoutRef = useRef(undefined) @@ -350,6 +352,14 @@ export const ExtensionStateContextProvider: React.FC<{ [], ) + const clearClineMessagesSnapshot = useCallback(() => { + activeSnapshotRef.current = null + if (snapshotTimeoutRef.current !== undefined) { + window.clearTimeout(snapshotTimeoutRef.current) + snapshotTimeoutRef.current = undefined + } + }, []) + const requestClineMessagesResync = useCallback( (receivedSeq?: number) => { if (resyncPendingRef.current) { @@ -380,6 +390,25 @@ export const ExtensionStateContextProvider: React.FC<{ [clearClineMessagesResync, requestClineMessagesResync], ) + const startClineMessagesSnapshotTimeout = useCallback( + (snapshotId: string, seq: number) => { + if (snapshotTimeoutRef.current !== undefined) { + window.clearTimeout(snapshotTimeoutRef.current) + } + snapshotTimeoutRef.current = window.setTimeout(() => { + const snapshot = activeSnapshotRef.current + if (snapshot?.snapshotId !== snapshotId || snapshot.seq !== seq) { + snapshotTimeoutRef.current = undefined + return + } + activeSnapshotRef.current = null + snapshotTimeoutRef.current = undefined + retryClineMessagesResync(seq) + }, CLINE_MESSAGES_SNAPSHOT_TIMEOUT_MS) + }, + [retryClineMessagesResync], + ) + const applyClineMessagesDelta = useCallback( (message: ExtensionMessage, operation: "append" | "update") => { const seq = message.clineMessagesSeq as number @@ -399,7 +428,7 @@ export const ExtensionStateContextProvider: React.FC<{ if (seq <= snapshot.seq) { return } - activeSnapshotRef.current = null + clearClineMessagesSnapshot() retryClineMessagesResync(seq) return } @@ -433,7 +462,7 @@ export const ExtensionStateContextProvider: React.FC<{ })) }, // Stryker disable next-line ArrayDeclaration: both dependencies are stable callbacks; an empty dependency list produces the same closure for the provider lifetime. - [requestClineMessagesResync, retryClineMessagesResync], + [clearClineMessagesSnapshot, requestClineMessagesResync, retryClineMessagesResync], ) const handleMessage = useCallback( @@ -456,7 +485,7 @@ export const ExtensionStateContextProvider: React.FC<{ activeTaskIdRef.current = nextTaskId clineMessagesSeqRef.current = 0 clineMessagesRef.current = [] - activeSnapshotRef.current = null + clearClineMessagesSnapshot() clearClineMessagesResync() } setState((prevState) => { @@ -545,7 +574,7 @@ export const ExtensionStateContextProvider: React.FC<{ const seq = message.clineMessagesSeq as number if (!Number.isSafeInteger(seq) || seq < 0) { - activeSnapshotRef.current = null + clearClineMessagesSnapshot() retryClineMessagesResync(typeof seq === "number" ? seq : undefined) break } @@ -555,7 +584,7 @@ export const ExtensionStateContextProvider: React.FC<{ const total = message.snapshotTotal as number if (!message.snapshotId || !Number.isSafeInteger(total) || total < 0) { - activeSnapshotRef.current = null + clearClineMessagesSnapshot() retryClineMessagesResync(seq) break } @@ -575,6 +604,7 @@ export const ExtensionStateContextProvider: React.FC<{ total, messages: [], } + startClineMessagesSnapshotTimeout(message.snapshotId, seq) break } case "clineMessagesSnapshotChunk": { @@ -585,7 +615,7 @@ export const ExtensionStateContextProvider: React.FC<{ const seq = message.clineMessagesSeq as number const snapshot = activeSnapshotRef.current if (!Number.isSafeInteger(seq) || seq < 0) { - activeSnapshotRef.current = null + clearClineMessagesSnapshot() retryClineMessagesResync(typeof seq === "number" ? seq : undefined) break } @@ -597,7 +627,7 @@ export const ExtensionStateContextProvider: React.FC<{ } if (message.snapshotId !== snapshot.snapshotId || seq !== snapshot.seq) { if (seq > snapshot.seq) { - activeSnapshotRef.current = null + clearClineMessagesSnapshot() retryClineMessagesResync(seq) } break @@ -612,7 +642,7 @@ export const ExtensionStateContextProvider: React.FC<{ startIndex !== snapshot.messages.length || snapshot.messages.length + chunk.length > snapshot.total ) { - activeSnapshotRef.current = null + clearClineMessagesSnapshot() retryClineMessagesResync(seq) break } @@ -628,7 +658,7 @@ export const ExtensionStateContextProvider: React.FC<{ const seq = message.clineMessagesSeq as number const snapshot = activeSnapshotRef.current if (!Number.isSafeInteger(seq) || seq < 0) { - activeSnapshotRef.current = null + clearClineMessagesSnapshot() retryClineMessagesResync(typeof seq === "number" ? seq : undefined) break } @@ -640,18 +670,18 @@ export const ExtensionStateContextProvider: React.FC<{ } if (message.snapshotId !== snapshot.snapshotId || seq !== snapshot.seq) { if (seq > snapshot.seq) { - activeSnapshotRef.current = null + clearClineMessagesSnapshot() retryClineMessagesResync(seq) } break } if (message.snapshotTotal !== snapshot.total || snapshot.messages.length !== snapshot.total) { - activeSnapshotRef.current = null + clearClineMessagesSnapshot() retryClineMessagesResync(seq) break } - activeSnapshotRef.current = null + clearClineMessagesSnapshot() clearClineMessagesResync() clineMessagesRef.current = snapshot.messages clineMessagesSeqRef.current = snapshot.seq @@ -757,10 +787,12 @@ export const ExtensionStateContextProvider: React.FC<{ // Stryker disable next-line ArrayDeclaration: every listed dependency is a stable callback; removing the list does not change this listener closure. [ applyClineMessagesDelta, + clearClineMessagesSnapshot, clearClineMessagesResync, requestClineMessagesResync, retryClineMessagesResync, setListApiConfigMeta, + startClineMessagesSnapshotTimeout, ], ) @@ -769,11 +801,12 @@ export const ExtensionStateContextProvider: React.FC<{ window.addEventListener("message", handleMessage) return () => { window.removeEventListener("message", handleMessage) + clearClineMessagesSnapshot() clearClineMessagesResync() } }, // Stryker disable next-line ArrayDeclaration: both effect dependencies are stable callbacks, making an empty list behaviorally identical for the provider lifetime. - [clearClineMessagesResync, handleMessage], + [clearClineMessagesResync, clearClineMessagesSnapshot, handleMessage], ) useEffect(() => { diff --git a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx index 594a0e2a74..39ee0a9ccb 100644 --- a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx +++ b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx @@ -645,6 +645,116 @@ describe("ExtensionStateContext", () => { expect(vi.getTimerCount()).toBe(0) }) + it("abandons an incomplete snapshot and requests recovery after the snapshot timeout", () => { + vi.useFakeTimers() + const postMessage = renderTranscriptWithPostMessageSpy({ clineMessagesSeq: 1 }) + + act(() => { + startSnapshot() + appendSnapshotChunk() + vi.advanceTimersByTime(30_000) + }) + + expect(postMessage).toHaveBeenCalledTimes(1) + expect(postMessage).toHaveBeenCalledWith({ + type: "requestClineMessagesResync", + taskId: "task-1", + expectedSeq: 2, + receivedSeq: 2, + }) + expect(vi.getTimerCount()).toBe(1) + }) + + it("clears the snapshot timeout when a snapshot completes", () => { + vi.useFakeTimers() + const postMessage = renderTranscriptWithPostMessageSpy({ clineMessagesSeq: 1 }) + + act(() => { + startSnapshot() + appendSnapshotChunk() + endSnapshot() + vi.advanceTimersByTime(30_000) + }) + + expect(postMessage).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) + expect(readTranscriptFields()).toEqual({ + currentTaskId: "task-1", + clineMessages: [makeMessage(2, "snapshot")], + clineMessagesSeq: 2, + }) + }) + + it("restarts the snapshot timeout when a replacement snapshot starts", () => { + vi.useFakeTimers() + const postMessage = renderTranscriptWithPostMessageSpy({ clineMessagesSeq: 1 }) + const replacement = makeMessage(3, "replacement") + + act(() => { + startSnapshot() + vi.advanceTimersByTime(20_000) + startSnapshot({ clineMessagesSeq: 3, snapshotId: "replacement" }) + vi.advanceTimersByTime(20_000) + appendSnapshotChunk({ + clineMessagesSeq: 3, + snapshotId: "replacement", + clineMessages: [replacement], + }) + endSnapshot({ clineMessagesSeq: 3, snapshotId: "replacement" }) + }) + + expect(postMessage).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) + expect(readTranscriptFields()).toEqual({ + currentTaskId: "task-1", + clineMessages: [replacement], + clineMessagesSeq: 3, + }) + }) + + it("clears the snapshot timeout when a newer delta invalidates the snapshot", () => { + vi.useFakeTimers() + const postMessage = renderTranscriptWithPostMessageSpy({ clineMessagesSeq: 1 }) + + act(() => { + startSnapshot() + appendClineMessage(makeMessage(3, "newer delta"), 3, "task-1") + }) + + expect(postMessage).toHaveBeenCalledTimes(1) + expect(postMessage).toHaveBeenCalledWith( + expect.objectContaining({ type: "requestClineMessagesResync", receivedSeq: 3 }), + ) + expect(vi.getTimerCount()).toBe(1) + }) + + it("clears the snapshot timeout when the task changes", () => { + vi.useFakeTimers() + const postMessage = renderTranscriptWithPostMessageSpy({ clineMessagesSeq: 1 }) + + act(() => { + startSnapshot() + dispatchExtensionMessage({ type: "state", state: { currentTaskId: "task-2" } }) + vi.advanceTimersByTime(30_000) + }) + + expect(postMessage).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) + }) + + it("clears the snapshot timeout when the provider unmounts", () => { + vi.useFakeTimers() + const postMessage = vi.spyOn(vscode, "postMessage").mockImplementation(() => undefined) + const { unmount } = renderTranscript({ clineMessagesSeq: 1 }) + postMessage.mockClear() + + act(() => startSnapshot()) + expect(vi.getTimerCount()).toBe(1) + unmount() + + expect(vi.getTimerCount()).toBe(0) + }) + it.each([ { name: "a nonnumeric sequence", overrides: { clineMessagesSeq: "2" }, receivedSeq: undefined }, { name: "a boolean sequence", overrides: { clineMessagesSeq: true }, receivedSeq: undefined }, From 4f301d0f4bb1ec1847e8b05ae2befaf2aff75ec7 Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Fri, 4 Sep 2026 07:50:26 -0600 Subject: [PATCH 22/38] test: cover transcript snapshot timeout mutations --- .../src/context/ExtensionStateContext.tsx | 20 +++-- .../__tests__/ExtensionStateContext.spec.tsx | 81 ++++++++++++++++++- 2 files changed, 91 insertions(+), 10 deletions(-) diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index ca2c095012..d5f3ee8549 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -352,13 +352,17 @@ export const ExtensionStateContextProvider: React.FC<{ [], ) - const clearClineMessagesSnapshot = useCallback(() => { - activeSnapshotRef.current = null - if (snapshotTimeoutRef.current !== undefined) { - window.clearTimeout(snapshotTimeoutRef.current) - snapshotTimeoutRef.current = undefined - } - }, []) + const clearClineMessagesSnapshot = useCallback( + () => { + activeSnapshotRef.current = null + if (snapshotTimeoutRef.current !== undefined) { + window.clearTimeout(snapshotTimeoutRef.current) + snapshotTimeoutRef.current = undefined + } + }, + // Stryker disable next-line ArrayDeclaration: an inserted constant cannot change this ref-only callback's stable identity or captured values. + [], + ) const requestClineMessagesResync = useCallback( (receivedSeq?: number) => { @@ -398,7 +402,6 @@ export const ExtensionStateContextProvider: React.FC<{ snapshotTimeoutRef.current = window.setTimeout(() => { const snapshot = activeSnapshotRef.current if (snapshot?.snapshotId !== snapshotId || snapshot.seq !== seq) { - snapshotTimeoutRef.current = undefined return } activeSnapshotRef.current = null @@ -406,6 +409,7 @@ export const ExtensionStateContextProvider: React.FC<{ retryClineMessagesResync(seq) }, CLINE_MESSAGES_SNAPSHOT_TIMEOUT_MS) }, + // Stryker disable next-line ArrayDeclaration: retryClineMessagesResync is stable, so omitting it cannot alter callback identity or captured values. [retryClineMessagesResync], ) diff --git a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx index 39ee0a9ccb..58a49ebe46 100644 --- a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx +++ b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx @@ -645,6 +645,17 @@ describe("ExtensionStateContext", () => { expect(vi.getTimerCount()).toBe(0) }) + it("does not clear a nonexistent snapshot timeout when the first snapshot starts", () => { + vi.useFakeTimers() + renderTranscript({ clineMessagesSeq: 1 }) + const clearTimeout = vi.spyOn(window, "clearTimeout") + + act(() => startSnapshot()) + + expect(clearTimeout).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(1) + }) + it("abandons an incomplete snapshot and requests recovery after the snapshot timeout", () => { vi.useFakeTimers() const postMessage = renderTranscriptWithPostMessageSpy({ clineMessagesSeq: 1 }) @@ -755,15 +766,67 @@ describe("ExtensionStateContext", () => { expect(vi.getTimerCount()).toBe(0) }) + it.each([ + { name: "a replacement ID", clineMessagesSeq: 2, snapshotId: "replacement" }, + { name: "a replacement sequence", clineMessagesSeq: 3, snapshotId: "snapshot-1" }, + ])("ignores a stale timeout callback after $name takes ownership", ({ clineMessagesSeq, snapshotId }) => { + vi.useFakeTimers() + const postMessage = renderTranscriptWithPostMessageSpy({ clineMessagesSeq: 1 }) + const setTimeout = vi.spyOn(window, "setTimeout") + + act(() => startSnapshot()) + const staleTimeout = setTimeout.mock.calls[0]?.[0] + if (typeof staleTimeout !== "function") { + throw new Error("Expected the snapshot timeout callback to be scheduled") + } + const replacement = makeMessage(clineMessagesSeq, "replacement") + + act(() => { + startSnapshot({ clineMessagesSeq, snapshotId }) + staleTimeout() + appendSnapshotChunk({ clineMessagesSeq, snapshotId, clineMessages: [replacement] }) + endSnapshot({ clineMessagesSeq, snapshotId }) + }) + + expect(postMessage).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) + expect(readTranscriptFields()).toEqual({ + currentTaskId: "task-1", + clineMessages: [replacement], + clineMessagesSeq, + }) + }) + + it("ignores a stale timeout callback after its snapshot is cleared", () => { + vi.useFakeTimers() + const postMessage = renderTranscriptWithPostMessageSpy({ clineMessagesSeq: 1 }) + const setTimeout = vi.spyOn(window, "setTimeout") + + act(() => startSnapshot()) + const staleTimeout = setTimeout.mock.calls[0]?.[0] + if (typeof staleTimeout !== "function") { + throw new Error("Expected the snapshot timeout callback to be scheduled") + } + + act(() => dispatchExtensionMessage({ type: "state", state: { currentTaskId: "task-2" } })) + expect(vi.getTimerCount()).toBe(0) + expect(() => act(() => staleTimeout())).not.toThrow() + expect(postMessage).not.toHaveBeenCalled() + }) + it.each([ { name: "a nonnumeric sequence", overrides: { clineMessagesSeq: "2" }, receivedSeq: undefined }, { name: "a boolean sequence", overrides: { clineMessagesSeq: true }, receivedSeq: undefined }, { name: "a fractional sequence", overrides: { clineMessagesSeq: 1.5 }, receivedSeq: 1.5 }, { name: "a negative sequence", overrides: { clineMessagesSeq: -1 }, receivedSeq: -1 }, ])("rejects a snapshot start with $name", ({ overrides, receivedSeq }) => { + vi.useFakeTimers() const postMessage = renderTranscriptWithPostMessageSpy({ clineMessagesSeq: 1 }) - act(() => startSnapshot(overrides)) + act(() => { + startSnapshot() + startSnapshot(overrides) + }) expect(postMessage).toHaveBeenCalledTimes(1) expect(postMessage).toHaveBeenCalledWith({ @@ -772,6 +835,7 @@ describe("ExtensionStateContext", () => { expectedSeq: 2, receivedSeq, }) + expect(vi.getTimerCount()).toBe(1) }) it.each([ @@ -781,14 +845,19 @@ describe("ExtensionStateContext", () => { { name: "a fractional total", overrides: { snapshotTotal: 1.5 } }, { name: "a negative total", overrides: { snapshotTotal: -1 } }, ])("rejects a snapshot start with $name", ({ overrides }) => { + vi.useFakeTimers() const postMessage = renderTranscriptWithPostMessageSpy({ clineMessagesSeq: 1 }) - act(() => startSnapshot(overrides)) + act(() => { + startSnapshot() + startSnapshot(overrides) + }) expect(postMessage).toHaveBeenCalledTimes(1) expect(postMessage).toHaveBeenCalledWith( expect.objectContaining({ type: "requestClineMessagesResync", receivedSeq: 2 }), ) + expect(vi.getTimerCount()).toBe(1) }) it("rejects a snapshot start for a different task before it can accept current-task chunks", () => { @@ -987,6 +1056,7 @@ describe("ExtensionStateContext", () => { expectedResyncSeq: 3, }, ])("restarts after a chunk with $name", ({ overrides, expectedResyncSeq }) => { + vi.useFakeTimers() const postMessage = renderTranscriptWithPostMessageSpy({ clineMessagesSeq: 1 }) act(() => { @@ -998,6 +1068,7 @@ describe("ExtensionStateContext", () => { expect(postMessage).toHaveBeenCalledWith( expect.objectContaining({ type: "requestClineMessagesResync", receivedSeq: expectedResyncSeq }), ) + expect(vi.getTimerCount()).toBe(1) }) it.each([ @@ -1033,6 +1104,7 @@ describe("ExtensionStateContext", () => { overrides: { clineMessages: [makeMessage(2, "first"), makeMessage(3, "overflow")] }, }, ])("rejects a snapshot chunk with $name", ({ overrides }) => { + vi.useFakeTimers() const postMessage = renderTranscriptWithPostMessageSpy({ clineMessagesSeq: 1 }) act(() => { @@ -1044,6 +1116,7 @@ describe("ExtensionStateContext", () => { expect(postMessage).toHaveBeenCalledWith( expect.objectContaining({ type: "requestClineMessagesResync", receivedSeq: 2 }), ) + expect(vi.getTimerCount()).toBe(1) }) it.each([ @@ -1104,6 +1177,7 @@ describe("ExtensionStateContext", () => { { name: "a newer sequence", overrides: { clineMessagesSeq: 3 } }, { name: "a newer ID and sequence", overrides: { snapshotId: "newer", clineMessagesSeq: 3 } }, ])("restarts after an end with $name", ({ overrides }) => { + vi.useFakeTimers() const postMessage = renderTranscriptWithPostMessageSpy({ clineMessagesSeq: 1 }) act(() => { @@ -1115,6 +1189,7 @@ describe("ExtensionStateContext", () => { expect(postMessage).toHaveBeenCalledWith( expect.objectContaining({ type: "requestClineMessagesResync", receivedSeq: 3 }), ) + expect(vi.getTimerCount()).toBe(1) }) it.each([ @@ -1142,6 +1217,7 @@ describe("ExtensionStateContext", () => { { name: "a mismatched declared total", overrides: { snapshotTotal: 2 } }, { name: "an incomplete message list", overrides: {} }, ])("rejects a snapshot end with $name", ({ name, overrides }) => { + vi.useFakeTimers() const postMessage = renderTranscriptWithPostMessageSpy({ clineMessagesSeq: 1 }) act(() => { @@ -1156,6 +1232,7 @@ describe("ExtensionStateContext", () => { expect(postMessage).toHaveBeenCalledWith( expect.objectContaining({ type: "requestClineMessagesResync", receivedSeq: 2 }), ) + expect(vi.getTimerCount()).toBe(1) }) it("reconstructs a snapshot and applies contiguous append and update deltas", () => { From 52adfa96853d1cfd843e6bd7ef4c2a4939f77313 Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Sat, 5 Sep 2026 11:24:47 -0600 Subject: [PATCH 23/38] fix: address transcript transport review feedback --- src/core/task/Task.ts | 5 +- src/core/task/__tests__/Task.spec.ts | 62 +++++++++++++++++++ src/core/webview/ClineProvider.ts | 11 ++-- .../webview/__tests__/ClineProvider.spec.ts | 8 +++ .../__tests__/webviewMessageHandler.spec.ts | 18 ++++++ 5 files changed, 97 insertions(+), 7 deletions(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 0cbe647046..2427de743a 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -3090,7 +3090,10 @@ export class Task extends EventEmitter implements TaskLike { } satisfies ClineApiReqInfo) await this.saveClineMessages() - await this.updateClineMessage(this.clineMessages[lastApiReqIndex]) + const apiRequestMessage = this.clineMessages[lastApiReqIndex] + if (apiRequestMessage) { + await this.updateClineMessage(apiRequestMessage) + } try { let cacheWriteTokens = 0 diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index 70b50e7104..4537044468 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -3677,6 +3677,68 @@ describe("Cline", () => { }) }) + describe("recursivelyMakeClineRequests", () => { + it.each([ + ["publishes an API request row that remains after persistence", false, 1], + ["does not publish a stale API request row removed during persistence", true, 0], + ])("%s", async (_description, removeRequestDuringSave, expectedUpdateCount) => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + const taskAccess = getTaskTestAccess(task) + + vi.mocked(processUserContentMentions).mockResolvedValueOnce({ + content: [{ type: "text", text: "hello" }], + mode: undefined, + }) + vi.spyOn(task.diffViewProvider, "reset").mockResolvedValue(undefined as never) + vi.spyOn(taskAccess, "addToApiConversationHistory").mockResolvedValue(undefined) + vi.spyOn(taskAccess, "safeEnsureModelFetched").mockResolvedValue(undefined) + vi.spyOn(task.api, "getModel").mockReturnValue({ + id: mockApiConfig.apiModelId!, + info: { + supportsImages: false, + supportsPromptCache: true, + contextWindow: 200_000, + maxTokens: 4096, + } as ModelInfo, + }) + vi.spyOn(task, "attemptApiRequest").mockImplementation(() => { + throw new Error("stop after request-row update") + }) + vi.spyOn(task, "say").mockImplementation(async (type) => { + if (type === "api_req_started") { + task.clineMessages.push({ + ts: Date.now(), + type: "say", + say: "api_req_started", + text: "{}", + }) + } + return undefined as never + }) + vi.spyOn(taskAccess, "saveClineMessages").mockImplementation(async () => { + if (removeRequestDuringSave) { + // Simulate a concurrent delete/edit truncating the transcript while persistence is awaited. + task.clineMessages = [] + } + return true + }) + const updateSpy = vi.mocked(mockProvider.postClineMessageUpdated) + updateSpy.mockClear() + + await expect(task.recursivelyMakeClineRequests([{ type: "text", text: "hello" }])).resolves.toBe(true) + + expect(updateSpy).toHaveBeenCalledTimes(expectedUpdateCount) + if (!removeRequestDuringSave) { + expect(updateSpy).toHaveBeenCalledWith(task.taskId, expect.objectContaining({ say: "api_req_started" })) + } + }) + }) + describe("safeEnsureModelFetched", () => { it("loads model metadata before getModel is used", async () => { const task = new Task({ diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 415ba63af4..e618b057ba 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2641,9 +2641,10 @@ export class ClineProvider } /** - * Like postStateToWebview but intentionally omits taskHistory. The final - * postMessageToWebview boundary removes transcript fields from every generic - * state message. + * Compatibility name for callers that need a lightweight generic state post. + * Transcript fields are removed from every generic state message at the + * postMessageToWebview boundary, while the canonical method below also omits + * taskHistory. * * Rationale: * - Cloud event handlers (auth, settings, user-info) and mode changes trigger state pushes @@ -2654,9 +2655,7 @@ export class ClineProvider * (cloud auth, org settings, profiles, etc.) without interfering with task message streaming. */ async postStateToWebviewWithoutClineMessages(): Promise { - const state = await this.getStateToPostToWebview({ includeTaskHistory: false }) - const { taskHistory: _omitHistory, ...rest } = state - await this.postMessageToWebview({ type: "state", state: rest }) + await this.postStateToWebviewWithoutTaskHistory() } /** diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index b22dca4809..4e6b03a229 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -1563,6 +1563,14 @@ describe("ClineProvider", () => { expect(postMessageSpy.mock.calls[0]?.[0].state).not.toHaveProperty("taskHistory") }) + test("postStateToWebviewWithoutClineMessages delegates to the canonical lightweight state post", async () => { + const postStateSpy = vi.spyOn(provider, "postStateToWebviewWithoutTaskHistory").mockResolvedValue(undefined) + + await provider.postStateToWebviewWithoutClineMessages() + + expect(postStateSpy).toHaveBeenCalledOnce() + }) + test("getStateToPostToWebview computes task history once after its base state resolves", async () => { const historyItem = { id: "history-task", diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index 3fa1314031..1821ccf9dc 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -128,6 +128,24 @@ const mockClineProvider = { cwd: "/mock/workspace", } as unknown as ClineProvider +describe("webviewMessageHandler - launch", () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(mockClineProvider.customModesManager.getCustomModes).mockResolvedValue([]) + Object.assign(mockClineProvider, { + getMcpHub: vi.fn().mockReturnValue(undefined), + providerSettingsManager: { listConfig: vi.fn().mockResolvedValue(undefined) }, + }) + }) + + it("synchronizes focused state with task history", async () => { + await webviewMessageHandler(mockClineProvider, { type: "webviewDidLaunch" }) + + expect(mockClineProvider.syncFocusedTaskToWebview).toHaveBeenCalledOnce() + expect(mockClineProvider.syncFocusedTaskToWebview).toHaveBeenCalledWith({ includeTaskHistory: true }) + }) +}) + describe("webviewMessageHandler - transcript resync", () => { beforeEach(() => { vi.clearAllMocks() From 7b07fdfdcc4b17d119a487061e2377231f6d7dfe Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Sun, 6 Sep 2026 01:21:10 -0600 Subject: [PATCH 24/38] fix(task): await transcript snapshots after overwrite persistence --- src/core/task/Task.ts | 2 +- src/core/task/__tests__/Task.spec.ts | 68 ++++++++++++++++++++++++++-- 2 files changed, 66 insertions(+), 4 deletions(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 2427de743a..b2ca2d5585 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -1313,6 +1313,7 @@ export class Task extends EventEmitter implements TaskLike { if (persist) { await this.saveClineMessages(false) } + await this.providerRef.deref()?.postClineMessagesSnapshot(this.taskId, { bumpSeq: true }) } private hydrateClineMessages(messages: ClineMessage[]) { @@ -1327,7 +1328,6 @@ export class Task extends EventEmitter implements TaskLike { this.cloudSyncedMessageTimestamps.add(msg.ts) } } - await this.providerRef.deref()?.postClineMessagesSnapshot(this.taskId, { bumpSeq: true }) } private hydrateApiConversationHistory(messages: ApiMessage[]) { diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index 4537044468..aa0f280abc 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -2178,14 +2178,18 @@ describe("Cline", () => { vi.useRealTimers() }) - it("posts a bumped snapshot after overwriting the transcript", async () => { + it("waits for persistence before posting a bumped snapshot after overwriting the transcript", async () => { const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, task: "test task", startTask: false, }) - const saveSpy = vi.spyOn(getTaskTestAccess(task), "saveClineMessages").mockResolvedValue(true) + let releaseSave!: (saved: boolean) => void + const pendingSave = new Promise((resolve) => { + releaseSave = resolve + }) + const saveSpy = vi.spyOn(getTaskTestAccess(task), "saveClineMessages").mockReturnValueOnce(pendingSave) const messages = [ { ts: 1, @@ -2195,13 +2199,71 @@ describe("Cline", () => { }, ] - await task.overwriteClineMessages(messages) + const overwritePromise = task.overwriteClineMessages(messages) + + await Promise.resolve() + expect(task.clineMessages).toEqual(messages) + expect(saveSpy).toHaveBeenCalledWith(false) + expect(mockProvider.postClineMessagesSnapshot).not.toHaveBeenCalled() + + releaseSave(true) + await overwritePromise expect(saveSpy).toHaveBeenCalledOnce() expect(mockProvider.postClineMessagesSnapshot).toHaveBeenCalledOnce() expect(mockProvider.postClineMessagesSnapshot).toHaveBeenCalledWith(task.taskId, { bumpSeq: true }) }) + it.each([true, false])("awaits the overwrite snapshot when persist is %s", async (persist) => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + const saveSpy = vi.spyOn(getTaskTestAccess(task), "saveClineMessages").mockResolvedValue(true) + let releaseSnapshot!: () => void + const pendingSnapshot = new Promise((resolve) => { + releaseSnapshot = resolve + }) + const snapshotSpy = vi.mocked(mockProvider.postClineMessagesSnapshot).mockReturnValueOnce(pendingSnapshot) + const messages = [{ ts: 1, type: "say" as const, say: "text" as const, text: "replacement" }] + let overwriteFinished = false + const overwritePromise = task.overwriteClineMessages(messages, persist).then(() => { + overwriteFinished = true + }) + + await vi.waitFor(() => expect(snapshotSpy).toHaveBeenCalledWith(task.taskId, { bumpSeq: true })) + expect(task.clineMessages).toEqual(messages) + expect(saveSpy).toHaveBeenCalledTimes(persist ? 1 : 0) + expect(overwriteFinished).toBe(false) + + releaseSnapshot() + await overwritePromise + + expect(snapshotSpy).toHaveBeenCalledOnce() + expect(overwriteFinished).toBe(true) + }) + + it("propagates an overwrite snapshot failure after persistence", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + const saveSpy = vi.spyOn(getTaskTestAccess(task), "saveClineMessages").mockResolvedValue(true) + const snapshotError = new Error("snapshot failed") + vi.mocked(mockProvider.postClineMessagesSnapshot).mockRejectedValueOnce(snapshotError) + const messages = [{ ts: 1, type: "say" as const, say: "text" as const, text: "replacement" }] + + await expect(task.overwriteClineMessages(messages)).rejects.toThrow(snapshotError) + + expect(task.clineMessages).toEqual(messages) + expect(saveSpy).toHaveBeenCalledWith(false) + expect(mockProvider.postClineMessagesSnapshot).toHaveBeenCalledWith(task.taskId, { bumpSeq: true }) + }) + it("still overwrites the transcript when the provider reference is unavailable", async () => { const task = new Task({ provider: mockProvider, From b4854c17a691e91e353817d15a309239792a5fff Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Sun, 6 Sep 2026 02:33:23 -0600 Subject: [PATCH 25/38] fix(task): synchronize transcript snapshots on overwrite and resume --- src/core/task/Task.ts | 14 +- .../task/__tests__/Task.persistence.spec.ts | 208 +++++++++++++++++- src/core/task/__tests__/Task.spec.ts | 47 ++++ 3 files changed, 265 insertions(+), 4 deletions(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index b2ca2d5585..a0c4978398 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -1309,6 +1309,7 @@ export class Task extends EventEmitter implements TaskLike { * Also resets cloud sync tracking to avoid re-syncing previously synced messages. */ public async overwriteClineMessages(newMessages: ClineMessage[], persist = true) { + this.debouncedPostPartialMessageUpdate.cancel() this.hydrateClineMessages(newMessages) if (persist) { await this.saveClineMessages(false) @@ -2350,16 +2351,23 @@ export class Task extends EventEmitter implements TaskLike { await this.clearPendingActionAfterDurableResult(this.pendingAction.actionId) } - if (this.pendingAction) { - this.isInitialized = true - await this.resumePendingTaskAction(this.pendingAction) + if (this.abort || this.abandoned) { return } + // Publish the transcript after both histories hydrate, before any resume prompt or pending-action replay. + await this.providerRef.deref()?.postClineMessagesSnapshot(this.taskId, { bumpSeq: true }) + if (this.abort || this.abandoned) { return } + if (this.pendingAction) { + this.isInitialized = true + await this.resumePendingTaskAction(this.pendingAction) + return + } + const lastClineMessage = this.clineMessages .slice() .reverse() diff --git a/src/core/task/__tests__/Task.persistence.spec.ts b/src/core/task/__tests__/Task.persistence.spec.ts index d4428e8bf8..ce3821ac59 100644 --- a/src/core/task/__tests__/Task.persistence.spec.ts +++ b/src/core/task/__tests__/Task.persistence.spec.ts @@ -1375,8 +1375,18 @@ describe("Task persistence", () => { .spyOn(getTaskPersistenceAccess(task), "resumePendingTaskAction") .mockResolvedValue(undefined) const ask = vi.spyOn(task, "ask") + const snapshotDeferred = createDeferred() + const snapshot = vi + .mocked(mockProvider.postClineMessagesSnapshot) + .mockReturnValueOnce(snapshotDeferred.promise) - await getTaskPersistenceAccess(task).resumeTaskFromHistory() + const resumePromise = getTaskPersistenceAccess(task).resumeTaskFromHistory() + await vi.waitFor(() => expect(snapshot).toHaveBeenCalledWith(task.taskId, { bumpSeq: true })) + expect(replay).not.toHaveBeenCalled() + expect(task.clineMessages).toEqual([expect.objectContaining({ text: "Child" })]) + expect(task.apiConversationHistory).toHaveLength(1) + snapshotDeferred.resolve() + await resumePromise expect(replay).toHaveBeenCalledWith(pendingAction) expect(ask).not.toHaveBeenCalled() @@ -1418,6 +1428,49 @@ describe("Task persistence", () => { expect(task.ask).toHaveBeenCalledWith("resume_task") }) + it.each(["abort", "abandoned"] as const)( + "does not publish resumed history when %s occurs during pending-action reconciliation", + async (flag) => { + mockReadTaskMessages.mockResolvedValue([{ ts: 1, type: "say", say: "text", text: "Child" }]) + mockReadApiMessages.mockResolvedValue([ + { + role: "user", + content: [{ type: "tool_result", tool_use_id: "finish-action", content: "Denied" }], + }, + ]) + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + historyItem: { + id: "child-1", + number: 1, + ts: 1, + task: "Child", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + pendingAction, + }, + startTask: false, + }) + const clearing = createDeferred() + mockProvider.clearPendingTaskAction = vi.fn().mockReturnValueOnce(clearing.promise) + const ask = vi.spyOn(task, "ask") + const replay = vi.spyOn(getTaskPersistenceAccess(task), "resumePendingTaskAction") + const resumePromise = task.run() + + await vi.waitFor(() => expect(mockProvider.clearPendingTaskAction).toHaveBeenCalledOnce()) + task[flag] = true + clearing.resolve(true) + await resumePromise + + expect(mockProvider.postClineMessagesSnapshot).not.toHaveBeenCalled() + expect(ask).not.toHaveBeenCalled() + expect(replay).not.toHaveBeenCalled() + expect(mockSaveTaskMessages).not.toHaveBeenCalled() + }, + ) + it("clears pending metadata after the matching tool result is saved", async () => { mockProvider.clearPendingTaskAction = vi.fn().mockResolvedValue(true) const task = new Task({ @@ -1679,6 +1732,156 @@ describe("Task persistence", () => { }) describe("resumeTaskFromHistory", () => { + it.each(["active", "completed"] as const)( + "publishes hydrated history before the %s task resume prompt", + async (status) => { + const messages = [ + { ts: 1, type: "say", say: "text", text: "Saved transcript" }, + ] satisfies ClineMessage[] + const apiMessages: Task["apiConversationHistory"] = [{ role: "user", content: "Saved API history" }] + const apiRead = createDeferred() + const snapshotDeferred = createDeferred() + mockReadTaskMessages.mockResolvedValue(messages) + mockReadApiMessages.mockReturnValueOnce(apiRead.promise) + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + historyItem: { + id: "history-snapshot", + number: 1, + ts: 1, + task: "Saved task", + status, + tokensIn: 10, + tokensOut: 5, + totalCost: 0.001, + }, + initialStatus: status, + startTask: false, + }) + const snapshot = vi.mocked(mockProvider.postClineMessagesSnapshot).mockImplementationOnce(() => { + expect(task.clineMessages).toEqual(messages) + expect(task.apiConversationHistory).toEqual(apiMessages) + return snapshotDeferred.promise + }) + const stopAfterPrompt = new Error("stop after resume prompt") + const ask = vi.spyOn(task, "ask").mockRejectedValueOnce(stopAfterPrompt) + const resumePromise = task.run() + const completion = expect(resumePromise).rejects.toThrow(stopAfterPrompt) + + await vi.waitFor(() => expect(mockReadApiMessages).toHaveBeenCalledOnce()) + expect(snapshot).not.toHaveBeenCalled() + expect(ask).not.toHaveBeenCalled() + apiRead.resolve(apiMessages) + + await vi.waitFor(() => expect(snapshot).toHaveBeenCalledWith(task.taskId, { bumpSeq: true })) + expect(ask).not.toHaveBeenCalled() + expect(mockSaveTaskMessages).not.toHaveBeenCalled() + expect(mockSaveApiMessages).not.toHaveBeenCalled() + snapshotDeferred.resolve() + await completion + + expect(snapshot).toHaveBeenCalledOnce() + expect(ask).toHaveBeenCalledWith(status === "completed" ? "resume_completed_task" : "resume_task") + expect(mockSaveTaskMessages).not.toHaveBeenCalled() + expect(mockSaveApiMessages).not.toHaveBeenCalled() + }, + ) + + it.each(["abort", "abandoned"] as const)( + "does not prompt when %s occurs during resume snapshot delivery", + async (flag) => { + mockReadTaskMessages.mockResolvedValue([{ ts: 1, type: "say", say: "text", text: "Saved transcript" }]) + mockReadApiMessages.mockResolvedValue([{ role: "user", content: "Saved API history" }]) + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + historyItem: { + id: "cancel-resume-snapshot", + number: 1, + ts: 1, + task: "Saved task", + tokensIn: 10, + tokensOut: 5, + totalCost: 0.001, + }, + startTask: false, + }) + const snapshotDeferred = createDeferred() + const snapshot = vi + .mocked(mockProvider.postClineMessagesSnapshot) + .mockReturnValueOnce(snapshotDeferred.promise) + const ask = vi.spyOn(task, "ask").mockResolvedValue({ response: "noButtonClicked" }) + const resumePromise = task.run() + + await vi.waitFor(() => expect(snapshot).toHaveBeenCalledOnce()) + task[flag] = true + snapshotDeferred.resolve() + await resumePromise + + expect(ask).not.toHaveBeenCalled() + expect(mockSaveTaskMessages).not.toHaveBeenCalled() + expect(mockSaveApiMessages).not.toHaveBeenCalled() + }, + ) + + it("does not prompt or persist when the resume snapshot fails", async () => { + mockReadTaskMessages.mockResolvedValue([{ ts: 1, type: "say", say: "text", text: "Saved transcript" }]) + mockReadApiMessages.mockResolvedValue([{ role: "user", content: "Saved API history" }]) + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + historyItem: { + id: "failed-resume-snapshot", + number: 1, + ts: 1, + task: "Saved task", + tokensIn: 10, + tokensOut: 5, + totalCost: 0.001, + }, + startTask: false, + }) + const snapshotError = new Error("resume snapshot failed") + vi.mocked(mockProvider.postClineMessagesSnapshot).mockRejectedValueOnce(snapshotError) + const ask = vi.spyOn(task, "ask").mockResolvedValue({ response: "noButtonClicked" }) + + await expect(task.run()).rejects.toThrow(snapshotError) + + expect(ask).not.toHaveBeenCalled() + expect(mockSaveTaskMessages).not.toHaveBeenCalled() + expect(mockSaveApiMessages).not.toHaveBeenCalled() + }) + + it("can hydrate and reach the resume prompt without a provider reference", async () => { + mockReadTaskMessages.mockResolvedValue([{ ts: 1, type: "say", say: "text", text: "Saved transcript" }]) + mockReadApiMessages.mockResolvedValue([{ role: "user", content: "Saved API history" }]) + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + historyItem: { + id: "missing-provider-resume", + number: 1, + ts: 1, + task: "Saved task", + tokensIn: 10, + tokensOut: 5, + totalCost: 0.001, + }, + startTask: false, + }) + vi.spyOn(task["providerRef"], "deref").mockReturnValue(undefined) + const stopAfterPrompt = new Error("stop after resume prompt") + const ask = vi.spyOn(task, "ask").mockRejectedValueOnce(stopAfterPrompt) + + await expect(task.run()).rejects.toThrow(stopAfterPrompt) + + expect(task.clineMessages).toEqual([expect.objectContaining({ text: "Saved transcript" })]) + expect(task.apiConversationHistory).toEqual([expect.objectContaining({ content: "Saved API history" })]) + expect(ask).toHaveBeenCalledWith("resume_task") + expect(mockProvider.postClineMessagesSnapshot).not.toHaveBeenCalled() + }) + it.each(["not_found", "invalid", "io_error"] as const)( "does not persist when hydration fails with %s", async (kind) => { @@ -1708,6 +1911,7 @@ describe("Task persistence", () => { expect(askSpy).not.toHaveBeenCalled() expect(mockSaveTaskMessages).not.toHaveBeenCalled() expect(mockProvider.updateTaskHistory).not.toHaveBeenCalled() + expect(mockProvider.postClineMessagesSnapshot).not.toHaveBeenCalled() }, ) @@ -1825,6 +2029,7 @@ describe("Task persistence", () => { expect(task.clineMessages).toHaveLength(0) expect(task.apiConversationHistory).toHaveLength(0) expect(mockSaveTaskMessages).not.toHaveBeenCalled() + expect(mockProvider.postClineMessagesSnapshot).not.toHaveBeenCalled() }) it("stops after API history hydration when the task is aborted", async () => { @@ -1862,6 +2067,7 @@ describe("Task persistence", () => { expect(askSpy).not.toHaveBeenCalled() expect(mockSaveTaskMessages).not.toHaveBeenCalled() expect(mockProvider.updateTaskHistory).not.toHaveBeenCalled() + expect(mockProvider.postClineMessagesSnapshot).not.toHaveBeenCalled() }) }) diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index aa0f280abc..d8398f77e7 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -2245,6 +2245,53 @@ describe("Cline", () => { expect(overwriteFinished).toBe(true) }) + it.each([true, false])("cancels stale partial updates before an overwrite with persist %s", async (persist) => { + vi.useFakeTimers() + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + const taskAccess = getTaskTestAccess(task) + let releaseSave!: (saved: boolean) => void + const pendingSave = new Promise((resolve) => { + releaseSave = resolve + }) + vi.spyOn(taskAccess, "saveClineMessages").mockReturnValueOnce(pendingSave) + const staleMessage = { + ts: 1, + type: "say" as const, + say: "text" as const, + text: "removed partial", + partial: true, + } + const replacement = { ...staleMessage, ts: 2, text: "replacement partial" } + task.clineMessages = [staleMessage] + await taskAccess.updateClineMessage(staleMessage) + + const overwritePromise = task.overwriteClineMessages([replacement], persist) + await vi.advanceTimersByTimeAsync(500) + + expect(task.clineMessages).toEqual([replacement]) + expect(mockProvider.postClineMessageUpdated).not.toHaveBeenCalled() + expect(mockProvider.postClineMessagesSnapshot).toHaveBeenCalledTimes(persist ? 0 : 1) + + releaseSave(true) + await overwritePromise + await vi.advanceTimersByTimeAsync(500) + + expect(mockProvider.postClineMessagesSnapshot).toHaveBeenCalledOnce() + expect(mockProvider.postClineMessagesSnapshot).toHaveBeenCalledWith(task.taskId, { bumpSeq: true }) + expect(mockProvider.postClineMessageUpdated).not.toHaveBeenCalled() + + await taskAccess.updateClineMessage(replacement) + await vi.advanceTimersByTimeAsync(500) + + expect(mockProvider.postClineMessageUpdated).toHaveBeenCalledOnce() + expect(mockProvider.postClineMessageUpdated).toHaveBeenCalledWith(task.taskId, replacement) + }) + it("propagates an overwrite snapshot failure after persistence", async () => { const task = new Task({ provider: mockProvider, From b7daa94a8763a016ae292672be7fe07becfcfb97 Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Sun, 6 Sep 2026 02:52:43 -0600 Subject: [PATCH 26/38] test(task): assert readiness before pending action replay --- src/core/task/__tests__/Task.persistence.spec.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/core/task/__tests__/Task.persistence.spec.ts b/src/core/task/__tests__/Task.persistence.spec.ts index ce3821ac59..f76644bac4 100644 --- a/src/core/task/__tests__/Task.persistence.spec.ts +++ b/src/core/task/__tests__/Task.persistence.spec.ts @@ -1373,7 +1373,9 @@ describe("Task persistence", () => { }) const replay = vi .spyOn(getTaskPersistenceAccess(task), "resumePendingTaskAction") - .mockResolvedValue(undefined) + .mockImplementation(async () => { + expect(task.isInitialized).toBe(true) + }) const ask = vi.spyOn(task, "ask") const snapshotDeferred = createDeferred() const snapshot = vi From 95b581e8372d489b3e0c0426d8229ff741d1ce1b Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Mon, 7 Sep 2026 09:28:17 -0600 Subject: [PATCH 27/38] fix(tests): await theme transitions before visual assertions --- webview-ui/playwright/themes.ts | 8 ++- .../AccessibilityContrast.visual.tsx | 53 +++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/webview-ui/playwright/themes.ts b/webview-ui/playwright/themes.ts index 65ec5ee384..1742a3e5e8 100644 --- a/webview-ui/playwright/themes.ts +++ b/webview-ui/playwright/themes.ts @@ -18,11 +18,17 @@ export const visualThemes: VisualTheme[] = [ ] export async function applyVisualTheme(page: Page, theme: VisualTheme) { - await page.evaluate(({ bodyClass, themeId }) => { + await page.evaluate(async ({ bodyClass, themeId }) => { document.documentElement.className = bodyClass document.documentElement.removeAttribute("style") document.body.className = bodyClass document.body.removeAttribute("style") document.body.dataset.vscodeThemeId = themeId + + // Flush the theme style change and wait for final colors before contrast/layout checks. + // Only CSS transitions are relevant here; loading animations may loop forever. + const transitions = document.getAnimations().filter((animation) => animation instanceof CSSTransition) + // A transition canceled by a component update is also settled. + await Promise.allSettled(transitions.map((transition) => transition.finished)) }, theme) } diff --git a/webview-ui/src/components/ui/__tests__/AccessibilityContrast.visual.tsx b/webview-ui/src/components/ui/__tests__/AccessibilityContrast.visual.tsx index 8571c1a05a..4f3c996d95 100644 --- a/webview-ui/src/components/ui/__tests__/AccessibilityContrast.visual.tsx +++ b/webview-ui/src/components/ui/__tests__/AccessibilityContrast.visual.tsx @@ -3,6 +3,59 @@ import { expectContrast } from "../../../../playwright/contrast" import { mountedStory } from "../../../../playwright/mounted-story" import { applyVisualTheme, visualThemes } from "../../../../playwright/themes" +test("settles theme transitions without waiting for looping animations", async ({ mount, page }) => { + const component = mountedStory(await mount("accessibility-contrast")) + const input = component.getByRole("textbox", { name: "API endpoint" }) + await input.evaluate((element) => { + element.style.transitionDuration = "1s" + element.style.transitionDelay = "100ms" + }) + await page.addStyleTag({ + content: ` + @keyframes theme-test-spin { to { transform: rotate(360deg); } } + [data-testid="unsupported-gradient"] { animation: theme-test-spin 1s linear infinite; } + `, + }) + + for (const theme of visualThemes) { + await applyVisualTheme(page, theme) + // Do not retry: the helper must return with final colors, not an intermediate frame. + expect( + await input.evaluate( + (element) => element.getAnimations().filter((animation) => animation instanceof CSSTransition).length, + ), + ).toBe(0) + await expectContrast(input, { background: input, label: `${theme.name} settled input text` }) + } + + expect( + await page.evaluate(() => + document + .getAnimations() + .some((animation) => animation instanceof CSSAnimation && animation.playState === "running"), + ), + ).toBe(true) +}) + +test("allows theme transitions to be canceled while settling", async ({ mount, page }) => { + const component = mountedStory(await mount("accessibility-contrast")) + const input = component.getByRole("textbox", { name: "API endpoint" }) + await input.evaluate((element) => { + element.style.transitionDuration = "1s" + element.addEventListener( + "transitionrun", + () => { + element.style.transitionProperty = "none" + }, + { once: true }, + ) + }) + + await applyVisualTheme(page, visualThemes[1]) + expect(await input.evaluate((element) => getComputedStyle(element).transitionProperty)).toBe("none") + await expectContrast(input, { background: input, label: "input text after a canceled transition" }) +}) + for (const theme of visualThemes) { test(`audits representative controls in the VS Code ${theme.name} theme`, async ({ mount, page }) => { const component = mountedStory(await mount("accessibility-contrast")) From 0c8edf83ddd55fa422e5dafc6c5b3d8da3344136 Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Mon, 7 Sep 2026 15:41:37 -0600 Subject: [PATCH 28/38] test(webview): assert injected animation by target and identity --- .../__tests__/AccessibilityContrast.visual.tsx | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/webview-ui/src/components/ui/__tests__/AccessibilityContrast.visual.tsx b/webview-ui/src/components/ui/__tests__/AccessibilityContrast.visual.tsx index 4f3c996d95..c5a976c37f 100644 --- a/webview-ui/src/components/ui/__tests__/AccessibilityContrast.visual.tsx +++ b/webview-ui/src/components/ui/__tests__/AccessibilityContrast.visual.tsx @@ -29,11 +29,18 @@ test("settles theme transitions without waiting for looping animations", async ( } expect( - await page.evaluate(() => - document - .getAnimations() - .some((animation) => animation instanceof CSSAnimation && animation.playState === "running"), - ), + await component + .getByTestId("unsupported-gradient") + .evaluate((element) => + element + .getAnimations() + .some( + (animation) => + animation instanceof CSSAnimation && + animation.animationName === "theme-test-spin" && + animation.playState === "running", + ), + ), ).toBe(true) }) From 53cbb970074df9853b10423fff9e897e7b581c83 Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Mon, 7 Sep 2026 20:02:36 -0600 Subject: [PATCH 29/38] fix(webview): capture transcript snapshots before queueing --- src/core/webview/ClineProvider.ts | 3 +- .../webview/__tests__/ClineProvider.spec.ts | 117 ++++++++++++++---- 2 files changed, 98 insertions(+), 22 deletions(-) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index e618b057ba..8219fc7e0f 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -1585,6 +1585,8 @@ export class ClineProvider ? this.bumpClineMessagesSeq(taskId) : this.getClineMessagesSeq(taskId) : 0 + // Capture the payload with its sequence so later deltas cannot leak into this snapshot. + const messages = structuredClone(currentTask?.clineMessages ?? []) const snapshotId = `${taskId ?? "none"}:${++this.nextClineMessagesSnapshotId}` const generation = options.generation ?? this.clineMessagesTransportGeneration @@ -1595,7 +1597,6 @@ export class ClineProvider if (!isCurrent()) { return } - const messages = structuredClone(currentTask?.clineMessages ?? []) await this.postMessageToWebview({ type: "clineMessagesSnapshotStart", diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 4e6b03a229..c9e72e3afc 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -1060,6 +1060,80 @@ describe("ClineProvider", () => { expect(new Set(posts.map(({ snapshotId }) => snapshotId)).size).toBe(1) }) + test.each([false, true])( + "captures snapshot payload with its sequence before queued deltas (bumpSeq=%s)", + async (bumpSeq) => { + const messages = Array.from( + { length: 200 }, + (_, index): ClineMessage => ({ + ts: index + 1, + type: "say", + say: "text", + text: `message ${index + 1}`, + images: ["original-image"], + }), + ) + const task = { taskId: "task-1", clineMessages: messages } + setCurrentTask(task) + provider["clineMessagesSeqByTaskId"].set(task.taskId, 4) + const postSpy = vi.spyOn(provider, "postMessageToWebview").mockResolvedValue(undefined) + const expectedSnapshot = structuredClone(messages) + let releaseQueue!: () => void + provider["clineMessagesPostQueue"] = new Promise((resolve) => { + releaseQueue = resolve + }) + + const snapshot = provider.postClineMessagesSnapshot(task.taskId, { bumpSeq }) + const appended: ClineMessage = { ts: 201, type: "say", say: "text", text: "appended after snapshot" } + task.clineMessages.push(appended) + const append = provider.postClineMessageAppended(task.taskId, appended) + messages[0].text = "updated after snapshot" + messages[0].images?.push("updated-image") + const update = provider.postClineMessageUpdated(task.taskId, messages[0]) + releaseQueue() + await Promise.all([snapshot, append, update]) + + const snapshotSeq = bumpSeq ? 5 : 4 + const snapshotId = "task-1:1" + expect(postSpy.mock.calls.map(([message]) => message)).toEqual([ + { + type: "clineMessagesSnapshotStart", + taskId: task.taskId, + clineMessagesSeq: snapshotSeq, + snapshotId, + snapshotTotal: 200, + }, + { + type: "clineMessagesSnapshotChunk", + taskId: task.taskId, + clineMessagesSeq: snapshotSeq, + snapshotId, + snapshotStartIndex: 0, + clineMessages: expectedSnapshot, + }, + { + type: "clineMessagesSnapshotEnd", + taskId: task.taskId, + clineMessagesSeq: snapshotSeq, + snapshotId, + snapshotTotal: 200, + }, + { + type: "clineMessageAppended", + taskId: task.taskId, + clineMessagesSeq: snapshotSeq + 1, + clineMessage: appended, + }, + { + type: "clineMessageUpdated", + taskId: task.taskId, + clineMessagesSeq: snapshotSeq + 2, + clineMessage: messages[0], + }, + ]) + }, + ) + test.each([ ["append", "clineMessageAppended"], ["update", "clineMessageUpdated"], @@ -1215,33 +1289,34 @@ describe("ClineProvider", () => { ) }) - test("drops a snapshot invalidated before its first post without cloning it", async () => { - const task = { - taskId: "task-1", - clineMessages: [{ ts: 1, type: "say", say: "text", text: "message" }] as ClineMessage[], - } - setCurrentTask(task) - const postSpy = vi.spyOn(provider, "postMessageToWebview") - let releaseQueue!: () => void - Object.assign(provider, { - clineMessagesPostQueue: new Promise((resolve) => { - releaseQueue = resolve - }), - }) + test.each(["focus", "generation"] as const)( + "drops a snapshot when %s changes before its first post", + async (change) => { + const task = { + taskId: "task-1", + clineMessages: [{ ts: 1, type: "say", say: "text", text: "message" }] as ClineMessage[], + } + setCurrentTask(task) + const postSpy = vi.spyOn(provider, "postMessageToWebview") + let releaseQueue!: () => void + Object.assign(provider, { + clineMessagesPostQueue: new Promise((resolve) => { + releaseQueue = resolve + }), + }) - const structuredCloneSpy = vi.spyOn(globalThis, "structuredClone") - try { const snapshot = provider.postClineMessagesSnapshot("task-1") - task.taskId = "task-2" + if (change === "focus") { + task.taskId = "task-2" + } else { + provider["clineMessagesTransportGeneration"]++ + } releaseQueue() await snapshot expect(postSpy).not.toHaveBeenCalled() - expect(structuredCloneSpy).not.toHaveBeenCalled() - } finally { - structuredCloneSpy.mockRestore() - } - }) + }, + ) test("uses monotonic task-scoped snapshot IDs and an empty no-task snapshot", async () => { await provider.resolveWebviewView(mockWebviewView) From f7591e7db56d598718a3a2ff56ea7f48a2893f70 Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Fri, 11 Sep 2026 08:12:07 -0600 Subject: [PATCH 30/38] test(webview): align upstream history test with empty-task transport Preserve upstream default-profile coverage after rebasing PR #1360 while retaining the explicit null focused-task identity required by transcript transport. Review-thread implementation remains deferred. --- src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts b/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts index 2bbf0736c6..c38bfdcad8 100644 --- a/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts @@ -647,7 +647,7 @@ describe("ClineProvider Task History Synchronization", () => { const state = await provider.getStateToPostToWebview() - expect(state.currentTaskId).toBeUndefined() + expect(state.currentTaskId).toBeNull() expect(state.currentApiConfigName).toBe("default") }) From 7346fbb38105022a777fc735d736022c45bf4064 Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Fri, 11 Sep 2026 09:36:28 -0600 Subject: [PATCH 31/38] fix(webview): release stale transcript work and verify transport protocol --- docs/architecture/task-lifecycle-model.md | 11 +- .../transcript-transport-model.md | 86 +++ package.json | 3 +- packages/types/src/vscode-extension-host.ts | 58 ++ scripts/check-transcript-transport.ts | 17 + src/__tests__/extension.spec.ts | 16 +- src/__tests__/helpers/provider-stub.ts | 9 +- src/core/webview/ClineProvider.ts | 165 ++---- .../webview/__tests__/ClineProvider.spec.ts | 503 +++++++++++----- .../__tests__/transcriptTransport.model.ts | 554 ++++++++++++++++++ .../__tests__/transcriptTransport.spec.ts | 146 +++++ .../__tests__/webviewMessageHandler.spec.ts | 37 +- src/core/webview/transcriptTransport.ts | 274 +++++++++ src/core/webview/webviewMessageHandler.ts | 4 +- src/extension.ts | 4 +- 15 files changed, 1596 insertions(+), 291 deletions(-) create mode 100644 docs/architecture/transcript-transport-model.md create mode 100644 scripts/check-transcript-transport.ts create mode 100644 src/core/webview/__tests__/transcriptTransport.model.ts create mode 100644 src/core/webview/__tests__/transcriptTransport.spec.ts create mode 100644 src/core/webview/transcriptTransport.ts diff --git a/docs/architecture/task-lifecycle-model.md b/docs/architecture/task-lifecycle-model.md index 9266d49987..765a39a4a8 100644 --- a/docs/architecture/task-lifecycle-model.md +++ b/docs/architecture/task-lifecycle-model.md @@ -6,14 +6,15 @@ Zoo Code checks task lifecycle protocols through one compositional verification pnpm lifecycle:model-check ``` -The command runs six independent bounded submodels in sequence: +The command runs seven independent bounded submodels in sequence: 1. the persisted task delegation lifecycle; 2. shared-store concurrency across task-history hosts; 3. production-backed provider handoff and scheduler ordering; 4. the task cleanup protocol; -5. request-stream parser scoping; and -6. completion persistence. +5. request-stream parser scoping; +6. completion persistence; and +7. production-backed transcript transport ownership and snapshot ordering. This umbrella command is the single model-check entry point in the `compile` CI job after type checking. Command-level composition does not merge the submodels' state spaces: each checker retains its own bounds, transitions, invariant ownership, reachability requirements, and counterexample format. In particular, parser state is not part of the persisted lifecycle graph. The focused parser checker remains directly runnable with `pnpm parser-scope:model-check` for debugging. @@ -80,6 +81,10 @@ The known-unsafe witnesses currently compare exact shortest action sequences. Th The umbrella command also runs a separate bounded child model for in-memory abort, disposal, and provider-shutdown ordering. It models cleanup settlement and rejection as environment transitions and makes no filesystem, editor Promise, fairness, or timing-liveness claim. See [Task cleanup protocol model check](./task-cleanup-protocol-model.md). +## Transcript transport model + +The umbrella command also runs **pnpm transcript-transport:model-check**, an exhaustive bounded explorer over the same production reducer used by the provider's transcript driver. It checks cancellable FIFO ownership, the single physical-send barrier across invalidations, task-scoped sequences, and atomic snapshot start/chunk/end ordering. Named landmarks require held posts, repeated resync, task switching/clear, queued deltas, and failure/recovery; injected legacy/mutant policies demonstrate invariant sensitivity. Its receiver oracle is not the React implementation, and an already-initiated physical send may complete after invalidation. See [Transcript transport ownership and bounded verification](./transcript-transport-model.md) for exact bounds, correspondence, counterexamples, and limitations. This independent protocol does not extend the persisted lifecycle state space. + ## Provider handoff and scheduler model `scripts/check-provider-handoff-scheduler.ts` is a separate bounded adapter model for the runtime boundary that the persisted lifecycle graph does not represent. Its breadth-first explorer normalizes provider-keyed records and owner arrays before deduplicating canonical states, then exhaustively explores enabled action orderings through depth 15 with a 20,000-state budget. It imports `selectHandoffExecutionContext` and the existing `delegateTaskToChild` and `completeDelegatedChild` reducers. A direct saved, unsaved, and locked-profile matrix verifies task-local configuration isolation. Stale provider lookup is caught before this pure selector, so focused provider tests verify the failed lookup, contextual log, and fallback. The protocol state then models two provider instances, their claims and parent snapshots, authoritative parent/child records, current task publication, commit/start ownership, the child scheduler permit, queued and resumed parent state, and one bounded redelegation generation. diff --git a/docs/architecture/transcript-transport-model.md b/docs/architecture/transcript-transport-model.md new file mode 100644 index 0000000000..33e4d8f9a8 --- /dev/null +++ b/docs/architecture/transcript-transport-model.md @@ -0,0 +1,86 @@ +# Transcript transport: ownership and bounded verification + +Run the focused checker with **pnpm transcript-transport:model-check**. It also runs as the seventh independent submodel in **pnpm lifecycle:model-check**, wired in [package.json](../../package.json). It does not change persisted task lifecycle reducers or workflow files. + +## Production boundary + +[TranscriptTransport](../../src/core/webview/transcriptTransport.ts) owns generation, task-scoped sequence allocation, explicit FIFO job descriptors, snapshot progress, and one physical-send barrier. [ClineProvider](../../src/core/webview/ClineProvider.ts) supplies current focus and the webview post callback. The provider's append/update/snapshot signatures and legacy CLI branches are unchanged. Resync additionally accepts optional client sequence diagnostics for metadata-only logging; they never select or modify the authoritative snapshot revision. + +The driver and the explorer both call [reduceTranscriptTransport](../../src/core/webview/transcriptTransport.ts) for admission, allocation, invalidation, task-sequence pruning, send initiation, and settlement. They also share the production frame-to-message conversion. This is not a separate queue specification that only resembles production. + +Payloads and caller resolvers live in driver-owned maps, outside the pure state. Invalidation synchronously removes all waiting jobs and their payload references, releases the active snapshot's unsent suffix, and resolves discarded waiting callers. There is no retained chain of old-generation closures. A physical post already invoked remains the sole in-flight owner until its Promise settles; its caller settles at that boundary. New-generation jobs may queue but cannot send until that barrier is released. Every later snapshot start, chunk, or end initiation rechecks generation and current focus. Rejection terminates that job, rejects its caller, logs the failure, and permits the next job to run. + +The driver intentionally **deep-clones at enqueue time**. Tasks mutate message objects and nested arrays while a post is waiting; shallow copying or cloning at drain time would pair an earlier sequence with later content. A stale-generation guard runs before cloning and before allocating either a sequence or snapshot ID. A second admission check protects the captured payload's ownership. + +The provider tests in [ClineProvider.spec.ts](../../src/core/webview/__tests__/ClineProvider.spec.ts) hold real post callbacks rather than injecting a private Promise queue. They retain focus-only and generation-only cancellation, CLI behavior, snapshot/delta ordering, deep snapshot isolation, and exact-boundary checks. The queued append/update regression mutates nested image arrays. The 401-message regression compares all three chunks to the exact corresponding original slices. Repeated-resync tests retain a held start or chunk, discard 26 waiting jobs, assert immediate payload/caller release, and prove one physical send and no stale end. + +## Exhaustive bounded state space + +The [explorer](../../src/core/webview/__tests__/transcriptTransport.model.ts) uses deterministic breadth-first search with canonical state deduplication. It explores every enabled ordering in four bounded scenarios; this is not randomized scheduling or a hand-selected trace list. A producer and controller retain their own program order, while admission, send initiation, send success/failure, focus change, and invalidation may interleave at every enabled boundary. + +| Scenario | Producer order | Controller order | Reachable states | Transitions | Maximum shortest depth | +| --------------------------------- | -------------------------- | ----------------------------------------- | ---------------: | ----------: | ---------------------: | +| Queued deltas / repeated resync | snapshot, append, update | resync, resync | 13,292 | 19,281 | 33 | +| Task switch / clear | snapshot, append, snapshot | switch to second task, clear | 7,523 | 10,334 | 33 | +| Invalidation / recovery | snapshot, update, snapshot | invalidate, resync | 6,030 | 8,149 | 31 | +| Focus before sync / stale request | snapshot, append, update | focus second task, resync, stale snapshot | 5,746 | 10,330 | 24 | + +These totals are diagnostics, not hard-coded ratchets: 32,591 states across independently explored scenarios and 48,094 examined transitions. Bounds are **two task IDs plus no task, up to five admitted jobs, two invalidations, four messages per snapshot, chunk size two, and at most one failed physical send per trace**. Standalone producer snapshots bump the sequence; resync snapshots retain the current sequence. Empty, exact-boundary, and multi-chunk snapshots arise within the bounds. Production uses chunk size 200; the provider regression checks 401 messages at the real chunk size. + +Each scenario has a **30,000-state budget and depth limit 40**. The checker fails on the first unseen successor beyond either bound, missing required action/landmark coverage, or any invariant violation. There is no truncated success. Every failure reports its scenario, bounds, shortest action trace, intermediate states, and the violating state. Mutants select the shortest witness across all four scenario graphs with stable tie ordering. + +The model exposes a scheduling point between settlement and the next pump, and between enqueue and pump. The production driver performs these synchronously within its continuation. This is a conservative scheduling over-approximation, not a claim that every model event boundary corresponds to an independently schedulable JavaScript callback. + +## Invariants and scope + +1. Generation increases exactly once per invalidation and never otherwise. Stale-generation admission allocates no job or snapshot ID. +2. No physical send overlaps another, including an old generation's held send. No old-generation or old-focus post/commit is **initiated** after ownership changes. +3. Invalidation retains no obsolete queue or payload. Discarded waiting callers settle immediately. Remaining payloads correspond exactly to active/queued jobs; remaining callers correspond exactly to those jobs plus an already-initiated physical send. +4. Allocated sequences follow enqueue/capture order: deltas and bumping snapshots increment; resync retains the current value. Sent sequence is nondecreasing and never exceeds allocation. Failed snapshots never resume their suffix. +5. The independent receiver oracle stages contiguous, exact snapshot payloads and exposes them only at a matching complete end marker. Start/chunks cannot change visible transcript or applied sequence. Applied sequence cannot decrease within one focused-task scope. + +Sequence monotonicity is **not global across task IDs or removed/recreated task lifetimes**. The production provider prunes a task's sequence on stack removal/history deletion; the model exercises the shared pruning action on switch/clear and tags its allocation/sent oracle with a task-lifetime epoch. A no-task snapshot has sequence zero. Receiver applied sequence resets on focus change/clear, as distinct from resync of the same task. The checker does not invent a persisted generation token or silently demand globally increasing sequences after clear. + +All 16 action classes are required: snapshot, append, update, resync, invalidate, switch, clear, focus, stale-snapshot, pump, start, chunk, end, settle, fail, discard. All 14 named reachability landmarks are required: + +- held-post-with-queued-delta; +- repeated-invalidation-while-held; +- cancelled-active-suffix-released; +- new-generation-waits-for-old-send; +- stale-physical-completion; +- already-initiated-stale-end-can-complete; +- task-switch-with-held-send; +- focus-changed-before-invalidation; +- clear-prunes-task-sequences; +- empty-snapshot-committed; +- multi-chunk-snapshot-committed; +- failed-post-with-queued-recovery; +- snapshot-recovery-after-failure; +- delta-applied-after-snapshot. + +## Invariant sensitivity + +Eight test-only reducer wrappers must produce their expected violation class through the same exhaustive explorer. No mutation switch exists in production. + +| Mutant | Shortest witness, excluding initial state | Detected violation | +| ----------------------------------- | ----------------------------------------- | --------------------------------- | +| stale-completion-starts-end | snapshot, pump, resync, settle | stale commit initiation | +| admit-stale-generation | focus, resync, stale-snapshot | obsolete admission allocates work | +| ignore-focus-at-post | snapshot, focus, pump | stale-focus initiation | +| legacy-generation-only-invalidation | snapshot, resync | retained obsolete jobs/payloads | +| reset-promise-barrier | snapshot, pump, resync, pump | overlapping physical sends | +| commit-before-chunks | snapshot, pump, settle, pump, settle | incomplete atomic snapshot | +| reuse-delta-sequence | snapshot, append | incorrect allocated sequence | +| continue-after-rejection | snapshot, pump, fail, pump | failed snapshot resumes posting | + +[transcriptTransport.spec.ts](../../src/core/webview/__tests__/transcriptTransport.spec.ts) runs the full checker, verifies deterministic shortest witnesses and both fail-closed budget paths, and exercises the actual driver with held/rejected start, chunk, end, and delta sends, plus synchronous rejection/recovery. The [CLI entry point](../../scripts/check-transcript-transport.ts) prints counts, action/landmark names, bounds, and mutant traces. + +## Limitations: initiation is not delivery revocation + +An active physical send cannot be unsent. In particular, **an end marker initiated before invalidation may complete afterward and publish its already-complete snapshot on the same focused task**. The generation is provider-local, not a wire field. The named stale-end-completion landmark deliberately requires this permitted behavior; the stale-completion-starts-end mutant forbids the materially different bug of initiating a new old-generation end after invalidation. The single physical barrier ensures a newer transcript's posts cannot overtake the held old one. + +The receiver is an independent protocol oracle, not the React reducer. It assumes ordered, lossless successful physical delivery at settlement and no delivery for a modeled rejection; a real post can deliver before its Promise settles. It deliberately cannot prove browser timer behavior, dropped/delayed messages, resync retry diagnostics, rendering, or restart behavior. Existing UI tests own those concerns. The provider's post wrapper swallows disposed-view failures and ignores the editor's boolean delivery result; model rejection covers errors reaching the transport callback, **not delivery acknowledgement**. + +Metadata state posts are outside this transcript FIFO, and a task may become focused before its asynchronous metadata synchronization completes. The focus-before-sync scenario checks the transcript's live-focus guard, not the metadata channel. There is no fairness/liveness claim: a permanently held physical post permanently blocks later physical transcript posts, although obsolete waiting jobs are still released on invalidation. Memory claims concern removal of owned references, not immediate garbage collection or memory retained by the editor's already-initiated post. + +This bounded check does not prove arbitrary queue lengths, sequence overflow, arbitrary repeated task-ID reuse, message validation, or all payload values. Driver/provider regressions cover concrete deep-clone behavior and runtime correspondence; the model independently checks ordering and ownership. No persisted lifecycle state is needed for these safety properties, so composition remains at the aggregate command boundary. diff --git a/package.json b/package.json index 1fd9ddc8fe..6d336cd5f5 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "check-types": "turbo check-types --log-order grouped --output-logs new-only", "test": "turbo test --log-order grouped --output-logs new-only", "test:mutation-ci": "node --test scripts/stryker-diff.test.mjs", - "lifecycle:model-check": "tsx scripts/check-task-lifecycle.ts && tsx scripts/check-task-store-concurrency.ts && tsx scripts/check-provider-handoff-scheduler.ts && pnpm cleanup-protocol:model-check && pnpm parser-scope:model-check && tsx scripts/check-completion-persistence.ts", + "lifecycle:model-check": "tsx scripts/check-task-lifecycle.ts && tsx scripts/check-task-store-concurrency.ts && tsx scripts/check-provider-handoff-scheduler.ts && pnpm cleanup-protocol:model-check && pnpm parser-scope:model-check && tsx scripts/check-completion-persistence.ts && pnpm transcript-transport:model-check", + "transcript-transport:model-check": "tsx scripts/check-transcript-transport.ts", "cleanup-protocol:model-check": "tsx scripts/check-task-cleanup-protocol.ts", "parser-scope:model-check": "node scripts/run-native-tool-call-parser-scoping.mjs", "mcp:integration-check": "tsx scripts/check-mcp-oauth-integration.ts", diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index 34683dee21..90cbc5abbb 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -143,12 +143,58 @@ export interface ExtensionMessage { isActive: boolean path?: string }> + /** + * Task scope for transcript deltas and every snapshot frame; it must match the + * webview's focused task. Omitted for the no-task scope, whose snapshot is empty + * with sequence 0. Unrelated message types may also use this as their task target. + */ taskId?: string + /** + * Complete message value for clineMessageAppended or clineMessageUpdated; updates + * replace the existing message identified by ts, not an array index or text patch. + * Also used by legacy messageUpdated (CLI); the sequenced webview requests a resync + * instead of applying that unsequenced legacy update. + */ clineMessage?: ClineMessage + /** + * Nonempty, ordered message slice for clineMessagesSnapshotChunk, beginning at + * snapshotStartIndex. Buffered until the matching end frame atomically replaces + * the transcript; start/end frames carry no messages. Legacy full transcripts + * live in state.clineMessages, not this top-level field. + */ clineMessages?: ClineMessage[] + /** + * Authoritative nonnegative safe-integer transcript revision, scoped to a task + * within this provider's retained transport state (not globally or persistently). + * Starts at 0; each accepted append/update or replacement increments it once. + * Focus/resync snapshots reuse the current revision, and all start/chunk/end + * frames share it. Deltas must be lastApplied + 1; snapshots may bridge gaps or + * reapply the current revision. No-task snapshots use 0. Legacy messageUpdated + * is unsequenced; generic browser state messages do not carry transcript revisions. + */ clineMessagesSeq?: number + /** + * Nonempty correlation ID shared by one snapshot's start, contiguous chunks, and + * end, together with taskId and clineMessagesSeq. The host uses the task ID (or + * "none") plus a provider-wide monotonically increasing snapshot counter, even + * when the revision is unchanged. Treat it as opaque, not a sequence/generation. + * Only a complete matching start/chunks/end transaction is applied atomically; + * an empty snapshot has start/end only, including in the no-task scope. + */ snapshotId?: string + /** + * Zero-based nonnegative safe-integer offset for clineMessagesSnapshotChunk. + * Must equal the number of messages buffered so far (no gaps/overlaps), be less + * than snapshotTotal, and satisfy offset + clineMessages.length <= snapshotTotal. + * Omitted on start/end; empty snapshots have no chunks. + */ snapshotStartIndex?: number + /** + * Nonnegative safe-integer message count declared by clineMessagesSnapshotStart + * and repeated unchanged by clineMessagesSnapshotEnd; omitted on chunks. The + * assembled count must equal it before atomic application. Zero means an empty + * transcript and no chunk frames, for either an empty task or the no-task scope. + */ snapshotTotal?: number routerModels?: RouterModels openAiModels?: string[] @@ -663,7 +709,19 @@ export interface WebviewMessage { | "requestClineMessagesResync" text?: string taskId?: string + /** + * Optional requestClineMessagesResync diagnostic: the NEXT sequence the webview + * expected (lastApplied + 1), not the last applied sequence. Untrusted and + * non-authoritative; the host may log valid nonnegative safe integers only and + * must not use this value to change its sequence or recovery behavior. + */ expectedSeq?: number + /** + * Optional requestClineMessagesResync diagnostic: the incoming sequence observed + * by the webview, if available. Untrusted and non-authoritative; the host may log + * valid nonnegative safe integers only, never use it to choose a snapshot revision + * or otherwise change recovery behavior. Omit when no sequence was observed. + */ receivedSeq?: number editedMessageContent?: string tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "cloud" diff --git a/scripts/check-transcript-transport.ts b/scripts/check-transcript-transport.ts new file mode 100644 index 0000000000..af824d1d29 --- /dev/null +++ b/scripts/check-transcript-transport.ts @@ -0,0 +1,17 @@ +import { + checkTranscriptTransportModel, + TRANSPORT_MODEL_BOUNDS, +} from "../src/core/webview/__tests__/transcriptTransport.model" + +const result = checkTranscriptTransportModel() +console.log(`Transcript transport model passed; bounds=${JSON.stringify(TRANSPORT_MODEL_BOUNDS)}`) +for (const scenario of result.results) { + console.log( + `${scenario.name}: ${scenario.states} states, ${scenario.transitions} transitions, maximum depth ${scenario.maximumDepth}`, + ) +} +console.log(`Actions (${result.actions.length}): ${result.actions.join(", ")}`) +console.log(`Landmarks (${result.landmarks.length}): ${result.landmarks.join(", ")}`) +for (const counterexample of result.counterexamples) { + console.log(`Mutant ${counterexample.name}: ${counterexample.violation}\n ${counterexample.trace.join(" -> ")}`) +} diff --git a/src/__tests__/extension.spec.ts b/src/__tests__/extension.spec.ts index bb72d567dd..343da2afb1 100644 --- a/src/__tests__/extension.spec.ts +++ b/src/__tests__/extension.spec.ts @@ -188,7 +188,7 @@ vi.mock("../core/webview/ClineProvider", async () => { resolveWebviewView: vi.fn(), postMessageToWebview: vi.fn(), postStateToWebview: vi.fn(), - postStateToWebviewWithoutClineMessages: vi.fn().mockResolvedValue(undefined), + postStateToWebviewWithoutTaskHistory: vi.fn().mockResolvedValue(undefined), getState: vi.fn().mockResolvedValue({}), initializeCloudProfileSyncWhenReady: vi.fn().mockResolvedValue(undefined), providerSettingsManager: {}, @@ -295,17 +295,17 @@ describe("extension.ts", () => { const provider = ( ClineProvider as unknown as { - getVisibleInstance(): { postStateToWebviewWithoutClineMessages: ReturnType } + getVisibleInstance(): { postStateToWebviewWithoutTaskHistory: ReturnType } } ).getVisibleInstance() - provider.postStateToWebviewWithoutClineMessages.mockClear() + provider.postStateToWebviewWithoutTaskHistory.mockClear() const refreshError = new Error("state refresh failed") - provider.postStateToWebviewWithoutClineMessages.mockRejectedValueOnce(refreshError) + provider.postStateToWebviewWithoutTaskHistory.mockRejectedValueOnce(refreshError) settingsUpdatedHandler!({}) await Promise.resolve() - expect(provider.postStateToWebviewWithoutClineMessages).toHaveBeenCalledTimes(1) + expect(provider.postStateToWebviewWithoutTaskHistory).toHaveBeenCalledTimes(1) const vscode = await import("vscode") const channel = vi.mocked(vscode.window.createOutputChannel).mock.results.at(-1)?.value expect(channel?.appendLine).toHaveBeenCalledWith( @@ -442,15 +442,15 @@ describe("extension.ts", () => { const visibleInstance = ( ClineProvider as unknown as { - getVisibleInstance(): { postStateToWebviewWithoutClineMessages: ReturnType } + getVisibleInstance(): { postStateToWebviewWithoutTaskHistory: ReturnType } } ).getVisibleInstance() - vi.mocked(visibleInstance.postStateToWebviewWithoutClineMessages).mockClear() + vi.mocked(visibleInstance.postStateToWebviewWithoutTaskHistory).mockClear() const onDidChangeHandler = vi.mocked(vscode.env.onDidChangeTelemetryEnabled).mock.calls[0][0] onDidChangeHandler(undefined as never) - expect(visibleInstance.postStateToWebviewWithoutClineMessages).toHaveBeenCalled() + expect(visibleInstance.postStateToWebviewWithoutTaskHistory).toHaveBeenCalled() }) }) diff --git a/src/__tests__/helpers/provider-stub.ts b/src/__tests__/helpers/provider-stub.ts index 2ad4257348..f826b86469 100644 --- a/src/__tests__/helpers/provider-stub.ts +++ b/src/__tests__/helpers/provider-stub.ts @@ -1,10 +1,11 @@ import { ClineProvider } from "../../core/webview/ClineProvider" import { TaskRegistry } from "../../core/task/TaskRegistry" import { type Task } from "../../core/task/Task" +import { TranscriptTransport } from "../../core/webview/transcriptTransport" type ProviderStubFields = { cancelledDelegationChildIds?: Set - clineMessagesSeqByTaskId?: Map + clineMessagesTransport?: TranscriptTransport log?: ReturnType syncFocusedTaskToWebview?: ReturnType taskHistoryStore?: { get: (id: string) => unknown; invalidate?: (id: string) => Promise } @@ -37,7 +38,11 @@ export function makeProviderStub(stub: T): ClineProvider { const s = stub as T & ProviderStubFields const proto = ClineProvider.prototype as unknown as PrivateProviderMethods s.cancelledDelegationChildIds ??= new Set() - s.clineMessagesSeqByTaskId ??= new Map() + s.clineMessagesTransport ??= new TranscriptTransport( + () => undefined, + async () => {}, + () => {}, + ) s.log ??= vi.fn() s.syncFocusedTaskToWebview ??= vi.fn().mockResolvedValue(undefined) s.taskHistoryStore ??= { get: () => undefined } diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 8219fc7e0f..edecde4f2e 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -132,6 +132,7 @@ import { getUri } from "./getUri" import { REQUESTY_BASE_URL } from "../../shared/utils/requesty" import { validateAndFixToolResultIds } from "../task/validateToolResultIds" import { PendingEditOperationStore, type PendingEditOperationInput } from "./PendingEditOperationStore" +import { TranscriptTransport } from "./transcriptTransport" /** * https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts @@ -222,15 +223,16 @@ 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 clineMessagesTransport = new TranscriptTransport( + () => this.getCurrentTask()?.taskId, + (message) => this.postMessageToWebview(message), + (error) => + this.log(`[clineMessages] transport failure: ${error instanceof Error ? error.message : String(error)}`), + ) private readonly _postStateToWebviewThrottled = debounce( async () => { try { - await this.postStateToWebviewWithoutClineMessages() + await this.postStateToWebviewWithoutTaskHistory() } catch (error) { this.log( `[ClineProvider#postStateToWebviewThrottled] Failed to post state: ${ @@ -366,7 +368,7 @@ export class ClineProvider this.providerSettingsManager = new ProviderSettingsManager(this.context) this.customModesManager = new CustomModesManager(this.context, async () => { - await this.postStateToWebviewWithoutClineMessages() + await this.postStateToWebviewWithoutTaskHistory() }) // Initialize MCP Hub through the singleton manager @@ -623,7 +625,7 @@ export class ClineProvider } if (task) { - this.clineMessagesSeqByTaskId.delete(task.taskId) + this.clineMessagesTransport.forgetTask(task.taskId) task.emit(RooCodeEventName.TaskUnfocused) try { @@ -1499,25 +1501,11 @@ 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 + return this.clineMessagesTransport.getSequence(taskId) } private invalidateClineMessagesTransport(): number { - return ++this.clineMessagesTransportGeneration + return this.clineMessagesTransport.invalidate() } public postClineMessageAppended(taskId: string, message: ClineMessage): Promise { @@ -1528,20 +1516,7 @@ export class ClineProvider return this.postStateToWebviewWithoutTaskHistory() } - 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, - }) - }) + return this.clineMessagesTransport.enqueue({ kind: "append", taskId }, [message]) } public postClineMessageUpdated(taskId: string, message: ClineMessage): Promise { @@ -1552,20 +1527,7 @@ export class ClineProvider return this.postMessageToWebview({ type: "messageUpdated", clineMessage: structuredClone(message) }) } - 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, - }) - }) + return this.clineMessagesTransport.enqueue({ kind: "update", taskId }, [message]) } public postClineMessagesSnapshot( @@ -1580,64 +1542,33 @@ export class ClineProvider return this.postStateToWebviewWithoutTaskHistory() } - const seq = taskId - ? options.bumpSeq - ? this.bumpClineMessagesSeq(taskId) - : this.getClineMessagesSeq(taskId) - : 0 - // Capture the payload with its sequence so later deltas cannot leak into this snapshot. - 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, - }) - }) + return this.clineMessagesTransport.enqueue( + { kind: "snapshot", taskId, ...options }, + currentTask?.clineMessages ?? [], + ) } - public resyncClineMessagesToWebview(taskId?: string): Promise { - if ((this.getCurrentTask()?.taskId ?? undefined) !== taskId) { + public resyncClineMessagesToWebview(taskId?: string, expectedSeq?: unknown, receivedSeq?: unknown): Promise { + const currentTaskId = this.getCurrentTask()?.taskId + if (currentTaskId !== taskId) { return Promise.resolve() } + // Untrusted webview diagnostics are log-only; never derive transport state from them. + const diagnosticSequence = (value: unknown): number | undefined => + typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : undefined + const previousGeneration = this.clineMessagesTransport.generation + const currentSeq = currentTaskId === undefined ? 0 : this.getClineMessagesSeq(currentTaskId) const generation = this.invalidateClineMessagesTransport() + this.log( + `[clineMessages] resync accepted: ${JSON.stringify({ + taskId: currentTaskId ?? null, + previousGeneration, + newGeneration: generation, + currentSeq, + expectedSeq: diagnosticSequence(expectedSeq), + receivedSeq: diagnosticSequence(receivedSeq), + })}`, + ) return this.postClineMessagesSnapshot(taskId, { generation }) } @@ -1648,7 +1579,7 @@ export class ClineProvider } else { await this.postStateToWebviewWithoutTaskHistory() } - if (generation !== this.clineMessagesTransportGeneration) { + if (generation !== this.clineMessagesTransport.generation) { return } await this.postClineMessagesSnapshot(this.getCurrentTask()?.taskId, { generation }) @@ -2545,7 +2476,7 @@ 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.clineMessagesTransport.forgetTask(taskId) } this.recentTasksCache = undefined @@ -2589,7 +2520,7 @@ export class ClineProvider async deleteTaskFromState(id: string) { await this.taskHistoryStore.delete(id) - this.clineMessagesSeqByTaskId.delete(id) + this.clineMessagesTransport.forgetTask(id) this.recentTasksCache = undefined await this.postStateToWebview() @@ -2641,24 +2572,6 @@ export class ClineProvider await this._postStateToWebviewThrottled.flush() } - /** - * Compatibility name for callers that need a lightweight generic state post. - * Transcript fields are removed from every generic state message at the - * postMessageToWebview boundary, while the canonical method below also omits - * taskHistory. - * - * Rationale: - * - Cloud event handlers (auth, settings, user-info) and mode changes trigger state pushes - * that have nothing to do with chat messages. Including clineMessages in these pushes - * creates race conditions where a stale snapshot of clineMessages (captured during async - * getStateToPostToWebview) overwrites newer messages the task has streamed in the meantime. - * - This method ensures cloud/mode events only push the state fields they actually affect - * (cloud auth, org settings, profiles, etc.) without interfering with task message streaming. - */ - async postStateToWebviewWithoutClineMessages(): Promise { - await this.postStateToWebviewWithoutTaskHistory() - } - /** * Fetches marketplace data on demand to avoid blocking main state updates */ diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index c9e72e3afc..2c8452edd0 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -898,6 +898,24 @@ describe("ClineProvider", () => { const setCurrentTask = (task: { taskId: string; clineMessages: ClineMessage[] } | undefined) => { vi.spyOn(provider, "getCurrentTask").mockImplementation(() => task as Task | undefined) } + const setSequence = (taskId: string, seq: number) => { + const transport = provider["clineMessagesTransport"] + transport["state"] = { + ...transport["state"], + sequences: new Map([...transport["state"].sequences, [taskId, seq]]), + } + } + // Hold an actual snapshot start, not a private Promise-chain replacement. This + // leaves the production drain and its physical-send barrier in control. + const holdTransport = () => { + let release!: () => void + const held = new Promise((resolve) => { + release = resolve + }) + vi.spyOn(provider, "postMessageToWebview").mockImplementationOnce(() => held) + const active = provider.postClineMessagesSnapshot() + return { active, release } + } test("preserves legacy transcript messages for CLI consumers", async () => { await provider.resolveWebviewView(mockWebviewView) @@ -947,33 +965,42 @@ describe("ClineProvider", () => { 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 appended: ClineMessage = { + ts: 1, + type: "say", + say: "text", + text: "original", + images: ["original-image"], + } + const updated = { ...appended, text: "updated", images: ["updated-image"] } + const held = holdTransport() 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([ + appended.images?.push("late-image") + updated.images[0] = "late-replacement" + held.release() + await Promise.all([held.active, appendPost, updatePost]) + + expect( + mockPostMessage.mock.calls + .map(([message]: [ExtensionMessage]) => message) + .filter( + ({ type }: ExtensionMessage) => + type === "clineMessageAppended" || type === "clineMessageUpdated", + ), + ).toEqual([ { type: "clineMessageAppended", taskId: "task-1", - clineMessage: expect.objectContaining({ text: "original" }), + clineMessage: expect.objectContaining({ text: "original", images: ["original-image"] }), clineMessagesSeq: 1, }, { type: "clineMessageUpdated", taskId: "task-1", - clineMessage: expect.objectContaining({ text: "updated" }), + clineMessage: expect.objectContaining({ text: "updated", images: ["updated-image"] }), clineMessagesSeq: 2, }, ]) @@ -984,8 +1011,8 @@ describe("ClineProvider", () => { setCurrentTask(task) const message = { ts: 1, type: "say", say: "text", text: "ignored" } as ClineMessage const postSpy = vi.spyOn(provider, "postMessageToWebview") - const previousGeneration = provider["clineMessagesTransportGeneration"] - const previousSnapshotId = provider["nextClineMessagesSnapshotId"] + const previousGeneration = provider["clineMessagesTransport"].generation + const previousSnapshotId = provider["clineMessagesTransport"]["state"].nextSnapshotId await Promise.all([ provider.postClineMessageAppended("task-2", message), @@ -995,22 +1022,22 @@ describe("ClineProvider", () => { ]) expect(postSpy).not.toHaveBeenCalled() - expect(provider["clineMessagesSeqByTaskId"].has("task-2")).toBe(false) - expect(provider["clineMessagesTransportGeneration"]).toBe(previousGeneration) - expect(provider["nextClineMessagesSnapshotId"]).toBe(previousSnapshotId) + expect(provider["clineMessagesTransport"]["state"].sequences.has("task-2")).toBe(false) + expect(provider["clineMessagesTransport"].generation).toBe(previousGeneration) + expect(provider["clineMessagesTransport"]["state"].nextSnapshotId).toBe(previousSnapshotId) }) test("safely rejects transcript work when no task is focused", async () => { setCurrentTask(undefined) const message = { ts: 1, type: "say", say: "text", text: "ignored" } as ClineMessage - const previousGeneration = provider["clineMessagesTransportGeneration"] + const previousGeneration = provider["clineMessagesTransport"].generation await expect(provider.postClineMessageAppended("task-1", message)).resolves.toBeUndefined() await expect(provider.postClineMessageUpdated("task-1", message)).resolves.toBeUndefined() await expect(provider.resyncClineMessagesToWebview("task-1")).resolves.toBeUndefined() - expect(provider["clineMessagesSeqByTaskId"].has("task-1")).toBe(false) - expect(provider["clineMessagesTransportGeneration"]).toBe(previousGeneration) + expect(provider["clineMessagesTransport"]["state"].sequences.has("task-1")).toBe(false) + expect(provider["clineMessagesTransport"].generation).toBe(previousGeneration) }) test("logs a failed delta post and continues processing the queue", async () => { @@ -1057,6 +1084,11 @@ describe("ClineProvider", () => { 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(posts.slice(1, 4).map(({ clineMessages }) => clineMessages)).toEqual([ + messages.slice(0, 200), + messages.slice(200, 400), + messages.slice(400, 401), + ]) expect(new Set(posts.map(({ snapshotId }) => snapshotId)).size).toBe(1) }) @@ -1075,13 +1107,10 @@ describe("ClineProvider", () => { ) const task = { taskId: "task-1", clineMessages: messages } setCurrentTask(task) - provider["clineMessagesSeqByTaskId"].set(task.taskId, 4) + setSequence(task.taskId, 4) const postSpy = vi.spyOn(provider, "postMessageToWebview").mockResolvedValue(undefined) const expectedSnapshot = structuredClone(messages) - let releaseQueue!: () => void - provider["clineMessagesPostQueue"] = new Promise((resolve) => { - releaseQueue = resolve - }) + const held = holdTransport() const snapshot = provider.postClineMessagesSnapshot(task.taskId, { bumpSeq }) const appended: ClineMessage = { ts: 201, type: "say", say: "text", text: "appended after snapshot" } @@ -1090,12 +1119,16 @@ describe("ClineProvider", () => { messages[0].text = "updated after snapshot" messages[0].images?.push("updated-image") const update = provider.postClineMessageUpdated(task.taskId, messages[0]) - releaseQueue() - await Promise.all([snapshot, append, update]) + held.release() + await Promise.all([held.active, snapshot, append, update]) const snapshotSeq = bumpSeq ? 5 : 4 - const snapshotId = "task-1:1" - expect(postSpy.mock.calls.map(([message]) => message)).toEqual([ + const snapshotId = "task-1:2" + expect( + postSpy.mock.calls + .map(([message]) => message) + .filter((message) => message.snapshotId !== "task-1:1"), + ).toEqual([ { type: "clineMessagesSnapshotStart", taskId: task.taskId, @@ -1145,12 +1178,7 @@ describe("ClineProvider", () => { setCurrentTask(task) mockPostMessage.mockClear() - let releaseQueue!: () => void - Object.assign(provider, { - clineMessagesPostQueue: new Promise((resolve) => { - releaseQueue = resolve - }), - }) + const held = holdTransport() const message = { ts: 1, type: "say", @@ -1164,7 +1192,7 @@ describe("ClineProvider", () => { task.taskId = "task-2" const focusSync = provider.syncFocusedTaskToWebview() - releaseQueue() + held.release() await Promise.all([pendingDelta, focusSync]) expect(mockPostMessage).not.toHaveBeenCalledWith( @@ -1185,12 +1213,7 @@ describe("ClineProvider", () => { const task = { taskId: "task-1", clineMessages: [] as ClineMessage[] } setCurrentTask(task) const postSpy = vi.spyOn(provider, "postMessageToWebview") - let releaseQueue!: () => void - Object.assign(provider, { - clineMessagesPostQueue: new Promise((resolve) => { - releaseQueue = resolve - }), - }) + const held = holdTransport() const message = { ts: 1, type: "say", say: "text", text: "queued" } as ClineMessage const pendingDelta = operation === "append" @@ -1198,7 +1221,7 @@ describe("ClineProvider", () => { : provider.postClineMessageUpdated("task-1", message) task.taskId = "task-2" - releaseQueue() + held.release() await pendingDelta expect(postSpy).not.toHaveBeenCalledWith(expect.objectContaining({ type: messageType })) @@ -1210,12 +1233,7 @@ describe("ClineProvider", () => { async (operation) => { const task = { taskId: "task-1", clineMessages: [] as ClineMessage[] } setCurrentTask(task) - let releaseQueue!: () => void - Object.assign(provider, { - clineMessagesPostQueue: new Promise((resolve) => { - releaseQueue = resolve - }), - }) + const held = holdTransport() const message = { ts: 1, type: "say", say: "text", text: "queued" } as ClineMessage const pendingDelta = operation === "append" @@ -1223,7 +1241,7 @@ describe("ClineProvider", () => { : provider.postClineMessageUpdated("task-1", message) setCurrentTask(undefined) - releaseQueue() + held.release() await expect(pendingDelta).resolves.toBeUndefined() }, @@ -1235,24 +1253,19 @@ describe("ClineProvider", () => { setCurrentTask(task) mockPostMessage.mockClear() - let releaseQueue!: () => void - Object.assign(provider, { - clineMessagesPostQueue: new Promise((resolve) => { - releaseQueue = resolve - }), - }) + const held = holdTransport() const pendingDelta = provider.postClineMessageAppended("task-1", { ts: 1, type: "say", say: "text", text: "stale generation", }) - const previousGeneration = provider["clineMessagesTransportGeneration"] + const previousGeneration = provider["clineMessagesTransport"].generation const resync = provider.resyncClineMessagesToWebview("task-1") - expect(provider["clineMessagesTransportGeneration"]).toBe(previousGeneration + 1) + expect(provider["clineMessagesTransport"].generation).toBe(previousGeneration + 1) - releaseQueue() + held.release() await Promise.all([pendingDelta, resync]) expect(mockPostMessage).not.toHaveBeenCalledWith( @@ -1267,12 +1280,7 @@ describe("ClineProvider", () => { const task = { taskId: "task-1", clineMessages: [] as ClineMessage[] } setCurrentTask(task) const postSpy = vi.spyOn(provider, "postMessageToWebview") - let releaseQueue!: () => void - Object.assign(provider, { - clineMessagesPostQueue: new Promise((resolve) => { - releaseQueue = resolve - }), - }) + const held = holdTransport() const pendingUpdate = provider.postClineMessageUpdated("task-1", { ts: 1, type: "say", @@ -1280,8 +1288,8 @@ describe("ClineProvider", () => { text: "stale generation", }) - provider["clineMessagesTransportGeneration"]++ - releaseQueue() + provider["invalidateClineMessagesTransport"]() + held.release() await pendingUpdate expect(postSpy).not.toHaveBeenCalledWith( @@ -1298,23 +1306,112 @@ describe("ClineProvider", () => { } setCurrentTask(task) const postSpy = vi.spyOn(provider, "postMessageToWebview") - let releaseQueue!: () => void - Object.assign(provider, { - clineMessagesPostQueue: new Promise((resolve) => { - releaseQueue = resolve - }), - }) + const held = holdTransport() const snapshot = provider.postClineMessagesSnapshot("task-1") if (change === "focus") { task.taskId = "task-2" } else { - provider["clineMessagesTransportGeneration"]++ + provider["invalidateClineMessagesTransport"]() } - releaseQueue() - await snapshot + held.release() + await Promise.all([held.active, snapshot]) - expect(postSpy).not.toHaveBeenCalled() + expect(postSpy).toHaveBeenCalledOnce() + expect(postSpy).toHaveBeenCalledWith( + expect.objectContaining({ type: "clineMessagesSnapshotStart", snapshotId: "task-1:1" }), + ) + }, + ) + + test("rejects a stale snapshot generation before cloning or allocating sequence and ID", async () => { + const readText = vi.fn(() => "must not be cloned") + setCurrentTask({ + taskId: "task-1", + clineMessages: [ + { + ts: 1, + type: "say", + get text() { + return readText() + }, + }, + ], + }) + const staleGeneration = provider["clineMessagesTransport"].generation + provider["invalidateClineMessagesTransport"]() + const postSpy = vi.spyOn(provider, "postMessageToWebview") + + await provider.postClineMessagesSnapshot("task-1", { generation: staleGeneration, bumpSeq: true }) + + expect(readText).not.toHaveBeenCalled() + expect(postSpy).not.toHaveBeenCalled() + expect(provider["clineMessagesTransport"].getSequence("task-1")).toBe(0) + expect(provider["clineMessagesTransport"]["state"].nextSnapshotId).toBe(0) + }) + + test.each(["clineMessagesSnapshotStart", "clineMessagesSnapshotChunk"] as const)( + "releases queued payloads and callers across repeated resync while a physical %s is held", + async (heldType) => { + const task = { + taskId: "task-1", + clineMessages: [{ ts: 1, type: "say", text: "snapshot" }] as ClineMessage[], + } + setCurrentTask(task) + let release!: () => void + let started!: () => void + const held = new Promise((resolve) => { + release = resolve + }) + const postStarted = new Promise((resolve) => { + started = resolve + }) + let inFlight = 0 + let maximumInFlight = 0 + let heldOnce = false + const postSpy = vi.spyOn(provider, "postMessageToWebview").mockImplementation(async (message) => { + inFlight++ + maximumInFlight = Math.max(maximumInFlight, inFlight) + if (message.type === heldType && !heldOnce) { + heldOnce = true + started() + await held + } + inFlight-- + }) + const active = provider.postClineMessagesSnapshot(task.taskId) + await postStarted + const queued = Array.from({ length: 25 }, () => provider.postClineMessagesSnapshot(task.taskId)) + queued.push(provider.postClineMessageUpdated(task.taskId, task.clineMessages[0])) + const transport = provider["clineMessagesTransport"] + expect(transport["payloads"].size).toBe(27) + const firstResync = provider.resyncClineMessagesToWebview(task.taskId) + // These must settle BEFORE the active physical send is released. + await Promise.all(queued) + expect(transport["payloads"].size).toBe(1) + expect(transport["callers"].size).toBe(2) + const finalResync = provider.resyncClineMessagesToWebview(task.taskId) + await firstResync + expect(transport["payloads"].size).toBe(1) + expect(transport["state"].queue).toHaveLength(1) + expect(inFlight).toBe(1) + const postsBeforeRelease = postSpy.mock.calls.length + expect(postsBeforeRelease).toBe(heldType === "clineMessagesSnapshotStart" ? 1 : 2) + release() + await Promise.all([active, finalResync]) + expect(maximumInFlight).toBe(1) + expect(postSpy.mock.calls.slice(postsBeforeRelease).map(([message]) => message.type)).toEqual([ + "clineMessagesSnapshotStart", + "clineMessagesSnapshotChunk", + "clineMessagesSnapshotEnd", + ]) + expect( + postSpy.mock.calls + .slice(postsBeforeRelease) + .every(([message]) => message.snapshotId === "task-1:28"), + ).toBe(true) + expect(transport["payloads"].size).toBe(0) + expect(transport["callers"].size).toBe(0) }, ) @@ -1380,7 +1477,7 @@ describe("ClineProvider", () => { vi.spyOn(provider, "postMessageToWebview").mockImplementation(async (message) => { postedTypes.push(message.type) if (message.type === "clineMessagesSnapshotStart") { - provider["clineMessagesTransportGeneration"]++ + provider["invalidateClineMessagesTransport"]() } }) @@ -1415,45 +1512,194 @@ describe("ClineProvider", () => { expect(postedTypes).toEqual(expectedTypes) }) - test("resyncs the focused task with the current sequence", async () => { - const task = { taskId: "task-1", clineMessages: [] as ClineMessage[] } - setCurrentTask(task) + test.each([ + { name: "both diagnostics", expectedSeq: 2, receivedSeq: 7 }, + { name: "missing diagnostics", expectedSeq: undefined, receivedSeq: undefined }, + { name: "only the expected sequence", expectedSeq: 2, receivedSeq: undefined }, + { name: "only the observed sequence", expectedSeq: undefined, receivedSeq: 7 }, + { name: "wildly different diagnostics", expectedSeq: Number.MAX_SAFE_INTEGER, receivedSeq: 0 }, + ])( + "resyncs the focused task with $name without changing its sequence", + async ({ expectedSeq, receivedSeq }) => { + const message: ClineMessage = { + ts: 1, + type: "say", + say: "text", + text: "secret transcript must not appear in resync logs", + images: ["data:image/png;base64,private-image"], + } + const task = { taskId: "task-1", clineMessages: [message] } + setCurrentTask(task) + const postSpy = vi.spyOn(provider, "postMessageToWebview").mockResolvedValue(undefined) + const logSpy = vi.spyOn(provider, "log").mockImplementation(() => {}) + const transport = provider["clineMessagesTransport"] + + await provider.postClineMessageAppended(task.taskId, message) + postSpy.mockClear() + logSpy.mockClear() + const previousGeneration = transport.generation + + await provider.resyncClineMessagesToWebview(task.taskId, expectedSeq, receivedSeq) + + expect(logSpy.mock.calls).toEqual([ + [ + `[clineMessages] resync accepted: ${JSON.stringify({ + taskId: task.taskId, + previousGeneration, + newGeneration: previousGeneration + 1, + currentSeq: 1, + expectedSeq, + receivedSeq, + })}`, + ], + ]) + const common = { taskId: task.taskId, clineMessagesSeq: 1, snapshotId: expect.any(String) } + expect(postSpy.mock.calls.map(([frame]) => frame)).toEqual([ + { ...common, type: "clineMessagesSnapshotStart", snapshotTotal: 1 }, + { ...common, type: "clineMessagesSnapshotChunk", snapshotStartIndex: 0, clineMessages: [message] }, + { ...common, type: "clineMessagesSnapshotEnd", snapshotTotal: 1 }, + ]) + expect(transport.generation).toBe(previousGeneration + 1) + expect(transport.getSequence(task.taskId)).toBe(1) + + await provider.postClineMessageUpdated(task.taskId, message) + expect(transport.getSequence(task.taskId)).toBe(2) + expect(postSpy).toHaveBeenLastCalledWith({ + type: "clineMessageUpdated", + taskId: task.taskId, + clineMessagesSeq: 2, + clineMessage: message, + }) + }, + ) + + test.each([ + { name: "without diagnostics", expectedSeq: undefined, receivedSeq: undefined }, + { name: "with diagnostics", expectedSeq: 1, receivedSeq: 0 }, + ])("logs and resyncs the empty no-task scope $name", async ({ expectedSeq, receivedSeq }) => { + setCurrentTask(undefined) + setSequence("unfocused-task", 99) + const transport = provider["clineMessagesTransport"] + const previousGeneration = transport.generation const postSpy = vi.spyOn(provider, "postMessageToWebview").mockResolvedValue(undefined) + const logSpy = vi.spyOn(provider, "log").mockImplementation(() => {}) - await provider.postClineMessageAppended("task-1", { - ts: 1, - type: "say", - say: "text", - text: "first", - }) - postSpy.mockClear() - await provider.resyncClineMessagesToWebview("task-1") + await provider.resyncClineMessagesToWebview(undefined, expectedSeq, receivedSeq) - expect(postSpy).toHaveBeenNthCalledWith( - 1, - expect.objectContaining({ type: "clineMessagesSnapshotStart", taskId: "task-1", clineMessagesSeq: 1 }), - ) + expect(logSpy.mock.calls).toEqual([ + [ + `[clineMessages] resync accepted: ${JSON.stringify({ + taskId: null, + previousGeneration, + newGeneration: previousGeneration + 1, + currentSeq: 0, + expectedSeq, + receivedSeq, + })}`, + ], + ]) + const common = { taskId: undefined, clineMessagesSeq: 0, snapshotId: expect.any(String), snapshotTotal: 0 } + expect(postSpy.mock.calls.map(([frame]) => frame)).toEqual([ + { ...common, type: "clineMessagesSnapshotStart" }, + { ...common, type: "clineMessagesSnapshotEnd" }, + ]) + expect(transport.generation).toBe(previousGeneration + 1) + expect([...transport["state"].sequences]).toEqual([["unfocused-task", 99]]) + }) + + test.each([ + { name: "wrong task", focusedTaskId: "task-1", requestedTaskId: "other-task" }, + { name: "missing task", focusedTaskId: "task-1", requestedTaskId: undefined }, + { name: "stale task in the no-task scope", focusedTaskId: undefined, requestedTaskId: "task-1" }, + ])( + "ignores a $name resync without logging or mutating transport", + async ({ focusedTaskId, requestedTaskId }) => { + setCurrentTask(focusedTaskId === undefined ? undefined : { taskId: focusedTaskId, clineMessages: [] }) + setSequence("task-1", 3) + const transport = provider["clineMessagesTransport"] + const previousState = transport["state"] + const postSpy = vi.spyOn(provider, "postMessageToWebview").mockResolvedValue(undefined) + const snapshotSpy = vi.spyOn(provider, "postClineMessagesSnapshot") + const logSpy = vi.spyOn(provider, "log").mockImplementation(() => {}) + + await provider.resyncClineMessagesToWebview(requestedTaskId, Number.MAX_SAFE_INTEGER, 0) + + expect(transport["state"]).toBe(previousState) + expect(postSpy).not.toHaveBeenCalled() + expect(snapshotSpy).not.toHaveBeenCalled() + expect(logSpy).not.toHaveBeenCalled() + }, + ) + + test.each([ + { name: "object", value: { secret: "do not log" } }, + { name: "array", value: ["do not log"] }, + { name: "string", value: "123" }, + { name: "boolean", value: true }, + { name: "null", value: null }, + { name: "NaN", value: Number.NaN }, + { name: "positive infinity", value: Number.POSITIVE_INFINITY }, + { name: "negative infinity", value: Number.NEGATIVE_INFINITY }, + { name: "negative integer", value: -1 }, + { name: "fraction", value: 1.5 }, + { name: "unsafe integer", value: Number.MAX_SAFE_INTEGER + 1 }, + { name: "bigint", value: 1n }, + { name: "symbol", value: Symbol("do not log") }, + ])("omits a $name diagnostic without affecting recovery or the other diagnostic", async ({ value }) => { + setCurrentTask({ taskId: "task-1", clineMessages: [] }) + setSequence("task-1", 3) + const transport = provider["clineMessagesTransport"] + const previousGeneration = transport.generation + const postSpy = vi.spyOn(provider, "postMessageToWebview").mockResolvedValue(undefined) + const logSpy = vi.spyOn(provider, "log").mockImplementation(() => {}) + + await provider.resyncClineMessagesToWebview("task-1", value, 8) + await provider.resyncClineMessagesToWebview("task-1", 9, value) + + expect(logSpy.mock.calls).toEqual([ + [ + `[clineMessages] resync accepted: ${JSON.stringify({ + taskId: "task-1", + previousGeneration, + newGeneration: previousGeneration + 1, + currentSeq: 3, + receivedSeq: 8, + })}`, + ], + [ + `[clineMessages] resync accepted: ${JSON.stringify({ + taskId: "task-1", + previousGeneration: previousGeneration + 1, + newGeneration: previousGeneration + 2, + currentSeq: 3, + expectedSeq: 9, + })}`, + ], + ]) + expect(postSpy.mock.calls.map(([frame]) => frame.clineMessagesSeq)).toEqual([3, 3, 3, 3]) + expect(transport.generation).toBe(previousGeneration + 2) + expect(transport.getSequence("task-1")).toBe(3) }) 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) + setSequence(task.taskId, 4) await provider.removeClineFromStack() - expect(provider["clineMessagesSeqByTaskId"].has(task.taskId)).toBe(false) + expect(provider["clineMessagesTransport"]["state"].sequences.has(task.taskId)).toBe(false) }) test("prunes sequence state when a task is deleted from history", async () => { - provider["clineMessagesSeqByTaskId"].set("deleted-task", 4) + setSequence("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) + expect(provider["clineMessagesTransport"]["state"].sequences.has("deleted-task")).toBe(false) }) test("prunes sequence state for every task deleted by a cascade", async () => { @@ -1489,14 +1735,14 @@ describe("ClineProvider", () => { vi.spyOn(ShadowCheckpointService, "deleteTask").mockResolvedValue(undefined) vi.spyOn(fs, "rm").mockResolvedValue(undefined) vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) - provider["clineMessagesSeqByTaskId"].set("parent", 4) - provider["clineMessagesSeqByTaskId"].set("child", 7) + setSequence("parent", 4) + setSequence("child", 7) await provider.deleteTaskWithId("parent") expect(provider.taskHistoryStore.deleteMany).toHaveBeenCalledWith(["parent", "child"]) - expect(provider["clineMessagesSeqByTaskId"].has("parent")).toBe(false) - expect(provider["clineMessagesSeqByTaskId"].has("child")).toBe(false) + expect(provider["clineMessagesTransport"]["state"].sequences.has("parent")).toBe(false) + expect(provider["clineMessagesTransport"]["state"].sequences.has("child")).toBe(false) }) test("abandons an older focus sync when a resync invalidates its state post", async () => { @@ -1528,7 +1774,7 @@ describe("ClineProvider", () => { setCurrentTask({ taskId: "task-1", clineMessages: [] }) vi.spyOn(provider, "postStateToWebviewWithoutTaskHistory").mockResolvedValue(undefined) const snapshotSpy = vi.spyOn(provider, "postClineMessagesSnapshot").mockResolvedValue(undefined) - const previousGeneration = provider["clineMessagesTransportGeneration"] + const previousGeneration = provider["clineMessagesTransport"].generation await provider.syncFocusedTaskToWebview() @@ -1542,7 +1788,7 @@ describe("ClineProvider", () => { .spyOn(provider, "postStateToWebviewWithoutTaskHistory") .mockResolvedValue(undefined) const snapshotSpy = vi.spyOn(provider, "postClineMessagesSnapshot").mockResolvedValue(undefined) - const previousGeneration = provider["clineMessagesTransportGeneration"] + const previousGeneration = provider["clineMessagesTransport"].generation await provider.syncFocusedTaskToWebview({ includeTaskHistory: true }) @@ -1618,34 +1864,17 @@ describe("ClineProvider", () => { expect(statePosts.map((message) => message.state?.clineMessagesSeq)).toEqual([undefined, undefined]) }) - test.each([ - [ - "postStateToWebviewWithoutTaskHistory", - (currentProvider: ClineProvider) => currentProvider.postStateToWebviewWithoutTaskHistory(), - ], - [ - "postStateToWebviewWithoutClineMessages", - (currentProvider: ClineProvider) => currentProvider.postStateToWebviewWithoutClineMessages(), - ], - ])("%s skips task history computation", async (_methodName, postState) => { + test("postStateToWebviewWithoutTaskHistory skips task history computation", async () => { const getAllSpy = vi.spyOn(provider.taskHistoryStore, "getAll") const postMessageSpy = vi.spyOn(provider, "postMessageToWebview").mockResolvedValue(undefined) - await postState(provider) + await provider.postStateToWebviewWithoutTaskHistory() expect(getAllSpy).not.toHaveBeenCalled() expect(postMessageSpy).toHaveBeenCalledOnce() expect(postMessageSpy.mock.calls[0]?.[0].state).not.toHaveProperty("taskHistory") }) - test("postStateToWebviewWithoutClineMessages delegates to the canonical lightweight state post", async () => { - const postStateSpy = vi.spyOn(provider, "postStateToWebviewWithoutTaskHistory").mockResolvedValue(undefined) - - await provider.postStateToWebviewWithoutClineMessages() - - expect(postStateSpy).toHaveBeenCalledOnce() - }) - test("getStateToPostToWebview computes task history once after its base state resolves", async () => { const historyItem = { id: "history-task", @@ -1702,9 +1931,7 @@ describe("ClineProvider", () => { }) test("posts on the leading edge and coalesces a burst into one trailing post", async () => { - const postStateSpy = vi - .spyOn(provider, "postStateToWebviewWithoutClineMessages") - .mockResolvedValue(undefined) + const postStateSpy = vi.spyOn(provider, "postStateToWebviewWithoutTaskHistory").mockResolvedValue(undefined) await provider.postStateToWebviewThrottled() await provider.postStateToWebviewThrottled() @@ -1720,9 +1947,7 @@ describe("ClineProvider", () => { }) test("does not starve state posts during continuous updates", async () => { - const postStateSpy = vi - .spyOn(provider, "postStateToWebviewWithoutClineMessages") - .mockResolvedValue(undefined) + const postStateSpy = vi.spyOn(provider, "postStateToWebviewWithoutTaskHistory").mockResolvedValue(undefined) await provider.postStateToWebviewThrottled() await vi.advanceTimersByTimeAsync(400) @@ -1743,7 +1968,7 @@ describe("ClineProvider", () => { releasePost = resolve }) const postStateSpy = vi - .spyOn(provider, "postStateToWebviewWithoutClineMessages") + .spyOn(provider, "postStateToWebviewWithoutTaskHistory") .mockResolvedValueOnce(undefined) .mockReturnValueOnce(pendingPost) @@ -1770,9 +1995,7 @@ describe("ClineProvider", () => { }) test("does not duplicate an idle leading post when flushed", async () => { - const postStateSpy = vi - .spyOn(provider, "postStateToWebviewWithoutClineMessages") - .mockResolvedValue(undefined) + const postStateSpy = vi.spyOn(provider, "postStateToWebviewWithoutTaskHistory").mockResolvedValue(undefined) await provider.postStateToWebviewThrottled() await provider.flushPostStateToWebviewThrottled() @@ -1784,7 +2007,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, "postStateToWebviewWithoutClineMessages").mockRejectedValue(error) + vi.spyOn(provider, "postStateToWebviewWithoutTaskHistory").mockRejectedValue(error) await expect(provider.postStateToWebviewThrottled()).resolves.toBeUndefined() expect(logSpy).toHaveBeenCalledWith( @@ -1794,7 +2017,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, "postStateToWebviewWithoutClineMessages").mockRejectedValue("state post failed") + vi.spyOn(provider, "postStateToWebviewWithoutTaskHistory").mockRejectedValue("state post failed") await expect(provider.postStateToWebviewThrottled()).resolves.toBeUndefined() expect(logSpy).toHaveBeenCalledWith( @@ -1806,7 +2029,7 @@ describe("ClineProvider", () => { const error = new Error("state post failed") const logSpy = vi.spyOn(provider, "log").mockImplementation(() => {}) const postStateSpy = vi - .spyOn(provider, "postStateToWebviewWithoutClineMessages") + .spyOn(provider, "postStateToWebviewWithoutTaskHistory") .mockResolvedValueOnce(undefined) .mockRejectedValueOnce(error) @@ -1821,9 +2044,7 @@ describe("ClineProvider", () => { }) test("cancels pending work on dispose and ignores later schedule or flush calls", async () => { - const postStateSpy = vi - .spyOn(provider, "postStateToWebviewWithoutClineMessages") - .mockResolvedValue(undefined) + const postStateSpy = vi.spyOn(provider, "postStateToWebviewWithoutTaskHistory").mockResolvedValue(undefined) await provider.postStateToWebviewThrottled() await provider.postStateToWebviewThrottled() diff --git a/src/core/webview/__tests__/transcriptTransport.model.ts b/src/core/webview/__tests__/transcriptTransport.model.ts new file mode 100644 index 0000000000..cb663fb14c --- /dev/null +++ b/src/core/webview/__tests__/transcriptTransport.model.ts @@ -0,0 +1,554 @@ +import { + createTranscriptTransportState, + reduceTranscriptTransport, + transcriptFrameMessage, + type TranscriptAction, + type TranscriptFrame, + type TranscriptJob, + type TranscriptTransportState, +} from "../transcriptTransport" + +type TaskId = "a" | "b" +type Intent = + | "snapshot" + | "append" + | "update" + | "resync" + | "invalidate" + | "switch" + | "clear" + | "focus" + | "stale-snapshot" +type Capture = { job: TranscriptJob; scope: string; values: number[]; failed: boolean } +type ModelState = { + transport: TranscriptTransportState + focus: TaskId | undefined + producer: number + controller: number + failures: number + data: Record + epochs: Record + captures: Capture[] + payloads: number[] + callers: number[] + physical?: TranscriptFrame + allocated: Record + sent: Record + visible: number[] + appliedSeq: number + staging?: { id: number; values: number[] } + committed: number[] + staleCompletions: number + staleCommitCompletions: number +} +type Scenario = { name: string; producer: Intent[]; controller: Intent[] } +type Event = { name: string; actor?: "producer" | "controller"; intent?: Intent; action?: TranscriptAction } +type Node = { state: ModelState; parent: number; event: string; depth: number } +type Reducer = typeof reduceTranscriptTransport +type Mutation = { name: string; expected: string; reduce: Reducer } + +export const TRANSPORT_MODEL_BOUNDS = { depth: 40, states: 30_000, chunkSize: 2, failures: 1 } as const +export const TRANSPORT_SCENARIOS: Scenario[] = [ + { + name: "queued-deltas-repeated-resync", + producer: ["snapshot", "append", "update"], + controller: ["resync", "resync"], + }, + { name: "task-switch-and-clear", producer: ["snapshot", "append", "snapshot"], controller: ["switch", "clear"] }, + { + name: "invalidation-and-recovery", + producer: ["snapshot", "update", "snapshot"], + controller: ["invalidate", "resync"], + }, + { + name: "focus-before-sync-and-stale-request", + producer: ["snapshot", "append", "update"], + controller: ["focus", "resync", "stale-snapshot"], + }, +] +export const TRANSPORT_ACTIONS = [ + "snapshot", + "append", + "update", + "resync", + "invalidate", + "switch", + "clear", + "focus", + "stale-snapshot", + "pump", + "start", + "chunk", + "end", + "settle", + "fail", + "discard", +] +export const TRANSPORT_LANDMARKS = { + "held-post-with-queued-delta": (s: ModelState) => + !!s.physical && s.transport.queue.some((job) => job.kind !== "snapshot"), + "repeated-invalidation-while-held": (s: ModelState) => + !!s.physical && s.transport.generation - s.physical.job.generation >= 2, + "cancelled-active-suffix-released": (s: ModelState) => + !!s.physical && s.physical.job.generation < s.transport.generation && !s.payloads.includes(s.physical.job.id), + "new-generation-waits-for-old-send": (s: ModelState) => + !!s.physical && s.physical.job.generation < s.transport.generation && s.transport.queue.length > 0, + "stale-physical-completion": (s: ModelState) => s.staleCompletions > 0, + "already-initiated-stale-end-can-complete": (s: ModelState) => s.staleCommitCompletions > 0, + "task-switch-with-held-send": (s: ModelState) => s.focus === "b" && s.physical?.job.taskId === "a", + "focus-changed-before-invalidation": (s: ModelState) => + s.focus === "b" && s.transport.generation === 0 && !!s.transport.active, + "clear-prunes-task-sequences": (s: ModelState) => !s.focus && s.transport.sequences.size === 0, + "empty-snapshot-committed": (s: ModelState) => s.committed.some((id) => s.captures[id - 1].job.total === 0), + "multi-chunk-snapshot-committed": (s: ModelState) => + s.committed.some((id) => s.captures[id - 1].job.total > TRANSPORT_MODEL_BOUNDS.chunkSize), + "failed-post-with-queued-recovery": (s: ModelState) => + s.failures > 0 && s.transport.queue.some((job) => job.kind === "snapshot"), + "snapshot-recovery-after-failure": (s: ModelState) => + s.committed.some((id) => s.captures.some((c) => c.failed && c.job.id < id)), + "delta-applied-after-snapshot": (s: ModelState) => + s.committed.length > 0 && s.appliedSeq > s.captures[s.committed.at(-1)! - 1].job.seq, +} satisfies Record boolean> + +function initialState(): ModelState { + return { + transport: createTranscriptTransportState(TRANSPORT_MODEL_BOUNDS.chunkSize), + focus: "a", + producer: 0, + controller: 0, + failures: 0, + data: { a: [1, 2, 3], b: [7] }, + epochs: { a: 0, b: 0 }, + captures: [], + payloads: [], + callers: [], + allocated: {}, + sent: {}, + visible: [], + appliedSeq: 0, + committed: [], + staleCompletions: 0, + staleCommitCompletions: 0, + } +} + +function enabled(s: ModelState, scenario: Scenario): Event[] { + const events: Event[] = [] + for (const actor of ["producer", "controller"] as const) { + const intent = scenario[actor][s[actor]] + if (intent) events.push({ name: `${actor}:${intent}`, actor, intent }) + } + if (!s.transport.inFlight && (s.transport.active || s.transport.queue.length)) { + events.push({ name: "pump", action: { type: "pump", focusedTaskId: s.focus } }) + } + if (s.transport.inFlight) { + events.push({ name: "settle", action: { type: "settle", success: true } }) + if (s.failures < TRANSPORT_MODEL_BOUNDS.failures) + events.push({ name: "fail", action: { type: "settle", success: false } }) + } + return events +} + +function requireInvariant(condition: unknown, message: string): asserts condition { + if (!condition) throw new Error(message) +} + +/** Independent receiver oracle. It sees physical deliveries, not private generation tokens. */ +function deliver(s: ModelState, frame: TranscriptFrame): void { + const { job, phase } = frame + if (job.taskId !== s.focus) return + const capture = s.captures[job.id - 1] + const oldVisible = [...s.visible] + const oldSeq = s.appliedSeq + const message = transcriptFrameMessage( + frame, + capture.values.map((value) => ({ ts: value, type: "say", text: String(value) })), + ) + if (phase === "start") { + if (job.seq >= s.appliedSeq) s.staging = { id: job.id, values: [] } + } else if (phase === "chunk") { + if (s.staging?.id === job.id) { + requireInvariant(message.snapshotStartIndex === s.staging.values.length, "non-contiguous snapshot chunk") + s.staging.values.push(...(message.clineMessages ?? []).map((m) => m.ts)) + } + } else if (phase === "end") { + requireInvariant(s.staging?.id === job.id, "snapshot commit without matching start") + requireInvariant( + JSON.stringify(s.staging.values) === JSON.stringify(capture.values), + "snapshot commit before complete chunks", + ) + if (job.seq >= s.appliedSeq) { + s.visible = s.staging.values + s.appliedSeq = job.seq + s.committed.push(job.id) + } + s.staging = undefined + } else if (!s.staging && job.seq === s.appliedSeq + 1) { + if (phase === "append") s.visible.push(capture.values[0]) + else if (s.visible.length) s.visible[0] = capture.values[0] + s.appliedSeq = job.seq + } + if (phase === "start" || phase === "chunk") { + requireInvariant( + JSON.stringify(s.visible) === JSON.stringify(oldVisible), + "snapshot exposed a partial transcript", + ) + requireInvariant(s.appliedSeq === oldSeq, "snapshot applied sequence before commit") + } + requireInvariant(s.appliedSeq >= oldSeq, "applied sequence regressed within focus scope") +} + +class ModelViolation extends Error { + constructor( + message: string, + readonly state: ModelState, + ) { + super(message) + } +} + +function step(source: ModelState, event: Event, reducer: Reducer, coverage: Set): ModelState { + const s = structuredClone(source) + try { + return executeStep(s, event, reducer, coverage) + } catch (error) { + throw new ModelViolation(error instanceof Error ? error.message : String(error), s) + } +} + +function executeStep(s: ModelState, event: Event, reducer: Reducer, coverage: Set): ModelState { + const apply = (action: TranscriptAction, values: number[] = []) => { + const before = s.transport + const transition = reducer(before, action) + s.transport = transition.state + requireInvariant( + s.transport.generation === before.generation + (action.type === "invalidate" ? 1 : 0), + "generation is not monotonic", + ) + if ( + action.type === "enqueue" && + action.request.generation !== undefined && + action.request.generation !== before.generation + ) { + requireInvariant( + !transition.accepted && + s.transport.nextJobId === before.nextJobId && + s.transport.nextSnapshotId === before.nextSnapshotId, + "stale-generation request allocated work", + ) + } + if (transition.accepted) { + const job = transition.accepted + const scope = job.taskId ? `${job.taskId}:${s.epochs[job.taskId as TaskId]}` : "none" + const previousSeq = s.allocated[scope] ?? 0 + const expectedSeq = + previousSeq + + (action.type === "enqueue" && + (action.request.kind !== "snapshot" || action.request.bumpSeq) && + job.taskId + ? 1 + : 0) + requireInvariant(job.seq === expectedSeq, "allocated sequence diverged from capture order") + s.allocated[scope] = job.seq + s.captures.push({ job, scope, values: [...values], failed: false }) + s.payloads.push(job.id) + s.callers.push(job.id) + } + for (const id of transition.release) s.payloads = s.payloads.filter((value) => value !== id) + for (const { id } of transition.settle) s.callers = s.callers.filter((value) => value !== id) + if (action.type === "invalidate") { + requireInvariant( + s.transport.queue.length === 0 && !s.transport.active && s.payloads.length === 0, + "invalidation retained obsolete queue or payload", + ) + requireInvariant( + s.callers.every((id) => id === s.physical?.job.id), + "discarded caller did not settle immediately", + ) + } + if (action.type === "settle") { + const physical = s.physical + requireInvariant(physical, "settled without physical send") + if (physical.job.generation < s.transport.generation) s.staleCompletions++ + if ( + action.success && + physical.phase === "end" && + physical.job.generation < s.transport.generation && + physical.job.taskId === s.focus + ) + s.staleCommitCompletions++ + if (action.success) deliver(s, physical) + else { + s.captures[physical.job.id - 1].failed = true + s.failures++ + } + s.physical = undefined + } + if (transition.post) { + const frame = transition.post + const capture = s.captures[frame.job.id - 1] + requireInvariant(!s.physical, "overlapping physical sends") + requireInvariant( + capture.job.generation === s.transport.generation && frame.job.taskId === s.focus, + "post or commit initiated after invalidation", + ) + requireInvariant(!capture.failed, "failed snapshot continued posting") + requireInvariant( + frame.job.seq >= (s.sent[capture.scope] ?? 0), + "sent sequence regressed within task lifetime", + ) + requireInvariant(frame.job.seq <= s.allocated[capture.scope], "sent sequence exceeds allocation") + requireInvariant(s.payloads.includes(frame.job.id), "post without payload ownership") + s.sent[capture.scope] = frame.job.seq + s.physical = frame + coverage.add(frame.phase) + } + if ((action.type === "pump" || action.type === "invalidate") && transition.release.length) + coverage.add("discard") + const owned = [ + ...s.transport.queue.map((job) => job.id), + ...(s.transport.active ? [s.transport.active.job.id] : []), + ].sort((a, b) => a - b) + requireInvariant( + JSON.stringify(s.payloads) === JSON.stringify(owned), + "payload ownership differs from queue and active job", + ) + const callers = [...new Set([...owned, ...(s.physical ? [s.physical.job.id] : [])])].sort((a, b) => a - b) + requireInvariant( + JSON.stringify(s.callers) === JSON.stringify(callers), + "caller ownership differs from queued and physical work", + ) + } + + if (event.action) { + coverage.add(event.name) + apply(event.action) + } else if (event.intent && event.actor) { + s[event.actor]++ + coverage.add(event.intent) + const intent = event.intent + if (intent === "switch" || intent === "clear" || intent === "focus") { + const previous = s.focus + s.focus = intent === "clear" ? undefined : "b" + s.visible = [] + s.appliedSeq = 0 + s.staging = undefined + if (previous && intent !== "focus") { + apply({ type: "forget-task", taskId: previous }) + s.epochs[previous]++ + } + } + if (["switch", "clear", "invalidate", "resync"].includes(intent)) apply({ type: "invalidate" }) + if (intent !== "invalidate" && intent !== "focus") { + const kind = intent === "append" || intent === "update" ? intent : "snapshot" + if (s.focus && kind === "append") s.data[s.focus].push(4) + if (s.focus && kind === "update") s.data[s.focus][0] = 9 + const values = !s.focus ? [] : kind === "snapshot" ? s.data[s.focus] : kind === "append" ? [4] : [9] + apply( + { + type: "enqueue", + request: { + kind, + taskId: s.focus, + bumpSeq: intent === "snapshot", + ...(intent === "stale-snapshot" ? { generation: s.transport.generation - 1 } : {}), + }, + total: values.length, + focusedTaskId: s.focus, + }, + values, + ) + } + } + return s +} + +function canonical(s: ModelState): string { + return JSON.stringify({ ...s, transport: { ...s.transport, sequences: [...s.transport.sequences].sort() } }) +} + +export function exploreTranscriptTransport( + scenario: Scenario, + reducer: Reducer = reduceTranscriptTransport, + bounds: { depth: number; states: number } = TRANSPORT_MODEL_BOUNDS, +) { + const nodes: Node[] = [{ state: initialState(), parent: -1, event: "initial", depth: 0 }] + const visited = new Set([canonical(nodes[0].state)]) + const actions = new Set() + const landmarks = new Set() + let transitions = 0 + let maximumDepth = 0 + const trace = (index: number, lastEvent: string, failureState: ModelState) => { + const path: Array<{ event: string; state: ModelState }> = [] + for (let i = index; i >= 0; i = nodes[i].parent) path.push({ event: nodes[i].event, state: nodes[i].state }) + return [...path.reverse(), { event: lastEvent, state: failureState }] + } + for (let index = 0; index < nodes.length; index++) { + const node = nodes[index] + maximumDepth = Math.max(maximumDepth, node.depth) + for (const [name, predicate] of Object.entries(TRANSPORT_LANDMARKS)) + if (predicate(node.state)) landmarks.add(name) + for (const event of enabled(node.state, scenario)) { + let next: ModelState + try { + next = step(node.state, event, reducer, actions) + } catch (error) { + const witness = trace(index, event.name, error instanceof ModelViolation ? error.state : node.state) + return { + states: visited.size, + transitions, + maximumDepth, + actions, + landmarks, + violation: error instanceof Error ? error.message : String(error), + witness, + } + } + transitions++ + const key = canonical(next) + if (visited.has(key)) continue + if (node.depth >= bounds.depth) + throw new Error(`${scenario.name}: depth ${bounds.depth} truncation at ${event.name}`) + if (visited.size >= bounds.states) + throw new Error(`${scenario.name}: state budget ${bounds.states} exceeded`) + visited.add(key) + nodes.push({ state: next, parent: index, event: event.name, depth: node.depth + 1 }) + } + } + return { + states: visited.size, + transitions, + maximumDepth, + actions, + landmarks, + violation: undefined, + witness: undefined, + } +} + +export const TRANSPORT_MUTATIONS: Mutation[] = [ + { + name: "stale-completion-starts-end", + expected: "post or commit initiated after invalidation", + reduce: (state, action) => { + const result = reduceTranscriptTransport(state, action) + if ( + action.type === "settle" && + state.inFlight && + state.inFlight.job.generation < state.generation && + state.inFlight.phase !== "end" + ) { + result.post = { ...state.inFlight, phase: "end" } + result.state = { ...result.state, inFlight: result.post } + } + return result + }, + }, + { + name: "admit-stale-generation", + expected: "stale-generation request allocated work", + reduce: (state, action) => + reduceTranscriptTransport( + state, + action.type === "enqueue" + ? { ...action, request: { ...action.request, generation: state.generation } } + : action, + ), + }, + { + name: "ignore-focus-at-post", + expected: "post or commit initiated after invalidation", + reduce: (state, action) => + reduceTranscriptTransport( + state, + action.type === "pump" + ? { ...action, focusedTaskId: state.active?.job.taskId ?? state.queue[0]?.taskId } + : action, + ), + }, + { + name: "legacy-generation-only-invalidation", + expected: "invalidation retained obsolete queue or payload", + reduce: (state, action) => + action.type === "invalidate" + ? { state: { ...state, generation: state.generation + 1 }, release: [], settle: [] } + : reduceTranscriptTransport(state, action), + }, + { + name: "reset-promise-barrier", + expected: "overlapping physical sends", + reduce: (state, action) => { + const result = reduceTranscriptTransport(state, action) + if (action.type === "invalidate") result.state = { ...result.state, inFlight: undefined } + return result + }, + }, + { + name: "commit-before-chunks", + expected: "snapshot commit before complete chunks", + reduce: (state, action) => { + const result = reduceTranscriptTransport(state, action) + if (result.post?.phase === "chunk") { + result.post = { ...result.post, phase: "end" } + result.state = { ...result.state, inFlight: result.post } + } + return result + }, + }, + { + name: "reuse-delta-sequence", + expected: "allocated sequence diverged from capture order", + reduce: (state, action) => { + const result = reduceTranscriptTransport(state, action) + if (result.accepted && result.accepted.kind !== "snapshot") { + const job = { ...result.accepted, seq: result.accepted.seq - 1 } + result.accepted = job + result.state = { ...result.state, queue: [...result.state.queue.slice(0, -1), job] } + } + return result + }, + }, + { + name: "continue-after-rejection", + expected: "failed snapshot continued posting", + reduce: (state, action) => + reduceTranscriptTransport(state, action.type === "settle" ? { ...action, success: true } : action), + }, +] + +export function checkTranscriptTransportModel() { + const results = TRANSPORT_SCENARIOS.map((scenario) => ({ + name: scenario.name, + ...exploreTranscriptTransport(scenario), + })) + for (const result of results) { + if (result.violation) + throw new Error( + `${result.name}: ${result.violation}\nBounds: ${JSON.stringify(TRANSPORT_MODEL_BOUNDS)}\n${JSON.stringify(result.witness, (_key, value: unknown) => (value instanceof Map ? [...value] : value), 2)}`, + ) + } + const actions = new Set(results.flatMap((result) => [...result.actions])) + const landmarks = new Set(results.flatMap((result) => [...result.landmarks])) + for (const action of TRANSPORT_ACTIONS) requireInvariant(actions.has(action), `unreachable action: ${action}`) + for (const landmark of Object.keys(TRANSPORT_LANDMARKS)) + requireInvariant(landmarks.has(landmark), `unreachable landmark: ${landmark}`) + const counterexamples = TRANSPORT_MUTATIONS.map((mutation) => { + const failures = TRANSPORT_SCENARIOS.map((scenario) => ({ + scenario: scenario.name, + ...exploreTranscriptTransport(scenario, mutation.reduce), + })).filter((result) => result.violation) + const result = failures.sort((a, b) => a.witness!.length - b.witness!.length)[0] + requireInvariant(result, `${mutation.name}: expected a counterexample`) + requireInvariant( + result.violation === mutation.expected, + `${mutation.name}: expected ${mutation.expected}; got ${result.violation}`, + ) + return { + name: mutation.name, + scenario: result.scenario, + violation: result.violation, + trace: result.witness!.map((entry) => entry.event), + } + }) + return { results, actions: [...actions].sort(), landmarks: [...landmarks].sort(), counterexamples } +} diff --git a/src/core/webview/__tests__/transcriptTransport.spec.ts b/src/core/webview/__tests__/transcriptTransport.spec.ts new file mode 100644 index 0000000000..e1c0a84b22 --- /dev/null +++ b/src/core/webview/__tests__/transcriptTransport.spec.ts @@ -0,0 +1,146 @@ +import type { ClineMessage, ExtensionMessage } from "@roo-code/types" +import { createTranscriptTransportState, TranscriptTransport } from "../transcriptTransport" +import { + checkTranscriptTransportModel, + exploreTranscriptTransport, + TRANSPORT_ACTIONS, + TRANSPORT_LANDMARKS, + TRANSPORT_MUTATIONS, + TRANSPORT_SCENARIOS, +} from "./transcriptTransport.model" + +describe("transcript transport bounded model", () => { + test("exhausts all scenarios, actions and landmarks and rejects every mutant", () => { + const result = checkTranscriptTransportModel() + expect(result.results).toHaveLength(TRANSPORT_SCENARIOS.length) + expect(result.actions).toEqual([...TRANSPORT_ACTIONS].sort()) + expect(result.landmarks).toEqual(Object.keys(TRANSPORT_LANDMARKS).sort()) + expect(result.counterexamples).toHaveLength(TRANSPORT_MUTATIONS.length) + }) + + test("fails closed on depth and state truncation", () => { + expect(() => + exploreTranscriptTransport(TRANSPORT_SCENARIOS[0], undefined, { depth: 0, states: 30_000 }), + ).toThrow("depth 0 truncation") + expect(() => exploreTranscriptTransport(TRANSPORT_SCENARIOS[0], undefined, { depth: 40, states: 1 })).toThrow( + "state budget 1 exceeded", + ) + }) + + test("produces deterministic shortest counterexamples", () => { + const mutation = TRANSPORT_MUTATIONS.find(({ name }) => name === "reset-promise-barrier")! + const first = exploreTranscriptTransport(TRANSPORT_SCENARIOS[0], mutation.reduce) + const second = exploreTranscriptTransport(TRANSPORT_SCENARIOS[0], mutation.reduce) + expect(first.witness).toEqual(second.witness) + expect(first.witness?.map(({ event }) => event)).toEqual([ + "initial", + "producer:snapshot", + "pump", + "controller:resync", + "pump", + ]) + }) +}) + +describe("transcript transport driver", () => { + const message: ClineMessage = { ts: 1, type: "say", text: "initial", images: ["image"] } + + test.each(["start", "chunk", "end", "delta"] as const)( + "keeps the physical barrier across rejected held %s and recovers", + async (phase) => { + const type: ExtensionMessage["type"] = + phase === "start" + ? "clineMessagesSnapshotStart" + : phase === "chunk" + ? "clineMessagesSnapshotChunk" + : phase === "end" + ? "clineMessagesSnapshotEnd" + : "clineMessageAppended" + let rejectHeld!: (error: Error) => void + let notifyStarted!: () => void + const held = new Promise((_resolve, reject) => { + rejectHeld = reject + }) + const started = new Promise((resolve) => { + notifyStarted = resolve + }) + let heldOnce = false + let physical = 0 + let maximumPhysical = 0 + const post = vi.fn(async (frame: ExtensionMessage) => { + physical++ + maximumPhysical = Math.max(maximumPhysical, physical) + try { + if (frame.type === type && !heldOnce) { + heldOnce = true + notifyStarted() + await held + } + } finally { + physical-- + } + }) + const log = vi.fn() + const transport = new TranscriptTransport(() => "a", post, log) + const active = transport.enqueue({ kind: phase === "delta" ? "append" : "snapshot", taskId: "a" }, [ + message, + ]) + const rejected = expect(active).rejects.toThrow("held post failed") + await started + const discarded = transport.enqueue({ kind: "update", taskId: "a" }, [message]) + transport.invalidate() + await discarded + const recovery = transport.enqueue({ kind: "snapshot", taskId: "a" }, [message]) + expect(physical).toBe(1) + expect(transport["payloads"].size).toBe(1) + const before = post.mock.calls.length + rejectHeld(new Error("held post failed")) + await Promise.all([rejected, recovery]) + expect(maximumPhysical).toBe(1) + expect(post.mock.calls.slice(before).map(([frame]) => frame.type)).toEqual([ + "clineMessagesSnapshotStart", + "clineMessagesSnapshotChunk", + "clineMessagesSnapshotEnd", + ]) + expect(log).toHaveBeenCalledOnce() + expect(transport["callers"].size).toBe(0) + expect(transport["payloads"].size).toBe(0) + }, + ) + + test("recovers from a synchronous post throw", async () => { + const post = vi + .fn<(frame: ExtensionMessage) => Promise>() + .mockImplementationOnce(() => { + throw new Error("sync failure") + }) + .mockResolvedValue(undefined) + const transport = new TranscriptTransport(() => "a", post, vi.fn()) + await expect(transport.enqueue({ kind: "append", taskId: "a" }, [message])).rejects.toThrow("sync failure") + await transport.enqueue({ kind: "update", taskId: "a" }, [message]) + expect(post.mock.calls.map(([frame]) => frame.clineMessagesSeq)).toEqual([1, 2]) + }) + + test("rejects invalid chunk-size bounds", () => { + for (const size of [0, -1, 1.5, Infinity]) + expect(() => createTranscriptTransportState(size)).toThrow("positive safe integer") + }) + + test("does not adopt a newer generation if cloning reenters invalidation", async () => { + const post = vi.fn().mockResolvedValue(undefined) + const transport = new TranscriptTransport(() => "a", post, vi.fn()) + const reentrant: ClineMessage = { + ts: 1, + type: "say", + get text() { + transport.invalidate() + return "obsolete" + }, + } + await transport.enqueue({ kind: "snapshot", taskId: "a", bumpSeq: true }, [reentrant]) + expect(transport.generation).toBe(1) + expect(transport.getSequence("a")).toBe(0) + expect(transport["state"].nextSnapshotId).toBe(0) + expect(post).not.toHaveBeenCalled() + }) +}) diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index 1821ccf9dc..c70043fa7b 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -69,7 +69,7 @@ vi.mock("@roo-code/telemetry", () => ({ }, })) -import type { ModelRecord } from "@roo-code/types" +import type { ModelRecord, WebviewMessage } from "@roo-code/types" import { webviewMessageHandler } from "../webviewMessageHandler" import type { ClineProvider } from "../ClineProvider" @@ -151,16 +151,41 @@ describe("webviewMessageHandler - transcript resync", () => { vi.clearAllMocks() }) - it("delegates a task-scoped transcript resync to the provider", async () => { + it.each>([ + { taskId: "task-1", expectedSeq: 4, receivedSeq: 7 }, + { taskId: "task-1" }, + { taskId: "task-1", expectedSeq: Number.MAX_SAFE_INTEGER, receivedSeq: 0 }, + { taskId: "task-1", expectedSeq: 0 }, + { taskId: "task-1", receivedSeq: 0 }, + {}, + { expectedSeq: 1, receivedSeq: 0 }, + ])("forwards transcript resync scope and optional diagnostics: %j", async (request) => { await webviewMessageHandler(mockClineProvider, { type: "requestClineMessagesResync", - taskId: "task-1", - expectedSeq: 4, - receivedSeq: 7, + ...request, }) expect(mockClineProvider.resyncClineMessagesToWebview).toHaveBeenCalledOnce() - expect(mockClineProvider.resyncClineMessagesToWebview).toHaveBeenCalledWith("task-1") + expect(mockClineProvider.resyncClineMessagesToWebview).toHaveBeenCalledWith( + request.taskId, + request.expectedSeq, + request.receivedSeq, + ) + expect(mockClineProvider.log).not.toHaveBeenCalled() + }) + + it("leaves validation of untrusted diagnostics to the provider without logging the payload", async () => { + const expectedSeq = { secret: "must not be logged" } + const receivedSeq = ["must not be logged"] + const message: WebviewMessage = { type: "requestClineMessagesResync", taskId: "task-1" } + // Runtime webview payloads can violate the compile-time message contract. + Object.assign(message, { expectedSeq, receivedSeq }) + + await webviewMessageHandler(mockClineProvider, message) + + expect(mockClineProvider.resyncClineMessagesToWebview).toHaveBeenCalledOnce() + expect(mockClineProvider.resyncClineMessagesToWebview).toHaveBeenCalledWith("task-1", expectedSeq, receivedSeq) + expect(mockClineProvider.log).not.toHaveBeenCalled() }) }) diff --git a/src/core/webview/transcriptTransport.ts b/src/core/webview/transcriptTransport.ts new file mode 100644 index 0000000000..bee1eb53ba --- /dev/null +++ b/src/core/webview/transcriptTransport.ts @@ -0,0 +1,274 @@ +import type { ClineMessage, ExtensionMessage } from "@roo-code/types" + +export type TranscriptRequest = { + kind: "append" | "update" | "snapshot" + taskId: string | undefined + generation?: number + bumpSeq?: boolean +} + +export type TranscriptJob = { + id: number + generation: number + taskId: string | undefined + seq: number + kind: TranscriptRequest["kind"] + total: number + snapshotId?: string +} + +export type TranscriptFrame = { + job: TranscriptJob + phase: "append" | "update" | "start" | "chunk" | "end" + start: number + count: number +} + +/** Payloads and Promise resolvers deliberately live outside the pure protocol state. */ +export type TranscriptTransportState = { + generation: number + nextJobId: number + nextSnapshotId: number + sequences: ReadonlyMap + chunkSize: number + queue: readonly TranscriptJob[] + active?: { job: TranscriptJob; position: number } + inFlight?: TranscriptFrame +} + +export type TranscriptAction = + | { type: "enqueue"; request: TranscriptRequest; total: number; focusedTaskId: string | undefined } + | { type: "invalidate" } + | { type: "forget-task"; taskId: string } + | { type: "pump"; focusedTaskId: string | undefined } + | { type: "settle"; success: boolean } + +export type TranscriptTransition = { + state: TranscriptTransportState + accepted?: TranscriptJob + post?: TranscriptFrame + /** Drop all owned payload references, including an invalidated snapshot's unsent suffix. */ + release: number[] + /** Active physical sends settle only at their actual completion boundary. */ + settle: Array<{ id: number; failed?: boolean }> +} + +export function createTranscriptTransportState(chunkSize = 200): TranscriptTransportState { + if (!Number.isSafeInteger(chunkSize) || chunkSize < 1) { + throw new Error("Transcript chunk size must be a positive safe integer") + } + return { generation: 0, nextJobId: 0, nextSnapshotId: 0, sequences: new Map(), chunkSize, queue: [] } +} + +export function isTranscriptRequestCurrent( + state: TranscriptTransportState, + request: TranscriptRequest, + focusedTaskId: string | undefined, +): boolean { + return ( + (request.generation ?? state.generation) === state.generation && + request.taskId === focusedTaskId && + (request.kind === "snapshot" || request.taskId !== undefined) + ) +} + +/** Shared by the production driver and the exhaustive bounded explorer. No I/O or mutation. */ +export function reduceTranscriptTransport( + state: TranscriptTransportState, + action: TranscriptAction, +): TranscriptTransition { + const result: TranscriptTransition = { state, release: [], settle: [] } + const discard = (job: TranscriptJob) => { + result.release.push(job.id) + if (state.inFlight?.job.id !== job.id) result.settle.push({ id: job.id }) + } + switch (action.type) { + case "enqueue": { + const { request, total, focusedTaskId } = action + if (!isTranscriptRequestCurrent(state, request, focusedTaskId)) return result + const sequences = new Map(state.sequences) + const seq = request.taskId + ? (sequences.get(request.taskId) ?? 0) + (request.kind !== "snapshot" || request.bumpSeq ? 1 : 0) + : 0 + if (request.taskId) sequences.set(request.taskId, seq) + const nextSnapshotId = state.nextSnapshotId + (request.kind === "snapshot" ? 1 : 0) + const job: TranscriptJob = { + id: state.nextJobId + 1, + generation: state.generation, + taskId: request.taskId, + seq, + kind: request.kind, + total, + ...(request.kind === "snapshot" ? { snapshotId: `${request.taskId ?? "none"}:${nextSnapshotId}` } : {}), + } + result.accepted = job + result.state = { ...state, sequences, nextSnapshotId, nextJobId: job.id, queue: [...state.queue, job] } + return result + } + case "invalidate": + state.queue.forEach(discard) + if (state.active) discard(state.active.job) + // Never reset inFlight: an already invoked physical send cannot be unsent. + result.state = { ...state, generation: state.generation + 1, queue: [], active: undefined } + return result + case "forget-task": { + const sequences = new Map(state.sequences) + sequences.delete(action.taskId) + result.state = { ...state, sequences } + return result + } + case "pump": { + if (state.inFlight) return result + let active = state.active + const queue = [...state.queue] + while (active || queue.length) { + active ??= { job: queue.shift()!, position: 0 } + const { job, position } = active + if (job.generation !== state.generation || job.taskId !== action.focusedTaskId) { + discard(job) + active = undefined + continue + } + const chunks = Math.ceil(job.total / state.chunkSize) + const phase = + job.kind !== "snapshot" ? job.kind : position === 0 ? "start" : position > chunks ? "end" : "chunk" + const start = phase === "chunk" ? (position - 1) * state.chunkSize : 0 + const frame: TranscriptFrame = { + job, + phase, + start, + count: phase === "chunk" ? Math.min(state.chunkSize, job.total - start) : 0, + } + result.post = frame + result.state = { ...state, queue, active, inFlight: frame } + return result + } + result.state = { ...state, queue, active } + return result + } + case "settle": { + if (!state.inFlight) return result + const { job, phase } = state.inFlight + const finished = !action.success || !state.active || phase === "end" || job.kind !== "snapshot" + if (finished) { + result.release.push(job.id) + result.settle.push({ id: job.id, failed: !action.success }) + } + result.state = { + ...state, + inFlight: undefined, + active: finished ? undefined : { job, position: state.active!.position + 1 }, + } + return result + } + } +} + +export function transcriptFrameMessage(frame: TranscriptFrame, messages: readonly ClineMessage[]): ExtensionMessage { + const { job, phase } = frame + const common = { taskId: job.taskId, clineMessagesSeq: job.seq } + if (phase === "append" || phase === "update") { + return { + ...common, + type: phase === "append" ? "clineMessageAppended" : "clineMessageUpdated", + clineMessage: messages[0], + } + } + if (phase === "chunk") { + return { + ...common, + type: "clineMessagesSnapshotChunk", + snapshotId: job.snapshotId, + snapshotStartIndex: frame.start, + clineMessages: messages.slice(frame.start, frame.start + frame.count), + } + } + return { + ...common, + type: phase === "start" ? "clineMessagesSnapshotStart" : "clineMessagesSnapshotEnd", + snapshotId: job.snapshotId, + snapshotTotal: job.total, + } +} + +/** One driver owns all physical transcript sends, even across repeated invalidations. */ +export class TranscriptTransport { + private state = createTranscriptTransportState() + private readonly payloads = new Map() + private readonly callers = new Map void; reject: (error: unknown) => void }>() + + constructor( + private readonly focusedTaskId: () => string | undefined, + private readonly postMessage: (message: ExtensionMessage) => Promise, + private readonly onError: (error: unknown) => void, + ) {} + + get generation(): number { + return this.state.generation + } + + getSequence(taskId: string): number { + return this.state.sequences.get(taskId) ?? 0 + } + + forgetTask(taskId: string): void { + this.apply({ type: "forget-task", taskId }) + } + + invalidate(): number { + this.apply({ type: "invalidate" }) + return this.generation + } + + enqueue(request: TranscriptRequest, messages: readonly ClineMessage[]): Promise { + // Guard before deep cloning (and allocating a sequence/ID). A delayed focus sync + // must not traverse a large, already-obsolete transcript. + const capturedRequest = { ...request, generation: request.generation ?? this.generation } + if (!isTranscriptRequestCurrent(this.state, capturedRequest, this.focusedTaskId())) return Promise.resolve() + // Task mutates message objects AND nested fields while posts are queued. Capture + // the complete value now, together with its sequence, not at physical-send time. + const payload = structuredClone(messages) + const { accepted } = this.apply({ + type: "enqueue", + request: capturedRequest, + total: payload.length, + focusedTaskId: this.focusedTaskId(), + }) + if (!accepted) return Promise.resolve() + this.payloads.set(accepted.id, payload) + const promise = new Promise((resolve, reject) => this.callers.set(accepted.id, { resolve, reject })) + this.drain() + return promise + } + + private apply(action: TranscriptAction, error?: unknown): TranscriptTransition { + const transition = reduceTranscriptTransport(this.state, action) + this.state = transition.state + for (const id of transition.release) this.payloads.delete(id) + for (const { id, failed } of transition.settle) { + const caller = this.callers.get(id) + this.callers.delete(id) + if (failed) caller?.reject(error) + else caller?.resolve() + } + return transition + } + + private drain(): void { + const { post } = this.apply({ type: "pump", focusedTaskId: this.focusedTaskId() }) + if (post) void this.send(post) + } + + private async send(frame: TranscriptFrame): Promise { + try { + // Do not retain the full payload in this async frame. Invalidation can release + // the unsent snapshot suffix while only this physical message remains held. + await this.postMessage(transcriptFrameMessage(frame, this.payloads.get(frame.job.id)!)) + this.apply({ type: "settle", success: true }) + } catch (error) { + this.onError(error) + this.apply({ type: "settle", success: false }, error) + } + this.drain() + } +} diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 4941fe1b29..12036a20e2 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -576,7 +576,7 @@ export const webviewMessageHandler = async ( switch (message.type) { case "requestClineMessagesResync": - await provider.resyncClineMessagesToWebview(message.taskId) + await provider.resyncClineMessagesToWebview(message.taskId, message.expectedSeq, message.receivedSeq) break case "themeFixtureProbeResponse": if (process.env.ROO_CODE_THEME_FIXTURE_PROBE === "1" && message.requestId && message.themeFixture) { @@ -1936,7 +1936,7 @@ export const webviewMessageHandler = async ( const existingPrompts = getGlobalState("customModePrompts") ?? {} const updatedPrompts = { ...existingPrompts, [message.promptMode]: message.customPrompt } await updateGlobalState("customModePrompts", updatedPrompts) - await provider.postStateToWebviewWithoutClineMessages() + await provider.postStateToWebviewWithoutTaskHistory() if (TelemetryService.hasInstance()) { // Determine which setting was changed by comparing objects diff --git a/src/extension.ts b/src/extension.ts index 0a78cd32ba..70c4b2d9b5 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -191,7 +191,7 @@ export async function activate(context: vscode.ExtensionContext) { // Push the new vscode.env.isTelemetryEnabled value to the webview too, so its // own PostHog client (gated separately in TelemetryClient.ts) can't keep // sending events after the global toggle flips off mid-session. - void ClineProvider.getVisibleInstance()?.postStateToWebviewWithoutClineMessages() + void ClineProvider.getVisibleInstance()?.postStateToWebviewWithoutTaskHistory() }), ) @@ -224,7 +224,7 @@ export async function activate(context: vscode.ExtensionContext) { // Initialize Roo Code Cloud service. settingsUpdatedHandler = () => { void ClineProvider.getVisibleInstance() - ?.postStateToWebviewWithoutClineMessages() + ?.postStateToWebviewWithoutTaskHistory() .catch((error) => { outputChannel.appendLine( `[CloudService] Failed to refresh state after settings update: ${error instanceof Error ? error.message : String(error)}`, From d87c5abe592bd0f2b39513df3b3e68a3cb1d0cb3 Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Fri, 11 Sep 2026 09:37:07 -0600 Subject: [PATCH 32/38] fix(task): deliver leading transcript updates and pin resume ordering --- src/core/task/Task.ts | 23 +- .../task/__tests__/Task.persistence.spec.ts | 49 +++- src/core/task/__tests__/Task.spec.ts | 272 ++++++++++++------ 3 files changed, 240 insertions(+), 104 deletions(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index a0c4978398..d33dbbe589 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -668,16 +668,21 @@ export class Task extends EventEmitter implements TaskLike { this.TOKEN_USAGE_EMIT_INTERVAL_MS, { leading: true, trailing: true, maxWait: this.TOKEN_USAGE_EMIT_INTERVAL_MS }, ) - this.debouncedPostPartialMessageUpdate = debounce((message: ClineMessage) => { - const provider = this.providerRef.deref() - if (!provider) { - return - } + // Show the first revision immediately, then coalesce streaming updates without starving the webview. + this.debouncedPostPartialMessageUpdate = debounce( + (message: ClineMessage) => { + const provider = this.providerRef.deref() + if (!provider) { + return + } - void provider.postClineMessageUpdated(this.taskId, message).catch((error) => { - console.error("[Task#updateClineMessage] incremental post failed:", error) - }) - }, PARTIAL_MESSAGE_UPDATE_DEBOUNCE_MS) + void provider.postClineMessageUpdated(this.taskId, message).catch((error) => { + console.error("[Task#updateClineMessage] incremental post failed:", error) + }) + }, + PARTIAL_MESSAGE_UPDATE_DEBOUNCE_MS, + { leading: true, trailing: true, maxWait: PARTIAL_MESSAGE_UPDATE_DEBOUNCE_MS }, + ) onCreated?.(this) diff --git a/src/core/task/__tests__/Task.persistence.spec.ts b/src/core/task/__tests__/Task.persistence.spec.ts index f76644bac4..1bb00f1332 100644 --- a/src/core/task/__tests__/Task.persistence.spec.ts +++ b/src/core/task/__tests__/Task.persistence.spec.ts @@ -1344,7 +1344,7 @@ describe("Task persistence", () => { result: "Done", } - it("replays an unresolved pending action instead of a generic resume ask", async () => { + it("awaits the hydrated snapshot before replaying an unresolved pending action instead of a generic resume ask", async () => { const messages: ClineMessage[] = [ { ts: 1, type: "say", say: "text", text: "Child" }, { ts: 2, type: "ask", ask: "tool", text: pendingAction.approvalText }, @@ -1371,26 +1371,51 @@ describe("Task persistence", () => { }, startTask: false, }) + const events: string[] = [] const replay = vi .spyOn(getTaskPersistenceAccess(task), "resumePendingTaskAction") .mockImplementation(async () => { + events.push("replay") expect(task.isInitialized).toBe(true) }) const ask = vi.spyOn(task, "ask") + const snapshotStarted = createDeferred() const snapshotDeferred = createDeferred() - const snapshot = vi - .mocked(mockProvider.postClineMessagesSnapshot) - .mockReturnValueOnce(snapshotDeferred.promise) + const snapshot = vi.mocked(mockProvider.postClineMessagesSnapshot).mockImplementationOnce(async () => { + events.push("snapshot started") + snapshotStarted.resolve() + await snapshotDeferred.promise + events.push("snapshot resolved") + }) - const resumePromise = getTaskPersistenceAccess(task).resumeTaskFromHistory() - await vi.waitFor(() => expect(snapshot).toHaveBeenCalledWith(task.taskId, { bumpSeq: true })) - expect(replay).not.toHaveBeenCalled() - expect(task.clineMessages).toEqual([expect.objectContaining({ text: "Child" })]) - expect(task.apiConversationHistory).toHaveLength(1) - snapshotDeferred.resolve() - await resumePromise + const resumePromise = getTaskPersistenceAccess(task) + .resumeTaskFromHistory() + .then(() => { + events.push("resume finished") + }) + try { + // An explicit entry signal avoids polling or guessed microtask counts. Racing resume settlement + // also makes a swapped branch that returns before the snapshot fail without hanging the test. + await Promise.race([snapshotStarted.promise, resumePromise]) + expect(snapshot).toHaveBeenCalledExactlyOnceWith(task.taskId, { bumpSeq: true }) + expect(events).toEqual(["snapshot started"]) + expect(replay).not.toHaveBeenCalled() + expect(ask).not.toHaveBeenCalled() + expect(task.isInitialized).toBe(false) + expect(task.clineMessages).toEqual([expect.objectContaining({ text: "Child" })]) + expect(task.apiConversationHistory).toEqual([ + expect.objectContaining({ + role: "assistant", + content: [{ type: "tool_use", id: "finish-action", name: "attempt_completion", input: {} }], + }), + ]) + } finally { + snapshotDeferred.resolve() + await resumePromise + } - expect(replay).toHaveBeenCalledWith(pendingAction) + expect(events).toEqual(["snapshot started", "snapshot resolved", "replay", "resume finished"]) + expect(replay).toHaveBeenCalledExactlyOnceWith(pendingAction) expect(ask).not.toHaveBeenCalled() expect(task.clineMessages).not.toEqual( expect.arrayContaining([expect.objectContaining({ text: pendingAction.approvalText })]), diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index d8398f77e7..9564a2d53c 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -2267,14 +2267,18 @@ describe("Cline", () => { partial: true, } const replacement = { ...staleMessage, ts: 2, text: "replacement partial" } + const firstMessage = { ...staleMessage, text: "first partial" } + const updatePostSpy = vi.mocked(mockProvider.postClineMessageUpdated) task.clineMessages = [staleMessage] + await taskAccess.updateClineMessage(firstMessage) await taskAccess.updateClineMessage(staleMessage) + expect(updatePostSpy.mock.calls).toEqual([[task.taskId, firstMessage]]) const overwritePromise = task.overwriteClineMessages([replacement], persist) await vi.advanceTimersByTimeAsync(500) expect(task.clineMessages).toEqual([replacement]) - expect(mockProvider.postClineMessageUpdated).not.toHaveBeenCalled() + expect(updatePostSpy.mock.calls).toEqual([[task.taskId, firstMessage]]) expect(mockProvider.postClineMessagesSnapshot).toHaveBeenCalledTimes(persist ? 0 : 1) releaseSave(true) @@ -2283,13 +2287,16 @@ describe("Cline", () => { expect(mockProvider.postClineMessagesSnapshot).toHaveBeenCalledOnce() expect(mockProvider.postClineMessagesSnapshot).toHaveBeenCalledWith(task.taskId, { bumpSeq: true }) - expect(mockProvider.postClineMessageUpdated).not.toHaveBeenCalled() + expect(updatePostSpy.mock.calls).toEqual([[task.taskId, firstMessage]]) await taskAccess.updateClineMessage(replacement) + expect(updatePostSpy.mock.calls).toEqual([ + [task.taskId, firstMessage], + [task.taskId, replacement], + ]) await vi.advanceTimersByTimeAsync(500) - expect(mockProvider.postClineMessageUpdated).toHaveBeenCalledOnce() - expect(mockProvider.postClineMessageUpdated).toHaveBeenCalledWith(task.taskId, replacement) + expect(updatePostSpy).toHaveBeenCalledTimes(2) }) it("propagates an overwrite snapshot failure after persistence", async () => { @@ -2472,7 +2479,7 @@ describe("Cline", () => { expect(mockProvider.postClineMessageAppended).toHaveBeenCalledWith(task.taskId, message) }) - it("serializes a new partial message before its debounced following update", async () => { + it("serializes a new partial message before its immediate leading update", async () => { vi.useFakeTimers() const task = new Task({ provider: mockProvider, @@ -2507,17 +2514,39 @@ describe("Cline", () => { expect(updatePostSpy).not.toHaveBeenCalled() releaseAppend() - await vi.advanceTimersByTimeAsync(500) await addThenUpdate + expect(updatePostSpy).toHaveBeenCalledOnce() expect(appendSpy.mock.invocationCallOrder[0]).toBeLessThan(updatePostSpy.mock.invocationCallOrder[0]) expect(updatePostSpy).toHaveBeenCalledWith(task.taskId, { ...partialMessage, text: "updated partial", }) + + await vi.advanceTimersByTimeAsync(500) + expect(updatePostSpy).toHaveBeenCalledOnce() + }) + + it("posts the first partial update immediately without duplicating it on the trailing edge", async () => { + vi.useFakeTimers() + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + const updatePostSpy = vi.mocked(mockProvider.postClineMessageUpdated) + const message = { ts: 1, type: "say" as const, say: "text" as const, text: "first partial", partial: true } + + const updatePromise = getTaskTestAccess(task).updateClineMessage(message) + + expect(updatePostSpy.mock.calls).toEqual([[task.taskId, message]]) + await updatePromise + await vi.advanceTimersByTimeAsync(1_000) + expect(updatePostSpy).toHaveBeenCalledOnce() }) - it("debounces partial updates and posts the latest revision on the trailing edge", async () => { + it("coalesces partial updates after the leading post and flushes the latest revision on the trailing edge", async () => { vi.useFakeTimers() const task = new Task({ provider: mockProvider, @@ -2528,34 +2557,33 @@ describe("Cline", () => { const taskAccess = getTaskTestAccess(task) const updatePostSpy = vi.mocked(mockProvider.postClineMessageUpdated) - void taskAccess.updateClineMessage({ + const first = { ts: 1, - type: "say", - say: "text", + type: "say" as const, + say: "text" as const, text: "first partial", partial: true, - }) - await vi.advanceTimersByTimeAsync(250) - void taskAccess.updateClineMessage({ - ts: 1, - type: "say", - say: "text", - text: "latest partial", - partial: true, - }) + } + const latest = { ...first, text: "latest partial" } + await taskAccess.updateClineMessage(first) + await vi.advanceTimersByTimeAsync(100) + await taskAccess.updateClineMessage({ ...first, text: "superseded partial" }) + await vi.advanceTimersByTimeAsync(150) + await taskAccess.updateClineMessage(latest) - await vi.advanceTimersByTimeAsync(499) - expect(updatePostSpy).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(249) + expect(updatePostSpy.mock.calls).toEqual([[task.taskId, first]]) await vi.advanceTimersByTimeAsync(1) - expect(updatePostSpy).toHaveBeenCalledOnce() - expect(updatePostSpy).toHaveBeenCalledWith( - task.taskId, - expect.objectContaining({ text: "latest partial", partial: true }), - ) + expect(updatePostSpy.mock.calls).toEqual([ + [task.taskId, first], + [task.taskId, latest], + ]) + await vi.advanceTimersByTimeAsync(1_000) + expect(updatePostSpy).toHaveBeenCalledTimes(2) }) - it("drops a debounced partial update when the provider reference expires", async () => { + it("bounds ongoing partial delivery by maxWait without posting every revision", async () => { vi.useFakeTimers() const task = new Task({ provider: mockProvider, @@ -2563,23 +2591,67 @@ describe("Cline", () => { task: "test task", startTask: false, }) - Object.defineProperty(task, "providerRef", { - value: { deref: () => undefined }, - configurable: true, - }) - - await getTaskTestAccess(task).updateClineMessage({ - ts: 1, - type: "say", - say: "text", - text: "partial", - partial: true, - }) - await vi.advanceTimersByTimeAsync(500) + const taskAccess = getTaskTestAccess(task) + const updatePostSpy = vi.mocked(mockProvider.postClineMessageUpdated) + const first = { ts: 1, type: "say" as const, say: "text" as const, text: "partial 0", partial: true } + await taskAccess.updateClineMessage(first) + + // Updates never pause for the debounce interval, so a trailing-only debounce would starve the webview. + for (let elapsed = 100; elapsed <= 1_400; elapsed += 100) { + await vi.advanceTimersByTimeAsync(100) + await taskAccess.updateClineMessage({ ...first, text: `partial ${elapsed}` }) + expect(updatePostSpy).toHaveBeenCalledTimes(1 + Math.floor(elapsed / 500)) + } - expect(mockProvider.postClineMessageUpdated).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(100) + expect(updatePostSpy.mock.calls).toEqual([ + [task.taskId, first], + [task.taskId, { ...first, text: "partial 400" }], + [task.taskId, { ...first, text: "partial 900" }], + [task.taskId, { ...first, text: "partial 1400" }], + ]) + await vi.advanceTimersByTimeAsync(1_000) + expect(updatePostSpy).toHaveBeenCalledTimes(4) }) + it.each(["leading", "trailing"] as const)( + "drops the %s partial update when the provider reference expires", + async (edge) => { + vi.useFakeTimers() + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + const taskAccess = getTaskTestAccess(task) + const first = { + ts: 1, + type: "say" as const, + say: "text" as const, + text: "first partial", + partial: true, + } + if (edge === "trailing") { + await taskAccess.updateClineMessage(first) + await taskAccess.updateClineMessage({ ...first, text: "queued partial" }) + } + Object.defineProperty(task, "providerRef", { + value: { deref: () => undefined }, + configurable: true, + }) + + if (edge === "leading") { + await taskAccess.updateClineMessage(first) + } + await vi.advanceTimersByTimeAsync(500) + + expect(vi.mocked(mockProvider.postClineMessageUpdated).mock.calls).toEqual( + edge === "leading" ? [] : [[task.taskId, first]], + ) + }, + ) + it("emits a complete update when the provider reference is unavailable", async () => { const task = new Task({ provider: mockProvider, @@ -2600,27 +2672,38 @@ describe("Cline", () => { expect(messageListener).toHaveBeenCalledWith({ action: "updated", message }) }) - it("cancels a pending partial update when the task is disposed", async () => { - vi.useFakeTimers() - const task = new Task({ - provider: mockProvider, - apiConfiguration: mockApiConfig, - task: "test task", - startTask: false, - }) + it.each(["dispose", "abortTask"] as const)( + "cancels a queued trailing partial update on %s", + async (cleanup) => { + vi.useFakeTimers() + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + const taskAccess = getTaskTestAccess(task) + vi.spyOn(taskAccess, "saveClineMessages").mockResolvedValue(true) + const updatePostSpy = vi.mocked(mockProvider.postClineMessageUpdated) + const first = { + ts: 1, + type: "say" as const, + say: "text" as const, + text: "first partial", + partial: true, + } - await getTaskTestAccess(task).updateClineMessage({ - ts: 1, - type: "say", - say: "text", - text: "partial", - partial: true, - }) - await task.dispose() - await vi.advanceTimersByTimeAsync(500) + await taskAccess.updateClineMessage(first) + await vi.advanceTimersByTimeAsync(100) + await taskAccess.updateClineMessage({ ...first, text: "queued partial" }) + expect(updatePostSpy.mock.calls).toEqual([[task.taskId, first]]) - expect(mockProvider.postClineMessageUpdated).not.toHaveBeenCalled() - }) + await task[cleanup]() + await vi.advanceTimersByTimeAsync(1_000) + + expect(updatePostSpy.mock.calls).toEqual([[task.taskId, first]]) + }, + ) it.each([ ["false", { ts: 1, type: "say" as const, say: "text" as const, text: "complete", partial: false }], @@ -2638,24 +2721,47 @@ describe("Cline", () => { const taskAccess = getTaskTestAccess(task) const updatePostSpy = vi.mocked(mockProvider.postClineMessageUpdated) - void taskAccess.updateClineMessage({ + const first = { ts: 1, - type: "say", - say: "text", - text: "partial", + type: "say" as const, + say: "text" as const, + text: "first partial", partial: true, + } + await taskAccess.updateClineMessage(first) + await vi.advanceTimersByTimeAsync(100) + await taskAccess.updateClineMessage({ ...first, text: "superseded partial" }) + expect(updatePostSpy.mock.calls).toEqual([[task.taskId, first]]) + + let releasePost!: () => void + const pendingPost = new Promise((resolve) => { + releasePost = resolve }) - await taskAccess.updateClineMessage(complete) - - expect(updatePostSpy).toHaveBeenCalledOnce() - expect(updatePostSpy).toHaveBeenCalledWith(task.taskId, complete) - - await vi.advanceTimersByTimeAsync(500) - expect(updatePostSpy).toHaveBeenCalledOnce() + updatePostSpy.mockReturnValueOnce(pendingPost) + const messageListener = vi.fn() + task.on(RooCodeEventName.Message, messageListener) + const completionPromise = taskAccess.updateClineMessage(complete) + + expect(updatePostSpy.mock.calls).toEqual([ + [task.taskId, first], + [task.taskId, complete], + ]) + expect(messageListener).not.toHaveBeenCalled() + + // A queued partial must not arrive after the final post, even while that post is still pending. + await vi.advanceTimersByTimeAsync(1_000) + expect(updatePostSpy).toHaveBeenCalledTimes(2) + expect(messageListener).not.toHaveBeenCalled() + + releasePost() + await completionPromise + expect(messageListener).toHaveBeenCalledExactlyOnceWith({ action: "updated", message: complete }) + await vi.advanceTimersByTimeAsync(1_000) + expect(updatePostSpy).toHaveBeenCalledTimes(2) }, ) - it("handles a rejected debounced partial update", async () => { + it.each(["leading", "trailing"] as const)("handles a rejected %s partial update", async (edge) => { vi.useFakeTimers() const task = new Task({ provider: mockProvider, @@ -2663,24 +2769,24 @@ describe("Cline", () => { task: "test task", startTask: false, }) + const taskAccess = getTaskTestAccess(task) + const first = { ts: 1, type: "say" as const, say: "text" as const, text: "first partial", partial: true } + if (edge === "trailing") { + await taskAccess.updateClineMessage(first) + } const postError = new Error("incremental update failed") vi.mocked(mockProvider.postClineMessageUpdated).mockRejectedValueOnce(postError) const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) try { - void getTaskTestAccess(task).updateClineMessage({ - ts: 1, - type: "say", - say: "text", - text: "partial", - partial: true, - }) + await taskAccess.updateClineMessage({ ...first, text: "rejected partial" }) await vi.advanceTimersByTimeAsync(500) - expect(consoleErrorSpy).toHaveBeenCalledWith( - "[Task#updateClineMessage] incremental post failed:", - postError, - ) + expect( + consoleErrorSpy.mock.calls.filter( + ([message]) => message === "[Task#updateClineMessage] incremental post failed:", + ), + ).toEqual([["[Task#updateClineMessage] incremental post failed:", postError]]) } finally { consoleErrorSpy.mockRestore() } From 6e137b9f403d84efc423c8e82625209bea22f6b0 Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Fri, 11 Sep 2026 09:37:38 -0600 Subject: [PATCH 33/38] perf(webview): index transcript updates and assert atomic timeout recovery --- .../src/context/ExtensionStateContext.tsx | 32 +- .../__tests__/ExtensionStateContext.spec.tsx | 311 +++++++++++++++++- 2 files changed, 323 insertions(+), 20 deletions(-) diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index d5f3ee8549..5eacd6641a 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -26,8 +26,6 @@ import { DEFAULT_DIFF_FUZZY_THRESHOLD, } from "@roo-code/types" -import { findLastIndex } from "@roo/array" - import { checkExistKey } from "@roo/checkExistApiConfig" import { Mode, defaultModeSlug, defaultPrompts } from "@roo/modes" import { CustomSupportPrompts } from "@roo/support-prompt" @@ -287,6 +285,12 @@ export const ExtensionStateContextProvider: React.FC<{ const activeTaskIdRef = useRef(state.currentTaskId ?? undefined) const clineMessagesSeqRef = useRef(state.clineMessagesSeq ?? 0) const clineMessagesRef = useRef(state.clineMessages) + const clineMessagesIndexRef = useRef | null>(null) + if (clineMessagesIndexRef.current === null) { + // Initialize once, preserving the last match when timestamps repeat. + clineMessagesIndexRef.current = new Map(state.clineMessages.map((message, index) => [message.ts, index])) + } + const clineMessagesIndex = clineMessagesIndexRef.current const activeSnapshotRef = useRef(null) const snapshotTimeoutRef = useRef(undefined) const resyncPendingRef = useRef(false) @@ -340,6 +344,15 @@ export const ExtensionStateContextProvider: React.FC<{ })) }, []) + const replaceClineMessages = useCallback( + (messages: ClineMessage[]) => { + clineMessagesRef.current = messages + clineMessagesIndex.clear() + messages.forEach((message, index) => clineMessagesIndex.set(message.ts, index)) + }, + [clineMessagesIndex], + ) + const clearClineMessagesResync = useCallback( () => { resyncPendingRef.current = false @@ -447,12 +460,14 @@ export const ExtensionStateContextProvider: React.FC<{ let nextMessages: ClineMessage[] if (operation === "append") { nextMessages = [...clineMessagesRef.current, clineMessage] + clineMessagesIndex.set(clineMessage.ts, nextMessages.length - 1) } else { - const index = findLastIndex(clineMessagesRef.current, (item) => item.ts === clineMessage.ts) - if (index === -1) { + const index = clineMessagesIndex.get(clineMessage.ts) + if (index === undefined) { requestClineMessagesResync(seq) return } + // Timestamp lookup is O(1) on average; the immutable array copy is still O(N). nextMessages = [...clineMessagesRef.current] nextMessages[index] = clineMessage } @@ -465,8 +480,8 @@ export const ExtensionStateContextProvider: React.FC<{ clineMessagesSeq: seq, })) }, - // Stryker disable next-line ArrayDeclaration: both dependencies are stable callbacks; an empty dependency list produces the same closure for the provider lifetime. - [clearClineMessagesSnapshot, requestClineMessagesResync, retryClineMessagesResync], + // Stryker disable next-line ArrayDeclaration: the index and callbacks are stable; an empty dependency list produces the same closure for the provider lifetime. + [clearClineMessagesSnapshot, clineMessagesIndex, requestClineMessagesResync, retryClineMessagesResync], ) const handleMessage = useCallback( @@ -488,7 +503,7 @@ export const ExtensionStateContextProvider: React.FC<{ if (taskChanged || taskCleared) { activeTaskIdRef.current = nextTaskId clineMessagesSeqRef.current = 0 - clineMessagesRef.current = [] + replaceClineMessages([]) clearClineMessagesSnapshot() clearClineMessagesResync() } @@ -687,7 +702,7 @@ export const ExtensionStateContextProvider: React.FC<{ clearClineMessagesSnapshot() clearClineMessagesResync() - clineMessagesRef.current = snapshot.messages + replaceClineMessages(snapshot.messages) clineMessagesSeqRef.current = snapshot.seq setState((prevState) => ({ ...prevState, @@ -793,6 +808,7 @@ export const ExtensionStateContextProvider: React.FC<{ applyClineMessagesDelta, clearClineMessagesSnapshot, clearClineMessagesResync, + replaceClineMessages, requestClineMessagesResync, retryClineMessagesResync, setListApiConfigMeta, diff --git a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx index 58a49ebe46..fe10982246 100644 --- a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx +++ b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx @@ -1,5 +1,5 @@ import { providerIdentifiers } from "@roo-code/types" -import { render, screen, act, appendClineMessage, hydrateExtensionState } from "@/utils/test-utils" +import { render, renderHook, screen, act, appendClineMessage, hydrateExtensionState } from "@/utils/test-utils" import React from "react" import { @@ -482,12 +482,277 @@ describe("ExtensionStateContext", () => { postMessage.mockClear() return postMessage } + const updateClineMessage = (clineMessage: ClineMessage, clineMessagesSeq: number, taskId?: string) => + dispatchExtensionMessage({ type: "clineMessageUpdated", taskId, clineMessagesSeq, clineMessage }) afterEach(() => { vi.restoreAllMocks() vi.useRealTimers() }) + it.each(["initial state", "appends", "snapshot"])( + "updates first, middle, and last timestamps after %s, including repeated updates", + (source) => { + const messages = Array.from({ length: 5 }, (_, index) => makeMessage(index, `message ${index}`)) + Object.freeze(messages) + const postMessage = renderTranscriptWithPostMessageSpy( + source === "initial state" ? { clineMessages: messages, clineMessagesSeq: 5 } : {}, + ) + act(() => { + if (source === "appends") { + messages.forEach((message, index) => appendClineMessage(message, index + 1, "task-1")) + } else if (source === "snapshot") { + hydrateExtensionState({ clineMessages: messages, clineMessagesSeq: 5 }, { taskId: "task-1" }) + } + }) + + let expectedMessages = messages + let seq = 5 + for (const index of [0, 2, 4]) { + for (const text of ["updated", "updated again"]) { + const updated = makeMessage(index, text) + seq += 1 + act(() => updateClineMessage(updated, seq, "task-1")) + expectedMessages = expectedMessages.map((message, position) => + position === index ? updated : message, + ) + expect(readTranscriptFields()).toEqual({ + currentTaskId: "task-1", + clineMessages: expectedMessages, + clineMessagesSeq: seq, + }) + } + } + expect(postMessage).not.toHaveBeenCalled() + expect(messages).toEqual( + Array.from({ length: 5 }, (_, index) => makeMessage(index, `message ${index}`)), + ) + }, + ) + + it("looks up updates without rereading transcript timestamps or rebuilding the index on render", () => { + const readTimestamp = vi.fn((ts: number) => ts) + const messages: ClineMessage[] = Array.from({ length: 1_000 }, (_, index) => ({ + ...makeMessage(index, `message ${index}`), + get ts() { + return readTimestamp(index) + }, + })) + const { result } = renderHook(() => useExtensionState(), { + wrapper: ({ children }) => ( + + {children} + + ), + }) + + for (const [offset, index] of [0, 500, 999].entries()) { + const previous = result.current.clineMessages + const updated = makeMessage(index, "updated") + readTimestamp.mockClear() + act(() => updateClineMessage(updated, offset + 2, "task-1")) + + expect(readTimestamp).not.toHaveBeenCalled() + expect(result.current.clineMessages === previous).toBe(false) + expect(result.current.clineMessages[index]).toBe(updated) + expect(previous[index]).toBe(messages[index]) + expect(result.current.clineMessages[1]).toBe(messages[1]) + expect(result.current.clineMessagesSeq).toBe(offset + 2) + } + }) + + it("rebuilds moved timestamps and drops removed timestamps when a replacement snapshot commits", () => { + const original = [makeMessage(10, "first"), makeMessage(20, "removed"), makeMessage(30, "last")] + const replacement = [makeMessage(30, "moved first"), makeMessage(40, "new"), makeMessage(10, "moved last")] + const postMessage = renderTranscriptWithPostMessageSpy({ clineMessages: original, clineMessagesSeq: 1 }) + + act(() => { + startSnapshot({ snapshotTotal: 3 }) + appendSnapshotChunk({ clineMessages: replacement }) + }) + expect(readTranscriptFields()).toEqual({ + currentTaskId: "task-1", + clineMessages: original, + clineMessagesSeq: 1, + }) + + const updatedFirst = makeMessage(30, "updated first") + const updatedMiddle = makeMessage(40, "updated middle") + const updatedLast = makeMessage(10, "updated last") + act(() => { + endSnapshot({ snapshotTotal: 3 }) + updateClineMessage(updatedFirst, 3, "task-1") + updateClineMessage(updatedMiddle, 4, "task-1") + updateClineMessage(updatedLast, 5, "task-1") + }) + expect(postMessage).not.toHaveBeenCalled() + const committed = { + currentTaskId: "task-1", + clineMessages: [updatedFirst, updatedMiddle, updatedLast], + clineMessagesSeq: 5, + } + expect(readTranscriptFields()).toEqual(committed) + + act(() => updateClineMessage(makeMessage(20, "stale timestamp"), 6, "task-1")) + expect(readTranscriptFields()).toEqual(committed) + expect(postMessage.mock.calls).toEqual([ + [{ type: "requestClineMessagesResync", taskId: "task-1", expectedSeq: 6, receivedSeq: 6 }], + ]) + }) + + it("drops all timestamp entries when an empty snapshot replaces the transcript", () => { + const postMessage = renderTranscriptWithPostMessageSpy({ + clineMessages: [makeMessage(10, "old")], + clineMessagesSeq: 1, + }) + act(() => { + startSnapshot({ snapshotTotal: 0 }) + endSnapshot({ snapshotTotal: 0 }) + updateClineMessage(makeMessage(10, "stale timestamp"), 3, "task-1") + }) + + expect(readTranscriptFields()).toEqual({ currentTaskId: "task-1", clineMessages: [], clineMessagesSeq: 2 }) + expect(postMessage.mock.calls).toEqual([ + [{ type: "requestClineMessagesResync", taskId: "task-1", expectedSeq: 3, receivedSeq: 3 }], + ]) + }) + + it.each([ + { name: "task switch", initialTaskId: "task-1", nextTaskId: "task-2" }, + { name: "task clear", initialTaskId: "task-1", nextTaskId: null }, + { name: "repeated no-task clear", initialTaskId: null, nextTaskId: null }, + ])( + "clears stale timestamp entries after $name and indexes subsequent appends", + ({ initialTaskId, nextTaskId }) => { + const postMessage = renderTranscriptWithPostMessageSpy({ + currentTaskId: initialTaskId, + clineMessages: [makeMessage(10, "first"), makeMessage(20, "middle"), makeMessage(30, "last")], + clineMessagesSeq: 3, + }) + const taskId = nextTaskId ?? undefined + act(() => { + dispatchExtensionMessage({ type: "state", state: { currentTaskId: nextTaskId } }) + updateClineMessage(makeMessage(20, "stale timestamp"), 1, taskId) + }) + + expect(readTranscriptFields()).toEqual({ + currentTaskId: nextTaskId, + clineMessages: [], + clineMessagesSeq: 0, + }) + expect(postMessage.mock.calls).toEqual([ + [{ type: "requestClineMessagesResync", taskId, expectedSeq: 1, receivedSeq: 1 }], + ]) + postMessage.mockClear() + + const updated = makeMessage(30, "updated at new position") + act(() => { + appendClineMessage(makeMessage(30, "reused timestamp"), 1, taskId) + updateClineMessage(updated, 2, taskId) + }) + expect(readTranscriptFields()).toEqual({ + currentTaskId: nextTaskId, + clineMessages: [updated], + clineMessagesSeq: 2, + }) + expect(postMessage).not.toHaveBeenCalled() + }, + ) + + it("preserves last-match timestamp semantics across initialization, appends, and snapshot replacement", () => { + const first = makeMessage(10, "earlier duplicate") + const last = makeMessage(10, "last duplicate") + const updated = makeMessage(10, "updated") + const postMessage = renderTranscriptWithPostMessageSpy({ + clineMessages: [first, last], + clineMessagesSeq: 1, + }) + + act(() => updateClineMessage(updated, 2, "task-1")) + expect(readTranscript().clineMessages).toEqual([first, updated]) + + const updatedAppend = makeMessage(10, "updated append") + act(() => { + appendClineMessage(last, 3, "task-1") + updateClineMessage(updatedAppend, 4, "task-1") + }) + expect(readTranscript().clineMessages).toEqual([first, updated, updatedAppend]) + + act(() => { + hydrateExtensionState({ clineMessages: [first, last], clineMessagesSeq: 5 }, { taskId: "task-1" }) + updateClineMessage(updated, 6, "task-1") + }) + expect(readTranscriptFields()).toEqual({ + currentTaskId: "task-1", + clineMessages: [first, updated], + clineMessagesSeq: 6, + }) + expect(postMessage).not.toHaveBeenCalled() + }) + + it.each<{ name: string; message: ExtensionMessage; requestsResync: boolean }>([ + { + name: "partial generic state", + message: { + type: "state", + state: { clineMessages: [makeMessage(30, "ignored replacement")], clineMessagesSeq: 99 }, + }, + requestsResync: false, + }, + { + name: "same-task generic state", + message: { + type: "state", + state: { + currentTaskId: "task-1", + clineMessages: [makeMessage(30, "ignored replacement")], + clineMessagesSeq: 99, + }, + }, + requestsResync: false, + }, + { + name: "legacy message update", + message: { type: "messageUpdated", clineMessage: makeMessage(20, "ignored update") }, + requestsResync: true, + }, + ])("preserves the timestamp index through $name", ({ message, requestsResync }) => { + const original = [makeMessage(10, "first"), makeMessage(20, "middle"), makeMessage(30, "last")] + const postMessage = renderTranscriptWithPostMessageSpy({ clineMessages: original, clineMessagesSeq: 3 }) + + act(() => dispatchExtensionMessage(message)) + expect(readTranscriptFields()).toEqual({ + currentTaskId: "task-1", + clineMessages: original, + clineMessagesSeq: 3, + }) + expect(postMessage.mock.calls).toEqual( + requestsResync + ? [ + [ + { + type: "requestClineMessagesResync", + taskId: "task-1", + expectedSeq: 4, + receivedSeq: undefined, + }, + ], + ] + : [], + ) + postMessage.mockClear() + + const updated = original.map((entry) => makeMessage(entry.ts, "updated")) + act(() => updated.forEach((entry, index) => updateClineMessage(entry, index + 4, "task-1"))) + expect(readTranscriptFields()).toEqual({ + currentTaskId: "task-1", + clineMessages: updated, + clineMessagesSeq: 6, + }) + expect(postMessage).not.toHaveBeenCalled() + }) + it("ignores a delta for a different task", () => { const existing = makeMessage(1, "existing") const postMessage = renderTranscriptWithPostMessageSpy({ clineMessages: [existing], clineMessagesSeq: 1 }) @@ -656,24 +921,46 @@ describe("ExtensionStateContext", () => { expect(vi.getTimerCount()).toBe(1) }) - it("abandons an incomplete snapshot and requests recovery after the snapshot timeout", () => { + it("abandons an incomplete replacement snapshot without changing the transcript or applied sequence", () => { vi.useFakeTimers() - const postMessage = renderTranscriptWithPostMessageSpy({ clineMessagesSeq: 1 }) + const existing = [makeMessage(1, "existing first"), makeMessage(2, "existing last")] + const postMessage = renderTranscriptWithPostMessageSpy({ clineMessages: existing, clineMessagesSeq: 7 }) + const unchanged = { currentTaskId: "task-1", clineMessages: existing, clineMessagesSeq: 7 } act(() => { - startSnapshot() - appendSnapshotChunk() - vi.advanceTimersByTime(30_000) + startSnapshot({ clineMessagesSeq: 10, snapshotTotal: 3 }) + appendSnapshotChunk({ + clineMessagesSeq: 10, + clineMessages: [makeMessage(2, "partial replacement")], + }) }) + expect(readTranscriptFields()).toEqual(unchanged) + act(() => vi.advanceTimersByTime(29_999)) + expect(postMessage).not.toHaveBeenCalled() + expect(readTranscriptFields()).toEqual(unchanged) + + act(() => vi.advanceTimersByTime(1)) + expect(readTranscriptFields()).toEqual(unchanged) + expect(postMessage.mock.calls).toEqual([ + [{ type: "requestClineMessagesResync", taskId: "task-1", expectedSeq: 8, receivedSeq: 10 }], + ]) + expect(vi.getTimerCount()).toBe(1) + + act(() => vi.advanceTimersByTime(30_000)) + expect(readTranscriptFields()).toEqual(unchanged) expect(postMessage).toHaveBeenCalledTimes(1) - expect(postMessage).toHaveBeenCalledWith({ - type: "requestClineMessagesResync", - taskId: "task-1", - expectedSeq: 2, - receivedSeq: 2, + expect(vi.getTimerCount()).toBe(0) + + // The discarded snapshot must not change the index or sequence used by the next delta. + const updated = makeMessage(1, "updated after timeout") + act(() => updateClineMessage(updated, 8, "task-1")) + expect(readTranscriptFields()).toEqual({ + currentTaskId: "task-1", + clineMessages: [updated, existing[1]], + clineMessagesSeq: 8, }) - expect(vi.getTimerCount()).toBe(1) + expect(postMessage).toHaveBeenCalledTimes(1) }) it("clears the snapshot timeout when a snapshot completes", () => { From 8620ca3b338a395b52592e0a71586c4b5be9f57b Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Fri, 11 Sep 2026 11:06:18 -0600 Subject: [PATCH 34/38] test: cover transcript transport mutation gaps --- src/__tests__/extension.spec.ts | 16 +++++ .../webview/__tests__/ClineProvider.spec.ts | 43 +++++++----- .../__tests__/transcriptTransport.spec.ts | 69 ++++++++++++++++++- 3 files changed, 109 insertions(+), 19 deletions(-) diff --git a/src/__tests__/extension.spec.ts b/src/__tests__/extension.spec.ts index 343da2afb1..63825dedf7 100644 --- a/src/__tests__/extension.spec.ts +++ b/src/__tests__/extension.spec.ts @@ -433,6 +433,22 @@ describe("extension.ts", () => { expect(updateTelemetryStateMock).toHaveBeenCalledWith(false) }) + test("updates telemetry without throwing when no webview provider is visible", async () => { + const vscode = await import("vscode") + const { TelemetryService } = await import("@roo-code/telemetry") + const { ClineProvider } = await import("../core/webview/ClineProvider") + const { activate } = await import("../extension") + await activate(mockContext) + + const updateTelemetryState = vi.mocked(TelemetryService.instance.updateTelemetryState) + updateTelemetryState.mockClear() + vi.mocked(ClineProvider.getVisibleInstance).mockReturnValueOnce(undefined) + const onDidChangeHandler = vi.mocked(vscode.env.onDidChangeTelemetryEnabled).mock.calls[0][0] + + expect(() => onDidChangeHandler(undefined as never)).not.toThrow() + expect(updateTelemetryState).toHaveBeenCalledOnce() + }) + test("pushes a state update to the webview so its own PostHog client picks up the new vscode.env.isTelemetryEnabled value", async () => { const vscode = await import("vscode") const { ClineProvider } = await import("../core/webview/ClineProvider") diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 2c8452edd0..3aeadd6c98 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -1006,25 +1006,32 @@ describe("ClineProvider", () => { ]) }) - 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") - const previousGeneration = provider["clineMessagesTransport"].generation - const previousSnapshotId = provider["clineMessagesTransport"]["state"].nextSnapshotId - - await Promise.all([ - provider.postClineMessageAppended("task-2", message), - provider.postClineMessageUpdated("task-2", message), - provider.postClineMessagesSnapshot("task-2"), - provider.resyncClineMessagesToWebview("task-2"), - ]) + test.each(["0", "1"])("ignores unfocused transcript work with CLI runtime %s", async (cliRuntime) => { + vi.stubEnv("ROO_CLI_RUNTIME", cliRuntime) + try { + 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") + const stateSpy = vi.spyOn(provider, "postStateToWebviewWithoutTaskHistory").mockResolvedValue(undefined) + const previousGeneration = provider["clineMessagesTransport"].generation + const previousSnapshotId = provider["clineMessagesTransport"]["state"].nextSnapshotId + + 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() - expect(provider["clineMessagesTransport"]["state"].sequences.has("task-2")).toBe(false) - expect(provider["clineMessagesTransport"].generation).toBe(previousGeneration) - expect(provider["clineMessagesTransport"]["state"].nextSnapshotId).toBe(previousSnapshotId) + expect(postSpy).not.toHaveBeenCalled() + expect(stateSpy).not.toHaveBeenCalled() + expect(provider["clineMessagesTransport"]["state"].sequences.has("task-2")).toBe(false) + expect(provider["clineMessagesTransport"].generation).toBe(previousGeneration) + expect(provider["clineMessagesTransport"]["state"].nextSnapshotId).toBe(previousSnapshotId) + } finally { + vi.unstubAllEnvs() + } }) test("safely rejects transcript work when no task is focused", async () => { diff --git a/src/core/webview/__tests__/transcriptTransport.spec.ts b/src/core/webview/__tests__/transcriptTransport.spec.ts index e1c0a84b22..665a325ebe 100644 --- a/src/core/webview/__tests__/transcriptTransport.spec.ts +++ b/src/core/webview/__tests__/transcriptTransport.spec.ts @@ -1,5 +1,5 @@ import type { ClineMessage, ExtensionMessage } from "@roo-code/types" -import { createTranscriptTransportState, TranscriptTransport } from "../transcriptTransport" +import { createTranscriptTransportState, reduceTranscriptTransport, TranscriptTransport } from "../transcriptTransport" import { checkTranscriptTransportModel, exploreTranscriptTransport, @@ -42,9 +42,57 @@ describe("transcript transport bounded model", () => { }) }) +describe("transcript transport reducer", () => { + test.each([true, false])("ignores settlement without a physical send (success=%s)", (success) => { + const state = createTranscriptTransportState() + const transition = reduceTranscriptTransport(state, { type: "settle", success }) + expect(transition).toEqual({ state, release: [], settle: [] }) + expect(transition.state).toBe(state) + }) +}) + describe("transcript transport driver", () => { const message: ClineMessage = { ts: 1, type: "say", text: "initial", images: ["image"] } + test.each(["append", "update", "snapshot"] as const)( + "rejects an unfocused %s before reading the payload or allocating work", + async (kind) => { + const readText = vi.fn(() => "obsolete") + const unread: ClineMessage = { + ts: 1, + type: "say", + get text() { + return readText() + }, + } + const post = vi.fn<(frame: ExtensionMessage) => Promise>().mockResolvedValue(undefined) + const transport = new TranscriptTransport(() => "a", post, vi.fn()) + const state = transport["state"] + + await transport.enqueue({ kind, taskId: "b" }, [unread]) + + expect(readText).not.toHaveBeenCalled() + expect(post).not.toHaveBeenCalled() + expect(transport["state"]).toBe(state) + expect(transport.getSequence("b")).toBe(0) + expect(transport["payloads"].size).toBe(0) + expect(transport["callers"].size).toBe(0) + }, + ) + + test.each(["append", "update"] as const)("rejects a %s without a task scope", async (kind) => { + const post = vi.fn<(frame: ExtensionMessage) => Promise>().mockResolvedValue(undefined) + const transport = new TranscriptTransport(() => undefined, post, vi.fn()) + const state = transport["state"] + + await transport.enqueue({ kind, taskId: undefined }, [message]) + + expect(post).not.toHaveBeenCalled() + expect(transport["state"]).toBe(state) + expect(transport["payloads"].size).toBe(0) + expect(transport["callers"].size).toBe(0) + }) + test.each(["start", "chunk", "end", "delta"] as const)( "keeps the physical barrier across rejected held %s and recovers", async (phase) => { @@ -126,6 +174,25 @@ describe("transcript transport driver", () => { expect(() => createTranscriptTransportState(size)).toThrow("positive safe integer") }) + test("delivers one message per chunk at the minimum valid chunk size", async () => { + const post = vi.fn<(frame: ExtensionMessage) => Promise>().mockResolvedValue(undefined) + const transport = new TranscriptTransport(() => "a", post, vi.fn()) + transport["state"] = createTranscriptTransportState(1) + const second = { ...message, ts: 2, text: "second" } + + await transport.enqueue({ kind: "snapshot", taskId: "a" }, [message, second]) + + const common = { taskId: "a", clineMessagesSeq: 0, snapshotId: "a:1" } + expect(post.mock.calls.map(([frame]) => frame)).toEqual([ + { ...common, type: "clineMessagesSnapshotStart", snapshotTotal: 2 }, + { ...common, type: "clineMessagesSnapshotChunk", snapshotStartIndex: 0, clineMessages: [message] }, + { ...common, type: "clineMessagesSnapshotChunk", snapshotStartIndex: 1, clineMessages: [second] }, + { ...common, type: "clineMessagesSnapshotEnd", snapshotTotal: 2 }, + ]) + expect(transport["payloads"].size).toBe(0) + expect(transport["callers"].size).toBe(0) + }) + test("does not adopt a newer generation if cloning reenters invalidation", async () => { const post = vi.fn().mockResolvedValue(undefined) const transport = new TranscriptTransport(() => "a", post, vi.fn()) From f7d2abb5bf06d9d439d133ef49c1cd925f11e863 Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Fri, 11 Sep 2026 11:43:56 -0600 Subject: [PATCH 35/38] fix(transcript): simplify ownership and verify canonical frames --- .../transcript-transport-model.md | 11 +- src/core/webview/ClineProvider.ts | 14 +- .../__tests__/transcriptTransport.model.ts | 78 +++++++++- .../__tests__/transcriptTransport.spec.ts | 138 +++++++++++++++++- src/core/webview/transcriptTransport.ts | 16 +- .../src/context/ExtensionStateContext.tsx | 18 +-- 6 files changed, 247 insertions(+), 28 deletions(-) diff --git a/docs/architecture/transcript-transport-model.md b/docs/architecture/transcript-transport-model.md index 33e4d8f9a8..e339fe1595 100644 --- a/docs/architecture/transcript-transport-model.md +++ b/docs/architecture/transcript-transport-model.md @@ -35,9 +35,10 @@ The model exposes a scheduling point between settlement and the next pump, and b 1. Generation increases exactly once per invalidation and never otherwise. Stale-generation admission allocates no job or snapshot ID. 2. No physical send overlaps another, including an old generation's held send. No old-generation or old-focus post/commit is **initiated** after ownership changes. -3. Invalidation retains no obsolete queue or payload. Discarded waiting callers settle immediately. Remaining payloads correspond exactly to active/queued jobs; remaining callers correspond exactly to those jobs plus an already-initiated physical send. +3. Invalidation retains no obsolete queue or payload. Discarded waiting callers settle immediately. Each settlement must consume a registered caller exactly once. Remaining payloads correspond exactly to active/queued jobs; remaining callers correspond exactly to those jobs plus an already-initiated physical send. 4. Allocated sequences follow enqueue/capture order: deltas and bumping snapshots increment; resync retains the current value. Sent sequence is nondecreasing and never exceeds allocation. Failed snapshots never resume their suffix. 5. The independent receiver oracle stages contiguous, exact snapshot payloads and exposes them only at a matching complete end marker. Start/chunks cannot change visible transcript or applied sequence. Applied sequence cannot decrease within one focused-task scope. +6. Job totals equal captured payload lengths. Only snapshots carry snapshot identities, unique across captures. Non-chunk frame ranges are zero; chunk descriptors have contiguous starts and positive, exact lengths bounded by the captured payload and chunk size. These checks precede wire conversion, whose array slicing can otherwise hide an overlarge final count. Sequence monotonicity is **not global across task IDs or removed/recreated task lifetimes**. The production provider prunes a task's sequence on stack removal/history deletion; the model exercises the shared pruning action on switch/clear and tags its allocation/sent oracle with a task-lifetime epoch. A no-task snapshot has sequence zero. Receiver applied sequence resets on focus change/clear, as distinct from resync of the same task. The checker does not invent a persisted generation token or silently demand globally increasing sequences after clear. @@ -60,7 +61,7 @@ All 16 action classes are required: snapshot, append, update, resync, invalidate ## Invariant sensitivity -Eight test-only reducer wrappers must produce their expected violation class through the same exhaustive explorer. No mutation switch exists in production. +Twelve test-only reducer wrappers must produce their expected violation class through the same exhaustive explorer. No mutation switch exists in production. | Mutant | Shortest witness, excluding initial state | Detected violation | | ----------------------------------- | ----------------------------------------- | --------------------------------- | @@ -72,9 +73,15 @@ Eight test-only reducer wrappers must produce their expected violation class thr | commit-before-chunks | snapshot, pump, settle, pump, settle | incomplete atomic snapshot | | reuse-delta-sequence | snapshot, append | incorrect allocated sequence | | continue-after-rejection | snapshot, pump, fail, pump | failed snapshot resumes posting | +| delta-snapshot-metadata | snapshot, append | delta carries snapshot metadata | +| non-chunk-payload-range | snapshot, pump | non-chunk payload range | +| overrun-final-chunk | switch, pump, settle, pump | chunk exceeds captured range | +| settle-caller-twice | snapshot, resync | settlement without owned caller | [transcriptTransport.spec.ts](../../src/core/webview/__tests__/transcriptTransport.spec.ts) runs the full checker, verifies deterministic shortest witnesses and both fail-closed budget paths, and exercises the actual driver with held/rejected start, chunk, end, and delta sends, plus synchronous rejection/recovery. The [CLI entry point](../../scripts/check-transcript-transport.ts) prints counts, action/landmark names, bounds, and mutant traces. +Focused reducer tests also check canonical descriptors for empty, exact-boundary, and partial-final chunks independently of wire output. Adversarial queued/active states retain obsolete-generation work with unchanged focus to verify the defense-in-depth pre-send guard discards it and permits current work. Such states are deliberately **not claimed reachable** through normal invalidation, which releases that work; no artificial action is added to the reachable-state explorer. A driver regression retains one held caller through two invalidations and checks both successful and failed settlement followed by recovery. + ## Limitations: initiation is not delivery revocation An active physical send cannot be unsent. In particular, **an end marker initiated before invalidation may complete afterward and publish its already-complete snapshot on the same focused task**. The generation is provider-local, not a wire field. The named stale-end-completion landmark deliberately requires this permitted behavior; the stale-completion-starts-end mutant forbids the materially different bug of initiating a new old-generation end after invalidation. The single physical barrier ensures a newer transcript's posts cannot overtake the held old one. diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index edecde4f2e..31a31335c1 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -1500,10 +1500,6 @@ export class ClineProvider } } - private getClineMessagesSeq(taskId: string): number { - return this.clineMessagesTransport.getSequence(taskId) - } - private invalidateClineMessagesTransport(): number { return this.clineMessagesTransport.invalidate() } @@ -1554,10 +1550,14 @@ export class ClineProvider return Promise.resolve() } // Untrusted webview diagnostics are log-only; never derive transport state from them. - const diagnosticSequence = (value: unknown): number | undefined => - typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : undefined + const diagnosticSequence = (value: unknown): number | undefined => { + if (!Number.isSafeInteger(value)) return undefined + // isSafeInteger rejects non-numbers without coercion, but is not a TS type predicate. + const sequence = value as number + return sequence >= 0 ? sequence : undefined + } const previousGeneration = this.clineMessagesTransport.generation - const currentSeq = currentTaskId === undefined ? 0 : this.getClineMessagesSeq(currentTaskId) + const currentSeq = this.clineMessagesTransport.getSequence(currentTaskId) const generation = this.invalidateClineMessagesTransport() this.log( `[clineMessages] resync accepted: ${JSON.stringify({ diff --git a/src/core/webview/__tests__/transcriptTransport.model.ts b/src/core/webview/__tests__/transcriptTransport.model.ts index cb663fb14c..b554f8bd2f 100644 --- a/src/core/webview/__tests__/transcriptTransport.model.ts +++ b/src/core/webview/__tests__/transcriptTransport.model.ts @@ -249,13 +249,27 @@ function executeStep(s: ModelState, event: Event, reducer: Reducer, coverage: Se ? 1 : 0) requireInvariant(job.seq === expectedSeq, "allocated sequence diverged from capture order") + requireInvariant(job.total === values.length, "job total differs from captured payload") + if (job.kind === "snapshot") { + requireInvariant( + typeof job.snapshotId === "string" && + job.snapshotId.length > 0 && + !s.captures.some((capture) => capture.job.snapshotId === job.snapshotId), + "snapshot lacks a unique identity", + ) + } else { + requireInvariant(!("snapshotId" in job), "delta carries snapshot metadata") + } s.allocated[scope] = job.seq s.captures.push({ job, scope, values: [...values], failed: false }) s.payloads.push(job.id) s.callers.push(job.id) } for (const id of transition.release) s.payloads = s.payloads.filter((value) => value !== id) - for (const { id } of transition.settle) s.callers = s.callers.filter((value) => value !== id) + for (const { id } of transition.settle) { + requireInvariant(s.callers.includes(id), "settlement lacks a registered caller") + s.callers = s.callers.filter((value) => value !== id) + } if (action.type === "invalidate") { requireInvariant( s.transport.queue.length === 0 && !s.transport.active && s.payloads.length === 0, @@ -293,6 +307,20 @@ function executeStep(s: ModelState, event: Event, reducer: Reducer, coverage: Se "post or commit initiated after invalidation", ) requireInvariant(!capture.failed, "failed snapshot continued posting") + if (frame.phase === "chunk") { + // Check the descriptor before wire slicing can clamp an overlarge count. + requireInvariant( + Number.isSafeInteger(frame.start) && + frame.start >= 0 && + frame.start === s.staging?.values.length && + frame.count > 0 && + frame.count === capture.values.slice(frame.start, frame.start + before.chunkSize).length && + frame.start + frame.count <= capture.values.length, + "chunk descriptor differs from captured payload range", + ) + } else { + requireInvariant(frame.start === 0 && frame.count === 0, "non-chunk frame carries a payload range") + } requireInvariant( frame.job.seq >= (s.sent[capture.scope] ?? 0), "sent sequence regressed within task lifetime", @@ -489,7 +517,7 @@ export const TRANSPORT_MUTATIONS: Mutation[] = [ reduce: (state, action) => { const result = reduceTranscriptTransport(state, action) if (result.post?.phase === "chunk") { - result.post = { ...result.post, phase: "end" } + result.post = { ...result.post, phase: "end", start: 0, count: 0 } result.state = { ...result.state, inFlight: result.post } } return result @@ -514,6 +542,52 @@ export const TRANSPORT_MUTATIONS: Mutation[] = [ reduce: (state, action) => reduceTranscriptTransport(state, action.type === "settle" ? { ...action, success: true } : action), }, + { + name: "delta-snapshot-metadata", + expected: "delta carries snapshot metadata", + reduce: (state, action) => { + const result = reduceTranscriptTransport(state, action) + if (result.accepted && result.accepted.kind !== "snapshot") { + const job = { ...result.accepted, snapshotId: "unused" } + result.accepted = job + result.state = { ...result.state, queue: [...result.state.queue.slice(0, -1), job] } + } + return result + }, + }, + { + name: "non-chunk-payload-range", + expected: "non-chunk frame carries a payload range", + reduce: (state, action) => { + const result = reduceTranscriptTransport(state, action) + if (result.post && result.post.phase !== "chunk") { + result.post = { ...result.post, start: 1, count: 1 } + result.state = { ...result.state, inFlight: result.post } + } + return result + }, + }, + { + name: "overrun-final-chunk", + expected: "chunk descriptor differs from captured payload range", + reduce: (state, action) => { + const result = reduceTranscriptTransport(state, action) + if (result.post?.phase === "chunk") { + result.post = { ...result.post, count: state.chunkSize } + result.state = { ...result.state, inFlight: result.post } + } + return result + }, + }, + { + name: "settle-caller-twice", + expected: "settlement lacks a registered caller", + reduce: (state, action) => { + const result = reduceTranscriptTransport(state, action) + result.settle.push(...result.settle) + return result + }, + }, ] export function checkTranscriptTransportModel() { diff --git a/src/core/webview/__tests__/transcriptTransport.spec.ts b/src/core/webview/__tests__/transcriptTransport.spec.ts index 665a325ebe..d78e8675bd 100644 --- a/src/core/webview/__tests__/transcriptTransport.spec.ts +++ b/src/core/webview/__tests__/transcriptTransport.spec.ts @@ -1,5 +1,11 @@ import type { ClineMessage, ExtensionMessage } from "@roo-code/types" -import { createTranscriptTransportState, reduceTranscriptTransport, TranscriptTransport } from "../transcriptTransport" +import { + createTranscriptTransportState, + reduceTranscriptTransport, + transcriptFrameMessage, + TranscriptTransport, + type TranscriptFrame, +} from "../transcriptTransport" import { checkTranscriptTransportModel, exploreTranscriptTransport, @@ -49,11 +55,141 @@ describe("transcript transport reducer", () => { expect(transition).toEqual({ state, release: [], settle: [] }) expect(transition.state).toBe(state) }) + + test.each(["queued", "active"] as const)("discards a stale-generation %s job before sending", (location) => { + const admitted = reduceTranscriptTransport(createTranscriptTransportState(2), { + type: "enqueue", + request: { kind: "snapshot", taskId: "a" }, + total: 3, + focusedTaskId: "a", + }) + let state = admitted.state + if (location === "active") { + state = reduceTranscriptTransport(state, { type: "pump", focusedTaskId: "a" }).state + state = reduceTranscriptTransport(state, { type: "settle", success: true }).state + } + // Adversarial reducer input: normal invalidation also releases this work. Keep + // the pre-send guard defensive if stale ownership ever reaches this boundary. + state = { ...state, generation: state.generation + 1 } + const current = reduceTranscriptTransport(state, { + type: "enqueue", + request: { kind: "append", taskId: "a" }, + total: 1, + focusedTaskId: "a", + }) + + const transition = reduceTranscriptTransport(current.state, { type: "pump", focusedTaskId: "a" }) + + expect(transition.release).toEqual([admitted.accepted!.id]) + expect(transition.settle).toEqual([{ id: admitted.accepted!.id }]) + expect(transition.post).toEqual({ job: current.accepted, phase: "append", start: 0, count: 0 }) + expect(transition.state.queue).toEqual([]) + expect(transition.state.active).toEqual({ job: current.accepted, position: 0 }) + }) + + test.each([ + { total: 0, chunks: [] }, + { total: 1, chunks: [{ start: 0, count: 1 }] }, + { total: 2, chunks: [{ start: 0, count: 2 }] }, + { + total: 3, + chunks: [ + { start: 0, count: 2 }, + { start: 2, count: 1 }, + ], + }, + { + total: 5, + chunks: [ + { start: 0, count: 2 }, + { start: 2, count: 2 }, + { start: 4, count: 1 }, + ], + }, + ])("describes exact captured ranges for a $total-message snapshot", ({ total, chunks }) => { + const messages: ClineMessage[] = Array.from({ length: total }, (_, ts) => ({ ts, type: "say" })) + const admitted = reduceTranscriptTransport(createTranscriptTransportState(2), { + type: "enqueue", + request: { kind: "snapshot", taskId: "a" }, + total: messages.length, + focusedTaskId: "a", + }) + let state = admitted.state + const frames: TranscriptFrame[] = [] + for (let index = 0; index < chunks.length + 2; index++) { + const transition = reduceTranscriptTransport(state, { type: "pump", focusedTaskId: "a" }) + expect(transition.post).toBeDefined() + frames.push(transition.post!) + state = reduceTranscriptTransport(transition.state, { type: "settle", success: true }).state + } + + expect(state.queue).toEqual([]) + expect(state.active).toBeUndefined() + expect(state.inFlight).toBeUndefined() + expect(frames).toEqual([ + { job: admitted.accepted, phase: "start", start: 0, count: 0 }, + ...chunks.map((range) => ({ job: admitted.accepted, phase: "chunk", ...range })), + { job: admitted.accepted, phase: "end", start: 0, count: 0 }, + ]) + expect(frames.slice(1, -1).map((frame) => transcriptFrameMessage(frame, messages).clineMessages)).toEqual( + chunks.map(({ start, count }) => messages.slice(start, start + count)), + ) + }) }) describe("transcript transport driver", () => { const message: ClineMessage = { ts: 1, type: "say", text: "initial", images: ["image"] } + test.each([true, false])( + "retains the sole held caller through repeated invalidation (success=%s)", + async (success) => { + let resolveHeld!: () => void + let rejectHeld!: (error: Error) => void + const held = new Promise((resolve, reject) => { + resolveHeld = resolve + rejectHeld = reject + }) + const post = vi + .fn<(frame: ExtensionMessage) => Promise>() + .mockReturnValueOnce(held) + .mockResolvedValue(undefined) + const log = vi.fn() + const transport = new TranscriptTransport(() => "a", post, log) + const resolved = vi.fn() + const rejected = vi.fn() + const active = transport.enqueue({ kind: "snapshot", taskId: "a" }, [message]).then(resolved, rejected) + const [heldId] = transport["callers"].keys() + + for (let generation = 0; generation < 2; generation++) { + const waiting = transport.enqueue({ kind: "update", taskId: "a" }, [message]) + transport.invalidate() + await waiting + expect([...transport["callers"].keys()]).toEqual([heldId]) + expect(transport["payloads"].size).toBe(0) + expect(post).toHaveBeenCalledOnce() + expect(resolved).not.toHaveBeenCalled() + expect(rejected).not.toHaveBeenCalled() + } + + const recovery = transport.enqueue({ kind: "snapshot", taskId: "a" }, [message]) + const failure = new Error("held post failed") + if (success) resolveHeld() + else rejectHeld(failure) + await Promise.all([active, recovery]) + + expect(resolved).toHaveBeenCalledTimes(success ? 1 : 0) + expect(rejected.mock.calls).toEqual(success ? [] : [[failure]]) + expect(log.mock.calls).toEqual(success ? [] : [[failure]]) + expect(transport["callers"].size).toBe(0) + expect(transport["payloads"].size).toBe(0) + expect(post.mock.calls.slice(1).map(([frame]) => frame.type)).toEqual([ + "clineMessagesSnapshotStart", + "clineMessagesSnapshotChunk", + "clineMessagesSnapshotEnd", + ]) + }, + ) + test.each(["append", "update", "snapshot"] as const)( "rejects an unfocused %s before reading the payload or allocating work", async (kind) => { diff --git a/src/core/webview/transcriptTransport.ts b/src/core/webview/transcriptTransport.ts index bee1eb53ba..6180d5aa12 100644 --- a/src/core/webview/transcriptTransport.ts +++ b/src/core/webview/transcriptTransport.ts @@ -14,12 +14,14 @@ export type TranscriptJob = { seq: number kind: TranscriptRequest["kind"] total: number + /** Snapshot identity is absent on delta descriptors. */ snapshotId?: string } export type TranscriptFrame = { job: TranscriptJob phase: "append" | "update" | "start" | "chunk" | "end" + /** Exact captured-payload range for chunks; both values are zero for other phases. */ start: number count: number } @@ -207,8 +209,10 @@ export class TranscriptTransport { return this.state.generation } - getSequence(taskId: string): number { - return this.state.sequences.get(taskId) ?? 0 + getSequence(taskId: string | undefined): number { + // Allow absent scopes in the read-only view; writers still require string task IDs. + const sequences: ReadonlyMap = this.state.sequences + return sequences.get(taskId) ?? 0 } forgetTask(taskId: string): void { @@ -246,10 +250,12 @@ export class TranscriptTransport { this.state = transition.state for (const id of transition.release) this.payloads.delete(id) for (const { id, failed } of transition.settle) { - const caller = this.callers.get(id) + // Admission registers before drain. The reducer settles each caller exactly once, + // retaining a physical-send caller across invalidations until its send settles. + const caller = this.callers.get(id)! this.callers.delete(id) - if (failed) caller?.reject(error) - else caller?.resolve() + if (failed) caller.reject(error) + else caller.resolve() } return transition } diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index 5eacd6641a..656890a31d 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -344,15 +344,6 @@ export const ExtensionStateContextProvider: React.FC<{ })) }, []) - const replaceClineMessages = useCallback( - (messages: ClineMessage[]) => { - clineMessagesRef.current = messages - clineMessagesIndex.clear() - messages.forEach((message, index) => clineMessagesIndex.set(message.ts, index)) - }, - [clineMessagesIndex], - ) - const clearClineMessagesResync = useCallback( () => { resyncPendingRef.current = false @@ -486,6 +477,11 @@ export const ExtensionStateContextProvider: React.FC<{ const handleMessage = useCallback( (event: MessageEvent) => { + const replaceClineMessages = (messages: ClineMessage[]) => { + clineMessagesRef.current = messages + clineMessagesIndex.clear() + messages.forEach((message, index) => clineMessagesIndex.set(message.ts, index)) + } const message: ExtensionMessage = event.data switch (message.type) { case "state": { @@ -803,12 +799,12 @@ export const ExtensionStateContextProvider: React.FC<{ } } }, - // Stryker disable next-line ArrayDeclaration: every listed dependency is a stable callback; removing the list does not change this listener closure. + // Stryker disable next-line ArrayDeclaration: the index and callbacks are stable; removing the list does not change this listener closure. [ applyClineMessagesDelta, clearClineMessagesSnapshot, clearClineMessagesResync, - replaceClineMessages, + clineMessagesIndex, requestClineMessagesResync, retryClineMessagesResync, setListApiConfigMeta, From 190b186fdbf60155f4e6272af3a1c06ecbb3369e Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Fri, 11 Sep 2026 17:43:49 -0600 Subject: [PATCH 36/38] fix(transcript): preserve producer identity across task replacement Publish dedicated transcript focus without generic-state side effects; guard stale metadata, reject empty deltas, and catch telemetry refresh failures. Add provider, receiver, CLI, and transport-model regressions for PR #1360 review feedback. --- .../ui/__tests__/transcript-focus.test.tsx | 51 ++ .../transcript-transport-model.md | 109 ++-- packages/types/src/vscode-extension-host.ts | 24 +- src/__tests__/extension.spec.ts | 69 ++- src/__tests__/helpers/provider-stub.ts | 16 +- src/__tests__/single-open-invariant.spec.ts | 22 +- src/core/task/Task.ts | 21 +- .../task/__tests__/Task.persistence.spec.ts | 238 +++++++- src/core/task/__tests__/Task.spec.ts | 85 +-- src/core/webview/ClineProvider.ts | 73 ++- .../webview/__tests__/ClineProvider.spec.ts | 156 ++++- .../__tests__/transcriptTransport.model.ts | 322 ++++++++-- .../__tests__/transcriptTransport.spec.ts | 316 ++++++++++ src/core/webview/transcriptTransport.ts | 43 +- src/extension.ts | 8 +- .../src/context/ExtensionStateContext.tsx | 64 +- .../__tests__/ExtensionStateContext.spec.tsx | 555 +++++++++++++++++- 17 files changed, 1983 insertions(+), 189 deletions(-) create mode 100644 apps/cli/src/ui/__tests__/transcript-focus.test.tsx diff --git a/apps/cli/src/ui/__tests__/transcript-focus.test.tsx b/apps/cli/src/ui/__tests__/transcript-focus.test.tsx new file mode 100644 index 0000000000..fb26b0961f --- /dev/null +++ b/apps/cli/src/ui/__tests__/transcript-focus.test.tsx @@ -0,0 +1,51 @@ +import { render } from "ink-testing-library" + +import { createMockClient } from "../../agent/extension-client.js" +import { useMessageHandlers, type UseMessageHandlersReturn } from "../hooks/useMessageHandlers.js" +import { useCLIStore } from "../store.js" + +describe("dedicated transcript focus compatibility", () => { + beforeEach(() => useCLIStore.getState().reset()) + afterEach(() => useCLIStore.getState().reset()) + + it("does not consume CLI resume readiness before the historical transcript arrives", () => { + let handlers: UseMessageHandlersReturn | undefined + function Harness() { + handlers = useMessageHandlers({ nonInteractive: false }) + return null + } + useCLIStore.getState().setIsResumingTask(true) + const { unmount } = render() + try { + expect(handlers).toBeDefined() + const before = useCLIStore.getState() + handlers!.handleExtensionMessage({ type: "clineMessagesFocus", taskId: "task-1", taskInstanceId: "new" }) + expect(useCLIStore.getState()).toBe(before) + expect(useCLIStore.getState().isResumingTask).toBe(true) + handlers!.handleExtensionMessage({ + type: "state", + state: { clineMessages: [{ ts: 1, type: "say", say: "text", text: "Historical first message" }] }, + }) + expect(useCLIStore.getState().messages).toEqual([ + expect.objectContaining({ content: "Historical first message" }), + ]) + expect(useCLIStore.getState().isResumingTask).toBe(false) + } finally { + unmount() + } + }) + + it("does not initialize the noninteractive client or overwrite its legacy transcript", () => { + const { client } = createMockClient() + client.handleMessage({ type: "clineMessagesFocus", taskId: "task-1", taskInstanceId: "new" }) + expect(client.isInitialized()).toBe(false) + client.handleMessage({ + type: "state", + state: { clineMessages: [{ ts: 1, type: "ask", ask: "tool", partial: false }], mode: "code" }, + }) + expect(client.isWaitingForInput()).toBe(true) + client.handleMessage({ type: "clineMessagesFocus" }) + expect(client.isWaitingForInput()).toBe(true) + expect(client.getCurrentMode()).toBe("code") + }) +}) diff --git a/docs/architecture/transcript-transport-model.md b/docs/architecture/transcript-transport-model.md index e339fe1595..b9dbd384c2 100644 --- a/docs/architecture/transcript-transport-model.md +++ b/docs/architecture/transcript-transport-model.md @@ -4,45 +4,58 @@ Run the focused checker with **pnpm transcript-transport:model-check**. It also ## Production boundary -[TranscriptTransport](../../src/core/webview/transcriptTransport.ts) owns generation, task-scoped sequence allocation, explicit FIFO job descriptors, snapshot progress, and one physical-send barrier. [ClineProvider](../../src/core/webview/ClineProvider.ts) supplies current focus and the webview post callback. The provider's append/update/snapshot signatures and legacy CLI branches are unchanged. Resync additionally accepts optional client sequence diagnostics for metadata-only logging; they never select or modify the authoritative snapshot revision. +[`TranscriptTransport`](../../src/core/webview/transcriptTransport.ts:213) owns generation, task-scoped sequence allocation, instance-scoped FIFO job descriptors, snapshot progress, and one physical-send barrier. [ClineProvider.ts](../../src/core/webview/ClineProvider.ts) supplies current task and instance focus and the webview post callback. Resync additionally accepts optional client sequence diagnostics for metadata-only logging; they never select or modify the authoritative snapshot revision. + +[`TranscriptRequest.taskInstanceId`](../../src/core/webview/transcriptTransport.ts:6) is optional for legacy fixtures, while every [`TranscriptJob`](../../src/core/webview/transcriptTransport.ts:11) retains its originating instance, including an absent value. The constructor preserves its first three arguments and adds a fourth focused-instance callback defaulting to an absent value. Identity comparisons are exact: an unscoped request cannot adopt a live instance, and an identified request cannot match absent focus. Production must provide the actual instance callback and the originating instance on requests. Every wire frame, including append/update and snapshot start/chunk/end, copies the descriptor's instance through [`transcriptFrameMessage()`](../../src/core/webview/transcriptTransport.ts:185); it never derives identity from later focus. The driver and the explorer both call [reduceTranscriptTransport](../../src/core/webview/transcriptTransport.ts) for admission, allocation, invalidation, task-sequence pruning, send initiation, and settlement. They also share the production frame-to-message conversion. This is not a separate queue specification that only resembles production. -Payloads and caller resolvers live in driver-owned maps, outside the pure state. Invalidation synchronously removes all waiting jobs and their payload references, releases the active snapshot's unsent suffix, and resolves discarded waiting callers. There is no retained chain of old-generation closures. A physical post already invoked remains the sole in-flight owner until its Promise settles; its caller settles at that boundary. New-generation jobs may queue but cannot send until that barrier is released. Every later snapshot start, chunk, or end initiation rechecks generation and current focus. Rejection terminates that job, rejects its caller, logs the failure, and permits the next job to run. +Payloads and caller resolvers live in driver-owned maps, outside the pure state. Invalidation synchronously removes all waiting jobs and their payload references, releases the active snapshot's unsent suffix, and resolves discarded waiting callers. There is no retained chain of old-generation closures. A physical post already invoked remains the sole in-flight owner until its Promise settles; its caller settles at that boundary. New-generation or new-instance jobs may queue but cannot send until that barrier is released. Every later delta, snapshot start, chunk, or end initiation rechecks generation, task ID, and task instance. Rejection terminates that job, rejects its caller, logs the failure, and permits the next job to run. + +The driver intentionally **deep-clones at enqueue time**. Tasks mutate message objects and nested arrays while a post is waiting; shallow copying or cloning at drain time would pair an earlier sequence with later content. Generation, task, and instance guards run before cloning and before allocating either a sequence or snapshot ID. A second reducer admission check protects the captured payload's ownership, including reentrant focus replacement during cloning. -The driver intentionally **deep-clones at enqueue time**. Tasks mutate message objects and nested arrays while a post is waiting; shallow copying or cloning at drain time would pair an earlier sequence with later content. A stale-generation guard runs before cloning and before allocating either a sequence or snapshot ID. A second admission check protects the captured payload's ownership. +**Empty append/update arrays return before capture or reducer admission.** They allocate no captured request, cloned payload, sequence, job/snapshot ID, frame, or payload/caller-map entry and leave protocol state unchanged. This is not a claim that returning an already-resolved Promise entails zero JavaScript runtime allocation. The reducer independently rejects zero-total deltas without changing state or producing effects. Empty snapshots remain valid and send start/end markers without chunks. The provider tests in [ClineProvider.spec.ts](../../src/core/webview/__tests__/ClineProvider.spec.ts) hold real post callbacks rather than injecting a private Promise queue. They retain focus-only and generation-only cancellation, CLI behavior, snapshot/delta ordering, deep snapshot isolation, and exact-boundary checks. The queued append/update regression mutates nested image arrays. The 401-message regression compares all three chunks to the exact corresponding original slices. Repeated-resync tests retain a held start or chunk, discard 26 waiting jobs, assert immediate payload/caller release, and prove one physical send and no stale end. +The production Task adapters pass the producer's own instance on all append/update calls (including deferred partial updates) and on overwrite/start/resume snapshots. The provider never fills an absent producer identity from current focus; absent identity only matches legacy absent focus. Resync is a controller operation and explicitly captures current focus. Stack publication/removal and in-place replacement invalidate transport and immediately post the new scope before awaiting cleanup or preparation, without resetting the physical-send barrier. The dedicated [`clineMessagesFocus`](../../packages/types/src/vscode-extension-host.ts:40) message publishes only task/instance ownership: it shares receiver scope-reset logic with metadata but cannot trigger generic settings hydration, reopen setup, or clear the legacy CLI's resume flag. Generic state retains its captured instance through asynchronous assembly; the final post boundary drops a mismatching task/instance rather than retagging stale metadata. Unscoped partial metadata and CLI transcript state remain supported. + +The adapter regressions in [Task.persistence.spec.ts](../../src/core/task/__tests__/Task.persistence.spec.ts) instantiate real distinct Tasks sharing one task ID and use the real provider constructor, registry, producer methods, and transport. They hold all five frame types, check publication before both old-task cleanup and replacement preparation, release obsolete queued callers while a send is held, reject delayed old producers even with the current generation, and recover through new-instance snapshots and deltas. Provider tests additionally hold an awaited authentication lookup after generic task metadata capture, then replace focus and prove the obsolete post is dropped for both generic-state methods in browser and CLI modes. The resync race pins the exact winning generation before and after release of the older state-post boundary. These tests supply the concrete adapter evidence that the independent model does not claim to prove. + ## Exhaustive bounded state space -The [explorer](../../src/core/webview/__tests__/transcriptTransport.model.ts) uses deterministic breadth-first search with canonical state deduplication. It explores every enabled ordering in four bounded scenarios; this is not randomized scheduling or a hand-selected trace list. A producer and controller retain their own program order, while admission, send initiation, send success/failure, focus change, and invalidation may interleave at every enabled boundary. +The [explorer](../../src/core/webview/__tests__/transcriptTransport.model.ts) uses deterministic breadth-first search with canonical state deduplication. It explores every enabled ordering in seven bounded scenarios; this is not randomized scheduling or a hand-selected trace list. A producer and controller retain their own program order, while admission, send initiation, send success/failure, focus publication, and invalidation may interleave at every enabled boundary. -| Scenario | Producer order | Controller order | Reachable states | Transitions | Maximum shortest depth | -| --------------------------------- | -------------------------- | ----------------------------------------- | ---------------: | ----------: | ---------------------: | -| Queued deltas / repeated resync | snapshot, append, update | resync, resync | 13,292 | 19,281 | 33 | -| Task switch / clear | snapshot, append, snapshot | switch to second task, clear | 7,523 | 10,334 | 33 | -| Invalidation / recovery | snapshot, update, snapshot | invalidate, resync | 6,030 | 8,149 | 31 | -| Focus before sync / stale request | snapshot, append, update | focus second task, resync, stale snapshot | 5,746 | 10,330 | 24 | +| Scenario | Producer order | Controller order | Reachable states | Transitions | Maximum shortest depth | +| --------------------------------- | -------------------------------------------------- | ----------------------------------------------- | ---------------: | ----------: | ---------------------: | +| Queued deltas / repeated resync | snapshot, append, update | resync, resync | 13,292 | 19,281 | 33 | +| Task switch / clear | snapshot, append, snapshot | switch to second task, clear | 7,523 | 10,334 | 33 | +| Invalidation / recovery | snapshot, update, snapshot | invalidate, resync | 6,030 | 8,149 | 31 | +| Focus before sync / stale request | snapshot, append, update | focus second task, resync, stale snapshot | 5,746 | 10,330 | 24 | +| Same-task instance / snapshot | snapshot, stale-instance append | replace instance, sync instance, append, update | 2,998 | 5,927 | 24 | +| Same-task instance / deltas | append, update, stale-instance snapshot | replace instance, sync instance | 1,317 | 2,034 | 15 | +| Empty deltas / valid recovery | empty append, empty update, append, empty snapshot | none | 21 | 23 | 10 | -These totals are diagnostics, not hard-coded ratchets: 32,591 states across independently explored scenarios and 48,094 examined transitions. Bounds are **two task IDs plus no task, up to five admitted jobs, two invalidations, four messages per snapshot, chunk size two, and at most one failed physical send per trace**. Standalone producer snapshots bump the sequence; resync snapshots retain the current sequence. Empty, exact-boundary, and multi-chunk snapshots arise within the bounds. Production uses chunk size 200; the provider regression checks 401 messages at the real chunk size. +These totals are diagnostics, not hard-coded ratchets: 36,927 states across independently explored scenarios and 56,078 examined transitions. Bounds are **two task IDs plus no task, at most two instances of the first task (one replacement), up to five admitted jobs, two invalidations, four messages per snapshot, chunk size two, and at most one failed physical send per trace**. Standalone producer snapshots bump the sequence; resync and instance-sync snapshots retain the current sequence. Empty, exact-boundary, and multi-chunk snapshots arise within the bounds. Production uses chunk size 200; the provider regression checks 401 messages at the real chunk size. -Each scenario has a **30,000-state budget and depth limit 40**. The checker fails on the first unseen successor beyond either bound, missing required action/landmark coverage, or any invariant violation. There is no truncated success. Every failure reports its scenario, bounds, shortest action trace, intermediate states, and the violating state. Mutants select the shortest witness across all four scenario graphs with stable tie ordering. +Each scenario has an unchanged **30,000-state budget and depth limit 40**. The checker fails on the first unseen successor beyond either bound, missing required action/landmark coverage, or any invariant violation. There is no truncated success. Every failure reports its scenario, bounds, shortest action trace, intermediate states, and the violating state. Mutants select the shortest witness across all seven scenario graphs with stable tie ordering. The model exposes a scheduling point between settlement and the next pump, and between enqueue and pump. The production driver performs these synchronously within its continuation. This is a conservative scheduling over-approximation, not a claim that every model event boundary corresponds to an independently schedulable JavaScript callback. +The replacement action publishes the same task ID with a new instance to both producer focus and the receiver, clearing receiver staging/visible state and applied sequence. It is separate from the later instance-sync action, which invalidates and admits the new snapshot. An explicitly delayed old-instance producer then attempts append or snapshot admission using the **current generation**, before or after sync; stale identity alone must reject it. Ordinary producer events represent fresh current-focus work. Old physical start/chunk/end/delta sends may settle on either side of replacement and sync. Splitting snapshot and delta races preserves the original bounds while requiring both late-end and late-delta receiver rejection, followed by acceptance of new-instance snapshots and deltas. + ## Invariants and scope -1. Generation increases exactly once per invalidation and never otherwise. Stale-generation admission allocates no job or snapshot ID. -2. No physical send overlaps another, including an old generation's held send. No old-generation or old-focus post/commit is **initiated** after ownership changes. +1. Generation increases exactly once per invalidation and never otherwise. Stale-generation admission allocates no job or snapshot ID. Stale-instance and empty-delta admission return identical protocol state without admission or other effects, including when the stale producer supplies the current generation. +2. No physical send overlaps another, including an old generation's or instance's held send. No old-generation, old-task, or old-instance post/commit is **initiated** after ownership changes. Descriptor, frame, and captured wire identity must equal the originating request's instance; a held wire message cannot acquire replacement identity at settlement. 3. Invalidation retains no obsolete queue or payload. Discarded waiting callers settle immediately. Each settlement must consume a registered caller exactly once. Remaining payloads correspond exactly to active/queued jobs; remaining callers correspond exactly to those jobs plus an already-initiated physical send. 4. Allocated sequences follow enqueue/capture order: deltas and bumping snapshots increment; resync retains the current value. Sent sequence is nondecreasing and never exceeds allocation. Failed snapshots never resume their suffix. -5. The independent receiver oracle stages contiguous, exact snapshot payloads and exposes them only at a matching complete end marker. Start/chunks cannot change visible transcript or applied sequence. Applied sequence cannot decrease within one focused-task scope. +5. The independent receiver oracle rejects a wire message unless both task and instance match published focus, before staging or applying any content. This includes a complete old-instance end marker and old append/update deltas. It stages contiguous, exact snapshot payloads and exposes them only at a matching complete end marker. Start/chunks cannot change visible transcript or applied sequence. Applied sequence cannot decrease within one focused-instance scope. 6. Job totals equal captured payload lengths. Only snapshots carry snapshot identities, unique across captures. Non-chunk frame ranges are zero; chunk descriptors have contiguous starts and positive, exact lengths bounded by the captured payload and chunk size. These checks precede wire conversion, whose array slicing can otherwise hide an overlarge final count. -Sequence monotonicity is **not global across task IDs or removed/recreated task lifetimes**. The production provider prunes a task's sequence on stack removal/history deletion; the model exercises the shared pruning action on switch/clear and tags its allocation/sent oracle with a task-lifetime epoch. A no-task snapshot has sequence zero. Receiver applied sequence resets on focus change/clear, as distinct from resync of the same task. The checker does not invent a persisted generation token or silently demand globally increasing sequences after clear. +Sequence monotonicity is **not global across task IDs or removed/recreated task lifetimes**. The production provider prunes a task's sequence on stack removal/history deletion; the model exercises the shared pruning action on switch/clear and tags its allocation/sent oracle with a task-lifetime epoch. Same-task instance replacement itself does not reset the transport's task-keyed sequence; a new-instance snapshot establishes the receiver's baseline. A no-task snapshot has sequence zero. Receiver applied sequence resets on task/instance change or clear, as distinct from resync of the same instance. The checker does not invent a persisted generation token or silently demand globally increasing sequences after clear. -All 16 action classes are required: snapshot, append, update, resync, invalidate, switch, clear, focus, stale-snapshot, pump, start, chunk, end, settle, fail, discard. All 14 named reachability landmarks are required: +All 23 action classes are required: snapshot, append, update, resync, invalidate, switch, clear, focus, stale-snapshot, replace-instance, sync-instance, stale-instance-append, stale-instance-snapshot, empty-append, empty-update, empty-snapshot, pump, start, chunk, end, settle, fail, discard. All 26 named reachability landmarks are required: - held-post-with-queued-delta; - repeated-invalidation-while-held; @@ -57,37 +70,59 @@ All 16 action classes are required: snapshot, append, update, resync, invalidate - multi-chunk-snapshot-committed; - failed-post-with-queued-recovery; - snapshot-recovery-after-failure; -- delta-applied-after-snapshot. +- delta-applied-after-snapshot; +- same-task-instance-published-before-sync; +- instance-replacement-with-held-send; +- stale-instance-current-generation-rejected; +- stale-instance-queued-job-discarded; +- stale-instance-active-suffix-discarded; +- old-instance-end-ignored; +- old-instance-append-ignored; +- old-instance-update-ignored; +- new-instance-snapshot-committed; +- new-instance-append-applied; +- new-instance-update-applied; +- new-instance-recovers-after-old-end-rejected (one trace rejects the old end, commits a new snapshot, and applies both new deltas). ## Invariant sensitivity -Twelve test-only reducer wrappers must produce their expected violation class through the same exhaustive explorer. No mutation switch exists in production. - -| Mutant | Shortest witness, excluding initial state | Detected violation | -| ----------------------------------- | ----------------------------------------- | --------------------------------- | -| stale-completion-starts-end | snapshot, pump, resync, settle | stale commit initiation | -| admit-stale-generation | focus, resync, stale-snapshot | obsolete admission allocates work | -| ignore-focus-at-post | snapshot, focus, pump | stale-focus initiation | -| legacy-generation-only-invalidation | snapshot, resync | retained obsolete jobs/payloads | -| reset-promise-barrier | snapshot, pump, resync, pump | overlapping physical sends | -| commit-before-chunks | snapshot, pump, settle, pump, settle | incomplete atomic snapshot | -| reuse-delta-sequence | snapshot, append | incorrect allocated sequence | -| continue-after-rejection | snapshot, pump, fail, pump | failed snapshot resumes posting | -| delta-snapshot-metadata | snapshot, append | delta carries snapshot metadata | -| non-chunk-payload-range | snapshot, pump | non-chunk payload range | -| overrun-final-chunk | switch, pump, settle, pump | chunk exceeds captured range | -| settle-caller-twice | snapshot, resync | settlement without owned caller | +Twenty test-only reducer, wire-conversion, and receiver-policy faults must produce their expected violation class through the same exhaustive explorer. The receiver policies are independent of React; these checks test the oracle's scope contract, not the production UI implementation. No mutation switch exists in production. + +| Mutant | Shortest witness, excluding initial state | Detected violation | +| ----------------------------------- | ---------------------------------------------------------------------------------- | --------------------------------------- | +| stale-completion-starts-end | snapshot, pump, resync, settle | stale commit initiation | +| admit-stale-generation | focus, resync, stale-snapshot | obsolete admission allocates work | +| ignore-focus-at-post | snapshot, focus, pump | stale-focus initiation | +| legacy-generation-only-invalidation | snapshot, resync | retained obsolete jobs/payloads | +| reset-promise-barrier | snapshot, pump, resync, pump | overlapping physical sends | +| commit-before-chunks | snapshot, pump, settle, pump, settle | incomplete atomic snapshot | +| reuse-delta-sequence | append | incorrect allocated sequence | +| continue-after-rejection | snapshot, pump, fail, pump | failed snapshot resumes posting | +| delta-snapshot-metadata | append | delta carries snapshot metadata | +| non-chunk-payload-range | snapshot, pump | non-chunk payload range | +| overrun-final-chunk | switch, pump, settle, pump | chunk exceeds captured range | +| settle-caller-twice | snapshot, resync | settlement without owned caller | +| admit-empty-delta | empty-append | empty delta allocates work | +| admit-stale-instance | snapshot, replace-instance, stale-instance-append | stale-instance admission allocates work | +| ignore-instance-at-post | snapshot, replace-instance, pump | stale-instance send initiation | +| drop-descriptor-instance | snapshot | descriptor loses origin identity | +| drop-wire-instance | snapshot, pump | wire loses origin identity | +| receiver-ignores-instance | snapshot, pump, replace-instance, settle | receiver accepts stale-instance frame | +| receiver-accepts-stale-end | snapshot, pump, settle, pump, settle, pump, settle, pump, replace-instance, settle | receiver accepts stale-instance end | +| receiver-accepts-stale-delta | append, pump, replace-instance, settle | receiver accepts stale-instance delta | [transcriptTransport.spec.ts](../../src/core/webview/__tests__/transcriptTransport.spec.ts) runs the full checker, verifies deterministic shortest witnesses and both fail-closed budget paths, and exercises the actual driver with held/rejected start, chunk, end, and delta sends, plus synchronous rejection/recovery. The [CLI entry point](../../scripts/check-transcript-transport.ts) prints counts, action/landmark names, bounds, and mutant traces. Focused reducer tests also check canonical descriptors for empty, exact-boundary, and partial-final chunks independently of wire output. Adversarial queued/active states retain obsolete-generation work with unchanged focus to verify the defense-in-depth pre-send guard discards it and permits current work. Such states are deliberately **not claimed reachable** through normal invalidation, which releases that work; no artificial action is added to the reachable-state explorer. A driver regression retains one held caller through two invalidations and checks both successful and failed settlement followed by recovery. +Instance regressions cover stale and absent identity before cloning, replacement reentered during cloning, queued and active pre-send rejection without invalidation, and all five held frame phases across same-task replacement with successful/failed physical settlement, with and without repeated invalidation. They check original wire identity, exactly-once caller settlement, and subsequent new-instance snapshot/delta sends. Empty-delta tests assert no cloning, capture/focus reads, state/sequence/ID/frame changes, or payload/caller insertion, then accept a valid delta and an empty snapshot. Legacy fixtures continue using absent identity on both request and focus. + ## Limitations: initiation is not delivery revocation -An active physical send cannot be unsent. In particular, **an end marker initiated before invalidation may complete afterward and publish its already-complete snapshot on the same focused task**. The generation is provider-local, not a wire field. The named stale-end-completion landmark deliberately requires this permitted behavior; the stale-completion-starts-end mutant forbids the materially different bug of initiating a new old-generation end after invalidation. The single physical barrier ensures a newer transcript's posts cannot overtake the held old one. +An active physical send cannot be unsent. In particular, **an end marker initiated before invalidation may complete afterward and publish its already-complete snapshot on the same focused task instance**. The generation is provider-local, not a wire field. The named stale-end-completion landmark deliberately requires this permitted behavior; the stale-completion-starts-end mutant forbids the materially different bug of initiating a new old-generation end after invalidation. Across same-task instance replacement, the captured wire identity instead lets the receiver reject the old completion. This prevents old content from entering the replacement scope, but does not cancel the physical send or settle its caller early. The single physical barrier ensures a newer transcript's posts cannot overtake the held old one. The receiver is an independent protocol oracle, not the React reducer. It assumes ordered, lossless successful physical delivery at settlement and no delivery for a modeled rejection; a real post can deliver before its Promise settles. It deliberately cannot prove browser timer behavior, dropped/delayed messages, resync retry diagnostics, rendering, or restart behavior. Existing UI tests own those concerns. The provider's post wrapper swallows disposed-view failures and ignores the editor's boolean delivery result; model rejection covers errors reaching the transport callback, **not delivery acknowledgement**. -Metadata state posts are outside this transcript FIFO, and a task may become focused before its asynchronous metadata synchronization completes. The focus-before-sync scenario checks the transcript's live-focus guard, not the metadata channel. There is no fairness/liveness claim: a permanently held physical post permanently blocks later physical transcript posts, although obsolete waiting jobs are still released on invalidation. Memory claims concern removal of owned references, not immediate garbage collection or memory retained by the editor's already-initiated post. +Metadata state posts are outside this transcript FIFO. The integration contract requires the provider to invalidate replacement ownership and publish task/instance focus synchronously before asynchronous preparation, and to guard stale generic metadata. The model assumes receiver focus publication has happened; it does not import or prove that provider/metadata ordering. Its separate replacement-before-sync/invalidation boundary is a conservative over-approximation testing identity protection even before cleanup, not permission for production to delay publication or invalidation. A legacy request with absent identity has no same-task replacement protection unless both endpoints use explicit instances. There is no fairness/liveness claim: a permanently held physical post permanently blocks later physical transcript posts, although obsolete waiting jobs are still released on invalidation. Memory claims concern removal of owned references, not immediate garbage collection or memory retained by the editor's already-initiated post. -This bounded check does not prove arbitrary queue lengths, sequence overflow, arbitrary repeated task-ID reuse, message validation, or all payload values. Driver/provider regressions cover concrete deep-clone behavior and runtime correspondence; the model independently checks ordering and ownership. No persisted lifecycle state is needed for these safety properties, so composition remains at the aggregate command boundary. +This bounded check does not prove arbitrary queue lengths, sequence overflow, repeated instance replacement or arbitrary task-ID reuse, instance-ID uniqueness/collision resistance, message validation, or all payload values. It assumes opaque distinct instance identities and models one replacement only. Driver/provider regressions cover concrete deep-clone behavior and runtime correspondence; UI tests own the real consumer. No persisted lifecycle reducer, status, persistence owner, or scheduler transition is changed or imported by this extension of the transport model, so composition remains at the aggregate command boundary. diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index 90cbc5abbb..d09e839a75 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -37,6 +37,7 @@ export interface ExtensionMessage { | "theme" | "workspaceUpdated" | "invoke" + | "clineMessagesFocus" | "clineMessageAppended" | "clineMessageUpdated" | "clineMessagesSnapshotStart" @@ -147,8 +148,18 @@ export interface ExtensionMessage { * Task scope for transcript deltas and every snapshot frame; it must match the * webview's focused task. Omitted for the no-task scope, whose snapshot is empty * with sequence 0. Unrelated message types may also use this as their task target. + * On clineMessagesFocus, publishes the authoritative task/instance scope before + * asynchronous preparation; omission clears focus. This message carries no + * generic state, does not hydrate settings, and is ignored by legacy CLI clients. */ taskId?: string + /** + * Originating task instance for every dedicated transcript delta and snapshot + * frame. Both taskId and taskInstanceId must match the webview's focused scope; + * a replacement instance must never retag frames from the previous instance. + * Omitted for no-task frames and legacy consumers without instance metadata. + */ + taskInstanceId?: string /** * Complete message value for clineMessageAppended or clineMessageUpdated; updates * replace the existing message identified by ts, not an array index or text patch. @@ -175,9 +186,9 @@ export interface ExtensionMessage { clineMessagesSeq?: number /** * Nonempty correlation ID shared by one snapshot's start, contiguous chunks, and - * end, together with taskId and clineMessagesSeq. The host uses the task ID (or - * "none") plus a provider-wide monotonically increasing snapshot counter, even - * when the revision is unchanged. Treat it as opaque, not a sequence/generation. + * end, together with taskId, taskInstanceId, and clineMessagesSeq. The host uses + * the task ID (or "none") plus a provider-wide monotonically increasing snapshot + * counter, even when the revision is unchanged. Treat it as opaque, not a sequence/generation. * Only a complete matching start/chunks/end transaction is applied atomically; * an empty snapshot has start/end only, including in the no-task scope. */ @@ -396,6 +407,13 @@ export type ExtensionState = Pick< * change task focus; null authoritatively means no task is focused. */ currentTaskId?: string | null + /** + * Focused task instance, published with currentTaskId before replacement work + * begins. Undefined supports legacy/initial partial metadata; omitted instance + * metadata preserves the same task's scope but is cleared on a task switch. + * Null explicitly clears the instance, including an authoritative no-task state. + */ + currentTaskInstanceId?: string | null currentTaskItem?: HistoryItem currentTaskTodos?: TodoItem[] // Initial todos for the current task apiConfiguration: ProviderSettings diff --git a/src/__tests__/extension.spec.ts b/src/__tests__/extension.spec.ts index 63825dedf7..0cdfafd69f 100644 --- a/src/__tests__/extension.spec.ts +++ b/src/__tests__/extension.spec.ts @@ -2,11 +2,13 @@ import type * as vscode from "vscode" +const mockOutputChannel = vi.hoisted(() => ({ + appendLine: vi.fn(), +})) + vi.mock("vscode", () => ({ window: { - createOutputChannel: vi.fn().mockReturnValue({ - appendLine: vi.fn(), - }), + createOutputChannel: vi.fn().mockReturnValue(mockOutputChannel), registerWebviewViewProvider: vi.fn(), registerUriHandler: vi.fn(), tabGroups: { @@ -442,11 +444,18 @@ describe("extension.ts", () => { const updateTelemetryState = vi.mocked(TelemetryService.instance.updateTelemetryState) updateTelemetryState.mockClear() + const visibleInstance = vi.mocked(ClineProvider.getVisibleInstance()!) + visibleInstance.postStateToWebviewWithoutTaskHistory.mockClear() + mockOutputChannel.appendLine.mockClear() vi.mocked(ClineProvider.getVisibleInstance).mockReturnValueOnce(undefined) const onDidChangeHandler = vi.mocked(vscode.env.onDidChangeTelemetryEnabled).mock.calls[0][0] - expect(() => onDidChangeHandler(undefined as never)).not.toThrow() + expect(() => onDidChangeHandler(vscode.env.isTelemetryEnabled)).not.toThrow() + await Promise.resolve() + expect(updateTelemetryState).toHaveBeenCalledOnce() + expect(visibleInstance.postStateToWebviewWithoutTaskHistory).not.toHaveBeenCalled() + expect(mockOutputChannel.appendLine).not.toHaveBeenCalled() }) test("pushes a state update to the webview so its own PostHog client picks up the new vscode.env.isTelemetryEnabled value", async () => { @@ -456,17 +465,53 @@ describe("extension.ts", () => { const { activate } = await import("../extension") await activate(mockContext) - const visibleInstance = ( - ClineProvider as unknown as { - getVisibleInstance(): { postStateToWebviewWithoutTaskHistory: ReturnType } - } - ).getVisibleInstance() - vi.mocked(visibleInstance.postStateToWebviewWithoutTaskHistory).mockClear() + const visibleInstance = vi.mocked(ClineProvider.getVisibleInstance()!) + visibleInstance.postStateToWebviewWithoutTaskHistory.mockClear() + mockOutputChannel.appendLine.mockClear() const onDidChangeHandler = vi.mocked(vscode.env.onDidChangeTelemetryEnabled).mock.calls[0][0] - onDidChangeHandler(undefined as never) + onDidChangeHandler(vscode.env.isTelemetryEnabled) + await Promise.resolve() + + expect(visibleInstance.postStateToWebviewWithoutTaskHistory).toHaveBeenCalledOnce() + expect(mockOutputChannel.appendLine).not.toHaveBeenCalled() + }) + + test.each([ + { + kind: "Error", + error: new Error("telemetry state refresh failed"), + message: "telemetry state refresh failed", + }, + { + kind: "non-Error", + error: "telemetry state refresh rejected", + message: "telemetry state refresh rejected", + }, + ])("logs $kind state-refresh rejections locally after a telemetry toggle", async ({ error, message }) => { + const vscode = await import("vscode") + const { TelemetryService } = await import("@roo-code/telemetry") + const { ClineProvider } = await import("../core/webview/ClineProvider") + const { activate } = await import("../extension") + await activate(mockContext) - expect(visibleInstance.postStateToWebviewWithoutTaskHistory).toHaveBeenCalled() + const visibleInstance = vi.mocked(ClineProvider.getVisibleInstance()!) + visibleInstance.postStateToWebviewWithoutTaskHistory.mockClear() + visibleInstance.postStateToWebviewWithoutTaskHistory.mockRejectedValueOnce(error) + const updateTelemetryState = vi.mocked(TelemetryService.instance.updateTelemetryState) + updateTelemetryState.mockClear() + mockOutputChannel.appendLine.mockClear() + vi.mocked(vscode.env).isTelemetryEnabled = false + const onDidChangeHandler = vi.mocked(vscode.env.onDidChangeTelemetryEnabled).mock.calls[0][0] + + expect(() => onDidChangeHandler(vscode.env.isTelemetryEnabled)).not.toThrow() + await Promise.resolve() + + expect(updateTelemetryState).toHaveBeenCalledWith(false) + expect(visibleInstance.postStateToWebviewWithoutTaskHistory).toHaveBeenCalledOnce() + expect(mockOutputChannel.appendLine).toHaveBeenCalledExactlyOnceWith( + `[TelemetryService] Failed to refresh state after telemetry toggle: ${message}`, + ) }) }) diff --git a/src/__tests__/helpers/provider-stub.ts b/src/__tests__/helpers/provider-stub.ts index f826b86469..863cb70849 100644 --- a/src/__tests__/helpers/provider-stub.ts +++ b/src/__tests__/helpers/provider-stub.ts @@ -8,6 +8,10 @@ type ProviderStubFields = { clineMessagesTransport?: TranscriptTransport log?: ReturnType syncFocusedTaskToWebview?: ReturnType + getCurrentTask?: ClineProvider["getCurrentTask"] + postMessageToWebview?: ClineProvider["postMessageToWebview"] + publishFocusedTaskScope?: () => Promise + invalidateClineMessagesTransport?: () => number taskHistoryStore?: { get: (id: string) => unknown; invalidate?: (id: string) => Promise } taskScheduler?: { schedule: (task: Task, run: () => Promise) => Promise } taskRegistry?: TaskRegistry @@ -19,6 +23,7 @@ type ProviderStubFields = { } type PrivateProviderMethods = { + publishFocusedTaskScope: (this: unknown) => Promise runDelegationTransition: (this: unknown, ...args: unknown[]) => unknown removeClineFromStack: (this: unknown, ...args: unknown[]) => unknown evictCurrentTask: (this: unknown, ...args: unknown[]) => unknown @@ -39,9 +44,12 @@ export function makeProviderStub(stub: T): ClineProvider { const proto = ClineProvider.prototype as unknown as PrivateProviderMethods s.cancelledDelegationChildIds ??= new Set() s.clineMessagesTransport ??= new TranscriptTransport( - () => undefined, - async () => {}, + () => s.getCurrentTask?.()?.taskId, + async (message) => { + await s.postMessageToWebview?.(message) + }, () => {}, + () => s.getCurrentTask?.()?.instanceId, ) s.log ??= vi.fn() s.syncFocusedTaskToWebview ??= vi.fn().mockResolvedValue(undefined) @@ -58,6 +66,10 @@ export function makeProviderStub(stub: T): ClineProvider { } delete s.clineStack + s.getCurrentTask ??= () => s.taskRegistry?.current + s.postMessageToWebview ??= vi.fn().mockResolvedValue(undefined) + s.invalidateClineMessagesTransport ??= () => s.clineMessagesTransport!.invalidate() + s.publishFocusedTaskScope ??= proto.publishFocusedTaskScope.bind(s) s.runDelegationTransition ??= proto.runDelegationTransition.bind(s) s.removeClineFromStack ??= proto.removeClineFromStack.bind(s) s.evictCurrentTask ??= proto.evictCurrentTask.bind(s) diff --git a/src/__tests__/single-open-invariant.spec.ts b/src/__tests__/single-open-invariant.spec.ts index 94eb5099d1..4949f83182 100644 --- a/src/__tests__/single-open-invariant.spec.ts +++ b/src/__tests__/single-open-invariant.spec.ts @@ -1,6 +1,5 @@ // npx vitest run __tests__/single-open-invariant.spec.ts -import { describe, it, expect, vi, beforeEach } from "vitest" import { type OutputChannel } from "vscode" import { ClineProvider } from "../core/webview/ClineProvider" import { TaskRegistry } from "../core/task/TaskRegistry" @@ -9,6 +8,7 @@ import { type Task } from "../core/task/Task" import { API } from "../extension/api" import * as ProfileValidatorMod from "../shared/ProfileValidator" import { providerIdentifiers } from "@roo-code/types/provider-identifiers" +import { makeProviderStub } from "./helpers/provider-stub" type PrivateClineProviderMethods = { createTask: ( @@ -240,8 +240,8 @@ describe("Single-open-task invariant", () => { const registry = new TaskRegistry() registry.push(existingTask as unknown as Task) - const provider = { - getCurrentTask: vi.fn(() => existingTask), + const provider = makeProviderStub({ + getCurrentTask: vi.fn(() => registry.current), taskRegistry: registry, taskHistoryStore: { get: vi.fn(() => undefined) }, markDelegatedChildInterrupted: vi.fn().mockResolvedValue(undefined), @@ -279,7 +279,7 @@ describe("Single-open-task invariant", () => { getProviderSettings: vi.fn(() => ({})), }, postStateToWebview: vi.fn(), - } as unknown as ClineProvider + }) const historyItem = { id: historyId, @@ -292,8 +292,16 @@ describe("Single-open-task invariant", () => { workspace: "/tmp", } - await privateClineProvider.createTaskWithHistoryItem.call(provider, historyItem) + const replacement = await privateClineProvider.createTaskWithHistoryItem.call(provider, historyItem) + expect(provider.postMessageToWebview).toHaveBeenCalledExactlyOnceWith({ + type: "clineMessagesFocus", + taskId: historyId, + taskInstanceId: replacement.instanceId, + }) + expect(vi.mocked(provider.postMessageToWebview).mock.invocationCallOrder[0]).toBeLessThan( + existingTask.abortTask.mock.invocationCallOrder[0], + ) expect(schedulespy).toHaveBeenCalledTimes(1) // evictCurrentTask must NOT have been called — in-place replace, no stack pop expect(removeClineFromStack).not.toHaveBeenCalled() @@ -316,7 +324,7 @@ describe("Single-open-task invariant", () => { }) const schedulespy = vi.fn().mockResolvedValue(undefined) - const provider = { + const provider = makeProviderStub({ historyTaskCreationQueue: Promise.resolve(), getCurrentTask: vi.fn(() => registry.current), taskRegistry: registry, @@ -352,7 +360,7 @@ describe("Single-open-task invariant", () => { getProviderSettings: vi.fn(() => ({})), }, postStateToWebview: vi.fn(), - } as unknown as ClineProvider + }) const historyItem = { id: "hist-concurrent-1", diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index d33dbbe589..76020145a6 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -676,7 +676,7 @@ export class Task extends EventEmitter implements TaskLike { return } - void provider.postClineMessageUpdated(this.taskId, message).catch((error) => { + void provider.postClineMessageUpdated(this.taskId, message, this.instanceId).catch((error) => { console.error("[Task#updateClineMessage] incremental post failed:", error) }) }, @@ -1290,7 +1290,7 @@ export class Task extends EventEmitter implements TaskLike { this.clineMessages.push(message) const provider = this.providerRef.deref() try { - await provider?.postClineMessageAppended(this.taskId, message) + await provider?.postClineMessageAppended(this.taskId, message, this.instanceId) } catch (error) { console.error("[Task#addToClineMessages] incremental post failed:", error) } @@ -1319,7 +1319,10 @@ export class Task extends EventEmitter implements TaskLike { if (persist) { await this.saveClineMessages(false) } - await this.providerRef.deref()?.postClineMessagesSnapshot(this.taskId, { bumpSeq: true }) + await this.providerRef.deref()?.postClineMessagesSnapshot(this.taskId, { + bumpSeq: true, + taskInstanceId: this.instanceId, + }) } private hydrateClineMessages(messages: ClineMessage[]) { @@ -1349,7 +1352,7 @@ export class Task extends EventEmitter implements TaskLike { this.debouncedPostPartialMessageUpdate(message) } else { this.debouncedPostPartialMessageUpdate.cancel() - await this.providerRef.deref()?.postClineMessageUpdated(this.taskId, message) + await this.providerRef.deref()?.postClineMessageUpdated(this.taskId, message, this.instanceId) } this.emit(RooCodeEventName.Message, { action: "updated", message }) @@ -2211,7 +2214,10 @@ 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()?.postClineMessagesSnapshot(this.taskId, { bumpSeq: true }) + await this.providerRef.deref()?.postClineMessagesSnapshot(this.taskId, { + bumpSeq: true, + taskInstanceId: this.instanceId, + }) await this.say("text", task, images) @@ -2361,7 +2367,10 @@ export class Task extends EventEmitter implements TaskLike { } // Publish the transcript after both histories hydrate, before any resume prompt or pending-action replay. - await this.providerRef.deref()?.postClineMessagesSnapshot(this.taskId, { bumpSeq: true }) + await this.providerRef.deref()?.postClineMessagesSnapshot(this.taskId, { + bumpSeq: true, + taskInstanceId: this.instanceId, + }) if (this.abort || this.abandoned) { return diff --git a/src/core/task/__tests__/Task.persistence.spec.ts b/src/core/task/__tests__/Task.persistence.spec.ts index 1bb00f1332..82886ae048 100644 --- a/src/core/task/__tests__/Task.persistence.spec.ts +++ b/src/core/task/__tests__/Task.persistence.spec.ts @@ -303,6 +303,232 @@ describe("Task persistence", () => { mockProvider.log = vi.fn() }) + describe("real Task/provider transcript adapters", () => { + const historyItem = { + id: "same-task", + number: 1, + ts: 1, + task: "Same task, distinct instances", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + } + const message = (text: string): ClineMessage => ({ ts: 1, type: "say", say: "text", text }) + const createTask = () => + new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, historyItem, startTask: false }) + + beforeEach(() => { + // Keep the real constructor, registry, producer methods and transport. Only + // editor I/O, persistence and generic metadata services are test doubles. + mockProvider.postClineMessageAppended = ClineProvider.prototype.postClineMessageAppended + mockProvider.postClineMessageUpdated = ClineProvider.prototype.postClineMessageUpdated + mockProvider.postClineMessagesSnapshot = ClineProvider.prototype.postClineMessagesSnapshot + }) + + it("publishes new focus before preparation and clears it before removal cleanup", async () => { + const task = createTask() + const post = vi.mocked(mockProvider.postMessageToWebview) + const preparing = createDeferred() + const preparation = createDeferred() + const generation = mockProvider["clineMessagesTransport"].generation + vi.spyOn(mockProvider, "performPreparationTasks").mockImplementationOnce(async () => { + preparing.resolve() + await preparation.promise + }) + const adding = mockProvider.addClineToStack(task) + try { + // The post invocation and invalidation precede even the first async continuation. + expect(post).toHaveBeenCalledWith({ + type: "clineMessagesFocus", + taskId: task.taskId, + taskInstanceId: task.instanceId, + }) + expect(mockProvider["clineMessagesTransport"].generation).toBe(generation + 1) + await preparing.promise + } finally { + preparation.resolve() + await adding + } + const abortStarted = createDeferred() + const abort = createDeferred() + vi.spyOn(task, "abortTask").mockImplementation(async () => { + abortStarted.resolve() + await abort.promise + }) + const removing = mockProvider.removeClineFromStack() + try { + expect(mockProvider.getCurrentTask()).toBeUndefined() + expect(post).toHaveBeenLastCalledWith({ + type: "clineMessagesFocus", + taskId: undefined, + taskInstanceId: undefined, + }) + await abortStarted.promise + expect(mockProvider["clineMessagesTransport"]["state"].sequences.has(task.taskId)).toBe(false) + } finally { + abort.resolve() + await removing + } + }) + + it.each([ + "clineMessagesSnapshotStart", + "clineMessagesSnapshotChunk", + "clineMessagesSnapshotEnd", + "clineMessageAppended", + "clineMessageUpdated", + ] as const)("publishes replacement before cleanup/preparation while old %s is held", async (heldType) => { + const oldTask = createTask() + oldTask.clineMessages = [message("old")] + await mockProvider.addClineToStack(oldTask) + oldTask["saveClineMessages"] = vi.fn().mockResolvedValue(true) + const held = createDeferred() + const started = createDeferred() + const abort = createDeferred() + const preparing = createDeferred() + const preparation = createDeferred() + let activeSends = 0 + let maximumSends = 0 + const post = vi.mocked(mockProvider.postMessageToWebview).mockImplementation(async (frame) => { + // Task initialization also posts unrelated metadata/actions outside this FIFO. + if (frame.taskInstanceId === undefined || frame.type === "clineMessagesFocus") return + activeSends++ + maximumSends = Math.max(maximumSends, activeSends) + if (frame.type === heldType && frame.taskInstanceId === oldTask.instanceId) { + started.resolve() + await held.promise + } + activeSends-- + }) + post.mockClear() + const active = + heldType === "clineMessageAppended" + ? oldTask["addToClineMessages"](message("held append")) + : heldType === "clineMessageUpdated" + ? oldTask["updateClineMessage"](message("held update")) + : oldTask.overwriteClineMessages([message("held snapshot")], false) + await started.promise + const queued = oldTask["updateClineMessage"](message("queued old update")) + const transport = mockProvider["clineMessagesTransport"] + const oldGeneration = transport.generation + const abortSpy = vi.spyOn(oldTask, "abortTask").mockImplementation(async () => { + const replacement = mockProvider.getCurrentTask()! + expect(replacement).not.toBe(oldTask) + expect(replacement.taskId).toBe(oldTask.taskId) + expect(replacement.instanceId).not.toBe(oldTask.instanceId) + expect(transport.generation).toBe(oldGeneration + 1) + expect(post).toHaveBeenLastCalledWith({ + type: "clineMessagesFocus", + taskId: oldTask.taskId, + taskInstanceId: replacement.instanceId, + }) + await abort.promise + }) + vi.spyOn(mockProvider, "performPreparationTasks").mockImplementation(async () => { + preparing.resolve() + await preparation.promise + }) + const replacing = mockProvider.createTaskWithHistoryItem(historyItem, { startTask: false }) + try { + await vi.waitFor(() => expect(abortSpy).toHaveBeenCalledOnce()) + await queued // Invalidated callers settle even though the physical post remains held. + expect(transport["payloads"].size).toBe(0) + expect(activeSends).toBe(1) + abort.resolve() + await preparing.promise + const beforeRelease = post.mock.calls.length + held.resolve() + await active + expect(post.mock.calls).toHaveLength(beforeRelease) // No old suffix after replacement. + preparation.resolve() + const replacement = await replacing + const newFrames = post.mock.calls + .slice(beforeRelease) + .map(([frame]) => frame) + .filter((frame) => frame.type !== "clineMessagesFocus") + expect(newFrames.filter((frame) => frame.type !== "state").map((frame) => frame.type)).toEqual([ + "clineMessagesSnapshotStart", + "clineMessagesSnapshotEnd", + ]) + expect( + newFrames + .filter((frame) => frame.type !== "state") + .every((frame) => frame.taskInstanceId === replacement.instanceId), + ).toBe(true) + const heldFrame = post.mock.calls.find(([frame]) => frame.type === heldType)![0] + expect(heldFrame.taskInstanceId).toBe(oldTask.instanceId) + expect(maximumSends).toBe(1) + expect(transport["callers"].size).toBe(0) + } finally { + held.resolve() + abort.resolve() + preparation.resolve() + await Promise.all([active, queued, replacing]) + } + }) + + it("rejects delayed old producers with the current generation and recovers through new Task producers", async () => { + vi.useFakeTimers() + const oldTask = createTask() + const replacement = createTask() + const saved = createDeferred() + try { + await mockProvider.addClineToStack(oldTask) + oldTask["saveClineMessages"] = vi.fn().mockReturnValueOnce(saved.promise).mockResolvedValue(true) + replacement["saveClineMessages"] = vi.fn().mockResolvedValue(true) + await oldTask["updateClineMessage"]({ ...message("leading"), partial: true }) + await oldTask["updateClineMessage"]({ ...message("delayed trailing"), partial: true }) + const overwrite = oldTask.overwriteClineMessages([message("delayed persisted snapshot")]) + // Requeue a trailing callback after overwrite's deliberate cancellation. + await oldTask["updateClineMessage"]({ ...message("leading again"), partial: true }) + await oldTask["updateClineMessage"]({ ...message("delayed trailing"), partial: true }) + await mockProvider.addClineToStack(replacement) + const post = vi.mocked(mockProvider.postMessageToWebview) + post.mockClear() + const transport = mockProvider["clineMessagesTransport"] + const before = transport["state"] + saved.resolve(true) + await overwrite + await vi.advanceTimersByTimeAsync(500) + await oldTask["addToClineMessages"](message("late append")) + await oldTask["updateClineMessage"](message("late final update")) + await mockProvider.postClineMessagesSnapshot(oldTask.taskId, { + generation: transport.generation, + taskInstanceId: oldTask.instanceId, + bumpSeq: true, + }) + expect(post).not.toHaveBeenCalled() + expect(transport["state"]).toBe(before) + + await replacement.overwriteClineMessages([message("recovered")], false) + await replacement["addToClineMessages"]({ ...message("new append"), ts: 2 }) + await replacement["updateClineMessage"]({ ...message("new update"), ts: 2 }) + const frames = post.mock.calls.map(([frame]) => frame) + expect(frames.map((frame) => frame.type)).toEqual([ + "clineMessagesSnapshotStart", + "clineMessagesSnapshotChunk", + "clineMessagesSnapshotEnd", + "clineMessageAppended", + "clineMessageUpdated", + ]) + expect(frames.every((frame) => frame.taskInstanceId === replacement.instanceId)).toBe(true) + const seq = before.sequences.get(replacement.taskId) ?? 0 + expect(frames.map((frame) => frame.clineMessagesSeq)).toEqual([ + seq + 1, + seq + 1, + seq + 1, + seq + 2, + seq + 3, + ]) + } finally { + saved.resolve(true) + oldTask["debouncedPostPartialMessageUpdate"].cancel() + replacement["debouncedPostPartialMessageUpdate"].cancel() + vi.useRealTimers() + } + }) + }) + // ── saveApiConversationHistory (via retrySaveApiConversationHistory) ── describe("saveApiConversationHistory", () => { @@ -1397,7 +1623,10 @@ describe("Task persistence", () => { // An explicit entry signal avoids polling or guessed microtask counts. Racing resume settlement // also makes a swapped branch that returns before the snapshot fail without hanging the test. await Promise.race([snapshotStarted.promise, resumePromise]) - expect(snapshot).toHaveBeenCalledExactlyOnceWith(task.taskId, { bumpSeq: true }) + expect(snapshot).toHaveBeenCalledExactlyOnceWith(task.taskId, { + bumpSeq: true, + taskInstanceId: task.instanceId, + }) expect(events).toEqual(["snapshot started"]) expect(replay).not.toHaveBeenCalled() expect(ask).not.toHaveBeenCalled() @@ -1801,7 +2030,12 @@ describe("Task persistence", () => { expect(ask).not.toHaveBeenCalled() apiRead.resolve(apiMessages) - await vi.waitFor(() => expect(snapshot).toHaveBeenCalledWith(task.taskId, { bumpSeq: true })) + await vi.waitFor(() => + expect(snapshot).toHaveBeenCalledWith(task.taskId, { + bumpSeq: true, + taskInstanceId: task.instanceId, + }), + ) expect(ask).not.toHaveBeenCalled() expect(mockSaveTaskMessages).not.toHaveBeenCalled() expect(mockSaveApiMessages).not.toHaveBeenCalled() diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index 9564a2d53c..1ae3a9e262 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -2211,7 +2211,10 @@ describe("Cline", () => { expect(saveSpy).toHaveBeenCalledOnce() expect(mockProvider.postClineMessagesSnapshot).toHaveBeenCalledOnce() - expect(mockProvider.postClineMessagesSnapshot).toHaveBeenCalledWith(task.taskId, { bumpSeq: true }) + expect(mockProvider.postClineMessagesSnapshot).toHaveBeenCalledWith(task.taskId, { + bumpSeq: true, + taskInstanceId: task.instanceId, + }) }) it.each([true, false])("awaits the overwrite snapshot when persist is %s", async (persist) => { @@ -2233,7 +2236,12 @@ describe("Cline", () => { overwriteFinished = true }) - await vi.waitFor(() => expect(snapshotSpy).toHaveBeenCalledWith(task.taskId, { bumpSeq: true })) + await vi.waitFor(() => + expect(snapshotSpy).toHaveBeenCalledWith(task.taskId, { + bumpSeq: true, + taskInstanceId: task.instanceId, + }), + ) expect(task.clineMessages).toEqual(messages) expect(saveSpy).toHaveBeenCalledTimes(persist ? 1 : 0) expect(overwriteFinished).toBe(false) @@ -2272,13 +2280,13 @@ describe("Cline", () => { task.clineMessages = [staleMessage] await taskAccess.updateClineMessage(firstMessage) await taskAccess.updateClineMessage(staleMessage) - expect(updatePostSpy.mock.calls).toEqual([[task.taskId, firstMessage]]) + expect(updatePostSpy.mock.calls).toEqual([[task.taskId, firstMessage, task.instanceId]]) const overwritePromise = task.overwriteClineMessages([replacement], persist) await vi.advanceTimersByTimeAsync(500) expect(task.clineMessages).toEqual([replacement]) - expect(updatePostSpy.mock.calls).toEqual([[task.taskId, firstMessage]]) + expect(updatePostSpy.mock.calls).toEqual([[task.taskId, firstMessage, task.instanceId]]) expect(mockProvider.postClineMessagesSnapshot).toHaveBeenCalledTimes(persist ? 0 : 1) releaseSave(true) @@ -2286,13 +2294,16 @@ describe("Cline", () => { await vi.advanceTimersByTimeAsync(500) expect(mockProvider.postClineMessagesSnapshot).toHaveBeenCalledOnce() - expect(mockProvider.postClineMessagesSnapshot).toHaveBeenCalledWith(task.taskId, { bumpSeq: true }) - expect(updatePostSpy.mock.calls).toEqual([[task.taskId, firstMessage]]) + expect(mockProvider.postClineMessagesSnapshot).toHaveBeenCalledWith(task.taskId, { + bumpSeq: true, + taskInstanceId: task.instanceId, + }) + expect(updatePostSpy.mock.calls).toEqual([[task.taskId, firstMessage, task.instanceId]]) await taskAccess.updateClineMessage(replacement) expect(updatePostSpy.mock.calls).toEqual([ - [task.taskId, firstMessage], - [task.taskId, replacement], + [task.taskId, firstMessage, task.instanceId], + [task.taskId, replacement, task.instanceId], ]) await vi.advanceTimersByTimeAsync(500) @@ -2315,7 +2326,10 @@ describe("Cline", () => { expect(task.clineMessages).toEqual(messages) expect(saveSpy).toHaveBeenCalledWith(false) - expect(mockProvider.postClineMessagesSnapshot).toHaveBeenCalledWith(task.taskId, { bumpSeq: true }) + expect(mockProvider.postClineMessagesSnapshot).toHaveBeenCalledWith(task.taskId, { + bumpSeq: true, + taskInstanceId: task.instanceId, + }) }) it("still overwrites the transcript when the provider reference is unavailable", async () => { @@ -2356,7 +2370,7 @@ describe("Cline", () => { await getTaskTestAccess(task).addToClineMessages(message) expect(mockProvider.postClineMessageAppended).toHaveBeenCalledOnce() - expect(mockProvider.postClineMessageAppended).toHaveBeenCalledWith(task.taskId, message) + expect(mockProvider.postClineMessageAppended).toHaveBeenCalledWith(task.taskId, message, task.instanceId) expect(mockProvider.postStateToWebviewThrottled).not.toHaveBeenCalled() expect(mockProvider.postStateToWebviewWithoutTaskHistory).not.toHaveBeenCalled() }) @@ -2414,7 +2428,7 @@ describe("Cline", () => { const addPromise = taskAccess.addToClineMessages(message) await Promise.resolve() - expect(postSpy).toHaveBeenCalledWith(task.taskId, message) + expect(postSpy).toHaveBeenCalledWith(task.taskId, message, task.instanceId) expect(messageListener).not.toHaveBeenCalled() releasePost() @@ -2476,7 +2490,7 @@ describe("Cline", () => { } await getTaskTestAccess(task).addToClineMessages(message) - expect(mockProvider.postClineMessageAppended).toHaveBeenCalledWith(task.taskId, message) + expect(mockProvider.postClineMessageAppended).toHaveBeenCalledWith(task.taskId, message, task.instanceId) }) it("serializes a new partial message before its immediate leading update", async () => { @@ -2509,7 +2523,7 @@ describe("Cline", () => { }) await Promise.resolve() - expect(appendSpy).toHaveBeenCalledWith(task.taskId, partialMessage) + expect(appendSpy).toHaveBeenCalledWith(task.taskId, partialMessage, task.instanceId) expect(partialAddSettled).toBe(false) expect(updatePostSpy).not.toHaveBeenCalled() @@ -2518,10 +2532,11 @@ describe("Cline", () => { expect(updatePostSpy).toHaveBeenCalledOnce() expect(appendSpy.mock.invocationCallOrder[0]).toBeLessThan(updatePostSpy.mock.invocationCallOrder[0]) - expect(updatePostSpy).toHaveBeenCalledWith(task.taskId, { - ...partialMessage, - text: "updated partial", - }) + expect(updatePostSpy).toHaveBeenCalledWith( + task.taskId, + { ...partialMessage, text: "updated partial" }, + task.instanceId, + ) await vi.advanceTimersByTimeAsync(500) expect(updatePostSpy).toHaveBeenCalledOnce() @@ -2540,7 +2555,7 @@ describe("Cline", () => { const updatePromise = getTaskTestAccess(task).updateClineMessage(message) - expect(updatePostSpy.mock.calls).toEqual([[task.taskId, message]]) + expect(updatePostSpy.mock.calls).toEqual([[task.taskId, message, task.instanceId]]) await updatePromise await vi.advanceTimersByTimeAsync(1_000) expect(updatePostSpy).toHaveBeenCalledOnce() @@ -2572,12 +2587,12 @@ describe("Cline", () => { await taskAccess.updateClineMessage(latest) await vi.advanceTimersByTimeAsync(249) - expect(updatePostSpy.mock.calls).toEqual([[task.taskId, first]]) + expect(updatePostSpy.mock.calls).toEqual([[task.taskId, first, task.instanceId]]) await vi.advanceTimersByTimeAsync(1) expect(updatePostSpy.mock.calls).toEqual([ - [task.taskId, first], - [task.taskId, latest], + [task.taskId, first, task.instanceId], + [task.taskId, latest, task.instanceId], ]) await vi.advanceTimersByTimeAsync(1_000) expect(updatePostSpy).toHaveBeenCalledTimes(2) @@ -2605,10 +2620,10 @@ describe("Cline", () => { await vi.advanceTimersByTimeAsync(100) expect(updatePostSpy.mock.calls).toEqual([ - [task.taskId, first], - [task.taskId, { ...first, text: "partial 400" }], - [task.taskId, { ...first, text: "partial 900" }], - [task.taskId, { ...first, text: "partial 1400" }], + [task.taskId, first, task.instanceId], + [task.taskId, { ...first, text: "partial 400" }, task.instanceId], + [task.taskId, { ...first, text: "partial 900" }, task.instanceId], + [task.taskId, { ...first, text: "partial 1400" }, task.instanceId], ]) await vi.advanceTimersByTimeAsync(1_000) expect(updatePostSpy).toHaveBeenCalledTimes(4) @@ -2647,7 +2662,7 @@ describe("Cline", () => { await vi.advanceTimersByTimeAsync(500) expect(vi.mocked(mockProvider.postClineMessageUpdated).mock.calls).toEqual( - edge === "leading" ? [] : [[task.taskId, first]], + edge === "leading" ? [] : [[task.taskId, first, task.instanceId]], ) }, ) @@ -2696,12 +2711,12 @@ describe("Cline", () => { await taskAccess.updateClineMessage(first) await vi.advanceTimersByTimeAsync(100) await taskAccess.updateClineMessage({ ...first, text: "queued partial" }) - expect(updatePostSpy.mock.calls).toEqual([[task.taskId, first]]) + expect(updatePostSpy.mock.calls).toEqual([[task.taskId, first, task.instanceId]]) await task[cleanup]() await vi.advanceTimersByTimeAsync(1_000) - expect(updatePostSpy.mock.calls).toEqual([[task.taskId, first]]) + expect(updatePostSpy.mock.calls).toEqual([[task.taskId, first, task.instanceId]]) }, ) @@ -2731,7 +2746,7 @@ describe("Cline", () => { await taskAccess.updateClineMessage(first) await vi.advanceTimersByTimeAsync(100) await taskAccess.updateClineMessage({ ...first, text: "superseded partial" }) - expect(updatePostSpy.mock.calls).toEqual([[task.taskId, first]]) + expect(updatePostSpy.mock.calls).toEqual([[task.taskId, first, task.instanceId]]) let releasePost!: () => void const pendingPost = new Promise((resolve) => { @@ -2743,8 +2758,8 @@ describe("Cline", () => { const completionPromise = taskAccess.updateClineMessage(complete) expect(updatePostSpy.mock.calls).toEqual([ - [task.taskId, first], - [task.taskId, complete], + [task.taskId, first, task.instanceId], + [task.taskId, complete, task.instanceId], ]) expect(messageListener).not.toHaveBeenCalled() @@ -3949,7 +3964,11 @@ describe("Cline", () => { expect(updateSpy).toHaveBeenCalledTimes(expectedUpdateCount) if (!removeRequestDuringSave) { - expect(updateSpy).toHaveBeenCalledWith(task.taskId, expect.objectContaining({ say: "api_req_started" })) + expect(updateSpy).toHaveBeenCalledWith( + task.taskId, + expect.objectContaining({ say: "api_req_started" }), + task.instanceId, + ) } }) }) @@ -4209,7 +4228,7 @@ describe("Cline", () => { const startPromise = taskAccess.startTask("new task") - expect(snapshotSpy).toHaveBeenCalledWith(task.taskId, { bumpSeq: true }) + expect(snapshotSpy).toHaveBeenCalledWith(task.taskId, { bumpSeq: true, taskInstanceId: task.instanceId }) expect(mockProvider.postStateToWebviewThrottled).not.toHaveBeenCalled() expect(saySpy).not.toHaveBeenCalled() diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 31a31335c1..3545852fed 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -228,6 +228,7 @@ export class ClineProvider (message) => this.postMessageToWebview(message), (error) => this.log(`[clineMessages] transport failure: ${error instanceof Error ? error.message : String(error)}`), + () => this.getCurrentTask()?.instanceId, ) private readonly _postStateToWebviewThrottled = debounce( async () => { @@ -578,6 +579,7 @@ export class ClineProvider // Add this cline instance into the stack that represents the order of // all the called tasks. this.taskRegistry.push(task) + await this.publishFocusedTaskScope() task.emit(RooCodeEventName.TaskFocused) // Perform special setup provider specific tasks. @@ -621,11 +623,12 @@ export class ClineProvider // Remove the focused Cline instance from the stack. let task = this.taskRegistry.current if (task) { + this.clineMessagesTransport.forgetTask(task.taskId) task = this.taskRegistry.remove(task.taskId) } + await this.publishFocusedTaskScope() if (task) { - this.clineMessagesTransport.forgetTask(task.taskId) task.emit(RooCodeEventName.TaskUnfocused) try { @@ -1389,6 +1392,11 @@ export class ClineProvider const oldTask = this.taskRegistry.current if (oldTask) { + // Publish replacement ownership before cleanup can yield. Old Task producers + // must already be stale during abort, not just after model preparation. + this.taskRegistry.replace(oldTask.taskId, task) + await this.publishFocusedTaskScope() + // Abort the old task to stop running processes and mark as abandoned try { await oldTask.abortTask(true) @@ -1404,9 +1412,6 @@ export class ClineProvider cleanupFunctions.forEach((cleanup) => cleanup()) this.taskEventListeners.delete(oldTask) } - - // Replace in-place: preserves stack index and current pointer - this.taskRegistry.replace(oldTask.taskId, task) } task.emit(RooCodeEventName.TaskFocused) @@ -1485,6 +1490,21 @@ export class ClineProvider return } + if (message.type === "state" && message.state) { + // State assembly awaits optional services after capturing its Task. Never let + // such a late post restore an old scope (or attach old metadata to a new one). + // Partial, unscoped metadata remains valid, including for CLI consumers. + const currentTask = this.getCurrentTask() + if ( + (message.state.currentTaskId !== undefined && + message.state.currentTaskId !== (currentTask?.taskId ?? null)) || + (message.state.currentTaskInstanceId !== undefined && + message.state.currentTaskInstanceId !== (currentTask?.instanceId ?? null)) + ) { + return + } + } + // 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. @@ -1504,34 +1524,49 @@ export class ClineProvider return this.clineMessagesTransport.invalidate() } - public postClineMessageAppended(taskId: string, message: ClineMessage): Promise { - if (this.getCurrentTask()?.taskId !== taskId) { + private async publishFocusedTaskScope(): Promise { + const generation = this.invalidateClineMessagesTransport() + const currentTask = this.getCurrentTask() + // No asynchronous state assembly before this post: held old frames must see + // the replacement scope even while abort/preparation or generic state is pending. + await this.postMessageToWebview({ + type: "clineMessagesFocus", + taskId: currentTask?.taskId, + taskInstanceId: currentTask?.instanceId, + }) + return generation + } + + public postClineMessageAppended(taskId: string, message: ClineMessage, taskInstanceId?: string): Promise { + const currentTask = this.getCurrentTask() + if (currentTask?.taskId !== taskId || currentTask?.instanceId !== taskInstanceId) { return Promise.resolve() } if (process.env.ROO_CLI_RUNTIME === "1") { return this.postStateToWebviewWithoutTaskHistory() } - return this.clineMessagesTransport.enqueue({ kind: "append", taskId }, [message]) + return this.clineMessagesTransport.enqueue({ kind: "append", taskId, taskInstanceId }, [message]) } - public postClineMessageUpdated(taskId: string, message: ClineMessage): Promise { - if (this.getCurrentTask()?.taskId !== taskId) { + public postClineMessageUpdated(taskId: string, message: ClineMessage, taskInstanceId?: string): Promise { + const currentTask = this.getCurrentTask() + if (currentTask?.taskId !== taskId || currentTask?.instanceId !== taskInstanceId) { return Promise.resolve() } if (process.env.ROO_CLI_RUNTIME === "1") { return this.postMessageToWebview({ type: "messageUpdated", clineMessage: structuredClone(message) }) } - return this.clineMessagesTransport.enqueue({ kind: "update", taskId }, [message]) + return this.clineMessagesTransport.enqueue({ kind: "update", taskId, taskInstanceId }, [message]) } public postClineMessagesSnapshot( taskId: string | undefined = this.getCurrentTask()?.taskId, - options: { bumpSeq?: boolean; generation?: number } = {}, + options: { bumpSeq?: boolean; generation?: number; taskInstanceId?: string } = {}, ): Promise { const currentTask = this.getCurrentTask() - if ((currentTask?.taskId ?? undefined) !== taskId) { + if (currentTask?.taskId !== taskId || currentTask?.instanceId !== options.taskInstanceId) { return Promise.resolve() } if (process.env.ROO_CLI_RUNTIME === "1") { @@ -1545,7 +1580,8 @@ export class ClineProvider } public resyncClineMessagesToWebview(taskId?: string, expectedSeq?: unknown, receivedSeq?: unknown): Promise { - const currentTaskId = this.getCurrentTask()?.taskId + const currentTask = this.getCurrentTask() + const currentTaskId = currentTask?.taskId if (currentTaskId !== taskId) { return Promise.resolve() } @@ -1569,11 +1605,12 @@ export class ClineProvider receivedSeq: diagnosticSequence(receivedSeq), })}`, ) - return this.postClineMessagesSnapshot(taskId, { generation }) + return this.postClineMessagesSnapshot(taskId, { generation, taskInstanceId: currentTask?.instanceId }) } public async syncFocusedTaskToWebview(options: { includeTaskHistory?: boolean } = {}): Promise { - const generation = this.invalidateClineMessagesTransport() + const currentTask = this.getCurrentTask() + const generation = await this.publishFocusedTaskScope() if (options.includeTaskHistory) { await this.postStateToWebview() } else { @@ -1582,7 +1619,10 @@ export class ClineProvider if (generation !== this.clineMessagesTransport.generation) { return } - await this.postClineMessagesSnapshot(this.getCurrentTask()?.taskId, { generation }) + await this.postClineMessagesSnapshot(currentTask?.taskId, { + generation, + taskInstanceId: currentTask?.instanceId, + }) } public requestWebviewThemeFixture(timeoutMs = 5_000): Promise { @@ -2859,6 +2899,7 @@ export class ClineProvider autoCondenseContextPercent: autoCondenseContextPercent ?? 100, uriScheme: vscode.env.uriScheme, currentTaskId: currentTask?.taskId ?? null, + currentTaskInstanceId: currentTask?.instanceId ?? 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 3aeadd6c98..9140466ae3 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -35,6 +35,7 @@ import { Terminal } from "../../../integrations/terminal/Terminal" import { MessageManager } from "../../message-manager" import { forceFullModelDetailsLoad, hasLoadedFullDetails } from "../../../api/providers/fetchers/lmstudio" import { ShadowCheckpointService } from "../../../services/checkpoints/ShadowCheckpointService" +import { openAiCodexOAuthManager } from "../../../integrations/openai-codex/oauth" const { mockAddCustomInstructions, mockTaskConstructor } = vi.hoisted(() => ({ mockAddCustomInstructions: vi.fn().mockResolvedValue("Combined instructions"), @@ -240,6 +241,7 @@ vi.mock("../../../integrations/openai-codex/oauth", () => ({ openAiCodexOAuthManager: { getAccessToken: vi.fn(), getAccountId: vi.fn(), + isAuthenticated: vi.fn().mockResolvedValue(false), }, })) @@ -895,7 +897,9 @@ describe("ClineProvider", () => { }) describe("transcript transport", () => { - const setCurrentTask = (task: { taskId: string; clineMessages: ClineMessage[] } | undefined) => { + const setCurrentTask = ( + task: { taskId: string; instanceId?: string; clineMessages: ClineMessage[] } | undefined, + ) => { vi.spyOn(provider, "getCurrentTask").mockImplementation(() => task as Task | undefined) } const setSequence = (taskId: string, seq: number) => { @@ -917,21 +921,154 @@ describe("ClineProvider", () => { return { active, release } } - test("preserves legacy transcript messages for CLI consumers", async () => { + test.each(["0", "1"])( + "rejects stale and unscoped producers before cloning in CLI runtime %s", + async (runtime) => { + vi.stubEnv("ROO_CLI_RUNTIME", runtime) + try { + const readText = vi.fn(() => "must not be cloned") + const message: ClineMessage = { + ts: 1, + type: "say", + get text() { + return readText() + }, + } + setCurrentTask({ taskId: "task-1", instanceId: "new", clineMessages: [message] }) + const post = vi.spyOn(provider, "postMessageToWebview") + const state = vi.spyOn(provider, "postStateToWebviewWithoutTaskHistory") + const transport = provider["clineMessagesTransport"] + const before = transport["state"] + for (const taskInstanceId of ["old", undefined]) { + await provider.postClineMessageAppended("task-1", message, taskInstanceId) + await provider.postClineMessageUpdated("task-1", message, taskInstanceId) + await provider.postClineMessagesSnapshot("task-1", { + taskInstanceId, + generation: transport.generation, + bumpSeq: true, + }) + } + expect(readText).not.toHaveBeenCalled() + expect(post).not.toHaveBeenCalled() + expect(state).not.toHaveBeenCalled() + expect(transport["state"]).toBe(before) + } finally { + vi.unstubAllEnvs() + } + }, + ) + + test.each([ + ["postStateToWebview", "0"], + ["postStateToWebviewWithoutTaskHistory", "0"], + ["postStateToWebview", "1"], + ["postStateToWebviewWithoutTaskHistory", "1"], + ] as const)("drops stale asynchronous %s metadata in CLI runtime %s", async (method, runtime) => { + vi.stubEnv("ROO_CLI_RUNTIME", runtime) + provider["view"] = mockWebviewView + const oldTask = { + taskId: "task-1", + instanceId: "old", + clineMessages: [{ ts: 1, type: "say" as const, text: "old" }], + } + const replacement = { ...oldTask, instanceId: "new", clineMessages: [] } + setCurrentTask(oldTask) + let release!: (value: boolean) => void + let started!: () => void + const held = new Promise((resolve) => { + release = resolve + }) + const metadataCaptured = new Promise((resolve) => { + started = resolve + }) + vi.mocked(openAiCodexOAuthManager.isAuthenticated).mockImplementationOnce(() => { + started() + return held + }) + const stalePost = provider[method]() + try { + await metadataCaptured + setCurrentTask(replacement) + await provider.syncFocusedTaskToWebview() + const beforeRelease = mockPostMessage.mock.calls.length + expect(mockPostMessage).toHaveBeenCalledWith({ + type: "clineMessagesFocus", + taskId: "task-1", + taskInstanceId: "new", + }) + release(false) + await stalePost + expect(mockPostMessage.mock.calls).toHaveLength(beforeRelease) + const currentState = await provider.getStateToPostToWebview() + expect(currentState.currentTaskInstanceId).toBe("new") + setCurrentTask(undefined) + await provider[method]() + expect(mockPostMessage).toHaveBeenLastCalledWith( + expect.objectContaining({ + type: "state", + state: expect.objectContaining({ currentTaskId: null, currentTaskInstanceId: null }), + }), + ) + // Unscoped metadata must still be deliverable, not stamped with new focus. + await provider.postMessageToWebview({ type: "state", state: { version: "metadata only" } }) + expect(mockPostMessage).toHaveBeenLastCalledWith({ type: "state", state: { version: "metadata only" } }) + } finally { + release(false) + await stalePost + vi.unstubAllEnvs() + } + }) + + test("publishes focus before generic metadata assembly can yield", async () => { + setCurrentTask({ taskId: "task-1", instanceId: "new", clineMessages: [] }) + const post = vi.spyOn(provider, "postMessageToWebview").mockResolvedValue(undefined) + vi.spyOn(provider, "postStateToWebviewWithoutTaskHistory").mockImplementation(async () => { + expect(post.mock.calls).toEqual([ + [ + { + type: "clineMessagesFocus", + taskId: "task-1", + taskInstanceId: "new", + }, + ], + ]) + }) + await provider.syncFocusedTaskToWebview() + expect(post.mock.calls.slice(1).map(([frame]) => frame.taskInstanceId)).toEqual(["new", "new"]) + }) + + test.each([ + { currentTaskId: "old-task" }, + { currentTaskId: null }, + { currentTaskInstanceId: "old-instance" }, + { currentTaskInstanceId: null }, + ])("rejects obsolete explicit generic scope %j", async (state) => { + provider["view"] = mockWebviewView + setCurrentTask({ taskId: "task-1", instanceId: "current-instance", clineMessages: [] }) + await provider.postMessageToWebview({ type: "state", state }) + expect(mockPostMessage).not.toHaveBeenCalled() + }) + + test.each([undefined, "cli-instance"])("preserves legacy CLI messages with instance %s", async (instanceId) => { await provider.resolveWebviewView(mockWebviewView) const previousCliRuntime = process.env.ROO_CLI_RUNTIME process.env.ROO_CLI_RUNTIME = "1" try { const task = { taskId: "task-1", + instanceId, 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 }) + await provider.postClineMessageAppended("task-1", task.clineMessages[0], instanceId) + await provider.postClineMessageUpdated( + "task-1", + { ...task.clineMessages[0], text: "updated" }, + instanceId, + ) + await provider.postClineMessagesSnapshot("task-1", { bumpSeq: true, taskInstanceId: instanceId }) expect(mockPostMessage).toHaveBeenNthCalledWith( 1, @@ -1753,7 +1890,7 @@ describe("ClineProvider", () => { }) test("abandons an older focus sync when a resync invalidates its state post", async () => { - const task = { taskId: "task-1", clineMessages: [] as ClineMessage[] } + const task = { taskId: "task-1", instanceId: "instance-1", clineMessages: [] as ClineMessage[] } setCurrentTask(task) let releaseStatePost!: () => void const statePostStarted = new Promise((resolve) => { @@ -1766,15 +1903,20 @@ describe("ClineProvider", () => { ) }) const snapshotSpy = vi.spyOn(provider, "postClineMessagesSnapshot") + const previousGeneration = provider["clineMessagesTransport"].generation const focusSync = provider.syncFocusedTaskToWebview() await statePostStarted + expect(snapshotSpy).not.toHaveBeenCalled() + expect(provider["clineMessagesTransport"].generation).toBe(previousGeneration + 1) const resync = provider.resyncClineMessagesToWebview("task-1") + const winningOptions = { generation: previousGeneration + 2, taskInstanceId: task.instanceId } + expect(snapshotSpy).toHaveBeenCalledExactlyOnceWith("task-1", winningOptions) releaseStatePost() await Promise.all([focusSync, resync]) expect(snapshotSpy).toHaveBeenCalledOnce() - expect(snapshotSpy).toHaveBeenCalledWith("task-1", { generation: expect.any(Number) }) + expect(snapshotSpy).toHaveBeenCalledWith("task-1", winningOptions) }) test("passes the new transport generation into a focused-task snapshot", async () => { diff --git a/src/core/webview/__tests__/transcriptTransport.model.ts b/src/core/webview/__tests__/transcriptTransport.model.ts index b554f8bd2f..424045025f 100644 --- a/src/core/webview/__tests__/transcriptTransport.model.ts +++ b/src/core/webview/__tests__/transcriptTransport.model.ts @@ -1,3 +1,4 @@ +import type { ExtensionMessage } from "@roo-code/types" import { createTranscriptTransportState, reduceTranscriptTransport, @@ -19,10 +20,29 @@ type Intent = | "clear" | "focus" | "stale-snapshot" -type Capture = { job: TranscriptJob; scope: string; values: number[]; failed: boolean } + | "replace-instance" + | "sync-instance" + | "stale-instance-append" + | "stale-instance-snapshot" + | "empty-append" + | "empty-update" + | "empty-snapshot" +type Capture = { + job: TranscriptJob + taskInstanceId: string | undefined + scope: string + values: number[] + failed: boolean +} type ModelState = { transport: TranscriptTransportState focus: TaskId | undefined + focusInstance: string | undefined + instanceSyncPending: boolean + staleInstanceRejected: boolean + discardedInstanceJobs: string[] + rejectedInstancePhases: string[] + appliedInstanceDeltas: string[] producer: number controller: number failures: number @@ -32,6 +52,7 @@ type ModelState = { payloads: number[] callers: number[] physical?: TranscriptFrame + physicalMessage?: ExtensionMessage allocated: Record sent: Record visible: number[] @@ -45,7 +66,12 @@ type Scenario = { name: string; producer: Intent[]; controller: Intent[] } type Event = { name: string; actor?: "producer" | "controller"; intent?: Intent; action?: TranscriptAction } type Node = { state: ModelState; parent: number; event: string; depth: number } type Reducer = typeof reduceTranscriptTransport -type Mutation = { name: string; expected: string; reduce: Reducer } +type ReceiverScope = Pick +type Faults = { + wire?: typeof transcriptFrameMessage + accepts?: (message: ExtensionMessage, scope: ReceiverScope) => boolean +} +type Mutation = Faults & { name: string; expected: string; reduce?: Reducer } export const TRANSPORT_MODEL_BOUNDS = { depth: 40, states: 30_000, chunkSize: 2, failures: 1 } as const export const TRANSPORT_SCENARIOS: Scenario[] = [ @@ -65,6 +91,21 @@ export const TRANSPORT_SCENARIOS: Scenario[] = [ producer: ["snapshot", "append", "update"], controller: ["focus", "resync", "stale-snapshot"], }, + { + name: "same-task-instance-snapshot-before-sync", + producer: ["snapshot", "stale-instance-append"], + controller: ["replace-instance", "sync-instance", "append", "update"], + }, + { + name: "same-task-instance-deltas-before-sync", + producer: ["append", "update", "stale-instance-snapshot"], + controller: ["replace-instance", "sync-instance"], + }, + { + name: "empty-deltas-before-valid-work", + producer: ["empty-append", "empty-update", "append", "empty-snapshot"], + controller: [], + }, ] export const TRANSPORT_ACTIONS = [ "snapshot", @@ -76,6 +117,13 @@ export const TRANSPORT_ACTIONS = [ "clear", "focus", "stale-snapshot", + "replace-instance", + "sync-instance", + "stale-instance-append", + "stale-instance-snapshot", + "empty-append", + "empty-update", + "empty-snapshot", "pump", "start", "chunk", @@ -108,12 +156,37 @@ export const TRANSPORT_LANDMARKS = { s.committed.some((id) => s.captures.some((c) => c.failed && c.job.id < id)), "delta-applied-after-snapshot": (s: ModelState) => s.committed.length > 0 && s.appliedSeq > s.captures[s.committed.at(-1)! - 1].job.seq, + "same-task-instance-published-before-sync": (s: ModelState) => + s.focus === "a" && s.focusInstance === "a:1" && s.instanceSyncPending && s.transport.generation === 0, + "instance-replacement-with-held-send": (s: ModelState) => + s.focusInstance === "a:1" && s.physical?.job.taskInstanceId === "a:0", + "stale-instance-current-generation-rejected": (s: ModelState) => s.staleInstanceRejected, + "stale-instance-queued-job-discarded": (s: ModelState) => s.discardedInstanceJobs.includes("queued"), + "stale-instance-active-suffix-discarded": (s: ModelState) => s.discardedInstanceJobs.includes("active"), + "old-instance-end-ignored": (s: ModelState) => s.rejectedInstancePhases.includes("end"), + "old-instance-append-ignored": (s: ModelState) => s.rejectedInstancePhases.includes("append"), + "old-instance-update-ignored": (s: ModelState) => s.rejectedInstancePhases.includes("update"), + "new-instance-snapshot-committed": (s: ModelState) => + s.committed.some((id) => s.captures[id - 1].job.taskInstanceId === "a:1"), + "new-instance-append-applied": (s: ModelState) => s.appliedInstanceDeltas.includes("append"), + "new-instance-update-applied": (s: ModelState) => s.appliedInstanceDeltas.includes("update"), + "new-instance-recovers-after-old-end-rejected": (s: ModelState) => + s.rejectedInstancePhases.includes("end") && + s.committed.some((id) => s.captures[id - 1].job.taskInstanceId === "a:1") && + s.appliedInstanceDeltas.includes("append") && + s.appliedInstanceDeltas.includes("update"), } satisfies Record boolean> function initialState(): ModelState { return { transport: createTranscriptTransportState(TRANSPORT_MODEL_BOUNDS.chunkSize), focus: "a", + focusInstance: "a:0", + instanceSyncPending: false, + staleInstanceRejected: false, + discardedInstanceJobs: [], + rejectedInstancePhases: [], + appliedInstanceDeltas: [], producer: 0, controller: 0, failures: 0, @@ -136,10 +209,15 @@ function enabled(s: ModelState, scenario: Scenario): Event[] { const events: Event[] = [] for (const actor of ["producer", "controller"] as const) { const intent = scenario[actor][s[actor]] + // A delayed old producer resumes only after the replacement has been published. + if (intent?.startsWith("stale-instance-") && s.focusInstance !== "a:1") continue if (intent) events.push({ name: `${actor}:${intent}`, actor, intent }) } if (!s.transport.inFlight && (s.transport.active || s.transport.queue.length)) { - events.push({ name: "pump", action: { type: "pump", focusedTaskId: s.focus } }) + events.push({ + name: "pump", + action: { type: "pump", focusedTaskId: s.focus, focusedTaskInstanceId: s.focusInstance }, + }) } if (s.transport.inFlight) { events.push({ name: "settle", action: { type: "settle", success: true } }) @@ -153,19 +231,28 @@ function requireInvariant(condition: unknown, message: string): asserts conditio if (!condition) throw new Error(message) } -/** Independent receiver oracle. It sees physical deliveries, not private generation tokens. */ -function deliver(s: ModelState, frame: TranscriptFrame): void { +function acceptsFocusedMessage(message: ExtensionMessage, scope: ReceiverScope): boolean { + return message.taskId === scope.focus && message.taskInstanceId === scope.focusInstance +} + +/** Independent receiver oracle. It sees captured wire identity, not private generation tokens. */ +function deliver(s: ModelState, frame: TranscriptFrame, message: ExtensionMessage, faults: Faults): void { const { job, phase } = frame - if (job.taskId !== s.focus) return + const accepted = (faults.accepts ?? acceptsFocusedMessage)(message, s) + const current = message.taskId === s.focus && message.taskInstanceId === s.focusInstance + requireInvariant(!accepted || current, "receiver accepted a stale-instance frame") + if (!accepted) { + if (message.taskId === s.focus && message.taskInstanceId !== s.focusInstance) { + s.rejectedInstancePhases = [...new Set([...s.rejectedInstancePhases, phase])].sort() + } + return + } const capture = s.captures[job.id - 1] const oldVisible = [...s.visible] const oldSeq = s.appliedSeq - const message = transcriptFrameMessage( - frame, - capture.values.map((value) => ({ ts: value, type: "say", text: String(value) })), - ) + const seq = message.clineMessagesSeq ?? 0 if (phase === "start") { - if (job.seq >= s.appliedSeq) s.staging = { id: job.id, values: [] } + if (seq >= s.appliedSeq) s.staging = { id: job.id, values: [] } } else if (phase === "chunk") { if (s.staging?.id === job.id) { requireInvariant(message.snapshotStartIndex === s.staging.values.length, "non-contiguous snapshot chunk") @@ -177,16 +264,20 @@ function deliver(s: ModelState, frame: TranscriptFrame): void { JSON.stringify(s.staging.values) === JSON.stringify(capture.values), "snapshot commit before complete chunks", ) - if (job.seq >= s.appliedSeq) { + if (seq >= s.appliedSeq) { s.visible = s.staging.values - s.appliedSeq = job.seq + s.appliedSeq = seq s.committed.push(job.id) } s.staging = undefined - } else if (!s.staging && job.seq === s.appliedSeq + 1) { - if (phase === "append") s.visible.push(capture.values[0]) - else if (s.visible.length) s.visible[0] = capture.values[0] - s.appliedSeq = job.seq + } else if (!s.staging && seq === s.appliedSeq + 1) { + requireInvariant(message.clineMessage, "delta lacks a wire payload") + if (phase === "append") s.visible.push(message.clineMessage.ts) + else if (s.visible.length) s.visible[0] = message.clineMessage.ts + s.appliedSeq = seq + if (message.taskInstanceId === "a:1") { + s.appliedInstanceDeltas = [...new Set([...s.appliedInstanceDeltas, phase])].sort() + } } if (phase === "start" || phase === "chunk") { requireInvariant( @@ -207,16 +298,16 @@ class ModelViolation extends Error { } } -function step(source: ModelState, event: Event, reducer: Reducer, coverage: Set): ModelState { +function step(source: ModelState, event: Event, reducer: Reducer, coverage: Set, faults: Faults): ModelState { const s = structuredClone(source) try { - return executeStep(s, event, reducer, coverage) + return executeStep(s, event, reducer, coverage, faults) } catch (error) { throw new ModelViolation(error instanceof Error ? error.message : String(error), s) } } -function executeStep(s: ModelState, event: Event, reducer: Reducer, coverage: Set): ModelState { +function executeStep(s: ModelState, event: Event, reducer: Reducer, coverage: Set, faults: Faults): ModelState { const apply = (action: TranscriptAction, values: number[] = []) => { const before = s.transport const transition = reducer(before, action) @@ -225,6 +316,21 @@ function executeStep(s: ModelState, event: Event, reducer: Reducer, coverage: Se s.transport.generation === before.generation + (action.type === "invalidate" ? 1 : 0), "generation is not monotonic", ) + if (action.type === "enqueue") { + const unchanged = + !transition.accepted && + s.transport === before && + !transition.post && + transition.release.length === 0 && + transition.settle.length === 0 + if (action.request.kind !== "snapshot" && action.total === 0) { + requireInvariant(unchanged, "empty delta allocated work") + } + if (action.request.taskInstanceId !== action.focusedTaskInstanceId) { + requireInvariant(unchanged, "stale-instance request allocated work") + if (action.request.generation === before.generation) s.staleInstanceRejected = true + } + } if ( action.type === "enqueue" && action.request.generation !== undefined && @@ -239,6 +345,10 @@ function executeStep(s: ModelState, event: Event, reducer: Reducer, coverage: Se } if (transition.accepted) { const job = transition.accepted + requireInvariant( + action.type === "enqueue" && job.taskInstanceId === action.request.taskInstanceId, + "descriptor lost originating instance identity", + ) const scope = job.taskId ? `${job.taskId}:${s.epochs[job.taskId as TaskId]}` : "none" const previousSeq = s.allocated[scope] ?? 0 const expectedSeq = @@ -261,11 +371,26 @@ function executeStep(s: ModelState, event: Event, reducer: Reducer, coverage: Se requireInvariant(!("snapshotId" in job), "delta carries snapshot metadata") } s.allocated[scope] = job.seq - s.captures.push({ job, scope, values: [...values], failed: false }) + s.captures.push({ + job, + taskInstanceId: action.request.taskInstanceId, + scope, + values: [...values], + failed: false, + }) s.payloads.push(job.id) s.callers.push(job.id) } - for (const id of transition.release) s.payloads = s.payloads.filter((value) => value !== id) + for (const id of transition.release) { + if (action.type === "pump") { + const job = s.captures[id - 1].job + if (job.taskId === s.focus && job.taskInstanceId !== s.focusInstance) { + const location = before.active?.job.id === id ? "active" : "queued" + s.discardedInstanceJobs = [...new Set([...s.discardedInstanceJobs, location])].sort() + } + } + s.payloads = s.payloads.filter((value) => value !== id) + } for (const { id } of transition.settle) { requireInvariant(s.callers.includes(id), "settlement lacks a registered caller") s.callers = s.callers.filter((value) => value !== id) @@ -288,15 +413,23 @@ function executeStep(s: ModelState, event: Event, reducer: Reducer, coverage: Se action.success && physical.phase === "end" && physical.job.generation < s.transport.generation && - physical.job.taskId === s.focus + physical.job.taskId === s.focus && + physical.job.taskInstanceId === s.focusInstance ) s.staleCommitCompletions++ - if (action.success) deliver(s, physical) - else { + if (action.success) { + requireInvariant(s.physicalMessage, "physical send lost its captured wire message") + requireInvariant( + s.physicalMessage.taskInstanceId === s.captures[physical.job.id - 1].taskInstanceId, + "wire lost originating instance identity", + ) + deliver(s, physical, s.physicalMessage, faults) + } else { s.captures[physical.job.id - 1].failed = true s.failures++ } s.physical = undefined + s.physicalMessage = undefined } if (transition.post) { const frame = transition.post @@ -306,6 +439,19 @@ function executeStep(s: ModelState, event: Event, reducer: Reducer, coverage: Se capture.job.generation === s.transport.generation && frame.job.taskId === s.focus, "post or commit initiated after invalidation", ) + requireInvariant(capture.taskInstanceId === s.focusInstance, "post initiated for a stale instance") + requireInvariant( + frame.job.taskInstanceId === capture.taskInstanceId, + "frame lost originating instance identity", + ) + const message = (faults.wire ?? transcriptFrameMessage)( + frame, + capture.values.map((value) => ({ ts: value, type: "say", text: String(value) })), + ) + requireInvariant( + message.taskId === capture.job.taskId && message.taskInstanceId === capture.taskInstanceId, + "wire lost originating instance identity", + ) requireInvariant(!capture.failed, "failed snapshot continued posting") if (frame.phase === "chunk") { // Check the descriptor before wire slicing can clamp an overlarge count. @@ -329,6 +475,7 @@ function executeStep(s: ModelState, event: Event, reducer: Reducer, coverage: Se requireInvariant(s.payloads.includes(frame.job.id), "post without payload ownership") s.sent[capture.scope] = frame.job.seq s.physical = frame + s.physicalMessage = message coverage.add(frame.phase) } if ((action.type === "pump" || action.type === "invalidate") && transition.release.length) @@ -355,34 +502,56 @@ function executeStep(s: ModelState, event: Event, reducer: Reducer, coverage: Se s[event.actor]++ coverage.add(event.intent) const intent = event.intent - if (intent === "switch" || intent === "clear" || intent === "focus") { + if (intent === "switch" || intent === "clear" || intent === "focus" || intent === "replace-instance") { const previous = s.focus - s.focus = intent === "clear" ? undefined : "b" + s.focus = intent === "replace-instance" ? "a" : intent === "clear" ? undefined : "b" + s.focusInstance = intent === "replace-instance" ? "a:1" : s.focus ? `${s.focus}:0` : undefined + if (intent === "replace-instance") { + // Publication is synchronous; later sync/invalidation may not have resumed yet. + s.instanceSyncPending = true + s.data.a = [5] + } s.visible = [] s.appliedSeq = 0 s.staging = undefined - if (previous && intent !== "focus") { + if (previous && (intent === "switch" || intent === "clear")) { apply({ type: "forget-task", taskId: previous }) s.epochs[previous]++ } } - if (["switch", "clear", "invalidate", "resync"].includes(intent)) apply({ type: "invalidate" }) - if (intent !== "invalidate" && intent !== "focus") { - const kind = intent === "append" || intent === "update" ? intent : "snapshot" - if (s.focus && kind === "append") s.data[s.focus].push(4) - if (s.focus && kind === "update") s.data[s.focus][0] = 9 - const values = !s.focus ? [] : kind === "snapshot" ? s.data[s.focus] : kind === "append" ? [4] : [9] + if (["switch", "clear", "invalidate", "resync", "sync-instance"].includes(intent)) apply({ type: "invalidate" }) + if (intent === "sync-instance") s.instanceSyncPending = false + if (intent !== "invalidate" && intent !== "focus" && intent !== "replace-instance") { + const staleInstance = intent.startsWith("stale-instance-") + const empty = intent.startsWith("empty-") + const kind = intent.endsWith("append") ? "append" : intent.endsWith("update") ? "update" : "snapshot" + if (s.focus && !staleInstance && !empty) { + if (kind === "append") s.data[s.focus].push(4) + if (kind === "update") s.data[s.focus][0] = 9 + } + const values = + empty || !s.focus + ? [] + : staleInstance + ? [8] + : kind === "snapshot" + ? s.data[s.focus] + : kind === "append" + ? [4] + : [9] apply( { type: "enqueue", request: { kind, taskId: s.focus, + taskInstanceId: staleInstance ? "a:0" : s.focusInstance, bumpSeq: intent === "snapshot", - ...(intent === "stale-snapshot" ? { generation: s.transport.generation - 1 } : {}), + generation: s.transport.generation - (intent === "stale-snapshot" ? 1 : 0), }, total: values.length, focusedTaskId: s.focus, + focusedTaskInstanceId: s.focusInstance, }, values, ) @@ -399,6 +568,7 @@ export function exploreTranscriptTransport( scenario: Scenario, reducer: Reducer = reduceTranscriptTransport, bounds: { depth: number; states: number } = TRANSPORT_MODEL_BOUNDS, + faults: Faults = {}, ) { const nodes: Node[] = [{ state: initialState(), parent: -1, event: "initial", depth: 0 }] const visited = new Set([canonical(nodes[0].state)]) @@ -419,7 +589,7 @@ export function exploreTranscriptTransport( for (const event of enabled(node.state, scenario)) { let next: ModelState try { - next = step(node.state, event, reducer, actions) + next = step(node.state, event, reducer, actions, faults) } catch (error) { const witness = trace(index, event.name, error instanceof ModelViolation ? error.state : node.state) return { @@ -490,7 +660,11 @@ export const TRANSPORT_MUTATIONS: Mutation[] = [ reduceTranscriptTransport( state, action.type === "pump" - ? { ...action, focusedTaskId: state.active?.job.taskId ?? state.queue[0]?.taskId } + ? { + ...action, + focusedTaskId: state.active?.job.taskId ?? state.queue[0]?.taskId, + focusedTaskInstanceId: state.active?.job.taskInstanceId ?? state.queue[0]?.taskInstanceId, + } : action, ), }, @@ -588,6 +762,78 @@ export const TRANSPORT_MUTATIONS: Mutation[] = [ return result }, }, + { + name: "admit-empty-delta", + expected: "empty delta allocated work", + reduce: (state, action) => + reduceTranscriptTransport( + state, + action.type === "enqueue" && action.request.kind !== "snapshot" && action.total === 0 + ? { ...action, total: 1 } + : action, + ), + }, + { + name: "admit-stale-instance", + expected: "stale-instance request allocated work", + reduce: (state, action) => + reduceTranscriptTransport( + state, + action.type === "enqueue" + ? { ...action, focusedTaskInstanceId: action.request.taskInstanceId } + : action, + ), + }, + { + name: "ignore-instance-at-post", + expected: "post initiated for a stale instance", + reduce: (state, action) => + reduceTranscriptTransport( + state, + action.type === "pump" + ? { ...action, focusedTaskInstanceId: (state.active?.job ?? state.queue[0])?.taskInstanceId } + : action, + ), + }, + { + name: "drop-descriptor-instance", + expected: "descriptor lost originating instance identity", + reduce: (state, action) => { + const result = reduceTranscriptTransport(state, action) + if (result.accepted) { + const job = { ...result.accepted, taskInstanceId: undefined } + result.accepted = job + result.state = { ...result.state, queue: [...result.state.queue.slice(0, -1), job] } + } + return result + }, + }, + { + name: "drop-wire-instance", + expected: "wire lost originating instance identity", + wire: (frame, messages) => ({ ...transcriptFrameMessage(frame, messages), taskInstanceId: undefined }), + }, + { + name: "receiver-ignores-instance", + expected: "receiver accepted a stale-instance frame", + accepts: (message, scope) => message.taskId === scope.focus, + }, + { + name: "receiver-accepts-stale-end", + expected: "receiver accepted a stale-instance frame", + accepts: (message, scope) => + message.taskId === scope.focus && + (message.type === "clineMessagesSnapshotEnd" || message.taskInstanceId === scope.focusInstance), + }, + { + name: "receiver-accepts-stale-delta", + expected: "receiver accepted a stale-instance frame", + accepts: (message, scope) => + message.taskId === scope.focus && + (message.type === "clineMessageAppended" || + message.type === "clineMessageUpdated" || + message.taskInstanceId === scope.focusInstance), + }, ] export function checkTranscriptTransportModel() { @@ -609,7 +855,7 @@ export function checkTranscriptTransportModel() { const counterexamples = TRANSPORT_MUTATIONS.map((mutation) => { const failures = TRANSPORT_SCENARIOS.map((scenario) => ({ scenario: scenario.name, - ...exploreTranscriptTransport(scenario, mutation.reduce), + ...exploreTranscriptTransport(scenario, mutation.reduce, TRANSPORT_MODEL_BOUNDS, mutation), })).filter((result) => result.violation) const result = failures.sort((a, b) => a.witness!.length - b.witness!.length)[0] requireInvariant(result, `${mutation.name}: expected a counterexample`) diff --git a/src/core/webview/__tests__/transcriptTransport.spec.ts b/src/core/webview/__tests__/transcriptTransport.spec.ts index d78e8675bd..0a0a49c880 100644 --- a/src/core/webview/__tests__/transcriptTransport.spec.ts +++ b/src/core/webview/__tests__/transcriptTransport.spec.ts @@ -49,6 +49,84 @@ describe("transcript transport bounded model", () => { }) describe("transcript transport reducer", () => { + test.each(["append", "update"] as const)("rejects an empty %s without allocating protocol state", (kind) => { + const state = createTranscriptTransportState() + const focus = { focusedTaskId: "a", focusedTaskInstanceId: "instance-1" } + const request = { kind, taskId: "a", taskInstanceId: "instance-1" } + const rejected = reduceTranscriptTransport(state, { type: "enqueue", request, total: 0, ...focus }) + + expect(rejected).toEqual({ state, release: [], settle: [] }) + expect(rejected.state).toBe(state) + const valid = reduceTranscriptTransport(rejected.state, { type: "enqueue", request, total: 1, ...focus }) + expect(valid.accepted).toMatchObject({ id: 1, seq: 1, total: 1, taskInstanceId: "instance-1" }) + const snapshot = reduceTranscriptTransport(valid.state, { + type: "enqueue", + request: { ...request, kind: "snapshot" }, + total: 0, + ...focus, + }) + expect(snapshot.accepted).toMatchObject({ id: 2, seq: 1, total: 0, snapshotId: "a:1" }) + }) + + test.each(["append", "update", "snapshot"] as const)( + "rejects stale or missing instance ownership for %s admission", + (kind) => { + for (const [taskInstanceId, focusedTaskInstanceId] of [ + ["instance-1", "instance-2"], + [undefined, "instance-2"], + ["instance-1", undefined], + ]) { + const state = createTranscriptTransportState() + const transition = reduceTranscriptTransport(state, { + type: "enqueue", + request: { kind, taskId: "a", taskInstanceId, generation: state.generation }, + total: 1, + focusedTaskId: "a", + focusedTaskInstanceId, + }) + expect(transition).toEqual({ state, release: [], settle: [] }) + expect(transition.state).toBe(state) + } + }, + ) + + test.each([ + { kind: "append", completedFrames: 0 }, + { kind: "update", completedFrames: 0 }, + { kind: "snapshot", completedFrames: 0 }, + { kind: "snapshot", completedFrames: 1 }, + { kind: "snapshot", completedFrames: 2 }, + ] as const)( + "discards stale-instance $kind before frame $completedFrames without invalidation", + ({ kind, completedFrames }) => { + const oldFocus = { focusedTaskId: "a", focusedTaskInstanceId: "instance-1" } + const currentFocus = { focusedTaskId: "a", focusedTaskInstanceId: "instance-2" } + const admitted = reduceTranscriptTransport(createTranscriptTransportState(1), { + type: "enqueue", + request: { kind, taskId: "a", taskInstanceId: "instance-1" }, + total: 1, + ...oldFocus, + }) + let state = admitted.state + for (let index = 0; index < completedFrames; index++) { + state = reduceTranscriptTransport(state, { type: "pump", ...oldFocus }).state + state = reduceTranscriptTransport(state, { type: "settle", success: true }).state + } + const current = reduceTranscriptTransport(state, { + type: "enqueue", + request: { kind: "append", taskId: "a", taskInstanceId: "instance-2" }, + total: 1, + ...currentFocus, + }) + const transition = reduceTranscriptTransport(current.state, { type: "pump", ...currentFocus }) + expect(transition.release).toEqual([admitted.accepted!.id]) + expect(transition.settle).toEqual([{ id: admitted.accepted!.id }]) + expect(transition.post).toEqual({ job: current.accepted, phase: "append", start: 0, count: 0 }) + expect(transition.state.generation).toBe(0) + expect(transition.state.queue).toEqual([]) + }, + ) + test.each([true, false])("ignores settlement without a physical send (success=%s)", (success) => { const state = createTranscriptTransportState() const transition = reduceTranscriptTransport(state, { type: "settle", success }) @@ -140,6 +218,244 @@ describe("transcript transport reducer", () => { describe("transcript transport driver", () => { const message: ClineMessage = { ts: 1, type: "say", text: "initial", images: ["image"] } + test.each(["append", "update"] as const)( + "rejects empty %s before cloning or admission, then recovers", + async (kind) => { + const post = vi.fn<(frame: ExtensionMessage) => Promise>().mockResolvedValue(undefined) + const focus = vi.fn(() => "instance-1") + const transport = new TranscriptTransport(() => "a", post, vi.fn(), focus) + const state = transport["state"] + const clone = vi.spyOn(globalThis, "structuredClone") + const payloadSet = vi.spyOn(transport["payloads"], "set") + const callerSet = vi.spyOn(transport["callers"], "set") + // Reading generation would mean the driver has already allocated an admission request. + const readGeneration = vi.fn(() => transport.generation) + const request = { + kind, + taskId: "a", + taskInstanceId: "instance-1", + get generation() { + return readGeneration() + }, + } + try { + await transport.enqueue(request, []) + expect(clone).not.toHaveBeenCalled() + expect(readGeneration).not.toHaveBeenCalled() + expect(focus).not.toHaveBeenCalled() + expect(post).not.toHaveBeenCalled() + expect(payloadSet).not.toHaveBeenCalled() + expect(callerSet).not.toHaveBeenCalled() + expect(transport["state"]).toBe(state) + expect(state).toEqual(createTranscriptTransportState()) + expect(transport.getSequence("a")).toBe(0) + expect(transport["payloads"].size).toBe(0) + expect(transport["callers"].size).toBe(0) + + await transport.enqueue(request, [message]) + await transport.enqueue({ kind: "snapshot", taskId: "a", taskInstanceId: "instance-1" }, []) + expect(clone).toHaveBeenCalledTimes(2) + expect(payloadSet.mock.calls.map(([id]) => id)).toEqual([1, 2]) + expect(callerSet.mock.calls.map(([id]) => id)).toEqual([1, 2]) + expect(post.mock.calls.map(([frame]) => frame)).toEqual([ + { + type: kind === "append" ? "clineMessageAppended" : "clineMessageUpdated", + taskId: "a", + taskInstanceId: "instance-1", + clineMessagesSeq: 1, + clineMessage: message, + }, + { + type: "clineMessagesSnapshotStart", + taskId: "a", + taskInstanceId: "instance-1", + clineMessagesSeq: 1, + snapshotId: "a:1", + snapshotTotal: 0, + }, + { + type: "clineMessagesSnapshotEnd", + taskId: "a", + taskInstanceId: "instance-1", + clineMessagesSeq: 1, + snapshotId: "a:1", + snapshotTotal: 0, + }, + ]) + expect(transport["state"].nextJobId).toBe(2) + expect(transport["state"].nextSnapshotId).toBe(1) + expect(transport["payloads"].size).toBe(0) + expect(transport["callers"].size).toBe(0) + } finally { + clone.mockRestore() + payloadSet.mockRestore() + callerSet.mockRestore() + } + }, + ) + + test.each(["append", "update", "snapshot"] as const)( + "rejects stale or absent %s instance before cloning", + async (kind) => { + const post = vi.fn<(frame: ExtensionMessage) => Promise>().mockResolvedValue(undefined) + const transport = new TranscriptTransport( + () => "a", + post, + vi.fn(), + () => "instance-2", + ) + const state = transport["state"] + const clone = vi.spyOn(globalThis, "structuredClone") + try { + for (const taskInstanceId of ["instance-1", undefined]) { + await transport.enqueue({ kind, taskId: "a", taskInstanceId, generation: transport.generation }, [ + message, + ]) + } + expect(clone).not.toHaveBeenCalled() + expect(post).not.toHaveBeenCalled() + expect(transport["state"]).toBe(state) + expect(transport.getSequence("a")).toBe(0) + expect(transport["payloads"].size).toBe(0) + expect(transport["callers"].size).toBe(0) + } finally { + clone.mockRestore() + } + }, + ) + + test.each(["append", "update", "snapshot"] as const)( + "rechecks %s instance after cloning without adopting live focus", + async (kind) => { + let instance = "instance-1" + const post = vi.fn<(frame: ExtensionMessage) => Promise>().mockResolvedValue(undefined) + const transport = new TranscriptTransport( + () => "a", + post, + vi.fn(), + () => instance, + ) + const state = transport["state"] + const reentrant: ClineMessage = { + ts: 1, + type: "say", + get text() { + instance = "instance-2" + return "obsolete" + }, + } + await transport.enqueue({ kind, taskId: "a", taskInstanceId: instance }, [reentrant]) + expect(instance).toBe("instance-2") + expect(transport["state"]).toBe(state) + expect(post).not.toHaveBeenCalled() + expect(transport["payloads"].size).toBe(0) + expect(transport["callers"].size).toBe(0) + }, + ) + + test.each( + (["start", "chunk", "end", "append", "update"] as const).flatMap((phase) => + [true, false].flatMap((success) => [true, false].map((invalidate) => ({ phase, success, invalidate }))), + ), + )( + "retains held $phase identity and settles once across replacement (success=$success, invalidate=$invalidate)", + async ({ phase, success, invalidate }) => { + const types = { + start: "clineMessagesSnapshotStart", + chunk: "clineMessagesSnapshotChunk", + end: "clineMessagesSnapshotEnd", + append: "clineMessageAppended", + update: "clineMessageUpdated", + } as const + let instance = "instance-1" + let resolveHeld!: () => void + let rejectHeld!: (error: Error) => void + let notifyStarted!: () => void + const held = new Promise((resolve, reject) => { + resolveHeld = resolve + rejectHeld = reject + }) + const started = new Promise((resolve) => { + notifyStarted = resolve + }) + let physical = 0 + let maximumPhysical = 0 + const post = vi.fn(async (frame: ExtensionMessage) => { + physical++ + maximumPhysical = Math.max(maximumPhysical, physical) + try { + if (frame.taskInstanceId === "instance-1" && frame.type === types[phase]) { + notifyStarted() + await held + } + } finally { + physical-- + } + }) + const log = vi.fn() + const transport = new TranscriptTransport( + () => "a", + post, + log, + () => instance, + ) + const resolved = vi.fn() + const rejected = vi.fn() + const active = transport + .enqueue( + { + kind: phase === "append" || phase === "update" ? phase : "snapshot", + taskId: "a", + taskInstanceId: instance, + }, + [message], + ) + .then(resolved, rejected) + await started + const physicalFrame = transport["state"].inFlight! + const waitingResolved = vi.fn() + const waiting = transport + .enqueue({ kind: "update", taskId: "a", taskInstanceId: instance }, [message]) + .then(waitingResolved) + const before = post.mock.calls.length + instance = "instance-2" + if (invalidate) { + transport.invalidate() + transport.invalidate() + await waiting + expect(transport["payloads"].size).toBe(0) + expect([...transport["callers"].keys()]).toEqual([physicalFrame.job.id]) + } + const recovery = transport.enqueue({ kind: "snapshot", taskId: "a", taskInstanceId: instance }, [message]) + const delta = transport.enqueue({ kind: "append", taskId: "a", taskInstanceId: instance }, [message]) + expect(transport["state"].inFlight).toBe(physicalFrame) + expect(physicalFrame.job.taskInstanceId).toBe("instance-1") + expect(post).toHaveBeenCalledTimes(before) + expect(resolved).not.toHaveBeenCalled() + expect(rejected).not.toHaveBeenCalled() + const failure = new Error("old instance post failed") + if (success) resolveHeld() + else rejectHeld(failure) + await Promise.all([active, waiting, recovery, delta]) + expect(maximumPhysical).toBe(1) + expect(resolved).toHaveBeenCalledTimes(success ? 1 : 0) + expect(rejected.mock.calls).toEqual(success ? [] : [[failure]]) + expect(log.mock.calls).toEqual(success ? [] : [[failure]]) + expect(waitingResolved).toHaveBeenCalledOnce() + expect(post.mock.calls.slice(0, before).every(([frame]) => frame.taskInstanceId === "instance-1")).toBe( + true, + ) + expect(post.mock.calls.slice(before).map(([frame]) => [frame.type, frame.taskInstanceId])).toEqual([ + ["clineMessagesSnapshotStart", "instance-2"], + ["clineMessagesSnapshotChunk", "instance-2"], + ["clineMessagesSnapshotEnd", "instance-2"], + ["clineMessageAppended", "instance-2"], + ]) + expect(transport["callers"].size).toBe(0) + expect(transport["payloads"].size).toBe(0) + }, + ) + test.each([true, false])( "retains the sole held caller through repeated invalidation (success=%s)", async (success) => { diff --git a/src/core/webview/transcriptTransport.ts b/src/core/webview/transcriptTransport.ts index 6180d5aa12..f527e36bff 100644 --- a/src/core/webview/transcriptTransport.ts +++ b/src/core/webview/transcriptTransport.ts @@ -3,6 +3,7 @@ import type { ClineMessage, ExtensionMessage } from "@roo-code/types" export type TranscriptRequest = { kind: "append" | "update" | "snapshot" taskId: string | undefined + taskInstanceId?: string generation?: number bumpSeq?: boolean } @@ -11,6 +12,7 @@ export type TranscriptJob = { id: number generation: number taskId: string | undefined + taskInstanceId: string | undefined seq: number kind: TranscriptRequest["kind"] total: number @@ -39,10 +41,16 @@ export type TranscriptTransportState = { } export type TranscriptAction = - | { type: "enqueue"; request: TranscriptRequest; total: number; focusedTaskId: string | undefined } + | { + type: "enqueue" + request: TranscriptRequest + total: number + focusedTaskId: string | undefined + focusedTaskInstanceId?: string + } | { type: "invalidate" } | { type: "forget-task"; taskId: string } - | { type: "pump"; focusedTaskId: string | undefined } + | { type: "pump"; focusedTaskId: string | undefined; focusedTaskInstanceId?: string } | { type: "settle"; success: boolean } export type TranscriptTransition = { @@ -66,10 +74,12 @@ export function isTranscriptRequestCurrent( state: TranscriptTransportState, request: TranscriptRequest, focusedTaskId: string | undefined, + focusedTaskInstanceId?: string, ): boolean { return ( (request.generation ?? state.generation) === state.generation && request.taskId === focusedTaskId && + request.taskInstanceId === focusedTaskInstanceId && (request.kind === "snapshot" || request.taskId !== undefined) ) } @@ -86,8 +96,9 @@ export function reduceTranscriptTransport( } switch (action.type) { case "enqueue": { - const { request, total, focusedTaskId } = action - if (!isTranscriptRequestCurrent(state, request, focusedTaskId)) return result + const { request, total, focusedTaskId, focusedTaskInstanceId } = action + if (request.kind !== "snapshot" && total === 0) return result + if (!isTranscriptRequestCurrent(state, request, focusedTaskId, focusedTaskInstanceId)) return result const sequences = new Map(state.sequences) const seq = request.taskId ? (sequences.get(request.taskId) ?? 0) + (request.kind !== "snapshot" || request.bumpSeq ? 1 : 0) @@ -98,6 +109,7 @@ export function reduceTranscriptTransport( id: state.nextJobId + 1, generation: state.generation, taskId: request.taskId, + taskInstanceId: request.taskInstanceId, seq, kind: request.kind, total, @@ -126,7 +138,11 @@ export function reduceTranscriptTransport( while (active || queue.length) { active ??= { job: queue.shift()!, position: 0 } const { job, position } = active - if (job.generation !== state.generation || job.taskId !== action.focusedTaskId) { + if ( + job.generation !== state.generation || + job.taskId !== action.focusedTaskId || + job.taskInstanceId !== action.focusedTaskInstanceId + ) { discard(job) active = undefined continue @@ -168,7 +184,7 @@ export function reduceTranscriptTransport( export function transcriptFrameMessage(frame: TranscriptFrame, messages: readonly ClineMessage[]): ExtensionMessage { const { job, phase } = frame - const common = { taskId: job.taskId, clineMessagesSeq: job.seq } + const common = { taskId: job.taskId, taskInstanceId: job.taskInstanceId, clineMessagesSeq: job.seq } if (phase === "append" || phase === "update") { return { ...common, @@ -203,6 +219,7 @@ export class TranscriptTransport { private readonly focusedTaskId: () => string | undefined, private readonly postMessage: (message: ExtensionMessage) => Promise, private readonly onError: (error: unknown) => void, + private readonly focusedTaskInstanceId: () => string | undefined = () => undefined, ) {} get generation(): number { @@ -225,10 +242,15 @@ export class TranscriptTransport { } enqueue(request: TranscriptRequest, messages: readonly ClineMessage[]): Promise { + // An empty delta must not consume a sequence or enter admission at all. + if (request.kind !== "snapshot" && messages.length === 0) return Promise.resolve() // Guard before deep cloning (and allocating a sequence/ID). A delayed focus sync // must not traverse a large, already-obsolete transcript. const capturedRequest = { ...request, generation: request.generation ?? this.generation } - if (!isTranscriptRequestCurrent(this.state, capturedRequest, this.focusedTaskId())) return Promise.resolve() + if ( + !isTranscriptRequestCurrent(this.state, capturedRequest, this.focusedTaskId(), this.focusedTaskInstanceId()) + ) + return Promise.resolve() // Task mutates message objects AND nested fields while posts are queued. Capture // the complete value now, together with its sequence, not at physical-send time. const payload = structuredClone(messages) @@ -237,6 +259,7 @@ export class TranscriptTransport { request: capturedRequest, total: payload.length, focusedTaskId: this.focusedTaskId(), + focusedTaskInstanceId: this.focusedTaskInstanceId(), }) if (!accepted) return Promise.resolve() this.payloads.set(accepted.id, payload) @@ -261,7 +284,11 @@ export class TranscriptTransport { } private drain(): void { - const { post } = this.apply({ type: "pump", focusedTaskId: this.focusedTaskId() }) + const { post } = this.apply({ + type: "pump", + focusedTaskId: this.focusedTaskId(), + focusedTaskInstanceId: this.focusedTaskInstanceId(), + }) if (post) void this.send(post) } diff --git a/src/extension.ts b/src/extension.ts index 70c4b2d9b5..d09c3ebc78 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -191,7 +191,13 @@ export async function activate(context: vscode.ExtensionContext) { // Push the new vscode.env.isTelemetryEnabled value to the webview too, so its // own PostHog client (gated separately in TelemetryClient.ts) can't keep // sending events after the global toggle flips off mid-session. - void ClineProvider.getVisibleInstance()?.postStateToWebviewWithoutTaskHistory() + void ClineProvider.getVisibleInstance() + ?.postStateToWebviewWithoutTaskHistory() + .catch((error: unknown) => { + outputChannel.appendLine( + `[TelemetryService] Failed to refresh state after telemetry toggle: ${error instanceof Error ? error.message : String(error)}`, + ) + }) }), ) diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index 656890a31d..77ef2eb002 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -279,10 +279,12 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode initialState?: ExtensionStateProviderInitialState }> = ({ children, initialState }) => { - const [state, setState] = useState(() => - mergeExtensionState(createInitialExtensionState(), initialState ?? {}), - ) + const [state, setState] = useState(() => { + const initial = mergeExtensionState(createInitialExtensionState(), initialState ?? {}) + return initial.currentTaskId === null ? { ...initial, currentTaskInstanceId: null } : initial + }) const activeTaskIdRef = useRef(state.currentTaskId ?? undefined) + const activeTaskInstanceIdRef = useRef(state.currentTaskInstanceId ?? undefined) const clineMessagesSeqRef = useRef(state.clineMessagesSeq ?? 0) const clineMessagesRef = useRef(state.clineMessages) const clineMessagesIndexRef = useRef | null>(null) @@ -421,7 +423,10 @@ export const ExtensionStateContextProvider: React.FC<{ (message: ExtensionMessage, operation: "append" | "update") => { const seq = message.clineMessagesSeq as number const clineMessage = message.clineMessage - if (message.taskId !== activeTaskIdRef.current) { + if ( + message.taskId !== activeTaskIdRef.current || + message.taskInstanceId !== activeTaskInstanceIdRef.current + ) { return } if (!Number.isSafeInteger(seq) || seq < 0 || !clineMessage) { @@ -484,31 +489,56 @@ export const ExtensionStateContextProvider: React.FC<{ } const message: ExtensionMessage = event.data switch (message.type) { + case "clineMessagesFocus": case "state": { const { clineMessages: _ignoredMessages, clineMessagesSeq: _ignoredMessagesSeq, ...newState - } = message.state ?? {} + } = message.type === "clineMessagesFocus" + ? { + currentTaskId: message.taskId ?? null, + currentTaskInstanceId: message.taskInstanceId ?? null, + } + : (message.state ?? {}) const hasCurrentTaskId = Object.prototype.hasOwnProperty.call(newState, "currentTaskId") const nextTaskId = hasCurrentTaskId ? (newState.currentTaskId ?? undefined) : activeTaskIdRef.current const taskChanged = hasCurrentTaskId && nextTaskId !== activeTaskIdRef.current const taskCleared = hasCurrentTaskId && newState.currentTaskId === null - if (taskChanged || taskCleared) { + const nextTaskInstanceId = taskCleared + ? undefined + : newState.currentTaskInstanceId !== undefined + ? (newState.currentTaskInstanceId ?? undefined) + : taskChanged + ? undefined + : activeTaskInstanceIdRef.current + const focusChanged = taskChanged || nextTaskInstanceId !== activeTaskInstanceIdRef.current + if (focusChanged || taskCleared) { + // Update both refs before React renders so queued frames cannot use the old scope. activeTaskIdRef.current = nextTaskId + activeTaskInstanceIdRef.current = nextTaskInstanceId clineMessagesSeqRef.current = 0 replaceClineMessages([]) clearClineMessagesSnapshot() clearClineMessagesResync() } setState((prevState) => { - const merged = mergeExtensionState(prevState, newState) + const merged = mergeExtensionState(prevState, { + ...newState, + currentTaskInstanceId: + newState.currentTaskInstanceId !== undefined + ? newState.currentTaskInstanceId + : taskChanged + ? undefined + : prevState.currentTaskInstanceId, + }) if (taskCleared) { return { ...merged, currentTaskId: null, + currentTaskInstanceId: null, currentTaskItem: undefined, currentTaskTodos: [], messageQueue: [], @@ -516,11 +546,14 @@ export const ExtensionStateContextProvider: React.FC<{ clineMessagesSeq: 0, } } - return taskChanged ? { ...merged, clineMessages: [], clineMessagesSeq: 0 } : merged + return focusChanged ? { ...merged, clineMessages: [], clineMessagesSeq: 0 } : merged }) if (taskCleared) { setCurrentCheckpoint(undefined) } + // Early scope publication is not settings hydration. In particular, it must + // not reopen setup and unmount the chat while generic metadata is pending. + if (message.type === "clineMessagesFocus") break setShowWelcome(!checkExistKey(newState.apiConfiguration, newState.zooCodeIsAuthenticated)) setDidHydrateState(true) // Update alwaysAllowFollowupQuestions if present in state message @@ -583,7 +616,10 @@ export const ExtensionStateContextProvider: React.FC<{ break } case "clineMessagesSnapshotStart": { - if (message.taskId !== activeTaskIdRef.current) { + if ( + message.taskId !== activeTaskIdRef.current || + message.taskInstanceId !== activeTaskInstanceIdRef.current + ) { break } @@ -623,7 +659,10 @@ export const ExtensionStateContextProvider: React.FC<{ break } case "clineMessagesSnapshotChunk": { - if (message.taskId !== activeTaskIdRef.current) { + if ( + message.taskId !== activeTaskIdRef.current || + message.taskInstanceId !== activeTaskInstanceIdRef.current + ) { break } @@ -666,7 +705,10 @@ export const ExtensionStateContextProvider: React.FC<{ break } case "clineMessagesSnapshotEnd": { - if (message.taskId !== activeTaskIdRef.current) { + if ( + message.taskId !== activeTaskIdRef.current || + message.taskInstanceId !== activeTaskInstanceIdRef.current + ) { break } diff --git a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx index fe10982246..1ef27ba720 100644 --- a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx +++ b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx @@ -1,5 +1,13 @@ import { providerIdentifiers } from "@roo-code/types" -import { render, renderHook, screen, act, appendClineMessage, hydrateExtensionState } from "@/utils/test-utils" +import { + render, + renderHook, + screen, + act, + appendClineMessage, + dispatchExtensionMessage, + hydrateExtensionState, +} from "@/utils/test-utils" import React from "react" import { @@ -18,10 +26,6 @@ import { 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 = () => { @@ -116,6 +120,7 @@ const InitialStateTestComponent = () => { const TranscriptTestComponent = () => { const { currentTaskId, + currentTaskInstanceId, currentTaskItem, currentTaskTodos, messageQueue, @@ -128,6 +133,7 @@ const TranscriptTestComponent = () => {
{JSON.stringify({ currentTaskId: currentTaskId ?? null, + currentTaskInstanceId, currentTaskItem: currentTaskItem ?? null, currentTaskTodos: currentTaskTodos ?? [], messageQueue: messageQueue ?? [], @@ -440,6 +446,10 @@ describe("ExtensionStateContext", () => { const { currentTaskId, clineMessages, clineMessagesSeq } = readTranscript() return { currentTaskId, clineMessages, clineMessagesSeq } } + const readScopedTranscriptFields = () => ({ + ...readTranscriptFields(), + currentTaskInstanceId: readTranscript().currentTaskInstanceId, + }) const renderTranscript = (initialState: Partial = {}) => render( @@ -490,6 +500,531 @@ describe("ExtensionStateContext", () => { vi.useRealTimers() }) + describe("instance-scoped focus", () => { + beforeEach(() => vi.useFakeTimers()) + + it("does not reopen setup or replace settings when early focus is published", () => { + const { result } = renderHook(() => useExtensionState(), { + wrapper: ({ children }) => ( + {children} + ), + }) + const apiConfiguration: ProviderSettings = { apiProvider: providerIdentifiers.fakeAi } + act(() => + dispatchExtensionMessage({ + type: "state", + state: { + apiConfiguration, + currentTaskId: "task-1", + currentTaskInstanceId: "old", + soundEnabled: true, + }, + }), + ) + expect(result.current.showWelcome).toBe(false) + expect(result.current.didHydrateState).toBe(true) + act(() => + dispatchExtensionMessage({ + type: "clineMessagesFocus", + taskId: "task-1", + taskInstanceId: "new", + }), + ) + expect(result.current.currentTaskInstanceId).toBe("new") + expect(result.current.apiConfiguration).toBe(apiConfiguration) + expect(result.current.soundEnabled).toBe(true) + expect(result.current.showWelcome).toBe(false) + }) + + it("does not mark initial settings hydrated on early focus publication", () => { + const { result } = renderHook(() => useExtensionState(), { + wrapper: ({ children }) => ( + {children} + ), + }) + expect(result.current.didHydrateState).toBe(false) + act(() => + dispatchExtensionMessage({ + type: "clineMessagesFocus", + taskId: "task-1", + taskInstanceId: "new", + }), + ) + expect(result.current.currentTaskInstanceId).toBe("new") + expect(result.current.didHydrateState).toBe(false) + }) + + it("preserves hydrated settings across focus clear and repeated publication", () => { + const { result } = renderHook(() => useExtensionState(), { + wrapper: ({ children }) => ( + {children} + ), + }) + act(() => + dispatchExtensionMessage({ + type: "state", + state: { + apiConfiguration: { apiProvider: providerIdentifiers.fakeAi }, + currentTaskId: "task-1", + currentTaskInstanceId: "old", + soundEnabled: true, + }, + }), + ) + act(() => dispatchExtensionMessage({ type: "clineMessagesFocus" })) + expect(result.current.currentTaskId).toBeNull() + expect(result.current.currentTaskInstanceId).toBeNull() + expect(result.current.clineMessages).toEqual([]) + expect(result.current.clineMessagesSeq).toBe(0) + expect(result.current.showWelcome).toBe(false) + expect(result.current.didHydrateState).toBe(true) + expect(result.current.soundEnabled).toBe(true) + act(() => + dispatchExtensionMessage({ type: "clineMessagesFocus", taskId: "task-1", taskInstanceId: "new" }), + ) + act(() => + dispatchExtensionMessage({ + type: "clineMessageAppended", + taskId: "task-1", + taskInstanceId: "new", + clineMessagesSeq: 1, + clineMessage: makeMessage(1, "new"), + }), + ) + const messages = result.current.clineMessages + act(() => + dispatchExtensionMessage({ type: "clineMessagesFocus", taskId: "task-1", taskInstanceId: "new" }), + ) + expect(result.current.clineMessages).toBe(messages) + expect(result.current.clineMessagesSeq).toBe(1) + expect(result.current.showWelcome).toBe(false) + }) + + it("changes both focus refs before processing frames in the same event batch", () => { + const postMessage = renderTranscriptWithPostMessageSpy({ + currentTaskInstanceId: "instance-1", + clineMessages: [makeMessage(10, "old transcript")], + clineMessagesSeq: 8, + }) + const updated = makeMessage(20, "current update") + act(() => { + dispatchExtensionMessage({ + type: "clineMessagesFocus", + taskId: "task-1", + taskInstanceId: "instance-2", + }) + dispatchExtensionMessage({ + type: "clineMessageAppended", + taskId: "task-1", + taskInstanceId: "instance-1", + clineMessagesSeq: 1, + clineMessage: makeMessage(10, "stale append"), + }) + dispatchExtensionMessage({ + type: "clineMessageAppended", + taskId: "task-1", + taskInstanceId: "instance-2", + clineMessagesSeq: 1, + clineMessage: makeMessage(20, "current append"), + }) + dispatchExtensionMessage({ + type: "state", + state: { version: "2.0.0", clineMessages: [makeMessage(10, "stale metadata transcript")] }, + }) + dispatchExtensionMessage({ + type: "clineMessageUpdated", + taskId: "task-1", + taskInstanceId: "instance-2", + clineMessagesSeq: 2, + clineMessage: updated, + }) + }) + expect(readScopedTranscriptFields()).toEqual({ + currentTaskId: "task-1", + currentTaskInstanceId: "instance-2", + clineMessages: [updated], + clineMessagesSeq: 2, + }) + expect(postMessage.mock.calls).toEqual([]) + expect(vi.getTimerCount()).toBe(0) + }) + + it.each<{ + name: string + initialInstanceId?: string + state: Partial + }>([ + { + name: "same-task replacement", + initialInstanceId: "instance-1", + state: { currentTaskId: "task-1", currentTaskInstanceId: "instance-2" }, + }, + { + name: "first instance metadata after legacy initialization", + state: { currentTaskId: "task-1", currentTaskInstanceId: "instance-2" }, + }, + { + name: "instance-only replacement metadata", + initialInstanceId: "instance-1", + state: { currentTaskInstanceId: "instance-2" }, + }, + { + name: "explicit instance clear", + initialInstanceId: "instance-1", + state: { currentTaskInstanceId: null }, + }, + ])( + "resets messages, sequence, index, snapshot, and resync timers on $name", + ({ initialInstanceId, state }) => { + const postMessage = renderTranscriptWithPostMessageSpy({ + currentTaskInstanceId: initialInstanceId, + clineMessages: [ + makeMessage(10, "old first"), + makeMessage(20, "old middle"), + makeMessage(30, "old last"), + ], + clineMessagesSeq: 7, + }) + act(() => { + dispatchExtensionMessage({ + type: "clineMessageAppended", + taskId: "task-1", + taskInstanceId: initialInstanceId, + clineMessagesSeq: 9, + clineMessage: makeMessage(40, "old gap"), + }) + startSnapshot({ taskInstanceId: initialInstanceId, clineMessagesSeq: 10 }) + appendSnapshotChunk({ taskInstanceId: initialInstanceId, clineMessagesSeq: 10 }) + }) + expect(postMessage.mock.calls).toEqual([ + [{ type: "requestClineMessagesResync", taskId: "task-1", expectedSeq: 8, receivedSeq: 9 }], + ]) + expect(vi.getTimerCount()).toBe(2) + postMessage.mockClear() + + act(() => dispatchExtensionMessage({ type: "state", state })) + const cleared = { + currentTaskId: "task-1", + currentTaskInstanceId: state.currentTaskInstanceId, + clineMessages: [], + clineMessagesSeq: 0, + } + expect(readScopedTranscriptFields()).toEqual(cleared) + expect(vi.getTimerCount()).toBe(0) + act(() => vi.advanceTimersByTime(30_000)) + expect(postMessage.mock.calls).toEqual([]) + + // The old timestamp index must not turn this unknown update into an append. + const scope = { taskId: "task-1", taskInstanceId: state.currentTaskInstanceId ?? undefined } + act(() => + dispatchExtensionMessage({ + type: "clineMessageUpdated", + ...scope, + clineMessagesSeq: 1, + clineMessage: makeMessage(20, "removed timestamp"), + }), + ) + expect(readScopedTranscriptFields()).toEqual(cleared) + expect(postMessage.mock.calls).toEqual([ + [{ type: "requestClineMessagesResync", taskId: "task-1", expectedSeq: 1, receivedSeq: 1 }], + ]) + + const updated = makeMessage(30, "new position") + act(() => { + dispatchExtensionMessage({ + type: "clineMessageAppended", + ...scope, + clineMessagesSeq: 1, + clineMessage: makeMessage(30, "reused timestamp"), + }) + dispatchExtensionMessage({ + type: "clineMessageUpdated", + ...scope, + clineMessagesSeq: 2, + clineMessage: updated, + }) + }) + expect(readScopedTranscriptFields()).toEqual({ + ...cleared, + clineMessages: [updated], + clineMessagesSeq: 2, + }) + expect(postMessage.mock.calls).toEqual([ + [{ type: "requestClineMessagesResync", taskId: "task-1", expectedSeq: 1, receivedSeq: 1 }], + ]) + }, + ) + + const frameTypes = [ + "clineMessageAppended", + "clineMessageUpdated", + "clineMessagesSnapshotStart", + "clineMessagesSnapshotChunk", + "clineMessagesSnapshotEnd", + ] as const + const rejectedScopes = [ + { identity: "old instance", taskId: "task-1", taskInstanceId: "instance-1" }, + { identity: "missing instance", taskId: "task-1", taskInstanceId: undefined }, + { identity: "wrong task", taskId: "task-2", taskInstanceId: "instance-2" }, + { identity: "missing task", taskId: undefined, taskInstanceId: "instance-2" }, + ] + it.each( + frameTypes.flatMap((type) => + ["before", "during", "after"].flatMap((stage) => + rejectedScopes.map((scope) => ({ type, stage, ...scope })), + ), + ), + )("ignores $identity $type $stage the replacement snapshot", ({ type, stage, taskId, taskInstanceId }) => { + const postMessage = renderTranscriptWithPostMessageSpy({ + currentTaskInstanceId: "instance-1", + clineMessages: [makeMessage(10, "old transcript")], + clineMessagesSeq: 8, + }) + const replacement = makeMessage(20, "replacement") + const snapshot = { taskInstanceId: "instance-2", snapshotId: "replacement", clineMessagesSeq: 2 } + const buffered = stage === "during" && type === "clineMessagesSnapshotEnd" + act(() => { + dispatchExtensionMessage({ + type: "clineMessagesFocus", + taskId: "task-1", + taskInstanceId: "instance-2", + }) + if (stage !== "before") { + startSnapshot(snapshot) + if (stage === "after" || buffered) { + appendSnapshotChunk({ ...snapshot, clineMessages: [replacement] }) + } + if (stage === "after") { + endSnapshot(snapshot) + } + } + }) + const expected = { + currentTaskId: "task-1", + currentTaskInstanceId: "instance-2", + clineMessages: stage === "after" ? [replacement] : [], + clineMessagesSeq: stage === "after" ? 2 : 0, + } + expect(readScopedTranscriptFields()).toEqual(expected) + + // Chunks/end match the current transaction; starts/deltas are newer so + // a missing scope guard would poison it rather than merely look stale. + const matchesSnapshot = type === "clineMessagesSnapshotChunk" || type === "clineMessagesSnapshotEnd" + const clineMessagesSeq = stage === "before" ? 1 : stage === "during" && matchesSnapshot ? 2 : 3 + const stale = makeMessage(stage === "after" ? 20 : 10, "stale frame") + const frame: ExtensionMessage = + type === "clineMessageAppended" || type === "clineMessageUpdated" + ? { type, taskId, taskInstanceId, clineMessagesSeq, clineMessage: stale } + : { + type, + taskId, + taskInstanceId, + clineMessagesSeq, + snapshotId: "replacement", + ...(type === "clineMessagesSnapshotChunk" + ? { snapshotStartIndex: 0, clineMessages: [stale] } + : { snapshotTotal: 1 }), + } + act(() => dispatchExtensionMessage(frame)) + expect(readScopedTranscriptFields()).toEqual(expected) + expect(postMessage.mock.calls).toEqual([]) + expect(vi.getTimerCount()).toBe(stage === "during" ? 1 : 0) + + act(() => { + if (stage === "before") { + startSnapshot(snapshot) + } + if (stage !== "after") { + if (!buffered) { + appendSnapshotChunk({ ...snapshot, clineMessages: [replacement] }) + } + endSnapshot(snapshot) + } + }) + expect(readScopedTranscriptFields()).toEqual({ + ...expected, + clineMessages: [replacement], + clineMessagesSeq: 2, + }) + + const appended = makeMessage(30, "current append") + const updated = makeMessage(20, "current update") + act(() => { + dispatchExtensionMessage({ + type: "clineMessageAppended", + taskId: "task-1", + taskInstanceId: "instance-2", + clineMessagesSeq: 3, + clineMessage: appended, + }) + dispatchExtensionMessage({ + type: "clineMessageUpdated", + taskId: "task-1", + taskInstanceId: "instance-2", + clineMessagesSeq: 4, + clineMessage: updated, + }) + }) + expect(readScopedTranscriptFields()).toEqual({ + ...expected, + clineMessages: [updated, appended], + clineMessagesSeq: 4, + }) + expect(vi.getTimerCount()).toBe(0) + expect(postMessage.mock.calls).toEqual([]) + }) + + it.each>([ + { version: "2.0.0" }, + { currentTaskId: "task-1", version: "2.0.0" }, + { currentTaskId: "task-1", currentTaskInstanceId: undefined }, + { currentTaskId: "task-1", currentTaskInstanceId: "instance-1" }, + { currentTaskInstanceId: "instance-1" }, + ])("preserves the focus, transcript, pending snapshot, and resync through metadata %j", (state) => { + const existing = makeMessage(10, "existing") + const replacement = makeMessage(20, "replacement") + const postMessage = renderTranscriptWithPostMessageSpy({ + currentTaskInstanceId: "instance-1", + clineMessages: [existing], + clineMessagesSeq: 3, + }) + const snapshot = { taskInstanceId: "instance-1", clineMessagesSeq: 6 } + act(() => { + dispatchExtensionMessage({ + type: "clineMessageAppended", + taskId: "task-1", + taskInstanceId: "instance-1", + clineMessagesSeq: 5, + clineMessage: makeMessage(30, "gap"), + }) + startSnapshot(snapshot) + appendSnapshotChunk({ ...snapshot, clineMessages: [replacement] }) + }) + expect(vi.getTimerCount()).toBe(2) + const clearTimeout = vi.spyOn(window, "clearTimeout") + act(() => { + dispatchExtensionMessage({ + type: "state", + state: { + ...state, + clineMessages: [makeMessage(99, "ignored generic transcript")], + clineMessagesSeq: 99, + }, + }) + dispatchExtensionMessage({ type: "messageUpdated", clineMessagesSeq: 99 }) + }) + expect(readScopedTranscriptFields()).toEqual({ + currentTaskId: "task-1", + currentTaskInstanceId: "instance-1", + clineMessages: [existing], + clineMessagesSeq: 3, + }) + expect(clearTimeout.mock.calls).toEqual([]) + expect(vi.getTimerCount()).toBe(2) + expect(postMessage.mock.calls).toEqual([ + [{ type: "requestClineMessagesResync", taskId: "task-1", expectedSeq: 4, receivedSeq: 5 }], + ]) + + const updated = makeMessage(20, "updated replacement") + act(() => { + endSnapshot(snapshot) + dispatchExtensionMessage({ + type: "clineMessageUpdated", + taskId: "task-1", + taskInstanceId: "instance-1", + clineMessagesSeq: 7, + clineMessage: updated, + }) + }) + expect(readScopedTranscriptFields()).toEqual({ + currentTaskId: "task-1", + currentTaskInstanceId: "instance-1", + clineMessages: [updated], + clineMessagesSeq: 7, + }) + expect(vi.getTimerCount()).toBe(0) + expect(postMessage.mock.calls).toEqual([ + [{ type: "requestClineMessagesResync", taskId: "task-1", expectedSeq: 4, receivedSeq: 5 }], + ]) + }) + + it.each(["task-2", null])("clears an omitted instance on a switch to %s", (currentTaskId) => { + const postMessage = renderTranscriptWithPostMessageSpy({ + currentTaskInstanceId: "instance-1", + clineMessages: [makeMessage(10, "old")], + clineMessagesSeq: 8, + }) + act(() => dispatchExtensionMessage({ type: "state", state: { currentTaskId } })) + const cleared = { + currentTaskId, + currentTaskInstanceId: currentTaskId === null ? null : undefined, + clineMessages: [], + clineMessagesSeq: 0, + } + expect(readScopedTranscriptFields()).toEqual(cleared) + act(() => dispatchExtensionMessage({ type: "state", state: { version: "2.0.0" } })) + expect(readScopedTranscriptFields()).toEqual(cleared) + + const snapshot = { taskId: currentTaskId ?? undefined, clineMessagesSeq: 0, snapshotTotal: 0 } + act(() => startSnapshot({ ...snapshot, taskInstanceId: "instance-1" })) + expect(vi.getTimerCount()).toBe(0) + act(() => startSnapshot(snapshot)) + expect(vi.getTimerCount()).toBe(1) + act(() => endSnapshot(snapshot)) + expect(vi.getTimerCount()).toBe(0) + expect(readScopedTranscriptFields()).toEqual(cleared) + expect(postMessage.mock.calls).toEqual([]) + }) + + it.each<{ + name: string + initialState: Partial + expectedInstanceId: string | null | undefined + }>([ + { name: "initial partial state", initialState: {}, expectedInstanceId: undefined }, + { name: "legacy task", initialState: { currentTaskId: "task-1" }, expectedInstanceId: undefined }, + { + name: "scoped task", + initialState: { currentTaskId: "task-1", currentTaskInstanceId: "instance-1" }, + expectedInstanceId: "instance-1", + }, + { + name: "explicit no-task with an obsolete instance", + initialState: { currentTaskId: null, currentTaskInstanceId: "instance-1" }, + expectedInstanceId: null, + }, + ])("initializes and preserves the scope for $name", ({ initialState, expectedInstanceId }) => { + const { result } = renderHook(() => useExtensionState(), { + wrapper: ({ children }) => ( + + {children} + + ), + }) + expect(result.current.currentTaskId).toBe(initialState.currentTaskId) + expect(result.current.currentTaskInstanceId).toBe(expectedInstanceId) + const messages = result.current.clineMessages + act(() => dispatchExtensionMessage({ type: "state", state: { version: "2.0.0" } })) + expect(result.current.currentTaskId).toBe(initialState.currentTaskId) + expect(result.current.currentTaskInstanceId).toBe(expectedInstanceId) + expect(result.current.clineMessages).toBe(messages) + expect(result.current.version).toBe("2.0.0") + + const snapshot = { + taskId: initialState.currentTaskId ?? undefined, + taskInstanceId: expectedInstanceId ?? undefined, + clineMessagesSeq: 0, + snapshotTotal: 0, + } + act(() => startSnapshot(snapshot)) + expect(vi.getTimerCount()).toBe(1) + act(() => endSnapshot(snapshot)) + expect(vi.getTimerCount()).toBe(0) + expect(result.current.clineMessages).toEqual([]) + expect(result.current.clineMessagesSeq).toBe(0) + }) + }) + it.each(["initial state", "appends", "snapshot"])( "updates first, middle, and last timestamps after %s, including repeated updates", (source) => { @@ -1645,6 +2180,7 @@ describe("ExtensionStateContext", () => { expect(readTranscript()).toEqual({ currentTaskId: null, + currentTaskInstanceId: null, currentTaskItem: null, currentTaskTodos: [], messageQueue: [], @@ -1672,6 +2208,7 @@ describe("ExtensionStateContext", () => { ) expect(readTranscript()).toEqual({ currentTaskId: null, + currentTaskInstanceId: null, currentTaskItem: null, currentTaskTodos: [], messageQueue: [], @@ -2104,7 +2641,13 @@ describe("ExtensionStateContext", () => { }) }) - expect(postMessage).toHaveBeenCalledTimes(5) + expect(postMessage.mock.calls).toEqual([ + [{ type: "requestClineMessagesResync", taskId: "task-1", expectedSeq: 2, receivedSeq: 2 }], + [{ type: "requestClineMessagesResync", taskId: "task-1", expectedSeq: 2, receivedSeq: 4 }], + [{ type: "requestClineMessagesResync", taskId: "task-1", expectedSeq: 2, receivedSeq: 5 }], + [{ type: "requestClineMessagesResync", taskId: "task-1", expectedSeq: 2, receivedSeq: 6 }], + [{ type: "requestClineMessagesResync", taskId: "task-1", expectedSeq: 2, receivedSeq: 7 }], + ]) expect(readTranscriptFields()).toEqual({ currentTaskId: "task-1", clineMessages: [], From 1679bf3a6e42cf8d4854a99f6cddeb75285ad080 Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Fri, 11 Sep 2026 21:54:15 -0600 Subject: [PATCH 37/38] fix(transcript): bound model test runtime and simplify dispatch Split exhaustive fault searches into separate tests while retaining all scenarios, bounds, and the existing timeout. Consolidate producer admission and frame dispatch without weakening instance guards. Full workspace, model, lint, type, and full-PR mutation validation passed. --- .../transcript-transport-model.md | 2 +- src/core/webview/ClineProvider.ts | 58 +++++++++---------- .../webview/__tests__/ClineProvider.spec.ts | 10 +++- .../__tests__/transcriptTransport.model.ts | 48 ++++++++------- .../__tests__/transcriptTransport.spec.ts | 17 ++++-- src/core/webview/transcriptTransport.ts | 34 +++++++---- 6 files changed, 101 insertions(+), 68 deletions(-) diff --git a/docs/architecture/transcript-transport-model.md b/docs/architecture/transcript-transport-model.md index b9dbd384c2..41f1617261 100644 --- a/docs/architecture/transcript-transport-model.md +++ b/docs/architecture/transcript-transport-model.md @@ -111,7 +111,7 @@ Twenty test-only reducer, wire-conversion, and receiver-policy faults must produ | receiver-accepts-stale-end | snapshot, pump, settle, pump, settle, pump, settle, pump, replace-instance, settle | receiver accepts stale-instance end | | receiver-accepts-stale-delta | append, pump, replace-instance, settle | receiver accepts stale-instance delta | -[transcriptTransport.spec.ts](../../src/core/webview/__tests__/transcriptTransport.spec.ts) runs the full checker, verifies deterministic shortest witnesses and both fail-closed budget paths, and exercises the actual driver with held/rejected start, chunk, end, and delta sends, plus synchronous rejection/recovery. The [CLI entry point](../../scripts/check-transcript-transport.ts) prints counts, action/landmark names, bounds, and mutant traces. +[transcriptTransport.spec.ts](../../src/core/webview/__tests__/transcriptTransport.spec.ts) runs the full checker as a scenario/coverage test plus one test per injected fault, verifies deterministic shortest witnesses and both fail-closed budget paths, and exercises the actual driver with held/rejected start, chunk, end, and delta sends, plus synchronous rejection/recovery. Each fault still searches all seven scenario graphs for the shortest witness; separating the test cases avoids accumulating every exhaustive search under a single test timeout without changing that timeout or any exploration bound. The [CLI entry point](../../scripts/check-transcript-transport.ts) runs the same checks together and prints counts, action/landmark names, bounds, and mutant traces. Focused reducer tests also check canonical descriptors for empty, exact-boundary, and partial-final chunks independently of wire output. Adversarial queued/active states retain obsolete-generation work with unchanged focus to verify the defense-in-depth pre-send guard discards it and permits current work. Such states are deliberately **not claimed reachable** through normal invalidation, which releases that work; no artificial action is added to the reachable-state explorer. A driver regression retains one held caller through two invalidations and checks both successful and failed settlement followed by recovery. diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 3545852fed..fc3acbfc62 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -132,7 +132,7 @@ import { getUri } from "./getUri" import { REQUESTY_BASE_URL } from "../../shared/utils/requesty" import { validateAndFixToolResultIds } from "../task/validateToolResultIds" import { PendingEditOperationStore, type PendingEditOperationInput } from "./PendingEditOperationStore" -import { TranscriptTransport } from "./transcriptTransport" +import { TranscriptTransport, type TranscriptRequest } from "./transcriptTransport" /** * https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts @@ -1503,14 +1503,18 @@ export class ClineProvider ) { return } - } - // 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 } + // 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") { + const { + clineMessages: _omitMessages, + clineMessagesSeq: _omitMessagesSeq, + ...metadataState + } = message.state + message = { ...message, state: metadataState } + } } try { @@ -1538,44 +1542,38 @@ export class ClineProvider } public postClineMessageAppended(taskId: string, message: ClineMessage, taskInstanceId?: string): Promise { - const currentTask = this.getCurrentTask() - if (currentTask?.taskId !== taskId || currentTask?.instanceId !== taskInstanceId) { - return Promise.resolve() - } - if (process.env.ROO_CLI_RUNTIME === "1") { - return this.postStateToWebviewWithoutTaskHistory() - } - - return this.clineMessagesTransport.enqueue({ kind: "append", taskId, taskInstanceId }, [message]) + return this.postTranscript({ kind: "append", taskId, taskInstanceId, message }) } public postClineMessageUpdated(taskId: string, message: ClineMessage, taskInstanceId?: string): Promise { - const currentTask = this.getCurrentTask() - if (currentTask?.taskId !== taskId || currentTask?.instanceId !== taskInstanceId) { - return Promise.resolve() - } - if (process.env.ROO_CLI_RUNTIME === "1") { - return this.postMessageToWebview({ type: "messageUpdated", clineMessage: structuredClone(message) }) - } - - return this.clineMessagesTransport.enqueue({ kind: "update", taskId, taskInstanceId }, [message]) + return this.postTranscript({ kind: "update", taskId, taskInstanceId, message }) } public postClineMessagesSnapshot( taskId: string | undefined = this.getCurrentTask()?.taskId, options: { bumpSeq?: boolean; generation?: number; taskInstanceId?: string } = {}, + ): Promise { + return this.postTranscript({ kind: "snapshot", taskId, ...options }) + } + + private postTranscript( + request: TranscriptRequest & ({ kind: "snapshot" } | { kind: "append" | "update"; message: ClineMessage }), ): Promise { const currentTask = this.getCurrentTask() - if (currentTask?.taskId !== taskId || currentTask?.instanceId !== options.taskInstanceId) { + // Every producer, including the legacy CLI path, must pass the same identity + // check before reading or cloning payloads from the focused task. + if (currentTask?.taskId !== request.taskId || currentTask?.instanceId !== request.taskInstanceId) { return Promise.resolve() } if (process.env.ROO_CLI_RUNTIME === "1") { - return this.postStateToWebviewWithoutTaskHistory() + return request.kind === "update" + ? this.postMessageToWebview({ type: "messageUpdated", clineMessage: structuredClone(request.message) }) + : this.postStateToWebviewWithoutTaskHistory() } return this.clineMessagesTransport.enqueue( - { kind: "snapshot", taskId, ...options }, - currentTask?.clineMessages ?? [], + request, + request.kind === "snapshot" ? (currentTask?.clineMessages ?? []) : [request.message], ) } diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 9140466ae3..69385929f7 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -934,7 +934,14 @@ describe("ClineProvider", () => { return readText() }, } - setCurrentTask({ taskId: "task-1", instanceId: "new", clineMessages: [message] }) + const readTranscript = vi.fn(() => [message]) + setCurrentTask({ + taskId: "task-1", + instanceId: "new", + get clineMessages() { + return readTranscript() + }, + }) const post = vi.spyOn(provider, "postMessageToWebview") const state = vi.spyOn(provider, "postStateToWebviewWithoutTaskHistory") const transport = provider["clineMessagesTransport"] @@ -949,6 +956,7 @@ describe("ClineProvider", () => { }) } expect(readText).not.toHaveBeenCalled() + expect(readTranscript).not.toHaveBeenCalled() expect(post).not.toHaveBeenCalled() expect(state).not.toHaveBeenCalled() expect(transport["state"]).toBe(before) diff --git a/src/core/webview/__tests__/transcriptTransport.model.ts b/src/core/webview/__tests__/transcriptTransport.model.ts index 424045025f..5fec1c9d0a 100644 --- a/src/core/webview/__tests__/transcriptTransport.model.ts +++ b/src/core/webview/__tests__/transcriptTransport.model.ts @@ -836,7 +836,7 @@ export const TRANSPORT_MUTATIONS: Mutation[] = [ }, ] -export function checkTranscriptTransportModel() { +export function checkTranscriptTransportScenarios() { const results = TRANSPORT_SCENARIOS.map((scenario) => ({ name: scenario.name, ...exploreTranscriptTransport(scenario), @@ -852,23 +852,31 @@ export function checkTranscriptTransportModel() { for (const action of TRANSPORT_ACTIONS) requireInvariant(actions.has(action), `unreachable action: ${action}`) for (const landmark of Object.keys(TRANSPORT_LANDMARKS)) requireInvariant(landmarks.has(landmark), `unreachable landmark: ${landmark}`) - const counterexamples = TRANSPORT_MUTATIONS.map((mutation) => { - const failures = TRANSPORT_SCENARIOS.map((scenario) => ({ - scenario: scenario.name, - ...exploreTranscriptTransport(scenario, mutation.reduce, TRANSPORT_MODEL_BOUNDS, mutation), - })).filter((result) => result.violation) - const result = failures.sort((a, b) => a.witness!.length - b.witness!.length)[0] - requireInvariant(result, `${mutation.name}: expected a counterexample`) - requireInvariant( - result.violation === mutation.expected, - `${mutation.name}: expected ${mutation.expected}; got ${result.violation}`, - ) - return { - name: mutation.name, - scenario: result.scenario, - violation: result.violation, - trace: result.witness!.map((entry) => entry.event), - } - }) - return { results, actions: [...actions].sort(), landmarks: [...landmarks].sort(), counterexamples } + return { results, actions: [...actions].sort(), landmarks: [...landmarks].sort() } +} + +export function checkTranscriptTransportMutation(mutation: Mutation) { + const failures = TRANSPORT_SCENARIOS.map((scenario) => ({ + scenario: scenario.name, + ...exploreTranscriptTransport(scenario, mutation.reduce, TRANSPORT_MODEL_BOUNDS, mutation), + })).filter((result) => result.violation) + const result = failures.sort((a, b) => a.witness!.length - b.witness!.length)[0] + requireInvariant(result, `${mutation.name}: expected a counterexample`) + requireInvariant( + result.violation === mutation.expected, + `${mutation.name}: expected ${mutation.expected}; got ${result.violation}`, + ) + return { + name: mutation.name, + scenario: result.scenario, + violation: result.violation, + trace: result.witness!.map((entry) => entry.event), + } +} + +export function checkTranscriptTransportModel() { + return { + ...checkTranscriptTransportScenarios(), + counterexamples: TRANSPORT_MUTATIONS.map(checkTranscriptTransportMutation), + } } diff --git a/src/core/webview/__tests__/transcriptTransport.spec.ts b/src/core/webview/__tests__/transcriptTransport.spec.ts index 0a0a49c880..37a33a70f1 100644 --- a/src/core/webview/__tests__/transcriptTransport.spec.ts +++ b/src/core/webview/__tests__/transcriptTransport.spec.ts @@ -7,7 +7,8 @@ import { type TranscriptFrame, } from "../transcriptTransport" import { - checkTranscriptTransportModel, + checkTranscriptTransportMutation, + checkTranscriptTransportScenarios, exploreTranscriptTransport, TRANSPORT_ACTIONS, TRANSPORT_LANDMARKS, @@ -16,12 +17,20 @@ import { } from "./transcriptTransport.model" describe("transcript transport bounded model", () => { - test("exhausts all scenarios, actions and landmarks and rejects every mutant", () => { - const result = checkTranscriptTransportModel() + test("exhausts all scenarios, actions and landmarks", () => { + const result = checkTranscriptTransportScenarios() expect(result.results).toHaveLength(TRANSPORT_SCENARIOS.length) expect(result.actions).toEqual([...TRANSPORT_ACTIONS].sort()) expect(result.landmarks).toEqual(Object.keys(TRANSPORT_LANDMARKS).sort()) - expect(result.counterexamples).toHaveLength(TRANSPORT_MUTATIONS.length) + }) + + // Keep every scenario and fault, but give each exhaustive fault search its own test timeout. + test.each(TRANSPORT_MUTATIONS)("rejects $name with its shortest counterexample", (mutation) => { + const result = checkTranscriptTransportMutation(mutation) + expect(result.name).toBe(mutation.name) + expect(result.violation).toBe(mutation.expected) + expect(result.trace[0]).toBe("initial") + expect(result.trace.length).toBeGreaterThan(1) }) test("fails closed on depth and state truncation", () => { diff --git a/src/core/webview/transcriptTransport.ts b/src/core/webview/transcriptTransport.ts index f527e36bff..e596cb16b3 100644 --- a/src/core/webview/transcriptTransport.ts +++ b/src/core/webview/transcriptTransport.ts @@ -97,14 +97,15 @@ export function reduceTranscriptTransport( switch (action.type) { case "enqueue": { const { request, total, focusedTaskId, focusedTaskInstanceId } = action - if (request.kind !== "snapshot" && total === 0) return result + const snapshot = request.kind === "snapshot" + if (!snapshot && total === 0) return result if (!isTranscriptRequestCurrent(state, request, focusedTaskId, focusedTaskInstanceId)) return result const sequences = new Map(state.sequences) const seq = request.taskId - ? (sequences.get(request.taskId) ?? 0) + (request.kind !== "snapshot" || request.bumpSeq ? 1 : 0) + ? (sequences.get(request.taskId) ?? 0) + (!snapshot || request.bumpSeq ? 1 : 0) : 0 if (request.taskId) sequences.set(request.taskId, seq) - const nextSnapshotId = state.nextSnapshotId + (request.kind === "snapshot" ? 1 : 0) + const nextSnapshotId = state.nextSnapshotId + (snapshot ? 1 : 0) const job: TranscriptJob = { id: state.nextJobId + 1, generation: state.generation, @@ -113,7 +114,7 @@ export function reduceTranscriptTransport( seq, kind: request.kind, total, - ...(request.kind === "snapshot" ? { snapshotId: `${request.taskId ?? "none"}:${nextSnapshotId}` } : {}), + ...(snapshot ? { snapshotId: `${request.taskId ?? "none"}:${nextSnapshotId}` } : {}), } result.accepted = job result.state = { ...state, sequences, nextSnapshotId, nextJobId: job.id, queue: [...state.queue, job] } @@ -182,29 +183,38 @@ export function reduceTranscriptTransport( } } +const transcriptMessageTypes = { + append: "clineMessageAppended", + update: "clineMessageUpdated", + start: "clineMessagesSnapshotStart", + chunk: "clineMessagesSnapshotChunk", + end: "clineMessagesSnapshotEnd", +} as const satisfies Record + export function transcriptFrameMessage(frame: TranscriptFrame, messages: readonly ClineMessage[]): ExtensionMessage { const { job, phase } = frame - const common = { taskId: job.taskId, taskInstanceId: job.taskInstanceId, clineMessagesSeq: job.seq } + const common = { + type: transcriptMessageTypes[phase], + taskId: job.taskId, + taskInstanceId: job.taskInstanceId, + clineMessagesSeq: job.seq, + } if (phase === "append" || phase === "update") { return { ...common, - type: phase === "append" ? "clineMessageAppended" : "clineMessageUpdated", clineMessage: messages[0], } } + const snapshot = { ...common, snapshotId: job.snapshotId } if (phase === "chunk") { return { - ...common, - type: "clineMessagesSnapshotChunk", - snapshotId: job.snapshotId, + ...snapshot, snapshotStartIndex: frame.start, clineMessages: messages.slice(frame.start, frame.start + frame.count), } } return { - ...common, - type: phase === "start" ? "clineMessagesSnapshotStart" : "clineMessagesSnapshotEnd", - snapshotId: job.snapshotId, + ...snapshot, snapshotTotal: job.total, } } From d258df9132cebffb9b7922b9e1439ec1e2b95435 Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Fri, 11 Sep 2026 23:27:34 -0600 Subject: [PATCH 38/38] fix(webview): reject transcript frames without an active task --- .../ChatView.clear-approval-buttons.spec.tsx | 1 + .../ChatView.notification-sound.spec.tsx | 1 + .../ChatView.scroll-debug-repro.spec.tsx | 1 + .../chat/__tests__/ChatView.spec.tsx | 21 ++-- .../src/context/ExtensionStateContext.tsx | 4 + .../__tests__/ExtensionStateContext.spec.tsx | 111 ++++++++++++++++-- 6 files changed, 115 insertions(+), 24 deletions(-) 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 7a8d6ac83c..2510d88b8a 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 @@ -105,6 +105,7 @@ const RESTORE_CHANGES_BUTTON_LABEL = "chat:restoreChanges.title" const hydrateState = (clineMessages: ClineMessage[]) => { hydrateExtensionState({ version: "1.0.0", + currentTaskId: "test-task-id", clineMessages, taskHistory: [], shouldShowAnnouncement: false, 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 4680ec5819..7ef87921d7 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 @@ -169,6 +169,7 @@ vi.mock("../ChatTextArea", () => { const mockPostMessage = (state: Partial) => { hydrateExtensionState({ version: "1.0.0", + currentTaskId: "test-task-id", clineMessages: [], taskHistory: [], shouldShowAnnouncement: false, 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 afa0f3be0b..27eb811b2a 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 @@ -222,6 +222,7 @@ const resolveFollowOutput = (isAtBottom: boolean): "auto" | false => { const postState = (clineMessages: ClineMessage[]) => { hydrateExtensionState({ version: "1.0.0", + currentTaskId: "test-task-id", clineMessages, taskHistory: [], shouldShowAnnouncement: false, diff --git a/webview-ui/src/components/chat/__tests__/ChatView.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatView.spec.tsx index ad6ffc3cfd..b6fa4fce4a 100644 --- a/webview-ui/src/components/chat/__tests__/ChatView.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatView.spec.tsx @@ -354,7 +354,7 @@ vi.mock("@vscode/webview-ui-toolkit/react", () => ({ const vscodePostMessageMock = mockVscodePostMessage(vi.mocked(vscode.postMessage)) const mockPostMessage = (state: Record) => { - hydrateExtensionState(makeExtensionState(state)) + hydrateExtensionState(makeExtensionState({ currentTaskId: "test-task-id", ...state })) } const dispatchExtensionMessage = async (data: Record) => { @@ -794,6 +794,7 @@ describe("ChatView - Version Indicator Tests", () => { // Hydrate state with no active task mockPostMessage({ version: "1.0.0", + currentTaskId: null, clineMessages: [], }) @@ -815,6 +816,7 @@ describe("ChatView - Version Indicator Tests", () => { // Hydrate state mockPostMessage({ version: "1.0.0", + currentTaskId: null, clineMessages: [], }) @@ -849,6 +851,7 @@ describe("ChatView - Version Indicator Tests", () => { // Hydrate state mockPostMessage({ version: "1.0.0", + currentTaskId: null, clineMessages: [], }) @@ -874,6 +877,7 @@ describe("ChatView - Version Indicator Tests", () => { // Hydrate state mockPostMessage({ version: "1.0.0", + currentTaskId: null, clineMessages: [], }) @@ -914,6 +918,7 @@ describe("ChatView - Version Indicator Tests", () => { // Hydrate state with no active task mockPostMessage({ version: "1.0.0", + currentTaskId: null, clineMessages: [], }) @@ -929,6 +934,7 @@ describe("ChatView - Welcome Screen Display Tests", () => { const { getByTestId, queryByTestId } = renderChatView() mockPostMessage({ + currentTaskId: null, cloudIsAuthenticated: false, taskHistory: [ { id: "1", ts: Date.now() - 6000 }, @@ -1017,17 +1023,10 @@ describe("ChatView - Message Queueing Tests", () => { it("shows sending is enabled when no task is active", async () => { const { getByTestId } = renderChatView() - // Hydrate state with completed task + // Hydrate the authoritative no-task state. mockPostMessage({ - clineMessages: [ - { - type: "ask", - ask: "completion_result", - ts: Date.now(), - text: "Task completed", - partial: false, - }, - ], + currentTaskId: null, + clineMessages: [], }) // Wait for state to be updated diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index 77ef2eb002..76eebcb405 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -424,6 +424,7 @@ export const ExtensionStateContextProvider: React.FC<{ const seq = message.clineMessagesSeq as number const clineMessage = message.clineMessage if ( + activeTaskIdRef.current === undefined || message.taskId !== activeTaskIdRef.current || message.taskInstanceId !== activeTaskInstanceIdRef.current ) { @@ -617,6 +618,7 @@ export const ExtensionStateContextProvider: React.FC<{ } case "clineMessagesSnapshotStart": { if ( + activeTaskIdRef.current === undefined || message.taskId !== activeTaskIdRef.current || message.taskInstanceId !== activeTaskInstanceIdRef.current ) { @@ -660,6 +662,7 @@ export const ExtensionStateContextProvider: React.FC<{ } case "clineMessagesSnapshotChunk": { if ( + activeTaskIdRef.current === undefined || message.taskId !== activeTaskIdRef.current || message.taskInstanceId !== activeTaskInstanceIdRef.current ) { @@ -706,6 +709,7 @@ export const ExtensionStateContextProvider: React.FC<{ } case "clineMessagesSnapshotEnd": { if ( + activeTaskIdRef.current === undefined || message.taskId !== activeTaskIdRef.current || message.taskInstanceId !== activeTaskInstanceIdRef.current ) { diff --git a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx index 1ef27ba720..0078f5386d 100644 --- a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx +++ b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx @@ -969,13 +969,93 @@ describe("ExtensionStateContext", () => { act(() => startSnapshot({ ...snapshot, taskInstanceId: "instance-1" })) expect(vi.getTimerCount()).toBe(0) act(() => startSnapshot(snapshot)) - expect(vi.getTimerCount()).toBe(1) + expect(vi.getTimerCount()).toBe(currentTaskId === null ? 0 : 1) act(() => endSnapshot(snapshot)) expect(vi.getTimerCount()).toBe(0) expect(readScopedTranscriptFields()).toEqual(cleared) expect(postMessage.mock.calls).toEqual([]) }) + it.each( + frameTypes.flatMap((type) => ["state", "clineMessagesFocus"].map((clearType) => ({ type, clearType }))), + )("ignores an unscoped $type after task clearing via $clearType", ({ type, clearType }) => { + const postMessage = renderTranscriptWithPostMessageSpy({ + currentTaskInstanceId: "instance-1", + clineMessages: [makeMessage(10, "old transcript")], + clineMessagesSeq: 8, + }) + const stale = makeMessage(10, "unscoped frame") + act(() => { + dispatchExtensionMessage( + clearType === "state" + ? { type: "state", state: { currentTaskId: null } } + : { type: "clineMessagesFocus" }, + ) + // Omit both identity fields and deliver before React renders the clear. + dispatchExtensionMessage( + type === "clineMessageAppended" || type === "clineMessageUpdated" + ? { type, clineMessagesSeq: 1, clineMessage: stale } + : { + type, + clineMessagesSeq: 1, + snapshotId: "unscoped", + ...(type === "clineMessagesSnapshotChunk" + ? { snapshotStartIndex: 0, clineMessages: [stale] } + : { snapshotTotal: 1 }), + }, + ) + }) + + expect(readScopedTranscriptFields()).toEqual({ + currentTaskId: null, + currentTaskInstanceId: null, + clineMessages: [], + clineMessagesSeq: 0, + }) + expect(vi.getTimerCount()).toBe(0) + act(() => vi.advanceTimersByTime(30_000)) + expect(postMessage).not.toHaveBeenCalled() + }) + + it.each(["state", "clineMessagesFocus"])( + "rejects a nonempty unscoped snapshot after task clearing via %s", + (clearType) => { + const postMessage = renderTranscriptWithPostMessageSpy({ + currentTaskInstanceId: "instance-1", + clineMessages: [makeMessage(10, "old transcript")], + clineMessagesSeq: 8, + }) + const pending = { taskInstanceId: "instance-1", clineMessagesSeq: 9 } + act(() => { + startSnapshot(pending) + appendSnapshotChunk(pending) + }) + expect(vi.getTimerCount()).toBe(1) + + act(() => { + dispatchExtensionMessage( + clearType === "state" + ? { type: "state", state: { currentTaskId: null } } + : { type: "clineMessagesFocus" }, + ) + const unscoped = { taskId: undefined, clineMessagesSeq: 1, snapshotId: "unscoped" } + startSnapshot(unscoped) + appendSnapshotChunk({ ...unscoped, clineMessages: [makeMessage(20, "unscoped snapshot")] }) + endSnapshot(unscoped) + }) + + expect(readScopedTranscriptFields()).toEqual({ + currentTaskId: null, + currentTaskInstanceId: null, + clineMessages: [], + clineMessagesSeq: 0, + }) + expect(vi.getTimerCount()).toBe(0) + act(() => vi.advanceTimersByTime(30_000)) + expect(postMessage).not.toHaveBeenCalled() + }, + ) + it.each<{ name: string initialState: Partial @@ -1017,11 +1097,11 @@ describe("ExtensionStateContext", () => { snapshotTotal: 0, } act(() => startSnapshot(snapshot)) - expect(vi.getTimerCount()).toBe(1) + expect(vi.getTimerCount()).toBe(initialState.currentTaskId ? 1 : 0) act(() => endSnapshot(snapshot)) expect(vi.getTimerCount()).toBe(0) expect(result.current.clineMessages).toEqual([]) - expect(result.current.clineMessagesSeq).toBe(0) + expect(result.current.clineMessagesSeq).toBe(initialState.currentTaskId ? 0 : undefined) }) }) @@ -1176,18 +1256,23 @@ describe("ExtensionStateContext", () => { clineMessages: [], clineMessagesSeq: 0, }) - expect(postMessage.mock.calls).toEqual([ - [{ type: "requestClineMessagesResync", taskId, expectedSeq: 1, receivedSeq: 1 }], - ]) + expect(postMessage.mock.calls).toEqual( + nextTaskId === null + ? [] + : [[{ type: "requestClineMessagesResync", taskId, expectedSeq: 1, receivedSeq: 1 }]], + ) postMessage.mockClear() + // Transcript delivery resumes only after a task becomes active again. + const activeTaskId = nextTaskId ?? "task-2" const updated = makeMessage(30, "updated at new position") act(() => { - appendClineMessage(makeMessage(30, "reused timestamp"), 1, taskId) - updateClineMessage(updated, 2, taskId) + dispatchExtensionMessage({ type: "state", state: { currentTaskId: activeTaskId } }) + appendClineMessage(makeMessage(30, "reused timestamp"), 1, activeTaskId) + updateClineMessage(updated, 2, activeTaskId) }) expect(readTranscriptFields()).toEqual({ - currentTaskId: nextTaskId, + currentTaskId: activeTaskId, clineMessages: [updated], clineMessagesSeq: 2, }) @@ -2191,6 +2276,7 @@ describe("ExtensionStateContext", () => { }) it("does not retain a pending transcript when the authoritative state clears the task", () => { + vi.useFakeTimers() const postMessage = renderTranscriptWithPostMessageSpy({ clineMessages: [makeMessage(1, "existing")], clineMessagesSeq: 1, @@ -2202,10 +2288,9 @@ describe("ExtensionStateContext", () => { appendSnapshotChunk({ taskId: undefined, clineMessagesSeq: 2, snapshotId: "pending" }) }) - expect(postMessage).toHaveBeenCalledTimes(1) - expect(postMessage).toHaveBeenCalledWith( - expect.objectContaining({ type: "requestClineMessagesResync", taskId: undefined, receivedSeq: 2 }), - ) + expect(vi.getTimerCount()).toBe(0) + act(() => vi.advanceTimersByTime(30_000)) + expect(postMessage).not.toHaveBeenCalled() expect(readTranscript()).toEqual({ currentTaskId: null, currentTaskInstanceId: null,