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/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..41f1617261 --- /dev/null +++ b/docs/architecture/transcript-transport-model.md @@ -0,0 +1,128 @@ +# 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: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 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. + +**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 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 | +| 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: 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 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. 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 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. 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: + +- 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; +- 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 + +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 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. + +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 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. + +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/package.json b/package.json index 1fd9ddc8fe..6d336cd5f5 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "check-types": "turbo check-types --log-order grouped --output-logs new-only", "test": "turbo test --log-order grouped --output-logs new-only", "test:mutation-ci": "node --test scripts/stryker-diff.test.mjs", - "lifecycle:model-check": "tsx scripts/check-task-lifecycle.ts && tsx scripts/check-task-store-concurrency.ts && tsx scripts/check-provider-handoff-scheduler.ts && pnpm cleanup-protocol:model-check && pnpm parser-scope:model-check && tsx scripts/check-completion-persistence.ts", + "lifecycle:model-check": "tsx scripts/check-task-lifecycle.ts && tsx scripts/check-task-store-concurrency.ts && tsx scripts/check-provider-handoff-scheduler.ts && pnpm cleanup-protocol:model-check && pnpm parser-scope:model-check && tsx scripts/check-completion-persistence.ts && pnpm transcript-transport:model-check", + "transcript-transport:model-check": "tsx scripts/check-transcript-transport.ts", "cleanup-protocol:model-check": "tsx scripts/check-task-cleanup-protocol.ts", "parser-scope:model-check": "node scripts/run-native-tool-call-parser-scoping.mjs", "mcp:integration-check": "tsx scripts/check-mcp-oauth-integration.ts", diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index 5f6b579779..d09e839a75 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -37,7 +37,13 @@ export interface ExtensionMessage { | "theme" | "workspaceUpdated" | "invoke" - | "messageUpdated" + | "clineMessagesFocus" + | "clineMessageAppended" + | "clineMessageUpdated" + | "clineMessagesSnapshotStart" + | "clineMessagesSnapshotChunk" + | "clineMessagesSnapshotEnd" + | "messageUpdated" // Legacy: a patched webview requests a full resync instead of applying this. | "mcpServers" | "enhancedPrompt" | "commitSearchResults" @@ -138,7 +144,69 @@ 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. + * 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. + * 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, 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. + */ + 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[] ollamaModels?: ModelRecord @@ -334,7 +402,18 @@ 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 + /** + * 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 @@ -426,10 +505,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 } @@ -646,8 +724,23 @@ export interface WebviewMessage { | "openRuleFile" | "openRulesDirectory" | "themeFixtureProbeResponse" + | "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" disabled?: boolean diff --git a/scripts/check-transcript-transport.ts b/scripts/check-transcript-transport.ts new file mode 100644 index 0000000000..af824d1d29 --- /dev/null +++ b/scripts/check-transcript-transport.ts @@ -0,0 +1,17 @@ +import { + checkTranscriptTransportModel, + TRANSPORT_MODEL_BOUNDS, +} from "../src/core/webview/__tests__/transcriptTransport.model" + +const result = checkTranscriptTransportModel() +console.log(`Transcript transport model passed; bounds=${JSON.stringify(TRANSPORT_MODEL_BOUNDS)}`) +for (const scenario of result.results) { + console.log( + `${scenario.name}: ${scenario.states} states, ${scenario.transitions} transitions, maximum depth ${scenario.maximumDepth}`, + ) +} +console.log(`Actions (${result.actions.length}): ${result.actions.join(", ")}`) +console.log(`Landmarks (${result.landmarks.length}): ${result.landmarks.join(", ")}`) +for (const counterexample of result.counterexamples) { + console.log(`Mutant ${counterexample.name}: ${counterexample.violation}\n ${counterexample.trace.join(" -> ")}`) +} diff --git a/src/__tests__/extension.spec.ts b/src/__tests__/extension.spec.ts index bb72d567dd..0cdfafd69f 100644 --- a/src/__tests__/extension.spec.ts +++ b/src/__tests__/extension.spec.ts @@ -2,11 +2,13 @@ import type * as vscode from "vscode" +const mockOutputChannel = vi.hoisted(() => ({ + appendLine: vi.fn(), +})) + vi.mock("vscode", () => ({ window: { - createOutputChannel: vi.fn().mockReturnValue({ - appendLine: vi.fn(), - }), + createOutputChannel: vi.fn().mockReturnValue(mockOutputChannel), registerWebviewViewProvider: vi.fn(), registerUriHandler: vi.fn(), tabGroups: { @@ -188,7 +190,7 @@ vi.mock("../core/webview/ClineProvider", async () => { resolveWebviewView: vi.fn(), postMessageToWebview: vi.fn(), postStateToWebview: vi.fn(), - postStateToWebviewWithoutClineMessages: vi.fn().mockResolvedValue(undefined), + postStateToWebviewWithoutTaskHistory: vi.fn().mockResolvedValue(undefined), getState: vi.fn().mockResolvedValue({}), initializeCloudProfileSyncWhenReady: vi.fn().mockResolvedValue(undefined), providerSettingsManager: {}, @@ -295,17 +297,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( @@ -433,6 +435,29 @@ 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() + 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(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 () => { const vscode = await import("vscode") const { ClineProvider } = await import("../core/webview/ClineProvider") @@ -440,17 +465,53 @@ describe("extension.ts", () => { const { activate } = await import("../extension") await activate(mockContext) - const visibleInstance = ( - ClineProvider as unknown as { - getVisibleInstance(): { postStateToWebviewWithoutClineMessages: ReturnType } - } - ).getVisibleInstance() - vi.mocked(visibleInstance.postStateToWebviewWithoutClineMessages).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.postStateToWebviewWithoutClineMessages).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 852e2f5a67..863cb70849 100644 --- a/src/__tests__/helpers/provider-stub.ts +++ b/src/__tests__/helpers/provider-stub.ts @@ -1,10 +1,17 @@ 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 + 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 @@ -16,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 @@ -35,7 +43,16 @@ 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.clineMessagesTransport ??= new TranscriptTransport( + () => s.getCurrentTask?.()?.taskId, + async (message) => { + await s.postMessageToWebview?.(message) + }, + () => {}, + () => s.getCurrentTask?.()?.instanceId, + ) 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() } @@ -49,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 af1631df9c..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), @@ -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: {}, @@ -278,7 +279,7 @@ describe("Single-open-task invariant", () => { getProviderSettings: vi.fn(() => ({})), }, postStateToWebview: vi.fn(), - } as unknown as ClineProvider + }) const historyItem = { id: historyId, @@ -291,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() @@ -315,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, @@ -341,6 +350,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: {}, @@ -350,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 92ee8184d6..76020145a6 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,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 }, ) + // 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, this.instanceId).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) @@ -1272,20 +1289,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, this.instanceId) } 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() @@ -1307,10 +1314,15 @@ 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) } + await this.providerRef.deref()?.postClineMessagesSnapshot(this.taskId, { + bumpSeq: true, + taskInstanceId: this.instanceId, + }) } private hydrateClineMessages(messages: ClineMessage[]) { @@ -1336,8 +1348,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?.postMessageToWebview({ type: "messageUpdated", clineMessage: message }) + if (message.partial === true) { + this.debouncedPostPartialMessageUpdate(message) + } else { + this.debouncedPostPartialMessageUpdate.cancel() + await this.providerRef.deref()?.postClineMessageUpdated(this.taskId, message, this.instanceId) + } this.emit(RooCodeEventName.Message, { action: "updated", message }) // Check if we should sync to cloud and haven't already synced this message @@ -1432,7 +1448,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 +1482,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 +1736,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 +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()?.postStateToWebviewWithoutTaskHistory() + await this.providerRef.deref()?.postClineMessagesSnapshot(this.taskId, { + bumpSeq: true, + taskInstanceId: this.instanceId, + }) await this.say("text", task, images) @@ -2342,16 +2362,26 @@ 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, + taskInstanceId: this.instanceId, + }) + if (this.abort || this.abandoned) { return } + if (this.pendingAction) { + this.isInitialized = true + await this.resumePendingTaskAction(this.pendingAction) + return + } + const lastClineMessage = this.clineMessages .slice() .reverse() @@ -2366,7 +2396,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 @@ -2697,6 +2727,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 @@ -3081,7 +3112,10 @@ export class Task extends EventEmitter implements TaskLike { } satisfies ClineApiReqInfo) await this.saveClineMessages() - await this.providerRef.deref()?.postStateToWebviewWithoutTaskHistory() + const apiRequestMessage = this.clineMessages[lastApiReqIndex] + if (apiRequestMessage) { + await this.updateClineMessage(apiRequestMessage) + } try { let cacheWriteTokens = 0 @@ -3152,12 +3186,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 +3837,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..82886ae048 100644 --- a/src/core/task/__tests__/Task.persistence.spec.ts +++ b/src/core/task/__tests__/Task.persistence.spec.ts @@ -296,10 +296,239 @@ 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() }) + 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", () => { @@ -1341,7 +1570,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 }, @@ -1368,14 +1597,54 @@ describe("Task persistence", () => { }, startTask: false, }) + const events: string[] = [] const replay = vi .spyOn(getTaskPersistenceAccess(task), "resumePendingTaskAction") - .mockResolvedValue(undefined) + .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).mockImplementationOnce(async () => { + events.push("snapshot started") + snapshotStarted.resolve() + await snapshotDeferred.promise + events.push("snapshot resolved") + }) - await getTaskPersistenceAccess(task).resumeTaskFromHistory() + 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, + taskInstanceId: task.instanceId, + }) + 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 })]), @@ -1415,6 +1684,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({ @@ -1676,6 +1988,161 @@ 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, + taskInstanceId: task.instanceId, + }), + ) + 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) => { @@ -1705,6 +2172,7 @@ describe("Task persistence", () => { expect(askSpy).not.toHaveBeenCalled() expect(mockSaveTaskMessages).not.toHaveBeenCalled() expect(mockProvider.updateTaskHistory).not.toHaveBeenCalled() + expect(mockProvider.postClineMessagesSnapshot).not.toHaveBeenCalled() }, ) @@ -1822,6 +2290,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 () => { @@ -1859,6 +2328,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 1bcacd459c..1ae3a9e262 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -187,6 +187,9 @@ vi.mock("vscode", () => { Disposable: { from: vi.fn(), }, + RelativePattern: vi.fn().mockImplementation(function (base: string, pattern: string) { + return { base, pattern } + }), TabInputText: vi.fn(), } }) @@ -371,6 +374,9 @@ describe("Cline", () => { mockProvider.postStateToWebviewWithoutTaskHistory = vi.fn().mockResolvedValue(undefined) mockProvider.postStateToWebviewThrottled = vi.fn().mockResolvedValue(undefined) mockProvider.flushPostStateToWebviewThrottled = vi.fn().mockResolvedValue(undefined) + mockProvider.postClineMessageAppended = vi.fn().mockResolvedValue(undefined) + mockProvider.postClineMessageUpdated = vi.fn().mockResolvedValue(undefined) + mockProvider.postClineMessagesSnapshot = vi.fn().mockResolvedValue(undefined) mockProvider.getTaskWithId = vi.fn().mockImplementation(async (id) => ({ historyItem: { id, @@ -1476,6 +1482,9 @@ describe("Cline", () => { postStateToWebviewThrottled: vi.fn().mockResolvedValue(undefined), flushPostStateToWebviewThrottled: vi.fn().mockResolvedValue(undefined), postMessageToWebview: vi.fn().mockResolvedValue(undefined), + postClineMessageAppended: vi.fn().mockResolvedValue(undefined), + postClineMessageUpdated: vi.fn().mockResolvedValue(undefined), + postClineMessagesSnapshot: vi.fn().mockResolvedValue(undefined), updateTaskHistory: vi.fn().mockResolvedValue(undefined), // Task receives a full ClineProvider at runtime; this focused unit test only exercises these methods. } as unknown as MockedClineProvider @@ -2164,8 +2173,186 @@ describe("Cline", () => { }) }) - describe("webview state throttling", () => { - it("schedules a complete new message without forcing an immediate state push", async () => { + describe("webview transcript transport", () => { + afterEach(() => { + vi.useRealTimers() + }) + + 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, + }) + 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, + type: "say" as const, + say: "text" as const, + text: "replacement transcript", + }, + ] + + 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, + taskInstanceId: task.instanceId, + }) + }) + + 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, + taskInstanceId: task.instanceId, + }), + ) + 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.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" } + 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, 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, task.instanceId]]) + 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, + 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.instanceId], + [task.taskId, replacement, task.instanceId], + ]) + await vi.advanceTimersByTimeAsync(500) + + expect(updatePostSpy).toHaveBeenCalledTimes(2) + }) + + 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, + taskInstanceId: task.instanceId, + }) + }) + + 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, apiConfiguration: mockApiConfig, @@ -2182,13 +2369,41 @@ 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, task.instanceId) + expect(mockProvider.postStateToWebviewThrottled).not.toHaveBeenCalled() expect(mockProvider.postStateToWebviewWithoutTaskHistory).not.toHaveBeenCalled() }) - it("waits for an unanswered ask flush before emitting the message", async () => { + 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, apiConfiguration: mockApiConfig, @@ -2197,11 +2412,11 @@ describe("Cline", () => { }) const taskAccess = getTaskTestAccess(task) vi.spyOn(taskAccess, "saveClineMessages").mockResolvedValue(true) - let releaseFlush!: () => void - const pendingFlush = new Promise((resolve) => { - releaseFlush = resolve + let releasePost!: () => void + const pendingPost = new Promise((resolve) => { + releasePost = resolve }) - const flushSpy = vi.mocked(mockProvider.flushPostStateToWebviewThrottled).mockReturnValueOnce(pendingFlush) + const postSpy = vi.mocked(mockProvider.postClineMessageAppended).mockReturnValueOnce(pendingPost) const messageListener = vi.fn() task.on(RooCodeEventName.Message, messageListener) const message = { @@ -2213,20 +2428,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, task.instanceId) expect(messageListener).not.toHaveBeenCalled() - releaseFlush() + releasePost() await addPromise - expect(flushSpy.mock.invocationCallOrder[0]).toBeLessThan(messageListener.mock.invocationCallOrder[0]) + expect(postSpy.mock.invocationCallOrder[0]).toBeLessThan(messageListener.mock.invocationCallOrder[0]) expect(messageListener).toHaveBeenCalledWith({ action: "created", message }) }) - it("continues the message lifecycle when throttled state scheduling and flushing fail", async () => { + it("continues the message lifecycle when an incremental append fails", async () => { const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, @@ -2234,10 +2446,8 @@ describe("Cline", () => { startTask: false, }) const taskAccess = getTaskTestAccess(task) - const postError = new Error("state schedule failed") - const flushError = new Error("state flush failed") - const postSpy = vi.mocked(mockProvider.postStateToWebviewThrottled).mockRejectedValueOnce(postError) - const flushSpy = vi.mocked(mockProvider.flushPostStateToWebviewThrottled).mockRejectedValueOnce(flushError) + const postError = new Error("incremental append failed") + const postSpy = vi.mocked(mockProvider.postClineMessageAppended).mockRejectedValueOnce(postError) const saveSpy = vi.spyOn(taskAccess, "saveClineMessages").mockResolvedValue(true) const messageListener = vi.fn() const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) @@ -2251,25 +2461,19 @@ describe("Cline", () => { await expect(taskAccess.addToClineMessages(message)).resolves.toBeUndefined() expect(consoleErrorSpy).toHaveBeenCalledWith( - "[Task#addToClineMessages] postStateToWebviewThrottled failed:", + "[Task#addToClineMessages] incremental post failed:", postError, ) - expect(consoleErrorSpy).toHaveBeenCalledWith( - "[Task#addToClineMessages] flushPostStateToWebviewThrottled failed:", - flushError, - ) expect(postSpy).toHaveBeenCalledOnce() - expect(flushSpy).toHaveBeenCalledOnce() expect(messageListener).toHaveBeenCalledWith({ action: "created", message }) expect(saveSpy).toHaveBeenCalledOnce() - expect(postSpy.mock.invocationCallOrder[0]).toBeLessThan(flushSpy.mock.invocationCallOrder[0]) - expect(flushSpy.mock.invocationCallOrder[0]).toBeLessThan(messageListener.mock.invocationCallOrder[0]) + expect(postSpy.mock.invocationCallOrder[0]).toBeLessThan(messageListener.mock.invocationCallOrder[0]) expect(messageListener.mock.invocationCallOrder[0]).toBeLessThan(saveSpy.mock.invocationCallOrder[0]) consoleErrorSpy.mockRestore() }) - it("keeps an already answered ask on the throttled path", async () => { + it("posts an already answered ask through the incremental transport", async () => { const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, @@ -2278,19 +2482,19 @@ 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, task.instanceId) }) - it("waits for a new partial message flush before a following message update", async () => { + it("serializes a new partial message before its immediate leading update", async () => { + vi.useFakeTimers() const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, @@ -2299,12 +2503,12 @@ describe("Cline", () => { }) const taskAccess = getTaskTestAccess(task) vi.spyOn(taskAccess, "saveClineMessages").mockResolvedValue(true) - let releaseFlush!: () => void - const pendingFlush = new Promise((resolve) => { - releaseFlush = resolve + let releaseAppend!: () => void + const pendingAppend = new Promise((resolve) => { + releaseAppend = resolve }) - const flushSpy = vi.mocked(mockProvider.flushPostStateToWebviewThrottled).mockReturnValueOnce(pendingFlush) - const updatePostSpy = vi.mocked(mockProvider.postMessageToWebview) + const appendSpy = vi.mocked(mockProvider.postClineMessageAppended).mockReturnValueOnce(pendingAppend) + const updatePostSpy = vi.mocked(mockProvider.postClineMessageUpdated) const partialMessage = { ts: 1, type: "say" as const, @@ -2319,22 +2523,288 @@ describe("Cline", () => { }) await Promise.resolve() - expect(mockProvider.postStateToWebviewThrottled).toHaveBeenCalledWith() - expect(flushSpy).toHaveBeenCalledWith() + expect(appendSpy).toHaveBeenCalledWith(task.taskId, partialMessage, task.instanceId) 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(updatePostSpy).toHaveBeenCalledOnce() + expect(appendSpy.mock.invocationCallOrder[0]).toBeLessThan(updatePostSpy.mock.invocationCallOrder[0]) + expect(updatePostSpy).toHaveBeenCalledWith( + task.taskId, + { ...partialMessage, text: "updated partial" }, + task.instanceId, + ) + + 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, task.instanceId]]) + await updatePromise + await vi.advanceTimersByTimeAsync(1_000) + expect(updatePostSpy).toHaveBeenCalledOnce() + }) + + 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, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + const taskAccess = getTaskTestAccess(task) + const updatePostSpy = vi.mocked(mockProvider.postClineMessageUpdated) + + const first = { + ts: 1, + type: "say" as const, + say: "text" as const, + text: "first 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(249) + expect(updatePostSpy.mock.calls).toEqual([[task.taskId, first, task.instanceId]]) + + await vi.advanceTimersByTimeAsync(1) + expect(updatePostSpy.mock.calls).toEqual([ + [task.taskId, first, task.instanceId], + [task.taskId, latest, task.instanceId], + ]) + await vi.advanceTimersByTimeAsync(1_000) + expect(updatePostSpy).toHaveBeenCalledTimes(2) + }) + + it("bounds ongoing partial delivery by maxWait without posting every revision", 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) + 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)) + } + + await vi.advanceTimersByTimeAsync(100) + expect(updatePostSpy.mock.calls).toEqual([ + [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) + }) + + 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, task.instanceId]], + ) + }, + ) + + 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.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 taskAccess.updateClineMessage(first) + await vi.advanceTimersByTimeAsync(100) + await taskAccess.updateClineMessage({ ...first, text: "queued partial" }) + 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, task.instanceId]]) + }, + ) + + 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) + + const first = { + ts: 1, + 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, task.instanceId]]) + + let releasePost!: () => void + const pendingPost = new Promise((resolve) => { + releasePost = resolve + }) + 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.instanceId], + [task.taskId, complete, task.instanceId], + ]) + 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.each(["leading", "trailing"] as const)("handles a rejected %s partial update", 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) + } + const postError = new Error("incremental update failed") + vi.mocked(mockProvider.postClineMessageUpdated).mockRejectedValueOnce(postError) + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + + try { + await taskAccess.updateClineMessage({ ...first, text: "rejected partial" }) + await vi.advanceTimersByTimeAsync(500) + + expect( + consoleErrorSpy.mock.calls.filter( + ([message]) => message === "[Task#updateClineMessage] incremental post failed:", + ), + ).toEqual([["[Task#updateClineMessage] incremental post failed:", postError]]) + } finally { + consoleErrorSpy.mockRestore() + } }) }) @@ -2688,6 +3158,114 @@ 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("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, @@ -3329,6 +3907,72 @@ 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" }), + task.instanceId, + ) + } + }) + }) + describe("safeEnsureModelFetched", () => { it("loads model metadata before getModel is used", async () => { const task = new Task({ @@ -3556,7 +4200,7 @@ describe("Cline", () => { }) describe("startTask", () => { - it("posts a clean state immediately before adding the first task message", async () => { + it("posts an empty transcript snapshot before adding the first task message", async () => { const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, @@ -3567,16 +4211,14 @@ describe("Cline", () => { task.clineMessages = [{ ts: 1, type: "say", say: "text", text: "stale message" }] - let resolvePostState: (() => void) | undefined - const pendingPostState = new Promise((resolve) => { - resolvePostState = resolve + let resolveSnapshot: (() => void) | undefined + const pendingSnapshot = new Promise((resolve) => { + resolveSnapshot = resolve + }) + const snapshotSpy = vi.mocked(mockProvider.postClineMessagesSnapshot).mockImplementationOnce(async () => { + expect(task.clineMessages).toEqual([]) + await pendingSnapshot }) - const postStateSpy = vi - .mocked(mockProvider.postStateToWebviewWithoutTaskHistory) - .mockImplementationOnce(async () => { - expect(task.clineMessages).toEqual([]) - await pendingPostState - }) const saySpy = vi.spyOn(task, "say").mockResolvedValue(undefined) vi.spyOn(taskAccess, "getEnabledMcpToolsCount").mockResolvedValue({ enabledToolCount: 0, @@ -3586,17 +4228,42 @@ describe("Cline", () => { const startPromise = taskAccess.startTask("new task") - expect(postStateSpy).toHaveBeenCalledTimes(1) + expect(snapshotSpy).toHaveBeenCalledWith(task.taskId, { bumpSeq: true, taskInstanceId: task.instanceId }) expect(mockProvider.postStateToWebviewThrottled).not.toHaveBeenCalled() expect(saySpy).not.toHaveBeenCalled() - resolvePostState?.() + resolveSnapshot?.() await startPromise expect(saySpy).toHaveBeenCalledOnce() 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()", () => { @@ -4022,6 +4689,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 495fe454b7..fc3acbfc62 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -132,6 +132,7 @@ import { getUri } from "./getUri" import { REQUESTY_BASE_URL } from "../../shared/utils/requesty" import { validateAndFixToolResultIds } from "../task/validateToolResultIds" import { PendingEditOperationStore, type PendingEditOperationInput } from "./PendingEditOperationStore" +import { TranscriptTransport, type TranscriptRequest } from "./transcriptTransport" /** * https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts @@ -222,6 +223,13 @@ export class ClineProvider private taskEventListeners: WeakMap void>> = new WeakMap() private currentWorkspacePath: string | undefined private _disposed = false + 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)}`), + () => this.getCurrentTask()?.instanceId, + ) private readonly _postStateToWebviewThrottled = debounce( async () => { try { @@ -312,12 +320,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 @@ -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 @@ -577,6 +579,7 @@ export class ClineProvider // Add this cline instance into the stack that represents the order of // all the called tasks. this.taskRegistry.push(task) + await this.publishFocusedTaskScope() task.emit(RooCodeEventName.TaskFocused) // Perform special setup provider specific tasks. @@ -588,6 +591,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) { @@ -618,8 +623,10 @@ 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) { task.emit(RooCodeEventName.TaskUnfocused) @@ -646,6 +653,8 @@ export class ClineProvider // garbage collected. task = undefined } + + await this.syncFocusedTaskToWebview() } /** @@ -1383,6 +1392,11 @@ export class ClineProvider const oldTask = this.taskRegistry.current if (oldTask) { + // Publish replacement ownership before cleanup can yield. Old Task producers + // must already be stale during abort, not just after model preparation. + this.taskRegistry.replace(oldTask.taskId, task) + await this.publishFocusedTaskScope() + // Abort the old task to stop running processes and mark as abandoned try { await oldTask.abortTask(true) @@ -1398,15 +1412,13 @@ 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) // Perform preparation tasks and set up event listeners await this.performPreparationTasks(task) + await this.syncFocusedTaskToWebview() this.log( `[createTaskWithHistoryItem] rehydrated task ${task.taskId}.${task.instanceId} in-place (flicker-free)`, @@ -1478,6 +1490,33 @@ 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. + if (process.env.ROO_CLI_RUNTIME !== "1") { + const { + clineMessages: _omitMessages, + clineMessagesSeq: _omitMessagesSeq, + ...metadataState + } = message.state + message = { ...message, state: metadataState } + } + } + try { await this.view?.webview.postMessage(message) } catch { @@ -1485,6 +1524,105 @@ export class ClineProvider } } + private invalidateClineMessagesTransport(): number { + return this.clineMessagesTransport.invalidate() + } + + 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 { + return this.postTranscript({ kind: "append", taskId, taskInstanceId, message }) + } + + public postClineMessageUpdated(taskId: string, message: ClineMessage, taskInstanceId?: string): Promise { + 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() + // 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 request.kind === "update" + ? this.postMessageToWebview({ type: "messageUpdated", clineMessage: structuredClone(request.message) }) + : this.postStateToWebviewWithoutTaskHistory() + } + + return this.clineMessagesTransport.enqueue( + request, + request.kind === "snapshot" ? (currentTask?.clineMessages ?? []) : [request.message], + ) + } + + public resyncClineMessagesToWebview(taskId?: string, expectedSeq?: unknown, receivedSeq?: unknown): Promise { + const currentTask = this.getCurrentTask() + const currentTaskId = currentTask?.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 => { + 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 = this.clineMessagesTransport.getSequence(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, taskInstanceId: currentTask?.instanceId }) + } + + public async syncFocusedTaskToWebview(options: { includeTaskHistory?: boolean } = {}): Promise { + const currentTask = this.getCurrentTask() + const generation = await this.publishFocusedTaskScope() + if (options.includeTaskHistory) { + await this.postStateToWebview() + } else { + await this.postStateToWebviewWithoutTaskHistory() + } + if (generation !== this.clineMessagesTransport.generation) { + return + } + await this.postClineMessagesSnapshot(currentTask?.taskId, { + generation, + taskInstanceId: currentTask?.instanceId, + }) + } + 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")) @@ -2375,6 +2513,9 @@ export class ClineProvider // Delete all tasks from state in one batch await this.taskHistoryStore.deleteMany(allIdsToDelete) + for (const taskId of allIdsToDelete) { + this.clineMessagesTransport.forgetTask(taskId) + } this.recentTasksCache = undefined // Delete associated shadow repositories or branches and task directories @@ -2417,6 +2558,7 @@ export class ClineProvider async deleteTaskFromState(id: string) { await this.taskHistoryStore.delete(id) + this.clineMessagesTransport.forgetTask(id) this.recentTasksCache = undefined await this.postStateToWebview() @@ -2428,9 +2570,7 @@ export class ClineProvider } async postStateToWebview() { - const clineMessagesSeq = ++this.clineMessagesSeq const state = await this.getStateToPostToWebview() - state.clineMessagesSeq = clineMessagesSeq await this.postMessageToWebview({ type: "state", state }) } @@ -2443,11 +2583,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 }) } /** @@ -2472,23 +2610,6 @@ export class ClineProvider await this._postStateToWebviewThrottled.flush() } - /** - * Like postStateToWebview but intentionally omits both clineMessages and 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 { - const state = await this.getStateToPostToWebview({ includeTaskHistory: false }) - const { clineMessages: _omitMessages, taskHistory: _omitHistory, ...rest } = state - await this.postMessageToWebview({ type: "state", state: rest }) - } - /** * Fetches marketplace data on demand to avoid blocking main state updates */ @@ -2775,7 +2896,8 @@ export class ClineProvider autoCondenseContext: autoCondenseContext ?? true, autoCondenseContextPercent: autoCondenseContextPercent ?? 100, uriScheme: vscode.env.uriScheme, - currentTaskId: currentTask?.taskId, + 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 bfd4706dcc..ada42b8950 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,9 +36,21 @@ 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" +import { openAiCodexOAuthManager } from "../../../integrations/openai-codex/oauth" -// 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 +123,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 +181,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() })), }, @@ -236,6 +243,7 @@ vi.mock("../../../integrations/openai-codex/oauth", () => ({ openAiCodexOAuthManager: { getAccessToken: vi.fn(), getAccountId: vi.fn(), + isAuthenticated: vi.fn().mockResolvedValue(false), }, })) @@ -263,27 +271,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 +399,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(), @@ -449,6 +436,7 @@ describe("ClineProvider", () => { beforeEach(() => { vi.clearAllMocks() + delete process.env.ROO_CLI_RUNTIME if (!TelemetryService.hasInstance()) { TelemetryService.createInstance([]) @@ -760,7 +748,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 +851,1112 @@ 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" } }) + }) + + 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) + }) + + 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; instanceId?: 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.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() + }, + } + 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"] + 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(readTranscript).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], 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, + 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: 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" + 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", images: ["original-image"] }), + clineMessagesSeq: 1, + }, + { + type: "clineMessageUpdated", + taskId: "task-1", + clineMessage: expect.objectContaining({ text: "updated", images: ["updated-image"] }), + clineMessagesSeq: 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(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 () => { + setCurrentTask(undefined) + const message = { ts: 1, type: "say", say: "text", text: "ignored" } as ClineMessage + 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["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 () => { + 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) => ({ + 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(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) + }) + + 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) + setSequence(task.taskId, 4) + const postSpy = vi.spyOn(provider, "postMessageToWebview").mockResolvedValue(undefined) + const expectedSnapshot = structuredClone(messages) + const held = holdTransport() + + 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]) + held.release() + await Promise.all([held.active, snapshot, append, update]) + + const snapshotSeq = bumpSeq ? 5 : 4 + 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, + 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"], + ] 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() + + const held = holdTransport() + 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() + held.release() + 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.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") + const held = holdTransport() + 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" + held.release() + await pendingDelta + + expect(postSpy).not.toHaveBeenCalledWith(expect.objectContaining({ type: messageType })) + }, + ) + + 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) + const held = holdTransport() + 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) + held.release() + + 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[] } + setCurrentTask(task) + mockPostMessage.mockClear() + + const held = holdTransport() + const pendingDelta = provider.postClineMessageAppended("task-1", { + ts: 1, + type: "say", + say: "text", + text: "stale generation", + }) + const previousGeneration = provider["clineMessagesTransport"].generation + const resync = provider.resyncClineMessagesToWebview("task-1") + + expect(provider["clineMessagesTransport"].generation).toBe(previousGeneration + 1) + + held.release() + 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("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") + const held = holdTransport() + const pendingUpdate = provider.postClineMessageUpdated("task-1", { + ts: 1, + type: "say", + say: "text", + text: "stale generation", + }) + + provider["invalidateClineMessagesTransport"]() + held.release() + await pendingUpdate + + expect(postSpy).not.toHaveBeenCalledWith( + expect.objectContaining({ type: "clineMessageUpdated", taskId: "task-1" }), + ) + }) + + 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") + const held = holdTransport() + + const snapshot = provider.postClineMessagesSnapshot("task-1") + if (change === "focus") { + task.taskId = "task-2" + } else { + provider["invalidateClineMessagesTransport"]() + } + held.release() + await Promise.all([held.active, snapshot]) + + 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) + }, + ) + + 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["invalidateClineMessagesTransport"]() + } + }) + + await provider.postClineMessagesSnapshot("task-1") + + expect(postedTypes).toEqual(["clineMessagesSnapshotStart"]) + }) + + 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.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.resyncClineMessagesToWebview(undefined, expectedSeq, receivedSeq) + + 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) + setSequence(task.taskId, 4) + + await provider.removeClineFromStack() + + expect(provider["clineMessagesTransport"]["state"].sequences.has(task.taskId)).toBe(false) + }) + + test("prunes sequence state when a task is deleted from history", async () => { + setSequence("deleted-task", 4) + vi.spyOn(provider.taskHistoryStore, "delete").mockResolvedValue(undefined) + vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) + + await provider.deleteTaskFromState("deleted-task") + + expect(provider["clineMessagesTransport"]["state"].sequences.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) + setSequence("parent", 4) + setSequence("child", 7) + + await provider.deleteTaskWithId("parent") + + expect(provider.taskHistoryStore.deleteMany).toHaveBeenCalledWith(["parent", "child"]) + 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 () => { + const task = { taskId: "task-1", instanceId: "instance-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") + 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", winningOptions) + }) + + 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["clineMessagesTransport"].generation + + 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["clineMessagesTransport"].generation + + 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 () => { let releasePost!: () => void const pendingPost = new Promise((resolve) => { @@ -894,7 +1989,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 @@ -910,7 +2007,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() @@ -919,27 +2015,19 @@ 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([ - [ - "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() @@ -978,6 +2066,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() @@ -1269,6 +2370,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 @@ -1279,6 +2381,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__/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") }) diff --git a/src/core/webview/__tests__/transcriptTransport.model.ts b/src/core/webview/__tests__/transcriptTransport.model.ts new file mode 100644 index 0000000000..5fec1c9d0a --- /dev/null +++ b/src/core/webview/__tests__/transcriptTransport.model.ts @@ -0,0 +1,882 @@ +import type { ExtensionMessage } from "@roo-code/types" +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" + | "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 + data: Record + epochs: Record + captures: Capture[] + payloads: number[] + callers: number[] + physical?: TranscriptFrame + physicalMessage?: ExtensionMessage + 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 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[] = [ + { + 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"], + }, + { + 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", + "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", +] +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, + "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, + 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]] + // 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, focusedTaskInstanceId: s.focusInstance }, + }) + } + 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) +} + +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 + 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 seq = message.clineMessagesSeq ?? 0 + if (phase === "start") { + 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") + 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 (seq >= s.appliedSeq) { + s.visible = s.staging.values + s.appliedSeq = seq + s.committed.push(job.id) + } + s.staging = undefined + } 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( + 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, faults: Faults): ModelState { + const s = structuredClone(source) + try { + 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, faults: Faults): 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") { + 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 && + 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 + 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 = + 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") + 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, + 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) { + 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) + } + 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 && + 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 + } + 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.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. + 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", + ) + 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 + s.physicalMessage = message + 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" || intent === "replace-instance") { + const previous = s.focus + 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 === "switch" || intent === "clear")) { + apply({ type: "forget-task", taskId: previous }) + s.epochs[previous]++ + } + } + 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", + generation: s.transport.generation - (intent === "stale-snapshot" ? 1 : 0), + }, + total: values.length, + focusedTaskId: s.focus, + focusedTaskInstanceId: s.focusInstance, + }, + 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, + faults: Faults = {}, +) { + 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, faults) + } 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, + focusedTaskInstanceId: state.active?.job.taskInstanceId ?? state.queue[0]?.taskInstanceId, + } + : 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", start: 0, count: 0 } + 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), + }, + { + 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 + }, + }, + { + 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 checkTranscriptTransportScenarios() { + 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}`) + 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 new file mode 100644 index 0000000000..37a33a70f1 --- /dev/null +++ b/src/core/webview/__tests__/transcriptTransport.spec.ts @@ -0,0 +1,674 @@ +import type { ClineMessage, ExtensionMessage } from "@roo-code/types" +import { + createTranscriptTransportState, + reduceTranscriptTransport, + transcriptFrameMessage, + TranscriptTransport, + type TranscriptFrame, +} from "../transcriptTransport" +import { + checkTranscriptTransportMutation, + checkTranscriptTransportScenarios, + exploreTranscriptTransport, + TRANSPORT_ACTIONS, + TRANSPORT_LANDMARKS, + TRANSPORT_MUTATIONS, + TRANSPORT_SCENARIOS, +} from "./transcriptTransport.model" + +describe("transcript transport bounded model", () => { + 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()) + }) + + // 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", () => { + 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 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 }) + 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(["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) => { + 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) => { + 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) => { + 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("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()) + 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.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..4a873597b9 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) @@ -214,6 +215,52 @@ 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[] + 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, { + type: "editMessageConfirm", + messageTs: 1000, + text: "Edited message", + restoreCheckpoint: false, + }) + + expect(mockCurrentTask.overwriteClineMessages).toHaveBeenCalledTimes(2) + expect(mockCurrentTask.overwriteClineMessages).toHaveBeenLastCalledWith([ + expect.objectContaining({ ts: 500, checkpoint }), + ]) + expect(mockCurrentTask.submitUserMessage).toHaveBeenCalledWith("Edited message", []) + expect(submitObservedCompletedOverwrites).toBe(2) + }) + it("should not use fallback when exact apiConversationHistoryIndex is found", async () => { const userMessageTs = 1000 const assistantMessageTs = 2000 diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index 4c2a301965..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" @@ -117,6 +117,9 @@ const mockClineProvider = { }, log: vi.fn(), 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(), @@ -125,6 +128,81 @@ 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() + }) + + 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", + ...request, + }) + + expect(mockClineProvider.resyncClineMessagesToWebview).toHaveBeenCalledOnce() + 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() + }) +}) + +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/src/core/webview/transcriptTransport.ts b/src/core/webview/transcriptTransport.ts new file mode 100644 index 0000000000..e596cb16b3 --- /dev/null +++ b/src/core/webview/transcriptTransport.ts @@ -0,0 +1,317 @@ +import type { ClineMessage, ExtensionMessage } from "@roo-code/types" + +export type TranscriptRequest = { + kind: "append" | "update" | "snapshot" + taskId: string | undefined + taskInstanceId?: string + generation?: number + bumpSeq?: boolean +} + +export type TranscriptJob = { + id: number + generation: number + taskId: string | undefined + taskInstanceId: string | undefined + 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 +} + +/** 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 + focusedTaskInstanceId?: string + } + | { type: "invalidate" } + | { type: "forget-task"; taskId: string } + | { type: "pump"; focusedTaskId: string | undefined; focusedTaskInstanceId?: string } + | { 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, + focusedTaskInstanceId?: string, +): boolean { + return ( + (request.generation ?? state.generation) === state.generation && + request.taskId === focusedTaskId && + request.taskInstanceId === focusedTaskInstanceId && + (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, focusedTaskInstanceId } = action + 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) + (!snapshot || request.bumpSeq ? 1 : 0) + : 0 + if (request.taskId) sequences.set(request.taskId, seq) + const nextSnapshotId = state.nextSnapshotId + (snapshot ? 1 : 0) + const job: TranscriptJob = { + id: state.nextJobId + 1, + generation: state.generation, + taskId: request.taskId, + taskInstanceId: request.taskInstanceId, + seq, + kind: request.kind, + total, + ...(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 || + job.taskInstanceId !== action.focusedTaskInstanceId + ) { + 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 + } + } +} + +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 = { + type: transcriptMessageTypes[phase], + taskId: job.taskId, + taskInstanceId: job.taskInstanceId, + clineMessagesSeq: job.seq, + } + if (phase === "append" || phase === "update") { + return { + ...common, + clineMessage: messages[0], + } + } + const snapshot = { ...common, snapshotId: job.snapshotId } + if (phase === "chunk") { + return { + ...snapshot, + snapshotStartIndex: frame.start, + clineMessages: messages.slice(frame.start, frame.start + frame.count), + } + } + return { + ...snapshot, + 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, + private readonly focusedTaskInstanceId: () => string | undefined = () => undefined, + ) {} + + get generation(): number { + return this.state.generation + } + + 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 { + this.apply({ type: "forget-task", taskId }) + } + + invalidate(): number { + this.apply({ type: "invalidate" }) + return this.generation + } + + 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(), 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) + const { accepted } = this.apply({ + type: "enqueue", + request: capturedRequest, + total: payload.length, + focusedTaskId: this.focusedTaskId(), + focusedTaskInstanceId: this.focusedTaskInstanceId(), + }) + 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) { + // 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() + } + return transition + } + + private drain(): void { + const { post } = this.apply({ + type: "pump", + focusedTaskId: this.focusedTaskId(), + focusedTaskInstanceId: this.focusedTaskInstanceId(), + }) + 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 0dad65a480..12036a20e2 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, }) - // Update the UI to reflect the deletion - await provider.postStateToWebview() + // 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,9 +540,9 @@ export const webviewMessageHandler = async ( globalStoragePath: provider.contextProxy.globalStorageUri.fsPath, }) - // Update the UI to reflect the deletion - await provider.postStateToWebview() - + // 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) @@ -574,6 +575,9 @@ export const webviewMessageHandler = async ( } switch (message.type) { + case "requestClineMessagesResync": + 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) { provider.resolveWebviewThemeFixtureProbe(message.requestId, message.themeFixture) @@ -584,7 +588,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 +877,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 +1936,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.postStateToWebviewWithoutTaskHistory() if (TelemetryService.hasInstance()) { // Determine which setting was changed by comparing objects diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index d90272962b..5700774d48 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -1036,7 +1036,7 @@ }, "core/webview/__tests__/ClineProvider.spec.ts": { "@typescript-eslint/no-explicit-any": { - "count": 198 + "count": 196 } }, "core/webview/__tests__/ClineProvider.sticky-mode.spec.ts": { diff --git a/src/extension.ts b/src/extension.ts index 0a78cd32ba..d09c3ebc78 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -191,7 +191,13 @@ export async function activate(context: vscode.ExtensionContext) { // Push the new vscode.env.isTelemetryEnabled value to the webview too, so its // own PostHog client (gated separately in TelemetryClient.ts) can't keep // sending events after the global toggle flips off mid-session. - void ClineProvider.getVisibleInstance()?.postStateToWebviewWithoutClineMessages() + void ClineProvider.getVisibleInstance() + ?.postStateToWebviewWithoutTaskHistory() + .catch((error: unknown) => { + outputChannel.appendLine( + `[TelemetryService] Failed to refresh state after telemetry toggle: ${error instanceof Error ? error.message : String(error)}`, + ) + }) }), ) @@ -224,7 +230,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)}`, 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/chat/__tests__/ChatView.clear-approval-buttons.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatView.clear-approval-buttons.spec.tsx index 14ccce9751..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 @@ -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,17 @@ 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", + currentTaskId: "test-task-id", + 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..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 @@ -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,19 @@ 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", + currentTaskId: "test-task-id", + clineMessages: [], + taskHistory: [], + shouldShowAnnouncement: false, + cloudIsAuthenticated: false, + telemetrySetting: "enabled", + messageQueue: [], + ...state, + }) } const defaultProps: ChatViewProps = { @@ -270,6 +202,7 @@ describe("ChatView - Notification Sound with Queued Messages", () => { messageQueue: [ { id: "msg-1", + timestamp: 1, text: "This is a queued message", images: [], }, @@ -293,6 +226,7 @@ describe("ChatView - Notification Sound with Queued Messages", () => { messageQueue: [ { id: "msg-1", + timestamp: 1, text: "This is a queued message", images: [], }, @@ -381,11 +315,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 +345,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..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 @@ -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,17 @@ 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", + currentTaskId: "test-task-id", + 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..b6fa4fce4a 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({ currentTaskId: "test-task-id", ...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 }, + ) }) } @@ -796,6 +794,7 @@ describe("ChatView - Version Indicator Tests", () => { // Hydrate state with no active task mockPostMessage({ version: "1.0.0", + currentTaskId: null, clineMessages: [], }) @@ -805,7 +804,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, @@ -817,6 +816,7 @@ describe("ChatView - Version Indicator Tests", () => { // Hydrate state mockPostMessage({ version: "1.0.0", + currentTaskId: null, clineMessages: [], }) @@ -851,6 +851,7 @@ describe("ChatView - Version Indicator Tests", () => { // Hydrate state mockPostMessage({ version: "1.0.0", + currentTaskId: null, clineMessages: [], }) @@ -876,6 +877,7 @@ describe("ChatView - Version Indicator Tests", () => { // Hydrate state mockPostMessage({ version: "1.0.0", + currentTaskId: null, clineMessages: [], }) @@ -916,6 +918,7 @@ describe("ChatView - Version Indicator Tests", () => { // Hydrate state with no active task mockPostMessage({ version: "1.0.0", + currentTaskId: null, clineMessages: [], }) @@ -931,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 }, @@ -1019,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/components/ui/__tests__/AccessibilityContrast.visual.tsx b/webview-ui/src/components/ui/__tests__/AccessibilityContrast.visual.tsx index 7c6ac75d9d..75d961352e 100644 --- a/webview-ui/src/components/ui/__tests__/AccessibilityContrast.visual.tsx +++ b/webview-ui/src/components/ui/__tests__/AccessibilityContrast.visual.tsx @@ -3,6 +3,66 @@ 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 component + .getByTestId("unsupported-gradient") + .evaluate((element) => + element + .getAnimations() + .some( + (animation) => + animation instanceof CSSAnimation && + animation.animationName === "theme-test-spin" && + 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")) diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index 177372f310..76eebcb405 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, @@ -25,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" @@ -156,6 +155,17 @@ export interface ExtensionStateContextType extends ExtensionState { export const ExtensionStateContext = createContext(undefined) +type ClineMessagesSnapshotBuffer = { + snapshotId: string + taskId?: string + seq: number + total: number + messages: ClineMessage[] +} + +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 @@ -171,21 +181,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 { @@ -284,9 +279,24 @@ 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) + 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) + const resyncTimeoutRef = useRef(undefined) const [didHydrateState, setDidHydrateState] = useState(false) const [showWelcome, setShowWelcome] = useState(false) @@ -336,13 +346,215 @@ export const ExtensionStateContextProvider: React.FC<{ })) }, []) + const clearClineMessagesResync = useCallback( + () => { + resyncPendingRef.current = false + 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 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) => { + 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 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) { + return + } + activeSnapshotRef.current = null + snapshotTimeoutRef.current = undefined + 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], + ) + + const applyClineMessagesDelta = useCallback( + (message: ExtensionMessage, operation: "append" | "update") => { + const seq = message.clineMessagesSeq as number + const clineMessage = message.clineMessage + if ( + activeTaskIdRef.current === undefined || + message.taskId !== activeTaskIdRef.current || + message.taskInstanceId !== activeTaskInstanceIdRef.current + ) { + return + } + if (!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 + } + clearClineMessagesSnapshot() + retryClineMessagesResync(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] + clineMessagesIndex.set(clineMessage.ts, nextMessages.length - 1) + } else { + 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 + } + + clineMessagesRef.current = nextMessages + clineMessagesSeqRef.current = seq + setState((prevState) => ({ + ...prevState, + clineMessages: nextMessages, + clineMessagesSeq: seq, + })) + }, + // 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( (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 "clineMessagesFocus": case "state": { - const newState = message.state ?? {} - setState((prevState) => mergeExtensionState(prevState, newState)) + const { + clineMessages: _ignoredMessages, + clineMessagesSeq: _ignoredMessagesSeq, + ...newState + } = 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 + 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, + currentTaskInstanceId: + newState.currentTaskInstanceId !== undefined + ? newState.currentTaskInstanceId + : taskChanged + ? undefined + : prevState.currentTaskInstanceId, + }) + if (taskCleared) { + return { + ...merged, + currentTaskId: null, + currentTaskInstanceId: null, + currentTaskItem: undefined, + currentTaskTodos: [], + messageQueue: [], + clineMessages: [], + clineMessagesSeq: 0, + } + } + 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 @@ -404,26 +616,155 @@ 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 ( + activeTaskIdRef.current === undefined || + message.taskId !== activeTaskIdRef.current || + message.taskInstanceId !== activeTaskInstanceIdRef.current + ) { + break + } + + const seq = message.clineMessagesSeq as number + if (!Number.isSafeInteger(seq) || seq < 0) { + clearClineMessagesSnapshot() + retryClineMessagesResync(typeof seq === "number" ? seq : undefined) + break + } + if (seq < clineMessagesSeqRef.current) { + break + } + + const total = message.snapshotTotal as number + if (!message.snapshotId || !Number.isSafeInteger(total) || total < 0) { + clearClineMessagesSnapshot() + retryClineMessagesResync(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: [], + } + startClineMessagesSnapshotTimeout(message.snapshotId, seq) + break + } + case "clineMessagesSnapshotChunk": { + if ( + activeTaskIdRef.current === undefined || + message.taskId !== activeTaskIdRef.current || + message.taskInstanceId !== activeTaskInstanceIdRef.current + ) { + break + } + + const seq = message.clineMessagesSeq as number + const snapshot = activeSnapshotRef.current + if (!Number.isSafeInteger(seq) || seq < 0) { + clearClineMessagesSnapshot() + retryClineMessagesResync(typeof seq === "number" ? seq : undefined) + break + } + if (!snapshot) { + if (seq > clineMessagesSeqRef.current) { + retryClineMessagesResync(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) { + clearClineMessagesSnapshot() + retryClineMessagesResync(seq) + } + break + } + + const chunk = message.clineMessages + const startIndex = message.snapshotStartIndex as number + if ( + !Array.isArray(chunk) || + chunk.length === 0 || + !Number.isSafeInteger(startIndex) || + startIndex !== snapshot.messages.length || + snapshot.messages.length + chunk.length > snapshot.total + ) { + clearClineMessagesSnapshot() + retryClineMessagesResync(seq) + break + } + + snapshot.messages.push(...chunk) + break + } + case "clineMessagesSnapshotEnd": { + if ( + activeTaskIdRef.current === undefined || + message.taskId !== activeTaskIdRef.current || + message.taskInstanceId !== activeTaskInstanceIdRef.current + ) { + break + } + + const seq = message.clineMessagesSeq as number + const snapshot = activeSnapshotRef.current + if (!Number.isSafeInteger(seq) || seq < 0) { + clearClineMessagesSnapshot() + retryClineMessagesResync(typeof seq === "number" ? seq : undefined) + break + } + if (!snapshot) { + if (seq > clineMessagesSeqRef.current) { + retryClineMessagesResync(seq) + } + break + } + 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 + } + + clearClineMessagesSnapshot() + clearClineMessagesResync() + replaceClineMessages(snapshot.messages) + clineMessagesSeqRef.current = snapshot.seq + setState((prevState) => ({ + ...prevState, + clineMessages: snapshot.messages, + clineMessagesSeq: snapshot.seq, + })) + break + } + case "clineMessageAppended": { + applyClineMessagesDelta(message, "append") + 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 + } + case "messageUpdated": { + // An unsequenced legacy update cannot be applied safely. + requestClineMessagesResync(message.clineMessagesSeq) break } case "skills": { @@ -504,15 +845,31 @@ export const ExtensionStateContextProvider: React.FC<{ } } }, - [setListApiConfigMeta], + // Stryker disable next-line ArrayDeclaration: the index and callbacks are stable; removing the list does not change this listener closure. + [ + applyClineMessagesDelta, + clearClineMessagesSnapshot, + clearClineMessagesResync, + clineMessagesIndex, + requestClineMessagesResync, + retryClineMessagesResync, + setListApiConfigMeta, + startClineMessagesSnapshotTimeout, + ], ) - useEffect(() => { - window.addEventListener("message", handleMessage) - return () => { - window.removeEventListener("message", handleMessage) - } - }, [handleMessage]) + useEffect( + () => { + 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, clearClineMessagesSnapshot, 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 4c2e2a092c..0078f5386d 100644 --- a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx +++ b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx @@ -1,11 +1,20 @@ import { providerIdentifiers } from "@roo-code/types" -import { render, screen, act } from "@/utils/test-utils" +import { + render, + renderHook, + screen, + act, + appendClineMessage, + dispatchExtensionMessage, + hydrateExtensionState, +} from "@/utils/test-utils" import React from "react" import { type ProviderSettings, type ExperimentId, type ExtensionState, + type ExtensionMessage, type ClineMessage, type MarketplaceItem, type MarketplaceInstalledMetadata, @@ -15,6 +24,9 @@ import { } from "@roo-code/types" import { ExtensionStateContextProvider, useExtensionState, mergeExtensionState } from "../ExtensionStateContext" +import { vscode } from "@/utils/vscode" + +const makeMessage = (ts: number, text: string): ClineMessage => ({ ts, type: "say", say: "text", text }) const TestComponent = () => { const { allowedCommands, setAllowedCommands, soundEnabled, showRooIgnoredFiles, setShowRooIgnoredFiles } = @@ -105,6 +117,34 @@ const InitialStateTestComponent = () => { ) } +const TranscriptTestComponent = () => { + const { + currentTaskId, + currentTaskInstanceId, + currentTaskItem, + currentTaskTodos, + messageQueue, + currentCheckpoint, + clineMessages, + clineMessagesSeq, + } = useExtensionState() + + return ( +
+ {JSON.stringify({ + currentTaskId: currentTaskId ?? null, + currentTaskInstanceId, + currentTaskItem: currentTaskItem ?? null, + currentTaskTodos: currentTaskTodos ?? [], + messageQueue: messageQueue ?? [], + currentCheckpoint: currentCheckpoint ?? null, + clineMessages, + clineMessagesSeq: clineMessagesSeq ?? 0, + })} +
+ ) +} + describe("ExtensionStateContext", () => { it("initializes with empty allowedCommands array", () => { render( @@ -399,6 +439,2387 @@ 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 readScopedTranscriptFields = () => ({ + ...readTranscriptFields(), + currentTaskInstanceId: readTranscript().currentTaskInstanceId, + }) + const renderTranscript = (initialState: Partial = {}) => + render( + + + , + ) + 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 + } + const updateClineMessage = (clineMessage: ClineMessage, clineMessagesSeq: number, taskId?: string) => + dispatchExtensionMessage({ type: "clineMessageUpdated", taskId, clineMessagesSeq, clineMessage }) + + afterEach(() => { + vi.restoreAllMocks() + 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(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 + 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(initialState.currentTaskId ? 1 : 0) + act(() => endSnapshot(snapshot)) + expect(vi.getTimerCount()).toBe(0) + expect(result.current.clineMessages).toEqual([]) + expect(result.current.clineMessagesSeq).toBe(initialState.currentTaskId ? 0 : undefined) + }) + }) + + 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( + 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(() => { + dispatchExtensionMessage({ type: "state", state: { currentTaskId: activeTaskId } }) + appendClineMessage(makeMessage(30, "reused timestamp"), 1, activeTaskId) + updateClineMessage(updated, 2, activeTaskId) + }) + expect(readTranscriptFields()).toEqual({ + currentTaskId: activeTaskId, + 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 }) + + 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 pendingTimerCount = vi.getTimerCount() + act(() => { + dispatchExtensionMessage({ type: "state", state: { currentTaskId: "task-2" } }) + appendClineMessage(makeMessage(2, "new gap"), 2, "task-2") + }) + + expect(pendingTimerCount).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("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 replacement snapshot without changing the transcript or applied sequence", () => { + vi.useFakeTimers() + 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({ 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(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(postMessage).toHaveBeenCalledTimes(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 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() + startSnapshot(overrides) + }) + + expect(postMessage).toHaveBeenCalledTimes(1) + expect(postMessage).toHaveBeenCalledWith({ + type: "requestClineMessagesResync", + taskId: "task-1", + expectedSeq: 2, + receivedSeq, + }) + expect(vi.getTimerCount()).toBe(1) + }) + + 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 }) => { + vi.useFakeTimers() + const postMessage = renderTranscriptWithPostMessageSpy({ clineMessagesSeq: 1 }) + + 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", () => { + 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 }) => { + vi.useFakeTimers() + const postMessage = renderTranscriptWithPostMessageSpy({ clineMessagesSeq: 1 }) + + act(() => { + startSnapshot() + appendSnapshotChunk(overrides) + }) + + expect(postMessage).toHaveBeenCalledTimes(1) + expect(postMessage).toHaveBeenCalledWith( + expect.objectContaining({ type: "requestClineMessagesResync", receivedSeq: expectedResyncSeq }), + ) + expect(vi.getTimerCount()).toBe(1) + }) + + 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 }) => { + vi.useFakeTimers() + const postMessage = renderTranscriptWithPostMessageSpy({ clineMessagesSeq: 1 }) + + act(() => { + startSnapshot() + appendSnapshotChunk(overrides) + }) + + expect(postMessage).toHaveBeenCalledTimes(1) + expect(postMessage).toHaveBeenCalledWith( + expect.objectContaining({ type: "requestClineMessagesResync", receivedSeq: 2 }), + ) + expect(vi.getTimerCount()).toBe(1) + }) + + 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 }) => { + vi.useFakeTimers() + const postMessage = renderTranscriptWithPostMessageSpy({ clineMessagesSeq: 1 }) + + act(() => { + startSnapshot() + endSnapshot(overrides) + }) + + expect(postMessage).toHaveBeenCalledTimes(1) + expect(postMessage).toHaveBeenCalledWith( + expect.objectContaining({ type: "requestClineMessagesResync", receivedSeq: 3 }), + ) + expect(vi.getTimerCount()).toBe(1) + }) + + 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 }) => { + vi.useFakeTimers() + 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 }), + ) + expect(vi.getTimerCount()).toBe(1) + }) + + 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(readTranscriptFields()).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(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, + currentTaskInstanceId: null, + currentTaskItem: null, + currentTaskTodos: [], + messageQueue: [], + currentCheckpoint: null, + clineMessages: [], + clineMessagesSeq: 0, + }) + }) + + 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, + }) + + act(() => { + startSnapshot({ clineMessagesSeq: 2, snapshotId: "pending" }) + dispatchExtensionMessage({ type: "state", state: { currentTaskId: null } }) + appendSnapshotChunk({ taskId: undefined, clineMessagesSeq: 2, snapshotId: "pending" }) + }) + + expect(vi.getTimerCount()).toBe(0) + act(() => vi.advanceTimersByTime(30_000)) + expect(postMessage).not.toHaveBeenCalled() + expect(readTranscript()).toEqual({ + currentTaskId: null, + currentTaskInstanceId: 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", () => { + 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() + } + }) + + 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(readTranscriptFields()).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(readTranscriptFields()).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.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: [], + clineMessagesSeq: 1, + }) + } finally { + postMessage.mockRestore() + } + }) + + 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 { + 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(readTranscriptFields()).toEqual({ + currentTaskId: "task-1", + clineMessages: [makeMessage(2, "hydrated"), makeMessage(3, "appended")], + clineMessagesSeq: 5, + }) + + act(() => { + hydrateExtensionState({ clineMessages: [] }, { taskId: "task-1", clineMessagesSeq: 6 }) + }) + expect(readTranscriptFields()).toEqual({ currentTaskId: "task-1", clineMessages: [], clineMessagesSeq: 6 }) + }) + }) }) describe("mergeExtensionState", () => { @@ -471,152 +2892,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..617e18a1ea 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 ?? undefined + 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()