From 3779a739bfc11efc0ae046f05bbc11a937408031 Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Sun, 23 Aug 2026 07:31:47 -0600 Subject: [PATCH 01/40] 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 a032a4de41101728995fb6706718e1ca8889851c Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Sun, 23 Aug 2026 07:34:42 -0600 Subject: [PATCH 02/40] 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 b5d9feefc53dbc8c77de8eefb18577a0d75bc25b Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Sun, 23 Aug 2026 12:52:16 -0600 Subject: [PATCH 03/40] 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 9dd51d7412..38def3b14e 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -380,6 +380,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, @@ -1485,6 +1488,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 @@ -2173,8 +2179,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, @@ -2191,13 +2197,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, @@ -2206,11 +2212,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 = { @@ -2222,20 +2228,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, @@ -2243,10 +2246,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(() => {}) @@ -2260,25 +2261,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, @@ -2287,19 +2282,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, @@ -2308,12 +2302,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, @@ -2328,21 +2322,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", }) }) }) @@ -3565,7 +3555,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, @@ -3576,16 +3566,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, @@ -3595,11 +3583,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 86ce5d8e67..f7fca17548 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -223,10 +223,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: ${ @@ -313,12 +318,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 @@ -589,6 +588,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) { @@ -647,6 +648,8 @@ export class ClineProvider // garbage collected. task = undefined } + + await this.syncFocusedTaskToWebview() } /** @@ -1408,6 +1411,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)`, @@ -1479,6 +1483,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 { @@ -1486,6 +1496,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")) @@ -2429,9 +2585,7 @@ export class ClineProvider } async postStateToWebview() { - const clineMessagesSeq = ++this.clineMessagesSeq const state = await this.getStateToPostToWebview() - state.clineMessagesSeq = clineMessagesSeq await this.postMessageToWebview({ type: "state", state }) } @@ -2444,11 +2598,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 }) } /** @@ -2474,7 +2626,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 @@ -2486,7 +2640,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 97c4dd877e..c20d984c0d 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -760,7 +760,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 () => { @@ -862,6 +863,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) => { @@ -989,7 +1073,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() @@ -1005,7 +1091,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) @@ -1026,7 +1114,7 @@ describe("ClineProvider", () => { releasePost = resolve }) const postStateSpy = vi - .spyOn(provider, "postStateToWebviewWithoutTaskHistory") + .spyOn(provider, "postStateToWebviewWithoutClineMessages") .mockResolvedValueOnce(undefined) .mockReturnValueOnce(pendingPost) @@ -1053,7 +1141,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() @@ -1065,7 +1155,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( @@ -1075,7 +1165,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( @@ -1087,7 +1177,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) @@ -1102,7 +1192,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 34a35ea3ca..06a367dd52 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 11dec87f4d0064ea5de1f496fb1d95490621afda Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Sun, 23 Aug 2026 12:53:19 -0600 Subject: [PATCH 04/40] 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 49bf62437c6f4e909ab1dd229b34aa3168599173 Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Sun, 23 Aug 2026 12:54:32 -0600 Subject: [PATCH 05/40] 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 58a4158da2d90e3910af2c3e584d38d80af93682 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/40] 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 bb468b29ebd6032e341c829d1d7b31f5ea1dff0c 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/40] 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 250d495cd6c2b3899355b21f459682d06d31cc52 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/40] 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 7cdf18c20491446f4f8b8094df729b64d1f2a0de 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/40] 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 dc3859758ffca9d8ff6ec67c843db91f018614be Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Sun, 23 Aug 2026 20:59:33 -0600 Subject: [PATCH 10/40] 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 38def3b14e..5b1c331aae 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -2180,6 +2180,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, @@ -2687,6 +2711,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, @@ -4019,6 +4107,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 f7fca17548..1894e39bc0 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -1483,8 +1483,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 } } @@ -1522,6 +1524,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 @@ -1543,6 +1548,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 @@ -1568,6 +1576,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 c20d984c0d..cecb474f7d 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -449,6 +449,7 @@ describe("ClineProvider", () => { beforeEach(() => { vi.clearAllMocks() + delete process.env.ROO_CLI_RUNTIME if (!TelemetryService.hasInstance()) { TelemetryService.createInstance([]) @@ -886,6 +887,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) => ({ @@ -913,36 +1030,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 e50a7febed057cadaefc469226c4ede7c8ebb26c Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Sun, 23 Aug 2026 21:14:54 -0600 Subject: [PATCH 11/40] 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 1894e39bc0..1d99f5421a 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -624,6 +624,7 @@ export class ClineProvider } if (task) { + this.clineMessagesSeqByTaskId.delete(task.taskId) task.emit(RooCodeEventName.TaskUnfocused) try { @@ -2543,6 +2544,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 @@ -2585,6 +2589,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 cecb474f7d..47abafc82a 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -1137,6 +1137,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 06a367dd52..2c2da5921a 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 6ddeed25438c708e86ee7edeeead622c7d984ee2 Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Sun, 23 Aug 2026 21:24:03 -0600 Subject: [PATCH 12/40] 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 0dab87508625f6e164831da7c0cacf18c9715727 Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Sun, 23 Aug 2026 21:39:46 -0600 Subject: [PATCH 13/40] 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 0d3e65559ea656bce529872f817dcd49cac61d38 Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Sun, 23 Aug 2026 21:59:24 -0600 Subject: [PATCH 14/40] 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 8f16c106ce07af7afae1533109f2c444ea3b99e5 Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Sun, 23 Aug 2026 23:38:12 -0600 Subject: [PATCH 15/40] 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 1d99f5421a..2de7d4e6e3 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2946,7 +2946,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 47abafc82a..4975e84fad 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -1300,6 +1300,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 c9d6f8b79082192bd975cfe97472ca6190b2dc05 Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Wed, 26 Aug 2026 20:42:33 -0600 Subject: [PATCH 16/40] 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 5b1c331aae..29c9d31e87 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -2180,6 +2180,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, @@ -2317,7 +2321,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, @@ -2351,6 +2356,7 @@ describe("Cline", () => { expect(updatePostSpy).not.toHaveBeenCalled() releaseAppend() + await vi.advanceTimersByTimeAsync(500) await addThenUpdate expect(appendSpy.mock.invocationCallOrder[0]).toBeLessThan(updatePostSpy.mock.invocationCallOrder[0]) @@ -2359,6 +2365,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 2de7d4e6e3..cceb190582 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -1586,7 +1586,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 @@ -1597,6 +1596,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 4975e84fad..4656aa5546 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -1072,8 +1072,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 @@ -1083,12 +1086,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 1b4f970db8d42f299bd1ec1257ae1f3a1e814eae Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Mon, 31 Aug 2026 23:15:01 -0600 Subject: [PATCH 17/40] 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 4656aa5546..d30d63a327 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -1225,7 +1225,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 @@ -1241,7 +1243,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() @@ -1250,11 +1251,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 eb33eab9592664f406f467a9a7250a1737780a88 Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Fri, 4 Sep 2026 00:48:05 -0600 Subject: [PATCH 18/40] 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 29c9d31e87..a056a53ad5 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -196,6 +196,9 @@ vi.mock("vscode", () => { Disposable: { from: vi.fn(), }, + RelativePattern: vi.fn().mockImplementation(function (base: string, pattern: string) { + return { base, pattern } + }), TabInputText: vi.fn(), } }) @@ -2208,6 +2211,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, @@ -2404,6 +2427,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" }], @@ -2883,6 +2973,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, @@ -3790,6 +3924,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 d30d63a327..9b6ca7bbb6 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -36,8 +36,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, @@ -110,13 +120,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 @@ -175,6 +178,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() })), }, @@ -263,27 +267,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;" @@ -412,7 +395,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(), @@ -882,6 +865,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) @@ -1072,6 +1066,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 0e5207046c..92640681e3 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 ad19c20b08829d13c95cdf2349040225715658ce Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Fri, 4 Sep 2026 04:15:18 -0600 Subject: [PATCH 19/40] 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 a056a53ad5..25306753ef 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -2254,6 +2254,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 9b6ca7bbb6..ea16345773 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" @@ -35,6 +36,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"), @@ -876,6 +878,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) @@ -966,6 +986,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), @@ -975,6 +997,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 () => { @@ -1066,6 +1104,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[] } @@ -1100,6 +1193,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", @@ -1128,6 +1247,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"]], [ @@ -1195,6 +1385,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) @@ -1219,6 +1452,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 () => { @@ -1651,6 +1911,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 @@ -1661,6 +1922,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 3c061b285bec1dff8825f37c32ba803ace478bf5 Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Fri, 4 Sep 2026 06:48:30 -0600 Subject: [PATCH 20/40] 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 ea16345773..1721aec384 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -1129,9 +1129,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 32fb122a845d8301e494b98e708958df1d5f9435 Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Fri, 4 Sep 2026 07:22:41 -0600 Subject: [PATCH 21/40] 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 ae010aaf75ac72e09e4f485d2744679cdc69983d Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Fri, 4 Sep 2026 07:50:26 -0600 Subject: [PATCH 22/40] 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 b21a821a15b9791382eb9e3d03cbdd9cf6c30f86 Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Sat, 5 Sep 2026 11:24:47 -0600 Subject: [PATCH 23/40] 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 25306753ef..c217e38c0f 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -3686,6 +3686,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 cceb190582..d0373ec8f1 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2642,9 +2642,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 @@ -2655,9 +2656,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 1721aec384..d884017cfa 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -1565,6 +1565,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 4607239bebd71f7694cf0a8b38713965293903ba Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Sun, 6 Sep 2026 01:21:10 -0600 Subject: [PATCH 24/40] 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 c217e38c0f..c46142ddc1 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -2187,14 +2187,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, @@ -2204,13 +2208,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 9a09252780cad22a35c66bb2125ffd087211c91f Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Sun, 6 Sep 2026 02:33:23 -0600 Subject: [PATCH 25/40] 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 c46142ddc1..e96d2436e8 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -2254,6 +2254,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 b073ebc355980ca3758df62b200ddf37dfb98b9f Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Sun, 6 Sep 2026 02:52:43 -0600 Subject: [PATCH 26/40] 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 a32a0d1c9070209ecef179cc367af5c3560254a2 Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Mon, 7 Sep 2026 09:28:17 -0600 Subject: [PATCH 27/40] 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 7c6ac75d9d..62f9eccee5 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 6c8c3fd001156095737a16bc93580c8842ada203 Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Mon, 7 Sep 2026 15:41:37 -0600 Subject: [PATCH 28/40] 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 62f9eccee5..75d961352e 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 ad4a52dd5e4a09c4b120db41052d551b6c30ced7 Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Mon, 7 Sep 2026 20:02:36 -0600 Subject: [PATCH 29/40] 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 d0373ec8f1..596c371476 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -1586,6 +1586,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 @@ -1596,7 +1598,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 d884017cfa..2d3442c74f 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -1062,6 +1062,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"], @@ -1217,33 +1291,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 d3c11304656e9fdbd0f8441322cc4f173270fd32 Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Fri, 11 Sep 2026 08:12:07 -0600 Subject: [PATCH 30/40] 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 14fcfb8b7df016ddc5a8bd5608682c95150a1663 Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Fri, 11 Sep 2026 09:36:28 -0600 Subject: [PATCH 31/40] 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 df3410bbc1..a642fcc5e5 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 && tsx scripts/check-delegated-mode-readers.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 && tsx scripts/check-delegated-mode-readers.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 56ccd52588..93e2f6f263 100644 --- a/src/__tests__/extension.spec.ts +++ b/src/__tests__/extension.spec.ts @@ -189,7 +189,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: {}, @@ -296,17 +296,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( @@ -443,15 +443,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 596c371476..7f375d1c64 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -133,6 +133,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 @@ -223,15 +224,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: ${ @@ -367,7 +369,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 @@ -624,7 +626,7 @@ export class ClineProvider } if (task) { - this.clineMessagesSeqByTaskId.delete(task.taskId) + this.clineMessagesTransport.forgetTask(task.taskId) task.emit(RooCodeEventName.TaskUnfocused) try { @@ -1500,25 +1502,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 { @@ -1529,20 +1517,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 { @@ -1553,20 +1528,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( @@ -1581,64 +1543,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 }) } @@ -1649,7 +1580,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 }) @@ -2546,7 +2477,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 @@ -2590,7 +2521,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() @@ -2642,24 +2573,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 2d3442c74f..0104b92b8e 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -900,6 +900,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) @@ -949,33 +967,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, }, ]) @@ -986,8 +1013,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), @@ -997,22 +1024,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 () => { @@ -1059,6 +1086,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) }) @@ -1077,13 +1109,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" } @@ -1092,12 +1121,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, @@ -1147,12 +1180,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", @@ -1166,7 +1194,7 @@ describe("ClineProvider", () => { task.taskId = "task-2" const focusSync = provider.syncFocusedTaskToWebview() - releaseQueue() + held.release() await Promise.all([pendingDelta, focusSync]) expect(mockPostMessage).not.toHaveBeenCalledWith( @@ -1187,12 +1215,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" @@ -1200,7 +1223,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 })) @@ -1212,12 +1235,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" @@ -1225,7 +1243,7 @@ describe("ClineProvider", () => { : provider.postClineMessageUpdated("task-1", message) setCurrentTask(undefined) - releaseQueue() + held.release() await expect(pendingDelta).resolves.toBeUndefined() }, @@ -1237,24 +1255,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( @@ -1269,12 +1282,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", @@ -1282,8 +1290,8 @@ describe("ClineProvider", () => { text: "stale generation", }) - provider["clineMessagesTransportGeneration"]++ - releaseQueue() + provider["invalidateClineMessagesTransport"]() + held.release() await pendingUpdate expect(postSpy).not.toHaveBeenCalledWith( @@ -1300,23 +1308,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) }, ) @@ -1382,7 +1479,7 @@ describe("ClineProvider", () => { vi.spyOn(provider, "postMessageToWebview").mockImplementation(async (message) => { postedTypes.push(message.type) if (message.type === "clineMessagesSnapshotStart") { - provider["clineMessagesTransportGeneration"]++ + provider["invalidateClineMessagesTransport"]() } }) @@ -1417,45 +1514,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 () => { @@ -1491,14 +1737,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 () => { @@ -1530,7 +1776,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() @@ -1544,7 +1790,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 }) @@ -1620,34 +1866,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", @@ -1704,9 +1933,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() @@ -1722,9 +1949,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) @@ -1745,7 +1970,7 @@ describe("ClineProvider", () => { releasePost = resolve }) const postStateSpy = vi - .spyOn(provider, "postStateToWebviewWithoutClineMessages") + .spyOn(provider, "postStateToWebviewWithoutTaskHistory") .mockResolvedValueOnce(undefined) .mockReturnValueOnce(pendingPost) @@ -1772,9 +1997,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() @@ -1786,7 +2009,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( @@ -1796,7 +2019,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( @@ -1808,7 +2031,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) @@ -1823,9 +2046,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 2c2da5921a..c2e91a5a21 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 8706de765b..3fba84f929 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() }), ) @@ -220,7 +220,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 0e35038d6d2d30d1aa0a4355793c1fc6bbd8de75 Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Fri, 11 Sep 2026 09:37:07 -0600 Subject: [PATCH 32/40] 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 e96d2436e8..96e0428523 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -2276,14 +2276,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) @@ -2292,13 +2296,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 () => { @@ -2481,7 +2488,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, @@ -2516,17 +2523,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, @@ -2537,34 +2566,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, @@ -2572,23 +2600,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, @@ -2609,27 +2681,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 }], @@ -2647,24 +2730,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, @@ -2672,24 +2778,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 15e9c3ba4284cd6ed5a380f805676b42025c250a Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Fri, 11 Sep 2026 09:37:38 -0600 Subject: [PATCH 33/40] 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 94fcaaeaaea0d54e385187e19e604aa77258d607 Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Fri, 11 Sep 2026 11:06:18 -0600 Subject: [PATCH 34/40] 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 93e2f6f263..815fd479de 100644 --- a/src/__tests__/extension.spec.ts +++ b/src/__tests__/extension.spec.ts @@ -434,6 +434,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 0104b92b8e..71d4d6ec78 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -1008,25 +1008,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 1d74db0f8adc1c307602e08682ff15e60de0eb7f Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Fri, 11 Sep 2026 11:43:56 -0600 Subject: [PATCH 35/40] 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 7f375d1c64..a22c81149e 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -1501,10 +1501,6 @@ export class ClineProvider } } - private getClineMessagesSeq(taskId: string): number { - return this.clineMessagesTransport.getSequence(taskId) - } - private invalidateClineMessagesTransport(): number { return this.clineMessagesTransport.invalidate() } @@ -1555,10 +1551,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 89fb5ff73259d6f4d14760c272ecfcb24d843c9b Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Fri, 11 Sep 2026 17:43:49 -0600 Subject: [PATCH 36/40] 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 815fd479de..9aceec70c1 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: { @@ -443,11 +445,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 () => { @@ -457,17 +466,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 96e0428523..f28efa9240 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -2220,7 +2220,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) => { @@ -2242,7 +2245,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) @@ -2281,13 +2289,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) @@ -2295,13 +2303,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) @@ -2324,7 +2335,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 () => { @@ -2365,7 +2379,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() }) @@ -2423,7 +2437,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() @@ -2485,7 +2499,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 () => { @@ -2518,7 +2532,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() @@ -2527,10 +2541,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() @@ -2549,7 +2564,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() @@ -2581,12 +2596,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) @@ -2614,10 +2629,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) @@ -2656,7 +2671,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]], ) }, ) @@ -2705,12 +2720,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]]) }, ) @@ -2740,7 +2755,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) => { @@ -2752,8 +2767,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() @@ -3958,7 +3973,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, + ) } }) }) @@ -4218,7 +4237,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 a22c81149e..46e5f5d754 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -229,6 +229,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 () => { @@ -579,6 +580,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. @@ -622,11 +624,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 { @@ -1390,6 +1393,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) @@ -1405,9 +1413,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) @@ -1486,6 +1491,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. @@ -1505,34 +1525,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") { @@ -1546,7 +1581,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() } @@ -1570,11 +1606,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 { @@ -1583,7 +1620,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 { @@ -2860,6 +2900,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 71d4d6ec78..0deb15a705 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -37,6 +37,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"), @@ -242,6 +243,7 @@ vi.mock("../../../integrations/openai-codex/oauth", () => ({ openAiCodexOAuthManager: { getAccessToken: vi.fn(), getAccountId: vi.fn(), + isAuthenticated: vi.fn().mockResolvedValue(false), }, })) @@ -897,7 +899,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) => { @@ -919,21 +923,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, @@ -1755,7 +1892,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) => { @@ -1768,15 +1905,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 3fba84f929..5188b17d5e 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 ea47b60c9120870767132098cb3a70fc19e0a8f0 Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Fri, 11 Sep 2026 21:54:15 -0600 Subject: [PATCH 37/40] 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 46e5f5d754..0132d9f09b 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -133,7 +133,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 @@ -1504,14 +1504,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 { @@ -1539,44 +1543,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 0deb15a705..dc68b74983 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -936,7 +936,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"] @@ -951,6 +958,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 4696070f1bac95a7e3d394797eaf6171544b5c83 Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Fri, 11 Sep 2026 23:27:34 -0600 Subject: [PATCH 38/40] 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, From 9e5a02fe0ac07415a7a7e2a47598b78765d811f4 Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Thu, 17 Sep 2026 17:49:57 -0600 Subject: [PATCH 39/40] fix(webview): coalesce transcript recovery and address review feedback --- .../transcript-transport-model.md | 4 +- .../src/context/ExtensionStateContext.tsx | 23 ++- .../__tests__/ExtensionStateContext.spec.tsx | 145 +++++++++++++++++- webview-ui/src/utils/test-utils.tsx | 6 +- 4 files changed, 156 insertions(+), 22 deletions(-) diff --git a/docs/architecture/transcript-transport-model.md b/docs/architecture/transcript-transport-model.md index 41f1617261..67d3776247 100644 --- a/docs/architecture/transcript-transport-model.md +++ b/docs/architecture/transcript-transport-model.md @@ -4,9 +4,9 @@ Run the focused checker with **pnpm transcript-transport:model-check**. It also ## Production boundary -[`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. +[`TranscriptTransport`](../../src/core/webview/transcriptTransport.ts:223) 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. +[`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:194); 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. diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index 76eebcb405..f9c4b3a46e 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -393,11 +393,16 @@ export const ExtensionStateContextProvider: React.FC<{ const retryClineMessagesResync = useCallback( (receivedSeq?: number) => { - clearClineMessagesResync() + // Only a failed accepted snapshot retires the pending recovery attempt. + // Its orphaned frames must share the next request instead of each retrying. + if (activeSnapshotRef.current) { + clearClineMessagesSnapshot() + clearClineMessagesResync() + } requestClineMessagesResync(receivedSeq) }, - // Stryker disable next-line ArrayDeclaration: both dependencies are stable callbacks; omitting them cannot alter callback identity or captured values. - [clearClineMessagesResync, requestClineMessagesResync], + // Stryker disable next-line ArrayDeclaration: the dependencies are stable callbacks; omitting them cannot alter callback identity or captured values. + [clearClineMessagesSnapshot, clearClineMessagesResync, requestClineMessagesResync], ) const startClineMessagesSnapshotTimeout = useCallback( @@ -410,7 +415,6 @@ export const ExtensionStateContextProvider: React.FC<{ if (snapshot?.snapshotId !== snapshotId || snapshot.seq !== seq) { return } - activeSnapshotRef.current = null snapshotTimeoutRef.current = undefined retryClineMessagesResync(seq) }, CLINE_MESSAGES_SNAPSHOT_TIMEOUT_MS) @@ -442,7 +446,6 @@ export const ExtensionStateContextProvider: React.FC<{ if (seq <= snapshot.seq) { return } - clearClineMessagesSnapshot() retryClineMessagesResync(seq) return } @@ -478,7 +481,7 @@ export const ExtensionStateContextProvider: React.FC<{ })) }, // 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], + [clineMessagesIndex, requestClineMessagesResync, retryClineMessagesResync], ) const handleMessage = useCallback( @@ -627,7 +630,6 @@ export const ExtensionStateContextProvider: React.FC<{ const seq = message.clineMessagesSeq as number if (!Number.isSafeInteger(seq) || seq < 0) { - clearClineMessagesSnapshot() retryClineMessagesResync(typeof seq === "number" ? seq : undefined) break } @@ -637,7 +639,6 @@ export const ExtensionStateContextProvider: React.FC<{ const total = message.snapshotTotal as number if (!message.snapshotId || !Number.isSafeInteger(total) || total < 0) { - clearClineMessagesSnapshot() retryClineMessagesResync(seq) break } @@ -672,7 +673,6 @@ export const ExtensionStateContextProvider: React.FC<{ const seq = message.clineMessagesSeq as number const snapshot = activeSnapshotRef.current if (!Number.isSafeInteger(seq) || seq < 0) { - clearClineMessagesSnapshot() retryClineMessagesResync(typeof seq === "number" ? seq : undefined) break } @@ -684,7 +684,6 @@ export const ExtensionStateContextProvider: React.FC<{ } if (message.snapshotId !== snapshot.snapshotId || seq !== snapshot.seq) { if (seq > snapshot.seq) { - clearClineMessagesSnapshot() retryClineMessagesResync(seq) } break @@ -699,7 +698,6 @@ export const ExtensionStateContextProvider: React.FC<{ startIndex !== snapshot.messages.length || snapshot.messages.length + chunk.length > snapshot.total ) { - clearClineMessagesSnapshot() retryClineMessagesResync(seq) break } @@ -719,7 +717,6 @@ export const ExtensionStateContextProvider: React.FC<{ const seq = message.clineMessagesSeq as number const snapshot = activeSnapshotRef.current if (!Number.isSafeInteger(seq) || seq < 0) { - clearClineMessagesSnapshot() retryClineMessagesResync(typeof seq === "number" ? seq : undefined) break } @@ -731,13 +728,11 @@ export const ExtensionStateContextProvider: React.FC<{ } if (message.snapshotId !== snapshot.snapshotId || seq !== snapshot.seq) { if (seq > snapshot.seq) { - clearClineMessagesSnapshot() retryClineMessagesResync(seq) } break } if (message.snapshotTotal !== snapshot.total || snapshot.messages.length !== snapshot.total) { - clearClineMessagesSnapshot() retryClineMessagesResync(seq) break } diff --git a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx index 0078f5386d..bd937f6e77 100644 --- a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx +++ b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx @@ -1928,8 +1928,8 @@ describe("ExtensionStateContext", () => { appendSnapshotChunk() }) - expect(postMessage).toHaveBeenCalledTimes(2) - expect(postMessage.mock.calls.map(([message]) => message.receivedSeq)).toEqual([undefined, 2]) + expect(postMessage).toHaveBeenCalledTimes(1) + expect(postMessage.mock.calls.map(([message]) => message.receivedSeq)).toEqual([undefined]) }) it.each([ @@ -2057,8 +2057,8 @@ describe("ExtensionStateContext", () => { appendSnapshotChunk() }) - expect(postMessage).toHaveBeenCalledTimes(2) - expect(postMessage.mock.calls.map(([message]) => message.receivedSeq)).toEqual([undefined, 2]) + expect(postMessage).toHaveBeenCalledTimes(1) + expect(postMessage.mock.calls.map(([message]) => message.receivedSeq)).toEqual([undefined]) }) it.each([ @@ -2463,6 +2463,98 @@ describe("ExtensionStateContext", () => { } }) + it.each([ + { name: "an invalid start sequence", fail: () => startSnapshot({ clineMessagesSeq: "invalid" }) }, + { name: "invalid start metadata", fail: () => startSnapshot({ snapshotTotal: -1 }) }, + { name: "an invalid chunk sequence", fail: () => appendSnapshotChunk({ clineMessagesSeq: "invalid" }) }, + { name: "a noncontiguous chunk", fail: () => appendSnapshotChunk({ snapshotStartIndex: 1 }) }, + { name: "an invalid end sequence", fail: () => endSnapshot({ clineMessagesSeq: "invalid" }) }, + { name: "an incomplete end", fail: () => endSnapshot() }, + { name: "an interleaved delta", fail: () => appendClineMessage(makeMessage(3, "gap"), 3, "task-1") }, + { name: "a snapshot timeout", fail: () => vi.advanceTimersByTime(30_000) }, + ])("coalesces orphaned snapshot frames after $name and retries a new failed attempt", ({ fail }) => { + vi.useFakeTimers() + const existing = makeMessage(1, "existing") + const postMessage = renderTranscriptWithPostMessageSpy({ clineMessages: [existing], clineMessagesSeq: 1 }) + + act(() => { + appendClineMessage(makeMessage(3, "initial gap"), 3, "task-1") + startSnapshot() + fail() + }) + expect(postMessage).toHaveBeenCalledTimes(2) + + act(() => { + for (let index = 0; index < 3; index++) { + startSnapshot({ clineMessagesSeq: "invalid" }) + startSnapshot({ snapshotTotal: -1 }) + appendSnapshotChunk() + appendSnapshotChunk({ clineMessagesSeq: "invalid" }) + endSnapshot() + endSnapshot({ clineMessagesSeq: "invalid" }) + appendClineMessage(makeMessage(3, "still pending"), 3, "task-1") + } + }) + expect(postMessage).toHaveBeenCalledTimes(2) + expect(vi.getTimerCount()).toBe(1) + expect(readTranscriptFields()).toEqual({ + currentTaskId: "task-1", + clineMessages: [existing], + clineMessagesSeq: 1, + }) + + act(() => { + startSnapshot({ snapshotId: "replacement" }) + appendSnapshotChunk({ snapshotId: "replacement", snapshotStartIndex: 1 }) + endSnapshot({ snapshotId: "replacement" }) + }) + expect(postMessage).toHaveBeenCalledTimes(3) + expect(postMessage).toHaveBeenLastCalledWith({ + type: "requestClineMessagesResync", + taskId: "task-1", + expectedSeq: 2, + receivedSeq: 2, + }) + + const recovered = makeMessage(2, "recovered") + const appended = makeMessage(3, "after recovery") + act(() => { + startSnapshot({ snapshotId: "recovery" }) + appendSnapshotChunk({ snapshotId: "recovery", clineMessages: [recovered] }) + endSnapshot({ snapshotId: "recovery" }) + appendClineMessage(appended, 3, "task-1") + }) + expect(postMessage).toHaveBeenCalledTimes(3) + expect(vi.getTimerCount()).toBe(0) + expect(readTranscriptFields()).toEqual({ + currentTaskId: "task-1", + clineMessages: [recovered, appended], + clineMessagesSeq: 3, + }) + }) + + it("coalesces orphaned frames without extending the lost-response timeout", () => { + vi.useFakeTimers() + const postMessage = renderTranscriptWithPostMessageSpy({ clineMessagesSeq: 1 }) + + act(() => appendSnapshotChunk()) + expect(postMessage).toHaveBeenCalledTimes(1) + act(() => { + vi.advanceTimersByTime(4_999) + startSnapshot({ snapshotTotal: -1 }) + appendSnapshotChunk() + endSnapshot() + }) + expect(postMessage).toHaveBeenCalledTimes(1) + act(() => { + vi.advanceTimersByTime(1) + endSnapshot() + appendSnapshotChunk() + }) + expect(postMessage).toHaveBeenCalledTimes(2) + expect(vi.getTimerCount()).toBe(1) + }) + it("allows another resync when a response is lost", async () => { vi.useFakeTimers() const postMessage = vi.spyOn(vscode, "postMessage").mockImplementation(() => undefined) @@ -2730,7 +2822,6 @@ describe("ExtensionStateContext", () => { [{ 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({ @@ -2792,6 +2883,50 @@ describe("ExtensionStateContext", () => { } }) + it.each(["metadata", "options"] as const)("hydrates instance-scoped snapshots from %s", (source) => { + vi.useFakeTimers() + const existing = makeMessage(1, "existing") + const hydrated = makeMessage(2, "hydrated") + const postMessage = renderTranscriptWithPostMessageSpy({ + currentTaskInstanceId: "instance-1", + clineMessages: [existing], + clineMessagesSeq: 1, + }) + const scope = { currentTaskId: "task-1", currentTaskInstanceId: "instance-1" } + const metadata = source === "metadata" ? scope : {} + const options = source === "options" ? { taskId: "task-1", taskInstanceId: "instance-1" } : {} + + act(() => hydrateExtensionState({ ...metadata, clineMessages: [hydrated], clineMessagesSeq: 2 }, options)) + expect(readScopedTranscriptFields()).toEqual({ ...scope, clineMessages: [hydrated], clineMessagesSeq: 2 }) + act(() => hydrateExtensionState({ ...metadata, clineMessages: [], clineMessagesSeq: 3 }, options)) + expect(readScopedTranscriptFields()).toEqual({ ...scope, clineMessages: [], clineMessagesSeq: 3 }) + expect(postMessage).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) + }) + + it("prefers the explicit snapshot instance override over metadata", () => { + const dispatch = vi.spyOn(window, "dispatchEvent") + hydrateExtensionState( + { + currentTaskId: "task-1", + currentTaskInstanceId: "metadata-instance", + clineMessages: [makeMessage(1, "snapshot")], + }, + { taskInstanceId: "override-instance" }, + ) + + expect( + dispatch.mock.calls.map(([event]) => (event instanceof MessageEvent ? event.data : undefined)), + ).toEqual([ + { type: "state", state: { currentTaskId: "task-1", currentTaskInstanceId: "metadata-instance" } }, + ...( + ["clineMessagesSnapshotStart", "clineMessagesSnapshotChunk", "clineMessagesSnapshotEnd"] as const + ).map((type) => + expect.objectContaining({ type, taskId: "task-1", taskInstanceId: "override-instance" }), + ), + ]) + }) + it("hydrates metadata, non-empty transcripts, and empty transcripts through shared helpers", () => { renderTranscript({ clineMessages: [makeMessage(1, "existing")], clineMessagesSeq: 1 }) diff --git a/webview-ui/src/utils/test-utils.tsx b/webview-ui/src/utils/test-utils.tsx index 617e18a1ea..a6111fb8c2 100644 --- a/webview-ui/src/utils/test-utils.tsx +++ b/webview-ui/src/utils/test-utils.tsx @@ -45,10 +45,11 @@ export const dispatchExtensionMessage = (message: ExtensionMessage) => { export const hydrateExtensionState = ( state: Partial, - options: { taskId?: string; clineMessagesSeq?: number } = {}, + options: { taskId?: string; taskInstanceId?: string; clineMessagesSeq?: number } = {}, ) => { const { clineMessages, clineMessagesSeq: stateSeq, ...metadataState } = state const taskId = options.taskId ?? metadataState.currentTaskId ?? undefined + const taskInstanceId = options.taskInstanceId ?? metadataState.currentTaskInstanceId ?? undefined const clineMessagesSeq = options.clineMessagesSeq ?? stateSeq ?? 0 dispatchExtensionMessage({ @@ -64,6 +65,7 @@ export const hydrateExtensionState = ( dispatchExtensionMessage({ type: "clineMessagesSnapshotStart", taskId, + taskInstanceId, clineMessagesSeq, snapshotId, snapshotTotal: clineMessages.length, @@ -73,6 +75,7 @@ export const hydrateExtensionState = ( dispatchExtensionMessage({ type: "clineMessagesSnapshotChunk", taskId, + taskInstanceId, clineMessagesSeq, snapshotId, snapshotStartIndex: 0, @@ -83,6 +86,7 @@ export const hydrateExtensionState = ( dispatchExtensionMessage({ type: "clineMessagesSnapshotEnd", taskId, + taskInstanceId, clineMessagesSeq, snapshotId, snapshotTotal: clineMessages.length, From aa72640e18f9eb0390667275c5c1e17092f429f3 Mon Sep 17 00:00:00 2001 From: Gh0st352 Date: Sat, 19 Sep 2026 02:47:27 -0600 Subject: [PATCH 40/40] fix(webview): preserve transcript persistence and renderer cleanup Reject failed transcript replacements before snapshot publication, preserve safe rollback semantics, and remove duplicate rewind saves. Shut down renderer-owned transport work, detach pending sends, and guard reopened webviews against stale continuations. Extend transport models and regression coverage. Validation: 12,654 Vitest tests passed (40 skipped), one Node test file passed, 719 focused tests passed, lifecycle models passed, and fresh workspace types/lint passed. Mutation-runner tests: 42 passed. Full-PR mutation execution remains blocked by 567 changed extension executable lines versus the unchanged 500-line cap; no budget or exclusion relaxed. --- .../transcript-transport-model.md | 68 ++- src/core/task/Task.ts | 84 ++- .../task/__tests__/Task.persistence.spec.ts | 322 ++++++++++- src/core/task/__tests__/Task.spec.ts | 26 +- src/core/webview/ClineProvider.ts | 129 +++-- .../webview/__tests__/ClineProvider.spec.ts | 524 +++++++++++++++--- .../ClineProvider.taskHistory.spec.ts | 6 +- .../__tests__/transcriptTransport.model.ts | 206 ++++++- .../__tests__/transcriptTransport.spec.ts | 279 +++++++++- .../webviewMessageHandler.checkpoint.spec.ts | 18 +- .../webviewMessageHandler.delete.spec.ts | 16 +- .../webviewMessageHandler.edit.spec.ts | 34 +- src/core/webview/transcriptTransport.ts | 145 ++++- src/core/webview/webviewMessageHandler.ts | 63 +-- src/eslint-suppressions.json | 6 +- 15 files changed, 1622 insertions(+), 304 deletions(-) diff --git a/docs/architecture/transcript-transport-model.md b/docs/architecture/transcript-transport-model.md index 67d3776247..a6ed4cee58 100644 --- a/docs/architecture/transcript-transport-model.md +++ b/docs/architecture/transcript-transport-model.md @@ -4,14 +4,20 @@ Run the focused checker with **pnpm transcript-transport:model-check**. It also ## Production boundary -[`TranscriptTransport`](../../src/core/webview/transcriptTransport.ts:223) 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. +[`TranscriptTransport`](../../src/core/webview/transcriptTransport.ts) owns generation, task-scoped sequence allocation, instance-scoped FIFO job descriptors, snapshot progress, and one physical-send barrier **per renderer session**. [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:194); it never derives identity from later focus. +[`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:227); 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. +The driver and the explorer both call [reduceTranscriptTransport](../../src/core/webview/transcriptTransport.ts) for admission, allocation, invalidation, shutdown/reopen, task-sequence pruning, send initiation, and frame-scoped 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 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. +**Renderer shutdown is not task invalidation.** Shutdown closes admission before payload capture, releases queued/active/in-flight descriptors and payloads, and resolves every pending caller as dropped, including an already-invalidated physical caller. It also clears the focused-task, post, and error callbacks and detaches the physical completion slot. Promise observers retain only that empty slot, not a suspended driver method, payload, caller resolver, or provider callback. Late success and rejection are consumed without logging, settling a new caller, or pumping a new renderer. The shared reducer independently requires matching job ID, originating generation, phase, and chunk start for settlement; comparing to the current generation would incorrectly block ordinary invalidation's legitimate old-send settlement. + +Shutdown is idempotent. Reopen only acts on a closed transport and installs fresh callbacks. Closing and reopening each advance generation once; neither resets task sequences, job IDs, or snapshot IDs. A generation captured before or during closure cannot acquire a reopened renderer, even with unchanged task/instance focus. Ordinary invalidation still advances generation and retains the physical barrier. Task removal/history deletion retains its separate sequence-pruning semantics. + +The provider shuts down transport synchronously on sidebar disposal and before the first awaited task cleanup during provider disposal. Sidebar closure does **not** abort, dispose, evict, or otherwise change background tasks. Resolving a new renderer closes prior resources and reopens the same transport with a callback bound to the captured view/session; webview launch performs the normal authoritative focus/state/snapshot sync. Disposed providers cannot reopen. Renderer disposal also cancels pending state debounce work, resets launch visibility, clears view/index references and the matching global panel reference, and disposes the renderer's disposal listener rather than accumulating it in provider-lifetime disposables. Session checks prevent stale disposal/message listeners, async initialization, focus sync, theme lookup, and generic metadata assembly from targeting or tearing down a replacement. Physical post result handlers are module-scoped and retain no provider or payload while the editor Promise is stalled. + 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. **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. @@ -24,21 +30,24 @@ The adapter regressions in [Task.persistence.spec.ts](../../src/core/task/__test ## 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 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. +The [explorer](../../src/core/webview/__tests__/transcriptTransport.model.ts) uses deterministic breadth-first search with canonical state deduplication. It explores every enabled ordering in ten 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, invalidation, renderer disposal/reopen, and detached late completion 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 | -| 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 | +| 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 | +| Renderer disposal / reopen | snapshot, append | shutdown, shutdown, reopen, snapshot | 1,483 | 2,489 | 24 | +| Renderer reopen / no task | snapshot | clear, shutdown, reopen, empty snapshot | 1,044 | 1,592 | 21 | +| Renderer reopen / new instance | snapshot, stale-instance append | shutdown, replace instance, reopen, sync instance | 645 | 1,144 | 20 | -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. +These totals are diagnostics, not hard-coded ratchets: 40,099 states across independently explored scenarios and 61,303 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, at most two shutdown calls and one reopen, four messages per snapshot, chunk size two, and at most one failed physical send per trace**. The disposal scenarios can retain one retired physical send alongside the reopened renderer's send. 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 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. +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 ten 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. @@ -46,16 +55,17 @@ The replacement action publishes the same task ID with a new instance to both pr ## Invariants and scope -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. +1. Generation increases exactly once per invalidation, open-to-closed shutdown, or closed-to-open reopen. Repeated shutdown/reopen calls do not reset IDs or sequences. Stale-generation admission allocates no job or snapshot ID. Closed, 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 **in the same renderer**, including an old generation's or instance's held send. A retired renderer's send may remain held while its successor sends. No old-generation, old-task, old-instance, or old-renderer 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 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. +7. Shutdown leaves no queue, active job, in-flight descriptor, payload, or caller; this includes shutdown after invalidation. Late physical success/failure for retired work has no reducer effects and cannot change the live send. Reopen preserves allocation history while resetting the independent renderer receiver's visible/staged state. Retired wire delivery belongs to the dead renderer and is not delivered to the new receiver; concrete view binding and callback detachment are verified by driver/provider tests, not inferred from map sizes or this oracle. 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 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: +All 27 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, shutdown, reopen, late-resolve, late-reject. All 34 named reachability landmarks are required: - held-post-with-queued-delta; - repeated-invalidation-while-held; @@ -83,13 +93,25 @@ All 23 action classes are required: snapshot, append, update, resync, invalidate - 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). +- shutdown-releases-held-and-queued-callers (requires both a physical send and queued work at shutdown); +- repeated-shutdown-with-held-send; +- closed-admission-rejected; +- reopened-renderer-sends-before-dead-renderer-settles; +- late-resolve-does-not-settle-new-send; +- late-reject-does-not-settle-new-send; +- reopened-snapshot-committed; +- reopened-no-task-snapshot-committed. ## Invariant sensitivity -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. +Twenty-four 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 | | ----------------------------------- | ---------------------------------------------------------------------------------- | --------------------------------------- | +| shutdown-retains-physical-caller | snapshot, pump, shutdown | shutdown retained transport ownership | +| admit-after-shutdown | shutdown, snapshot | closed admission allocated work | +| reopen-resets-identities | clear, shutdown, reopen | renderer boundary reset identities | +| late-completion-settles-live-send | clear, pump, shutdown, reopen, snapshot, pump, late-resolve | late completion changed live transport | | 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 | @@ -111,18 +133,20 @@ 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 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. +[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 ten 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. 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. +Disposal driver tests hold all five frame phases through repeated shutdown, settle queued and active callers before physical completion, assert callback/continuation detachment, and reopen with new-instance work while the old physical send remains held. Both late success and rejection must leave a held new send untouched. Additional cases cover invalidation before shutdown, no-task snapshots, pre-clone closed admission, preserved IDs/sequences, stale generations from before/during closure, repeated live reopen, and shutdown/reopen reentered during cloning. Provider integration tests invoke real disposal listeners and the physical webview post boundary, prove background task survival, and cover stale listeners, delayed focus/metadata/initialization, view re-resolution, and irreversible provider shutdown before awaited task cleanup. Tests do not use GC timing as an oracle. + ## 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 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. 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. +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 blocks later physical transcript posts **until that renderer is shut down**, although obsolete waiting jobs are still released on invalidation. Reopen can send without waiting for the dead renderer, but does not cancel the editor-owned physical operation. Memory claims concern removal of owned references, not immediate garbage collection or memory retained by the editor's already-initiated post. Arbitrary unrelated provider operations already awaiting external services are not cancelled by renderer shutdown. -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. +This bounded check does not prove arbitrary queue lengths, sequence overflow, repeated instance replacement or arbitrary task-ID reuse, arbitrary renderer reopen cycles, instance-ID uniqueness/collision resistance, message validation, or all payload values. It assumes opaque distinct instance identities and models one instance replacement and one renderer reopen 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/src/core/task/Task.ts b/src/core/task/Task.ts index 76020145a6..4305697a85 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -4,6 +4,7 @@ import os from "os" import crypto from "crypto" import { v7 as uuidv7 } from "uuid" import EventEmitter from "events" +import { isDeepStrictEqual } from "util" import { AskIgnoredError } from "./AskIgnoredError" import { RateLimitClock, createRateLimitClock } from "./RateLimitClock" @@ -206,6 +207,17 @@ type AssistantMessagePersistenceCancellation = { resolve: () => void } +/** A transcript and its derived task history are separate, non-transactional writes. */ +export class ClineMessagesPersistenceError extends Error { + constructor( + cause: unknown, + public readonly transcriptPersisted: boolean, + ) { + super(cause instanceof Error ? cause.message : String(cause), { cause }) + this.name = "ClineMessagesPersistenceError" + } +} + export class Task extends EventEmitter implements TaskLike { readonly taskId: string readonly rootTaskId?: string @@ -347,6 +359,8 @@ export class Task extends EventEmitter implements TaskLike { // LLM Messages & Chat Messages apiConversationHistory: ApiMessage[] = [] clineMessages: ClineMessage[] = [] + private clineMessagesSaveVersion = 0 + private pendingClineMessageReplacements = 0 // Ask private askResponse?: ClineAskResponse @@ -1312,13 +1326,56 @@ export class Task extends EventEmitter implements TaskLike { /** * Replaces the entire Cline message history, restores todo state, and persists. * Also resets cloud sync tracking to avoid re-syncing previously synced messages. + * Rejects without publishing a snapshot on persistence failure. Only a failed + * transcript write can restore the previous in-memory state, and only while no + * newer save or mutation has superseded it. Metadata/history failures retain the + * already-persisted replacement; these writes cannot be rolled back atomically. */ public async overwriteClineMessages(newMessages: ClineMessage[], persist = true) { this.debouncedPostPartialMessageUpdate.cancel() - this.hydrateClineMessages(newMessages) + const previousMessages = this.clineMessages + const previousTodos = this.todoList + const previousCloudSyncedTimestamps = new Set(this.cloudSyncedMessageTimestamps) + // Give every replacement its own identity, even when a caller passes the live array. + this.hydrateClineMessages([...newMessages]) + const replacement = this.clineMessages if (persist) { - await this.saveClineMessages(false) + const replacementTodos = this.todoList + const snapshot = structuredClone({ + messages: replacement, + todos: replacementTodos, + cloudSyncedTimestamps: this.cloudSyncedMessageTimestamps, + }) + const saveVersion = this.clineMessagesSaveVersion + 1 + // A previous pending replacement is not a safe rollback target: its own + // write may fail while this write is in flight. Keep live state in that case. + const canRestorePrevious = this.pendingClineMessageReplacements++ === 0 + try { + await this.persistClineMessages(false) + } catch (error) { + if ( + canRestorePrevious && + error instanceof ClineMessagesPersistenceError && + !error.transcriptPersisted && + this.clineMessagesSaveVersion === saveVersion && + this.clineMessages === replacement && + this.todoList === replacementTodos && + isDeepStrictEqual(this.clineMessages, snapshot.messages) && + isDeepStrictEqual(this.todoList, snapshot.todos) && + isDeepStrictEqual(this.cloudSyncedMessageTimestamps, snapshot.cloudSyncedTimestamps) + ) { + this.clineMessages = previousMessages + this.todoList = previousTodos + this.cloudSyncedMessageTimestamps = previousCloudSyncedTimestamps + } + throw error + } finally { + this.pendingClineMessageReplacements-- + } } + // The provider snapshots live state. An older save must not publish a newer, + // still-pending replacement on its behalf (that replacement may fail). + if (this.clineMessages !== replacement) return await this.providerRef.deref()?.postClineMessagesSnapshot(this.taskId, { bumpSeq: true, taskInstanceId: this.instanceId, @@ -1373,12 +1430,27 @@ export class Task extends EventEmitter implements TaskLike { /** Persists Cline messages and updates task metadata in the history store. Returns false on failure. */ private async saveClineMessages(merge = true): Promise { try { + await this.persistClineMessages(merge) + return true + } catch (error) { + console.error("Failed to save Roo messages:", error) + return false + } + } + + /** Strict persistence boundary for replacements; streaming saves keep their boolean contract. */ + private async persistClineMessages(merge = true): Promise { + this.clineMessagesSaveVersion++ + let transcriptPersisted = false + try { + const messages = structuredClone(this.clineMessages) await saveTaskMessages({ - messages: structuredClone(this.clineMessages), + messages, taskId: this.taskId, globalStoragePath: this.globalStoragePath, merge, }) + transcriptPersisted = true if (this._taskApiConfigName === undefined) { await this.taskApiConfigReady @@ -1389,7 +1461,7 @@ export class Task extends EventEmitter implements TaskLike { rootTaskId: this.rootTaskId, parentTaskId: this.parentTaskId, taskNumber: this.taskNumber, - messages: this.clineMessages, + messages, globalStoragePath: this.globalStoragePath, workspace: this.cwd, mode: this._taskMode || defaultModeSlug, // Use the task's own mode, not the current provider mode. @@ -1407,10 +1479,8 @@ export class Task extends EventEmitter implements TaskLike { const provider = this.providerRef.deref() const existingStatus = provider?.taskHistoryStore.get(this.taskId)?.status await provider?.updateTaskHistory(existingStatus ? { ...historyItem, status: existingStatus } : historyItem) - return true } catch (error) { - console.error("Failed to save Roo messages:", error) - return false + throw new ClineMessagesPersistenceError(error, transcriptPersisted) } } diff --git a/src/core/task/__tests__/Task.persistence.spec.ts b/src/core/task/__tests__/Task.persistence.spec.ts index 82886ae048..b094e89ada 100644 --- a/src/core/task/__tests__/Task.persistence.spec.ts +++ b/src/core/task/__tests__/Task.persistence.spec.ts @@ -2,6 +2,7 @@ import * as os from "os" import * as path from "path" +import * as fs from "fs/promises" import * as vscode from "vscode" import { @@ -10,12 +11,14 @@ import { type GlobalState, type PendingTaskAction, type ProviderSettings, + type TodoItem, } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" import type { Anthropic } from "@anthropic-ai/sdk" -import { Task } from "../Task" +import { ClineMessagesPersistenceError, Task } from "../Task" import { ClineProvider } from "../../webview/ClineProvider" +import { webviewMessageHandler } from "../../webview/webviewMessageHandler" import { ContextProxy } from "../../config/ContextProxy" import { providerIdentifiers } from "@roo-code/types/provider-identifiers" import { attemptCompletionTool, type AttemptCompletionCallbacks } from "../../tools/AttemptCompletionTool" @@ -471,10 +474,11 @@ describe("Task persistence", () => { vi.useFakeTimers() const oldTask = createTask() const replacement = createTask() - const saved = createDeferred() + const saved = createDeferred() try { await mockProvider.addClineToStack(oldTask) - oldTask["saveClineMessages"] = vi.fn().mockReturnValueOnce(saved.promise).mockResolvedValue(true) + mockSaveTaskMessages.mockReturnValueOnce(saved.promise) + oldTask["saveClineMessages"] = vi.fn().mockResolvedValue(true) replacement["saveClineMessages"] = vi.fn().mockResolvedValue(true) await oldTask["updateClineMessage"]({ ...message("leading"), partial: true }) await oldTask["updateClineMessage"]({ ...message("delayed trailing"), partial: true }) @@ -487,7 +491,7 @@ describe("Task persistence", () => { post.mockClear() const transport = mockProvider["clineMessagesTransport"] const before = transport["state"] - saved.resolve(true) + saved.resolve() await overwrite await vi.advanceTimersByTimeAsync(500) await oldTask["addToClineMessages"](message("late append")) @@ -521,7 +525,7 @@ describe("Task persistence", () => { seq + 3, ]) } finally { - saved.resolve(true) + saved.resolve() oldTask["debouncedPostPartialMessageUpdate"].cancel() replacement["debouncedPostPartialMessageUpdate"].cancel() vi.useRealTimers() @@ -1198,6 +1202,314 @@ describe("Task persistence", () => { // ── saveClineMessages ──────────────────────────────────────────────── + describe("replacement persistence integrity", () => { + const message = (ts: number, text: string): ClineMessage => ({ ts, type: "say", say: "text", text }) + const todos: TodoItem[] = [{ id: "todo", content: "Replacement todo", status: "in_progress" }] + const replacement = (): ClineMessage[] => [ + { ts: 2, type: "ask", ask: "tool", text: JSON.stringify({ tool: "updateTodoList", todos }) }, + { ...message(3, "partial"), partial: true }, + ] + const createTask = () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + task["hydrateClineMessages"]([message(1, "original")]) + return task + } + + it("rejects a transcript write error without a snapshot and restores the untouched prior state", async () => { + const task = createTask() + const original = task.clineMessages + const originalTodos = task.todoList + // Cloud tracking need not be identical to all non-partial transcript timestamps. + task["cloudSyncedMessageTimestamps"].add(99) + const onMessage = vi.fn() + task.on(RooCodeEventName.Message, onMessage) + const cause = new Error("disk full") + mockSaveTaskMessages.mockRejectedValueOnce(cause) + + await expect(task.overwriteClineMessages(replacement())).rejects.toMatchObject({ + name: "ClineMessagesPersistenceError", + cause, + transcriptPersisted: false, + }) + + expect(task.clineMessages).toBe(original) + expect(task.todoList).toBe(originalTodos) + expect(task["cloudSyncedMessageTimestamps"]).toEqual(new Set([1, 99])) + expect(mockTaskMetadata).not.toHaveBeenCalled() + expect(mockProvider.updateTaskHistory).not.toHaveBeenCalled() + expect(mockProvider.postClineMessagesSnapshot).not.toHaveBeenCalled() + expect(onMessage).not.toHaveBeenCalled() + }) + + it.each(["metadata", "history"] as const)( + "retains the persisted replacement but rejects without a snapshot on %s failure", + async (phase) => { + const task = createTask() + const messages = replacement() + const cause = new Error(`${phase} failed`) + if (phase === "metadata") mockTaskMetadata.mockRejectedValueOnce(cause) + else vi.mocked(mockProvider.updateTaskHistory).mockRejectedValueOnce(cause) + + await expect(task.overwriteClineMessages(messages)).rejects.toMatchObject({ + name: "ClineMessagesPersistenceError", + cause, + transcriptPersisted: true, + }) + + expect(mockSaveTaskMessages).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ messages, merge: false }), + ) + expect(task.clineMessages).toEqual(messages) + expect(task.todoList).toEqual(todos) + expect(task["cloudSyncedMessageTimestamps"]).toEqual(new Set([2])) + expect(mockProvider.postClineMessagesSnapshot).not.toHaveBeenCalled() + expect(mockProvider.updateTaskHistory).toHaveBeenCalledTimes(phase === "history" ? 1 : 0) + }, + ) + + it.each(["transcript", "metadata", "history"] as const)( + "keeps the correct on-disk transcript after a real-file %s failure", + async (phase) => { + const actualFs = await vi.importActual("fs/promises") + const { saveTaskMessages } = await vi.importActual< + typeof import("../../task-persistence/taskMessages") + >("../../task-persistence/taskMessages") + const { GlobalFileNames } = await import("../../../shared/globalFileNames") + const storage = await actualFs.mkdtemp(path.join(os.tmpdir(), "replacement-integrity-")) + const task = createTask() + const original = task.clineMessages + const messages = replacement() + const transcriptPath = path.join(storage, "tasks", task.taskId, GlobalFileNames.uiMessages) + try { + await vi.mocked(fs.mkdir).withImplementation(actualFs.mkdir, async () => { + await saveTaskMessages({ messages: original, taskId: task.taskId, globalStoragePath: storage }) + let failedStorage = storage + if (phase === "transcript") { + // A regular file cannot be a storage directory: exercise a real filesystem rejection. + failedStorage = path.join(storage, "not-a-directory") + await actualFs.writeFile(failedStorage, "blocked") + } else if (phase === "metadata") { + mockTaskMetadata.mockRejectedValueOnce(new Error("metadata failed")) + } else { + vi.mocked(mockProvider.updateTaskHistory).mockRejectedValueOnce(new Error("history failed")) + } + mockSaveTaskMessages.mockImplementationOnce((options) => + saveTaskMessages({ ...options, globalStoragePath: failedStorage }), + ) + await expect(task.overwriteClineMessages(messages)).rejects.toMatchObject({ + name: "ClineMessagesPersistenceError", + transcriptPersisted: phase !== "transcript", + }) + }) + + const diskMessages: unknown = JSON.parse(await actualFs.readFile(transcriptPath, "utf8")) + expect(diskMessages).toEqual(phase === "transcript" ? original : messages) + expect(task.clineMessages).toEqual(diskMessages) + expect(mockProvider.postClineMessagesSnapshot).not.toHaveBeenCalled() + } finally { + await actualFs.rm(storage, { recursive: true, force: true }) + } + }, + ) + + it.each([true, false])("preserves hydration and publication semantics with persist=%s", async (persist) => { + const task = createTask() + const messages = replacement() + const onMessage = vi.fn() + task.on(RooCodeEventName.Message, onMessage) + + await expect(task.overwriteClineMessages(messages, persist)).resolves.toBeUndefined() + + expect(task.clineMessages).toEqual(messages) + expect(task.todoList).toEqual(todos) + expect(task["cloudSyncedMessageTimestamps"]).toEqual(new Set([2])) + expect(mockSaveTaskMessages).toHaveBeenCalledTimes(persist ? 1 : 0) + expect(mockTaskMetadata).toHaveBeenCalledTimes(persist ? 1 : 0) + expect(mockProvider.updateTaskHistory).toHaveBeenCalledTimes(persist ? 1 : 0) + expect(mockProvider.postClineMessagesSnapshot).toHaveBeenCalledExactlyOnceWith(task.taskId, { + bumpSeq: true, + taskInstanceId: task.instanceId, + }) + expect(onMessage).not.toHaveBeenCalled() + }) + + it.each(["append", "update", "save", "overwrite", "hydrate", "todos", "cloud"] as const)( + "does not roll back a concurrent %s when an older transcript write fails", + async (operation) => { + const task = createTask() + const held = createDeferred() + const cause = new Error("delayed write failure") + mockSaveTaskMessages.mockImplementationOnce(async () => { + await held.promise + throw cause + }) + const failed = expect(task.overwriteClineMessages(replacement())).rejects.toMatchObject({ + cause, + transcriptPersisted: false, + }) + try { + if (operation === "append") { + await task["addToClineMessages"](message(4, "concurrent append")) + } else if (operation === "update") { + task.clineMessages[1].text = "concurrent update" + await task["updateClineMessage"](task.clineMessages[1]) + } else if (operation === "save") { + // Even an identical successful save prevents rollback to pre-replacement memory. + await expect(task["saveClineMessages"]()).resolves.toBe(true) + } else if (operation === "todos") { + task.todoList![0].status = "completed" + } else if (operation === "cloud") { + task["cloudSyncedMessageTimestamps"].add(3) + } else { + // Passing the same array must still establish a new replacement owner. + await task.overwriteClineMessages(task.clineMessages, operation === "overwrite") + } + const current = task.clineMessages + const currentTodos = task.todoList + const currentSynced = new Set(task["cloudSyncedMessageTimestamps"]) + const snapshots = vi.mocked(mockProvider.postClineMessagesSnapshot).mock.calls.length + held.resolve() + await failed + + expect(task.clineMessages).toBe(current) + expect(task.todoList).toBe(currentTodos) + expect(task["cloudSyncedMessageTimestamps"]).toEqual(currentSynced) + expect(mockProvider.postClineMessagesSnapshot).toHaveBeenCalledTimes(snapshots) + } finally { + held.resolve() + await failed + task["debouncedPostPartialMessageUpdate"].cancel() + } + }, + ) + + it("does not publish a newer pending replacement when an older save succeeds", async () => { + const task = createTask() + const firstWrite = createDeferred() + const secondWrite = createDeferred() + const cause = new Error("newer write failed") + mockSaveTaskMessages.mockReturnValueOnce(firstWrite.promise).mockImplementationOnce(async () => { + await secondWrite.promise + throw cause + }) + const first = task.overwriteClineMessages([message(2, "persisted")]) + const second = expect(task.overwriteClineMessages([message(3, "not persisted")])).rejects.toMatchObject({ + cause, + transcriptPersisted: false, + }) + try { + firstWrite.resolve() + await first + expect(mockTaskMetadata).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ messages: [expect.objectContaining({ text: "persisted" })] }), + ) + expect(mockProvider.postClineMessagesSnapshot).not.toHaveBeenCalled() + secondWrite.resolve() + await second + // The prior replacement was pending at admission, not a safe rollback target. + expect(task.clineMessages).toEqual([expect.objectContaining({ text: "not persisted" })]) + expect(mockProvider.postClineMessagesSnapshot).not.toHaveBeenCalled() + } finally { + firstWrite.resolve() + secondWrite.resolve() + await Promise.all([first, second]) + } + }) + + it("does not resurrect an earlier failed replacement when overlapping writes both fail", async () => { + const task = createTask() + const held = createDeferred() + mockSaveTaskMessages.mockImplementationOnce(async () => { + await held.promise + throw new Error("first write failed") + }) + const first = expect(task.overwriteClineMessages([message(2, "first")])).rejects.toThrow( + "first write failed", + ) + mockSaveTaskMessages.mockImplementationOnce(async () => { + await first + throw new Error("second write failed") + }) + const second = expect(task.overwriteClineMessages([message(3, "second")])).rejects.toThrow( + "second write failed", + ) + held.resolve() + await Promise.all([first, second]) + + expect(task.clineMessages).toEqual([expect.objectContaining({ text: "second" })]) + expect(mockProvider.postClineMessagesSnapshot).not.toHaveBeenCalled() + }) + + it("propagates the strict failure while ordinary saves keep returning false", async () => { + const task = createTask() + mockTaskMetadata.mockRejectedValueOnce(new Error("metadata failed")) + await expect(task["saveClineMessages"]()).resolves.toBe(false) + mockSaveTaskMessages.mockRejectedValueOnce(new Error("write failed")) + await expect(task.overwriteClineMessages(replacement())).rejects.toBeInstanceOf( + ClineMessagesPersistenceError, + ) + }) + + describe.each(["deleteMessageConfirm", "editMessageConfirm"] as const)("%s", (type) => { + const setup = () => { + const task = createTask() + const kept: ClineMessage = { + ...message(1, "kept"), + checkpoint: { hash: "checkpoint-hash" }, + } + task["hydrateClineMessages"]([kept, { ...message(2, "remove"), say: "user_feedback" }]) + task.apiConversationHistory = [ + { ts: 1, role: "user", content: "kept" }, + { ts: 2, role: "user", content: "remove" }, + ] + vi.spyOn(mockProvider, "getCurrentTask").mockReturnValue(task) + const submit = vi.spyOn(task, "submitUserMessage").mockResolvedValue(undefined) + return { task, kept, submit } + } + + it.each(["transcript", "metadata", "history"] as const)( + "reports a %s failure and stops before API rewind or edited-message submission", + async (phase) => { + const { task, kept, submit } = setup() + const original = task.clineMessages + const cause = new Error(`${phase} failed`) + if (phase === "transcript") mockSaveTaskMessages.mockRejectedValueOnce(cause) + else if (phase === "metadata") mockTaskMetadata.mockRejectedValueOnce(cause) + else vi.mocked(mockProvider.updateTaskHistory).mockRejectedValueOnce(cause) + + await webviewMessageHandler(mockProvider, { type, messageTs: 2, text: "edited" }) + + expect(vscode.window.showErrorMessage).toHaveBeenCalledOnce() + expect(mockSaveTaskMessages).toHaveBeenCalledTimes(1) + expect(mockSaveApiMessages).not.toHaveBeenCalled() + expect(mockProvider.postClineMessagesSnapshot).not.toHaveBeenCalled() + expect(submit).not.toHaveBeenCalled() + expect(task.clineMessages).toEqual(phase === "transcript" ? original : [kept]) + }, + ) + + it("persists and snapshots once, retaining checkpoint metadata", async () => { + const { task, kept, submit } = setup() + + await webviewMessageHandler(mockProvider, { type, messageTs: 2, text: "edited" }) + + expect(vscode.window.showErrorMessage).not.toHaveBeenCalled() + expect(task.clineMessages).toEqual([kept]) + expect(mockSaveTaskMessages).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ messages: [kept], merge: false }), + ) + expect(mockProvider.postClineMessagesSnapshot).toHaveBeenCalledOnce() + expect(mockSaveApiMessages).toHaveBeenCalledOnce() + expect(submit).toHaveBeenCalledTimes(type === "editMessageConfirm" ? 1 : 0) + }) + }) + }) + describe("saveClineMessages", () => { it("returns true on success", async () => { mockSaveTaskMessages.mockResolvedValueOnce([]) diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index f28efa9240..a012093861 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -2194,11 +2194,12 @@ describe("Cline", () => { task: "test task", startTask: false, }) - let releaseSave!: (saved: boolean) => void - const pendingSave = new Promise((resolve) => { + let releaseSave!: () => void + const pendingSave = new Promise((resolve) => { releaseSave = resolve }) - const saveSpy = vi.spyOn(getTaskTestAccess(task), "saveClineMessages").mockReturnValueOnce(pendingSave) + const saveSpy = vi.fn().mockReturnValueOnce(pendingSave) + task["persistClineMessages"] = saveSpy const messages = [ { ts: 1, @@ -2215,7 +2216,7 @@ describe("Cline", () => { expect(saveSpy).toHaveBeenCalledWith(false) expect(mockProvider.postClineMessagesSnapshot).not.toHaveBeenCalled() - releaseSave(true) + releaseSave() await overwritePromise expect(saveSpy).toHaveBeenCalledOnce() @@ -2233,7 +2234,8 @@ describe("Cline", () => { task: "test task", startTask: false, }) - const saveSpy = vi.spyOn(getTaskTestAccess(task), "saveClineMessages").mockResolvedValue(true) + const saveSpy = vi.fn().mockResolvedValue(undefined) + task["persistClineMessages"] = saveSpy let releaseSnapshot!: () => void const pendingSnapshot = new Promise((resolve) => { releaseSnapshot = resolve @@ -2271,11 +2273,11 @@ describe("Cline", () => { startTask: false, }) const taskAccess = getTaskTestAccess(task) - let releaseSave!: (saved: boolean) => void - const pendingSave = new Promise((resolve) => { + let releaseSave!: () => void + const pendingSave = new Promise((resolve) => { releaseSave = resolve }) - vi.spyOn(taskAccess, "saveClineMessages").mockReturnValueOnce(pendingSave) + task["persistClineMessages"] = vi.fn().mockReturnValueOnce(pendingSave) const staleMessage = { ts: 1, type: "say" as const, @@ -2298,7 +2300,7 @@ describe("Cline", () => { expect(updatePostSpy.mock.calls).toEqual([[task.taskId, firstMessage, task.instanceId]]) expect(mockProvider.postClineMessagesSnapshot).toHaveBeenCalledTimes(persist ? 0 : 1) - releaseSave(true) + releaseSave() await overwritePromise await vi.advanceTimersByTimeAsync(500) @@ -2326,7 +2328,8 @@ describe("Cline", () => { task: "test task", startTask: false, }) - const saveSpy = vi.spyOn(getTaskTestAccess(task), "saveClineMessages").mockResolvedValue(true) + const saveSpy = vi.fn().mockResolvedValue(undefined) + task["persistClineMessages"] = saveSpy 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" }] @@ -2348,7 +2351,8 @@ describe("Cline", () => { task: "test task", startTask: false, }) - const saveSpy = vi.spyOn(getTaskTestAccess(task), "saveClineMessages").mockResolvedValue(true) + const saveSpy = vi.fn().mockResolvedValue(undefined) + task["persistClineMessages"] = saveSpy Object.defineProperty(task, "providerRef", { value: { deref: () => undefined }, configurable: true, diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 0132d9f09b..192f68c6f8 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -102,7 +102,7 @@ import { getWorkspaceGitInfo } from "../../utils/git" import { getWorkspacePath } from "../../utils/path" import { OrganizationAllowListViolationError } from "../../utils/errors" -import { setPanel } from "../../activate/registerCommands" +import { getPanel, setPanel } from "../../activate/registerCommands" import { t } from "../../i18n" @@ -187,6 +187,18 @@ type GetStateOptions = { includeTaskHistory?: boolean } +// Do not suspend a provider method on an editor-owned Promise: a dead renderer +// can hold it forever. These result handlers retain neither provider nor payload. +function ignorePostResult(): void {} + +function postToWebview(webview: vscode.Webview | undefined, message: ExtensionMessage): Promise { + try { + return Promise.resolve(webview?.postMessage(message)).then(ignorePostResult, ignorePostResult) + } catch { + return Promise.resolve() + } +} + export class ClineProvider extends EventEmitter implements vscode.WebviewViewProvider, TelemetryPropertiesProvider, TaskProviderLike @@ -209,6 +221,7 @@ export class ClineProvider >() private nextThemeFixtureProbeId = 0 private view?: vscode.WebviewView | vscode.WebviewPanel + private webviewSession = 0 private taskRegistry = new TaskRegistry() private taskScheduler = new TaskScheduler() private static readonly delegationTransitionLocks = new Map>() @@ -824,6 +837,14 @@ export class ClineProvider - https://github.com/microsoft/vscode-extension-samples/blob/main/webview-sample/src/extension.ts */ private clearWebviewResources() { + this.clineMessagesTransport.shutdown() + this._postStateToWebviewThrottled.cancel() + this.webviewSession++ + if (this.view && getPanel() === this.view) { + setPanel(undefined, this.renderContext === "editor" ? "tab" : "sidebar") + } + this.view = undefined + this.isViewLaunched = false this.rejectPendingThemeFixtureProbes(new Error("Webview was disposed before the theme fixture probe completed")) while (this.webviewDisposables.length) { const x = this.webviewDisposables.pop() @@ -831,6 +852,8 @@ export class ClineProvider x.dispose() } } + this.codeIndexStatusSubscription = undefined + this.codeIndexManager = undefined } /** Drain one task's memoized cleanup without preventing the remaining provider shutdown work. */ @@ -850,7 +873,11 @@ export class ClineProvider } this._disposed = true - this._postStateToWebviewThrottled.cancel() + let view = this.view + // Close renderer ownership before any task cleanup can await a held post. + this.clearWebviewResources() + if (view && "dispose" in view) view.dispose() + view = undefined this.log("Disposing ClineProvider...") // Reject any tasks still waiting for a scheduler permit so they don't @@ -877,13 +904,6 @@ export class ClineProvider this.clearAllPendingEditOperations() this.log("Cleared pending operations") - if (this.view && "dispose" in this.view) { - this.view.dispose() - this.log("Disposed webview") - } - - this.clearWebviewResources() - // Clean up cloud service event listener if (CloudService.hasInstance()) { CloudService.instance.off("settings-updated", this.handleCloudSettingsUpdate) @@ -1029,8 +1049,35 @@ export class ClineProvider } async resolveWebviewView(webviewView: vscode.WebviewView | vscode.WebviewPanel) { + if (this._disposed) return + this.clearWebviewResources() this.view = webviewView + const session = this.webviewSession + const isCurrentView = () => !this._disposed && this.webviewSession === session && this.view === webviewView + this.clineMessagesTransport.reopen( + () => this.getCurrentTask()?.taskId, + (message) => (isCurrentView() ? this.postMessageToWebview(message, webviewView) : Promise.resolve()), + (error) => + this.log( + `[clineMessages] transport failure: ${error instanceof Error ? error.message : String(error)}`, + ), + () => this.getCurrentTask()?.instanceId, + ) const inTabMode = "onDidChangeViewState" in webviewView + // Register before initialization yields. The listener belongs to this renderer, + // not the provider's lifetime, and a late old listener cannot close its successor. + this.webviewDisposables.push( + webviewView.onDidDispose(async () => { + if (!isCurrentView()) return + if (inTabMode) { + this.log("Disposing ClineProvider instance for tab view") + await this.dispose() + } else { + this.log("Clearing webview resources for sidebar view") + this.clearWebviewResources() + } + }), + ) if (inTabMode) { setPanel(webviewView, "tab") @@ -1051,11 +1098,13 @@ export class ClineProvider localResourceRoots: resourceRoots, } - webviewView.webview.html = + const html = this.contextProxy.extensionMode === vscode.ExtensionMode.Development && process.env.ROO_CODE_THEME_FIXTURE_PROBE !== "1" ? await this.getHMRHtmlContent(webviewView.webview) : await this.getHtmlContent(webviewView.webview) + if (!isCurrentView()) return + webviewView.webview.html = html // Initialize out-of-scope variables that need to receive persistent // global state values. @@ -1073,6 +1122,7 @@ export class ClineProvider ttsEnabled, ttsSpeed, }) => { + if (!isCurrentView()) return Terminal.setShellIntegrationTimeout(terminalShellIntegrationTimeout) Terminal.setShellIntegrationDisabled(terminalShellIntegrationDisabled) Terminal.setCommandDelay(terminalCommandDelay) @@ -1086,6 +1136,7 @@ export class ClineProvider setTtsSpeed(ttsSpeed ?? 1) }, ) + if (!isCurrentView()) return // Sets up an event listener to listen for messages passed from the webview view context // and executes code based on the message that is received. @@ -1098,7 +1149,7 @@ export class ClineProvider // current workspace. const activeEditorSubscription = vscode.window.onDidChangeActiveTextEditor(() => { // Update subscription when workspace might have changed. - this.updateCodeIndexStatusSubscription() + if (isCurrentView()) this.updateCodeIndexStatusSubscription() }) this.webviewDisposables.push(activeEditorSubscription) @@ -1108,6 +1159,7 @@ export class ClineProvider // WebviewView and WebviewPanel have all the same properties except // for this visibility listener panel. const viewStateDisposable = webviewView.onDidChangeViewState(() => { + if (!isCurrentView()) return if (this.view?.visible) { void this.postMessageToWebview({ type: "action", action: "didBecomeVisible" }) } else { @@ -1119,6 +1171,7 @@ export class ClineProvider } else if ("onDidChangeVisibility" in webviewView) { // sidebar const visibilityDisposable = webviewView.onDidChangeVisibility(() => { + if (!isCurrentView()) return if (this.view?.visible) { void this.postMessageToWebview({ type: "action", action: "didBecomeVisible" }) } else { @@ -1129,29 +1182,12 @@ export class ClineProvider this.webviewDisposables.push(visibilityDisposable) } - // Listen for when the view is disposed - // This happens when the user closes the view or when the view is closed programmatically - webviewView.onDidDispose( - async () => { - if (inTabMode) { - this.log("Disposing ClineProvider instance for tab view") - await this.dispose() - } else { - this.log("Clearing webview resources for sidebar view") - this.clearWebviewResources() - // Reset current workspace manager reference when view is disposed - this.codeIndexManager = undefined - } - }, - null, - this.disposables, - ) - // Listen for when color changes const configDisposable = vscode.workspace.onDidChangeConfiguration(async (e) => { - if (e && e.affectsConfiguration("workbench.colorTheme")) { + if (isCurrentView() && e && e.affectsConfiguration("workbench.colorTheme")) { // Sends latest theme name to webview - await this.postMessageToWebview({ type: "theme", text: JSON.stringify(await getTheme()) }) + const text = JSON.stringify(await getTheme()) + if (isCurrentView()) await this.postMessageToWebview({ type: "theme", text }, webviewView) } }) this.webviewDisposables.push(configDisposable) @@ -1162,6 +1198,7 @@ export class ClineProvider if (!currentTask || currentTask.abandoned || currentTask.abort) { await this.removeClineFromStack() } + if (!isCurrentView()) return // Ensure zoo-gateway profile is seeded for users who signed in before this feature existed. // Without this, users with a valid cached token but no zoo-gateway profile would need to @@ -1486,9 +1523,9 @@ export class ClineProvider return task } - public async postMessageToWebview(message: ExtensionMessage) { - if (this._disposed) { - return + public postMessageToWebview(message: ExtensionMessage, view = this.view): Promise { + if (this._disposed || view !== this.view) { + return Promise.resolve() } if (message.type === "state" && message.state) { @@ -1502,7 +1539,7 @@ export class ClineProvider (message.state.currentTaskInstanceId !== undefined && message.state.currentTaskInstanceId !== (currentTask?.instanceId ?? null)) ) { - return + return Promise.resolve() } // Browser webviews use the dedicated transcript transport below. The CLI @@ -1518,11 +1555,7 @@ export class ClineProvider } } - try { - await this.view?.webview.postMessage(message) - } catch { - // View disposed, drop message silently - } + return postToWebview(view?.webview, message) } private invalidateClineMessagesTransport(): number { @@ -1560,6 +1593,7 @@ export class ClineProvider private postTranscript( request: TranscriptRequest & ({ kind: "snapshot" } | { kind: "append" | "update"; message: ClineMessage }), ): Promise { + if (this._disposed || this.clineMessagesTransport.closed) return Promise.resolve() const currentTask = this.getCurrentTask() // Every producer, including the legacy CLI path, must pass the same identity // check before reading or cloning payloads from the focused task. @@ -1579,6 +1613,7 @@ export class ClineProvider } public resyncClineMessagesToWebview(taskId?: string, expectedSeq?: unknown, receivedSeq?: unknown): Promise { + if (this._disposed || this.clineMessagesTransport.closed) return Promise.resolve() const currentTask = this.getCurrentTask() const currentTaskId = currentTask?.taskId if (currentTaskId !== taskId) { @@ -1608,8 +1643,11 @@ export class ClineProvider } public async syncFocusedTaskToWebview(options: { includeTaskHistory?: boolean } = {}): Promise { + if (this._disposed || this.clineMessagesTransport.closed) return const currentTask = this.getCurrentTask() + const session = this.webviewSession const generation = await this.publishFocusedTaskScope() + if (this.webviewSession !== session || this._disposed) return if (options.includeTaskHistory) { await this.postStateToWebview() } else { @@ -1849,8 +1887,11 @@ export class ClineProvider * @param webview A reference to the extension webview */ private setWebviewMessageListener(webview: vscode.Webview) { - const onReceiveMessage = async (message: WebviewMessage) => - webviewMessageHandler(this, message, this.marketplaceManager) + const session = this.webviewSession + const onReceiveMessage = async (message: WebviewMessage) => { + if (this._disposed || this.webviewSession !== session) return + await webviewMessageHandler(this, message, this.marketplaceManager) + } const messageDisposable = webview.onDidReceiveMessage(onReceiveMessage) this.webviewDisposables.push(messageDisposable) @@ -2571,7 +2612,9 @@ export class ClineProvider } async postStateToWebview() { + const session = this.webviewSession const state = await this.getStateToPostToWebview() + if (this.webviewSession !== session) return await this.postMessageToWebview({ type: "state", state }) } @@ -2584,7 +2627,9 @@ export class ClineProvider * `taskHistoryUpdated` / `taskHistoryItemUpdated`. */ async postStateToWebviewWithoutTaskHistory(): Promise { + const session = this.webviewSession const state = await this.getStateToPostToWebview({ includeTaskHistory: false }) + if (this.webviewSession !== session) return const { taskHistory: _omitHistory, ...metadataState } = state await this.postMessageToWebview({ type: "state", state: metadataState }) } diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index dc68b74983..607f324bfd 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -38,6 +38,7 @@ 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" +import { makeDisposable, makeEventEmitter } from "../../../test-utils/vscode" const { mockAddCustomInstructions, mockTaskConstructor } = vi.hoisted(() => ({ mockAddCustomInstructions: vi.fn().mockResolvedValue("Combined instructions"), @@ -515,10 +516,7 @@ describe("ClineProvider", () => { cspSource: "vscode-webview://test-csp-source", }, visible: true, - onDidDispose: vi.fn().mockImplementation((callback) => { - callback() - return { dispose: vi.fn() } - }), + onDidDispose: vi.fn(() => makeDisposable()), onDidChangeVisibility: vi.fn().mockImplementation(() => { return { dispose: vi.fn() } }), @@ -899,6 +897,292 @@ describe("ClineProvider", () => { }) describe("transcript transport", () => { + const sidebar = () => { + const disposed = makeEventEmitter() + const messages = makeEventEmitter() + const post = vi.fn<(message: ExtensionMessage) => Promise>().mockResolvedValue(true) + const onDidDispose = vi.fn(disposed.event) + const view: vscode.WebviewView = { + viewType: "test-sidebar", + visible: true, + show: vi.fn(), + onDidDispose, + onDidChangeVisibility: () => makeDisposable(), + webview: { + html: "", + options: {}, + cspSource: "test", + postMessage: post, + onDidReceiveMessage: messages.event, + asWebviewUri: (uri) => uri, + }, + } + return { view, post, disposed, onDidDispose } + } + + test.each([true, false])( + "sidebar disposal releases held/queued work without stopping its task (late success=%s)", + async (success) => { + const task = new Task(defaultTaskOptions) + Object.assign(task, { + taskId: "task-1", + instanceId: "instance-1", + clineMessages: [{ ts: 1, type: "say", text: "old" }], + }) + provider["taskRegistry"].push(task) + const old = sidebar() + await provider.resolveWebviewView(old.view) + const providerDisposables = provider["disposables"].length + let resolveOld!: (value: boolean) => void + let rejectOld!: (error: Error) => void + const held = new Promise((resolve, reject) => { + resolveOld = resolve + rejectOld = reject + }) + old.post.mockClear().mockReturnValueOnce(held) + const transport = provider["clineMessagesTransport"] + const snapshot = provider.postClineMessagesSnapshot(task.taskId, { + taskInstanceId: task.instanceId, + bumpSeq: true, + }) + const queued = [ + provider.postClineMessageAppended(task.taskId, task.clineMessages[0], task.instanceId), + provider.postClineMessageUpdated(task.taskId, task.clineMessages[0], task.instanceId), + provider.postClineMessagesSnapshot(task.taskId, { taskInstanceId: task.instanceId }), + ] + const completion = transport["pendingSend"]! + const oldSend = transport["callbacks"]!.postMessage + const sequence = transport.getSequence(task.taskId) + const oldGeneration = transport.generation + const staleDispose = old.onDidDispose.mock.calls[0][0] + old.disposed.fire() + old.disposed.fire() + await Promise.all([snapshot, ...queued]) + expect(provider.getCurrentTask()).toBe(task) + expect(task.abortTask).not.toHaveBeenCalled() + expect(task.dispose).not.toHaveBeenCalled() + expect(provider["_disposed"]).toBe(false) + expect(provider["view"]).toBeUndefined() + const { getPanel } = await import("../../../activate/registerCommands") + expect(getPanel()).not.toBe(old.view) + expect(provider["webviewDisposables"]).toEqual([]) + expect(provider["disposables"]).toHaveLength(providerDisposables) + expect(provider["codeIndexStatusSubscription"]).toBeUndefined() + expect(provider["codeIndexManager"]).toBeUndefined() + expect(completion.finish).toBeUndefined() + expect(transport["callbacks"]).toBeUndefined() + expect(transport["callers"].size).toBe(0) + expect(transport["payloads"].size).toBe(0) + const clone = vi.spyOn(globalThis, "structuredClone") + try { + await provider.postClineMessagesSnapshot(task.taskId, { taskInstanceId: task.instanceId }) + await provider.postClineMessageAppended(task.taskId, task.clineMessages[0], task.instanceId) + await provider.postClineMessageUpdated(task.taskId, task.clineMessages[0], task.instanceId) + await provider.resyncClineMessagesToWebview(task.taskId) + expect(clone).not.toHaveBeenCalled() + } finally { + clone.mockRestore() + } + const fresh = sidebar() + await provider.resolveWebviewView(fresh.view) + await staleDispose() + expect(provider["view"]).toBe(fresh.view) + expect(transport.closed).toBe(false) + const beforeStaleSend = fresh.post.mock.calls.length + await oldSend({ + type: "clineMessagesSnapshotEnd", + taskId: task.taskId, + taskInstanceId: task.instanceId, + snapshotId: "obsolete", + clineMessagesSeq: sequence, + }) + expect(fresh.post).toHaveBeenCalledTimes(beforeStaleSend) + task.clineMessages = [{ ts: 2, type: "say", text: "fresh" }] + await provider.syncFocusedTaskToWebview() + expect( + fresh.post.mock.calls + .filter(([frame]) => frame.type === "clineMessagesSnapshotChunk") + .map(([frame]) => frame.clineMessages), + ).toEqual([task.clineMessages]) + expect(transport.getSequence(task.taskId)).toBe(sequence) + let releaseFresh!: (value: boolean) => void + const freshPhysical = new Promise((resolve) => { + releaseFresh = resolve + }) + fresh.post.mockReturnValueOnce(freshPhysical) + const delta = provider.postClineMessageAppended(task.taskId, task.clineMessages[0], task.instanceId) + const currentState = transport["state"] + const count = fresh.post.mock.calls.length + if (success) resolveOld(true) + else rejectOld(new Error("disposed renderer failed")) + await held.catch(() => {}) + await new Promise((resolve) => setImmediate(resolve)) + expect(transport["state"]).toBe(currentState) + expect(fresh.post).toHaveBeenCalledTimes(count) + await provider.postClineMessagesSnapshot(task.taskId, { + taskInstanceId: task.instanceId, + generation: oldGeneration, + }) + expect(fresh.post).toHaveBeenCalledTimes(count) + releaseFresh(true) + await delta + expect(transport.getSequence(task.taskId)).toBe(sequence + 1) + expect(old.post).toHaveBeenCalledOnce() + expect( + fresh.post.mock.calls + .filter(([frame]) => frame.type.startsWith("clineMessage")) + .every(([frame]) => frame.taskInstanceId === task.instanceId), + ).toBe(true) + }, + ) + + test("reopens an empty sidebar and ignores a stale asynchronous focus sync", async () => { + const old = sidebar() + await provider.resolveWebviewView(old.view) + let release!: (value: boolean) => void + old.post.mockReturnValueOnce( + new Promise((resolve) => { + release = resolve + }), + ) + const staleSync = provider.syncFocusedTaskToWebview() + old.disposed.fire() + const fresh = sidebar() + await provider.resolveWebviewView(fresh.view) + await provider.syncFocusedTaskToWebview() + const before = fresh.post.mock.calls.length + release(true) + await staleSync + expect(fresh.post).toHaveBeenCalledTimes(before) + expect( + fresh.post.mock.calls + .filter(([frame]) => frame.type.startsWith("clineMessagesSnapshot")) + .map(([frame]) => [frame.type, frame.taskId, frame.clineMessagesSeq]), + ).toEqual([ + ["clineMessagesSnapshotStart", undefined, 0], + ["clineMessagesSnapshotEnd", undefined, 0], + ]) + }) + + test.each(["postStateToWebview", "postStateToWebviewWithoutTaskHistory"] as const)( + "drops %s metadata captured for a closed renderer", + async (method) => { + const old = sidebar() + await provider.resolveWebviewView(old.view) + let release!: (value: boolean) => void + let started!: () => void + const captured = new Promise((resolve) => { + started = resolve + }) + vi.mocked(openAiCodexOAuthManager.isAuthenticated).mockImplementationOnce(() => { + started() + return new Promise((resolve) => { + release = resolve + }) + }) + const stale = provider[method]() + await captured + old.disposed.fire() + const fresh = sidebar() + await provider.resolveWebviewView(fresh.view) + const before = fresh.post.mock.calls.length + release(false) + await stale + expect(fresh.post).toHaveBeenCalledTimes(before) + await provider[method]() + expect(fresh.post).toHaveBeenCalledTimes(before + 1) + }, + ) + + test("old initialization cannot revive resources after disposal and reopen", async () => { + const state = await provider.getState() + let release!: (value: typeof state) => void + vi.spyOn(provider, "getState").mockReturnValueOnce( + new Promise((resolve) => { + release = resolve + }), + ) + const old = sidebar() + const resolving = provider.resolveWebviewView(old.view) + expect(old.onDidDispose).toHaveBeenCalledOnce() + old.disposed.fire() + const fresh = sidebar() + await provider.resolveWebviewView(fresh.view) + const resources = [...provider["webviewDisposables"]] + release(state) + await resolving + expect(provider["view"]).toBe(fresh.view) + expect(provider["webviewDisposables"]).toEqual(resources) + expect(old.view.webview.html).toBe("") + await provider.syncFocusedTaskToWebview() + expect(fresh.post).toHaveBeenCalledWith(expect.objectContaining({ type: "clineMessagesSnapshotEnd" })) + }) + + test("provider shutdown closes transport before awaited task cleanup and cannot reopen", async () => { + const task = new Task(defaultTaskOptions) + Object.assign(task, { taskId: "task-1", instanceId: "instance-1", clineMessages: [{ ts: 1, type: "say" }] }) + provider["taskRegistry"].push(task) + const old = sidebar() + await provider.resolveWebviewView(old.view) + let releasePost!: (value: boolean) => void + old.post.mockReturnValueOnce( + new Promise((resolve) => { + releasePost = resolve + }), + ) + const snapshot = provider.postClineMessagesSnapshot(task.taskId, { taskInstanceId: task.instanceId }) + let releaseAbort!: () => void + vi.mocked(task.abortTask).mockReturnValueOnce( + new Promise((resolve) => { + releaseAbort = resolve + }), + ) + const shutdown = provider.dispose() + await snapshot + await vi.waitFor(() => expect(task.abortTask).toHaveBeenCalledOnce()) + expect(task.dispose).not.toHaveBeenCalled() + expect(provider["clineMessagesTransport"].closed).toBe(true) + expect(provider["view"]).toBeUndefined() + const fresh = sidebar() + await provider.resolveWebviewView(fresh.view) + expect(fresh.onDidDispose).not.toHaveBeenCalled() + expect(fresh.post).not.toHaveBeenCalled() + releaseAbort() + await shutdown + await provider.dispose() + releasePost(true) + expect(task.dispose).toHaveBeenCalledOnce() + expect(provider["clineMessagesTransport"]["callers"].size).toBe(0) + }) + + test("tab disposal shuts down transport and owns the provider lifetime", async () => { + const tab = sidebar() + const dispose = vi.fn() + const panel: vscode.WebviewPanel = { + webview: tab.view.webview, + viewType: "test-tab", + title: "Test", + viewColumn: undefined, + visible: true, + active: true, + options: {}, + reveal: vi.fn(), + dispose, + onDidChangeViewState: () => makeDisposable(), + onDidDispose: tab.onDidDispose, + } + await provider.resolveWebviewView(panel) + tab.post.mockImplementation(() => new Promise(() => {})) + const snapshot = provider.postClineMessagesSnapshot() + const onDispose = tab.onDidDispose.mock.calls[0][0] + await onDispose() + await snapshot + expect(provider["_disposed"]).toBe(true) + expect(provider["view"]).toBeUndefined() + expect(provider["clineMessagesTransport"]["callbacks"]).toBeUndefined() + expect(dispose).toHaveBeenCalledOnce() + }) + const setCurrentTask = ( task: { taskId: string; instanceId?: string; clineMessages: ClineMessage[] } | undefined, ) => { @@ -2395,7 +2679,9 @@ describe("ClineProvider", () => { .spyOn(provider.workspaceTracker!, "initializeFilePaths") .mockReturnValue(initializationPromise) const logSpy = vi.spyOn(provider, "log") - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] await expect(messageHandler({ type: "webviewDidLaunch" })).resolves.toBeUndefined() expect(initializeSpy).toHaveBeenCalledOnce() @@ -2447,7 +2733,9 @@ describe("ClineProvider", () => { await provider.addClineToStack(mockCline) // Get the message handler - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] // Trigger clearTask message await messageHandler({ type: "clearTask" }) @@ -2475,7 +2763,9 @@ describe("ClineProvider", () => { await provider.addClineToStack(childTask) // Get the message handler - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] // Trigger clearTask message await messageHandler({ type: "clearTask" }) @@ -2493,7 +2783,9 @@ describe("ClineProvider", () => { const postStateToWebviewSpy = vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) // Get the message handler - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] // Trigger clearTask message await messageHandler({ type: "clearTask" }) @@ -2519,7 +2811,9 @@ describe("ClineProvider", () => { expect(provider.getTaskStackSize()).toBe(1) // Get the message handler - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] // Trigger clearTask message (simulating cancel during API retry) await messageHandler({ type: "clearTask" }) @@ -2752,7 +3046,9 @@ describe("ClineProvider", () => { test("handles writeDelayMs message", async () => { await provider.resolveWebviewView(mockWebviewView) - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] await messageHandler({ type: "updateSettings", updatedSettings: { writeDelayMs: 2000 } }) @@ -2765,7 +3061,9 @@ describe("ClineProvider", () => { await provider.resolveWebviewView(mockWebviewView) // Get the message handler from onDidReceiveMessage - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] // Simulate setting sound to enabled await messageHandler({ type: "updateSettings", updatedSettings: { soundEnabled: true } }) @@ -2802,7 +3100,9 @@ describe("ClineProvider", () => { test("handles autoCondenseContext message", async () => { await provider.resolveWebviewView(mockWebviewView) - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] await messageHandler({ type: "updateSettings", updatedSettings: { autoCondenseContext: false } }) expect(updateGlobalStateSpy).toHaveBeenCalledWith("autoCondenseContext", false) expect(mockContext.globalState.update).toHaveBeenCalledWith("autoCondenseContext", false) @@ -2821,7 +3121,9 @@ describe("ClineProvider", () => { test("handles autoCondenseContextPercent message", async () => { await provider.resolveWebviewView(mockWebviewView) - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] await messageHandler({ type: "updateSettings", updatedSettings: { autoCondenseContextPercent: 75 } }) @@ -2891,7 +3193,9 @@ describe("ClineProvider", () => { it("loads saved API config when switching modes", async () => { await provider.resolveWebviewView(mockWebviewView) - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] const profile: ProviderSettingsEntry = { name: "test-config", @@ -2918,7 +3222,9 @@ describe("ClineProvider", () => { it("saves current config when switching to mode without config", async () => { await provider.resolveWebviewView(mockWebviewView) - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] ;(provider as any).providerSettingsManager = { getModeConfigId: vi.fn().mockResolvedValue(undefined), @@ -2941,7 +3247,9 @@ describe("ClineProvider", () => { it("saves config as default for current mode when loading config", async () => { await provider.resolveWebviewView(mockWebviewView) - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] const profile: ProviderSettingsEntry = { apiProvider: providerIdentifiers.anthropic, @@ -2968,7 +3276,9 @@ describe("ClineProvider", () => { it("load API configuration by ID works and updates mode config", async () => { await provider.resolveWebviewView(mockWebviewView) - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] const profile: ProviderSettingsEntry = { name: "config-by-id", @@ -2998,7 +3308,9 @@ describe("ClineProvider", () => { test("handles showRooIgnoredFiles setting", async () => { await provider.resolveWebviewView(mockWebviewView) - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] // Default value should be false expect((await provider.getState()).showRooIgnoredFiles).toBe(false) @@ -3018,7 +3330,9 @@ describe("ClineProvider", () => { test("handles updatePrompt message correctly", async () => { await provider.resolveWebviewView(mockWebviewView) - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] // Mock existing prompts const existingPrompts = { @@ -3076,7 +3390,9 @@ describe("ClineProvider", () => { test("handles maxWorkspaceFiles message", async () => { await provider.resolveWebviewView(mockWebviewView) - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] await messageHandler({ type: "updateSettings", updatedSettings: { maxWorkspaceFiles: 300 } }) @@ -3087,7 +3403,9 @@ describe("ClineProvider", () => { test("handles mode-specific custom instructions updates", async () => { await provider.resolveWebviewView(mockWebviewView) - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] // Mock existing prompts const existingPrompts = { @@ -3144,7 +3462,9 @@ describe("ClineProvider", () => { // Create new provider with updated mock context provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) await provider.resolveWebviewView(mockWebviewView) - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] ;(provider as any).providerSettingsManager = { listConfig: vi @@ -3213,7 +3533,9 @@ describe("ClineProvider", () => { ;(provider as any).createTaskWithHistoryItem = vi.fn() // Trigger message deletion - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] await messageHandler({ type: "deleteMessage", value: 4000 }) // Verify that the dialog message was sent to webview @@ -3249,7 +3571,9 @@ describe("ClineProvider", () => { Object.assign(provider, { taskRegistry: new TaskRegistry() }) // Trigger message deletion - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] await messageHandler({ type: "deleteMessage", value: 2000 }) // Verify no dialog was shown since there's no current cline @@ -3305,7 +3629,9 @@ describe("ClineProvider", () => { // Trigger message edit // Get the message handler function that was registered with the webview - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] // Call the message handler with a submitEditedMessage message await messageHandler({ @@ -3358,7 +3684,7 @@ describe("ClineProvider", () => { const getMessageHandler = () => { const mockCalls = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls expect(mockCalls.length).toBeGreaterThan(0) - return mockCalls[0][0] + return mockCalls.at(-1)![0] } test("handles mcpEnabled setting correctly", async () => { @@ -3417,7 +3743,9 @@ describe("ClineProvider", () => { const { SYSTEM_PROMPT } = await import("../../prompts/system") vi.mocked(SYSTEM_PROMPT).mockRejectedValueOnce(new Error("Test error")) - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] await messageHandler({ type: "getSystemPrompt", mode: "code" }) expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("errors.get_system_prompt") @@ -3823,7 +4151,9 @@ describe("ClineProvider", () => { describe("updateCustomMode", () => { test("updates both file and state when updating custom mode", async () => { await provider.resolveWebviewView(mockWebviewView) - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] // Mock CustomModesManager methods ;(provider as any).customModesManager = { @@ -3885,7 +4215,9 @@ describe("ClineProvider", () => { describe("upsertApiConfiguration", () => { test("handles error in upsertApiConfiguration gracefully", async () => { await provider.resolveWebviewView(mockWebviewView) - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] ;(provider as any).providerSettingsManager = { setModeConfig: vi.fn().mockRejectedValue(new Error("Failed to update mode config")), @@ -3918,7 +4250,9 @@ describe("ClineProvider", () => { test("handles successful upsertApiConfiguration", async () => { await provider.resolveWebviewView(mockWebviewView) - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] ;(provider as any).providerSettingsManager = { setModeConfig: vi.fn(), @@ -3957,7 +4291,9 @@ describe("ClineProvider", () => { test("handles buildApiHandler error in updateApiConfiguration", async () => { await provider.resolveWebviewView(mockWebviewView) - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] // Mock buildApiHandler to throw an error const { buildApiHandler } = await import("../../../api") @@ -4006,7 +4342,9 @@ describe("ClineProvider", () => { test("handles successful saveApiConfiguration", async () => { await provider.resolveWebviewView(mockWebviewView) - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] ;(provider as any).providerSettingsManager = { setModeConfig: vi.fn(), @@ -4523,7 +4861,9 @@ describe("Project MCP Settings", () => { // Set up the webview await provider.resolveWebviewView(mockWebviewView) - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] // Ensure the message handler is properly set up expect(messageHandler).toBeDefined() @@ -4550,7 +4890,9 @@ describe("Project MCP Settings", () => { test("handles openProjectMcpSettings when workspace is not open", async () => { await provider.resolveWebviewView(mockWebviewView) - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] // Mock no workspace folders ;(vscode.workspace as any).workspaceFolders = [] @@ -4564,7 +4906,9 @@ describe("Project MCP Settings", () => { test("handles openProjectMcpSettings file creation error", async () => { await provider.resolveWebviewView(mockWebviewView) - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] // Mock workspace folders ;(vscode.workspace as any).workspaceFolders = [{ uri: { fsPath: "/test/workspace" } }] @@ -4820,10 +5164,7 @@ describe("ClineProvider - Router Models", () => { asWebviewUri: vi.fn(), }, visible: true, - onDidDispose: vi.fn().mockImplementation((callback) => { - callback() - return { dispose: vi.fn() } - }), + onDidDispose: vi.fn(() => makeDisposable()), onDidChangeVisibility: vi.fn().mockImplementation(() => { return { dispose: vi.fn() } }), @@ -4838,7 +5179,9 @@ describe("ClineProvider - Router Models", () => { test("handles requestRouterModels with successful responses", async () => { await provider.resolveWebviewView(mockWebviewView) - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] // Mock getState to return API configuration vi.spyOn(provider, "getState").mockResolvedValue({ @@ -4913,7 +5256,9 @@ describe("ClineProvider - Router Models", () => { test("handles requestRouterModels with individual provider failures", async () => { await provider.resolveWebviewView(mockWebviewView) - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] vi.spyOn(provider, "getState").mockResolvedValue({ apiConfiguration: { @@ -4984,7 +5329,9 @@ describe("ClineProvider - Router Models", () => { test("handles requestRouterModels with LiteLLM values from message", async () => { await provider.resolveWebviewView(mockWebviewView) - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] // Mock state without LiteLLM config vi.spyOn(provider, "getState").mockResolvedValue({ @@ -5019,7 +5366,9 @@ describe("ClineProvider - Router Models", () => { test("skips LiteLLM when neither config nor message values are provided", async () => { await provider.resolveWebviewView(mockWebviewView) - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] vi.spyOn(provider, "getState").mockResolvedValue({ apiConfiguration: { @@ -5070,7 +5419,9 @@ describe("ClineProvider - Router Models", () => { test("handles requestLmStudioModels with proper response", async () => { await provider.resolveWebviewView(mockWebviewView) - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] vi.spyOn(provider, "getState").mockResolvedValue({ apiConfiguration: { @@ -5174,10 +5525,7 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { asWebviewUri: vi.fn(), }, visible: true, - onDidDispose: vi.fn().mockImplementation((callback) => { - callback() - return { dispose: vi.fn() } - }), + onDidDispose: vi.fn(() => makeDisposable()), onDidChangeVisibility: vi.fn().mockImplementation(() => { return { dispose: vi.fn() } }), @@ -5235,7 +5583,9 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { historyItem: { id: "test-task-id" }, }) - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] await messageHandler({ type: "submitEditedMessage", value: 3000, @@ -5291,7 +5641,9 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { historyItem: { id: "test-task-id" }, }) - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] await messageHandler({ type: "submitEditedMessage", value: 3000, @@ -5341,7 +5693,9 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { historyItem: { id: "test-task-id" }, }) - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] // Should not throw error, but handle gracefully await expect( @@ -5383,7 +5737,9 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { historyItem: { id: "test-task-id" }, }) - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] // Should handle connection error gracefully await expect( @@ -5435,7 +5791,9 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { historyItem: { id: "test-task-id" }, }) - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] // Simulate concurrent edit operations const edit1Promise = messageHandler({ @@ -5487,7 +5845,9 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { // Mock no current cline (simulating permission failure) vi.spyOn(provider, "getCurrentTask").mockReturnValue(undefined) - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] await messageHandler({ type: "submitEditedMessage", @@ -5515,7 +5875,9 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { historyItem: { id: "test-task-id" }, }) - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] await messageHandler({ type: "submitEditedMessage", @@ -5539,7 +5901,9 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { }) test("handles malformed edit requests", async () => { - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] // Test with missing value await messageHandler({ @@ -5565,7 +5929,9 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { }) test("handles invalid message formats", async () => { - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] // Test with null message - should throw error await expect(messageHandler(null)).rejects.toThrow() @@ -5597,7 +5963,9 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { await provider.addClineToStack(mockCline) - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] // Test with negative timestamp await messageHandler({ @@ -5637,7 +6005,9 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { historyItem: { id: "test-task-id" }, }) - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] // Try to edit a message that doesn't exist (timestamp 5000) await messageHandler({ @@ -5681,7 +6051,9 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { historyItem: { id: "test-task-id" }, }) - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] // Try to delete a message that doesn't exist (timestamp 5000) await messageHandler({ @@ -5732,7 +6104,9 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { historyItem: { id: "test-task-id" }, }) - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] await messageHandler({ type: "submitEditedMessage", @@ -5778,7 +6152,9 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { historyItem: { id: "test-task-id" }, }) - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] await messageHandler({ type: "deleteMessage", value: 2000 }) @@ -5824,7 +6200,9 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { historyItem: { id: "test-task-id" }, }) - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] const largeEditedContent = "B".repeat(15000) await messageHandler({ @@ -5870,7 +6248,9 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { historyItem: { id: "test-task-id" }, }) - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] await messageHandler({ type: "deleteMessage", value: 3000 }) @@ -5913,7 +6293,9 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { }) ;(provider as any).createTaskWithHistoryItem = vi.fn() - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] await messageHandler({ type: "deleteMessage", value: 2000 }) @@ -5948,7 +6330,9 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { await provider.addClineToStack(mockCline) - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] await messageHandler({ type: "submitEditedMessage", @@ -5987,7 +6371,9 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { historyItem: { id: "test-task-id" }, }) - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] await messageHandler({ type: "deleteMessage", value: 1000 }) @@ -6033,7 +6419,9 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { historyItem: { id: "test-task-id" }, }) - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] await messageHandler({ type: "submitEditedMessage", diff --git a/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts b/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts index c38bfdcad8..7ae140acb3 100644 --- a/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts @@ -339,10 +339,8 @@ describe("ClineProvider Task History Synchronization", () => { cspSource: "vscode-webview://test-csp-source", }, visible: true, - onDidDispose: vi.fn().mockImplementation((callback) => { - callback() - return { dispose: vi.fn() } - }), + // Registering a listener does not dispose the renderer. + onDidDispose: vi.fn(() => ({ dispose: vi.fn() })), onDidChangeVisibility: vi.fn().mockImplementation(() => { return { dispose: vi.fn() } }), diff --git a/src/core/webview/__tests__/transcriptTransport.model.ts b/src/core/webview/__tests__/transcriptTransport.model.ts index 5fec1c9d0a..06bb7c6ed9 100644 --- a/src/core/webview/__tests__/transcriptTransport.model.ts +++ b/src/core/webview/__tests__/transcriptTransport.model.ts @@ -27,8 +27,11 @@ type Intent = | "empty-append" | "empty-update" | "empty-snapshot" + | "shutdown" + | "reopen" type Capture = { job: TranscriptJob + renderer: number taskInstanceId: string | undefined scope: string values: number[] @@ -36,6 +39,13 @@ type Capture = { } type ModelState = { transport: TranscriptTransportState + renderer: number + shutdowns: number + shutdownWithQueued: boolean + closedAdmissionRejected: boolean + retired: Array<{ frame: TranscriptFrame; message: ExtensionMessage }> + lateOutcomes: string[] + lateWhileSending: string[] focus: TaskId | undefined focusInstance: string | undefined instanceSyncPending: boolean @@ -106,6 +116,21 @@ export const TRANSPORT_SCENARIOS: Scenario[] = [ producer: ["empty-append", "empty-update", "append", "empty-snapshot"], controller: [], }, + { + name: "renderer-disposal-and-reopen", + producer: ["snapshot", "append"], + controller: ["shutdown", "shutdown", "reopen", "snapshot"], + }, + { + name: "renderer-reopen-without-task", + producer: ["snapshot"], + controller: ["clear", "shutdown", "reopen", "empty-snapshot"], + }, + { + name: "renderer-reopen-with-new-instance", + producer: ["snapshot", "stale-instance-append"], + controller: ["shutdown", "replace-instance", "reopen", "sync-instance"], + }, ] export const TRANSPORT_ACTIONS = [ "snapshot", @@ -131,6 +156,10 @@ export const TRANSPORT_ACTIONS = [ "settle", "fail", "discard", + "shutdown", + "reopen", + "late-resolve", + "late-reject", ] export const TRANSPORT_LANDMARKS = { "held-post-with-queued-delta": (s: ModelState) => @@ -175,11 +204,32 @@ export const TRANSPORT_LANDMARKS = { s.committed.some((id) => s.captures[id - 1].job.taskInstanceId === "a:1") && s.appliedInstanceDeltas.includes("append") && s.appliedInstanceDeltas.includes("update"), + "shutdown-releases-held-and-queued-callers": (s: ModelState) => + s.shutdownWithQueued && s.transport.closed && s.callers.length === 0 && s.payloads.length === 0, + "repeated-shutdown-with-held-send": (s: ModelState) => s.shutdowns === 2 && s.retired.length > 0, + "closed-admission-rejected": (s: ModelState) => s.closedAdmissionRejected, + "reopened-renderer-sends-before-dead-renderer-settles": (s: ModelState) => + s.renderer > 0 && !!s.physical && s.retired.length > 0, + "late-resolve-does-not-settle-new-send": (s: ModelState) => s.lateWhileSending.includes("resolve"), + "late-reject-does-not-settle-new-send": (s: ModelState) => s.lateWhileSending.includes("reject"), + "reopened-snapshot-committed": (s: ModelState) => s.committed.some((id) => s.captures[id - 1].renderer > 0), + "reopened-no-task-snapshot-committed": (s: ModelState) => + s.committed.some((id) => { + const capture = s.captures[id - 1] + return capture.renderer > 0 && capture.job.taskId === undefined + }), } satisfies Record boolean> function initialState(): ModelState { return { transport: createTranscriptTransportState(TRANSPORT_MODEL_BOUNDS.chunkSize), + renderer: 0, + shutdowns: 0, + shutdownWithQueued: false, + closedAdmissionRejected: false, + retired: [], + lateOutcomes: [], + lateWhileSending: [], focus: "a", focusInstance: "a:0", instanceSyncPending: false, @@ -220,9 +270,14 @@ function enabled(s: ModelState, scenario: Scenario): Event[] { }) } if (s.transport.inFlight) { - events.push({ name: "settle", action: { type: "settle", success: true } }) + events.push({ name: "settle", action: { type: "settle", frame: s.transport.inFlight, success: true } }) + if (s.failures < TRANSPORT_MODEL_BOUNDS.failures) + events.push({ name: "fail", action: { type: "settle", frame: s.transport.inFlight, success: false } }) + } + for (const { frame } of s.retired) { + events.push({ name: "late-resolve", action: { type: "settle", frame, success: true } }) if (s.failures < TRANSPORT_MODEL_BOUNDS.failures) - events.push({ name: "fail", action: { type: "settle", success: false } }) + events.push({ name: "late-reject", action: { type: "settle", frame, success: false } }) } return events } @@ -312,8 +367,12 @@ function executeStep(s: ModelState, event: Event, reducer: Reducer, coverage: Se const before = s.transport const transition = reducer(before, action) s.transport = transition.state + const generationChange = + action.type === "invalidate" || + (action.type === "shutdown" && !before.closed) || + (action.type === "reopen" && before.closed) requireInvariant( - s.transport.generation === before.generation + (action.type === "invalidate" ? 1 : 0), + s.transport.generation === before.generation + (generationChange ? 1 : 0), "generation is not monotonic", ) if (action.type === "enqueue") { @@ -323,6 +382,10 @@ function executeStep(s: ModelState, event: Event, reducer: Reducer, coverage: Se !transition.post && transition.release.length === 0 && transition.settle.length === 0 + if (before.closed) { + requireInvariant(unchanged, "closed admission allocated work") + s.closedAdmissionRejected = true + } if (action.request.kind !== "snapshot" && action.total === 0) { requireInvariant(unchanged, "empty delta allocated work") } @@ -373,6 +436,7 @@ function executeStep(s: ModelState, event: Event, reducer: Reducer, coverage: Se s.allocated[scope] = job.seq s.captures.push({ job, + renderer: s.renderer, taskInstanceId: action.request.taskInstanceId, scope, values: [...values], @@ -405,35 +469,89 @@ function executeStep(s: ModelState, event: Event, reducer: Reducer, coverage: Se "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 && - physical.job.taskInstanceId === s.focusInstance + if (action.type === "shutdown" || action.type === "reopen") { + requireInvariant( + s.transport.nextJobId === before.nextJobId && + s.transport.nextSnapshotId === before.nextSnapshotId && + JSON.stringify([...s.transport.sequences]) === JSON.stringify([...before.sequences]), + "renderer boundary reset sequences or identities", ) - s.staleCommitCompletions++ - if (action.success) { - requireInvariant(s.physicalMessage, "physical send lost its captured wire message") + if (action.type === "shutdown") { + s.shutdowns++ + s.shutdownWithQueued ||= !!before.inFlight && before.queue.length > 0 requireInvariant( - s.physicalMessage.taskInstanceId === s.captures[physical.job.id - 1].taskInstanceId, - "wire lost originating instance identity", + s.transport.closed && + !s.transport.inFlight && + !s.transport.active && + s.transport.queue.length === 0 && + s.payloads.length === 0 && + s.callers.length === 0, + "shutdown retained transport ownership", ) - deliver(s, physical, s.physicalMessage, faults) + if (s.physical) { + requireInvariant(s.physicalMessage, "physical send lost its wire message") + s.retired.push({ frame: s.physical, message: s.physicalMessage }) + } + s.physical = undefined + s.physicalMessage = undefined + } else if (before.closed) { + requireInvariant(!s.transport.closed, "reopen did not enable renderer") + s.renderer++ + s.visible = [] + s.appliedSeq = 0 + s.staging = undefined + } + } + if (action.type === "settle") { + const retired = s.retired.find(({ frame }) => frame.job.id === action.frame.job.id) + if (retired) { + requireInvariant( + transition.state === before && + !transition.post && + transition.release.length === 0 && + transition.settle.length === 0, + "late completion changed live transport", + ) + const outcome = action.success ? "resolve" : "reject" + s.lateOutcomes = [...new Set([...s.lateOutcomes, outcome])].sort() + if (s.physical) s.lateWhileSending = [...new Set([...s.lateWhileSending, outcome])].sort() + if (!action.success) s.failures++ + // This wire belongs to the disposed renderer, never its replacement. + s.retired = s.retired.filter((entry) => entry !== retired) } else { - s.captures[physical.job.id - 1].failed = true - s.failures++ + 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 && + physical.job.taskInstanceId === s.focusInstance + ) + s.staleCommitCompletions++ + 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 } - s.physical = undefined - s.physicalMessage = undefined } if (transition.post) { const frame = transition.post const capture = s.captures[frame.job.id - 1] + requireInvariant( + !s.transport.closed && capture.renderer === s.renderer, + "post initiated for a disposed renderer", + ) requireInvariant(!s.physical, "overlapping physical sends") requireInvariant( capture.job.generation === s.transport.generation && frame.job.taskId === s.focus, @@ -502,6 +620,10 @@ function executeStep(s: ModelState, event: Event, reducer: Reducer, coverage: Se s[event.actor]++ coverage.add(event.intent) const intent = event.intent + if (intent === "shutdown" || intent === "reopen") { + apply({ type: intent }) + return s + } if (intent === "switch" || intent === "clear" || intent === "focus" || intent === "replace-instance") { const previous = s.focus s.focus = intent === "replace-instance" ? "a" : intent === "clear" ? undefined : "b" @@ -625,6 +747,44 @@ export function exploreTranscriptTransport( } export const TRANSPORT_MUTATIONS: Mutation[] = [ + { + name: "shutdown-retains-physical-caller", + expected: "shutdown retained transport ownership", + reduce: (state, action) => { + if (action.type !== "shutdown" || state.closed) return reduceTranscriptTransport(state, action) + const result = reduceTranscriptTransport(state, { type: "invalidate" }) + result.state = { ...result.state, closed: true } + return result + }, + }, + { + name: "admit-after-shutdown", + expected: "closed admission allocated work", + reduce: (state, action) => + reduceTranscriptTransport( + action.type === "enqueue" && state.closed ? { ...state, closed: false } : state, + action, + ), + }, + { + name: "reopen-resets-identities", + expected: "renderer boundary reset sequences or identities", + reduce: (state, action) => { + const result = reduceTranscriptTransport(state, action) + if (action.type === "reopen") + result.state = { ...result.state, nextJobId: 0, nextSnapshotId: 0, sequences: new Map() } + return result + }, + }, + { + name: "late-completion-settles-live-send", + expected: "late completion changed live transport", + reduce: (state, action) => + reduceTranscriptTransport( + state, + action.type === "settle" && state.inFlight ? { ...action, frame: state.inFlight } : action, + ), + }, { name: "stale-completion-starts-end", expected: "post or commit initiated after invalidation", diff --git a/src/core/webview/__tests__/transcriptTransport.spec.ts b/src/core/webview/__tests__/transcriptTransport.spec.ts index 37a33a70f1..da75c488e1 100644 --- a/src/core/webview/__tests__/transcriptTransport.spec.ts +++ b/src/core/webview/__tests__/transcriptTransport.spec.ts @@ -58,6 +58,36 @@ describe("transcript transport bounded model", () => { }) describe("transcript transport reducer", () => { + test.each(["job", "generation", "phase", "start"] as const)("ignores a mismatched %s completion token", (field) => { + const admitted = reduceTranscriptTransport(createTranscriptTransportState(), { + type: "enqueue", + request: { kind: "snapshot", taskId: "a" }, + total: 1, + focusedTaskId: "a", + }) + const sent = reduceTranscriptTransport(admitted.state, { type: "pump", focusedTaskId: "a" }) + const frame = structuredClone(sent.post!) + if (field === "job") frame.job.id++ + if (field === "generation") frame.job.generation++ + if (field === "phase") frame.phase = "end" + if (field === "start") frame.start++ + for (const success of [true, false]) { + const ignored = reduceTranscriptTransport(sent.state, { type: "settle", frame, success }) + expect(ignored).toEqual({ state: sent.state, release: [], settle: [] }) + expect(ignored.state).toBe(sent.state) + } + }) + + test("closed pump and repeated boundaries are no-ops", () => { + const open = createTranscriptTransportState() + expect(reduceTranscriptTransport(open, { type: "reopen" }).state).toBe(open) + const closed = reduceTranscriptTransport(open, { type: "shutdown" }).state + expect(reduceTranscriptTransport(closed, { type: "shutdown" }).state).toBe(closed) + const pump = reduceTranscriptTransport(closed, { type: "pump", focusedTaskId: undefined }) + expect(pump).toEqual({ state: closed, release: [], settle: [] }) + expect(pump.state).toBe(closed) + }) + 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" } @@ -119,7 +149,11 @@ describe("transcript transport reducer", () => { 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 + state = reduceTranscriptTransport(state, { + type: "settle", + frame: state.inFlight!, + success: true, + }).state } const current = reduceTranscriptTransport(state, { type: "enqueue", @@ -138,7 +172,14 @@ 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 }) + const admitted = reduceTranscriptTransport(state, { + type: "enqueue", + request: { kind: "append", taskId: "a" }, + total: 1, + focusedTaskId: "a", + }) + const frame: TranscriptFrame = { job: admitted.accepted!, phase: "append", start: 0, count: 0 } + const transition = reduceTranscriptTransport(state, { type: "settle", frame, success }) expect(transition).toEqual({ state, release: [], settle: [] }) expect(transition.state).toBe(state) }) @@ -153,7 +194,7 @@ describe("transcript transport reducer", () => { let state = admitted.state if (location === "active") { state = reduceTranscriptTransport(state, { type: "pump", focusedTaskId: "a" }).state - state = reduceTranscriptTransport(state, { type: "settle", success: true }).state + state = reduceTranscriptTransport(state, { type: "settle", frame: state.inFlight!, 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. @@ -207,7 +248,11 @@ describe("transcript transport reducer", () => { 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 + state = reduceTranscriptTransport(transition.state, { + type: "settle", + frame: transition.post!, + success: true, + }).state } expect(state.queue).toEqual([]) @@ -227,6 +272,232 @@ describe("transcript transport reducer", () => { describe("transcript transport driver", () => { const message: ClineMessage = { ts: 1, type: "say", text: "initial", images: ["image"] } + test.each( + (["start", "chunk", "end", "append", "update"] as const).flatMap((phase) => + [true, false].map((success) => ({ phase, success })), + ), + )( + "shutdown detaches held $phase and permits a fresh renderer (late success=$success)", + async ({ phase, success }) => { + const types = { + start: "clineMessagesSnapshotStart", + chunk: "clineMessagesSnapshotChunk", + end: "clineMessagesSnapshotEnd", + append: "clineMessageAppended", + update: "clineMessageUpdated", + } as const + let resolveOld!: () => void + let rejectOld!: (error: Error) => void + const oldPhysical = new Promise((resolve, reject) => { + resolveOld = resolve + rejectOld = reject + }) + const oldPost = vi.fn((frame: ExtensionMessage) => + frame.type === types[phase] ? oldPhysical : Promise.resolve(), + ) + const log = vi.fn() + const transport = new TranscriptTransport( + () => "a", + oldPost, + log, + () => "instance-1", + ) + const resolved = vi.fn() + const rejected = vi.fn() + const active = transport + .enqueue( + { + kind: phase === "append" || phase === "update" ? phase : "snapshot", + taskId: "a", + taskInstanceId: "instance-1", + bumpSeq: true, + }, + [message], + ) + .then(resolved, rejected) + await vi.waitFor(() => + expect(oldPost).toHaveBeenCalledWith(expect.objectContaining({ type: types[phase] })), + ) + const waiting = ["append", "update", "snapshot"].map((kind) => + transport.enqueue( + { kind: kind as "append" | "update" | "snapshot", taskId: "a", taskInstanceId: "instance-1" }, + [message], + ), + ) + const completion = transport["pendingSend"]! + const before = transport["state"] + const oldFrames = oldPost.mock.calls.length + transport.shutdown() + transport.shutdown() + await Promise.all([active, ...waiting]) + expect(resolved).toHaveBeenCalledOnce() + expect(rejected).not.toHaveBeenCalled() + expect(completion.finish).toBeUndefined() + expect(transport["callbacks"]).toBeUndefined() + expect(transport["pendingSend"]).toBeUndefined() + expect(transport["payloads"].size).toBe(0) + expect(transport["callers"].size).toBe(0) + expect(transport["state"]).toMatchObject({ + closed: true, + generation: before.generation + 1, + queue: [], + active: undefined, + inFlight: undefined, + }) + expect(transport["state"].nextJobId).toBe(before.nextJobId) + expect(transport.getSequence("a")).toBe(before.sequences.get("a")) + + let releaseNew!: () => void + const newPhysical = new Promise((resolve) => { + releaseNew = resolve + }) + const newPost = vi + .fn<(frame: ExtensionMessage) => Promise>() + .mockReturnValueOnce(newPhysical) + .mockResolvedValue(undefined) + transport.reopen( + () => "a", + newPost, + log, + () => "instance-2", + ) + const recovered = vi.fn() + const fresh = transport + .enqueue({ kind: "snapshot", taskId: "a", taskInstanceId: "instance-2" }, [message]) + .then(recovered) + const delta = transport.enqueue({ kind: "append", taskId: "a", taskInstanceId: "instance-2" }, [message]) + expect(newPost).toHaveBeenCalledOnce() + const newState = transport["state"] + expect(newState.inFlight?.job.id).toBe(before.nextJobId + 1) + expect(newState.inFlight?.job.snapshotId).toBe(`a:${before.nextSnapshotId + 1}`) + // A repeated reopen cannot replace callbacks or reset an already-live barrier. + transport.reopen(() => "b", oldPost, log) + if (success) resolveOld() + else rejectOld(new Error("dead renderer rejected")) + await oldPhysical.catch(() => {}) + await Promise.resolve() + expect(transport["state"]).toBe(newState) + expect(recovered).not.toHaveBeenCalled() + expect(newPost).toHaveBeenCalledOnce() + expect(log).not.toHaveBeenCalled() + releaseNew() + await Promise.all([fresh, delta]) + expect(resolved).toHaveBeenCalledOnce() + expect(recovered).toHaveBeenCalledOnce() + expect(oldPost).toHaveBeenCalledTimes(oldFrames) + expect( + newPost.mock.calls.map(([frame]) => [frame.type, frame.taskInstanceId, frame.clineMessagesSeq]), + ).toEqual([ + ["clineMessagesSnapshotStart", "instance-2", before.sequences.get("a")], + ["clineMessagesSnapshotChunk", "instance-2", before.sequences.get("a")], + ["clineMessagesSnapshotEnd", "instance-2", before.sequences.get("a")], + ["clineMessageAppended", "instance-2", before.sequences.get("a")! + 1], + ]) + }, + ) + + test.each([true, false])( + "shutdown after invalidation settles the detached caller (success=%s)", + async (success) => { + let resolve!: () => void + let reject!: (error: Error) => void + const held = new Promise((yes, no) => { + resolve = yes + reject = no + }) + const log = vi.fn() + const transport = new TranscriptTransport( + () => undefined, + () => held, + log, + ) + const settled = vi.fn() + const active = transport.enqueue({ kind: "snapshot", taskId: undefined }, []).then(settled) + transport.invalidate() + transport.shutdown() + await active + expect(settled).toHaveBeenCalledOnce() + if (success) resolve() + else reject(new Error("late failure")) + await held.catch(() => {}) + const post = vi.fn<(frame: ExtensionMessage) => Promise>().mockResolvedValue(undefined) + transport.reopen(() => undefined, post, log) + await transport.enqueue({ kind: "snapshot", taskId: undefined }, []) + expect(post.mock.calls.map(([frame]) => [frame.type, frame.snapshotId, frame.clineMessagesSeq])).toEqual([ + ["clineMessagesSnapshotStart", "none:2", 0], + ["clineMessagesSnapshotEnd", "none:2", 0], + ]) + expect(log).not.toHaveBeenCalled() + }, + ) + + test.each(["append", "update", "snapshot"] as const)( + "drops closed %s before capture and rejects old generations after reopen", + async (kind) => { + const focus = vi.fn(() => "a") + const post = vi.fn<(frame: ExtensionMessage) => Promise>().mockResolvedValue(undefined) + const transport = new TranscriptTransport(focus, post, vi.fn()) + const oldGeneration = transport.generation + transport.shutdown() + const closedState = transport["state"] + const clone = vi.spyOn(globalThis, "structuredClone") + try { + await transport.enqueue({ kind, taskId: "a" }, [message]) + expect(focus).not.toHaveBeenCalled() + expect(transport["state"]).toBe(closedState) + transport.reopen(focus, post, vi.fn()) + await transport.enqueue({ kind, taskId: "a", generation: oldGeneration }, [message]) + await transport.enqueue({ kind, taskId: "a", generation: closedState.generation }, [message]) + expect(clone).not.toHaveBeenCalled() + expect(post).not.toHaveBeenCalled() + await transport.enqueue({ kind, taskId: "a" }, [message]) + expect(post).toHaveBeenCalled() + } finally { + clone.mockRestore() + } + }, + ) + + test("does not adopt a reopened renderer if capture reenters shutdown", async () => { + const post = vi.fn<(frame: ExtensionMessage) => Promise>().mockResolvedValue(undefined) + const transport = new TranscriptTransport(() => "a", post, vi.fn()) + await transport.enqueue({ kind: "snapshot", taskId: "a" }, [ + { + ts: 1, + type: "say", + get text() { + transport.shutdown() + transport.reopen(() => "a", post, vi.fn()) + return "obsolete" + }, + }, + ]) + expect(post).not.toHaveBeenCalled() + expect(transport["state"].nextJobId).toBe(0) + await transport.enqueue({ kind: "append", taskId: "a" }, [message]) + expect(post).toHaveBeenCalledOnce() + }) + + test("handles shutdown reentered by a physical post that then throws", async () => { + const log = vi.fn() + const freshPost = vi.fn<(frame: ExtensionMessage) => Promise>().mockResolvedValue(undefined) + const transport = new TranscriptTransport( + () => "a", + () => { + transport.shutdown() + transport.reopen(() => "a", freshPost, log) + throw new Error("disposed synchronously") + }, + log, + ) + await transport.enqueue({ kind: "snapshot", taskId: "a" }, [message]) + await transport.enqueue({ kind: "append", taskId: "a" }, [message]) + expect(log).not.toHaveBeenCalled() + expect(freshPost).toHaveBeenCalledOnce() + expect(transport["payloads"].size).toBe(0) + expect(transport["callers"].size).toBe(0) + }) + test.each(["append", "update"] as const)( "rejects empty %s before cloning or admission, then recovers", async (kind) => { diff --git a/src/core/webview/__tests__/webviewMessageHandler.checkpoint.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.checkpoint.spec.ts index 71a58d6f8d..9a6de39c74 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.checkpoint.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.checkpoint.spec.ts @@ -79,7 +79,7 @@ describe("webviewMessageHandler - checkpoint operations", () => { describe("delete operations with checkpoint restoration", () => { it("should call handleCheckpointRestoreOperation for checkpoint deletes", async () => { // Mock handleCheckpointRestoreOperation - ;(handleCheckpointRestoreOperation as any).mockResolvedValue(undefined) + vi.mocked(handleCheckpointRestoreOperation).mockResolvedValue(undefined) // Call the handler with delete confirmation await webviewMessageHandler(mockProvider, { @@ -99,7 +99,8 @@ describe("webviewMessageHandler - checkpoint operations", () => { }) }) - it("should save messages for non-checkpoint deletes", async () => { + it("delegates persistence to the rewind for non-checkpoint deletes", async () => { + const retainedMessage = mockCline.clineMessages[0] // Call the handler with delete confirmation (no checkpoint restoration) await webviewMessageHandler(mockProvider, { type: "deleteMessageConfirm", @@ -107,12 +108,9 @@ describe("webviewMessageHandler - checkpoint operations", () => { restoreCheckpoint: false, }) - // Verify saveTaskMessages was called - expect(saveTaskMessages).toHaveBeenCalledWith({ - messages: expect.any(Array), - taskId: "test-task-123", - globalStoragePath: "/test/storage", - }) + // The task overwrite owns persistence; the handler must not save a second time. + expect(mockCline.overwriteClineMessages).toHaveBeenCalledExactlyOnceWith([retainedMessage]) + expect(saveTaskMessages).not.toHaveBeenCalled() // Verify checkpoint restore was NOT called expect(mockCline.checkpointRestore).not.toHaveBeenCalled() @@ -122,7 +120,7 @@ describe("webviewMessageHandler - checkpoint operations", () => { describe("edit operations with checkpoint restoration", () => { it("should call handleCheckpointRestoreOperation for checkpoint edits", async () => { // Mock handleCheckpointRestoreOperation - ;(handleCheckpointRestoreOperation as any).mockResolvedValue(undefined) + vi.mocked(handleCheckpointRestoreOperation).mockResolvedValue(undefined) // Call the handler with edit confirmation await webviewMessageHandler(mockProvider, { @@ -213,7 +211,7 @@ describe("webviewMessageHandler - checkpoint operations", () => { }) it("does not restore when task re-initialization times out", async () => { - ;(pWaitFor as any).mockRejectedValueOnce(new Error("timed out")) + vi.mocked(pWaitFor).mockRejectedValueOnce(new Error("timed out")) await webviewMessageHandler(mockProvider, { type: "completionCheckpointRestore" }) diff --git a/src/core/webview/__tests__/webviewMessageHandler.delete.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.delete.spec.ts index 41534ff837..405909ce76 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.delete.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.delete.spec.ts @@ -3,6 +3,7 @@ import { webviewMessageHandler } from "../webviewMessageHandler" import * as vscode from "vscode" import { ClineProvider } from "../ClineProvider" import { MessageManager } from "../../message-manager" +import { saveTaskMessages } from "../../task-persistence" // Mock the saveTaskMessages function vi.mock("../../task-persistence", async (importOriginal) => ({ @@ -247,7 +248,7 @@ describe("webviewMessageHandler delete functionality", () => { ]) }) - it("publishes restored checkpoint metadata after deleting messages", async () => { + it("rewinds once with preserved 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" }] @@ -257,10 +258,7 @@ describe("webviewMessageHandler delete functionality", () => { ] getCurrentTaskMock.overwriteClineMessages.mockImplementation( async (messages: (typeof preservedMessage)[]) => { - getCurrentTaskMock.clineMessages = structuredClone(messages).map((message) => { - const { checkpoint: _checkpoint, ...withoutCheckpoint } = message - return withoutCheckpoint - }) + getCurrentTaskMock.clineMessages = structuredClone(messages) }, ) @@ -269,10 +267,10 @@ describe("webviewMessageHandler delete functionality", () => { messageTs: 2000, }) - expect(getCurrentTaskMock.overwriteClineMessages).toHaveBeenCalledTimes(2) - expect(getCurrentTaskMock.overwriteClineMessages).toHaveBeenLastCalledWith([ - expect.objectContaining({ ts: 1000, checkpoint }), - ]) + expect(getCurrentTaskMock.overwriteClineMessages).toHaveBeenCalledExactlyOnceWith([preservedMessage]) + expect(getCurrentTaskMock.clineMessages).toEqual([preservedMessage]) + expect(saveTaskMessages).not.toHaveBeenCalled() + expect(vscode.window.showErrorMessage).not.toHaveBeenCalled() }) describe("condense preservation behavior", () => { diff --git a/src/core/webview/__tests__/webviewMessageHandler.edit.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.edit.spec.ts index 4a873597b9..bfb8c2e571 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.edit.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.edit.spec.ts @@ -43,6 +43,7 @@ import type { ClineProvider } from "../ClineProvider" import type { ClineMessage } from "@roo-code/types" import type { ApiMessage } from "../../task-persistence/apiMessages" import { MessageManager } from "../../message-manager" +import { saveTaskMessages } from "../../task-persistence" describe("webviewMessageHandler - Edit Message with Timestamp Fallback", () => { let mockClineProvider: ClineProvider @@ -215,7 +216,7 @@ describe("webviewMessageHandler - Edit Message with Timestamp Fallback", () => { ]) }) - it("publishes restored checkpoint metadata before submitting an edited message", async () => { + it("awaits one rewind with preserved checkpoint metadata before submitting an edited message", async () => { const checkpoint = { hash: "checkpoint-hash", type: "user_message" } const preservedMessage = { ts: 500, @@ -234,31 +235,40 @@ describe("webviewMessageHandler - Edit Message with Timestamp Fallback", () => { ] as ApiMessage[] let completedOverwrites = 0 let submitObservedCompletedOverwrites = 0 + let releaseOverwrite!: () => void + const pendingOverwrite = new Promise((resolve) => { + releaseOverwrite = resolve + }) mockCurrentTask.overwriteClineMessages.mockImplementation(async (messages: ClineMessage[]) => { - await Promise.resolve() - mockCurrentTask.clineMessages = structuredClone(messages).map((message) => { - const { checkpoint: _checkpoint, ...withoutCheckpoint } = message - return withoutCheckpoint - }) + await pendingOverwrite + mockCurrentTask.clineMessages = structuredClone(messages) completedOverwrites += 1 }) mockCurrentTask.submitUserMessage.mockImplementation(() => { submitObservedCompletedOverwrites = completedOverwrites }) - await webviewMessageHandler(mockClineProvider, { + const edit = 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 }), - ]) + try { + await vi.waitFor(() => expect(mockCurrentTask.overwriteClineMessages).toHaveBeenCalledOnce()) + expect(mockCurrentTask.overwriteApiConversationHistory).not.toHaveBeenCalled() + expect(mockCurrentTask.submitUserMessage).not.toHaveBeenCalled() + } finally { + releaseOverwrite() + await edit + } + + expect(mockCurrentTask.overwriteClineMessages).toHaveBeenCalledExactlyOnceWith([preservedMessage]) + expect(mockCurrentTask.clineMessages).toEqual([preservedMessage]) + expect(saveTaskMessages).not.toHaveBeenCalled() expect(mockCurrentTask.submitUserMessage).toHaveBeenCalledWith("Edited message", []) - expect(submitObservedCompletedOverwrites).toBe(2) + expect(submitObservedCompletedOverwrites).toBe(1) }) it("should not use fallback when exact apiConversationHistoryIndex is found", async () => { diff --git a/src/core/webview/transcriptTransport.ts b/src/core/webview/transcriptTransport.ts index e596cb16b3..a650e4b99a 100644 --- a/src/core/webview/transcriptTransport.ts +++ b/src/core/webview/transcriptTransport.ts @@ -30,6 +30,7 @@ export type TranscriptFrame = { /** Payloads and Promise resolvers deliberately live outside the pure protocol state. */ export type TranscriptTransportState = { + closed: boolean generation: number nextJobId: number nextSnapshotId: number @@ -49,9 +50,11 @@ export type TranscriptAction = focusedTaskInstanceId?: string } | { type: "invalidate" } + | { type: "shutdown" } + | { type: "reopen" } | { type: "forget-task"; taskId: string } | { type: "pump"; focusedTaskId: string | undefined; focusedTaskInstanceId?: string } - | { type: "settle"; success: boolean } + | { type: "settle"; frame: TranscriptFrame; success: boolean } export type TranscriptTransition = { state: TranscriptTransportState @@ -59,7 +62,7 @@ export type TranscriptTransition = { 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. */ + /** Invalidation waits for physical completion; renderer shutdown settles every caller immediately. */ settle: Array<{ id: number; failed?: boolean }> } @@ -67,7 +70,7 @@ export function createTranscriptTransportState(chunkSize = 200): TranscriptTrans 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: [] } + return { closed: false, generation: 0, nextJobId: 0, nextSnapshotId: 0, sequences: new Map(), chunkSize, queue: [] } } export function isTranscriptRequestCurrent( @@ -77,6 +80,7 @@ export function isTranscriptRequestCurrent( focusedTaskInstanceId?: string, ): boolean { return ( + !state.closed && (request.generation ?? state.generation) === state.generation && request.taskId === focusedTaskId && request.taskInstanceId === focusedTaskInstanceId && @@ -126,6 +130,28 @@ export function reduceTranscriptTransport( // Never reset inFlight: an already invoked physical send cannot be unsent. result.state = { ...state, generation: state.generation + 1, queue: [], active: undefined } return result + case "shutdown": { + if (state.closed) return result + const ids = new Set(state.queue.map((job) => job.id)) + if (state.active) ids.add(state.active.job.id) + if (state.inFlight) ids.add(state.inFlight.job.id) + result.release = [...ids] + result.settle = [...ids].map((id) => ({ id })) + result.state = { + ...state, + closed: true, + generation: state.generation + 1, + queue: [], + active: undefined, + inFlight: undefined, + } + return result + } + case "reopen": + // Preserve sequences and IDs. Work captured before/during closure must never + // acquire the new renderer's generation, even if the task is unchanged. + if (state.closed) result.state = { ...state, closed: false, generation: state.generation + 1 } + return result case "forget-task": { const sequences = new Map(state.sequences) sequences.delete(action.taskId) @@ -133,7 +159,7 @@ export function reduceTranscriptTransport( return result } case "pump": { - if (state.inFlight) return result + if (state.closed || state.inFlight) return result let active = state.active const queue = [...state.queue] while (active || queue.length) { @@ -166,7 +192,14 @@ export function reduceTranscriptTransport( return result } case "settle": { - if (!state.inFlight) return result + if ( + !state.inFlight || + state.inFlight.job.id !== action.frame.job.id || + state.inFlight.job.generation !== action.frame.job.generation || + state.inFlight.phase !== action.frame.phase || + state.inFlight.start !== action.frame.start + ) + return result const { job, phase } = state.inFlight const finished = !action.success || !state.active || phase === "end" || job.kind !== "snapshot" if (finished) { @@ -219,18 +252,62 @@ export function transcriptFrameMessage(frame: TranscriptFrame, messages: readonl } } -/** One driver owns all physical transcript sends, even across repeated invalidations. */ +type TranscriptCallbacks = { + focusedTaskId: () => string | undefined + postMessage: (message: ExtensionMessage) => Promise + onError: (error: unknown) => void + focusedTaskInstanceId: () => string | undefined +} + +type SendCompletion = { finish?: (success: boolean, error?: unknown) => void } + +// The physical Promise retains only this detachable slot, not the driver, provider, +// frame, payload or caller. Keep these handlers outside the driver's lexical scope. +function observePhysicalSend(promise: Promise, completion: SendCompletion): void { + void promise.then( + () => completion.finish?.(true), + (error: unknown) => completion.finish?.(false, error), + ) +} + +/** One physical-send barrier per renderer, preserved across ordinary invalidations. */ export class TranscriptTransport { private state = createTranscriptTransportState() private readonly payloads = new Map() private readonly callers = new Map void; reject: (error: unknown) => void }>() + private callbacks?: TranscriptCallbacks + private pendingSend?: SendCompletion constructor( - private readonly focusedTaskId: () => string | undefined, - private readonly postMessage: (message: ExtensionMessage) => Promise, - private readonly onError: (error: unknown) => void, - private readonly focusedTaskInstanceId: () => string | undefined = () => undefined, - ) {} + focusedTaskId: () => string | undefined, + postMessage: (message: ExtensionMessage) => Promise, + onError: (error: unknown) => void, + focusedTaskInstanceId: () => string | undefined = () => undefined, + ) { + this.callbacks = { focusedTaskId, postMessage, onError, focusedTaskInstanceId } + } + + get closed(): boolean { + return this.state.closed + } + + shutdown(): void { + if (this.pendingSend) this.pendingSend.finish = undefined + this.pendingSend = undefined + this.callbacks = undefined + this.apply({ type: "shutdown" }) + } + + reopen( + focusedTaskId: () => string | undefined, + postMessage: (message: ExtensionMessage) => Promise, + onError: (error: unknown) => void, + focusedTaskInstanceId: () => string | undefined = () => undefined, + ): void { + if (!this.closed) return + this.callbacks = { focusedTaskId, postMessage, onError, focusedTaskInstanceId } + this.apply({ type: "reopen" }) + } get generation(): number { return this.state.generation @@ -252,13 +329,20 @@ export class TranscriptTransport { } enqueue(request: TranscriptRequest, messages: readonly ClineMessage[]): Promise { + const callbacks = this.callbacks + if (!callbacks || this.closed) return Promise.resolve() // 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(), this.focusedTaskInstanceId()) + !isTranscriptRequestCurrent( + this.state, + capturedRequest, + callbacks.focusedTaskId(), + callbacks.focusedTaskInstanceId(), + ) ) return Promise.resolve() // Task mutates message objects AND nested fields while posts are queued. Capture @@ -268,8 +352,8 @@ export class TranscriptTransport { type: "enqueue", request: capturedRequest, total: payload.length, - focusedTaskId: this.focusedTaskId(), - focusedTaskInstanceId: this.focusedTaskInstanceId(), + focusedTaskId: callbacks.focusedTaskId(), + focusedTaskInstanceId: callbacks.focusedTaskInstanceId(), }) if (!accepted) return Promise.resolve() this.payloads.set(accepted.id, payload) @@ -294,24 +378,35 @@ export class TranscriptTransport { } private drain(): void { + const callbacks = this.callbacks + if (!callbacks) return const { post } = this.apply({ type: "pump", - focusedTaskId: this.focusedTaskId(), - focusedTaskInstanceId: this.focusedTaskInstanceId(), + focusedTaskId: callbacks.focusedTaskId(), + focusedTaskInstanceId: callbacks.focusedTaskInstanceId(), }) - if (post) void this.send(post) + if (post) this.send(post) } - private async send(frame: TranscriptFrame): Promise { + private send(frame: TranscriptFrame): void { + const completion: SendCompletion = { + finish: (success, error) => { + completion.finish = undefined + if (this.pendingSend !== completion) return + this.pendingSend = undefined + this.apply({ type: "settle", frame, success }, error) + if (!success) this.callbacks?.onError(error) + this.drain() + }, + } + this.pendingSend = completion 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 }) + observePhysicalSend( + this.callbacks!.postMessage(transcriptFrameMessage(frame, this.payloads.get(frame.job.id)!)), + completion, + ) } catch (error) { - this.onError(error) - this.apply({ type: "settle", success: false }, error) + completion.finish?.(false, error) } - this.drain() } } diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index c2e91a5a21..c3ad2b7311 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -36,7 +36,6 @@ import { CloudService } from "@roo-code/cloud" import { TelemetryService } from "@roo-code/telemetry" import { type ApiMessage } from "../task-persistence/apiMessages" -import { saveTaskMessages } from "../task-persistence" import { importRooTaskHistory } from "../task-persistence/importRooTaskHistory" import { ClineProvider } from "./ClineProvider" @@ -341,37 +340,9 @@ export const webviewMessageHandler = async ( vscode.window.showWarningMessage("No checkpoint found before this message") } } else { - // For non-checkpoint deletes, preserve checkpoint associations for remaining messages - // Store checkpoints from messages that will be preserved - const preservedCheckpoints = new Map() - for (let i = 0; i < messageIndex; i++) { - const msg = currentCline.clineMessages[i] - if (msg?.checkpoint && msg.ts) { - preservedCheckpoints.set(msg.ts, msg.checkpoint) - } - } - - // Delete this message and all subsequent messages using MessageManager + // Rewind preserves the complete retained messages, including checkpoints, + // and owns persistence and snapshot publication. Do not pre-mutate or re-save. await currentCline.messageManager.rewindToTimestamp(targetMessage.ts!, { includeTargetMessage: false }) - - // Restore checkpoint associations for preserved messages - for (const [ts, checkpoint] of preservedCheckpoints) { - const msgIndex = currentCline.clineMessages.findIndex((msg) => msg.ts === ts) - if (msgIndex !== -1) { - currentCline.clineMessages[msgIndex].checkpoint = checkpoint - } - } - - // Save the updated messages with restored checkpoints - await saveTaskMessages({ - messages: currentCline.clineMessages, - taskId: currentCline.taskId, - globalStoragePath: provider.contextProxy.globalStorageUri.fsPath, - }) - - // 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) @@ -510,39 +481,13 @@ export const webviewMessageHandler = async ( } } - // Store checkpoints from messages that will be preserved - const preservedCheckpoints = new Map() - for (let i = 0; i < deleteFromMessageIndex; i++) { - const msg = currentCline.clineMessages[i] - if (msg?.checkpoint && msg.ts) { - preservedCheckpoints.set(msg.ts, msg.checkpoint) - } - } - - // Delete the original (user) message and all subsequent messages using MessageManager + // Rewind preserves checkpoint metadata and publishes only after persistence. + // A rejection must stop the edit before submitting a new user message. const rewindTs = currentCline.clineMessages[deleteFromMessageIndex]?.ts if (rewindTs) { await currentCline.messageManager.rewindToTimestamp(rewindTs, { includeTargetMessage: false }) } - // Restore checkpoint associations for preserved messages - for (const [ts, checkpoint] of preservedCheckpoints) { - const msgIndex = currentCline.clineMessages.findIndex((msg) => msg.ts === ts) - if (msgIndex !== -1) { - currentCline.clineMessages[msgIndex].checkpoint = checkpoint - } - } - - // Save the updated messages with restored checkpoints - await saveTaskMessages({ - messages: currentCline.clineMessages, - taskId: currentCline.taskId, - 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) diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index 92640681e3..99253d925b 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": 196 + "count": 141 } }, "core/webview/__tests__/ClineProvider.sticky-mode.spec.ts": { @@ -1081,7 +1081,7 @@ }, "core/webview/__tests__/webviewMessageHandler.checkpoint.spec.ts": { "@typescript-eslint/no-explicit-any": { - "count": 5 + "count": 2 } }, "core/webview/__tests__/webviewMessageHandler.cloudAuth.spec.ts": { @@ -1131,7 +1131,7 @@ }, "core/webview/webviewMessageHandler.ts": { "@typescript-eslint/no-explicit-any": { - "count": 5 + "count": 3 } }, "extension/__tests__/api-delete-queued-message.spec.ts": {