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:
@@ -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);
|
||||
|
||||
|
||||
@@ -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) ||
|
||||
|
||||
@@ -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>
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user