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 355b08aa7e..735841380b 100644 --- a/docs/architecture/task-lifecycle-model.md +++ b/docs/architecture/task-lifecycle-model.md @@ -6,15 +6,16 @@ Zoo Code checks task lifecycle protocols through one umbrella command for indepe pnpm lifecycle:model-check ``` -The baseline command runs seven independent bounded checks in sequence: +The baseline command runs eight independent bounded checks in sequence: 1. the persisted task delegation lifecycle; 2. shared-store concurrency across task-history hosts; 3. production-backed handoff reducers with an abstract provider/scheduler protocol; 4. the task cleanup protocol; 5. request-stream parser scoping; -6. completion persistence; and -7. delegated-mode reader refinement. +6. completion persistence; +7. delegated-mode reader refinement; and +8. production-backed transcript transport ownership and snapshot ordering. The planned two-sibling fan-out protocol is intentionally outside the baseline and CI umbrella. Run it explicitly with `pnpm fanout-protocol:model-check`; it describes optional future functionality, not current production coverage. @@ -85,6 +86,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 checks task-local configuration selection within those cases. Stale provider lookup is caught before this pure selector, so focused provider tests check 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..a6ed4cee58 --- /dev/null +++ b/docs/architecture/transcript-transport-model.md @@ -0,0 +1,152 @@ +# Transcript transport: ownership and bounded verification + +Run the focused checker with **pnpm transcript-transport:model-check**. It also runs as the seventh independent submodel in **pnpm lifecycle:model-check**, wired in [package.json](../../package.json). It does not change persisted task lifecycle reducers or workflow files. + +## Production boundary + +[`TranscriptTransport`](../../src/core/webview/transcriptTransport.ts) owns generation, task-scoped sequence allocation, instance-scoped FIFO job descriptors, snapshot progress, and one physical-send barrier **per renderer session**. [ClineProvider.ts](../../src/core/webview/ClineProvider.ts) supplies current task and instance focus and the webview post callback. Resync additionally accepts optional client sequence diagnostics for metadata-only logging; they never select or modify the authoritative snapshot revision. + +[`TranscriptRequest.taskInstanceId`](../../src/core/webview/transcriptTransport.ts:6) is optional for legacy fixtures, while every [`TranscriptJob`](../../src/core/webview/transcriptTransport.ts:11) retains its originating instance, including an absent value. The constructor preserves its first three arguments and adds a fourth focused-instance callback defaulting to an absent value. Identity comparisons are exact: an unscoped request cannot adopt a live instance, and an identified request cannot match absent focus. Production must provide the actual instance callback and the originating instance on requests. Every wire frame, including append/update and snapshot start/chunk/end, copies the descriptor's instance through [`transcriptFrameMessage()`](../../src/core/webview/transcriptTransport.ts:227); it never derives identity from later focus. + +The driver and the explorer both call [reduceTranscriptTransport](../../src/core/webview/transcriptTransport.ts) for admission, allocation, invalidation, shutdown/reopen, task-sequence pruning, send initiation, and frame-scoped settlement. They also share the production frame-to-message conversion. This is not a separate queue specification that only resembles production. + +Payloads and caller resolvers live in driver-owned maps, outside the pure state. Invalidation synchronously removes all waiting jobs and their payload references, releases the active snapshot's unsent suffix, and resolves discarded waiting callers. There is no retained chain of old-generation closures. A physical post already invoked remains the sole in-flight owner until its Promise settles; its caller settles at that boundary. New-generation or new-instance jobs may queue but cannot send until that barrier is released. Every later delta, snapshot start, chunk, or end initiation rechecks generation, task ID, and task instance. Rejection terminates that job, rejects its caller, logs the failure, and permits the next job to run. + +**Renderer shutdown is not task invalidation.** Shutdown closes admission before payload capture, releases queued/active/in-flight descriptors and payloads, and resolves every pending caller as dropped, including an already-invalidated physical caller. It also clears the focused-task, post, and error callbacks and detaches the physical completion slot. Promise observers retain only that empty slot, not a suspended driver method, payload, caller resolver, or provider callback. Late success and rejection are consumed without logging, settling a new caller, or pumping a new renderer. The shared reducer independently requires matching job ID, originating generation, phase, and chunk start for settlement; comparing to the current generation would incorrectly block ordinary invalidation's legitimate old-send settlement. + +Shutdown is idempotent. Reopen only acts on a closed transport and installs fresh callbacks. Closing and reopening each advance generation once; neither resets task sequences, job IDs, or snapshot IDs. A generation captured before or during closure cannot acquire a reopened renderer, even with unchanged task/instance focus. Ordinary invalidation still advances generation and retains the physical barrier. Task removal/history deletion retains its separate sequence-pruning semantics. + +The provider shuts down transport synchronously on sidebar disposal and before the first awaited task cleanup during provider disposal. Sidebar closure does **not** abort, dispose, evict, or otherwise change background tasks. Resolving a new renderer closes prior resources and reopens the same transport with a callback bound to the captured view/session; webview launch performs the normal authoritative focus/state/snapshot sync. Disposed providers cannot reopen. Renderer disposal also cancels pending state debounce work, resets launch visibility, clears view/index references and the matching global panel reference, and disposes the renderer's disposal listener rather than accumulating it in provider-lifetime disposables. Session checks prevent stale disposal/message listeners, async initialization, focus sync, theme lookup, and generic metadata assembly from targeting or tearing down a replacement. Physical post result handlers are module-scoped and retain no provider or payload while the editor Promise is stalled. + +The driver intentionally **deep-clones at enqueue time**. Tasks mutate message objects and nested arrays while a post is waiting; shallow copying or cloning at drain time would pair an earlier sequence with later content. Generation, task, and instance guards run before cloning and before allocating either a sequence or snapshot ID. A second reducer admission check protects the captured payload's ownership, including reentrant focus replacement during cloning. + +**Empty append/update arrays return before capture or reducer admission.** They allocate no captured request, cloned payload, sequence, job/snapshot ID, frame, or payload/caller-map entry and leave protocol state unchanged. This is not a claim that returning an already-resolved Promise entails zero JavaScript runtime allocation. The reducer independently rejects zero-total deltas without changing state or producing effects. Empty snapshots remain valid and send start/end markers without chunks. + +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 ten bounded scenarios; this is not randomized scheduling or a hand-selected trace list. A producer and controller retain their own program order, while admission, send initiation, send success/failure, focus publication, invalidation, renderer disposal/reopen, and detached late completion may interleave at every enabled boundary. + +| Scenario | Producer order | Controller order | Reachable states | Transitions | Maximum shortest depth | +| --------------------------------- | -------------------------------------------------- | ------------------------------------------------- | ---------------: | ----------: | ---------------------: | +| Queued deltas / repeated resync | snapshot, append, update | resync, resync | 13,292 | 19,281 | 33 | +| Task switch / clear | snapshot, append, snapshot | switch to second task, clear | 7,523 | 10,334 | 33 | +| Invalidation / recovery | snapshot, update, snapshot | invalidate, resync | 6,030 | 8,149 | 31 | +| Focus before sync / stale request | snapshot, append, update | focus second task, resync, stale snapshot | 5,746 | 10,330 | 24 | +| Same-task instance / snapshot | snapshot, stale-instance append | replace instance, sync instance, append, update | 2,998 | 5,927 | 24 | +| Same-task instance / deltas | append, update, stale-instance snapshot | replace instance, sync instance | 1,317 | 2,034 | 15 | +| Empty deltas / valid recovery | empty append, empty update, append, empty snapshot | none | 21 | 23 | 10 | +| Renderer disposal / reopen | snapshot, append | shutdown, shutdown, reopen, snapshot | 1,483 | 2,489 | 24 | +| Renderer reopen / no task | snapshot | clear, shutdown, reopen, empty snapshot | 1,044 | 1,592 | 21 | +| Renderer reopen / new instance | snapshot, stale-instance append | shutdown, replace instance, reopen, sync instance | 645 | 1,144 | 20 | + +These totals are diagnostics, not hard-coded ratchets: 40,099 states across independently explored scenarios and 61,303 examined transitions. Bounds are **two task IDs plus no task, at most two instances of the first task (one replacement), up to five admitted jobs, two invalidations, at most two shutdown calls and one reopen, four messages per snapshot, chunk size two, and at most one failed physical send per trace**. The disposal scenarios can retain one retired physical send alongside the reopened renderer's send. Standalone producer snapshots bump the sequence; resync and instance-sync snapshots retain the current sequence. Empty, exact-boundary, and multi-chunk snapshots arise within the bounds. Production uses chunk size 200; the provider regression checks 401 messages at the real chunk size. + +Each scenario has an unchanged **30,000-state budget and depth limit 40**. The checker fails on the first unseen successor beyond either bound, missing required action/landmark coverage, or any invariant violation. There is no truncated success. Every failure reports its scenario, bounds, shortest action trace, intermediate states, and the violating state. Mutants select the shortest witness across all ten scenario graphs with stable tie ordering. + +The model exposes a scheduling point between settlement and the next pump, and between enqueue and pump. The production driver performs these synchronously within its continuation. This is a conservative scheduling over-approximation, not a claim that every model event boundary corresponds to an independently schedulable JavaScript callback. + +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, open-to-closed shutdown, or closed-to-open reopen. Repeated shutdown/reopen calls do not reset IDs or sequences. Stale-generation admission allocates no job or snapshot ID. Closed, stale-instance, and empty-delta admission return identical protocol state without admission or other effects, including when the stale producer supplies the current generation. +2. No physical send overlaps another **in the same renderer**, including an old generation's or instance's held send. A retired renderer's send may remain held while its successor sends. No old-generation, old-task, old-instance, or old-renderer post/commit is **initiated** after ownership changes. Descriptor, frame, and captured wire identity must equal the originating request's instance; a held wire message cannot acquire replacement identity at settlement. +3. Invalidation retains no obsolete queue or payload. Discarded waiting callers settle immediately. Each settlement must consume a registered caller exactly once. Remaining payloads correspond exactly to active/queued jobs; remaining callers correspond exactly to those jobs plus an already-initiated physical send. +4. Allocated sequences follow enqueue/capture order: deltas and bumping snapshots increment; resync retains the current value. Sent sequence is nondecreasing and never exceeds allocation. Failed snapshots never resume their suffix. +5. The independent receiver oracle rejects a wire message unless both task and instance match published focus, before staging or applying any content. This includes a complete old-instance end marker and old append/update deltas. It stages contiguous, exact snapshot payloads and exposes them only at a matching complete end marker. Start/chunks cannot change visible transcript or applied sequence. Applied sequence cannot decrease within one focused-instance scope. +6. Job totals equal captured payload lengths. Only snapshots carry snapshot identities, unique across captures. Non-chunk frame ranges are zero; chunk descriptors have contiguous starts and positive, exact lengths bounded by the captured payload and chunk size. These checks precede wire conversion, whose array slicing can otherwise hide an overlarge final count. +7. Shutdown leaves no queue, active job, in-flight descriptor, payload, or caller; this includes shutdown after invalidation. Late physical success/failure for retired work has no reducer effects and cannot change the live send. Reopen preserves allocation history while resetting the independent renderer receiver's visible/staged state. Retired wire delivery belongs to the dead renderer and is not delivered to the new receiver; concrete view binding and callback detachment are verified by driver/provider tests, not inferred from map sizes or this oracle. + +Sequence monotonicity is **not global across task IDs or removed/recreated task lifetimes**. The production provider prunes a task's sequence on stack removal/history deletion; the model exercises the shared pruning action on switch/clear and tags its allocation/sent oracle with a task-lifetime epoch. Same-task instance replacement itself does not reset the transport's task-keyed sequence; a new-instance snapshot establishes the receiver's baseline. A no-task snapshot has sequence zero. Receiver applied sequence resets on task/instance change or clear, as distinct from resync of the same instance. The checker does not invent a persisted generation token or silently demand globally increasing sequences after clear. + +All 27 action classes are required: snapshot, append, update, resync, invalidate, switch, clear, focus, stale-snapshot, replace-instance, sync-instance, stale-instance-append, stale-instance-snapshot, empty-append, empty-update, empty-snapshot, pump, start, chunk, end, settle, fail, discard, shutdown, reopen, late-resolve, late-reject. All 34 named reachability landmarks are required: + +- held-post-with-queued-delta; +- repeated-invalidation-while-held; +- 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). +- shutdown-releases-held-and-queued-callers (requires both a physical send and queued work at shutdown); +- repeated-shutdown-with-held-send; +- closed-admission-rejected; +- reopened-renderer-sends-before-dead-renderer-settles; +- late-resolve-does-not-settle-new-send; +- late-reject-does-not-settle-new-send; +- reopened-snapshot-committed; +- reopened-no-task-snapshot-committed. + +## Invariant sensitivity + +Twenty-four test-only reducer, wire-conversion, and receiver-policy faults must produce their expected violation class through the same exhaustive explorer. The receiver policies are independent of React; these checks test the oracle's scope contract, not the production UI implementation. No mutation switch exists in production. + +| Mutant | Shortest witness, excluding initial state | Detected violation | +| ----------------------------------- | ---------------------------------------------------------------------------------- | --------------------------------------- | +| shutdown-retains-physical-caller | snapshot, pump, shutdown | shutdown retained transport ownership | +| admit-after-shutdown | shutdown, snapshot | closed admission allocated work | +| reopen-resets-identities | clear, shutdown, reopen | renderer boundary reset identities | +| late-completion-settles-live-send | clear, pump, shutdown, reopen, snapshot, pump, late-resolve | late completion changed live transport | +| stale-completion-starts-end | snapshot, pump, resync, settle | stale commit initiation | +| admit-stale-generation | focus, resync, stale-snapshot | obsolete admission allocates work | +| ignore-focus-at-post | snapshot, focus, pump | stale-focus initiation | +| 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 ten scenario graphs for the shortest witness; separating the test cases avoids accumulating every exhaustive search under a single test timeout without changing that timeout or any exploration bound. The [CLI entry point](../../scripts/check-transcript-transport.ts) runs the same checks together and prints counts, action/landmark names, bounds, and mutant traces. + +Focused reducer tests also check canonical descriptors for empty, exact-boundary, and partial-final chunks independently of wire output. Adversarial queued/active states retain obsolete-generation work with unchanged focus to verify the defense-in-depth pre-send guard discards it and permits current work. Such states are deliberately **not claimed reachable** through normal invalidation, which releases that work; no artificial action is added to the reachable-state explorer. A driver regression retains one held caller through two invalidations and checks both successful and failed settlement followed by recovery. + +Instance regressions cover stale and absent identity before cloning, replacement reentered during cloning, queued and active pre-send rejection without invalidation, and all five held frame phases across same-task replacement with successful/failed physical settlement, with and without repeated invalidation. They check original wire identity, exactly-once caller settlement, and subsequent new-instance snapshot/delta sends. Empty-delta tests assert no cloning, capture/focus reads, state/sequence/ID/frame changes, or payload/caller insertion, then accept a valid delta and an empty snapshot. Legacy fixtures continue using absent identity on both request and focus. + +Disposal driver tests hold all five frame phases through repeated shutdown, settle queued and active callers before physical completion, assert callback/continuation detachment, and reopen with new-instance work while the old physical send remains held. Both late success and rejection must leave a held new send untouched. Additional cases cover invalidation before shutdown, no-task snapshots, pre-clone closed admission, preserved IDs/sequences, stale generations from before/during closure, repeated live reopen, and shutdown/reopen reentered during cloning. Provider integration tests invoke real disposal listeners and the physical webview post boundary, prove background task survival, and cover stale listeners, delayed focus/metadata/initialization, view re-resolution, and irreversible provider shutdown before awaited task cleanup. Tests do not use GC timing as an oracle. + +## Limitations: initiation is not delivery revocation + +An active physical send cannot be unsent. In particular, **an end marker initiated before invalidation may complete afterward and publish its already-complete snapshot on the same focused task instance**. The generation is provider-local, not a wire field. The named stale-end-completion landmark deliberately requires this permitted behavior; the stale-completion-starts-end mutant forbids the materially different bug of initiating a new old-generation end after invalidation. Across same-task instance replacement, the captured wire identity instead lets the receiver reject the old completion. This prevents old content from entering the replacement scope, but does not cancel the physical send or settle its caller early. The single physical barrier ensures a newer transcript's posts cannot overtake the held old one. + +The receiver is an independent protocol oracle, not the React reducer. It assumes ordered, lossless successful physical delivery at settlement and no delivery for a modeled rejection; a real post can deliver before its Promise settles. It deliberately cannot prove browser timer behavior, dropped/delayed messages, resync retry diagnostics, rendering, or restart behavior. Existing UI tests own those concerns. The provider's post wrapper swallows disposed-view failures and ignores the editor's boolean delivery result; model rejection covers errors reaching the transport callback, **not delivery acknowledgement**. + +Metadata state posts are outside this transcript FIFO. The integration contract requires the provider to invalidate replacement ownership and publish task/instance focus synchronously before asynchronous preparation, and to guard stale generic metadata. The model assumes receiver focus publication has happened; it does not import or prove that provider/metadata ordering. Its separate replacement-before-sync/invalidation boundary is a conservative over-approximation testing identity protection even before cleanup, not permission for production to delay publication or invalidation. A legacy request with absent identity has no same-task replacement protection unless both endpoints use explicit instances. There is no fairness/liveness claim: a permanently held physical post blocks later physical transcript posts **until that renderer is shut down**, although obsolete waiting jobs are still released on invalidation. Reopen can send without waiting for the dead renderer, but does not cancel the editor-owned physical operation. Memory claims concern removal of owned references, not immediate garbage collection or memory retained by the editor's already-initiated post. Arbitrary unrelated provider operations already awaiting external services are not cancelled by renderer shutdown. + +This bounded check does not prove arbitrary queue lengths, sequence overflow, repeated instance replacement or arbitrary task-ID reuse, arbitrary renderer reopen cycles, instance-ID uniqueness/collision resistance, message validation, or all payload values. It assumes opaque distinct instance identities and models one instance replacement and one renderer reopen only. Driver/provider regressions cover concrete deep-clone behavior and runtime correspondence; UI tests own the real consumer. No persisted lifecycle reducer, status, persistence owner, or scheduler transition is changed or imported by this extension of the transport model, so composition remains at the aggregate command boundary. diff --git a/package.json b/package.json index e4d3867e0b..c75151114c 100644 --- a/package.json +++ b/package.json @@ -13,8 +13,9 @@ "check-types": "turbo check-types --log-order grouped --output-logs new-only", "test": "turbo test --log-order grouped --output-logs new-only", "test:mutation-ci": "node --test scripts/stryker-diff.test.mjs", - "lifecycle:model-check": "tsx scripts/check-task-lifecycle.ts && tsx scripts/check-task-store-concurrency.ts && tsx scripts/check-provider-handoff-scheduler.ts && pnpm cleanup-protocol:model-check && pnpm parser-scope:model-check && tsx scripts/check-completion-persistence.ts && tsx scripts/check-delegated-mode-readers.ts", + "lifecycle:model-check": "tsx scripts/check-task-lifecycle.ts && tsx scripts/check-task-store-concurrency.ts && tsx scripts/check-provider-handoff-scheduler.ts && pnpm cleanup-protocol:model-check && pnpm parser-scope:model-check && tsx scripts/check-completion-persistence.ts && tsx scripts/check-delegated-mode-readers.ts && pnpm transcript-transport:model-check", "fanout-protocol:model-check": "tsx scripts/check-task-fanout-protocol.ts", + "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 56ccd52588..9aceec70c1 100644 --- a/src/__tests__/extension.spec.ts +++ b/src/__tests__/extension.spec.ts @@ -2,11 +2,13 @@ import type * as vscode from "vscode" +const mockOutputChannel = vi.hoisted(() => ({ + appendLine: vi.fn(), +})) + vi.mock("vscode", () => ({ window: { - createOutputChannel: vi.fn().mockReturnValue({ - appendLine: vi.fn(), - }), + createOutputChannel: vi.fn().mockReturnValue(mockOutputChannel), registerWebviewViewProvider: vi.fn(), registerUriHandler: vi.fn(), tabGroups: { @@ -189,7 +191,7 @@ vi.mock("../core/webview/ClineProvider", async () => { resolveWebviewView: vi.fn(), postMessageToWebview: vi.fn(), postStateToWebview: vi.fn(), - postStateToWebviewWithoutClineMessages: vi.fn().mockResolvedValue(undefined), + postStateToWebviewWithoutTaskHistory: vi.fn().mockResolvedValue(undefined), getState: vi.fn().mockResolvedValue({}), initializeCloudProfileSyncWhenReady: vi.fn().mockResolvedValue(undefined), providerSettingsManager: {}, @@ -296,17 +298,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( @@ -434,6 +436,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") @@ -441,17 +466,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 55798437c3..51fd317084 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -4,6 +4,7 @@ import os from "os" import crypto from "crypto" import { v7 as uuidv7 } from "uuid" import EventEmitter from "events" +import { isDeepStrictEqual } from "util" import { AskIgnoredError } from "./AskIgnoredError" import { RateLimitClock, createRateLimitClock } from "./RateLimitClock" @@ -183,6 +184,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 @@ -216,6 +218,17 @@ type AssistantMessagePersistenceCancellation = { resolve: () => void } +/** A transcript and its derived task history are separate, non-transactional writes. */ +export class ClineMessagesPersistenceError extends Error { + constructor( + cause: unknown, + public readonly transcriptPersisted: boolean, + ) { + super(cause instanceof Error ? cause.message : String(cause), { cause }) + this.name = "ClineMessagesPersistenceError" + } +} + export class Task extends EventEmitter implements TaskLike { readonly taskId: string readonly rootTaskId?: string @@ -364,6 +377,8 @@ export class Task extends EventEmitter implements TaskLike { // LLM Messages & Chat Messages apiConversationHistory: ApiMessage[] = [] clineMessages: ClineMessage[] = [] + private clineMessagesSaveVersion = 0 + private pendingClineMessageReplacements = 0 // Ask private askResponse?: ClineAskResponse @@ -513,6 +528,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() @@ -684,6 +700,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) @@ -1290,20 +1321,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() @@ -1323,12 +1344,60 @@ export class Task extends EventEmitter implements TaskLike { /** * Replaces the entire Cline message history, restores todo state, and persists. * Also resets cloud sync tracking to avoid re-syncing previously synced messages. + * Rejects without publishing a snapshot on persistence failure. Only a failed + * transcript write can restore the previous in-memory state, and only while no + * newer save or mutation has superseded it. Metadata/history failures retain the + * already-persisted replacement; these writes cannot be rolled back atomically. */ public async overwriteClineMessages(newMessages: ClineMessage[], persist = true) { - this.hydrateClineMessages(newMessages) + this.debouncedPostPartialMessageUpdate.cancel() + const previousMessages = this.clineMessages + const previousTodos = this.todoList + const previousCloudSyncedTimestamps = new Set(this.cloudSyncedMessageTimestamps) + // Give every replacement its own identity, even when a caller passes the live array. + this.hydrateClineMessages([...newMessages]) + const replacement = this.clineMessages if (persist) { - await this.saveClineMessages(false) + const replacementTodos = this.todoList + const snapshot = structuredClone({ + messages: replacement, + todos: replacementTodos, + cloudSyncedTimestamps: this.cloudSyncedMessageTimestamps, + }) + const saveVersion = this.clineMessagesSaveVersion + 1 + // A previous pending replacement is not a safe rollback target: its own + // write may fail while this write is in flight. Keep live state in that case. + const canRestorePrevious = this.pendingClineMessageReplacements++ === 0 + try { + await this.persistClineMessages(false) + } catch (error) { + if ( + canRestorePrevious && + error instanceof ClineMessagesPersistenceError && + !error.transcriptPersisted && + this.clineMessagesSaveVersion === saveVersion && + this.clineMessages === replacement && + this.todoList === replacementTodos && + isDeepStrictEqual(this.clineMessages, snapshot.messages) && + isDeepStrictEqual(this.todoList, snapshot.todos) && + isDeepStrictEqual(this.cloudSyncedMessageTimestamps, snapshot.cloudSyncedTimestamps) + ) { + this.clineMessages = previousMessages + this.todoList = previousTodos + this.cloudSyncedMessageTimestamps = previousCloudSyncedTimestamps + } + throw error + } finally { + this.pendingClineMessageReplacements-- + } } + // The provider snapshots live state. An older save must not publish a newer, + // still-pending replacement on its behalf (that replacement may fail). + if (this.clineMessages !== replacement) return + await this.providerRef.deref()?.postClineMessagesSnapshot(this.taskId, { + bumpSeq: true, + taskInstanceId: this.instanceId, + }) } private hydrateClineMessages(messages: ClineMessage[]) { @@ -1354,8 +1423,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 @@ -1375,12 +1448,27 @@ export class Task extends EventEmitter implements TaskLike { /** Persists Cline messages and updates task metadata in the history store. Returns false on failure. */ private async saveClineMessages(merge = true): Promise { try { + await this.persistClineMessages(merge) + return true + } catch (error) { + console.error("Failed to save Roo messages:", error) + return false + } + } + + /** Strict persistence boundary for replacements; streaming saves keep their boolean contract. */ + private async persistClineMessages(merge = true): Promise { + this.clineMessagesSaveVersion++ + let transcriptPersisted = false + try { + const messages = structuredClone(this.clineMessages) await saveTaskMessages({ - messages: structuredClone(this.clineMessages), + messages, taskId: this.taskId, globalStoragePath: this.globalStoragePath, merge, }) + transcriptPersisted = true if (this._taskApiConfigName === undefined) { await this.taskApiConfigReady @@ -1391,7 +1479,7 @@ export class Task extends EventEmitter implements TaskLike { rootTaskId: this.rootTaskId, parentTaskId: this.parentTaskId, taskNumber: this.taskNumber, - messages: this.clineMessages, + messages, globalStoragePath: this.globalStoragePath, workspace: this.cwd, mode: this._taskMode || defaultModeSlug, // Use the task's own mode, not the current provider mode. @@ -1409,10 +1497,8 @@ export class Task extends EventEmitter implements TaskLike { const provider = this.providerRef.deref() const existingStatus = provider?.taskHistoryStore.get(this.taskId)?.status await provider?.updateTaskHistory(existingStatus ? { ...historyItem, status: existingStatus } : historyItem) - return true } catch (error) { - console.error("Failed to save Roo messages:", error) - return false + throw new ClineMessagesPersistenceError(error, transcriptPersisted) } } @@ -1450,7 +1536,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 @@ -1484,10 +1570,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 @@ -1740,6 +1824,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) @@ -2242,7 +2329,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) @@ -2387,16 +2477,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() @@ -2411,7 +2511,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 @@ -2748,6 +2848,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 @@ -3141,7 +3242,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 @@ -3212,12 +3316,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 @@ -3862,7 +3970,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..b094e89ada 100644 --- a/src/core/task/__tests__/Task.persistence.spec.ts +++ b/src/core/task/__tests__/Task.persistence.spec.ts @@ -2,6 +2,7 @@ import * as os from "os" import * as path from "path" +import * as fs from "fs/promises" import * as vscode from "vscode" import { @@ -10,12 +11,14 @@ import { type GlobalState, type PendingTaskAction, type ProviderSettings, + type TodoItem, } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" import type { Anthropic } from "@anthropic-ai/sdk" -import { Task } from "../Task" +import { ClineMessagesPersistenceError, Task } from "../Task" import { ClineProvider } from "../../webview/ClineProvider" +import { webviewMessageHandler } from "../../webview/webviewMessageHandler" import { ContextProxy } from "../../config/ContextProxy" import { providerIdentifiers } from "@roo-code/types/provider-identifiers" import { attemptCompletionTool, type AttemptCompletionCallbacks } from "../../tools/AttemptCompletionTool" @@ -296,10 +299,240 @@ 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) + mockSaveTaskMessages.mockReturnValueOnce(saved.promise) + oldTask["saveClineMessages"] = vi.fn().mockResolvedValue(true) + replacement["saveClineMessages"] = vi.fn().mockResolvedValue(true) + await oldTask["updateClineMessage"]({ ...message("leading"), partial: true }) + await oldTask["updateClineMessage"]({ ...message("delayed trailing"), partial: true }) + 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() + 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() + oldTask["debouncedPostPartialMessageUpdate"].cancel() + replacement["debouncedPostPartialMessageUpdate"].cancel() + vi.useRealTimers() + } + }) + }) + // ── saveApiConversationHistory (via retrySaveApiConversationHistory) ── describe("saveApiConversationHistory", () => { @@ -969,6 +1202,314 @@ describe("Task persistence", () => { // ── saveClineMessages ──────────────────────────────────────────────── + describe("replacement persistence integrity", () => { + const message = (ts: number, text: string): ClineMessage => ({ ts, type: "say", say: "text", text }) + const todos: TodoItem[] = [{ id: "todo", content: "Replacement todo", status: "in_progress" }] + const replacement = (): ClineMessage[] => [ + { ts: 2, type: "ask", ask: "tool", text: JSON.stringify({ tool: "updateTodoList", todos }) }, + { ...message(3, "partial"), partial: true }, + ] + const createTask = () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + task["hydrateClineMessages"]([message(1, "original")]) + return task + } + + it("rejects a transcript write error without a snapshot and restores the untouched prior state", async () => { + const task = createTask() + const original = task.clineMessages + const originalTodos = task.todoList + // Cloud tracking need not be identical to all non-partial transcript timestamps. + task["cloudSyncedMessageTimestamps"].add(99) + const onMessage = vi.fn() + task.on(RooCodeEventName.Message, onMessage) + const cause = new Error("disk full") + mockSaveTaskMessages.mockRejectedValueOnce(cause) + + await expect(task.overwriteClineMessages(replacement())).rejects.toMatchObject({ + name: "ClineMessagesPersistenceError", + cause, + transcriptPersisted: false, + }) + + expect(task.clineMessages).toBe(original) + expect(task.todoList).toBe(originalTodos) + expect(task["cloudSyncedMessageTimestamps"]).toEqual(new Set([1, 99])) + expect(mockTaskMetadata).not.toHaveBeenCalled() + expect(mockProvider.updateTaskHistory).not.toHaveBeenCalled() + expect(mockProvider.postClineMessagesSnapshot).not.toHaveBeenCalled() + expect(onMessage).not.toHaveBeenCalled() + }) + + it.each(["metadata", "history"] as const)( + "retains the persisted replacement but rejects without a snapshot on %s failure", + async (phase) => { + const task = createTask() + const messages = replacement() + const cause = new Error(`${phase} failed`) + if (phase === "metadata") mockTaskMetadata.mockRejectedValueOnce(cause) + else vi.mocked(mockProvider.updateTaskHistory).mockRejectedValueOnce(cause) + + await expect(task.overwriteClineMessages(messages)).rejects.toMatchObject({ + name: "ClineMessagesPersistenceError", + cause, + transcriptPersisted: true, + }) + + expect(mockSaveTaskMessages).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ messages, merge: false }), + ) + expect(task.clineMessages).toEqual(messages) + expect(task.todoList).toEqual(todos) + expect(task["cloudSyncedMessageTimestamps"]).toEqual(new Set([2])) + expect(mockProvider.postClineMessagesSnapshot).not.toHaveBeenCalled() + expect(mockProvider.updateTaskHistory).toHaveBeenCalledTimes(phase === "history" ? 1 : 0) + }, + ) + + it.each(["transcript", "metadata", "history"] as const)( + "keeps the correct on-disk transcript after a real-file %s failure", + async (phase) => { + const actualFs = await vi.importActual("fs/promises") + const { saveTaskMessages } = await vi.importActual< + typeof import("../../task-persistence/taskMessages") + >("../../task-persistence/taskMessages") + const { GlobalFileNames } = await import("../../../shared/globalFileNames") + const storage = await actualFs.mkdtemp(path.join(os.tmpdir(), "replacement-integrity-")) + const task = createTask() + const original = task.clineMessages + const messages = replacement() + const transcriptPath = path.join(storage, "tasks", task.taskId, GlobalFileNames.uiMessages) + try { + await vi.mocked(fs.mkdir).withImplementation(actualFs.mkdir, async () => { + await saveTaskMessages({ messages: original, taskId: task.taskId, globalStoragePath: storage }) + let failedStorage = storage + if (phase === "transcript") { + // A regular file cannot be a storage directory: exercise a real filesystem rejection. + failedStorage = path.join(storage, "not-a-directory") + await actualFs.writeFile(failedStorage, "blocked") + } else if (phase === "metadata") { + mockTaskMetadata.mockRejectedValueOnce(new Error("metadata failed")) + } else { + vi.mocked(mockProvider.updateTaskHistory).mockRejectedValueOnce(new Error("history failed")) + } + mockSaveTaskMessages.mockImplementationOnce((options) => + saveTaskMessages({ ...options, globalStoragePath: failedStorage }), + ) + await expect(task.overwriteClineMessages(messages)).rejects.toMatchObject({ + name: "ClineMessagesPersistenceError", + transcriptPersisted: phase !== "transcript", + }) + }) + + const diskMessages: unknown = JSON.parse(await actualFs.readFile(transcriptPath, "utf8")) + expect(diskMessages).toEqual(phase === "transcript" ? original : messages) + expect(task.clineMessages).toEqual(diskMessages) + expect(mockProvider.postClineMessagesSnapshot).not.toHaveBeenCalled() + } finally { + await actualFs.rm(storage, { recursive: true, force: true }) + } + }, + ) + + it.each([true, false])("preserves hydration and publication semantics with persist=%s", async (persist) => { + const task = createTask() + const messages = replacement() + const onMessage = vi.fn() + task.on(RooCodeEventName.Message, onMessage) + + await expect(task.overwriteClineMessages(messages, persist)).resolves.toBeUndefined() + + expect(task.clineMessages).toEqual(messages) + expect(task.todoList).toEqual(todos) + expect(task["cloudSyncedMessageTimestamps"]).toEqual(new Set([2])) + expect(mockSaveTaskMessages).toHaveBeenCalledTimes(persist ? 1 : 0) + expect(mockTaskMetadata).toHaveBeenCalledTimes(persist ? 1 : 0) + expect(mockProvider.updateTaskHistory).toHaveBeenCalledTimes(persist ? 1 : 0) + expect(mockProvider.postClineMessagesSnapshot).toHaveBeenCalledExactlyOnceWith(task.taskId, { + bumpSeq: true, + taskInstanceId: task.instanceId, + }) + expect(onMessage).not.toHaveBeenCalled() + }) + + it.each(["append", "update", "save", "overwrite", "hydrate", "todos", "cloud"] as const)( + "does not roll back a concurrent %s when an older transcript write fails", + async (operation) => { + const task = createTask() + const held = createDeferred() + const cause = new Error("delayed write failure") + mockSaveTaskMessages.mockImplementationOnce(async () => { + await held.promise + throw cause + }) + const failed = expect(task.overwriteClineMessages(replacement())).rejects.toMatchObject({ + cause, + transcriptPersisted: false, + }) + try { + if (operation === "append") { + await task["addToClineMessages"](message(4, "concurrent append")) + } else if (operation === "update") { + task.clineMessages[1].text = "concurrent update" + await task["updateClineMessage"](task.clineMessages[1]) + } else if (operation === "save") { + // Even an identical successful save prevents rollback to pre-replacement memory. + await expect(task["saveClineMessages"]()).resolves.toBe(true) + } else if (operation === "todos") { + task.todoList![0].status = "completed" + } else if (operation === "cloud") { + task["cloudSyncedMessageTimestamps"].add(3) + } else { + // Passing the same array must still establish a new replacement owner. + await task.overwriteClineMessages(task.clineMessages, operation === "overwrite") + } + const current = task.clineMessages + const currentTodos = task.todoList + const currentSynced = new Set(task["cloudSyncedMessageTimestamps"]) + const snapshots = vi.mocked(mockProvider.postClineMessagesSnapshot).mock.calls.length + held.resolve() + await failed + + expect(task.clineMessages).toBe(current) + expect(task.todoList).toBe(currentTodos) + expect(task["cloudSyncedMessageTimestamps"]).toEqual(currentSynced) + expect(mockProvider.postClineMessagesSnapshot).toHaveBeenCalledTimes(snapshots) + } finally { + held.resolve() + await failed + task["debouncedPostPartialMessageUpdate"].cancel() + } + }, + ) + + it("does not publish a newer pending replacement when an older save succeeds", async () => { + const task = createTask() + const firstWrite = createDeferred() + const secondWrite = createDeferred() + const cause = new Error("newer write failed") + mockSaveTaskMessages.mockReturnValueOnce(firstWrite.promise).mockImplementationOnce(async () => { + await secondWrite.promise + throw cause + }) + const first = task.overwriteClineMessages([message(2, "persisted")]) + const second = expect(task.overwriteClineMessages([message(3, "not persisted")])).rejects.toMatchObject({ + cause, + transcriptPersisted: false, + }) + try { + firstWrite.resolve() + await first + expect(mockTaskMetadata).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ messages: [expect.objectContaining({ text: "persisted" })] }), + ) + expect(mockProvider.postClineMessagesSnapshot).not.toHaveBeenCalled() + secondWrite.resolve() + await second + // The prior replacement was pending at admission, not a safe rollback target. + expect(task.clineMessages).toEqual([expect.objectContaining({ text: "not persisted" })]) + expect(mockProvider.postClineMessagesSnapshot).not.toHaveBeenCalled() + } finally { + firstWrite.resolve() + secondWrite.resolve() + await Promise.all([first, second]) + } + }) + + it("does not resurrect an earlier failed replacement when overlapping writes both fail", async () => { + const task = createTask() + const held = createDeferred() + mockSaveTaskMessages.mockImplementationOnce(async () => { + await held.promise + throw new Error("first write failed") + }) + const first = expect(task.overwriteClineMessages([message(2, "first")])).rejects.toThrow( + "first write failed", + ) + mockSaveTaskMessages.mockImplementationOnce(async () => { + await first + throw new Error("second write failed") + }) + const second = expect(task.overwriteClineMessages([message(3, "second")])).rejects.toThrow( + "second write failed", + ) + held.resolve() + await Promise.all([first, second]) + + expect(task.clineMessages).toEqual([expect.objectContaining({ text: "second" })]) + expect(mockProvider.postClineMessagesSnapshot).not.toHaveBeenCalled() + }) + + it("propagates the strict failure while ordinary saves keep returning false", async () => { + const task = createTask() + mockTaskMetadata.mockRejectedValueOnce(new Error("metadata failed")) + await expect(task["saveClineMessages"]()).resolves.toBe(false) + mockSaveTaskMessages.mockRejectedValueOnce(new Error("write failed")) + await expect(task.overwriteClineMessages(replacement())).rejects.toBeInstanceOf( + ClineMessagesPersistenceError, + ) + }) + + describe.each(["deleteMessageConfirm", "editMessageConfirm"] as const)("%s", (type) => { + const setup = () => { + const task = createTask() + const kept: ClineMessage = { + ...message(1, "kept"), + checkpoint: { hash: "checkpoint-hash" }, + } + task["hydrateClineMessages"]([kept, { ...message(2, "remove"), say: "user_feedback" }]) + task.apiConversationHistory = [ + { ts: 1, role: "user", content: "kept" }, + { ts: 2, role: "user", content: "remove" }, + ] + vi.spyOn(mockProvider, "getCurrentTask").mockReturnValue(task) + const submit = vi.spyOn(task, "submitUserMessage").mockResolvedValue(undefined) + return { task, kept, submit } + } + + it.each(["transcript", "metadata", "history"] as const)( + "reports a %s failure and stops before API rewind or edited-message submission", + async (phase) => { + const { task, kept, submit } = setup() + const original = task.clineMessages + const cause = new Error(`${phase} failed`) + if (phase === "transcript") mockSaveTaskMessages.mockRejectedValueOnce(cause) + else if (phase === "metadata") mockTaskMetadata.mockRejectedValueOnce(cause) + else vi.mocked(mockProvider.updateTaskHistory).mockRejectedValueOnce(cause) + + await webviewMessageHandler(mockProvider, { type, messageTs: 2, text: "edited" }) + + expect(vscode.window.showErrorMessage).toHaveBeenCalledOnce() + expect(mockSaveTaskMessages).toHaveBeenCalledTimes(1) + expect(mockSaveApiMessages).not.toHaveBeenCalled() + expect(mockProvider.postClineMessagesSnapshot).not.toHaveBeenCalled() + expect(submit).not.toHaveBeenCalled() + expect(task.clineMessages).toEqual(phase === "transcript" ? original : [kept]) + }, + ) + + it("persists and snapshots once, retaining checkpoint metadata", async () => { + const { task, kept, submit } = setup() + + await webviewMessageHandler(mockProvider, { type, messageTs: 2, text: "edited" }) + + expect(vscode.window.showErrorMessage).not.toHaveBeenCalled() + expect(task.clineMessages).toEqual([kept]) + expect(mockSaveTaskMessages).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ messages: [kept], merge: false }), + ) + expect(mockProvider.postClineMessagesSnapshot).toHaveBeenCalledOnce() + expect(mockSaveApiMessages).toHaveBeenCalledOnce() + expect(submit).toHaveBeenCalledTimes(type === "editMessageConfirm" ? 1 : 0) + }) + }) + }) + describe("saveClineMessages", () => { it("returns true on success", async () => { mockSaveTaskMessages.mockResolvedValueOnce([]) @@ -1341,7 +1882,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 +1909,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 +1996,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 +2300,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 +2484,7 @@ describe("Task persistence", () => { expect(askSpy).not.toHaveBeenCalled() expect(mockSaveTaskMessages).not.toHaveBeenCalled() expect(mockProvider.updateTaskHistory).not.toHaveBeenCalled() + expect(mockProvider.postClineMessagesSnapshot).not.toHaveBeenCalled() }, ) @@ -1822,6 +2602,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 +2640,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 c35acf864d..b639dcf39d 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -205,6 +205,9 @@ vi.mock("vscode", () => { Disposable: { from: vi.fn(), }, + RelativePattern: vi.fn().mockImplementation(function (base: string, pattern: string) { + return { base, pattern } + }), TabInputText: vi.fn(), } }) @@ -409,6 +412,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, @@ -1943,6 +1949,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 @@ -2630,8 +2639,190 @@ 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!: () => void + const pendingSave = new Promise((resolve) => { + releaseSave = resolve + }) + const saveSpy = vi.fn().mockReturnValueOnce(pendingSave) + task["persistClineMessages"] = saveSpy + 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() + 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.fn().mockResolvedValue(undefined) + task["persistClineMessages"] = saveSpy + 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!: () => void + const pendingSave = new Promise((resolve) => { + releaseSave = resolve + }) + task["persistClineMessages"] = vi.fn().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() + 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.fn().mockResolvedValue(undefined) + task["persistClineMessages"] = saveSpy + const snapshotError = new Error("snapshot failed") + vi.mocked(mockProvider.postClineMessagesSnapshot).mockRejectedValueOnce(snapshotError) + const messages = [{ ts: 1, type: "say" as const, say: "text" as const, text: "replacement" }] + + 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.fn().mockResolvedValue(undefined) + task["persistClineMessages"] = saveSpy + 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, @@ -2648,13 +2839,46 @@ 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, + }) + // Settle constructor state reads before observing transport errors or losing the provider. + await Promise.all([task.waitForModeInitialization(), task.waitForApiConfigInitialization()]) + 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" } + + try { + 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() + expect(mockProvider.postClineMessageAppended).not.toHaveBeenCalled() + } finally { + consoleErrorSpy.mockRestore() + } + }) + + it("waits for an incremental append before emitting the message", async () => { const task = new Task({ provider: mockProvider, apiConfiguration: mockApiConfig, @@ -2663,11 +2887,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 = { @@ -2679,20 +2903,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, @@ -2700,10 +2921,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(() => {}) @@ -2717,25 +2936,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, @@ -2744,19 +2957,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, @@ -2765,12 +2978,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, @@ -2785,22 +2998,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() + } }) }) @@ -3154,6 +3633,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(stubModelInfo) + 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(stubModelInfo) + 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, @@ -3990,6 +4577,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(stubModelInfo) + 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", () => { afterEach(() => { vi.restoreAllMocks() @@ -5248,7 +5901,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, @@ -5259,16 +5912,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, @@ -5278,17 +5929,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()", () => { @@ -5714,6 +6390,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 86ce5d8e67..192f68c6f8 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -102,7 +102,7 @@ import { getWorkspaceGitInfo } from "../../utils/git" import { getWorkspacePath } from "../../utils/path" import { OrganizationAllowListViolationError } from "../../utils/errors" -import { setPanel } from "../../activate/registerCommands" +import { getPanel, setPanel } from "../../activate/registerCommands" import { t } from "../../i18n" @@ -133,6 +133,7 @@ import { getUri } from "./getUri" import { REQUESTY_BASE_URL } from "../../shared/utils/requesty" import { validateAndFixToolResultIds } from "../task/validateToolResultIds" import { PendingEditOperationStore, type PendingEditOperationInput } from "./PendingEditOperationStore" +import { TranscriptTransport, type TranscriptRequest } from "./transcriptTransport" /** * https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts @@ -186,6 +187,18 @@ type GetStateOptions = { includeTaskHistory?: boolean } +// Do not suspend a provider method on an editor-owned Promise: a dead renderer +// can hold it forever. These result handlers retain neither provider nor payload. +function ignorePostResult(): void {} + +function postToWebview(webview: vscode.Webview | undefined, message: ExtensionMessage): Promise { + try { + return Promise.resolve(webview?.postMessage(message)).then(ignorePostResult, ignorePostResult) + } catch { + return Promise.resolve() + } +} + export class ClineProvider extends EventEmitter implements vscode.WebviewViewProvider, TelemetryPropertiesProvider, TaskProviderLike @@ -208,6 +221,7 @@ export class ClineProvider >() private nextThemeFixtureProbeId = 0 private view?: vscode.WebviewView | vscode.WebviewPanel + private webviewSession = 0 private taskRegistry = new TaskRegistry() private taskScheduler = new TaskScheduler() private static readonly delegationTransitionLocks = new Map>() @@ -223,6 +237,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 { @@ -313,12 +334,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 @@ -368,7 +383,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 @@ -578,6 +593,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. @@ -589,6 +605,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) { @@ -619,8 +637,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) @@ -647,6 +667,8 @@ export class ClineProvider // garbage collected. task = undefined } + + await this.syncFocusedTaskToWebview() } /** @@ -815,6 +837,14 @@ export class ClineProvider - https://github.com/microsoft/vscode-extension-samples/blob/main/webview-sample/src/extension.ts */ private clearWebviewResources() { + this.clineMessagesTransport.shutdown() + this._postStateToWebviewThrottled.cancel() + this.webviewSession++ + if (this.view && getPanel() === this.view) { + setPanel(undefined, this.renderContext === "editor" ? "tab" : "sidebar") + } + this.view = undefined + this.isViewLaunched = false this.rejectPendingThemeFixtureProbes(new Error("Webview was disposed before the theme fixture probe completed")) while (this.webviewDisposables.length) { const x = this.webviewDisposables.pop() @@ -822,6 +852,8 @@ export class ClineProvider x.dispose() } } + this.codeIndexStatusSubscription = undefined + this.codeIndexManager = undefined } /** Drain one task's memoized cleanup without preventing the remaining provider shutdown work. */ @@ -841,7 +873,11 @@ export class ClineProvider } this._disposed = true - this._postStateToWebviewThrottled.cancel() + let view = this.view + // Close renderer ownership before any task cleanup can await a held post. + this.clearWebviewResources() + if (view && "dispose" in view) view.dispose() + view = undefined this.log("Disposing ClineProvider...") // Reject any tasks still waiting for a scheduler permit so they don't @@ -868,13 +904,6 @@ export class ClineProvider this.clearAllPendingEditOperations() this.log("Cleared pending operations") - if (this.view && "dispose" in this.view) { - this.view.dispose() - this.log("Disposed webview") - } - - this.clearWebviewResources() - // Clean up cloud service event listener if (CloudService.hasInstance()) { CloudService.instance.off("settings-updated", this.handleCloudSettingsUpdate) @@ -1020,8 +1049,35 @@ export class ClineProvider } async resolveWebviewView(webviewView: vscode.WebviewView | vscode.WebviewPanel) { + if (this._disposed) return + this.clearWebviewResources() this.view = webviewView + const session = this.webviewSession + const isCurrentView = () => !this._disposed && this.webviewSession === session && this.view === webviewView + this.clineMessagesTransport.reopen( + () => this.getCurrentTask()?.taskId, + (message) => (isCurrentView() ? this.postMessageToWebview(message, webviewView) : Promise.resolve()), + (error) => + this.log( + `[clineMessages] transport failure: ${error instanceof Error ? error.message : String(error)}`, + ), + () => this.getCurrentTask()?.instanceId, + ) const inTabMode = "onDidChangeViewState" in webviewView + // Register before initialization yields. The listener belongs to this renderer, + // not the provider's lifetime, and a late old listener cannot close its successor. + this.webviewDisposables.push( + webviewView.onDidDispose(async () => { + if (!isCurrentView()) return + if (inTabMode) { + this.log("Disposing ClineProvider instance for tab view") + await this.dispose() + } else { + this.log("Clearing webview resources for sidebar view") + this.clearWebviewResources() + } + }), + ) if (inTabMode) { setPanel(webviewView, "tab") @@ -1042,11 +1098,13 @@ export class ClineProvider localResourceRoots: resourceRoots, } - webviewView.webview.html = + const html = this.contextProxy.extensionMode === vscode.ExtensionMode.Development && process.env.ROO_CODE_THEME_FIXTURE_PROBE !== "1" ? await this.getHMRHtmlContent(webviewView.webview) : await this.getHtmlContent(webviewView.webview) + if (!isCurrentView()) return + webviewView.webview.html = html // Initialize out-of-scope variables that need to receive persistent // global state values. @@ -1064,6 +1122,7 @@ export class ClineProvider ttsEnabled, ttsSpeed, }) => { + if (!isCurrentView()) return Terminal.setShellIntegrationTimeout(terminalShellIntegrationTimeout) Terminal.setShellIntegrationDisabled(terminalShellIntegrationDisabled) Terminal.setCommandDelay(terminalCommandDelay) @@ -1077,6 +1136,7 @@ export class ClineProvider setTtsSpeed(ttsSpeed ?? 1) }, ) + if (!isCurrentView()) return // Sets up an event listener to listen for messages passed from the webview view context // and executes code based on the message that is received. @@ -1089,7 +1149,7 @@ export class ClineProvider // current workspace. const activeEditorSubscription = vscode.window.onDidChangeActiveTextEditor(() => { // Update subscription when workspace might have changed. - this.updateCodeIndexStatusSubscription() + if (isCurrentView()) this.updateCodeIndexStatusSubscription() }) this.webviewDisposables.push(activeEditorSubscription) @@ -1099,6 +1159,7 @@ export class ClineProvider // WebviewView and WebviewPanel have all the same properties except // for this visibility listener panel. const viewStateDisposable = webviewView.onDidChangeViewState(() => { + if (!isCurrentView()) return if (this.view?.visible) { void this.postMessageToWebview({ type: "action", action: "didBecomeVisible" }) } else { @@ -1110,6 +1171,7 @@ export class ClineProvider } else if ("onDidChangeVisibility" in webviewView) { // sidebar const visibilityDisposable = webviewView.onDidChangeVisibility(() => { + if (!isCurrentView()) return if (this.view?.visible) { void this.postMessageToWebview({ type: "action", action: "didBecomeVisible" }) } else { @@ -1120,29 +1182,12 @@ export class ClineProvider this.webviewDisposables.push(visibilityDisposable) } - // Listen for when the view is disposed - // This happens when the user closes the view or when the view is closed programmatically - webviewView.onDidDispose( - async () => { - if (inTabMode) { - this.log("Disposing ClineProvider instance for tab view") - await this.dispose() - } else { - this.log("Clearing webview resources for sidebar view") - this.clearWebviewResources() - // Reset current workspace manager reference when view is disposed - this.codeIndexManager = undefined - } - }, - null, - this.disposables, - ) - // Listen for when color changes const configDisposable = vscode.workspace.onDidChangeConfiguration(async (e) => { - if (e && e.affectsConfiguration("workbench.colorTheme")) { + if (isCurrentView() && e && e.affectsConfiguration("workbench.colorTheme")) { // Sends latest theme name to webview - await this.postMessageToWebview({ type: "theme", text: JSON.stringify(await getTheme()) }) + const text = JSON.stringify(await getTheme()) + if (isCurrentView()) await this.postMessageToWebview({ type: "theme", text }, webviewView) } }) this.webviewDisposables.push(configDisposable) @@ -1153,6 +1198,7 @@ export class ClineProvider if (!currentTask || currentTask.abandoned || currentTask.abort) { await this.removeClineFromStack() } + if (!isCurrentView()) return // Ensure zoo-gateway profile is seeded for users who signed in before this feature existed. // Without this, users with a valid cached token but no zoo-gateway profile would need to @@ -1384,6 +1430,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) @@ -1399,15 +1450,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)`, @@ -1474,16 +1523,143 @@ export class ClineProvider return task } - public async postMessageToWebview(message: ExtensionMessage) { - if (this._disposed) { - return + public postMessageToWebview(message: ExtensionMessage, view = this.view): Promise { + if (this._disposed || view !== this.view) { + return Promise.resolve() } - try { - await this.view?.webview.postMessage(message) - } catch { - // View disposed, drop message silently + 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 Promise.resolve() + } + + // 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 } + } } + + return postToWebview(view?.webview, message) + } + + 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 { + if (this._disposed || this.clineMessagesTransport.closed) return Promise.resolve() + const currentTask = this.getCurrentTask() + // Every producer, including the legacy CLI path, must pass the same identity + // check before reading or cloning payloads from the focused task. + 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 { + if (this._disposed || this.clineMessagesTransport.closed) return Promise.resolve() + 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 { + if (this._disposed || this.clineMessagesTransport.closed) return + const currentTask = this.getCurrentTask() + const session = this.webviewSession + const generation = await this.publishFocusedTaskScope() + if (this.webviewSession !== session || this._disposed) return + if (options.includeTaskHistory) { + await this.postStateToWebview() + } else { + await this.postStateToWebviewWithoutTaskHistory() + } + if (generation !== this.clineMessagesTransport.generation) { + return + } + await this.postClineMessagesSnapshot(currentTask?.taskId, { + generation, + taskInstanceId: currentTask?.instanceId, + }) } public requestWebviewThemeFixture(timeoutMs = 5_000): Promise { @@ -1711,8 +1887,11 @@ export class ClineProvider * @param webview A reference to the extension webview */ private setWebviewMessageListener(webview: vscode.Webview) { - const onReceiveMessage = async (message: WebviewMessage) => - webviewMessageHandler(this, message, this.marketplaceManager) + const session = this.webviewSession + const onReceiveMessage = async (message: WebviewMessage) => { + if (this._disposed || this.webviewSession !== session) return + await webviewMessageHandler(this, message, this.marketplaceManager) + } const messageDisposable = webview.onDidReceiveMessage(onReceiveMessage) this.webviewDisposables.push(messageDisposable) @@ -2376,6 +2555,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 @@ -2418,6 +2600,7 @@ export class ClineProvider async deleteTaskFromState(id: string) { await this.taskHistoryStore.delete(id) + this.clineMessagesTransport.forgetTask(id) this.recentTasksCache = undefined await this.postStateToWebview() @@ -2429,9 +2612,9 @@ export class ClineProvider } async postStateToWebview() { - const clineMessagesSeq = ++this.clineMessagesSeq + const session = this.webviewSession const state = await this.getStateToPostToWebview() - state.clineMessagesSeq = clineMessagesSeq + if (this.webviewSession !== session) return await this.postMessageToWebview({ type: "state", state }) } @@ -2444,11 +2627,11 @@ export class ClineProvider * `taskHistoryUpdated` / `taskHistoryItemUpdated`. */ async postStateToWebviewWithoutTaskHistory(): Promise { - const clineMessagesSeq = ++this.clineMessagesSeq + const session = this.webviewSession const state = await this.getStateToPostToWebview({ includeTaskHistory: false }) - state.clineMessagesSeq = clineMessagesSeq - const { taskHistory: _omit, ...rest } = state - await this.postMessageToWebview({ type: "state", state: rest }) + if (this.webviewSession !== session) return + const { taskHistory: _omitHistory, ...metadataState } = state + await this.postMessageToWebview({ type: "state", state: metadataState }) } /** @@ -2473,23 +2656,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 */ @@ -2776,7 +2942,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 97c4dd877e..607f324bfd 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,22 @@ 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" +import { makeDisposable, makeEventEmitter } from "../../../test-utils/vscode" -// 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 +124,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 +182,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 +244,7 @@ vi.mock("../../../integrations/openai-codex/oauth", () => ({ openAiCodexOAuthManager: { getAccessToken: vi.fn(), getAccountId: vi.fn(), + isAuthenticated: vi.fn().mockResolvedValue(false), }, })) @@ -263,27 +272,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 +400,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 +437,7 @@ describe("ClineProvider", () => { beforeEach(() => { vi.clearAllMocks() + delete process.env.ROO_CLI_RUNTIME if (!TelemetryService.hasInstance()) { TelemetryService.createInstance([]) @@ -527,10 +516,7 @@ describe("ClineProvider", () => { cspSource: "vscode-webview://test-csp-source", }, visible: true, - onDidDispose: vi.fn().mockImplementation((callback) => { - callback() - return { dispose: vi.fn() } - }), + onDidDispose: vi.fn(() => makeDisposable()), onDidChangeVisibility: vi.fn().mockImplementation(() => { return { dispose: vi.fn() } }), @@ -760,7 +746,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 +849,1398 @@ 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 sidebar = () => { + const disposed = makeEventEmitter() + const messages = makeEventEmitter() + const post = vi.fn<(message: ExtensionMessage) => Promise>().mockResolvedValue(true) + const onDidDispose = vi.fn(disposed.event) + const view: vscode.WebviewView = { + viewType: "test-sidebar", + visible: true, + show: vi.fn(), + onDidDispose, + onDidChangeVisibility: () => makeDisposable(), + webview: { + html: "", + options: {}, + cspSource: "test", + postMessage: post, + onDidReceiveMessage: messages.event, + asWebviewUri: (uri) => uri, + }, + } + return { view, post, disposed, onDidDispose } + } + + test.each([true, false])( + "sidebar disposal releases held/queued work without stopping its task (late success=%s)", + async (success) => { + const task = new Task(defaultTaskOptions) + Object.assign(task, { + taskId: "task-1", + instanceId: "instance-1", + clineMessages: [{ ts: 1, type: "say", text: "old" }], + }) + provider["taskRegistry"].push(task) + const old = sidebar() + await provider.resolveWebviewView(old.view) + const providerDisposables = provider["disposables"].length + let resolveOld!: (value: boolean) => void + let rejectOld!: (error: Error) => void + const held = new Promise((resolve, reject) => { + resolveOld = resolve + rejectOld = reject + }) + old.post.mockClear().mockReturnValueOnce(held) + const transport = provider["clineMessagesTransport"] + const snapshot = provider.postClineMessagesSnapshot(task.taskId, { + taskInstanceId: task.instanceId, + bumpSeq: true, + }) + const queued = [ + provider.postClineMessageAppended(task.taskId, task.clineMessages[0], task.instanceId), + provider.postClineMessageUpdated(task.taskId, task.clineMessages[0], task.instanceId), + provider.postClineMessagesSnapshot(task.taskId, { taskInstanceId: task.instanceId }), + ] + const completion = transport["pendingSend"]! + const oldSend = transport["callbacks"]!.postMessage + const sequence = transport.getSequence(task.taskId) + const oldGeneration = transport.generation + const staleDispose = old.onDidDispose.mock.calls[0][0] + old.disposed.fire() + old.disposed.fire() + await Promise.all([snapshot, ...queued]) + expect(provider.getCurrentTask()).toBe(task) + expect(task.abortTask).not.toHaveBeenCalled() + expect(task.dispose).not.toHaveBeenCalled() + expect(provider["_disposed"]).toBe(false) + expect(provider["view"]).toBeUndefined() + const { getPanel } = await import("../../../activate/registerCommands") + expect(getPanel()).not.toBe(old.view) + expect(provider["webviewDisposables"]).toEqual([]) + expect(provider["disposables"]).toHaveLength(providerDisposables) + expect(provider["codeIndexStatusSubscription"]).toBeUndefined() + expect(provider["codeIndexManager"]).toBeUndefined() + expect(completion.finish).toBeUndefined() + expect(transport["callbacks"]).toBeUndefined() + expect(transport["callers"].size).toBe(0) + expect(transport["payloads"].size).toBe(0) + const clone = vi.spyOn(globalThis, "structuredClone") + try { + await provider.postClineMessagesSnapshot(task.taskId, { taskInstanceId: task.instanceId }) + await provider.postClineMessageAppended(task.taskId, task.clineMessages[0], task.instanceId) + await provider.postClineMessageUpdated(task.taskId, task.clineMessages[0], task.instanceId) + await provider.resyncClineMessagesToWebview(task.taskId) + expect(clone).not.toHaveBeenCalled() + } finally { + clone.mockRestore() + } + const fresh = sidebar() + await provider.resolveWebviewView(fresh.view) + await staleDispose() + expect(provider["view"]).toBe(fresh.view) + expect(transport.closed).toBe(false) + const beforeStaleSend = fresh.post.mock.calls.length + await oldSend({ + type: "clineMessagesSnapshotEnd", + taskId: task.taskId, + taskInstanceId: task.instanceId, + snapshotId: "obsolete", + clineMessagesSeq: sequence, + }) + expect(fresh.post).toHaveBeenCalledTimes(beforeStaleSend) + task.clineMessages = [{ ts: 2, type: "say", text: "fresh" }] + await provider.syncFocusedTaskToWebview() + expect( + fresh.post.mock.calls + .filter(([frame]) => frame.type === "clineMessagesSnapshotChunk") + .map(([frame]) => frame.clineMessages), + ).toEqual([task.clineMessages]) + expect(transport.getSequence(task.taskId)).toBe(sequence) + let releaseFresh!: (value: boolean) => void + const freshPhysical = new Promise((resolve) => { + releaseFresh = resolve + }) + fresh.post.mockReturnValueOnce(freshPhysical) + const delta = provider.postClineMessageAppended(task.taskId, task.clineMessages[0], task.instanceId) + const currentState = transport["state"] + const count = fresh.post.mock.calls.length + if (success) resolveOld(true) + else rejectOld(new Error("disposed renderer failed")) + await held.catch(() => {}) + await new Promise((resolve) => setImmediate(resolve)) + expect(transport["state"]).toBe(currentState) + expect(fresh.post).toHaveBeenCalledTimes(count) + await provider.postClineMessagesSnapshot(task.taskId, { + taskInstanceId: task.instanceId, + generation: oldGeneration, + }) + expect(fresh.post).toHaveBeenCalledTimes(count) + releaseFresh(true) + await delta + expect(transport.getSequence(task.taskId)).toBe(sequence + 1) + expect(old.post).toHaveBeenCalledOnce() + expect( + fresh.post.mock.calls + .filter(([frame]) => frame.type.startsWith("clineMessage")) + .every(([frame]) => frame.taskInstanceId === task.instanceId), + ).toBe(true) + }, + ) + + test("reopens an empty sidebar and ignores a stale asynchronous focus sync", async () => { + const old = sidebar() + await provider.resolveWebviewView(old.view) + let release!: (value: boolean) => void + old.post.mockReturnValueOnce( + new Promise((resolve) => { + release = resolve + }), + ) + const staleSync = provider.syncFocusedTaskToWebview() + old.disposed.fire() + const fresh = sidebar() + await provider.resolveWebviewView(fresh.view) + await provider.syncFocusedTaskToWebview() + const before = fresh.post.mock.calls.length + release(true) + await staleSync + expect(fresh.post).toHaveBeenCalledTimes(before) + expect( + fresh.post.mock.calls + .filter(([frame]) => frame.type.startsWith("clineMessagesSnapshot")) + .map(([frame]) => [frame.type, frame.taskId, frame.clineMessagesSeq]), + ).toEqual([ + ["clineMessagesSnapshotStart", undefined, 0], + ["clineMessagesSnapshotEnd", undefined, 0], + ]) + }) + + test.each(["postStateToWebview", "postStateToWebviewWithoutTaskHistory"] as const)( + "drops %s metadata captured for a closed renderer", + async (method) => { + const old = sidebar() + await provider.resolveWebviewView(old.view) + let release!: (value: boolean) => void + let started!: () => void + const captured = new Promise((resolve) => { + started = resolve + }) + vi.mocked(openAiCodexOAuthManager.isAuthenticated).mockImplementationOnce(() => { + started() + return new Promise((resolve) => { + release = resolve + }) + }) + const stale = provider[method]() + await captured + old.disposed.fire() + const fresh = sidebar() + await provider.resolveWebviewView(fresh.view) + const before = fresh.post.mock.calls.length + release(false) + await stale + expect(fresh.post).toHaveBeenCalledTimes(before) + await provider[method]() + expect(fresh.post).toHaveBeenCalledTimes(before + 1) + }, + ) + + test("old initialization cannot revive resources after disposal and reopen", async () => { + const state = await provider.getState() + let release!: (value: typeof state) => void + vi.spyOn(provider, "getState").mockReturnValueOnce( + new Promise((resolve) => { + release = resolve + }), + ) + const old = sidebar() + const resolving = provider.resolveWebviewView(old.view) + expect(old.onDidDispose).toHaveBeenCalledOnce() + old.disposed.fire() + const fresh = sidebar() + await provider.resolveWebviewView(fresh.view) + const resources = [...provider["webviewDisposables"]] + release(state) + await resolving + expect(provider["view"]).toBe(fresh.view) + expect(provider["webviewDisposables"]).toEqual(resources) + expect(old.view.webview.html).toBe("") + await provider.syncFocusedTaskToWebview() + expect(fresh.post).toHaveBeenCalledWith(expect.objectContaining({ type: "clineMessagesSnapshotEnd" })) + }) + + test("provider shutdown closes transport before awaited task cleanup and cannot reopen", async () => { + const task = new Task(defaultTaskOptions) + Object.assign(task, { taskId: "task-1", instanceId: "instance-1", clineMessages: [{ ts: 1, type: "say" }] }) + provider["taskRegistry"].push(task) + const old = sidebar() + await provider.resolveWebviewView(old.view) + let releasePost!: (value: boolean) => void + old.post.mockReturnValueOnce( + new Promise((resolve) => { + releasePost = resolve + }), + ) + const snapshot = provider.postClineMessagesSnapshot(task.taskId, { taskInstanceId: task.instanceId }) + let releaseAbort!: () => void + vi.mocked(task.abortTask).mockReturnValueOnce( + new Promise((resolve) => { + releaseAbort = resolve + }), + ) + const shutdown = provider.dispose() + await snapshot + await vi.waitFor(() => expect(task.abortTask).toHaveBeenCalledOnce()) + expect(task.dispose).not.toHaveBeenCalled() + expect(provider["clineMessagesTransport"].closed).toBe(true) + expect(provider["view"]).toBeUndefined() + const fresh = sidebar() + await provider.resolveWebviewView(fresh.view) + expect(fresh.onDidDispose).not.toHaveBeenCalled() + expect(fresh.post).not.toHaveBeenCalled() + releaseAbort() + await shutdown + await provider.dispose() + releasePost(true) + expect(task.dispose).toHaveBeenCalledOnce() + expect(provider["clineMessagesTransport"]["callers"].size).toBe(0) + }) + + test("tab disposal shuts down transport and owns the provider lifetime", async () => { + const tab = sidebar() + const dispose = vi.fn() + const panel: vscode.WebviewPanel = { + webview: tab.view.webview, + viewType: "test-tab", + title: "Test", + viewColumn: undefined, + visible: true, + active: true, + options: {}, + reveal: vi.fn(), + dispose, + onDidChangeViewState: () => makeDisposable(), + onDidDispose: tab.onDidDispose, + } + await provider.resolveWebviewView(panel) + tab.post.mockImplementation(() => new Promise(() => {})) + const snapshot = provider.postClineMessagesSnapshot() + const onDispose = tab.onDidDispose.mock.calls[0][0] + await onDispose() + await snapshot + expect(provider["_disposed"]).toBe(true) + expect(provider["view"]).toBeUndefined() + expect(provider["clineMessagesTransport"]["callbacks"]).toBeUndefined() + expect(dispose).toHaveBeenCalledOnce() + }) + + const setCurrentTask = ( + task: { taskId: string; instanceId?: string; clineMessages: ClineMessage[] } | undefined, + ) => { + 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 +2273,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 +2291,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 +2299,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 +2350,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 +2654,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 +2665,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 () => { @@ -1292,7 +2679,9 @@ describe("ClineProvider", () => { .spyOn(provider.workspaceTracker!, "initializeFilePaths") .mockReturnValue(initializationPromise) const logSpy = vi.spyOn(provider, "log") - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] await expect(messageHandler({ type: "webviewDidLaunch" })).resolves.toBeUndefined() expect(initializeSpy).toHaveBeenCalledOnce() @@ -1344,7 +2733,9 @@ describe("ClineProvider", () => { await provider.addClineToStack(mockCline) // Get the message handler - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] // Trigger clearTask message await messageHandler({ type: "clearTask" }) @@ -1372,7 +2763,9 @@ describe("ClineProvider", () => { await provider.addClineToStack(childTask) // Get the message handler - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] // Trigger clearTask message await messageHandler({ type: "clearTask" }) @@ -1390,7 +2783,9 @@ describe("ClineProvider", () => { const postStateToWebviewSpy = vi.spyOn(provider, "postStateToWebview").mockResolvedValue(undefined) // Get the message handler - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] // Trigger clearTask message await messageHandler({ type: "clearTask" }) @@ -1416,7 +2811,9 @@ describe("ClineProvider", () => { expect(provider.getTaskStackSize()).toBe(1) // Get the message handler - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] // Trigger clearTask message (simulating cancel during API retry) await messageHandler({ type: "clearTask" }) @@ -1649,7 +3046,9 @@ describe("ClineProvider", () => { test("handles writeDelayMs message", async () => { await provider.resolveWebviewView(mockWebviewView) - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] await messageHandler({ type: "updateSettings", updatedSettings: { writeDelayMs: 2000 } }) @@ -1662,7 +3061,9 @@ describe("ClineProvider", () => { await provider.resolveWebviewView(mockWebviewView) // Get the message handler from onDidReceiveMessage - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] // Simulate setting sound to enabled await messageHandler({ type: "updateSettings", updatedSettings: { soundEnabled: true } }) @@ -1699,7 +3100,9 @@ describe("ClineProvider", () => { test("handles autoCondenseContext message", async () => { await provider.resolveWebviewView(mockWebviewView) - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] await messageHandler({ type: "updateSettings", updatedSettings: { autoCondenseContext: false } }) expect(updateGlobalStateSpy).toHaveBeenCalledWith("autoCondenseContext", false) expect(mockContext.globalState.update).toHaveBeenCalledWith("autoCondenseContext", false) @@ -1718,7 +3121,9 @@ describe("ClineProvider", () => { test("handles autoCondenseContextPercent message", async () => { await provider.resolveWebviewView(mockWebviewView) - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] await messageHandler({ type: "updateSettings", updatedSettings: { autoCondenseContextPercent: 75 } }) @@ -1788,7 +3193,9 @@ describe("ClineProvider", () => { it("loads saved API config when switching modes", async () => { await provider.resolveWebviewView(mockWebviewView) - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] const profile: ProviderSettingsEntry = { name: "test-config", @@ -1815,7 +3222,9 @@ describe("ClineProvider", () => { it("saves current config when switching to mode without config", async () => { await provider.resolveWebviewView(mockWebviewView) - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] ;(provider as any).providerSettingsManager = { getModeConfigId: vi.fn().mockResolvedValue(undefined), @@ -1838,7 +3247,9 @@ describe("ClineProvider", () => { it("saves config as default for current mode when loading config", async () => { await provider.resolveWebviewView(mockWebviewView) - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] const profile: ProviderSettingsEntry = { apiProvider: providerIdentifiers.anthropic, @@ -1865,7 +3276,9 @@ describe("ClineProvider", () => { it("load API configuration by ID works and updates mode config", async () => { await provider.resolveWebviewView(mockWebviewView) - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] const profile: ProviderSettingsEntry = { name: "config-by-id", @@ -1895,7 +3308,9 @@ describe("ClineProvider", () => { test("handles showRooIgnoredFiles setting", async () => { await provider.resolveWebviewView(mockWebviewView) - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] // Default value should be false expect((await provider.getState()).showRooIgnoredFiles).toBe(false) @@ -1915,7 +3330,9 @@ describe("ClineProvider", () => { test("handles updatePrompt message correctly", async () => { await provider.resolveWebviewView(mockWebviewView) - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] // Mock existing prompts const existingPrompts = { @@ -1973,7 +3390,9 @@ describe("ClineProvider", () => { test("handles maxWorkspaceFiles message", async () => { await provider.resolveWebviewView(mockWebviewView) - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] await messageHandler({ type: "updateSettings", updatedSettings: { maxWorkspaceFiles: 300 } }) @@ -1984,7 +3403,9 @@ describe("ClineProvider", () => { test("handles mode-specific custom instructions updates", async () => { await provider.resolveWebviewView(mockWebviewView) - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] // Mock existing prompts const existingPrompts = { @@ -2041,7 +3462,9 @@ describe("ClineProvider", () => { // Create new provider with updated mock context provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) await provider.resolveWebviewView(mockWebviewView) - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] ;(provider as any).providerSettingsManager = { listConfig: vi @@ -2110,7 +3533,9 @@ describe("ClineProvider", () => { ;(provider as any).createTaskWithHistoryItem = vi.fn() // Trigger message deletion - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] await messageHandler({ type: "deleteMessage", value: 4000 }) // Verify that the dialog message was sent to webview @@ -2146,7 +3571,9 @@ describe("ClineProvider", () => { Object.assign(provider, { taskRegistry: new TaskRegistry() }) // Trigger message deletion - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] await messageHandler({ type: "deleteMessage", value: 2000 }) // Verify no dialog was shown since there's no current cline @@ -2202,7 +3629,9 @@ describe("ClineProvider", () => { // Trigger message edit // Get the message handler function that was registered with the webview - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] // Call the message handler with a submitEditedMessage message await messageHandler({ @@ -2255,7 +3684,7 @@ describe("ClineProvider", () => { const getMessageHandler = () => { const mockCalls = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls expect(mockCalls.length).toBeGreaterThan(0) - return mockCalls[0][0] + return mockCalls.at(-1)![0] } test("handles mcpEnabled setting correctly", async () => { @@ -2314,7 +3743,9 @@ describe("ClineProvider", () => { const { SYSTEM_PROMPT } = await import("../../prompts/system") vi.mocked(SYSTEM_PROMPT).mockRejectedValueOnce(new Error("Test error")) - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] await messageHandler({ type: "getSystemPrompt", mode: "code" }) expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("errors.get_system_prompt") @@ -2720,7 +4151,9 @@ describe("ClineProvider", () => { describe("updateCustomMode", () => { test("updates both file and state when updating custom mode", async () => { await provider.resolveWebviewView(mockWebviewView) - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] // Mock CustomModesManager methods ;(provider as any).customModesManager = { @@ -2782,7 +4215,9 @@ describe("ClineProvider", () => { describe("upsertApiConfiguration", () => { test("handles error in upsertApiConfiguration gracefully", async () => { await provider.resolveWebviewView(mockWebviewView) - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] ;(provider as any).providerSettingsManager = { setModeConfig: vi.fn().mockRejectedValue(new Error("Failed to update mode config")), @@ -2815,7 +4250,9 @@ describe("ClineProvider", () => { test("handles successful upsertApiConfiguration", async () => { await provider.resolveWebviewView(mockWebviewView) - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] ;(provider as any).providerSettingsManager = { setModeConfig: vi.fn(), @@ -2854,7 +4291,9 @@ describe("ClineProvider", () => { test("handles buildApiHandler error in updateApiConfiguration", async () => { await provider.resolveWebviewView(mockWebviewView) - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] // Mock buildApiHandler to throw an error const { buildApiHandler } = await import("../../../api") @@ -2903,7 +4342,9 @@ describe("ClineProvider", () => { test("handles successful saveApiConfiguration", async () => { await provider.resolveWebviewView(mockWebviewView) - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] ;(provider as any).providerSettingsManager = { setModeConfig: vi.fn(), @@ -3420,7 +4861,9 @@ describe("Project MCP Settings", () => { // Set up the webview await provider.resolveWebviewView(mockWebviewView) - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] // Ensure the message handler is properly set up expect(messageHandler).toBeDefined() @@ -3447,7 +4890,9 @@ describe("Project MCP Settings", () => { test("handles openProjectMcpSettings when workspace is not open", async () => { await provider.resolveWebviewView(mockWebviewView) - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] // Mock no workspace folders ;(vscode.workspace as any).workspaceFolders = [] @@ -3461,7 +4906,9 @@ describe("Project MCP Settings", () => { test("handles openProjectMcpSettings file creation error", async () => { await provider.resolveWebviewView(mockWebviewView) - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] // Mock workspace folders ;(vscode.workspace as any).workspaceFolders = [{ uri: { fsPath: "/test/workspace" } }] @@ -3717,10 +5164,7 @@ describe("ClineProvider - Router Models", () => { asWebviewUri: vi.fn(), }, visible: true, - onDidDispose: vi.fn().mockImplementation((callback) => { - callback() - return { dispose: vi.fn() } - }), + onDidDispose: vi.fn(() => makeDisposable()), onDidChangeVisibility: vi.fn().mockImplementation(() => { return { dispose: vi.fn() } }), @@ -3735,7 +5179,9 @@ describe("ClineProvider - Router Models", () => { test("handles requestRouterModels with successful responses", async () => { await provider.resolveWebviewView(mockWebviewView) - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] // Mock getState to return API configuration vi.spyOn(provider, "getState").mockResolvedValue({ @@ -3810,7 +5256,9 @@ describe("ClineProvider - Router Models", () => { test("handles requestRouterModels with individual provider failures", async () => { await provider.resolveWebviewView(mockWebviewView) - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] vi.spyOn(provider, "getState").mockResolvedValue({ apiConfiguration: { @@ -3881,7 +5329,9 @@ describe("ClineProvider - Router Models", () => { test("handles requestRouterModels with LiteLLM values from message", async () => { await provider.resolveWebviewView(mockWebviewView) - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] // Mock state without LiteLLM config vi.spyOn(provider, "getState").mockResolvedValue({ @@ -3916,7 +5366,9 @@ describe("ClineProvider - Router Models", () => { test("skips LiteLLM when neither config nor message values are provided", async () => { await provider.resolveWebviewView(mockWebviewView) - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] vi.spyOn(provider, "getState").mockResolvedValue({ apiConfiguration: { @@ -3967,7 +5419,9 @@ describe("ClineProvider - Router Models", () => { test("handles requestLmStudioModels with proper response", async () => { await provider.resolveWebviewView(mockWebviewView) - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] vi.spyOn(provider, "getState").mockResolvedValue({ apiConfiguration: { @@ -4071,10 +5525,7 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { asWebviewUri: vi.fn(), }, visible: true, - onDidDispose: vi.fn().mockImplementation((callback) => { - callback() - return { dispose: vi.fn() } - }), + onDidDispose: vi.fn(() => makeDisposable()), onDidChangeVisibility: vi.fn().mockImplementation(() => { return { dispose: vi.fn() } }), @@ -4132,7 +5583,9 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { historyItem: { id: "test-task-id" }, }) - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] await messageHandler({ type: "submitEditedMessage", value: 3000, @@ -4188,7 +5641,9 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { historyItem: { id: "test-task-id" }, }) - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] await messageHandler({ type: "submitEditedMessage", value: 3000, @@ -4238,7 +5693,9 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { historyItem: { id: "test-task-id" }, }) - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] // Should not throw error, but handle gracefully await expect( @@ -4280,7 +5737,9 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { historyItem: { id: "test-task-id" }, }) - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] // Should handle connection error gracefully await expect( @@ -4332,7 +5791,9 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { historyItem: { id: "test-task-id" }, }) - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] // Simulate concurrent edit operations const edit1Promise = messageHandler({ @@ -4384,7 +5845,9 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { // Mock no current cline (simulating permission failure) vi.spyOn(provider, "getCurrentTask").mockReturnValue(undefined) - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] await messageHandler({ type: "submitEditedMessage", @@ -4412,7 +5875,9 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { historyItem: { id: "test-task-id" }, }) - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] await messageHandler({ type: "submitEditedMessage", @@ -4436,7 +5901,9 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { }) test("handles malformed edit requests", async () => { - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] // Test with missing value await messageHandler({ @@ -4462,7 +5929,9 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { }) test("handles invalid message formats", async () => { - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] // Test with null message - should throw error await expect(messageHandler(null)).rejects.toThrow() @@ -4494,7 +5963,9 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { await provider.addClineToStack(mockCline) - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] // Test with negative timestamp await messageHandler({ @@ -4534,7 +6005,9 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { historyItem: { id: "test-task-id" }, }) - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] // Try to edit a message that doesn't exist (timestamp 5000) await messageHandler({ @@ -4578,7 +6051,9 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { historyItem: { id: "test-task-id" }, }) - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] // Try to delete a message that doesn't exist (timestamp 5000) await messageHandler({ @@ -4629,7 +6104,9 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { historyItem: { id: "test-task-id" }, }) - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] await messageHandler({ type: "submitEditedMessage", @@ -4675,7 +6152,9 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { historyItem: { id: "test-task-id" }, }) - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] await messageHandler({ type: "deleteMessage", value: 2000 }) @@ -4721,7 +6200,9 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { historyItem: { id: "test-task-id" }, }) - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] const largeEditedContent = "B".repeat(15000) await messageHandler({ @@ -4767,7 +6248,9 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { historyItem: { id: "test-task-id" }, }) - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] await messageHandler({ type: "deleteMessage", value: 3000 }) @@ -4810,7 +6293,9 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { }) ;(provider as any).createTaskWithHistoryItem = vi.fn() - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] await messageHandler({ type: "deleteMessage", value: 2000 }) @@ -4845,7 +6330,9 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { await provider.addClineToStack(mockCline) - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] await messageHandler({ type: "submitEditedMessage", @@ -4884,7 +6371,9 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { historyItem: { id: "test-task-id" }, }) - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] await messageHandler({ type: "deleteMessage", value: 1000 }) @@ -4930,7 +6419,9 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { historyItem: { id: "test-task-id" }, }) - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] + const messageHandler = vi.mocked( + mockWebviewView.webview.onDidReceiveMessage as vscode.Webview["onDidReceiveMessage"], + ).mock.lastCall![0] await messageHandler({ type: "submitEditedMessage", diff --git a/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts b/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts index 2bbf0736c6..7ae140acb3 100644 --- a/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts @@ -339,10 +339,8 @@ describe("ClineProvider Task History Synchronization", () => { cspSource: "vscode-webview://test-csp-source", }, visible: true, - onDidDispose: vi.fn().mockImplementation((callback) => { - callback() - return { dispose: vi.fn() } - }), + // Registering a listener does not dispose the renderer. + onDidDispose: vi.fn(() => ({ dispose: vi.fn() })), onDidChangeVisibility: vi.fn().mockImplementation(() => { return { dispose: vi.fn() } }), @@ -647,7 +645,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..06bb7c6ed9 --- /dev/null +++ b/src/core/webview/__tests__/transcriptTransport.model.ts @@ -0,0 +1,1042 @@ +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" + | "shutdown" + | "reopen" +type Capture = { + job: TranscriptJob + renderer: number + taskInstanceId: string | undefined + scope: string + values: number[] + failed: boolean +} +type ModelState = { + transport: TranscriptTransportState + renderer: number + shutdowns: number + shutdownWithQueued: boolean + closedAdmissionRejected: boolean + retired: Array<{ frame: TranscriptFrame; message: ExtensionMessage }> + lateOutcomes: string[] + lateWhileSending: string[] + focus: TaskId | undefined + focusInstance: string | undefined + instanceSyncPending: boolean + 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: [], + }, + { + name: "renderer-disposal-and-reopen", + producer: ["snapshot", "append"], + controller: ["shutdown", "shutdown", "reopen", "snapshot"], + }, + { + name: "renderer-reopen-without-task", + producer: ["snapshot"], + controller: ["clear", "shutdown", "reopen", "empty-snapshot"], + }, + { + name: "renderer-reopen-with-new-instance", + producer: ["snapshot", "stale-instance-append"], + controller: ["shutdown", "replace-instance", "reopen", "sync-instance"], + }, +] +export const TRANSPORT_ACTIONS = [ + "snapshot", + "append", + "update", + "resync", + "invalidate", + "switch", + "clear", + "focus", + "stale-snapshot", + "replace-instance", + "sync-instance", + "stale-instance-append", + "stale-instance-snapshot", + "empty-append", + "empty-update", + "empty-snapshot", + "pump", + "start", + "chunk", + "end", + "settle", + "fail", + "discard", + "shutdown", + "reopen", + "late-resolve", + "late-reject", +] +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"), + "shutdown-releases-held-and-queued-callers": (s: ModelState) => + s.shutdownWithQueued && s.transport.closed && s.callers.length === 0 && s.payloads.length === 0, + "repeated-shutdown-with-held-send": (s: ModelState) => s.shutdowns === 2 && s.retired.length > 0, + "closed-admission-rejected": (s: ModelState) => s.closedAdmissionRejected, + "reopened-renderer-sends-before-dead-renderer-settles": (s: ModelState) => + s.renderer > 0 && !!s.physical && s.retired.length > 0, + "late-resolve-does-not-settle-new-send": (s: ModelState) => s.lateWhileSending.includes("resolve"), + "late-reject-does-not-settle-new-send": (s: ModelState) => s.lateWhileSending.includes("reject"), + "reopened-snapshot-committed": (s: ModelState) => s.committed.some((id) => s.captures[id - 1].renderer > 0), + "reopened-no-task-snapshot-committed": (s: ModelState) => + s.committed.some((id) => { + const capture = s.captures[id - 1] + return capture.renderer > 0 && capture.job.taskId === undefined + }), +} satisfies Record boolean> + +function initialState(): ModelState { + return { + transport: createTranscriptTransportState(TRANSPORT_MODEL_BOUNDS.chunkSize), + renderer: 0, + shutdowns: 0, + shutdownWithQueued: false, + closedAdmissionRejected: false, + retired: [], + lateOutcomes: [], + lateWhileSending: [], + focus: "a", + focusInstance: "a:0", + instanceSyncPending: false, + 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", frame: s.transport.inFlight, success: true } }) + if (s.failures < TRANSPORT_MODEL_BOUNDS.failures) + events.push({ name: "fail", action: { type: "settle", frame: s.transport.inFlight, success: false } }) + } + for (const { frame } of s.retired) { + events.push({ name: "late-resolve", action: { type: "settle", frame, success: true } }) + if (s.failures < TRANSPORT_MODEL_BOUNDS.failures) + events.push({ name: "late-reject", action: { type: "settle", frame, 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 + const generationChange = + action.type === "invalidate" || + (action.type === "shutdown" && !before.closed) || + (action.type === "reopen" && before.closed) + requireInvariant( + s.transport.generation === before.generation + (generationChange ? 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 (before.closed) { + requireInvariant(unchanged, "closed admission allocated work") + s.closedAdmissionRejected = true + } + if (action.request.kind !== "snapshot" && action.total === 0) { + requireInvariant(unchanged, "empty delta allocated work") + } + 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, + renderer: s.renderer, + 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 === "shutdown" || action.type === "reopen") { + requireInvariant( + s.transport.nextJobId === before.nextJobId && + s.transport.nextSnapshotId === before.nextSnapshotId && + JSON.stringify([...s.transport.sequences]) === JSON.stringify([...before.sequences]), + "renderer boundary reset sequences or identities", + ) + if (action.type === "shutdown") { + s.shutdowns++ + s.shutdownWithQueued ||= !!before.inFlight && before.queue.length > 0 + requireInvariant( + s.transport.closed && + !s.transport.inFlight && + !s.transport.active && + s.transport.queue.length === 0 && + s.payloads.length === 0 && + s.callers.length === 0, + "shutdown retained transport ownership", + ) + if (s.physical) { + requireInvariant(s.physicalMessage, "physical send lost its wire message") + s.retired.push({ frame: s.physical, message: s.physicalMessage }) + } + s.physical = undefined + s.physicalMessage = undefined + } else if (before.closed) { + requireInvariant(!s.transport.closed, "reopen did not enable renderer") + s.renderer++ + s.visible = [] + s.appliedSeq = 0 + s.staging = undefined + } + } + if (action.type === "settle") { + const retired = s.retired.find(({ frame }) => frame.job.id === action.frame.job.id) + if (retired) { + requireInvariant( + transition.state === before && + !transition.post && + transition.release.length === 0 && + transition.settle.length === 0, + "late completion changed live transport", + ) + const outcome = action.success ? "resolve" : "reject" + s.lateOutcomes = [...new Set([...s.lateOutcomes, outcome])].sort() + if (s.physical) s.lateWhileSending = [...new Set([...s.lateWhileSending, outcome])].sort() + if (!action.success) s.failures++ + // This wire belongs to the disposed renderer, never its replacement. + s.retired = s.retired.filter((entry) => entry !== retired) + } else { + 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.transport.closed && capture.renderer === s.renderer, + "post initiated for a disposed renderer", + ) + requireInvariant(!s.physical, "overlapping physical sends") + requireInvariant( + capture.job.generation === s.transport.generation && frame.job.taskId === s.focus, + "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 === "shutdown" || intent === "reopen") { + apply({ type: intent }) + return s + } + if (intent === "switch" || intent === "clear" || intent === "focus" || intent === "replace-instance") { + const previous = s.focus + s.focus = intent === "replace-instance" ? "a" : intent === "clear" ? undefined : "b" + 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: "shutdown-retains-physical-caller", + expected: "shutdown retained transport ownership", + reduce: (state, action) => { + if (action.type !== "shutdown" || state.closed) return reduceTranscriptTransport(state, action) + const result = reduceTranscriptTransport(state, { type: "invalidate" }) + result.state = { ...result.state, closed: true } + return result + }, + }, + { + name: "admit-after-shutdown", + expected: "closed admission allocated work", + reduce: (state, action) => + reduceTranscriptTransport( + action.type === "enqueue" && state.closed ? { ...state, closed: false } : state, + action, + ), + }, + { + name: "reopen-resets-identities", + expected: "renderer boundary reset sequences or identities", + reduce: (state, action) => { + const result = reduceTranscriptTransport(state, action) + if (action.type === "reopen") + result.state = { ...result.state, nextJobId: 0, nextSnapshotId: 0, sequences: new Map() } + return result + }, + }, + { + name: "late-completion-settles-live-send", + expected: "late completion changed live transport", + reduce: (state, action) => + reduceTranscriptTransport( + state, + action.type === "settle" && state.inFlight ? { ...action, frame: state.inFlight } : action, + ), + }, + { + name: "stale-completion-starts-end", + expected: "post or commit initiated after invalidation", + 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..da75c488e1 --- /dev/null +++ b/src/core/webview/__tests__/transcriptTransport.spec.ts @@ -0,0 +1,945 @@ +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(["job", "generation", "phase", "start"] as const)("ignores a mismatched %s completion token", (field) => { + const admitted = reduceTranscriptTransport(createTranscriptTransportState(), { + type: "enqueue", + request: { kind: "snapshot", taskId: "a" }, + total: 1, + focusedTaskId: "a", + }) + const sent = reduceTranscriptTransport(admitted.state, { type: "pump", focusedTaskId: "a" }) + const frame = structuredClone(sent.post!) + if (field === "job") frame.job.id++ + if (field === "generation") frame.job.generation++ + if (field === "phase") frame.phase = "end" + if (field === "start") frame.start++ + for (const success of [true, false]) { + const ignored = reduceTranscriptTransport(sent.state, { type: "settle", frame, success }) + expect(ignored).toEqual({ state: sent.state, release: [], settle: [] }) + expect(ignored.state).toBe(sent.state) + } + }) + + test("closed pump and repeated boundaries are no-ops", () => { + const open = createTranscriptTransportState() + expect(reduceTranscriptTransport(open, { type: "reopen" }).state).toBe(open) + const closed = reduceTranscriptTransport(open, { type: "shutdown" }).state + expect(reduceTranscriptTransport(closed, { type: "shutdown" }).state).toBe(closed) + const pump = reduceTranscriptTransport(closed, { type: "pump", focusedTaskId: undefined }) + expect(pump).toEqual({ state: closed, release: [], settle: [] }) + expect(pump.state).toBe(closed) + }) + + test.each(["append", "update"] as const)("rejects an empty %s without allocating protocol state", (kind) => { + const state = createTranscriptTransportState() + const focus = { focusedTaskId: "a", focusedTaskInstanceId: "instance-1" } + 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", + frame: state.inFlight!, + 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 admitted = reduceTranscriptTransport(state, { + type: "enqueue", + request: { kind: "append", taskId: "a" }, + total: 1, + focusedTaskId: "a", + }) + const frame: TranscriptFrame = { job: admitted.accepted!, phase: "append", start: 0, count: 0 } + const transition = reduceTranscriptTransport(state, { type: "settle", frame, success }) + expect(transition).toEqual({ state, release: [], settle: [] }) + expect(transition.state).toBe(state) + }) + + 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", frame: state.inFlight!, success: true }).state + } + // Adversarial reducer input: normal invalidation also releases this work. Keep + // the pre-send guard defensive if stale ownership ever reaches this boundary. + 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", + frame: transition.post!, + 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( + (["start", "chunk", "end", "append", "update"] as const).flatMap((phase) => + [true, false].map((success) => ({ phase, success })), + ), + )( + "shutdown detaches held $phase and permits a fresh renderer (late success=$success)", + async ({ phase, success }) => { + const types = { + start: "clineMessagesSnapshotStart", + chunk: "clineMessagesSnapshotChunk", + end: "clineMessagesSnapshotEnd", + append: "clineMessageAppended", + update: "clineMessageUpdated", + } as const + let resolveOld!: () => void + let rejectOld!: (error: Error) => void + const oldPhysical = new Promise((resolve, reject) => { + resolveOld = resolve + rejectOld = reject + }) + const oldPost = vi.fn((frame: ExtensionMessage) => + frame.type === types[phase] ? oldPhysical : Promise.resolve(), + ) + const log = vi.fn() + const transport = new TranscriptTransport( + () => "a", + oldPost, + log, + () => "instance-1", + ) + const resolved = vi.fn() + const rejected = vi.fn() + const active = transport + .enqueue( + { + kind: phase === "append" || phase === "update" ? phase : "snapshot", + taskId: "a", + taskInstanceId: "instance-1", + bumpSeq: true, + }, + [message], + ) + .then(resolved, rejected) + await vi.waitFor(() => + expect(oldPost).toHaveBeenCalledWith(expect.objectContaining({ type: types[phase] })), + ) + const waiting = ["append", "update", "snapshot"].map((kind) => + transport.enqueue( + { kind: kind as "append" | "update" | "snapshot", taskId: "a", taskInstanceId: "instance-1" }, + [message], + ), + ) + const completion = transport["pendingSend"]! + const before = transport["state"] + const oldFrames = oldPost.mock.calls.length + transport.shutdown() + transport.shutdown() + await Promise.all([active, ...waiting]) + expect(resolved).toHaveBeenCalledOnce() + expect(rejected).not.toHaveBeenCalled() + expect(completion.finish).toBeUndefined() + expect(transport["callbacks"]).toBeUndefined() + expect(transport["pendingSend"]).toBeUndefined() + expect(transport["payloads"].size).toBe(0) + expect(transport["callers"].size).toBe(0) + expect(transport["state"]).toMatchObject({ + closed: true, + generation: before.generation + 1, + queue: [], + active: undefined, + inFlight: undefined, + }) + expect(transport["state"].nextJobId).toBe(before.nextJobId) + expect(transport.getSequence("a")).toBe(before.sequences.get("a")) + + let releaseNew!: () => void + const newPhysical = new Promise((resolve) => { + releaseNew = resolve + }) + const newPost = vi + .fn<(frame: ExtensionMessage) => Promise>() + .mockReturnValueOnce(newPhysical) + .mockResolvedValue(undefined) + transport.reopen( + () => "a", + newPost, + log, + () => "instance-2", + ) + const recovered = vi.fn() + const fresh = transport + .enqueue({ kind: "snapshot", taskId: "a", taskInstanceId: "instance-2" }, [message]) + .then(recovered) + const delta = transport.enqueue({ kind: "append", taskId: "a", taskInstanceId: "instance-2" }, [message]) + expect(newPost).toHaveBeenCalledOnce() + const newState = transport["state"] + expect(newState.inFlight?.job.id).toBe(before.nextJobId + 1) + expect(newState.inFlight?.job.snapshotId).toBe(`a:${before.nextSnapshotId + 1}`) + // A repeated reopen cannot replace callbacks or reset an already-live barrier. + transport.reopen(() => "b", oldPost, log) + if (success) resolveOld() + else rejectOld(new Error("dead renderer rejected")) + await oldPhysical.catch(() => {}) + await Promise.resolve() + expect(transport["state"]).toBe(newState) + expect(recovered).not.toHaveBeenCalled() + expect(newPost).toHaveBeenCalledOnce() + expect(log).not.toHaveBeenCalled() + releaseNew() + await Promise.all([fresh, delta]) + expect(resolved).toHaveBeenCalledOnce() + expect(recovered).toHaveBeenCalledOnce() + expect(oldPost).toHaveBeenCalledTimes(oldFrames) + expect( + newPost.mock.calls.map(([frame]) => [frame.type, frame.taskInstanceId, frame.clineMessagesSeq]), + ).toEqual([ + ["clineMessagesSnapshotStart", "instance-2", before.sequences.get("a")], + ["clineMessagesSnapshotChunk", "instance-2", before.sequences.get("a")], + ["clineMessagesSnapshotEnd", "instance-2", before.sequences.get("a")], + ["clineMessageAppended", "instance-2", before.sequences.get("a")! + 1], + ]) + }, + ) + + test.each([true, false])( + "shutdown after invalidation settles the detached caller (success=%s)", + async (success) => { + let resolve!: () => void + let reject!: (error: Error) => void + const held = new Promise((yes, no) => { + resolve = yes + reject = no + }) + const log = vi.fn() + const transport = new TranscriptTransport( + () => undefined, + () => held, + log, + ) + const settled = vi.fn() + const active = transport.enqueue({ kind: "snapshot", taskId: undefined }, []).then(settled) + transport.invalidate() + transport.shutdown() + await active + expect(settled).toHaveBeenCalledOnce() + if (success) resolve() + else reject(new Error("late failure")) + await held.catch(() => {}) + const post = vi.fn<(frame: ExtensionMessage) => Promise>().mockResolvedValue(undefined) + transport.reopen(() => undefined, post, log) + await transport.enqueue({ kind: "snapshot", taskId: undefined }, []) + expect(post.mock.calls.map(([frame]) => [frame.type, frame.snapshotId, frame.clineMessagesSeq])).toEqual([ + ["clineMessagesSnapshotStart", "none:2", 0], + ["clineMessagesSnapshotEnd", "none:2", 0], + ]) + expect(log).not.toHaveBeenCalled() + }, + ) + + test.each(["append", "update", "snapshot"] as const)( + "drops closed %s before capture and rejects old generations after reopen", + async (kind) => { + const focus = vi.fn(() => "a") + const post = vi.fn<(frame: ExtensionMessage) => Promise>().mockResolvedValue(undefined) + const transport = new TranscriptTransport(focus, post, vi.fn()) + const oldGeneration = transport.generation + transport.shutdown() + const closedState = transport["state"] + const clone = vi.spyOn(globalThis, "structuredClone") + try { + await transport.enqueue({ kind, taskId: "a" }, [message]) + expect(focus).not.toHaveBeenCalled() + expect(transport["state"]).toBe(closedState) + transport.reopen(focus, post, vi.fn()) + await transport.enqueue({ kind, taskId: "a", generation: oldGeneration }, [message]) + await transport.enqueue({ kind, taskId: "a", generation: closedState.generation }, [message]) + expect(clone).not.toHaveBeenCalled() + expect(post).not.toHaveBeenCalled() + await transport.enqueue({ kind, taskId: "a" }, [message]) + expect(post).toHaveBeenCalled() + } finally { + clone.mockRestore() + } + }, + ) + + test("does not adopt a reopened renderer if capture reenters shutdown", async () => { + const post = vi.fn<(frame: ExtensionMessage) => Promise>().mockResolvedValue(undefined) + const transport = new TranscriptTransport(() => "a", post, vi.fn()) + await transport.enqueue({ kind: "snapshot", taskId: "a" }, [ + { + ts: 1, + type: "say", + get text() { + transport.shutdown() + transport.reopen(() => "a", post, vi.fn()) + return "obsolete" + }, + }, + ]) + expect(post).not.toHaveBeenCalled() + expect(transport["state"].nextJobId).toBe(0) + await transport.enqueue({ kind: "append", taskId: "a" }, [message]) + expect(post).toHaveBeenCalledOnce() + }) + + test("handles shutdown reentered by a physical post that then throws", async () => { + const log = vi.fn() + const freshPost = vi.fn<(frame: ExtensionMessage) => Promise>().mockResolvedValue(undefined) + const transport = new TranscriptTransport( + () => "a", + () => { + transport.shutdown() + transport.reopen(() => "a", freshPost, log) + throw new Error("disposed synchronously") + }, + log, + ) + await transport.enqueue({ kind: "snapshot", taskId: "a" }, [message]) + await transport.enqueue({ kind: "append", taskId: "a" }, [message]) + expect(log).not.toHaveBeenCalled() + expect(freshPost).toHaveBeenCalledOnce() + expect(transport["payloads"].size).toBe(0) + expect(transport["callers"].size).toBe(0) + }) + + test.each(["append", "update"] as const)( + "rejects empty %s before cloning or admission, then recovers", + async (kind) => { + 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.checkpoint.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.checkpoint.spec.ts index 71a58d6f8d..9a6de39c74 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.checkpoint.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.checkpoint.spec.ts @@ -79,7 +79,7 @@ describe("webviewMessageHandler - checkpoint operations", () => { describe("delete operations with checkpoint restoration", () => { it("should call handleCheckpointRestoreOperation for checkpoint deletes", async () => { // Mock handleCheckpointRestoreOperation - ;(handleCheckpointRestoreOperation as any).mockResolvedValue(undefined) + vi.mocked(handleCheckpointRestoreOperation).mockResolvedValue(undefined) // Call the handler with delete confirmation await webviewMessageHandler(mockProvider, { @@ -99,7 +99,8 @@ describe("webviewMessageHandler - checkpoint operations", () => { }) }) - it("should save messages for non-checkpoint deletes", async () => { + it("delegates persistence to the rewind for non-checkpoint deletes", async () => { + const retainedMessage = mockCline.clineMessages[0] // Call the handler with delete confirmation (no checkpoint restoration) await webviewMessageHandler(mockProvider, { type: "deleteMessageConfirm", @@ -107,12 +108,9 @@ describe("webviewMessageHandler - checkpoint operations", () => { restoreCheckpoint: false, }) - // Verify saveTaskMessages was called - expect(saveTaskMessages).toHaveBeenCalledWith({ - messages: expect.any(Array), - taskId: "test-task-123", - globalStoragePath: "/test/storage", - }) + // The task overwrite owns persistence; the handler must not save a second time. + expect(mockCline.overwriteClineMessages).toHaveBeenCalledExactlyOnceWith([retainedMessage]) + expect(saveTaskMessages).not.toHaveBeenCalled() // Verify checkpoint restore was NOT called expect(mockCline.checkpointRestore).not.toHaveBeenCalled() @@ -122,7 +120,7 @@ describe("webviewMessageHandler - checkpoint operations", () => { describe("edit operations with checkpoint restoration", () => { it("should call handleCheckpointRestoreOperation for checkpoint edits", async () => { // Mock handleCheckpointRestoreOperation - ;(handleCheckpointRestoreOperation as any).mockResolvedValue(undefined) + vi.mocked(handleCheckpointRestoreOperation).mockResolvedValue(undefined) // Call the handler with edit confirmation await webviewMessageHandler(mockProvider, { @@ -213,7 +211,7 @@ describe("webviewMessageHandler - checkpoint operations", () => { }) it("does not restore when task re-initialization times out", async () => { - ;(pWaitFor as any).mockRejectedValueOnce(new Error("timed out")) + vi.mocked(pWaitFor).mockRejectedValueOnce(new Error("timed out")) await webviewMessageHandler(mockProvider, { type: "completionCheckpointRestore" }) diff --git a/src/core/webview/__tests__/webviewMessageHandler.delete.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.delete.spec.ts index ef2bee3f6d..405909ce76 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.delete.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.delete.spec.ts @@ -3,6 +3,7 @@ import { webviewMessageHandler } from "../webviewMessageHandler" import * as vscode from "vscode" import { ClineProvider } from "../ClineProvider" import { MessageManager } from "../../message-manager" +import { saveTaskMessages } from "../../task-persistence" // Mock the saveTaskMessages function vi.mock("../../task-persistence", async (importOriginal) => ({ @@ -247,6 +248,31 @@ describe("webviewMessageHandler delete functionality", () => { ]) }) + it("rewinds once with preserved checkpoint metadata after deleting messages", async () => { + const checkpoint = { hash: "checkpoint-hash", type: "user_message" } + const preservedMessage = { ts: 1000, say: "user", text: "First message", checkpoint } + getCurrentTaskMock.clineMessages = [preservedMessage, { ts: 2000, say: "user", text: "Delete this" }] + 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) + }, + ) + + await webviewMessageHandler(provider, { + type: "deleteMessageConfirm", + messageTs: 2000, + }) + + expect(getCurrentTaskMock.overwriteClineMessages).toHaveBeenCalledExactlyOnceWith([preservedMessage]) + expect(getCurrentTaskMock.clineMessages).toEqual([preservedMessage]) + expect(saveTaskMessages).not.toHaveBeenCalled() + expect(vscode.window.showErrorMessage).not.toHaveBeenCalled() + }) + 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..bfb8c2e571 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.edit.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.edit.spec.ts @@ -43,6 +43,7 @@ import type { ClineProvider } from "../ClineProvider" import type { ClineMessage } from "@roo-code/types" import type { ApiMessage } from "../../task-persistence/apiMessages" import { MessageManager } from "../../message-manager" +import { saveTaskMessages } from "../../task-persistence" describe("webviewMessageHandler - Edit Message with Timestamp Fallback", () => { let mockClineProvider: ClineProvider @@ -59,6 +60,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 +216,61 @@ describe("webviewMessageHandler - Edit Message with Timestamp Fallback", () => { ]) }) + it("awaits one rewind with preserved checkpoint metadata before submitting an edited message", async () => { + const checkpoint = { hash: "checkpoint-hash", type: "user_message" } + const preservedMessage = { + ts: 500, + 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 + let releaseOverwrite!: () => void + const pendingOverwrite = new Promise((resolve) => { + releaseOverwrite = resolve + }) + mockCurrentTask.overwriteClineMessages.mockImplementation(async (messages: ClineMessage[]) => { + await pendingOverwrite + mockCurrentTask.clineMessages = structuredClone(messages) + completedOverwrites += 1 + }) + mockCurrentTask.submitUserMessage.mockImplementation(() => { + submitObservedCompletedOverwrites = completedOverwrites + }) + + const edit = webviewMessageHandler(mockClineProvider, { + type: "editMessageConfirm", + messageTs: 1000, + text: "Edited message", + restoreCheckpoint: false, + }) + + try { + await vi.waitFor(() => expect(mockCurrentTask.overwriteClineMessages).toHaveBeenCalledOnce()) + expect(mockCurrentTask.overwriteApiConversationHistory).not.toHaveBeenCalled() + expect(mockCurrentTask.submitUserMessage).not.toHaveBeenCalled() + } finally { + releaseOverwrite() + await edit + } + + expect(mockCurrentTask.overwriteClineMessages).toHaveBeenCalledExactlyOnceWith([preservedMessage]) + expect(mockCurrentTask.clineMessages).toEqual([preservedMessage]) + expect(saveTaskMessages).not.toHaveBeenCalled() + expect(mockCurrentTask.submitUserMessage).toHaveBeenCalledWith("Edited message", []) + expect(submitObservedCompletedOverwrites).toBe(1) + }) + 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..a650e4b99a --- /dev/null +++ b/src/core/webview/transcriptTransport.ts @@ -0,0 +1,412 @@ +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 = { + closed: boolean + 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: "shutdown" } + | { type: "reopen" } + | { type: "forget-task"; taskId: string } + | { type: "pump"; focusedTaskId: string | undefined; focusedTaskInstanceId?: string } + | { type: "settle"; frame: TranscriptFrame; 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[] + /** Invalidation waits for physical completion; renderer shutdown settles every caller immediately. */ + 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 { closed: false, 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 ( + !state.closed && + (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 "shutdown": { + if (state.closed) return result + const ids = new Set(state.queue.map((job) => job.id)) + if (state.active) ids.add(state.active.job.id) + if (state.inFlight) ids.add(state.inFlight.job.id) + result.release = [...ids] + result.settle = [...ids].map((id) => ({ id })) + result.state = { + ...state, + closed: true, + generation: state.generation + 1, + queue: [], + active: undefined, + inFlight: undefined, + } + return result + } + case "reopen": + // Preserve sequences and IDs. Work captured before/during closure must never + // acquire the new renderer's generation, even if the task is unchanged. + if (state.closed) result.state = { ...state, closed: false, generation: state.generation + 1 } + return result + case "forget-task": { + const sequences = new Map(state.sequences) + sequences.delete(action.taskId) + result.state = { ...state, sequences } + return result + } + case "pump": { + if (state.closed || 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 || + state.inFlight.job.id !== action.frame.job.id || + state.inFlight.job.generation !== action.frame.job.generation || + state.inFlight.phase !== action.frame.phase || + state.inFlight.start !== action.frame.start + ) + return result + const { job, phase } = state.inFlight + const finished = !action.success || !state.active || phase === "end" || job.kind !== "snapshot" + if (finished) { + 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, + } +} + +type TranscriptCallbacks = { + focusedTaskId: () => string | undefined + postMessage: (message: ExtensionMessage) => Promise + onError: (error: unknown) => void + focusedTaskInstanceId: () => string | undefined +} + +type SendCompletion = { finish?: (success: boolean, error?: unknown) => void } + +// The physical Promise retains only this detachable slot, not the driver, provider, +// frame, payload or caller. Keep these handlers outside the driver's lexical scope. +function observePhysicalSend(promise: Promise, completion: SendCompletion): void { + void promise.then( + () => completion.finish?.(true), + (error: unknown) => completion.finish?.(false, error), + ) +} + +/** One physical-send barrier per renderer, preserved across ordinary invalidations. */ +export class TranscriptTransport { + private state = createTranscriptTransportState() + private readonly payloads = new Map() + private readonly callers = new Map void; reject: (error: unknown) => void }>() + private callbacks?: TranscriptCallbacks + private pendingSend?: SendCompletion + + constructor( + focusedTaskId: () => string | undefined, + postMessage: (message: ExtensionMessage) => Promise, + onError: (error: unknown) => void, + focusedTaskInstanceId: () => string | undefined = () => undefined, + ) { + this.callbacks = { focusedTaskId, postMessage, onError, focusedTaskInstanceId } + } + + get closed(): boolean { + return this.state.closed + } + + shutdown(): void { + if (this.pendingSend) this.pendingSend.finish = undefined + this.pendingSend = undefined + this.callbacks = undefined + this.apply({ type: "shutdown" }) + } + + reopen( + focusedTaskId: () => string | undefined, + postMessage: (message: ExtensionMessage) => Promise, + onError: (error: unknown) => void, + focusedTaskInstanceId: () => string | undefined = () => undefined, + ): void { + if (!this.closed) return + this.callbacks = { focusedTaskId, postMessage, onError, focusedTaskInstanceId } + this.apply({ type: "reopen" }) + } + + get generation(): number { + return this.state.generation + } + + 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 { + const callbacks = this.callbacks + if (!callbacks || this.closed) return Promise.resolve() + // An empty delta must not consume a sequence or enter admission at all. + if (request.kind !== "snapshot" && messages.length === 0) return Promise.resolve() + // Guard before deep cloning (and allocating a sequence/ID). A delayed focus sync + // must not traverse a large, already-obsolete transcript. + const capturedRequest = { ...request, generation: request.generation ?? this.generation } + if ( + !isTranscriptRequestCurrent( + this.state, + capturedRequest, + callbacks.focusedTaskId(), + callbacks.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: callbacks.focusedTaskId(), + focusedTaskInstanceId: callbacks.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 callbacks = this.callbacks + if (!callbacks) return + const { post } = this.apply({ + type: "pump", + focusedTaskId: callbacks.focusedTaskId(), + focusedTaskInstanceId: callbacks.focusedTaskInstanceId(), + }) + if (post) this.send(post) + } + + private send(frame: TranscriptFrame): void { + const completion: SendCompletion = { + finish: (success, error) => { + completion.finish = undefined + if (this.pendingSend !== completion) return + this.pendingSend = undefined + this.apply({ type: "settle", frame, success }, error) + if (!success) this.callbacks?.onError(error) + this.drain() + }, + } + this.pendingSend = completion + try { + observePhysicalSend( + this.callbacks!.postMessage(transcriptFrameMessage(frame, this.payloads.get(frame.job.id)!)), + completion, + ) + } catch (error) { + completion.finish?.(false, error) + } + } +} diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 34a35ea3ca..c3ad2b7311 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -36,7 +36,6 @@ import { CloudService } from "@roo-code/cloud" import { TelemetryService } from "@roo-code/telemetry" import { type ApiMessage } from "../task-persistence/apiMessages" -import { saveTaskMessages } from "../task-persistence" import { importRooTaskHistory } from "../task-persistence/importRooTaskHistory" import { ClineProvider } from "./ClineProvider" @@ -341,36 +340,9 @@ export const webviewMessageHandler = async ( vscode.window.showWarningMessage("No checkpoint found before this message") } } else { - // For non-checkpoint deletes, preserve checkpoint associations for remaining messages - // Store checkpoints from messages that will be preserved - const preservedCheckpoints = new Map() - for (let i = 0; i < messageIndex; i++) { - const msg = currentCline.clineMessages[i] - if (msg?.checkpoint && msg.ts) { - preservedCheckpoints.set(msg.ts, msg.checkpoint) - } - } - - // Delete this message and all subsequent messages using MessageManager + // Rewind preserves the complete retained messages, including checkpoints, + // and owns persistence and snapshot publication. Do not pre-mutate or re-save. await currentCline.messageManager.rewindToTimestamp(targetMessage.ts!, { includeTargetMessage: false }) - - // Restore checkpoint associations for preserved messages - for (const [ts, checkpoint] of preservedCheckpoints) { - const msgIndex = currentCline.clineMessages.findIndex((msg) => msg.ts === ts) - if (msgIndex !== -1) { - currentCline.clineMessages[msgIndex].checkpoint = checkpoint - } - } - - // Save the updated messages with restored checkpoints - await saveTaskMessages({ - messages: currentCline.clineMessages, - taskId: currentCline.taskId, - globalStoragePath: provider.contextProxy.globalStorageUri.fsPath, - }) - - // Update the UI to reflect the deletion - await provider.postStateToWebview() } } catch (error) { console.error("Error in delete message:", error) @@ -509,39 +481,13 @@ export const webviewMessageHandler = async ( } } - // Store checkpoints from messages that will be preserved - const preservedCheckpoints = new Map() - for (let i = 0; i < deleteFromMessageIndex; i++) { - const msg = currentCline.clineMessages[i] - if (msg?.checkpoint && msg.ts) { - preservedCheckpoints.set(msg.ts, msg.checkpoint) - } - } - - // Delete the original (user) message and all subsequent messages using MessageManager + // Rewind preserves checkpoint metadata and publishes only after persistence. + // A rejection must stop the edit before submitting a new user message. const rewindTs = currentCline.clineMessages[deleteFromMessageIndex]?.ts if (rewindTs) { await currentCline.messageManager.rewindToTimestamp(rewindTs, { includeTargetMessage: false }) } - // Restore checkpoint associations for preserved messages - for (const [ts, checkpoint] of preservedCheckpoints) { - const msgIndex = currentCline.clineMessages.findIndex((msg) => msg.ts === ts) - if (msgIndex !== -1) { - currentCline.clineMessages[msgIndex].checkpoint = checkpoint - } - } - - // Save the updated messages with restored checkpoints - await saveTaskMessages({ - messages: currentCline.clineMessages, - taskId: currentCline.taskId, - globalStoragePath: provider.contextProxy.globalStorageUri.fsPath, - }) - - // Update the UI to reflect the deletion - await provider.postStateToWebview() - await currentCline.submitUserMessage(editedContent, images) } catch (error) { console.error("Error in edit message:", error) @@ -574,6 +520,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 +533,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 +822,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 +1881,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 93741e9174..537b7940af 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": 141 } }, "core/webview/__tests__/ClineProvider.sticky-mode.spec.ts": { @@ -1081,7 +1081,7 @@ }, "core/webview/__tests__/webviewMessageHandler.checkpoint.spec.ts": { "@typescript-eslint/no-explicit-any": { - "count": 5 + "count": 2 } }, "core/webview/__tests__/webviewMessageHandler.cloudAuth.spec.ts": { @@ -1131,7 +1131,7 @@ }, "core/webview/webviewMessageHandler.ts": { "@typescript-eslint/no-explicit-any": { - "count": 5 + "count": 3 } }, "extension/__tests__/api-delete-queued-message.spec.ts": { diff --git a/src/extension.ts b/src/extension.ts index 8706de765b..5188b17d5e 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -191,7 +191,13 @@ export async function activate(context: vscode.ExtensionContext) { // Push the new vscode.env.isTelemetryEnabled value to the webview too, so its // own PostHog client (gated separately in TelemetryClient.ts) can't keep // sending events after the global toggle flips off mid-session. - void ClineProvider.getVisibleInstance()?.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)}`, + ) + }) }), ) @@ -220,7 +226,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..f9c4b3a46e 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,218 @@ 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) => { + // Only a failed accepted snapshot retires the pending recovery attempt. + // Its orphaned frames must share the next request instead of each retrying. + if (activeSnapshotRef.current) { + clearClineMessagesSnapshot() + clearClineMessagesResync() + } + requestClineMessagesResync(receivedSeq) + }, + // Stryker disable next-line ArrayDeclaration: the dependencies are stable callbacks; omitting them cannot alter callback identity or captured values. + [clearClineMessagesSnapshot, 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 + } + 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 + } + 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. + [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 +619,147 @@ 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) { + 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) { + 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) { + 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) { + 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 + ) { + 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) { + 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) { + retryClineMessagesResync(seq) + } + break + } + if (message.snapshotTotal !== snapshot.total || snapshot.messages.length !== snapshot.total) { + 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 +840,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..bd937f6e77 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,2522 @@ 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(1) + expect(postMessage.mock.calls.map(([message]) => message.receivedSeq)).toEqual([undefined]) + }) + + 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(1) + expect(postMessage.mock.calls.map(([message]) => message.receivedSeq)).toEqual([undefined]) + }) + + 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.each([ + { name: "an invalid start sequence", fail: () => startSnapshot({ clineMessagesSeq: "invalid" }) }, + { name: "invalid start metadata", fail: () => startSnapshot({ snapshotTotal: -1 }) }, + { name: "an invalid chunk sequence", fail: () => appendSnapshotChunk({ clineMessagesSeq: "invalid" }) }, + { name: "a noncontiguous chunk", fail: () => appendSnapshotChunk({ snapshotStartIndex: 1 }) }, + { name: "an invalid end sequence", fail: () => endSnapshot({ clineMessagesSeq: "invalid" }) }, + { name: "an incomplete end", fail: () => endSnapshot() }, + { name: "an interleaved delta", fail: () => appendClineMessage(makeMessage(3, "gap"), 3, "task-1") }, + { name: "a snapshot timeout", fail: () => vi.advanceTimersByTime(30_000) }, + ])("coalesces orphaned snapshot frames after $name and retries a new failed attempt", ({ fail }) => { + vi.useFakeTimers() + const existing = makeMessage(1, "existing") + const postMessage = renderTranscriptWithPostMessageSpy({ clineMessages: [existing], clineMessagesSeq: 1 }) + + act(() => { + appendClineMessage(makeMessage(3, "initial gap"), 3, "task-1") + startSnapshot() + fail() + }) + expect(postMessage).toHaveBeenCalledTimes(2) + + act(() => { + for (let index = 0; index < 3; index++) { + startSnapshot({ clineMessagesSeq: "invalid" }) + startSnapshot({ snapshotTotal: -1 }) + appendSnapshotChunk() + appendSnapshotChunk({ clineMessagesSeq: "invalid" }) + endSnapshot() + endSnapshot({ clineMessagesSeq: "invalid" }) + appendClineMessage(makeMessage(3, "still pending"), 3, "task-1") + } + }) + expect(postMessage).toHaveBeenCalledTimes(2) + expect(vi.getTimerCount()).toBe(1) + expect(readTranscriptFields()).toEqual({ + currentTaskId: "task-1", + clineMessages: [existing], + clineMessagesSeq: 1, + }) + + act(() => { + startSnapshot({ snapshotId: "replacement" }) + appendSnapshotChunk({ snapshotId: "replacement", snapshotStartIndex: 1 }) + endSnapshot({ snapshotId: "replacement" }) + }) + expect(postMessage).toHaveBeenCalledTimes(3) + expect(postMessage).toHaveBeenLastCalledWith({ + type: "requestClineMessagesResync", + taskId: "task-1", + expectedSeq: 2, + receivedSeq: 2, + }) + + const recovered = makeMessage(2, "recovered") + const appended = makeMessage(3, "after recovery") + act(() => { + startSnapshot({ snapshotId: "recovery" }) + appendSnapshotChunk({ snapshotId: "recovery", clineMessages: [recovered] }) + endSnapshot({ snapshotId: "recovery" }) + appendClineMessage(appended, 3, "task-1") + }) + expect(postMessage).toHaveBeenCalledTimes(3) + expect(vi.getTimerCount()).toBe(0) + expect(readTranscriptFields()).toEqual({ + currentTaskId: "task-1", + clineMessages: [recovered, appended], + clineMessagesSeq: 3, + }) + }) + + it("coalesces orphaned frames without extending the lost-response timeout", () => { + vi.useFakeTimers() + const postMessage = renderTranscriptWithPostMessageSpy({ clineMessagesSeq: 1 }) + + act(() => appendSnapshotChunk()) + expect(postMessage).toHaveBeenCalledTimes(1) + act(() => { + vi.advanceTimersByTime(4_999) + startSnapshot({ snapshotTotal: -1 }) + appendSnapshotChunk() + endSnapshot() + }) + expect(postMessage).toHaveBeenCalledTimes(1) + act(() => { + vi.advanceTimersByTime(1) + endSnapshot() + appendSnapshotChunk() + }) + expect(postMessage).toHaveBeenCalledTimes(2) + expect(vi.getTimerCount()).toBe(1) + }) + + it("allows another resync when a response is lost", async () => { + vi.useFakeTimers() + const postMessage = vi.spyOn(vscode, "postMessage").mockImplementation(() => undefined) + 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: 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.each(["metadata", "options"] as const)("hydrates instance-scoped snapshots from %s", (source) => { + vi.useFakeTimers() + const existing = makeMessage(1, "existing") + const hydrated = makeMessage(2, "hydrated") + const postMessage = renderTranscriptWithPostMessageSpy({ + currentTaskInstanceId: "instance-1", + clineMessages: [existing], + clineMessagesSeq: 1, + }) + const scope = { currentTaskId: "task-1", currentTaskInstanceId: "instance-1" } + const metadata = source === "metadata" ? scope : {} + const options = source === "options" ? { taskId: "task-1", taskInstanceId: "instance-1" } : {} + + act(() => hydrateExtensionState({ ...metadata, clineMessages: [hydrated], clineMessagesSeq: 2 }, options)) + expect(readScopedTranscriptFields()).toEqual({ ...scope, clineMessages: [hydrated], clineMessagesSeq: 2 }) + act(() => hydrateExtensionState({ ...metadata, clineMessages: [], clineMessagesSeq: 3 }, options)) + expect(readScopedTranscriptFields()).toEqual({ ...scope, clineMessages: [], clineMessagesSeq: 3 }) + expect(postMessage).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) + }) + + it("prefers the explicit snapshot instance override over metadata", () => { + const dispatch = vi.spyOn(window, "dispatchEvent") + hydrateExtensionState( + { + currentTaskId: "task-1", + currentTaskInstanceId: "metadata-instance", + clineMessages: [makeMessage(1, "snapshot")], + }, + { taskInstanceId: "override-instance" }, + ) + + expect( + dispatch.mock.calls.map(([event]) => (event instanceof MessageEvent ? event.data : undefined)), + ).toEqual([ + { type: "state", state: { currentTaskId: "task-1", currentTaskInstanceId: "metadata-instance" } }, + ...( + ["clineMessagesSnapshotStart", "clineMessagesSnapshotChunk", "clineMessagesSnapshotEnd"] as const + ).map((type) => + expect.objectContaining({ type, taskId: "task-1", taskInstanceId: "override-instance" }), + ), + ]) + }) + + it("hydrates metadata, non-empty transcripts, and empty transcripts through shared helpers", () => { + renderTranscript({ clineMessages: [makeMessage(1, "existing")], clineMessagesSeq: 1 }) + + 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 +3027,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..a6111fb8c2 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,71 @@ 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; taskInstanceId?: string; clineMessagesSeq?: number } = {}, +) => { + const { clineMessages, clineMessagesSeq: stateSeq, ...metadataState } = state + const taskId = options.taskId ?? metadataState.currentTaskId ?? undefined + const taskInstanceId = options.taskInstanceId ?? metadataState.currentTaskInstanceId ?? undefined + const clineMessagesSeq = options.clineMessagesSeq ?? stateSeq ?? 0 + + dispatchExtensionMessage({ + type: "state", + state: metadataState, + }) + + if (clineMessages === undefined) { + return + } + + const snapshotId = `test-transcript-${++nextTranscriptSnapshotId}` + dispatchExtensionMessage({ + type: "clineMessagesSnapshotStart", + taskId, + taskInstanceId, + clineMessagesSeq, + snapshotId, + snapshotTotal: clineMessages.length, + }) + + if (clineMessages.length > 0) { + dispatchExtensionMessage({ + type: "clineMessagesSnapshotChunk", + taskId, + taskInstanceId, + clineMessagesSeq, + snapshotId, + snapshotStartIndex: 0, + clineMessages, + }) + } + + dispatchExtensionMessage({ + type: "clineMessagesSnapshotEnd", + taskId, + taskInstanceId, + 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()