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