Unverified 提交 477015ef authored 作者: Will Chen's avatar Will Chen 提交者: GitHub

Add stringent search_replace tool for local agent (#2367)

## Summary - Add new `search_replace` tool with strict matching requirements: - Requires minimum 3 lines of context for unambiguous matching - Enforces exact-only matching (no fuzzy/lenient fallback) - Rejects ambiguous matches and identical old/new strings - Extend `applySearchReplace` processor with `SearchReplaceOptions` interface - Update system prompt with file editing tool selection guidelines - Add comprehensive unit tests (43 tests) and E2E test ## Test plan - Unit tests: `npm run test -- search_replace` (43 tests passing) - E2E tests: `npm run e2e -- --grep "local-agent"` (19 tests passing) - Verify lint passes: `npm run fmt && npm run lint && npm run ts` 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- devin-review-badge-begin --> --- <a href="https://app.devin.ai/review/dyad-sh/dyad/pull/2367"> <picture> <source media="(prefers-color-scheme: dark)" srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1"> <img src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1" alt="Open with Devin"> </picture> </a> <!-- devin-review-badge-end --> <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Introduces a precise, single-instance `search_replace` tool for local agent edits and wires it across stack. > > - New `search_replace` tool: strict schema (`file_path`, `old_string`, `new_string`), rejects identical/ambiguous matches, streams XML, deploys Supabase functions when applicable; registered in `tool_definitions` > - Processor overhaul: replace Levenshtein with cascading matching (exact → whitespace-tolerant → unicode-normalized), detailed failure diagnostics, marker escaping helper, async fs usage > - Update `edit_file` schema/usage from `description` → `instructions` (tool schema, XML builder, examples) > - Prompt: add file-editing tool selection guidance and post-edit verification > - UI: `DyadSearchReplace` gains `data-testid="dyad-search-replace"` for E2E visibility > - Tests: comprehensive unit tests for processor and tool, DSL runner with pass/fail suites, plus new Playwright E2E and fixtures; snapshots updated > - Chore: remove `fastest-levenshtein` dependency from `package.json`/lockfile > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit dfa9b9e172e89f135fc71cd1f35f7f3c614b9af4. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> <!-- This is an auto-generated description by cubic. --> --- ## Summary by cubic Adds a strict search_replace tool for precise, single-instance file edits by the local agent. Enforces exact matches with ≥3 non-empty lines of context and auto-deploys Supabase functions when server functions are edited. - New search_replace tool: exact-only matching, requires ≥3 non-empty lines of context, rejects ambiguous matches, and disallows identical old/new strings - Processor: implement cascading matching passes; add SearchReplaceOptions (exactMatchOnly, rejectIdentical) and wire into applySearchReplace - Prompt: add clear edit-tool selection guidance, post-edit verification, and write_file fallback - Tests: comprehensive unit tests plus an E2E covering the new tool; snapshots updated - UI: add data-testid to surface search-replace actions and register the tool in definitions - Dependencies: remove fastest-levenshtein - Edit tool: rename description → instructions in the schema and XML; examples and prompt updated <sup>Written for commit dfa9b9e172e89f135fc71cd1f35f7f3c614b9af4. Summary will update on new commits.</sup> <!-- End of auto-generated description by cubic. --> --------- Co-authored-by: 's avatarClaude Opus 4.5 <noreply@anthropic.com>
上级 f4305a37
import type { LocalAgentFixture } from "../../../../testing/fake-llm-server/localAgentTypes";
export const fixture: LocalAgentFixture = {
description: "Use search_replace to make a targeted edit to a file",
turns: [
{
text: "Let me first read the file to see its contents.",
toolCalls: [
{
name: "read_file",
args: {
path: "src/App.tsx",
},
},
],
},
{
text: "Now I'll use search_replace to update the text with proper context.",
toolCalls: [
{
name: "search_replace",
args: {
file_path: "src/App.tsx",
// NOTE: This old_string must exactly match the content of the 'minimal' template's src/App.tsx
// If the minimal template changes, this fixture must be updated accordingly
old_string: `const App = () => <div>Minimal imported app</div>;
export default App;`,
new_string: `const App = () => <div>Updated via search_replace</div>;
export default App;`,
},
},
],
},
{
text: "Done! I've updated the message using search_replace. The edit was applied successfully.",
},
],
};
import { expect } from "@playwright/test";
import { testSkipIfWindows } from "./helpers/test_helper";
/**
* E2E tests for the search_replace agent tool
* Tests targeted file editing with the strict search_replace tool
*/
testSkipIfWindows("local-agent - search_replace edit", async ({ po }) => {
await po.setUpDyadPro({ localAgent: true });
await po.importApp("minimal");
await po.selectLocalAgentMode();
await po.sendPrompt("tc=local-agent/search-replace");
// Verify the search_replace output is shown
await expect(po.page.getByTestId("dyad-search-replace")).toBeVisible();
await po.snapshotMessages();
await po.snapshotAppFiles({
name: "after-search-replace",
files: ["src/App.tsx"],
});
});
......@@ -56,7 +56,7 @@
"type": "function",
"function": {
"name": "edit_file",
"description": "\n## When to Use edit_file\n\nUse the `edit_file` tool ONLY when you are editing an existing file. The edit output will be read by a less intelligent model, which will quickly apply the edit. You should make it clear what the edit is, while also minimizing the unchanged code you write.\n\n**Use only ONE edit_file call per file.** If you need to make multiple changes to the same file, include all edits in sequence within a single call using `// ... existing code ...` comments between them.\n\n## When NOT to Use edit_file\n\nDo NOT use this tool when:\n- You are creating a brand-new file (use file creation tools instead).\n- You are rewriting most of an existing file (in those cases, output the complete file instead).\n\n## Basic Format\n\nWhen writing the edit, you should specify each edit in sequence, with the special comment // ... existing code ... to represent unchanged code in between edited lines.\n\nBasic example:\n```\nedit_file(path=\"file.js\", description=\"change code\", content=\"\"\"\n// ... existing code ...\nFIRST_EDIT\n// ... existing code ...\nSECOND_EDIT\n// ... existing code ...\nTHIRD_EDIT\n// ... existing code ...\n\"\"\")\n```\n\n## General Principles\n\nYou should bias towards repeating as few lines of the original file as possible to convey the change.\n\nNEVER show unmodified code in the edit, unless sufficient context of unchanged lines around the code you're editing is needed to resolve ambiguity.\n\nDO NOT omit spans of pre-existing code without using the // ... existing code ... comment to indicate its absence.\n\n## Example: Basic Edit\n```\nedit_file(path=\"LandingPage.tsx\", description=\"Update title.\", content=\"\"\"\n// ... existing code ...\n\nconst LandingPage = () => {\n // ... existing code ...\n return (\n <div>hello</div>\n );\n};\n\n// ... existing code ...\n\"\"\")\n```\n\n## Example: Deleting Code\n\n**When deleting code, you must provide surrounding context and leave an explicit comment indicating what was removed.**\n```\nedit_file(path=\"utils.ts\", description=\"Remove deprecated helper function\", content=\"\"\"\n// ... existing code ...\n\nexport function currentHelper() {\n return \"active\";\n}\n\n// REMOVED: deprecatedHelper() function\n\nexport function anotherHelper() {\n return \"working\";\n}\n\n// ... existing code ...\n\"\"\")\n```\n",
"description": "\n## When to Use edit_file\n\nUse the `edit_file` tool when you need to modify **a section or function** within an existing file. The edit output will be read by a less intelligent model, which will quickly apply the edit. You should make it clear what the edit is, while also minimizing the unchanged code you write.\n\n**Use only ONE edit_file call per file.** If you need to make multiple changes to the same file, include all edits in sequence within a single call using `// ... existing code ...` comments between them.\n\n## When NOT to Use edit_file\n\nDo NOT use this tool when:\n- You are making a **small, surgical edit** (1-3 lines) like fixing a typo, renaming a variable, updating a single value, or changing an import. Use `search_replace` instead for these precise changes.\n- You are creating a brand-new file (use `write_file` instead).\n- You are rewriting most of an existing file (in those cases, use `write_file` to output the complete file instead).\n\n## Basic Format\n\nWhen writing the edit, you should specify each edit in sequence, with the special comment // ... existing code ... to represent unchanged code in between edited lines.\n\nBasic example:\n```\nedit_file(path=\"file.js\", instructions=\"I am adding error handling to the fetchData function and updating the return type.\", content=\"\"\"\n// ... existing code ...\nFIRST_EDIT\n// ... existing code ...\nSECOND_EDIT\n// ... existing code ...\nTHIRD_EDIT\n// ... existing code ...\n\"\"\")\n```\n\n## General Principles\n\nYou should bias towards repeating as few lines of the original file as possible to convey the change.\n\nNEVER show unmodified code in the edit, unless sufficient context of unchanged lines around the code you're editing is needed to resolve ambiguity.\n\nDO NOT omit spans of pre-existing code without using the // ... existing code ... comment to indicate its absence.\n\n## Example: Basic Edit\n```\nedit_file(path=\"LandingPage.tsx\", instructions=\"I am changing the return statement in LandingPage to render a div with 'hello' instead of the previous content.\", content=\"\"\"\n// ... existing code ...\n\nconst LandingPage = () => {\n // ... existing code ...\n return (\n <div>hello</div>\n );\n};\n\n// ... existing code ...\n\"\"\")\n```\n\n## Example: Deleting Code\n\n**When deleting code, you must provide surrounding context and leave an explicit comment indicating what was removed.**\n```\nedit_file(path=\"utils.ts\", instructions=\"I am removing the deprecatedHelper function located between currentHelper and anotherHelper.\", content=\"\"\"\n// ... existing code ...\n\nexport function currentHelper() {\n return \"active\";\n}\n\n// REMOVED: deprecatedHelper() function\n\nexport function anotherHelper() {\n return \"working\";\n}\n\n// ... existing code ...\n\"\"\")\n```\n",
"parameters": {
"type": "object",
"properties": {
......@@ -68,9 +68,9 @@
"type": "string",
"description": "The updated code snippet to apply"
},
"description": {
"instructions": {
"type": "string",
"description": "Brief description of the edit"
"description": "Instructions for the edit. A single sentence describing what you are going to do for the sketched edit. This helps the less intelligent model apply the edit correctly. Use first person to describe what you are doing. Don't repeat what you've said in previous messages. Use it to disambiguate any uncertainty in the edit."
}
},
"required": [
......@@ -82,6 +82,37 @@
}
}
},
{
"type": "function",
"function": {
"name": "search_replace",
"description": "Use this tool to propose a search and replace operation on an existing file.\n\nThe tool will replace ONE occurrence of old_string with new_string in the specified file.\n\nCRITICAL REQUIREMENTS FOR USING THIS TOOL:\n\n1. UNIQUENESS: The old_string MUST uniquely identify the specific instance you want to change. This means:\n - Include AT LEAST 3-5 lines of context BEFORE the change point\n - Include AT LEAST 3-5 lines of context AFTER the change point\n - Include all whitespace, indentation, and surrounding code exactly as it appears in the file\n\n2. SINGLE INSTANCE: This tool can only change ONE instance at a time. If you need to change multiple instances:\n - Make separate calls to this tool for each instance\n - Each call must uniquely identify its specific instance using extensive context\n\n3. VERIFICATION: Before using this tool:\n - If multiple instances exist, gather enough context to uniquely identify each one\n - Plan separate tool calls for each instance\n",
"parameters": {
"type": "object",
"properties": {
"file_path": {
"type": "string",
"description": "The path to the file you want to search and replace in."
},
"old_string": {
"type": "string",
"description": "The text to replace (must be unique within the file, and must match the file contents exactly, including all whitespace and indentation)"
},
"new_string": {
"type": "string",
"description": "The edited text to replace the old_string (must be different from the old_string)"
}
},
"required": [
"file_path",
"old_string",
"new_string"
],
"additionalProperties": false,
"$schema": "http://json-schema.org/draft-07/schema#"
}
}
},
{
"type": "function",
"function": {
......
......@@ -54,6 +54,23 @@ You have tools at your disposal to solve the coding task. Follow these rules reg
- **Handle errors gracefully**: If a tool fails, explain the issue and suggest alternatives
</tool_calling_best_practices>
<file_editing_tool_selection>
You have three tools for editing files. Choose based on the scope of your change:
| Scope | Tool | Examples |
|-------|------|----------|
| **Small** (a few lines) | `search_replace` or `edit_file` | Fix a typo, rename a variable, update a value, change an import |
| **Medium** (one function or section) | `edit_file` | Rewrite a function, add a new component, modify multiple related lines |
| **Large** (most of the file) | `write_file` | Major refactor, rewrite a module, create a new file |
**Tips:**
- `edit_file` supports `// ... existing code ...` markers to skip unchanged sections
- When in doubt, prefer `search_replace` for precision or `write_file` for simplicity
**Post-edit verification (REQUIRED):**
After every edit, read the file to verify changes applied correctly. If something went wrong, try a different tool and verify again.
</file_editing_tool_selection>
<development_workflow>
1. **Understand:** Think about the user's request and the relevant codebase context. Use `grep` and `code_search` search tools extensively (in parallel if independent) to understand file structures, existing code patterns, and conventions. Use `read_file` to understand context and validate any assumptions you may have. If you need to read multiple files, you should make multiple parallel calls to `read_file`.
2. **Plan:** Build a coherent and grounded (based on the understanding in step 1) plan for how you intend to resolve the user's task. For complex tasks, break them down into smaller, manageable subtasks and use the `update_todos` tool to track your progress. Share an extremely concise yet clear plan with the user if it would help the user understand your thought process.
......
......@@ -9,6 +9,8 @@
- button:
- img
- img
- text: Approved
- img
- text: claude-opus-4-5
- img
- text: less than a minute ago
......@@ -22,7 +24,7 @@
- img
- text: Turbo Edit App.tsx
- img
- text: "src/App.tsx Summary: Update welcome message"
- text: src/App.tsx
- paragraph: Done! I've updated the title from 'Minimal imported app' to 'UPDATED imported app'. The change has been applied successfully.
- button:
- img
......
=== src/App.tsx ===
const App = () => <div>Updated via search_replace</div>;
export default App;
- paragraph: /Generate an AI_RULES\.md file for this app\. Describe the tech stack in 5-\d+ bullet points and describe clear rules about what libraries to use for what\./
- img
- text: file1.txt
- button "Edit":
- img
- img
- text: file1.txt
- paragraph: More EOM
- button:
- img
- img
- text: Approved
- img
- text: claude-opus-4-5
- img
- text: less than a minute ago
- button "Request ID":
- img
- paragraph: tc=local-agent/search-replace
- paragraph: Let me first read the file to see its contents.
- img
- text: App.tsx Read src/App.tsx
- paragraph: Now I'll use search_replace to update the text with proper context.
- img
- text: Search & Replace App.tsx
- img
- text: src/App.tsx
- paragraph: Done! I've updated the message using search_replace. The edit was applied successfully.
- button:
- img
- img
- text: claude-opus-4-5
- img
- text: less than a minute ago
- button "Request ID":
- img
- button "Undo":
- img
- button "Retry":
- img
\ No newline at end of file
module.exports = {
"**/*.{ts,tsx}": () => "npm run ts",
"**/*.{js,mjs,cjs,jsx,ts,mts,cts,tsx,vue,astro,svelte}": "oxlint",
"*.{js,css,md,ts,tsx,jsx,json,yml,yaml,txt}": "oxfmt",
"*.{js,css,md,ts,tsx,jsx,json,yml,yaml}": "oxfmt",
};
......@@ -65,7 +65,6 @@
"electron-playwright-helpers": "^2.1.0",
"electron-squirrel-startup": "^1.0.1",
"esbuild-register": "^3.6.0",
"fastest-levenshtein": "^1.0.16",
"fix-path": "^4.0.0",
"framer-motion": "^12.6.3",
"geist": "^1.3.1",
......@@ -12719,15 +12718,6 @@
],
"license": "BSD-3-Clause"
},
"node_modules/fastest-levenshtein": {
"version": "1.0.16",
"resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz",
"integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==",
"license": "MIT",
"engines": {
"node": ">= 4.9.1"
}
},
"node_modules/fastq": {
"version": "1.19.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz",
......
......@@ -100,7 +100,6 @@
"electron-playwright-helpers": "^2.1.0",
"electron-squirrel-startup": "^1.0.1",
"esbuild-register": "^3.6.0",
"fastest-levenshtein": "^1.0.16",
"fix-path": "^4.0.0",
"framer-motion": "^12.6.3",
"geist": "^1.3.1",
......
......@@ -43,6 +43,7 @@ export const DyadSearchReplace: React.FC<DyadSearchReplaceProps> = ({
return (
<div
data-testid="dyad-search-replace"
className={`bg-(--background-lightest) hover:bg-(--background-lighter) rounded-lg px-4 py-2 border my-2 cursor-pointer ${
inProgress
? "border-amber-500"
......
......@@ -20,6 +20,7 @@ import { setChatSummaryTool } from "./tools/set_chat_summary";
import { addIntegrationTool } from "./tools/add_integration";
import { readLogsTool } from "./tools/read_logs";
import { editFileTool } from "./tools/edit_file";
import { searchReplaceTool } from "./tools/search_replace";
import { webSearchTool } from "./tools/web_search";
import { webCrawlTool } from "./tools/web_crawl";
import { updateTodosTool } from "./tools/update_todos";
......@@ -40,12 +41,11 @@ import { getSupabaseClientCode } from "@/supabase_admin/supabase_context";
export const TOOL_DEFINITIONS: readonly ToolDefinition[] = [
writeFileTool,
editFileTool,
searchReplaceTool,
deleteFileTool,
renameFileTool,
addDependencyTool,
executeSqlTool,
// Do not enable search-replace tool for now due to concerns around reliability
// searchReplaceTool,
readFileTool,
listFilesTool,
grepTool,
......
......@@ -17,7 +17,12 @@ const logger = log.scope("edit_file");
const editFileSchema = z.object({
path: z.string().describe("The file path relative to the app root"),
content: z.string().describe("The updated code snippet to apply"),
description: z.string().optional().describe("Brief description of the edit"),
instructions: z
.string()
.optional()
.describe(
"Instructions for the edit. A single sentence describing what you are going to do for the sketched edit. This helps the less intelligent model apply the edit correctly. Use first person to describe what you are doing. Don't repeat what you've said in previous messages. Use it to disambiguate any uncertainty in the edit.",
),
});
const turboFileEditResponseSchema = z.object({
......@@ -29,7 +34,7 @@ async function callTurboFileEdit(
path: string;
content: string;
originalContent: string;
description?: string;
instructions?: string;
},
ctx: AgentContext,
): Promise<string> {
......@@ -39,7 +44,7 @@ async function callTurboFileEdit(
path: params.path,
content: params.content,
originalContent: params.originalContent,
description: params.description ?? "",
instructions: params.instructions ?? "",
}),
});
......@@ -57,15 +62,16 @@ async function callTurboFileEdit(
const DESCRIPTION = `
## When to Use edit_file
Use the \`edit_file\` tool ONLY when you are editing an existing file. The edit output will be read by a less intelligent model, which will quickly apply the edit. You should make it clear what the edit is, while also minimizing the unchanged code you write.
Use the \`edit_file\` tool when you need to modify **a section or function** within an existing file. The edit output will be read by a less intelligent model, which will quickly apply the edit. You should make it clear what the edit is, while also minimizing the unchanged code you write.
**Use only ONE edit_file call per file.** If you need to make multiple changes to the same file, include all edits in sequence within a single call using \`// ... existing code ...\` comments between them.
## When NOT to Use edit_file
Do NOT use this tool when:
- You are creating a brand-new file (use file creation tools instead).
- You are rewriting most of an existing file (in those cases, output the complete file instead).
- You are making a **small, surgical edit** (1-3 lines) like fixing a typo, renaming a variable, updating a single value, or changing an import. Use \`search_replace\` instead for these precise changes.
- You are creating a brand-new file (use \`write_file\` instead).
- You are rewriting most of an existing file (in those cases, use \`write_file\` to output the complete file instead).
## Basic Format
......@@ -73,7 +79,7 @@ When writing the edit, you should specify each edit in sequence, with the specia
Basic example:
\`\`\`
edit_file(path="file.js", description="change code", content="""
edit_file(path="file.js", instructions="I am adding error handling to the fetchData function and updating the return type.", content="""
// ... existing code ...
FIRST_EDIT
// ... existing code ...
......@@ -94,7 +100,7 @@ DO NOT omit spans of pre-existing code without using the // ... existing code ..
## Example: Basic Edit
\`\`\`
edit_file(path="LandingPage.tsx", description="Update title.", content="""
edit_file(path="LandingPage.tsx", instructions="I am changing the return statement in LandingPage to render a div with 'hello' instead of the previous content.", content="""
// ... existing code ...
const LandingPage = () => {
......@@ -112,7 +118,7 @@ const LandingPage = () => {
**When deleting code, you must provide surrounding context and leave an explicit comment indicating what was removed.**
\`\`\`
edit_file(path="utils.ts", description="Remove deprecated helper function", content="""
edit_file(path="utils.ts", instructions="I am removing the deprecatedHelper function located between currentHelper and anotherHelper.", content="""
// ... existing code ...
export function currentHelper() {
......@@ -141,7 +147,7 @@ export const editFileTool: ToolDefinition<z.infer<typeof editFileSchema>> = {
buildXml: (args, isComplete) => {
if (!args.path) return undefined;
let xml = `<dyad-edit path="${escapeXmlAttr(args.path)}" description="${escapeXmlAttr(args.description ?? "")}">\n${args.content ?? ""}`;
let xml = `<dyad-edit path="${escapeXmlAttr(args.path)}" description="${escapeXmlAttr(args.instructions ?? "")}">\n${args.content ?? ""}`;
if (isComplete) {
xml += "\n</dyad-edit>";
}
......@@ -169,7 +175,7 @@ export const editFileTool: ToolDefinition<z.infer<typeof editFileSchema>> = {
path: args.path,
content: args.content,
originalContent,
description: args.description,
instructions: args.instructions,
},
ctx,
);
......
......@@ -2,57 +2,83 @@ import fs from "node:fs";
import path from "node:path";
import { z } from "zod";
import log from "electron-log";
import { ToolDefinition, AgentContext, escapeXmlAttr } from "./types";
import {
ToolDefinition,
AgentContext,
escapeXmlAttr,
escapeXmlContent,
} from "./types";
import { safeJoin } from "@/ipc/utils/path_utils";
import { deploySupabaseFunction } from "../../../../../../supabase_admin/supabase_management_client";
import { applySearchReplace } from "@/pro/main/ipc/processors/search_replace_processor";
import { escapeSearchReplaceMarkers } from "@/pro/shared/search_replace_markers";
import { deploySupabaseFunction } from "@/supabase_admin/supabase_management_client";
import {
isServerFunction,
isSharedServerModule,
} from "../../../../../../supabase_admin/supabase_utils";
import { applySearchReplace } from "../../../../../../pro/main/ipc/processors/search_replace_processor";
} from "@/supabase_admin/supabase_utils";
const readFile = fs.promises.readFile;
const logger = log.scope("search_replace");
const searchReplaceSchema = z.object({
path: z.string().describe("The file path to edit"),
search: z
file_path: z
.string()
.describe("The path to the file you want to search and replace in."),
old_string: z
.string()
.describe(
"Content to search for in the file. This should match the existing code that will be replaced",
"The text to replace (must be unique within the file, and must match the file contents exactly, including all whitespace and indentation)",
),
replace: z
new_string: z
.string()
.describe("New content to replace the search content with"),
description: z
.string()
.optional()
.describe("Brief description of the changes"),
.describe(
"The edited text to replace the old_string (must be different from the old_string)",
),
});
export const searchReplaceTool: ToolDefinition<
z.infer<typeof searchReplaceSchema>
> = {
name: "search_replace",
description:
"Apply targeted search/replace edits to a file. This is the preferred tool for editing a file.",
description: `Use this tool to propose a search and replace operation on an existing file.
The tool will replace ONE occurrence of old_string with new_string in the specified file.
CRITICAL REQUIREMENTS FOR USING THIS TOOL:
1. UNIQUENESS: The old_string MUST uniquely identify the specific instance you want to change. This means:
- Include AT LEAST 3-5 lines of context BEFORE the change point
- Include AT LEAST 3-5 lines of context AFTER the change point
- Include all whitespace, indentation, and surrounding code exactly as it appears in the file
2. SINGLE INSTANCE: This tool can only change ONE instance at a time. If you need to change multiple instances:
- Make separate calls to this tool for each instance
- Each call must uniquely identify its specific instance using extensive context
3. VERIFICATION: Before using this tool:
- If multiple instances exist, gather enough context to uniquely identify each one
- Plan separate tool calls for each instance
`,
inputSchema: searchReplaceSchema,
defaultConsent: "always",
modifiesState: true,
getConsentPreview: (args) => `Edit ${args.path}`,
getConsentPreview: (args) => `Edit ${args.file_path}`,
buildXml: (args, isComplete) => {
if (!args.path) return undefined;
if (!args.file_path) return undefined;
let xml = `<dyad-search-replace path="${escapeXmlAttr(args.path)}" description="${escapeXmlAttr(args.description ?? "")}">\n<<<<<<< SEARCH\n${args.search ?? ""}`;
const escapedOld = escapeSearchReplaceMarkers(args.old_string ?? "");
// Add separator and replace content if replace has started
if (args.replace !== undefined) {
xml += `\n=======\n${args.replace}`;
let xml = `<dyad-search-replace path="${escapeXmlAttr(args.file_path)}" description="">\n<<<<<<< SEARCH\n${escapeXmlContent(escapedOld)}`;
// Add separator and replace content if new_string has started
if (args.new_string !== undefined) {
const escapedNew = escapeSearchReplaceMarkers(args.new_string);
xml += `\n=======\n${escapeXmlContent(escapedNew)}`;
}
if (isComplete) {
if (args.replace == undefined) {
if (args.new_string === undefined) {
xml += "\n=======\n";
}
xml += "\n>>>>>>> REPLACE\n</dyad-search-replace>";
......@@ -62,20 +88,29 @@ export const searchReplaceTool: ToolDefinition<
},
execute: async (args, ctx: AgentContext) => {
const fullFilePath = safeJoin(ctx.appPath, args.path);
// Validate old_string !== new_string
if (args.old_string === args.new_string) {
throw new Error("old_string and new_string must be different");
}
const fullFilePath = safeJoin(ctx.appPath, args.file_path);
// Track if this is a shared module
if (isSharedServerModule(args.path)) {
if (isSharedServerModule(args.file_path)) {
ctx.isSharedModulesChanged = true;
}
if (!fs.existsSync(fullFilePath)) {
throw new Error(`File does not exist: ${args.path}`);
throw new Error(`File does not exist: ${args.file_path}`);
}
const original = await readFile(fullFilePath, "utf8");
const original = await fs.promises.readFile(fullFilePath, "utf8");
// Construct the operations string in the expected format
const operations = `<<<<<<< SEARCH\n${args.search}\n=======\n${args.replace}\n>>>>>>> REPLACE`;
const escapedOld = escapeSearchReplaceMarkers(args.old_string);
const escapedNew = escapeSearchReplaceMarkers(args.new_string);
const operations = `<<<<<<< SEARCH\n${escapedOld}\n=======\n${escapedNew}\n>>>>>>> REPLACE`;
const result = applySearchReplace(original, operations);
if (!result.success || typeof result.content !== "string") {
......@@ -84,19 +119,19 @@ export const searchReplaceTool: ToolDefinition<
);
}
fs.writeFileSync(fullFilePath, result.content);
await fs.promises.writeFile(fullFilePath, result.content);
logger.log(`Successfully applied search-replace to: ${fullFilePath}`);
// Deploy Supabase function if applicable
if (
ctx.supabaseProjectId &&
isServerFunction(args.path) &&
isServerFunction(args.file_path) &&
!ctx.isSharedModulesChanged
) {
try {
await deploySupabaseFunction({
supabaseProjectId: ctx.supabaseProjectId,
functionName: path.basename(path.dirname(args.path)),
functionName: path.basename(path.dirname(args.file_path)),
appPath: ctx.appPath,
organizationSlug: ctx.supabaseOrganizationSlug ?? null,
});
......@@ -105,6 +140,6 @@ export const searchReplaceTool: ToolDefinition<
}
}
return `Successfully applied edits to ${args.path}`;
return `Successfully applied edits to ${args.file_path}`;
},
};
import { describe, it, expect } from "vitest";
import { readFileSync } from "fs";
import { join } from "path";
import { applySearchReplace } from "@/pro/main/ipc/processors/search_replace_processor";
import {
parseDslTestCases,
isPassingTestCase,
isFailingTestCase,
} from "@/pro/main/ipc/processors/search_replace_dsl_test_runner";
// Load test case files
const passesContent = readFileSync(
join(__dirname, "search_replace_passes.txt"),
"utf-8",
);
const failsContent = readFileSync(
join(__dirname, "search_replace_fails.txt"),
"utf-8",
);
const passingTestCases =
parseDslTestCases(passesContent).filter(isPassingTestCase);
const failingTestCases =
parseDslTestCases(failsContent).filter(isFailingTestCase);
describe("search_replace_processor - DSL passing tests", () => {
it.each(passingTestCases.map((tc) => [tc.name, tc]))(
"%s",
(_name, testCase) => {
if (!isPassingTestCase(testCase)) {
throw new Error("Expected passing test case");
}
const { success, content, error } = applySearchReplace(
testCase.original,
testCase.diff,
);
expect(success).toBe(true);
if (error) {
throw new Error(`Unexpected error: ${error}`);
}
expect(content).toBe(testCase.expectedOutput);
},
);
});
describe("search_replace_processor - DSL failing tests", () => {
it.each(failingTestCases.map((tc) => [tc.name, tc]))(
"%s",
(_name, testCase) => {
if (!isFailingTestCase(testCase)) {
throw new Error("Expected failing test case");
}
const { success, error } = applySearchReplace(
testCase.original,
testCase.diff,
);
expect(success).toBe(false);
expect(error).toBeDefined();
expect(error).toMatch(testCase.errorPattern);
},
);
});
/**
* DSL Test Runner for Search/Replace Processor
*
* Parses test cases from .txt files with the following format:
*
* --- Test case name: descriptive name ---
* <original_file>
* ... original content ...
* </original_file>
* <<<<<<< SEARCH
* ... search content ...
* =======
* ... replace content ...
* >>>>>>> REPLACE
* <output_file>
* ... expected output ...
* </output_file>
*
* For failing tests, use <error_pattern> instead of <output_file>:
* <error_pattern>
* regex pattern to match error message
* </error_pattern>
*/
export interface PassingTestCase {
name: string;
original: string;
diff: string;
expectedOutput: string;
}
export interface FailingTestCase {
name: string;
original: string;
diff: string;
errorPattern: RegExp;
}
export type TestCase = PassingTestCase | FailingTestCase;
export function isPassingTestCase(tc: TestCase): tc is PassingTestCase {
return "expectedOutput" in tc;
}
export function isFailingTestCase(tc: TestCase): tc is FailingTestCase {
return "errorPattern" in tc;
}
/**
* Parse a DSL test file into test cases.
* Handles both passing tests (with <output_file>) and failing tests (with <error_pattern>).
*/
export function parseDslTestCases(content: string): TestCase[] {
const testCases: TestCase[] = [];
// Split by test case delimiter
const testCasePattern = /---\s*Test case name:\s*(.+?)\s*---/g;
const matches = [...content.matchAll(testCasePattern)];
if (matches.length === 0) {
return testCases;
}
for (let i = 0; i < matches.length; i++) {
const match = matches[i];
const testName = match[1].trim();
const startIndex = match.index! + match[0].length;
const endIndex =
i + 1 < matches.length ? matches[i + 1].index! : content.length;
const testContent = content.slice(startIndex, endIndex);
const testCase = parseTestCase(testName, testContent);
if (testCase) {
testCases.push(testCase);
}
}
return testCases;
}
function parseTestCase(name: string, content: string): TestCase | null {
// Extract original file content
const originalMatch = content.match(
/<original_file>\r?\n([\s\S]*?)<\/original_file>/,
);
if (!originalMatch) {
console.warn(`Test case "${name}": missing <original_file> tag`);
return null;
}
const original = originalMatch[1];
// Extract diff content (everything between </original_file> and <output_file> or <error_pattern>)
const afterOriginal = content.slice(
content.indexOf("</original_file>") + "</original_file>".length,
);
// Find the diff block - it should contain the search/replace markers
let diff: string;
const outputFileIndex = afterOriginal.indexOf("<output_file>");
const errorPatternIndex = afterOriginal.indexOf("<error_pattern>");
if (outputFileIndex !== -1 && errorPatternIndex !== -1) {
// Both present - use whichever comes first
diff = afterOriginal.slice(0, Math.min(outputFileIndex, errorPatternIndex));
} else if (outputFileIndex !== -1) {
diff = afterOriginal.slice(0, outputFileIndex);
} else if (errorPatternIndex !== -1) {
diff = afterOriginal.slice(0, errorPatternIndex);
} else {
console.warn(
`Test case "${name}": missing <output_file> or <error_pattern> tag`,
);
return null;
}
// Trim leading/trailing whitespace but preserve internal structure
diff = diff.trim();
// Check for output_file (passing test)
const outputMatch = content.match(
/<output_file>\r?\n([\s\S]*?)<\/output_file>/,
);
if (outputMatch) {
return {
name,
original,
diff,
expectedOutput: outputMatch[1],
};
}
// Check for error_pattern (failing test)
const errorMatch = content.match(
/<error_pattern>\r?\n([\s\S]*?)<\/error_pattern>/,
);
if (errorMatch) {
const patternStr = errorMatch[1].trim();
try {
return {
name,
original,
diff,
errorPattern: new RegExp(patternStr, "i"),
};
} catch {
console.warn(
`Test case "${name}": invalid regex pattern "${patternStr}"`,
);
return null;
}
}
console.warn(
`Test case "${name}": missing <output_file> or <error_pattern> tag`,
);
return null;
}
--- Test case name: empty search block not allowed ---
<original_file>
a
b
</original_file>
<<<<<<< SEARCH
=======
REPLACEMENT
>>>>>>> REPLACE
<error_pattern>
empty SEARCH block is not allowed
</error_pattern>
--- Test case name: search block does not match any content ---
<original_file>
foo
bar
baz
</original_file>
<<<<<<< SEARCH
NOT IN FILE
=======
STILL NOT
>>>>>>> REPLACE
<error_pattern>
Search block did not match any content
</error_pattern>
--- Test case name: ambiguous match multiple locations ---
<original_file>
foo
bar
baz
bar
qux
</original_file>
<<<<<<< SEARCH
bar
=======
BAR
>>>>>>> REPLACE
<error_pattern>
ambiguous|multiple
</error_pattern>
--- Test case name: ambiguous match with whitespace normalization ---
<original_file>
if (ready) {
start();
}
if (ready) {
start();
}
</original_file>
<<<<<<< SEARCH
if (ready) {
start();
}
=======
if (ready) {
launch();
}
>>>>>>> REPLACE
<error_pattern>
ambiguous
</error_pattern>
--- Test case name: invalid diff format missing markers ---
<original_file>
some content
</original_file>
this is not a valid diff format
<error_pattern>
Invalid diff format.*missing required sections
</error_pattern>
--- Test case name: case mismatch is case sensitive ---
<original_file>
Hello World
goodbye
</original_file>
<<<<<<< SEARCH
hello world
=======
hi world
>>>>>>> REPLACE
<error_pattern>
Search block did not match any content
</error_pattern>
--- Test case name: partial line does not match ---
<original_file>
the quick brown fox jumps
over the lazy dog
</original_file>
<<<<<<< SEARCH
brown fox
=======
red fox
>>>>>>> REPLACE
<error_pattern>
Search block did not match any content
</error_pattern>
--- Test case name: search with extra context that does not exist ---
<original_file>
line one
line two
line three
</original_file>
<<<<<<< SEARCH
line zero
line one
line two
=======
replaced
>>>>>>> REPLACE
<error_pattern>
Search block did not match any content
</error_pattern>
--- Test case name: three or more identical matches ---
<original_file>
item
---
item
---
item
</original_file>
<<<<<<< SEARCH
item
=======
ITEM
>>>>>>> REPLACE
<error_pattern>
ambiguous|multiple
</error_pattern>
--- Test case name: only search marker no replace ---
<original_file>
content here
</original_file>
<<<<<<< SEARCH
content here
<error_pattern>
Invalid diff format.*missing required sections
</error_pattern>
--- Test case name: only separator no markers ---
<original_file>
content here
</original_file>
=======
replacement
<error_pattern>
Invalid diff format.*missing required sections
</error_pattern>
--- Test case name: search content is just empty lines ---
<original_file>
line one
line three
</original_file>
<<<<<<< SEARCH
=======
replaced
>>>>>>> REPLACE
<error_pattern>
empty SEARCH block is not allowed
</error_pattern>
--- Test case name: multiline search partial match fails ---
<original_file>
alpha
beta
gamma
delta
</original_file>
<<<<<<< SEARCH
beta
gamma
epsilon
=======
replaced
>>>>>>> REPLACE
<error_pattern>
Search block did not match any content
</error_pattern>
--- Test case name: search matches but wrong line count ---
<original_file>
function foo() {
return 1;
}
</original_file>
<<<<<<< SEARCH
function foo() {
return 1;
}
extra line that does not exist
=======
replaced
>>>>>>> REPLACE
<error_pattern>
Search block did not match any content
</error_pattern>
--- Test case name: empty original file cannot match search ---
<original_file>
</original_file>
<<<<<<< SEARCH
some content
=======
replacement
>>>>>>> REPLACE
<error_pattern>
Search block did not match any content
</error_pattern>
......@@ -2,32 +2,8 @@ import { describe, it, expect } from "vitest";
import { applySearchReplace } from "./search_replace_processor";
describe("applySearchReplace", () => {
describe("fuzzy matching with Levenshtein distance", () => {
it("should match content with minor typos", () => {
const originalContent = `function hello() {
console.log("Hello, World!");
return true;
}`;
// Search block has a typo: "consle" instead of "console"
const diffContent = `<<<<<<< SEARCH
function hello() {
consle.log("Hello, World!");
return true;
}
=======
function hello() {
console.log("Hello, Universe!");
return true;
}
>>>>>>> REPLACE`;
const result = applySearchReplace(originalContent, diffContent);
expect(result.success).toBe(true);
expect(result.content).toContain("Hello, Universe!");
});
it("should match content with smart quotes normalized", () => {
describe("cascading fuzzy matching", () => {
it("should match content with smart quotes normalized (Pass 4)", () => {
const originalContent = `function greet() {
console.log("Hello");
}`;
......@@ -48,16 +24,16 @@ function greet() {
expect(result.content).toContain("Goodbye");
});
it("should fail when similarity is below threshold", () => {
it("should fail when content does not match in any pass", () => {
const originalContent = `function hello() {
console.log("Hello, World!");
return true;
}`;
// Search block is too different (multiple typos and changes)
// Search block is completely different
const diffContent = `<<<<<<< SEARCH
function goodbye() {
consle.error("Bye, Earth!");
console.error("Bye, Earth!");
return false;
}
=======
......@@ -69,19 +45,19 @@ function hello() {
const result = applySearchReplace(originalContent, diffContent);
expect(result.success).toBe(false);
expect(result.error).toContain("Best fuzzy match had similarity");
expect(result.error).toContain("did not match any content");
});
it("should prefer exact match over fuzzy match", () => {
it("should prefer exact match when available", () => {
const originalContent = `function hello() {
console.log("Hello");
}
function hello() {
consle.log("Hello");
console.log("Hello");
}`;
// Should match the first exact occurrence, not the fuzzy one
// Both occurrences are exact matches - should be ambiguous
const diffContent = `<<<<<<< SEARCH
function hello() {
console.log("Hello");
......@@ -93,13 +69,11 @@ function hello() {
>>>>>>> REPLACE`;
const result = applySearchReplace(originalContent, diffContent);
expect(result.success).toBe(true);
// Should only replace the first exact match
expect(result.content).toContain('console.log("Goodbye")');
expect(result.content).toContain('consle.log("Hello")');
expect(result.success).toBe(false);
expect(result.error).toContain("ambiguous");
});
it("should handle whitespace differences with lenient matching before fuzzy", () => {
it("should handle whitespace differences with edge whitespace normalization (Pass 3)", () => {
const originalContent = `function test() {
console.log("test");
}`;
......
/**
* Escapes marker-like lines inside SEARCH/REPLACE content so that
* `parseSearchReplaceBlocks` doesn't treat them as block separators.
*
* The parser treats lines that start with:
* - `<<<<<<<` (SEARCH/open marker)
* - `=======` (separator)
* - `>>>>>>>` (REPLACE/close marker)
*
* as structural markers unless they are prefixed with `\`.
*
* The corresponding unescape step lives in the processor (`unescapeMarkers`).
*/
export function escapeSearchReplaceMarkers(content: string | null): string {
if (!content) return "";
return content.replace(
/^(\\)?(<<<<<<<|=======|>>>>>>>)/gm,
(full, maybeSlash: string | undefined, marker: string) =>
maybeSlash ? full : `\\${marker}`,
);
}
......@@ -104,6 +104,23 @@ You have tools at your disposal to solve the coding task. Follow these rules reg
- **Handle errors gracefully**: If a tool fails, explain the issue and suggest alternatives
</tool_calling_best_practices>
<file_editing_tool_selection>
You have three tools for editing files. Choose based on the scope of your change:
| Scope | Tool | Examples |
|-------|------|----------|
| **Small** (a few lines) | \`search_replace\` or \`edit_file\` | Fix a typo, rename a variable, update a value, change an import |
| **Medium** (one function or section) | \`edit_file\` | Rewrite a function, add a new component, modify multiple related lines |
| **Large** (most of the file) | \`write_file\` | Major refactor, rewrite a module, create a new file |
**Tips:**
- \`edit_file\` supports \`// ... existing code ...\` markers to skip unchanged sections
- When in doubt, prefer \`search_replace\` for precision or \`write_file\` for simplicity
**Post-edit verification (REQUIRED):**
After every edit, read the file to verify changes applied correctly. If something went wrong, try a different tool and verify again.
</file_editing_tool_selection>
<development_workflow>
1. **Understand:** Think about the user's request and the relevant codebase context. Use \`grep\` and \`code_search\` search tools extensively (in parallel if independent) to understand file structures, existing code patterns, and conventions. Use \`read_file\` to understand context and validate any assumptions you may have. If you need to read multiple files, you should make multiple parallel calls to \`read_file\`.
2. **Plan:** Build a coherent and grounded (based on the understanding in step 1) plan for how you intend to resolve the user's task. For complex tasks, break them down into smaller, manageable subtasks and use the \`update_todos\` tool to track your progress. Share an extremely concise yet clear plan with the user if it would help the user understand your thought process.
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论