feat(coding-agent): added atom mode to EditTool with path fallback

- Added `atom` edit mode to `EditTool` with single-point hashline-style dispatch and `executeAtomSingle`.
- Added `AtomToolEdit`/`AtomParams` schema and `atom` prompt docs to require one op per edit entry.
- Changed `EditTool` entry processing to inherit per-entry `path` from a top-level request `path` fallback.
- Added top-level `path` defaults to patch and replace schemas and now reject missing entry paths.
- Added an `atom` streaming strategy and registered it in `EDIT_MODE_STRATEGIES` for live mode support.
- Enabled hashline read anchors for `atom` mode via `file-display-mode.ts` path-display logic.
- Added `atom` unit tests covering schema checks, anchor conflicts, and hash mismatch diagnostics.
This commit is contained in:
can1357
2026-04-25 23:51:49 +02:00
parent 72a781759c
commit 79bad40cfd
10 changed files with 1035 additions and 21 deletions
+83 -11
View File
@@ -9,6 +9,7 @@ import {
writethroughNoop,
} from "../lsp";
import applyPatchDescription from "../prompts/tools/apply-patch.md" with { type: "text" };
import atomDescription from "../prompts/tools/atom.md" with { type: "text" };
import chunkEditDescription from "../prompts/tools/chunk-edit.md" with { type: "text" };
import hashlineDescription from "../prompts/tools/hashline.md" with { type: "text" };
import patchDescription from "../prompts/tools/patch.md" with { type: "text" };
@@ -24,6 +25,13 @@ import {
isApplyPatchParams,
} from "./modes/apply-patch";
import applyPatchGrammar from "./modes/apply-patch.lark" with { type: "text" };
import {
type AtomParams,
type AtomToolEdit,
atomEditParamsSchema,
executeAtomSingle,
isAtomParams,
} from "./modes/atom";
import {
type ChunkParams,
type ChunkToolEdit,
@@ -62,6 +70,7 @@ export * from "./apply-patch";
export * from "./diff";
export * from "./line-hash";
export * from "./modes/apply-patch";
export * from "./modes/atom";
export * from "./modes/chunk";
export * from "./modes/hashline";
export * from "./modes/patch";
@@ -74,12 +83,20 @@ type TInput =
| typeof replaceEditSchema
| typeof patchEditSchema
| typeof hashlineEditParamsSchema
| typeof atomEditParamsSchema
| typeof chunkEditParamsSchema
| typeof vimSchema
| typeof applyPatchSchema;
type VimParams = Static<typeof vimSchema>;
type EditParams = ReplaceParams | PatchParams | HashlineParams | ChunkParams | VimParams | ApplyPatchParams;
type EditParams =
| ReplaceParams
| PatchParams
| HashlineParams
| AtomParams
| ChunkParams
| VimParams
| ApplyPatchParams;
type EditToolResultDetails = EditToolDetails | VimToolDetails;
type EditModeDefinition = {
@@ -148,6 +165,25 @@ function createEditWritethrough(session: ToolSession): WritethroughCallback {
return enableLsp ? createLspWritethrough(session.cwd, { enableFormat, enableDiagnostics }) : writethroughNoop;
}
/**
* Resolve per-entry `path` against an optional top-level `path` default.
* If both are absent on an entry, throws a descriptive error.
*/
function resolveEntryPaths<T extends { path?: string }>(
edits: readonly T[],
topLevelPath: string | undefined,
): (T & { path: string })[] {
return edits.map((edit, i) => {
const path = (edit && typeof edit.path === "string" && edit.path) || topLevelPath;
if (!path) {
throw new Error(
`Edit ${i}: missing \`path\`. Provide \`path\` on this edit or supply a top-level \`path\` for the request.`,
);
}
return { ...edit, path };
});
}
/** Group items by a key, preserving insertion order. */
function groupBy<T, K>(items: T[], key: (item: T) => K): Map<K, T[]> {
const map = new Map<K, T[]>();
@@ -333,8 +369,9 @@ export class EditTool implements AgentTool<TInput> {
batchRequest: LspBatchRequest | undefined,
onUpdate?: (partialResult: AgentToolResult<EditToolDetails, TInput>) => void,
) => {
const { edits } = params as ChunkParams;
const byFile = groupBy(edits, (e: ChunkToolEdit) => parseChunkEditPath(e.path).filePath);
const { edits, path: topPath } = params as ChunkParams & { path?: string };
const resolved = resolveEntryPaths(edits as ChunkToolEdit[], topPath);
const byFile = groupBy(resolved, (e: ChunkToolEdit) => parseChunkEditPath(e.path).filePath);
const entries = [...byFile.entries()].map(([filePath, fileEdits]) => ({
path: filePath,
run: (br: LspBatchRequest | undefined) =>
@@ -363,8 +400,9 @@ export class EditTool implements AgentTool<TInput> {
batchRequest: LspBatchRequest | undefined,
onUpdate?: (partialResult: AgentToolResult<EditToolDetails, TInput>) => void,
) => {
const { edits } = params as PatchParams;
const entries = edits.map((entry: PatchEditEntry) => ({
const { edits, path: topPath } = params as PatchParams & { path?: string };
const resolved = resolveEntryPaths(edits as PatchEditEntry[], topPath);
const entries = resolved.map(entry => ({
path: entry.path,
run: (br: LspBatchRequest | undefined) =>
executePatchSingle({
@@ -394,8 +432,8 @@ export class EditTool implements AgentTool<TInput> {
onUpdate?: (partialResult: AgentToolResult<EditToolDetails, TInput>) => void,
) => {
const entries = expandApplyPatchToEntries(params as ApplyPatchParams);
const perFile = entries.map((entry: PatchEditEntry) => ({
path: entry.path,
const perFile = entries.map(entry => ({
path: entry.path!,
run: (br: LspBatchRequest | undefined) =>
executePatchSingle({
session: tool.session,
@@ -423,8 +461,9 @@ export class EditTool implements AgentTool<TInput> {
batchRequest: LspBatchRequest | undefined,
onUpdate?: (partialResult: AgentToolResult<EditToolDetails, TInput>) => void,
) => {
const { edits } = params as HashlineParams;
const byFile = groupBy(edits, (e: HashlineToolEdit) => e.path);
const { edits, path: topPath } = params as HashlineParams & { path?: string };
const resolved = resolveEntryPaths(edits as HashlineToolEdit[], topPath);
const byFile = groupBy(resolved, e => e.path);
const entries = [...byFile.entries()].map(([path, fileEdits]) => ({
path,
run: (br: LspBatchRequest | undefined) =>
@@ -441,6 +480,38 @@ export class EditTool implements AgentTool<TInput> {
return executePerFile(entries, batchRequest, onUpdate);
},
},
atom: {
description: () => prompt.render(atomDescription),
parameters: atomEditParamsSchema,
invalidParamsMessage:
"Edit tool requires `{ edits: [...] }` (an array of entries). Each entry needs exactly one op key (set, before, after, del, sub, ins, append, prepend) plus optional `path`.",
validate: isAtomParams,
execute: (
tool: EditTool,
params: EditParams,
signal: AbortSignal | undefined,
batchRequest: LspBatchRequest | undefined,
onUpdate?: (partialResult: AgentToolResult<EditToolDetails, TInput>) => void,
) => {
const { edits, path: topPath } = params as AtomParams & { path?: string };
const resolved = resolveEntryPaths(edits as AtomToolEdit[], topPath);
const byFile = groupBy(resolved, e => e.path);
const entries = [...byFile.entries()].map(([path, fileEdits]) => ({
path,
run: (br: LspBatchRequest | undefined) =>
executeAtomSingle({
session: tool.session,
path,
edits: fileEdits,
signal,
batchRequest: br,
writethrough: tool.#writethrough,
beginDeferredDiagnosticsForPath: p => tool.#beginDeferredDiagnosticsForPath(p),
}),
}));
return executePerFile(entries, batchRequest, onUpdate);
},
},
replace: {
description: () => prompt.render(replaceDescription),
parameters: replaceEditSchema,
@@ -453,8 +524,9 @@ export class EditTool implements AgentTool<TInput> {
batchRequest: LspBatchRequest | undefined,
onUpdate?: (partialResult: AgentToolResult<EditToolDetails, TInput>) => void,
) => {
const { edits } = params as ReplaceParams;
const entries = edits.map((entry: ReplaceEditEntry) => ({
const { edits, path: topPath } = params as ReplaceParams & { path?: string };
const resolved = resolveEntryPaths(edits as ReplaceEditEntry[], topPath);
const entries = resolved.map(entry => ({
path: entry.path,
run: (br: LspBatchRequest | undefined) =>
executeReplaceSingle({
@@ -0,0 +1,576 @@
/**
* Atom edit mode — single-point hashline-anchored edits.
*
* Each op references exactly **one** anchor (`LINE#HASH`). Range endpoints,
* vim-style motions, and column addressing are intentionally absent: to
* replace many lines, the model issues many ops. Reuses hashline's anchor
* staleness scheme (`computeLineHash`) verbatim.
*
* Op shapes (one per entry):
* { path, set: "5#th", to: "..." | ["..."] } // replace one line
* { path, before: "5#th", lines: "..." | ["..."] } // insert above anchor
* { path, after: "5#th", lines: "..." | ["..."] } // insert below anchor
* { path, del: "5#th" } // delete one line
* { path, sub: "5#th", find: "...", to: "..." } // substring rewrite on anchor line
* { path, ins: "5#th", find: "...", to: "..." } // overwrite from substring to EOL
* { path, append: "..." | ["..."] } // append to EOF
* { path, prepend: "..." | ["..."] } // prepend at BOF
*
* For deleting or moving files, the agent should use bash.
*/
import type { AgentToolResult } from "@oh-my-pi/pi-agent-core";
import { type Static, Type } from "@sinclair/typebox";
import type { WritethroughCallback, WritethroughDeferredHandle } from "../../lsp";
import type { ToolSession } from "../../tools";
import { assertEditableFileContent } from "../../tools/auto-generated-guard";
import { invalidateFsScanAfterWrite } from "../../tools/fs-cache-invalidation";
import { outputMeta } from "../../tools/output-meta";
import { enforcePlanModeWrite, resolvePlanPath } from "../../tools/plan-mode-guard";
import { generateDiffString } from "../diff";
import { computeLineHash } from "../line-hash";
import { detectLineEnding, normalizeToLF, restoreLineEndings, stripBom } from "../normalize";
import type { EditToolDetails, LspBatchRequest } from "../renderer";
import {
type Anchor,
buildCompactHashlineDiffPreview,
HashlineMismatchError,
type HashMismatch,
hashlineParseText,
parseTag,
} from "./hashline";
// ═══════════════════════════════════════════════════════════════════════════
// Schema
// ═══════════════════════════════════════════════════════════════════════════
const linesSchema = Type.Union([Type.Array(Type.String()), Type.String()]);
/**
* Flat entry shape: every op key is optional, and the runtime validator
* (`isAtomParams` + `resolveAtomToolEdit`) enforces that exactly one op key
* is present per entry. We use a flat schema instead of a 9-member discriminated
* union to keep the tool definition compact (the schema is re-sent on every
* turn, so duplicating `path` + descriptions across 9 union members 2×'s
* total token usage on long benchmarks).
*/
export const atomEditSchema = Type.Object(
{
path: Type.Optional(Type.String({ description: "file path override" })),
// Exactly one of the following op keys is required per entry:
set: Type.Optional(Type.String({ description: "line anchor to replace, 123#th" })),
before: Type.Optional(Type.String({ description: "line anchor to insert before, 123#th" })),
after: Type.Optional(Type.String({ description: "line anchor to insert after, 123#th" })),
del: Type.Optional(Type.String({ description: "line anchor to delete, 123#th" })),
sub: Type.Optional(Type.String({ description: "line anchor to rewrite, 123#th" })),
ins: Type.Optional(
Type.String({ description: "line anchor to overwrite from a substring to end-of-line, 123#th" }),
),
append: Type.Optional(linesSchema),
prepend: Type.Optional(linesSchema),
// Payload (used by set/before/after/sub/ins/append/prepend):
lines: Type.Optional(linesSchema),
find: Type.Optional(
Type.String({
description:
"sub/ins: substring on the anchored line that must occur exactly once. Use the shortest unique fragment.",
}),
),
},
{ additionalProperties: false },
);
export const atomEditParamsSchema = Type.Object(
{
path: Type.Optional(Type.String({ description: "Default file path used when an edit omits its own `path`" })),
edits: Type.Array(atomEditSchema, { description: "edits" }),
},
{ additionalProperties: false },
);
export type AtomToolEdit = Static<typeof atomEditSchema>;
export type AtomParams = Static<typeof atomEditParamsSchema>;
// ═══════════════════════════════════════════════════════════════════════════
// Internal resolved op shapes
// ═══════════════════════════════════════════════════════════════════════════
export type AtomEdit =
| { op: "set"; pos: Anchor; lines: string[] }
| { op: "before"; pos: Anchor; lines: string[] }
| { op: "after"; pos: Anchor; lines: string[] }
| { op: "del"; pos: Anchor }
| { op: "sub"; pos: Anchor; find: string; to: string }
| { op: "ins"; pos: Anchor; find: string; to: string }
| { op: "append_file"; lines: string[] }
| { op: "prepend_file"; lines: string[] };
// ═══════════════════════════════════════════════════════════════════════════
// Param guards
// ═══════════════════════════════════════════════════════════════════════════
const ATOM_OP_KEYS = ["set", "before", "after", "del", "sub", "ins", "append", "prepend"] as const;
export function isAtomParams(params: unknown): params is AtomParams {
// Minimal shape check. Per-entry validation (op key presence, exclusivity,
// payload sanity) all happens in `resolveAtomToolEdit` so the model gets a
// specific actionable error rather than a generic "invalid parameters" message.
if (typeof params !== "object" || params === null) return false;
if (!("edits" in params) || !Array.isArray(params.edits)) return false;
return true;
}
// ═══════════════════════════════════════════════════════════════════════════
// Resolution
// ═══════════════════════════════════════════════════════════════════════════
/**
* Parse an anchor reference like `"5#th"`.
*
* Tolerant: on a malformed reference we still try to extract a 1-indexed line
* number from the leading digits so the validator can surface the *correct*
* `LINE#HASH:content` for the user. The bogus hash is preserved in the returned
* anchor so the validator emits a content-rich mismatch error.
*
* If we cannot recover even a line number, throw a usage-style error with the
* raw reference quoted.
*/
function parseAnchor(raw: string, opName: string): Anchor {
if (typeof raw !== "string" || raw.length === 0) {
throw new Error(`${opName} requires an anchor of the form "LINE#ID" (e.g. "5#th").`);
}
try {
return parseTag(raw);
} catch {
const lineMatch = /^\s*[>+-]*\s*(\d+)/.exec(raw);
if (lineMatch) {
const line = Number.parseInt(lineMatch[1], 10);
if (line >= 1) {
// Sentinel hash that will never match a real line, forcing the validator
// to report a mismatch with the actual hash + line content.
return { line, hash: "??" };
}
}
throw new Error(
`${opName} requires an anchor of the form "LINE#ID" (e.g. "5#th"). Received ${JSON.stringify(raw)}; could not extract a line number.`,
);
}
}
function subInsLinesToString(lines: unknown, opName: string): string {
if (typeof lines === "string") return lines;
if (Array.isArray(lines)) return lines.join("\n");
throw new Error(`${opName} requires a string or array \`lines\` value (the replacement text).`);
}
function classifyAtomEdit(edit: AtomToolEdit): string {
for (const k of ATOM_OP_KEYS) {
if (k in edit) return k;
}
return "unknown";
}
function resolveAtomToolEdit(edit: AtomToolEdit, editIndex = 0): AtomEdit {
const opKeysPresent = ATOM_OP_KEYS.filter(k => k in edit);
if (opKeysPresent.length === 0) {
throw new Error(
`Edit ${editIndex}: missing op key. Each entry must include exactly one of: ${ATOM_OP_KEYS.join(", ")}.`,
);
}
if (opKeysPresent.length > 1) {
throw new Error(
`Edit ${editIndex}: multiple op keys (${opKeysPresent.join(", ")}). Each entry is exactly one op — split into ${opKeysPresent.length} separate entries.`,
);
}
if ("set" in edit && typeof edit.set === "string") {
return { op: "set", pos: parseAnchor(edit.set, "set"), lines: hashlineParseText(edit.lines) };
}
if ("before" in edit && typeof edit.before === "string") {
return { op: "before", pos: parseAnchor(edit.before, "before"), lines: hashlineParseText(edit.lines) };
}
if ("after" in edit && typeof edit.after === "string") {
return { op: "after", pos: parseAnchor(edit.after, "after"), lines: hashlineParseText(edit.lines) };
}
if ("del" in edit && typeof edit.del === "string") {
return { op: "del", pos: parseAnchor(edit.del, "del") };
}
if ("sub" in edit && typeof edit.sub === "string") {
if (typeof edit.find !== "string" || edit.find.length === 0) {
throw new Error("sub requires a non-empty `find` string.");
}
const to = subInsLinesToString(edit.lines, "sub");
return { op: "sub", pos: parseAnchor(edit.sub, "sub"), find: edit.find, to };
}
if ("ins" in edit && typeof edit.ins === "string") {
if (typeof edit.find !== "string" || edit.find.length === 0) {
throw new Error("ins requires a non-empty `find` string (the position-anchor on the line).");
}
const to = subInsLinesToString(edit.lines, "ins");
return { op: "ins", pos: parseAnchor(edit.ins, "ins"), find: edit.find, to };
}
if ("append" in edit) {
return { op: "append_file", lines: hashlineParseText(edit.append) };
}
if ("prepend" in edit) {
return { op: "prepend_file", lines: hashlineParseText(edit.prepend) };
}
throw new Error(`Unknown atom edit shape: ${JSON.stringify(edit)}`);
}
// ═══════════════════════════════════════════════════════════════════════════
// Validation
// ═══════════════════════════════════════════════════════════════════════════
function getAtomAnchor(edit: AtomEdit): Anchor | undefined {
switch (edit.op) {
case "set":
case "before":
case "after":
case "del":
case "sub":
case "ins":
return edit.pos;
default:
return undefined;
}
}
function validateAtomAnchors(edits: AtomEdit[], fileLines: string[]): HashMismatch[] {
const mismatches: HashMismatch[] = [];
for (const edit of edits) {
const anchor = getAtomAnchor(edit);
if (!anchor) continue;
if (anchor.line < 1 || anchor.line > fileLines.length) {
throw new Error(`Line ${anchor.line} does not exist (file has ${fileLines.length} lines)`);
}
const actualHash = computeLineHash(anchor.line, fileLines[anchor.line - 1]);
if (actualHash !== anchor.hash) {
mismatches.push({ line: anchor.line, expected: anchor.hash, actual: actualHash });
}
}
return mismatches;
}
function validateNoConflictingAnchorOps(edits: AtomEdit[]): void {
// For each anchor line, at most one mutating op (set/del/sub/ins).
// before/after may coexist (they don't mutate the anchor line).
const mutatingPerLine = new Map<number, string>();
for (const edit of edits) {
if (edit.op === "set" || edit.op === "del" || edit.op === "sub" || edit.op === "ins") {
const existing = mutatingPerLine.get(edit.pos.line);
if (existing) {
throw new Error(
`Conflicting ops on anchor line ${edit.pos.line}: \`${existing}\` and \`${edit.op}\`. ` +
`At most one of set/del/sub is allowed per anchor.`,
);
}
mutatingPerLine.set(edit.pos.line, edit.op);
}
}
}
// ═══════════════════════════════════════════════════════════════════════════
// Apply
// ═══════════════════════════════════════════════════════════════════════════
function getAtomEditSortKey(edit: AtomEdit, fileLineCount: number): { sortLine: number; precedence: number } {
switch (edit.op) {
case "sub":
case "ins":
return { sortLine: edit.pos.line, precedence: -1 };
case "set":
case "del":
return { sortLine: edit.pos.line, precedence: 0 };
case "after":
return { sortLine: edit.pos.line, precedence: 1 };
case "before":
return { sortLine: edit.pos.line, precedence: 2 };
case "append_file":
return { sortLine: fileLineCount + 1, precedence: 3 };
case "prepend_file":
return { sortLine: 0, precedence: 3 };
}
}
function applyAtomEditToLines(edit: AtomEdit, fileLines: string[], trackFirstChanged: (line: number) => void): void {
switch (edit.op) {
case "set": {
const lines = edit.lines.length === 0 ? [""] : edit.lines;
fileLines.splice(edit.pos.line - 1, 1, ...lines);
trackFirstChanged(edit.pos.line);
break;
}
case "del": {
fileLines.splice(edit.pos.line - 1, 1);
trackFirstChanged(edit.pos.line);
break;
}
case "before": {
if (edit.lines.length === 0) break;
fileLines.splice(edit.pos.line - 1, 0, ...edit.lines);
trackFirstChanged(edit.pos.line);
break;
}
case "after": {
if (edit.lines.length === 0) break;
fileLines.splice(edit.pos.line, 0, ...edit.lines);
trackFirstChanged(edit.pos.line + 1);
break;
}
case "sub": {
const idx = edit.pos.line - 1;
const current = fileLines[idx];
const first = current.indexOf(edit.find);
if (first === -1) {
throw new Error(
`sub: substring \`${edit.find}\` not found on line ${edit.pos.line}. ` +
`Current line content: ${JSON.stringify(current)}`,
);
}
const second = current.indexOf(edit.find, first + 1);
if (second !== -1) {
throw new Error(
`sub: substring \`${edit.find}\` occurs more than once on line ${edit.pos.line}; ` +
`use a longer substring that uniquely identifies the target. ` +
`Current line content: ${JSON.stringify(current)}`,
);
}
const next = current.slice(0, first) + edit.to + current.slice(first + edit.find.length);
// Allow `to` to introduce newlines, expanding into multiple lines.
const newLines = next.includes("\n") ? next.split("\n") : [next];
fileLines.splice(idx, 1, ...newLines);
trackFirstChanged(edit.pos.line);
break;
}
case "ins": {
const idx = edit.pos.line - 1;
const current = fileLines[idx];
const first = current.indexOf(edit.find);
if (first === -1) {
throw new Error(
`ins: substring \`${edit.find}\` not found on line ${edit.pos.line}. ` +
`Current line content: ${JSON.stringify(current)}`,
);
}
const second = current.indexOf(edit.find, first + 1);
if (second !== -1) {
throw new Error(
`ins: substring \`${edit.find}\` occurs more than once on line ${edit.pos.line}; ` +
`use a longer substring that uniquely identifies the position. ` +
`Current line content: ${JSON.stringify(current)}`,
);
}
// Replace from start of `find` to end-of-line with `to` (vim-insert style).
const next = current.slice(0, first) + edit.to;
const newLines = next.includes("\n") ? next.split("\n") : [next];
fileLines.splice(idx, 1, ...newLines);
trackFirstChanged(edit.pos.line);
break;
}
case "append_file": {
if (edit.lines.length === 0) break;
if (fileLines.length === 1 && fileLines[0] === "") {
fileLines.splice(0, 1, ...edit.lines);
trackFirstChanged(1);
} else {
fileLines.splice(fileLines.length, 0, ...edit.lines);
trackFirstChanged(fileLines.length - edit.lines.length + 1);
}
break;
}
case "prepend_file": {
if (edit.lines.length === 0) break;
if (fileLines.length === 1 && fileLines[0] === "") {
fileLines.splice(0, 1, ...edit.lines);
} else {
fileLines.splice(0, 0, ...edit.lines);
}
trackFirstChanged(1);
break;
}
}
}
function maybeAutocorrectEscapedTabIndentation(edits: AtomEdit[], warnings: string[]): void {
const enabled = Bun.env.PI_HASHLINE_AUTOCORRECT_ESCAPED_TABS !== "0";
if (!enabled) return;
for (const edit of edits) {
if (edit.op !== "set" && edit.op !== "before" && edit.op !== "after") continue;
if (edit.lines.length === 0) continue;
const hasEscapedTabs = edit.lines.some(line => line.includes("\\t"));
if (!hasEscapedTabs) continue;
const hasRealTabs = edit.lines.some(line => line.includes("\t"));
if (hasRealTabs) continue;
let correctedCount = 0;
const corrected = edit.lines.map(line =>
line.replace(/^((?:\\t)+)/, escaped => {
correctedCount += escaped.length / 2;
return "\t".repeat(escaped.length / 2);
}),
);
if (correctedCount === 0) continue;
edit.lines = corrected;
warnings.push(
`Auto-corrected escaped tab indentation in edit: converted leading \\t sequence(s) to real tab characters`,
);
}
}
export function applyAtomEdits(
text: string,
edits: AtomEdit[],
): {
lines: string;
firstChangedLine: number | undefined;
warnings?: string[];
} {
if (edits.length === 0) {
return { lines: text, firstChangedLine: undefined };
}
const fileLines = text.split("\n");
const warnings: string[] = [];
let firstChangedLine: number | undefined;
const mismatches = validateAtomAnchors(edits, fileLines);
if (mismatches.length > 0) {
throw new HashlineMismatchError(mismatches, fileLines);
}
validateNoConflictingAnchorOps(edits);
maybeAutocorrectEscapedTabIndentation(edits, warnings);
const annotated = edits
.map((edit, idx) => {
const { sortLine, precedence } = getAtomEditSortKey(edit, fileLines.length);
return { edit, idx, sortLine, precedence };
})
.sort((a, b) => b.sortLine - a.sortLine || a.precedence - b.precedence || a.idx - b.idx);
const trackFirstChanged = (line: number) => {
if (firstChangedLine === undefined || line < firstChangedLine) {
firstChangedLine = line;
}
};
for (const { edit } of annotated) {
applyAtomEditToLines(edit, fileLines, trackFirstChanged);
}
return {
lines: fileLines.join("\n"),
firstChangedLine,
...(warnings.length > 0 ? { warnings } : {}),
};
}
// ═══════════════════════════════════════════════════════════════════════════
// Executor
// ═══════════════════════════════════════════════════════════════════════════
export interface ExecuteAtomSingleOptions {
session: ToolSession;
path: string;
edits: AtomToolEdit[];
signal?: AbortSignal;
batchRequest?: LspBatchRequest;
writethrough: WritethroughCallback;
beginDeferredDiagnosticsForPath: (path: string) => WritethroughDeferredHandle;
}
export async function executeAtomSingle(
options: ExecuteAtomSingleOptions,
): Promise<AgentToolResult<EditToolDetails, typeof atomEditParamsSchema>> {
const { session, path, edits, signal, batchRequest, writethrough, beginDeferredDiagnosticsForPath } = options;
const contentEdits = edits.map((edit, i) => resolveAtomToolEdit(edit, i));
enforcePlanModeWrite(session, path, { op: "update" });
if (path.endsWith(".ipynb") && contentEdits.length > 0) {
throw new Error("Cannot edit Jupyter notebooks with the Edit tool. Use the NotebookEdit tool instead.");
}
const absolutePath = resolvePlanPath(session, path);
const sourceFile = Bun.file(absolutePath);
const sourceExists = await sourceFile.exists();
if (!sourceExists) {
const lines: string[] = [];
for (const edit of contentEdits) {
if (edit.op === "append_file") {
lines.push(...edit.lines);
} else if (edit.op === "prepend_file") {
lines.unshift(...edit.lines);
} else {
throw new Error(`File not found: ${path}`);
}
}
await Bun.write(absolutePath, lines.join("\n"));
invalidateFsScanAfterWrite(absolutePath);
return {
content: [{ type: "text", text: `Created ${path}` }],
details: {
diff: "",
op: "create",
meta: outputMeta().get(),
},
};
}
const rawContent = await sourceFile.text();
assertEditableFileContent(rawContent, path);
const { bom, text } = stripBom(rawContent);
const originalEnding = detectLineEnding(text);
const originalNormalized = normalizeToLF(text);
const result = applyAtomEdits(originalNormalized, contentEdits);
if (originalNormalized === result.lines) {
throw new Error(`No changes made to ${path}. The edits produced identical content.`);
}
const finalContent = bom + restoreLineEndings(result.lines, originalEnding);
const diagnostics = await writethrough(
absolutePath,
finalContent,
signal,
Bun.file(absolutePath),
batchRequest,
dst => (dst === absolutePath ? beginDeferredDiagnosticsForPath(absolutePath) : undefined),
);
invalidateFsScanAfterWrite(absolutePath);
const diffResult = generateDiffString(originalNormalized, result.lines);
const meta = outputMeta()
.diagnostics(diagnostics?.summary ?? "", diagnostics?.messages ?? [])
.get();
const resultText = `Updated ${path}`;
const preview = buildCompactHashlineDiffPreview(diffResult.diff);
const summaryLine = `Changes: +${preview.addedLines} -${preview.removedLines}${
preview.preview ? "" : " (no textual diff preview)"
}`;
const warningsBlock = result.warnings?.length ? `\n\nWarnings:\n${result.warnings.join("\n")}` : "";
const previewBlock = preview.preview ? `\n\nDiff preview:\n${preview.preview}` : "";
return {
content: [
{
type: "text",
text: `${resultText}\n${summaryLine}${previewBlock}${warningsBlock}`,
},
],
details: {
diff: diffResult.diff,
firstChangedLine: result.firstChangedLine ?? diffResult.firstChangedLine,
diagnostics,
op: "update",
meta,
},
};
}
// Helpers exposed for tests / external dispatch.
export { classifyAtomEdit, parseAnchor, resolveAtomToolEdit };
@@ -1577,7 +1577,7 @@ export async function computePatchDiff(
}
export const patchEditEntrySchema = Type.Object({
path: Type.String({ description: "File path" }),
path: Type.Optional(Type.String({ description: "File path (omit to use top-level `path`)" })),
op: Type.Optional(
StringEnum(["create", "delete", "update"], {
description: "Operation (default: update)",
@@ -1588,6 +1588,7 @@ export const patchEditEntrySchema = Type.Object({
});
export const patchEditSchema = Type.Object({
path: Type.Optional(Type.String({ description: "Default file path used when an edit omits its own `path`" })),
edits: Type.Array(patchEditEntrySchema, { description: "Patch operations", minItems: 1 }),
});
@@ -1710,6 +1711,9 @@ export async function executePatchSingle(
beginDeferredDiagnosticsForPath,
} = options;
const { path, op: rawOp, rename, diff } = params;
if (typeof path !== "string" || path.length === 0) {
throw new Error("patch edit: missing `path`. Provide `path` on the edit or supply a top-level `path`.");
}
const op: Operation = rawOp === "create" || rawOp === "delete" ? rawOp : "update";
@@ -977,13 +977,14 @@ export function findContextLine(
}
export const replaceEditEntrySchema = Type.Object({
path: Type.String({ description: "File path (relative or absolute)" }),
path: Type.Optional(Type.String({ description: "File path (omit to use top-level `path`)" })),
old_text: Type.String({ description: "Text to find (fuzzy whitespace matching enabled)" }),
new_text: Type.String({ description: "Replacement text" }),
all: Type.Optional(Type.Boolean({ description: "Replace all occurrences (default: unique match required)" })),
});
export const replaceEditSchema = Type.Object({
path: Type.Optional(Type.String({ description: "Default file path used when an edit omits its own `path`" })),
edits: Type.Array(replaceEditEntrySchema, { description: "Replacements", minItems: 1 }),
});
@@ -1022,6 +1023,9 @@ export async function executeReplaceSingle(
beginDeferredDiagnosticsForPath,
} = options;
const { path, old_text, new_text, all } = params;
if (typeof path !== "string" || path.length === 0) {
throw new Error("replace edit: missing `path`. Provide `path` on the edit or supply a top-level `path`.");
}
enforcePlanModeWrite(session, path);
+3 -4
View File
@@ -489,10 +489,9 @@ function renderSingleFileResult(
const rename = args?.rename || firstEdit?.rename || firstEdit?.move || details?.move;
const { language } = formatEditDescription(rawPath, uiTheme, { rename });
const metadataLine =
op !== "delete"
? `\n${formatMetadataLine(countLines(args?.newText ?? args?.oldText ?? args?.diff ?? args?.patch ?? ""), language, uiTheme)}`
: "";
const editTextSource = args?.newText ?? args?.oldText ?? args?.diff ?? args?.patch;
const metadataLineCount = editTextSource ? countLines(editTextSource) : null;
const metadataLine = op !== "delete" ? `\n${formatMetadataLine(metadataLineCount, language, uiTheme)}` : "";
const errorText = isError
? (details && "errorText" in details && details.errorText) ||
+22 -2
View File
@@ -190,7 +190,6 @@ const patchStrategy: EditStreamingStrategy<PatchArgs> = {
interface HashlineArgs {
edits?: HashlineToolEdit[];
move?: string;
__partialJson?: string;
}
@@ -207,7 +206,7 @@ const hashlineStrategy: EditStreamingStrategy<HashlineArgs> = {
return !!e && typeof e === "object" && (e as { path?: string }).path === path;
});
ctx.signal.throwIfAborted();
const result = await computeHashlineDiff({ path, edits: fileEdits, move: args.move }, ctx.cwd);
const result = await computeHashlineDiff({ path, edits: fileEdits }, ctx.cwd);
ctx.signal.throwIfAborted();
return [toPerFilePreview(path, result)];
},
@@ -328,6 +327,26 @@ const vimStrategy: EditStreamingStrategy<unknown> = {
},
};
interface AtomArgs {
path?: string;
edits?: unknown[];
}
const atomStrategy: EditStreamingStrategy<AtomArgs> = {
extractCompleteEdits(args, partialJson) {
if (!args.edits) return args;
return { ...args, edits: dropIncompleteLastEdit(args.edits, partialJson, "edits") };
},
async computeDiffPreview() {
// Atom edits are line-anchored and validated against live file hashes; a
// streaming preview without that validation could mislead. Skip for now.
return null;
},
renderStreamingFallback() {
return "";
},
};
export const EDIT_MODE_STRATEGIES: Record<EditMode, EditStreamingStrategy<unknown>> = {
replace: replaceStrategy as EditStreamingStrategy<unknown>,
patch: patchStrategy as EditStreamingStrategy<unknown>,
@@ -335,6 +354,7 @@ export const EDIT_MODE_STRATEGIES: Record<EditMode, EditStreamingStrategy<unknow
chunk: chunkStrategy as EditStreamingStrategy<unknown>,
apply_patch: applyPatchStrategy as EditStreamingStrategy<unknown>,
vim: vimStrategy,
atom: atomStrategy as EditStreamingStrategy<unknown>,
};
export { resolveEditMode };
@@ -0,0 +1,88 @@
Applies precise file edits using single-point `LINE#ID` anchors from `read` output.
Each op references **exactly one** anchor — there are no ranges, no two-endpoint addresses. To replace many lines, issue many ops.
Read the file first. Copy anchors exactly from the latest `read` output. After any successful edit, re-read before editing that file again.
<operations>
**Top level**
- `edits` — array of edit entries. Each entry is exactly one op.
- `path` (optional) — default file path used when an edit omits its own `path`. Lets you share the path across many edits in one request.
`{ path?, … }` — one of the following ops:
- `set: "55#th", lines: […]` — replace one anchored line with one or more lines
- `before: "55#th", lines: […]` — insert lines above the anchored line
- `after: "55#th", lines: […]` — insert lines below the anchored line
- `del: "55#th"` — delete one anchored line
- `sub: "55#th", find: "…", lines: …` — replace a unique substring on the anchored line
- `ins: "55#th", find: "…", lines: "…"` — overwrite from the start of `find` to **end-of-line** with `lines`. Everything on the anchored line after (and including) `find` is **discarded**. If you want to preserve trailing content, use `sub` instead.
- `append: […]` — append at end of file
- `prepend: …` — prepend at start of file
**Minimum content rule for `sub` and `ins`**: `find` must occur exactly once on the anchored line. Use the **shortest** unique fragment — not the whole line. The replacement `lines` should also be the smallest change that does the job. Restating large amounts of unchanged text is wasted output and increases the chance of stale-line conflicts.
**File-scoped ops**
**Path resolution**: each entry uses its own `path` if present, otherwise falls back to the request-level `path`. Provide one or the other; if neither is set, the edit is rejected.
</operations>
<examples>
All examples below reference the same file:
```ts title="a.ts"
{{hline 1 "// @ts-ignore"}}
{{hline 2 "const timeout = 5000;"}}
{{hline 3 "const tag = \"DO NOT SHIP\";"}}
{{hline 4 ""}}
{{hline 5 "function alpha() {"}}
{{hline 6 "\tlog();"}}
{{hline 7 "}"}}
{{hline 8 ""}}
{{hline 9 "function beta() {"}}
{{hline 10 "\t// TODO: remove after migration"}}
{{hline 11 "\tlegacy();"}}
{{hline 12 "\ttry {"}}
{{hline 13 "\t\treturn parse(data);"}}
{{hline 14 "\t} catch (err) {"}}
{{hline 15 "\t\tconsole.error(err);"}}
{{hline 16 "\t\treturn null;"}}
{{hline 17 "\t}"}}
{{hline 18 "}"}}
```
# Replace one line
`{edits:[{path:"a.ts",set:{{href 2 "const timeout = 5000;"}},lines:"const timeout = 30_000;"}]}`
# Rewrite a single token (cheaper than `set`)
`sub` rewrites a substring without repeating the rest of the line.
`{edits:[{path:"a.ts",sub:{{href 2 "const timeout = 5000;"}},find:"5000",lines:"30_000"}]}`
# Truncate a line tail with `ins` (vim-insert)
Use `ins` when the change is “replace from this point onward.” Pick the shortest unique anchor.
Original line 3: `const tag = "DO NOT SHIP";`
`{edits:[{path:"a.ts",ins:{{href 3 "const tag = \"DO NOT SHIP\";"}},find:"DO",lines:"OK\";"}]}`
Result: `const tag = "OK";`. `find:"DO"` positions the cursor at `D`; everything from there to end-of-line is replaced by `lines`.
# Replace a multi-line block (issue one op per line)
Replace the catch body. One `set` per line — no range, no boundary confusion. Lift `path` to the top level when all entries target the same file:
`{path:"a.ts",edits:[{set:{{href 15 "\t\tconsole.error(err);"}},lines:"\t\tif (isEnoent(err)) return null;"},{set:{{href 16 "\t\treturn null;"}},lines:"\t\tthrow err;"}]}`
Or per-entry `path` (use when edits span multiple files):
`{edits:[{path:"a.ts",set:{{href 15 "\t\tconsole.error(err);"}},lines:"\t\tif (isEnoent(err)) return null;"},{path:"b.ts",set:{{href 16 "\t\treturn null;"}},lines:"\t\tthrow err;"}]}`
# Delete adjacent lines (issue one `del` per line)
`{path:"a.ts",edits:[{del:{{href 10 "\t// TODO: remove after migration"}}},{del:{{href 11 "\tlegacy();"}}}]}`
# Insert before a sibling
`{edits:[{path:"a.ts",before:{{href 9 "function beta() {"}},lines:["function gamma() {","\tvalidate();","}",""]}]}`
# Insert after a line
`{edits:[{path:"a.ts",after:{{href 6 "\tlog();"}},lines:["\tvalidate();"]}]}`
# Expand one line into many
`set` accepts an array.
`{edits:[{path:"a.ts",set:{{href 6 "\tlog();"}},lines:["\tvalidate();","\tlog();","\tcleanup();"]}]}`
</examples>
<critical>
- Make the minimum exact edit. Do not rewrite nearby code unless the op requires it.
- Each entry in `edits` is exactly one op. Never combine multiple ops in a single entry.
- Copy anchors exactly as `N#ID` from the latest `read` output. Anchors validate the file hasn't changed since you read it; mismatches reject all ops in the request.
- After **any** edit that changes line count (insert, multi-line `set`, `del`), all anchors below the change are stale. Re-read the file before issuing more edits to the same file. To reduce re-reads, batch edits in a single request and order them **bottom-up** so earlier edits don't shift later anchors.
- For `sub`, the `find` substring must occur **exactly once** on the anchored line. If it could match more than once, use a longer substring or use `set` instead.
- At most one of `set`/`del`/`sub` may target any single anchor line. `before`/`after` may coexist with them.
- `lines` content must be literal file content with matching indentation. If the file uses tabs, use real tabs.
- You **MUST NOT** use this tool to reformat or clean up unrelated code — use project-specific linters or code formatters instead.
</critical>
+2 -1
View File
@@ -1,11 +1,12 @@
import { $env, $flag } from "@oh-my-pi/pi-utils";
export type EditMode = "replace" | "patch" | "hashline" | "chunk" | "vim" | "apply_patch";
export type EditMode = "replace" | "patch" | "hashline" | "chunk" | "vim" | "apply_patch" | "atom";
export const DEFAULT_EDIT_MODE: EditMode = "hashline";
const EDIT_MODE_IDS = {
apply_patch: "apply_patch",
atom: "atom",
chunk: "chunk",
hashline: "hashline",
patch: "patch",
@@ -27,7 +27,9 @@ export interface FileDisplayModeSession {
export function resolveFileDisplayMode(session: FileDisplayModeSession): FileDisplayMode {
const { settings } = session;
const hasEditTool = session.hasEditTool ?? true;
const hashLines = hasEditTool && resolveEditMode(session) === "hashline" && settings.get("readHashLines") !== false;
const editMode = resolveEditMode(session);
const usesHashLineAnchors = editMode === "hashline" || editMode === "atom";
const hashLines = hasEditTool && usesHashLineAnchors && settings.get("readHashLines") !== false;
const chunked = hasEditTool && resolveEditMode(session) === "chunk";
return {
hashLines,
@@ -0,0 +1,248 @@
import { describe, expect, it } from "bun:test";
import {
type AtomEdit,
applyAtomEdits,
computeLineHash,
HashlineMismatchError,
isAtomParams,
resolveAtomToolEdit,
} from "@oh-my-pi/pi-coding-agent/edit";
import type { Anchor } from "@oh-my-pi/pi-coding-agent/edit/modes/hashline";
function tag(line: number, content: string): Anchor {
return { line, hash: computeLineHash(line, content) };
}
describe("isAtomParams", () => {
it("accepts empty edits", () => {
expect(isAtomParams({ edits: [] })).toBe(true);
});
it("accepts a `set` op", () => {
expect(isAtomParams({ edits: [{ path: "a.ts", set: "1#aa", lines: "x" }] })).toBe(true);
});
it("accepts a `sub` op", () => {
expect(isAtomParams({ edits: [{ path: "a.ts", sub: "1#aa", find: "x", lines: "y" }] })).toBe(true);
});
it("accepts file-scoped append", () => {
expect(isAtomParams({ edits: [{ path: "a.ts", append: "z" }] })).toBe(true);
});
it("defers multi-op entries to the resolver for an actionable error", () => {
// Schema-level guard accepts; resolver throws with named keys.
expect(isAtomParams({ edits: [{ path: "a.ts", set: "1#aa", lines: "x", del: "1#aa" }] })).toBe(true);
expect(() => resolveAtomToolEdit({ path: "a.ts", set: "1#aa", lines: "x", del: "1#aa" } as never)).toThrow(
/multiple op keys.*set, del/,
);
});
it("accepts entries without path (resolved at dispatcher from top-level)", () => {
expect(isAtomParams({ path: "a.ts", edits: [{ set: "1#XQ", lines: "x" }] as unknown[] })).toBe(true);
});
});
describe("applyAtomEdits — set", () => {
it("replaces a single line", () => {
const content = "aaa\nbbb\nccc";
const edits: AtomEdit[] = [{ op: "set", pos: tag(2, "bbb"), lines: ["BBB"] }];
const result = applyAtomEdits(content, edits);
expect(result.lines).toBe("aaa\nBBB\nccc");
expect(result.firstChangedLine).toBe(2);
});
it("expands one line into many", () => {
const content = "aaa\nbbb\nccc";
const edits: AtomEdit[] = [{ op: "set", pos: tag(2, "bbb"), lines: ["X", "Y", "Z"] }];
const result = applyAtomEdits(content, edits);
expect(result.lines).toBe("aaa\nX\nY\nZ\nccc");
});
it("rejects on stale hash", () => {
const content = "aaa\nbbb\nccc";
const edits: AtomEdit[] = [{ op: "set", pos: { line: 2, hash: "ZZ" }, lines: ["BBB"] }];
expect(() => applyAtomEdits(content, edits)).toThrow(HashlineMismatchError);
});
});
describe("applyAtomEdits — del", () => {
it("removes a line", () => {
const content = "aaa\nbbb\nccc";
const edits: AtomEdit[] = [{ op: "del", pos: tag(2, "bbb") }];
const result = applyAtomEdits(content, edits);
expect(result.lines).toBe("aaa\nccc");
});
it("multiple deletes apply bottom-up so anchors stay valid", () => {
const content = "aaa\nbbb\nccc\nddd";
const edits: AtomEdit[] = [
{ op: "del", pos: tag(2, "bbb") },
{ op: "del", pos: tag(3, "ccc") },
];
const result = applyAtomEdits(content, edits);
expect(result.lines).toBe("aaa\nddd");
});
});
describe("applyAtomEdits — before/after", () => {
it("before inserts above the anchor", () => {
const content = "aaa\nbbb\nccc";
const edits: AtomEdit[] = [{ op: "before", pos: tag(2, "bbb"), lines: ["NEW"] }];
const result = applyAtomEdits(content, edits);
expect(result.lines).toBe("aaa\nNEW\nbbb\nccc");
});
it("after inserts below the anchor", () => {
const content = "aaa\nbbb\nccc";
const edits: AtomEdit[] = [{ op: "after", pos: tag(2, "bbb"), lines: ["NEW"] }];
const result = applyAtomEdits(content, edits);
expect(result.lines).toBe("aaa\nbbb\nNEW\nccc");
});
it("before + after on same anchor coexist with set", () => {
const content = "aaa\nbbb\nccc";
const edits: AtomEdit[] = [
{ op: "before", pos: tag(2, "bbb"), lines: ["B"] },
{ op: "set", pos: tag(2, "bbb"), lines: ["BBB"] },
{ op: "after", pos: tag(2, "bbb"), lines: ["A"] },
];
const result = applyAtomEdits(content, edits);
expect(result.lines).toBe("aaa\nB\nBBB\nA\nccc");
});
});
describe("applyAtomEdits — sub", () => {
it("replaces a unique substring", () => {
const content = "const timeout = 5000;";
const edits: AtomEdit[] = [{ op: "sub", pos: tag(1, content), find: "5000", to: "30_000" }];
const result = applyAtomEdits(content, edits);
expect(result.lines).toBe("const timeout = 30_000;");
});
it("errors when find is absent", () => {
const content = "const timeout = 5000;";
const edits: AtomEdit[] = [{ op: "sub", pos: tag(1, content), find: "missing", to: "x" }];
expect(() => applyAtomEdits(content, edits)).toThrow(/not found/);
});
it("errors when find is non-unique", () => {
const content = "abc abc";
const edits: AtomEdit[] = [{ op: "sub", pos: tag(1, content), find: "abc", to: "Z" }];
expect(() => applyAtomEdits(content, edits)).toThrow(/more than once/);
});
it("rejects conflict with set on same anchor", () => {
const content = "abc";
const edits: AtomEdit[] = [
{ op: "sub", pos: tag(1, "abc"), find: "abc", to: "x" },
{ op: "set", pos: tag(1, "abc"), lines: ["y"] },
];
expect(() => applyAtomEdits(content, edits)).toThrow(/Conflicting ops/);
});
});
describe("applyAtomEdits — ins", () => {
it("replaces from anchor to end-of-line (vim-insert style)", () => {
const content = `const tag = "DO NOT SHIP";`;
const edits: AtomEdit[] = [{ op: "ins", pos: tag(1, content), find: "DO", to: 'OK";' }];
const result = applyAtomEdits(content, edits);
expect(result.lines).toBe('const tag = "OK";');
});
it("appends after find when find is at end-of-line", () => {
const content = "foo bar";
const edits: AtomEdit[] = [{ op: "ins", pos: tag(1, content), find: "bar", to: "BAZ" }];
const result = applyAtomEdits(content, edits);
expect(result.lines).toBe("foo BAZ");
});
it("errors when find is absent", () => {
const content = "abc def";
const edits: AtomEdit[] = [{ op: "ins", pos: tag(1, content), find: "missing", to: "x" }];
expect(() => applyAtomEdits(content, edits)).toThrow(/not found/);
});
it("errors when find is non-unique", () => {
const content = "abc abc";
const edits: AtomEdit[] = [{ op: "ins", pos: tag(1, content), find: "abc", to: "Z" }];
expect(() => applyAtomEdits(content, edits)).toThrow(/more than once/);
});
it("rejects conflict with sub on same anchor", () => {
const content = "abc def";
const edits: AtomEdit[] = [
{ op: "sub", pos: tag(1, content), find: "abc", to: "x" },
{ op: "ins", pos: tag(1, content), find: "def", to: "y" },
];
expect(() => applyAtomEdits(content, edits)).toThrow(/Conflicting ops/);
});
});
describe("applyAtomEdits — file-scoped", () => {
it("append_file appends at EOF", () => {
const content = "aaa\nbbb";
const edits: AtomEdit[] = [{ op: "append_file", lines: ["ccc"] }];
const result = applyAtomEdits(content, edits);
expect(result.lines).toBe("aaa\nbbb\nccc");
});
it("prepend_file prepends at BOF", () => {
const content = "aaa\nbbb";
const edits: AtomEdit[] = [{ op: "prepend_file", lines: ["ZZZ"] }];
const result = applyAtomEdits(content, edits);
expect(result.lines).toBe("ZZZ\naaa\nbbb");
});
it("append to empty file replaces empty line", () => {
const content = "";
const edits: AtomEdit[] = [{ op: "append_file", lines: ["aaa"] }];
const result = applyAtomEdits(content, edits);
expect(result.lines).toBe("aaa");
});
});
describe("applyAtomEdits — out of range", () => {
it("rejects line beyond file length", () => {
const content = "aaa\nbbb";
const edits: AtomEdit[] = [{ op: "set", pos: { line: 99, hash: "ZZ" }, lines: ["x"] }];
expect(() => applyAtomEdits(content, edits)).toThrow(/does not exist/);
});
});
describe("parseAnchor (atom tolerant) + applyAtomEdits", () => {
it("surfaces correct anchor + content when the model invents an out-of-alphabet hash", () => {
const content = "alpha\nbravo\ncharlie";
// `XG` is not in the alphabet; should be rejected with the actual anchor exposed.
const toolEdit = { path: "a.ts", set: "2#XG", lines: "BRAVO" };
const resolved = resolveAtomToolEdit(toolEdit) as AtomEdit;
expect(() => applyAtomEdits(content, [resolved])).toThrow(HashlineMismatchError);
try {
applyAtomEdits(content, [resolved]);
} catch (err) {
const msg = (err as Error).message;
expect(msg).toContain(">>>");
expect(msg).toContain("bravo");
expect(msg).toContain(`2#${computeLineHash(2, "bravo")}`);
}
});
it("surfaces correct anchor + content when the model omits the hash entirely", () => {
const content = "alpha\nbravo\ncharlie";
const toolEdit = { path: "a.ts", set: "2", lines: "BRAVO" };
const resolved = resolveAtomToolEdit(toolEdit) as AtomEdit;
expect(() => applyAtomEdits(content, [resolved])).toThrow(HashlineMismatchError);
});
it("surfaces correct anchor when the model uses pipe-separator (LINE|content) form", () => {
const content = "alpha\nbravo\ncharlie";
const toolEdit = { path: "a.ts", set: "2|bravo", lines: "BRAVO" };
const resolved = resolveAtomToolEdit(toolEdit) as AtomEdit;
expect(() => applyAtomEdits(content, [resolved])).toThrow(HashlineMismatchError);
});
it("throws a usage-style error when no line number can be extracted", () => {
const toolEdit = { path: "a.ts", set: " if (!x) return;", lines: "x" };
expect(() => resolveAtomToolEdit(toolEdit)).toThrow(/could not extract a line number/);
});
});