Agent CLI深水区 - 编辑器Diff与补丁引擎
Agent CLI 深水区 07:编辑器、Diff 与补丁引擎
为什么编辑不是简单 writeFile
让 Agent 改代码,模型写不出代码反而是小事,真正危险的是下面这些事故:
- 覆盖用户未保存内容。
- 替换错位置。
- 破坏编码和换行。
- 大文件整体重写导致 diff 混乱。
- 写入后没有验证。
所以成熟 Agent CLI 通常不会只有 write_file,还会有更精细的编辑工具。
三种编辑方式
先把三种方式分清,后面整篇都围绕它们展开。表里的 patch 指补丁:用 diff 格式描述“哪几行删、哪几行加”,一份 patch 可以同时描述多处修改。
| 方式 | 说明 | 适合 |
|---|---|---|
| full write | 整个文件重写 | 新文件、小文件 |
| string replace | 用 old/new 替换 | 精确小改 |
| patch apply | 统一 diff / patch | 多处修改 |
入门最容易实现的是 string replace:只需要字符串查找和替换,不用解析 diff 格式。
EditInput
代码块收起展开
// EditInput = 模型调用 edit_file 时需要传入的参数。
// 这个工具只适合“小范围精确替换”,不适合整文件重写。
type EditInput = {
path: string; // 要编辑的文件路径,必须解析到 workspace 内。
oldText: string; // 当前文件里必须精确存在的旧文本,相当于编辑锚点。
newText: string; // 要替换成的新文本。
replaceAll?: boolean;// 是否替换所有命中。默认 false,避免误改多个位置。
};为什么要 oldText?
因为它是防误改的锚点。举个例子:文件里有三处 timeout = 30,如果工具只接收“把 30 改成 60”这种指令,根本不知道该改哪一处。要求模型传入 oldText: "connectTimeout = 30" 这样带上下文的精确原文,等于强迫它证明自己刚读过文件、知道要替换的是哪段。
editFile 实现
代码块收起展开
// editFile = 精确字符串替换版编辑工具。
// 职责:校验路径 -> 读取文件 -> 校验 oldText -> 生成 diff -> 请求批准 -> 写入。
export async function editFile(input: EditInput, ctx: ToolContext): Promise<ToolResult> {
// 把用户/模型给的相对路径解析成绝对路径,并限制在工作区内。
// 这一步防止 ../../ 逃逸到 workspace 外。
const abs = resolveWorkspacePath(ctx.cwd, input.path);
// 读取当前文件内容。注意真实系统还要检测编码、BOM、二进制文件。
const original = await readFile(abs, "utf8");
// 统计 oldText 出现次数。编辑工具的第一条安全线就是“锚点必须明确”。
const count = countOccurrences(original, input.oldText);
// 找不到 oldText,说明模型上下文过期或路径错了,必须拒绝。
if (count === 0) {
throw new AgentError("TOOL_INPUT_INVALID", "oldText not found.", {
path: input.path
});
}
// oldText 出现多次时默认拒绝,因为模型可能只想改其中一处。
// 除非模型显式传 replaceAll=true,才允许全部替换。
if (count > 1 && !input.replaceAll) {
throw new AgentError("TOOL_INPUT_INVALID", "oldText appears multiple times.", {
path: input.path,
count
});
}
// 在内存里生成新内容,还没有落盘。
const updated = input.replaceAll
? original.split(input.oldText).join(input.newText)
: original.replace(input.oldText, input.newText);
// diff 必须由程序根据 old/new 生成,不能让模型自己描述“我改了什么”。
const diff = createUnifiedDiff(original, updated, input.path);
// 写入前让用户/权限系统看到真实 diff。
await requireWriteApproval({
path: input.path,
diff
});
// 最后才写文件。真实系统建议用 atomic write,而不是普通 writeFile。
await writeFile(abs, updated, "utf8");
// 工具返回 diff 和替换次数,模型下一步可以据此决定是否跑测试。
return {
summary: `Edited ${input.path}`,
content: diff,
metadata: {
replacements: input.replaceAll ? count : 1
}
};
}这段代码的保护点:
- 找不到 oldText 就拒绝。
- oldText 多次出现就拒绝,除非明确 replaceAll。
- 写入前生成 diff。
- 写入前要求批准。
- 返回 diff 给模型和用户。
为什么不能盲目 replace
坏写法:
代码块收起展开
content.replace("config", "settings")问题:
- 可能替换注释。
- 可能替换字符串。
- 可能替换多个无关位置。
- 模型以为改了一处,实际改了多处。
所以 Agent 编辑工具必须要求精确上下文。
Unified Diff
diff 的意义是让用户和 Agent 都看到“改了什么”。Unified Diff 就是 git diff 输出的那种格式:--- 和 +++ 两行标出旧文件和新文件,正文里 - 开头是删掉的行,+ 开头是新增的行,没有前缀的是用来定位的上下文行。
比如把默认模型从 sonnet 换成 opus,diff 长这样:
代码块收起展开
--- a/src/config.ts
+++ b/src/config.ts
export const config = {
- model: "sonnet",
+ model: "opus",
};简化实现:
代码块收起展开
// createUnifiedDiff = 把修改前/后的文本转换成统一 diff。
// 这里是教学版:真实项目建议用 diff 库处理复杂行变更。
function createUnifiedDiff(oldText: string, newText: string, path: string) {
// 按行比较。这里先假设输入已经处理好 CRLF/LF。
const oldLines = oldText.split("\n");
const newLines = newText.split("\n");
return [
// diff 头:a/ 表示旧文件,b/ 表示新文件。
`--- a/${path}`,
`+++ b/${path}`,
// simpleLineDiff 负责产出以 +、-、空格开头的变更行。
...simpleLineDiff(oldLines, newLines)
].join("\n");
}真实项目建议用成熟 diff 库。自己手写 diff 容易在复杂场景下错。
写入前后都要检查
写入前:
- 路径安全。
- oldText 唯一。
- diff 可展示。
- 用户批准。
写入后:
- 文件能重新读取。
- 内容包含 newText。
- 可选:运行 formatter(代码格式化工具,类似 Java 里的 google-java-format)。
- 可选:运行测试。
代码块收起展开
// verifyEdit = 写入后的最小回读校验。
// 它不能证明语义正确,但能证明文件确实写进去了。
async function verifyEdit(abs: string, expected: string) {
// 重新从磁盘读取,而不是相信内存里的 updated。
const after = await readFile(abs, "utf8");
// 如果新内容没出现,说明写入失败、写错文件或被其他进程覆盖。
if (!after.includes(expected)) {
throw new AgentError("TOOL_EXECUTION_FAILED", "Edit verification failed.");
}
}编码和换行
先说清两个缩写:LF 就是 \n(Linux/macOS 的换行),CRLF 是 \r\n(回车加换行,Windows 的习惯)。Windows 项目常见 CRLF。不要随便把整文件改成 LF,否则 diff 里会显示每一行都被改过。
代码块收起展开
// detectLineEnding = 判断原文件主要使用哪种换行。
// Windows 项目常见 CRLF,Unix/macOS 项目常见 LF。
function detectLineEnding(text: string) {
return text.includes("\r\n") ? "\r\n" : "\n";
}
// preserveLineEnding = 把更新后的文本换行统一回原文件风格。
// 目的:避免因为一次小修改导致全文件换行都变了,diff 爆炸。
function preserveLineEnding(original: string, updated: string) {
const ending = detectLineEnding(original);
return updated.replace(/\r?\n/g, ending);
}写入前:
代码块收起展开
const updatedWithOriginalEnding = preserveLineEnding(original, updated);Patch Apply
更高级的编辑工具支持 patch。先解释一个词:hunk 指 diff 里一段连续的修改块,git diff 输出里每个 @@ -x,y +m,n @@ 开头的段落就是一个 hunk,一份 patch 由若干个 hunk 组成:
代码块收起展开
// PatchInput = 更高级的补丁输入。
// 它不是简单 oldText/newText,而是多个 hunk 组成的结构化 diff。
type PatchInput = {
path: string; // 要应用 patch 的文件。
hunks: Array<{
oldStart: number; // patch 认为旧文件从第几行开始匹配。
oldLines: string[]; // 需要在旧文件中找到的上下文/删除行。
newLines: string[]; // 应用后要变成的新行。
}>;
};patch apply 的难点:
- 行号可能过期:patch 是模型基于早些时候读到的文件生成的,这期间文件可能已经被改过。
- 上下文可能变了。
- 多 hunk 之间会互相影响行号。
- 失败时要精确告诉哪一段失败。
Patch 失败如何恢复
当 patch 失败,不要让模型继续猜。
返回:
代码块收起展开
Patch failed at hunk 2.
Expected context:
...
Actual nearby content:
...
Please re-read the file before generating a new patch.这样模型下一轮会先读文件,而不是继续用过期上下文。
Edit Tool 的审计
代码块收起展开
// EditAudit = 编辑审计记录。
// 真实 Agent CLI 应该能回答:谁在什么时候改了哪个文件、改前改后是什么版本。
type EditAudit = {
sessionId: string; // 哪个会话发起的编辑。
path: string; // 被修改的文件。
editType: "replace" | "write" | "patch"; // 编辑方式:精确替换、整文件写入、patch。
oldHash: string; // 修改前文件 hash。
newHash: string; // 修改后文件 hash。
diff: string; // 真实 diff,用于审查和回放。
approvedByUser: boolean; // 是否经过用户/策略批准。
time: string; // 发生时间。
};为什么要 hash?
因为 diff 可能很长,hash 能快速确认文件版本。
模型如何学会正确编辑
工具描述要写清楚:
代码块收起展开
Use edit_file for small, precise replacements.
oldText must match the current file exactly.
If oldText is not unique, read the file again and include more surrounding context.
Do not use write_file for modifying existing large files unless replacing the whole file is intended.工具描述不是随便写,它会直接影响模型调用方式。
编辑系统常见坑
这张表按“坑、踩了会怎样、怎么修”三列来读,修复手段全是前文出现过的机制:
| 坑 | 后果 | 修复 |
|---|---|---|
| 整文件重写 | diff 巨大,易覆盖用户改动 | 小范围 edit |
| oldText 不唯一 | 改错位置 | 要求唯一或 replaceAll |
| 不看 diff | 用户无法审查 | 写前 diff preview |
| 不保留换行 | 全文件变更 | 检测 CRLF/LF |
| patch 失败继续猜 | 越改越乱 | 要求 re-read |
| 写完不验证 | 以为成功实际失败 | read back check |
读源码时看编辑引擎
重点找:
- 有哪些编辑工具。
- 是否区分 write/edit/patch。
- oldText 是否必须唯一。
- diff 在哪里生成。
- 用户批准发生在写前还是写后。
- 是否保留换行和编码。
- patch 失败如何反馈。
- 写入后是否验证。
编辑引擎决定 Agent 是“可靠协作”,还是“随机覆盖文件”。
从一次真实编辑看全链路
假设用户说:
把
src/config.ts里的默认模型从sonnet改成opus,顺便更新测试。
成熟 Agent CLI 不应该立刻写文件,而是走一条每一步都留下记录、事后能查的链路。图里第二个框 QueryEngine 是对话主循环(第 06 篇拆过的那个模块):它负责收用户消息、调模型、把模型要求的工具调用派发出去,整条编辑链路就是由它一步步调度的:
这条链路的重点是每一步都有明确边界,有点像 Servlet 过滤器链:请求要一层层通过检查,才能到达真正落盘那一步。各环节的分工:
- 搜索负责定位,不让模型凭记忆猜路径。
- 读取负责建立当前事实,不让模型使用过期上下文。
- 编辑引擎负责把意图转换成可验证变更。
- 权限层负责在落盘前让人看见 diff。
- 测试负责证明改动没有破坏行为。
- Trace(完整的执行记录:每一步调了什么工具、传了什么参数、返回了什么)负责让失败能回放。
文件状态模型
编辑系统至少要分清四个关键节点:文件读到了没有(Loaded)、用户批准了没有(Approved)、写进磁盘没有(Written)、写完验证过没有(Verified)。
这四个是底线,少了任何一个都会出前面说的事故。下面的状态机在这四个节点之间又补了几个中间态,把路拆得更细:Unknown(还没读文件)、Planned(编辑计划已生成但还没出 diff)、Preview(diff 已经摆给用户看、等批准),一共七个主状态。
另外还有两个失败分支:Stale 指读完之后文件被别人改了、hash 对不上,Rejected 指用户在预览时按了拒绝,两条虚线都退回 Loaded 重新读文件再来:
关键点:Loaded 到 Written 之间,文件可能被用户或另一个进程改掉。
所以编辑工具不能只依赖路径和 oldText,还应该记录读取时的 hash。
代码块收起展开
// LoadedFile = Agent 读到的文件快照。
// 重点是 hash:后续写入前要确认磁盘文件还是这个版本。
type LoadedFile = {
path: string; // 文件路径。
text: string; // 读取到的完整文本。
hash: string; // 读取时的内容 hash,用作乐观锁。
lineEnding: "\n" | "\r\n"; // 原始换行风格。
encoding: "utf8" | "utf8-bom" | "unknown"; // 原始编码信息,写回时要尽量保持。
};
// EditPlan = 尚未落盘的编辑计划。
// baseHash 表示“这个计划基于哪个文件版本生成”。
type EditPlan = {
path: string; // 要修改的文件。
baseHash: string; // 生成计划时文件的 hash。
operations: EditOperation[]; // 一个或多个编辑操作,例如 replace、insert、delete。
};写入前重新读一次:
代码块收起展开
// assertNotStale = 写入前的乐观锁检查。
// 如果用户或另一个工具在中途改了文件,就拒绝继续写。
async function assertNotStale(plan: EditPlan, abs: string) {
// 写入前重新读当前磁盘内容。
const current = await readFile(abs, "utf8");
// 计算当前版本 hash。
const currentHash = sha256(current);
// 如果当前 hash 和计划里的 baseHash 不同,说明上下文过期。
if (currentHash !== plan.baseHash) {
throw new AgentError("FILE_STALE", "File changed after it was read.", {
path: plan.path,
expectedHash: plan.baseHash,
actualHash: currentHash
});
}
}这就是乐观锁。它和数据库里的 version 字段是同一个思想:我可以修改,但必须证明我修改的是刚才读到的那个版本。
oldText 精确匹配为什么还不够
oldText 唯一匹配只能防一类错,防不了全部错。
| 风险 | oldText 能否发现 | 需要额外机制 |
|---|---|---|
| 找不到文本 | 能 | 返回失败并要求重读 |
| 同一文本多处出现 | 能 | 要求更多上下文 |
| 文件被用户改过 | 不一定 | baseHash / mtime 检查 |
| 换行符被全文件转换 | 不能 | 行尾检测与保持 |
| 编码从 UTF-8 BOM 变无 BOM | 不能 | 编码探测 |
| 目标是生成文件 | 不能 | generated file guard |
| 语义改错位置 | 不能 | AST / 测试 / 人审 |
| 二进制文件被当文本 | 不能 | binary detection |
代码块收起展开
表里几个词展开说一下:mtime 指文件的最后修改时间;
UTF-8 BOM 是文件开头可选的三个标记字节(EF BB BF,Windows 记事本经常写入),读写时把它弄丢,有些工具打开文件就会出问题;
generated file guard 指对自动生成的文件(编译产物、lock 文件这类)加保护,它们本来就不该手改;
AST 是抽象语法树,把代码解析成结构之后才能判断改的是不是语义上正确的位置;binary detection 指先判断文件是不是二进制,图片或 class 文件被当成文本做替换会直接损坏。
所以“可用编辑工具”通常是组合防线:
- path allowlist
- binary check
- file size limit
- baseHash
- oldText uniqueness
- diff preview
- approval
- atomic write
- read-back verification
- optional formatter/test
这串防线每一层单独看都简单,价值在叠加:任何一层拦下,写入就不会发生。
原子写入
原子写入的“原子”和数据库事务的原子性是同一个意思:要么新内容完整落盘,要么文件保持原样,不允许出现写了一半的中间状态。直接 writeFile(abs, updated) 做不到,有两个问题:
- 写到一半进程崩了,文件可能损坏。
- Windows 上杀进程、杀终端时更容易留下半文件。
更稳妥的写法:
代码块收起展开
// atomicWriteText = 原子写入教学版。
// 思路:先写临时文件,再 rename 覆盖目标文件,降低半写入风险。
async function atomicWriteText(abs: string, text: string) {
// 临时文件放在同目录,避免跨磁盘 rename 失败。
const dir = dirname(abs);
// 带 pid 是为了减少多个进程同时写临时文件时重名。
const tmp = join(dir, `.${basename(abs)}.${process.pid}.tmp`);
// 先把完整内容写到临时文件。
await writeFile(tmp, text, "utf8");
// rename 在同一文件系统内通常是原子替换。
await rename(tmp, abs);
}真实系统还要处理:
- 目标文件权限。
- tmp 文件清理。
- 跨磁盘 rename 失败。
- Windows 文件被占用。
- antivirus / editor lock。
如果写失败,不要假装成功,要把错误归类:
代码块收起展开
// WriteFailureKind = 写入失败分类。
// 分类越具体,模型越容易知道下一步是重试、重读,还是请求用户处理权限。
type WriteFailureKind =
| "permission_denied" // 没有写权限。
| "file_locked" // 文件被编辑器、杀毒软件或其他进程占用。
| "path_not_found" // 目录或文件不存在。
| "disk_full" // 磁盘空间不足。
| "stale_file" // 文件版本变了,不能基于旧上下文写。
| "unknown"; // 未分类错误,应该保留原始错误信息。错误越具体,模型越容易下一步恢复。
Patch Engine 的核心对象
不要把 patch 当字符串处理。它应该被解析成结构。
代码块收起展开
// Patch = 一个完整补丁,可能涉及多个文件。
type Patch = {
files: FilePatch[]; // 每个 FilePatch 对应一个文件的增删改。
};
// FilePatch = 单个文件的补丁。
type FilePatch = {
oldPath: string; // 修改前路径。新增文件时可能是 /dev/null。
newPath: string; // 修改后路径。删除文件时可能是 /dev/null。
hunks: Hunk[]; // 一个文件里可能有多处不连续修改。
kind: "add" | "delete" | "modify" | "rename"; // 文件级操作类型。
};
// Hunk = patch 中一段连续的修改区域。
type Hunk = {
oldStart: number; // 旧文件中这段从第几行开始。
oldCount: number; // 旧文件中这段覆盖多少行。
newStart: number; // 新文件中这段从第几行开始。
newCount: number; // 新文件中这段覆盖多少行。
lines: PatchLine[]; // 具体每一行是上下文、删除还是新增。
};
// PatchLine = hunk 内的行。
type PatchLine =
| { kind: "context"; text: string } // 未改变的上下文行,用来定位。
| { kind: "remove"; text: string } // 旧文件中要删除的行。
| { kind: "add"; text: string }; // 新文件中要新增的行。Apply 的结果也要结构化:
代码块收起展开
// ApplyResult = patch 应用结果。
// 成功时返回更新后的文本;失败时返回失败 hunk 和附近上下文。
type ApplyResult =
| {
ok: true; // patch 应用成功。
updatedText: string; // 应用 patch 后的新文件内容。
appliedHunks: number; // 成功应用了几个 hunk。
warnings: string[]; // 非致命提示,例如行号发生漂移。
}
| {
ok: false; // patch 应用失败。
failedHunkIndex: number; // 第几个 hunk 失败。
reason: "context_not_found" | "overlap" | "malformed_patch"; // 失败原因。
expected: string; // patch 期望找到的上下文。
nearby: string; // 当前文件中相近位置的内容,帮助模型重读和修正。
};失败时只回一句“失败了”几乎没有价值,真正有用的是这几条信息:
- 第几个 hunk 失败。
- 期望上下文是什么。
- 当前文件附近内容是什么。
- 是否建议重读文件。
这能把模型从“继续猜 patch”拉回“重新建立事实”。
Hunk 定位:行号只是提示,不是真理
Unified diff 里有行号,但行号可能过期。稳一点的做法:
- 先按 oldStart 附近尝试匹配。
- 匹配失败后,在有限窗口内搜索上下文。
- 仍失败则全文件搜索完整 remove/context 块。
- 多处匹配则拒绝,要求更精确上下文。
伪代码:
代码块收起展开
// locateHunk = 在当前文件里找到某个 hunk 应该应用的位置。
// 返回值是 0-based 行号;找不到或多义时应该返回 null/失败。
function locateHunk(lines: string[], hunk: Hunk): number | null {
// add 行是新内容,不应该参与“在旧文件里定位”。
// context/remove 才是旧文件中应该存在的内容。
const expected = hunk.lines
.filter(line => line.kind !== "add")
.map(line => line.text);
// patch 里的 oldStart 是 1-based,这里转成数组下标 0-based。
const hinted = hunk.oldStart - 1;
// 先在 oldStart 附近搜索,允许上下文有少量行号漂移。
const window = range(Math.max(0, hinted - 20), Math.min(lines.length, hinted + 20));
// 如果附近能唯一匹配,直接使用。
const local = findUniqueMatch(lines, expected, window);
if (local !== null) return local;
// 附近找不到,再全文件搜索;如果多处匹配,findUniqueMatch 应该拒绝。
const global = findUniqueMatch(lines, expected, range(0, lines.length));
return global;
}这里的原则是:可以容忍轻微行号漂移,但不能容忍多义匹配。
三路合并的心智模型
三路合并(three-way merge)是 Git 合并分支时的做法:拿共同祖先版本、你的版本、对方的版本三份内容对比,找出各自改了什么。Agent 编辑最好也套用这个心智模型,三个版本分别对应:
| 版本 | 含义 |
|---|---|
| Base | Agent 读取时的文件 |
| Current | 写入前磁盘上的文件 |
| Desired | Agent 想写成的文件 |
如果 Base == Current,可以安全写。
如果 Base != Current,说明用户或其他工具改过文件。
此时有三种策略:
| 策略 | 行为 | 适合 |
|---|---|---|
| fail fast | 拒绝写入,要求重读 | 默认安全策略 |
| rebase patch | 把 patch 尝试应用到 Current | 小范围非冲突修改 |
| merge | 做三路合并并标冲突 | 高级 IDE/编辑器场景 |
Agent CLI 默认应该 fail fast(发现版本对不上就立刻报错停下,而不是硬着头皮继续)。
因为模型不是 Git,它很容易把用户刚改的东西“合并没了”。表里的 rebase patch 指把原来基于旧版本生成的补丁,挪到当前最新内容上重新套用,只在改动小、和用户的修改不冲突时才敢用。
编辑权限不是一个 yes/no
粗糙权限:
Allow write? yes/no
成熟的权限系统应该看风险大小。下面这张表每行是一个评估维度,同一个“写文件”请求落在左列还是右列,需要的确认强度完全不同:
| 风险项 | 低风险 | 高风险 |
|---|---|---|
| 文件数量 | 1 个文件 | 多文件批量 |
| 文件类型 | 源码/测试 | 配置/密钥/锁文件 |
| 改动大小 | 几行 | 大段重写 |
| 路径 | workspace 内 | workspace 外 |
| 操作 | replace | delete/rename |
| 可恢复性 | git tracked | untracked 且无备份 |
最后一行解释一下:git tracked 的文件改坏了还能用 git 恢复到上一个提交;untracked(git 没跟踪、也没备份)的文件被覆盖就真的找不回来了,所以风险更高。
可以设计风险评分:
代码块收起展开
// scoreEditRisk = 根据变更规模和敏感度给编辑打风险分。
// 分数越高,越应该要求用户明确确认,甚至禁止自动执行。
function scoreEditRisk(change: PlannedChange): number {
let risk = 0;
// 一次改太多文件,说明影响面变大。
if (change.files.length > 3) risk += 2;
// 改动行数太多,diff 审查成本高,也更容易误伤。
if (change.totalChangedLines > 80) risk += 2;
// 命中密钥、配置、锁文件等敏感路径时提高风险。
if (change.paths.some(isSensitivePath)) risk += 3;
// workspace 外路径基本应该直接拒绝,这里给极高分。
if (change.paths.some(isOutsideWorkspace)) risk += 10;
// 删除操作比普通替换危险。
if (change.kind === "delete") risk += 3;
return risk;
}风险高不一定禁止,但要升级提示。
编辑工具返回值要喂给模型
编辑完成后的工具返回值有两个读者:用户看摘要,模型则把它当成下一轮决策的事实来源。下面注释里的 QueryEngine 就是前面链路图里那个对话主循环:工具结果先交回它手里,由它拼进下一轮请求再发给模型。
代码块收起展开
// EditToolResult = 编辑工具返回给 QueryEngine/模型的事实。
// 它应该足够详细,让模型知道下一步该不该跑测试、该不该继续改。
type EditToolResult = {
summary: string; // 一句话摘要,例如 Edited src/config.ts。
diff: string; // 真实 diff。
changedFiles: string[]; // 本次改动涉及哪些文件。
oldHash: string; // 修改前版本。
newHash: string; // 修改后版本。
verification: {
readBack: boolean; // 是否写后回读。
formatter?: "passed" | "failed" | "skipped"; // 是否跑了 formatter。
tests?: "passed" | "failed" | "skipped"; // 是否跑了测试。
};
};如果只返回 ok,模型下一步不知道:
- 改了哪些文件。
- diff 是什么。
- 有没有验证。
- 是否还需要跑测试。
- 失败时该读哪个文件。
编辑系统的测试用例
编辑引擎要写单元测试,不要只靠人工体验。下表每行是一个必测场景,右列是该场景下断言的预期行为:
| 测试 | 目标 |
|---|---|
| oldText 找不到 | 必须失败 |
| oldText 多处出现 | 默认失败 |
| replaceAll=true | 多处替换才允许 |
| CRLF 文件 | 写后仍是 CRLF |
| UTF-8 BOM | 写后保留 BOM |
| 文件 hash 变化 | 写前拒绝 |
| patch 行号漂移 | 可在窗口内匹配 |
| patch 多处匹配 | 拒绝 |
| binary file | 拒绝文本编辑 |
| workspace 外路径 | 拒绝 |
| atomic write 失败 | 不报告成功 |
一个最小测试例子:
代码块收起展开
// 这个测试验证:文件被别人改过后,旧编辑计划不能继续写入。
it("rejects stale file before writing", async () => {
// 先读取文件,生成一个基于当前 hash 的编辑计划。
const loaded = await loadFile("config.ts");
const plan = buildReplacePlan(loaded, "sonnet", "opus");
// 模拟用户或其他进程在中途修改了同一个文件。
await writeFile("config.ts", "user changed it", "utf8");
// 应用旧计划必须失败,并且错误码应该明确是 FILE_STALE。
await expect(applyEditPlan(plan)).rejects.toMatchObject({
code: "FILE_STALE"
});
});Java 后端视角类比
如果你从 Java 后端转 Agent CLI,可以这样类比:
| Agent 编辑概念 | Java 后端类比 |
|---|---|
| baseHash | 数据库乐观锁 version |
| diff preview | 事务提交前审计日志 |
| write approval | 高危操作二次确认 |
| atomic write | 事务 commit |
| patch failure | SQL update affected rows = 0 |
| read-back verification | 写后查询确认 |
| per-file lock | 按资源 key 加锁 |
| audit record | 操作日志 / binlog |
写文件这个动作本身没难度,难的是把文件修改做成一套小型事务系统。上表 baseHash 那一行,和你在 MySQL 里写 update ... set ... where id = ? and version = ? 是同一件事。
per-file lock 这行前文没单独讲过,补一句:指编辑引擎对同一个文件同一时刻只放行一个编辑在途,两个工具调用并发改同一个文件时,后来的要么排队要么直接失败,思路和你在 Java 里按 key 从 ConcurrentHashMap 拿锁对象、把同一资源的写操作串行化是一样的。
前面讲的 baseHash 是乐观锁,事后发现冲突就拒绝;per-file lock 是悲观锁,直接不让冲突发生,两者可以叠着用。
源码验收门槛
编辑引擎的验收标准必须比普通 writeFile 高。因为它直接修改用户资产,错误一次就可能污染整个工作区。这张表的读法:中间列是合格实现在源码里应该能找到的证据,右列是踩坑实现的典型样子。
| 验收项 | 必须看到的源码证据 | 不合格表现 |
|---|---|---|
| 写前快照 | 读取内容、mtime/hash、编码、换行风格 | 只拿路径和 newText |
| 预览可审 | 用户批准的是具体 diff 或 patch plan | 只显示“将修改文件” |
| stale 检测 | apply 前重新读取并比较 base hash | 用户中途改文件仍覆盖 |
| 原子写入 | temp file + rename 或平台等价机制 | 写一半崩溃留下半截文件 |
| 写后验证 | read-back hash/diff 确认落盘内容 | 调用 writeFile 成功就算完成 |
| 失败可恢复 | patch 失败返回邻近上下文和失败 hunk | 只说 replace failed |
| 审计可回放 | 记录 old/new hash、diff、approval、tool call id | 只能从 git diff 猜发生了什么 |
最小回归样例:同一个文件做三次测试:精确替换成功、oldText 不存在失败、中途被外部修改失败。合格实现必须只让第一种落盘,后两种保留原文件,并给模型足够上下文重新制定编辑方案。
读源码时的高阶问题
读到编辑模块时,不要只问“有没有 edit_file”。要追问:
- 文件读取后是否记录 hash?
- 写入前是否检测 stale?
- diff 是模型生成的,还是程序根据 old/new 生成的?
- 用户批准的是自然语言描述,还是实际 diff?
- patch 失败时是否返回附近上下文?
- 大文件、二进制、生成文件、锁文件如何处理?
- 是否支持 dry-run(只演练生成 diff、不真正写盘的模式)?
- 是否能撤销或回滚?
- 编辑 trace 是否可回放?
- 测试失败后是否保留已写文件,还是自动回滚?
这些问题决定这个 Agent 是“能帮你写代码”,还是“你要防着它写代码”。