diff --git a/docs/changelogs/0.7.x.md b/docs/changelogs/0.7.x.md index 180d997..380a53a 100644 --- a/docs/changelogs/0.7.x.md +++ b/docs/changelogs/0.7.x.md @@ -5,6 +5,13 @@ All notable changes in the **0.7.x** release series are documented here. ## [Unreleased] ### Added +- An `edit` tool that replaces an exact stretch of an existing text file, + so changing a few lines no longer means rewriting the whole file. The + match is literal — no regex, no whitespace or similarity guessing — and + must be unique unless `replace_all` is set, so a call that no longer fits + the file fails with a message saying how to recover instead of editing + the wrong place. A UTF-8 BOM and a CRLF file's line endings are handled + for you; creating a file and rewriting one whole stay with `write`. - English translations for all research notes, organized alongside their hand-written Chinese sources of truth. Development notes now link to the matching research language, and the pull-request workflow reports whether diff --git a/docs/dev_notes/en/0.7.x.md b/docs/dev_notes/en/0.7.x.md new file mode 100644 index 0000000..0247d1f --- /dev/null +++ b/docs/dev_notes/en/0.7.x.md @@ -0,0 +1,230 @@ +# Development Notes — 0.7.x + +> Generated from the Chinese source [`../zh-CN/0.7.x.md`](../zh-CN/0.7.x.md). Do not edit by hand. + +## 0.7.0 - YYYY.MM.DD + +This version builds the edit tool. It starts from a survey of the edit tools of mainstream code agents, see [edit tool research](../../research/en/edit_tool.md). + +There are two basic motivations for building an edit tool. + +First, when changing a few lines of code, sending just the small stretch of old text and its replacement is enough — there is no need to make the model regenerate the entire file. That saves output tokens, and it keeps a full copy of the file from occupying the context for a long time. + +Second, a whole-file rewrite easily changes places nobody asked it to change, whereas a unique `old_text` match is itself a verifiable precondition — when the old content is no longer there the call simply fails and the model re-reads, instead of overwriting unconditionally. + +Of course, partial edits can also be made through bash, but at agent runtime it is hard to reliably derive from a dynamic shell snippet which file was changed, what old content it assumed, and what diff it actually produced. A dedicated tool also fails in a structured way (no match / N matches / the file has changed), which is easier to recover from than a chunk of shell stderr. + +I tested what happens without an edit tool: I first wrote a `quick_sort.py`, then asked for comments to be added. nanoPyCodeAgent 0.6.x called the write tool and rewrote the whole file — confirming that without an edit tool the context grows. + +Based on the research, here is what this round's edit tool implements. + +### The edit tool contract + +**Input** (`input_schema`, in the same style as `read`/`write`): + +```text +path string required File path, absolute or relative to the agent's working directory; a leading ~ is expanded +old_text string required The text to replace, character for character as it appears in the file; must not be empty +new_text string required The replacement text; an empty string means deleting old_text exactly +replace_all boolean optional Defaults to false; when true, replaces every non-overlapping match +``` + +**Output**: like `read`/`write`, it returns a single stretch of plain text plus an `is_error` flag, not a structured object. On success it returns a one-line summary and does not feed the file's content back to the model: + +```text +[edited src/app.py: replaced 1 occurrence at line 42] +[edited src/app.py: replaced 3 occurrences, first at line 12] +``` + +On failure `is_error=true`, and every kind of failure has to state both what happened and what to do next: + +```text +[no match for old_text in src/app.py; read the file again and copy the text exactly — the CRLF retry was already tried] +[old_text matches 3 times in src/app.py; add surrounding context to make it unique, or set replace_all=true] +[old_text and new_text are identical: nothing to change] +[old_text is empty; use write to create a file or replace it whole] +[file not found: src/app.py] +[src/app.py is a directory, not a file] +[src/app.py is not valid UTF-8; edit is text-only] +[src/app.py is 12000000 bytes, over the 10000000 limit; edit it with bash] +``` + +In the terminal the call shows up as `[edit] src/app.py` plus a folded little old/new diff. The full `old_text`/`new_text` is no longer fed back through the tool result — it is already in the tool input of that assistant message. + +### Other implementation points for the edit tool + +- **Field naming follows this project**: use `path` and snake_case, consistent with the existing `read`/`write`. Claude Code's `old_string` and OpenCode's `filePath`/`oldString` are deliberately not copied — a clear tool description is enough to teach the model, and there is no reason to fracture this project's naming just to align with some other product. +- **Unique match, fail outright when it is not**: by default `old_text` has to occur exactly once in the file; zero matches fails, and multiple matches without `replace_all` also fails and reports the match count. With `replace_all=True` it replaces every non-overlapping match and returns the actual count. Better to make the model retry once more than to guess a location. +- **Exact only, no fuzzy matching**: no regex, and no fallbacks such as trimming, indentation flexibility, similarity scoring or Unicode normalization. The only input conversions allowed are the BOM and line endings — both directional, both enumerable in scope, and both of which must be stated honestly in the tool description. Unlike OpenCode V1 and Pi, we must not claim "exact" to the model while the real matching boundary is wider. +- **Draw a clear line against `write`**: it only edits regular files that already exist; creating new files and rewriting whole ones stay with `write`. `old_text` must not be empty, and `old_text == new_text` fails. `new_text=""` means an exact deletion, with no hidden semantics like "and also take the following newline with it." +- **Strict UTF-8 and BOM handling**: files that are not valid UTF-8, and files containing NUL, are refused — `read` displays bad bytes as replacement characters to make them inspectable, and if edit round-tripped that view it would corrupt the original bytes for good. A UTF-8 BOM is stripped before matching and restored on write; otherwise an `old_text` aimed at the first line would mysteriously fail to match because of one invisible character. +- **Match `read`'s newline view with one directional CRLF retry**: first do a raw exact pass on `old_text` as given. Only when that finds nothing, and the file contains `\r\n`, and `old_text` contains `\n` but no `\r`, is its LF→CRLF form retried once — with `new_text` converted along with it. The two passes are never unioned: uniqueness and counting both happen in whichever pass actually matched. In a file with mixed line endings, a fragment in the other style simply does not match this time; that is a deliberate fail-closed, and the error message has to say that the CRLF retry was already tried. +- **File size reuses `MAX_READ_BYTES`, a 10 MB cap**: the implementation is a whole-file read-compute-write, so anything over the cap is refused outright with a suggestion to use bash or a dedicated script instead. +- **Paths, symlinks and error style align with `read`/`write`**: `~` expansion, the regular-file check and the error format all stay consistent. The first version writes back directly and explicitly offers no mtime check, no CAS and no atomic replace — do not pretend in the copy to have guarantees that do not exist. +- **The tool description has to teach the model how to choose**: prefer edit for ordinary partial changes, write for new files or whole-file rewrites, bash for bulk mechanical transforms. `old_text` has to be character-for-character identical, two to four lines is usually enough, it must not carry read's line-number prefix, and the model must not splice in `\r` itself. +- **Test matrix**: unique replacement / deletion / Unicode / no-op; not-found, repeated matches and `replace_all` counting; untouched content keeping its exact bytes under LF, CRLF, no trailing newline and mixed line endings; BOM stripping and restoration; invalid UTF-8 and NUL refusal; missing files, directories, FIFOs, over-cap files; `~` and relative paths, symlink behavior consistent with `write`; the folded terminal display and the `is_error` setting; several edits in one reply taking effect in order. + +Explicitly out of scope for this version: no enforced prior Read (there is no read revision registry today, and a unique `old_text` is already a local precondition), no `edits[]` batching, no `apply_patch`, no local imitation of a formatter / LSP / history / approval UI, and no per-file queue (tool calls currently execute single-threaded and in order). These wait until parallel calls, approvals and remote filesystems genuinely show up, at which point they all get upgraded together into a mutation core shared by write and edit. + +### QA + +What follows are questions and answers from discussions with the agent: I asked the questions, the agent organized the answers, providing the explanation the material above needs. + +#### Q: What exactly are the fallbacks mentioned under "exact only, no fuzzy matching"? What do "supporting regex" and "supporting trim" look like as code behavior? + +First, a baseline: **exact means taking `old_text`'s byte sequence and finding an identical substring in the file**. The four spaces in `" return x\n"`, the `\n` at the end of the line, whether each quote is straight or curly — all of it has to line up. Every item below opens a hole in that baseline, and each one happens *after* exact matching fails, answering the question "should we try again with a relaxed standard?" + +**1. Regex** + +"Supporting regex" = treating `old_text` as a pattern instead of a literal: + +```python +content.find(old_text) # literal matching (what we want) +re.sub(old_text, new_text, content) # regex matching (what we do not want) +``` + +The difference is that `. * + ? ( ) [ ] { } | ^ $ \` become **metacharacters**: the model wants to delete `foo(bar)` from the source, and the regex reads it as "foo followed by a capture group bar", actually matching `foobar`; `a.b` matches `axb`. These symbols are extremely dense in code, and the model would have to escape each one to express "I mean this literal text" — which it frequently forgets. Conversely, regex can express bulk patterns like `def \w+\(`, but that is bash's job (`sed`/`perl`). None of the five surveyed projects does regex in edit; this rule keeps sed's mindset out. + +**2. Trim (comparing after stripping whitespace per line / on both sides)** + +"Supporting trim" = after exact fails, split both sides into lines, `strip()` the leading and trailing whitespace off each, and compare line by line: + +```text +The line in the file (two trailing spaces): " return x " +The old_text the model submitted: " return x" +``` + +Exact does not match (two trailing spaces missing); with per-line trim on, both sides strip down to `"return x"` and it counts as a hit, then that whole line in the file is replaced. Codex's `seek_sequence` does "ignore trailing whitespace → trim both sides" (`en/edit_tool.md:273`), Pi does per-line trailing-whitespace normalization (`en/edit_tool.md:170`), and OpenCode V1 has a dedicated trim replacer (`en/edit_tool.md:224`). + +Why they do it: trailing whitespace is especially easy to lose in transit. Claude Code's API message normalization silently strips non-Markdown per-line trailing whitespace from `new_string` (`en/edit_tool.md:208`), so the model never gets a chance to submit accurate trailing whitespace at all. + +Why we do not: first, the matched range stops being equal to the bytes the model wrote (it thinks it is changing `" return x"`, while what actually gets changed is `" return x "`). Second, **what to write back** becomes another question that has to be settled — keep the file's original trailing whitespace, or erase it per the model's version? Two trailing spaces in Markdown are a hard line break, and erasing them changes the meaning. Once this hole is open, the sentence "the tool changes exactly what the model submitted" is no longer true. + +**3. Indentation flexibility** + +"Supporting indentation flexibility" = allowing `old_text` as a whole to be one level less (or more) indented than the file, and after matching, re-indenting `new_text` to the file's original indentation: + +```text +In the file (inside a function, 8-space indent): The old_text the model submitted (flush left): + if x: if x: + return 1 return 1 +``` + +With this level enabled, the tool notices every line is missing the same 8-space prefix, decides it is the same block, and adds the 8 spaces back to each line of `new_text` when replacing. OpenCode V1 has this replacer (`en/edit_tool.md:224`). It addresses the model's habit of rewriting from memory rather than copying, at the cost that in a language like Python — where indentation *is* syntax — getting the level wrong while re-indenting is a silent semantic error, and what the model receives is "success". + +**4. Similarity (block anchor + Levenshtein)** + +The widest level of all: use only `old_text`'s first and last lines as anchors to locate the region, and require the lines in between not to be identical but merely "similar". OpenCode V1 finds candidate regions by the first/last lines, computes a Levenshtein edit distance over the middle content to get a similarity score between 0 and 1, and **accepts anything ≥ 0.65**, taking the highest-scoring candidate and keeping the first on a tie (`en/edit_tool.md:224-225`): + +```text +The old_text the model submitted: What is actually in the file: +def run(x): def run(x): + y = x + 1 y = x * 2 ← the middle lines all differ + z = y * 3 z = y - 7 + return z log(z) + return z +``` + +The ends line up, the middle clears the similarity bar, and the tool deletes those 5 lines from the file as a block and swaps in `new_text` — **the old content the model quoted and the content actually deleted are not the same thing**, and the reply it gets is "replaced 1 occurrence". OpenCode V1 has to add an extra guard for this ("reject when the matched span is far larger than `old_text`", `en/edit_tool.md:224`). It can afford this because V1 has a human approval diff as a backstop before anything reaches disk (`en/edit_tool.md:226`); our first version has no approval UI, so there is nothing here to catch what falls through this hole. + +**5. Unicode normalization** + +"Supporting" it = folding characters that "look alike but have different code points" into the same thing before comparing: + +| Category | What the model submitted | What is actually in the file | +| --- | --- | --- | +| smart quotes | `"hello"` (U+201C/U+201D) | `"hello"` (U+0022) | +| dash | `a – b` (en dash) | `a - b` (hyphen) | +| special spaces | NBSP (U+00A0) | ordinary space (U+0020) | +| NFKC | `(` fullwidth parenthesis, `fi` ligature | `(`, `fi` | + +Model output passes through rendering and the tokenizer, which easily turns straight quotes into curly ones, so all five projects implement some version of this level: Grok has optional confusable normalization (`en/edit_tool.md:116`), Pi does NFKC + punctuation + special spaces (`en/edit_tool.md:170`), Claude Code does only quote normalization plus a fixed desanitize set (`en/edit_tool.md:202`), and Codex normalizes punctuation and spaces (`en/edit_tool.md:273`). The crucial difference is what gets written back: Grok maps the matched position back to the original UTF-8 bytes and fails closed when the mapping is not clean; Pi replaces on the normalized text and copies back only the lines it did not touch, so **the lines it did touch incidentally undergo NFKC / punctuation / trailing-whitespace changes**, producing modifications the model never asked for (`en/edit_tool.md:307`). + +**So why do the BOM and line-ending conversions we kept not count as fuzzy?** + +| | The five levels above | BOM / line endings | +| --- | --- | --- | +| Cause | Guessing the model copied it wrong | Caused by our own `read` view | +| Direction | Two-way folding, both sides change | One-way conversion, only the `old_text` side changes | +| Trigger | Heuristic scoring | Can be written as a single if | +| Matched range | May be larger than the literal the model submitted | Strictly equal | + +`read` drops the `\r` of CRLF when displaying, so multi-line original text in the model's hands necessarily has LF only — it could not produce `\r` even if it wanted to. That is not the model copying it wrong, it is a mismatch our own view created, so the tool supplies the conversion: the file contains `\r\n`, `old_text` contains `\n` and no `\r`, and the first pass came up empty — only when all three hold does one LF→CRLF retry happen. The BOM is the same story: `read` does not strip U+FEFF, so an invisible character hangs at the start of the first line, and without stripping it the match fails mysteriously. Both can be written into the tool description in one sentence for the model to verify; a similarity threshold of 0.65 cannot, and even if written down the model would have no way to judge which stretch was actually matched. + +**The trade-off in one sentence**: fuzzy matching raises the first-try success rate, and what it costs is that the range the tool is actually authorized to modify becomes larger than the range the model expressed. Products with a human approval diff can afford that cost; our first version has no approval, no undo and no checkpoint, so one wrong replacement lands straight on disk. So we choose fail closed — the cost of failure is only that the model reads the file once more and retries, and that happens to be what agents are best at. + +#### Q: What are the mechanisms behind those five items under "explicitly out of scope for this version", and why not do them now? + +**1. No enforced prior Read (read-before-edit)** + +"What it looks like if done": the agent keeps a table in the session recording, for each file, **whether it has been read in this session and which version was read** (a timestamp or content hash). Before an edit runs, it consults that table: never read means refuse outright and make the model read first; read but the file's mtime is newer than that read means someone changed it in the meantime, so refuse as well. + +Claude Code does the heaviest version of this among the five projects: it does not merely suggest reading in the tool description, it enforces session read state at runtime, and a system-injected partial view does not count. Before writing it re-reads the current metadata synchronously to double-check, and deliberately inserts no `await` between the check and the write, squeezing the race window into a single event loop tick (`en/edit_tool.md:200`). + +Why not now: we do not have that table, and building one means first building a read revision registry — and `read` supports `offset`/`limit` anyway, so what it returns is often just a window onto the file, which makes "has been read" an ambiguous state to begin with. More importantly, a unique `old_text` match **is itself a precondition**: if the old content is gone, the call fails, and that already covers the main scenario of "the file changed, so do not blind-write". Recording mtime only narrows the window; it does not eliminate TOCTOU (between the check and the actual write another process can still cut in), while adding a pile of state to carry (`en/edit_tool.md:367`). + +When to add it: once there is undo/checkpoint or an approval UI and we need an explicit answer to "which version was the model's judgment based on". + +**2. No `edits[]` batching** + +"What it looks like if done": the schema goes from a single old/new to an array, submitting several changes to the same file in one call: + +```text +path: string +edits: [ {old_text, new_text}, {old_text, new_text}, ... ] +``` + +Pi has exactly this shape: every `old_text` matches against **the same original file** (not against the result of the previous item), everything is validated first, overlapping ranges are rejected, and then they are applied in reverse order with a single write to disk (`en/edit_tool.md:149-177`). The benefit is one read, one write, a group of changes that logically all hold or all fail together, and fewer tool round trips. + +Why not now: our agent loop is single-threaded and sequential — multiple `tool_use` blocks in one assistant reply are executed one at a time in the order the model gave them (`src/nanopycodeagent/agent.py:172-179`), so the model can already fire several edits in one turn, with each seeing the previous one's result. Batching would only save a few I/O operations, in exchange for overlap detection, per-item validation, and the question of how to report partial failure (`en/edit_tool.md:368`). + +There is one more lesson from Pi: it once supported both a single old/new and `edits[]`, and the model kept mixing the two and producing invalid calls; in the end only the array survived, with the old shape demoted to a runtime input-migration layer (`en/edit_tool.md:161`, `en/edit_tool.md:315`). So if we ever do upgrade, only one public schema can remain — the two cannot coexist. + +When to add it: when tool round-trip latency, or "many changes to one file", becomes a measured bottleneck. + +**3. No `apply_patch`** + +"What it looks like if done": instead of old/new strings, the call carries a whole patch text, expressing additions, deletions, changes and moves across several files at once: + +```text +*** Begin Patch +*** Update File: src/app.py +@@ def run(): +- old line ++ new line +*** Add File: src/new.py ++content +*** Delete File: src/old.py +*** End Patch +``` + +Codex has only this one file-modification tool; even creating a file is expressed with `*** Add File`, and no whole-file write is exposed (`en/edit_tool.md:246-267`). It is the most expressive option, with one parser and one permission entry point shared across files. + +Why not now: it means writing a parser for a patch language, handling error recovery when a hunk fails to locate, facing partial commits where **the first few files write successfully and a later one fails** during a multi-file sequential submission (Codex itself does not roll back either, `en/edit_tool.md:277`), and redefining which set of paths permissions apply to. All of that would significantly inflate the core of a nano agent. Besides, the default model is Claude Sonnet, which is more familiar with the old/new form of Edit (`en/edit_tool.md:369`); OpenCode simply dispatches by model — `apply_patch` for the GPT family, `edit` + `write` for everything else (`en/edit_tool.md:218`). + +When to add it: when a single call needs to express something atomically across several files (a rename plus updating all references, say), or when adapting to a model whose training distribution is patch-shaped. + +**4. No local imitation of a formatter / LSP / history / approval UI** + +These four are an entire lifecycle mature agents hang around "writing to disk". One at a time: + +- **formatter**: after writing, automatically run something like prettier/black, then **recompute the diff from the formatted result** before returning it to the model. OpenCode V1 does this (`en/edit_tool.md:222`); the cost is that what finally lands on disk may be larger than the diff shown at approval time. +- **LSP**: after writing, notify the language server to re-analyze and stuff the newly produced diagnostics (type errors, undefined variables) into the tool result, so the model sees on the spot whether it broke something. OpenCode V1 has it, and Claude Code has it when an IDE is connected (`en/edit_tool.md:206`, `en/edit_tool.md:222`). +- **history / undo**: save a snapshot before every write, supporting rewind to a given step. Grok's `FileWritten` event carries previous/new content precisely to feed the hunk tracker and rewind (`en/edit_tool.md:120`). +- **approval UI**: put the diff in front of a human before writing, and only write once approved. OpenCode V1/V2's `edit` permission is exactly this (`en/edit_tool.md:222`, `en/edit_tool.md:238`). + +Why not now: these are **not capabilities of the edit tool but of the whole mutation / exec control plane**. Adding a layer only on edit is bypassed by one `sed -i` in bash — approval, snapshots, path protection, all of it: as long as bash can still write arbitrary files, the defense has a hole (`en/edit_tool.md:370`). A half-installed security boundary is more dangerous than none, because it makes people believe there is one. + +When to add it: once bash and the file tools land inside the same OS / container / VM filesystem boundary, build it as a whole then, rather than simulating one on edit first. + +**5. No per-file queue** + +"What it looks like if done": take an in-process lock keyed by the file's canonical path (the real path with symlinks resolved), serializing the whole "read current content → compute new content → write back" stretch, so two mutations of the same file cannot interleave while different files still run in parallel. Pi uses a mutation queue keyed by `realpath` (`en/edit_tool.md:177`); OpenCode V2 goes further and does `writeIfUnchanged(expectedBytes)` inside that lock — comparing the current bytes against the bytes read at approval time and declaring it stale if they differ (`en/edit_tool.md:238-242`). + +What it prevents is a **lost update**: two changes run concurrently, A reads the old content, B reads the old content, A writes, B writes, and A's change is silently overwritten by B. + +Why not now: we have no concurrency at all. Tool calls are dispatched sequentially in a single while loop (`src/nanopycodeagent/agent.py:172-179`), only one edit runs at any moment, and an in-process lock would always be available and never contended (`en/edit_tool.md:333`, `en/edit_tool.md:371`). And to be clear: this kind of lock **can only coordinate the tools that participate in it** — external editors and bash subprocesses can still cut in between the read and the write, so it was never a cross-writer CAS (`en/edit_tool.md:242`). + +When to add it: the day parallel tool calls actually arrive — at which point the whole read-compute-write stretch goes into a shared queue keyed by canonical path, rather than locking only the write itself. + +**The shared logic behind these five**: either they address **a problem that does not exist yet** under the current architecture (concurrency, batching round trips), or they **cannot be solved at all** under the current architecture (approval and snapshots are bypassed by bash), or they **would inflate the core for unclear benefit** (a patch parser, a revision registry). The closing line of the research puts it well: first solve the token and accidental-overwrite problems of partial edits with a unique old-text precondition, and wait until the architecture genuinely develops concurrency, approval and remote needs before upgrading the mutation core — rather than simulating safety ahead of time (`en/edit_tool.md:412`). diff --git a/docs/dev_notes/zh-CN/0.7.x.md b/docs/dev_notes/zh-CN/0.7.x.md new file mode 100644 index 0000000..cc6b19b --- /dev/null +++ b/docs/dev_notes/zh-CN/0.7.x.md @@ -0,0 +1,230 @@ +# 开发笔记 — 0.7.x + +> 本文件为**手写中文源文件**(source of truth);英文版 [`../en/0.7.x.md`](../en/0.7.x.md) 由其生成。 + +## 0.7.0 - YYYY.MM.DD + +这个版本开发 edit 工具,先对主流 code agent 的 edit 工具作了调研,见 [edit 工具调研](../../research/zh-CN/edit_tool.md)。 + +开发 edit 工具最基本的初衷有两条: + +一是改几行代码时,只传要改的那一小段旧文本和新文本就够了,不必让模型把整个文件重新生成一遍,既省输出 token,也不用让这份完整内容长期占着上下文。 + +二是整文件重写容易顺手改到没让它改的地方,而 `old_text` 唯一匹配本身就是一个可验证的前置条件——旧内容已经不在了就直接失败、让模型重读,而不是无条件覆盖。 + +当然,其实也可通过 bash 对文件进行部分编辑,但 agent 运行时很难从一段动态 shell 里可靠推导出改了哪个文件、以什么旧内容为前提、实际产生了什么 diff;专用工具的失败也是结构化的(找不到 / 找到 N 处 / 文件已变化),比一段 shell stderr 好恢复。 + +我测试了没有 edit 工具的情况,先写了个 quick_sort.py 文件,再要求添加注释的时候,0.6.x 版本的 nanoPyCodeAgent 就会调用 write 工具,对整个文件进行重写,验证了无 edit 工具时,会增加上下文。 + +以下是根据调研结果,决定的本次 edit 工具的实现: + +### edit tool 实现契约 + +**输入**(`input_schema`,风格与 `read`/`write` 一致): + +```text +path string 必填 文件路径,绝对或相对于 agent 工作目录,开头的 ~ 会展开 +old_text string 必填 要被替换的原文,与文件内容逐字一致,不得为空 +new_text string 必填 替换后的文本,可以是空串,表示精确删除 old_text +replace_all boolean 可选 默认 false;true 时替换所有不重叠的匹配 +``` + +**输出**:和 `read`/`write` 一样,只返回一段纯文本加一个 `is_error` 标志,不返回结构化对象。成功时回一行摘要,不把文件内容回灌给模型: + +```text +[edited src/app.py: replaced 1 occurrence at line 42] +[edited src/app.py: replaced 3 occurrences, first at line 12] +``` + +失败时 `is_error=true`,每种失败都要说清是什么情况、下一步怎么办: + +```text +[no match for old_text in src/app.py; read the file again and copy the text exactly — the CRLF retry was already tried] +[old_text matches 3 times in src/app.py; add surrounding context to make it unique, or set replace_all=true] +[old_text and new_text are identical: nothing to change] +[old_text is empty; use write to create a file or replace it whole] +[file not found: src/app.py] +[src/app.py is a directory, not a file] +[src/app.py is not valid UTF-8; edit is text-only] +[src/app.py is 12000000 bytes, over the 10000000 limit; edit it with bash] +``` + +终端里这次调用显示成 `[edit] src/app.py` 加一段折叠后的 old/new 小 diff。完整的 `old_text`/`new_text` 不再回灌进 tool result——它已经在这条 assistant 消息的 tool input 里了。 + +### edit tool 其它实现要点 + +- **字段命名跟着本项目走**:用 `path` 和 snake_case,与现有的 `read`/`write` 一致。Claude Code 的 `old_string`、OpenCode 的 `filePath`/`oldString` 都不照抄——清晰的工具描述足以教会模型,没必要为了对齐某个产品让本项目的命名分裂。 +- **唯一匹配,错就直接失败**:默认要求 `old_text` 在文件中恰好出现一次;命中 0 处失败,命中多处且没开 `replace_all` 也失败并报出匹配数;`replace_all=True` 时替换所有不重叠的匹配并返回实际次数。宁可让模型多重试一次,也不猜位置。 +- **只做 exact,不做模糊匹配**:不支持正则,不做 trim、缩进弹性、相似度和 Unicode 归一化这类回退。允许的输入换算只有 BOM 和行尾两项,方向确定、范围可枚举,而且必须如实写进工具描述——不能像 OpenCode V1、Pi 那样对模型宣称 exact,实际匹配边界却更宽。 +- **划清和 `write` 的职责**:只编辑已经存在的普通文件,创建新文件和整文件重写继续归 `write`。`old_text` 不得为空,`old_text == new_text` 失败;`new_text=""` 表示精确删除,不附带「顺手把后面那个换行也删掉」这类隐藏语义。 +- **严格 UTF-8 与 BOM 处理**:拒绝非法 UTF-8 和含 NUL 的文件——`read` 用替换字符显示坏字节是为了方便看,edit 要是照着 round-trip 就会把原字节永久改坏。UTF-8 BOM 在匹配前剥掉、写回时原样补上,否则针对首行的 `old_text` 会因为一个看不见的字符神秘失配。 +- **兼容 `read` 的换行视图,做一次方向确定的 CRLF 重试**:先拿 `old_text` 原样做 raw exact;只有在命中 0 处、文件含 `\r\n`、且 `old_text` 含 `\n` 不含 `\r` 时,才用它的 LF→CRLF 形式重试一次,写入的 `new_text` 也一并换算。两趟不取并集,唯一性判定和计数都发生在真正命中的那一趟;混合行尾文件里另一种风格的片段这次就是匹配不到,属于有意的 fail closed,错误信息要讲清 CRLF 重试已经试过。 +- **文件大小沿用 `MAX_READ_BYTES` 的 10 MB 上限**:实现是整文件 read-compute-write,超限直接拒绝并建议改用 bash 或专用脚本。 +- **路径、symlink、错误风格与 `read`/`write` 对齐**:`~` 展开、普通文件检查、错误格式都保持一致。首版直接写回,明确不提供 mtime 校验、CAS 和原子替换——没有的保证就不要在文案里假装有。 +- **工具描述要教会模型怎么选**:普通局部修改优先 edit,新文件或整文件重写用 write,批量机械变换用 bash;`old_text` 要逐字一致、通常 2–4 行足够、不要带 read 的行号前缀,也不要自己拼 `\r`。 +- **测试矩阵**:唯一替换 / 删除 / Unicode / no-op;not-found、重复匹配与 `replace_all` 计数;LF、CRLF、无末尾换行、混合行尾下未触及内容保持原字节;BOM 剥离与补回;非法 UTF-8 与 NUL 拒绝;missing、目录、FIFO、超限;`~` 与相对路径、symlink 行为与 `write` 一致;终端折叠展示与 `is_error` 设置;同一轮回复里多个 edit 顺序生效。 + +这一版明确不做的:不强制 prior Read(当前没有 read revision registry,唯一 `old_text` 已经是局部前置条件)、不做 `edits[]` 批量、不做 `apply_patch`、不做 formatter / LSP / history / 审批 UI 的局部版本、不做 per-file 队列(工具调用目前是单线程顺序执行的)。这些要等真的出现并行调用、审批和远端文件系统需求时,再统一升级成 write/edit 共用的 mutation core。 + +### QA + +以下是与 agent 讨论时的问答,由我提问,agent 整理答案,以提供对上述信息的必要说明。 + +#### Q:「只做 exact,不做模糊匹配」里提到的那些回退,具体指什么?「支持正则」「支持 trim」到底是什么样的代码行为? + +先立个基准:**exact 就是拿 `old_text` 的字节序列在文件里找一模一样的子串**。`" return x\n"` 里的四个空格、行尾那个 `\n`、每个引号是直的还是弯的,全都要对上。下面每一项,都是在这个基准上开一个口子——都发生在 exact 匹配失败之后,回答的是「要不要用一个放宽的标准再试一次」。 + +**1. 正则** + +「支持正则」= 把 `old_text` 当成模式(pattern)而不是字面量: + +```python +content.find(old_text) # 字面量匹配(我们要的) +re.sub(old_text, new_text, content) # 正则匹配(我们不要的) +``` + +差别在于 `. * + ? ( ) [ ] { } | ^ $ \` 会变成**元字符**:模型想删掉源码里的 `foo(bar)`,正则会读成「foo 后面跟一个捕获组 bar」,实际匹配 `foobar`;`a.b` 会匹配 `axb`。代码里这些符号密度极高,模型得逐个转义才能表达「我就要这段原文」,而它经常忘。反过来,正则能表达 `def \w+\(` 这种批量模式,但那是 bash(`sed`/`perl`)的活儿。调研的五个项目没有一个在 edit 里做正则,这条是把 sed 的思路挡在门外。 + +**2. trim(逐行 / 两侧去空白后比较)** + +「支持 trim」= exact 失败后,把两边按行切开、各自 `strip()` 掉首尾空白,再逐行比较: + +```text +文件里的这一行(行尾有两个空格): " return x " +模型交上来的 old_text: " return x" +``` + +exact 不匹配(少了两个尾空格);开了逐行 trim,两边都 strip 成 `"return x"` 就算命中,然后替换文件里那一整行。Codex 的 `seek_sequence` 做「忽略行尾空白 → 两侧 trim」(`zh-CN/edit_tool.md:273`),Pi 做逐行尾空白归一化(`zh-CN/edit_tool.md:170`),OpenCode V1 有专门的 trim replacer(`zh-CN/edit_tool.md:224`)。 + +他们为什么要做:尾空白特别容易在传输中丢。Claude Code 的 API 消息规范化就会静默删掉 `new_string` 里非 Markdown 的逐行尾空白(`zh-CN/edit_tool.md:208`),模型压根没机会交出准确的尾空白。 + +我们为什么不做:一是命中范围不再等于模型写的那串字节(它以为在改 `" return x"`,实际被改掉的是 `" return x "`);二是**替换时写回什么**会变成一个必须再拍板的问题——保留文件原有的尾空白,还是按模型的版本抹掉?Markdown 行尾两个空格是硬换行,抹掉就改了语义。这个口子一开,「工具改的就是模型提交的东西」这句话就不成立了。 + +**3. 缩进弹性** + +「支持缩进弹性」= 允许 `old_text` 整体比文件里少缩进(或多缩进)一层,匹配上之后再按文件原有缩进把 `new_text` 重新缩排: + +```text +文件里(函数体内,8 空格缩进): 模型交上来的 old_text(顺手顶格了): + if x: if x: + return 1 return 1 +``` + +开了这一档,工具发现每行都少了同样的 8 空格前缀,就判定为同一块,替换时再给 `new_text` 每行补回 8 空格。OpenCode V1 有这个 replacer(`zh-CN/edit_tool.md:224`)。它解决的是模型「凭记忆重写而不是照抄」的毛病,代价是在 Python 这种缩进即语法的语言里,补缩进一旦判错层级就是静默的语义错误,而模型收到的是「成功」。 + +**4. 相似度(block anchor + Levenshtein)** + +尺度最大的一档:只用 `old_text` 的首行和末行当锚点定位,中间那些行不要求一样,只要求「像」。OpenCode V1 的做法是拿首末行找候选区间,对中间内容算 Levenshtein 编辑距离得到 0~1 的相似度分,**≥ 0.65 就认**,多个候选取最高分,同分保留第一个(`zh-CN/edit_tool.md:224-225`): + +```text +模型交上来的 old_text: 文件里实际是: +def run(x): def run(x): + y = x + 1 y = x * 2 ← 中间几行都不一样 + z = y * 3 z = y - 7 + return z log(z) + return z +``` + +首尾对上、中间相似度过线,工具就把文件里那 5 行整块删掉换成 `new_text`——**模型引用的旧内容和实际被删掉的内容不是一回事**,而它得到的回复是「替换成功 1 处」。OpenCode V1 为此还得额外加一条「命中跨度比 `old_text` 大太多就拒绝」的保护(`zh-CN/edit_tool.md:224`)。它敢这么做是因为 V1 落盘前有一道人类审批的 diff 兜底(`zh-CN/edit_tool.md:226`);我们首版没有审批 UI,这个口子在我们这儿没有任何东西接得住。 + +**5. Unicode 归一化** + +「支持」= 把两边「长得像但码点不同」的字符先折叠成同一个再比较: + +| 类别 | 模型交上来的 | 文件里实际是 | +| --- | --- | --- | +| smart quotes | `"hello"`(U+201C/U+201D) | `"hello"`(U+0022) | +| dash | `a – b`(en dash) | `a - b`(hyphen) | +| 特殊空格 | NBSP(U+00A0) | 普通空格(U+0020) | +| NFKC | `(` 全角括号、`fi` 合字 | `(`、`fi` | + +模型输出经过渲染和 tokenizer 环节,很容易把直引号变成弯引号,所以五个项目全都做了这一档的某种版本:Grok 是可选的 confusable 归一化(`zh-CN/edit_tool.md:116`)、Pi 是 NFKC + 标点 + 特殊空格(`zh-CN/edit_tool.md:170`)、Claude Code 只做引号归一加一组固定 desanitize(`zh-CN/edit_tool.md:202`)、Codex 做标点/空格归一(`zh-CN/edit_tool.md:273`)。关键差别在写回什么:Grok 会把命中位置映射回原始 UTF-8 字节,映射不干净就 fail closed;Pi 是在归一化后的文本上替换、只把没碰到的行拷回原文,所以**被碰到的那些行会顺带发生 NFKC / 标点 / 尾空白变化**,产生模型没要求的改动(`zh-CN/edit_tool.md:307`)。 + +**那我们保留的 BOM 和行尾换算,凭什么不算 fuzzy?** + +| | 上面五档 | BOM / 行尾 | +| --- | --- | --- | +| 起因 | 猜模型抄错了 | 我们自己的 `read` 视图造成的 | +| 方向 | 双向折叠,两边都改 | 单向换算,只改 `old_text` 一侧 | +| 触发条件 | 启发式打分 | 可以写成一句 if | +| 命中范围 | 可能大于模型提交的字面量 | 严格等于 | + +`read` 展示时把 CRLF 的 `\r` 去掉了,所以模型手里的多行原文必然只有 LF——它想给也给不出 `\r`。这不是模型抄错,是我们自己的视图造成的失配,所以由工具补上这次换算:文件含 `\r\n`、`old_text` 含 `\n` 不含 `\r`、第一趟颗粒无收,三个条件同时成立才做一次 LF→CRLF 重试。BOM 同理,`read` 不剥 U+FEFF,首行开头挂着一个看不见的字符,不剥就会神秘失配。两条都能一句话写进工具描述让模型验证;相似度阈值 0.65 写不进去,写了模型也无从判断这次命中的到底是哪一段。 + +**取舍一句话**:fuzzy 提高的是一次成功率,付出的是工具实际获准修改的范围大于模型表达的范围。有人类审批 diff 的产品付得起这个代价;我们首版没有审批、没有 undo、没有 checkpoint,一次错误替换就是直接落盘。所以选 fail closed——失败的代价只是模型多读一次文件再重试一次,而这正好是 agent 最擅长的事。 + +#### Q:「这一版明确不做的」那五条,分别是什么机制?为什么现在不做? + +**1. 不强制 prior Read(read-before-edit)** + +「做了是什么样」:agent 在会话里维护一张表,记下每个文件**这次会话里被 read 过没有、read 的是哪个版本**(时间戳或内容 hash)。edit 执行前先查这张表:没读过就直接拒绝,让模型先 read;读过但文件的 mtime 比那次 read 还新,说明期间被别人改了,也拒绝。 + +Claude Code 是五个项目里做得最重的:不只在工具描述里建议 read,而是运行时硬性要求会话 read state,而且系统自动注入的 partial view 不算数;落盘前还要再同步读一次当前 metadata 复核,检查到写入之间刻意不插入 `await`,把竞态窗口压到一个事件循环内(`zh-CN/edit_tool.md:200`)。 + +现在为什么不做:我们没有这张表,要做就得先建一套 read revision registry,而且 `read` 本来就支持 `offset`/`limit`,返回的常常只是文件的一个窗口——「读过」这个状态本身就含糊。更关键的是,`old_text` 唯一匹配**本身就是一个前置条件**:旧内容不在了就失败,这已经覆盖了「文件被改过所以不该盲写」的主要场景。记 mtime 只是把窗口变窄,并不能消除 TOCTOU(检查完到真正写入之间,别的进程照样能插一脚),却要多背一份状态(`zh-CN/edit_tool.md:367`)。 + +什么时候该补:等有了 undo/checkpoint 或审批 UI,需要一个明确的「模型基于哪个版本做的判断」时。 + +**2. 不做 `edits[]` 批量** + +「做了是什么样」:schema 从单个 old/new 变成一个数组,一次调用提交同一文件的多处改动: + +```text +path: string +edits: [ {old_text, new_text}, {old_text, new_text}, ... ] +``` + +Pi 就是这个形状:每个 `old_text` 都在**同一份原始文件**上匹配(不是在前一项的结果上接着匹配),先全部验证、拒绝重叠范围,再倒序应用、只写一次盘(`zh-CN/edit_tool.md:149-177`)。好处是一次读、一次写、一组改动逻辑上一起成立或一起失败,也省下工具往返。 + +现在为什么不做:我们的 agent loop 是单线程顺序执行的——一条 assistant 回复里的多个 `tool_use` block 按模型给出的顺序逐个执行(`src/nanopycodeagent/agent.py:172-179`),所以模型本来就能在一轮里连发多个 edit,后一个看得到前一个的结果。批量省下的只是几次 I/O,换来的是重叠检测、逐项校验、部分失败怎么报这些复杂度(`zh-CN/edit_tool.md:368`)。 + +还有一条来自 Pi 的教训:它曾经同时支持单个 old/new 和 `edits[]` 两种形状,结果模型反复混用、产生非法调用,最后只保留数组,旧形状退到运行时的输入迁移层(`zh-CN/edit_tool.md:161`、`zh-CN/edit_tool.md:315`)。所以真要升级,只能保留一种公开 schema,不能两种并存。 + +什么时候该补:工具往返延迟或「一个文件多点修改」成为实测瓶颈的时候。 + +**3. 不做 `apply_patch`** + +「做了是什么样」:不再传 old/new 字符串,而是传一整段 patch 文本,一次表达多个文件的增删改移: + +```text +*** Begin Patch +*** Update File: src/app.py +@@ def run(): +- old line ++ new line +*** Add File: src/new.py ++content +*** Delete File: src/old.py +*** End Patch +``` + +Codex 只有这一个文件修改工具,连新建文件都用 `*** Add File` 表达,不暴露整文件 write(`zh-CN/edit_tool.md:246-267`)。表达力最强,多文件共用一个 parser 和一个权限入口。 + +现在为什么不做:要写 patch 语言的 parser、要处理 hunk 定位失败的错误恢复、要面对多文件顺序提交时**前几个文件写成功、后面失败**的 partial commit(Codex 自己也不回滚,`zh-CN/edit_tool.md:277`),还要重新定义权限作用于哪一组路径。这些会显著撑大一个 nano agent 的核心。另外默认模型是 Claude Sonnet,它对 old/new 形式的 Edit 更熟(`zh-CN/edit_tool.md:369`);OpenCode 干脆按模型分发——GPT 系给 `apply_patch`,其他给 `edit` + `write`(`zh-CN/edit_tool.md:218`)。 + +什么时候该补:需要一次调用跨多文件原子表达(比如重命名 + 改所有引用),或者要适配以 patch 为训练分布的模型时。 + +**4. 不做 formatter / LSP / history / 审批 UI 的局部版本** + +这四样是成熟 agent 在「写盘」前后挂的一整条生命周期,逐个说: + +- **formatter**:落盘后自动跑 prettier/black 之类,然后**基于格式化后的结果重算 diff** 再返回给模型。OpenCode V1 这么做(`zh-CN/edit_tool.md:222`),代价是最终落盘内容可能大于审批时看到的 diff。 +- **LSP**:写完后通知语言服务器重新分析,把新产生的诊断(类型错误、未定义变量)塞进 tool result,让模型当场看到自己改坏了没有。OpenCode V1 有,Claude Code 在有 IDE 连接时有(`zh-CN/edit_tool.md:206`、`zh-CN/edit_tool.md:222`)。 +- **history / undo**:每次写盘前存一份快照,支持 rewind 到某一步。Grok 的 `FileWritten` 事件带 previous/new content 就是喂给 hunk tracker 和 rewind 的(`zh-CN/edit_tool.md:120`)。 +- **审批 UI**:落盘前把 diff 摆给人看,批准了才写。OpenCode V1/V2 的 `edit` permission 就是这个(`zh-CN/edit_tool.md:222`、`zh-CN/edit_tool.md:238`)。 + +现在为什么不做:这些**都不是 edit 工具的能力,而是整个 mutation / exec 控制面的能力**。只在 edit 上加一层,bash 一句 `sed -i` 就绕过去了——审批也好、快照也好、路径保护也好,只要 bash 还能任意写文件,防线就是漏的(`zh-CN/edit_tool.md:370`)。装一半的安全边界比没有更危险,因为它会让人以为有。 + +什么时候该补:等 bash 和文件工具一起进入同一个 OS / 容器 / VM 的文件系统边界时,再整体建,而不是先在 edit 上模拟一个。 + +**5. 不做 per-file 队列** + +「做了是什么样」:以文件的 canonical path(解析完 symlink 的真实路径)为 key 加一把进程内的锁,让「读当前内容 → 算出新内容 → 写回」这一整段串行,同一个文件的两次 mutation 不会交错,不同文件仍可并行。Pi 用 `realpath` 归并的 mutation queue(`zh-CN/edit_tool.md:177`),OpenCode V2 更进一步,在这把锁里做 `writeIfUnchanged(expectedBytes)`——比对当前字节和批准时读到的字节,不一样就判定 stale(`zh-CN/edit_tool.md:238-242`)。 + +它防的是 **lost update**:两个改动并发跑,A 读到旧内容、B 读到旧内容、A 写、B 写,A 的改动被 B 静默覆盖。 + +现在为什么不做:我们根本没有并发。工具调用是在一个 while 循环里顺序 dispatch 的(`src/nanopycodeagent/agent.py:172-179`),同一时刻只有一个 edit 在跑,进程内的锁永远拿得到、永远没有竞争对手(`zh-CN/edit_tool.md:333`、`zh-CN/edit_tool.md:371`)。而且要说明白:这类锁**只能协调参与它的自家工具**,外部编辑器和 bash 子进程照样能在读和写之间插进来,它从来就不是跨写者的 CAS(`zh-CN/edit_tool.md:242`)。 + +什么时候该补:真的引入并行 tool call 的那天——那时以 canonical path 为 key,把整段 read-compute-write 一起放进共享队列,而不是只锁写的那一下。 + +**这五条的共同逻辑**:它们要么在当前架构下**还没有对应的问题**(并发、批量往返),要么**在当前架构下根本解决不了**(审批、快照被 bash 绕过),要么**会把核心撑大而收益不明确**(patch parser、revision registry)。调研最后那句话说得挺准:先用唯一 old-text 前置条件解决局部修改的 token 与误覆盖问题,等架构真的出现并发、审批和远端需求,再升级 mutation core,而不是提前模拟安全性(`zh-CN/edit_tool.md:412`)。 diff --git a/src/nanopycodeagent/agent.py b/src/nanopycodeagent/agent.py index cf823fd..528c4ca 100644 --- a/src/nanopycodeagent/agent.py +++ b/src/nanopycodeagent/agent.py @@ -2,9 +2,10 @@ Run the program, type a message, and Agent replies. The full conversation is kept in memory so each turn has context. The model can call a ``read`` tool -to view files, a ``write`` tool to create or overwrite them, and a ``bash`` -tool to run shell commands; every call and its output are echoed to the -terminal as they happen. Type ``/exit`` to quit. +to view files, a ``write`` tool to create or overwrite them, an ``edit`` +tool to replace part of one, and a ``bash`` tool to run shell commands; +every call and its output are echoed to the terminal as they happen. Type +``/exit`` to quit. The loop handles only the happy path: anything unexpected — a network error, a Ctrl-C mid-turn — crashes the session, and restarting it is the recovery. @@ -29,6 +30,7 @@ from anthropic.types import MessageParam, ToolResultBlockParam, ToolUseBlock from .bash_tool import BASH_TOOL, run_bash +from .edit_tool import EDIT_TOOL, edit_preview, run_edit from .read_tool import READ_TOOL, run_read from .settings import load_settings_env from .terminal import Spinner, print_tool_output, print_tool_use @@ -40,14 +42,15 @@ MAX_TOKENS = 8192 SYSTEM_PROMPT = ( "You are nanoPyCodeAgent, a concise and helpful coding assistant. " - "Prefer the read tool for viewing files and the write tool for creating " - "files or rewriting them whole. Use the bash tool to run commands, " - "search with grep, and complete tasks that need real command output " - "instead of guessing." + "Prefer the read tool for viewing files, the edit tool for changing " + "part of an existing file, and the write tool for creating files or " + "rewriting them whole. Use the bash tool to run commands, search with " + "grep, and complete tasks that need real command output instead of " + "guessing." ) # Every tool offered to the model on each request. -TOOLS = [READ_TOOL, WRITE_TOOL, BASH_TOOL] +TOOLS = [READ_TOOL, WRITE_TOOL, EDIT_TOOL, BASH_TOOL] def _package_version() -> str: @@ -81,6 +84,16 @@ def _run_one_tool(block: ToolUseBlock) -> ToolResultBlockParam: # goes and how it starts, not hundreds of lines. print_tool_use(f"[write] {path}\n{content_preview(content)}") output, is_error = run_write(path, content) + elif block.name == "edit": + path = block.input["path"] + old_text = block.input["old_text"] + new_text = block.input["new_text"] + # The echo folds both sides into a small -/+ diff: the terminal + # shows what is being swapped, not the whole strings again. + print_tool_use(f"[edit] {path}\n{edit_preview(old_text, new_text)}") + output, is_error = run_edit( + path, old_text, new_text, replace_all=block.input.get("replace_all", False) + ) else: # bash — the only other tool offered command = block.input["command"] print_tool_use(f"[bash]$ {command}") diff --git a/src/nanopycodeagent/edit_tool.py b/src/nanopycodeagent/edit_tool.py new file mode 100644 index 0000000..9abb6d8 --- /dev/null +++ b/src/nanopycodeagent/edit_tool.py @@ -0,0 +1,260 @@ +"""The ``edit`` tool: its definition and its execution. + +Each call replaces one exact stretch of an existing UTF-8 text file, so a +small change costs a small message: the model sends the old text and the +new text instead of regenerating the whole file the way ``write`` needs it +to. The old text doubles as a precondition — it has to be present, and by +default exactly once — so an edit fails loudly when the file has moved on, +where a whole-file overwrite would silently bury the change. + +Matching is exact. The only conversions applied to the model's input are +the UTF-8 BOM and, for a CRLF file, a single LF→CRLF retry; both are +forced by what ``read`` shows the model, both are stated in the tool +description, and neither widens the match beyond the literal it was given. +There is no regex, whitespace, indentation or similarity fallback: with no +approval step between the match and the write, a fuzzy hit would let the +tool change more than the model asked for and report success. +""" + +import shlex +from pathlib import Path + +from anthropic.types import ToolParam + +from .read_tool import MAX_READ_BYTES + +# The terminal echo folds each side of the edit to this many lines, and any +# one line to this many characters. The full strings are already in the +# model's tool input, so the echo only has to show what is being swapped. +EDIT_PREVIEW_LINES = 6 +EDIT_PREVIEW_LINE_CHARS = 200 + +EDIT_TOOL: ToolParam = { + "name": "edit", + "description": ( + "Replace an exact stretch of text in an existing UTF-8 text file. " + "Prefer this over write for changing part of a file: only the old " + "and new text travel, and the rest of the file is left untouched. " + "Use write to create a file or rewrite it whole, and bash to " + "transform many files at once. The match is literal, not a regex, " + "and must be unique unless replace_all is set; nothing is trimmed, " + "re-indented or fuzzily matched, so a call that does not match " + "fails instead of guessing a place to edit. The two exceptions are " + "the UTF-8 BOM and line endings: in a CRLF file, a multi-line " + "old_text written with plain newlines still matches and is written " + "back with CRLF. On failure, read the file again rather than " + "resending the same call." + ), + "input_schema": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": ( + "Path to the file, absolute or relative to the agent's " + "working directory. A leading ~ is expanded. The file " + "must already exist." + ), + }, + "old_text": { + "type": "string", + "description": ( + "The text to replace, copied from the file character " + "for character — without read's line-number prefix. Two " + "to four lines are usually enough to be unique; add " + "surrounding lines when they are not." + ), + }, + "new_text": { + "type": "string", + "description": ( + "The replacement text. An empty string deletes old_text " + "exactly, taking nothing else with it." + ), + }, + "replace_all": { + "type": "boolean", + "description": ( + "Replace every occurrence instead of requiring a unique " + "one (default false). Use it only when every identical " + "occurrence should change, e.g. renaming a local " + "variable." + ), + }, + }, + "required": ["path", "old_text", "new_text"], + }, +} + + +def _to_crlf(text: str) -> str: + """Return ``text`` with every line ending written as CRLF.""" + return text.replace("\r\n", "\n").replace("\n", "\r\n") + + +def _fold(text: str, marker: str) -> str: + """Fold one side of an edit into a few marked, length-capped lines.""" + if not text: + return "" + lines = text.split("\n") + if lines and lines[-1] == "": + lines.pop() # the newline ending the last line starts no new one + shown = [ + line + if len(line) <= EDIT_PREVIEW_LINE_CHARS + else line[:EDIT_PREVIEW_LINE_CHARS] + "..." + for line in lines[:EDIT_PREVIEW_LINES] + ] + hidden = len(lines) - len(shown) + if hidden > 0: + shown.append(f"... (+{hidden} more lines)") + return "\n".join(f"{marker} {line}" for line in shown) + + +def edit_preview(old_text: str, new_text: str) -> str: + """Fold both sides of an edit into a small ``-``/``+`` diff for the echo. + + A deletion shows only the removed side, so an empty ``new_text`` reads + as a removal rather than as a swap for nothing. + """ + sides = (_fold(old_text, "-"), _fold(new_text, "+")) + return "\n".join(side for side in sides if side) + + +def _match(text: str, old_text: str, new_text: str) -> tuple[str, str, int, bool]: + """Locate ``old_text`` in ``text``; return what to use and how often. + + The first pass is a raw exact search. Only when it finds nothing does a + single CRLF pass follow, and only for the case that makes it necessary: + ``read`` drops the ``\\r`` of a CRLF file when it shows it, so a + multi-line ``old_text`` copied out of that view can hold LF alone — + the model cannot produce the file's real bytes. An ``old_text`` that + does carry ``\\r`` is taken at its word and gets the raw pass only. + + The two passes are never merged: whichever one matches decides both + uniqueness and the replacement count, so there is no way for hits in + different encodings to overlap or to be counted twice. In a file with + mixed line endings that means one call reaches one style and the other + fails closed, which the caller's error message says out loud. + + Returns ``(old, new, count, crlf_pass_ran)``. + """ + count = text.count(old_text) + if count: + return old_text, new_text, count, False + if "\r\n" in text and "\n" in old_text and "\r" not in old_text: + old_crlf = _to_crlf(old_text) + return old_crlf, _to_crlf(new_text), text.count(old_crlf), True + return old_text, new_text, 0, False + + +def run_edit( + path_str: str, old_text: str, new_text: str, replace_all: bool = False +) -> tuple[str, bool]: + """Replace ``old_text`` with ``new_text`` and return ``(output, is_error)``. + + The file has to exist and be a regular UTF-8 text file: creating one and + rewriting one whole are ``write``'s job, and an empty ``old_text`` is + refused rather than quietly becoming either. ``is_error`` is true for a + bad argument, a missing or unreadable target, a file this tool cannot + round-trip byte for byte, no match, or an ambiguous match. Every error + says what to do next — read again, add context, set ``replace_all``, + fall back to bash — instead of only what failed. + + Invalid UTF-8 and NUL bytes are refused: ``read`` shows those files with + replacement characters so they can be inspected, and writing that view + back would corrupt the original bytes for good. A UTF-8 BOM is stripped + before matching and restored on write, since ``read`` leaves it in place + and an invisible character would otherwise break every ``old_text`` + aimed at the first line. + + The write is a plain read-compute-write, like ``write``'s: no mtime + check, no compare-and-swap, no atomic replace. The unique ``old_text`` + is the precondition; in this agent's single-threaded loop the rest + would be pretend safety. + """ + if not old_text: + return ( + "[old_text is empty; use write to create a file or replace it whole]", + True, + ) + if old_text == new_text: + return "[old_text and new_text are identical: nothing to change]", True + + path = Path(path_str).expanduser() + if not path.exists(): + return f"[file not found: {path_str}; use write to create it]", True + if path.is_dir(): + return f"[{path_str} is a directory, not a file]", True + if not path.is_file(): + # Same reasoning as read's and write's: a FIFO blocks until the other + # end shows up, and a device file is not workspace text. + return f"[{path_str} is not a regular file; edit is text-only]", True + try: + size = path.stat().st_size + if size > MAX_READ_BYTES: + return ( + f"[{path_str} is {size} bytes, over the {MAX_READ_BYTES}-byte " + f"cap; edit rewrites the whole file in memory, so change it " + f"with bash instead, e.g. with sed -i]", + True, + ) + data = path.read_bytes() + except FileNotFoundError: # the file went away after the check above + return f"[file not found: {path_str}]", True + except OSError as exc: + return f"[cannot read {path_str}: {exc}]", True + + # Unlike read, which samples the head to spot a binary file, edit has to + # scan all of it: a NUL anywhere would come back through the round-trip. + if b"\x00" in data: + return f"[{path_str} contains NUL bytes; edit is text-only]", True + try: + text = data.decode("utf-8") # strict: no replacement characters + except UnicodeDecodeError as exc: + return ( + f"[{path_str} is not valid UTF-8: {exc}; edit only changes text " + f"it can write back byte for byte — use bash instead]", + True, + ) + + bom = text.startswith("\ufeff") + if bom: + text = text[1:] + + old, new, count, crlf_pass_ran = _match(text, old_text, new_text) + if count == 0: + retried = " — the CRLF form was already tried" if crlf_pass_ran else "" + return ( + f"[no match for old_text in {path_str}; read the file again and " + f"copy the text exactly{retried}]", + True, + ) + if count > 1 and not replace_all: + return ( + f"[old_text matches {count} times in {path_str}; add surrounding " + f"context to make it unique, or set replace_all=true]", + True, + ) + + # str.replace walks left to right over non-overlapping matches, the same + # ones str.count reported, so the count above is what actually changes. + line = text.count("\n", 0, text.index(old)) + 1 + edited = text.replace(old, new) if replace_all else text.replace(old, new, 1) + try: + path.write_bytes(("\ufeff" + edited if bom else edited).encode("utf-8")) + except OSError as exc: + return ( + f"[cannot write {path_str}: {exc}; inspect it with bash, e.g. " + f"ls -l -- {shlex.quote(str(path))}]", + True, + ) + + replaced = count if replace_all else 1 + if replaced == 1: + return f"[edited {path_str}: replaced 1 occurrence at line {line}]", False + return ( + f"[edited {path_str}: replaced {replaced} occurrences, " + f"first at line {line}]", + False, + ) diff --git a/tests/helpers.py b/tests/helpers.py index 2226ff7..246939b 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -38,6 +38,13 @@ def write_tool_use_block(block_id, **arguments): ) +def edit_tool_use_block(block_id, **arguments): + """A minimal stand-in for an ``edit`` tool_use content block.""" + return SimpleNamespace( + type="tool_use", id=block_id, name="edit", input=arguments + ) + + def write_settings(path, env): """Write a ``settings.json`` with the given ``env`` mapping.""" path.write_text(json.dumps({"env": env}), encoding="utf-8") diff --git a/tests/test_agent.py b/tests/test_agent.py index 9cf3f21..092cc6c 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -13,6 +13,7 @@ FakeClient, FakeMessages, FakeStream, + edit_tool_use_block, patch_client_and_input, read_tool_use_block, text_block, @@ -274,6 +275,92 @@ def test_tool_use_turn_runs_read_and_feeds_result_back(monkeypatch, capsys, tmp_ } +def test_tool_use_turn_runs_edit_and_feeds_result_back(monkeypatch, capsys, tmp_path): + target = tmp_path / "app.py" + target.write_text("alpha\nbeta\n", encoding="utf-8") + tool_turn = FakeStream( + [ + edit_tool_use_block( + "tu_1", path=str(target), old_text="beta", new_text="BETA" + ) + ], + stop_reason="tool_use", + ) + final = [text_block("done")] + messages = FakeMessages([tool_turn, final]) + client = FakeClient(messages) + patch_client_and_input(monkeypatch, client=client, inputs=["edit it", "/exit"]) + + agent.run() + + out = capsys.readouterr().out + # The echo names the target and previews the swap on the next lines. + assert f"[edit] {target}\n" in out + assert "- beta" in out + assert "+ BETA" in out + assert target.read_text(encoding="utf-8") == "alpha\nBETA\n" + assert messages.calls[1][-1] == { + "role": "user", + "content": [ + { + "type": "tool_result", + "tool_use_id": "tu_1", + "content": f"[edited {target}: replaced 1 occurrence at line 2]", + "is_error": False, + } + ], + } + + +def test_edits_in_one_reply_see_the_previous_result(monkeypatch, capsys, tmp_path): + target = tmp_path / "app.py" + target.write_text("one\ntwo\n", encoding="utf-8") + tool_turn = FakeStream( + [ + edit_tool_use_block("tu_1", path=str(target), old_text="one", new_text="1"), + edit_tool_use_block("tu_2", path=str(target), old_text="two", new_text="2"), + ], + stop_reason="tool_use", + ) + final = [text_block("done")] + messages = FakeMessages([tool_turn, final]) + client = FakeClient(messages) + patch_client_and_input(monkeypatch, client=client, inputs=["edit it", "/exit"]) + + agent.run() + + # The calls run in the order the model returned them, each reading what + # the one before it wrote. + assert target.read_text(encoding="utf-8") == "1\n2\n" + results = messages.calls[1][-1]["content"] + assert [r["tool_use_id"] for r in results] == ["tu_1", "tu_2"] + assert all(r["is_error"] is False for r in results) + + +def test_failed_edit_is_reported_as_an_error_result(monkeypatch, capsys, tmp_path): + target = tmp_path / "app.py" + target.write_text("alpha\n", encoding="utf-8") + tool_turn = FakeStream( + [ + edit_tool_use_block( + "tu_1", path=str(target), old_text="missing", new_text="x" + ) + ], + stop_reason="tool_use", + ) + final = [text_block("done")] + messages = FakeMessages([tool_turn, final]) + client = FakeClient(messages) + patch_client_and_input(monkeypatch, client=client, inputs=["edit it", "/exit"]) + + agent.run() + + result = messages.calls[1][-1]["content"][0] + assert result["is_error"] is True # the model is told to recover, not retry blind + assert "no match for old_text" in result["content"] + assert target.read_text(encoding="utf-8") == "alpha\n" + + def test_tool_use_turn_runs_multiple_tool_calls(monkeypatch, capsys): tool_turn = FakeStream( [tool_use_block("tu_1", "echo one"), tool_use_block("tu_2", "echo two")], diff --git a/tests/test_edit_tool.py b/tests/test_edit_tool.py new file mode 100644 index 0000000..5d6b589 --- /dev/null +++ b/tests/test_edit_tool.py @@ -0,0 +1,382 @@ +"""Tests for the ``edit`` tool's execution (``edit_tool.py``). + +These edit real files under pytest's ``tmp_path`` and assert on bytes +wherever line endings, a BOM or trailing whitespace are at stake: the whole +point of the tool is that everything outside the matched span comes back +unchanged. The preview caps are patched down so the folding cases stay +small. +""" + +import os + +import pytest + +from nanopycodeagent import edit_tool + + +def test_unique_match_is_replaced(tmp_path): + path = tmp_path / "app.py" + path.write_text("alpha\nbeta\ngamma\n", encoding="utf-8") + + output, is_error = edit_tool.run_edit(str(path), "beta", "BETA") + + assert path.read_text(encoding="utf-8") == "alpha\nBETA\ngamma\n" + assert output == f"[edited {path}: replaced 1 occurrence at line 2]" + assert is_error is False + + +def test_multi_line_replacement_reports_the_first_changed_line(tmp_path): + path = tmp_path / "app.py" + path.write_text("a\nb\nc\nd\ne\n", encoding="utf-8") + + output, _ = edit_tool.run_edit(str(path), "c\nd", "C\nD") + + assert path.read_text(encoding="utf-8") == "a\nb\nC\nD\ne\n" + assert "at line 3" in output + + +def test_empty_new_text_deletes_exactly_and_nothing_more(tmp_path): + path = tmp_path / "app.py" + path.write_text("keep\ndrop\nkeep\n", encoding="utf-8") + + # The newline after the match is not swept along with it: deleting + # "drop" leaves the blank line its own newline made. + output, is_error = edit_tool.run_edit(str(path), "drop", "") + + assert path.read_text(encoding="utf-8") == "keep\n\nkeep\n" + assert is_error is False + assert output.startswith(f"[edited {path}:") + + +def test_unicode_text_is_replaced_as_utf8(tmp_path): + path = tmp_path / "cn.txt" + path.write_text("你好\n世界\n", encoding="utf-8") + + output, is_error = edit_tool.run_edit(str(path), "世界", "地球") + + assert path.read_bytes() == "你好\n地球\n".encode("utf-8") + assert is_error is False + + +def test_identical_old_and_new_text_is_an_error(tmp_path): + path = tmp_path / "app.py" + path.write_text("same\n", encoding="utf-8") + + output, is_error = edit_tool.run_edit(str(path), "same", "same") + + assert path.read_text(encoding="utf-8") == "same\n" # untouched + assert "identical" in output + assert is_error is True + + +def test_empty_old_text_points_at_write(tmp_path): + path = tmp_path / "app.py" + path.write_text("content\n", encoding="utf-8") + + output, is_error = edit_tool.run_edit(str(path), "", "new") + + assert path.read_text(encoding="utf-8") == "content\n" + assert "write" in output # creation and whole-file rewrites stay there + assert is_error is True + + +def test_no_match_fails_and_leaves_the_file_alone(tmp_path): + path = tmp_path / "app.py" + path.write_text("alpha\nbeta\n", encoding="utf-8") + + output, is_error = edit_tool.run_edit(str(path), "missing", "x") + + assert path.read_text(encoding="utf-8") == "alpha\nbeta\n" + assert "no match for old_text" in output + assert "read the file again" in output # the error says how to recover + assert is_error is True + + +def test_repeated_match_reports_the_count_instead_of_guessing(tmp_path): + path = tmp_path / "app.py" + path.write_text("x = 1\ny = 1\nz = 1\n", encoding="utf-8") + + output, is_error = edit_tool.run_edit(str(path), "1", "2") + + assert path.read_text(encoding="utf-8") == "x = 1\ny = 1\nz = 1\n" + assert "matches 3 times" in output + assert "replace_all=true" in output + assert is_error is True + + +def test_replace_all_changes_every_occurrence_and_counts_them(tmp_path): + path = tmp_path / "app.py" + path.write_text("x = 1\ny = 1\nz = 1\n", encoding="utf-8") + + output, is_error = edit_tool.run_edit(str(path), "= 1", "= 2", replace_all=True) + + assert path.read_text(encoding="utf-8") == "x = 2\ny = 2\nz = 2\n" + assert output == f"[edited {path}: replaced 3 occurrences, first at line 1]" + assert is_error is False + + +def test_old_text_is_a_literal_not_a_regex(tmp_path): + path = tmp_path / "app.py" + path.write_text("a.b\naxb\n", encoding="utf-8") + + # As a regex, "a.b" would match both lines and the first hit would be + # ambiguous; as a literal it matches exactly one. + output, is_error = edit_tool.run_edit(str(path), "a.b", "OK") + + assert path.read_text(encoding="utf-8") == "OK\naxb\n" + assert is_error is False + + +def test_dedented_old_text_does_not_match(tmp_path): + path = tmp_path / "app.py" + path.write_text("def f(x):\n if x:\n return 1\n", encoding="utf-8") + + # No indentation flexibility: the model has to send what is in the file. + output, is_error = edit_tool.run_edit( + str(path), "if x:\n return 1", "if x:\n return 2" + ) + + assert "no match for old_text" in output + assert is_error is True + + +def test_trailing_whitespace_difference_does_not_match(tmp_path): + path = tmp_path / "app.py" + path.write_text("value = 1\n", encoding="utf-8") + + # No whitespace normalization either: "value = 1" is simply not there. + output, is_error = edit_tool.run_edit(str(path), "value = 1", "value = 2") + + assert "no match for old_text" in output + assert is_error is True + + +def test_untouched_bytes_survive_an_edit(tmp_path): + path = tmp_path / "raw.txt" + # Trailing spaces, a CRLF line, and no final newline: all of it has to + # come back byte for byte outside the matched span. + path.write_bytes(b"one \r\ntwo\nthree") + + output, is_error = edit_tool.run_edit(str(path), "three", "3") + + assert path.read_bytes() == b"one \r\ntwo\n3" + assert is_error is False + + +def test_lf_old_text_matches_a_crlf_file_and_is_written_back_as_crlf(tmp_path): + path = tmp_path / "crlf.txt" + path.write_bytes(b"one\r\ntwo\r\nthree\r\n") + + # read shows CRLF files without the \r, so a multi-line old_text copied + # out of that view can only hold LF; the retry is what makes it land. + output, is_error = edit_tool.run_edit(str(path), "one\ntwo", "1\n2") + + assert path.read_bytes() == b"1\r\n2\r\nthree\r\n" + assert is_error is False + + +def test_the_crlf_pass_counts_its_own_occurrences(tmp_path): + path = tmp_path / "crlf.txt" + path.write_bytes(b"a\r\nb\r\na\r\nb\r\n") + + output, is_error = edit_tool.run_edit( + str(path), "a\nb", "X\nY", replace_all=True + ) + + assert path.read_bytes() == b"X\r\nY\r\nX\r\nY\r\n" + assert "replaced 2 occurrences" in output + assert is_error is False + + +def test_mixed_endings_reach_one_style_per_call(tmp_path): + path = tmp_path / "mixed.txt" + path.write_bytes(b"a\r\nb\r\na\nb\n") + + # The raw pass finds the LF pair, so the CRLF pass never runs and the + # CRLF pair is left alone — one call, one line-ending style. + output, is_error = edit_tool.run_edit(str(path), "a\nb", "X\nY") + + assert path.read_bytes() == b"a\r\nb\r\nX\nY\n" + assert "replaced 1 occurrence" in output + assert is_error is False + + +def test_single_line_old_text_spans_both_ending_styles(tmp_path): + path = tmp_path / "mixed.txt" + path.write_bytes(b"x\r\nx\n") + + # An old_text without a newline is line-ending agnostic: the raw pass + # finds every occurrence and no retry is needed. + output, is_error = edit_tool.run_edit(str(path), "x", "y", replace_all=True) + + assert path.read_bytes() == b"y\r\ny\n" + assert "replaced 2 occurrences" in output + assert is_error is False + + +def test_old_text_carrying_cr_gets_the_raw_pass_only(tmp_path): + path = tmp_path / "crlf.txt" + path.write_bytes(b"one\r\ntwo\r\n") + + # An explicit \r is taken at face value: it either matches the real + # bytes or it fails, with no second guess. + output, is_error = edit_tool.run_edit(str(path), "one\r\ntwo", "1\r\n2") + assert path.read_bytes() == b"1\r\n2\r\n" + assert is_error is False + + output, is_error = edit_tool.run_edit(str(path), "nope\r\nhere", "x") + assert "no match for old_text" in output + assert "CRLF" not in output # no retry ran, so none is claimed + assert is_error is True + + +def test_a_failed_crlf_retry_says_it_was_tried(tmp_path): + path = tmp_path / "crlf.txt" + path.write_bytes(b"one\r\ntwo\r\n") + + output, is_error = edit_tool.run_edit(str(path), "nope\nhere", "x") + + assert "CRLF form was already tried" in output + assert is_error is True + + +def test_bom_is_stripped_for_matching_and_restored_on_write(tmp_path): + path = tmp_path / "bom.py" + path.write_bytes("\ufeffimport os\nimport sys\n".encode("utf-8")) + + # read leaves the BOM in place, so an old_text aimed at the first line + # would never match unless the tool takes it off first. + output, is_error = edit_tool.run_edit(str(path), "import os", "import io") + + assert path.read_bytes() == "\ufeffimport io\nimport sys\n".encode("utf-8") + assert is_error is False + + +def test_invalid_utf8_is_refused_instead_of_round_tripped(tmp_path): + path = tmp_path / "broken.txt" + path.write_bytes(b"ok \xff\xfe still here\n") + + output, is_error = edit_tool.run_edit(str(path), "ok", "fine") + + assert path.read_bytes() == b"ok \xff\xfe still here\n" # bytes preserved + assert "not valid UTF-8" in output + assert is_error is True + + +def test_nul_bytes_are_refused(tmp_path): + path = tmp_path / "binary.bin" + path.write_bytes(b"text\x00more text\n") + + output, is_error = edit_tool.run_edit(str(path), "text", "TEXT") + + assert path.read_bytes() == b"text\x00more text\n" + assert "NUL" in output + assert is_error is True + + +def test_missing_file_points_at_write(tmp_path): + output, is_error = edit_tool.run_edit(str(tmp_path / "gone.py"), "a", "b") + + assert "file not found" in output + assert "write" in output # edit never creates + assert is_error is True + + +def test_directory_target_is_an_error(tmp_path): + output, is_error = edit_tool.run_edit(str(tmp_path), "a", "b") + + assert "is a directory" in output + assert is_error is True + + +@pytest.mark.skipif(not hasattr(os, "mkfifo"), reason="POSIX-only file type") +def test_fifo_is_rejected_instead_of_blocking(tmp_path): + path = tmp_path / "pipe" + os.mkfifo(path) + + output, is_error = edit_tool.run_edit(str(path), "a", "b") + + assert "not a regular file" in output + assert is_error is True + + +def test_file_over_the_byte_cap_is_turned_away(tmp_path, monkeypatch): + path = tmp_path / "big.txt" + path.write_text("alpha\nbeta\n", encoding="utf-8") + monkeypatch.setattr(edit_tool, "MAX_READ_BYTES", 4) + + output, is_error = edit_tool.run_edit(str(path), "alpha", "ALPHA") + + assert path.read_text(encoding="utf-8") == "alpha\nbeta\n" + assert "over the 4-byte cap" in output + assert "bash" in output # the error names the way to edit it anyway + assert is_error is True + + +def test_tilde_is_expanded(tmp_path, monkeypatch): + monkeypatch.setenv("HOME", str(tmp_path)) + (tmp_path / "home.txt").write_text("hello\n", encoding="utf-8") + + output, is_error = edit_tool.run_edit("~/home.txt", "hello", "hi") + + assert (tmp_path / "home.txt").read_text(encoding="utf-8") == "hi\n" + assert is_error is False + + +def test_relative_path_resolves_against_the_working_directory(tmp_path, monkeypatch): + (tmp_path / "notes.txt").write_text("old\n", encoding="utf-8") + monkeypatch.chdir(tmp_path) + + output, is_error = edit_tool.run_edit("notes.txt", "old", "new") + + assert (tmp_path / "notes.txt").read_text(encoding="utf-8") == "new\n" + assert is_error is False + + +def test_symlink_to_a_regular_file_edits_its_target(tmp_path): + target = tmp_path / "target.txt" + target.write_text("old\n", encoding="utf-8") + link = tmp_path / "link.txt" + link.symlink_to(target) + + output, is_error = edit_tool.run_edit(str(link), "old", "new") + + assert target.read_text(encoding="utf-8") == "new\n" + assert link.is_symlink() # the link itself was not replaced + assert is_error is False + + +def test_edits_apply_one_after_another(tmp_path): + path = tmp_path / "app.py" + path.write_text("one\ntwo\n", encoding="utf-8") + + edit_tool.run_edit(str(path), "one", "1") + # The second call reads what the first one wrote, so the model can chain + # edits within a single reply. + output, is_error = edit_tool.run_edit(str(path), "two", "2") + + assert path.read_text(encoding="utf-8") == "1\n2\n" + assert is_error is False + + +def test_preview_shows_both_sides_of_a_short_edit(): + assert edit_tool.edit_preview("old\n", "new\n") == "- old\n+ new" + + +def test_preview_of_a_deletion_shows_only_the_removed_side(): + assert edit_tool.edit_preview("gone\n", "") == "- gone" + + +def test_preview_folds_long_sides(monkeypatch): + monkeypatch.setattr(edit_tool, "EDIT_PREVIEW_LINES", 2) + old = "".join(f"l{n}\n" for n in range(1, 6)) + + assert edit_tool.edit_preview(old, "x\n") == ( + "- l1\n- l2\n- ... (+3 more lines)\n+ x" + ) + + +def test_preview_cuts_an_over_long_line(monkeypatch): + monkeypatch.setattr(edit_tool, "EDIT_PREVIEW_LINE_CHARS", 5) + + assert edit_tool.edit_preview("abcdefgh\n", "z\n") == "- abcde...\n+ z"