char-slop/ai-dots

ai dotfiles

git clone https://git.t4t.associates/char-slop/ai-dots

Charlotte Somexperiment: add hashline9720b9d

main
15.8 KiB356 linesraw
1import assert from "node:assert/strict";
2import { mkdtemp, readFile, rm, stat, symlink, writeFile } from "node:fs/promises";
3import { tmpdir } from "node:os";
4import { join } from "node:path";
5import { test, type TestContext } from "node:test";
6import { stripVTControlCharacters } from "node:util";
7import {
8  type ExtensionAPI,
9  type ExtensionContext,
10  initTheme,
11  ToolExecutionComponent,
12  type ToolDefinition,
13} from "@earendil-works/pi-coding-agent";
14import type { TUI } from "@earendil-works/pi-tui";
15import hashline from "./hashline.ts";
16
17async function fixture(t: TestContext, content: string | Buffer) {
18  const cwd = await mkdtemp(join(tmpdir(), "hashline-test-"));
19  t.after(() => rm(cwd, { recursive: true, force: true }));
20  const path = join(cwd, "file.txt");
21  await writeFile(path, content);
22  const tools = new Map<string, ToolDefinition>();
23  const api = { registerTool: (tool: ToolDefinition) => tools.set(tool.name, tool) } as unknown as ExtensionAPI;
24  hashline(api);
25  return {
26    path,
27    tools,
28    reload: () => hashline(api),
29    async call(name: string, args: object = {}, signal?: AbortSignal) {
30      const result = await tools.get(name)!.execute(
31        "test", { path: "file.txt", ...args }, signal, undefined, { cwd } as ExtensionContext,
32      );
33      return result.content.filter((part) => part.type === "text").map((part) => part.text).join("\n");
34    },
35  };
36}
37
38function anchors(output: string): string[] {
39  return output.split("\n").filter((row) => row.includes("│")).map((row) => row.split("│")[0]);
40}
41
42test("edit rendering has one layer of padding for calls, results, and errors", async (t) => {
43  const f = await fixture(t, "");
44  initTheme("dark", false);
45  const component = new ToolExecutionComponent(
46    "edit", "test", { path: "file.txt", edits: [] }, {}, f.tools.get("edit"),
47    { requestRender() {} } as TUI, tmpdir(),
48  );
49  assert.deepEqual(component.render(80).map((line) => stripVTControlCharacters(line).trimEnd()), [
50    "", "", " edit file.txt", "",
51  ]);
52  component.updateResult({
53    content: [{ type: "text", text: "Applied.\n-old\n+new" }], details: {}, isError: false,
54  });
55  assert.deepEqual(component.render(80).map((line) => stripVTControlCharacters(line).trimEnd()), [
56    "", "", " edit file.txt", " Applied.", " -old", " +new", "",
57  ]);
58  component.updateResult({
59    content: [{ type: "text", text: "Ambiguous hashes." }], details: {}, isError: true,
60  });
61  assert.deepEqual(component.render(80).map((line) => stripVTControlCharacters(line).trimEnd()), [
62    "", "", " edit file.txt", " Ambiguous hashes.", "",
63  ]);
64});
65
66test("diff anchors stay in model output but not the visible diff", async (t) => {
67  const f = await fixture(t, "original\n");
68  const [old] = anchors(await f.call("read"));
69  assert.match(old, /^[A-Za-z]{4}$/);
70  const args = { path: f.path, edits: [{ old: [old], new: ["ABCD│literal"] }] };
71  const tool = f.tools.get("edit")!;
72  const result = await tool.execute("test", args, undefined, undefined, { cwd: tmpdir() } as ExtensionContext);
73  const modelOutput = result.content.filter((part) => part.type === "text").map((part) => part.text).join("\n");
74  assert.ok(modelOutput.includes(`-${old}│original`));
75  assert.match(modelOutput, /^\+[A-Za-z]{4}│ABCD│literal$/m);
76  const snapshot = structuredClone(result);
77
78  initTheme("dark", false);
79  const component = new ToolExecutionComponent(
80    "edit", "test", args, {}, tool, { requestRender() {} } as TUI, tmpdir(),
81  );
82  component.updateResult({ ...result, isError: false });
83  for (const expanded of [false, true]) {
84    component.setExpanded(expanded);
85    const visible = component.render(120).map((line) => stripVTControlCharacters(line).trimEnd());
86    assert.ok(visible.includes(" -original"));
87    assert.ok(visible.includes(" +ABCD│literal"));
88    assert.ok(!visible.some((line) => line.includes(`-${old}│`)));
89  }
90  assert.deepEqual(result, snapshot);
91
92  component.updateResult({
93    content: [{ type: "text", text: "-0123456789abcdef│old session\n+fedcba9876543210│restored" }],
94    details: {}, isError: false,
95  });
96  const history = stripVTControlCharacters(component.render(120).join("\n"));
97  assert.match(history, /-old session/);
98  assert.match(history, /\+restored/);
99  assert.doesNotMatch(history, //);
100});
101
102test("long edit results are collapsed until expanded", async (t) => {
103  const f = await fixture(t, "");
104  initTheme("dark", false);
105  const component = new ToolExecutionComponent(
106    "edit", "test", { path: "file.txt", edits: [] }, {}, f.tools.get("edit"),
107    { requestRender() {} } as TUI, tmpdir(),
108  );
109  component.updateResult({
110    content: [{ type: "text", text: Array.from({ length: 12 }, (_, i) => `line ${i}`).join("\n") }],
111    details: {}, isError: false,
112  });
113  const collapsed = stripVTControlCharacters(component.render(80).join("\n"));
114  assert.match(collapsed, /2 more lines/);
115  assert.doesNotMatch(collapsed, /line 11/);
116  component.setExpanded(true);
117  const expanded = stripVTControlCharacters(component.render(80).join("\n"));
118  assert.match(expanded, /line 11/);
119  assert.doesNotMatch(expanded, /more lines/);
120});
121
122test("references survive external line movement and a fresh extension instance", async (t) => {
123  const f = await fixture(t, "first\ntarget\nlast\n");
124  const [first, target, last] = anchors(await f.call("read"));
125  await writeFile(f.path, "inserted\nlast\nfirst\ntarget\n");
126  f.reload();
127  const moved = anchors(await f.call("read"));
128  assert.deepEqual(moved.slice(1), [last, first, target]);
129  await f.call("edit", { edits: [{ old: [target], new: ["changed"] }] });
130  assert.equal(await readFile(f.path, "utf8"), "inserted\nlast\nfirst\nchanged\n");
131});
132
133test("duplicate lines need unique adjacent context, which is preserved", async (t) => {
134  const original = "left\nsame\nright\nleft\nsame\nother\n";
135  const f = await fixture(t, original);
136  const [left, same, right, leftAgain, sameAgain] = anchors(await f.call("read"));
137  assert.equal(left, leftAgain);
138  assert.equal(same, sameAgain);
139  await assert.rejects(f.call("edit", { edits: [{ old: [same], new: ["changed"] }] }), /ambiguous/);
140  await assert.rejects(f.call("edit", {
141    edits: [{ before: [left], old: [same], new: ["changed"] }],
142  }), /ambiguous/);
143  assert.equal(await readFile(f.path, "utf8"), original);
144  await f.call("edit", {
145    edits: [{ before: [left], old: [same], after: [right], new: ["changed"] }],
146  });
147  assert.equal(await readFile(f.path, "utf8"), "left\nchanged\nright\nleft\nsame\nother\n");
148});
149
150test("different lines with colliding hashes are resolved by context", async (t) => {
151  const candidates = Array.from({ length: 10_000 }, (_, i) => `collision candidate ${i}`);
152  const f = await fixture(t, candidates.join("\n"));
153  const seen = new Map<string, string>();
154  let collision: string[] | undefined;
155  for (let offset = 1; offset <= candidates.length && !collision;) {
156    const page = anchors(await f.call("read", { offset }));
157    for (const [i, anchor] of page.entries()) {
158      assert.match(anchor, /^[A-Za-z]{4}$/);
159      const line = candidates[offset - 1 + i];
160      const previous = seen.get(anchor);
161      if (previous !== undefined) {
162        collision = [previous, line];
163        break;
164      }
165      seen.set(anchor, line);
166    }
167    offset += page.length;
168  }
169  assert.ok(collision, "expected a collision in the four-letter anchor space");
170  const [first, second] = collision;
171  assert.notEqual(first, second);
172  const original = `first context\n${first}\nsecond context\n${second}\n`;
173  await writeFile(f.path, original);
174  const [, firstHash, context, secondHash] = anchors(await f.call("read"));
175  assert.equal(firstHash, secondHash);
176  await assert.rejects(f.call("edit", { edits: [{ old: [secondHash], new: ["changed"] }] }), /ambiguous/);
177  assert.equal(await readFile(f.path, "utf8"), original);
178  await f.call("edit", { edits: [{ before: [context], old: [secondHash], new: ["changed"] }] });
179  assert.equal(await readFile(f.path, "utf8"), `first context\n${first}\nsecond context\nchanged\n`);
180});
181
182test("batches resolve against the original, regardless of order or line count changes", async (t) => {
183  const f = await fixture(t, "one\ntwo\nthree\nfour\n");
184  const [one, two, three, four] = anchors(await f.call("read"));
185  await f.call("edit", { edits: [
186    { old: [four], new: ["last"] },
187    { old: [one], new: ["first", "extra"] },
188  ] });
189  assert.equal(await readFile(f.path, "utf8"), "first\nextra\ntwo\nthree\nlast\n");
190  assert.deepEqual(anchors(await f.call("read")).slice(2, 4), [two, three]);
191});
192
193test("a stale interior line refuses the entire batch without writing", async (t) => {
194  const f = await fixture(t, "one\ntwo\nthree\nfour\n");
195  const [one, two, three, four] = anchors(await f.call("read"));
196  const external = "one\nexternal\nthree\nfour\n";
197  await writeFile(f.path, external);
198  await assert.rejects(f.call("edit", { edits: [
199    { old: [four], new: ["would change"] },
200    { old: [one, two, three], new: ["replacement"] },
201  ] }), /stale/);
202  assert.equal(await readFile(f.path, "utf8"), external);
203});
204
205test("hashes include trailing whitespace and the entire line", async (t) => {
206  const long = "x".repeat(1000);
207  const original = `text\ntext \n${long}a\n${long}b\n`;
208  const f = await fixture(t, original);
209  const hashes = anchors(await f.call("read"));
210  assert.equal(new Set(hashes).size, 4);
211  await writeFile(f.path, original.replace("text \n", "text  \n"));
212  await assert.rejects(f.call("edit", { edits: [{ old: [hashes[1]], new: ["changed"] }] }), /stale/);
213});
214
215test("overlapping ranges and same-position insertions are refused", async (t) => {
216  const original = "one\ntwo\nthree\n";
217  const f = await fixture(t, original);
218  const [one, two, three] = anchors(await f.call("read"));
219  for (const edits of [
220    [{ old: [one, two], new: [] }, { old: [two, three], new: [] }],
221    [{ before: [one], old: [], new: ["x"] }, { after: [two], old: [], new: ["y"] }],
222  ]) {
223    await assert.rejects(f.call("edit", { edits }), /Overlapping/);
224    assert.equal(await readFile(f.path, "utf8"), original);
225  }
226});
227
228test("context may overlap other edits, but is always checked against the original", async (t) => {
229  const f = await fixture(t, "one\ntwo\nthree\n");
230  const [one, two, three] = anchors(await f.call("read"));
231  await f.call("edit", { edits: [
232    { old: [one], after: [two], new: ["first"] },
233    { before: [one], old: [two], after: [three], new: ["second"] },
234  ] });
235  assert.equal(await readFile(f.path, "utf8"), "first\nsecond\nthree\n");
236});
237
238test("insert before/after, delete, and reuse hashes returned by edit", async (t) => {
239  const f = await fixture(t, "middle\n");
240  const [middle] = anchors(await f.call("read"));
241  const diff = await f.call("edit", { edits: [
242    { old: [], after: [middle], new: ["first"] },
243    { before: [middle], old: [], new: ["last"] },
244  ] });
245  assert.equal(await readFile(f.path, "utf8"), "first\nmiddle\nlast\n");
246  const [first, last] = anchors(diff).map((anchor) => anchor.slice(1));
247  await f.call("edit", { edits: [{ old: [first, middle, last], new: [] }] });
248  assert.equal(await readFile(f.path, "utf8"), "");
249});
250
251test("empty files can be seeded, but nonempty files require a selector", async (t) => {
252  const f = await fixture(t, "");
253  assert.match(await f.call("read"), /Empty file/);
254  await f.call("edit", { edits: [{ old: [], new: ["seed"] }] });
255  assert.equal(await readFile(f.path, "utf8"), "seed\n");
256  await assert.rejects(f.call("edit", { edits: [{ old: [], new: ["unsafe"] }] }), /context/);
257});
258
259test("BOM, mixed line endings, and missing final newline survive edits", async (t) => {
260  const f = await fixture(t, "\uFEFFone\r\ntwo\nthree");
261  const [one, two, three] = anchors(await f.call("read"));
262  await f.call("edit", { edits: [{ old: [one], new: ["first"] }] });
263  assert.equal(await readFile(f.path, "utf8"), "\uFEFFfirst\r\ntwo\nthree");
264  await f.call("edit", { edits: [{ before: [three], old: [], new: ["last"] }] });
265  assert.equal(await readFile(f.path, "utf8"), "\uFEFFfirst\r\ntwo\nthree\r\nlast");
266  assert.equal(anchors(await f.call("read"))[1], two);
267});
268
269test("no-op replacements preserve mixed line endings byte-for-byte", async (t) => {
270  const original = "\uFEFFone\r\ntwo\nthree";
271  const f = await fixture(t, original);
272  const old = anchors(await f.call("read"));
273  assert.match(await f.call("edit", { edits: [{ old, new: ["one", "two", "three"] }] }), /No changes/);
274  assert.equal(await readFile(f.path, "utf8"), original);
275});
276
277test("blank replacement lines are not deletion, including at unterminated EOF", async (t) => {
278  const f = await fixture(t, "one\ntwo");
279  const [, two] = anchors(await f.call("read"));
280  await f.call("edit", { edits: [{ old: [two], new: [""] }] });
281  assert.equal(await readFile(f.path, "utf8"), "one\n\n");
282});
283
284test("no-op edits do not write, and replacement text is literal", async (t) => {
285  const f = await fixture(t, "one\n");
286  const [one] = anchors(await f.call("read"));
287  const before = await stat(f.path);
288  assert.match(await f.call("edit", { edits: [{ old: [one], new: ["one"] }] }), /No changes/);
289  assert.equal((await stat(f.path)).mtimeMs, before.mtimeMs);
290  await f.call("edit", { edits: [{ old: [one], new: [`${one}│one`, "one", "one"] }] });
291  assert.equal(await readFile(f.path, "utf8"), `${one}│one\none\none\n`);
292});
293
294test("invalid text and embedded newlines are refused without changing bytes", async (t) => {
295  for (const content of [Buffer.from([0xff, 0xfe, 65, 0]), Buffer.from([97, 0, 98]), Buffer.from([0xff])]) {
296    const f = await fixture(t, content);
297    await assert.rejects(f.call("read"), /UTF-8/);
298    await assert.rejects(f.call("edit", { edits: [{ old: [], new: ["unsafe"] }] }), /UTF-8/);
299    assert.deepEqual(await readFile(f.path), content);
300  }
301  const f = await fixture(t, "one\n");
302  const [one] = anchors(await f.call("read"));
303  for (const line of ["two\nthree", "two\rthree", "two\0three"]) {
304    await assert.rejects(f.call("edit", { edits: [{ old: [one], new: [line] }] }), /CR\/LF/);
305    assert.equal(await readFile(f.path, "utf8"), "one\n");
306  }
307});
308
309test("paged reads provide the same anchors and respect line and byte limits", async (t) => {
310  const f = await fixture(t, Array.from({ length: 2005 }, (_, i) => i.toString(36)).join("\n"));
311  const page = await f.call("read");
312  assert.equal(anchors(page).length, 2000);
313  assert.match(page, /offset=2001/);
314  const small = await f.call("read", { offset: 2, limit: 2 });
315  assert.deepEqual(anchors(small), anchors(page).slice(1, 3));
316  assert.match(small, /offset=4/);
317  assert.equal(anchors(await f.call("read", { offset: 2001 })).length, 5);
318  await assert.rejects(f.call("read", { offset: 2006 }), /beyond/);
319
320  await writeFile(f.path, ("x".repeat(30_000) + "\n").repeat(3));
321  const bytes = await f.call("read");
322  assert.equal(anchors(bytes).length, 1);
323  assert.match(bytes, /offset=2/);
324  await writeFile(f.path, "x".repeat(60_000));
325  await assert.rejects(f.call("read"), /exceeds/);
326});
327
328test("parallel edits through symlink aliases do not overwrite each other", async (t) => {
329  const f = await fixture(t, "one\ntwo\n");
330  const alias = f.path + ".link";
331  await symlink(f.path, alias);
332  const [one, two] = anchors(await f.call("read"));
333  await Promise.all([
334    f.call("edit", { edits: [{ old: [one], new: ["first"] }] }),
335    f.call("edit", { path: alias, edits: [{ old: [two], new: ["second"] }] }),
336  ]);
337  assert.equal(await readFile(f.path, "utf8"), "first\nsecond\n");
338});
339
340test("image reads delegate to Pi's built-in reader", async (t) => {
341  const png = Buffer.from(
342    "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jX1sAAAAASUVORK5CYII=",
343    "base64",
344  );
345  const f = await fixture(t, png);
346  const path = f.path + ".png";
347  await writeFile(path, png);
348  assert.match(await f.call("read", { path }), /Read image file/);
349});
350
351test("aborted calls do not write", async (t) => {
352  const f = await fixture(t, "one\n");
353  const [one] = anchors(await f.call("read"));
354  await assert.rejects(f.call("edit", { edits: [{ old: [one], new: ["changed"] }] }, AbortSignal.abort()));
355  assert.equal(await readFile(f.path, "utf8"), "one\n");
356});