mirror of
https://github.com/cline/cline.git
synced 2026-09-11 05:47:07 +08:00
Compare commits
63
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a42a99cb03 | ||
|
|
8f07c16e88 | ||
|
|
f8f42773b7 | ||
|
|
3e69d4c134 | ||
|
|
fb4dd3efd9 | ||
|
|
1598011bb4 | ||
|
|
019e8a0317 | ||
|
|
2ea4949615 | ||
|
|
aed443d074 | ||
|
|
c995bfd07d | ||
|
|
4cfdd2cf27 | ||
|
|
b3ef435c2b | ||
|
|
0dc302ca6f | ||
|
|
08d5213e83 | ||
|
|
2a200ea701 | ||
|
|
61dd5916e8 | ||
|
|
ff5744871b | ||
|
|
7b1ea867b5 | ||
|
|
c81a829808 | ||
|
|
324483ce53 | ||
|
|
8669fac865 | ||
|
|
cab96ddf71 | ||
|
|
d58181ff18 | ||
|
|
37ca90e80e | ||
|
|
9135b41a84 | ||
|
|
b6bdef1eaa | ||
|
|
bf6e04264b | ||
|
|
c890e3a9f4 | ||
|
|
e95888ca89 | ||
|
|
8ae3366ccf | ||
|
|
331f5ca502 | ||
|
|
20547ec082 | ||
|
|
b40f722ff4 | ||
|
|
27a973d020 | ||
|
|
350bd63359 | ||
|
|
29d34c8498 | ||
|
|
121c7b3ad3 | ||
|
|
b60616076d | ||
|
|
beadae54fb | ||
|
|
1d50a0b55f | ||
|
|
c9cbed84b9 | ||
|
|
4bb97e471b | ||
|
|
f1f133380e | ||
|
|
3ded613158 | ||
|
|
8e1531ba2a | ||
|
|
05e1a5ab22 | ||
|
|
1559f76294 | ||
|
|
93399eace7 | ||
|
|
984dd3e5cd | ||
|
|
4f4fe116dd | ||
|
|
d56351cc23 | ||
|
|
31c6a1b938 | ||
|
|
3596cf4719 | ||
|
|
856582e852 | ||
|
|
8058335027 | ||
|
|
45efd134f4 | ||
|
|
4ae091bdab | ||
|
|
4930b8a9da | ||
|
|
8ff555f93c | ||
|
|
c52c72cdf8 | ||
|
|
edf98132cb | ||
|
|
12e7203cc5 | ||
|
|
658974524f |
@@ -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")
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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/)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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.`,
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
+177
@@ -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
|
||||
}
|
||||
@@ -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",
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(/^\/+/, "")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -21,4 +21,4 @@ Module.prototype.require = function (path: string) {
|
||||
}
|
||||
|
||||
// Required to have access to String.prototype.toPosix
|
||||
import "../utils/path"
|
||||
require("../utils/path")
|
||||
|
||||
@@ -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" })
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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-")
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user