Skip to content
23 changes: 23 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 @@ -905,6 +905,29 @@ replacement content here
expect(error).toContain("Best Match Length: 0 characters")
})

it("should include a whole-lines tip when no sufficiently similar match is found", 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("Tip: The SEARCH block must contain complete, whole lines")
})

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
Expand Down
2 changes: 1 addition & 1 deletion src/core/diff/strategies/multi-search-replace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -577,7 +577,7 @@

diffResults.push({
success: false,
error: `No sufficiently similar match found${lineRange} (${Math.floor(bestMatchScore * 100)}% similar, needs ${Math.floor(this.fuzzyThreshold * 100)}%)\n\nDebug Info:\n- Similarity Score: ${Math.floor(bestMatchScore * 100)}%\n- Required Threshold: ${Math.floor(this.fuzzyThreshold * 100)}%\n- Search Range: ${startLine ? `starting at line ${startLine}` : "start to end"}\n- Levenshtein Distance: ${levenDist >= 0 ? `${levenDist} characters` : "N/A"}\n- Search Length: ${searchChunk.length} characters\n- Best Match Length: ${bestMatchContent ? bestMatchContent.length : 0} characters\n- Tried both standard and aggressive line number stripping\n- Tip: Use the read_file tool to get the latest content of the file before attempting to use the apply_diff tool again, as the file content may have changed\n\nSearch Content:\n${searchChunk}${bestMatchSection}${originalContentSection}`,
error: `No sufficiently similar match found${lineRange} (${Math.floor(bestMatchScore * 100)}% similar, needs ${Math.floor(this.fuzzyThreshold * 100)}%)\n\nDebug Info:\n- Similarity Score: ${Math.floor(bestMatchScore * 100)}%\n- Required Threshold: ${Math.floor(this.fuzzyThreshold * 100)}%\n- Search Range: ${startLine ? `starting at line ${startLine}` : "start to end"}\n- Levenshtein Distance: ${levenDist >= 0 ? `${levenDist} characters` : "N/A"}\n- Search Length: ${searchChunk.length} characters\n- Best Match Length: ${bestMatchContent ? bestMatchContent.length : 0} characters\n- Tried both standard and aggressive line number stripping\n- Tip: Use the read_file tool to get the latest content of the file before attempting to use the apply_diff tool again, as the file content may have changed\n- Tip: The SEARCH block must contain complete, whole lines — partial-line (substring) matching is not supported. If the target line is very long, copy the entire line into the SEARCH block\n\nSearch Content:\n${searchChunk}${bestMatchSection}${originalContentSection}`,

Check warning on line 580 in src/core/diff/strategies/multi-search-replace.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/diff/strategies/multi-search-replace.ts:580: 6 mutation test gaps; example: Survived ArithmeticOperator mutant (replacement: bestMatchScore / 100). See the job summary for the complete list and resolution guidance.
})
continue
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import type OpenAI from "openai"
import { apply_diff } from "../apply_diff"

// Helper type to access function tools
type FunctionTool = OpenAI.Chat.ChatCompletionTool & { type: "function" }

// Helper to get function definition from tool
const getFunctionDef = (tool: OpenAI.Chat.ChatCompletionTool) => (tool as FunctionTool).function

// OpenAI types the parameter schema as FunctionParameters (Record<string, unknown>).
// Narrow it with runtime checks instead of assertions so the spec tracks schema changes.
function asRecord(value: unknown): Record<string, unknown> {
if (typeof value !== "object" || value === null) {
throw new Error("expected an object value in the apply_diff parameter schema")
}
return value as Record<string, unknown>
}

const getDiffParameterDescription = (): string => {
const parameters = asRecord(getFunctionDef(apply_diff).parameters)
const properties = asRecord(parameters["properties"])
const diff = asRecord(properties["diff"])
const description = diff["description"]
if (typeof description !== "string") {
throw new Error("diff parameter must declare a string description")
}
return description
}

describe("apply_diff diff parameter description", () => {
const diffDescription = getDiffParameterDescription()

it("should require complete, whole lines in the SEARCH block", () => {
expect(diffDescription).toContain("The SEARCH block must contain complete, whole lines")
})

it("should warn that partial-line (substring) matching is not supported", () => {
expect(diffDescription).toContain("partial-line (substring) matching is not supported")
})
})
1 change: 1 addition & 0 deletions src/core/prompts/tools/native-tools/apply_diff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const DIFF_PARAMETER_DESCRIPTION = `A string containing one or more search/repla
CRITICAL:
- The ':start_line:[line_number]' header is strongly recommended for accurate matching. When provided, it must follow the exact syntax ':start_line:[integer]' (for example: ':start_line:220'). Do not write headers with shorthand forms like ':220' or variations like ':start_line=220'.
- Copy the exact lines from the source file for a 100% string match including all whitespace, indentation, and newlines.
- The SEARCH block must contain complete, whole lines — partial-line (substring) matching is not supported. When the target line is very long (e.g. a Markdown paragraph), copy the entire line into the SEARCH block.
- Ensure the separator '-------' is on its own line immediately following ':start_line:[line_number]' with a newline.`

export const apply_diff = {
Expand Down
Loading