char-slop/berry
multipurpose self-modifiable agentic harness
git clone https://git.t4t.associates/char-slop/berry
3d23fd2
main
1import { Signal } from "@char/aftercare" ; 2import { assert , assertEquals , assertStringIncludes } from "@std/assert" ; 3import { decodeBase64 } from "@std/encoding/base64" ; 4import type { ImageContent } from "@earendil-works/pi-ai" ; 5import { IDBFactory } from "fake-indexeddb" ; 6import { PNG , until } from "@berry/util" ; 7import { withDom } from "@berry/util/dom.ts" ; 8import { composer } from "./composer.tsx" ; 9import { imageDraft } from "./image-drafts.ts" ; 10import type { CommandInfo } from "./conversation.tsx" ; 11import { Scope } from "./ui.ts" ; 12 13Deno . test ( "pasted images are durable route-specific drafts and sends retire only captured attachments" , async ( t ) => { 14let decode = () => Promise . resolve ({ width :1 , height :1 , close () {} }); 15const globals = { 16indexedDB :new IDBFactory (), 17crypto :{ getRandomValues :crypto . getRandomValues . bind ( crypto ) }, 18createImageBitmap :() => decode (), 19}; 20await withDom ( async ( window ) => { 21const commands = new Signal < readonly CommandInfo []>([]); 22const scopes :Scope [] = []; 23const errors :string [] = []; 24const calls :{ text :string ; followUp :boolean ; images :readonly ImageContent [] }[] = []; 25let send = () => Promise . resolve (); 26const file = ( name = "screenshot.png" ) => 27new File ([ decodeBase64 ( PNG )], name , { type :"image/png" }); 28const open = async ( route :string , busy = false ) => { 29location . hash = `#/ ${ route } ` ; 30const scope = new Scope (); 31scopes . push ( scope ); 32const view = composer ( scope , route , new Signal ( busy ), commands , ( text , followUp , images ) => { 33calls . push ({ text, followUp, images}); 34return send (); 35}, ( message ) => errors . push ( message )); 36document . body . replaceChildren ( view . element ); 37await imageDraft ( route ). ready ; 38const input = view . element . querySelector ( "textarea" ) ! ; 39return { 40 ...view , 41 input, 42paste ( files :readonly File [], text = "" ) { 43const event = new window . Event ( "paste" , { cancelable :true , bubbles :true }); 44Object . defineProperty ( event , "clipboardData" , { 45value :{ 46items :files . map (( file ) => ({ 47kind :"file" , 48type :file . type , 49getAsFile :() => file , 50})), 51getData :( format :string ) => format === "text/plain" ?text :"" , 52}, 53}); 54input . dispatchEvent ( event as unknown as Event ); 55return event . defaultPrevented ; 56}, 57async saved () { 58await until (() => 59imageDraft ( route ). items . get (). length > 0 && 60imageDraft ( route ). items . get (). every (( image ) => image . status !== "loading" ) 61); 62}, 63async submit ( followUp = false ) { 64input . dispatchEvent ( 65new window . KeyboardEvent ( "keydown" , { 66key :"Enter" , 67altKey :followUp , 68}) as unknown as Event , 69); 70await until (() => ! imageDraft ( route ). submitting ); 71}, 72leave () { 73scope . dispose (); 74view . element . remove (); 75}, 76}; 77}; 78let reloads = 0 ; 79const fresh = async ( route :string ) => { 80const module = await import ( 81new URL ( `./image-drafts.ts?reload= ${ ++ reloads } ` , import . meta. url ). href 82) as typeof import ( "./image-drafts.ts" ); 83const draft = module . imageDraft ( route ); 84await draft . ready ; 85assertEquals ( draft . error . get (), null ); 86return draft . items . get (); 87}; 88try { 89await t . step ( 90"ordinary paste stays native and mixed paste preserves the text selection" , 91async () => { 92const view = await open ( "mixed" ); 93assertEquals ( view . paste ([], "plain text" ), false ); 94view . setText ( "hello world" ); 95view . input . setSelectionRange ( 6 , 11 ); 96assertEquals ( view . paste ([ file ()], "there" ), true ); 97assertEquals ( view . input . value , "hello there" ); 98await view . saved (); 99assertEquals ( view . element . querySelector ( "img" ) ! . alt , "screenshot.png" ); 100await view . submit (); 101assertEquals ( calls . at ( - 1 ), { 102text :"hello there" , 103followUp :false , 104images :[{ type :"image" , mimeType :"image/png" , data :PNG }], 105}); 106assertEquals ( view . input . value , "" ); 107assertEquals ( view . element . querySelectorAll ( ".attachment" ). length , 0 ); 108assertEquals ( await fresh ( "mixed" ), []); 109}, 110); 111await t . step ( 112"images survive navigation and a fresh frontend without crossing branch routes" , 113async () => { 114for ( const route of [ "draft" , "draft/fork@4" ]) { 115const view = await open ( route ); 116view . paste ([ file ( ` ${ route } .png` )]); 117await view . saved (); 118view . leave (); 119} 120for ( const route of [ "draft" , "draft/fork@4" ]) { 121const restored = await fresh ( route ); 122assertEquals ( restored . length , 1 ); 123assertEquals ( restored [ 0 ]. name , ` ${ route } .png` ); 124assertEquals ( restored [ 0 ]. blob . type , "image/png" ); 125assertEquals ( new Uint8Array ( await restored [ 0 ]. blob . arrayBuffer ()), decodeBase64 ( PNG )); 126const view = await open ( route ); 127assertEquals ( view . element . querySelector ( "img" ) ! . alt , ` ${ route } .png` ); 128view . leave (); 129} 130assertEquals (( await open ( "unrelated" )). element . querySelectorAll ( ".attachment" ). length , 0 ); 131}, 132); 133await t . step ( 134"failed sends retain images and image-only retries clear their durable draft" , 135async () => { 136const view = await open ( "retry-images" ); 137view . paste ([ file ()]); 138await view . saved (); 139send = () => Promise . reject ( new Error ( "offline" )); 140await view . submit (); 141assertEquals ( errors . at ( - 1 ), "offline" ); 142assertEquals (( await fresh ( "retry-images" )). length , 1 ); 143assertEquals ( view . element . querySelectorAll ( ".attachment" ). length , 1 ); 144send = () => Promise . resolve (); 145await view . submit (); 146assertEquals ( calls . at ( - 1 ) ! . text , "" ); 147assertEquals ( calls . at ( - 1 ) ! . images [ 0 ]. data , PNG ); 148assertEquals ( await fresh ( "retry-images" ), []); 149}, 150); 151await t . step ( 152"pending sends protect later pastes and edits, even after returning to the route" , 153async () => { 154const pending = Promise . withResolvers < void >(); 155send = () => pending . promise ; 156const view = await open ( "pending-images" ); 157view . setText ( "old message" ); 158view . paste ([ file ( "old.png" )]); 159await view . saved (); 160const before = calls . length ; 161const submitted = view . submit (); 162await until (() => calls . length === before + 1 ); 163view . leave (); 164const returned = await open ( "pending-images" ); 165const duplicate = returned . submit (); 166assertEquals ( calls . length , before + 1 ); 167returned . setText ( "next message" ); 168returned . paste ([ file ( "next.png" )]); 169await returned . saved (); 170pending . resolve (); 171await submitted ; 172await duplicate ; 173assertEquals ( returned . input . value , "next message" ); 174assertEquals ([ ...returned . element . querySelectorAll ( "img" )]. map (( img ) => img . alt ), [ 175"next.png" , 176]); 177assertEquals (( await fresh ( "pending-images" )). map (( image ) => image . name ), [ "next.png" ]); 178send = () => Promise . resolve (); 179}, 180); 181await t . step ( 182"commands refuse attachments and busy sends preserve their delivery mode" , 183async () => { 184let commandsRun = 0 ; 185commands . set ([{ 186name :"local" , 187extension :"test" , 188description :"local" , 189run :() => { 190commandsRun ++ ; 191return Promise . resolve (); 192}, 193}]); 194const view = await open ( "command-images" ); 195view . paste ([ file ()]); 196await view . saved (); 197view . setText ( "/local" ); 198const before = calls . length ; 199await view . submit (); 200assertEquals ( commandsRun , 0 ); 201assertEquals ( calls . length , before ); 202assertEquals ( errors . at ( - 1 ), "Slash commands cannot include images" ); 203assertEquals (( await fresh ( "command-images" )). length , 1 ); 204const busy = await open ( "busy-images" , true ); 205busy . paste ([ file ()]); 206await busy . saved (); 207await busy . submit ( true ); 208assertEquals ( calls . at ( - 1 ) ! . followUp , true ); 209assertEquals ( calls . at ( - 1 ) ! . images [ 0 ]. data , PNG ); 210}, 211); 212await t . step ( 213"restored messages retain attachments and never replace text or image drafts" , 214async () => { 215const view = await open ( "restored-images" ); 216const content = [ 217{ type :"text" as const , text :"Bring this back" }, 218{ type :"image" as const , mimeType :"image/png" , data :PNG }, 219]; 220view . setText ( " " ); 221assertEquals ( await view . restore ( content ), false ); 222assertEquals ( view . input . value , " " ); 223view . setText ( "" ); 224assertEquals ( await view . restore ( content ), true ); 225assertEquals ( view . input . value , "Bring this back" ); 226assertEquals (( await fresh ( "restored-images" )). length , 1 ); 227view . setText ( "" ); 228assertEquals ( await view . restore ( "must not replace the image" ), false ); 229assertEquals ( view . input . value , "" ); 230view . setText ( "Bring this back" ); 231await view . submit (); 232assertEquals ( calls . at ( - 1 ), { 233text :"Bring this back" , 234followUp :false , 235images :[{ type :"image" , mimeType :"image/png" , data :PNG }], 236}); 237assertEquals ( await fresh ( "restored-images" ), []); 238}, 239); 240await t . step ( "invalid and undecodable images cannot be sent and can be removed" , async () => { 241const view = await open ( "invalid-images" ); 242const before = calls . length ; 243view . paste ([ new File ([ "not an image" ], "broken.png" , { type :"image/png" })]); 244await view . saved (); 245assertStringIncludes ( 246view . element . querySelector ( ".attachment-status" ) ! . textContent ! , 247"declared format" , 248); 249await view . submit (); 250assertEquals ( calls . length , before ); 251assertEquals ( await fresh ( "invalid-images" ), []); 252view . element . querySelector < HTMLButtonElement >( '[title="Remove image"]' ) ! . click (); 253await until (() => imageDraft ( "invalid-images" ). items . get (). length === 0 ); 254decode = () => Promise . reject ( new Error ( "Cannot decode image" )); 255view . paste ([ file ()]); 256await view . saved (); 257assertStringIncludes ( 258view . element . querySelector ( ".attachment-status" ) ! . textContent ! , 259"Cannot decode" , 260); 261decode = () => Promise . resolve ({ width :1 , height :1 , close () {} }); 262const crowded = await open ( "crowded-images" ); 263crowded . paste ( Array . from ({ length :5 }, () => file ())); 264await until (() => errors . at ( - 1 )?. includes ( "at most 4" ) === true ); 265assertEquals ( crowded . element . querySelectorAll ( ".attachment" ). length , 0 ); 266}); 267assert ( calls . length > 0 ); 268} finally { 269for ( const scope of scopes ) scope . dispose (); 270} 271}, { globals}); 272});