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