char-slop/berry

multipurpose self-modifiable agentic harness

git clone https://git.t4t.associates/char-slop/berry

Charlotte Somallow .berry/AGENTS.md to override AGENTS.mdb2cc381

main
8.5 KiB187 linesraw
1import {
2  assert,
3  assertEquals,
4  assertMatch,
5  assertRejects,
6  assertStringIncludes,
7} from "@std/assert";
8import { mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises";
9import { join } from "node:path";
10import { localEnvironment } from "@berry/api/environment.ts";
11import { rootedEnvironment, testExtension } from "@berry/api/testing.ts";
12import code from "./mod.ts";
13import { tempDir } from "@berry/util";
14
15async function project(run: (root: string) => Promise<void>) {
16  await using rootDir = await tempDir({ prefix: "code-test-" });
17  const root = rootDir.path;
18  await run(root);
19}
20
21type BashResult = { output: string; exitCode: number; fullOutputPath?: string };
22
23Deno.test("files, commands, and project context all come from the conversation's environment", () =>
24  project(async (root) => {
25    // nothing here is at these paths on this machine, so a path that bypasses the environment finds nothing
26    await mkdir(join(root, "project", "src"), { recursive: true });
27    await mkdir(join(root, "home"));
28    await mkdir(join(root, "bin"));
29    await writeFile(join(root, "AGENTS.md"), "outer rules");
30    await writeFile(join(root, "project", "AGENTS.md"), "project rules");
31    await writeFile(join(root, "project", "notes.txt"), "hello\n");
32    await writeFile(join(root, "home", "todo.txt"), "at home\n");
33    await writeFile(join(root, "bin", "jj"), "");
34    const env = rootedEnvironment(root, { cwd: "/project", variables: { PATH: "/bin" } });
35    const ext = testExtension(code, { env });
36
37    assertMatch(await ext.call("read", { path: "notes.txt" }) as string, /│hello$/);
38    assertMatch(await ext.call("read", { path: "/project/notes.txt" }) as string, /│hello$/);
39    assertMatch(await ext.call("read", { path: "~/todo.txt" }) as string, /│at home$/);
40    const [anchor] = (await ext.call("read", { path: "notes.txt" }) as string).split("│");
41    await ext.call("edit", { path: "notes.txt", edits: [{ old: [anchor], new: ["edited"] }] });
42    assertEquals(await readFile(join(root, "project", "notes.txt"), "utf8"), "edited\n");
43    await ext.call("write", { path: "/project/src/new.txt", content: "x" });
44    assertEquals(await readFile(join(root, "project", "src", "new.txt"), "utf8"), "x");
45    assertEquals(await ext.call("list", { path: "/project" }), "AGENTS.md\nnotes.txt\nsrc/");
46    assertEquals(
47      (await ext.call("bash", { command: "cat notes.txt", timeout: 10 }) as BashResult).output,
48      "edited\n",
49    );
50
51    const prompt = await ext.prompt();
52    assertStringIncludes(prompt, "current working directory: /project");
53    assert(prompt.indexOf("## /AGENTS.md") < prompt.indexOf("## /project/AGENTS.md"));
54    assertStringIncludes(prompt, "project rules");
55    // its default prose comes first, as the harness prepends it
56    assertStringIncludes(prompt, "guidelines:");
57    // the commands it has, not this machine's
58    assertStringIncludes(prompt, "available workflow commands: jj");
59    const commands = prompt.split("\n").find((line) =>
60      line.startsWith("available workflow commands:")
61    )!;
62    assert(!commands.includes("tmux"));
63  }));
64
65Deno.test(".berry/AGENTS.md replaces only its directory's instructions, even when empty", () =>
66  project(async (root) => {
67    await mkdir(join(root, "project", ".berry"), { recursive: true });
68    await mkdir(join(root, "project", "src"));
69    await writeFile(join(root, "AGENTS.md"), "outer rules");
70    await writeFile(join(root, "project", "AGENTS.md"), "shared rules");
71    await writeFile(join(root, "project", "src", "AGENTS.md"), "inner rules");
72    const override = join(root, "project", ".berry", "AGENTS.md");
73    await writeFile(override, "berry rules");
74    const ext = testExtension(code, { env: rootedEnvironment(root, { cwd: "/project/src" }) });
75
76    const prompt = await ext.prompt();
77    assertStringIncludes(prompt, "outer rules");
78    assertStringIncludes(prompt, "/project/.berry/AGENTS.md");
79    assertStringIncludes(prompt, "berry rules");
80    assertStringIncludes(prompt, "inner rules");
81    assert(!prompt.includes("shared rules"));
82    assert(prompt.indexOf("outer rules") < prompt.indexOf("berry rules"));
83    assert(prompt.indexOf("berry rules") < prompt.indexOf("inner rules"));
84
85    await writeFile(override, "");
86    const empty = await ext.prompt();
87    assert(!empty.includes("shared rules"));
88    assert(!empty.includes("berry rules"));
89    assertStringIncludes(empty, "outer rules");
90    assertStringIncludes(empty, "inner rules");
91
92    await rm(override);
93    assertStringIncludes(await ext.prompt(), "shared rules");
94  }));
95
96Deno.test("list shows types, descends to a depth, and skips opaque directories", () =>
97  project(async (root) => {
98    await mkdir(join(root, "src", "deep"), { recursive: true });
99    await mkdir(join(root, "node_modules", "pkg"), { recursive: true });
100    await writeFile(join(root, "src", "a.ts"), "");
101    await writeFile(join(root, "src", "deep", "b.ts"), "");
102    await symlink(join(root, "src"), join(root, "link"));
103    const ext = testExtension(code, { env: localEnvironment(root) });
104
105    assertEquals(await ext.call("list", {}), "link@\nnode_modules/\nsrc/");
106    assertEquals(
107      await ext.call("list", { depth: 3 }),
108      ["link@", "node_modules/", "src/", "src/a.ts", "src/deep/", "src/deep/b.ts"].join("\n"),
109    );
110    assertEquals(await ext.call("list", { path: "src" }), "a.ts\ndeep/");
111  }));
112
113Deno.test("list skips paths ignored by nested gitignores", () =>
114  project(async (root) => {
115    await mkdir(join(root, "data"), { recursive: true });
116    await mkdir(join(root, "src"), { recursive: true });
117    await writeFile(join(root, ".gitignore"), "data/\n*.log\n");
118    await writeFile(join(root, "src/.gitignore"), "target.ts\n!keep.log\n");
119    await writeFile(join(root, "data/target.ts"), "");
120    await writeFile(join(root, "src/target.ts"), "");
121    await writeFile(join(root, "src/target.log"), "");
122    await writeFile(join(root, "src/keep.log"), "");
123    await writeFile(join(root, "target.ts"), "");
124    const ext = testExtension(code, { env: localEnvironment(root) });
125
126    assertEquals(await ext.call("list", {}), ".gitignore\nsrc/\ntarget.ts");
127    assertEquals(
128      await ext.call("list", { depth: 3 }),
129      [".gitignore", "src/", "src/.gitignore", "src/keep.log", "target.ts"].join("\n"),
130    );
131  }));
132
133Deno.test("write creates parent directories and replaces files", () =>
134  project(async (root) => {
135    const ext = testExtension(code, { env: localEnvironment(root) });
136    await ext.call("write", { path: "a/b/c.txt", content: "one" });
137    await ext.call("write", { path: "a/b/c.txt", content: "two" });
138    assertEquals(await readFile(join(root, "a/b/c.txt"), "utf8"), "two");
139  }));
140
141Deno.test("bash reports exit codes without rejecting and streams output", () =>
142  project(async (root) => {
143    const ext = testExtension(code, { env: localEnvironment(root) });
144    const streamed: string[] = [];
145    const result = await ext.call("bash", {
146      command: "echo out; echo err >&2; exit 3",
147      timeout: 10,
148    }, {
149      output: (chunk) => streamed.push(chunk),
150    }) as BashResult;
151    assertEquals(result.exitCode, 3);
152    assertEquals(result.output.split("\n").filter(Boolean).sort(), ["err", "out"]);
153    assertEquals(streamed.join(""), result.output);
154  }));
155
156Deno.test("bash requires an explicit timeout before executing a command", () =>
157  project(async (root) => {
158    const ext = testExtension(code, { env: localEnvironment(root) });
159    await assertRejects(() => ext.call("bash", { command: "touch ran" }), Error, "timeout");
160    assertEquals(await Deno.stat(join(root, "ran")).catch(() => null), null);
161  }));
162
163Deno.test("bash rejects on timeout and abort", () =>
164  project(async (root) => {
165    const ext = testExtension(code, { env: localEnvironment(root) });
166    await assertRejects(
167      () => ext.call("bash", { command: "sleep 5", timeout: 0.2 }),
168      Error,
169      "timed out",
170    );
171    const controller = new AbortController();
172    setTimeout(() => controller.abort(), 100);
173    await assertRejects(() =>
174      ext.call("bash", { command: "sleep 5", timeout: 10 }, { signal: controller.signal })
175    );
176  }));
177
178Deno.test("long bash output keeps its tail and spills the whole of it", () =>
179  project(async (root) => {
180    const ext = testExtension(code, { env: localEnvironment(root) });
181    const result = await ext.call("bash", { command: "seq 1 5000", timeout: 10 }) as BashResult;
182    const lines = result.output.trim().split("\n");
183    assertEquals(lines.length, 2000);
184    assertEquals(lines.at(-1), "5000");
185    assert(result.fullOutputPath);
186    assertEquals((await readFile(result.fullOutputPath, "utf8")).trim().split("\n").length, 5000);
187  }));