- 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.
249 lines
9.1 KiB
TypeScript
249 lines
9.1 KiB
TypeScript
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/);
|
|
});
|
|
});
|