Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
339eedf
feat(issue-457-edit-unsuccessfull): introduce configurable relaxed di…
nigeldelviero Jun 4, 2026
f1945d3
Update packages/types/src/global-settings.ts
nigeldelviero Jun 4, 2026
735759b
fix(issue-457-edit-unsuccessfull): fix js doc and remove hard-coded e…
nigeldelviero Jun 4, 2026
2587469
fix(issue-457-edit-unsuccessfull): limit original content preview to …
nigeldelviero Jun 5, 2026
6bdb35c
fix(issue-457-edit-unsuccessfull): clamp the threshold
nigeldelviero Jun 5, 2026
5ffb87e
fix(issue-457-edit-unsuccessfull): remove access private property
nigeldelviero Jun 5, 2026
b0ddd4c
fix(issue-457-edit-unsuccessfull): remove from Pick and import types
nigeldelviero Jun 5, 2026
c5d258f
fix(issue-457-edit-unsuccessfull): Move diffFuzzyThreshold to new 'Fi…
nigeldelviero Jun 6, 2026
91c5eac
Revert "fix(issue-457-edit-unsuccessfull): limit original content pre…
nigeldelviero Jun 6, 2026
40520b0
fix(issue-457-edit-unsuccessfull): clarify diffuzyThreshold JSDoc com…
nigeldelviero Jun 6, 2026
ee7a699
fix(issue-457-edit-unsuccessfull): Default diffFuzzyThreshold to 1.0 …
nigeldelviero Jun 6, 2026
4474089
fix(issue-457-edit-unsuccessfull): Correct the documented default for…
nigeldelviero Jun 6, 2026
b619c45
fix(issue-457-edit-unsuccessfull): added missing translations
nigeldelviero Jun 9, 2026
0c788e2
feat(issue-457-edit-unsuccessfull): introduce configurable relaxed di…
nigeldelviero Jun 4, 2026
8c9050f
Update packages/types/src/global-settings.ts
nigeldelviero Jun 4, 2026
8f9a1a8
fix(issue-457-edit-unsuccessfull): fix js doc and remove hard-coded e…
nigeldelviero Jun 4, 2026
b6635cf
fix(issue-457-edit-unsuccessfull): limit original content preview to …
nigeldelviero Jun 5, 2026
90c0705
fix(issue-457-edit-unsuccessfull): remove from Pick and import types
nigeldelviero Jun 5, 2026
1c7e85a
fix(issue-457-edit-unsuccessfull): Move diffFuzzyThreshold to new 'Fi…
nigeldelviero Jun 6, 2026
630ef75
Revert "fix(issue-457-edit-unsuccessfull): limit original content pre…
nigeldelviero Jun 6, 2026
fc0a14c
fix(issue-457-edit-unsuccessfull): clarify diffuzyThreshold JSDoc com…
nigeldelviero Jun 6, 2026
98a31a5
fix(issue-457-edit-unsuccessfull): Correct the documented default for…
nigeldelviero Jun 6, 2026
fb9a30c
fix(multi-search-replace): cap original content preview to bounded wi…
nigeldelviero Jun 22, 2026
eaca0fa
fix(multi-search-replace): address review feedback
nigeldelviero Jun 22, 2026
7c5368b
fix(multi-search-replace): fix coverage holes
nigeldelviero Jun 22, 2026
8449c27
fix(test): align no-match diagnostics expectations with current error…
nigeldelviero Jun 22, 2026
fdfff90
test(settings): remove invalid checkpoints default assertion
nigeldelviero Jun 22, 2026
c78e938
test(settings): cover settings fallback defaults
nigeldelviero Jun 22, 2026
dde907c
test: stabilize settings fallback defaults
nigeldelviero Jun 22, 2026
249698e
test: cover ClineProvider partial branches
nigeldelviero Jun 22, 2026
9543a5b
test: cover multi-search-replace failure branches
nigeldelviero Jun 22, 2026
ccf8c45
test: cover remaining codecov patch branches on ClineProvider and mul…
nigeldelviero Jun 22, 2026
a875f01
docs: correct constructor comment to reflect actual default threshold…
nigeldelviero Jun 23, 2026
c3b4b6d
fix: align schema min(0.5) with JSDoc range and constructor clamp
nigeldelviero Jun 23, 2026
fd5d031
fix: remove duplicate fileEdits.diffFuzzyThreshold entry in en locale
nigeldelviero Jun 23, 2026
aacf6fc
test: cover diffFuzzyThreshold passthrough in getStateToPostToWebview
nigeldelviero Jun 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 21 additions & 6 deletions packages/types/src/global-settings.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,18 @@
import { z } from "zod"

import { type Keys } from "./type-fu.js"
import { codebaseIndexConfigSchema, codebaseIndexModelsSchema } from "./codebase-index.js"
import { experimentsSchema } from "./experiment.js"
import { historyItemSchema } from "./history.js"
import { customModePromptsSchema, customSupportPromptsSchema, modeConfigSchema } from "./mode.js"
import {
type ProviderSettings,
PROVIDER_SETTINGS_KEYS,
providerSettingsEntrySchema,
providerSettingsSchema,
} from "./provider-settings.js"
import { historyItemSchema } from "./history.js"
import { codebaseIndexModelsSchema, codebaseIndexConfigSchema } from "./codebase-index.js"
import { experimentsSchema } from "./experiment.js"
import { telemetrySettingsSchema } from "./telemetry.js"
import { modeConfigSchema } from "./mode.js"
import { customModePromptsSchema, customSupportPromptsSchema } from "./mode.js"
import { toolNamesSchema } from "./tool.js"
import { type Keys } from "./type-fu.js"
import { languagesSchema } from "./vscode.js"

/**
Expand All @@ -23,6 +22,16 @@ import { languagesSchema } from "./vscode.js"
*/
export const DEFAULT_WRITE_DELAY_MS = 1000

/**
* Default fuzzy matching threshold for the multi-search-replace diff strategy.
* A value of 1.0 (exact match) is used by default for safety, especially when
* auto-approval for writes is enabled. This prevents unintended changes from
* being applied due to minor mismatches. Users can lower this threshold manually
* in settings to reduce "Edit Unsuccessful" errors caused by minor whitespace
* or formatting differences, accepting a higher risk of unintended edits.
*/
export const DEFAULT_DIFF_FUZZY_THRESHOLD = 1.0

/**
* Terminal output preview size options for persisted command output.
*
Expand Down Expand Up @@ -102,6 +111,12 @@ export const globalSettingsSchema = z.object({
alwaysAllowWriteOutsideWorkspace: z.boolean().optional(),
alwaysAllowWriteProtected: z.boolean().optional(),
writeDelayMs: z.number().min(0).optional(),
/**
* Fuzzy matching threshold for the multi-search-replace diff strategy.
* Range: 0.5 (50% minimum similarity) to 1.0 (exact match only).
* `@default` 1.0
*/
diffFuzzyThreshold: z.number().min(0.5).max(1).optional(),
requestDelaySeconds: z.number().optional(),
alwaysAllowMcp: z.boolean().optional(),
alwaysAllowModeSwitch: z.boolean().optional(),
Expand Down
1 change: 1 addition & 0 deletions packages/types/src/vscode-extension-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,7 @@ export type ExtensionState = Pick<
taskHistory: HistoryItem[]

writeDelayMs: number
diffFuzzyThreshold: number

enableCheckpoints: boolean
checkpointTimeout: number // Timeout for checkpoint initialization in seconds (default: 15)
Expand Down
40 changes: 40 additions & 0 deletions src/__tests__/single-open-invariant.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,46 @@ describe("Single-open-task invariant", () => {
expect(addClineToStack).toHaveBeenCalledTimes(1)
})

it("Subtask create: keeps existing task open when parentTask is provided", async () => {
vi.spyOn(ProfileValidatorMod.ProfileValidator, "isProfileAllowed").mockReturnValue(true)

const removeClineFromStack = vi.fn().mockResolvedValue(undefined)
const addClineToStack = vi.fn().mockResolvedValue(undefined)
const parentTask = { taskId: "parent-1" }

const provider = {
clineStack: [parentTask],
setValues: vi.fn(),
getState: vi.fn().mockResolvedValue({
apiConfiguration: { apiProvider: "anthropic", consecutiveMistakeLimit: 0 },
organizationAllowList: "*",
enableCheckpoints: true,
checkpointTimeout: 60,
cloudUserInfo: null,
}),
removeClineFromStack,
addClineToStack,
setProviderProfile: vi.fn(),
log: vi.fn(),
getStateToPostToWebview: vi.fn(),
providerSettingsManager: { getModeConfigId: vi.fn(), listConfig: vi.fn() },
customModesManager: { getCustomModes: vi.fn().mockResolvedValue([]) },
taskCreationCallback: vi.fn(),
contextProxy: {
extensionUri: {},
setValue: vi.fn(),
getValue: vi.fn(),
setProviderSettings: vi.fn(),
getProviderSettings: vi.fn(() => ({})),
},
} as unknown as ClineProvider

await (ClineProvider.prototype as any).createTask.call(provider, "Subtask", undefined, parentTask as any)

expect(removeClineFromStack).not.toHaveBeenCalled()
expect(addClineToStack).toHaveBeenCalledTimes(1)
})

it("History resume path always closes current before rehydration (non-rehydrating case)", async () => {
const removeClineFromStack = vi.fn().mockResolvedValue(undefined)
const addClineToStack = vi.fn().mockResolvedValue(undefined)
Expand Down
218 changes: 218 additions & 0 deletions src/core/diff/strategies/__tests__/multi-search-replace.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -878,6 +878,119 @@ function processData(data) {
expect(result.success).toBe(false)
})

it("should include line range debug info when search fails with start_line marker", async () => {
const originalContent = "line one\nline two"
const diffContent = `test.ts
<<<<<<< SEARCH
:start_line:999
-------
non-existent content that cannot be found in the file
=======
replacement content here
>>>>>>> REPLACE`

const result = await strategy.applyDiff(originalContent, diffContent)
expect(result.success).toBe(false)
const error =
!result.success && result.failParts?.[0]
? "error" in result.failParts[0]
? result.failParts[0].error
: ""
: ""
expect(error).toContain("No sufficiently similar match found")
expect(error).toContain("at line: 999")
expect(error).toContain("Search Range: starting at line 999")
expect(error).toContain("Best Match Found:\n(no match)")
expect(error).toContain("Levenshtein Distance: N/A")
expect(error).toContain("Best Match Length: 0 characters")
})

it("should include scoped original content when search fails with start_line that has a low-score match", async () => {
const originalContent = "function existing() {\n return 42;\n}\n"
const diffContent = `test.ts
<<<<<<< SEARCH
:start_line:1
-------
function different() {
return 99;
}
=======
function newVersion() {
return 99;
}
>>>>>>> REPLACE`

const result = await strategy.applyDiff(originalContent, diffContent)
expect(result.success).toBe(false)
const error =
!result.success && result.failParts?.[0]
? "error" in result.failParts[0]
? result.failParts[0].error
: ""
: ""
expect(error).toContain("No sufficiently similar match found")
expect(error).toContain("at line: 1")
expect(error).toContain("Search Range: starting at line 1")
expect(error).toContain("Best Match Found:")
expect(error).toContain("Original Content:\n1 | function existing()")
})

it("should include best-match debug info when unscoped search is below threshold", async () => {
const strictStrategy = new MultiSearchReplaceDiffStrategy(1, 5)
const originalContent = "function processUsers(data) {\n return data.map(user => user.name);\n}\n"
const diffContent = `test.ts
<<<<<<< SEARCH
function processUsers(data) {
return data.map(user => user.username);
}
=======
function processUsers(data) {
return data.map(user => user.displayName);
}
>>>>>>> REPLACE`

const result = await strictStrategy.applyDiff(originalContent, diffContent)
expect(result.success).toBe(false)
const error =
!result.success && result.failParts?.[0]
? "error" in result.failParts[0]
? result.failParts[0].error
: ""
: ""
expect(error).toContain("Search Range: start to end")
expect(error).toContain("Levenshtein Distance:")
expect(error).toContain("characters")
expect(error).toContain("Best Match Length:")
expect(error).toContain("Best Match Found:\n1 | function processUsers(data)")
})

it("should include zero-match info when unscoped search finds no similarity at all", async () => {
const strictStrategy = new MultiSearchReplaceDiffStrategy(1, 0)
const originalContent = "xxxxxx\nyyyyyy\nzzzzzz"
const diffContent = `test.ts
<<<<<<< SEARCH
!!!!!!
=======
aaaaaa
>>>>>>> REPLACE`

const result = await strictStrategy.applyDiff(originalContent, diffContent)
expect(result.success).toBe(false)
const error =
!result.success && result.failParts?.[0]
? "error" in result.failParts[0]
? result.failParts[0].error
: ""
: ""
expect(error).toContain("No sufficiently similar match found")
expect(error).toContain("Search Range: start to end")
expect(error).toContain("Best Match Found:\n(no match)")
expect(error).toContain("Levenshtein Distance: N/A")
expect(error).toContain("Best Match Length: 0 characters")
expect(error).toContain("Original Content:")
expect(error).toContain("1 | xxxxxx")
})

it("should match content with extra whitespace", async () => {
const originalContent = "function sum(a, b) {\n return a + b;\n}"
const diffContent = `test.ts
Expand Down Expand Up @@ -1374,4 +1487,109 @@ function sum(a, b) {
}
})
})

describe("fuzzyThreshold and diagnostics", () => {
const originalContent =
"function calculateTotal(price: number, tax: number) {\n\tconst subtotal = price;\n\treturn subtotal + tax;\n}\n"

it("should succeed with near-miss match (e.g. minor whitespace diff) when threshold is 0.90", async () => {
const strategy = new MultiSearchReplaceDiffStrategy(0.9)
// Near-miss search block with slightly different formatting/whitespace (e.g., spaces instead of tab, missing semicolon)
const diff =
"<<<<<<< SEARCH\n" +
"function calculateTotal(price: number, tax: number) {\n" +
" const subtotal = price\n" +
" return subtotal + tax;\n" +
"}\n" +
"=======\n" +
"function calculateTotal(price: number, tax: number) {\n" +
" const subtotal = price;\n" +
" return (subtotal + tax) * 1.1;\n" +
"}\n" +
">>>>>>> REPLACE"

const result = await strategy.applyDiff(originalContent, diff)
expect(result.success).toBe(true)
if (result.success) {
expect(result.content).toContain("(subtotal + tax) * 1.1")
}
})

it("should fail with near-miss match when threshold is set to 1.0", async () => {
const strategy = new MultiSearchReplaceDiffStrategy(1.0)
const diff =
"<<<<<<< SEARCH\n" +
"function calculateTotal(price: number, tax: number) {\n" +
" const subtotal = price\n" +
" return subtotal + tax;\n" +
"}\n" +
"=======\n" +
"function calculateTotal(price: number, tax: number) {\n" +
" const subtotal = price;\n" +
" return (subtotal + tax) * 1.1;\n" +
"}\n" +
">>>>>>> REPLACE"

const result = await strategy.applyDiff(originalContent, diff)
expect(result.success).toBe(false)
expect(result.failParts).toBeDefined()
expect(result.failParts!.length).toBeGreaterThan(0)
const failedPart = result.failParts![0]
expect(failedPart).toHaveProperty("error")
expect((failedPart as { error: string }).error).toContain("No sufficiently similar match found")
})

it("should output enhanced error diagnostics (Levenshtein distance, character counts) when a match fails", async () => {
const strategy = new MultiSearchReplaceDiffStrategy(0.95)
const diff =
"<<<<<<< SEARCH\n" +
"function calculateGrandTotal(initialPrice: number, standardTax: number) {\n" +
" const totalVal = initialPrice\n" +
" return totalVal + standardTax;\n" +
"}\n" +
"=======\n" +
"function calculateTotal(price: number, tax: number) {\n" +
" const subtotal = price;\n" +
" return (subtotal + tax) * 1.1;\n" +
"}\n" +
">>>>>>> REPLACE"

const result = await strategy.applyDiff(originalContent, diff)
expect(result.success).toBe(false)
expect(result.failParts).toBeDefined()
expect(result.failParts!.length).toBeGreaterThan(0)
const failedPart = result.failParts![0]
expect(failedPart).toHaveProperty("error")
const errorMsg = (failedPart as { error: string }).error
expect(errorMsg).toContain("Debug Info:")
expect(errorMsg).toContain("Similarity Score:")
expect(errorMsg).toContain("Required Threshold: 95%")
expect(errorMsg).toContain("Levenshtein Distance:")
expect(errorMsg).toContain("Search Length:")
expect(errorMsg).toContain("Best Match Length:")
})
it("should report no-match diagnostics when search content is completely different and no :start_line: is given", async () => {
const strategy = new MultiSearchReplaceDiffStrategy(0.9)
const diff =
"<<<<<<< SEARCH\n" +
"§§§§§§§§§§§§\n" +
"§§§§§§§§§§§§\n" +
"=======\n" +
"¤¤¤¤¤¤¤¤¤¤¤¤\n" +
"¤¤¤¤¤¤¤¤¤¤¤¤\n" +
">>>>>>> REPLACE"

const result = await strategy.applyDiff(originalContent, diff)
expect(result.success).toBe(false)
expect(result.failParts).toBeDefined()
expect(result.failParts!.length).toBeGreaterThan(0)
const failedPart = result.failParts![0]
expect(failedPart).toHaveProperty("error")
const errorMsg = (failedPart as { error: string }).error
expect(errorMsg).toContain("No sufficiently similar match found")
expect(errorMsg).toContain("Best Match Found:")
expect(errorMsg).toContain("Levenshtein Distance:")
expect(errorMsg).toContain("Search Range: start to end")
})
})
})
Loading
Loading