mirror of
https://github.com/cline/cline.git
synced 2026-09-12 09:14:50 +08:00
Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c83573206e | ||
|
|
07de604cd5 | ||
|
|
b0551dafbc | ||
|
|
1a081a8550 | ||
|
|
38d6a4ba83 | ||
|
|
f11f69771d | ||
|
|
eff6b337a8 | ||
|
|
f804acd36a | ||
|
|
2d16d1ce5d | ||
|
|
cb6139be5d | ||
|
|
990e30dd7b | ||
|
|
f9065cc282 | ||
|
|
2fabc6624e | ||
|
|
c26a5a46e4 | ||
|
|
9042af7f60 |
@@ -121,6 +121,11 @@ ERROR_SERVICE_API_KEY=your-posthog-error-tracking-api-key
|
||||
# GRPC_RECORDER_ENABLED=true
|
||||
# GRPC_RECORDER_FILE_NAME=test-recording
|
||||
|
||||
# Remote-workspace latency debugging / fallback flags
|
||||
# CLINE_DISABLE_PRESENTATION_SCHEDULER=true
|
||||
# CLINE_DISABLE_EPHEMERAL_MESSAGE_PERSISTENCE=true
|
||||
# CLINE_DISABLE_TASK_UI_DELTA_SYNC=true
|
||||
|
||||
# Test mode
|
||||
# E2E_TEST=true
|
||||
# IS_TEST=true
|
||||
|
||||
@@ -0,0 +1,502 @@
|
||||
# Technique Plan: Task UI Delta Sync for Active Task Execution
|
||||
|
||||
This document is the implementation plan for the **task UI delta sync** technique identified in `docs/remote-workspace-latency-branch-analysis-report.md` as the fourth most impactful technique in the branch and the strongest long-term transport architecture improvement.
|
||||
|
||||
The key idea is:
|
||||
|
||||
> **During active task execution, send targeted deltas rather than repeated full snapshots.**
|
||||
|
||||
This technique is more invasive than the first three top-ranked improvements, but it is strategically important because it moves the system toward a better model for remote workspaces: full-state snapshots for hydration and recovery, targeted deltas for live execution.
|
||||
|
||||
## How To Use This Plan
|
||||
|
||||
This plan should be executed on a dedicated extraction branch, while `eve_troubleshooting-remote-workspaces` is treated as the **fully developed reference implementation**.
|
||||
|
||||
That distinction matters. This technique is not a hypothetical architecture proposal; it is a plan for extracting and verifying a technique that already exists in integrated form in the reference branch. Developers working this plan should actively inspect the reference implementation for each step and pull implementation details from it deliberately.
|
||||
|
||||
Be smart about this. Because delta sync spans backend mutation publishing, transport contracts, and frontend application logic, the fastest way to make the development process stronger and smoother is to let the reference branch answer the “how did we already solve this edge case?” question early, rather than rediscovering it late.
|
||||
|
||||
## Developer Operating Posture
|
||||
|
||||
This is the most architecturally ambitious of the top four techniques. It changes how the system thinks about live task transport. That means the correct mindset is not “build a clever delta layer,” but:
|
||||
|
||||
- preserve snapshots as canonical hydration and recovery,
|
||||
- shrink active-execution transport to the minimum necessary changes,
|
||||
- and fall back to resync aggressively when invariants are violated.
|
||||
|
||||
The cross-cutting project wisdom still applies here:
|
||||
|
||||
> **Stop treating every streamed chunk as a durable, full-state, immediately-presented event.**
|
||||
|
||||
For this technique, the emphasis is on moving active execution away from **full-state** and toward **targeted transport**.
|
||||
|
||||
## Document Type, Audience, and Quality Bar
|
||||
|
||||
This is an **extraction implementation plan** for a **Staff+ level distributed systems / infrastructure engineer**. It assumes the reader is capable of reasoning about transport contracts, ordering invariants, state hydration, and recovery semantics.
|
||||
|
||||
The quality bar is especially high here because this technique crosses backend, transport, and frontend boundaries. The plan must therefore make it easy to answer:
|
||||
|
||||
- what the transport contract is,
|
||||
- what invariants must hold,
|
||||
- what recovery behavior is expected,
|
||||
- and how the extracted version will be validated against the reference implementation.
|
||||
|
||||
## Artifact Stack and Dependency Position
|
||||
|
||||
This doc should be read as part of the following artifact sequence:
|
||||
|
||||
1. `docs/remote-workspace-latency-branch-analysis-report.md` explains why delta sync is strategically valuable but later in the extraction order.
|
||||
2. `eve_troubleshooting-remote-workspaces` shows the integrated end state and should be consulted constantly.
|
||||
3. This document defines the extraction steps, invariants, and test strategy for a smaller implementation branch.
|
||||
|
||||
Because this technique is more coupled than the other top-four techniques, keeping that sequence explicit will make development much smoother.
|
||||
|
||||
## Minimal Coherent Extraction Boundary
|
||||
|
||||
The smallest coherent PR for this technique should usually include:
|
||||
|
||||
- shared delta type definitions,
|
||||
- backend publish/subscribe infrastructure,
|
||||
- message-state delta emission,
|
||||
- frontend delta application with sequencing and resync,
|
||||
- and tests covering ordering, divergence, and recovery.
|
||||
|
||||
What should **not** be split away if avoidable:
|
||||
|
||||
- sequence validation from delta application,
|
||||
- resync path from initial delta rollout,
|
||||
- backend emission from frontend application if the goal is an end-to-end usable slice,
|
||||
- and the fallback snapshot path that preserves product correctness.
|
||||
|
||||
## Common Failure Modes While Extracting
|
||||
|
||||
Watch for these failure modes explicitly:
|
||||
|
||||
- treating deltas as a replacement for snapshots rather than a companion to them,
|
||||
- making the reducer permissive instead of sequence-strict,
|
||||
- emitting deltas from the wrong abstraction boundary,
|
||||
- forgetting task-identity filtering and task-switch behavior,
|
||||
- and validating only happy-path ordered deltas without aggressive resync/fallback testing.
|
||||
|
||||
---
|
||||
|
||||
## Why This Technique Matters
|
||||
|
||||
Even after presentation scheduling, deferred persistence, and state coalescing, active execution can still generate meaningful transport churn. Full snapshots are fundamentally a coarse-grained mechanism. They resend lots of state that did not change.
|
||||
|
||||
In remote mode, that means unnecessary work across the whole pipeline:
|
||||
|
||||
- backend snapshot construction,
|
||||
- serialization,
|
||||
- remote transport,
|
||||
- frontend parsing,
|
||||
- broad state replacement / render churn.
|
||||
|
||||
Delta sync fixes the shape of the transport itself by sending only the state mutations that matter:
|
||||
|
||||
- message added,
|
||||
- message updated,
|
||||
- message deleted,
|
||||
- task metadata updated,
|
||||
- explicit resync signal.
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- Active task execution can advance the webview primarily through task UI deltas.
|
||||
- Full-state snapshots remain the canonical initialization and recovery path.
|
||||
- Delta application is sequence-safe and can resync on gap or divergence.
|
||||
- Backend message mutations publish minimal targeted deltas.
|
||||
- Frontend applies deltas with minimal state churn.
|
||||
- Task switches and stale deltas do not corrupt the UI.
|
||||
|
||||
---
|
||||
|
||||
## Files Most Likely to Change
|
||||
|
||||
- `src/shared/TaskUiDelta.ts`
|
||||
- `src/core/controller/ui/subscribeToTaskUiDeltas.ts`
|
||||
- `src/core/task/message-state.ts`
|
||||
- `src/core/controller/index.ts`
|
||||
- `webview-ui/src/context/ExtensionStateContext.tsx`
|
||||
- `webview-ui/src/context/taskUiDeltaState.ts`
|
||||
- `webview-ui/src/context/taskUiDebugCounters.ts`
|
||||
- related tests in backend and webview
|
||||
|
||||
---
|
||||
|
||||
## Step-by-Step Implementation Plan
|
||||
|
||||
## Step 1 — Define the delta model and sequencing contract
|
||||
|
||||
### Goal
|
||||
|
||||
Create a small, explicit, versionable transport contract for active task execution changes.
|
||||
|
||||
### Mental model
|
||||
|
||||
Delta systems fail when they are “implicit.” They need an explicit contract for:
|
||||
|
||||
- what changed,
|
||||
- which task it belongs to,
|
||||
- in what order it must be applied,
|
||||
- what to do if order is broken.
|
||||
|
||||
### Work
|
||||
|
||||
- [x] Define delta event types.
|
||||
- [x] Ensure every delta contains `taskId` and `sequence`.
|
||||
- [x] Define resync behavior on missing/stale sequence.
|
||||
|
||||
### Detailed code changes
|
||||
|
||||
- In `src/shared/TaskUiDelta.ts`:
|
||||
- [x] define or refine delta union including:
|
||||
- [x] `message_added`
|
||||
- [x] `message_updated`
|
||||
- [x] `message_deleted`
|
||||
- [x] `task_metadata_updated`
|
||||
- [x] `task_state_resynced`
|
||||
- [x] document the sequencing contract in comments.
|
||||
- Decide that:
|
||||
- [x] deltas are only valid for the current task,
|
||||
- [x] sequence must increment monotonically by 1,
|
||||
- [x] a gap triggers full resync.
|
||||
|
||||
Do not improvise this contract from memory. Read the reference implementation branch carefully and preserve the exact mental model it uses for sequence monotonicity and recovery semantics.
|
||||
|
||||
### Tests
|
||||
|
||||
- [x] Unit test: type helpers or guards behave correctly.
|
||||
- [x] Unit test: sequence mismatch triggers resync result.
|
||||
|
||||
---
|
||||
|
||||
## Step 2 — Build backend delta publishing infrastructure
|
||||
|
||||
### Goal
|
||||
|
||||
Provide a transport channel for task UI deltas parallel to existing state and partial-message subscriptions.
|
||||
|
||||
### Mental model
|
||||
|
||||
Full-state snapshots and deltas should coexist, not replace each other outright. The backend must be able to publish deltas cheaply while retaining the existing snapshot transport as a recovery path.
|
||||
|
||||
### Work
|
||||
|
||||
- [x] Add subscription/publisher mechanism for task UI deltas.
|
||||
- [x] Ensure it is failure-safe and non-blocking.
|
||||
- [x] Keep transport format minimal, ideally serialized delta JSON.
|
||||
|
||||
### Detailed code changes
|
||||
|
||||
- In `src/core/controller/ui/subscribeToTaskUiDeltas.ts`:
|
||||
- [x] implement backend subscription registry / broadcaster.
|
||||
- [x] add `sendTaskUiDelta(...)` helper.
|
||||
- [x] record payload-size metrics if useful.
|
||||
- If protobuf transport needs changes:
|
||||
- [x] ensure message contract is appropriately wired through `proto/cline/ui.proto` or equivalent.
|
||||
|
||||
This is a good example of where “be smart about this” matters. The developer should not just make the channel exist; they should make it easy to reason about, easy to debug, and obviously subordinate to the canonical snapshot path.
|
||||
|
||||
### Tests
|
||||
|
||||
- [x] Unit test: subscribers receive published deltas.
|
||||
- [x] Unit test: publisher handles no-subscriber case safely.
|
||||
- [x] Unit test: serialized payload shape is stable.
|
||||
|
||||
---
|
||||
|
||||
## Step 3 — Publish deltas from message-state mutations
|
||||
|
||||
### Goal
|
||||
|
||||
Make the message-state layer emit task UI deltas whenever live task messages mutate.
|
||||
|
||||
### Mental model
|
||||
|
||||
The message-state layer is the natural source of truth for chat mutation events. If deltas are emitted here, the system stays aligned with actual message semantics rather than ad hoc UI-side guesses.
|
||||
|
||||
### Work
|
||||
|
||||
- [x] Emit `message_added` on add.
|
||||
- [x] Emit `message_updated` on update.
|
||||
- [x] Emit `message_deleted` on delete.
|
||||
- [x] Emit `task_state_resynced` on full replacement/set flows.
|
||||
- [x] Increment a per-task delta sequence on each publish.
|
||||
|
||||
### Detailed code changes
|
||||
|
||||
- In `src/core/task/message-state.ts`:
|
||||
- [x] wire `emitClineMessagesChanged(...)` to publish deltas when delta sync is enabled.
|
||||
- [x] use `taskState.taskUiDeltaSequence` as the monotonic sequence source.
|
||||
- [x] send minimal payloads for each mutation type.
|
||||
- Ensure ephemeral and durable mutations both publish the same deltas so live UI behavior does not depend on durability choice.
|
||||
|
||||
This step should be executed with the reference implementation branch open beside the extraction branch. The key engineering task is not simply “emit deltas,” but “emit deltas from the true state mutation boundary without creating semantic skew between durable and ephemeral paths.”
|
||||
|
||||
### Tests
|
||||
|
||||
- [x] Unit test: add publishes `message_added` with correct sequence.
|
||||
- [x] Unit test: update publishes `message_updated` with correct sequence.
|
||||
- [x] Unit test: delete publishes `message_deleted` with correct sequence.
|
||||
- [x] Unit test: set/overwrite publishes `task_state_resynced`.
|
||||
|
||||
---
|
||||
|
||||
## Step 4 — Publish task metadata deltas outside message-state mutations
|
||||
|
||||
### Goal
|
||||
|
||||
Handle non-message hot-path changes, such as focus-chain and background-command metadata, without relying on full snapshots.
|
||||
|
||||
### Mental model
|
||||
|
||||
Some of the most annoying snapshot churn comes from small metadata updates that are orthogonal to the message list. These deserve their own lightweight path.
|
||||
|
||||
### Work
|
||||
|
||||
- [x] Add controller helper for metadata delta publication.
|
||||
- [x] Route focus-chain and background-command metadata through it.
|
||||
- [x] Fall back to snapshot posting when no current task or invalid task context exists.
|
||||
|
||||
### Detailed code changes
|
||||
|
||||
- In `src/core/controller/index.ts`:
|
||||
- [x] add or refine `postTaskMetadataDelta(...)`.
|
||||
- [x] only publish deltas when target task matches current active task.
|
||||
- [x] otherwise request a normal full-state post as fallback.
|
||||
|
||||
Keep the fallback path boring and reliable. Smart engineering here means preferring explicit fallback to snapshot sync over any attempt to get fancy when task identity or activity context is ambiguous.
|
||||
|
||||
### Tests
|
||||
|
||||
- [x] Unit test: metadata delta publishes for current active task.
|
||||
- [x] Unit test: mismatched/non-active task falls back to snapshot path.
|
||||
|
||||
---
|
||||
|
||||
## Step 5 — Implement frontend delta application and ordering safety
|
||||
|
||||
### Goal
|
||||
|
||||
Make the webview able to apply deltas incrementally while detecting sequence gaps and requesting resync.
|
||||
|
||||
### Mental model
|
||||
|
||||
Frontend delta handling must be strict, not permissive. If it misses a sequence or applies a delta for the wrong task, stale UI bugs will appear and be hard to debug.
|
||||
|
||||
### Work
|
||||
|
||||
- [x] Track latest applied sequence in the frontend.
|
||||
- [x] Ignore deltas for non-current tasks.
|
||||
- [x] Trigger resync on sequence mismatch.
|
||||
- [x] Apply message add/update/delete with minimal array churn.
|
||||
|
||||
### Detailed code changes
|
||||
|
||||
- In `webview-ui/src/context/taskUiDeltaState.ts`:
|
||||
- [x] validate `delta.sequence === latestSequence + 1`.
|
||||
- [x] return `resync` on mismatch.
|
||||
- [x] ignore deltas for non-current tasks while still advancing sequence semantics intentionally if that is the chosen policy.
|
||||
- [x] apply message mutations minimally.
|
||||
- In `webview-ui/src/context/ExtensionStateContext.tsx`:
|
||||
- [x] subscribe to delta stream,
|
||||
- [x] feed deltas into reducer/helper,
|
||||
- [x] trigger full-state resync when helper returns `resync`.
|
||||
|
||||
The frontend side should be implemented with a bias toward correctness and repairability. If you find yourself making the delta reducer permissive to “keep things working,” stop and compare with the reference implementation. The right answer is usually stricter sequencing plus easier resync.
|
||||
|
||||
### Tests
|
||||
|
||||
- [x] Webview test: ordered deltas produce correct final state.
|
||||
- [x] Webview test: sequence gap triggers resync path.
|
||||
- [x] Webview test: stale/non-current-task delta is ignored safely.
|
||||
|
||||
---
|
||||
|
||||
## Step 6 — Keep full-state snapshots as canonical hydration and recovery path
|
||||
|
||||
### Goal
|
||||
|
||||
Ensure deltas complement snapshots rather than replacing them unsafely.
|
||||
|
||||
### Mental model
|
||||
|
||||
Snapshots are still the canonical state source for:
|
||||
|
||||
- initial load,
|
||||
- task switch,
|
||||
- reconnect/reopen,
|
||||
- recovery after divergence.
|
||||
|
||||
Deltas should advance current state, not become the sole source of truth.
|
||||
|
||||
### Work
|
||||
|
||||
- [x] Preserve `subscribeToState` as initialization path.
|
||||
- [x] Reset delta sequence on full snapshot hydration.
|
||||
- [x] Trigger snapshot fetch on divergence.
|
||||
|
||||
### Detailed code changes
|
||||
|
||||
- In `ExtensionStateContext.tsx`:
|
||||
- [x] after receiving a fresh full snapshot, reset latest delta sequence tracking.
|
||||
- [x] on resync request, fetch latest state and replace current state.
|
||||
- Ensure startup / reload still works even if no deltas arrive.
|
||||
|
||||
This step is essential to keeping the rest of Cline’s product surfaces healthy. Delta sync should improve active execution, not quietly turn startup, reopen, or task switching into undefined behavior.
|
||||
|
||||
### Tests
|
||||
|
||||
- [x] Regression test: initial load hydrates correctly without prior deltas.
|
||||
- [x] Regression test: reopening or task switching still works.
|
||||
- [x] Regression test: full snapshot repairs intentionally diverged delta state.
|
||||
|
||||
---
|
||||
|
||||
## Step 7 — Minimize frontend churn when applying deltas
|
||||
|
||||
### Goal
|
||||
|
||||
Capture the benefit of deltas by applying them with minimal structural churn in React state.
|
||||
|
||||
### Mental model
|
||||
|
||||
A delta transport is less valuable if the frontend responds by rebuilding large portions of state anyway. The frontend should patch the smallest possible region.
|
||||
|
||||
### Work
|
||||
|
||||
- [x] Update only the changed message when possible.
|
||||
- [x] Avoid replacing `clineMessages` unless necessary.
|
||||
- [x] Keep metadata updates narrow.
|
||||
|
||||
### Detailed code changes
|
||||
|
||||
- In `webview-ui/src/context/taskUiDeltaState.ts`:
|
||||
- [x] add/update should preserve array identity only where safe and replace minimal slices.
|
||||
- [x] delete should only filter when message exists.
|
||||
- [x] metadata updates should shallow-merge only changed fields.
|
||||
|
||||
Be smart about this at the React-state level too: if the frontend re-renders large portions of the tree on every delta, then the transport win will be partially squandered.
|
||||
|
||||
### Tests
|
||||
|
||||
- [x] Webview test: unchanged update payload does not cause unnecessary state replacement.
|
||||
- [x] Webview test: active message row updates correctly under repeated deltas.
|
||||
|
||||
---
|
||||
|
||||
## Step 8 — Instrument, debug, and validate remote-mode benefit
|
||||
|
||||
### Goal
|
||||
|
||||
Make the delta system observable and prove it reduces snapshot dependence during active execution.
|
||||
|
||||
### Mental model
|
||||
|
||||
Delta systems are harder to reason about than snapshots, so they need better visibility. Developers should be able to see:
|
||||
|
||||
- how many deltas were applied,
|
||||
- how many full states were still applied,
|
||||
- how often resync happened.
|
||||
|
||||
### Work
|
||||
|
||||
- [x] Add debug counters for full-state applications, partial-message applications, delta applications, and resync requests.
|
||||
- [x] Compare default mode vs delta-disabled mode in validation harness.
|
||||
- [x] Ensure feature flag exists for safe staged rollout.
|
||||
|
||||
### Detailed code changes
|
||||
|
||||
- In `webview-ui/src/context/taskUiDebugCounters.ts`:
|
||||
- [x] add counters for delta application and resync requests.
|
||||
- In `.env.example` / `latency.ts`:
|
||||
- [x] preserve `CLINE_DISABLE_TASK_UI_DELTA_SYNC` or equivalent.
|
||||
- In validation tooling:
|
||||
- [x] compare `stateUpdateCount`, `taskDeltaCount`, and payload bytes across variants.
|
||||
|
||||
Since the reference implementation already exists, one of the strongest ways to smooth development is to validate the extracted technique against both disabled-mode behavior and the known-good reference branch behavior.
|
||||
|
||||
### Tests
|
||||
|
||||
- [ ] Validation harness scenario: delta-enabled mode reduces full-state payload bytes during active execution.
|
||||
- [x] Validation harness scenario: delta-disabled variant falls back cleanly to snapshot behavior.
|
||||
- [x] Unit test: debug counters increment correctly where applicable.
|
||||
|
||||
---
|
||||
|
||||
## Step 9 — Validate the technique in large-file-write and long-running task scenarios
|
||||
|
||||
### Goal
|
||||
|
||||
Confirm that delta sync specifically helps long, noisy task executions, including large-file operations.
|
||||
|
||||
### Mental model
|
||||
|
||||
Large-file writes are not only about the write tool itself. They often generate a lot of nearby live task activity that becomes expensive when transported as snapshots. Delta sync should reduce that excess movement.
|
||||
|
||||
That is why this technique still matters for large-file-write scenarios, even though it is not the first thing to land: it attacks the remaining active-execution transport cost after the first three higher-ROI techniques have already reduced hot-path churn.
|
||||
|
||||
### Work
|
||||
|
||||
- [x] Add scenario coverage for long active execution with many message mutations.
|
||||
- [x] Compare delta-enabled vs delta-disabled behavior.
|
||||
- [x] Verify convergence at the end of execution.
|
||||
|
||||
### Tests
|
||||
|
||||
- [x] Integration/validation scenario: long-running execution with many message updates works correctly under delta sync.
|
||||
- [x] Regression test: final UI state matches snapshot-based state.
|
||||
- [x] Regression test: no stale message duplication or ordering bug appears after many updates.
|
||||
|
||||
---
|
||||
|
||||
## Developer Checklist Summary
|
||||
|
||||
- [x] Define delta model and sequencing contract
|
||||
- [x] Build backend delta subscription/publishing infrastructure
|
||||
- [x] Publish deltas from message-state mutations
|
||||
- [x] Publish metadata deltas for non-message hot paths
|
||||
- [x] Implement frontend delta application with strict ordering safety
|
||||
- [x] Preserve full snapshots for hydration and recovery
|
||||
- [x] Minimize frontend churn during delta application
|
||||
- [x] Add observability, flags, and validation coverage
|
||||
- [x] Validate large-file / long-running execution scenarios
|
||||
|
||||
## Extraction Progress Notes
|
||||
|
||||
- Implemented the core task UI delta transport and reducer path in commit `05d7cc315` (`Add task UI delta sync transport and reducers`).
|
||||
- Added extraction-branch follow-up coverage in commit `8839a5bf6` (`Add task UI delta sync test coverage and env flag helper`), including backend delta broadcaster tests, latency/env-flag helper coverage, reducer sequencing tests, and a webview context delta hydration test.
|
||||
- Added latency-analysis helpers and validation scripts for comparing delta-enabled vs delta-disabled runs (`src/services/telemetry/taskLatencySummary.ts`, `scripts/validate-latency-scenarios.ts`, `scripts/analyze-task-latency-metrics.mjs`, and `scripts/compare-task-latency-metrics.mjs`).
|
||||
- Added message-state regression coverage in commit `cdee38396` (`Add message-state task UI delta regression tests`) and fixed verification follow-up issues in commit `5548080d9`.
|
||||
- Added controller metadata delta coverage (`src/test/controller-task-ui-metadata.test.ts`) plus webview resync/task-switch regression coverage in `webview-ui/src/context/ExtensionStateContext.test.tsx`.
|
||||
- Added frontend churn/debug counter coverage in `webview-ui/src/context/taskUiDeltaState.test.ts` and `webview-ui/src/context/taskUiDebugCounters.test.ts`.
|
||||
- Wired focus-chain metadata and background-command metadata through task-specific delta publication, with snapshot fallback when task identity is ambiguous.
|
||||
- Preserved snapshot hydration/resync semantics alongside delta application and added frontend debug counters for snapshot, partial-message, delta, and resync activity.
|
||||
- Built the standalone validation target, fixed the latency harness mock response path for `latency_validation`, and ran `scripts/validate-latency-scenarios.ts` end-to-end across local/remote and delta-enabled/delta-disabled variants.
|
||||
- Validation now shows clean snapshot fallback when delta sync is disabled (`taskDeltaCount: 0`, `taskDeltaPayloadBytes: 0`, `completed: true`) while delta-enabled variants deliver 31 task deltas / 14,690 delta bytes and still converge successfully.
|
||||
- Extended the validation harness with a `long_running` scenario (`latency_validation_long`) that drives 152 partial-message events and 165 task UI deltas while still converging to 7 unique final messages with no duplicate/stale rows in either local or remote mode.
|
||||
- The long-running scenario now provides direct enabled-vs-disabled comparison data via `totalTransportBytes`, `taskDeltaCount`, `finalUniqueMessageCount`, and `hasDuplicateMessagesAtCompletion`.
|
||||
- Added semantic final-state signatures to the validation harness so each delta-enabled variant is compared directly against the `delta_disabled` baseline. The current runs now report `allVariantsMatchBaseline: true` for both `basic` and `long_running` scenarios in local and remote modes.
|
||||
- Reduced unconditional snapshot posting inside the task loop by gating several active-execution `postStateToWebview()` calls behind the delta-sync flag. This removed snapshot posts for finalized `api_req_started` updates, usage-chunk metric refreshes, and several ask/say paths when delta sync is enabled.
|
||||
- Latest validation runs now show lower absolute snapshot traffic than the earlier harness runs (for example, remote `long_running` `statePayloadBytes` dropped from ~111.8 KB to ~101.2 KB, and `stateUpdateCount` from 16 to 15), confirming that some active-execution full-state churn was successfully removed.
|
||||
- Installed dependencies, regenerated protos, and verified the focused backend and webview coverage locally. Successful verification included:
|
||||
- `npm run test:unit -- src/test/controller-task-ui-metadata.test.ts src/core/controller/ui/subscribeToTaskUiDeltas.test.ts src/test/message-state-handler.test.ts src/core/task/__tests__/latency.test.ts src/services/telemetry/__tests__/taskLatencySummary.test.ts`
|
||||
- `cd webview-ui && npm run test -- src/context/taskUiDeltaState.test.ts src/context/taskUiDebugCounters.test.ts src/context/ExtensionStateContext.test.tsx`
|
||||
- The webview verification now passes without the earlier React `act(...)` warning noise after wrapping streamed state updates in `act(...)`.
|
||||
- Remaining validation gaps:
|
||||
- In the current synthetic scenarios, `statePayloadBytes` remain effectively unchanged **between** delta-enabled and delta-disabled variants, even though the absolute snapshot volume is lower than before. This indicates the remaining snapshot traffic is dominated by canonical hydration / task-boundary state posts that both variants still legitimately share.
|
||||
- The next likely step is to separate *hydration/task-boundary* snapshot bytes from *active-execution* snapshot bytes in the validation harness (or eliminate additional task-boundary snapshot posts that are still unnecessary in delta-enabled mode) so the delta-specific reduction can be demonstrated directly.
|
||||
|
||||
---
|
||||
|
||||
## Final Mental Model Recap
|
||||
|
||||
- **Full snapshots establish truth.**
|
||||
- **Deltas advance truth during active execution.**
|
||||
- **If ordering breaks, resync instead of guessing.**
|
||||
- **The point is not cleverness; the point is to avoid shipping unchanged state over and over in remote mode.**
|
||||
|
||||
That is the mindset developers should keep while implementing this technique.
|
||||
@@ -232,6 +232,10 @@ message ShowWebviewEvent {
|
||||
bool preserve_editor_focus = 1; // When true, webview should not steal focus from editor
|
||||
}
|
||||
|
||||
message TaskUiDeltaEvent {
|
||||
string delta_json = 1;
|
||||
}
|
||||
|
||||
// UiService provides methods for managing UI interactions
|
||||
service UiService {
|
||||
// Scrolls to a specific settings section in the settings view
|
||||
@@ -267,6 +271,9 @@ service UiService {
|
||||
// Subscribe to partial message updates (streaming Cline messages as they're built)
|
||||
rpc subscribeToPartialMessage(EmptyRequest) returns (stream ClineMessage);
|
||||
|
||||
// Subscribe to task UI delta updates for active task execution state
|
||||
rpc subscribeToTaskUiDeltas(EmptyRequest) returns (stream TaskUiDeltaEvent);
|
||||
|
||||
// Initialize webview when it launches
|
||||
rpc initializeWebview(EmptyRequest) returns (Empty);
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import fs from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { summarizeTaskLatencyEvents } from "../src/services/telemetry/taskLatencySummary"
|
||||
|
||||
function parseEventLines(raw) {
|
||||
return raw
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line))
|
||||
.map((entry) => entry.properties ?? entry)
|
||||
.filter((entry) => entry)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const inputPath = process.argv[2]
|
||||
if (!inputPath) {
|
||||
console.error("Usage: node scripts/analyze-task-latency-metrics.mjs <path-to-jsonl>")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const absolutePath = path.resolve(process.cwd(), inputPath)
|
||||
const raw = await fs.readFile(absolutePath, "utf8")
|
||||
const events = parseEventLines(raw)
|
||||
const summary = summarizeTaskLatencyEvents(events)
|
||||
console.log(JSON.stringify(summary, null, 2))
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
import fs from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { compareTaskLatencySummaries, summarizeTaskLatencyEvents } from "../src/services/telemetry/taskLatencySummary"
|
||||
|
||||
function parseEventLines(raw) {
|
||||
return raw
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line))
|
||||
.map((entry) => entry.properties ?? entry)
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
async function loadSummary(inputPath) {
|
||||
const absolutePath = path.resolve(process.cwd(), inputPath)
|
||||
const raw = await fs.readFile(absolutePath, "utf8")
|
||||
return summarizeTaskLatencyEvents(parseEventLines(raw))
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const baselinePath = process.argv[2]
|
||||
const candidatePath = process.argv[3]
|
||||
if (!baselinePath || !candidatePath) {
|
||||
console.error("Usage: node scripts/compare-task-latency-metrics.mjs <baseline-jsonl> <candidate-jsonl>")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const baseline = await loadSummary(baselinePath)
|
||||
const candidate = await loadSummary(candidatePath)
|
||||
console.log(JSON.stringify(compareTaskLatencySummaries(baseline, candidate), null, 2))
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,441 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
|
||||
import { type ChildProcess, spawn } from "node:child_process"
|
||||
import { once } from "node:events"
|
||||
import net from "node:net"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { credentials } from "@grpc/grpc-js"
|
||||
import { AccountServiceClient } from "../src/generated/grpc-js/cline/account"
|
||||
import { StateServiceClient } from "../src/generated/grpc-js/cline/state"
|
||||
import { TaskServiceClient } from "../src/generated/grpc-js/cline/task"
|
||||
import { UiServiceClient } from "../src/generated/grpc-js/cline/ui"
|
||||
|
||||
type ValidationMode = "local" | "remote"
|
||||
|
||||
type ValidationVariant = {
|
||||
name: string
|
||||
env: Record<string, string>
|
||||
}
|
||||
|
||||
type ValidationScenario = {
|
||||
name: string
|
||||
prompt: string
|
||||
}
|
||||
|
||||
type ScenarioResult = {
|
||||
scenario: string
|
||||
variant: string
|
||||
mode: ValidationMode
|
||||
newTaskRpcMs: number
|
||||
firstStateMs: number | null
|
||||
firstPartialMessageMs: number | null
|
||||
firstTaskDeltaMs: number | null
|
||||
completionMs: number | null
|
||||
stateUpdateCount: number
|
||||
partialMessageCount: number
|
||||
taskDeltaCount: number
|
||||
statePayloadBytes: number
|
||||
taskDeltaPayloadBytes: number
|
||||
totalTransportBytes: number
|
||||
messageCountAtCompletion: number | null
|
||||
finalUniqueMessageCount: number | null
|
||||
hasDuplicateMessagesAtCompletion: boolean | null
|
||||
finalStateSignature: string | null
|
||||
completed: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
type ScenarioComparison = {
|
||||
scenario: string
|
||||
mode: ValidationMode
|
||||
baselineVariant: string
|
||||
baselineSignature: string | null
|
||||
baselineSemanticSignature: string | null
|
||||
allVariantsMatchBaseline: boolean
|
||||
mismatchedVariants: string[]
|
||||
}
|
||||
|
||||
function buildSemanticMessageSignature(message: any) {
|
||||
const messageType = message.type
|
||||
const sayType = message.say ?? null
|
||||
const askType = message.ask ?? null
|
||||
|
||||
let normalizedText: string | null = message.text ?? null
|
||||
if (sayType === "api_req_started" || sayType === "hook_status" || sayType === "checkpoint_created") {
|
||||
normalizedText = null
|
||||
}
|
||||
if (messageType === "ask") {
|
||||
normalizedText = null
|
||||
}
|
||||
|
||||
return {
|
||||
type: messageType,
|
||||
say: sayType,
|
||||
ask: askType,
|
||||
text: normalizedText,
|
||||
partial: message.partial ?? false,
|
||||
images: Array.isArray(message.images) ? message.images.length : 0,
|
||||
files: Array.isArray(message.files) ? message.files.length : 0,
|
||||
}
|
||||
}
|
||||
|
||||
function buildSemanticStateSignature(state: any, clineMessages: any[]): string {
|
||||
return JSON.stringify({
|
||||
clineMessages: clineMessages.map(buildSemanticMessageSignature),
|
||||
currentFocusChainChecklist: state.currentFocusChainChecklist ?? null,
|
||||
backgroundCommandRunning: state.backgroundCommandRunning ?? false,
|
||||
backgroundCommandTaskId: state.backgroundCommandTaskId ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url))
|
||||
const PROJECT_ROOT = path.resolve(SCRIPT_DIR, "..")
|
||||
|
||||
const variants: ValidationVariant[] = [
|
||||
{ name: "default", env: {} },
|
||||
{
|
||||
name: "presentation_disabled",
|
||||
env: {
|
||||
CLINE_DISABLE_PRESENTATION_SCHEDULER: "true",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ephemeral_disabled",
|
||||
env: {
|
||||
CLINE_DISABLE_EPHEMERAL_MESSAGE_PERSISTENCE: "true",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "delta_disabled",
|
||||
env: {
|
||||
CLINE_DISABLE_TASK_UI_DELTA_SYNC: "true",
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const scenarios: ValidationScenario[] = [
|
||||
{ name: "basic", prompt: "latency_validation" },
|
||||
{ name: "long_running", prompt: "latency_validation_long" },
|
||||
]
|
||||
|
||||
async function waitForPort(port: number, timeoutMs = 20_000): Promise<void> {
|
||||
const startedAt = Date.now()
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const socket = net.connect(port, "127.0.0.1", () => {
|
||||
socket.destroy()
|
||||
resolve()
|
||||
})
|
||||
socket.on("error", reject)
|
||||
})
|
||||
return
|
||||
} catch {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
}
|
||||
}
|
||||
throw new Error(`Timed out waiting for port ${port}`)
|
||||
}
|
||||
|
||||
async function getFreePort(): Promise<number> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const server = net.createServer()
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address()
|
||||
if (!address || typeof address === "string") {
|
||||
server.close(() => reject(new Error("Unable to allocate port")))
|
||||
return
|
||||
}
|
||||
const { port } = address
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
reject(error)
|
||||
return
|
||||
}
|
||||
resolve(port)
|
||||
})
|
||||
})
|
||||
server.on("error", reject)
|
||||
})
|
||||
}
|
||||
|
||||
function unaryCall<T>(fn: (callback: (error: Error | null, response: T) => void) => void): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
fn((error, response) => {
|
||||
if (error) {
|
||||
reject(error)
|
||||
return
|
||||
}
|
||||
resolve(response)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function startServer(mode: ValidationMode, envOverrides: Record<string, string>) {
|
||||
const grpcPort = await getFreePort()
|
||||
const hostbridgePort = await getFreePort()
|
||||
const env: Record<string, string> = {
|
||||
...process.env,
|
||||
PROTOBUS_PORT: String(grpcPort),
|
||||
HOSTBRIDGE_PORT: String(hostbridgePort),
|
||||
E2E_TEST: "true",
|
||||
CLINE_ENVIRONMENT: "local",
|
||||
TEST_HOSTBRIDGE_REMOTE_NAME: mode === "remote" ? "ssh-remote" : "",
|
||||
TEST_HOSTBRIDGE_PLATFORM: mode === "remote" ? "VS Code Remote" : "VS Code",
|
||||
GRPC_RECORDER_ENABLED: "false",
|
||||
...envOverrides,
|
||||
}
|
||||
|
||||
const child = spawn("npx", ["tsx", path.join(PROJECT_ROOT, "scripts", "test-standalone-core-api-server.ts")], {
|
||||
cwd: PROJECT_ROOT,
|
||||
env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
})
|
||||
|
||||
child.stdout?.on("data", () => {
|
||||
// Drain stdout so the spawned server cannot block on a full pipe buffer.
|
||||
})
|
||||
|
||||
let stderr = ""
|
||||
child.stderr?.on("data", (chunk) => {
|
||||
stderr += chunk.toString()
|
||||
})
|
||||
|
||||
await waitForPort(grpcPort)
|
||||
return { child, grpcPort, hostbridgePort, getStderr: () => stderr }
|
||||
}
|
||||
|
||||
async function stopServer(child: ChildProcess) {
|
||||
if (child.killed || child.exitCode !== null) {
|
||||
return
|
||||
}
|
||||
child.kill("SIGINT")
|
||||
try {
|
||||
await Promise.race([once(child, "exit"), new Promise((resolve) => setTimeout(resolve, 5_000))])
|
||||
} catch {
|
||||
child.kill("SIGKILL")
|
||||
}
|
||||
}
|
||||
|
||||
async function runScenario(
|
||||
mode: ValidationMode,
|
||||
variant: ValidationVariant,
|
||||
scenario: ValidationScenario,
|
||||
): Promise<ScenarioResult> {
|
||||
const server = await startServer(mode, variant.env)
|
||||
const address = `127.0.0.1:${server.grpcPort}`
|
||||
const accountClient = new AccountServiceClient(address, credentials.createInsecure())
|
||||
const stateClient = new StateServiceClient(address, credentials.createInsecure())
|
||||
const taskClient = new TaskServiceClient(address, credentials.createInsecure())
|
||||
const uiClient = new UiServiceClient(address, credentials.createInsecure())
|
||||
|
||||
let currentTaskId: string | undefined
|
||||
const startedAt = Date.now()
|
||||
let firstStateMs: number | null = null
|
||||
let firstPartialMessageMs: number | null = null
|
||||
let firstTaskDeltaMs: number | null = null
|
||||
let completionMs: number | null = null
|
||||
let stateUpdateCount = 0
|
||||
let partialMessageCount = 0
|
||||
let taskDeltaCount = 0
|
||||
let statePayloadBytes = 0
|
||||
let taskDeltaPayloadBytes = 0
|
||||
let messageCountAtCompletion: number | null = null
|
||||
let finalUniqueMessageCount: number | null = null
|
||||
let hasDuplicateMessagesAtCompletion: boolean | null = null
|
||||
let finalStateSignature: string | null = null
|
||||
let completed = false
|
||||
|
||||
const stateStream = stateClient.subscribeToState({})
|
||||
const partialStream = uiClient.subscribeToPartialMessage({})
|
||||
const deltaStream = uiClient.subscribeToTaskUiDeltas({})
|
||||
|
||||
for (const stream of [stateStream, partialStream, deltaStream]) {
|
||||
stream.on("error", (streamError: any) => {
|
||||
if (streamError?.code === 1 || streamError?.details === "Cancelled on client") {
|
||||
return
|
||||
}
|
||||
console.error("validation stream error", streamError)
|
||||
})
|
||||
}
|
||||
|
||||
stateStream.on("data", (response: { stateJson?: string }) => {
|
||||
stateUpdateCount += 1
|
||||
const stateJson = response.stateJson || "{}"
|
||||
statePayloadBytes += Buffer.byteLength(stateJson, "utf8")
|
||||
if (firstStateMs === null) {
|
||||
firstStateMs = Date.now() - startedAt
|
||||
}
|
||||
try {
|
||||
const state = JSON.parse(stateJson)
|
||||
const activeTaskId = state.currentTaskItem?.id
|
||||
if (activeTaskId) {
|
||||
currentTaskId = activeTaskId
|
||||
}
|
||||
const clineMessages = Array.isArray(state.clineMessages) ? state.clineMessages : []
|
||||
const hasCompletion = clineMessages.some(
|
||||
(message: any) => message.ask === "completion_result" || message.ask === "resume_completed_task",
|
||||
)
|
||||
if (hasCompletion && completionMs === null) {
|
||||
completionMs = Date.now() - startedAt
|
||||
messageCountAtCompletion = clineMessages.length
|
||||
const uniqueMessageTs = new Set(clineMessages.map((message: any) => message.ts))
|
||||
finalUniqueMessageCount = uniqueMessageTs.size
|
||||
hasDuplicateMessagesAtCompletion = uniqueMessageTs.size !== clineMessages.length
|
||||
finalStateSignature = buildSemanticStateSignature(state, clineMessages)
|
||||
completed = true
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors in validation harness
|
||||
}
|
||||
})
|
||||
|
||||
partialStream.on("data", (message: { say?: string; text?: string }) => {
|
||||
partialMessageCount += 1
|
||||
if (firstPartialMessageMs === null && (message.say === "text" || message.say === "reasoning")) {
|
||||
firstPartialMessageMs = Date.now() - startedAt
|
||||
}
|
||||
})
|
||||
|
||||
deltaStream.on("data", (event: { deltaJson?: string }) => {
|
||||
taskDeltaCount += 1
|
||||
const deltaJson = event.deltaJson || ""
|
||||
taskDeltaPayloadBytes += Buffer.byteLength(deltaJson, "utf8")
|
||||
if (firstTaskDeltaMs === null) {
|
||||
try {
|
||||
const delta = JSON.parse(deltaJson)
|
||||
if (delta.type?.startsWith("message_") || delta.type === "task_metadata_updated") {
|
||||
firstTaskDeltaMs = Date.now() - startedAt
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
let newTaskRpcMs = 0
|
||||
let error: string | undefined
|
||||
|
||||
try {
|
||||
await unaryCall<{ value?: string }>((callback) => accountClient.accountLoginClicked({}, callback as any))
|
||||
await unaryCall((callback) => accountClient.getUserOrganizations({}, callback as any))
|
||||
|
||||
const rpcStartedAt = Date.now()
|
||||
const newTaskResponse = await unaryCall<{ value?: string }>((callback) =>
|
||||
taskClient.newTask(
|
||||
{
|
||||
metadata: undefined,
|
||||
text: scenario.prompt,
|
||||
images: [],
|
||||
files: [],
|
||||
taskSettings: undefined,
|
||||
},
|
||||
callback as any,
|
||||
),
|
||||
)
|
||||
newTaskRpcMs = Date.now() - rpcStartedAt
|
||||
currentTaskId = newTaskResponse.value || currentTaskId
|
||||
|
||||
const timeoutAt = Date.now() + 20_000
|
||||
while (!completed && Date.now() < timeoutAt) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
}
|
||||
if (!completed) {
|
||||
error = "Scenario timed out before completion"
|
||||
}
|
||||
} catch (scenarioError) {
|
||||
error = scenarioError instanceof Error ? scenarioError.message : String(scenarioError)
|
||||
}
|
||||
|
||||
stateStream.cancel()
|
||||
partialStream.cancel()
|
||||
deltaStream.cancel()
|
||||
accountClient.close()
|
||||
stateClient.close()
|
||||
taskClient.close()
|
||||
uiClient.close()
|
||||
await stopServer(server.child)
|
||||
|
||||
return {
|
||||
scenario: scenario.name,
|
||||
variant: variant.name,
|
||||
mode,
|
||||
newTaskRpcMs,
|
||||
firstStateMs,
|
||||
firstPartialMessageMs,
|
||||
firstTaskDeltaMs,
|
||||
completionMs,
|
||||
stateUpdateCount,
|
||||
partialMessageCount,
|
||||
taskDeltaCount,
|
||||
statePayloadBytes,
|
||||
taskDeltaPayloadBytes,
|
||||
totalTransportBytes: statePayloadBytes + taskDeltaPayloadBytes,
|
||||
messageCountAtCompletion,
|
||||
finalUniqueMessageCount,
|
||||
hasDuplicateMessagesAtCompletion,
|
||||
finalStateSignature,
|
||||
completed,
|
||||
error,
|
||||
}
|
||||
}
|
||||
|
||||
function compareScenarioResults(results: ScenarioResult[]): ScenarioComparison[] {
|
||||
const comparisons = new Map<string, ScenarioComparison>()
|
||||
|
||||
for (const result of results) {
|
||||
const key = `${result.scenario}:${result.mode}`
|
||||
const comparison = comparisons.get(key)
|
||||
if (!comparison) {
|
||||
comparisons.set(key, {
|
||||
scenario: result.scenario,
|
||||
mode: result.mode,
|
||||
baselineVariant: "delta_disabled",
|
||||
baselineSignature: result.variant === "delta_disabled" ? result.finalStateSignature : null,
|
||||
baselineSemanticSignature: result.variant === "delta_disabled" ? result.finalStateSignature : null,
|
||||
allVariantsMatchBaseline: true,
|
||||
mismatchedVariants: [],
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if (result.variant === "delta_disabled") {
|
||||
comparison.baselineSignature = result.finalStateSignature
|
||||
comparison.baselineSemanticSignature = result.finalStateSignature
|
||||
}
|
||||
}
|
||||
|
||||
for (const result of results) {
|
||||
const key = `${result.scenario}:${result.mode}`
|
||||
const comparison = comparisons.get(key)
|
||||
if (!comparison || result.variant === comparison.baselineVariant) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (comparison.baselineSignature !== result.finalStateSignature) {
|
||||
comparison.allVariantsMatchBaseline = false
|
||||
comparison.mismatchedVariants.push(result.variant)
|
||||
}
|
||||
}
|
||||
|
||||
return [...comparisons.values()]
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const results: ScenarioResult[] = []
|
||||
for (const scenario of scenarios) {
|
||||
for (const mode of ["local", "remote"] as const) {
|
||||
for (const variant of variants) {
|
||||
results.push(await runScenario(mode, variant, scenario))
|
||||
}
|
||||
}
|
||||
}
|
||||
const comparisons = compareScenarioResults(results)
|
||||
console.log(JSON.stringify({ results, comparisons }, null, 2))
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -59,6 +59,7 @@ import { appendClineStealthModels } from "./models/refreshOpenRouterModels"
|
||||
import { checkCliInstallation } from "./state/checkCliInstallation"
|
||||
import { sendStateUpdate } from "./state/subscribeToState"
|
||||
import { sendChatButtonClickedEvent } from "./ui/subscribeToChatButtonClicked"
|
||||
import { sendTaskUiDelta } from "./ui/subscribeToTaskUiDeltas"
|
||||
|
||||
/*
|
||||
https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts
|
||||
@@ -499,9 +500,37 @@ export class Controller {
|
||||
}
|
||||
this.backgroundCommandRunning = running
|
||||
this.backgroundCommandTaskId = nextTaskId
|
||||
if (this.task && nextTaskId && this.task.taskId === nextTaskId) {
|
||||
void this.postTaskMetadataDelta({
|
||||
backgroundCommandRunning: running,
|
||||
backgroundCommandTaskId: nextTaskId,
|
||||
})
|
||||
return
|
||||
}
|
||||
void this.postStateToWebview()
|
||||
}
|
||||
|
||||
async postTaskMetadataDelta(
|
||||
metadata: Partial<
|
||||
Pick<ExtensionState, "currentFocusChainChecklist" | "backgroundCommandRunning" | "backgroundCommandTaskId">
|
||||
>,
|
||||
taskId?: string,
|
||||
) {
|
||||
const targetTask = this.task
|
||||
const resolvedTaskId = taskId ?? targetTask?.taskId
|
||||
if (!targetTask || !resolvedTaskId || targetTask.taskId !== resolvedTaskId) {
|
||||
await this.postStateToWebview()
|
||||
return
|
||||
}
|
||||
|
||||
await sendTaskUiDelta({
|
||||
type: "task_metadata_updated",
|
||||
taskId: resolvedTaskId,
|
||||
sequence: ++targetTask.taskState.taskUiDeltaSequence,
|
||||
metadata,
|
||||
})
|
||||
}
|
||||
|
||||
async cancelBackgroundCommand(): Promise<void> {
|
||||
const didCancel = await this.task?.cancelBackgroundCommand()
|
||||
if (!didCancel) {
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { EmptyRequest } from "@shared/proto/cline/common"
|
||||
import { TaskUiDeltaEvent } from "@shared/proto/cline/ui"
|
||||
import { strict as assert } from "assert"
|
||||
import { describe, it } from "mocha"
|
||||
import { registerTaskUiDeltaCallback, sendTaskUiDelta, subscribeToTaskUiDeltas } from "./subscribeToTaskUiDeltas"
|
||||
|
||||
describe("subscribeToTaskUiDeltas", () => {
|
||||
it("broadcasts serialized task deltas to active stream subscribers", async () => {
|
||||
const received: TaskUiDeltaEvent[] = []
|
||||
const callbackReceived: Array<{ type: string; sequence: number }> = []
|
||||
const unsubscribe = registerTaskUiDeltaCallback((delta) => {
|
||||
callbackReceived.push({ type: delta.type, sequence: delta.sequence })
|
||||
})
|
||||
|
||||
await subscribeToTaskUiDeltas({} as any, EmptyRequest.create({}), async (message) => {
|
||||
received.push(message)
|
||||
})
|
||||
|
||||
await sendTaskUiDelta({
|
||||
type: "message_updated",
|
||||
taskId: "task-1",
|
||||
sequence: 1,
|
||||
message: { ts: 123, type: "say", say: "text", text: "delta-text" },
|
||||
})
|
||||
const stats = await sendTaskUiDelta({
|
||||
type: "message_updated",
|
||||
taskId: "task-1",
|
||||
sequence: 2,
|
||||
message: { ts: 124, type: "say", say: "text", text: "delta-text-2" },
|
||||
})
|
||||
|
||||
assert.equal(received.length, 2)
|
||||
assert.ok(received[0]?.deltaJson)
|
||||
assert.ok(stats)
|
||||
assert.ok((stats?.payloadBytes ?? 0) > 0)
|
||||
assert.ok((stats?.broadcastDurationMs ?? -1) >= 0)
|
||||
assert.ok((stats?.streamSubscriberCount ?? 0) >= 1)
|
||||
assert.ok((stats?.callbackSubscriberCount ?? 0) >= 1)
|
||||
|
||||
const parsed = JSON.parse(received[0]!.deltaJson)
|
||||
assert.equal(parsed.type, "message_updated")
|
||||
assert.equal(parsed.taskId, "task-1")
|
||||
assert.equal(parsed.sequence, 1)
|
||||
assert.equal(parsed.message.text, "delta-text")
|
||||
const parsedSecond = JSON.parse(received[1]!.deltaJson)
|
||||
assert.equal(parsedSecond.sequence, 2)
|
||||
assert.equal(parsedSecond.message.text, "delta-text-2")
|
||||
assert.deepStrictEqual(callbackReceived, [
|
||||
{ type: "message_updated", sequence: 1 },
|
||||
{ type: "message_updated", sequence: 2 },
|
||||
])
|
||||
unsubscribe()
|
||||
})
|
||||
|
||||
it("removes stream subscribers that throw during delivery", async () => {
|
||||
const received: TaskUiDeltaEvent[] = []
|
||||
|
||||
await subscribeToTaskUiDeltas({} as any, EmptyRequest.create({}), async () => {
|
||||
throw new Error("stream disconnected")
|
||||
})
|
||||
|
||||
await subscribeToTaskUiDeltas({} as any, EmptyRequest.create({}), async (message) => {
|
||||
received.push(message)
|
||||
})
|
||||
|
||||
await sendTaskUiDelta({
|
||||
type: "task_state_resynced",
|
||||
taskId: "task-1",
|
||||
sequence: 1,
|
||||
})
|
||||
|
||||
await sendTaskUiDelta({
|
||||
type: "task_metadata_updated",
|
||||
taskId: "task-1",
|
||||
sequence: 2,
|
||||
metadata: { backgroundCommandRunning: true, backgroundCommandTaskId: "task-1" },
|
||||
})
|
||||
|
||||
assert.equal(received.length, 2)
|
||||
const first = JSON.parse(received[0]!.deltaJson)
|
||||
const second = JSON.parse(received[1]!.deltaJson)
|
||||
assert.equal(first.type, "task_state_resynced")
|
||||
assert.equal(second.type, "task_metadata_updated")
|
||||
assert.equal(second.metadata.backgroundCommandRunning, true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,82 @@
|
||||
import { EmptyRequest } from "@shared/proto/cline/common"
|
||||
import { TaskUiDeltaEvent } from "@shared/proto/cline/ui"
|
||||
import { telemetryService } from "@/services/telemetry"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import type { TaskUiDelta } from "@/shared/TaskUiDelta"
|
||||
import { getRequestRegistry, StreamingResponseHandler } from "../grpc-handler"
|
||||
import type { Controller } from "../index"
|
||||
|
||||
const activeTaskUiDeltaSubscriptions = new Set<StreamingResponseHandler<TaskUiDeltaEvent>>()
|
||||
export type TaskUiDeltaCallback = (delta: TaskUiDelta) => void
|
||||
const callbackSubscriptions = new Set<TaskUiDeltaCallback>()
|
||||
|
||||
export type TaskUiDeltaDeliveryStats = {
|
||||
payloadBytes: number
|
||||
broadcastDurationMs: number
|
||||
streamSubscriberCount: number
|
||||
callbackSubscriberCount: number
|
||||
}
|
||||
|
||||
export function registerTaskUiDeltaCallback(callback: TaskUiDeltaCallback): () => void {
|
||||
callbackSubscriptions.add(callback)
|
||||
return () => {
|
||||
callbackSubscriptions.delete(callback)
|
||||
}
|
||||
}
|
||||
|
||||
export async function subscribeToTaskUiDeltas(
|
||||
_controller: Controller,
|
||||
_request: EmptyRequest,
|
||||
responseStream: StreamingResponseHandler<TaskUiDeltaEvent>,
|
||||
requestId?: string,
|
||||
): Promise<void> {
|
||||
activeTaskUiDeltaSubscriptions.add(responseStream)
|
||||
|
||||
const cleanup = () => {
|
||||
activeTaskUiDeltaSubscriptions.delete(responseStream)
|
||||
}
|
||||
|
||||
if (requestId) {
|
||||
getRequestRegistry().registerRequest(requestId, cleanup, { type: "task_ui_delta_subscription" }, responseStream)
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendTaskUiDelta(delta: TaskUiDelta): Promise<TaskUiDeltaDeliveryStats | undefined> {
|
||||
let deltaJson: string
|
||||
try {
|
||||
deltaJson = JSON.stringify(delta)
|
||||
} catch (error) {
|
||||
Logger.error("Error serializing task UI delta:", error)
|
||||
return undefined
|
||||
}
|
||||
|
||||
const payloadBytes = Buffer.byteLength(deltaJson, "utf8")
|
||||
telemetryService.captureGrpcResponseSize(payloadBytes, "cline.UiService", "subscribeToTaskUiDeltas")
|
||||
const startedAt = performance.now()
|
||||
|
||||
const promises = Array.from(activeTaskUiDeltaSubscriptions).map(async (responseStream) => {
|
||||
try {
|
||||
await responseStream(TaskUiDeltaEvent.create({ deltaJson }), false)
|
||||
} catch (error) {
|
||||
Logger.error("Error sending task UI delta:", error)
|
||||
activeTaskUiDeltaSubscriptions.delete(responseStream)
|
||||
}
|
||||
})
|
||||
|
||||
for (const callback of callbackSubscriptions) {
|
||||
try {
|
||||
callback(delta)
|
||||
} catch (error) {
|
||||
Logger.error("Error sending task UI delta to callback subscriber:", error)
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(promises)
|
||||
|
||||
return {
|
||||
payloadBytes,
|
||||
broadcastDurationMs: Math.max(0, performance.now() - startedAt),
|
||||
streamSubscriberCount: activeTaskUiDeltaSubscriptions.size,
|
||||
callbackSubscriberCount: callbackSubscriptions.size,
|
||||
}
|
||||
}
|
||||
@@ -62,6 +62,7 @@ export class TaskState {
|
||||
apiRequestsSinceLastTodoUpdate = 0
|
||||
currentFocusChainChecklist: string | null = null
|
||||
todoListWasUpdatedByUser = false
|
||||
taskUiDeltaSequence = 0
|
||||
|
||||
// Task Abort / Cancellation
|
||||
abort = false
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { strict as assert } from "assert"
|
||||
import { afterEach, describe, it } from "mocha"
|
||||
import {
|
||||
getEnvironmentDetailsStaticCacheTtlMs,
|
||||
getPresentationCadenceMs,
|
||||
getRequestBoundaryCacheTtlMs,
|
||||
getStateUpdateCadenceMs,
|
||||
getUsageUpdateCadenceMs,
|
||||
isEphemeralMessagePersistenceDisabled,
|
||||
isPresentationSchedulingDisabled,
|
||||
isRemoteWorkspaceEnvironment,
|
||||
isTaskUiDeltaSyncDisabled,
|
||||
shouldWaitForTerminalCooldown,
|
||||
summarizeChunkToWebviewDelays,
|
||||
} from "../latency"
|
||||
|
||||
describe("task latency helpers", () => {
|
||||
afterEach(() => {
|
||||
delete process.env.CLINE_PRESENTATION_CADENCE_MS
|
||||
delete process.env.CLINE_REMOTE_PRESENTATION_CADENCE_MS
|
||||
delete process.env.CLINE_STATE_UPDATE_CADENCE_MS
|
||||
delete process.env.CLINE_REMOTE_STATE_UPDATE_CADENCE_MS
|
||||
delete process.env.CLINE_USAGE_UPDATE_CADENCE_MS
|
||||
delete process.env.CLINE_REMOTE_USAGE_UPDATE_CADENCE_MS
|
||||
delete process.env.CLINE_REQUEST_BOUNDARY_CACHE_TTL_MS
|
||||
delete process.env.CLINE_REMOTE_REQUEST_BOUNDARY_CACHE_TTL_MS
|
||||
delete process.env.CLINE_ENVIRONMENT_DETAILS_STATIC_CACHE_TTL_MS
|
||||
delete process.env.CLINE_REMOTE_ENVIRONMENT_DETAILS_STATIC_CACHE_TTL_MS
|
||||
delete process.env.CLINE_DISABLE_PRESENTATION_SCHEDULER
|
||||
delete process.env.CLINE_DISABLE_EPHEMERAL_MESSAGE_PERSISTENCE
|
||||
delete process.env.CLINE_DISABLE_TASK_UI_DELTA_SYNC
|
||||
})
|
||||
|
||||
it("detects remote workspaces from remoteName, platform, and version metadata", () => {
|
||||
assert.equal(isRemoteWorkspaceEnvironment({ remoteName: "ssh-remote" }), true)
|
||||
assert.equal(isRemoteWorkspaceEnvironment({ platform: "VS Code Remote" }), true)
|
||||
assert.equal(isRemoteWorkspaceEnvironment({ version: "Remote Server 1.0" }), true)
|
||||
assert.equal(isRemoteWorkspaceEnvironment({ platform: "darwin", version: "1.0.0", remoteName: null }), false)
|
||||
})
|
||||
|
||||
it("uses remote-aware presentation and state update cadences", () => {
|
||||
assert.equal(getPresentationCadenceMs(false, "immediate"), 0)
|
||||
assert.equal(getPresentationCadenceMs(false, "normal"), 40)
|
||||
assert.equal(getPresentationCadenceMs(true, "normal"), 90)
|
||||
assert.equal(getPresentationCadenceMs(true, "low"), 125)
|
||||
|
||||
assert.equal(getStateUpdateCadenceMs(false, "immediate"), 0)
|
||||
assert.equal(getStateUpdateCadenceMs(false, "normal"), 16)
|
||||
assert.equal(getStateUpdateCadenceMs(true, "normal"), 110)
|
||||
assert.equal(getStateUpdateCadenceMs(true, "low"), 150)
|
||||
assert.equal(getUsageUpdateCadenceMs(false), 250)
|
||||
assert.equal(getUsageUpdateCadenceMs(true), 400)
|
||||
assert.equal(getRequestBoundaryCacheTtlMs(false), 500)
|
||||
assert.equal(getRequestBoundaryCacheTtlMs(true), 1000)
|
||||
assert.equal(getEnvironmentDetailsStaticCacheTtlMs(false), 30_000)
|
||||
assert.equal(getEnvironmentDetailsStaticCacheTtlMs(true), 60_000)
|
||||
})
|
||||
|
||||
it("respects cadence overrides from environment variables", () => {
|
||||
process.env.CLINE_PRESENTATION_CADENCE_MS = "22"
|
||||
process.env.CLINE_REMOTE_PRESENTATION_CADENCE_MS = "77"
|
||||
process.env.CLINE_STATE_UPDATE_CADENCE_MS = "18"
|
||||
process.env.CLINE_REMOTE_STATE_UPDATE_CADENCE_MS = "99"
|
||||
process.env.CLINE_USAGE_UPDATE_CADENCE_MS = "333"
|
||||
process.env.CLINE_REMOTE_USAGE_UPDATE_CADENCE_MS = "555"
|
||||
process.env.CLINE_REQUEST_BOUNDARY_CACHE_TTL_MS = "444"
|
||||
process.env.CLINE_REMOTE_REQUEST_BOUNDARY_CACHE_TTL_MS = "888"
|
||||
process.env.CLINE_ENVIRONMENT_DETAILS_STATIC_CACHE_TTL_MS = "1234"
|
||||
process.env.CLINE_REMOTE_ENVIRONMENT_DETAILS_STATIC_CACHE_TTL_MS = "5678"
|
||||
|
||||
assert.equal(getPresentationCadenceMs(false, "normal"), 22)
|
||||
assert.equal(getPresentationCadenceMs(true, "normal"), 77)
|
||||
assert.equal(getStateUpdateCadenceMs(false, "normal"), 18)
|
||||
assert.equal(getStateUpdateCadenceMs(true, "normal"), 99)
|
||||
assert.equal(getUsageUpdateCadenceMs(false), 333)
|
||||
assert.equal(getUsageUpdateCadenceMs(true), 555)
|
||||
assert.equal(getRequestBoundaryCacheTtlMs(false), 444)
|
||||
assert.equal(getRequestBoundaryCacheTtlMs(true), 888)
|
||||
assert.equal(getEnvironmentDetailsStaticCacheTtlMs(false), 1234)
|
||||
assert.equal(getEnvironmentDetailsStaticCacheTtlMs(true), 5678)
|
||||
})
|
||||
|
||||
it("supports development flags for disabling schedulers and delta sync", () => {
|
||||
process.env.CLINE_DISABLE_PRESENTATION_SCHEDULER = "true"
|
||||
process.env.CLINE_DISABLE_EPHEMERAL_MESSAGE_PERSISTENCE = "1"
|
||||
process.env.CLINE_DISABLE_TASK_UI_DELTA_SYNC = "yes"
|
||||
|
||||
assert.equal(isPresentationSchedulingDisabled(), true)
|
||||
assert.equal(isEphemeralMessagePersistenceDisabled(), true)
|
||||
assert.equal(isTaskUiDeltaSyncDisabled(), true)
|
||||
})
|
||||
|
||||
it("waits for terminal cooldown only when there is active heat or a recent edit", () => {
|
||||
assert.equal(
|
||||
shouldWaitForTerminalCooldown({
|
||||
busyTerminalIds: [],
|
||||
isProcessHot: () => true,
|
||||
didEditFile: false,
|
||||
}),
|
||||
false,
|
||||
)
|
||||
|
||||
assert.equal(
|
||||
shouldWaitForTerminalCooldown({
|
||||
busyTerminalIds: [1, 2],
|
||||
isProcessHot: () => false,
|
||||
didEditFile: false,
|
||||
}),
|
||||
false,
|
||||
)
|
||||
|
||||
assert.equal(
|
||||
shouldWaitForTerminalCooldown({
|
||||
busyTerminalIds: [1, 2],
|
||||
isProcessHot: (terminalId) => terminalId === 2,
|
||||
didEditFile: false,
|
||||
}),
|
||||
true,
|
||||
)
|
||||
|
||||
assert.equal(
|
||||
shouldWaitForTerminalCooldown({
|
||||
busyTerminalIds: [1],
|
||||
isProcessHot: () => false,
|
||||
didEditFile: true,
|
||||
}),
|
||||
true,
|
||||
)
|
||||
})
|
||||
|
||||
it("summarizes chunk-to-webview delays with median and p95 percentiles", () => {
|
||||
assert.deepStrictEqual(summarizeChunkToWebviewDelays([]), { medianMs: 0, p95Ms: 0 })
|
||||
assert.deepStrictEqual(summarizeChunkToWebviewDelays([10, 20, 30, 40, 50]), { medianMs: 30, p95Ms: 50 })
|
||||
assert.deepStrictEqual(summarizeChunkToWebviewDelays([5, 15, 25, 35]), { medianMs: 15, p95Ms: 35 })
|
||||
})
|
||||
})
|
||||
@@ -24,6 +24,7 @@ export interface FocusChainDependencies {
|
||||
mode: Mode
|
||||
stateManager: StateManager
|
||||
postStateToWebview: () => Promise<void>
|
||||
postTaskMetadataDelta: (metadata: { currentFocusChainChecklist?: string | null }) => Promise<void>
|
||||
say: (type: ClineSay, text?: string, images?: string[], files?: string[], partial?: boolean) => Promise<number | undefined>
|
||||
focusChainSettings: FocusChainSettings
|
||||
}
|
||||
@@ -33,6 +34,7 @@ export class FocusChainManager {
|
||||
private taskState: TaskState
|
||||
private stateManager: StateManager
|
||||
private postStateToWebview: () => Promise<void>
|
||||
private postTaskMetadataDelta: (metadata: { currentFocusChainChecklist?: string | null }) => Promise<void>
|
||||
private say: (
|
||||
type: ClineSay,
|
||||
text?: string,
|
||||
@@ -50,6 +52,7 @@ export class FocusChainManager {
|
||||
this.taskState = dependencies.taskState
|
||||
this.stateManager = dependencies.stateManager
|
||||
this.postStateToWebview = dependencies.postStateToWebview
|
||||
this.postTaskMetadataDelta = dependencies.postTaskMetadataDelta
|
||||
this.say = dependencies.say
|
||||
this.focusChainSettings = dependencies.focusChainSettings
|
||||
}
|
||||
@@ -85,7 +88,7 @@ export class FocusChainManager {
|
||||
})
|
||||
.on("unlink", async () => {
|
||||
this.taskState.currentFocusChainChecklist = null
|
||||
await this.postStateToWebview()
|
||||
await this.postTaskMetadataDelta({ currentFocusChainChecklist: null })
|
||||
})
|
||||
.on("error", (error) => {
|
||||
Logger.error(`[Task ${this.taskId}] Failed to watch focus chain file:`, error)
|
||||
@@ -120,7 +123,7 @@ export class FocusChainManager {
|
||||
this.taskState.currentFocusChainChecklist = markdownTodoList
|
||||
this.taskState.todoListWasUpdatedByUser = true
|
||||
|
||||
await this.postStateToWebview()
|
||||
await this.postTaskMetadataDelta({ currentFocusChainChecklist: markdownTodoList })
|
||||
telemetryService.captureFocusChainListWritten(this.taskId)
|
||||
} else {
|
||||
Logger.log(
|
||||
@@ -301,12 +304,14 @@ export class FocusChainManager {
|
||||
// Write the model's update to the markdown file
|
||||
try {
|
||||
await this.writeFocusChainToDisk(taskProgress.trim())
|
||||
await this.postTaskMetadataDelta({ currentFocusChainChecklist: taskProgress.trim() })
|
||||
|
||||
// Send the task_progress message to the UI immediately
|
||||
await this.say("task_progress", taskProgress.trim())
|
||||
} catch (error) {
|
||||
Logger.error(`[Task ${this.taskId}] focus chain list: Failed to write to markdown file:`, error)
|
||||
// Fall back to creating a task_progress message directly if file write fails
|
||||
await this.postTaskMetadataDelta({ currentFocusChainChecklist: taskProgress.trim() })
|
||||
await this.say("task_progress", taskProgress.trim())
|
||||
Logger.log(`[Task ${this.taskId}] focus chain list: Sent fallback task_progress message to UI`)
|
||||
}
|
||||
@@ -316,6 +321,7 @@ export class FocusChainManager {
|
||||
if (markdownTodoList) {
|
||||
const _previousList = this.taskState.currentFocusChainChecklist
|
||||
this.taskState.currentFocusChainChecklist = markdownTodoList
|
||||
await this.postTaskMetadataDelta({ currentFocusChainChecklist: markdownTodoList })
|
||||
|
||||
// Create a task_progress message to display the focus chain list in the UI
|
||||
await this.say("task_progress", markdownTodoList)
|
||||
|
||||
+22
-13
@@ -114,6 +114,7 @@ import { Controller } from "../controller"
|
||||
import { executeHook } from "../hooks/hook-executor"
|
||||
import { StateManager } from "../storage/StateManager"
|
||||
import { FocusChainManager } from "./focus-chain"
|
||||
import { isTaskUiDeltaSyncDisabled } from "./latency"
|
||||
import { MessageStateHandler } from "./message-state"
|
||||
import { StreamChunkCoordinator } from "./StreamChunkCoordinator"
|
||||
import { StreamResponseHandler } from "./StreamResponseHandler"
|
||||
@@ -256,6 +257,7 @@ export class Task {
|
||||
|
||||
// Command executor for running shell commands (extracted from executeCommandTool)
|
||||
private commandExecutor!: CommandExecutor
|
||||
private readonly taskUiDeltaSyncDisabled = isTaskUiDeltaSyncDisabled()
|
||||
|
||||
constructor(params: TaskParams) {
|
||||
const {
|
||||
@@ -368,6 +370,7 @@ export class Task {
|
||||
mode: this.stateManager.getGlobalSettingsKey("mode"),
|
||||
stateManager: this.stateManager,
|
||||
postStateToWebview: this.postStateToWebview,
|
||||
postTaskMetadataDelta: (metadata) => this.controller.postTaskMetadataDelta(metadata, this.taskId),
|
||||
say: this.say.bind(this),
|
||||
focusChainSettings: focusChainSettings,
|
||||
})
|
||||
@@ -598,7 +601,7 @@ export class Task {
|
||||
if (partial) {
|
||||
if (isUpdatingPreviousPartial) {
|
||||
// existing partial message, so update it
|
||||
await this.messageStateHandler.updateClineMessage(lastMessageIndex, {
|
||||
await this.messageStateHandler.updateClineMessageEphemeral(lastMessageIndex, {
|
||||
text,
|
||||
partial,
|
||||
})
|
||||
@@ -615,14 +618,14 @@ export class Task {
|
||||
// this.askResponseImages = undefined
|
||||
askTs = Date.now()
|
||||
this.taskState.lastMessageTs = askTs
|
||||
await this.messageStateHandler.addToClineMessages({
|
||||
await this.messageStateHandler.addToClineMessagesEphemeral({
|
||||
ts: askTs,
|
||||
type: "ask",
|
||||
ask: type,
|
||||
text,
|
||||
partial,
|
||||
})
|
||||
await this.postStateToWebview()
|
||||
await this.postStateToWebviewIfDeltaSyncDisabled()
|
||||
throw new Error("Current ask promise was ignored 2")
|
||||
}
|
||||
// partial=false means its a complete version of a previously partial message
|
||||
@@ -663,7 +666,7 @@ export class Task {
|
||||
ask: type,
|
||||
text,
|
||||
})
|
||||
await this.postStateToWebview()
|
||||
await this.postStateToWebviewIfDeltaSyncDisabled()
|
||||
}
|
||||
} else {
|
||||
// this is a new non-partial message, so add it like normal
|
||||
@@ -680,7 +683,7 @@ export class Task {
|
||||
ask: type,
|
||||
text,
|
||||
})
|
||||
await this.postStateToWebview()
|
||||
await this.postStateToWebviewIfDeltaSyncDisabled()
|
||||
}
|
||||
|
||||
if (type !== "command_output") {
|
||||
@@ -779,7 +782,7 @@ export class Task {
|
||||
if (isUpdatingPreviousPartial) {
|
||||
// existing partial message, so update it
|
||||
const lastIndex = this.messageStateHandler.getClineMessages().length - 1
|
||||
await this.messageStateHandler.updateClineMessage(lastIndex, {
|
||||
await this.messageStateHandler.updateClineMessageEphemeral(lastIndex, {
|
||||
text,
|
||||
images,
|
||||
files,
|
||||
@@ -793,7 +796,7 @@ export class Task {
|
||||
// this is a new partial message, so add it with partial state
|
||||
const sayTs = Date.now()
|
||||
this.taskState.lastMessageTs = sayTs
|
||||
await this.messageStateHandler.addToClineMessages({
|
||||
await this.messageStateHandler.addToClineMessagesEphemeral({
|
||||
ts: sayTs,
|
||||
type: "say",
|
||||
say: type,
|
||||
@@ -803,7 +806,7 @@ export class Task {
|
||||
partial,
|
||||
modelInfo,
|
||||
})
|
||||
await this.postStateToWebview()
|
||||
await this.postStateToWebviewIfDeltaSyncDisabled()
|
||||
return sayTs
|
||||
}
|
||||
// partial=false means its a complete version of a previously partial message
|
||||
@@ -836,7 +839,7 @@ export class Task {
|
||||
files,
|
||||
modelInfo,
|
||||
})
|
||||
await this.postStateToWebview()
|
||||
await this.postStateToWebviewIfDeltaSyncDisabled()
|
||||
return sayTs
|
||||
}
|
||||
// this is a new non-partial message, so add it like normal
|
||||
@@ -851,7 +854,7 @@ export class Task {
|
||||
files,
|
||||
modelInfo,
|
||||
})
|
||||
await this.postStateToWebview()
|
||||
await this.postStateToWebviewIfDeltaSyncDisabled()
|
||||
return sayTs
|
||||
}
|
||||
|
||||
@@ -1673,6 +1676,12 @@ export class Task {
|
||||
return { model, providerId, customPrompt, mode }
|
||||
}
|
||||
|
||||
private async postStateToWebviewIfDeltaSyncDisabled(): Promise<void> {
|
||||
if (this.taskUiDeltaSyncDisabled) {
|
||||
await this.postStateToWebview()
|
||||
}
|
||||
}
|
||||
|
||||
private async writePromptMetadataArtifacts(params: { systemPrompt: string; providerInfo: ApiProviderInfo }): Promise<void> {
|
||||
const enabledFlag = process.env.CLINE_WRITE_PROMPT_ARTIFACTS?.toLowerCase()
|
||||
const enabled = enabledFlag === "1" || enabledFlag === "true" || enabledFlag === "yes"
|
||||
@@ -2555,7 +2564,7 @@ export class Task {
|
||||
request: userContent.map((block) => formatContentBlockToMarkdown(block)).join("\n\n"),
|
||||
} satisfies ClineApiReqInfo),
|
||||
})
|
||||
await this.postStateToWebview()
|
||||
await this.postStateToWebviewIfDeltaSyncDisabled()
|
||||
|
||||
try {
|
||||
const taskMetrics: {
|
||||
@@ -2605,7 +2614,7 @@ export class Task {
|
||||
}
|
||||
|
||||
await updateApiReqMsgFromMetrics()
|
||||
await this.postStateToWebview()
|
||||
await this.postStateToWebviewIfDeltaSyncDisabled()
|
||||
await telemetryService.captureTokenUsage(
|
||||
this.ulid,
|
||||
usageInputTokens,
|
||||
@@ -2978,7 +2987,7 @@ export class Task {
|
||||
// Update the api_req_started message with final usage and cost details
|
||||
await finalizeApiReqMsg()
|
||||
await this.messageStateHandler.saveClineMessagesAndUpdateHistory()
|
||||
await this.postStateToWebview()
|
||||
await this.postStateToWebviewIfDeltaSyncDisabled()
|
||||
|
||||
// need to call here in case the stream was aborted
|
||||
if (this.taskState.abort) {
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
export type PresentationPriority = "immediate" | "normal" | "low"
|
||||
|
||||
export type TaskLatencyTrigger = "text" | "reasoning" | "tool" | "finalization" | "other"
|
||||
|
||||
function readBooleanEnv(envVarName: string): boolean {
|
||||
const rawValue = process.env[envVarName]?.toLowerCase()
|
||||
return rawValue === "1" || rawValue === "true" || rawValue === "yes"
|
||||
}
|
||||
|
||||
function readCadenceOverride(envVarName: string): number | undefined {
|
||||
const rawValue = process.env[envVarName]
|
||||
if (!rawValue) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const parsed = Number.parseInt(rawValue, 10)
|
||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return parsed
|
||||
}
|
||||
|
||||
function getCadenceOverride(args: { isRemoteWorkspace: boolean; localEnvVar: string; remoteEnvVar: string }): number | undefined {
|
||||
return args.isRemoteWorkspace ? readCadenceOverride(args.remoteEnvVar) : readCadenceOverride(args.localEnvVar)
|
||||
}
|
||||
|
||||
export function isRemoteWorkspaceEnvironment(host: { platform?: string; version?: string; remoteName?: string | null }): boolean {
|
||||
if (host.remoteName) {
|
||||
return true
|
||||
}
|
||||
|
||||
const platform = host.platform?.toLowerCase() ?? ""
|
||||
const version = host.version?.toLowerCase() ?? ""
|
||||
return platform.includes("remote") || version.includes("remote")
|
||||
}
|
||||
|
||||
export function isPresentationSchedulingDisabled(): boolean {
|
||||
return readBooleanEnv("CLINE_DISABLE_PRESENTATION_SCHEDULER")
|
||||
}
|
||||
|
||||
export function isEphemeralMessagePersistenceDisabled(): boolean {
|
||||
return readBooleanEnv("CLINE_DISABLE_EPHEMERAL_MESSAGE_PERSISTENCE")
|
||||
}
|
||||
|
||||
export function isTaskUiDeltaSyncDisabled(): boolean {
|
||||
return readBooleanEnv("CLINE_DISABLE_TASK_UI_DELTA_SYNC")
|
||||
}
|
||||
|
||||
export function getPresentationCadenceMs(isRemoteWorkspace: boolean, priority: PresentationPriority): number {
|
||||
if (priority === "immediate") {
|
||||
return 0
|
||||
}
|
||||
|
||||
const override = getCadenceOverride({
|
||||
isRemoteWorkspace,
|
||||
localEnvVar: priority === "low" ? "CLINE_PRESENTATION_LOW_CADENCE_MS" : "CLINE_PRESENTATION_CADENCE_MS",
|
||||
remoteEnvVar: priority === "low" ? "CLINE_REMOTE_PRESENTATION_LOW_CADENCE_MS" : "CLINE_REMOTE_PRESENTATION_CADENCE_MS",
|
||||
})
|
||||
if (override !== undefined) {
|
||||
return override
|
||||
}
|
||||
|
||||
if (priority === "low") {
|
||||
return isRemoteWorkspace ? 125 : 50
|
||||
}
|
||||
|
||||
return isRemoteWorkspace ? 90 : 40
|
||||
}
|
||||
|
||||
export function getStateUpdateCadenceMs(isRemoteWorkspace: boolean, priority: PresentationPriority): number {
|
||||
if (priority === "immediate") {
|
||||
return 0
|
||||
}
|
||||
|
||||
const override = getCadenceOverride({
|
||||
isRemoteWorkspace,
|
||||
localEnvVar: priority === "low" ? "CLINE_STATE_UPDATE_LOW_CADENCE_MS" : "CLINE_STATE_UPDATE_CADENCE_MS",
|
||||
remoteEnvVar: priority === "low" ? "CLINE_REMOTE_STATE_UPDATE_LOW_CADENCE_MS" : "CLINE_REMOTE_STATE_UPDATE_CADENCE_MS",
|
||||
})
|
||||
if (override !== undefined) {
|
||||
return override
|
||||
}
|
||||
|
||||
if (priority === "low") {
|
||||
return isRemoteWorkspace ? 150 : 40
|
||||
}
|
||||
|
||||
return isRemoteWorkspace ? 110 : 16
|
||||
}
|
||||
|
||||
export function getUsageUpdateCadenceMs(isRemoteWorkspace: boolean): number {
|
||||
const override = getCadenceOverride({
|
||||
isRemoteWorkspace,
|
||||
localEnvVar: "CLINE_USAGE_UPDATE_CADENCE_MS",
|
||||
remoteEnvVar: "CLINE_REMOTE_USAGE_UPDATE_CADENCE_MS",
|
||||
})
|
||||
if (override !== undefined) {
|
||||
return override
|
||||
}
|
||||
|
||||
return isRemoteWorkspace ? 400 : 250
|
||||
}
|
||||
|
||||
export function getRequestBoundaryCacheTtlMs(isRemoteWorkspace: boolean): number {
|
||||
const override = getCadenceOverride({
|
||||
isRemoteWorkspace,
|
||||
localEnvVar: "CLINE_REQUEST_BOUNDARY_CACHE_TTL_MS",
|
||||
remoteEnvVar: "CLINE_REMOTE_REQUEST_BOUNDARY_CACHE_TTL_MS",
|
||||
})
|
||||
if (override !== undefined) {
|
||||
return override
|
||||
}
|
||||
|
||||
return isRemoteWorkspace ? 1000 : 500
|
||||
}
|
||||
|
||||
export function getEnvironmentDetailsStaticCacheTtlMs(isRemoteWorkspace: boolean): number {
|
||||
const override = getCadenceOverride({
|
||||
isRemoteWorkspace,
|
||||
localEnvVar: "CLINE_ENVIRONMENT_DETAILS_STATIC_CACHE_TTL_MS",
|
||||
remoteEnvVar: "CLINE_REMOTE_ENVIRONMENT_DETAILS_STATIC_CACHE_TTL_MS",
|
||||
})
|
||||
if (override !== undefined) {
|
||||
return override
|
||||
}
|
||||
|
||||
return isRemoteWorkspace ? 60_000 : 30_000
|
||||
}
|
||||
|
||||
export function shouldWaitForTerminalCooldown(args: {
|
||||
busyTerminalIds: number[]
|
||||
isProcessHot: (terminalId: number) => boolean
|
||||
didEditFile: boolean
|
||||
}): boolean {
|
||||
if (args.busyTerminalIds.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (args.didEditFile) {
|
||||
return true
|
||||
}
|
||||
|
||||
return args.busyTerminalIds.some((terminalId) => {
|
||||
try {
|
||||
return args.isProcessHot(terminalId)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function summarizeChunkToWebviewDelays(delaysMs: number[]): { medianMs: number; p95Ms: number } {
|
||||
if (delaysMs.length === 0) {
|
||||
return { medianMs: 0, p95Ms: 0 }
|
||||
}
|
||||
|
||||
const sorted = [...delaysMs].sort((a, b) => a - b)
|
||||
const percentile = (ratio: number) => sorted[Math.min(sorted.length - 1, Math.max(0, Math.ceil(sorted.length * ratio) - 1))]
|
||||
return {
|
||||
medianMs: percentile(0.5),
|
||||
p95Ms: percentile(0.95),
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,9 @@ import { HistoryItem } from "@/shared/HistoryItem"
|
||||
import { ClineStorageMessage } from "@/shared/messages/content"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { getCwd, getDesktopDir } from "@/utils/path"
|
||||
import { sendTaskUiDelta } from "../controller/ui/subscribeToTaskUiDeltas"
|
||||
import { ensureTaskDirectoryExists, saveApiConversationHistory, saveClineMessages } from "../storage/disk"
|
||||
import { isTaskUiDeltaSyncDisabled } from "./latency"
|
||||
import { TaskState } from "./TaskState"
|
||||
|
||||
// Event types for clineMessages changes
|
||||
@@ -48,12 +50,14 @@ interface MessageStateHandlerParams {
|
||||
export class MessageStateHandler extends EventEmitter<MessageStateHandlerEvents> {
|
||||
private apiConversationHistory: ClineStorageMessage[] = []
|
||||
private clineMessages: ClineMessage[] = []
|
||||
private hasDirtyEphemeralChanges = false
|
||||
private taskIsFavorited: boolean
|
||||
private checkpointTracker: CheckpointTracker | undefined
|
||||
private updateTaskHistory: (historyItem: HistoryItem) => Promise<HistoryItem[]>
|
||||
private taskId: string
|
||||
private ulid: string
|
||||
private taskState: TaskState
|
||||
private readonly taskUiDeltaSyncDisabled = isTaskUiDeltaSyncDisabled()
|
||||
|
||||
// Mutex to prevent concurrent state modifications (RC-4)
|
||||
// Protects against data loss from race conditions when multiple
|
||||
@@ -75,6 +79,31 @@ export class MessageStateHandler extends EventEmitter<MessageStateHandlerEvents>
|
||||
*/
|
||||
private emitClineMessagesChanged(change: ClineMessageChange): void {
|
||||
this.emit("clineMessagesChanged", change)
|
||||
if (!this.taskUiDeltaSyncDisabled) {
|
||||
void this.emitTaskUiDeltaForChange(change)
|
||||
}
|
||||
}
|
||||
|
||||
private async emitTaskUiDeltaForChange(change: ClineMessageChange): Promise<void> {
|
||||
const sequence = ++this.taskState.taskUiDeltaSequence
|
||||
if (change.type === "add" && change.message) {
|
||||
await sendTaskUiDelta({ type: "message_added", taskId: this.taskId, sequence, message: change.message })
|
||||
return
|
||||
}
|
||||
|
||||
if (change.type === "update" && change.message) {
|
||||
await sendTaskUiDelta({ type: "message_updated", taskId: this.taskId, sequence, message: change.message })
|
||||
return
|
||||
}
|
||||
|
||||
if (change.type === "delete" && change.previousMessage) {
|
||||
await sendTaskUiDelta({ type: "message_deleted", taskId: this.taskId, sequence, messageTs: change.previousMessage.ts })
|
||||
return
|
||||
}
|
||||
|
||||
if (change.type === "set") {
|
||||
await sendTaskUiDelta({ type: "task_state_resynced", taskId: this.taskId, sequence })
|
||||
}
|
||||
}
|
||||
|
||||
setCheckpointTracker(tracker: CheckpointTracker | undefined) {
|
||||
@@ -105,6 +134,7 @@ export class MessageStateHandler extends EventEmitter<MessageStateHandlerEvents>
|
||||
setClineMessages(newMessages: ClineMessage[]) {
|
||||
const previousMessages = this.clineMessages
|
||||
this.clineMessages = newMessages
|
||||
this.hasDirtyEphemeralChanges = true
|
||||
this.emitClineMessagesChanged({
|
||||
type: "set",
|
||||
messages: this.clineMessages,
|
||||
@@ -183,6 +213,22 @@ export class MessageStateHandler extends EventEmitter<MessageStateHandlerEvents>
|
||||
})
|
||||
}
|
||||
|
||||
async addToClineMessagesEphemeral(message: ClineMessage) {
|
||||
return await this.withStateLock(async () => {
|
||||
message.conversationHistoryIndex = this.apiConversationHistory.length - 1
|
||||
message.conversationHistoryDeletedRange = this.taskState.conversationHistoryDeletedRange
|
||||
const index = this.clineMessages.length
|
||||
this.clineMessages.push(message)
|
||||
this.hasDirtyEphemeralChanges = true
|
||||
this.emitClineMessagesChanged({
|
||||
type: "add",
|
||||
messages: this.clineMessages,
|
||||
index,
|
||||
message,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async overwriteApiConversationHistory(newHistory: ClineStorageMessage[]): Promise<void> {
|
||||
// Protect with mutex to prevent concurrent modifications from corrupting data (RC-4)
|
||||
return await this.withStateLock(async () => {
|
||||
@@ -223,6 +269,7 @@ export class MessageStateHandler extends EventEmitter<MessageStateHandlerEvents>
|
||||
return await this.withStateLock(async () => {
|
||||
const previousMessages = this.clineMessages
|
||||
this.clineMessages = newMessages
|
||||
this.hasDirtyEphemeralChanges = true
|
||||
this.emitClineMessagesChanged({
|
||||
type: "set",
|
||||
messages: this.clineMessages,
|
||||
@@ -261,6 +308,26 @@ export class MessageStateHandler extends EventEmitter<MessageStateHandlerEvents>
|
||||
})
|
||||
}
|
||||
|
||||
async updateClineMessageEphemeral(index: number, updates: Partial<ClineMessage>): Promise<void> {
|
||||
return await this.withStateLock(async () => {
|
||||
if (index < 0 || index >= this.clineMessages.length) {
|
||||
throw new Error(`Invalid message index: ${index}`)
|
||||
}
|
||||
|
||||
const previousMessage = { ...this.clineMessages[index] }
|
||||
Object.assign(this.clineMessages[index], updates)
|
||||
this.hasDirtyEphemeralChanges = true
|
||||
|
||||
this.emitClineMessagesChanged({
|
||||
type: "update",
|
||||
messages: this.clineMessages,
|
||||
index,
|
||||
previousMessage,
|
||||
message: this.clineMessages[index],
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a specific message from the clineMessages array
|
||||
* The entire operation (validate, delete, save) is atomic to prevent races (RC-4)
|
||||
@@ -288,4 +355,13 @@ export class MessageStateHandler extends EventEmitter<MessageStateHandlerEvents>
|
||||
await this.saveClineMessagesAndUpdateHistoryInternal()
|
||||
})
|
||||
}
|
||||
|
||||
async flushClineMessagesAndUpdateHistory(): Promise<void> {
|
||||
return await this.withStateLock(async () => {
|
||||
if (!this.hasDirtyEphemeralChanges) {
|
||||
return
|
||||
}
|
||||
await this.saveClineMessagesAndUpdateHistoryInternal()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { strict as assert } from "assert"
|
||||
import { describe, it } from "mocha"
|
||||
import { compareTaskLatencySummaries, summarizeTaskLatencyEvents } from "../taskLatencySummary"
|
||||
|
||||
describe("taskLatencySummary", () => {
|
||||
it("summarizes averages and ranges across latency events", () => {
|
||||
const summary = summarizeTaskLatencyEvents([
|
||||
{
|
||||
ulid: "task-1",
|
||||
requestIndex: 1,
|
||||
presentationInvocationCount: 2,
|
||||
partialMessageCount: 4,
|
||||
statePostCount: 1,
|
||||
statePostSerializedBytes: 100,
|
||||
persistenceFlushCount: 1,
|
||||
chunkToWebviewMedianMs: 20,
|
||||
chunkToWebviewP95Ms: 35,
|
||||
taskInitializationDurationMs: 600,
|
||||
},
|
||||
{
|
||||
ulid: "task-1",
|
||||
requestIndex: 2,
|
||||
presentationInvocationCount: 4,
|
||||
partialMessageCount: 6,
|
||||
statePostCount: 3,
|
||||
statePostSerializedBytes: 300,
|
||||
persistenceFlushCount: 2,
|
||||
chunkToWebviewMedianMs: 30,
|
||||
chunkToWebviewP95Ms: 45,
|
||||
taskInitializationDurationMs: 900,
|
||||
},
|
||||
])
|
||||
|
||||
assert.equal(summary.eventCount, 2)
|
||||
assert.equal(summary.requestCount, 2)
|
||||
assert.deepStrictEqual(summary.metrics.presentationInvocationCount, { average: 3, min: 2, max: 4 })
|
||||
assert.deepStrictEqual(summary.metrics.statePostSerializedBytes, { average: 200, min: 100, max: 300 })
|
||||
assert.deepStrictEqual(summary.metrics.chunkToWebviewP95Ms, { average: 40, min: 35, max: 45 })
|
||||
assert.deepStrictEqual(summary.metrics.taskInitializationDurationMs, { average: 750, min: 600, max: 900 })
|
||||
})
|
||||
|
||||
it("returns zeroed summaries when events are empty or metrics are absent", () => {
|
||||
const summary = summarizeTaskLatencyEvents([{ ulid: "task-1", requestIndex: 1 }])
|
||||
assert.equal(summary.eventCount, 1)
|
||||
assert.equal(summary.requestCount, 1)
|
||||
assert.deepStrictEqual(summary.metrics.partialMessageCount, { average: 0, min: 0, max: 0 })
|
||||
|
||||
const empty = summarizeTaskLatencyEvents([])
|
||||
assert.equal(empty.eventCount, 0)
|
||||
assert.equal(empty.requestCount, 0)
|
||||
assert.deepStrictEqual(empty.metrics.statePostCount, { average: 0, min: 0, max: 0 })
|
||||
})
|
||||
|
||||
it("compares latency summaries for before/after analysis", () => {
|
||||
const baseline = summarizeTaskLatencyEvents([{ ulid: "task", requestIndex: 1, presentationInvocationCount: 5, statePostCount: 4 }])
|
||||
const candidate = summarizeTaskLatencyEvents([{ ulid: "task", requestIndex: 1, presentationInvocationCount: 3, statePostCount: 2 }])
|
||||
|
||||
const comparison = compareTaskLatencySummaries(baseline, candidate)
|
||||
assert.equal(comparison.baselineEvents, 1)
|
||||
assert.equal(comparison.candidateEvents, 1)
|
||||
assert.deepStrictEqual(comparison.metricDiffs.presentationInvocationCount, {
|
||||
averageDelta: -2,
|
||||
minDelta: -2,
|
||||
maxDelta: -2,
|
||||
})
|
||||
assert.deepStrictEqual(comparison.metricDiffs.statePostCount, {
|
||||
averageDelta: -2,
|
||||
minDelta: -2,
|
||||
maxDelta: -2,
|
||||
})
|
||||
})
|
||||
|
||||
it("normalizes task initialization events into latency summaries", () => {
|
||||
const summary = summarizeTaskLatencyEvents([
|
||||
{ event: "task.initialization", ulid: "task-1", taskId: "task-a", durationMs: 500 },
|
||||
{ event: "task.initialization", ulid: "task-2", taskId: "task-b", durationMs: 700 },
|
||||
])
|
||||
|
||||
assert.equal(summary.eventCount, 2)
|
||||
assert.equal(summary.requestCount, 0)
|
||||
assert.deepStrictEqual(summary.metrics.taskInitializationDurationMs, {
|
||||
average: 600,
|
||||
min: 500,
|
||||
max: 700,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,120 @@
|
||||
type TaskLatencyEvent = {
|
||||
presentationInvocationCount?: number
|
||||
partialMessageCount?: number
|
||||
statePostCount?: number
|
||||
statePostSerializedBytes?: number
|
||||
persistenceFlushCount?: number
|
||||
chunkToWebviewMedianMs?: number
|
||||
chunkToWebviewP95Ms?: number
|
||||
taskInitializationDurationMs?: number
|
||||
durationMs?: number
|
||||
requestIndex?: number
|
||||
ulid?: string
|
||||
taskId?: string
|
||||
event?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
type NumericMetricKey =
|
||||
| "presentationInvocationCount"
|
||||
| "partialMessageCount"
|
||||
| "statePostCount"
|
||||
| "statePostSerializedBytes"
|
||||
| "persistenceFlushCount"
|
||||
| "chunkToWebviewMedianMs"
|
||||
| "chunkToWebviewP95Ms"
|
||||
| "taskInitializationDurationMs"
|
||||
|
||||
export type TaskLatencySummary = {
|
||||
eventCount: number
|
||||
requestCount: number
|
||||
metrics: Record<NumericMetricKey, { average: number; min: number; max: number }>
|
||||
}
|
||||
|
||||
export type TaskLatencySummaryComparison = {
|
||||
baselineEvents: number
|
||||
candidateEvents: number
|
||||
metricDiffs: Record<NumericMetricKey, { averageDelta: number; minDelta: number; maxDelta: number }>
|
||||
}
|
||||
|
||||
const METRIC_KEYS: NumericMetricKey[] = [
|
||||
"presentationInvocationCount",
|
||||
"partialMessageCount",
|
||||
"statePostCount",
|
||||
"statePostSerializedBytes",
|
||||
"persistenceFlushCount",
|
||||
"chunkToWebviewMedianMs",
|
||||
"chunkToWebviewP95Ms",
|
||||
"taskInitializationDurationMs",
|
||||
]
|
||||
|
||||
function normalizeEvent(event: TaskLatencyEvent): TaskLatencyEvent {
|
||||
if (Number.isFinite(event.taskInitializationDurationMs)) {
|
||||
return event
|
||||
}
|
||||
|
||||
if (event.event === "task.initialization" && Number.isFinite(event.durationMs)) {
|
||||
return {
|
||||
...event,
|
||||
taskInitializationDurationMs: event.durationMs as number,
|
||||
}
|
||||
}
|
||||
|
||||
return event
|
||||
}
|
||||
|
||||
function summarizeMetric(events: TaskLatencyEvent[], key: NumericMetricKey) {
|
||||
const values = events.map((event) => event[key]).filter((value): value is number => Number.isFinite(value))
|
||||
if (values.length === 0) {
|
||||
return { average: 0, min: 0, max: 0 }
|
||||
}
|
||||
|
||||
const total = values.reduce((sum, value) => sum + value, 0)
|
||||
return {
|
||||
average: total / values.length,
|
||||
min: Math.min(...values),
|
||||
max: Math.max(...values),
|
||||
}
|
||||
}
|
||||
|
||||
export function summarizeTaskLatencyEvents(events: TaskLatencyEvent[]): TaskLatencySummary {
|
||||
const normalizedEvents = events.map(normalizeEvent)
|
||||
const requestKeys = new Set(
|
||||
normalizedEvents
|
||||
.map((event) => {
|
||||
if (event.ulid && Number.isFinite(event.requestIndex)) {
|
||||
return `${event.ulid}:${event.requestIndex}`
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
.filter((value): value is string => Boolean(value)),
|
||||
)
|
||||
|
||||
return {
|
||||
eventCount: normalizedEvents.length,
|
||||
requestCount: requestKeys.size,
|
||||
metrics: Object.fromEntries(METRIC_KEYS.map((key) => [key, summarizeMetric(normalizedEvents, key)])) as TaskLatencySummary["metrics"],
|
||||
}
|
||||
}
|
||||
|
||||
export function compareTaskLatencySummaries(
|
||||
baseline: TaskLatencySummary,
|
||||
candidate: TaskLatencySummary,
|
||||
): TaskLatencySummaryComparison {
|
||||
return {
|
||||
baselineEvents: baseline.eventCount,
|
||||
candidateEvents: candidate.eventCount,
|
||||
metricDiffs: Object.fromEntries(
|
||||
METRIC_KEYS.map((key) => [
|
||||
key,
|
||||
{
|
||||
averageDelta: candidate.metrics[key].average - baseline.metrics[key].average,
|
||||
minDelta: candidate.metrics[key].min - baseline.metrics[key].min,
|
||||
maxDelta: candidate.metrics[key].max - baseline.metrics[key].max,
|
||||
},
|
||||
]),
|
||||
) as TaskLatencySummaryComparison["metricDiffs"],
|
||||
}
|
||||
}
|
||||
|
||||
export type { TaskLatencyEvent }
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { ClineMessage, ExtensionState } from "@shared/ExtensionMessage"
|
||||
|
||||
/**
|
||||
* Narrow task metadata updates that can be streamed during active execution.
|
||||
*
|
||||
* Snapshots remain the canonical hydration/recovery source of truth. Deltas are
|
||||
* only valid for the active task and must be applied strictly in sequence.
|
||||
*/
|
||||
export type TaskUiMetadataDelta = Partial<
|
||||
Pick<ExtensionState, "currentFocusChainChecklist" | "backgroundCommandRunning" | "backgroundCommandTaskId">
|
||||
>
|
||||
|
||||
/**
|
||||
* Task UI delta contract for active task execution.
|
||||
*
|
||||
* Sequencing contract:
|
||||
* - `taskId` scopes the delta to a specific active task.
|
||||
* - `sequence` must increase monotonically by exactly 1.
|
||||
* - Any gap, duplicate, or out-of-order delta must trigger snapshot resync.
|
||||
* - `task_state_resynced` signals the receiver to discard local sequencing state
|
||||
* and rehydrate from the canonical full snapshot path.
|
||||
*/
|
||||
export type TaskUiDelta =
|
||||
| {
|
||||
type: "message_added"
|
||||
taskId: string
|
||||
sequence: number
|
||||
message: ClineMessage
|
||||
}
|
||||
| {
|
||||
type: "message_updated"
|
||||
taskId: string
|
||||
sequence: number
|
||||
message: ClineMessage
|
||||
}
|
||||
| {
|
||||
type: "message_deleted"
|
||||
taskId: string
|
||||
sequence: number
|
||||
messageTs: number
|
||||
}
|
||||
| {
|
||||
type: "task_metadata_updated"
|
||||
taskId: string
|
||||
sequence: number
|
||||
metadata: TaskUiMetadataDelta
|
||||
}
|
||||
| {
|
||||
type: "task_state_resynced"
|
||||
taskId: string
|
||||
sequence: number
|
||||
}
|
||||
|
||||
export function isTaskUiDeltaMessageMutation(
|
||||
delta: TaskUiDelta,
|
||||
): delta is Extract<TaskUiDelta, { type: "message_added" | "message_updated" | "message_deleted" }> {
|
||||
return delta.type === "message_added" || delta.type === "message_updated" || delta.type === "message_deleted"
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, it } from "mocha"
|
||||
import "should"
|
||||
import * as sinon from "sinon"
|
||||
import { Controller } from "../core/controller"
|
||||
import * as taskUiDeltaModule from "../core/controller/ui/subscribeToTaskUiDeltas"
|
||||
|
||||
describe("Controller.postTaskMetadataDelta", () => {
|
||||
it("publishes metadata deltas for the current active task", async () => {
|
||||
const sendTaskUiDeltaStub = sinon.stub(taskUiDeltaModule, "sendTaskUiDelta").resolves(undefined)
|
||||
const postStateToWebview = sinon.stub().resolves()
|
||||
|
||||
const fakeController = {
|
||||
task: {
|
||||
taskId: "task-1",
|
||||
taskState: {
|
||||
taskUiDeltaSequence: 0,
|
||||
},
|
||||
},
|
||||
postStateToWebview,
|
||||
}
|
||||
|
||||
await Controller.prototype.postTaskMetadataDelta.call(fakeController as any, {
|
||||
backgroundCommandRunning: true,
|
||||
backgroundCommandTaskId: "task-1",
|
||||
})
|
||||
|
||||
sinon.assert.calledOnce(sendTaskUiDeltaStub)
|
||||
sinon.assert.calledWithExactly(sendTaskUiDeltaStub, {
|
||||
type: "task_metadata_updated",
|
||||
taskId: "task-1",
|
||||
sequence: 1,
|
||||
metadata: {
|
||||
backgroundCommandRunning: true,
|
||||
backgroundCommandTaskId: "task-1",
|
||||
},
|
||||
})
|
||||
sinon.assert.notCalled(postStateToWebview)
|
||||
|
||||
sendTaskUiDeltaStub.restore()
|
||||
})
|
||||
|
||||
it("falls back to posting full state when task identity is missing or mismatched", async () => {
|
||||
const sendTaskUiDeltaStub = sinon.stub(taskUiDeltaModule, "sendTaskUiDelta").resolves(undefined)
|
||||
const postStateToWebview = sinon.stub().resolves()
|
||||
|
||||
const fakeController = {
|
||||
task: {
|
||||
taskId: "task-1",
|
||||
taskState: {
|
||||
taskUiDeltaSequence: 4,
|
||||
},
|
||||
},
|
||||
postStateToWebview,
|
||||
}
|
||||
|
||||
await Controller.prototype.postTaskMetadataDelta.call(
|
||||
fakeController as any,
|
||||
{ currentFocusChainChecklist: "- [x] one" },
|
||||
"task-2",
|
||||
)
|
||||
|
||||
sinon.assert.notCalled(sendTaskUiDeltaStub)
|
||||
sinon.assert.calledOnce(postStateToWebview)
|
||||
|
||||
sendTaskUiDeltaStub.restore()
|
||||
})
|
||||
})
|
||||
@@ -53,7 +53,7 @@ The user wants me to replace the name "john" with "cline" in the test.ts file. I
|
||||
export const name = "john"
|
||||
\`\`\`
|
||||
|
||||
I need to change "john" to "cline". This is a simple targeted edit, so I should use the replace_in_file tool rather than write_to_file since I\'m only changing one small part of the file.
|
||||
I need to change "john" to "cline". This is a simple targeted edit, so I should use the replace_in_file tool rather than write_to_file since I'm only changing one small part of the file.
|
||||
|
||||
I need to:
|
||||
1. Use replace_in_file to change "john" to "cline" in the test.ts file
|
||||
@@ -61,7 +61,7 @@ I need to:
|
||||
3. The REPLACE block should be: \`export const name = "cline"\`
|
||||
</thinking>
|
||||
|
||||
I\'ll replace "john" with "cline" in the test.ts file.
|
||||
I'll replace "john" with "cline" in the test.ts file.
|
||||
|
||||
<replace_in_file>
|
||||
<path>test.ts</path>
|
||||
@@ -74,8 +74,41 @@ export const name = "cline"
|
||||
</diff>
|
||||
</replace_in_file>`
|
||||
|
||||
const latency_validation = `I'll complete a lightweight validation task so the latency harness can measure end-to-end task UI behavior.
|
||||
|
||||
<attempt_completion>
|
||||
<result>
|
||||
Latency validation scenario completed successfully.
|
||||
</result>
|
||||
</attempt_completion>`
|
||||
|
||||
const latency_validation_long = `I'll complete a longer-running validation task so the latency harness can measure repeated task UI updates under sustained streaming load.
|
||||
|
||||
Here is a streamed progress narrative with enough material to force multiple incremental updates while still converging on a single correct final UI state. The harness should observe the active task accumulating partial message activity, task UI deltas, and final completion without producing duplicate stale rows.
|
||||
|
||||
Progress checkpoint 1: initializing the long-running validation flow.
|
||||
Progress checkpoint 2: continuing the long-running validation flow.
|
||||
Progress checkpoint 3: continuing the long-running validation flow.
|
||||
Progress checkpoint 4: continuing the long-running validation flow.
|
||||
Progress checkpoint 5: continuing the long-running validation flow.
|
||||
Progress checkpoint 6: continuing the long-running validation flow.
|
||||
Progress checkpoint 7: continuing the long-running validation flow.
|
||||
Progress checkpoint 8: continuing the long-running validation flow.
|
||||
Progress checkpoint 9: continuing the long-running validation flow.
|
||||
Progress checkpoint 10: continuing the long-running validation flow.
|
||||
Progress checkpoint 11: continuing the long-running validation flow.
|
||||
Progress checkpoint 12: continuing the long-running validation flow.
|
||||
|
||||
<attempt_completion>
|
||||
<result>
|
||||
Long-running latency validation scenario completed successfully.
|
||||
</result>
|
||||
</attempt_completion>`
|
||||
|
||||
export const E2E_MOCK_API_RESPONSES = {
|
||||
DEFAULT: "Hello! I'm a mock Cline API response.",
|
||||
REPLACE_REQUEST: replace_in_file,
|
||||
EDIT_REQUEST: edit_request,
|
||||
LATENCY_VALIDATION: latency_validation,
|
||||
LATENCY_VALIDATION_LONG: latency_validation_long,
|
||||
}
|
||||
|
||||
@@ -377,6 +377,11 @@ export class ClineApiServerMock {
|
||||
const parsed = JSON.parse(body)
|
||||
const { _messages, model = "claude-3-5-sonnet-20241022", stream = true } = parsed
|
||||
let responseText = E2E_MOCK_API_RESPONSES.DEFAULT
|
||||
if (body.includes("latency_validation_long")) {
|
||||
responseText = E2E_MOCK_API_RESPONSES.LATENCY_VALIDATION_LONG
|
||||
} else if (body.includes("latency_validation")) {
|
||||
responseText = E2E_MOCK_API_RESPONSES.LATENCY_VALIDATION
|
||||
}
|
||||
if (body.includes("[replace_in_file for 'test.ts'] Result:")) {
|
||||
responseText = E2E_MOCK_API_RESPONSES.REPLACE_REQUEST
|
||||
}
|
||||
@@ -454,31 +459,30 @@ export class ClineApiServerMock {
|
||||
|
||||
sendChunk()
|
||||
return
|
||||
} else {
|
||||
const response = {
|
||||
id: generationId,
|
||||
object: "chat.completion",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: "Hello! I'm a mock Cline API response.",
|
||||
},
|
||||
finish_reason: "stop",
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
prompt_tokens: 140,
|
||||
completion_tokens: responseText.length,
|
||||
total_tokens: 140 + responseText.length,
|
||||
cost: (140 + responseText.length) * 0.00015,
|
||||
},
|
||||
}
|
||||
return sendJson(response)
|
||||
}
|
||||
const response = {
|
||||
id: generationId,
|
||||
object: "chat.completion",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: "Hello! I'm a mock Cline API response.",
|
||||
},
|
||||
finish_reason: "stop",
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
prompt_tokens: 140,
|
||||
completion_tokens: responseText.length,
|
||||
total_tokens: 140 + responseText.length,
|
||||
cost: (140 + responseText.length) * 0.00015,
|
||||
},
|
||||
}
|
||||
return sendJson(response)
|
||||
}
|
||||
|
||||
// Generation details endpoint
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it } from "mocha"
|
||||
import "should"
|
||||
import should from "should"
|
||||
import { registerTaskUiDeltaCallback } from "../core/controller/ui/subscribeToTaskUiDeltas"
|
||||
import { MessageStateHandler } from "../core/task/message-state"
|
||||
import { TaskState } from "../core/task/TaskState"
|
||||
import { ClineMessage } from "../shared/ExtensionMessage"
|
||||
@@ -264,4 +265,71 @@ describe("MessageStateHandler Mutex Protection", () => {
|
||||
finalHistory[0].content.should.equal("new1")
|
||||
finalHistory[1].content.should.equal("new2")
|
||||
})
|
||||
|
||||
it("publishes message_added deltas with monotonically increasing sequence numbers", async () => {
|
||||
const handler = createTestHandler()
|
||||
const received: Array<{ type: string; sequence: number; text?: string }> = []
|
||||
const unsubscribe = registerTaskUiDeltaCallback((delta) => {
|
||||
received.push({
|
||||
type: delta.type,
|
||||
sequence: delta.sequence,
|
||||
text: "message" in delta ? delta.message.text : undefined,
|
||||
})
|
||||
})
|
||||
|
||||
await handler.addToClineMessagesEphemeral(createTestMessage("first"))
|
||||
await handler.addToClineMessagesEphemeral(createTestMessage("second"))
|
||||
|
||||
received.should.deepEqual([
|
||||
{ type: "message_added", sequence: 1, text: "first" },
|
||||
{ type: "message_added", sequence: 2, text: "second" },
|
||||
])
|
||||
unsubscribe()
|
||||
})
|
||||
|
||||
it("publishes message_updated and message_deleted deltas with correct sequence numbers", async () => {
|
||||
const handler = createTestHandler()
|
||||
const received: Array<{ type: string; sequence: number; text?: string; messageTs?: number }> = []
|
||||
const unsubscribe = registerTaskUiDeltaCallback((delta) => {
|
||||
received.push({
|
||||
type: delta.type,
|
||||
sequence: delta.sequence,
|
||||
text: "message" in delta ? delta.message.text : undefined,
|
||||
messageTs: "messageTs" in delta ? delta.messageTs : undefined,
|
||||
})
|
||||
})
|
||||
|
||||
const message = createTestMessage("original")
|
||||
await handler.addToClineMessagesEphemeral(message)
|
||||
await handler.updateClineMessageEphemeral(0, { text: "updated" })
|
||||
await handler.deleteClineMessage(0).catch(() => {
|
||||
// deleteClineMessage persists to disk; ignore persistence failures in this unit test.
|
||||
})
|
||||
|
||||
received[0]?.type.should.equal("message_added")
|
||||
received[1]?.should.deepEqual({
|
||||
type: "message_updated",
|
||||
sequence: 2,
|
||||
text: "updated",
|
||||
messageTs: undefined,
|
||||
})
|
||||
received[2]?.type.should.equal("message_deleted")
|
||||
received[2]?.sequence.should.equal(3)
|
||||
should.exist(received[2]?.messageTs)
|
||||
received[2]!.messageTs!.should.equal(message.ts)
|
||||
unsubscribe()
|
||||
})
|
||||
|
||||
it("publishes task_state_resynced when the full message state is replaced", async () => {
|
||||
const handler = createTestHandler()
|
||||
const received: Array<{ type: string; sequence: number }> = []
|
||||
const unsubscribe = registerTaskUiDeltaCallback((delta) => {
|
||||
received.push({ type: delta.type, sequence: delta.sequence })
|
||||
})
|
||||
|
||||
handler.setClineMessages([createTestMessage("replacement")])
|
||||
|
||||
received.should.deepEqual([{ type: "task_state_resynced", sequence: 1 }])
|
||||
unsubscribe()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
import { act, render, screen, waitFor } from "@testing-library/react"
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import { ExtensionStateContextProvider, useExtensionState } from "./ExtensionStateContext"
|
||||
|
||||
type StreamCallbacks<T> = {
|
||||
onResponse?: (value: T) => void
|
||||
onError?: (error: unknown) => void
|
||||
onComplete?: () => void
|
||||
}
|
||||
|
||||
const subscriptions = {
|
||||
state: undefined as StreamCallbacks<{ stateJson?: string }> | undefined,
|
||||
partial: undefined as StreamCallbacks<any> | undefined,
|
||||
delta: undefined as StreamCallbacks<{ deltaJson?: string }> | undefined,
|
||||
}
|
||||
|
||||
vi.mock("../services/grpc-client", () => ({
|
||||
StateServiceClient: {
|
||||
subscribeToState: (_request: unknown, callbacks: StreamCallbacks<{ stateJson?: string }>) => {
|
||||
subscriptions.state = callbacks
|
||||
return () => {
|
||||
subscriptions.state = undefined
|
||||
}
|
||||
},
|
||||
getLatestState: vi.fn().mockResolvedValue({
|
||||
stateJson: JSON.stringify({
|
||||
version: "resynced",
|
||||
clineMessages: [{ ts: 99, type: "say", say: "text", text: "resynced" }],
|
||||
currentTaskItem: { id: "task-1" },
|
||||
}),
|
||||
}),
|
||||
getAvailableTerminalProfiles: vi.fn().mockResolvedValue({ profiles: [] }),
|
||||
},
|
||||
UiServiceClient: {
|
||||
subscribeToMcpButtonClicked: vi.fn().mockReturnValue(() => {}),
|
||||
subscribeToHistoryButtonClicked: vi.fn().mockReturnValue(() => {}),
|
||||
subscribeToChatButtonClicked: vi.fn().mockReturnValue(() => {}),
|
||||
subscribeToAccountButtonClicked: vi.fn().mockReturnValue(() => {}),
|
||||
subscribeToSettingsButtonClicked: vi.fn().mockReturnValue(() => {}),
|
||||
subscribeToWorktreesButtonClicked: vi.fn().mockReturnValue(() => {}),
|
||||
subscribeToRelinquishControl: vi.fn().mockReturnValue(() => {}),
|
||||
subscribeToPartialMessage: (_request: unknown, callbacks: StreamCallbacks<any>) => {
|
||||
subscriptions.partial = callbacks
|
||||
return () => {
|
||||
subscriptions.partial = undefined
|
||||
}
|
||||
},
|
||||
subscribeToTaskUiDeltas: (_request: unknown, callbacks: StreamCallbacks<{ deltaJson?: string }>) => {
|
||||
subscriptions.delta = callbacks
|
||||
return () => {
|
||||
subscriptions.delta = undefined
|
||||
}
|
||||
},
|
||||
initializeWebview: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
McpServiceClient: {
|
||||
subscribeToMcpServers: vi.fn().mockReturnValue(() => {}),
|
||||
subscribeToMcpMarketplaceCatalog: vi.fn().mockReturnValue(() => {}),
|
||||
},
|
||||
ModelsServiceClient: {
|
||||
subscribeToOpenRouterModels: vi.fn().mockReturnValue(() => {}),
|
||||
subscribeToLiteLlmModels: vi.fn().mockReturnValue(() => {}),
|
||||
refreshOpenRouterModelsRpc: vi.fn().mockResolvedValue({ models: [] }),
|
||||
refreshVercelAiGatewayModelsRpc: vi.fn().mockResolvedValue({ models: [] }),
|
||||
refreshClineModelsRpc: vi.fn().mockResolvedValue({ models: [] }),
|
||||
refreshBasetenModelsRpc: vi.fn().mockResolvedValue({ models: [] }),
|
||||
refreshLiteLlmModelsRpc: vi.fn().mockResolvedValue({ models: [] }),
|
||||
refreshHicapModels: vi.fn().mockResolvedValue({ models: [] }),
|
||||
},
|
||||
FileServiceClient: {},
|
||||
}))
|
||||
|
||||
function ContextProbe() {
|
||||
const state = useExtensionState() as any
|
||||
return (
|
||||
<>
|
||||
<div data-testid="version">{state.version}</div>
|
||||
<div data-testid="message-count">{state.clineMessages.length}</div>
|
||||
<div data-testid="latest-message">{state.clineMessages.at(-1)?.text ?? ""}</div>
|
||||
<div data-testid="background-command">{String(state.backgroundCommandRunning)}</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
describe("ExtensionStateContextProvider", () => {
|
||||
it("hydrates from full state and applies streaming task UI deltas", async () => {
|
||||
render(
|
||||
<ExtensionStateContextProvider>
|
||||
<ContextProbe />
|
||||
</ExtensionStateContextProvider>,
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
subscriptions.state?.onResponse?.({
|
||||
stateJson: JSON.stringify({
|
||||
version: "initial",
|
||||
clineMessages: [],
|
||||
currentTaskItem: { id: "task-1" },
|
||||
backgroundCommandRunning: false,
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("version").textContent).toBe("initial")
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
subscriptions.delta?.onResponse?.({
|
||||
deltaJson: JSON.stringify({
|
||||
type: "message_added",
|
||||
taskId: "task-1",
|
||||
sequence: 1,
|
||||
message: { ts: 1, type: "say", say: "text", text: "hello" },
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("message-count").textContent).toBe("1")
|
||||
expect(screen.getByTestId("latest-message").textContent).toBe("hello")
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
subscriptions.delta?.onResponse?.({
|
||||
deltaJson: JSON.stringify({
|
||||
type: "message_updated",
|
||||
taskId: "task-1",
|
||||
sequence: 2,
|
||||
message: { ts: 1, type: "say", say: "text", text: "hello world" },
|
||||
}),
|
||||
})
|
||||
subscriptions.delta?.onResponse?.({
|
||||
deltaJson: JSON.stringify({
|
||||
type: "task_metadata_updated",
|
||||
taskId: "task-1",
|
||||
sequence: 3,
|
||||
metadata: { backgroundCommandRunning: true, backgroundCommandTaskId: "task-1" },
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("latest-message").textContent).toBe("hello world")
|
||||
expect(screen.getByTestId("background-command").textContent).toBe("true")
|
||||
})
|
||||
})
|
||||
|
||||
it("requests a full-state resync when a delta sequence gap is detected", async () => {
|
||||
render(
|
||||
<ExtensionStateContextProvider>
|
||||
<ContextProbe />
|
||||
</ExtensionStateContextProvider>,
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
subscriptions.state?.onResponse?.({
|
||||
stateJson: JSON.stringify({
|
||||
version: "initial",
|
||||
clineMessages: [],
|
||||
currentTaskItem: { id: "task-1" },
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
subscriptions.delta?.onResponse?.({
|
||||
deltaJson: JSON.stringify({
|
||||
type: "message_added",
|
||||
taskId: "task-1",
|
||||
sequence: 2,
|
||||
message: { ts: 2, type: "say", say: "text", text: "should trigger resync" },
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("version").textContent).toBe("resynced")
|
||||
expect(screen.getByTestId("latest-message").textContent).toBe("resynced")
|
||||
})
|
||||
})
|
||||
|
||||
it("resets delta sequencing when a full snapshot switches to a different task", async () => {
|
||||
render(
|
||||
<ExtensionStateContextProvider>
|
||||
<ContextProbe />
|
||||
</ExtensionStateContextProvider>,
|
||||
)
|
||||
|
||||
await act(async () => {
|
||||
subscriptions.state?.onResponse?.({
|
||||
stateJson: JSON.stringify({
|
||||
version: "task-1-state",
|
||||
clineMessages: [],
|
||||
currentTaskItem: { id: "task-1" },
|
||||
}),
|
||||
})
|
||||
subscriptions.delta?.onResponse?.({
|
||||
deltaJson: JSON.stringify({
|
||||
type: "message_added",
|
||||
taskId: "task-1",
|
||||
sequence: 1,
|
||||
message: { ts: 1, type: "say", say: "text", text: "task one" },
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("latest-message").textContent).toBe("task one")
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
subscriptions.state?.onResponse?.({
|
||||
stateJson: JSON.stringify({
|
||||
version: "task-2-state",
|
||||
clineMessages: [],
|
||||
currentTaskItem: { id: "task-2" },
|
||||
}),
|
||||
})
|
||||
subscriptions.delta?.onResponse?.({
|
||||
deltaJson: JSON.stringify({
|
||||
type: "message_added",
|
||||
taskId: "task-2",
|
||||
sequence: 1,
|
||||
message: { ts: 2, type: "say", say: "text", text: "task two" },
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("version").textContent).toBe("task-2-state")
|
||||
expect(screen.getByTestId("latest-message").textContent).toBe("task two")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,4 @@
|
||||
import { DEFAULT_AUTO_APPROVAL_SETTINGS } from "@shared/AutoApprovalSettings"
|
||||
import { findLastIndex } from "@shared/array"
|
||||
import { DEFAULT_BROWSER_SETTINGS } from "@shared/BrowserSettings"
|
||||
import { DEFAULT_PLATFORM, type ExtensionState } from "@shared/ExtensionMessage"
|
||||
import { DEFAULT_FOCUS_CHAIN_SETTINGS } from "@shared/FocusChainSettings"
|
||||
@@ -26,7 +25,14 @@ import {
|
||||
} from "../../../src/shared/api"
|
||||
import { Environment } from "../../../src/shared/config-types"
|
||||
import type { McpMarketplaceCatalog, McpServer, McpViewTab } from "../../../src/shared/mcp"
|
||||
import type { TaskUiDelta } from "../../../src/shared/TaskUiDelta"
|
||||
import { McpServiceClient, ModelsServiceClient, StateServiceClient, UiServiceClient } from "../services/grpc-client"
|
||||
import { mergeExtensionStateSnapshot } from "./mergeExtensionState"
|
||||
import { mergePartialMessage } from "./mergePartialMessage"
|
||||
import { ensureDebugTaskUiCounters, incrementDebugTaskUiCounter } from "./taskUiDebugCounters"
|
||||
import { applyTaskUiDeltaToState } from "./taskUiDeltaState"
|
||||
|
||||
const IS_DEV = process.env.IS_DEV === '"true"'
|
||||
|
||||
export interface ExtensionStateContextType extends ExtensionState {
|
||||
didHydrateState: boolean
|
||||
@@ -319,6 +325,7 @@ export const ExtensionStateContextProvider: React.FC<{
|
||||
const [huggingFaceModels, setHuggingFaceModels] = useState<Record<string, ModelInfo>>({})
|
||||
const [mcpServers, setMcpServers] = useState<McpServer[]>([])
|
||||
const [mcpMarketplaceCatalog, setMcpMarketplaceCatalog] = useState<McpMarketplaceCatalog>({ items: [] })
|
||||
const latestTaskUiDeltaSequenceRef = useRef<number>(0)
|
||||
|
||||
// References to store subscription cancellation functions
|
||||
const stateSubscriptionRef = useRef<(() => void) | null>(null)
|
||||
@@ -330,6 +337,7 @@ export const ExtensionStateContextProvider: React.FC<{
|
||||
const settingsButtonClickedSubscriptionRef = useRef<(() => void) | null>(null)
|
||||
const worktreesButtonClickedSubscriptionRef = useRef<(() => void) | null>(null)
|
||||
const partialMessageUnsubscribeRef = useRef<(() => void) | null>(null)
|
||||
const taskUiDeltaUnsubscribeRef = useRef<(() => void) | null>(null)
|
||||
const mcpMarketplaceUnsubscribeRef = useRef<(() => void) | null>(null)
|
||||
const openRouterModelsUnsubscribeRef = useRef<(() => void) | null>(null)
|
||||
const liteLlmModelsUnsubscribeRef = useRef<(() => void) | null>(null)
|
||||
@@ -348,6 +356,24 @@ export const ExtensionStateContextProvider: React.FC<{
|
||||
}, [])
|
||||
const mcpServersSubscriptionRef = useRef<(() => void) | null>(null)
|
||||
|
||||
const resyncCurrentTaskState = useCallback(async () => {
|
||||
try {
|
||||
const latestState = await StateServiceClient.getLatestState(EmptyRequest.create({}))
|
||||
if (!latestState.stateJson) {
|
||||
return
|
||||
}
|
||||
|
||||
const stateData = JSON.parse(latestState.stateJson) as ExtensionState
|
||||
setState((prevState) => ({
|
||||
...prevState,
|
||||
...stateData,
|
||||
}))
|
||||
latestTaskUiDeltaSequenceRef.current = 0
|
||||
} catch (error) {
|
||||
console.error("Failed to resync extension state after task delta gap:", error)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Subscribe to state updates and UI events using the gRPC streaming API
|
||||
useEffect(() => {
|
||||
// Set up state subscription
|
||||
@@ -355,25 +381,14 @@ export const ExtensionStateContextProvider: React.FC<{
|
||||
onResponse: (response) => {
|
||||
if (response.stateJson) {
|
||||
try {
|
||||
incrementDebugTaskUiCounter(
|
||||
IS_DEV,
|
||||
typeof window === "undefined" ? undefined : window,
|
||||
"fullStateApplications",
|
||||
)
|
||||
const stateData = JSON.parse(response.stateJson) as ExtensionState
|
||||
setState((prevState) => {
|
||||
// Versioning logic for autoApprovalSettings
|
||||
const incomingVersion = stateData.autoApprovalSettings?.version ?? 1
|
||||
const currentVersion = prevState.autoApprovalSettings?.version ?? 1
|
||||
const shouldUpdateAutoApproval = incomingVersion > currentVersion
|
||||
// HACK: Preserve clineMessages if currentTaskItem is the same
|
||||
if (stateData.currentTaskItem?.id === prevState.currentTaskItem?.id) {
|
||||
stateData.clineMessages = stateData.clineMessages?.length
|
||||
? stateData.clineMessages
|
||||
: prevState.clineMessages
|
||||
}
|
||||
|
||||
const newState = {
|
||||
...stateData,
|
||||
autoApprovalSettings: shouldUpdateAutoApproval
|
||||
? stateData.autoApprovalSettings
|
||||
: prevState.autoApprovalSettings,
|
||||
}
|
||||
const newState = mergeExtensionStateSnapshot(prevState, stateData)
|
||||
|
||||
// Update welcome screen state based on API configuration if welcome view not in progress
|
||||
if (!newState.welcomeViewCompleted && !showWelcome) {
|
||||
@@ -385,6 +400,7 @@ export const ExtensionStateContextProvider: React.FC<{
|
||||
}
|
||||
|
||||
setDidHydrateState(true)
|
||||
latestTaskUiDeltaSequenceRef.current = 0
|
||||
|
||||
return newState
|
||||
})
|
||||
@@ -512,16 +528,12 @@ export const ExtensionStateContextProvider: React.FC<{
|
||||
}
|
||||
|
||||
const partialMessage = convertProtoToClineMessage(protoMessage)
|
||||
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 === partialMessage.ts)
|
||||
if (lastIndex !== -1) {
|
||||
const newClineMessages = [...prevState.clineMessages]
|
||||
newClineMessages[lastIndex] = partialMessage
|
||||
return { ...prevState, clineMessages: newClineMessages }
|
||||
}
|
||||
return prevState
|
||||
})
|
||||
incrementDebugTaskUiCounter(
|
||||
IS_DEV,
|
||||
typeof window === "undefined" ? undefined : window,
|
||||
"partialMessageApplications",
|
||||
)
|
||||
setState((prevState) => mergePartialMessage(prevState, partialMessage))
|
||||
} catch (error) {
|
||||
console.error("Failed to process partial message:", error, protoMessage)
|
||||
}
|
||||
@@ -534,6 +546,47 @@ export const ExtensionStateContextProvider: React.FC<{
|
||||
},
|
||||
})
|
||||
|
||||
taskUiDeltaUnsubscribeRef.current = UiServiceClient.subscribeToTaskUiDeltas(EmptyRequest.create({}), {
|
||||
onResponse: (response: { deltaJson?: string }) => {
|
||||
if (!response.deltaJson) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const delta = JSON.parse(response.deltaJson) as TaskUiDelta
|
||||
setState((prevState) => {
|
||||
const result = applyTaskUiDeltaToState(prevState, delta, latestTaskUiDeltaSequenceRef.current)
|
||||
const counters = ensureDebugTaskUiCounters(IS_DEV, typeof window === "undefined" ? undefined : window)
|
||||
latestTaskUiDeltaSequenceRef.current = result.nextSequence
|
||||
if (result.kind === "resync") {
|
||||
if (counters) {
|
||||
counters.taskUiDeltaResyncRequests += 1
|
||||
}
|
||||
void resyncCurrentTaskState()
|
||||
return prevState
|
||||
}
|
||||
if (result.kind === "ignored") {
|
||||
return prevState
|
||||
}
|
||||
if (counters) {
|
||||
counters.taskUiDeltaApplications += 1
|
||||
}
|
||||
|
||||
return result.state
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to process task UI delta:", error)
|
||||
}
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const typedError = error as Error
|
||||
console.error("Error in taskUiDelta subscription:", typedError)
|
||||
},
|
||||
onComplete: () => {
|
||||
console.log("taskUiDelta subscription completed")
|
||||
},
|
||||
})
|
||||
|
||||
// Subscribe to MCP marketplace catalog updates
|
||||
mcpMarketplaceUnsubscribeRef.current = McpServiceClient.subscribeToMcpMarketplaceCatalog(EmptyRequest.create({}), {
|
||||
onResponse: (catalog) => {
|
||||
@@ -660,6 +713,10 @@ export const ExtensionStateContextProvider: React.FC<{
|
||||
partialMessageUnsubscribeRef.current()
|
||||
partialMessageUnsubscribeRef.current = null
|
||||
}
|
||||
if (taskUiDeltaUnsubscribeRef.current) {
|
||||
taskUiDeltaUnsubscribeRef.current()
|
||||
taskUiDeltaUnsubscribeRef.current = null
|
||||
}
|
||||
if (mcpMarketplaceUnsubscribeRef.current) {
|
||||
mcpMarketplaceUnsubscribeRef.current()
|
||||
mcpMarketplaceUnsubscribeRef.current = null
|
||||
@@ -685,7 +742,17 @@ export const ExtensionStateContextProvider: React.FC<{
|
||||
mcpServersSubscriptionRef.current = null
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
}, [
|
||||
closeMcpView,
|
||||
navigateToAccount,
|
||||
navigateToChat,
|
||||
navigateToHistory,
|
||||
navigateToMcp,
|
||||
navigateToSettings,
|
||||
navigateToWorktrees,
|
||||
resyncCurrentTaskState,
|
||||
showWelcome,
|
||||
])
|
||||
|
||||
const refreshOpenRouterModels = useCallback(() => {
|
||||
ModelsServiceClient.refreshOpenRouterModelsRpc(EmptyRequest.create({}))
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { ExtensionState } from "@shared/ExtensionMessage"
|
||||
import deepEqual from "fast-deep-equal"
|
||||
|
||||
export function mergeExtensionStateSnapshot(prevState: ExtensionState, incomingState: ExtensionState): ExtensionState {
|
||||
const incomingVersion = incomingState.autoApprovalSettings?.version ?? 1
|
||||
const currentVersion = prevState.autoApprovalSettings?.version ?? 1
|
||||
const shouldUpdateAutoApproval = incomingVersion > currentVersion
|
||||
|
||||
const nextClineMessages =
|
||||
incomingState.currentTaskItem?.id === prevState.currentTaskItem?.id
|
||||
? incomingState.clineMessages?.length
|
||||
? incomingState.clineMessages
|
||||
: prevState.clineMessages
|
||||
: incomingState.clineMessages
|
||||
|
||||
const newState = {
|
||||
...incomingState,
|
||||
clineMessages: nextClineMessages,
|
||||
autoApprovalSettings: shouldUpdateAutoApproval ? incomingState.autoApprovalSettings : prevState.autoApprovalSettings,
|
||||
}
|
||||
|
||||
return deepEqual(newState, prevState) ? prevState : newState
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { findLastIndex } from "@shared/array"
|
||||
import type { ClineMessage, ExtensionState } from "@shared/ExtensionMessage"
|
||||
import deepEqual from "fast-deep-equal"
|
||||
|
||||
export function mergePartialMessage(prevState: ExtensionState, partialMessage: ClineMessage): ExtensionState {
|
||||
const lastIndex = findLastIndex(prevState.clineMessages, (msg) => msg.ts === partialMessage.ts)
|
||||
if (lastIndex === -1) {
|
||||
return prevState
|
||||
}
|
||||
|
||||
if (deepEqual(prevState.clineMessages[lastIndex], partialMessage)) {
|
||||
return prevState
|
||||
}
|
||||
|
||||
const newClineMessages = [...prevState.clineMessages]
|
||||
newClineMessages[lastIndex] = partialMessage
|
||||
return { ...prevState, clineMessages: newClineMessages }
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { type DebugTaskUiCounters, ensureDebugTaskUiCounters, incrementDebugTaskUiCounter } from "./taskUiDebugCounters"
|
||||
|
||||
describe("taskUiDebugCounters", () => {
|
||||
it("returns undefined when debug mode is disabled or window is absent", () => {
|
||||
expect(ensureDebugTaskUiCounters(false, window)).toBeUndefined()
|
||||
expect(ensureDebugTaskUiCounters(true, undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
it("initializes counters once and increments individual keys", () => {
|
||||
const targetWindow = window as Window & { __CLINE_DEBUG_TASK_UI_COUNTERS__?: DebugTaskUiCounters }
|
||||
delete targetWindow.__CLINE_DEBUG_TASK_UI_COUNTERS__
|
||||
|
||||
const counters = ensureDebugTaskUiCounters(true, targetWindow)
|
||||
expect(counters).toEqual({
|
||||
fullStateApplications: 0,
|
||||
partialMessageApplications: 0,
|
||||
taskUiDeltaApplications: 0,
|
||||
taskUiDeltaResyncRequests: 0,
|
||||
})
|
||||
|
||||
incrementDebugTaskUiCounter(true, targetWindow, "taskUiDeltaApplications")
|
||||
incrementDebugTaskUiCounter(true, targetWindow, "taskUiDeltaApplications")
|
||||
incrementDebugTaskUiCounter(true, targetWindow, "taskUiDeltaResyncRequests")
|
||||
|
||||
expect(targetWindow.__CLINE_DEBUG_TASK_UI_COUNTERS__).toEqual({
|
||||
fullStateApplications: 0,
|
||||
partialMessageApplications: 0,
|
||||
taskUiDeltaApplications: 2,
|
||||
taskUiDeltaResyncRequests: 1,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
export type DebugTaskUiCounters = {
|
||||
fullStateApplications: number
|
||||
partialMessageApplications: number
|
||||
taskUiDeltaApplications: number
|
||||
taskUiDeltaResyncRequests: number
|
||||
}
|
||||
|
||||
export type DebugTaskUiCounterKey = keyof DebugTaskUiCounters
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__CLINE_DEBUG_TASK_UI_COUNTERS__?: DebugTaskUiCounters
|
||||
}
|
||||
}
|
||||
|
||||
export function ensureDebugTaskUiCounters(isDev: boolean, targetWindow: Window | undefined): DebugTaskUiCounters | undefined {
|
||||
if (!isDev || !targetWindow) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
targetWindow.__CLINE_DEBUG_TASK_UI_COUNTERS__ ??= {
|
||||
fullStateApplications: 0,
|
||||
partialMessageApplications: 0,
|
||||
taskUiDeltaApplications: 0,
|
||||
taskUiDeltaResyncRequests: 0,
|
||||
}
|
||||
|
||||
return targetWindow.__CLINE_DEBUG_TASK_UI_COUNTERS__
|
||||
}
|
||||
|
||||
export function incrementDebugTaskUiCounter(
|
||||
isDev: boolean,
|
||||
targetWindow: Window | undefined,
|
||||
key: DebugTaskUiCounterKey,
|
||||
): DebugTaskUiCounters | undefined {
|
||||
const counters = ensureDebugTaskUiCounters(isDev, targetWindow)
|
||||
if (!counters) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
counters[key] += 1
|
||||
return counters
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
import type { ExtensionState } from "../../../src/shared/ExtensionMessage"
|
||||
import type { TaskUiDelta } from "../../../src/shared/TaskUiDelta"
|
||||
import { applyTaskUiDeltaToState } from "./taskUiDeltaState"
|
||||
|
||||
const createState = (): ExtensionState =>
|
||||
({
|
||||
version: "test",
|
||||
clineMessages: [],
|
||||
taskHistory: [],
|
||||
shouldShowAnnouncement: false,
|
||||
autoApprovalSettings: { enabled: false, actions: {}, version: 1 },
|
||||
browserSettings: { viewport: "desktop", screencast: true },
|
||||
focusChainSettings: { enabled: false, reminderIntervalRequests: 5 },
|
||||
preferredLanguage: "English",
|
||||
mode: "act",
|
||||
platform: "macOS",
|
||||
environment: "production",
|
||||
telemetrySetting: "unset",
|
||||
distinctId: "distinct-id",
|
||||
planActSeparateModelsSetting: true,
|
||||
enableCheckpointsSetting: true,
|
||||
mcpDisplayMode: "sidebar",
|
||||
globalClineRulesToggles: {},
|
||||
localClineRulesToggles: {},
|
||||
localCursorRulesToggles: {},
|
||||
localWindsurfRulesToggles: {},
|
||||
localAgentsRulesToggles: {},
|
||||
localWorkflowToggles: {},
|
||||
globalWorkflowToggles: {},
|
||||
shellIntegrationTimeout: 4_000,
|
||||
terminalReuseEnabled: true,
|
||||
vscodeTerminalExecutionMode: "vscodeTerminal",
|
||||
terminalOutputLineLimit: 500,
|
||||
maxConsecutiveMistakes: 3,
|
||||
defaultTerminalProfile: "default",
|
||||
isNewUser: false,
|
||||
welcomeViewCompleted: true,
|
||||
strictPlanModeEnabled: false,
|
||||
yoloModeToggled: false,
|
||||
useAutoCondense: false,
|
||||
subagentsEnabled: false,
|
||||
clineWebToolsEnabled: { user: true, featureFlag: false },
|
||||
worktreesEnabled: { user: true, featureFlag: false },
|
||||
favoritedModelIds: [],
|
||||
lastDismissedInfoBannerVersion: 0,
|
||||
lastDismissedModelBannerVersion: 0,
|
||||
lastDismissedCliBannerVersion: 0,
|
||||
remoteConfigSettings: {},
|
||||
onboardingModels: undefined,
|
||||
backgroundCommandRunning: false,
|
||||
backgroundCommandTaskId: undefined,
|
||||
backgroundEditEnabled: false,
|
||||
doubleCheckCompletionEnabled: false,
|
||||
globalSkillsToggles: {},
|
||||
localSkillsToggles: {},
|
||||
mcpResponsesCollapsed: false,
|
||||
customPrompt: undefined,
|
||||
workspaceRoots: [],
|
||||
primaryRootIndex: 0,
|
||||
isMultiRootWorkspace: false,
|
||||
multiRootSetting: { user: false, featureFlag: false },
|
||||
hooksEnabled: false,
|
||||
nativeToolCallSetting: false,
|
||||
enableParallelToolCalling: false,
|
||||
currentTaskItem: {
|
||||
id: "task-1",
|
||||
ts: 1,
|
||||
task: "demo",
|
||||
tokensIn: 0,
|
||||
tokensOut: 0,
|
||||
cacheWrites: 0,
|
||||
cacheReads: 0,
|
||||
totalCost: 0,
|
||||
size: 0,
|
||||
cwdOnTaskInitialization: "/workspace",
|
||||
isFavorited: false,
|
||||
},
|
||||
}) as unknown as ExtensionState
|
||||
|
||||
const createDelta = (overrides: Partial<TaskUiDelta>): TaskUiDelta =>
|
||||
({
|
||||
type: "task_state_resynced",
|
||||
taskId: "task-1",
|
||||
sequence: 1,
|
||||
...overrides,
|
||||
}) as TaskUiDelta
|
||||
|
||||
describe("applyTaskUiDeltaToState", () => {
|
||||
it("applies added and updated message deltas", () => {
|
||||
const state = createState()
|
||||
const added = applyTaskUiDeltaToState(
|
||||
state,
|
||||
createDelta({
|
||||
type: "message_added",
|
||||
message: { ts: 10, type: "say", say: "text", text: "hello" },
|
||||
}),
|
||||
0,
|
||||
)
|
||||
|
||||
expect(added.kind).toBe("applied")
|
||||
if (added.kind !== "applied") {
|
||||
throw new Error("expected applied result")
|
||||
}
|
||||
expect(added.state.clineMessages).toHaveLength(1)
|
||||
|
||||
const updated = applyTaskUiDeltaToState(
|
||||
added.state,
|
||||
createDelta({
|
||||
sequence: 2,
|
||||
type: "message_updated",
|
||||
message: { ts: 10, type: "say", say: "text", text: "updated" },
|
||||
}),
|
||||
added.nextSequence,
|
||||
)
|
||||
|
||||
expect(updated.kind).toBe("applied")
|
||||
if (updated.kind !== "applied") {
|
||||
throw new Error("expected applied result")
|
||||
}
|
||||
expect(updated.state.clineMessages[0].text).toBe("updated")
|
||||
})
|
||||
|
||||
it("requests a resync when a sequence gap is detected", () => {
|
||||
const state = createState()
|
||||
const result = applyTaskUiDeltaToState(
|
||||
state,
|
||||
createDelta({
|
||||
sequence: 3,
|
||||
type: "message_added",
|
||||
message: { ts: 10, type: "say", say: "text", text: "hello" },
|
||||
}),
|
||||
1,
|
||||
)
|
||||
|
||||
expect(result).toEqual({ kind: "resync", nextSequence: 1 })
|
||||
})
|
||||
|
||||
it("requests a full snapshot resync when the backend emits a task_state_resynced delta", () => {
|
||||
const state = createState()
|
||||
state.clineMessages = [{ ts: 10, type: "say", say: "text", text: "stale local state" } as any]
|
||||
|
||||
const result = applyTaskUiDeltaToState(
|
||||
state,
|
||||
createDelta({
|
||||
sequence: 1,
|
||||
type: "task_state_resynced",
|
||||
}),
|
||||
0,
|
||||
)
|
||||
|
||||
expect(result).toEqual({ kind: "resync", nextSequence: 0 })
|
||||
})
|
||||
|
||||
it("ignores deltas for other tasks", () => {
|
||||
const state = createState()
|
||||
const result = applyTaskUiDeltaToState(
|
||||
state,
|
||||
createDelta({
|
||||
taskId: "task-2",
|
||||
type: "message_added",
|
||||
message: { ts: 10, type: "say", say: "text", text: "hello" },
|
||||
}),
|
||||
0,
|
||||
)
|
||||
|
||||
expect(result).toEqual({ kind: "ignored", nextSequence: 1 })
|
||||
})
|
||||
|
||||
it("applies task metadata deltas without replacing the message list", () => {
|
||||
const state = createState()
|
||||
state.clineMessages = [{ ts: 10, type: "say", say: "text", text: "hello" }]
|
||||
|
||||
const result = applyTaskUiDeltaToState(
|
||||
state,
|
||||
createDelta({
|
||||
type: "task_metadata_updated",
|
||||
metadata: {
|
||||
currentFocusChainChecklist: "- [x] done",
|
||||
backgroundCommandRunning: true,
|
||||
backgroundCommandTaskId: "task-1",
|
||||
},
|
||||
}),
|
||||
0,
|
||||
)
|
||||
|
||||
expect(result.kind).toBe("applied")
|
||||
if (result.kind !== "applied") {
|
||||
throw new Error("expected applied result")
|
||||
}
|
||||
|
||||
expect(result.state.currentFocusChainChecklist).toBe("- [x] done")
|
||||
expect(result.state.backgroundCommandRunning).toBe(true)
|
||||
expect(result.state.backgroundCommandTaskId).toBe("task-1")
|
||||
expect(result.state.clineMessages).toEqual(state.clineMessages)
|
||||
})
|
||||
|
||||
it("preserves state references when a metadata delta does not change values", () => {
|
||||
const state = createState()
|
||||
const result = applyTaskUiDeltaToState(
|
||||
state,
|
||||
createDelta({
|
||||
type: "task_metadata_updated",
|
||||
metadata: {
|
||||
backgroundCommandRunning: false,
|
||||
backgroundCommandTaskId: undefined,
|
||||
},
|
||||
}),
|
||||
0,
|
||||
)
|
||||
|
||||
expect(result.kind).toBe("applied")
|
||||
if (result.kind !== "applied") {
|
||||
throw new Error("expected applied result")
|
||||
}
|
||||
expect(result.state).toBe(state)
|
||||
})
|
||||
|
||||
it("preserves message array reference when an update delta is identical to existing content", () => {
|
||||
const state = createState()
|
||||
const existingMessage = { ts: 10, type: "say", say: "text", text: "hello" } as const
|
||||
state.clineMessages = [existingMessage as any]
|
||||
|
||||
const result = applyTaskUiDeltaToState(
|
||||
state,
|
||||
createDelta({
|
||||
type: "message_updated",
|
||||
message: { ...existingMessage },
|
||||
}),
|
||||
0,
|
||||
)
|
||||
|
||||
expect(result.kind).toBe("applied")
|
||||
if (result.kind !== "applied") {
|
||||
throw new Error("expected applied result")
|
||||
}
|
||||
expect(result.state).toBe(state)
|
||||
expect(result.state.clineMessages).toBe(state.clineMessages)
|
||||
})
|
||||
|
||||
it("preserves state references when a delete delta targets a missing message", () => {
|
||||
const state = createState()
|
||||
state.clineMessages = [{ ts: 10, type: "say", say: "text", text: "hello" } as any]
|
||||
|
||||
const result = applyTaskUiDeltaToState(
|
||||
state,
|
||||
createDelta({
|
||||
type: "message_deleted",
|
||||
messageTs: 999,
|
||||
}),
|
||||
0,
|
||||
)
|
||||
|
||||
expect(result.kind).toBe("applied")
|
||||
if (result.kind !== "applied") {
|
||||
throw new Error("expected applied result")
|
||||
}
|
||||
expect(result.state).toBe(state)
|
||||
expect(result.state.clineMessages).toBe(state.clineMessages)
|
||||
})
|
||||
|
||||
it("converges to the same final task state as an equivalent full snapshot", () => {
|
||||
const initialState = createState()
|
||||
|
||||
const deltas: TaskUiDelta[] = [
|
||||
createDelta({
|
||||
sequence: 1,
|
||||
type: "message_added",
|
||||
message: { ts: 10, type: "say", say: "text", text: "hello" },
|
||||
}),
|
||||
createDelta({
|
||||
sequence: 2,
|
||||
type: "message_added",
|
||||
message: { ts: 20, type: "say", say: "reasoning", text: "thinking", partial: true },
|
||||
}),
|
||||
createDelta({
|
||||
sequence: 3,
|
||||
type: "message_updated",
|
||||
message: { ts: 20, type: "say", say: "reasoning", text: "thinking complete", partial: false },
|
||||
}),
|
||||
createDelta({
|
||||
sequence: 4,
|
||||
type: "task_metadata_updated",
|
||||
metadata: {
|
||||
backgroundCommandRunning: true,
|
||||
backgroundCommandTaskId: "task-1",
|
||||
currentFocusChainChecklist: "- [x] streamed",
|
||||
},
|
||||
}),
|
||||
createDelta({
|
||||
sequence: 5,
|
||||
type: "message_deleted",
|
||||
messageTs: 10,
|
||||
}),
|
||||
]
|
||||
|
||||
let state = initialState
|
||||
let sequence = 0
|
||||
for (const delta of deltas) {
|
||||
const result = applyTaskUiDeltaToState(state, delta, sequence)
|
||||
expect(result.kind).toBe("applied")
|
||||
if (result.kind !== "applied") {
|
||||
throw new Error("expected applied result")
|
||||
}
|
||||
state = result.state
|
||||
sequence = result.nextSequence
|
||||
}
|
||||
|
||||
const expectedSnapshot: ExtensionState = {
|
||||
...createState(),
|
||||
clineMessages: [{ ts: 20, type: "say", say: "reasoning", text: "thinking complete", partial: false } as any],
|
||||
backgroundCommandRunning: true,
|
||||
backgroundCommandTaskId: "task-1",
|
||||
currentFocusChainChecklist: "- [x] streamed",
|
||||
}
|
||||
|
||||
expect(state.clineMessages).toEqual(expectedSnapshot.clineMessages)
|
||||
expect(state.backgroundCommandRunning).toBe(expectedSnapshot.backgroundCommandRunning)
|
||||
expect(state.backgroundCommandTaskId).toBe(expectedSnapshot.backgroundCommandTaskId)
|
||||
expect(state.currentFocusChainChecklist).toBe(expectedSnapshot.currentFocusChainChecklist)
|
||||
})
|
||||
|
||||
it("applies ordered delta events sequentially while advancing the cursor", () => {
|
||||
let state = createState()
|
||||
let sequence = 0
|
||||
|
||||
const orderedDeltas: TaskUiDelta[] = [
|
||||
createDelta({
|
||||
sequence: 1,
|
||||
type: "message_added",
|
||||
message: { ts: 100, type: "say", say: "text", text: "first" },
|
||||
}),
|
||||
createDelta({
|
||||
sequence: 2,
|
||||
type: "message_updated",
|
||||
message: { ts: 100, type: "say", say: "text", text: "first updated" },
|
||||
}),
|
||||
createDelta({
|
||||
sequence: 3,
|
||||
type: "task_metadata_updated",
|
||||
metadata: { backgroundCommandRunning: true },
|
||||
}),
|
||||
]
|
||||
|
||||
for (const delta of orderedDeltas) {
|
||||
const result = applyTaskUiDeltaToState(state, delta, sequence)
|
||||
expect(result.kind).toBe("applied")
|
||||
if (result.kind !== "applied") {
|
||||
throw new Error("expected applied result")
|
||||
}
|
||||
state = result.state
|
||||
sequence = result.nextSequence
|
||||
}
|
||||
|
||||
expect(sequence).toBe(3)
|
||||
expect(state.clineMessages).toEqual([{ ts: 100, type: "say", say: "text", text: "first updated" }])
|
||||
expect(state.backgroundCommandRunning).toBe(true)
|
||||
})
|
||||
|
||||
it("updates the active message row correctly under repeated deltas", () => {
|
||||
let state = createState()
|
||||
let sequence = 0
|
||||
|
||||
const deltas: TaskUiDelta[] = [
|
||||
createDelta({
|
||||
sequence: 1,
|
||||
type: "message_added",
|
||||
message: { ts: 500, type: "say", say: "text", text: "draft", partial: true },
|
||||
}),
|
||||
createDelta({
|
||||
sequence: 2,
|
||||
type: "message_updated",
|
||||
message: { ts: 500, type: "say", say: "text", text: "draft + more", partial: true },
|
||||
}),
|
||||
createDelta({
|
||||
sequence: 3,
|
||||
type: "message_updated",
|
||||
message: { ts: 500, type: "say", say: "text", text: "final", partial: false },
|
||||
}),
|
||||
]
|
||||
|
||||
for (const delta of deltas) {
|
||||
const result = applyTaskUiDeltaToState(state, delta, sequence)
|
||||
expect(result.kind).toBe("applied")
|
||||
if (result.kind !== "applied") {
|
||||
throw new Error("expected applied result")
|
||||
}
|
||||
state = result.state
|
||||
sequence = result.nextSequence
|
||||
}
|
||||
|
||||
expect(state.clineMessages).toHaveLength(1)
|
||||
expect(state.clineMessages[0]).toEqual({ ts: 500, type: "say", say: "text", text: "final", partial: false })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,97 @@
|
||||
import { findLastIndex } from "@shared/array"
|
||||
import type { ExtensionState } from "@shared/ExtensionMessage"
|
||||
import type { TaskUiDelta } from "@shared/TaskUiDelta"
|
||||
import deepEqual from "fast-deep-equal"
|
||||
|
||||
export type TaskUiDeltaApplicationResult =
|
||||
| { kind: "ignored"; nextSequence: number }
|
||||
| { kind: "resync"; nextSequence: number }
|
||||
| { kind: "applied"; nextSequence: number; state: ExtensionState }
|
||||
|
||||
export function applyTaskUiDeltaToState(
|
||||
state: ExtensionState,
|
||||
delta: TaskUiDelta,
|
||||
latestSequence: number,
|
||||
): TaskUiDeltaApplicationResult {
|
||||
const expectedSequence = latestSequence + 1
|
||||
if (delta.sequence !== expectedSequence) {
|
||||
return { kind: "resync", nextSequence: latestSequence }
|
||||
}
|
||||
|
||||
if (delta.taskId !== state.currentTaskItem?.id) {
|
||||
return { kind: "ignored", nextSequence: delta.sequence }
|
||||
}
|
||||
|
||||
if (delta.type === "task_state_resynced") {
|
||||
return { kind: "resync", nextSequence: 0 }
|
||||
}
|
||||
|
||||
if (delta.type === "task_metadata_updated") {
|
||||
const metadataChanged = Object.entries(delta.metadata).some(([key, value]) => {
|
||||
return !deepEqual(state[key as keyof ExtensionState], value)
|
||||
})
|
||||
if (!metadataChanged) {
|
||||
return { kind: "applied", nextSequence: delta.sequence, state }
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "applied",
|
||||
nextSequence: delta.sequence,
|
||||
state: {
|
||||
...state,
|
||||
...delta.metadata,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (delta.type === "message_deleted") {
|
||||
const hasMessageToDelete = state.clineMessages.some((message) => message.ts === delta.messageTs)
|
||||
if (!hasMessageToDelete) {
|
||||
return {
|
||||
kind: "applied",
|
||||
nextSequence: delta.sequence,
|
||||
state,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "applied",
|
||||
nextSequence: delta.sequence,
|
||||
state: {
|
||||
...state,
|
||||
clineMessages: state.clineMessages.filter((message) => message.ts !== delta.messageTs),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const existingIndex = findLastIndex(state.clineMessages, (message) => message.ts === delta.message.ts)
|
||||
if (existingIndex === -1) {
|
||||
return {
|
||||
kind: "applied",
|
||||
nextSequence: delta.sequence,
|
||||
state: {
|
||||
...state,
|
||||
clineMessages: [...state.clineMessages, delta.message],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (deepEqual(state.clineMessages[existingIndex], delta.message)) {
|
||||
return {
|
||||
kind: "applied",
|
||||
nextSequence: delta.sequence,
|
||||
state,
|
||||
}
|
||||
}
|
||||
|
||||
const clineMessages = [...state.clineMessages]
|
||||
clineMessages[existingIndex] = delta.message
|
||||
return {
|
||||
kind: "applied",
|
||||
nextSequence: delta.sequence,
|
||||
state: {
|
||||
...state,
|
||||
clineMessages,
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user