From 7db4be336953db80bfe489b24d73e0d50d5cccb8 Mon Sep 17 00:00:00 2001 From: Michael Ravits Date: Sun, 12 Jul 2026 21:16:15 +0000 Subject: [PATCH] fix: skip no-op search/replace blocks in Squiggle AI edits (#3621) LLMs sometimes emit blocks where SEARCH and REPLACE are identical (e.g. an unchanged context block alongside real edits). This failed the whole edit with 'Search and replace texts are identical'. Skip such blocks instead; fail only when no block changes anything. Co-Authored-By: Claude Fable 5 --- .../ai/__tests__/searchReplace_test.ts | 38 +++++++++++++++++++ .../ai/src/squiggle/searchReplace.ts | 20 +++++++--- 2 files changed, 53 insertions(+), 5 deletions(-) diff --git a/internal-packages/ai/__tests__/searchReplace_test.ts b/internal-packages/ai/__tests__/searchReplace_test.ts index f35c613542..c0df04b1a6 100644 --- a/internal-packages/ai/__tests__/searchReplace_test.ts +++ b/internal-packages/ai/__tests__/searchReplace_test.ts @@ -18,6 +18,44 @@ Goodbye, world! expect(result.value).toBe("Goodbye, world!"); }); + test("should skip no-op blocks when other blocks make changes", () => { + const originalText = "first line\nsecond line"; + const promptResponse = ` +<<<<<<< SEARCH +first line +======= +first line +>>>>>>> REPLACE + +<<<<<<< SEARCH +second line +======= +changed line +>>>>>>> REPLACE + `; + + const result = processSearchReplaceResponse(originalText, promptResponse); + + expect(result.success).toBe(true); + expect(result.value).toBe("first line\nchanged line"); + }); + + test("should fail when all blocks are no-ops", () => { + const originalText = "Hello, world!"; + const promptResponse = ` +<<<<<<< SEARCH +Hello, world! +======= +Hello, world! +>>>>>>> REPLACE + `; + + const result = processSearchReplaceResponse(originalText, promptResponse); + + expect(result.success).toBe(false); + expect(result.value).toContain("no-ops"); + }); + test("should handle empty prompt response", () => { const originalText = "Hello, world!"; const promptResponse = ""; diff --git a/internal-packages/ai/src/squiggle/searchReplace.ts b/internal-packages/ai/src/squiggle/searchReplace.ts index 822b1dd995..d3f59cc1c5 100644 --- a/internal-packages/ai/src/squiggle/searchReplace.ts +++ b/internal-packages/ai/src/squiggle/searchReplace.ts @@ -50,15 +50,16 @@ function applySearchReplaceBlocks( blocks: Array<{ search: string; replace: string }> ): { success: boolean; value: string } { let updatedText = originalText; + // LLMs sometimes emit blocks where search === replace (e.g. an unchanged + // context block alongside real edits). Those are harmless no-ops — skip + // them instead of failing the whole edit. We only fail if *no* block + // changes anything, so the model gets feedback and retries. + let appliedBlocks = 0; try { for (const block of blocks) { - // Check if search and replace are identical if (block.search === block.replace) { - return { - success: false, - value: `Error: Search and replace texts are identical: \n\`\`\`\n${block.search}\n\`\`\``, - }; + continue; } if (block.search === "") { @@ -81,6 +82,15 @@ function applySearchReplaceBlocks( block.replace + updatedText.slice(match.index + match[0].length); } + appliedBlocks++; + } + + if (appliedBlocks === 0) { + return { + success: false, + value: + "Error: All search/replace blocks were no-ops (SEARCH and REPLACE texts are identical), so the code was not changed. Put the modified text in the REPLACE section.", + }; } return { success: true, value: updatedText };