Compare commits

...
Author SHA1 Message Date
candieduniverse a42a99cb03 fix: unblock vsix packaging typecheck 2026-04-20 08:57:50 -07:00
candieduniverse 8f07c16e88 docs: sync crash plan completion status 2026-04-20 08:57:50 -07:00
candieduniverse f8f42773b7 test: lock down patch parser ordering warnings 2026-04-20 08:57:50 -07:00
candieduniverse 3e69d4c134 test: clarify out-of-order patch diagnostics 2026-04-20 08:57:50 -07:00
candieduniverse fb4dd3efd9 perf: bound diff view large edit work 2026-04-20 08:57:50 -07:00
candieduniverse 1598011bb4 perf: reduce message state persistence churn 2026-04-20 08:57:50 -07:00
candieduniverse 019e8a0317 docs: tighten crash plan coverage status 2026-04-20 08:57:50 -07:00
candieduniverse 2ea4949615 docs: refresh crash investigation status 2026-04-20 08:57:50 -07:00
candieduniverse aed443d074 test: add message state soak coverage 2026-04-20 08:57:50 -07:00
candieduniverse c995bfd07d test: extend crash investigation soak coverage 2026-04-20 08:57:49 -07:00
candieduniverse 4cfdd2cf27 test: add crash investigation soak entrypoints 2026-04-20 08:57:49 -07:00
candieduniverse b3ef435c2b test: refresh broad crash verification 2026-04-20 08:57:49 -07:00
candieduniverse 0dc302ca6f test: add generated large-file fixtures 2026-04-20 08:57:49 -07:00
candieduniverse 08d5213e83 test: cover multi-file patch execution 2026-04-20 08:57:49 -07:00
candieduniverse 2a200ea701 test: summarize multi-file patch previews 2026-04-20 08:57:49 -07:00
candieduniverse 61dd5916e8 test: summarize huge replace diff errors 2026-04-20 08:57:49 -07:00
candieduniverse ff5744871b test: cover large diff view saves 2026-04-20 08:57:49 -07:00
candieduniverse 7b1ea867b5 test: summarize oversized patch approvals 2026-04-20 08:57:49 -07:00
candieduniverse c81a829808 test: guard shared large edit ops 2026-04-20 08:57:49 -07:00
candieduniverse 324483ce53 test: cover long message histories 2026-04-20 08:57:49 -07:00
candieduniverse 8669fac865 test: cover large ui message reloads 2026-04-20 08:57:49 -07:00
candieduniverse cab96ddf71 test: cover webview state hydration 2026-04-20 08:57:49 -07:00
candieduniverse d58181ff18 test: cover controller state broadcast 2026-04-20 08:57:49 -07:00
candieduniverse 37ca90e80e test: cover message add churn 2026-04-20 08:57:49 -07:00
candieduniverse 9135b41a84 test: cover message state churn 2026-04-20 08:57:49 -07:00
candieduniverse b6bdef1eaa test: guard large state rebroadcasts 2026-04-20 08:57:49 -07:00
candieduniverse bf6e04264b fix: allow whole-file diff replace 2026-04-20 08:57:49 -07:00
candieduniverse c890e3a9f4 test: stress repeated patch chunks 2026-04-20 08:57:49 -07:00
candieduniverse e95888ca89 test: cover diff abort and mcp restarts 2026-04-20 08:57:49 -07:00
candieduniverse 8ae3366ccf test: harden abort and mcp churn cleanup 2026-04-20 08:57:49 -07:00
candieduniverse 331f5ca502 test: cover mcp settings refresh churn 2026-04-20 08:57:49 -07:00
candieduniverse 20547ec082 test: cover mcp error accumulation 2026-04-20 08:57:49 -07:00
candieduniverse b40f722ff4 test: cover mcp notification backlog 2026-04-20 08:57:48 -07:00
candieduniverse 27a973d020 test: cover large write guardrails 2026-04-20 08:57:48 -07:00
candieduniverse 350bd63359 fix: clear background command tracking 2026-04-20 08:57:48 -07:00
candieduniverse 29d34c8498 test: cover hook abort cleanup 2026-04-20 08:57:48 -07:00
candieduniverse 121c7b3ad3 test: cover abort focus cleanup 2026-04-20 08:57:48 -07:00
candieduniverse b60616076d test: add stress failure reports 2026-04-20 08:57:48 -07:00
candieduniverse beadae54fb fix: await focus chain cleanup 2026-04-20 08:57:48 -07:00
candieduniverse 1d50a0b55f fix: bound diff fallback work 2026-04-20 08:57:48 -07:00
candieduniverse c9cbed84b9 fix: bound patch parser similarity work 2026-04-20 08:57:48 -07:00
candieduniverse 4bb97e471b fix: await controller mcp cleanup 2026-04-20 08:57:48 -07:00
candieduniverse f1f133380e test: cover mcp watcher cleanup 2026-04-20 08:57:48 -07:00
candieduniverse 3ded613158 test: cover repeated abort cleanup 2026-04-20 08:57:48 -07:00
candieduniverse 8e1531ba2a fix: summarize large diff errors 2026-04-20 08:57:48 -07:00
candieduniverse 05e1a5ab22 fix: summarize large write payloads 2026-04-20 08:57:48 -07:00
candieduniverse 1559f76294 fix: summarize large apply-patch payloads 2026-04-20 08:57:48 -07:00
candieduniverse 93399eace7 test: add repeated watcher churn coverage 2026-04-20 08:57:48 -07:00
candieduniverse 984dd3e5cd test: cover watcher disposal churn 2026-04-20 08:57:48 -07:00
candieduniverse 4f4fe116dd fix: cap patch search line length 2026-04-20 08:57:48 -07:00
candieduniverse d56351cc23 fix: guard giant diff lines 2026-04-20 08:57:48 -07:00
candieduniverse 31c6a1b938 perf: skip noop task history writes 2026-04-20 08:57:48 -07:00
candieduniverse 3596cf4719 perf: suppress duplicate state broadcasts 2026-04-20 08:57:48 -07:00
candieduniverse 856582e852 feat: track mcp truncation telemetry 2026-04-20 08:57:47 -07:00
candieduniverse 8058335027 fix: warn on large state snapshots 2026-04-20 08:57:47 -07:00
candieduniverse 45efd134f4 fix: cap patch parser search blocks 2026-04-20 08:57:47 -07:00
candieduniverse 4ae091bdab test: harden teardown watcher lifecycle 2026-04-20 08:57:47 -07:00
candieduniverse 4930b8a9da fix: bound mcp backlog growth 2026-04-20 08:57:47 -07:00
candieduniverse 8ff555f93c fix: await task abort cleanup 2026-04-20 08:57:47 -07:00
candieduniverse c52c72cdf8 perf: trim state broadcast and message churn 2026-04-20 08:57:47 -07:00
candieduniverse edf98132cb fix: guard oversized file edit payloads 2026-04-20 08:57:47 -07:00
candieduniverse 12e7203cc5 fix: harden patch parser stress path 2026-04-20 08:57:47 -07:00
candieduniverse 658974524f test: add crash stress test foundation 2026-04-20 08:57:47 -07:00
64 changed files with 5622 additions and 233 deletions
+1 -1
View File
@@ -211,7 +211,7 @@ export class ACPDiffViewProvider extends FileEditProvider {
* content via the ACP connection. Otherwise, it falls back to the
* FileEditProvider's local fs implementation.
*/
protected override async saveDocument(): Promise<Boolean> {
protected override async saveDocument(): Promise<boolean> {
// If we can't write files via ACP, fall back to FileEditProvider
if (!this.canWriteFile()) {
Logger.debug("[ACPDiffViewProvider] Client does not support fs.writeTextFile, falling back to local fs")
+39
View File
@@ -0,0 +1,39 @@
# Crash Candidate Matrix
This document tracks the currently known or suspected crash-class failure modes in the Cline VS Code extension.
It is intended to be updated as the team moves each candidate from suspicion to reproduction, test coverage, fix implementation, and verification.
## Status meanings
- **Suspected**: We have architectural or code evidence that this may cause crashes or severe instability.
- **Reproducing**: We are actively building a trigger and failure oracle.
- **Confirmed**: We can reliably trigger the failure or a clear crash-class symptom.
- **Fix in progress**: A fix is being implemented.
- **Mitigated**: A fix landed and targeted regression tests pass.
- **Residual risk**: The immediate failure was mitigated, but follow-up work still exists.
- **Closed**: The candidate is well-covered and no significant residual risk remains.
## Candidates
| ID | Owner | Title | Subsystem | Suspected root cause | Trigger pattern | Failure signal / oracle | Test layer | Likely files | Risk | Status |
|---|---|---|---|---|---|---|---|---|---|---|
| CAND-001 | TBD | Full-state rebroadcast of large `clineMessages` | Controller + webview state transport | Full task state, including entire chat history, is repeatedly materialized, JSON-stringified, transmitted, parsed, and rendered | Large conversation + frequent `postStateToWebview()` calls + partial updates | Payload size exceeds budget, rising memory, slower updates, stream breakage | Integration / extension-host | `src/core/controller/index.ts`, `src/core/controller/state/subscribeToState.ts`, `webview-ui/src/context/ExtensionStateContext.tsx` | Critical | Residual risk (payload warnings, duplicate/no-op rebroadcast suppression, large-payload webview parse coverage, and an initial 1,000-broadcast soak landed; paging/windowing still open) |
| CAND-002 | TBD | Full-array message persistence churn | Message state + disk persistence | Hot-path mutations trigger repeated whole-array writes and task-history bookkeeping | Thousands of message additions/updates during long tasks | Mutation latency growth, disk write amplification, memory / CPU drift | Integration | `src/core/task/message-state.ts`, `src/core/storage/disk.ts` | High | Residual risk (cached task-dir size reuse, no-op history suppression, no-op message-update persistence skipping, explicit API-history save paths for cancellation flows, large-history churn tests, and a 10,000-message soak landed; append/coalesce persistence is still open) |
| CAND-003 | TBD | File-edit string amplification | File edit pipeline | Very large file contents are duplicated across original, new, streamed, approval, pretty-diff, and final-content representations | Huge file edits, huge single-line files, repeated edit attempts | Peak heap far above file size, timeouts, extension-host instability | Integration / extension-host | `src/core/task/tools/handlers/WriteToFileToolHandler.ts`, `src/core/task/tools/handlers/ApplyPatchHandler.ts`, `src/integrations/editor/DiffViewProvider.ts` | Critical | Residual risk (shared large-edit byte guards, oversized approval-payload summarization, large diff save/scroll coverage, replace-in-file huge-original failure summarization, multi-file apply_patch preview/execution summarization, diff-view reset-cycle soak coverage, and reduced-heap large-file regression runs now cover core write/apply flows; chunked/direct edit strategies still open) |
| CAND-004 | TBD | Base64 diff URI payload explosion | VS Code diff presentation | Original file content is embedded into virtual-doc URI query strings | Open diff editor for very large files | URI/open failure, extreme memory spike, freeze/crash while opening diff | Extension-host | `src/hosts/vscode/VscodeDiffViewProvider.ts`, `src/extension.ts` | Critical | Mitigated |
| CAND-005 | TBD | Quadratic patch matching and similarity fallback | Patch parsing | Fuzzy matching and Levenshtein-style similarity may blow up on large near-match contexts | Large near-match patches, long lines, repeated chunks | Time budget exceeded, high CPU, reduced-heap failure | Unit stress | `src/core/task/tools/utils/PatchParser.ts` | Critical | Mitigated (search-block/line budgets, repeated-chunk and near-match stress coverage, oversized partial-match skipping, and clearer out-of-order chunk diagnostics landed; cheaper non-quadratic heuristics are still an optional follow-up) |
| CAND-006 | TBD | Diff reconstruction blowup on giant inputs | Diff reconstruction | SEARCH/REPLACE reconstruction repeatedly splits, scans, and slices giant strings | Huge SEARCH blocks, giant single-line replace operations | Time budget exceeded, high memory growth, reduced-heap failure | Unit stress | `src/core/assistant-message/diff.ts` | High | Mitigated |
| CAND-007 | TBD | MCP pending notification backlog | MCP integration | Notifications accumulate while no active task consumes them | Noisy MCP server with no task callback | Queue length rises without bound, memory drifts upward | Integration / soak | `src/services/mcp/McpHub.ts` | High | Closed |
| CAND-008 | TBD | Unbounded MCP error accumulation | MCP integration | Error text is concatenated indefinitely for noisy or failing connections | Repeated transport errors / stderr bursts | Error string growth, higher memory use, degraded server-state updates | Integration | `src/services/mcp/McpHub.ts`, `src/core/controller/index.ts` | Medium | Closed |
| CAND-009 | TBD | Async teardown races in task abort | Task lifecycle | Async disposals are triggered but not fully awaited in abort flow | Repeated create/cancel/clear-task loops | Watchers / handles drift upward, events after teardown, unstable repeated churn | Integration / soak | `src/core/task/index.ts`, `src/core/context/context-tracking/FileContextTracker.ts`, `src/core/ignore/ClineIgnoreController.ts` | Critical | Mitigated (awaited cleanup plus initial 1,000-cycle abort-cleanup soak coverage landed) |
| CAND-010 | TBD | Watcher accumulation from tracked files | File context tracking | Per-file watchers can accumulate as large tasks touch more files | Long tasks with many reads/edits across many files | Watcher count grows and does not return to baseline | Integration / soak | `src/core/context/context-tracking/FileContextTracker.ts` | High | Residual risk |
| CAND-011 | TBD | Focus-chain watcher / debounce lifecycle drift | Focus-chain task support | Long-lived watcher and debounce timers may race or outlive task lifecycle | Focus-chain enabled tasks with repeated restarts/cancels | Post-teardown updates or rising active-handle count | Integration / soak | `src/core/task/focus-chain/index.ts` | Medium | Mitigated |
| CAND-012 | TBD | Webview retained-memory pressure | Webview lifecycle | Hidden-but-retained webview keeps large React state and message history resident | Large tasks + hidden retained sidebar | Memory remains elevated despite user not viewing task UI | Extension-host / soak | `src/extension.ts`, `webview-ui/src/context/ExtensionStateContext.tsx` | High | Suspected |
## Next actions
- [ ] Add owner and priority assignments.
- [ ] Add evidence notes beside CAND-002 through CAND-006 as more workload shapes are covered.
- [ ] Add repro links and failing test paths once each candidate is under investigation.
- [ ] Update status as candidates move through the workflow.
File diff suppressed because it is too large Load Diff
+4
View File
@@ -421,6 +421,10 @@
"test": "npx npm-run-all test:unit test:integration",
"test:integration": "vscode-test",
"test:unit": "cross-env TS_NODE_PROJECT=./tsconfig.unit-test.json mocha",
"test:unit:low-heap": "node scripts/run-mocha-low-heap.mjs",
"test:crash-investigation": "node scripts/run-mocha-low-heap.mjs src/test/stress-utils.test.ts src/core/task/tools/utils/__tests__/PatchParser.stress.test.ts src/core/assistant-message/__tests__/diff.stress.test.ts src/core/task/tools/handlers/__tests__/WriteToFileToolHandler.largeEditGuards.test.ts src/core/task/tools/handlers/__tests__/ApplyPatchHandler.largeEditGuards.test.ts src/integrations/editor/__tests__/DiffViewProvider.test.ts src/core/task/utils/__tests__/taskAbortCleanup.test.ts src/core/context/context-tracking/FileContextTracker.test.ts src/core/task/focus-chain/index.test.ts src/services/mcp/__tests__/limits.test.ts src/core/controller/state/__tests__/subscribeToState.test.ts src/test/message-state-handler.test.ts src/test/hook-executor.test.ts",
"test:crash-investigation:low-heap": "cross-env CLINE_TEST_MAX_OLD_SPACE_SIZE_MB=512 node scripts/run-mocha-low-heap.mjs src/test/stress-utils.test.ts src/core/task/tools/utils/__tests__/PatchParser.stress.test.ts src/core/assistant-message/__tests__/diff.stress.test.ts src/core/task/tools/handlers/__tests__/WriteToFileToolHandler.largeEditGuards.test.ts src/core/task/tools/handlers/__tests__/ApplyPatchHandler.largeEditGuards.test.ts src/integrations/editor/__tests__/DiffViewProvider.test.ts src/core/task/utils/__tests__/taskAbortCleanup.test.ts src/core/context/context-tracking/FileContextTracker.test.ts src/core/task/focus-chain/index.test.ts src/services/mcp/__tests__/limits.test.ts src/core/controller/state/__tests__/subscribeToState.test.ts src/test/message-state-handler.test.ts src/test/hook-executor.test.ts",
"test:crash-investigation:soak": "node scripts/run-mocha-low-heap.mjs src/test/message-state-handler.stress.test.ts src/core/controller/state/__tests__/subscribeToState.stress.test.ts src/core/task/utils/__tests__/taskAbortCleanup.test.ts src/core/task/utils/__tests__/taskAbortCleanup.stress.test.ts src/integrations/editor/__tests__/DiffViewProvider.stress.test.ts src/core/task/focus-chain/index.test.ts src/services/mcp/__tests__/limits.test.ts src/services/mcp/__tests__/limits.stress.test.ts",
"test:coverage": "vscode-test --coverage",
"test:sca-server": "npx tsx watch scripts/test-standalone-core-api-server.ts",
"test:tp-orchestrator": "npx tsx scripts/testing-platform-orchestrator.ts",
+46
View File
@@ -0,0 +1,46 @@
import { spawn } from "node:child_process"
const forwardedArgs = process.argv.slice(2)
const heapMb = process.env.CLINE_TEST_MAX_OLD_SPACE_SIZE_MB || "768"
if (forwardedArgs.length === 0) {
console.error(
"Usage: npm run test:unit:low-heap -- <test-file-or-glob> [additional mocha args...]\n" +
"Example: npm run test:unit:low-heap -- src/test/stress-utils.test.ts\n" +
"Override heap with CLINE_TEST_MAX_OLD_SPACE_SIZE_MB=<mb> if you want a more aggressive stress limit.",
)
process.exit(1)
}
const child = spawn(
process.execPath,
[
`--max-old-space-size=${heapMb}`,
"--require",
"ts-node/register/transpile-only",
"--require",
"source-map-support/register",
"--require",
"tsconfig-paths/register",
"--require",
"./src/test/requires.ts",
"./node_modules/mocha/bin/mocha",
"--no-config",
...forwardedArgs,
],
{
stdio: "inherit",
env: {
...process.env,
TS_NODE_PROJECT: process.env.TS_NODE_PROJECT || "./tsconfig.unit-test.json",
},
},
)
child.on("exit", (code, signal) => {
if (signal) {
process.kill(process.pid, signal)
return
}
process.exit(code ?? 1)
})
@@ -0,0 +1,131 @@
import { expect } from "chai"
import { describe, it } from "mocha"
import { measureAsyncOperation, measureUtf8Bytes } from "@/test/stress-utils"
import { constructNewFileContent, MAX_DIFF_FALLBACK_WORK_UNITS, MAX_DIFF_LINE_BYTES } from "../diff"
function makeLargeOriginalContent(blockCount: number): string {
return Array.from({ length: blockCount }, (_, i) => {
return [`function block${i}() {`, ` const payload = "${`value-${i}-`.repeat(16)}"`, ` return payload`, `}`].join("\n")
}).join("\n\n")
}
describe("constructNewFileContent stress", () => {
it("handles large empty-search whole-file replacement within a bounded time budget", async function () {
this.timeout(10_000)
const original = makeLargeOriginalContent(220)
const replacement = Array.from({ length: 220 }, (_, i) => {
return [`function rewritten${i}() {`, ` return "${`rewritten-${i}-`.repeat(12)}"`, `}`].join("\n")
}).join("\n\n")
const diff = ["------- SEARCH", "=======", replacement, "+++++++ REPLACE"].join("\n")
const measured = await measureAsyncOperation("diff whole-file replace stress", async () => {
return constructNewFileContent(diff, original, true, "v1")
})
expect(measureUtf8Bytes(original)).to.be.greaterThan(10_000)
expect(measured.durationMs).to.be.lessThan(5_000)
expect(measured.result.newContent).to.equal(`${replacement}\n`)
expect(measured.result.matchIndices).to.deep.equal([0])
})
it("handles many ordered replacements in a large file within a bounded time budget", async function () {
this.timeout(10_000)
const original = makeLargeOriginalContent(180)
const replacementIndexes = [10, 25, 40, 55, 70, 85, 100, 115, 130, 145]
const diff = replacementIndexes
.map((index) => {
const oldLine = ` const payload = "${`value-${index}-`.repeat(16)}"`
const newLine = ` const payload = "${`updated-${index}-`.repeat(16)}"`
return ["------- SEARCH", oldLine, "=======", newLine, "+++++++ REPLACE"].join("\n")
})
.join("\n\n")
const measured = await measureAsyncOperation("diff ordered replacements stress", async () => {
return constructNewFileContent(diff, original, true, "v1")
})
expect(measureUtf8Bytes(original)).to.be.greaterThan(10_000)
expect(measured.durationMs).to.be.lessThan(5_000)
for (const index of replacementIndexes) {
expect(measured.result.newContent).to.include(`updated-${index}-updated-${index}-`)
}
})
it("uses fallback matching on repeated trimmed multi-line blocks without throwing", async function () {
this.timeout(10_000)
const original = Array.from({ length: 120 }, (_, i) => {
return [`section ${i}`, " begin", ` payload ${i}`, " end"].join("\n")
}).join("\n")
const diff = [
"------- SEARCH",
"begin",
"payload 90",
"end",
"=======",
"begin",
"payload 90 updated",
"end",
"+++++++ REPLACE",
].join("\n")
const measured = await measureAsyncOperation("diff fallback stress", async () => {
return constructNewFileContent(diff, original, true, "v1")
})
expect(measured.durationMs).to.be.lessThan(5_000)
expect(measured.result.newContent).to.include("payload 90 updated")
expect(measured.result.matchIndices).to.have.lengthOf(1)
})
it("fails fast on giant single-line diff payloads", async function () {
this.timeout(10_000)
const giantLine = "x".repeat(MAX_DIFF_LINE_BYTES + 1)
const original = "small line\nsecond line"
const diff = ["------- SEARCH", giantLine, "=======", "updated", "+++++++ REPLACE"].join("\n")
const startedAt = Date.now()
try {
await constructNewFileContent(diff, original, true, "v1")
expect.fail("Expected constructNewFileContent to reject giant single-line diff payloads")
} catch (error) {
expect(Date.now() - startedAt).to.be.lessThan(1_000)
expect((error as Error).message).to.match(/SEARCH\/REPLACE payload contains a line that is too large/)
}
})
it("skips oversized fallback matching work for giant near-match multi-line searches", async function () {
this.timeout(10_000)
const original = Array.from({ length: 3_000 }, (_, i) => {
return [`section ${i}`, " begin", ` payload ${i}`, " end"].join("\n")
}).join("\n")
const diff = [
"------- SEARCH",
...Array.from({ length: 64 }, (_, i) => `begin ${i}`),
"payload 90",
"end 90",
"=======",
"begin",
"payload 90 updated",
"end",
"+++++++ REPLACE",
].join("\n")
expect((3_000 - 66 + 1) * 66).to.be.greaterThan(MAX_DIFF_FALLBACK_WORK_UNITS)
const startedAt = Date.now()
try {
await constructNewFileContent(diff, original, true, "v1")
expect.fail("Expected constructNewFileContent to fail once oversized fallback work is skipped")
} catch (error) {
expect(Date.now() - startedAt).to.be.lessThan(1_000)
expect((error as Error).message).to.match(/does not match anything in the file/)
}
})
})
+41 -1
View File
@@ -1,6 +1,6 @@
import { expect } from "chai"
import { describe, it } from "mocha"
import { constructNewFileContent as cnfc } from "./diff"
import { constructNewFileContent as cnfc, MAX_DIFF_LINE_BYTES } from "./diff"
async function cnfc2(diffContent: string, originalContent: string, isFinal: boolean): Promise<string> {
const result = await cnfc(diffContent, originalContent, isFinal, "v2")
@@ -252,6 +252,46 @@ replaced
}
})
it("should reject giant single-line SEARCH payloads", async () => {
const giantLine = "x".repeat(MAX_DIFF_LINE_BYTES + 1)
const original = "small line\nsecond line"
const diff = `------- SEARCH\n${giantLine}\n=======\nupdated\n+++++++ REPLACE`
try {
await cnfc(diff, original, true)
expect.fail("Expected v1 diff reconstruction to reject giant single-line payloads")
} catch (error) {
expect((error as Error).message).to.match(/SEARCH\/REPLACE payload contains a line that is too large/)
}
try {
await cnfc2(diff, original, true)
expect.fail("Expected v2 diff reconstruction to reject giant single-line payloads")
} catch (error) {
expect((error as Error).message).to.match(/SEARCH\/REPLACE payload contains a line that is too large/)
}
})
it("should reject giant single-line original content", async () => {
const giantLine = "x".repeat(MAX_DIFF_LINE_BYTES + 1)
const original = giantLine
const diff = `------- SEARCH\nsmall\n=======\nupdated\n+++++++ REPLACE`
try {
await cnfc(diff, original, true)
expect.fail("Expected v1 diff reconstruction to reject giant single-line original content")
} catch (error) {
expect((error as Error).message).to.match(/original file contains a line that is too large/)
}
try {
await cnfc2(diff, original, true)
expect.fail("Expected v2 diff reconstruction to reject giant single-line original content")
} catch (error) {
expect((error as Error).message).to.match(/original file contains a line that is too large/)
}
})
it("should handle missing final REPLACE marker when isFinal is true", async () => {
const original = "line1\nline2\nline3"
const diff = `------- SEARCH
+62 -6
View File
@@ -2,6 +2,56 @@ const SEARCH_BLOCK_START = "------- SEARCH"
const SEARCH_BLOCK_END = "======="
const REPLACE_BLOCK_END = "+++++++ REPLACE"
export const MAX_DIFF_LINE_BYTES = 200 * 1024
export const MAX_DIFF_FALLBACK_WORK_UNITS = 50_000
function getLargestLineBytes(content: string): number {
let largest = 0
for (const line of content.split("\n")) {
const lineBytes = Buffer.byteLength(line, "utf8")
if (lineBytes > largest) {
largest = lineBytes
}
}
return largest
}
function formatBytes(bytes: number): string {
if (bytes < 1024) {
return `${bytes} B`
}
if (bytes < 1024 * 1024) {
return `${(bytes / 1024).toFixed(1)} KB`
}
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
function exceedsDiffFallbackWorkBudget(originalLineCount: number, searchLineCount: number, startLineNum: number): boolean {
const candidateWindowCount = Math.max(0, originalLineCount - searchLineCount - startLineNum + 1)
const workUnits = candidateWindowCount * Math.max(searchLineCount, 1)
return workUnits > MAX_DIFF_FALLBACK_WORK_UNITS
}
function assertDiffLineLengthsWithinBudget(diffContent: string, originalContent: string): void {
const largestOriginalLineBytes = getLargestLineBytes(originalContent)
if (largestOriginalLineBytes > MAX_DIFF_LINE_BYTES) {
throw new Error(
`Refusing to reconstruct diff content because the original file contains a line that is too large ` +
`(${formatBytes(largestOriginalLineBytes)} > ${formatBytes(MAX_DIFF_LINE_BYTES)}). ` +
`Use a narrower edit or a different strategy for giant single-line content.`,
)
}
const largestDiffLineBytes = getLargestLineBytes(diffContent)
if (largestDiffLineBytes > MAX_DIFF_LINE_BYTES) {
throw new Error(
`Refusing to reconstruct diff content because the SEARCH/REPLACE payload contains a line that is too large ` +
`(${formatBytes(largestDiffLineBytes)} > ${formatBytes(MAX_DIFF_LINE_BYTES)}). ` +
`Split the change into smaller line-oriented edits.`,
)
}
}
/**
* Converts a character index in a string to a 1-based line number.
* @param content - The full content string
@@ -66,6 +116,10 @@ function lineTrimmedFallbackMatch(originalContent: string, searchContent: string
startLineNum++
}
if (exceedsDiffFallbackWorkBudget(originalLines.length, searchLines.length, startLineNum)) {
return false
}
// For each possible starting position in original content
for (let i = startLineNum; i <= originalLines.length - searchLines.length; i++) {
let matches = true
@@ -155,6 +209,10 @@ function blockAnchorFallbackMatch(originalContent: string, searchContent: string
startLineNum++
}
if (exceedsDiffFallbackWorkBudget(originalLines.length, searchLines.length, startLineNum)) {
return false
}
// Look for matching start and end anchors
for (let i = startLineNum; i <= originalLines.length - searchBlockSize; i++) {
// Check if first line matches
@@ -248,6 +306,7 @@ export async function constructNewFileContent(
isFinal: boolean,
version: "v1" | "v2" = "v1",
): Promise<{ newContent: string; matchIndices: number[] }> {
assertDiffLineLengthsWithinBudget(diffContent, originalContent)
const constructor = constructNewFileContentVersionMapping[version]
if (!constructor) {
throw new Error(`Invalid version '${version}' for file content constructor`)
@@ -328,12 +387,9 @@ async function constructNewFileContentV1(
searchMatchIndex = 0
searchEndIndex = 0
} else {
// ERROR: Empty search block with non-empty file indicates malformed SEARCH marker
throw new Error(
"Empty SEARCH block detected with non-empty file. This usually indicates a malformed SEARCH marker.\n" +
"Please ensure your SEARCH marker follows the correct format:\n" +
"- Use '------- SEARCH' (7+ dashes + space + SEARCH)\n",
)
// Whole-file replacement scenario: replace the entire current file.
searchMatchIndex = 0
searchEndIndex = originalContent.length
}
} else {
// Add check for inefficient full-file search
@@ -61,6 +61,18 @@ describe("FileContextTracker", () => {
sandbox.restore()
})
it("should expire stale Cline edit markers so they do not grow forever", async () => {
const clock = sandbox.useFakeTimers()
tracker.markFileAsEditedByCline(filePath)
clock.tick(31_000)
tracker.markFileAsEditedByCline("src/another-file.ts")
const recentEdits = (tracker as any).recentlyEditedByCline as Map<string, number>
expect(recentEdits.has(filePath)).to.be.false
expect(recentEdits.has("src/another-file.ts")).to.be.true
})
it("should add a record when a file is read by a tool", async () => {
await tracker.trackFileContext(filePath, "read_tool")
@@ -178,6 +190,14 @@ describe("FileContextTracker", () => {
expect(mockFileSystemWatcher.on.called).to.be.true
})
it("should not create duplicate watchers when the same file is tracked repeatedly", async () => {
await tracker.trackFileContext(filePath, "read_tool")
await tracker.trackFileContext(filePath, "file_mentioned")
expect(chokidarWatchStub.calledOnce).to.be.true
expect(((tracker as any).fileWatchers as Map<string, any>).size).to.equal(1)
})
it("should track user edits when file watcher detects changes", async () => {
// First track the file to set up the watcher
await tracker.trackFileContext(filePath, "read_tool")
@@ -234,11 +254,68 @@ describe("FileContextTracker", () => {
it("should dispose file watchers when dispose is called", async () => {
// Track a file to set up the watcher
await tracker.trackFileContext(filePath, "read_tool")
tracker.markFileAsEditedByCline(filePath)
// Call dispose
await tracker.dispose()
// Verify the watcher was closed
expect(mockFileSystemWatcher.close.called).to.be.true
expect(((tracker as any).recentlyEditedByCline as Map<string, number>).size).to.equal(0)
})
it("should close every tracked watcher during dispose", async () => {
const watcherA = {
close: sandbox.stub().resolves(),
on: sandbox.stub(),
}
watcherA.on.returns(watcherA)
const watcherB = {
close: sandbox.stub().resolves(),
on: sandbox.stub(),
}
watcherB.on.returns(watcherB)
chokidarWatchStub.onFirstCall().returns(watcherA as any)
chokidarWatchStub.onSecondCall().returns(watcherB as any)
await tracker.trackFileContext("src/file-a.ts", "read_tool")
await tracker.trackFileContext("src/file-b.ts", "read_tool")
expect(((tracker as any).fileWatchers as Map<string, any>).size).to.equal(2)
await tracker.dispose()
expect(watcherA.close.calledOnce).to.be.true
expect(watcherB.close.calledOnce).to.be.true
expect(((tracker as any).fileWatchers as Map<string, any>).size).to.equal(0)
})
it("does not accumulate tracked watchers across repeated setup and dispose cycles", async () => {
const createdWatchers: Array<{ close: sinon.SinonStub; on: sinon.SinonStub }> = []
chokidarWatchStub.callsFake(() => {
const watcher = {
close: sandbox.stub().resolves(),
on: sandbox.stub(),
}
watcher.on.returns(watcher)
createdWatchers.push(watcher)
return watcher as any
})
for (let cycle = 0; cycle < 5; cycle++) {
const cycleTracker = new FileContextTracker({} as Controller, `${taskId}-${cycle}`)
await cycleTracker.trackFileContext(`src/file-${cycle}.ts`, "read_tool")
await cycleTracker.trackFileContext(`src/file-${cycle}-b.ts`, "read_tool")
expect(((cycleTracker as any).fileWatchers as Map<string, any>).size).to.equal(2)
await cycleTracker.dispose()
expect(((cycleTracker as any).fileWatchers as Map<string, any>).size).to.equal(0)
}
expect(createdWatchers).to.have.lengthOf(10)
for (const watcher of createdWatchers) {
expect(watcher.close.calledOnce).to.be.true
}
})
})
@@ -23,13 +23,15 @@ If a file is modified outside of Cline, we detect and track this change to preve
This is used when restoring a task (non-git "checkpoint" restore), and mid-task.
*/
export class FileContextTracker {
private static readonly RECENT_CLINE_EDIT_TTL_MS = 30_000
private static readonly MAX_RECENT_CLINE_EDIT_ENTRIES = 1_000
private controller: Controller
readonly taskId: string
// File tracking and watching
private fileWatchers = new Map<string, FSWatcher>()
private recentlyModifiedFiles = new Set<string>()
private recentlyEditedByCline = new Set<string>()
private recentlyEditedByCline = new Map<string, number>()
constructor(controller: Controller, taskId: string) {
this.controller = controller
@@ -66,8 +68,7 @@ export class FileContextTracker {
// Track file changes
watcher.on("change", () => {
if (this.recentlyEditedByCline.has(filePath)) {
this.recentlyEditedByCline.delete(filePath) // This was an edit by Cline, no need to inform Cline
if (this.consumeRecentClineEditMarker(filePath)) {
} else {
this.recentlyModifiedFiles.add(filePath) // This was a user edit, we will inform Cline
this.trackFileContext(filePath, "user_edited") // Update the task metadata with file tracking
@@ -175,7 +176,33 @@ export class FileContextTracker {
* Marks a file as edited by Cline to prevent false positives in file watchers
*/
markFileAsEditedByCline(filePath: string): void {
this.recentlyEditedByCline.add(filePath)
this.pruneExpiredRecentClineEdits()
this.recentlyEditedByCline.set(filePath, Date.now() + FileContextTracker.RECENT_CLINE_EDIT_TTL_MS)
if (this.recentlyEditedByCline.size > FileContextTracker.MAX_RECENT_CLINE_EDIT_ENTRIES) {
const oldestKey = this.recentlyEditedByCline.keys().next().value
if (oldestKey) {
this.recentlyEditedByCline.delete(oldestKey)
}
}
}
private consumeRecentClineEditMarker(filePath: string): boolean {
this.pruneExpiredRecentClineEdits()
const expiresAt = this.recentlyEditedByCline.get(filePath)
if (!expiresAt) {
return false
}
this.recentlyEditedByCline.delete(filePath)
return expiresAt > Date.now()
}
private pruneExpiredRecentClineEdits(): void {
const now = Date.now()
for (const [trackedPath, expiresAt] of this.recentlyEditedByCline) {
if (expiresAt <= now) {
this.recentlyEditedByCline.delete(trackedPath)
}
}
}
/**
@@ -185,6 +212,7 @@ export class FileContextTracker {
const closePromises = Array.from(this.fileWatchers.values()).map((watcher) => watcher.close())
await Promise.all(closePromises)
this.fileWatchers.clear()
this.recentlyEditedByCline.clear()
}
/**
@@ -0,0 +1,185 @@
import { strict as assert } from "node:assert"
import { afterEach, describe, it } from "mocha"
import sinon from "sinon"
import { Controller } from "../index"
import { resetStateSubscriptionsForTest, subscribeToState } from "../state/subscribeToState"
describe("Controller.updateTaskHistory", () => {
it("skips persisting when the incoming task history item is unchanged", async () => {
const existingItem = {
id: "task-1",
ulid: "01-test",
ts: 123,
task: "Investigate crash",
tokensIn: 10,
tokensOut: 20,
cacheWrites: 0,
cacheReads: 0,
totalCost: 0,
size: 100,
cwdOnTaskInitialization: "/tmp/project",
isFavorited: false,
} as any
const history = [existingItem]
const stateManager = {
getGlobalStateKey: sinon.stub().withArgs("taskHistory").returns(history),
setGlobalState: sinon.stub(),
}
const result = await Controller.prototype.updateTaskHistory.call({ stateManager } as any, { ...existingItem })
assert.equal(result, history)
sinon.assert.notCalled(stateManager.setGlobalState)
})
it("persists when an existing task history item changes", async () => {
const existingItem = {
id: "task-1",
ulid: "01-test",
ts: 123,
task: "Investigate crash",
tokensIn: 10,
tokensOut: 20,
cacheWrites: 0,
cacheReads: 0,
totalCost: 0,
size: 100,
cwdOnTaskInitialization: "/tmp/project",
isFavorited: false,
} as any
const updatedItem = {
...existingItem,
tokensOut: 21,
size: 101,
} as any
const history = [existingItem]
const stateManager = {
getGlobalStateKey: sinon.stub().withArgs("taskHistory").returns(history),
setGlobalState: sinon.stub(),
}
const result = await Controller.prototype.updateTaskHistory.call({ stateManager } as any, updatedItem)
assert.equal(result[0], updatedItem)
sinon.assert.calledOnceWithExactly(stateManager.setGlobalState, "taskHistory", history)
})
it("persists when adding a new task history item", async () => {
const history: any[] = []
const newItem = {
id: "task-2",
ulid: "02-test",
ts: 456,
task: "Reduce persistence churn",
tokensIn: 5,
tokensOut: 8,
cacheWrites: 0,
cacheReads: 0,
totalCost: 0,
size: 50,
cwdOnTaskInitialization: "/tmp/project",
isFavorited: false,
} as any
const stateManager = {
getGlobalStateKey: sinon.stub().withArgs("taskHistory").returns(history),
setGlobalState: sinon.stub(),
}
const result = await Controller.prototype.updateTaskHistory.call({ stateManager } as any, newItem)
assert.equal(result.length, 1)
assert.equal(result[0], newItem)
sinon.assert.calledOnceWithExactly(stateManager.setGlobalState, "taskHistory", history)
})
})
describe("Controller.dispose", () => {
afterEach(() => {
resetStateSubscriptionsForTest()
})
it("awaits MCP hub disposal before resolving", async () => {
const events: string[] = []
let resolveMcpDispose!: () => void
const mcpDisposePromise = new Promise<void>((resolve) => {
resolveMcpDispose = resolve
})
const controllerLike = {
remoteConfigTimer: undefined,
clearTask: async () => {
events.push("clearTask")
},
mcpHub: {
dispose: async () => {
events.push("mcpDispose:start")
await mcpDisposePromise
events.push("mcpDispose:end")
},
},
} as any
const disposePromise = Controller.prototype.dispose.call(controllerLike)
await Promise.resolve()
assert.deepStrictEqual(events, ["clearTask", "mcpDispose:start"])
let settled = false
void disposePromise.then(() => {
settled = true
})
await Promise.resolve()
assert.equal(settled, false)
resolveMcpDispose()
await disposePromise
assert.deepStrictEqual(events, ["clearTask", "mcpDispose:start", "mcpDispose:end"])
})
it("postStateToWebview skips building state when there are no active subscribers", async () => {
let stateCalls = 0
const controllerLike = {
getStateToPostToWebview: async () => {
stateCalls += 1
return { mode: "act", clineMessages: [] }
},
} as any
await Controller.prototype.postStateToWebview.call(controllerLike)
assert.equal(stateCalls, 0)
})
it("postStateToWebview broadcasts large clineMessages snapshots through the controller path", async () => {
const payloads: string[] = []
const responseStream = async ({ stateJson }: { stateJson: string }) => {
payloads.push(stateJson)
}
const initialLargeState = {
mode: "act",
clineMessages: [{ ts: 1, type: "say", say: "text", text: "x".repeat(512 * 1024) }],
} as any
const changedLargeState = {
mode: "act",
clineMessages: [{ ts: 2, type: "say", say: "text", text: "y".repeat(512 * 1024) }],
} as any
const getStateToPostToWebview = sinon.stub()
getStateToPostToWebview.onFirstCall().resolves(initialLargeState)
getStateToPostToWebview.onSecondCall().resolves(changedLargeState)
const controllerLike = { getStateToPostToWebview } as any
await subscribeToState(controllerLike, {} as any, responseStream)
assert.equal(payloads.length, 1)
assert.equal(payloads[0], JSON.stringify(initialLargeState))
await Controller.prototype.postStateToWebview.call(controllerLike)
assert.equal(payloads.length, 2)
assert.equal(payloads[1], JSON.stringify(changedLargeState))
sinon.assert.calledTwice(getStateToPostToWebview)
})
})
+9 -2
View File
@@ -19,6 +19,7 @@ import type { TelemetrySetting } from "@shared/TelemetrySetting"
import type { UserInfo } from "@shared/UserInfo"
import { fileExistsAtPath } from "@utils/fs"
import axios from "axios"
import deepEqual from "fast-deep-equal"
import fs from "fs/promises"
import open from "open"
import pWaitFor from "p-wait-for"
@@ -57,7 +58,7 @@ import { sendMcpMarketplaceCatalogEvent } from "./mcp/subscribeToMcpMarketplaceC
import { getClineOnboardingModels } from "./models/getClineOnboardingModels"
import { appendClineStealthModels } from "./models/refreshOpenRouterModels"
import { checkCliInstallation } from "./state/checkCliInstallation"
import { sendStateUpdate } from "./state/subscribeToState"
import { hasActiveStateSubscribers, sendStateUpdate } from "./state/subscribeToState"
import { sendChatButtonClickedEvent } from "./ui/subscribeToChatButtonClicked"
/*
@@ -172,7 +173,7 @@ export class Controller {
}
await this.clearTask()
this.mcpHub.dispose()
await this.mcpHub.dispose()
Logger.error("Controller disposed")
}
@@ -838,6 +839,9 @@ export class Controller {
}
async postStateToWebview() {
if (!hasActiveStateSubscribers()) {
return
}
const state = await this.getStateToPostToWebview()
await sendStateUpdate(state)
}
@@ -1038,6 +1042,9 @@ export class Controller {
const history = this.stateManager.getGlobalStateKey("taskHistory")
const existingItemIndex = history.findIndex((h) => h.id === item.id)
if (existingItemIndex !== -1) {
if (deepEqual(history[existingItemIndex], item)) {
return history
}
history[existingItemIndex] = item
} else {
history.push(item)
@@ -0,0 +1,40 @@
import { strict as assert } from "node:assert"
import { describe, it } from "mocha"
import sinon from "sinon"
import { Logger } from "@/shared/services/Logger"
import { LARGE_STATE_SNAPSHOT_WARNING_BYTES, serializeStateSnapshot, warnOnLargeStateSnapshot } from "../stateSnapshot"
describe("stateSnapshot", () => {
it("serializes state and measures UTF-8 byte size", () => {
const state = { message: "hello🙂" } as any
const serialized = serializeStateSnapshot(state)
assert.equal(serialized.stateJson, JSON.stringify(state))
assert.equal(serialized.sizeBytes, Buffer.byteLength(JSON.stringify(state), "utf8"))
})
it("does not warn when snapshot size stays within threshold", () => {
const warnStub = sinon.stub(Logger, "warn")
try {
const didWarn = warnOnLargeStateSnapshot(LARGE_STATE_SNAPSHOT_WARNING_BYTES, "subscribeToState")
assert.equal(didWarn, false)
sinon.assert.notCalled(warnStub)
} finally {
warnStub.restore()
}
})
it("warns when snapshot size exceeds threshold", () => {
const warnStub = sinon.stub(Logger, "warn")
try {
const didWarn = warnOnLargeStateSnapshot(LARGE_STATE_SNAPSHOT_WARNING_BYTES + 1, "subscribeToState")
assert.equal(didWarn, true)
sinon.assert.calledOnce(warnStub)
assert.match(String(warnStub.firstCall.args[0]), /Large state snapshot for subscribeToState/)
} finally {
warnStub.restore()
}
})
})
@@ -0,0 +1,50 @@
import { strict as assert } from "node:assert"
import { afterEach, describe, it } from "mocha"
import { measureAsyncOperation, measureUtf8Bytes } from "@/test/stress-utils"
import { resetStateSubscriptionsForTest, sendStateUpdate, subscribeToState } from "../subscribeToState"
describe("subscribeToState soak", () => {
afterEach(() => {
resetStateSubscriptionsForTest()
})
it("handles 1,000 repeated state broadcasts with a growing conversation within a bounded budget", async function () {
this.timeout(20_000)
const sentPayloads: string[] = []
const responseStream = async ({ stateJson }: { stateJson: string }) => {
sentPayloads.push(stateJson)
}
const controller = {
getStateToPostToWebview: async () => ({ mode: "act", clineMessages: [] }),
} as any
await subscribeToState(controller, {} as any, responseStream)
const messageChunk = "x".repeat(1024)
const measured = await measureAsyncOperation("subscribeToState soak broadcasts", async () => {
for (let i = 1; i <= 1_000; i++) {
await sendStateUpdate({
mode: "act",
clineMessages: Array.from({ length: i }, (_, index) => ({
ts: index,
type: "say",
say: "text",
text: `${index}-${messageChunk}`,
})),
} as any)
}
return sentPayloads[sentPayloads.length - 1]
})
assert.equal(sentPayloads.length, 1_001)
assert.ok(measured.result)
assert.ok(measureUtf8Bytes(measured.result!).toString())
assert.ok(measureUtf8Bytes(measured.result!) >= 1_000 * 1024)
assert.ok(measured.durationMs < 20_000)
// This soak intentionally grows the serialized conversation to ~1MB+ across 1,000 updates.
// Keep the heap-growth budget meaningful without making it unrealistically tight for CI variance.
assert.ok(measured.diff.heapUsedDelta < 512 * 1024 * 1024)
})
})
@@ -0,0 +1,84 @@
import { strict as assert } from "node:assert"
import { afterEach, describe, it } from "mocha"
import "should"
import sinon from "sinon"
import { Logger } from "@/shared/services/Logger"
import { LARGE_STATE_SNAPSHOT_WARNING_BYTES } from "../stateSnapshot"
import { hasActiveStateSubscribers, resetStateSubscriptionsForTest, sendStateUpdate, subscribeToState } from "../subscribeToState"
describe("subscribeToState state broadcast guards", () => {
afterEach(() => {
resetStateSubscriptionsForTest()
})
it("has no active subscribers by default", () => {
hasActiveStateSubscribers().should.equal(false)
})
it("sendStateUpdate should no-op before serialization when there are no subscribers", async () => {
await sendStateUpdate({
toJSON() {
throw new Error("state should not have been serialized")
},
} as any)
})
it("suppresses duplicate serialized state updates for the same subscriber", async () => {
const sentPayloads: string[] = []
const responseStream = async ({ stateJson }: { stateJson: string }) => {
sentPayloads.push(stateJson)
}
const controller = {
getStateToPostToWebview: async () => ({ mode: "act", clineMessages: [] }),
} as any
await subscribeToState(controller, {} as any, responseStream)
assert.equal(sentPayloads.length, 1)
await sendStateUpdate({ mode: "act", clineMessages: [] } as any)
assert.equal(sentPayloads.length, 1)
await sendStateUpdate({ mode: "act", clineMessages: [{ ts: 1, type: "say", say: "text", text: "next" }] } as any)
assert.equal(sentPayloads.length, 2)
})
it("warns once for a large clineMessages snapshot and suppresses identical rebroadcasts", async () => {
const warnStub = sinon.stub(Logger, "warn")
const sentPayloads: string[] = []
const responseStream = async ({ stateJson }: { stateJson: string }) => {
sentPayloads.push(stateJson)
}
const oversizedText = "x".repeat(LARGE_STATE_SNAPSHOT_WARNING_BYTES)
const largeState = {
mode: "act",
clineMessages: [{ ts: 1, type: "say", say: "text", text: oversizedText }],
} as any
const changedLargeState = {
mode: "act",
clineMessages: [{ ts: 1, type: "say", say: "text", text: `${oversizedText}!` }],
} as any
const controller = {
getStateToPostToWebview: async () => largeState,
} as any
try {
await subscribeToState(controller, {} as any, responseStream)
assert.equal(sentPayloads.length, 1)
assert.equal(sentPayloads[0], JSON.stringify(largeState))
const initialWarnCount = warnStub.callCount
assert.ok(initialWarnCount >= 1)
await sendStateUpdate(largeState)
assert.equal(sentPayloads.length, 1)
assert.equal(warnStub.callCount, initialWarnCount)
await sendStateUpdate(changedLargeState)
assert.equal(sentPayloads.length, 2)
assert.equal(sentPayloads[1], JSON.stringify(changedLargeState))
assert.ok(warnStub.callCount > initialWarnCount)
} finally {
warnStub.restore()
}
})
})
+3 -1
View File
@@ -1,6 +1,7 @@
import { EmptyRequest } from "@shared/proto/cline/common"
import { State } from "@shared/proto/cline/state"
import { Controller } from "../index"
import { recordStateSnapshotTelemetry, serializeStateSnapshot } from "./stateSnapshot"
/**
* Get the latest extension state
@@ -13,7 +14,8 @@ export async function getLatestState(controller: Controller, _: EmptyRequest): P
const state = await controller.getStateToPostToWebview()
// Convert the state to a JSON string
const stateJson = JSON.stringify(state)
const { stateJson, sizeBytes } = serializeStateSnapshot(state)
recordStateSnapshotTelemetry(sizeBytes, "getLatestState")
// Return the state as a JSON string
return State.create({
@@ -0,0 +1,31 @@
import { telemetryService } from "@/services/telemetry"
import type { ExtensionState } from "@/shared/ExtensionMessage"
import { Logger } from "@/shared/services/Logger"
export const LARGE_STATE_SNAPSHOT_WARNING_BYTES = 4 * 1024 * 1024
const STATE_SERVICE_NAME = "cline.StateService"
export function serializeStateSnapshot(state: ExtensionState): { stateJson: string; sizeBytes: number } {
const stateJson = JSON.stringify(state)
return {
stateJson,
sizeBytes: Buffer.byteLength(stateJson, "utf8"),
}
}
export function warnOnLargeStateSnapshot(sizeBytes: number, method: string): boolean {
if (sizeBytes <= LARGE_STATE_SNAPSHOT_WARNING_BYTES) {
return false
}
Logger.warn(
`[StateService] Large state snapshot for ${method}: ` +
`size=${(sizeBytes / (1024 * 1024)).toFixed(1)}MB (threshold=${(LARGE_STATE_SNAPSHOT_WARNING_BYTES / (1024 * 1024)).toFixed(1)}MB)`,
)
return true
}
export function recordStateSnapshotTelemetry(sizeBytes: number, method: string): void {
telemetryService.captureGrpcResponseSize(sizeBytes, STATE_SERVICE_NAME, method)
warnOnLargeStateSnapshot(sizeBytes, method)
}
+33 -9
View File
@@ -1,13 +1,21 @@
import { EmptyRequest } from "@shared/proto/cline/common"
import { State } from "@shared/proto/cline/state"
import { telemetryService } from "@/services/telemetry"
import { ExtensionState } from "@/shared/ExtensionMessage"
import { Logger } from "@/shared/services/Logger"
import { getRequestRegistry, StreamingResponseHandler } from "../grpc-handler"
import { Controller } from "../index"
import { recordStateSnapshotTelemetry, serializeStateSnapshot } from "./stateSnapshot"
// Keep track of active state subscriptions
const activeStateSubscriptions = new Set<StreamingResponseHandler<State>>()
const activeStateSubscriptions = new Map<StreamingResponseHandler<State>, string>()
export function hasActiveStateSubscribers(): boolean {
return activeStateSubscriptions.size > 0
}
export function resetStateSubscriptionsForTest(): void {
activeStateSubscriptions.clear()
}
/**
* Subscribe to state updates
@@ -23,7 +31,7 @@ export async function subscribeToState(
requestId?: string,
): Promise<void> {
// Add this subscription to the active subscriptions
activeStateSubscriptions.add(responseStream)
activeStateSubscriptions.set(responseStream, "")
// Register cleanup when the connection is closed
const cleanup = () => {
@@ -37,9 +45,9 @@ export async function subscribeToState(
// Send the initial state
const initialState = await controller.getStateToPostToWebview()
const initialStateJson = JSON.stringify(initialState)
const { stateJson: initialStateJson, sizeBytes } = serializeStateSnapshot(initialState)
recordStateSizeTelemetry(Buffer.byteLength(initialStateJson, "utf8"))
recordStateSizeTelemetry(sizeBytes)
try {
await responseStream(
@@ -48,6 +56,7 @@ export async function subscribeToState(
},
false, // Not the last message
)
activeStateSubscriptions.set(responseStream, initialStateJson)
} catch (error) {
Logger.error("Error sending initial state:", error)
activeStateSubscriptions.delete(responseStream)
@@ -59,17 +68,31 @@ export async function subscribeToState(
* @param state The state to send
*/
export async function sendStateUpdate(state: ExtensionState): Promise<void> {
if (!hasActiveStateSubscribers()) {
return
}
let stateJson: string
let sizeBytes: number
try {
stateJson = JSON.stringify(state)
const serialized = serializeStateSnapshot(state)
stateJson = serialized.stateJson
sizeBytes = serialized.sizeBytes
} catch (error) {
Logger.error("Error serializing state update:", error)
return
}
recordStateSizeTelemetry(Buffer.byteLength(stateJson, "utf8"))
const subscribersNeedingUpdate = Array.from(activeStateSubscriptions.entries()).filter(
([_responseStream, lastStateJson]) => lastStateJson !== stateJson,
)
if (subscribersNeedingUpdate.length === 0) {
return
}
const promises = Array.from(activeStateSubscriptions).map(async (responseStream) => {
recordStateSizeTelemetry(sizeBytes)
const promises = subscribersNeedingUpdate.map(async ([responseStream]) => {
try {
await responseStream(
{
@@ -77,6 +100,7 @@ export async function sendStateUpdate(state: ExtensionState): Promise<void> {
},
false, // Not the last message
)
activeStateSubscriptions.set(responseStream, stateJson)
} catch (error) {
Logger.error("Error sending state update:", error)
activeStateSubscriptions.delete(responseStream)
@@ -87,5 +111,5 @@ export async function sendStateUpdate(state: ExtensionState): Promise<void> {
}
function recordStateSizeTelemetry(sizeBytes: number): void {
telemetryService.captureGrpcResponseSize(sizeBytes, "cline.StateService", "subscribeToState")
recordStateSnapshotTelemetry(sizeBytes, "subscribeToState")
}
@@ -65,3 +65,18 @@ describe("formatResponse.executeCommandMissingCommandError", () => {
result.should.not.containEql("Reminder: Instructions for Tool Use")
})
})
describe("formatResponse.diffError", () => {
it("should include full file content for small files", () => {
const result = formatResponse.diffError("src/index.ts", "line1\nline2")
result.should.containEql('<file_content path="src/index.ts">')
result.should.containEql("line1\nline2")
})
it("should summarize oversized original content instead of embedding it fully", () => {
const hugeContent = "x".repeat(70 * 1024)
const result = formatResponse.diffError("src/huge.ts", hugeContent)
result.should.not.containEql('<file_content path="src/huge.ts">')
result.should.containEql("Original file content for 'src/huge.ts' omitted from tool payload")
})
})
+80 -26
View File
@@ -3,6 +3,7 @@ import * as diff from "diff"
import * as path from "path"
import { Mode } from "@/shared/storage/types"
import { ClineIgnoreController, LOCK_TEXT_SYMBOL } from "../ignore/ClineIgnoreController"
import { getSafeEditDisplayContent } from "../task/tools/utils/LargeEditGuards"
const CONTEXT_WINDOW_WARNING_THRESHOLD_PERCENT = 50
@@ -268,40 +269,93 @@ Otherwise, if you have not completed the task and do not need additional informa
autoFormattingEdits: string | undefined,
finalContent: string | undefined,
newProblemsMessage: string | undefined,
) =>
`The user made the following updates to your content:\n\n${userEdits}\n\n` +
(autoFormattingEdits
? `The user's editor also applied the following auto-formatting to your content:\n\n${autoFormattingEdits}\n\n(Note: Pay close attention to changes such as single quotes being converted to double quotes, semicolons being removed or added, long lines being broken into multiple lines, adjusting indentation style, adding/removing trailing commas, etc. This will help you ensure future SEARCH/REPLACE operations to this file are accurate.)\n\n`
: "") +
`The updated content, which includes both your original modifications and the additional edits, has been successfully saved to ${relPath.toPosix()}. Here is the full, updated content of the file that was saved:\n\n` +
`<final_file_content path="${relPath.toPosix()}">\n${finalContent}\n</final_file_content>\n\n` +
`Please note:\n` +
`1. You do not need to re-write the file with these changes, as they have already been applied.\n` +
`2. Proceed with the task using this updated file content as the new baseline.\n` +
`3. If the user's edits have addressed part of the task or changed the requirements, adjust your approach accordingly.` +
`4. IMPORTANT: For any future changes to this file, use the final_file_content shown above as your reference. This content reflects the current state of the file, including both user edits and any auto-formatting (e.g., if you used single quotes but the formatter converted them to double quotes). Always base your SEARCH/REPLACE operations on this final version to ensure accuracy.\n` +
`${newProblemsMessage}`,
) => {
const userEditsDisplay = getSafeEditDisplayContent(userEdits, {
relPath,
context: "User edits",
}).text
const autoFormattingDisplay = autoFormattingEdits
? getSafeEditDisplayContent(autoFormattingEdits, {
relPath,
context: "Auto-formatting edits",
}).text
: undefined
const finalContentDisplay = getSafeEditDisplayContent(finalContent, {
relPath,
context: "Final file content",
})
const futureReferenceInstruction = finalContentDisplay.wasSummarized
? `4. IMPORTANT: The full saved file content was omitted from this tool payload to keep it bounded. For any future changes to this file, read the file again or rely on the editor's current saved state instead of this summary.\n`
: `4. IMPORTANT: For any future changes to this file, use the final_file_content shown above as your reference. This content reflects the current state of the file, including both user edits and any auto-formatting (e.g., if you used single quotes but the formatter converted them to double quotes). Always base your SEARCH/REPLACE operations on this final version to ensure accuracy.\n`
return (
`The user made the following updates to your content:\n\n${userEditsDisplay}\n\n` +
(autoFormattingDisplay
? `The user's editor also applied the following auto-formatting to your content:\n\n${autoFormattingDisplay}\n\n(Note: Pay close attention to changes such as single quotes being converted to double quotes, semicolons being removed or added, long lines being broken into multiple lines, adjusting indentation style, adding/removing trailing commas, etc. This will help you ensure future SEARCH/REPLACE operations to this file are accurate.)\n\n`
: "") +
`The updated content, which includes both your original modifications and the additional edits, has been successfully saved to ${relPath.toPosix()}. ` +
(finalContentDisplay.wasSummarized
? `${finalContentDisplay.text}\n\n`
: `Here is the full, updated content of the file that was saved:\n\n<final_file_content path="${relPath.toPosix()}">\n${finalContentDisplay.text}\n</final_file_content>\n\n`) +
`Please note:\n` +
`1. You do not need to re-write the file with these changes, as they have already been applied.\n` +
`2. Proceed with the task using this updated file content as the new baseline.\n` +
`3. If the user's edits have addressed part of the task or changed the requirements, adjust your approach accordingly.` +
futureReferenceInstruction +
`${newProblemsMessage}`
)
},
fileEditWithoutUserChanges: (
relPath: string,
autoFormattingEdits: string | undefined,
finalContent: string | undefined,
newProblemsMessage: string | undefined,
) =>
`The content was successfully saved to ${relPath.toPosix()}.\n\n` +
(autoFormattingEdits
? `Along with your edits, the user's editor applied the following auto-formatting to your content:\n\n${autoFormattingEdits}\n\n(Note: Pay close attention to changes such as single quotes being converted to double quotes, semicolons being removed or added, long lines being broken into multiple lines, adjusting indentation style, adding/removing trailing commas, etc. This will help you ensure future SEARCH/REPLACE operations to this file are accurate.)\n\n`
: "") +
`Here is the full, updated content of the file that was saved:\n\n` +
`<final_file_content path="${relPath.toPosix()}">\n${finalContent}\n</final_file_content>\n\n` +
`IMPORTANT: For any future changes to this file, use the final_file_content shown above as your reference. This content reflects the current state of the file, including any auto-formatting (e.g., if you used single quotes but the formatter converted them to double quotes). Always base your SEARCH/REPLACE operations on this final version to ensure accuracy.\n\n` +
`${newProblemsMessage}`,
) => {
const autoFormattingDisplay = autoFormattingEdits
? getSafeEditDisplayContent(autoFormattingEdits, {
relPath,
context: "Auto-formatting edits",
}).text
: undefined
const finalContentDisplay = getSafeEditDisplayContent(finalContent, {
relPath,
context: "Final file content",
})
const futureReferenceInstruction = finalContentDisplay.wasSummarized
? `IMPORTANT: The full saved file content was omitted from this tool payload to keep it bounded. For any future changes to this file, read the file again or rely on the editor's current saved state instead of this summary.\n\n`
: `IMPORTANT: For any future changes to this file, use the final_file_content shown above as your reference. This content reflects the current state of the file, including any auto-formatting (e.g., if you used single quotes but the formatter converted them to double quotes). Always base your SEARCH/REPLACE operations on this final version to ensure accuracy.\n\n`
return (
`The content was successfully saved to ${relPath.toPosix()}.\n\n` +
(autoFormattingDisplay
? `Along with your edits, the user's editor applied the following auto-formatting to your content:\n\n${autoFormattingDisplay}\n\n(Note: Pay close attention to changes such as single quotes being converted to double quotes, semicolons being removed or added, long lines being broken into multiple lines, adjusting indentation style, adding/removing trailing commas, etc. This will help you ensure future SEARCH/REPLACE operations to this file are accurate.)\n\n`
: "") +
(finalContentDisplay.wasSummarized
? `${finalContentDisplay.text}\n\n`
: `Here is the full, updated content of the file that was saved:\n\n<final_file_content path="${relPath.toPosix()}">\n${finalContentDisplay.text}\n</final_file_content>\n\n`) +
futureReferenceInstruction +
`${newProblemsMessage}`
)
},
diffError: (relPath: string, originalContent: string | undefined) =>
`This is likely because the SEARCH block content doesn't match exactly with what's in the file, or if you used multiple SEARCH/REPLACE blocks they may not have been in the order they appear in the file. (Please also ensure that when using the replace_in_file tool, Do NOT add extra characters to the markers (e.g., ------- SEARCH> is INVALID). Do NOT forget to use the closing +++++++ REPLACE marker. Do NOT modify the marker format in any way. Malformed XML will cause complete tool failure and break the entire editing process.)\n\n` +
`The file was reverted to its original state:\n\n` +
`<file_content path="${relPath.toPosix()}">\n${originalContent}\n</file_content>\n\n` +
`Now that you have the latest state of the file, try the operation again with fewer, more precise SEARCH blocks. For large files especially, it may be prudent to try to limit yourself to <5 SEARCH/REPLACE blocks at a time, then wait for the user to respond with the result of the operation before following up with another replace_in_file call to make additional edits.\n(If you run into this error 3 times in a row, you may use the write_to_file tool as a fallback.)`,
(() => {
const originalContentDisplay = getSafeEditDisplayContent(originalContent, {
relPath,
context: "Original file content",
})
const originalContentSection = originalContentDisplay.wasSummarized
? `${originalContentDisplay.text}\n\n`
: `<file_content path="${relPath.toPosix()}">\n${originalContentDisplay.text}\n</file_content>\n\n`
return (
`This is likely because the SEARCH block content doesn't match exactly with what's in the file, or if you used multiple SEARCH/REPLACE blocks they may not have been in the order they appear in the file. (Please also ensure that when using the replace_in_file tool, Do NOT add extra characters to the markers (e.g., ------- SEARCH> is INVALID). Do NOT forget to use the closing +++++++ REPLACE marker. Do NOT modify the marker format in any way. Malformed XML will cause complete tool failure and break the entire editing process.)\n\n` +
`The file was reverted to its original state:\n\n` +
originalContentSection +
`Now that you have the latest state of the file, try the operation again with fewer, more precise SEARCH blocks. For large files especially, it may be prudent to try to limit yourself to <5 SEARCH/REPLACE blocks at a time, then wait for the user to respond with the result of the operation before following up with another replace_in_file call to make additional edits.\n(If you run into this error 3 times in a row, you may use the write_to_file tool as a fallback.)`
)
})(),
toolAlreadyUsed: (toolName: string) =>
`Tool [${toolName}] was not executed because a tool has already been used in this message. Only one tool may be used per message. You must assess the first tool's result before proceeding to use the next tool.`,
+31
View File
@@ -10,10 +10,13 @@ import { HostProvider } from "@/hosts/host-provider"
import { setVscodeHostProviderMock } from "@/test/host-provider-test-utils"
import {
ensureStateDirectoryExists,
ensureTaskDirectoryExists,
getAllHooksDirs,
getSavedClineMessages,
getTaskHistoryStateFilePath,
getWorkspaceHooksDirs,
readTaskHistoryFromState,
saveClineMessages,
setRuntimeHooksDir,
writeTaskHistoryToState,
} from "../disk"
@@ -297,6 +300,34 @@ describe("disk - atomic writes", () => {
sandbox.restore()
})
describe("saveClineMessages and getSavedClineMessages", () => {
it("round-trips large ui_messages histories without truncation", async () => {
const taskId = `large-history-${Date.now()}`
const messages = Array.from({ length: 1_200 }, (_, i) => ({
ts: i + 1,
type: "say",
say: "text",
text: `message-${i}-${"payload-".repeat(64)}`,
})) as any
await saveClineMessages(taskId, messages)
const reloaded = await getSavedClineMessages(taskId)
const firstMessage = reloaded[0]
const lastMessage = reloaded[1_199]
reloaded.should.have.length(1_200)
if (firstMessage?.text === undefined || lastMessage?.text === undefined) {
throw new Error("Expected first and last reloaded messages to include text")
}
firstMessage.text.should.equal(messages[0]?.text)
lastMessage.text.should.equal(messages[1_199]?.text)
const taskDir = await ensureTaskDirectoryExists(taskId)
const raw = await fs.readFile(path.join(taskDir, "ui_messages.json"), "utf8")
raw.length.should.be.greaterThan(100_000)
})
})
describe("writeTaskHistoryToState and readTaskHistoryFromState", () => {
it("should write and read task history correctly", async () => {
const items = [createTestHistoryItem("test-1", "Build a todo app"), createTestHistoryItem("test-2", "Fix a bug")]
+154
View File
@@ -0,0 +1,154 @@
import { strict as assert } from "node:assert"
import { describe, it } from "mocha"
import sinon from "sinon"
import { FocusChainManager } from "./index"
describe("FocusChainManager lifecycle guards", () => {
it("does not post state from a queued debounce after dispose", async () => {
const clock = sinon.useFakeTimers()
const postStateToWebview = sinon.stub().resolves()
const manager = new FocusChainManager({
taskId: "task-1",
taskState: {
currentFocusChainChecklist: null,
todoListWasUpdatedByUser: false,
apiRequestCount: 0,
apiRequestsSinceLastTodoUpdate: 0,
didRespondToPlanAskBySwitchingMode: false,
} as any,
mode: "act" as any,
stateManager: {
getGlobalSettingsKey: () => "act",
} as any,
postStateToWebview,
say: sinon.stub().resolves(undefined),
focusChainSettings: {
enabled: true,
remindClineInterval: 5,
} as any,
})
sinon.stub(manager as any, "readFocusChainFromDisk").resolves("- [ ] test")
;(manager as any).updateFCListFromMarkdownFileAndNotifyUI()
manager.dispose()
await clock.tickAsync(350)
assert.equal(postStateToWebview.called, false)
clock.restore()
})
it("closes the file watcher when disposed", async () => {
const close = sinon.stub()
const manager = new FocusChainManager({
taskId: "task-2",
taskState: {
currentFocusChainChecklist: null,
todoListWasUpdatedByUser: false,
apiRequestCount: 0,
apiRequestsSinceLastTodoUpdate: 0,
didRespondToPlanAskBySwitchingMode: false,
} as any,
mode: "act" as any,
stateManager: {
getGlobalSettingsKey: () => "act",
} as any,
postStateToWebview: sinon.stub().resolves(),
say: sinon.stub().resolves(undefined),
focusChainSettings: {
enabled: true,
remindClineInterval: 5,
} as any,
})
;(manager as any).focusChainFileWatcher = { close }
await manager.dispose()
assert.equal(close.calledOnce, true)
assert.equal((manager as any).focusChainFileWatcher, undefined)
})
it("does not accumulate watcher references across repeated dispose cycles", async () => {
const closes: sinon.SinonStub[] = []
for (let i = 0; i < 5; i++) {
const close = sinon.stub()
closes.push(close)
const manager = new FocusChainManager({
taskId: `task-${i}`,
taskState: {
currentFocusChainChecklist: null,
todoListWasUpdatedByUser: false,
apiRequestCount: 0,
apiRequestsSinceLastTodoUpdate: 0,
didRespondToPlanAskBySwitchingMode: false,
} as any,
mode: "act" as any,
stateManager: {
getGlobalSettingsKey: () => "act",
} as any,
postStateToWebview: sinon.stub().resolves(),
say: sinon.stub().resolves(undefined),
focusChainSettings: {
enabled: true,
remindClineInterval: 5,
} as any,
})
;(manager as any).focusChainFileWatcher = { close }
await manager.dispose()
assert.equal((manager as any).focusChainFileWatcher, undefined)
}
for (const close of closes) {
assert.equal(close.calledOnce, true)
}
})
it("awaits async watcher closure before dispose resolves", async () => {
let resolveClose!: () => void
const closePromise = new Promise<void>((resolve) => {
resolveClose = resolve
})
const manager = new FocusChainManager({
taskId: "task-async",
taskState: {
currentFocusChainChecklist: null,
todoListWasUpdatedByUser: false,
apiRequestCount: 0,
apiRequestsSinceLastTodoUpdate: 0,
didRespondToPlanAskBySwitchingMode: false,
} as any,
mode: "act" as any,
stateManager: {
getGlobalSettingsKey: () => "act",
} as any,
postStateToWebview: sinon.stub().resolves(),
say: sinon.stub().resolves(undefined),
focusChainSettings: {
enabled: true,
remindClineInterval: 5,
} as any,
})
;(manager as any).focusChainFileWatcher = {
close: sinon.stub().returns(closePromise),
}
const disposePromise = manager.dispose()
await Promise.resolve()
let settled = false
void disposePromise.then(() => {
settled = true
})
await Promise.resolve()
assert.equal(settled, false)
resolveClose()
await disposePromise
assert.equal((manager as any).focusChainFileWatcher, undefined)
})
})
+45 -34
View File
@@ -44,6 +44,7 @@ export class FocusChainManager {
private hasTrackedFirstProgress = false
private focusChainSettings: FocusChainSettings
private fileUpdateDebounceTimer?: NodeJS.Timeout
private disposed = false
constructor(dependencies: FocusChainDependencies) {
this.taskId = dependencies.taskId
@@ -61,6 +62,9 @@ export class FocusChainManager {
* @returns Promise<void> - Resolves when watcher is set up, logs errors if setup fails
*/
public async setupFocusChainFileWatcher() {
if (this.disposed) {
return
}
try {
const taskDir = await ensureTaskDirectoryExists(this.taskId)
const focusChainFilePath = getFocusChainFilePath(taskDir, this.taskId)
@@ -104,14 +108,23 @@ export class FocusChainManager {
* @returns Promise<void> - Updates taskState.currentFocusChainChecklist and calls postStateToWebview()
*/
private async updateFCListFromMarkdownFileAndNotifyUI() {
if (this.disposed) {
return
}
if (this.fileUpdateDebounceTimer) {
clearTimeout(this.fileUpdateDebounceTimer)
}
// Debounce file watcher to prevent false positives
this.fileUpdateDebounceTimer = setTimeout(async () => {
if (this.disposed) {
return
}
try {
const markdownTodoList = await this.readFocusChainFromDisk()
if (this.disposed) {
return
}
if (markdownTodoList) {
const previousList = this.taskState.currentFocusChainChecklist
@@ -165,28 +178,28 @@ export class FocusChainManager {
`
// If there are no user changes, proceed with reminders based on list progress
} else {
let progressBasedMessageStub = ""
// If there are items on the list, but none have been completed yet, remind the model to update the list when appropriate
if (completedItems === 0 && totalItems > 0) {
progressBasedMessageStub =
"\n\n**Note:** No items are marked complete yet. As you work through the task, remember to mark items as complete when finished."
} else if (percentComplete >= 25 && percentComplete < 50) {
progressBasedMessageStub = `\n\n**Note:** ${percentComplete}% of items are complete.`
} else if (percentComplete >= 50 && percentComplete < 75) {
progressBasedMessageStub = `\n\n**Note:** ${percentComplete}% of items are complete. Proceed with the task.`
} else if (percentComplete >= 75) {
progressBasedMessageStub = `\n\n**Note:** ${percentComplete}% of items are complete! Focus on finishing the remaining items.`
}
// Every item on the list has been completed. Hooray!
else if (completedItems === totalItems && totalItems > 0) {
progressBasedMessageStub = FocusChainPrompts.completed
.replace("{{totalItems}}", totalItems.toString())
.replace("{{currentFocusChainChecklist}}", this.taskState.currentFocusChainChecklist)
}
}
let progressBasedMessageStub = ""
// If there are items on the list, but none have been completed yet, remind the model to update the list when appropriate
if (completedItems === 0 && totalItems > 0) {
progressBasedMessageStub =
"\n\n**Note:** No items are marked complete yet. As you work through the task, remember to mark items as complete when finished."
} else if (percentComplete >= 25 && percentComplete < 50) {
progressBasedMessageStub = `\n\n**Note:** ${percentComplete}% of items are complete.`
} else if (percentComplete >= 50 && percentComplete < 75) {
progressBasedMessageStub = `\n\n**Note:** ${percentComplete}% of items are complete. Proceed with the task.`
} else if (percentComplete >= 75) {
progressBasedMessageStub = `\n\n**Note:** ${percentComplete}% of items are complete! Focus on finishing the remaining items.`
}
// Every item on the list has been completed. Hooray!
else if (completedItems === totalItems && totalItems > 0) {
progressBasedMessageStub = FocusChainPrompts.completed
.replace("{{totalItems}}", totalItems.toString())
.replace("{{currentFocusChainChecklist}}", this.taskState.currentFocusChainChecklist)
}
// Return with progress-based stub
return `\n
// Return with progress-based stub
return `\n
${introUpdateRequired}\n
${listCurrentProgress}\n
${this.taskState.currentFocusChainChecklist}\n
@@ -194,25 +207,22 @@ export class FocusChainManager {
${FocusChainPrompts.reminder}\n
${progressBasedMessageStub}\n
`
}
}
// When switching from Plan to Act, request that a new list be generated
else if (this.taskState.didRespondToPlanAskBySwitchingMode) {
if (this.taskState.didRespondToPlanAskBySwitchingMode) {
return `${FocusChainPrompts.initial}`
}
// When in plan mode, lists are optional. TODO - May want to improve this soft prompt approach in a future version
else if (this.stateManager.getGlobalSettingsKey("mode") === "plan") {
if (this.stateManager.getGlobalSettingsKey("mode") === "plan") {
return FocusChainPrompts.planModeReminder
} else {
// Check if we're early in the task
const isEarlyInTask = this.taskState.apiRequestCount < 10
if (isEarlyInTask) {
return FocusChainPrompts.recommended
} else {
return FocusChainPrompts.apiRequestCount.replace("{{apiRequestCount}}", this.taskState.apiRequestCount.toString())
}
}
// Check if we're early in the task
const isEarlyInTask = this.taskState.apiRequestCount < 10
if (isEarlyInTask) {
return FocusChainPrompts.recommended
}
return FocusChainPrompts.apiRequestCount.replace("{{apiRequestCount}}", this.taskState.apiRequestCount.toString())
}
/**
@@ -394,14 +404,15 @@ export class FocusChainManager {
* @requires No parameters needed
* @returns void - Cleans up timers and watchers, no return value
*/
public dispose() {
public async dispose() {
this.disposed = true
if (this.fileUpdateDebounceTimer) {
clearTimeout(this.fileUpdateDebounceTimer)
this.fileUpdateDebounceTimer = undefined
}
if (this.focusChainFileWatcher) {
this.focusChainFileWatcher.close()
await Promise.resolve(this.focusChainFileWatcher.close())
this.focusChainFileWatcher = undefined
}
}
+12 -13
View File
@@ -130,6 +130,7 @@ import { TaskState } from "./TaskState"
import { ToolExecutor } from "./ToolExecutor"
import { detectAvailableCliTools, extractProviderDomainFromUrl, updateApiReqMsg } from "./utils"
import { buildUserFeedbackContent } from "./utils/buildUserFeedbackContent"
import { performTaskAbortCleanup } from "./utils/taskAbortCleanup"
export type ToolResponse = ClineToolResponseContent
@@ -979,7 +980,7 @@ export class Task {
// Save conversation state to disk
await this.messageStateHandler.saveClineMessagesAndUpdateHistory()
await this.messageStateHandler.overwriteApiConversationHistory(this.messageStateHandler.getApiConversationHistory())
await this.messageStateHandler.saveApiConversationHistory()
// Update UI
await this.postStateToWebview()
@@ -1042,7 +1043,7 @@ export class Task {
this.taskState.didFinishAbortingStream = true
// Save BOTH files so Controller.cancelTask() can find the task
await this.messageStateHandler.saveClineMessagesAndUpdateHistory()
await this.messageStateHandler.overwriteApiConversationHistory(this.messageStateHandler.getApiConversationHistory())
await this.messageStateHandler.saveApiConversationHistory()
await this.postStateToWebview()
}
@@ -1642,19 +1643,17 @@ export class Task {
// PHASE 7: Clean up resources
this.terminalManager.disposeAll()
this.urlContentFetcher.closeBrowser()
await this.browserSession.dispose()
this.clineIgnoreController.dispose()
this.fileContextTracker.dispose()
// need to await for when we want to make sure directories/files are reverted before
// re-starting the task from a checkpoint
await this.diffViewProvider.revertChanges()
// Clear the notification callback when task is aborted
this.mcpHub.clearNotificationCallback()
if (this.FocusChainManager) {
this.FocusChainManager.dispose()
}
await this.presentationScheduler.dispose()
await performTaskAbortCleanup({
urlContentFetcher: this.urlContentFetcher,
diffViewProvider: this.diffViewProvider,
browserSession: this.browserSession,
clineIgnoreController: this.clineIgnoreController,
fileContextTracker: this.fileContextTracker,
focusChainManager: this.FocusChainManager,
presentationScheduler: this.presentationScheduler,
})
} finally {
// Release task folder lock
if (this.taskLockAcquired) {
+80 -9
View File
@@ -14,6 +14,8 @@ import { getCwd, getDesktopDir } from "@/utils/path"
import { ensureTaskDirectoryExists, saveApiConversationHistory, saveClineMessages } from "../storage/disk"
import { TaskState } from "./TaskState"
const TASK_DIRECTORY_SIZE_CACHE_TTL_MS = 5_000
// Event types for clineMessages changes
export type ClineMessageChangeType = "add" | "update" | "delete" | "set"
@@ -43,6 +45,12 @@ interface MessageStateHandlerParams {
updateTaskHistory: (historyItem: HistoryItem) => Promise<HistoryItem[]>
taskState: TaskState
checkpointManagerErrorMessage?: string
now?: () => number
getTaskDirectorySize?: (taskDir: string) => Promise<number>
getCurrentWorkingDirectory?: () => Promise<string>
ensureTaskDirectoryExists?: (taskId: string) => Promise<string>
saveClineMessages?: (taskId: string, messages: ClineMessage[]) => Promise<void>
saveApiConversationHistory?: (taskId: string, messages: ClineStorageMessage[]) => Promise<void>
}
export class MessageStateHandler extends EventEmitter<MessageStateHandlerEvents> {
@@ -54,6 +62,16 @@ export class MessageStateHandler extends EventEmitter<MessageStateHandlerEvents>
private taskId: string
private ulid: string
private taskState: TaskState
private readonly now: () => number
private readonly getTaskDirectorySize: (taskDir: string) => Promise<number>
private readonly getCurrentWorkingDirectory: () => Promise<string>
private readonly ensureTaskDirectoryExistsFn: (taskId: string) => Promise<string>
private readonly saveClineMessagesFn: (taskId: string, messages: ClineMessage[]) => Promise<void>
private readonly saveApiConversationHistoryFn: (taskId: string, messages: ClineStorageMessage[]) => Promise<void>
private hasCachedTaskDirSize = false
private cachedTaskDirSize = 0
private lastTaskDirSizeComputedAt = 0
private pendingTaskDirSizePromise?: Promise<number>
// Mutex to prevent concurrent state modifications (RC-4)
// Protects against data loss from race conditions when multiple
@@ -68,6 +86,40 @@ export class MessageStateHandler extends EventEmitter<MessageStateHandlerEvents>
this.taskState = params.taskState
this.taskIsFavorited = params.taskIsFavorited ?? false
this.updateTaskHistory = params.updateTaskHistory
this.now = params.now ?? (() => Date.now())
this.getTaskDirectorySize =
params.getTaskDirectorySize ??
(async (taskDir: string) => {
return await getFolderSize.loose(taskDir)
})
this.getCurrentWorkingDirectory = params.getCurrentWorkingDirectory ?? (() => getCwd(getDesktopDir()))
this.ensureTaskDirectoryExistsFn = params.ensureTaskDirectoryExists ?? ensureTaskDirectoryExists
this.saveClineMessagesFn = params.saveClineMessages ?? saveClineMessages
this.saveApiConversationHistoryFn = params.saveApiConversationHistory ?? saveApiConversationHistory
}
private async getCachedTaskDirectorySize(taskDir: string): Promise<number> {
const currentTime = this.now()
if (this.pendingTaskDirSizePromise) {
return await this.pendingTaskDirSizePromise
}
if (this.hasCachedTaskDirSize && currentTime - this.lastTaskDirSizeComputedAt < TASK_DIRECTORY_SIZE_CACHE_TTL_MS) {
return this.cachedTaskDirSize
}
this.pendingTaskDirSizePromise = (async () => {
try {
const size = await this.getTaskDirectorySize(taskDir)
this.hasCachedTaskDirSize = true
this.cachedTaskDirSize = size
this.lastTaskDirSizeComputedAt = this.now()
return size
} finally {
this.pendingTaskDirSizePromise = undefined
}
})()
return await this.pendingTaskDirSizePromise
}
/**
@@ -119,7 +171,7 @@ export class MessageStateHandler extends EventEmitter<MessageStateHandlerEvents>
*/
private async saveClineMessagesAndUpdateHistoryInternal(): Promise<void> {
try {
await saveClineMessages(this.taskId, this.clineMessages)
await this.saveClineMessagesFn(this.taskId, this.clineMessages)
// combined as they are in ChatView
const apiMetrics = getApiMetrics(combineApiRequests(combineCommandSequences(this.clineMessages.slice(1))))
@@ -132,16 +184,16 @@ export class MessageStateHandler extends EventEmitter<MessageStateHandlerEvents>
)
]
const lastModelInfo = [...this.apiConversationHistory].reverse().find((msg) => msg.modelInfo !== undefined)
const taskDir = await ensureTaskDirectoryExists(this.taskId)
const taskDir = await this.ensureTaskDirectoryExistsFn(this.taskId)
let taskDirSize = 0
try {
// getFolderSize.loose silently ignores errors
// returns # of bytes, size/1000/1000 = MB
taskDirSize = await getFolderSize.loose(taskDir)
taskDirSize = await this.getCachedTaskDirectorySize(taskDir)
} catch (error) {
Logger.error("Failed to get task directory size:", taskDir, error)
}
const cwd = await getCwd(getDesktopDir())
const cwd = await this.getCurrentWorkingDirectory()
await this.updateTaskHistory({
id: this.taskId,
ulid: this.ulid,
@@ -179,15 +231,24 @@ export class MessageStateHandler extends EventEmitter<MessageStateHandlerEvents>
// Protect with mutex to prevent concurrent modifications from corrupting data (RC-4)
return await this.withStateLock(async () => {
this.apiConversationHistory.push(message)
await saveApiConversationHistory(this.taskId, this.apiConversationHistory)
await this.saveApiConversationHistoryFn(this.taskId, this.apiConversationHistory)
})
}
async saveApiConversationHistory(): Promise<void> {
return await this.withStateLock(async () => {
await this.saveApiConversationHistoryFn(this.taskId, this.apiConversationHistory)
})
}
async overwriteApiConversationHistory(newHistory: ClineStorageMessage[]): Promise<void> {
// Protect with mutex to prevent concurrent modifications from corrupting data (RC-4)
return await this.withStateLock(async () => {
if (Object.is(this.apiConversationHistory, newHistory)) {
return
}
this.apiConversationHistory = newHistory
await saveApiConversationHistory(this.taskId, this.apiConversationHistory)
await this.saveApiConversationHistoryFn(this.taskId, this.apiConversationHistory)
})
}
@@ -242,18 +303,28 @@ export class MessageStateHandler extends EventEmitter<MessageStateHandlerEvents>
throw new Error(`Invalid message index: ${index}`)
}
const currentMessage = this.clineMessages[index]
const updateEntries = Object.entries(updates) as Array<[keyof ClineMessage, ClineMessage[keyof ClineMessage]]>
if (updateEntries.length === 0) {
return
}
const hasActualChange = updateEntries.some(([key, value]) => !Object.is(currentMessage[key], value))
if (!hasActualChange) {
return
}
// Capture previous state before mutation
const previousMessage = { ...this.clineMessages[index] }
const previousMessage = { ...currentMessage }
// Apply updates to the message
Object.assign(this.clineMessages[index], updates)
Object.assign(currentMessage, updates)
this.emitClineMessagesChanged({
type: "update",
messages: this.clineMessages,
index,
previousMessage,
message: this.clineMessages[index],
message: currentMessage,
})
// Save changes and update history
@@ -19,6 +19,7 @@ import type { TaskConfig } from "../types/TaskConfig"
import type { StronglyTypedUIHelpers } from "../types/UIHelpers"
import { captureAccepted, captureRejected, getModelInfo } from "../utils/AiOutputTelemetry"
import { type FileOpsResult, FileProviderOperations } from "../utils/FileProviderOperations"
import { getSafeEditDisplayContent } from "../utils/LargeEditGuards"
import { PatchParser } from "../utils/PatchParser"
import { PathResolver } from "../utils/PathResolver"
import { ToolResultUtils } from "../utils/ToolResultUtils"
@@ -158,7 +159,10 @@ export class ApplyPatchHandler implements IFullyManagedTool {
JSON.stringify({
tool: PatchClineSayMap[actionType],
path: getReadablePath(config.cwd, finalPath),
content: rawInput,
content: getSafeEditDisplayContent(rawInput, {
relPath: finalPath,
context: "Patch preview",
}).text,
operationIsLocatedInWorkspace: await isLocatedInWorkspace(finalPath),
}),
true,
@@ -362,13 +366,17 @@ export class ApplyPatchHandler implements IFullyManagedTool {
// Format response similar to WriteToFileToolHandler
if (result.userEdits) {
// User made edits during approval
responseLines.push(`\nThe user made edits to the file:\n${result.userEdits}\n`)
const userEditsDisplay = getSafeEditDisplayContent(result.userEdits, {
relPath: path,
context: "User edits",
})
responseLines.push(`\nThe user made edits to the file:\n${userEditsDisplay.text}\n`)
await config.callbacks.say(
"user_feedback_diff",
JSON.stringify({
tool: "editedExistingFile",
path,
diff: result.userEdits,
diff: userEditsDisplay.text,
}),
)
@@ -387,12 +395,24 @@ export class ApplyPatchHandler implements IFullyManagedTool {
})
}
if (result.autoFormattingEdits) {
responseLines.push(`\nAuto-formatting was applied to ${path}:\n${result.autoFormattingEdits}\n`)
const autoFormattingDisplay = getSafeEditDisplayContent(result.autoFormattingEdits, {
relPath: path,
context: "Auto-formatting edits",
})
responseLines.push(`\nAuto-formatting was applied to ${path}:\n${autoFormattingDisplay.text}\n`)
}
if (result.finalContent) {
responseLines.push(`\n<final_file_content path="${path}">`)
responseLines.push(result.finalContent)
responseLines.push(`</final_file_content>`)
const finalContentDisplay = getSafeEditDisplayContent(result.finalContent, {
relPath: path,
context: "Final file content",
})
if (finalContentDisplay.wasSummarized) {
responseLines.push(`\n${finalContentDisplay.text}`)
} else {
responseLines.push(`\n<final_file_content path="${path}">`)
responseLines.push(finalContentDisplay.text || "")
responseLines.push(`</final_file_content>`)
}
}
if (result.newProblemsMessage) {
responseLines.push(`\n\n${result.newProblemsMessage}`)
@@ -685,19 +705,25 @@ export class ApplyPatchHandler implements IFullyManagedTool {
const summaries = await Promise.all(
Object.entries(changes).map(async ([file, change]) => {
const operationIsLocatedInWorkspace = await isLocatedInWorkspace(file)
const changeContent =
change.type === PatchActionType.UPDATE && change.movePath ? change.oldContent : change.newContent
const displayContent = getSafeEditDisplayContent(changeContent, {
relPath: change.movePath || file,
context: "Patch change preview",
}).text
switch (change.type) {
case PatchActionType.ADD:
return {
tool: "newFileCreated",
path: file,
content: change.newContent,
content: displayContent,
operationIsLocatedInWorkspace,
} as ClineSayTool
case PatchActionType.UPDATE:
return {
tool: change.movePath ? "newFileCreated" : "editedExistingFile",
path: change.movePath || file,
content: change.movePath ? change.oldContent : change.newContent,
content: displayContent,
operationIsLocatedInWorkspace,
startLineNumbers: change.startLineNumbers,
} as ClineSayTool
@@ -705,7 +731,7 @@ export class ApplyPatchHandler implements IFullyManagedTool {
return {
tool: "fileDeleted",
path: file,
content: change.newContent,
content: displayContent,
operationIsLocatedInWorkspace,
} as ClineSayTool
}
@@ -722,7 +748,13 @@ export class ApplyPatchHandler implements IFullyManagedTool {
rawInput: string,
change?: FileChange,
): Promise<boolean> {
const patch = { ...message, content: rawInput }
const patch = {
...message,
content: getSafeEditDisplayContent(rawInput, {
relPath: message.path || "patch",
context: "Patch input",
}).text,
}
const completeMessage = JSON.stringify(patch)
const shouldAutoApprove = await config.callbacks.shouldAutoApproveToolWithPath(block.name, message.path)
@@ -19,6 +19,7 @@ import type { ToolValidator } from "../ToolValidator"
import type { TaskConfig } from "../types/TaskConfig"
import type { StronglyTypedUIHelpers } from "../types/UIHelpers"
import { captureAccepted, captureRejected, getModelInfo } from "../utils/AiOutputTelemetry"
import { getSafeEditDisplayContent, validateFileEditSafety } from "../utils/LargeEditGuards"
import { applyModelContentFixes } from "../utils/ModelContentProcessor"
import { ToolDisplayUtils } from "../utils/ToolDisplayUtils"
import { ToolResultUtils } from "../utils/ToolResultUtils"
@@ -53,6 +54,10 @@ export class WriteToFileToolHandler implements IFullyManagedTool {
try {
const { relPath, absolutePath, fileExists, diff, content, newContent, matchIndices } = result
const displayContent = getSafeEditDisplayContent(diff || content, {
relPath,
context: block.name === "replace_in_file" ? "File edit diff" : "File write content",
}).text
// Create and show partial UI message
const sharedMessageProps: ClineSayTool = {
@@ -61,7 +66,7 @@ export class WriteToFileToolHandler implements IFullyManagedTool {
config.cwd,
uiHelpers.removeClosingTag(block, block.params.path ? "path" : "absolutePath", relPath),
),
content: diff || content,
content: displayContent,
operationIsLocatedInWorkspace: await isLocatedInWorkspace(relPath),
startLineNumbers: matchIndices?.map((idx) =>
getLineNumberFromCharIndex(config.services.diffViewProvider.originalContent || "", idx),
@@ -164,12 +169,16 @@ export class WriteToFileToolHandler implements IFullyManagedTool {
}
const { relPath, absolutePath, fileExists, diff, content, newContent, workspaceContext, matchIndices } = result
const displayContent = getSafeEditDisplayContent(diff || content, {
relPath,
context: block.name === "replace_in_file" ? "File edit diff" : "File write content",
}).text
// Handle approval flow
const sharedMessageProps: ClineSayTool = {
tool: fileExists ? "editedExistingFile" : "newFileCreated",
path: getReadablePath(config.cwd, relPath),
content: diff || content,
content: displayContent,
operationIsLocatedInWorkspace: await isLocatedInWorkspace(relPath),
startLineNumbers: matchIndices?.map((idx) =>
getLineNumberFromCharIndex(config.services.diffViewProvider.originalContent || "", idx),
@@ -191,7 +200,7 @@ export class WriteToFileToolHandler implements IFullyManagedTool {
const completeMessage = JSON.stringify({
...sharedMessageProps,
content: diff || content,
content: displayContent,
operationIsLocatedInWorkspace: await isLocatedInWorkspace(relPath),
// ? formatResponse.createPrettyPatch(
// relPath,
@@ -377,13 +386,17 @@ export class WriteToFileToolHandler implements IFullyManagedTool {
// Handle user edits if any
if (userEdits) {
const userEditsDisplay = getSafeEditDisplayContent(userEdits, {
relPath,
context: "User edits",
}).text
await config.services.fileContextTracker.trackFileContext(relPath, "user_edited")
await config.callbacks.say(
"user_feedback_diff",
JSON.stringify({
tool: fileExists ? "editedExistingFile" : "newFileCreated",
path: relPath,
diff: userEdits,
diff: userEditsDisplay,
}),
)
@@ -575,6 +588,36 @@ export class WriteToFileToolHandler implements IFullyManagedTool {
return
}
try {
validateFileEditSafety(newContent, {
relPath: resolvedPath,
operation: fileExists ? "edit" : "write",
})
} catch (error) {
if (block.partial) {
return
}
config.taskState.consecutiveMistakeCount++
const errorResponse = formatResponse.toolError((error as Error).message)
ToolResultUtils.pushToolResult(
errorResponse,
block,
config.taskState.userMessageContent,
ToolDisplayUtils.getToolDescription,
config.coordinator,
config.taskState.toolUseIdMap,
)
if (!config.enableParallelToolCalling) {
config.taskState.didAlreadyUseTool = true
}
if (config.services.diffViewProvider.isEditing) {
await config.services.diffViewProvider.revertChanges()
await config.services.diffViewProvider.reset()
}
return
}
return { relPath, absolutePath, fileExists, diff, content, newContent, workspaceContext, matchIndices }
}
}
@@ -0,0 +1,183 @@
import { strict as assert } from "node:assert"
import os from "node:os"
import path from "node:path"
import { describe, it } from "mocha"
import sinon from "sinon"
import { HostProvider } from "@/hosts/host-provider"
import { PatchActionType } from "@/shared/Patch"
import { MAX_FILE_EDIT_DISPLAY_BYTES } from "../../utils/LargeEditGuards"
import { ApplyPatchHandler } from "../ApplyPatchHandler"
describe("ApplyPatchHandler large edit guards", () => {
it("summarizes oversized patch input in approval payloads", async () => {
let askedMessage = ""
const handler = new ApplyPatchHandler({ checkClineIgnorePath: () => ({ ok: true }) } as any)
const config = {
ulid: "ulid-1",
autoApprovalSettings: { enableNotifications: false },
api: {
getModel: () => ({ id: "test-model" }),
},
services: {
stateManager: {
getApiConfiguration: () => ({ planModeApiProvider: "openai", actModeApiProvider: "openai" }),
getGlobalSettingsKey: () => "act",
},
},
taskState: {
userMessageContent: [],
didRejectTool: false,
},
callbacks: {
shouldAutoApproveToolWithPath: sinon.stub().resolves(false),
removeLastPartialMessageIfExistsWithType: sinon.stub().resolves(),
ask: sinon.stub().callsFake(async (_kind: string, message: string) => {
askedMessage = message
return { response: "yesButtonClicked" }
}),
say: sinon.stub().resolves(),
},
} as any
const oversizedPatch = `*** Begin Patch\n*** Add File: big.ts\n+${"x".repeat(MAX_FILE_EDIT_DISPLAY_BYTES + 1024)}\n*** End Patch`
const approved = await (handler as any).handleApproval(
config,
{ name: "apply_patch", isNativeToolCall: false },
{ tool: "newFileCreated", path: "big.ts", content: "placeholder" },
oversizedPatch,
)
assert.equal(approved, true)
assert.match(askedMessage, /omitted from tool payload/)
assert.doesNotMatch(askedMessage, new RegExp(`x{${MAX_FILE_EDIT_DISPLAY_BYTES + 100}}`))
})
it("summarizes multiple oversized file changes in multi-file patch previews", async () => {
const sandbox = sinon.createSandbox()
const handler = new ApplyPatchHandler({ checkClineIgnorePath: () => ({ ok: true }) } as any)
const hugeA = "a".repeat(MAX_FILE_EDIT_DISPLAY_BYTES + 512)
const hugeB = "b".repeat(MAX_FILE_EDIT_DISPLAY_BYTES + 1024)
sandbox.stub(HostProvider, "workspace").value({
getWorkspacePaths: async () => ({ paths: [] }),
})
try {
const summaries = await (handler as any).generateChangeSummary({
"big-a.ts": {
type: PatchActionType.ADD,
newContent: hugeA,
},
"big-b.ts": {
type: PatchActionType.UPDATE,
oldContent: "before",
newContent: hugeB,
},
})
assert.equal(summaries.length, 2)
assert.equal(summaries[0].path, "big-a.ts")
assert.equal(summaries[1].path, "big-b.ts")
assert.match(summaries[0].content || "", /omitted from tool payload/)
assert.match(summaries[1].content || "", /omitted from tool payload/)
assert.doesNotMatch(summaries[0].content || "", /a{1000}/)
assert.doesNotMatch(summaries[1].content || "", /b{1000}/)
} finally {
sandbox.restore()
}
})
it("applies a multi-file patch with oversized final content summaries", async () => {
const sandbox = sinon.createSandbox()
const handler = new ApplyPatchHandler({ checkClineIgnorePath: () => ({ ok: true }) } as any)
const cwd = path.join(os.tmpdir(), `cline-apply-patch-${Date.now()}`)
const hugeA = "a".repeat(MAX_FILE_EDIT_DISPLAY_BYTES + 512)
const hugeB = "b".repeat(MAX_FILE_EDIT_DISPLAY_BYTES + 1024)
sandbox.stub(HostProvider, "workspace").value({
getWorkspacePaths: async () => ({ paths: [cwd] }),
})
const diffViewProvider = {
isEditing: false,
editType: undefined as string | undefined,
originalContent: "",
open: sandbox.stub().resolves(),
update: sandbox.stub().resolves(),
saveChanges: sandbox
.stub()
.onFirstCall()
.resolves({ finalContent: hugeA })
.onSecondCall()
.resolves({ finalContent: hugeB }),
revertChanges: sandbox.stub().resolves(),
reset: sandbox.stub().resolves(),
deleteFile: sandbox.stub().resolves(),
}
const config = {
ulid: "ulid-1",
cwd,
api: {
getModel: () => ({ id: "test-model" }),
},
autoApprovalSettings: { enableNotifications: false },
services: {
stateManager: {
getApiConfiguration: () => ({ planModeApiProvider: "openai", actModeApiProvider: "openai" }),
getGlobalSettingsKey: (key: string) => {
if (key === "mode") return "act"
if (key === "hooksEnabled") return false
return undefined
},
},
diffViewProvider,
fileContextTracker: {
markFileAsEditedByCline: sandbox.stub(),
trackFileContext: sandbox.stub().resolves(),
},
},
taskState: {
userMessageContent: [],
didRejectTool: false,
fileReadCache: new Map<string, string>(),
didEditFile: false,
consecutiveMistakeCount: 0,
},
callbacks: {
shouldAutoApproveToolWithPath: sandbox.stub().resolves(true),
removeLastPartialMessageIfExistsWithType: sandbox.stub().resolves(),
say: sandbox.stub().resolves(),
},
} as any
const patchInput = [
"*** Begin Patch",
"*** Add File: big-a.ts",
`+${hugeA}`,
"*** Add File: big-b.ts",
`+${hugeB}`,
"*** End Patch",
].join("\n")
try {
const result = await handler.execute(config, {
type: "tool_use",
name: "apply_patch",
params: { input: patchInput },
partial: false,
isNativeToolCall: false,
} as any)
const resultText = typeof result === "string" ? result : JSON.stringify(result)
assert.match(resultText, /Successfully applied patch to the following files:/)
assert.match(resultText, /Final file content for 'big-a\.ts' omitted from tool payload/)
assert.match(resultText, /Final file content for 'big-b\.ts' omitted from tool payload/)
assert.doesNotMatch(resultText, /a{1000}/)
assert.doesNotMatch(resultText, /b{1000}/)
assert.equal(diffViewProvider.saveChanges.callCount, 2)
} finally {
sandbox.restore()
}
})
})
@@ -0,0 +1,177 @@
import { strict as assert } from "node:assert"
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { afterEach, beforeEach, describe, it } from "mocha"
import sinon from "sinon"
import { TaskState } from "../../../TaskState"
import { ToolValidator } from "../../ToolValidator"
import type { TaskConfig } from "../../types/TaskConfig"
import { MAX_FILE_EDIT_CONTENT_BYTES } from "../../utils/LargeEditGuards"
import { WriteToFileToolHandler } from "../WriteToFileToolHandler"
function createConfig(tmpDir: string) {
const taskState = new TaskState()
const diffViewProvider = {
isEditing: false,
editType: undefined,
originalContent: "",
open: sinon.stub().resolves(),
update: sinon.stub().resolves(),
saveChanges: sinon.stub().resolves({}),
revertChanges: sinon.stub().resolves(),
reset: sinon.stub().resolves(),
scrollToFirstDiff: sinon.stub().resolves(),
getOriginalContentForLLM: sinon.stub().callsFake(() => diffViewProvider.originalContent),
}
const callbacks = {
say: sinon.stub().resolves(undefined),
ask: sinon.stub().resolves({ response: "yesButtonClicked" }),
saveCheckpoint: sinon.stub().resolves(),
sayAndCreateMissingParamError: sinon.stub().resolves("missing"),
removeLastPartialMessageIfExistsWithType: sinon.stub().resolves(),
executeCommandTool: sinon.stub().resolves([false, "ok"]),
cancelRunningCommandTool: sinon.stub().resolves(false),
doesLatestTaskCompletionHaveNewChanges: sinon.stub().resolves(false),
updateFCListFromToolResponse: sinon.stub().resolves(),
shouldAutoApproveTool: sinon.stub().returns([true, true]),
shouldAutoApproveToolWithPath: sinon.stub().resolves(true),
postStateToWebview: sinon.stub().resolves(),
reinitExistingTaskFromId: sinon.stub().resolves(),
cancelTask: sinon.stub().resolves(),
updateTaskHistory: sinon.stub().resolves([]),
applyLatestBrowserSettings: sinon.stub().resolves({}),
switchToActMode: sinon.stub().resolves(false),
setActiveHookExecution: sinon.stub().resolves(),
clearActiveHookExecution: sinon.stub().resolves(),
getActiveHookExecution: sinon.stub().resolves(undefined),
runUserPromptSubmitHook: sinon.stub().resolves({}),
}
const config = {
taskId: "task-1",
ulid: "ulid-1",
cwd: tmpDir,
mode: "act",
strictPlanModeEnabled: false,
yoloModeToggled: true,
doubleCheckCompletionEnabled: false,
vscodeTerminalExecutionMode: "backgroundExec",
enableParallelToolCalling: true,
isSubagentExecution: true,
taskState,
messageState: {} as any,
api: {
getModel: () => ({ id: "test-model", info: { supportsImages: false, contextWindow: 128_000 } }),
},
autoApprovalSettings: {
enableNotifications: false,
actions: { executeSafeCommands: false, executeAllCommands: false },
},
autoApprover: {
shouldAutoApproveTool: sinon.stub().returns([true, true]),
},
browserSettings: {} as any,
focusChainSettings: {} as any,
services: {
stateManager: {
getGlobalStateKey: () => undefined,
getGlobalSettingsKey: (key: string) => {
if (key === "mode") return "act"
if (key === "hooksEnabled") return false
return undefined
},
getApiConfiguration: () => ({ planModeApiProvider: "openai", actModeApiProvider: "openai" }),
},
fileContextTracker: {
trackFileContext: sinon.stub().resolves(),
markFileAsEditedByCline: sinon.stub(),
},
mcpHub: {} as any,
browserSession: {} as any,
urlContentFetcher: {} as any,
diffViewProvider,
clineIgnoreController: { validateAccess: () => true, filterPaths: (paths: string[]) => paths },
commandPermissionController: {} as any,
contextManager: {} as any,
},
callbacks,
coordinator: { getHandler: sinon.stub() },
} as unknown as TaskConfig
return { config, callbacks, taskState, diffViewProvider }
}
describe("WriteToFileToolHandler large edit guards", () => {
let sandbox: sinon.SinonSandbox
let tmpDir: string
beforeEach(() => {
sandbox = sinon.createSandbox()
tmpDir = path.join(os.tmpdir(), `cline-write-large-${Date.now()}`)
})
afterEach(() => {
sandbox.restore()
})
it("returns a tool error and avoids opening the diff view for oversized write_to_file payloads", async () => {
const { config, taskState, diffViewProvider } = createConfig(tmpDir)
const handler = new WriteToFileToolHandler(new ToolValidator({ validateAccess: () => true } as any))
const oversized = "x".repeat(MAX_FILE_EDIT_CONTENT_BYTES + 1)
const result = await handler.execute(config, {
type: "tool_use",
name: "write_to_file",
params: {
path: "big.ts",
content: oversized,
},
partial: false,
} as any)
assert.equal(result, "")
assert.equal(taskState.consecutiveMistakeCount, 1)
assert.equal(taskState.didAlreadyUseTool, false)
assert.equal(taskState.userMessageContent.length, 1)
const oversizedWriteMessage = taskState.userMessageContent[0] as any
assert.match(oversizedWriteMessage.text, /edit payload is too large/)
sinon.assert.notCalled(diffViewProvider.open)
sinon.assert.notCalled(diffViewProvider.update)
sinon.assert.notCalled(diffViewProvider.saveChanges)
sinon.assert.notCalled(diffViewProvider.revertChanges)
})
it("summarizes huge original file content when replace_in_file diff construction fails", async () => {
const { config, taskState, diffViewProvider } = createConfig(tmpDir)
const handler = new WriteToFileToolHandler(new ToolValidator({ validateAccess: () => true } as any))
const relPath = "big.ts"
const absolutePath = path.join(tmpDir, relPath)
const hugeOriginal = "x".repeat(70 * 1024)
await fs.mkdir(tmpDir, { recursive: true })
await fs.writeFile(absolutePath, hugeOriginal, "utf8")
diffViewProvider.originalContent = hugeOriginal
const result = await handler.execute(config, {
type: "tool_use",
name: "replace_in_file",
params: {
path: relPath,
diff: "<<<<<<< SEARCH\nnot-present\n=======\nreplacement\n>>>>>>> REPLACE",
},
partial: false,
} as any)
assert.equal(result, "")
assert.equal(taskState.consecutiveMistakeCount, 1)
assert.equal(taskState.userMessageContent.length, 1)
const diffFailureMessage = taskState.userMessageContent[0] as any
assert.match(diffFailureMessage.text, /omitted from tool payload/)
assert.doesNotMatch(diffFailureMessage.text, /<file_content path="big\.ts">/)
sinon.assert.calledOnce(diffViewProvider.open)
sinon.assert.calledOnce(diffViewProvider.reset)
sinon.assert.notCalled(diffViewProvider.saveChanges)
})
})
@@ -1,4 +1,5 @@
import type { DiffViewProvider } from "@/integrations/editor/DiffViewProvider"
import { validateFileEditSafety } from "./LargeEditGuards"
export interface FileOpsResult {
finalContent?: string
@@ -30,7 +31,8 @@ export class FileProviderOperations {
* Creates a file. If isFinal is false, prepares the creation without saving.
* Call saveChanges() after approval when isFinal is false.
*/
async createFile(path: string, content: string, isFinal: boolean = true): Promise<FileOpsResult | undefined> {
async createFile(path: string, content: string, isFinal = true): Promise<FileOpsResult | undefined> {
validateFileEditSafety(content, { relPath: path, operation: "create" })
this.provider.editType = "create"
await this.openFile(path)
// Always pass isFinal=true to update() to ensure proper document finalization
@@ -48,7 +50,8 @@ export class FileProviderOperations {
* Modifies a file. If isFinal is false, prepares the modification without saving.
* Call saveChanges() after approval when isFinal is false.
*/
async modifyFile(path: string, content: string, isFinal: boolean = true): Promise<FileOpsResult | undefined> {
async modifyFile(path: string, content: string, isFinal = true): Promise<FileOpsResult | undefined> {
validateFileEditSafety(content, { relPath: path, operation: "edit" })
this.provider.editType = "modify"
await this.openFile(path)
// Always pass isFinal=true to update() to ensure proper document finalization
@@ -67,40 +70,33 @@ export class FileProviderOperations {
* Opens the file in the diff view to show it will be deleted.
* Call deleteFile() with isFinal=true after approval when isFinal is false.
*/
async deleteFile(path: string, isFinal: boolean = true): Promise<FileOpsResult | undefined> {
async deleteFile(path: string, isFinal = true): Promise<FileOpsResult | undefined> {
this.provider.editType = "delete"
await this.openFile(path)
if (isFinal) {
await this.provider.deleteFile(path)
return undefined
} else {
// Update with empty content to show the file will be deleted
// Always pass isFinal=true to update() to ensure proper document finalization
await this.provider.update("", true)
return undefined
}
// Update with empty content to show the file will be deleted
// Always pass isFinal=true to update() to ensure proper document finalization
await this.provider.update("", true)
return undefined
}
/**
* Moves a file from oldPath to newPath. If isFinal is false, prepares the move without saving.
* Call saveChanges() after approval when isFinal is false.
*/
async moveFile(
oldPath: string,
newPath: string,
content: string,
isFinal: boolean = true,
): Promise<FileOpsResult | undefined> {
async moveFile(oldPath: string, newPath: string, content: string, isFinal = true): Promise<FileOpsResult | undefined> {
if (isFinal) {
const result = await this.createFile(newPath, content, isFinal)
await this.deleteFile(oldPath, isFinal)
return result
} else {
await this.createFile(newPath, content, isFinal)
await this.deleteFile(oldPath, isFinal)
return undefined
}
await this.createFile(newPath, content, isFinal)
await this.deleteFile(oldPath, isFinal)
return undefined
}
async getFileContent(): Promise<string | undefined> {
@@ -0,0 +1,111 @@
export const MAX_FILE_EDIT_CONTENT_BYTES = 1024 * 1024 // 1MB
export const MAX_FILE_EDIT_LINE_BYTES = 200 * 1024 // 200KB per line
export const MAX_FILE_EDIT_DISPLAY_BYTES = 64 * 1024 // 64KB for tool approval/result payloads
function formatBytes(bytes: number): string {
if (bytes < 1024) {
return `${bytes} B`
}
if (bytes < 1024 * 1024) {
return `${(bytes / 1024).toFixed(1)} KB`
}
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
export function getLargestLineBytes(content: string): number {
let largest = 0
for (const line of content.split("\n")) {
const bytes = Buffer.byteLength(line, "utf8")
if (bytes > largest) {
largest = bytes
}
}
return largest
}
export function validateFileEditSafety(
content: string,
{
relPath,
operation,
maxContentBytes = MAX_FILE_EDIT_CONTENT_BYTES,
maxLineBytes = MAX_FILE_EDIT_LINE_BYTES,
}: {
relPath: string
operation: string
maxContentBytes?: number
maxLineBytes?: number
},
): void {
const contentBytes = Buffer.byteLength(content, "utf8")
if (contentBytes > maxContentBytes) {
throw new Error(
`Refusing to ${operation} '${relPath}' because the edit payload is too large for safe in-extension editing ` +
`(${formatBytes(contentBytes)} > ${formatBytes(maxContentBytes)}). ` +
`Break the change into smaller edits, edit a narrower region, or use a more incremental strategy.`,
)
}
const largestLineBytes = getLargestLineBytes(content)
if (largestLineBytes > maxLineBytes) {
throw new Error(
`Refusing to ${operation} '${relPath}' because at least one line is too large for safe in-extension editing ` +
`(${formatBytes(largestLineBytes)} > ${formatBytes(maxLineBytes)}). ` +
`Split the edit into smaller line-oriented changes or use a different strategy for giant single-line content.`,
)
}
}
export function getSafeEditDisplayContent(
content: string | undefined,
{
relPath,
context,
maxDisplayBytes = MAX_FILE_EDIT_DISPLAY_BYTES,
maxLineBytes = MAX_FILE_EDIT_LINE_BYTES,
}: {
relPath: string
context: string
maxDisplayBytes?: number
maxLineBytes?: number
},
): { text: string | undefined; wasSummarized: boolean } {
if (content === undefined) {
return { text: undefined, wasSummarized: false }
}
const contentBytes = Buffer.byteLength(content, "utf8")
const largestLineBytes = getLargestLineBytes(content)
if (contentBytes <= maxDisplayBytes && largestLineBytes <= maxLineBytes) {
return { text: content, wasSummarized: false }
}
return {
text:
`[${context} for '${relPath}' omitted from tool payload: total size ${formatBytes(contentBytes)}, ` +
`largest line ${formatBytes(largestLineBytes)}. Review the editor diff or saved file for full content.]`,
wasSummarized: true,
}
}
export function shouldSummarizeEditDisplayContent(
content: string | undefined,
{
maxDisplayBytes = MAX_FILE_EDIT_DISPLAY_BYTES,
maxLineBytes = MAX_FILE_EDIT_LINE_BYTES,
}: {
maxDisplayBytes?: number
maxLineBytes?: number
} = {},
): boolean {
if (content === undefined) {
return false
}
const contentBytes = Buffer.byteLength(content, "utf8")
if (contentBytes > maxDisplayBytes) {
return true
}
return getLargestLineBytes(content) > maxLineBytes
}
+130 -37
View File
@@ -140,15 +140,21 @@ export class PatchParser {
}
const [nextChunkContext, chunks, endPatchIndex, eof] = peek(this.lines, this.index)
assertPatchSearchBlockWithinBudget(nextChunkContext, this.currentPath || _path)
const [newIndex, fuzz, similarity] = findContext(fileLines, nextChunkContext, index, eof)
if (newIndex === -1) {
const ctxText = nextChunkContext.join("\n")
const [earlierIndex] = findContext(fileLines, nextChunkContext, 0, eof)
const mayBeOutOfOrder = earlierIndex !== -1 && earlierIndex < index
// Add warning but continue - skip this chunk
this.addWarning({
path: this.currentPath || _path,
chunkIndex: action.chunks.length,
message: `Could not find matching context (similarity: ${similarity.toFixed(2)}). Chunk skipped.`,
message: mayBeOutOfOrder
? `Could not find matching context after line ${index} (similarity: ${similarity.toFixed(2)}). ` +
`A matching context exists earlier in the file, so SEARCH/REPLACE chunks may be out of order. Chunk skipped.`
: `Could not find matching context (similarity: ${similarity.toFixed(2)}). Chunk skipped.`,
context: ctxText.length > 200 ? `${ctxText.substring(0, 200)}...` : ctxText,
})
// Move patch index forward to skip this chunk, but keep file position
@@ -212,17 +218,105 @@ export class PatchParser {
/**
* Calculate similarity between two strings (0-1 range)
*/
export const MAX_PATCH_SEARCH_BLOCK_BYTES = 256 * 1024
export const MAX_PATCH_SEARCH_LINE_BYTES = 200 * 1024
const MAX_LEVENSHTEIN_SIMILARITY_CHARS = 512
export const MAX_PARTIAL_MATCH_WORK_UNITS = 50_000
function getUtf8ByteLength(value: string): number {
return Buffer.byteLength(value, "utf8")
}
function assertPatchSearchBlockWithinBudget(context: string[], path: string): void {
let largestLineBytes = 0
for (const line of context) {
const lineBytes = getUtf8ByteLength(line)
if (lineBytes > largestLineBytes) {
largestLineBytes = lineBytes
}
}
if (largestLineBytes > MAX_PATCH_SEARCH_LINE_BYTES) {
throw new DiffError(
`Patch search block for ${path} contains a line that is too large (${largestLineBytes.toLocaleString()} bytes). ` +
`Maximum supported line size is ${MAX_PATCH_SEARCH_LINE_BYTES.toLocaleString()} bytes.`,
)
}
const contextText = context.join("\n")
const contextBytes = getUtf8ByteLength(contextText)
if (contextBytes <= MAX_PATCH_SEARCH_BLOCK_BYTES) {
return
}
throw new DiffError(
`Patch search block for ${path} is too large (${contextBytes.toLocaleString()} bytes). Maximum supported size is ${MAX_PATCH_SEARCH_BLOCK_BYTES.toLocaleString()} bytes.`,
)
}
function calculateSimilarity(str1: string, str2: string): number {
const longer = str1.length > str2.length ? str1 : str2
const shorter = str1.length > str2.length ? str2 : str1
if (longer.length === 0) {
return 1.0
}
if (longer.length > MAX_LEVENSHTEIN_SIMILARITY_CHARS) {
return calculateLineSimilarityFromStrings(str1, str2)
}
const editDistance = levenshteinDistance(shorter, longer)
return (longer.length - editDistance) / longer.length
}
function calculateLineSimilarityFromStrings(str1: string, str2: string): number {
return calculateLineSimilarityFromArrays(str1.split("\n"), str2.split("\n"))
}
function calculateLineSimilarityFromArrays(lines1: string[], lines2: string[]): number {
const maxLineCount = Math.max(lines1.length, lines2.length)
if (maxLineCount === 0) {
return 1
}
let matchedWeight = 0
let totalWeight = 0
for (let i = 0; i < maxLineCount; i++) {
const left = lines1[i] ?? ""
const right = lines2[i] ?? ""
const weight = Math.max(left.length, right.length, 1)
if (left === right) {
matchedWeight += weight
}
totalWeight += weight
}
return totalWeight === 0 ? 1 : matchedWeight / totalWeight
}
function matchWindowAt(lines: string[], context: string[], startIdx: number): boolean {
for (let j = 0; j < context.length; j++) {
if ((lines[startIdx + j] ?? "") !== context[j]) {
return false
}
}
return true
}
function windowLineSimilarityAt(lines: string[], context: string[], startIdx: number): number {
let matchedWeight = 0
let totalWeight = 0
for (let j = 0; j < context.length; j++) {
const left = lines[startIdx + j] ?? ""
const right = context[j] ?? ""
const weight = Math.max(left.length, right.length, 1)
if (left === right) {
matchedWeight += weight
}
totalWeight += weight
}
return totalWeight === 0 ? 1 : matchedWeight / totalWeight
}
/**
* Calculate Levenshtein distance between two strings
*/
@@ -260,62 +354,61 @@ function findContext(lines: string[], context: string[], start: number, eof: boo
if (context.length === 0) {
return [start, 0, 1.0]
}
if (context.length > lines.length) {
return [-1, 0, 0]
}
let bestSimilarity = 0
const canonicalLines = lines.map((line) => canonicalize(line))
const trailingTrimmedLines = canonicalLines.map((line) => line.trimEnd())
const fullyTrimmedLines = canonicalLines.map((line) => line.trim())
const canonicalContextLines = context.map((line) => canonicalize(line))
const trailingTrimmedContextLines = canonicalContextLines.map((line) => line.trimEnd())
const fullyTrimmedContextLines = canonicalContextLines.map((line) => line.trim())
const canonicalContext = canonicalContextLines.join("\n")
const maxStartIndex = lines.length - context.length
const partialMatchWorkUnits = (maxStartIndex + 1) * Math.max(context.length, 1)
const findCore = (startIdx: number): [number, number, number] => {
if (startIdx > maxStartIndex) {
return [-1, 0, bestSimilarity]
}
const boundedStartIdx = Math.max(0, startIdx)
// Pass 1: exact equality after canonicalization
const canonicalContext = canonicalize(context.join("\n"))
for (let i = startIdx; i < lines.length; i++) {
const segment = canonicalize(lines.slice(i, i + context.length).join("\n"))
if (segment === canonicalContext) {
for (let i = boundedStartIdx; i <= maxStartIndex; i++) {
if (matchWindowAt(canonicalLines, canonicalContextLines, i)) {
return [i, 0, 1.0]
}
// Track best similarity for reporting
const similarity = calculateSimilarity(segment, canonicalContext)
if (similarity > bestSimilarity) {
bestSimilarity = similarity
}
}
// Pass 2: ignore trailing whitespace
for (let i = startIdx; i < lines.length; i++) {
const segment = canonicalize(
lines
.slice(i, i + context.length)
.map((s) => s.trimEnd())
.join("\n"),
)
const ctx = canonicalize(context.map((s) => s.trimEnd()).join("\n"))
if (segment === ctx) {
for (let i = boundedStartIdx; i <= maxStartIndex; i++) {
if (matchWindowAt(trailingTrimmedLines, trailingTrimmedContextLines, i)) {
return [i, 1, 1.0]
}
}
// Pass 3: ignore all surrounding whitespace
for (let i = startIdx; i < lines.length; i++) {
const segment = canonicalize(
lines
.slice(i, i + context.length)
.map((s) => s.trim())
.join("\n"),
)
const ctx = canonicalize(context.map((s) => s.trim()).join("\n"))
if (segment === ctx) {
for (let i = boundedStartIdx; i <= maxStartIndex; i++) {
if (matchWindowAt(fullyTrimmedLines, fullyTrimmedContextLines, i)) {
return [i, 100, 1.0]
}
}
// Pass 4: Partial matching with similarity threshold (66% match = 2/3 lines)
const SIMILARITY_THRESHOLD = 0.66
for (let i = startIdx; i < lines.length; i++) {
const segment = canonicalize(lines.slice(i, i + context.length).join("\n"))
const similarity = calculateSimilarity(segment, canonicalContext)
if (similarity >= SIMILARITY_THRESHOLD) {
return [i, 1000, similarity]
}
if (similarity > bestSimilarity) {
bestSimilarity = similarity
if (partialMatchWorkUnits <= MAX_PARTIAL_MATCH_WORK_UNITS) {
const SIMILARITY_THRESHOLD = 0.66
for (let i = boundedStartIdx; i <= maxStartIndex; i++) {
const similarity =
canonicalContext.length > MAX_LEVENSHTEIN_SIMILARITY_CHARS
? windowLineSimilarityAt(canonicalLines, canonicalContextLines, i)
: calculateSimilarity(canonicalLines.slice(i, i + context.length).join("\n"), canonicalContext)
if (similarity >= SIMILARITY_THRESHOLD) {
return [i, 1000, similarity]
}
if (similarity > bestSimilarity) {
bestSimilarity = similarity
}
}
}
@@ -0,0 +1,44 @@
import { strict as assert } from "node:assert"
import { describe, it } from "mocha"
import sinon from "sinon"
import { FileProviderOperations } from "../FileProviderOperations"
import { MAX_FILE_EDIT_CONTENT_BYTES } from "../LargeEditGuards"
function createProvider() {
return {
editType: undefined as string | undefined,
originalContent: "",
open: sinon.stub().resolves(),
update: sinon.stub().resolves(),
saveChanges: sinon.stub().resolves({}),
revertChanges: sinon.stub().resolves(),
reset: sinon.stub().resolves(),
deleteFile: sinon.stub().resolves(),
} as any
}
describe("FileProviderOperations", () => {
it("rejects oversized createFile payloads before opening the diff provider", async () => {
const provider = createProvider()
const ops = new FileProviderOperations(provider)
const oversized = "x".repeat(MAX_FILE_EDIT_CONTENT_BYTES + 1)
await assert.rejects(() => ops.createFile("big.ts", oversized), /edit payload is too large/)
sinon.assert.notCalled(provider.open)
sinon.assert.notCalled(provider.update)
sinon.assert.notCalled(provider.saveChanges)
})
it("rejects oversized modifyFile payloads before opening the diff provider", async () => {
const provider = createProvider()
const ops = new FileProviderOperations(provider)
const oversized = "x".repeat(MAX_FILE_EDIT_CONTENT_BYTES + 1)
await assert.rejects(() => ops.modifyFile("big.ts", oversized), /edit payload is too large/)
sinon.assert.notCalled(provider.open)
sinon.assert.notCalled(provider.update)
sinon.assert.notCalled(provider.saveChanges)
})
})
@@ -0,0 +1,84 @@
import { describe, it } from "mocha"
import "should"
import {
getLargestLineBytes,
getSafeEditDisplayContent,
MAX_FILE_EDIT_CONTENT_BYTES,
MAX_FILE_EDIT_DISPLAY_BYTES,
MAX_FILE_EDIT_LINE_BYTES,
shouldSummarizeEditDisplayContent,
validateFileEditSafety,
} from "../LargeEditGuards"
describe("LargeEditGuards", () => {
it("measures the largest line in UTF-8 bytes", () => {
getLargestLineBytes("a\n🙂🙂\nabc").should.equal(Buffer.byteLength("🙂🙂", "utf8"))
})
it("allows content within byte and line budgets", () => {
;(() =>
validateFileEditSafety("line1\nline2", {
relPath: "safe.ts",
operation: "edit",
maxContentBytes: 32,
maxLineBytes: 32,
})).should.not.throw()
})
it("rejects oversized total edit payloads", () => {
const oversized = "x".repeat(MAX_FILE_EDIT_CONTENT_BYTES + 1)
;(() => validateFileEditSafety(oversized, { relPath: "big.ts", operation: "edit" })).should.throw(
/edit payload is too large/,
)
})
it("rejects oversized single-line payloads", () => {
const giantLine = "x".repeat(MAX_FILE_EDIT_LINE_BYTES + 1)
;(() => validateFileEditSafety(giantLine, { relPath: "big-line.ts", operation: "edit" })).should.throw(
/at least one line is too large/,
)
})
it("returns original content for small display payloads", () => {
const result = getSafeEditDisplayContent("line1\nline2", {
relPath: "safe.ts",
context: "Patch preview",
maxDisplayBytes: 128,
maxLineBytes: 128,
})
result.wasSummarized.should.equal(false)
result.text!.should.equal("line1\nline2")
})
it("summarizes oversized display payloads", () => {
const oversized = "x".repeat(MAX_FILE_EDIT_DISPLAY_BYTES + 1)
const result = getSafeEditDisplayContent(oversized, {
relPath: "big.ts",
context: "Patch preview",
})
result.wasSummarized.should.equal(true)
result.text!.should.match(/omitted from tool payload/)
})
it("summarizes payloads with giant lines even if total size is small enough", () => {
const giantLine = "x".repeat(MAX_FILE_EDIT_LINE_BYTES + 1)
const result = getSafeEditDisplayContent(giantLine, {
relPath: "big-line.ts",
context: "Patch preview",
maxDisplayBytes: giantLine.length + 10,
})
result.wasSummarized.should.equal(true)
result.text!.should.match(/largest line/)
})
it("detects when display content should be summarized", () => {
shouldSummarizeEditDisplayContent("small", { maxDisplayBytes: 16, maxLineBytes: 16 }).should.equal(false)
shouldSummarizeEditDisplayContent("x".repeat(MAX_FILE_EDIT_DISPLAY_BYTES + 1)).should.equal(true)
shouldSummarizeEditDisplayContent("x".repeat(MAX_FILE_EDIT_LINE_BYTES + 1), {
maxDisplayBytes: MAX_FILE_EDIT_LINE_BYTES + 32,
}).should.equal(true)
})
})
@@ -0,0 +1,168 @@
import { performance } from "node:perf_hooks"
import { expect } from "chai"
import { describe, it } from "mocha"
import { PatchActionType } from "@/shared/Patch"
import { measureAsyncOperation } from "@/test/stress-utils"
import { MAX_PARTIAL_MATCH_WORK_UNITS, MAX_PATCH_SEARCH_LINE_BYTES, PatchParser } from "../PatchParser"
function makeRepeatedFile(lineCount: number, payloadWidth: number): string {
return Array.from({ length: lineCount }, (_, i) => {
const repeated = `${"segment-".repeat(payloadWidth)}${i}`
return `const value${i} = "${repeated}"`
}).join("\n")
}
describe("PatchParser stress", () => {
it("parses many repeated exact-match chunks in a single patch without stalling", async function () {
this.timeout(10_000)
const chunkCount = 120
const original = Array.from({ length: chunkCount }, (_, i) => {
return [`function block${i}() {`, ` old${i}()`, `}`].join("\n")
}).join("\n")
const patchLines = [
"*** Begin Patch",
"*** Update File: stress.ts",
...Array.from({ length: chunkCount }, (_, i) => [
"@@",
` function block${i}() {`,
`- old${i}()`,
`+ new${i}()`,
" }",
]).flat(),
"*** End Patch",
]
const measured = await measureAsyncOperation("PatchParser repeated chunk stress", async () => {
const parser = new PatchParser(patchLines, { "stress.ts": original })
return parser.parse()
})
expect(measured.durationMs).to.be.lessThan(2_000)
expect(measured.result.patch.actions["stress.ts"]?.type).to.equal(PatchActionType.UPDATE)
expect(measured.result.patch.actions["stress.ts"]?.chunks).to.have.lengthOf(chunkCount)
expect(measured.result.patch.warnings).to.be.undefined
})
it("handles large near-match contexts without failing while reporting fuzzy matches", async function () {
this.timeout(10_000)
const originalFile = makeRepeatedFile(260, 14)
const originalLines = originalFile.split("\n")
const contextStart = 140
const contextLength = 18
const targetContext = originalLines.slice(contextStart, contextStart + contextLength)
const searchContext = [...targetContext]
searchContext[9] = searchContext[9].replace("segment-segment-", "segment-SEGMENT-")
const replacementLine = targetContext[10].replace("segment-", "patched-segment-")
const patchLines = [
"*** Begin Patch",
"*** Update File: stress.ts",
"@@",
...searchContext.flatMap((line, index) => {
if (index === 10) {
return [`-${line}`, `+${replacementLine}`]
}
return ` ${line}`
}),
"*** End Patch",
]
const measured = await measureAsyncOperation("PatchParser near-match stress", async () => {
const parser = new PatchParser(patchLines, { "stress.ts": originalFile })
return parser.parse()
})
expect(measured.durationMs).to.be.lessThan(5_000)
expect(measured.result.patch.actions["stress.ts"]?.type).to.equal(PatchActionType.UPDATE)
expect(measured.result.patch.actions["stress.ts"]?.chunks).to.be.an("array")
expect(measured.result.patch.warnings).to.be.undefined
})
it("fails fast when a patch search block exceeds the configured byte budget", async function () {
this.timeout(10_000)
const safeLine = "x".repeat(32 * 1024)
const oversizedContextLines = Array.from({ length: 9 }, () => safeLine)
const patchLines = [
"*** Begin Patch",
"*** Update File: stress.ts",
"@@",
...oversizedContextLines.map((line) => ` ${line}`),
"-old",
"+new",
"*** End Patch",
]
const parser = new PatchParser(patchLines, { "stress.ts": `${oversizedContextLines.join("\n")}\nold` })
const startedAt = performance.now()
try {
parser.parse()
expect.fail("Expected PatchParser to reject oversized search block")
} catch (error) {
const durationMs = performance.now() - startedAt
expect(durationMs).to.be.lessThan(1_000)
expect(error).to.be.instanceOf(Error)
expect((error as Error).message).to.match(/Patch search block for stress\.ts is too large/)
}
})
it("fails fast when a patch search line exceeds the configured line budget", async function () {
this.timeout(10_000)
const oversizedLine = "x".repeat(MAX_PATCH_SEARCH_LINE_BYTES + 1)
const patchLines = [
"*** Begin Patch",
"*** Update File: stress.ts",
"@@",
` ${oversizedLine}`,
"-old",
"+new",
"*** End Patch",
]
const parser = new PatchParser(patchLines, { "stress.ts": "context\nold" })
const startedAt = performance.now()
try {
parser.parse()
expect.fail("Expected PatchParser to reject oversized search line")
} catch (error) {
const durationMs = performance.now() - startedAt
expect(durationMs).to.be.lessThan(1_000)
expect(error).to.be.instanceOf(Error)
expect((error as Error).message).to.match(/contains a line that is too large/)
}
})
it("skips expensive partial matching on giant repeated contexts without stalling", async function () {
this.timeout(10_000)
const original = Array.from({ length: 3_000 }, (_, i) => `const row${i} = repeatedValue(${i})`).join("\n")
const patchLines = [
"*** Begin Patch",
"*** Update File: stress.ts",
"@@",
...Array.from({ length: 64 }, (_, i) => ` const row${i} = repeatedVALUE(${i})`),
"-const row64 = repeatedVALUE(64)",
"+const row64 = updatedValue(64)",
"*** End Patch",
]
const parser = new PatchParser(patchLines, { "stress.ts": original })
const startedAt = performance.now()
const result = parser.parse()
const durationMs = performance.now() - startedAt
expect((3_000 - 65 + 1) * 65).to.be.greaterThan(MAX_PARTIAL_MATCH_WORK_UNITS)
expect(durationMs).to.be.lessThan(1_000)
expect(result.patch.actions["stress.ts"]?.type).to.equal(PatchActionType.UPDATE)
expect(result.patch.actions["stress.ts"]?.chunks).to.have.lengthOf(0)
expect(result.patch.warnings).to.have.lengthOf(1)
expect(result.patch.warnings?.[0]?.message).to.match(/Could not find matching context/)
})
})
@@ -1161,6 +1161,26 @@ describe("PatchParser", () => {
})
describe("Partial Matching and Warnings", () => {
it("rejects oversized patch search blocks before fuzzy matching", () => {
const safeLine = "x".repeat(32 * 1024)
const oversizedContextLines = Array.from({ length: 9 }, () => safeLine)
const patchLines = [
"*** Begin Patch",
"*** Update File: big.ts",
"@@",
...oversizedContextLines.map((line) => ` ${line}`),
"-old",
"+new",
"*** End Patch",
]
const parser = new PatchParser(patchLines, {
"big.ts": `${oversizedContextLines.join("\n")}\nold`,
})
expect(() => parser.parse()).to.throw(DiffError, /Patch search block for big\.ts is too large/)
})
it("should skip invalid chunks and add warnings", () => {
const patchLines = [
"*** Begin Patch",
@@ -1224,5 +1244,34 @@ describe("PatchParser", () => {
// Should have 1 warning for skipped chunk
expect(result.patch.warnings).to.have.lengthOf(1)
})
it("should warn clearly when chunks appear out of file order", () => {
const patchLines = [
"*** Begin Patch",
"*** Update File: test.ts",
"@@",
" function second() {",
"- old2()",
"+ new2()",
" }",
"@@",
" function first() {",
"- old1()",
"+ new1()",
" }",
"*** End Patch",
]
const parser = new PatchParser(patchLines, {
"test.ts": "function first() {\n old1()\n}\nfunction second() {\n old2()\n}",
})
const result = parser.parse()
expect(result.patch.actions["test.ts"]).to.exist
expect(result.patch.actions["test.ts"].chunks).to.have.lengthOf(1)
expect(result.patch.actions["test.ts"].chunks[0].origIndex).to.equal(4)
expect(result.patch.warnings).to.have.lengthOf(1)
expect(result.patch.warnings?.[0]?.message).to.match(/chunks may be out of order/i)
})
})
})
@@ -0,0 +1,79 @@
import { strict as assert } from "node:assert"
import { describe, it } from "mocha"
import { measureAsyncOperation } from "@/test/stress-utils"
import { performTaskAbortCleanup } from "../taskAbortCleanup"
describe("performTaskAbortCleanup soak", () => {
it("handles 1,000 repeated abort cleanup cycles within a bounded budget", async function () {
this.timeout(20_000)
const closeBrowserCalls: string[] = []
const diffReverts: string[] = []
const diffResets: string[] = []
const browserDisposals: string[] = []
const ignoreDisposals: string[] = []
const trackerDisposals: string[] = []
const focusDisposals: string[] = []
const presentationDisposals: string[] = []
const measured = await measureAsyncOperation("taskAbortCleanup soak cycles", async () => {
for (let cycle = 0; cycle < 1_000; cycle++) {
await performTaskAbortCleanup({
urlContentFetcher: {
closeBrowser: async () => {
closeBrowserCalls.push(`closeBrowser-${cycle}`)
},
},
diffViewProvider: {
revertChanges: async () => {
diffReverts.push(`diffRevert-${cycle}`)
},
reset: async () => {
diffResets.push(`diffReset-${cycle}`)
},
},
browserSession: {
dispose: async () => {
browserDisposals.push(`browser-${cycle}`)
},
},
clineIgnoreController: {
dispose: async () => {
ignoreDisposals.push(`ignore-${cycle}`)
},
},
fileContextTracker: {
dispose: async () => {
trackerDisposals.push(`tracker-${cycle}`)
},
},
focusChainManager: {
dispose: async () => {
focusDisposals.push(`focus-${cycle}`)
},
},
presentationScheduler: {
dispose: async () => {
presentationDisposals.push(`presentation-${cycle}`)
},
},
})
}
return closeBrowserCalls.length
})
assert.equal(measured.result, 1_000)
assert.equal(closeBrowserCalls.length, 1_000)
assert.equal(diffReverts.length, 1_000)
assert.equal(diffResets.length, 1_000)
assert.equal(browserDisposals.length, 1_000)
assert.equal(ignoreDisposals.length, 1_000)
assert.equal(trackerDisposals.length, 1_000)
assert.equal(focusDisposals.length, 1_000)
assert.equal(presentationDisposals.length, 1_000)
assert.ok(measured.durationMs < 20_000)
assert.ok(measured.diff.heapUsedDelta < 128 * 1024 * 1024)
assert.ok(measured.diff.activeHandleCountDelta <= 2)
})
})
@@ -0,0 +1,265 @@
import { strict as assert } from "node:assert"
import { describe, it } from "mocha"
import { performTaskAbortCleanup } from "../taskAbortCleanup"
function createDeferred() {
let resolve!: () => void
const promise = new Promise<void>((res) => {
resolve = res
})
return { promise, resolve }
}
async function flushMicrotasks(iterations = 5) {
for (let i = 0; i < iterations; i++) {
await Promise.resolve()
}
}
describe("performTaskAbortCleanup", () => {
it("waits for async disposers before completing abort cleanup", async () => {
const ignoreDeferred = createDeferred()
const trackerDeferred = createDeferred()
const events: string[] = []
const cleanupPromise = performTaskAbortCleanup({
urlContentFetcher: {
closeBrowser: () => {
events.push("closeBrowser")
},
},
diffViewProvider: {
revertChanges: async () => {
events.push("diffRevert")
},
reset: async () => {
events.push("diffReset")
},
},
browserSession: {
dispose: async () => {
events.push("browserSession")
},
},
clineIgnoreController: {
dispose: async () => {
events.push("clineIgnore:start")
await ignoreDeferred.promise
events.push("clineIgnore:end")
},
},
fileContextTracker: {
dispose: async () => {
events.push("fileTracker:start")
await trackerDeferred.promise
events.push("fileTracker:end")
},
},
focusChainManager: {
dispose: () => {
events.push("focusChain")
},
},
presentationScheduler: {
dispose: async () => {
events.push("presentationScheduler")
},
},
})
await flushMicrotasks()
assert.deepStrictEqual(events, [
"closeBrowser",
"diffRevert",
"diffReset",
"browserSession",
"clineIgnore:start",
"fileTracker:start",
"focusChain",
])
let settled = false
void cleanupPromise.then(() => {
settled = true
})
await flushMicrotasks()
assert.equal(settled, false)
ignoreDeferred.resolve()
await flushMicrotasks()
assert.equal(settled, false)
trackerDeferred.resolve()
await cleanupPromise
assert.deepStrictEqual(events, [
"closeBrowser",
"diffRevert",
"diffReset",
"browserSession",
"clineIgnore:start",
"fileTracker:start",
"focusChain",
"clineIgnore:end",
"fileTracker:end",
"presentationScheduler",
])
})
it("cleans up all resources across repeated abort cycles without drift", async () => {
const closeBrowserCalls: string[] = []
const diffReverts: string[] = []
const diffResets: string[] = []
const browserDisposals: string[] = []
const ignoreDisposals: string[] = []
const trackerDisposals: string[] = []
const focusDisposals: string[] = []
const presentationDisposals: string[] = []
for (let cycle = 0; cycle < 5; cycle++) {
await performTaskAbortCleanup({
urlContentFetcher: {
closeBrowser: async () => {
closeBrowserCalls.push(`closeBrowser-${cycle}`)
},
},
diffViewProvider: {
revertChanges: async () => {
diffReverts.push(`diffRevert-${cycle}`)
},
reset: async () => {
diffResets.push(`diffReset-${cycle}`)
},
},
browserSession: {
dispose: async () => {
browserDisposals.push(`browser-${cycle}`)
},
},
clineIgnoreController: {
dispose: async () => {
ignoreDisposals.push(`ignore-${cycle}`)
},
},
fileContextTracker: {
dispose: async () => {
trackerDisposals.push(`tracker-${cycle}`)
},
},
focusChainManager: {
dispose: async () => {
focusDisposals.push(`focus-${cycle}`)
},
},
presentationScheduler: {
dispose: async () => {
presentationDisposals.push(`presentation-${cycle}`)
},
},
})
}
assert.deepStrictEqual(closeBrowserCalls, [
"closeBrowser-0",
"closeBrowser-1",
"closeBrowser-2",
"closeBrowser-3",
"closeBrowser-4",
])
assert.deepStrictEqual(browserDisposals, ["browser-0", "browser-1", "browser-2", "browser-3", "browser-4"])
assert.deepStrictEqual(diffReverts, ["diffRevert-0", "diffRevert-1", "diffRevert-2", "diffRevert-3", "diffRevert-4"])
assert.deepStrictEqual(diffResets, ["diffReset-0", "diffReset-1", "diffReset-2", "diffReset-3", "diffReset-4"])
assert.deepStrictEqual(ignoreDisposals, ["ignore-0", "ignore-1", "ignore-2", "ignore-3", "ignore-4"])
assert.deepStrictEqual(trackerDisposals, ["tracker-0", "tracker-1", "tracker-2", "tracker-3", "tracker-4"])
assert.deepStrictEqual(focusDisposals, ["focus-0", "focus-1", "focus-2", "focus-3", "focus-4"])
assert.deepStrictEqual(presentationDisposals, [
"presentation-0",
"presentation-1",
"presentation-2",
"presentation-3",
"presentation-4",
])
})
it("awaits async focus-chain cleanup before abort cleanup resolves", async () => {
const focusDeferred = createDeferred()
const events: string[] = []
const cleanupPromise = performTaskAbortCleanup({
urlContentFetcher: {
closeBrowser: async () => {
events.push("closeBrowser")
},
},
diffViewProvider: {
revertChanges: async () => {
events.push("diffRevert")
},
reset: async () => {
events.push("diffReset")
},
},
browserSession: {
dispose: async () => {
events.push("browserSession")
},
},
clineIgnoreController: {
dispose: async () => {
events.push("clineIgnore")
},
},
fileContextTracker: {
dispose: async () => {
events.push("fileTracker")
},
},
focusChainManager: {
dispose: async () => {
events.push("focusChain:start")
await focusDeferred.promise
events.push("focusChain:end")
},
},
presentationScheduler: {
dispose: async () => {
events.push("presentationScheduler")
},
},
})
await flushMicrotasks()
assert.deepStrictEqual(events, [
"closeBrowser",
"diffRevert",
"diffReset",
"browserSession",
"clineIgnore",
"fileTracker",
"focusChain:start",
])
let settled = false
void cleanupPromise.then(() => {
settled = true
})
await flushMicrotasks()
assert.equal(settled, false)
focusDeferred.resolve()
await cleanupPromise
assert.deepStrictEqual(events, [
"closeBrowser",
"diffRevert",
"diffReset",
"browserSession",
"clineIgnore",
"fileTracker",
"focusChain:start",
"focusChain:end",
"presentationScheduler",
])
})
})
+37
View File
@@ -0,0 +1,37 @@
export interface TaskAbortCleanupDependencies {
urlContentFetcher?: {
closeBrowser?: () => void | Promise<void>
}
diffViewProvider?: {
revertChanges?: () => void | Promise<void>
reset?: () => void | Promise<void>
}
browserSession: {
dispose: () => Promise<void>
}
clineIgnoreController: {
dispose: () => Promise<void>
}
fileContextTracker: {
dispose: () => Promise<void>
}
focusChainManager?: {
dispose: () => void | Promise<void>
}
presentationScheduler: {
dispose: () => Promise<void>
}
}
export async function performTaskAbortCleanup(deps: TaskAbortCleanupDependencies): Promise<void> {
await Promise.resolve(deps.urlContentFetcher?.closeBrowser?.())
await Promise.resolve(deps.diffViewProvider?.revertChanges?.())
await Promise.resolve(deps.diffViewProvider?.reset?.())
await deps.browserSession.dispose()
await Promise.all([
deps.clineIgnoreController.dispose(),
deps.fileContextTracker.dispose(),
Promise.resolve(deps.focusChainManager?.dispose()),
])
await deps.presentationScheduler.dispose()
}
+5 -1
View File
@@ -40,6 +40,10 @@ import {
import { workspaceResolver } from "./core/workspace"
import { findMatchingNotebookCell, getContextForCommand, showWebview } from "./hosts/vscode/commandUtils"
import { abortCommitGeneration, generateCommitMsg } from "./hosts/vscode/commit-message-generator"
import {
getDiffOriginalContentIdFromUriPath,
getRegisteredDiffOriginalContent,
} from "./hosts/vscode/diff/originalContentRegistry"
import { registerClineOutputChannel } from "./hosts/vscode/hostbridge/env/debugLog"
import {
disposeVscodeCommentReviewController,
@@ -153,7 +157,7 @@ export async function activate(context: vscode.ExtensionContext) {
*/
const diffContentProvider = new (class implements vscode.TextDocumentContentProvider {
provideTextDocumentContent(uri: vscode.Uri): string {
return Buffer.from(uri.query, "base64").toString("utf-8")
return getRegisteredDiffOriginalContent(getDiffOriginalContentIdFromUriPath(uri.path))
}
})()
context.subscriptions.push(vscode.workspace.registerTextDocumentContentProvider(DIFF_VIEW_URI_SCHEME, diffContentProvider))
+2 -3
View File
@@ -55,7 +55,7 @@ export class ExternalDiffViewProvider extends DiffViewProvider {
return lines.length
}
protected async saveDocument(): Promise<Boolean> {
protected async saveDocument(): Promise<boolean> {
if (!this.activeDiffEditorId) {
return false
}
@@ -68,9 +68,8 @@ export class ExternalDiffViewProvider extends DiffViewProvider {
// consider it a real error.
Logger.log("Diff not found:", this.activeDiffEditorId)
return false
} else {
throw err
}
throw err
}
}
+22 -6
View File
@@ -2,6 +2,11 @@ import { DiffViewProvider } from "@integrations/editor/DiffViewProvider"
import * as path from "path"
import * as vscode from "vscode"
import { DecorationController } from "@/hosts/vscode/DecorationController"
import {
getDiffOriginalContentIdFromUriPath,
registerDiffOriginalContent,
unregisterDiffOriginalContent,
} from "@/hosts/vscode/diff/originalContentRegistry"
import { NotebookDiffView } from "@/hosts/vscode/NotebookDiffView"
import { Logger } from "@/shared/services/Logger"
import { arePathsEqual } from "@/utils/path"
@@ -10,6 +15,7 @@ export const DIFF_VIEW_URI_SCHEME = "cline-diff"
export class VscodeDiffViewProvider extends DiffViewProvider {
private activeDiffEditor?: vscode.TextEditor
private currentOriginalContentId?: string
private fadedOverlayController?: DecorationController
private activeLineController?: DecorationController
@@ -49,6 +55,7 @@ export class VscodeDiffViewProvider extends DiffViewProvider {
)
if (diffTab && diffTab.input instanceof vscode.TabInputTextDiff) {
this.currentOriginalContentId = getDiffOriginalContentIdFromUriPath(diffTab.input.original.path)
// Use already open diff editor.
this.activeDiffEditor = await vscode.window.showTextDocument(diffTab.input.modified, {
preserveFocus: true,
@@ -58,6 +65,9 @@ export class VscodeDiffViewProvider extends DiffViewProvider {
this.activeDiffEditor = await new Promise<vscode.TextEditor>((resolve, reject) => {
const fileName = path.basename(uri.fsPath)
const fileExists = this.editType === "modify"
const originalContentId = registerDiffOriginalContent(this.originalContent ?? "")
this.currentOriginalContentId = originalContentId
const originalUri = vscode.Uri.from({ scheme: DIFF_VIEW_URI_SCHEME, path: `/${originalContentId}` })
const disposable = vscode.window.onDidChangeActiveTextEditor((editor) => {
if (editor && arePathsEqual(editor.document.uri.fsPath, uri.fsPath)) {
disposable.dispose()
@@ -66,11 +76,7 @@ export class VscodeDiffViewProvider extends DiffViewProvider {
})
vscode.commands.executeCommand(
"vscode.diff",
vscode.Uri.parse(
`${DIFF_VIEW_URI_SCHEME}:${fileName.replace(/%/g, "%25").replace(/#/g, "%23").replace(/\?/g, "%3F")}`,
).with({
query: Buffer.from(this.originalContent ?? "").toString("base64"),
}),
originalUri,
uri,
`${fileName}: ${fileExists ? "Original ↔ Cline's Changes" : "New File"} (Editable)`,
{
@@ -80,6 +86,10 @@ export class VscodeDiffViewProvider extends DiffViewProvider {
// This may happen on very slow machines ie project idx
setTimeout(() => {
disposable.dispose()
unregisterDiffOriginalContent(originalContentId)
if (this.currentOriginalContentId === originalContentId) {
this.currentOriginalContentId = undefined
}
reject(new Error("Failed to open diff editor, please try again..."))
}, 10_000)
})
@@ -190,7 +200,7 @@ export class VscodeDiffViewProvider extends DiffViewProvider {
return this.activeDiffEditor.document.getText()
}
protected override async saveDocument(): Promise<Boolean> {
protected override async saveDocument(): Promise<boolean> {
if (!this.activeDiffEditor) {
return false
}
@@ -207,6 +217,10 @@ export class VscodeDiffViewProvider extends DiffViewProvider {
.flatMap((tg) => tg.tabs)
.filter((tab) => tab.input instanceof vscode.TabInputTextDiff && tab.input?.original?.scheme === DIFF_VIEW_URI_SCHEME)
for (const tab of tabs) {
if (!(tab.input instanceof vscode.TabInputTextDiff)) {
continue
}
unregisterDiffOriginalContent(getDiffOriginalContentIdFromUriPath(tab.input.original.path))
// trying to close dirty views results in save popup
if (!tab.isDirty) {
try {
@@ -225,6 +239,8 @@ export class VscodeDiffViewProvider extends DiffViewProvider {
}
this.activeDiffEditor = undefined
unregisterDiffOriginalContent(this.currentOriginalContentId)
this.currentOriginalContentId = undefined
this.fadedOverlayController = undefined
this.activeLineController = undefined
}
@@ -0,0 +1,22 @@
import { describe, it } from "mocha"
import "should"
import {
getDiffOriginalContentIdFromUriPath,
getRegisteredDiffOriginalContent,
registerDiffOriginalContent,
unregisterDiffOriginalContent,
} from "./originalContentRegistry"
describe("originalContentRegistry", () => {
it("registers, retrieves, and unregisters diff original content", () => {
const id = registerDiffOriginalContent("hello world")
getRegisteredDiffOriginalContent(id).should.equal("hello world")
unregisterDiffOriginalContent(id)
getRegisteredDiffOriginalContent(id).should.equal("")
})
it("extracts registry ids from URI paths", () => {
getDiffOriginalContentIdFromUriPath("/diff-123").should.equal("diff-123")
getDiffOriginalContentIdFromUriPath("diff-456").should.equal("diff-456")
})
})
@@ -0,0 +1,23 @@
const diffOriginalContentRegistry = new Map<string, string>()
let nextDiffOriginalContentId = 0
export function registerDiffOriginalContent(content: string): string {
const id = `diff-${Date.now()}-${nextDiffOriginalContentId++}`
diffOriginalContentRegistry.set(id, content)
return id
}
export function getRegisteredDiffOriginalContent(id: string): string {
return diffOriginalContentRegistry.get(id) ?? ""
}
export function unregisterDiffOriginalContent(id: string | undefined): void {
if (!id) {
return
}
diffOriginalContentRegistry.delete(id)
}
export function getDiffOriginalContentIdFromUriPath(uriPath: string): string {
return uriPath.replace(/^\/+/, "")
}
+24 -17
View File
@@ -2,9 +2,9 @@ import { formatResponse } from "@core/prompts/responses"
import { workspaceResolver } from "@core/workspace"
import { createDirectoriesForFile } from "@utils/fs"
import { getCwd } from "@utils/path"
import * as diff from "diff"
import * as fs from "fs/promises"
import * as iconv from "iconv-lite"
import { shouldSummarizeEditDisplayContent } from "@/core/task/tools/utils/LargeEditGuards"
import { HostProvider } from "@/hosts/host-provider"
import { diagnosticsToProblemsString, getNewDiagnostics } from "@/integrations/diagnostics"
import { DiagnosticSeverity, FileDiagnostics } from "@/shared/proto/index.cline"
@@ -22,7 +22,7 @@ export abstract class DiffViewProvider {
private preDiagnostics: FileDiagnostics[] = []
protected relPath?: string
protected absolutePath?: string
protected fileEncoding: string = "utf8"
protected fileEncoding = "utf8"
private streamedLines: string[] = []
private newContent?: string
@@ -158,7 +158,7 @@ export abstract class DiffViewProvider {
*
* @returns true if the file was saved.
*/
protected abstract saveDocument(): Promise<Boolean>
protected abstract saveDocument(): Promise<boolean>
/**
* Closes all open diff views.
@@ -383,11 +383,20 @@ export abstract class DiffViewProvider {
let autoFormattingEdits: string | undefined
if (normalizedPreSaveContent !== normalizedPostSaveContent) {
// auto-formatting was done by the editor
autoFormattingEdits = formatResponse.createPrettyPatch(
this.relPath.toPosix(),
normalizedPreSaveContent,
normalizedPostSaveContent,
)
if (
shouldSummarizeEditDisplayContent(normalizedPreSaveContent) ||
shouldSummarizeEditDisplayContent(normalizedPostSaveContent)
) {
autoFormattingEdits =
`[Auto-formatting edits for '${this.relPath.toPosix()}' omitted from tool payload: ` +
`pre-save or post-save content exceeded safe diff display thresholds. Review the saved file content for the final result.]`
} else {
autoFormattingEdits = formatResponse.createPrettyPatch(
this.relPath.toPosix(),
normalizedPreSaveContent,
normalizedPostSaveContent,
)
}
}
// Strip notebook outputs to reduce context size (outputs aren't needed for editing)
@@ -451,17 +460,15 @@ export abstract class DiffViewProvider {
return
}
const currentContent = (await this.getDocumentText()) || ""
const diffs = diff.diffLines(this.originalContent || "", currentContent)
let lineCount = 0
for (const part of diffs) {
if (part.added || part.removed) {
// Found the first diff, scroll to it
this.scrollEditorToLine(lineCount)
const originalLines = (this.originalContent || "").split("\n")
const currentLines = currentContent.split("\n")
const maxComparableLength = Math.max(originalLines.length, currentLines.length)
for (let i = 0; i < maxComparableLength; i++) {
if (originalLines[i] !== currentLines[i]) {
await this.scrollEditorToLine(i)
return
}
if (!part.removed) {
lineCount += part.count || 0
}
}
}
+1 -1
View File
@@ -104,7 +104,7 @@ export class FileEditProvider extends DiffViewProvider {
return this.getDocumentText()
}
protected async saveDocument(): Promise<Boolean> {
protected async saveDocument(): Promise<boolean> {
if (!this.absolutePath || !this.documentContent) {
return false
}
@@ -0,0 +1,92 @@
import { strict as assert } from "node:assert"
import { describe, it } from "mocha"
import { measureAsyncOperation } from "@/test/stress-utils"
import { DiffViewProvider } from "../DiffViewProvider"
class SoakDiffViewProvider extends DiffViewProvider {
public documentText = ""
public openCount = 0
public replaceCount = 0
public resetCount = 0
async openDiffEditor(): Promise<void> {
this.openCount += 1
}
async scrollEditorToLine(_line: number): Promise<void> {}
async scrollAnimation(_startLine: number, _endLine: number): Promise<void> {}
async truncateDocument(lineNumber: number): Promise<void> {
const lines = this.documentText.split("\n")
this.documentText = lines.slice(0, lineNumber).join("\n")
}
async getDocumentLineCount(): Promise<number> {
return this.documentText.split("\n").length
}
async getDocumentText(): Promise<string | undefined> {
return this.documentText
}
async saveDocument(): Promise<boolean> {
return true
}
async closeAllDiffViews(): Promise<void> {}
async resetDiffView(): Promise<void> {
this.resetCount += 1
this.documentText = ""
}
async replaceText(
content: string,
_rangeToReplace: { startLine: number; endLine: number },
_currentLine: number | undefined,
): Promise<void> {
this.replaceCount += 1
this.documentText = content
}
public beginSyntheticEdit(initialContent: string) {
this.isEditing = true
this.originalContent = initialContent
this.documentText = initialContent
}
}
describe("DiffViewProvider soak", () => {
it("handles 1,000 repeated diff-edit open/update/reset cycles within a bounded budget", async function () {
this.timeout(20_000)
const provider = new SoakDiffViewProvider()
const original = Array.from({ length: 200 }, (_, i) => `line${i + 1}-${"payload".repeat(8)}`).join("\n")
const updated = `${original}\ncycle-tail`
const measured = await measureAsyncOperation("diff view open/update/reset soak", async () => {
for (let cycle = 0; cycle < 1_000; cycle++) {
provider.beginSyntheticEdit(original)
await provider.openDiffEditor()
await provider.update(updated, true)
await provider.reset()
}
return {
openCount: provider.openCount,
replaceCount: provider.replaceCount,
resetCount: provider.resetCount,
}
})
assert.deepStrictEqual(measured.result, {
openCount: 1_000,
replaceCount: 1_000,
resetCount: 1_000,
})
assert.equal(provider.isEditing, false)
assert.equal(provider.documentText, "")
assert.ok(measured.durationMs < 20_000)
assert.ok(measured.diff.heapUsedDelta < 128 * 1024 * 1024)
})
})
@@ -1,13 +1,19 @@
import * as assert from "assert"
import { describe, it } from "mocha"
import sinon from "sinon"
import { formatResponse } from "@/core/prompts/responses"
import { HostProvider } from "@/hosts/host-provider"
import { DiffViewProvider } from "../DiffViewProvider"
class TestBoundaryDiffViewProvider extends DiffViewProvider {
public documentText: string = ""
public documentText = ""
public truncatedAt: number | undefined
public scrolledToLine: number | undefined
async openDiffEditor(): Promise<void> {}
async scrollEditorToLine(line: number): Promise<void> {}
async scrollEditorToLine(line: number): Promise<void> {
this.scrolledToLine = line
}
async scrollAnimation(startLine: number, endLine: number): Promise<void> {}
async truncateDocument(lineNumber: number): Promise<void> {
@@ -26,7 +32,7 @@ class TestBoundaryDiffViewProvider extends DiffViewProvider {
return this.documentText
}
async saveDocument(): Promise<Boolean> {
async saveDocument(): Promise<boolean> {
return true
}
async closeAllDiffViews(): Promise<void> {}
@@ -61,6 +67,66 @@ class TestBoundaryDiffViewProvider extends DiffViewProvider {
this.documentText = initialContent
this.originalContent = initialContent
this.truncatedAt = undefined
this.scrolledToLine = undefined
}
}
class SaveChangesTestDiffViewProvider extends DiffViewProvider {
public documentText = ""
public saved = false
public showedFile = false
public closedDiffs = false
public postSaveContent = ""
async openDiffEditor(): Promise<void> {}
async scrollEditorToLine(_line: number): Promise<void> {}
async scrollAnimation(_startLine: number, _endLine: number): Promise<void> {}
async truncateDocument(_lineNumber: number): Promise<void> {}
async getDocumentLineCount(): Promise<number> {
return this.documentText.split("\n").length
}
async getDocumentText(): Promise<string | undefined> {
return this.documentText
}
async saveDocument(): Promise<boolean> {
this.saved = true
this.documentText = this.postSaveContent
return true
}
async closeAllDiffViews(): Promise<void> {
this.closedDiffs = true
}
async resetDiffView(): Promise<void> {}
async replaceText(
content: string,
_rangeToReplace: { startLine: number; endLine: number },
_currentLine: number | undefined,
): Promise<void> {
this.documentText = content
}
override async showFile(_absolutePath: string): Promise<void> {
this.showedFile = true
}
public setupForSave(args: {
relPath: string
absolutePath: string
originalContent: string
newContent: string
preSaveContent: string
postSaveContent: string
}) {
this.isEditing = true
this.editType = "modify"
;(this as any).relPath = args.relPath
;(this as any).absolutePath = args.absolutePath
this.originalContent = args.originalContent
this.documentText = args.preSaveContent
this.postSaveContent = args.postSaveContent
;(this as any).newContent = args.newContent
this.saved = false
this.showedFile = false
this.closedDiffs = false
}
}
@@ -190,6 +256,93 @@ describe("DiffViewProvider content finalization with isFinal=true", () => {
})
})
describe("DiffViewProvider scrollToFirstDiff", () => {
it("scrolls to the first changed line in a very large diff", async () => {
const provider = new TestBoundaryDiffViewProvider()
const original = Array.from({ length: 5_000 }, (_, i) => `line${i + 1}`).join("\n")
provider.setup(original)
const changed = original.replace("line4501", "line4501-edited")
provider.documentText = changed
await provider.scrollToFirstDiff()
assert.strictEqual(provider.scrolledToLine, 4_500)
})
})
describe("DiffViewProvider saveChanges", () => {
it("handles giant pre-save/post-save content without dropping final content metadata", async () => {
const sandbox = sinon.createSandbox()
const provider = new SaveChangesTestDiffViewProvider()
const base = Array.from({ length: 1_200 }, (_, i) => `line${i + 1}-${"payload".repeat(16)}`).join("\n")
const newContent = `${base}\ncline-tail`
const preSaveContent = `${base}\nuser-tail`
const postSaveContent = `${base}\nautoformatted-tail`
sandbox.stub(HostProvider, "workspace").value({
getDiagnostics: async () => ({ fileDiagnostics: [] }),
})
try {
provider.setupForSave({
relPath: "big.ts",
absolutePath: "/tmp/big.ts",
originalContent: base,
newContent,
preSaveContent,
postSaveContent,
})
const result = await provider.saveChanges()
assert.strictEqual(provider.saved, true)
assert.strictEqual(provider.showedFile, true)
assert.strictEqual(provider.closedDiffs, true)
assert.ok(result.finalContent)
assert.ok(result.finalContent!.includes("autoformatted-tail"))
assert.ok(result.userEdits)
assert.ok(result.autoFormattingEdits)
assert.strictEqual(result.newProblemsMessage, "")
} finally {
sandbox.restore()
}
})
it("summarizes oversized auto-formatting diffs instead of building giant pretty patches", async () => {
const sandbox = sinon.createSandbox()
const provider = new SaveChangesTestDiffViewProvider()
const giantTail = "x".repeat(80_000)
const base = "header\nbody"
const newContent = `${base}\ncline-tail`
const preSaveContent = `${base}\n${giantTail}`
const postSaveContent = `${base}\n${giantTail}formatted`
const prettyPatchStub = sandbox.stub(formatResponse, "createPrettyPatch")
sandbox.stub(HostProvider, "workspace").value({
getDiagnostics: async () => ({ fileDiagnostics: [] }),
})
try {
provider.setupForSave({
relPath: "big.ts",
absolutePath: "/tmp/big.ts",
originalContent: base,
newContent,
preSaveContent,
postSaveContent,
})
const result = await provider.saveChanges()
assert.ok(result.autoFormattingEdits)
assert.match(result.autoFormattingEdits!, /omitted from tool payload/)
assert.strictEqual(prettyPatchStub.callCount, 1)
assert.ok(result.finalContent!.includes("formatted"))
} finally {
sandbox.restore()
}
})
})
describe("DiffViewProvider Update Throttling", () => {
// Tests for the throttling added in PR #8785 to prevent performance issues
// during streaming, especially with large files like notebooks.
@@ -199,7 +352,7 @@ describe("DiffViewProvider Update Throttling", () => {
// Only the final line (without trailing newline) is deferred until isFinal=true.
class ThrottleTestDiffViewProvider extends DiffViewProvider {
public documentText: string = ""
public documentText = ""
public replaceTextCallCount = 0
async openDiffEditor(): Promise<void> {}
@@ -215,7 +368,7 @@ describe("DiffViewProvider Update Throttling", () => {
return this.documentText
}
async saveDocument(): Promise<Boolean> {
async saveDocument(): Promise<boolean> {
return true
}
async closeAllDiffViews(): Promise<void> {}
@@ -0,0 +1,63 @@
import assert from "node:assert/strict"
import { EventEmitter } from "node:events"
import { describe, it } from "mocha"
import type { TerminalCompletionDetails, TerminalProcessEvents, TerminalProcessResultPromise } from "../types"
import { StandaloneTerminalManager } from "./StandaloneTerminalManager"
class FakeBackgroundProcess extends EventEmitter<TerminalProcessEvents> {
public isHot = false
public waitForShellIntegration = false
public terminateCalls = 0
continue(): void {}
getUnretrievedOutput(): string {
return ""
}
getCompletionDetails(): TerminalCompletionDetails {
return {}
}
terminate(): void {
this.terminateCalls++
}
asResultPromise(): TerminalProcessResultPromise {
const promise = Promise.resolve() as TerminalProcessResultPromise
const process = this as unknown as FakeBackgroundProcess & Partial<TerminalProcessResultPromise>
process.then = promise.then.bind(promise)
process.catch = promise.catch.bind(promise)
process.finally = promise.finally.bind(promise)
return process as TerminalProcessResultPromise
}
}
describe("StandaloneTerminalManager background command cleanup", () => {
it("does not accumulate tracked background commands across repeated track/cancel cycles", () => {
const manager = new StandaloneTerminalManager()
const createdProcesses: FakeBackgroundProcess[] = []
for (let cycle = 0; cycle < 5; cycle++) {
const process = new FakeBackgroundProcess()
createdProcesses.push(process)
const tracked = manager.trackBackgroundCommand(process.asResultPromise(), `sleep ${cycle}`, [`line ${cycle}`])
assert.equal(manager.hasActiveBackgroundCommands(), true)
assert.equal(manager.getRunningBackgroundCommands().length, 1)
assert.equal(manager.cancelBackgroundCommand(tracked.id), true)
assert.equal(process.terminateCalls, 1)
assert.equal(manager.hasActiveBackgroundCommands(), false)
assert.equal(manager.getRunningBackgroundCommands().length, 0)
assert.equal(manager.getAllBackgroundCommands().length, 0)
assert.equal((manager as any).backgroundTimeouts.size, 0)
assert.equal((manager as any).logStreams.size, 0)
}
manager.disposeAll()
for (const process of createdProcesses) {
assert.equal(process.terminateCalls, 1)
}
})
})
@@ -93,6 +93,12 @@ export class StandaloneTerminalManager implements ITerminalManager {
/** Map of background command ID to timeout handle */
private backgroundTimeouts: Map<string, NodeJS.Timeout> = new Map()
private clearBackgroundCommandTracking(id: string): void {
this.backgroundTimeouts.delete(id)
this.logStreams.delete(id)
this.backgroundCommands.delete(id)
}
/**
* Run a command in the specified terminal.
* @param terminalInfo The terminal to run the command in
@@ -457,6 +463,7 @@ export class StandaloneTerminalManager implements ITerminalManager {
process.on("completed", (details) => {
// Guard: Skip if already handled by timeout
if (backgroundCommand.status !== "running") {
this.clearBackgroundCommandTracking(id)
return
}
const timeout = this.backgroundTimeouts.get(id)
@@ -482,12 +489,14 @@ export class StandaloneTerminalManager implements ITerminalManager {
backgroundCommand.status = "completed"
}
logStream.end()
this.clearBackgroundCommandTracking(id)
})
// Listen for errors - clear timeout
process.on("error", (error: Error) => {
// Guard: Skip if already handled by timeout
if (backgroundCommand.status !== "running") {
this.clearBackgroundCommandTracking(id)
return
}
const timeout = this.backgroundTimeouts.get(id)
@@ -502,6 +511,7 @@ export class StandaloneTerminalManager implements ITerminalManager {
backgroundCommand.exitCode = Number.parseInt(exitCodeMatch[1], 10)
}
logStream.end()
this.clearBackgroundCommandTracking(id)
})
this.backgroundCommands.set(id, backgroundCommand)
@@ -559,7 +569,6 @@ export class StandaloneTerminalManager implements ITerminalManager {
if (logStream) {
logStream.write("\n[CANCELLED] Command cancelled by user\n")
logStream.end()
this.logStreams.delete(id)
}
// Terminate process
@@ -568,6 +577,7 @@ export class StandaloneTerminalManager implements ITerminalManager {
}
command.status = "error"
this.clearBackgroundCommandTracking(id)
return true
}
+54 -21
View File
@@ -44,6 +44,7 @@ import { expandEnvironmentVariables } from "@/utils/envExpansion"
import { getServerAuthHash } from "@/utils/mcpAuth"
import { TelemetryService } from "../telemetry/TelemetryService"
import { DEFAULT_REQUEST_TIMEOUT_MS } from "./constants"
import { appendBoundedMcpError, enqueuePendingMcpNotification, PendingMcpNotification } from "./limits"
import { McpOAuthManager } from "./McpOAuthManager"
import { StreamableHttpReconnectHandler } from "./StreamableHttpReconnectHandler"
import { BaseConfigSchema, McpSettingsSchema, ServerConfigSchema } from "./schemas"
@@ -84,12 +85,7 @@ export class McpHub {
private static mcpServerKeys = new Map<string, string>()
// Store notifications for display in chat
private pendingNotifications: Array<{
serverName: string
level: string
message: string
timestamp: number
}> = []
private pendingNotifications: PendingMcpNotification[] = []
// Callback for sending notifications to active task
private notificationCallback?: (serverName: string, level: string, message: string) => void
@@ -628,12 +624,20 @@ export class McpHub {
} else {
// Fallback: store for later retrieval
//Logger.log(`[MCP Debug] No active task, storing notification: ${message}`)
this.pendingNotifications.push({
const enqueueResult = enqueuePendingMcpNotification(this.pendingNotifications, {
serverName: name,
level,
message,
timestamp: Date.now(),
})
this.pendingNotifications = enqueueResult.queue
if (enqueueResult.droppedCount > 0) {
this.telemetryService.captureMcpNotificationDropped(
name,
enqueueResult.droppedCount,
this.pendingNotifications.length,
)
}
}
})
//Logger.log(`[MCP Debug] Successfully set notifications/message handler for ${name}`)
@@ -670,8 +674,37 @@ export class McpHub {
}
private appendErrorMessage(connection: McpConnection, error: string) {
const newError = connection.server.error ? `${connection.server.error}\n${error}` : error
connection.server.error = newError //.slice(0, 800)
const appendResult = appendBoundedMcpError(connection.server.error, error)
connection.server.error = appendResult.value
if (appendResult.truncated) {
this.telemetryService.captureMcpErrorTruncated(
connection.server.name,
appendResult.originalLength,
appendResult.retainedLength,
)
}
}
private dispatchOrQueueNotification(serverName: string, level: string, message: string): void {
if (this.notificationCallback) {
this.notificationCallback(serverName, level, message)
return
}
const enqueueResult = enqueuePendingMcpNotification(this.pendingNotifications, {
serverName,
level,
message,
timestamp: Date.now(),
})
this.pendingNotifications = enqueueResult.queue
if (enqueueResult.droppedCount > 0) {
this.telemetryService.captureMcpNotificationDropped(
serverName,
enqueueResult.droppedCount,
this.pendingNotifications.length,
)
}
}
private async fetchToolsList(serverName: string): Promise<McpTool[]> {
@@ -830,13 +863,13 @@ export class McpHub {
// Update or add servers
for (const [name, config] of Object.entries(newServers)) {
const currentConnection = this.connections.find((conn) => conn.server.name === name)
if (config.type === "stdio") {
this.setupFileWatcher(name, config)
}
if (!currentConnection) {
// New server
try {
if (config.type === "stdio") {
this.setupFileWatcher(name, config)
}
await this.connectToServer(name, config, "rpc")
} catch (error) {
Logger.error(`Failed to connect to new MCP server ${name}:`, error)
@@ -844,9 +877,6 @@ export class McpHub {
} else if (this.configsRequireRestart(JSON.parse(currentConnection.server.config), config)) {
// Existing server with changed connection config (excludes Cline-specific settings)
try {
if (config.type === "stdio") {
this.setupFileWatcher(name, config)
}
await this.deleteConnection(name) // Don't clear OAuth - just reconnecting with new config
await this.connectToServer(name, config, "rpc")
Logger.log(`Reconnected MCP server with updated config: ${name}`)
@@ -898,13 +928,13 @@ export class McpHub {
// Update or add servers
for (const [name, config] of Object.entries(newServers)) {
const currentConnection = this.connections.find((conn) => conn.server.name === name)
if (config.type === "stdio") {
this.setupFileWatcher(name, config)
}
if (!currentConnection) {
// New server
try {
if (config.type === "stdio") {
this.setupFileWatcher(name, config)
}
await this.connectToServer(name, config, "internal")
connectionChangesOccurred = true
} catch (error) {
@@ -918,9 +948,6 @@ export class McpHub {
currentConnection.server.error = ""
await this.notifyWebviewOfServerChanges()
if (config.type === "stdio") {
this.setupFileWatcher(name, config)
}
await this.deleteConnection(name)
await this.connectToServer(name, config, "internal")
Logger.log(`Reconnected MCP server with updated config: ${name}`)
@@ -1581,6 +1608,12 @@ export class McpHub {
*/
setNotificationCallback(callback: (serverName: string, level: string, message: string) => void): void {
this.notificationCallback = callback
if (this.pendingNotifications.length > 0) {
const notifications = this.getPendingNotifications()
for (const notification of notifications) {
callback(notification.serverName, notification.level, notification.message)
}
}
//Logger.log("[MCP Debug] Notification callback set")
}
@@ -0,0 +1,43 @@
import { strict as assert } from "node:assert"
import { describe, it } from "mocha"
import { measureAsyncOperation } from "@/test/stress-utils"
import { MAX_PENDING_MCP_NOTIFICATIONS } from "../limits"
import { McpHub } from "../McpHub"
describe("mcp soak", () => {
it("handles a noisy queued notification run within bounded queue and delivery budgets", async function () {
this.timeout(20_000)
const droppedEvents: Array<{ serverName: string; droppedCount: number; retainedCount: number }> = []
const hub = Object.create(McpHub.prototype) as any
hub.pendingNotifications = []
hub.notificationCallback = undefined
hub.telemetryService = {
captureMcpNotificationDropped: (serverName: string, droppedCount: number, retainedCount: number) => {
droppedEvents.push({ serverName, droppedCount, retainedCount })
},
}
const measured = await measureAsyncOperation("mcp noisy queued notification soak", async () => {
for (let i = 0; i < 10_000; i++) {
;(hub as any).dispatchOrQueueNotification("server-a", i % 2 === 0 ? "info" : "warning", `message-${i}`)
}
const delivered: string[] = []
hub.setNotificationCallback((_serverName: string, _level: string, message: string) => {
delivered.push(message)
})
return delivered
})
assert.equal(hub.pendingNotifications.length, 0)
assert.equal(measured.result.length, MAX_PENDING_MCP_NOTIFICATIONS)
assert.equal(measured.result[0], `message-${10_000 - MAX_PENDING_MCP_NOTIFICATIONS}`)
assert.equal(measured.result.at(-1), "message-9999")
assert.equal(droppedEvents.length, 10_000 - MAX_PENDING_MCP_NOTIFICATIONS)
assert.ok(measured.durationMs < 20_000)
assert.ok(measured.diff.heapUsedDelta < 128 * 1024 * 1024)
})
})
+248
View File
@@ -0,0 +1,248 @@
import { strict as assert } from "node:assert"
import { describe, it } from "mocha"
import {
appendBoundedMcpError,
enqueuePendingMcpNotification,
MAX_MCP_SERVER_ERROR_CHARS,
MAX_PENDING_MCP_NOTIFICATIONS,
} from "../limits"
import { McpHub } from "../McpHub"
describe("mcp limits", () => {
it("caps pending notifications by dropping the oldest entries", () => {
const queue = Array.from({ length: MAX_PENDING_MCP_NOTIFICATIONS }, (_, i) => ({
serverName: `server-${i}`,
level: "info",
message: `message-${i}`,
timestamp: i,
}))
const updated = enqueuePendingMcpNotification(queue, {
serverName: "server-new",
level: "warning",
message: "message-new",
timestamp: 999,
})
assert.equal(updated.queue.length, MAX_PENDING_MCP_NOTIFICATIONS)
assert.equal(updated.droppedCount, 1)
assert.equal(updated.queue[0]?.serverName, "server-1")
assert.equal(updated.queue.at(-1)?.serverName, "server-new")
})
it("keeps accumulated MCP server error text within the configured budget", () => {
const existing = "a".repeat(MAX_MCP_SERVER_ERROR_CHARS - 10)
const appended = appendBoundedMcpError(existing, "b".repeat(100))
assert.ok(appended.value.length <= MAX_MCP_SERVER_ERROR_CHARS)
assert.equal(appended.truncated, true)
assert.ok(appended.value.includes("truncated"))
assert.ok(appended.value.endsWith("b".repeat(100).slice(-Math.min(100, appended.value.length))))
})
it("closes all MCP file watchers during repeated teardown cycles", async () => {
for (let cycle = 0; cycle < 5; cycle++) {
const watcherA = { close: () => undefined }
const watcherB = { close: () => undefined }
let closed = 0
const hub = Object.create(McpHub.prototype) as any
hub.fileWatchers = new Map([
[
"server-a",
{
close: () => {
closed += 1
watcherA.close()
},
},
],
[
"server-b",
{
close: () => {
closed += 1
watcherB.close()
},
},
],
])
hub.settingsWatcher = { close: async () => undefined }
hub.connections = []
hub.deleteConnection = async () => undefined
await hub.dispose()
assert.equal(closed, 2)
assert.equal(hub.fileWatchers.size, 0)
}
})
it("bounds noisy queued notifications and flushes them when a task callback is registered", () => {
const droppedEvents: Array<{ serverName: string; droppedCount: number; retainedCount: number }> = []
const hub = Object.create(McpHub.prototype) as any
hub.pendingNotifications = []
hub.notificationCallback = undefined
hub.telemetryService = {
captureMcpNotificationDropped: (serverName: string, droppedCount: number, retainedCount: number) => {
droppedEvents.push({ serverName, droppedCount, retainedCount })
},
}
for (let i = 0; i < MAX_PENDING_MCP_NOTIFICATIONS + 3; i++) {
;(hub as any).dispatchOrQueueNotification("server-a", "info", `message-${i}`)
}
assert.equal(hub.pendingNotifications.length, MAX_PENDING_MCP_NOTIFICATIONS)
assert.equal(hub.pendingNotifications[0]?.message, "message-3")
assert.equal(droppedEvents.length, 3)
const delivered: string[] = []
hub.setNotificationCallback((_serverName: string, _level: string, message: string) => {
delivered.push(message)
})
assert.equal(hub.pendingNotifications.length, 0)
assert.deepEqual(
delivered,
Array.from({ length: MAX_PENDING_MCP_NOTIFICATIONS }, (_, i) => `message-${i + 3}`),
)
;(hub as any).dispatchOrQueueNotification("server-a", "warning", "live-message")
assert.deepEqual(delivered.at(-1), "live-message")
})
it("bounds repeated MCP server stderr accumulation and records truncation telemetry", () => {
const truncatedEvents: Array<{ serverName: string; originalLength: number; retainedLength: number }> = []
const hub = Object.create(McpHub.prototype) as any
hub.telemetryService = {
captureMcpErrorTruncated: (serverName: string, originalLength: number, retainedLength: number) => {
truncatedEvents.push({ serverName, originalLength, retainedLength })
},
}
const connection = {
server: {
name: "server-a",
error: "",
},
} as any
for (let i = 0; i < 6; i++) {
;(hub as any).appendErrorMessage(connection, `error-${i}-` + "x".repeat(MAX_MCP_SERVER_ERROR_CHARS / 2))
}
assert.ok(connection.server.error.length <= MAX_MCP_SERVER_ERROR_CHARS)
assert.ok(connection.server.error.includes("truncated"))
assert.ok(truncatedEvents.length >= 1)
assert.equal(truncatedEvents.at(-1)?.serverName, "server-a")
})
it("re-establishes stdio file watchers across repeated settings refreshes", async () => {
const closedWatchers: string[] = []
const hub = Object.create(McpHub.prototype) as any
const serverConfig = {
type: "stdio",
command: "node",
args: ["build/index.js"],
disabled: false,
autoApprove: [],
timeout: 60,
}
hub.fileWatchers = new Map([
[
"server-a",
{
close: () => {
closedWatchers.push("initial")
},
},
],
])
hub.connections = [
{
server: {
name: "server-a",
config: JSON.stringify(serverConfig),
tools: [],
},
},
]
hub.isConnecting = false
hub.deleteConnection = async () => undefined
hub.connectToServer = async () => undefined
let watcherIndex = 0
hub.setupFileWatcher = (name: string) => {
const watcherId = `watcher-${watcherIndex++}`
hub.fileWatchers.set(name, {
close: () => {
closedWatchers.push(watcherId)
},
})
}
for (let cycle = 0; cycle < 3; cycle++) {
await hub.updateServerConnectionsRPC({ "server-a": serverConfig as any })
assert.equal(hub.fileWatchers.size, 1)
assert.equal(hub.isConnecting, false)
}
assert.equal(watcherIndex, 3)
assert.deepEqual(closedWatchers, ["initial", "watcher-0", "watcher-1"])
})
it("restarts MCP connections repeatedly without leaving restart state stuck", async () => {
const hub = Object.create(McpHub.prototype) as any
const serverConfig = {
type: "stdio",
command: "node",
args: ["build/index.js"],
disabled: false,
autoApprove: [],
timeout: 60,
}
const connection = {
server: {
name: "server-a",
config: JSON.stringify(serverConfig),
status: "connected",
error: "previous error",
},
}
let deleteCalls = 0
let connectCalls = 0
hub.connections = [connection]
hub.isConnecting = false
hub.deleteConnection = async (name: string) => {
assert.equal(name, "server-a")
deleteCalls++
}
hub.connectToServer = async (name: string, config: any, source: "rpc" | "internal") => {
assert.equal(name, "server-a")
assert.equal(source, "rpc")
assert.equal(config.command, "node")
connectCalls++
}
hub.readAndValidateMcpSettingsFile = async () => ({ mcpServers: { "server-a": serverConfig } })
hub.getSortedMcpServers = (serverOrder: string[]) => serverOrder.map((name) => ({ name }))
for (let cycle = 0; cycle < 2; cycle++) {
const servers = await hub.restartConnectionRPC("server-a")
assert.deepEqual(servers, [{ name: "server-a" }])
assert.equal(connection.server.status, "connecting")
assert.equal(connection.server.error, "")
assert.equal(hub.isConnecting, false)
}
assert.equal(deleteCalls, 2)
assert.equal(connectCalls, 2)
})
})
+66
View File
@@ -0,0 +1,66 @@
export const MAX_PENDING_MCP_NOTIFICATIONS = 200
export const MAX_MCP_SERVER_ERROR_CHARS = 32 * 1024
const MCP_ERROR_TRUNCATION_MARKER = "\n...[older MCP errors truncated]...\n"
export interface PendingMcpNotification {
serverName: string
level: string
message: string
timestamp: number
}
export interface PendingMcpNotificationEnqueueResult {
queue: PendingMcpNotification[]
droppedCount: number
}
export interface BoundedMcpErrorResult {
value: string
truncated: boolean
originalLength: number
retainedLength: number
}
export function enqueuePendingMcpNotification(
queue: PendingMcpNotification[],
notification: PendingMcpNotification,
maxNotifications: number = MAX_PENDING_MCP_NOTIFICATIONS,
): PendingMcpNotificationEnqueueResult {
const nextQueue = [...queue, notification]
if (nextQueue.length <= maxNotifications) {
return {
queue: nextQueue,
droppedCount: 0,
}
}
const droppedCount = nextQueue.length - maxNotifications
return {
queue: nextQueue.slice(nextQueue.length - maxNotifications),
droppedCount,
}
}
export function appendBoundedMcpError(
existingError: string | undefined,
newError: string,
maxChars: number = MAX_MCP_SERVER_ERROR_CHARS,
): BoundedMcpErrorResult {
const combined = existingError ? `${existingError}\n${newError}` : newError
if (combined.length <= maxChars) {
return {
value: combined,
truncated: false,
originalLength: combined.length,
retainedLength: combined.length,
}
}
const tailBudget = Math.max(0, maxChars - MCP_ERROR_TRUNCATION_MARKER.length)
const value = `${MCP_ERROR_TRUNCATION_MARKER}${combined.slice(-tailBudget)}`
return {
value,
truncated: true,
originalLength: combined.length,
retainedLength: value.length,
}
}
@@ -248,6 +248,10 @@ export class TelemetryService {
TOOL_USED: "task.tool_used",
// Tracks when MCP tools are used
MCP_TOOL_CALLED: "task.mcp_tool_called",
// Tracks when bounded MCP notification queues drop older entries
MCP_NOTIFICATION_DROPPED: "task.mcp_notification_dropped",
// Tracks when accumulated MCP server error text is truncated to stay within budget
MCP_ERROR_TRUNCATED: "task.mcp_error_truncated",
// Tracks when a historical task is loaded from storage
HISTORICAL_LOADED: "task.historical_loaded",
// Tracks when the retry button is clicked for failed operations
@@ -1089,6 +1093,28 @@ export class TelemetryService {
})
}
public captureMcpNotificationDropped(serverName: string, droppedCount: number, retainedCount: number) {
this.capture({
event: TelemetryService.EVENTS.TASK.MCP_NOTIFICATION_DROPPED,
properties: {
serverName,
droppedCount,
retainedCount,
},
})
}
public captureMcpErrorTruncated(serverName: string, originalLength: number, retainedLength: number) {
this.capture({
event: TelemetryService.EVENTS.TASK.MCP_ERROR_TRUNCATED,
properties: {
serverName,
originalLength,
retainedLength,
},
})
}
/**
* Records interactions with the git-based checkpoint system
* @param ulid Unique identifier for the task
@@ -207,6 +207,32 @@ describe("TelemetryService metrics", () => {
assert.strictEqual(provider.histograms[0].attributes.is_remote_workspace, true)
})
it("captureMcpNotificationDropped emits an MCP queue-drop telemetry event", () => {
const provider = new FakeProvider()
const service = createTelemetryService(provider)
service.captureMcpNotificationDropped("server-a", 3, 200)
const event = provider.logs.find((entry) => entry.event === "task.mcp_notification_dropped")
assert.ok(event)
assert.strictEqual(event?.properties?.serverName, "server-a")
assert.strictEqual(event?.properties?.droppedCount, 3)
assert.strictEqual(event?.properties?.retainedCount, 200)
})
it("captureMcpErrorTruncated emits an MCP error truncation telemetry event", () => {
const provider = new FakeProvider()
const service = createTelemetryService(provider)
service.captureMcpErrorTruncated("server-b", 40000, 32000)
const event = provider.logs.find((entry) => entry.event === "task.mcp_error_truncated")
assert.ok(event)
assert.strictEqual(event?.properties?.serverName, "server-b")
assert.strictEqual(event?.properties?.originalLength, 40000)
assert.strictEqual(event?.properties?.retainedLength, 32000)
})
it("captureConversationTurnEvent emits counters with cache and cost", () => {
const provider = new FakeProvider()
const service = createTelemetryService(provider)
+17 -1
View File
@@ -6,6 +6,7 @@ import * as path from "path"
import * as sinon from "sinon"
import { hookFileName } from "../core/hooks/__tests__/test-utils"
import { HookDiscoveryCache } from "../core/hooks/HookDiscoveryCache"
import { HookProcessRegistry } from "../core/hooks/HookProcessRegistry"
import { executeHook } from "../core/hooks/hook-executor"
import { StateManager } from "../core/storage/StateManager"
import { MessageStateHandler } from "../core/task/message-state"
@@ -68,6 +69,7 @@ setTimeout(() => {
// Reset the hook discovery cache before each test
// This ensures tests get a fresh cache and can discover newly created hooks
HookDiscoveryCache.resetForTesting()
HookProcessRegistry.resetForTesting()
// Create temporary directory for test hooks
baseTempDir = await fs.mkdtemp(path.join(os.tmpdir(), "hook-test-"))
@@ -90,6 +92,9 @@ setTimeout(() => {
})
afterEach(async () => {
await HookProcessRegistry.terminateAll()
HookProcessRegistry.resetForTesting()
// Clean up temporary directory (including entire base directory)
try {
await fs.rm(baseTempDir, { recursive: true, force: true })
@@ -244,6 +249,9 @@ setTimeout(() => {
let capturedAbortController: AbortController | null = null
let setHookCalled = false
let clearHookCalled = false
let activeCountDuringExecution = 0
HookProcessRegistry.getActiveCount().should.equal(0)
const result = await executeHook({
hookName: "TaskStart",
@@ -264,6 +272,7 @@ setTimeout(() => {
// Give the spawned hook process enough time to become fully active,
// especially on slower Windows/PowerShell CI runners, before aborting.
setTimeout(() => {
activeCountDuringExecution = Math.max(activeCountDuringExecution, HookProcessRegistry.getActiveCount())
capturedAbortController?.abort()
}, abortDelayMs)
},
@@ -280,6 +289,8 @@ setTimeout(() => {
setHookCalled.should.equal(true)
// clearHookCalled should be true after abort
clearHookCalled.should.equal(true)
activeCountDuringExecution.should.be.greaterThanOrEqual(1)
HookProcessRegistry.getActiveCount().should.equal(0)
})
it("should not allow cancellation for non-cancellable hooks", async function () {
@@ -444,7 +455,12 @@ setTimeout(() => {
// Should have at least one hook message
messages.length.should.be.greaterThan(0)
const hookMessage = messages.find((m) => m.say === "hook_status")
should.exist(hookMessage)
if (!hookMessage) {
throw new Error("Expected hook_status message to be created")
}
if (hookMessage.say !== "hook_status") {
throw new Error(`Expected hook_status message, got ${hookMessage.say}`)
}
})
it("should update hook message to completed status on success", async function () {
@@ -0,0 +1,61 @@
import { strict as assert } from "node:assert"
import { describe, it } from "mocha"
import { MessageStateHandler } from "../core/task/message-state"
import { TaskState } from "../core/task/TaskState"
import type { ClineMessage } from "../shared/ExtensionMessage"
import { measureAsyncOperation } from "./stress-utils"
function createTestMessage(text: string): ClineMessage {
return {
ts: Date.now(),
type: "say",
say: "text",
text,
}
}
describe("MessageStateHandler soak", () => {
it("handles 10,000 incremental message updates within a bounded budget", async function () {
this.timeout(30_000)
const taskState = new TaskState()
let saveCalls = 0
let historyCalls = 0
const handler = new MessageStateHandler({
taskId: "stress-task-id",
ulid: "stress-ulid",
taskState,
updateTaskHistory: async () => {
historyCalls += 1
return []
},
getTaskDirectorySize: async () => 32_768,
getCurrentWorkingDirectory: async () => "/tmp/project",
ensureTaskDirectoryExists: async () => "/tmp/project/.cline/tasks/stress-task-id",
saveClineMessages: async () => {
saveCalls += 1
},
saveApiConversationHistory: async () => {},
})
handler.setApiConversationHistory([{ role: "user", content: "seed", ts: Date.now() }] as any)
handler.setClineMessages([createTestMessage("task-seed")])
const measured = await measureAsyncOperation("message-state 10k incremental updates", async () => {
for (let i = 0; i < 10_000; i++) {
await handler.addToClineMessages(createTestMessage(`message-${i}-${"x".repeat(64)}`))
}
return handler.getClineMessages().length
})
assert.equal(measured.result, 10_001)
assert.equal(handler.getClineMessages().length, 10_001)
assert.equal(handler.getClineMessages().at(-1)?.text, `message-9999-${"x".repeat(64)}`)
assert.equal(saveCalls, 10_000)
assert.equal(historyCalls, 10_000)
assert.ok(measured.durationMs < 30_000)
assert.ok(measured.diff.heapUsedDelta < 256 * 1024 * 1024)
})
})
+281
View File
@@ -24,6 +24,233 @@ describe("MessageStateHandler Mutex Protection", () => {
})
}
it("should reuse cached task directory size across rapid consecutive saves", async () => {
const taskState = new TaskState()
let nowMs = 1_000
let taskDirSizeCalls = 0
let savedMessagesCalls = 0
const handler = new MessageStateHandler({
taskId: "test-task-id",
ulid: "test-ulid",
taskState,
updateTaskHistory: async () => [],
now: () => nowMs,
getTaskDirectorySize: async () => {
taskDirSizeCalls += 1
return 1234
},
getCurrentWorkingDirectory: async () => "/tmp/project",
ensureTaskDirectoryExists: async () => "/tmp/project/.cline/tasks/test-task-id",
saveClineMessages: async () => {
savedMessagesCalls += 1
},
saveApiConversationHistory: async () => {},
})
handler.setApiConversationHistory([{ role: "user", content: "hello", ts: Date.now() }])
handler.setClineMessages([createTestMessage("task"), createTestMessage("one")])
await handler.saveClineMessagesAndUpdateHistory()
nowMs += 100
handler.setClineMessages([createTestMessage("task"), createTestMessage("one"), createTestMessage("two")])
await handler.saveClineMessagesAndUpdateHistory()
taskDirSizeCalls.should.equal(1)
savedMessagesCalls.should.equal(2)
})
it("should recompute task directory size after the cache TTL expires", async () => {
const taskState = new TaskState()
let nowMs = 1_000
let taskDirSizeCalls = 0
const handler = new MessageStateHandler({
taskId: "test-task-id",
ulid: "test-ulid",
taskState,
updateTaskHistory: async () => [],
now: () => nowMs,
getTaskDirectorySize: async () => {
taskDirSizeCalls += 1
return 1234 + taskDirSizeCalls
},
getCurrentWorkingDirectory: async () => "/tmp/project",
ensureTaskDirectoryExists: async () => "/tmp/project/.cline/tasks/test-task-id",
saveClineMessages: async () => {},
saveApiConversationHistory: async () => {},
})
handler.setApiConversationHistory([{ role: "user", content: "hello", ts: Date.now() }])
handler.setClineMessages([createTestMessage("task"), createTestMessage("one")])
await handler.saveClineMessagesAndUpdateHistory()
nowMs += 6_000
handler.setClineMessages([createTestMessage("task"), createTestMessage("one"), createTestMessage("two")])
await handler.saveClineMessagesAndUpdateHistory()
taskDirSizeCalls.should.equal(2)
})
it("should reuse cached task directory size across repeated updateClineMessage churn on a large history", async function () {
this.timeout(5_000)
const taskState = new TaskState()
let nowMs = 1_000
let taskDirSizeCalls = 0
let savedMessagesCalls = 0
const handler = new MessageStateHandler({
taskId: "test-task-id",
ulid: "test-ulid",
taskState,
updateTaskHistory: async () => [],
now: () => nowMs,
getTaskDirectorySize: async () => {
taskDirSizeCalls += 1
return 4096
},
getCurrentWorkingDirectory: async () => "/tmp/project",
ensureTaskDirectoryExists: async () => "/tmp/project/.cline/tasks/test-task-id",
saveClineMessages: async () => {
savedMessagesCalls += 1
},
saveApiConversationHistory: async () => {},
})
handler.setApiConversationHistory([{ role: "user", content: "hello", ts: Date.now() }])
handler.setClineMessages(Array.from({ length: 1_500 }, (_, i) => createTestMessage(`message-${i}-${"x".repeat(256)}`)))
for (let i = 0; i < 25; i++) {
await handler.updateClineMessage(1_499, { text: `updated-${i}-${"y".repeat(256)}` })
nowMs += 100
}
taskDirSizeCalls.should.equal(1)
savedMessagesCalls.should.equal(25)
handler.getClineMessages()[1_499]?.text?.should.equal(`updated-24-${"y".repeat(256)}`)
})
it("should skip save and event emission for no-op updateClineMessage calls", async () => {
const taskState = new TaskState()
let savedMessagesCalls = 0
let emittedChanges = 0
const handler = new MessageStateHandler({
taskId: "test-task-id",
ulid: "test-ulid",
taskState,
updateTaskHistory: async () => [],
getTaskDirectorySize: async () => 4096,
getCurrentWorkingDirectory: async () => "/tmp/project",
ensureTaskDirectoryExists: async () => "/tmp/project/.cline/tasks/test-task-id",
saveClineMessages: async () => {
savedMessagesCalls += 1
},
saveApiConversationHistory: async () => {},
})
handler.on("clineMessagesChanged", () => {
emittedChanges += 1
})
const originalMessage = createTestMessage("stable-text")
handler.setApiConversationHistory([{ role: "user", content: "hello", ts: Date.now() }] as any)
handler.setClineMessages([originalMessage])
emittedChanges = 0
await handler.updateClineMessage(0, { text: "stable-text" })
savedMessagesCalls.should.equal(0)
emittedChanges.should.equal(0)
handler.getClineMessages()[0]?.text?.should.equal("stable-text")
})
it("should reuse cached task directory size across repeated addToClineMessages churn on a large history", async function () {
this.timeout(5_000)
const taskState = new TaskState()
let nowMs = 1_000
let taskDirSizeCalls = 0
let savedMessagesCalls = 0
const handler = new MessageStateHandler({
taskId: "test-task-id",
ulid: "test-ulid",
taskState,
updateTaskHistory: async () => [],
now: () => nowMs,
getTaskDirectorySize: async () => {
taskDirSizeCalls += 1
return 8192
},
getCurrentWorkingDirectory: async () => "/tmp/project",
ensureTaskDirectoryExists: async () => "/tmp/project/.cline/tasks/test-task-id",
saveClineMessages: async () => {
savedMessagesCalls += 1
},
saveApiConversationHistory: async () => {},
})
handler.setApiConversationHistory([{ role: "user", content: "hello", ts: Date.now() }])
handler.setClineMessages(Array.from({ length: 1_000 }, (_, i) => createTestMessage(`baseline-${i}-${"x".repeat(128)}`)))
for (let i = 0; i < 40; i++) {
await handler.addToClineMessages(createTestMessage(`added-${i}-${"z".repeat(128)}`))
nowMs += 100
}
taskDirSizeCalls.should.equal(1)
savedMessagesCalls.should.equal(40)
handler.getClineMessages().length.should.equal(1_040)
handler
.getClineMessages()
.at(-1)
?.text?.should.equal(`added-39-${"z".repeat(128)}`)
})
it("should save long histories with large per-message text bodies without dropping messages", async function () {
this.timeout(5_000)
const taskState = new TaskState()
let savedMessagesCalls = 0
let updatedHistoryItem: any
const handler = new MessageStateHandler({
taskId: "test-task-id",
ulid: "test-ulid",
taskState,
updateTaskHistory: async (historyItem) => {
updatedHistoryItem = historyItem
return []
},
getTaskDirectorySize: async () => 16_384,
getCurrentWorkingDirectory: async () => "/tmp/project",
ensureTaskDirectoryExists: async () => "/tmp/project/.cline/tasks/test-task-id",
saveClineMessages: async () => {
savedMessagesCalls += 1
},
saveApiConversationHistory: async () => {},
})
const body = "payload-".repeat(1_024)
const clineMessages = Array.from({ length: 180 }, (_, i) => createTestMessage(`message-${i}-${body}`))
const apiHistory = Array.from({ length: 180 }, (_, i) => ({
role: (i % 2 === 0 ? "user" : "assistant") as "user" | "assistant",
content: `history-${i}-${body}`,
ts: i + 1,
}))
handler.setApiConversationHistory(apiHistory as any)
handler.setClineMessages(clineMessages)
await handler.saveClineMessagesAndUpdateHistory()
savedMessagesCalls.should.equal(1)
handler.getClineMessages().length.should.equal(180)
handler.getClineMessages()[179]?.text?.should.equal(`message-179-${body}`)
should.exist(updatedHistoryItem)
updatedHistoryItem.task.should.equal(`message-0-${body}`)
updatedHistoryItem.size.should.equal(16_384)
})
/**
* Helper to create a test ClineMessage
*/
@@ -223,6 +450,60 @@ describe("MessageStateHandler Mutex Protection", () => {
history[2].role.should.equal("user")
})
it("should skip overwriteApiConversationHistory when passed the same array reference", async () => {
const taskState = new TaskState()
let saveApiCalls = 0
const handler = new MessageStateHandler({
taskId: "test-task-id",
ulid: "test-ulid",
taskState,
updateTaskHistory: async () => [],
saveClineMessages: async () => {},
saveApiConversationHistory: async () => {
saveApiCalls += 1
},
})
const history = [{ role: "user" as const, content: "same-ref", ts: Date.now() }]
handler.setApiConversationHistory(history)
await handler.overwriteApiConversationHistory(history)
saveApiCalls.should.equal(0)
handler.getApiConversationHistory().should.equal(history)
})
it("should save current API conversation history without requiring overwrite", async () => {
const taskState = new TaskState()
let saveApiCalls = 0
let savedHistory: any[] | undefined
const handler = new MessageStateHandler({
taskId: "test-task-id",
ulid: "test-ulid",
taskState,
updateTaskHistory: async () => [],
saveClineMessages: async () => {},
saveApiConversationHistory: async (_taskId, messages) => {
saveApiCalls += 1
savedHistory = messages as any[]
},
})
const history = [
{ role: "user" as const, content: "msg-1", ts: Date.now() },
{ role: "assistant" as const, content: "msg-2", ts: Date.now() + 1 },
]
handler.setApiConversationHistory(history)
await handler.saveApiConversationHistory()
saveApiCalls.should.equal(1)
should.exist(savedHistory)
savedHistory!.should.equal(history)
})
/**
* Test overwrite operations
*/
+1 -1
View File
@@ -21,4 +21,4 @@ Module.prototype.require = function (path: string) {
}
// Required to have access to String.prototype.toPosix
import "../utils/path"
require("../utils/path")
+169
View File
@@ -0,0 +1,169 @@
import { describe, it } from "mocha"
import "should"
import {
assertUtf8ByteBudget,
createLargeTextFixture,
createMultiFilePatchFixture,
createNotebookFixture,
createSingleLineFixture,
createStressFailureReport,
diffProcessResourceSnapshots,
measureAsyncOperation,
measureUtf8Bytes,
sampleEventLoopLagStats,
takeProcessResourceSnapshot,
WORKSTREAM_E_LARGE_TEXT_FIXTURE_BYTES,
WORKSTREAM_E_VERY_LARGE_TEXT_FIXTURE_BYTES,
} from "./stress-utils"
describe("stress-utils", () => {
it("measureUtf8Bytes should count ASCII and multi-byte UTF-8 content correctly", () => {
measureUtf8Bytes("abc").should.equal(3)
measureUtf8Bytes("🙂").should.equal(Buffer.byteLength("🙂", "utf8"))
measureUtf8Bytes("a🙂b").should.equal(Buffer.byteLength("a🙂b", "utf8"))
})
it("assertUtf8ByteBudget should allow content within the budget", () => {
;(() => assertUtf8ByteBudget("abcd", 4, "test payload")).should.not.throw()
})
it("assertUtf8ByteBudget should throw a readable error when budget is exceeded", () => {
;(() => assertUtf8ByteBudget("abcde", 4, "test payload")).should.throw(/test payload exceeded UTF-8 byte budget/)
})
it("createLargeTextFixture should generate at least the requested byte size across many lines", () => {
const fixture = createLargeTextFixture(8 * 1024, { linePrefix: "large", lineLength: 64 })
measureUtf8Bytes(fixture).should.be.greaterThanOrEqual(8 * 1024)
fixture.split("\n").length.should.be.greaterThan(10)
})
it("createSingleLineFixture should generate a giant single line with exact ASCII byte length", () => {
const fixture = createSingleLineFixture(4096, "z")
measureUtf8Bytes(fixture).should.equal(4096)
fixture.should.not.containEql("\n")
})
it("createNotebookFixture should produce valid notebook JSON at or above the requested size", () => {
const fixture = createNotebookFixture(12 * 1024)
const parsed = JSON.parse(fixture)
measureUtf8Bytes(fixture).should.be.greaterThanOrEqual(12 * 1024)
parsed.nbformat.should.equal(4)
parsed.cells.should.be.an.Array().and.have.length(1)
parsed.cells[0].outputs.should.be.an.Array().and.have.length(1)
})
it("createMultiFilePatchFixture should generate an apply_patch payload with all file sections", () => {
const patch = createMultiFilePatchFixture([
{ path: "a.ts", content: "alpha\nbeta" },
{ path: "b.ts", content: "gamma" },
])
patch.should.containEql("*** Begin Patch")
patch.should.containEql("*** Add File: a.ts")
patch.should.containEql("*** Add File: b.ts")
patch.should.containEql("+alpha")
patch.should.containEql("+beta")
patch.should.containEql("+gamma")
patch.should.containEql("*** End Patch")
})
it("exports the expected Workstream E large fixture byte budgets", () => {
WORKSTREAM_E_LARGE_TEXT_FIXTURE_BYTES.should.equal(5 * 1024 * 1024)
WORKSTREAM_E_VERY_LARGE_TEXT_FIXTURE_BYTES.should.equal(20 * 1024 * 1024)
})
it("takeProcessResourceSnapshot should return process memory and active handle information", () => {
const snapshot = takeProcessResourceSnapshot()
snapshot.timestampMs.should.be.a.Number()
snapshot.performanceNowMs.should.be.a.Number()
snapshot.memory.heapUsed.should.be.a.Number()
snapshot.memory.heapTotal.should.be.a.Number()
snapshot.memory.rss.should.be.a.Number()
snapshot.activeHandles.count.should.be.a.Number()
snapshot.activeHandles.types.should.be.an.Array()
})
it("diffProcessResourceSnapshots should compute deltas between snapshots", () => {
const before = {
timestampMs: 1,
performanceNowMs: 10,
memory: { heapUsed: 10, heapTotal: 20, external: 30, arrayBuffers: 40, rss: 50 },
activeHandles: { count: 1, types: ["Timeout"] },
}
const after = {
timestampMs: 2,
performanceNowMs: 25,
memory: { heapUsed: 15, heapTotal: 22, external: 31, arrayBuffers: 45, rss: 60 },
activeHandles: { count: 3, types: ["Timeout", "FSWatcher", "Socket"] },
}
const diff = diffProcessResourceSnapshots(before, after)
diff.durationMs.should.equal(15)
diff.heapUsedDelta.should.equal(5)
diff.heapTotalDelta.should.equal(2)
diff.externalDelta.should.equal(1)
diff.arrayBuffersDelta.should.equal(5)
diff.rssDelta.should.equal(10)
diff.activeHandleCountDelta.should.equal(2)
diff.activeHandleTypesAdded.should.deepEqual(["FSWatcher", "Socket"])
})
it("measureAsyncOperation should return result, duration, and resource snapshots", async function () {
this.timeout(5000)
const measured = await measureAsyncOperation("example operation", async () => {
return 42
})
measured.label.should.equal("example operation")
measured.result.should.equal(42)
measured.durationMs.should.be.greaterThanOrEqual(0)
measured.before.memory.heapUsed.should.be.a.Number()
measured.after.memory.heapUsed.should.be.a.Number()
measured.diff.durationMs.should.be.greaterThanOrEqual(0)
})
it("sampleEventLoopLagStats should return non-negative lag measurements", async function () {
this.timeout(5000)
const stats = await sampleEventLoopLagStats(40, 10)
stats.runtimeMs.should.be.greaterThanOrEqual(0)
stats.minMs.should.be.greaterThanOrEqual(0)
stats.maxMs.should.be.greaterThanOrEqual(0)
stats.meanMs.should.be.greaterThanOrEqual(0)
stats.p50Ms.should.be.greaterThanOrEqual(0)
stats.p95Ms.should.be.greaterThanOrEqual(0)
stats.p99Ms.should.be.greaterThanOrEqual(0)
})
it("createStressFailureReport should produce a stable structured failure payload", async function () {
this.timeout(5000)
const measured = await measureAsyncOperation("failing operation", async () => 42)
const report = createStressFailureReport(measured, {
error: new Error("boom"),
annotations: {
testCase: "stress-utils",
iteration: 1,
},
})
report.label.should.equal("failing operation")
report.timestampMs.should.equal(measured.after.timestampMs)
report.durationMs.should.equal(measured.durationMs)
report.before.should.deepEqual(measured.before)
report.after.should.deepEqual(measured.after)
report.diff.should.deepEqual(measured.diff)
report.error!.should.deepEqual({ name: "Error", message: "boom" })
report.annotations!.should.deepEqual({ testCase: "stress-utils", iteration: 1 })
})
it("createStressFailureReport should normalize non-Error failures", async function () {
this.timeout(5000)
const measured = await measureAsyncOperation("string failure", async () => "ok")
const report = createStressFailureReport(measured, { error: "plain failure" })
report.error!.should.deepEqual({ name: "UnknownError", message: "plain failure" })
})
})
+366
View File
@@ -0,0 +1,366 @@
import { monitorEventLoopDelay, performance } from "node:perf_hooks"
export interface MemorySnapshot {
heapUsed: number
heapTotal: number
external: number
arrayBuffers: number
rss: number
}
export interface ActiveHandleSnapshot {
count: number
types: string[]
}
export interface ProcessResourceSnapshot {
timestampMs: number
performanceNowMs: number
memory: MemorySnapshot
activeHandles: ActiveHandleSnapshot
}
export interface ProcessResourceDiff {
durationMs: number
heapUsedDelta: number
heapTotalDelta: number
externalDelta: number
arrayBuffersDelta: number
rssDelta: number
activeHandleCountDelta: number
activeHandleTypesAdded: string[]
}
export interface EventLoopLagStats {
minMs: number
maxMs: number
meanMs: number
stddevMs: number
p50Ms: number
p95Ms: number
p99Ms: number
runtimeMs: number
sampleCountEstimate: number
}
export interface MeasuredAsyncOperation<TResult> {
label: string
result: TResult
durationMs: number
before: ProcessResourceSnapshot
after: ProcessResourceSnapshot
diff: ProcessResourceDiff
}
export interface StressFailureReport {
label: string
timestampMs: number
durationMs: number
before: ProcessResourceSnapshot
after: ProcessResourceSnapshot
diff: ProcessResourceDiff
error?: {
name: string
message: string
}
eventLoopLag?: EventLoopLagStats
annotations?: Record<string, string | number | boolean>
}
export const WORKSTREAM_E_LARGE_TEXT_FIXTURE_BYTES = 5 * 1024 * 1024
export const WORKSTREAM_E_VERY_LARGE_TEXT_FIXTURE_BYTES = 20 * 1024 * 1024
export interface GeneratedTextFixtureOptions {
linePrefix?: string
lineLength?: number
fillChar?: string
}
export interface GeneratedPatchFileSpec {
path: string
content: string
}
function toMemorySnapshot(memoryUsage: NodeJS.MemoryUsage): MemorySnapshot {
return {
heapUsed: memoryUsage.heapUsed,
heapTotal: memoryUsage.heapTotal,
external: memoryUsage.external,
arrayBuffers: memoryUsage.arrayBuffers,
rss: memoryUsage.rss,
}
}
function getActiveHandleTypes(): string[] {
const getHandles = (
process as NodeJS.Process & {
_getActiveHandles?: () => unknown[]
}
)._getActiveHandles
if (!getHandles) {
return []
}
try {
return getHandles.call(process).map((handle) => {
const constructorName = (handle as { constructor?: { name?: string } })?.constructor?.name
return constructorName || "UnknownHandle"
})
} catch {
return []
}
}
export function takeProcessResourceSnapshot(): ProcessResourceSnapshot {
const activeHandleTypes = getActiveHandleTypes()
return {
timestampMs: Date.now(),
performanceNowMs: performance.now(),
memory: toMemorySnapshot(process.memoryUsage()),
activeHandles: {
count: activeHandleTypes.length,
types: activeHandleTypes,
},
}
}
export function diffProcessResourceSnapshots(
before: ProcessResourceSnapshot,
after: ProcessResourceSnapshot,
): ProcessResourceDiff {
const beforeTypeCounts = new Map<string, number>()
for (const type of before.activeHandles.types) {
beforeTypeCounts.set(type, (beforeTypeCounts.get(type) || 0) + 1)
}
const addedTypes: string[] = []
const remainingBeforeCounts = new Map(beforeTypeCounts)
for (const type of after.activeHandles.types) {
const count = remainingBeforeCounts.get(type) || 0
if (count > 0) {
remainingBeforeCounts.set(type, count - 1)
} else {
addedTypes.push(type)
}
}
return {
durationMs: Math.max(0, after.performanceNowMs - before.performanceNowMs),
heapUsedDelta: after.memory.heapUsed - before.memory.heapUsed,
heapTotalDelta: after.memory.heapTotal - before.memory.heapTotal,
externalDelta: after.memory.external - before.memory.external,
arrayBuffersDelta: after.memory.arrayBuffers - before.memory.arrayBuffers,
rssDelta: after.memory.rss - before.memory.rss,
activeHandleCountDelta: after.activeHandles.count - before.activeHandles.count,
activeHandleTypesAdded: addedTypes,
}
}
export function measureUtf8Bytes(content: string): number {
return Buffer.byteLength(content, "utf8")
}
export function createLargeTextFixture(targetBytes: number, options: GeneratedTextFixtureOptions = {}): string {
if (targetBytes <= 0) {
return ""
}
const linePrefix = options.linePrefix ?? "fixture-line"
const lineLength = Math.max(16, options.lineLength ?? 96)
const fillChar = (options.fillChar ?? "x").charAt(0) || "x"
const lines: string[] = []
let totalBytes = 0
let index = 1
while (totalBytes < targetBytes) {
const prefix = `${linePrefix}-${String(index).padStart(6, "0")}-`
const fillLength = Math.max(1, lineLength - prefix.length)
const line = `${prefix}${fillChar.repeat(fillLength)}`
lines.push(line)
totalBytes += Buffer.byteLength(line, "utf8") + 1
index += 1
}
return lines.join("\n")
}
export function createSingleLineFixture(targetBytes: number, fillChar = "x"): string {
if (targetBytes <= 0) {
return ""
}
return (fillChar.charAt(0) || "x").repeat(targetBytes)
}
export function createNotebookFixture(targetBytes: number): string {
if (targetBytes <= 0) {
return JSON.stringify({ cells: [], metadata: {}, nbformat: 4, nbformat_minor: 5 })
}
const sourcePayload = createLargeTextFixture(Math.max(256, Math.floor(targetBytes * 0.45)), {
linePrefix: "cell",
lineLength: 88,
fillChar: "s",
})
const outputPayload = createLargeTextFixture(Math.max(256, Math.floor(targetBytes * 0.35)), {
linePrefix: "output",
lineLength: 88,
fillChar: "o",
})
const notebook = {
cells: [
{
cell_type: "code",
execution_count: 1,
metadata: {
generatedFixture: true,
},
outputs: [
{
output_type: "stream",
name: "stdout",
text: outputPayload.split("\n").map((line) => `${line}\n`),
},
],
source: sourcePayload.split("\n").map((line) => `${line}\n`),
},
],
metadata: {
language_info: {
name: "typescript",
},
},
nbformat: 4,
nbformat_minor: 5,
}
let serialized = JSON.stringify(notebook)
const currentBytes = measureUtf8Bytes(serialized)
if (currentBytes < targetBytes) {
;(notebook.metadata as Record<string, unknown>).padding = "p".repeat(targetBytes - currentBytes)
serialized = JSON.stringify(notebook)
}
return serialized
}
export function createMultiFilePatchFixture(files: GeneratedPatchFileSpec[]): string {
const lines = ["*** Begin Patch"]
for (const file of files) {
lines.push(`*** Add File: ${file.path}`)
for (const line of file.content.split("\n")) {
lines.push(`+${line}`)
}
}
lines.push("*** End Patch")
return lines.join("\n")
}
export function createWorkstreamELargeTextFixture(): string {
return createLargeTextFixture(WORKSTREAM_E_LARGE_TEXT_FIXTURE_BYTES)
}
export function createWorkstreamEVeryLargeTextFixture(): string {
return createLargeTextFixture(WORKSTREAM_E_VERY_LARGE_TEXT_FIXTURE_BYTES)
}
export function createWorkstreamESingleLineFixture(targetBytes = WORKSTREAM_E_LARGE_TEXT_FIXTURE_BYTES): string {
return createSingleLineFixture(targetBytes)
}
export function createWorkstreamENotebookFixture(targetBytes = WORKSTREAM_E_LARGE_TEXT_FIXTURE_BYTES): string {
return createNotebookFixture(targetBytes)
}
export function assertUtf8ByteBudget(content: string, maxBytes: number, label = "content"): void {
const actualBytes = measureUtf8Bytes(content)
if (actualBytes > maxBytes) {
throw new Error(
`${label} exceeded UTF-8 byte budget: ${actualBytes} bytes > ${maxBytes} bytes ` +
`(over by ${actualBytes - maxBytes} bytes)`,
)
}
}
export async function sleep(ms: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, ms))
}
function nanosToMs(nanos: number): number {
return nanos / 1_000_000
}
export async function sampleEventLoopLagStats(runtimeMs = 50, resolutionMs = 10): Promise<EventLoopLagStats> {
const histogram = monitorEventLoopDelay({ resolution: resolutionMs })
histogram.enable()
const startedAt = performance.now()
try {
await sleep(runtimeMs)
} finally {
histogram.disable()
}
const endedAt = performance.now()
return {
minMs: nanosToMs(histogram.min),
maxMs: nanosToMs(histogram.max),
meanMs: Number.isFinite(histogram.mean) ? nanosToMs(histogram.mean) : 0,
stddevMs: Number.isFinite(histogram.stddev) ? nanosToMs(histogram.stddev) : 0,
p50Ms: nanosToMs(histogram.percentile(50)),
p95Ms: nanosToMs(histogram.percentile(95)),
p99Ms: nanosToMs(histogram.percentile(99)),
runtimeMs: Math.max(0, endedAt - startedAt),
sampleCountEstimate: histogram.exceeds,
}
}
export async function measureAsyncOperation<TResult>(
label: string,
operation: () => Promise<TResult>,
): Promise<MeasuredAsyncOperation<TResult>> {
const before = takeProcessResourceSnapshot()
const operationStart = performance.now()
const result = await operation()
const operationEnd = performance.now()
const after = takeProcessResourceSnapshot()
return {
label,
result,
durationMs: Math.max(0, operationEnd - operationStart),
before,
after,
diff: diffProcessResourceSnapshots(before, after),
}
}
export function createStressFailureReport<TResult>(
measured: MeasuredAsyncOperation<TResult>,
options?: {
error?: unknown
eventLoopLag?: EventLoopLagStats
annotations?: Record<string, string | number | boolean>
},
): StressFailureReport {
const error = options?.error
const normalizedError = error
? {
name: error instanceof Error ? error.name : "UnknownError",
message: error instanceof Error ? error.message : String(error),
}
: undefined
return {
label: measured.label,
timestampMs: measured.after.timestampMs,
durationMs: measured.durationMs,
before: measured.before,
after: measured.after,
diff: measured.diff,
error: normalizedError,
eventLoopLag: options?.eventLoopLag,
annotations: options?.annotations,
}
}
+1
View File
@@ -15,6 +15,7 @@ export const env = {
export const version = "1.103.0"
export const workspace = {
workspaceFolders: [],
getConfiguration: (section?: string) => {
return {
get: (key: string, defaultValue?: any) => {
@@ -0,0 +1,100 @@
import { act, render, screen, waitFor } from "@testing-library/react"
import { describe, expect, it, vi } from "vitest"
import { ExtensionStateContextProvider, useExtensionState } from "./ExtensionStateContext"
const stateSubscription = {
callbacks: undefined as { onResponse?: (response: { stateJson?: string }) => void } | undefined,
}
const makeUnsubscribe = () => vi.fn()
vi.mock("@/services/grpc-client", () => ({
StateServiceClient: {
subscribeToState: vi.fn((_request, callbacks) => {
stateSubscription.callbacks = callbacks
return makeUnsubscribe()
}),
getAvailableTerminalProfiles: vi.fn(async () => ({ profiles: [] })),
},
UiServiceClient: {
subscribeToMcpButtonClicked: vi.fn(() => makeUnsubscribe()),
subscribeToHistoryButtonClicked: vi.fn(() => makeUnsubscribe()),
subscribeToChatButtonClicked: vi.fn(() => makeUnsubscribe()),
subscribeToSettingsButtonClicked: vi.fn(() => makeUnsubscribe()),
subscribeToWorktreesButtonClicked: vi.fn(() => makeUnsubscribe()),
subscribeToPartialMessage: vi.fn(() => makeUnsubscribe()),
subscribeToAccountButtonClicked: vi.fn(() => makeUnsubscribe()),
subscribeToRelinquishControl: vi.fn(() => makeUnsubscribe()),
initializeWebview: vi.fn(async () => undefined),
},
McpServiceClient: {
subscribeToMcpServers: vi.fn(() => makeUnsubscribe()),
subscribeToMcpMarketplaceCatalog: vi.fn(() => makeUnsubscribe()),
},
ModelsServiceClient: {
subscribeToOpenRouterModels: vi.fn(() => makeUnsubscribe()),
subscribeToLiteLlmModels: vi.fn(() => makeUnsubscribe()),
refreshOpenRouterModelsRpc: vi.fn(async () => ({ models: [] })),
refreshVercelAiGatewayModelsRpc: vi.fn(async () => ({ models: [] })),
refreshBasetenModelsRpc: vi.fn(async () => ({ models: [] })),
refreshLiteLlmModelsRpc: vi.fn(async () => ({ models: [] })),
refreshClineModelsRpc: vi.fn(async () => ({ models: [] })),
refreshHicapModels: vi.fn(async () => ({ models: [] })),
},
}))
function StateProbe() {
const state = useExtensionState() as ReturnType<typeof useExtensionState> & { clineMessages: Array<{ text?: string }> }
const { didHydrateState, clineMessages } = state
return (
<>
<div data-testid="hydrated">{String(didHydrateState)}</div>
<div data-testid="count">{clineMessages.length}</div>
<div data-testid="last-text">{clineMessages.at(-1)?.text ?? ""}</div>
</>
)
}
describe("ExtensionStateContextProvider", () => {
it("hydrates repeated large stateJson payloads from the state subscription", async () => {
const largeText = "x".repeat(256 * 1024)
const firstState = {
version: "1.0.0",
mode: "act",
clineMessages: Array.from({ length: 12 }, (_, i) => ({
ts: i + 1,
type: "say",
say: "text",
text: `first-${i}-${largeText}`,
})),
taskHistory: [],
} as any
const secondState = {
...firstState,
clineMessages: [...firstState.clineMessages, { ts: 99, type: "say", say: "text", text: `second-tail-${largeText}` }],
} as any
render(
<ExtensionStateContextProvider>
<StateProbe />
</ExtensionStateContextProvider>,
)
await waitFor(() => expect(stateSubscription.callbacks?.onResponse).toBeTypeOf("function"))
act(() => {
stateSubscription.callbacks?.onResponse?.({ stateJson: JSON.stringify(firstState) })
})
await waitFor(() => expect(screen.getByTestId("hydrated").textContent).toBe("true"))
expect(screen.getByTestId("count").textContent).toBe("12")
expect(screen.getByTestId("last-text").textContent).toContain("first-11-")
act(() => {
stateSubscription.callbacks?.onResponse?.({ stateJson: JSON.stringify(secondState) })
})
await waitFor(() => expect(screen.getByTestId("count").textContent).toBe("13"))
expect(screen.getByTestId("last-text").textContent).toContain("second-tail-")
})
})