diff --git a/src/core/diff/strategies/__tests__/multi-search-replace.spec.ts b/src/core/diff/strategies/__tests__/multi-search-replace.spec.ts index 297e64fa83..07ffc419a4 100644 --- a/src/core/diff/strategies/__tests__/multi-search-replace.spec.ts +++ b/src/core/diff/strategies/__tests__/multi-search-replace.spec.ts @@ -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 diff --git a/src/core/diff/strategies/multi-search-replace.ts b/src/core/diff/strategies/multi-search-replace.ts index a8ba0215d3..c8af32c70a 100644 --- a/src/core/diff/strategies/multi-search-replace.ts +++ b/src/core/diff/strategies/multi-search-replace.ts @@ -577,7 +577,7 @@ export class MultiSearchReplaceDiffStrategy implements DiffStrategy { 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}`, }) continue } diff --git a/src/core/prompts/tools/native-tools/__tests__/apply_diff.spec.ts b/src/core/prompts/tools/native-tools/__tests__/apply_diff.spec.ts new file mode 100644 index 0000000000..5df5488242 --- /dev/null +++ b/src/core/prompts/tools/native-tools/__tests__/apply_diff.spec.ts @@ -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). +// Narrow it with runtime checks instead of assertions so the spec tracks schema changes. +function asRecord(value: unknown): Record { + if (typeof value !== "object" || value === null) { + throw new Error("expected an object value in the apply_diff parameter schema") + } + return value as Record +} + +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") + }) +}) diff --git a/src/core/prompts/tools/native-tools/apply_diff.ts b/src/core/prompts/tools/native-tools/apply_diff.ts index cdbd99df33..720dddef3a 100644 --- a/src/core/prompts/tools/native-tools/apply_diff.ts +++ b/src/core/prompts/tools/native-tools/apply_diff.ts @@ -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 = {