- Added inline hashline parse and apply support for `<` prepend and `+` append operations with prefix+suffix edits. - Added fail-fast behavior to reject inline modify ops combined with delete or replace on same line. - Renamed HASHLINE_* and mode symbols to HL_* in prompt tooling, read/search checks, and prompt templates. - Standardized separators to `PI_HL_SEP`/`HL_EDIT_SEP` and fixed `HL_BODY_SEP='|'`, updating parser formatting behavior. - Updated benchmark subtype constants and python cleanup test setup to use HL_* values and AgentRegistry mock failure injection.
347 lines
14 KiB
TypeScript
347 lines
14 KiB
TypeScript
import { beforeAll, describe, expect, it } from "bun:test";
|
|
import * as fs from "node:fs/promises";
|
|
import * as os from "node:os";
|
|
import * as path from "node:path";
|
|
import { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
|
import {
|
|
applyHashlineEdits,
|
|
buildCompactHashlineDiffPreview,
|
|
computeLineHash,
|
|
type ExecuteHashlineSingleOptions,
|
|
executeHashlineSingle,
|
|
generateDiffString,
|
|
HashlineMismatchError,
|
|
HL_BODY_SEP,
|
|
HL_BODY_SEP_RE_RAW,
|
|
HL_EDIT_SEP,
|
|
hashlineEditParamsSchema,
|
|
parseHashline,
|
|
splitHashlineInput,
|
|
splitHashlineInputs,
|
|
} from "@oh-my-pi/pi-coding-agent/edit";
|
|
import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
|
|
import { Value } from "@sinclair/typebox/value";
|
|
|
|
beforeAll(async () => {
|
|
_resetSettingsForTest();
|
|
await Settings.init({ inMemory: true, cwd: process.cwd() });
|
|
});
|
|
|
|
// Single source of truth for the payload separator under test. Every literal
|
|
// payload line in this file goes through `pl()` so flipping
|
|
// `HL_EDIT_SEP` (e.g. to ">" or "\\") flips the test inputs in
|
|
// lockstep without any `|`-vs-`>` churn.
|
|
const sep = HL_EDIT_SEP;
|
|
const pl = (text: string): string => `${sep}${text}`;
|
|
const outputSep = HL_BODY_SEP;
|
|
const outputSepRe = HL_BODY_SEP_RE_RAW;
|
|
|
|
function tag(line: number, content: string): string {
|
|
return `${line}${computeLineHash(line, content)}`;
|
|
}
|
|
|
|
function mistag(line: number, content: string): string {
|
|
const hash = computeLineHash(line, content);
|
|
return `${line}${hash === "zz" ? "yy" : "zz"}`;
|
|
}
|
|
|
|
function applyDiff(content: string, diff: string): string {
|
|
return applyHashlineEdits(content, parseHashline(diff)).lines;
|
|
}
|
|
|
|
async function withTempDir(fn: (tempDir: string) => Promise<void>): Promise<void> {
|
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "hashline-edit-"));
|
|
try {
|
|
await fn(tempDir);
|
|
} finally {
|
|
await fs.rm(tempDir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
function hashlineExecuteOptions(tempDir: string, input: string): ExecuteHashlineSingleOptions {
|
|
return {
|
|
session: { cwd: tempDir } as ToolSession,
|
|
input,
|
|
writethrough: async (targetPath, content) => {
|
|
await Bun.write(targetPath, content);
|
|
return undefined;
|
|
},
|
|
beginDeferredDiagnosticsForPath: () => ({
|
|
onDeferredDiagnostics: () => {},
|
|
signal: new AbortController().signal,
|
|
finalize: () => {},
|
|
}),
|
|
};
|
|
}
|
|
|
|
describe("hashline parser — block op syntax", () => {
|
|
const content = "aaa\nbbb\nccc";
|
|
|
|
it("inserts payload before/after a Lid, and at BOF/EOF", () => {
|
|
const diff = [
|
|
`< ${tag(2, "bbb")}`,
|
|
pl("before b"),
|
|
`+ ${tag(2, "bbb")}`,
|
|
pl("after b"),
|
|
"+ BOF",
|
|
pl("top"),
|
|
"+ EOF",
|
|
pl("tail"),
|
|
].join("\n");
|
|
expect(applyDiff(content, diff)).toBe("top\naaa\nbefore b\nbbb\nafter b\nccc\ntail");
|
|
});
|
|
|
|
it("inserts after the final line via `+ ANCHOR` instead of falling off the file", () => {
|
|
const diff = [`+ ${tag(3, "ccc")}`, pl("tail")].join("\n");
|
|
expect(applyDiff(content, diff)).toBe("aaa\nbbb\nccc\ntail");
|
|
});
|
|
|
|
it("blanks a line in place when `= ANCHOR` has no payload", () => {
|
|
const diff = `= ${tag(2, "bbb")}`;
|
|
expect(applyDiff(content, diff)).toBe("aaa\n\nccc");
|
|
});
|
|
|
|
it("blanks a range to a single empty line when `= A..B` has no payload", () => {
|
|
const diff = `= ${tag(1, "aaa")}..${tag(2, "bbb")}`;
|
|
expect(applyDiff(content, diff)).toBe("\nccc");
|
|
});
|
|
|
|
it("deletes one line or an inclusive range", () => {
|
|
expect(applyDiff(content, `- ${tag(2, "bbb")}`)).toBe("aaa\nccc");
|
|
expect(applyDiff(content, `- ${tag(2, "bbb")}..${tag(3, "ccc")}`)).toBe("aaa");
|
|
});
|
|
|
|
it("replaces one line or an inclusive range with payload lines", () => {
|
|
const single = [`= ${tag(2, "bbb")}`, pl("BBB")].join("\n");
|
|
expect(applyDiff(content, single)).toBe("aaa\nBBB\nccc");
|
|
|
|
const range = [`= ${tag(2, "bbb")}..${tag(3, "ccc")}`, pl("BBB"), pl("CCC")].join("\n");
|
|
expect(applyDiff(content, range)).toBe("aaa\nBBB\nCCC");
|
|
});
|
|
|
|
it("preserves payload text exactly after the first separator", () => {
|
|
const diff = [`= ${tag(2, "bbb")}`, pl(""), pl("# not a header"), pl("+ not an op"), pl(" spaced")].join("\n");
|
|
expect(applyDiff(content, diff)).toBe("aaa\n\n# not a header\n+ not an op\n spaced\nccc");
|
|
});
|
|
|
|
it("rejects missing payloads and orphan payload lines", () => {
|
|
expect(() => parseHashline(`+ ${tag(1, "aaa")}`)).toThrow(/require at least one/);
|
|
expect(() => parseHashline(pl("orphan"))).toThrow(/payload line has no preceding/);
|
|
});
|
|
|
|
it("rejects old cursor and equals-inline syntax after cutover", () => {
|
|
expect(() => parseHashline(`@${tag(1, "aaa")}\n+old`)).toThrow(/unrecognized op/);
|
|
expect(() => parseHashline(`${tag(1, "aaa")}=AAA`)).toThrow(/unrecognized op/);
|
|
});
|
|
});
|
|
|
|
describe("hashline parser — inline modify syntax", () => {
|
|
const content = "alpha\nbeta\ngamma";
|
|
|
|
it("prepends text to the anchored line via `< ANCHOR<sep>TEXT`", () => {
|
|
const diff = `< ${tag(2, "beta")}${pl("// ")}`;
|
|
expect(applyDiff(content, diff)).toBe("alpha\n// beta\ngamma");
|
|
});
|
|
|
|
it("appends text to the anchored line via `+ ANCHOR<sep>TEXT`", () => {
|
|
const diff = `+ ${tag(2, "beta")}${pl(" // tag")}`;
|
|
expect(applyDiff(content, diff)).toBe("alpha\nbeta // tag\ngamma");
|
|
});
|
|
|
|
it("combines a prepend and an append on the same line", () => {
|
|
const diff = [`< ${tag(2, "beta")}${pl("[")}`, `+ ${tag(2, "beta")}${pl("]")}`].join("\n");
|
|
expect(applyDiff(content, diff)).toBe("alpha\n[beta]\ngamma");
|
|
});
|
|
|
|
it("stacks multiple prepends with later edits wrapping earlier ones", () => {
|
|
const diff = [`< ${tag(2, "beta")}${pl("A")}`, `< ${tag(2, "beta")}${pl("B")}`].join("\n");
|
|
expect(applyDiff(content, diff)).toBe("alpha\nBAbeta\ngamma");
|
|
});
|
|
|
|
it("stacks multiple appends with later edits wrapping earlier ones", () => {
|
|
const diff = [`+ ${tag(2, "beta")}${pl("A")}`, `+ ${tag(2, "beta")}${pl("B")}`].join("\n");
|
|
expect(applyDiff(content, diff)).toBe("alpha\nbetaAB\ngamma");
|
|
});
|
|
|
|
it("appends inline AND inserts payload lines after the modified line", () => {
|
|
const diff = [`+ ${tag(2, "beta")}${pl(" // tag")}`, pl("inserted-after-1"), pl("inserted-after-2")].join("\n");
|
|
expect(applyDiff(content, diff)).toBe("alpha\nbeta // tag\ninserted-after-1\ninserted-after-2\ngamma");
|
|
});
|
|
|
|
it("prepends inline AND inserts payload lines before the modified line", () => {
|
|
const diff = [`< ${tag(2, "beta")}${pl("// ")}`, pl("inserted-before-1"), pl("inserted-before-2")].join("\n");
|
|
expect(applyDiff(content, diff)).toBe("alpha\ninserted-before-1\ninserted-before-2\n// beta\ngamma");
|
|
});
|
|
|
|
it("allows a block insert-before to coexist with an inline modify on the same line", () => {
|
|
const diff = [`< ${tag(2, "beta")}`, pl("// note"), `+ ${tag(2, "beta")}${pl("!")}`].join("\n");
|
|
expect(applyDiff(content, diff)).toBe("alpha\n// note\nbeta!\ngamma");
|
|
});
|
|
|
|
it("rejects combining inline modify with a delete on the same line", () => {
|
|
const diff = [`- ${tag(2, "beta")}`, `+ ${tag(2, "beta")}${pl("!")}`].join("\n");
|
|
expect(() => applyDiff(content, diff)).toThrow(/cannot combine inline modify/);
|
|
});
|
|
|
|
it("validates the anchor hash for inline modify just like other ops", () => {
|
|
const diff = `+ ${mistag(2, "beta")}${pl("!")}`;
|
|
expect(() => applyDiff(content, diff)).toThrow(HashlineMismatchError);
|
|
});
|
|
|
|
it("treats an empty inline payload as a no-op when nothing else follows", () => {
|
|
const diff = `+ ${tag(2, "beta")}${pl("")}`;
|
|
const result = applyHashlineEdits(content, parseHashline(diff));
|
|
expect(result.lines).toBe(content);
|
|
});
|
|
});
|
|
|
|
describe("hashline — stale anchors", () => {
|
|
it("throws HashlineMismatchError when a Lid hash no longer matches", () => {
|
|
const diff = [`= ${mistag(2, "bbb")}`, pl("BBB")].join("\n");
|
|
expect(() => applyDiff("aaa\nbbb\nccc", diff)).toThrow(HashlineMismatchError);
|
|
});
|
|
|
|
it("rebases a uniquely shifted anchor within the configured window", () => {
|
|
const stale = tag(2, "bbb");
|
|
const diff = [`= ${stale}`, pl("BBB")].join("\n");
|
|
const result = applyHashlineEdits("aaa\nINSERTED\nbbb\nccc", parseHashline(diff));
|
|
expect(result.lines).toBe("aaa\nINSERTED\nBBB\nccc");
|
|
expect(result.warnings?.[0]).toContain(`Auto-rebased anchor ${stale}`);
|
|
});
|
|
|
|
it("rejects when the line is in bounds but the hash matches no nearby line", () => {
|
|
// Two-char hash, fabricated by guaranteeing it equals neither line 2's nor any other line's hash
|
|
const fakeHash = computeLineHash(2, "bbb") === "zz" ? "yy" : "zz";
|
|
const diff = [`= 2${fakeHash}`, pl("BBB")].join("\n");
|
|
expect(() => applyDiff("aaa\nbbb\nccc", diff)).toThrow(HashlineMismatchError);
|
|
});
|
|
|
|
it("rejects when multiple lines within the rebase window share the same hash", () => {
|
|
// Significant-content lines hash by content alone; identical content gives
|
|
// identical hashes, so multiple lines in ±5 collide and force a reject.
|
|
const file = ["x = 1", "y = 2", "x = 1", "z = 3", "x = 1", "w = 4"].join("\n");
|
|
const collidingHash = computeLineHash(1, "x = 1");
|
|
// User points at line 4 (`z = 3`) with the colliding hash; the rebase
|
|
// window covers lines 1, 3, and 5, all of which match — ambiguous.
|
|
const diff = [`= 4${collidingHash}`, pl("REPLACED")].join("\n");
|
|
expect(() => applyDiff(file, diff)).toThrow(HashlineMismatchError);
|
|
});
|
|
});
|
|
|
|
describe("splitHashlineInput — @ headers", () => {
|
|
it("extracts path and diff body from @path header", () => {
|
|
const input = [`@src/foo.ts`, `= ${tag(2, "bbb")}`, pl("BBB")].join("\n");
|
|
expect(splitHashlineInput(input)).toEqual({ path: "src/foo.ts", diff: `= ${tag(2, "bbb")}\n${pl("BBB")}` });
|
|
});
|
|
|
|
it("strips leading blank lines and unquotes matching path quotes", () => {
|
|
expect(splitHashlineInput(`\n@"foo bar.ts"\n+ BOF\n${pl("x")}`)).toEqual({
|
|
path: "foo bar.ts",
|
|
diff: `+ BOF\n${pl("x")}`,
|
|
});
|
|
});
|
|
|
|
it("normalizes cwd-prefixed absolute paths to cwd-relative paths", () => {
|
|
const cwd = process.cwd();
|
|
const absolute = path.join(cwd, "src", "foo.ts");
|
|
expect(splitHashlineInput(`@${absolute}\n+ BOF\n${pl("x")}`, { cwd }).path).toBe("src/foo.ts");
|
|
});
|
|
|
|
it("uses explicit fallback path only when input has recognizable operations", () => {
|
|
expect(splitHashlineInput(`+ BOF\n${pl("x")}`, { path: "a.ts" })).toEqual({
|
|
path: "a.ts",
|
|
diff: `+ BOF\n${pl("x")}`,
|
|
});
|
|
expect(() => splitHashlineInput("plain text", { path: "a.ts" })).toThrow(/must begin with/);
|
|
});
|
|
|
|
it("splits multiple edit sections", () => {
|
|
const input = ["@a.ts", "+ BOF", pl("a"), "@b.ts", "+ EOF", pl("b")].join("\n");
|
|
expect(splitHashlineInputs(input)).toEqual([
|
|
{ path: "a.ts", diff: `+ BOF\n${pl("a")}` },
|
|
{ path: "b.ts", diff: `+ EOF\n${pl("b")}` },
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("hashline executor", () => {
|
|
it("creates a missing file with a file-scoped insert", async () => {
|
|
await withTempDir(async tempDir => {
|
|
const input = `@new.ts\n+ BOF\n${pl("export const x = 1;")}\n`;
|
|
const result = await executeHashlineSingle(hashlineExecuteOptions(tempDir, input));
|
|
expect(result.content[0]?.type === "text" ? result.content[0].text : "").toContain("new.ts:");
|
|
expect(await Bun.file(path.join(tempDir, "new.ts")).text()).toBe("export const x = 1;");
|
|
});
|
|
});
|
|
|
|
it("preflights every section before writing multi-file edits", async () => {
|
|
await withTempDir(async tempDir => {
|
|
const aPath = path.join(tempDir, "a.ts");
|
|
const bPath = path.join(tempDir, "b.ts");
|
|
await Bun.write(aPath, "aaa\n");
|
|
await Bun.write(bPath, "bbb\n");
|
|
const input = ["@a.ts", `= ${tag(1, "aaa")}`, pl("AAA"), "@b.ts", `= ${mistag(1, "bbb")}`, pl("BBB")].join(
|
|
"\n",
|
|
);
|
|
|
|
await expect(executeHashlineSingle(hashlineExecuteOptions(tempDir, input))).rejects.toThrow(
|
|
/changed since the last read/,
|
|
);
|
|
expect(await Bun.file(aPath).text()).toBe("aaa\n");
|
|
expect(await Bun.file(bPath).text()).toBe("bbb\n");
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("hashlineEditParamsSchema — extra-field tolerance", () => {
|
|
it("accepts extra `path` field alongside `input`", () => {
|
|
expect(Value.Check(hashlineEditParamsSchema, { path: "x.ts", input: `@x.ts\n+ BOF\n${pl("x")}` })).toBe(true);
|
|
});
|
|
|
|
it("still requires `input`", () => {
|
|
expect(Value.Check(hashlineEditParamsSchema, { path: "x.ts" })).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("buildCompactHashlineDiffPreview — anchors track post-edit line numbers", () => {
|
|
it("emits hashes against the new file's line numbers for context after a range expansion", () => {
|
|
const before = ["a1", "a2", "a3", "a4", "a5", "a6", "a7"].join("\n");
|
|
const after = ["a1", "a2", "a3", "X", "Y", "Z", "a5", "a6", "a7"].join("\n");
|
|
const { diff } = generateDiffString(before, after);
|
|
const preview = buildCompactHashlineDiffPreview(diff);
|
|
|
|
// Walk the preview and verify every ` LINE+HASH${outputSep}content` line matches what
|
|
// the file now has at that line number.
|
|
const newFileLines = after.split("\n");
|
|
for (const line of preview.preview.split("\n")) {
|
|
if (!line.startsWith(" ")) continue;
|
|
// Skip context-elision markers ("...") which carry no real file content.
|
|
if (line.endsWith(`${outputSep}...`)) continue;
|
|
const match = new RegExp(`^\\s(\\d+)([a-z]{2})${outputSepRe}(.*)$`).exec(line);
|
|
expect(match).not.toBeNull();
|
|
if (!match) continue;
|
|
const lineNum = Number(match[1]);
|
|
const hash = match[2];
|
|
const content = match[3];
|
|
expect(newFileLines[lineNum - 1]).toBe(content);
|
|
expect(computeLineHash(lineNum, content)).toBe(hash);
|
|
}
|
|
});
|
|
|
|
it("emits + lines with hashes against new line numbers and - lines with the placeholder", () => {
|
|
const before = "alpha\nbeta\ngamma\n";
|
|
const after = "alpha\nDELTA\nEPSILON\ngamma\n";
|
|
const { diff } = generateDiffString(before, after);
|
|
const preview = buildCompactHashlineDiffPreview(diff);
|
|
|
|
const additions = preview.preview.split("\n").filter(line => line.startsWith("+"));
|
|
expect(additions).toEqual([
|
|
`+2${computeLineHash(2, "DELTA")}${outputSep}DELTA`,
|
|
`+3${computeLineHash(3, "EPSILON")}${outputSep}EPSILON`,
|
|
]);
|
|
|
|
const removals = preview.preview.split("\n").filter(line => line.startsWith("-"));
|
|
expect(removals).toEqual([`-2--${outputSep}beta`]);
|
|
});
|
|
});
|