mirror of
https://github.com/cline/cline.git
synced 2026-09-11 16:42:40 +08:00
Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c652edd67 | ||
|
|
132956852f | ||
|
|
c54c41a55a | ||
|
|
de7c799a36 | ||
|
|
dedacaee21 | ||
|
|
127e612c7e | ||
|
|
cb4ee08858 | ||
|
|
61b15348e5 | ||
|
|
a8eb3211fc | ||
|
|
e5c1b5c692 | ||
|
|
35effd13b3 | ||
|
|
8c2929471a | ||
|
|
c8a7b4f036 | ||
|
|
8dab3072a4 | ||
|
|
d40cc337d3 | ||
|
|
0d1afe06bb | ||
|
|
fe5f2499d8 | ||
|
|
418bcab53b |
@@ -125,6 +125,12 @@ ERROR_SERVICE_API_KEY=your-posthog-error-tracking-api-key
|
||||
# E2E_TEST=true
|
||||
# IS_TEST=true
|
||||
|
||||
# Remote workspace latency tuning
|
||||
# CLINE_STATE_UPDATE_CADENCE_MS=16
|
||||
# CLINE_REMOTE_STATE_UPDATE_CADENCE_MS=110
|
||||
# CLINE_STATE_UPDATE_LOW_CADENCE_MS=40
|
||||
# CLINE_REMOTE_STATE_UPDATE_LOW_CADENCE_MS=150
|
||||
|
||||
# ============================================================================
|
||||
# USAGE INSTRUCTIONS
|
||||
# ============================================================================
|
||||
|
||||
@@ -0,0 +1,462 @@
|
||||
# Technique Plan: Controller Full-State Coalescing
|
||||
|
||||
This document is the implementation plan for the **controller full-state coalescing** technique identified in `docs/remote-workspace-latency-branch-analysis-report.md` as one of the top four highest-impact improvements for remote-workspace UX.
|
||||
|
||||
The key idea is:
|
||||
|
||||
> **Full state is a snapshot transport, not a token transport.**
|
||||
|
||||
When Cline is actively streaming, repeatedly rebuilding and sending large `ExtensionState` snapshots is one of the biggest avoidable costs in remote mode. Even if each snapshot is “not that large,” the repeated serialization, transport, parsing, and frontend reconciliation create visible UI churn.
|
||||
|
||||
This technique reduces that cost by coalescing repeated `postStateToWebview()` requests into a scheduler-driven snapshot flow with priorities and remote-aware cadence.
|
||||
|
||||
## How To Use This Plan
|
||||
|
||||
This plan is for extracting a coherent technique into its **own branch**, while treating `eve_troubleshooting-remote-workspaces` as the **fully developed reference implementation**.
|
||||
|
||||
That means the branch work has already been done once. The goal now is not to rediscover the architecture from scratch; it is to produce a smaller, easier-to-review implementation plan that tells a developer exactly how to extract and verify the technique with high confidence.
|
||||
|
||||
Be smart about this. Continually compare your work to the reference implementation and actively take implementation details from it when executing each step. The reference branch should be your source of truth for subtle behaviors, edge-case handling, and interactions with the rest of the product surface.
|
||||
|
||||
## Developer Operating Posture
|
||||
|
||||
This technique sits at the boundary between extension-host state management and frontend hydration. That makes it highly leveraged and easy to get wrong in ways that only show up under load or during cross-surface interactions.
|
||||
|
||||
While implementing:
|
||||
|
||||
- keep snapshot semantics explicit,
|
||||
- preserve immediate behavior where product correctness or UX requires it,
|
||||
- and use the reference implementation to understand which callsites were intentionally allowed to coalesce.
|
||||
|
||||
The governing principle remains:
|
||||
|
||||
> **Stop treating every streamed chunk as a durable, full-state, immediately-presented event.**
|
||||
|
||||
For this technique, the emphasis is on the **full-state** part.
|
||||
|
||||
## Document Type, Audience, and Quality Bar
|
||||
|
||||
This is an **extraction implementation plan** written for a **Staff+ level distributed systems / infrastructure engineer**. Its purpose is to turn an already-integrated optimization into a smaller, understandable, safe-to-review change set.
|
||||
|
||||
The quality bar is:
|
||||
|
||||
- state-posting behavior must remain easy to reason about,
|
||||
- the extracted scheduler must preserve correctness across non-streaming product surfaces,
|
||||
- and the doc must make explicit where coalescing is appropriate versus dangerous.
|
||||
|
||||
## Artifact Stack and Dependency Position
|
||||
|
||||
This doc should be used in the following sequence:
|
||||
|
||||
1. read the branch analysis report for prioritization context,
|
||||
2. inspect `eve_troubleshooting-remote-workspaces` for the integrated implementation,
|
||||
3. use this plan to extract a smaller, coherent PR with clear verification boundaries.
|
||||
|
||||
This sequence reduces rework and helps ensure the extraction remains anchored to the actual behavior we already know works.
|
||||
|
||||
## Minimal Coherent Extraction Boundary
|
||||
|
||||
The smallest coherent PR for this technique should usually include:
|
||||
|
||||
- controller-level scheduler introduction,
|
||||
- controller `postStateToWebview()` routing changes,
|
||||
- build/send/payload instrumentation,
|
||||
- priority selection defaults,
|
||||
- and tests covering coalescing plus immediate bypass.
|
||||
|
||||
What should **not** be split away if avoidable:
|
||||
|
||||
- scheduler extraction from controller integration,
|
||||
- payload/build/send instrumentation from the coalescing work,
|
||||
- default-priority logic from the scheduler rollout,
|
||||
- and product-surface regression coverage for init/cancel/auth/task switching.
|
||||
|
||||
## Common Failure Modes While Extracting
|
||||
|
||||
Watch for these failure modes explicitly:
|
||||
|
||||
- coalescing the scheduler mechanically without auditing callsite urgency,
|
||||
- reducing full-state post count while accidentally delaying critical UI transitions,
|
||||
- proving frequency reduction without measuring payload size or send/build time,
|
||||
- treating streaming and non-streaming surfaces the same,
|
||||
- and forgetting that snapshot bugs often manifest as broad UI inconsistency rather than narrow chat glitches.
|
||||
|
||||
---
|
||||
|
||||
## Why This Technique Matters
|
||||
|
||||
In remote workspaces, a full-state update can imply all of the following:
|
||||
|
||||
- gather current extension state,
|
||||
- build `ExtensionState`,
|
||||
- JSON serialize it,
|
||||
- transport it from extension host to webview boundary,
|
||||
- parse it on the receiving side,
|
||||
- merge it into frontend state,
|
||||
- trigger React render work.
|
||||
|
||||
That is acceptable for:
|
||||
|
||||
- initialization,
|
||||
- task switches,
|
||||
- mode changes,
|
||||
- recovery/resync.
|
||||
|
||||
It is **not** acceptable as the default transport for high-frequency streaming churn.
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- Repeated `postStateToWebview()` calls during active streaming are coalesced.
|
||||
- Immediate flushes are still available where correctness or UX requires them.
|
||||
- State payload size and posting frequency are instrumented.
|
||||
- Streaming tasks generate significantly fewer full-state pushes.
|
||||
- No regressions appear in task switching, auth changes, cancellation, or other non-streaming product surfaces.
|
||||
|
||||
---
|
||||
|
||||
## Files Most Likely to Change
|
||||
|
||||
- `src/core/controller/StateUpdateScheduler.ts`
|
||||
- `src/core/controller/index.ts`
|
||||
- `src/core/controller/state/subscribeToState.ts`
|
||||
- `src/core/controller/postStateToWebview.test.ts`
|
||||
- `src/core/controller/StateUpdateScheduler.test.ts`
|
||||
- `src/core/task/index.ts` for request metrics hookup
|
||||
|
||||
---
|
||||
|
||||
## Step-by-Step Implementation Plan
|
||||
|
||||
## Step 1 — Define snapshot posting priorities and rules
|
||||
|
||||
### Goal
|
||||
|
||||
Make it explicit which state posts must remain immediate and which can be coalesced.
|
||||
|
||||
### Mental model
|
||||
|
||||
If every caller thinks its update is urgent, the scheduler will collapse back into immediate mode and lose its value. We need a principled split between:
|
||||
|
||||
- **must be immediate**,
|
||||
- **safe to coalesce**,
|
||||
- **background/low priority**.
|
||||
|
||||
### Work
|
||||
|
||||
- [x] Define `immediate`, `normal`, and `low` state update priorities.
|
||||
- [x] Audit core state-posting callsites.
|
||||
- [x] Document which flows must bypass coalescing.
|
||||
|
||||
### Detailed code changes
|
||||
|
||||
- In `src/core/controller/index.ts`, document priority expectations near `postStateToWebview(...)`.
|
||||
- Categorize at least these as typically immediate:
|
||||
- [x] task initialization,
|
||||
- [x] explicit task clear/switch,
|
||||
- [x] auth/login/logout state changes,
|
||||
- [x] mode switch.
|
||||
- Categorize these as usually coalescible during streaming:
|
||||
- [x] usage/cost updates,
|
||||
- [x] background command state churn,
|
||||
- [x] focus-chain intermediate changes,
|
||||
- [x] repeated chat-state updates caused by streaming partials.
|
||||
|
||||
Use the reference implementation branch to validate these classifications before finalizing them in your extraction. The point is not to make an abstract list; it is to preserve the already-learned boundary between “must feel instant” and “safe to batch.”
|
||||
|
||||
### Tests
|
||||
|
||||
- [ ] No behavior test required yet beyond upcoming scheduler tests.
|
||||
|
||||
---
|
||||
|
||||
## Step 2 — Build the controller-level state update scheduler
|
||||
|
||||
### Goal
|
||||
|
||||
Create a scheduler that coalesces repeated full-state posting requests while preserving immediate flush semantics.
|
||||
|
||||
### Mental model
|
||||
|
||||
This scheduler is conceptually parallel to the presentation scheduler, but it controls **snapshot delivery**, not message presentation. The implementation must handle:
|
||||
|
||||
- pending work,
|
||||
- in-progress flushes,
|
||||
- higher-priority upgrades,
|
||||
- rerun-on-dirty behavior.
|
||||
|
||||
### Work
|
||||
|
||||
- [x] Implement `StateUpdateScheduler` with request/flush/dispose behavior.
|
||||
- [x] Support priority merging.
|
||||
- [x] Avoid overlapping snapshot flushes.
|
||||
- [x] Re-run once if additional work arrives during flush.
|
||||
|
||||
### Detailed code changes
|
||||
|
||||
- In `src/core/controller/StateUpdateScheduler.ts`:
|
||||
- [x] track `scheduledTimer`, `pendingPriority`, `flushInProgress`, `pendingWhileFlushing`, and `disposed`.
|
||||
- [x] support `requestFlush(priority)`.
|
||||
- [x] support `flushNow()`.
|
||||
- [x] support `dispose()`.
|
||||
- [x] ensure `immediate` preempts a pending delayed timer.
|
||||
|
||||
Be smart about the scheduler design here. A controller-level scheduler can look mechanically similar to the presentation scheduler, but the failure mode is different: a presentation bug is usually visible in one message stream, while a snapshot bug can destabilize the whole UI state model.
|
||||
|
||||
### Tests
|
||||
|
||||
- [x] Unit test: repeated normal-priority calls inside cadence window produce one flush.
|
||||
- [x] Unit test: immediate-priority request bypasses delay.
|
||||
- [x] Unit test: updates arriving while flush is running trigger exactly one follow-up flush.
|
||||
- [x] Unit test: dispose clears scheduled work.
|
||||
|
||||
---
|
||||
|
||||
## Step 3 — Route `postStateToWebview()` through the scheduler
|
||||
|
||||
### Goal
|
||||
|
||||
Make the scheduler the default behavior for full-state posting without breaking existing callers.
|
||||
|
||||
### Mental model
|
||||
|
||||
The API should remain convenient for the rest of the codebase. Most callers should still say “post state,” but the controller decides whether that means immediate flush or scheduled coalescing.
|
||||
|
||||
### Work
|
||||
|
||||
- [x] Add optional priority parameter to `postStateToWebview(...)`.
|
||||
- [x] Determine the default priority based on whether the task is actively streaming.
|
||||
- [x] Preserve an explicit immediate path.
|
||||
|
||||
### Detailed code changes
|
||||
|
||||
- In `src/core/controller/index.ts`:
|
||||
- [x] instantiate `StateUpdateScheduler` in the constructor.
|
||||
- [x] change `postStateToWebview(options?)` so it:
|
||||
- [x] flushes immediately for `priority: "immediate"`,
|
||||
- [x] otherwise requests scheduled flush.
|
||||
- [x] add `getDefaultStateUpdatePriority()` that returns `normal` while streaming and `immediate` when idle/non-task.
|
||||
|
||||
When extracting this step, prefer preserving the reference implementation’s method boundaries and control flow. That will make later comparison and debugging much smoother.
|
||||
|
||||
### Tests
|
||||
|
||||
- [x] Unit test: no-task / idle state posts remain immediate by default.
|
||||
- [x] Unit test: active-streaming posts default to coalesced priority.
|
||||
|
||||
---
|
||||
|
||||
## Step 4 — Instrument full-state payload size, build time, and send time
|
||||
|
||||
### Goal
|
||||
|
||||
Quantify the actual cost of snapshot posting and verify coalescing reduces it.
|
||||
|
||||
### Mental model
|
||||
|
||||
Snapshot frequency alone is not enough. One giant expensive snapshot can be worse than several small ones. We want to measure:
|
||||
|
||||
- how often full-state posts happen,
|
||||
- how large they are,
|
||||
- how long they take to build,
|
||||
- how long they take to send.
|
||||
|
||||
### Work
|
||||
|
||||
- [x] Measure `getStateToPostToWebview()` build time.
|
||||
- [x] Measure serialized payload size.
|
||||
- [x] Measure send time.
|
||||
- [x] Feed these metrics into per-request latency telemetry.
|
||||
|
||||
### Detailed code changes
|
||||
|
||||
- In `src/core/controller/index.ts`:
|
||||
- [x] add `flushStateToWebview()` that records build duration and delivery stats.
|
||||
- [x] call `task?.noteStateUpdateMetrics(...)` with build duration, payload bytes, and send duration.
|
||||
- In `src/core/controller/state/subscribeToState.ts`:
|
||||
- [x] ensure payload byte counting remains available and accurate.
|
||||
|
||||
This step is not just observability polish. It is what lets the team prove that the extracted technique is actually reducing snapshot churn rather than merely moving it around.
|
||||
|
||||
### Tests
|
||||
|
||||
- [x] Unit test: state update metrics are recorded when a flush occurs.
|
||||
- [x] Unit test: payload byte accounting is invoked.
|
||||
|
||||
---
|
||||
|
||||
## Step 5 — Audit and tune state-posting callsites
|
||||
|
||||
### Goal
|
||||
|
||||
Ensure callsites use the right priority and are not silently undermining the scheduler.
|
||||
|
||||
### Mental model
|
||||
|
||||
The scheduler provides the mechanism; callsite audit provides the correctness. Without the audit, some codepaths will still over-post or misuse immediacy.
|
||||
|
||||
### Work
|
||||
|
||||
- [x] Review all major `postStateToWebview()` callsites.
|
||||
- [x] Keep task initialization and state transitions immediate where appropriate.
|
||||
- [x] Allow hot streaming churn to use normal or low priority.
|
||||
|
||||
### Detailed code changes
|
||||
|
||||
- In controller and task flows, inspect callsites involving:
|
||||
- [x] task init / resume,
|
||||
- [x] cancel / clear,
|
||||
- [x] auth state changes,
|
||||
- [x] usage updates,
|
||||
- [x] focus-chain metadata,
|
||||
- [x] background command metadata,
|
||||
- [x] periodic stream-related updates.
|
||||
- Where needed, pass explicit priority rather than relying only on defaults.
|
||||
|
||||
The smart move here is to audit callsites with the reference implementation open, because the coalescing behavior only makes sense in context. The subtle value of the reference branch is that it already captures where the team discovered hidden urgency requirements.
|
||||
|
||||
### Tests
|
||||
|
||||
- [x] Regression test: task init still hydrates the UI immediately.
|
||||
- [x] Regression test: cancel/clear still updates UI promptly.
|
||||
- [x] Regression test: auth/settings flows are not delayed in a user-visible bad way.
|
||||
|
||||
---
|
||||
|
||||
## Step 6 — Validate behavior during high-churn scenarios, especially large-file writes
|
||||
|
||||
### Goal
|
||||
|
||||
Verify that the feature materially helps the scenarios that produce the most snapshot churn.
|
||||
|
||||
### Mental model
|
||||
|
||||
Large-file writes often produce:
|
||||
|
||||
- repeated tool/progress updates,
|
||||
- repeated request metadata changes,
|
||||
- repeated message-state changes,
|
||||
- possible repeated snapshot posts.
|
||||
|
||||
This technique should reduce the transport overhead from that churn even if the write tool itself remains functionally the same.
|
||||
|
||||
That is the key link to the large-file-write scenario: even when the tool work is mostly backend-side, the surrounding UI state churn can still create a slow, noisy experience if snapshots are over-posted.
|
||||
|
||||
### Work
|
||||
|
||||
- [x] Add or extend validation scenarios for high-churn task execution.
|
||||
- [x] Compare full-state update count enabled vs disabled.
|
||||
- [x] Compare payload-byte totals enabled vs disabled.
|
||||
- [x] Add a heavier long-running eval/harness scenario scaffold for manual or CI validation.
|
||||
|
||||
### Tests
|
||||
|
||||
- [x] Validation harness scenario: coalescing reduces full-state count in long-running tasks.
|
||||
- [x] Validation harness scenario: remote mode benefits more than local mode.
|
||||
- [x] Regression test: final state still fully converges after coalesced posting.
|
||||
|
||||
---
|
||||
|
||||
## Step 7 — Add remote-aware cadence and tuning controls
|
||||
|
||||
### Goal
|
||||
|
||||
Choose defaults that are appropriate for remote environments and safe to tune during rollout.
|
||||
|
||||
### Mental model
|
||||
|
||||
Remote mode should intentionally trade a little more coalescing for much less transport thrash. That should be tunable, not hard-coded forever.
|
||||
|
||||
### Work
|
||||
|
||||
- [x] Centralize cadence defaults in `latency.ts`.
|
||||
- [x] Expose env var overrides for local and remote state cadence.
|
||||
- [x] Document these in `.env.example`.
|
||||
|
||||
### Detailed code changes
|
||||
|
||||
- In `src/core/task/latency.ts`:
|
||||
- [x] add/preserve `getStateUpdateCadenceMs(isRemoteWorkspace, priority)`.
|
||||
- In `.env.example`:
|
||||
- [x] document state update cadence overrides.
|
||||
|
||||
Keep the tuning hooks aligned with the reference implementation so extracted behavior can be compared apples-to-apples during rollout and validation.
|
||||
|
||||
### Tests
|
||||
|
||||
- [x] Unit test: remote defaults are more conservative than local defaults.
|
||||
- [x] Unit test: env override behavior works as expected.
|
||||
|
||||
---
|
||||
|
||||
## Step 8 — Verify product-surface safety outside the streaming path
|
||||
|
||||
### Goal
|
||||
|
||||
Make sure coalescing full-state snapshots does not degrade other product surfaces.
|
||||
|
||||
### Mental model
|
||||
|
||||
The branch goal is not just “remote chat feels better.” It is “remote workspaces improve without harming the rest of Cline.” State posting is used by many surfaces, so safety checks matter.
|
||||
|
||||
### Work
|
||||
|
||||
- [x] Test history/task switching behavior.
|
||||
- [x] Test settings/auth-related UI updates.
|
||||
- [x] Test onboarding / welcome state hydration.
|
||||
- [x] Test focus-chain/background-command metadata behavior.
|
||||
|
||||
### Tests
|
||||
|
||||
- [x] Regression test: state hydration on startup remains correct.
|
||||
- [x] Regression test: switching tasks shows the correct snapshot.
|
||||
- [x] Regression test: metadata deltas + snapshot flow do not leave stale UI after task switch.
|
||||
|
||||
---
|
||||
|
||||
## Developer Checklist Summary
|
||||
|
||||
- [x] Define snapshot posting priorities
|
||||
- [x] Build controller-level scheduler
|
||||
- [x] Route `postStateToWebview()` through scheduler
|
||||
- [x] Instrument build/send/payload metrics
|
||||
- [x] Audit state-posting callsites
|
||||
- [ ] Validate high-churn and large-file-write scenarios
|
||||
- [x] Add remote-aware cadence tuning
|
||||
- [ ] Verify non-streaming product-surface safety
|
||||
|
||||
## Progress Notes
|
||||
|
||||
- Added stronger `StateUpdateScheduler` regression coverage for disposal-during-flush and `flushNow()` draining semantics.
|
||||
- Added controller regression coverage that keeps mode switches, auth callback hydration, and background-command state updates on the intended priority paths.
|
||||
- Added controller regression coverage for task reinitialization and task-history deletion flows during task switching.
|
||||
- Added subscribe-to-state coverage to verify startup subscribers receive the latest full snapshot immediately.
|
||||
- Confirmed that state post metrics are currently accumulated on `Task.requestLatencyMetrics`, but are not yet forwarded through `getTaskCompletionTelemetry()` / `captureTaskCompleted()`, so Step 6 still needs explicit validation plumbing rather than just more assertions.
|
||||
- Wired state-post metrics through task completion telemetry so high-churn validation can compare state post count, serialized bytes, build duration, and send duration across runs.
|
||||
- Added task-switch regression coverage to verify active-task snapshot data wins over stale metadata after switching tasks.
|
||||
- Added scheduler burst-validation coverage showing coalesced normal-priority updates reduce flush count versus immediate flushing, and that remote cadence coalesces more aggressively than local cadence under the same burst pattern.
|
||||
- Added controller regression coverage verifying that multiple coalesced streaming-era state updates still flush the latest full snapshot rather than an intermediate stale snapshot.
|
||||
- Added controller burst-comparison coverage showing coalesced streaming-era snapshot delivery sends fewer full-state updates and fewer total serialized bytes than equivalent immediate flushing for the same update burst.
|
||||
- Added a heavier smoke/eval scenario scaffold (`09-state-coalescing-burst`) plus README guidance for comparing coalescing-enabled and near-immediate cadence runs with the new telemetry fields.
|
||||
|
||||
## Recommended Next Validation Step
|
||||
|
||||
The next coherent increment should be to extend task-completion telemetry (or an equivalent validation harness payload) so it includes:
|
||||
|
||||
- full-state post count,
|
||||
- total serialized state bytes,
|
||||
- total build duration for state snapshots,
|
||||
- and total send duration for state snapshots.
|
||||
|
||||
With that wiring in place, Step 6 can be closed by running a long-lived / high-churn task scenario and comparing coalescing-on versus coalescing-disabled behavior, ideally across both local and remote workspace environments.
|
||||
|
||||
---
|
||||
|
||||
## Final Mental Model Recap
|
||||
|
||||
- **Full state is for hydration and synchronization.**
|
||||
- **It is too expensive to be the main streaming transport in remote mode.**
|
||||
- **Coalescing snapshots preserves correctness while reducing transport thrash.**
|
||||
|
||||
That is the idea developers should keep front-of-mind while implementing this technique.
|
||||
@@ -73,6 +73,7 @@ cline auth -p cline -k "$CLINE_API_KEY" -m anthropic/claude-sonnet-4.5
|
||||
| 05-typescript-function | Generate TypeScript | Code generation |
|
||||
| 06-apply-patch | Edit file (GPT-5) | `apply_patch` tool, native tool calling |
|
||||
| 07-edit-gemini | Edit file (Gemini) | Gemini model variant |
|
||||
| 09-state-coalescing-burst | Multi-step churn project | Heavier long-running scenario for state coalescing validation |
|
||||
|
||||
### Per-Scenario Models
|
||||
|
||||
@@ -114,6 +115,31 @@ Shows `pass@1` when trials < 3, `pass@3` otherwise.
|
||||
```
|
||||
3. (Optional) Add `template/` directory with starting files
|
||||
|
||||
## State Coalescing Validation Scenario
|
||||
|
||||
The `09-state-coalescing-burst` scenario is intended as a heavier smoke/eval scaffold for the controller full-state coalescing work. It drives a longer multi-file task with multiple creation and refinement steps so the extension emits more task-state churn than the simpler smoke tests.
|
||||
|
||||
Recommended usage when validating the coalescing technique:
|
||||
|
||||
```bash
|
||||
# Baseline / coalescing-enabled run
|
||||
npm run eval:smoke:run -- --scenario 09-state-coalescing-burst --trials 1
|
||||
|
||||
# Comparison run with more aggressive immediate-like cadence
|
||||
CLINE_STATE_UPDATE_CADENCE_MS=0 \
|
||||
CLINE_REMOTE_STATE_UPDATE_CADENCE_MS=0 \
|
||||
CLINE_STATE_UPDATE_LOW_CADENCE_MS=0 \
|
||||
CLINE_REMOTE_STATE_UPDATE_LOW_CADENCE_MS=0 \
|
||||
npm run eval:smoke:run -- --scenario 09-state-coalescing-burst --trials 1
|
||||
```
|
||||
|
||||
This scenario does not automatically assert telemetry deltas yet, but it provides a repeatable long-running task for comparing `task.completed` telemetry fields such as:
|
||||
|
||||
- `statePostCount`
|
||||
- `statePostSerializedBytes`
|
||||
- `statePostBuildDurationMs`
|
||||
- `statePostSendDurationMs`
|
||||
|
||||
## CI Integration
|
||||
|
||||
Smoke tests run automatically via `.github/workflows/cline-evals-regression.yml`.
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "State coalescing burst scenario",
|
||||
"description": "Exercises a longer multi-step project creation/edit flow to generate heavier UI state churn for coalescing validation",
|
||||
"prompt": "Create a tiny static site project with these files: index.html, styles.css, app.js, README.md, and package.json. The site should render a task dashboard with three task cards, simple styling, and a button that toggles completed state in JavaScript. Include a short README explaining the file layout and how the toggle works. After creating the files, review them and make one more refinement pass to improve naming consistency and polish the README.",
|
||||
"expectedFiles": [
|
||||
"index.html",
|
||||
"styles.css",
|
||||
"app.js",
|
||||
"README.md",
|
||||
"package.json"
|
||||
],
|
||||
"expectedContent": [
|
||||
{
|
||||
"file": "index.html",
|
||||
"contains": "task"
|
||||
},
|
||||
{
|
||||
"file": "styles.css",
|
||||
"contains": ".task"
|
||||
},
|
||||
{
|
||||
"file": "app.js",
|
||||
"contains": "toggle"
|
||||
},
|
||||
{
|
||||
"file": "README.md",
|
||||
"contains": "file"
|
||||
},
|
||||
{
|
||||
"file": "package.json",
|
||||
"contains": "name"
|
||||
}
|
||||
],
|
||||
"timeout": 240
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
import { strict as assert } from "assert"
|
||||
import { describe, it } from "mocha"
|
||||
import { StateUpdateScheduler } from "./StateUpdateScheduler"
|
||||
|
||||
class FakeTimerController {
|
||||
private now = 0
|
||||
private nextId = 1
|
||||
private timers = new Map<number, { time: number; callback: () => void }>()
|
||||
|
||||
setTimeout = (callback: () => void, delay: number) => {
|
||||
const id = this.nextId++
|
||||
this.timers.set(id, { time: this.now + delay, callback })
|
||||
return id as unknown as ReturnType<typeof setTimeout>
|
||||
}
|
||||
|
||||
clearTimeout = (handle: ReturnType<typeof setTimeout>) => {
|
||||
this.timers.delete(handle as unknown as number)
|
||||
}
|
||||
|
||||
advance(ms: number) {
|
||||
this.now += ms
|
||||
let ran = true
|
||||
while (ran) {
|
||||
ran = false
|
||||
for (const [id, timer] of [...this.timers.entries()].sort((a, b) => a[1].time - b[1].time)) {
|
||||
if (timer.time <= this.now) {
|
||||
this.timers.delete(id)
|
||||
timer.callback()
|
||||
ran = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getNow = () => this.now
|
||||
}
|
||||
|
||||
describe("StateUpdateScheduler", () => {
|
||||
it("reduces flush count for bursty normal-priority updates compared with immediate flushes", async () => {
|
||||
const immediateTimer = new FakeTimerController()
|
||||
let immediateFlushCount = 0
|
||||
const immediateScheduler = new StateUpdateScheduler({
|
||||
flush: async () => {
|
||||
immediateFlushCount += 1
|
||||
},
|
||||
getDelayMs: () => 0,
|
||||
setTimeoutFn: immediateTimer.setTimeout as typeof setTimeout,
|
||||
clearTimeoutFn: immediateTimer.clearTimeout as typeof clearTimeout,
|
||||
getNow: immediateTimer.getNow,
|
||||
})
|
||||
|
||||
for (let i = 0; i < 6; i++) {
|
||||
await immediateScheduler.flushNow()
|
||||
}
|
||||
|
||||
const coalescedTimer = new FakeTimerController()
|
||||
let coalescedFlushCount = 0
|
||||
const coalescedScheduler = new StateUpdateScheduler({
|
||||
flush: async () => {
|
||||
coalescedFlushCount += 1
|
||||
},
|
||||
getDelayMs: () => 25,
|
||||
setTimeoutFn: coalescedTimer.setTimeout as typeof setTimeout,
|
||||
clearTimeoutFn: coalescedTimer.clearTimeout as typeof clearTimeout,
|
||||
getNow: coalescedTimer.getNow,
|
||||
})
|
||||
|
||||
for (let i = 0; i < 6; i++) {
|
||||
coalescedScheduler.requestFlush("normal")
|
||||
coalescedTimer.advance(5)
|
||||
await Promise.resolve()
|
||||
}
|
||||
|
||||
coalescedTimer.advance(25)
|
||||
await Promise.resolve()
|
||||
|
||||
assert.equal(immediateFlushCount, 6)
|
||||
assert.equal(coalescedFlushCount, 2)
|
||||
assert.ok(coalescedFlushCount < immediateFlushCount)
|
||||
})
|
||||
|
||||
it("coalesces more aggressively with remote cadence than with local cadence under the same burst", async () => {
|
||||
const runBurst = async (delayMs: number) => {
|
||||
const timer = new FakeTimerController()
|
||||
let flushCount = 0
|
||||
const scheduler = new StateUpdateScheduler({
|
||||
flush: async () => {
|
||||
flushCount += 1
|
||||
},
|
||||
getDelayMs: () => delayMs,
|
||||
setTimeoutFn: timer.setTimeout as typeof setTimeout,
|
||||
clearTimeoutFn: timer.clearTimeout as typeof clearTimeout,
|
||||
getNow: timer.getNow,
|
||||
})
|
||||
|
||||
for (let i = 0; i < 8; i++) {
|
||||
scheduler.requestFlush("normal")
|
||||
timer.advance(20)
|
||||
await Promise.resolve()
|
||||
}
|
||||
|
||||
timer.advance(delayMs)
|
||||
await Promise.resolve()
|
||||
return flushCount
|
||||
}
|
||||
|
||||
const localFlushCount = await runBurst(16)
|
||||
const remoteFlushCount = await runBurst(110)
|
||||
|
||||
assert.equal(localFlushCount, 8)
|
||||
assert.equal(remoteFlushCount, 2)
|
||||
assert.ok(remoteFlushCount < localFlushCount)
|
||||
})
|
||||
|
||||
it("coalesces repeated normal-priority requests into one flush", async () => {
|
||||
const timer = new FakeTimerController()
|
||||
let flushCount = 0
|
||||
const scheduler = new StateUpdateScheduler({
|
||||
flush: async () => {
|
||||
flushCount += 1
|
||||
},
|
||||
getDelayMs: () => 50,
|
||||
setTimeoutFn: timer.setTimeout as typeof setTimeout,
|
||||
clearTimeoutFn: timer.clearTimeout as typeof clearTimeout,
|
||||
getNow: timer.getNow,
|
||||
})
|
||||
|
||||
scheduler.requestFlush("normal")
|
||||
scheduler.requestFlush("normal")
|
||||
scheduler.requestFlush("low")
|
||||
|
||||
assert.equal(flushCount, 0)
|
||||
timer.advance(49)
|
||||
assert.equal(flushCount, 0)
|
||||
timer.advance(1)
|
||||
await Promise.resolve()
|
||||
|
||||
assert.equal(flushCount, 1)
|
||||
})
|
||||
|
||||
it("flushes immediately when requested", async () => {
|
||||
const timer = new FakeTimerController()
|
||||
let flushCount = 0
|
||||
const scheduler = new StateUpdateScheduler({
|
||||
flush: async () => {
|
||||
flushCount += 1
|
||||
},
|
||||
getDelayMs: () => 50,
|
||||
setTimeoutFn: timer.setTimeout as typeof setTimeout,
|
||||
clearTimeoutFn: timer.clearTimeout as typeof clearTimeout,
|
||||
getNow: timer.getNow,
|
||||
})
|
||||
|
||||
scheduler.requestFlush("normal")
|
||||
await scheduler.flushNow()
|
||||
|
||||
assert.equal(flushCount, 1)
|
||||
timer.advance(100)
|
||||
await Promise.resolve()
|
||||
assert.equal(flushCount, 1)
|
||||
})
|
||||
|
||||
it("runs one follow-up flush when updates arrive during an active flush", async () => {
|
||||
const timer = new FakeTimerController()
|
||||
let flushCount = 0
|
||||
let releaseFlush: (() => void) | undefined
|
||||
const scheduler = new StateUpdateScheduler({
|
||||
flush: async () => {
|
||||
flushCount += 1
|
||||
if (flushCount === 1) {
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseFlush = resolve
|
||||
})
|
||||
}
|
||||
},
|
||||
getDelayMs: () => 10,
|
||||
setTimeoutFn: timer.setTimeout as typeof setTimeout,
|
||||
clearTimeoutFn: timer.clearTimeout as typeof clearTimeout,
|
||||
getNow: timer.getNow,
|
||||
})
|
||||
|
||||
scheduler.requestFlush("normal")
|
||||
timer.advance(10)
|
||||
await Promise.resolve()
|
||||
assert.equal(flushCount, 1)
|
||||
|
||||
scheduler.requestFlush("normal")
|
||||
releaseFlush?.()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
timer.advance(10)
|
||||
await Promise.resolve()
|
||||
|
||||
assert.equal(flushCount, 2)
|
||||
})
|
||||
|
||||
it("dispose clears scheduled work", async () => {
|
||||
let flushCount = 0
|
||||
let timerCleared = false
|
||||
const scheduler = new StateUpdateScheduler({
|
||||
flush: async () => {
|
||||
flushCount += 1
|
||||
},
|
||||
getDelayMs: () => 10,
|
||||
setTimeoutFn: (() => 1 as any) as unknown as typeof setTimeout,
|
||||
clearTimeoutFn: (() => {
|
||||
timerCleared = true
|
||||
}) as typeof clearTimeout,
|
||||
})
|
||||
|
||||
scheduler.requestFlush("normal")
|
||||
await scheduler.dispose()
|
||||
await scheduler.flushNow()
|
||||
|
||||
assert.equal(timerCleared, true)
|
||||
assert.equal(flushCount, 0)
|
||||
})
|
||||
|
||||
it("does not schedule a follow-up flush after disposal during an active flush", async () => {
|
||||
const timer = new FakeTimerController()
|
||||
let flushCount = 0
|
||||
let resolveFlush: (() => void) | undefined
|
||||
const scheduler = new StateUpdateScheduler({
|
||||
flush: async () => {
|
||||
flushCount += 1
|
||||
await new Promise<void>((resolve) => {
|
||||
resolveFlush = resolve
|
||||
})
|
||||
},
|
||||
getDelayMs: () => 10,
|
||||
setTimeoutFn: timer.setTimeout as typeof setTimeout,
|
||||
clearTimeoutFn: timer.clearTimeout as typeof clearTimeout,
|
||||
getNow: timer.getNow,
|
||||
})
|
||||
|
||||
scheduler.requestFlush("normal")
|
||||
timer.advance(10)
|
||||
await Promise.resolve()
|
||||
assert.equal(flushCount, 1)
|
||||
|
||||
scheduler.requestFlush("normal")
|
||||
await scheduler.dispose()
|
||||
resolveFlush?.()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
timer.advance(20)
|
||||
await Promise.resolve()
|
||||
assert.equal(flushCount, 1)
|
||||
})
|
||||
|
||||
it("flushNow drains pending updates immediately after the current flush completes", async () => {
|
||||
const timer = new FakeTimerController()
|
||||
let flushCount = 0
|
||||
let resolveFlush: (() => void) | undefined
|
||||
const scheduler = new StateUpdateScheduler({
|
||||
flush: async () => {
|
||||
flushCount += 1
|
||||
await new Promise<void>((resolve) => {
|
||||
resolveFlush = resolve
|
||||
})
|
||||
},
|
||||
getDelayMs: () => 25,
|
||||
setTimeoutFn: timer.setTimeout as typeof setTimeout,
|
||||
clearTimeoutFn: timer.clearTimeout as typeof clearTimeout,
|
||||
getNow: timer.getNow,
|
||||
})
|
||||
|
||||
scheduler.requestFlush("normal")
|
||||
timer.advance(25)
|
||||
await Promise.resolve()
|
||||
assert.equal(flushCount, 1)
|
||||
|
||||
scheduler.requestFlush("normal")
|
||||
const drainPromise = scheduler.flushNow()
|
||||
resolveFlush?.()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
assert.equal(flushCount, 2)
|
||||
|
||||
resolveFlush?.()
|
||||
await drainPromise
|
||||
timer.advance(50)
|
||||
await Promise.resolve()
|
||||
assert.equal(flushCount, 2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,147 @@
|
||||
type StateUpdatePriority = "immediate" | "normal" | "low"
|
||||
|
||||
type StateUpdateSchedulerOptions = {
|
||||
flush: () => Promise<void>
|
||||
getDelayMs: (priority: StateUpdatePriority) => number
|
||||
setTimeoutFn?: typeof setTimeout
|
||||
clearTimeoutFn?: typeof clearTimeout
|
||||
onFlushError?: (error: unknown) => void
|
||||
getNow?: () => number
|
||||
metrics?: {
|
||||
onFlushStarted?: (priority: StateUpdatePriority) => void
|
||||
onFlushCompleted?: (durationMs: number, priority: StateUpdatePriority) => void
|
||||
}
|
||||
}
|
||||
|
||||
export class StateUpdateScheduler {
|
||||
private scheduledTimer: ReturnType<typeof setTimeout> | undefined
|
||||
private pendingPriority: StateUpdatePriority | undefined
|
||||
private flushInProgress = false
|
||||
private disposed = false
|
||||
private pendingWhileFlushing = false
|
||||
|
||||
private readonly flush: () => Promise<void>
|
||||
private readonly getDelayMs: (priority: StateUpdatePriority) => number
|
||||
private readonly setTimeoutFn: typeof setTimeout
|
||||
private readonly clearTimeoutFn: typeof clearTimeout
|
||||
private readonly onFlushError?: (error: unknown) => void
|
||||
private readonly getNow: () => number
|
||||
private readonly metrics?: StateUpdateSchedulerOptions["metrics"]
|
||||
|
||||
constructor(options: StateUpdateSchedulerOptions) {
|
||||
this.flush = options.flush
|
||||
this.getDelayMs = options.getDelayMs
|
||||
this.setTimeoutFn = options.setTimeoutFn ?? setTimeout
|
||||
this.clearTimeoutFn = options.clearTimeoutFn ?? clearTimeout
|
||||
this.onFlushError = options.onFlushError
|
||||
this.getNow = options.getNow ?? (() => performance.now())
|
||||
this.metrics = options.metrics
|
||||
}
|
||||
|
||||
requestFlush(priority: StateUpdatePriority = "normal"): void {
|
||||
if (this.disposed) {
|
||||
return
|
||||
}
|
||||
|
||||
this.pendingPriority = this.mergePriority(this.pendingPriority, priority)
|
||||
|
||||
if (this.flushInProgress) {
|
||||
this.pendingWhileFlushing = true
|
||||
return
|
||||
}
|
||||
|
||||
if (this.pendingPriority === "immediate") {
|
||||
if (this.scheduledTimer) {
|
||||
this.clearTimeoutFn(this.scheduledTimer)
|
||||
this.scheduledTimer = undefined
|
||||
}
|
||||
void this.runFlushCycle()
|
||||
return
|
||||
}
|
||||
|
||||
if (this.scheduledTimer) {
|
||||
return
|
||||
}
|
||||
|
||||
const nextPriority = this.pendingPriority ?? "normal"
|
||||
const delayMs = this.getDelayMs(nextPriority)
|
||||
this.scheduledTimer = this.setTimeoutFn(() => {
|
||||
this.scheduledTimer = undefined
|
||||
void this.runFlushCycle()
|
||||
}, delayMs)
|
||||
}
|
||||
|
||||
async flushNow(): Promise<void> {
|
||||
if (this.disposed) {
|
||||
return
|
||||
}
|
||||
|
||||
this.pendingPriority = this.mergePriority(this.pendingPriority, "immediate")
|
||||
if (this.scheduledTimer) {
|
||||
this.clearTimeoutFn(this.scheduledTimer)
|
||||
this.scheduledTimer = undefined
|
||||
}
|
||||
await this.runFlushCycle()
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
this.disposed = true
|
||||
if (this.scheduledTimer) {
|
||||
this.clearTimeoutFn(this.scheduledTimer)
|
||||
this.scheduledTimer = undefined
|
||||
}
|
||||
this.pendingPriority = undefined
|
||||
this.pendingWhileFlushing = false
|
||||
}
|
||||
|
||||
private async runFlushCycle(): Promise<void> {
|
||||
if (this.disposed || this.flushInProgress || !this.pendingPriority) {
|
||||
return
|
||||
}
|
||||
|
||||
this.flushInProgress = true
|
||||
const priority = this.pendingPriority
|
||||
this.pendingPriority = undefined
|
||||
this.pendingWhileFlushing = false
|
||||
const startedAt = this.getNow()
|
||||
this.metrics?.onFlushStarted?.(priority)
|
||||
|
||||
try {
|
||||
await this.flush()
|
||||
this.metrics?.onFlushCompleted?.(Math.max(0, this.getNow() - startedAt), priority)
|
||||
} catch (error) {
|
||||
this.onFlushError?.(error)
|
||||
} finally {
|
||||
this.flushInProgress = false
|
||||
}
|
||||
|
||||
if (this.disposed) {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.pendingPriority || this.pendingWhileFlushing) {
|
||||
const priorityToRun = this.pendingPriority
|
||||
if (priorityToRun === "immediate") {
|
||||
await this.runFlushCycle()
|
||||
} else {
|
||||
this.requestFlush(priorityToRun ?? "normal")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private mergePriority(current: StateUpdatePriority | undefined, next: StateUpdatePriority): StateUpdatePriority {
|
||||
if (!current) {
|
||||
return next
|
||||
}
|
||||
|
||||
const rank: Record<StateUpdatePriority, number> = {
|
||||
low: 0,
|
||||
normal: 1,
|
||||
immediate: 2,
|
||||
}
|
||||
|
||||
return rank[next] > rank[current] ? next : current
|
||||
}
|
||||
}
|
||||
|
||||
export type { StateUpdatePriority }
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Anthropic } from "@anthropic-ai/sdk"
|
||||
import { buildApiHandler } from "@core/api"
|
||||
import { getHooksEnabledSafe } from "@core/hooks/hooks-utils"
|
||||
import { getStateUpdateCadenceMs, isRemoteWorkspaceEnvironment } from "@core/task/latency"
|
||||
import { tryAcquireTaskLockWithRetry } from "@core/task/TaskLockUtils"
|
||||
import { detectWorkspaceRoots } from "@core/workspace/detection"
|
||||
import { setupWorkspaceManager } from "@core/workspace/setup"
|
||||
@@ -56,6 +57,7 @@ import { Task } from "../task"
|
||||
import { sendMcpMarketplaceCatalogEvent } from "./mcp/subscribeToMcpMarketplaceCatalog"
|
||||
import { getClineOnboardingModels } from "./models/getClineOnboardingModels"
|
||||
import { appendClineStealthModels } from "./models/refreshOpenRouterModels"
|
||||
import { type StateUpdatePriority, StateUpdateScheduler } from "./StateUpdateScheduler"
|
||||
import { checkCliInstallation } from "./state/checkCliInstallation"
|
||||
import { sendStateUpdate } from "./state/subscribeToState"
|
||||
import { sendChatButtonClickedEvent } from "./ui/subscribeToChatButtonClicked"
|
||||
@@ -85,6 +87,9 @@ export class Controller {
|
||||
|
||||
// Timer for periodic remote config fetching
|
||||
private remoteConfigTimer?: NodeJS.Timeout
|
||||
private isRemoteWorkspaceEnvironment = false
|
||||
private readonly stateUpdateScheduler: StateUpdateScheduler
|
||||
private readonly schedulerDebugLoggingEnabled = process.env.CLINE_DEBUG_LATENCY === "1"
|
||||
|
||||
// Public getter for workspace manager with lazy initialization - To get workspaces when task isn't initialized (Used by file mentions)
|
||||
async ensureWorkspaceManager(): Promise<WorkspaceRootManager | undefined> {
|
||||
@@ -118,6 +123,14 @@ export class Controller {
|
||||
}
|
||||
|
||||
constructor(readonly context: ClineExtensionContext) {
|
||||
void HostProvider.env
|
||||
.getHostVersion({})
|
||||
.then((hostVersion) => {
|
||||
this.isRemoteWorkspaceEnvironment = isRemoteWorkspaceEnvironment(hostVersion)
|
||||
})
|
||||
.catch((error) => {
|
||||
Logger.debug(`[Controller] Failed to detect remote workspace state: ${error}`)
|
||||
})
|
||||
Session.reset() // Reset session on controller initialization
|
||||
PromptRegistry.getInstance() // Ensure prompts and tools are registered
|
||||
this.stateManager = StateManager.get()
|
||||
@@ -156,6 +169,24 @@ export class Controller {
|
||||
// Check CLI installation status once on startup
|
||||
checkCliInstallation(this)
|
||||
|
||||
this.stateUpdateScheduler = new StateUpdateScheduler({
|
||||
flush: async () => this.flushStateToWebview(),
|
||||
getDelayMs: (priority) => getStateUpdateCadenceMs(this.isRemoteWorkspaceEnvironment, priority),
|
||||
onFlushError: (error) => Logger.debug(`[Controller] Failed scheduled state flush: ${error}`),
|
||||
metrics: {
|
||||
onFlushStarted: (priority) => {
|
||||
if (this.schedulerDebugLoggingEnabled) {
|
||||
Logger.debug(`[Controller] state flush started (${priority})`)
|
||||
}
|
||||
},
|
||||
onFlushCompleted: (durationMs, priority) => {
|
||||
if (this.schedulerDebugLoggingEnabled) {
|
||||
Logger.debug(`[Controller] state flush completed (${priority}) in ${durationMs}ms`)
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Logger.log("[Controller] ClineProvider instantiated")
|
||||
}
|
||||
|
||||
@@ -172,6 +203,7 @@ export class Controller {
|
||||
}
|
||||
|
||||
await this.clearTask()
|
||||
await this.stateUpdateScheduler.dispose()
|
||||
this.mcpHub.dispose()
|
||||
|
||||
Logger.error("Controller disposed")
|
||||
@@ -259,7 +291,7 @@ export class Controller {
|
||||
// Check if the user has completed enough tasks to no longer be considered a "new user"
|
||||
if (isNewUser && !historyItem && taskHistory && taskHistory.length >= NEW_USER_TASK_COUNT_THRESHOLD) {
|
||||
this.stateManager.setGlobalState("isNewUser", false)
|
||||
await this.postStateToWebview()
|
||||
await this.postStateToWebview({ priority: "immediate" })
|
||||
}
|
||||
|
||||
if (autoApprovalSettings) {
|
||||
@@ -377,6 +409,7 @@ export class Controller {
|
||||
}
|
||||
|
||||
await this.postStateToWebview()
|
||||
await this.postStateToWebview({ priority: "immediate" })
|
||||
|
||||
// Additional safety
|
||||
if (this.task) {
|
||||
@@ -400,7 +433,7 @@ export class Controller {
|
||||
this.task.api = buildApiHandler({ ...apiConfiguration, ulid: this.task.ulid }, modeToSwitchTo)
|
||||
}
|
||||
|
||||
await this.postStateToWebview()
|
||||
await this.postStateToWebview({ priority: "immediate" })
|
||||
|
||||
if (this.task) {
|
||||
if (this.task.taskState.isAwaitingPlanResponse && didSwitchToActMode) {
|
||||
@@ -499,7 +532,7 @@ export class Controller {
|
||||
}
|
||||
this.backgroundCommandRunning = running
|
||||
this.backgroundCommandTaskId = nextTaskId
|
||||
void this.postStateToWebview()
|
||||
void this.postStateToWebview({ priority: "normal" })
|
||||
}
|
||||
|
||||
async cancelBackgroundCommand(): Promise<void> {
|
||||
@@ -550,7 +583,7 @@ export class Controller {
|
||||
this.task.api = buildApiHandler({ ...updatedConfig, ulid: this.task.ulid }, currentMode)
|
||||
}
|
||||
|
||||
await this.postStateToWebview()
|
||||
await this.postStateToWebview({ priority: "immediate" })
|
||||
} catch (error) {
|
||||
Logger.error("Failed to handle auth callback:", error)
|
||||
HostProvider.window.showMessage({
|
||||
@@ -601,7 +634,7 @@ export class Controller {
|
||||
this.task.api = buildApiHandler({ ...updatedConfig, ulid: this.task.ulid }, currentMode)
|
||||
}
|
||||
|
||||
await this.postStateToWebview()
|
||||
await this.postStateToWebview({ priority: "immediate" })
|
||||
} catch (error) {
|
||||
Logger.error("Failed to handle auth callback:", error)
|
||||
HostProvider.window.showMessage({
|
||||
@@ -616,7 +649,7 @@ export class Controller {
|
||||
async handleMcpOAuthCallback(serverHash: string, code: string, state: string | null) {
|
||||
try {
|
||||
await this.mcpHub.completeOAuth(serverHash, code, state)
|
||||
await this.postStateToWebview()
|
||||
await this.postStateToWebview({ priority: "immediate" })
|
||||
HostProvider.window.showMessage({
|
||||
type: ShowMessageType.INFORMATION,
|
||||
message: `Successfully authenticated MCP server`,
|
||||
@@ -837,9 +870,38 @@ export class Controller {
|
||||
return updatedTaskHistory
|
||||
}
|
||||
|
||||
async postStateToWebview() {
|
||||
async postStateToWebview(options?: { priority?: StateUpdatePriority }) {
|
||||
// Priority guidance:
|
||||
// - immediate: task init/clear, task switching, auth changes, mode switches, or any UX-critical hydration.
|
||||
// - normal: streaming-era snapshot churn such as usage/cost updates and background command metadata.
|
||||
// - low: reserved for future non-urgent background state sync where extra batching is acceptable.
|
||||
const priority = options?.priority ?? this.getDefaultStateUpdatePriority()
|
||||
if (priority === "immediate") {
|
||||
await this.stateUpdateScheduler.flushNow()
|
||||
return
|
||||
}
|
||||
|
||||
this.stateUpdateScheduler.requestFlush(priority)
|
||||
}
|
||||
|
||||
private getDefaultStateUpdatePriority(): StateUpdatePriority {
|
||||
if (!this.task) {
|
||||
return "immediate"
|
||||
}
|
||||
|
||||
return this.task.taskState.isStreaming ? "normal" : "immediate"
|
||||
}
|
||||
|
||||
private async flushStateToWebview() {
|
||||
const buildStartedAt = performance.now()
|
||||
const state = await this.getStateToPostToWebview()
|
||||
await sendStateUpdate(state)
|
||||
const buildDurationMs = Math.max(0, performance.now() - buildStartedAt)
|
||||
const deliveryStats = await sendStateUpdate(state)
|
||||
this.task?.noteStateUpdateMetrics({
|
||||
buildDurationMs,
|
||||
serializedBytes: deliveryStats.payloadBytes,
|
||||
sendDurationMs: deliveryStats.sendDurationMs,
|
||||
})
|
||||
}
|
||||
|
||||
async getStateToPostToWebview(): Promise<ExtensionState> {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { strict as assert } from "assert"
|
||||
import { sendStateUpdate, subscribeToState } from "./subscribeToState"
|
||||
|
||||
describe("subscribeToState", () => {
|
||||
it("sends the latest full snapshot immediately when a subscriber connects", async () => {
|
||||
const received: string[] = []
|
||||
const initialState = {
|
||||
mode: "act",
|
||||
clineMessages: [],
|
||||
currentTaskItem: { id: "task-42", task: "Resume me", ts: 42 },
|
||||
}
|
||||
const controller = {
|
||||
getStateToPostToWebview: async () => initialState,
|
||||
} as any
|
||||
|
||||
await subscribeToState(controller, {} as any, async (message) => {
|
||||
received.push(message.stateJson ?? "")
|
||||
})
|
||||
|
||||
assert.equal(received.length, 1)
|
||||
assert.deepEqual(JSON.parse(received[0]), initialState)
|
||||
})
|
||||
|
||||
it("returns delivery stats and broadcasts updates to subscribers", async () => {
|
||||
const received: string[] = []
|
||||
const controller = {
|
||||
getStateToPostToWebview: async () => ({ mode: "act", clineMessages: [] }),
|
||||
} as any
|
||||
|
||||
await subscribeToState(controller, {} as any, async (message) => {
|
||||
received.push(message.stateJson ?? "")
|
||||
})
|
||||
|
||||
assert.equal(received.length, 1)
|
||||
|
||||
const stats = await sendStateUpdate({ mode: "plan", clineMessages: [] } as any)
|
||||
assert.equal(received.length, 2)
|
||||
assert.ok(stats.payloadBytes > 0)
|
||||
assert.ok(stats.sendDurationMs >= 0)
|
||||
assert.ok(stats.subscriberCount >= 1)
|
||||
assert.ok(received[1]?.includes('"mode":"plan"'))
|
||||
})
|
||||
})
|
||||
@@ -9,6 +9,12 @@ import { Controller } from "../index"
|
||||
// Keep track of active state subscriptions
|
||||
const activeStateSubscriptions = new Set<StreamingResponseHandler<State>>()
|
||||
|
||||
export type StateUpdateDeliveryStats = {
|
||||
payloadBytes: number
|
||||
sendDurationMs: number
|
||||
subscriberCount: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to state updates
|
||||
* @param controller The controller instance
|
||||
@@ -58,16 +64,22 @@ export async function subscribeToState(
|
||||
* Send a state update to all active subscribers
|
||||
* @param state The state to send
|
||||
*/
|
||||
export async function sendStateUpdate(state: ExtensionState): Promise<void> {
|
||||
export async function sendStateUpdate(state: ExtensionState): Promise<StateUpdateDeliveryStats> {
|
||||
let stateJson: string
|
||||
try {
|
||||
stateJson = JSON.stringify(state)
|
||||
} catch (error) {
|
||||
Logger.error("Error serializing state update:", error)
|
||||
return
|
||||
return {
|
||||
payloadBytes: 0,
|
||||
sendDurationMs: 0,
|
||||
subscriberCount: activeStateSubscriptions.size,
|
||||
}
|
||||
}
|
||||
|
||||
recordStateSizeTelemetry(Buffer.byteLength(stateJson, "utf8"))
|
||||
const payloadBytes = Buffer.byteLength(stateJson, "utf8")
|
||||
recordStateSizeTelemetry(payloadBytes)
|
||||
const startedAt = performance.now()
|
||||
|
||||
const promises = Array.from(activeStateSubscriptions).map(async (responseStream) => {
|
||||
try {
|
||||
@@ -84,6 +96,12 @@ export async function sendStateUpdate(state: ExtensionState): Promise<void> {
|
||||
})
|
||||
|
||||
await Promise.all(promises)
|
||||
|
||||
return {
|
||||
payloadBytes,
|
||||
sendDurationMs: Math.max(0, performance.now() - startedAt),
|
||||
subscriberCount: activeStateSubscriptions.size,
|
||||
}
|
||||
}
|
||||
|
||||
function recordStateSizeTelemetry(sizeBytes: number): void {
|
||||
|
||||
@@ -7,6 +7,10 @@ export class TaskState {
|
||||
// Task-level timing
|
||||
taskStartTimeMs = Date.now()
|
||||
taskFirstTokenTimeMs?: number
|
||||
statePostCount = 0
|
||||
statePostBuildDurationMs = 0
|
||||
statePostSerializedBytes = 0
|
||||
statePostSendDurationMs = 0
|
||||
|
||||
// Streaming flags
|
||||
isStreaming = false
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { strict as assert } from "assert"
|
||||
import { afterEach, describe, it } from "mocha"
|
||||
import { getStateUpdateCadenceMs, isRemoteWorkspaceEnvironment } from "../latency"
|
||||
|
||||
describe("task latency helpers", () => {
|
||||
afterEach(() => {
|
||||
delete process.env.CLINE_STATE_UPDATE_CADENCE_MS
|
||||
delete process.env.CLINE_REMOTE_STATE_UPDATE_CADENCE_MS
|
||||
delete process.env.CLINE_STATE_UPDATE_LOW_CADENCE_MS
|
||||
delete process.env.CLINE_REMOTE_STATE_UPDATE_LOW_CADENCE_MS
|
||||
})
|
||||
|
||||
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 state update cadences", () => {
|
||||
assert.equal(getStateUpdateCadenceMs(false, "immediate"), 0)
|
||||
assert.equal(getStateUpdateCadenceMs(false, "normal"), 16)
|
||||
assert.equal(getStateUpdateCadenceMs(true, "normal"), 110)
|
||||
assert.equal(getStateUpdateCadenceMs(true, "low"), 150)
|
||||
})
|
||||
|
||||
it("respects cadence overrides from environment variables", () => {
|
||||
process.env.CLINE_STATE_UPDATE_CADENCE_MS = "18"
|
||||
process.env.CLINE_REMOTE_STATE_UPDATE_CADENCE_MS = "99"
|
||||
process.env.CLINE_STATE_UPDATE_LOW_CADENCE_MS = "33"
|
||||
process.env.CLINE_REMOTE_STATE_UPDATE_LOW_CADENCE_MS = "144"
|
||||
|
||||
assert.equal(getStateUpdateCadenceMs(false, "normal"), 18)
|
||||
assert.equal(getStateUpdateCadenceMs(true, "normal"), 99)
|
||||
assert.equal(getStateUpdateCadenceMs(false, "low"), 33)
|
||||
assert.equal(getStateUpdateCadenceMs(true, "low"), 144)
|
||||
})
|
||||
})
|
||||
@@ -253,6 +253,12 @@ export class Task {
|
||||
|
||||
// Task Locking (Sqlite)
|
||||
private taskLockAcquired: boolean
|
||||
private requestLatencyMetrics = {
|
||||
statePostCount: 0,
|
||||
statePostBuildDurationMs: 0,
|
||||
statePostSerializedBytes: 0,
|
||||
statePostSendDurationMs: 0,
|
||||
}
|
||||
|
||||
// Command executor for running shell commands (extracted from executeCommandTool)
|
||||
private commandExecutor!: CommandExecutor
|
||||
@@ -569,6 +575,17 @@ export class Task {
|
||||
)
|
||||
}
|
||||
|
||||
public noteStateUpdateMetrics(metrics: { buildDurationMs: number; serializedBytes: number; sendDurationMs: number }) {
|
||||
this.requestLatencyMetrics.statePostCount += 1
|
||||
this.requestLatencyMetrics.statePostBuildDurationMs += metrics.buildDurationMs
|
||||
this.requestLatencyMetrics.statePostSerializedBytes += metrics.serializedBytes
|
||||
this.requestLatencyMetrics.statePostSendDurationMs += metrics.sendDurationMs
|
||||
this.taskState.statePostCount += 1
|
||||
this.taskState.statePostBuildDurationMs += metrics.buildDurationMs
|
||||
this.taskState.statePostSerializedBytes += metrics.serializedBytes
|
||||
this.taskState.statePostSendDurationMs += metrics.sendDurationMs
|
||||
}
|
||||
|
||||
// Communicate with webview
|
||||
|
||||
// partial has three valid states true (partial message), false (completion of partial message), undefined (individual complete message)
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { StateUpdatePriority } from "@core/controller/StateUpdateScheduler"
|
||||
|
||||
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 getStateUpdateCadenceMs(isRemoteWorkspace: boolean, priority: StateUpdatePriority): 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
|
||||
}
|
||||
@@ -18,6 +18,10 @@ export function getTaskCompletionTelemetry(config: TaskConfig) {
|
||||
apiFormat: model.info.apiFormat,
|
||||
timeToFirstTokenMs: config.taskState.taskFirstTokenTimeMs,
|
||||
durationMs,
|
||||
statePostCount: config.taskState.statePostCount,
|
||||
statePostBuildDurationMs: config.taskState.statePostBuildDurationMs,
|
||||
statePostSerializedBytes: config.taskState.statePostSerializedBytes,
|
||||
statePostSendDurationMs: config.taskState.statePostSendDurationMs,
|
||||
mode: currentMode,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -705,6 +705,10 @@ export class TelemetryService {
|
||||
apiFormat?: ApiFormat
|
||||
timeToFirstTokenMs?: number
|
||||
durationMs?: number
|
||||
statePostCount?: number
|
||||
statePostBuildDurationMs?: number
|
||||
statePostSerializedBytes?: number
|
||||
statePostSendDurationMs?: number
|
||||
mode: Mode
|
||||
},
|
||||
) {
|
||||
@@ -719,6 +723,10 @@ export class TelemetryService {
|
||||
apiFormatName,
|
||||
timeToFirstTokenMs: args?.timeToFirstTokenMs,
|
||||
durationMs: args?.durationMs,
|
||||
statePostCount: args?.statePostCount,
|
||||
statePostBuildDurationMs: args?.statePostBuildDurationMs,
|
||||
statePostSerializedBytes: args?.statePostSerializedBytes,
|
||||
statePostSendDurationMs: args?.statePostSendDurationMs,
|
||||
mode: args?.mode,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -304,6 +304,10 @@ describe("TelemetryService metrics", () => {
|
||||
apiFormat: ApiFormat.OPENAI_RESPONSES,
|
||||
timeToFirstTokenMs: 350,
|
||||
durationMs: 2100,
|
||||
statePostCount: 6,
|
||||
statePostBuildDurationMs: 48,
|
||||
statePostSerializedBytes: 4096,
|
||||
statePostSendDurationMs: 21,
|
||||
mode: "act",
|
||||
})
|
||||
|
||||
@@ -317,6 +321,10 @@ describe("TelemetryService metrics", () => {
|
||||
assert.strictEqual(completionEvent?.properties?.apiFormatName, "OPENAI_RESPONSES")
|
||||
assert.strictEqual(completionEvent?.properties?.timeToFirstTokenMs, 350)
|
||||
assert.strictEqual(completionEvent?.properties?.durationMs, 2100)
|
||||
assert.strictEqual(completionEvent?.properties?.statePostCount, 6)
|
||||
assert.strictEqual(completionEvent?.properties?.statePostBuildDurationMs, 48)
|
||||
assert.strictEqual(completionEvent?.properties?.statePostSerializedBytes, 4096)
|
||||
assert.strictEqual(completionEvent?.properties?.statePostSendDurationMs, 21)
|
||||
|
||||
const ttftMetric = provider.histograms.find((entry) => entry.name === TelemetryService.METRICS.API.TTFT_SECONDS)
|
||||
assert.ok(ttftMetric)
|
||||
|
||||
@@ -0,0 +1,483 @@
|
||||
import { afterEach, before, beforeEach, describe, it } from "mocha"
|
||||
import "should"
|
||||
import { Controller } from "@core/controller"
|
||||
import * as sinon from "sinon"
|
||||
import { setTimeout as setTimeoutPromise } from "timers/promises"
|
||||
import { ClineEndpoint } from "@/config"
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
|
||||
describe("Controller postStateToWebview", () => {
|
||||
let controller: Controller
|
||||
let stateManagerStub: sinon.SinonStub
|
||||
let mockStateManager: any
|
||||
let hostProviderInitialized = false
|
||||
let mockGetHostVersion: sinon.SinonStub
|
||||
|
||||
before(async () => {
|
||||
if (!ClineEndpoint.isInitialized()) {
|
||||
await ClineEndpoint.initialize("/test/extension")
|
||||
}
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
if (!HostProvider.isInitialized()) {
|
||||
mockGetHostVersion = sinon.stub().resolves({
|
||||
clineVersion: "1.0.0",
|
||||
platform: "darwin",
|
||||
clineType: "vscode",
|
||||
})
|
||||
const mockHostBridge: any = {
|
||||
workspaceClient: {},
|
||||
envClient: {
|
||||
getHostVersion: mockGetHostVersion,
|
||||
},
|
||||
windowClient: {},
|
||||
diffClient: {},
|
||||
}
|
||||
|
||||
HostProvider.initialize(
|
||||
() => null as any,
|
||||
() => null as any,
|
||||
() => null as any,
|
||||
() => null as any,
|
||||
mockHostBridge,
|
||||
() => {},
|
||||
async (path: string) => `http://localhost${path}`,
|
||||
async () => "",
|
||||
"/test/extension",
|
||||
"/test/storage",
|
||||
)
|
||||
hostProviderInitialized = true
|
||||
}
|
||||
|
||||
await require("@/registry").HostRegistryInfo.init()
|
||||
|
||||
mockStateManager = {
|
||||
getRemoteConfigSettings: sinon.stub().returns({}),
|
||||
getApiConfiguration: sinon.stub().returns({}),
|
||||
getGlobalStateKey: sinon.stub().returns(undefined),
|
||||
getGlobalSettingsKey: sinon.stub().returns(undefined),
|
||||
getWorkspaceStateKey: sinon.stub().returns(undefined),
|
||||
setGlobalState: sinon.stub(),
|
||||
setApiConfiguration: sinon.stub(),
|
||||
registerCallbacks: sinon.stub(),
|
||||
}
|
||||
|
||||
const StateManager = require("@core/storage/StateManager").StateManager
|
||||
stateManagerStub = sinon.stub(StateManager, "get").returns(mockStateManager)
|
||||
|
||||
controller = new Controller({
|
||||
globalState: { get: sinon.stub(), update: sinon.stub().resolves() },
|
||||
workspaceState: { get: sinon.stub(), update: sinon.stub().resolves() },
|
||||
secrets: { get: sinon.stub().resolves(), store: sinon.stub().resolves(), delete: sinon.stub().resolves() },
|
||||
subscriptions: [],
|
||||
extensionPath: "/test/path",
|
||||
globalStoragePath: "/test/storage",
|
||||
globalStorageUri: { fsPath: "/test/storage" },
|
||||
} as any)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
stateManagerStub.restore()
|
||||
if (hostProviderInitialized) {
|
||||
HostProvider.reset()
|
||||
hostProviderInitialized = false
|
||||
}
|
||||
})
|
||||
|
||||
it("flushes immediately when there is no active task", async () => {
|
||||
const flushNow = sinon.stub().resolves()
|
||||
const requestFlush = sinon.stub()
|
||||
;(controller as any).stateUpdateScheduler = {
|
||||
flushNow,
|
||||
requestFlush,
|
||||
dispose: sinon.stub().resolves(),
|
||||
}
|
||||
|
||||
await controller.postStateToWebview()
|
||||
|
||||
sinon.assert.calledOnce(flushNow)
|
||||
sinon.assert.notCalled(requestFlush)
|
||||
})
|
||||
|
||||
it("coalesces through the scheduler when the active task is streaming", async () => {
|
||||
const flushNow = sinon.stub().resolves()
|
||||
const requestFlush = sinon.stub()
|
||||
;(controller as any).stateUpdateScheduler = {
|
||||
flushNow,
|
||||
requestFlush,
|
||||
dispose: sinon.stub().resolves(),
|
||||
}
|
||||
;(controller as any).task = {
|
||||
taskState: {
|
||||
isStreaming: true,
|
||||
},
|
||||
}
|
||||
|
||||
await controller.postStateToWebview()
|
||||
|
||||
sinon.assert.calledOnceWithExactly(requestFlush, "normal")
|
||||
sinon.assert.notCalled(flushNow)
|
||||
})
|
||||
|
||||
it("honors explicit immediate priority even while streaming", async () => {
|
||||
const flushNow = sinon.stub().resolves()
|
||||
const requestFlush = sinon.stub()
|
||||
;(controller as any).stateUpdateScheduler = {
|
||||
flushNow,
|
||||
requestFlush,
|
||||
dispose: sinon.stub().resolves(),
|
||||
}
|
||||
;(controller as any).task = {
|
||||
taskState: {
|
||||
isStreaming: true,
|
||||
},
|
||||
}
|
||||
|
||||
await controller.postStateToWebview({ priority: "immediate" })
|
||||
|
||||
sinon.assert.calledOnce(flushNow)
|
||||
sinon.assert.notCalled(requestFlush)
|
||||
})
|
||||
|
||||
it("treats non-streaming active tasks as immediate by default", async () => {
|
||||
const flushNow = sinon.stub().resolves()
|
||||
const requestFlush = sinon.stub()
|
||||
;(controller as any).stateUpdateScheduler = {
|
||||
flushNow,
|
||||
requestFlush,
|
||||
dispose: sinon.stub().resolves(),
|
||||
}
|
||||
;(controller as any).task = {
|
||||
taskState: {
|
||||
isStreaming: false,
|
||||
},
|
||||
}
|
||||
|
||||
await controller.postStateToWebview()
|
||||
|
||||
sinon.assert.calledOnce(flushNow)
|
||||
sinon.assert.notCalled(requestFlush)
|
||||
})
|
||||
|
||||
it("records state update metrics when a flush occurs", async () => {
|
||||
const noteStateUpdateMetrics = sinon.stub()
|
||||
;(controller as any).task = { noteStateUpdateMetrics }
|
||||
const getStateToPostToWebview = sinon.stub(controller, "getStateToPostToWebview").resolves({ foo: "bar" } as any)
|
||||
const stateModule = require("@core/controller/state/subscribeToState")
|
||||
const sendStateUpdateStub = sinon.stub(stateModule, "sendStateUpdate").resolves({
|
||||
payloadBytes: 123,
|
||||
sendDurationMs: 7,
|
||||
subscriberCount: 1,
|
||||
})
|
||||
|
||||
try {
|
||||
await (controller as any).flushStateToWebview()
|
||||
|
||||
sinon.assert.calledOnce(getStateToPostToWebview)
|
||||
sinon.assert.calledOnce(sendStateUpdateStub)
|
||||
sinon.assert.calledOnce(noteStateUpdateMetrics)
|
||||
const metrics = noteStateUpdateMetrics.firstCall.args[0]
|
||||
metrics.serializedBytes.should.equal(123)
|
||||
metrics.sendDurationMs.should.equal(7)
|
||||
metrics.buildDurationMs.should.be.greaterThanOrEqual(0)
|
||||
} finally {
|
||||
getStateToPostToWebview.restore()
|
||||
sendStateUpdateStub.restore()
|
||||
}
|
||||
})
|
||||
|
||||
it("flushes the latest snapshot after coalescing multiple streaming-era state updates", async () => {
|
||||
let snapshot = { currentTaskItem: { id: "task-1" }, currentFocusChainChecklist: "- [ ] first" }
|
||||
;(controller as any).task = {
|
||||
taskState: {
|
||||
isStreaming: true,
|
||||
},
|
||||
noteStateUpdateMetrics: sinon.stub(),
|
||||
}
|
||||
|
||||
const getStateToPostToWebview = sinon.stub(controller, "getStateToPostToWebview").callsFake(async () => snapshot as any)
|
||||
const stateModule = require("@core/controller/state/subscribeToState")
|
||||
const sentStates: any[] = []
|
||||
const sendStateUpdateStub = sinon.stub(stateModule, "sendStateUpdate").callsFake(async (state: any) => {
|
||||
sentStates.push(state)
|
||||
return {
|
||||
payloadBytes: 256,
|
||||
sendDurationMs: 3,
|
||||
subscriberCount: 1,
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
await controller.postStateToWebview()
|
||||
snapshot = { currentTaskItem: { id: "task-2" }, currentFocusChainChecklist: "- [x] latest" }
|
||||
await controller.postStateToWebview()
|
||||
|
||||
await setTimeoutPromise(40)
|
||||
|
||||
sinon.assert.calledOnce(sendStateUpdateStub)
|
||||
sinon.assert.calledOnce(getStateToPostToWebview)
|
||||
sentStates.should.deepEqual([{ currentTaskItem: { id: "task-2" }, currentFocusChainChecklist: "- [x] latest" }])
|
||||
} finally {
|
||||
getStateToPostToWebview.restore()
|
||||
sendStateUpdateStub.restore()
|
||||
}
|
||||
})
|
||||
|
||||
it("reduces snapshot sends and payload bytes versus immediate flushing for the same streaming burst", async () => {
|
||||
const stateModule = require("@core/controller/state/subscribeToState")
|
||||
|
||||
const runBurst = async ({ immediate }: { immediate: boolean }) => {
|
||||
let sendCount = 0
|
||||
let totalPayloadBytes = 0
|
||||
let snapshotVersion = 0
|
||||
|
||||
;(controller as any).task = {
|
||||
taskState: {
|
||||
isStreaming: true,
|
||||
},
|
||||
noteStateUpdateMetrics: sinon.stub(),
|
||||
}
|
||||
|
||||
const getStateToPostToWebview = sinon.stub(controller, "getStateToPostToWebview").callsFake(async () => {
|
||||
return {
|
||||
currentTaskItem: { id: `task-${snapshotVersion}` },
|
||||
currentFocusChainChecklist: `- [ ] item ${snapshotVersion}`,
|
||||
} as any
|
||||
})
|
||||
const sendStateUpdateStub = sinon.stub(stateModule, "sendStateUpdate").callsFake(async (state: any) => {
|
||||
sendCount += 1
|
||||
totalPayloadBytes += Buffer.byteLength(JSON.stringify(state), "utf8")
|
||||
return {
|
||||
payloadBytes: Buffer.byteLength(JSON.stringify(state), "utf8"),
|
||||
sendDurationMs: 1,
|
||||
subscriberCount: 1,
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
for (let i = 0; i < 6; i++) {
|
||||
snapshotVersion = i
|
||||
if (immediate) {
|
||||
await controller.postStateToWebview({ priority: "immediate" })
|
||||
} else {
|
||||
await controller.postStateToWebview()
|
||||
}
|
||||
}
|
||||
|
||||
if (!immediate) {
|
||||
await setTimeoutPromise(40)
|
||||
}
|
||||
|
||||
return { sendCount, totalPayloadBytes }
|
||||
} finally {
|
||||
getStateToPostToWebview.restore()
|
||||
sendStateUpdateStub.restore()
|
||||
}
|
||||
}
|
||||
|
||||
const coalesced = await runBurst({ immediate: false })
|
||||
const immediate = await runBurst({ immediate: true })
|
||||
|
||||
coalesced.sendCount.should.be.lessThan(immediate.sendCount)
|
||||
coalesced.totalPayloadBytes.should.be.lessThan(immediate.totalPayloadBytes)
|
||||
immediate.sendCount.should.equal(6)
|
||||
})
|
||||
|
||||
it("posts background command state updates at normal priority", async () => {
|
||||
const postStateToWebview = sinon.stub(controller, "postStateToWebview").resolves()
|
||||
|
||||
controller.updateBackgroundCommandState(true, "task-123")
|
||||
await Promise.resolve()
|
||||
|
||||
sinon.assert.calledOnceWithExactly(postStateToWebview, { priority: "normal" })
|
||||
})
|
||||
|
||||
it("keeps mode switches on the immediate path", async () => {
|
||||
const postStateToWebview = sinon.stub(controller, "postStateToWebview").resolves()
|
||||
|
||||
const didSwitch = await controller.togglePlanActMode("plan")
|
||||
|
||||
didSwitch.should.equal(false)
|
||||
sinon.assert.calledOnceWithExactly(postStateToWebview, { priority: "immediate" })
|
||||
sinon.assert.calledWith(mockStateManager.setGlobalState, "mode", "plan")
|
||||
})
|
||||
|
||||
it("keeps auth callback state hydration on the immediate path", async () => {
|
||||
mockStateManager.getGlobalSettingsKey.callsFake((key: string) => {
|
||||
switch (key) {
|
||||
case "planActSeparateModelsSetting":
|
||||
return false
|
||||
case "mode":
|
||||
return "act"
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
})
|
||||
mockStateManager.getApiConfiguration.returns({
|
||||
planModeApiProvider: "openrouter",
|
||||
actModeApiProvider: "openrouter",
|
||||
})
|
||||
|
||||
const handleAuthCallback = sinon.stub(controller.authService, "handleAuthCallback").resolves()
|
||||
const postStateToWebview = sinon.stub(controller, "postStateToWebview").resolves()
|
||||
const fetchRemoteConfigModule = require("@core/storage/remote-config/fetch")
|
||||
const fetchRemoteConfigStub = sinon.stub(fetchRemoteConfigModule, "fetchRemoteConfig").resolves()
|
||||
|
||||
try {
|
||||
await controller.handleAuthCallback("token-123", "google")
|
||||
|
||||
sinon.assert.calledOnce(handleAuthCallback)
|
||||
sinon.assert.calledWith(mockStateManager.setGlobalState, "welcomeViewCompleted", true)
|
||||
sinon.assert.calledOnceWithExactly(postStateToWebview, { priority: "immediate" })
|
||||
} finally {
|
||||
fetchRemoteConfigStub.restore()
|
||||
}
|
||||
})
|
||||
|
||||
it("reinitializes an existing task from history when switching tasks", async () => {
|
||||
const historyItem = { id: "task-2", task: "Continue task", ts: Date.now() } as any
|
||||
const getTaskWithId = sinon.stub(controller, "getTaskWithId").resolves({ historyItem } as any)
|
||||
const initTask = sinon.stub(controller, "initTask").resolves("task-2")
|
||||
|
||||
await controller.reinitExistingTaskFromId("task-2")
|
||||
|
||||
sinon.assert.calledOnceWithExactly(getTaskWithId, "task-2")
|
||||
sinon.assert.calledOnceWithExactly(initTask, undefined, undefined, undefined, historyItem)
|
||||
})
|
||||
|
||||
it("updates task history state and notifies the UI when deleting a task", async () => {
|
||||
const existingHistory = [
|
||||
{ id: "task-1", task: "Keep me", ts: 1 },
|
||||
{ id: "task-2", task: "Remove me", ts: 2 },
|
||||
]
|
||||
mockStateManager.getGlobalStateKey.callsFake((key: string) => {
|
||||
if (key === "taskHistory") {
|
||||
return existingHistory
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
const postStateToWebview = sinon.stub(controller, "postStateToWebview").resolves()
|
||||
|
||||
const updatedHistory = await controller.deleteTaskFromState("task-2")
|
||||
|
||||
updatedHistory.should.deepEqual([{ id: "task-1", task: "Keep me", ts: 1 }])
|
||||
sinon.assert.calledWith(mockStateManager.setGlobalState, "taskHistory", updatedHistory)
|
||||
sinon.assert.calledOnce(postStateToWebview)
|
||||
})
|
||||
|
||||
it("includes the active task snapshot when building state for task switching", async () => {
|
||||
const existingHistory = [
|
||||
{ id: "task-1", task: "Old task", ts: 1 },
|
||||
{ id: "task-2", task: "Active task", ts: 2 },
|
||||
]
|
||||
mockStateManager.getGlobalStateKey.callsFake((key: string) => {
|
||||
if (key === "taskHistory") {
|
||||
return existingHistory
|
||||
}
|
||||
if (key === "isNewUser") {
|
||||
return false
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
;(controller as any).task = {
|
||||
taskId: "task-2",
|
||||
messageStateHandler: {
|
||||
getClineMessages: () => [],
|
||||
},
|
||||
taskState: {
|
||||
checkpointManagerErrorMessage: undefined,
|
||||
currentFocusChainChecklist: null,
|
||||
},
|
||||
}
|
||||
|
||||
const state = await controller.getStateToPostToWebview()
|
||||
|
||||
state.currentTaskItem?.id.should.equal("task-2")
|
||||
state.taskHistory?.map((item) => item.id).should.deepEqual(["task-2", "task-1"])
|
||||
})
|
||||
|
||||
it("uses active-task metadata after task switches so stale checklist state does not leak", async () => {
|
||||
const existingHistory = [
|
||||
{ id: "task-1", task: "Old task", ts: 1 },
|
||||
{ id: "task-2", task: "New task", ts: 2 },
|
||||
]
|
||||
mockStateManager.getGlobalStateKey.callsFake((key: string) => {
|
||||
if (key === "taskHistory") {
|
||||
return existingHistory
|
||||
}
|
||||
if (key === "isNewUser") {
|
||||
return false
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
|
||||
;(controller as any).task = {
|
||||
taskId: "task-2",
|
||||
messageStateHandler: {
|
||||
getClineMessages: () => [{ ts: 22, type: "say", say: "text", text: "new task message" }],
|
||||
},
|
||||
taskState: {
|
||||
checkpointManagerErrorMessage: undefined,
|
||||
currentFocusChainChecklist: "- [x] switched to the new task",
|
||||
},
|
||||
}
|
||||
|
||||
controller.updateBackgroundCommandState(true, "task-1")
|
||||
const state = await controller.getStateToPostToWebview()
|
||||
|
||||
state.currentTaskItem?.id.should.equal("task-2")
|
||||
state.currentFocusChainChecklist?.should.equal("- [x] switched to the new task")
|
||||
state.clineMessages.should.deepEqual([{ ts: 22, type: "say", say: "text", text: "new task message" }])
|
||||
state.backgroundCommandTaskId?.should.equal("task-1")
|
||||
})
|
||||
|
||||
it("detects remote workspace host metadata during controller initialization", async () => {
|
||||
HostProvider.reset()
|
||||
hostProviderInitialized = false
|
||||
|
||||
mockGetHostVersion = sinon.stub().resolves({
|
||||
clineVersion: "1.0.0",
|
||||
platform: "darwin",
|
||||
clineType: "vscode",
|
||||
remoteName: "ssh-remote",
|
||||
})
|
||||
|
||||
HostProvider.initialize(
|
||||
() => null as any,
|
||||
() => null as any,
|
||||
() => null as any,
|
||||
() => null as any,
|
||||
{
|
||||
workspaceClient: {},
|
||||
envClient: {
|
||||
getHostVersion: mockGetHostVersion,
|
||||
},
|
||||
windowClient: {},
|
||||
diffClient: {},
|
||||
} as any,
|
||||
() => {},
|
||||
async (path: string) => `http://localhost${path}`,
|
||||
async () => "",
|
||||
"/test/extension",
|
||||
"/test/storage",
|
||||
)
|
||||
hostProviderInitialized = true
|
||||
|
||||
controller = new Controller({
|
||||
globalState: { get: sinon.stub(), update: sinon.stub().resolves() },
|
||||
workspaceState: { get: sinon.stub(), update: sinon.stub().resolves() },
|
||||
secrets: { get: sinon.stub().resolves(), store: sinon.stub().resolves(), delete: sinon.stub().resolves() },
|
||||
subscriptions: [],
|
||||
extensionPath: "/test/path",
|
||||
globalStoragePath: "/test/storage",
|
||||
globalStorageUri: { fsPath: "/test/storage" },
|
||||
} as any)
|
||||
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
sinon.assert.called(mockGetHostVersion)
|
||||
;(controller as any).isRemoteWorkspaceEnvironment.should.equal(true)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user