From 79bad40cfdf99ec5fd44e72dd2cf1c88cc386373 Mon Sep 17 00:00:00 2001 From: can1357 Date: Sat, 25 Apr 2026 23:51:49 +0200 Subject: [PATCH] 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. --- packages/coding-agent/src/edit/index.ts | 94 ++- packages/coding-agent/src/edit/modes/atom.ts | 576 ++++++++++++++++++ packages/coding-agent/src/edit/modes/patch.ts | 6 +- .../coding-agent/src/edit/modes/replace.ts | 6 +- packages/coding-agent/src/edit/renderer.ts | 7 +- packages/coding-agent/src/edit/streaming.ts | 24 +- .../coding-agent/src/prompts/tools/atom.md | 88 +++ packages/coding-agent/src/utils/edit-mode.ts | 3 +- .../src/utils/file-display-mode.ts | 4 +- packages/coding-agent/test/core/atom.test.ts | 248 ++++++++ 10 files changed, 1035 insertions(+), 21 deletions(-) create mode 100644 packages/coding-agent/src/edit/modes/atom.ts create mode 100644 packages/coding-agent/src/prompts/tools/atom.md create mode 100644 packages/coding-agent/test/core/atom.test.ts diff --git a/packages/coding-agent/src/edit/index.ts b/packages/coding-agent/src/edit/index.ts index 6a1b88094..41d5c9de3 100644 --- a/packages/coding-agent/src/edit/index.ts +++ b/packages/coding-agent/src/edit/index.ts @@ -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; -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( + 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(items: T[], key: (item: T) => K): Map { const map = new Map(); @@ -333,8 +369,9 @@ export class EditTool implements AgentTool { batchRequest: LspBatchRequest | undefined, onUpdate?: (partialResult: AgentToolResult) => 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 { batchRequest: LspBatchRequest | undefined, onUpdate?: (partialResult: AgentToolResult) => 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 { onUpdate?: (partialResult: AgentToolResult) => 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 { batchRequest: LspBatchRequest | undefined, onUpdate?: (partialResult: AgentToolResult) => 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 { 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) => 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 { batchRequest: LspBatchRequest | undefined, onUpdate?: (partialResult: AgentToolResult) => 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({ diff --git a/packages/coding-agent/src/edit/modes/atom.ts b/packages/coding-agent/src/edit/modes/atom.ts new file mode 100644 index 000000000..803a217ac --- /dev/null +++ b/packages/coding-agent/src/edit/modes/atom.ts @@ -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; +export type AtomParams = Static; + +// ═══════════════════════════════════════════════════════════════════════════ +// 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(); + 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> { + 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 }; diff --git a/packages/coding-agent/src/edit/modes/patch.ts b/packages/coding-agent/src/edit/modes/patch.ts index 7f6e9f9f7..0c89af124 100644 --- a/packages/coding-agent/src/edit/modes/patch.ts +++ b/packages/coding-agent/src/edit/modes/patch.ts @@ -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"; diff --git a/packages/coding-agent/src/edit/modes/replace.ts b/packages/coding-agent/src/edit/modes/replace.ts index 4439a098a..29a570de9 100644 --- a/packages/coding-agent/src/edit/modes/replace.ts +++ b/packages/coding-agent/src/edit/modes/replace.ts @@ -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); diff --git a/packages/coding-agent/src/edit/renderer.ts b/packages/coding-agent/src/edit/renderer.ts index 7df451ee8..37a862295 100644 --- a/packages/coding-agent/src/edit/renderer.ts +++ b/packages/coding-agent/src/edit/renderer.ts @@ -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) || diff --git a/packages/coding-agent/src/edit/streaming.ts b/packages/coding-agent/src/edit/streaming.ts index 02b4b37eb..0b95a9cd2 100644 --- a/packages/coding-agent/src/edit/streaming.ts +++ b/packages/coding-agent/src/edit/streaming.ts @@ -190,7 +190,6 @@ const patchStrategy: EditStreamingStrategy = { interface HashlineArgs { edits?: HashlineToolEdit[]; - move?: string; __partialJson?: string; } @@ -207,7 +206,7 @@ const hashlineStrategy: EditStreamingStrategy = { 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 = { }, }; +interface AtomArgs { + path?: string; + edits?: unknown[]; +} + +const atomStrategy: EditStreamingStrategy = { + 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> = { replace: replaceStrategy as EditStreamingStrategy, patch: patchStrategy as EditStreamingStrategy, @@ -335,6 +354,7 @@ export const EDIT_MODE_STRATEGIES: Record, apply_patch: applyPatchStrategy as EditStreamingStrategy, vim: vimStrategy, + atom: atomStrategy as EditStreamingStrategy, }; export { resolveEditMode }; diff --git a/packages/coding-agent/src/prompts/tools/atom.md b/packages/coding-agent/src/prompts/tools/atom.md new file mode 100644 index 000000000..a8e28286d --- /dev/null +++ b/packages/coding-agent/src/prompts/tools/atom.md @@ -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. + + +**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. + + + +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();"]}]}` + + + +- 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. + diff --git a/packages/coding-agent/src/utils/edit-mode.ts b/packages/coding-agent/src/utils/edit-mode.ts index dadf637a0..8e2b57aa1 100644 --- a/packages/coding-agent/src/utils/edit-mode.ts +++ b/packages/coding-agent/src/utils/edit-mode.ts @@ -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", diff --git a/packages/coding-agent/src/utils/file-display-mode.ts b/packages/coding-agent/src/utils/file-display-mode.ts index 5f6e6e064..e4140f90a 100644 --- a/packages/coding-agent/src/utils/file-display-mode.ts +++ b/packages/coding-agent/src/utils/file-display-mode.ts @@ -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, diff --git a/packages/coding-agent/test/core/atom.test.ts b/packages/coding-agent/test/core/atom.test.ts new file mode 100644 index 000000000..fabe553bf --- /dev/null +++ b/packages/coding-agent/test/core/atom.test.ts @@ -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/); + }); +});