char-slop/berry
multipurpose self-modifiable agentic harness
git clone https://git.t4t.associates/char-slop/berry
32b51b2
main
1import { Signal } from "@char/aftercare" ; 2import { fauxAssistantMessage , fauxText } from "@earendil-works/pi-ai/providers/faux" ; 3import { assert , assertEquals , assertExists , assertStrictEquals } from "@std/assert" ; 4import { type FakeRequest , fill , press } from "@berry/util/dom.ts" ; 5import type { Shared } from "./conversation.tsx" ; 6import { withBerry } from "./testing.ts" ; 7 8const tid = "aaaaaaaaaaaaa" ; 9const steer = { id :1 , mode :"steer" , content :"First message" }; 10const followUp = { id :2 , mode :"followUp" , content :"Second message" }; 11const attachment = { type :"image" , mimeType :"image/png" , data :"unchanged-image-bytes" }; 12const illustrated = { 13 ...followUp , 14content :[{ type :"text" , text :"Original caption" }, attachment ], 15}; 16const run = { taskId :1 , inputs :[ 3 ] }; 17type Page = { 18requests :() => FakeRequest []; 19shared :Shared ; 20snapshot :( items :unknown [], live ?:object , agent ?:object , entries ?:unknown []) => Promise < void >; 21shell :( state :object ) => Promise < void >; 22configuration :( name :string , values :object ) => Promise < void >; 23settle :() => Promise < void >; 24respond :( response :() => unknown ) => void ; 25tree :( value :unknown ) => void ; 26commandCatalog :( response :() => unknown ) => void ; 27}; 28 29function withConversation ( test :( page :Page ) => Promise < void >) { 30return withBerry ( async ({ server, scope, settle}) => { 31let respond :() => unknown = () => ({}); 32let tree :unknown = {}; 33let commandCatalog :() => unknown = () => []; 34server . respond (({ method, path}) => 35method !== "GET" 36 ?respond () 37 :path . endsWith ( "/tree" ) 38 ?tree 39 :path . endsWith ( "/commands" ) 40 ?commandCatalog () 41 :[] 42); 43const shared :Shared = { 44extensions :new Signal ([]), 45models :new Signal ([]), 46enabled :new Signal ([]), 47machines :new Signal ([ "local" ]), 48projects :new Signal ({}), 49refreshModels () {}, 50}; 51const { conversation} = await import ( "./conversation.tsx" ); 52document . body . append ( ...conversation ( scope , tid , shared )); 53await test ({ 54requests :server . changes , 55 shared, 56 settle, 57async snapshot ( items , live = {}, agent = {}, entries = []) { 58server . snapshot ( `conversation: ${ tid } ` , { 59 entries, 60docs :{ "pi.inbox" :{ items}, "pi.live" :live , "pi.agent" :agent }, 61}); 62await settle (); 63}, 64async shell ( state ) { 65server . snapshot ( `shell: ${ tid } ` , state ); 66await settle (); 67}, 68async configuration ( name , values ) { 69server . snapshot ( `configuration: ${ tid } : ${ name } ` , values ); 70await settle (); 71}, 72respond :( next ) => void ( respond = next ), 73tree :( value ) => void ( tree = value ), 74commandCatalog :( next ) => void ( commandCatalog = next ), 75}); 76}, { url :`http://berry.test/#/ ${ tid } ` }); 77} 78 79Deno . test ( "sending from the bottom follows the message when the working status reduces the viewport" , async () => { 80await withConversation ( async ({ snapshot, settle}) => { 81const scroller = document . querySelector < HTMLElement >( ".scroller" ) ! ; 82const working = document . querySelector < HTMLElement >( ".working" ) ! ; 83let top = 0 ; 84Object . defineProperties ( scroller , { 85clientHeight :{ get :() => working . hidden ?400 :376 }, 86scrollHeight :{ 87get :() => 800 + scroller . querySelectorAll ( ".message" ). length * 200 , 88}, 89scrollTop :{ 90get :() => top , 91set :( value :number ) => { 92top = Math . max ( 0 , Math . min ( value , scroller . scrollHeight - scroller . clientHeight )); 93}, 94}, 95}); 96const previous = { 97id :1 , 98kind :"pi.user" , 99model :[{ role :"user" , content :"earlier message" , timestamp :0 }], 100}; 101await snapshot ([], {}, {}, [ previous ]); 102assertEquals ( top , scroller . scrollHeight - scroller . clientHeight ); 103 104const input = document . querySelector < HTMLTextAreaElement >( ".composer textarea" ) ! ; 105fill ( input , "new message" ); 106press ( input , "Enter" ); 107await settle (); 108await snapshot ([], { run}, {}, [ previous , { 109id :2 , 110kind :"pi.user" , 111model :[{ role :"user" , content :"new message" , timestamp :0 }], 112}]); 113assertEquals ( working . hidden , false ); 114assertEquals ( top , scroller . scrollHeight - scroller . clientHeight ); 115}); 116}); 117 118function row ( index = 0 ) :HTMLElement { 119const element = document . querySelectorAll < HTMLElement >( ".inbox .queued" )[ index ]; 120assertExists ( element ); 121return element ; 122} 123 124function button ( element :Element , label :string ) :HTMLButtonElement { 125const found = [ ...element . querySelectorAll ( "button" )]. find (( b ) => b . textContent === label ); 126assertExists ( found ); 127return found ; 128} 129 130function editor ( element :Element ) :HTMLTextAreaElement { 131const textarea = element . querySelector ( "textarea" ); 132assertExists ( textarea ); 133return textarea ; 134} 135 136Deno . test ( "abort restores the first stopped input, keeps the rest out of the live queue, and protects newer drafts" , async () => { 137await withConversation ( async ({ snapshot, respond, requests, settle}) => { 138const input = document . querySelector < HTMLTextAreaElement >( ".composer textarea" ) ! ; 139const abort = document . querySelector < HTMLButtonElement >( '[aria-label="Abort response"]' ) ! ; 140await snapshot ([ steer , followUp ], { run}); 141const response = Promise . withResolvers < unknown >(); 142respond (() => response . promise ); 143abort . click (); 144await settle (); 145await snapshot ([]); 146response . resolve ({ inputs :[ followUp , { ...steer , content :"Latest queued edit" }] }); 147await settle (); 148assertEquals ( input . value , "Latest queued edit" ); 149const stopped = document . querySelector ( ".stopped-inputs" ) ! ; 150assertEquals ( stopped . querySelectorAll ( ".queued" ). length , 1 ); 151assertEquals ( stopped . textContent ! . includes ( followUp . content ), true ); 152button ( stopped , "Restore" ). click (); 153await settle (); 154assertEquals ( input . value , "Latest queued edit" ); 155assertEquals ( stopped . querySelectorAll ( ".queued" ). length , 1 ); 156fill ( input , "" ); 157button ( stopped , "Restore" ). click (); 158await settle (); 159assertEquals ( input . value , followUp . content ); 160assertEquals ( stopped . querySelectorAll ( ".queued" ). length , 0 ); 161assertEquals ( requests (). filter (({ path}) => path . endsWith ( "/messages" )), []); 162 163fill ( input , "" ); 164await snapshot ([ steer ], { run}); 165const delayed = Promise . withResolvers < unknown >(); 166respond (() => delayed . promise ); 167abort . click (); 168await settle (); 169fill ( input , "Typed while aborting" ); 170await snapshot ([]); 171delayed . resolve ({ inputs :[ steer ] }); 172await settle (); 173assertEquals ( input . value , "Typed while aborting" ); 174assertEquals ( stopped . querySelectorAll ( ".queued" ). length , 1 ); 175button ( stopped , "Dismiss" ). click (); 176await settle (); 177assertEquals ( stopped . querySelectorAll ( ".queued" ). length , 0 ); 178 179await snapshot ([ followUp ], { run}); 180respond (() => Response . json ({ error :"Could not stop" }, { status :500 })); 181abort . click (); 182await settle (); 183assertEquals ( input . value , "Typed while aborting" ); 184assertEquals ( stopped . querySelectorAll ( ".queued" ). length , 0 ); 185assertEquals ( row (). textContent ! . includes ( followUp . content ), true ); 186fill ( input , "" ); 187}); 188}); 189 190Deno . test ( "pending configuration does not block multiple follow-ups, but idle sends wait for it" , async () => { 191await withConversation ( async ({ shared, snapshot, configuration, respond, requests, settle}) => { 192shared . extensions . set ([{ 193name :"probe" , 194description :null , 195status :"loaded" , 196error :null , 197running :true , 198builtin :false , 199alwaysOn :false , 200defaultEnabled :false , 201web :null , 202formerly :[], 203promptProse :null , 204configuration :{ 205properties :{ 206setting :{ type :"string" , default :"" }, 207other :{ type :"string" , default :"" }, 208}, 209}, 210}]); 211await snapshot ([], { run}, { extensions :[ "probe" ] }); 212await configuration ( "probe" , { setting :"original" , other :"original" }); 213const saved = Promise . withResolvers < unknown >(); 214let responding = 0 ; 215respond (() => responding ++ === 0 ?saved . promise :{}); 216const dropdown = document . querySelector < HTMLDetailsElement >( 217".conversation-header .extensions" , 218) ! ; 219dropdown . querySelector ( "summary" ) ! . click (); 220await settle (); 221const setting = document . querySelector < HTMLInputElement >( ".configuration input" ) ! ; 222setting . focus (); 223fill ( setting , "updated" ); 224const other = document . querySelectorAll < HTMLInputElement >( ".configuration input" )[ 1 ]; 225other . focus (); 226assertEquals ( other . disabled , false ); 227fill ( other , "also updated" ); 228other . blur (); 229assertEquals ( requests (). filter (({ method}) => method === "PATCH" ), []); 230document . body . click (); 231assertEquals ( dropdown . open , false ); 232await settle (); 233assertEquals ( requests (). at ( - 1 )?. path , `/api/conversations/ ${ tid } /configuration/probe` ); 234assertEquals ( requests (). at ( - 1 )?. body , { setting :"updated" , other :"also updated" }); 235 236const input = document . querySelector < HTMLTextAreaElement >( ".composer textarea" ) ! ; 237fill ( input , "First follow-up" ); 238press ( input , "Enter" , { altKey :true }); 239await settle (); 240assertEquals ( input . value , "" ); 241fill ( input , "Second follow-up" ); 242press ( input , "Enter" , { altKey :true }); 243await settle (); 244assertEquals ( 245requests (). filter (({ path}) => path . endsWith ( "/messages" )). map (({ body}) => body ), 246[ 247{ content :"First follow-up" , whenBusy :"followUp" }, 248{ content :"Second follow-up" , whenBusy :"followUp" }, 249], 250); 251 252await snapshot ([], {}, { extensions :[ "probe" ] }); 253fill ( input , "Idle message" ); 254press ( input , "Enter" ); 255await settle (); 256assertEquals ( input . value , "Idle message" ); 257assertEquals ( requests (). filter (({ path}) => path . endsWith ( "/messages" )). length , 2 ); 258saved . resolve ({ setting :"updated" , other :"also updated" }); 259await settle (); 260assertEquals ( requests (). at ( - 1 )?. body , { content :"Idle message" }); 261assertEquals ( input . value , "" ); 262}); 263}); 264 265Deno . test ( "command completion follows target changes and ignores stale catalogs" , async () => { 266await withConversation ( async ({ snapshot, commandCatalog, settle}) => { 267const workspace = { name :"workspace" , extension :"code" , description :"jj workspace" }; 268commandCatalog (() => [ workspace ]); 269const binding = ( id :number , environment :string , cwd :string ) => ({ 270 id, 271kind :"berry.event" , 272data :{ ext :"environment" , event :{ type :"bind" , environment, cwd} }, 273}); 274const first = binding ( 1 , "local" , "/repo" ); 275await snapshot ([], {}, { extensions :[ "code" ] }, [ first ]); 276const input = document . querySelector < HTMLTextAreaElement >( ".composer textarea" ) ! ; 277fill ( input , "/work" ); 278await settle (); 279const offered = () => 280[ ...document . querySelectorAll ( ".suggestions button" )]. some (( button ) => 281button . textContent ?. includes ( "/workspace" ) 282); 283assertEquals ( offered (), true ); 284 285let resolveOld ! :( value :unknown ) => void ; 286commandCatalog (() => new Promise (( resolve ) => resolveOld = resolve )); 287const second = binding ( 2 , "local" , "/elsewhere" ); 288await snapshot ([], {}, { extensions :[ "code" ] }, [ first , second ]); 289assertEquals ( offered (), false ); 290commandCatalog (() => []); 291const third = binding ( 3 , "remote" , "/elsewhere" ); 292await snapshot ([], {}, { extensions :[ "code" ] }, [ first , second , third ]); 293resolveOld ([ workspace ]); 294await settle (); 295assertEquals ( offered (), false ); 296 297commandCatalog (() => [ workspace ]); 298await snapshot ([], {}, { extensions :[ "code" ] }, [ 299first , 300second , 301third , 302binding ( 4 , "remote" , "/repo" ), 303]); 304assertEquals ( offered (), true ); 305}); 306}); 307 308Deno . test ( "the queue lists steers before follow-ups, as they will reach the agent" , async () => { 309await withConversation ( async ({ snapshot}) => { 310const later = { ...followUp , id :1 }; 311const sooner = { ...steer , id :2 }; 312await snapshot ([ later ], { run}); 313await snapshot ([ later , sooner ], { run}); 314const content = ( index :number ) => row ( index ). querySelector ( ".queued-content" ) ! . textContent ; 315assertEquals ([ content ( 0 ), content ( 1 )], [ sooner . content , later . content ]); 316}); 317}); 318 319Deno . test ( "queued editors retain identity, focus, selection and local drafts across snapshots" , async () => { 320await withConversation ( async ({ snapshot, shared, requests}) => { 321await snapshot ([ steer , followUp ], { run, generation :{ attempt :1 } }); 322const first = row (); 323const second = row ( 1 ); 324button ( first , "Edit" ). click (); 325const draft = editor ( first ); 326fill ( draft , "Unsent local draft" ); 327draft . setSelectionRange ( 3 , 9 ); 328await snapshot ([ steer , followUp , { ...steer , id :4 }], { 329 run, 330tools :[{ callId :"call" , name :"test" , status :"running" }], 331}); 332shared . machines . set ([ "local" , "remote" ]); 333assertStrictEquals ( row (), first ); 334assertStrictEquals ( editor ( first ), draft ); 335assertStrictEquals ( document . activeElement , draft ); 336assertEquals ([ draft . selectionStart , draft . selectionEnd ], [ 3 , 9 ]); 337assertEquals ( draft . value , "Unsent local draft" ); 338button ( second , "Edit" ). click (); 339fill ( editor ( second ), "Another local draft" ); 340await snapshot ([ steer , followUp ]); 341assertEquals ( editor ( first ). value , "Unsent local draft" ); 342assertEquals ( editor ( second ). value , "Another local draft" ); 343assertEquals ( requests (), []); 344}); 345}); 346 347Deno . test ( "queued rows save only literal text, need text unless they hold images, and retract once the snapshot agrees" , async () => { 348await withConversation ( async ({ snapshot, requests, respond, settle}) => { 349await snapshot ([ steer , illustrated ]); 350const first = row (); 351const second = row ( 1 ); 352button ( second , "Edit" ). click (); 353assertEquals ( editor ( second ). value , "Original caption" ); 354fill ( editor ( second ), "/not-a-command replacement" ); 355const response = Promise . withResolvers < Response >(); 356respond (() => response . promise ); 357button ( second , "Save" ). click (); 358assertEquals ([ ...second . querySelectorAll ( "button" )]. every (( b ) => b . disabled ), true ); 359assertEquals ( editor ( second ). readOnly , true ); 360assertEquals ( requests (), [{ 361method :"PATCH" , 362path :`/api/conversations/ ${ tid } /inbox/2` , 363body :{ text :"/not-a-command replacement" }, 364}]); 365const newer = { 366 ...illustrated , 367content :[{ type :"text" , text :"Newer server revision" }, attachment ], 368}; 369await snapshot ([ steer , newer ]); 370assertEquals ( editor ( second ). value , "/not-a-command replacement" ); 371response . resolve ( Response . json ({})); 372await settle (); 373assertStrictEquals ( row (), first ); 374assertStrictEquals ( row ( 1 ), second ); 375assertEquals ( second . querySelector ( "form" ) ! . hidden , true ); 376assertEquals ( second . querySelector ( ".queued-content" ) ! . textContent , "Newer server revision" ); 377assertEquals ( document . querySelector < HTMLTextAreaElement >( ".composer textarea" ) ! . value , "" ); 378 379respond (() => ({})); 380button ( first , "Edit" ). click (); 381fill ( editor ( first ), " \n " ); 382assertEquals ( button ( first , "Save" ). disabled , true ); 383button ( first , "Save" ). click (); 384assertEquals ( requests (). length , 1 ); 385button ( first , "Cancel" ). click (); 386button ( second , "Edit" ). click (); 387fill ( editor ( second ), "" ); 388assertEquals ( button ( second , "Save" ). disabled , false ); 389button ( second , "Save" ). click (); 390await settle (); 391assertEquals ( requests (). at ( - 1 ), { 392method :"PATCH" , 393path :`/api/conversations/ ${ tid } /inbox/2` , 394body :{ text :"" }, 395}); 396const pictured = { ...illustrated , content :[ attachment ] }; 397await snapshot ([ steer , pictured ]); 398 399const retracting = Promise . withResolvers < Response >(); 400respond (() => retracting . promise ); 401button ( first , "Retract" ). click (); 402assertEquals ( button ( first , "Edit" ). disabled , true ); 403assertEquals ( button ( first , "Retract" ). disabled , true ); 404assertEquals ( requests (). at ( - 1 ), { 405method :"DELETE" , 406path :`/api/conversations/ ${ tid } /inbox/1` , 407body :undefined , 408}); 409retracting . resolve ( Response . json ({})); 410await settle (); 411assertStrictEquals ( row (), first ); 412await snapshot ([ pictured ]); 413assertStrictEquals ( row (), second ); 414assertEquals ( document . querySelectorAll ( ".queued" ). length , 1 ); 415}); 416}); 417 418Deno . test ( "failed queue edits and retractions keep the row, say why, and can be retried" , async () => { 419await withConversation ( async ({ snapshot, respond, settle, requests}) => { 420await snapshot ([ steer ]); 421button ( row (), "Edit" ). click (); 422const draft = editor ( row ()); 423fill ( draft , "Keep this draft" ); 424respond (() => 425Promise . resolve ( Response . json ({ error :"Input is no longer queued" }, { status :409 })) 426); 427button ( row (), "Save" ). click (); 428await settle (); 429await snapshot ([ steer ]); 430assertStrictEquals ( editor ( row ()), draft ); 431assertEquals ( draft . value , "Keep this draft" ); 432assertEquals ( draft . readOnly , false ); 433assertEquals ( button ( row (), "Save" ). disabled , false ); 434const error = row (). querySelector < HTMLElement >( '[role="alert"]' ) ! ; 435assertEquals ( error . hidden , false ); 436assertEquals ( error . textContent , "Input is no longer queued" ); 437respond (() => ({})); 438button ( row (), "Save" ). click (); 439await settle (); 440assertEquals ( requests (). length , 2 ); 441assertEquals ( row (). querySelector ( "form" ) ! . hidden , true ); 442assertEquals ( error . hidden , true ); 443 444respond (() => Promise . reject ( new TypeError ( "Network unavailable" ))); 445button ( row (), "Retract" ). click (); 446await settle (); 447assertEquals ( error . hidden , false ); 448assertEquals ( error . textContent , "Network unavailable" ); 449assertEquals ( button ( row (), "Retract" ). disabled , false ); 450}); 451}); 452Deno . test ( "compaction status follows live tasks without making background compaction busy" , async () => { 453await withConversation ( async ({ snapshot, requests, settle}) => { 454const status = document . querySelector < HTMLElement >( ".working" ) ! ; 455const abort = document . querySelector < HTMLButtonElement >( '[aria-label="Abort response"]' ) ! ; 456const compaction = { taskId :2 , reason :"background" , blocking :false , attempt :0 }; 457await snapshot ([], { compactions :[ compaction ] }); 458assertEquals ( status . hidden , false ); 459assertEquals ( abort . hidden , true ); 460fill ( document . querySelector < HTMLTextAreaElement >( ".composer textarea" ) ! , "Keep going" ); 461document . querySelector < HTMLButtonElement >( ".composer button.primary" ) ! . click (); 462await settle (); 463const sent = requests (). find (( request ) => request . path . endsWith ( "/messages" )); 464assertExists ( sent ); 465assertEquals (( sent . body as { whenBusy ?:string }). whenBusy , undefined ); 466 467await snapshot ([], { 468 run, 469compactions :[{ ...compaction , retry :{ at :Date . now () + 1000 , error :"Try again" } }], 470}); 471assertEquals ( abort . hidden , false ); 472 473await snapshot ([], { 474 run, 475compactions :[{ ...compaction , reason :"threshold" , blocking :true }], 476}); 477await snapshot ([], { run, compactions :[] }); 478assertEquals ( status . hidden , false ); 479await snapshot ([]); 480assertEquals ( status . hidden , true ); 481}); 482}); 483 484Deno . test ( "context usage drops immediately after compaction and uses the next response's measurement" , async () => { 485await withConversation ( async ({ shared, snapshot}) => { 486const model = { provider :"test" , modelId :"model" }; 487shared . models . set ([{ 488provider :model . provider , 489id :model . modelId , 490name :"Test model" , 491input :[ "text" ], 492reasoning :false , 493thinkingLevels :[ "off" ], 494contextWindow :100_000 , 495cost :{ input :0 , output :0 }, 496}]); 497const oldMessage = fauxAssistantMessage ([ fauxText ( "r" . repeat ( 800 ))]); 498oldMessage . usage = { ...oldMessage . usage , input :79_800 , output :200 , totalTokens :80_000 }; 499const retained = { 500id :2 , 501conversationId :tid , 502kind :"pi.assistant" , 503model :[ oldMessage ], 504}; 505const trailing = { 506id :3 , 507conversationId :tid , 508kind :"pi.user" , 509model :[{ role :"user" , content :"u" . repeat ( 1200 ), timestamp :0 }], 510}; 511await snapshot ([], {}, { model}, [ retained , trailing ]); 512const context = document . querySelector < HTMLElement >( ".conversation-header .usage span" ) ! ; 513assertEquals ( context . textContent , "80k/100k" ); 514 515const summary = { 516id :4 , 517conversationId :tid , 518kind :"pi.compaction" , 519head :retained . id , 520model :[{ role :"user" , content :"s" . repeat ( 400 ), timestamp :0 }], 521data :{ reason :"manual" }, 522}; 523await snapshot ([], {}, { model}, [ summary , retained , trailing ]); 524assertEquals ( context . textContent , "600/100k" ); 525 526const freshMessage = fauxAssistantMessage ([ fauxText ( "Fresh response" )]); 527freshMessage . usage = { ...freshMessage . usage , input :1100 , output :100 , totalTokens :1200 }; 528const response = { 529id :5 , 530conversationId :tid , 531kind :"pi.assistant" , 532model :[ freshMessage ], 533}; 534await snapshot ([], {}, { model}, [ summary , retained , trailing , response ]); 535assertEquals ( context . textContent , "1.2k/100k" ); 536 537await snapshot ([], {}, { model}, [{ 538id :6 , 539conversationId :tid , 540kind :"pi.reset" , 541head :6 , 542}]); 543assertEquals ( context . textContent , "0/100k" ); 544}); 545}); 546 547const branches = { 548head :11 , 549branches :[{ id :11 , tip :4 , created :0 }], 550entries :[ 551{ id :1 , parent :null , branch :11 , kind :"berry.start" , text :"" }, 552{ id :2 , parent :1 , branch :11 , kind :"pi.user" , text :"Original message" }, 553{ id :3 , parent :2 , branch :11 , kind :"pi.assistant" , text :"Original reply" }, 554{ id :4 , parent :3 , branch :11 , kind :"pi.user" , text :"Latest message" }, 555], 556}; 557/** A transcript of entries with these IDs. */ 558const through = ( ...ids :number []) => 559ids . map (( id ) => ({ id, conversationId :1 , kind :"berry.start" })); 560 561async function openTree ( command :string , settle :() => Promise < void >) { 562const input = document . querySelector < HTMLTextAreaElement >( ".composer textarea" ) ! ; 563fill ( input , command ); 564document . querySelector < HTMLButtonElement >( ".composer button.primary" ) ! . click (); 565await settle (); 566return document . querySelector < HTMLDialogElement >( "dialog" ) ! ; 567} 568 569Deno . test ( "the tree stays open until the transcript reaches the choice, which shows until the conversation moves on" , async () => { 570await withConversation ( async ({ snapshot, tree, requests, settle}) => { 571const agent = { extensions :[ "code" ] }; 572await snapshot ([], {}, agent , through ( 1 , 2 , 3 , 4 )); 573tree ( branches ); 574const dialog = await openTree ( "/tree" , settle ); 575document . querySelector < HTMLButtonElement >( '.tree-row[data-id="3"]' ) ! . click (); 576await settle (); 577assertEquals ( requests (), [{ 578method :"POST" , 579path :`/api/conversations/ ${ tid } /branch` , 580body :{ entryId :3 }, 581}]); 582const continuation = document . querySelector < HTMLElement >( ".continuation" ) ! ; 583assertEquals ( dialog . isConnected , true ); 584assertEquals ( continuation . hidden , true ); 585 586await snapshot ([], {}, agent , through ( 1 , 2 , 3 )); 587assertEquals ( dialog . isConnected , false ); 588assertEquals ( continuation . hidden , false ); 589assertEquals ( continuation . textContent ?. includes ( "Original reply" ), true ); 590 591const input = document . querySelector < HTMLTextAreaElement >( ".composer textarea" ) ! ; 592fill ( input , "!printf hello" ); 593document . querySelector < HTMLButtonElement >( ".composer button.primary" ) ! . click (); 594await settle (); 595assertEquals ( requests (). at ( - 1 )?. path , `/api/conversations/ ${ tid } /shell` ); 596await snapshot ([], {}, agent , through ( 1 , 2 , 3 , 5 )); 597assertEquals ( continuation . hidden , true ); 598}); 599}); 600 601Deno . test ( "choosing a message of yours in the tree brings it back to edit from before it, until undone" , async () => { 602await withConversation ( async ({ snapshot, tree, requests, settle}) => { 603await snapshot ([], {}, {}, through ( 1 , 2 , 3 , 4 )); 604tree ( branches ); 605await openTree ( "/tree" , settle ); 606document . querySelector < HTMLButtonElement >( '.tree-row[data-id="2"]' ) ! . click (); 607await settle (); 608assertEquals ( requests (), [{ 609method :"POST" , 610path :`/api/conversations/ ${ tid } /branch` , 611body :{ entryId :1 }, 612}]); 613await snapshot ([], {}, {}, through ( 1 )); 614const input = document . querySelector < HTMLTextAreaElement >( ".composer textarea" ) ! ; 615assertEquals ( input . value , "Original message" ); 616const continuation = document . querySelector < HTMLElement >( ".continuation" ) ! ; 617 618button ( continuation , "undo" ). click (); 619await settle (); 620assertEquals ( requests (). at ( - 1 ), { 621method :"POST" , 622path :`/api/conversations/ ${ tid } /branch` , 623body :{ entryId :4 }, 624}); 625assertEquals ( input . value , "" ); 626await snapshot ([], {}, {}, through ( 1 , 2 , 3 , 4 )); 627assertEquals ( continuation . hidden , true ); 628}); 629}); 630 631Deno . test ( "regenerating the agent's message from the tree closes it once the transcript moves past it" , async () => { 632await withConversation ( async ({ snapshot, tree, requests, settle}) => { 633await snapshot ([], {}, {}, through ( 1 , 2 , 3 , 4 )); 634tree ( branches ); 635const dialog = await openTree ( "/tree" , settle ); 636assertEquals ( 637[ ...document . querySelectorAll < HTMLElement >( ".tree-regenerate" )]. map (( b ) => b . dataset . id ), 638[ "3" ], 639); 640document . querySelector < HTMLButtonElement >( '.tree-regenerate[data-id="3"]' ) ! . click (); 641await settle (); 642assertEquals ( requests (), [{ 643method :"POST" , 644path :`/api/conversations/ ${ tid } /regenerate` , 645body :{ entryId :3 }, 646}]); 647assertEquals ( dialog . isConnected , true ); 648// the branch and the run's first entry can arrive in one frame 649await snapshot ([], {}, {}, through ( 1 , 2 , 5 )); 650assertEquals ( dialog . isConnected , false ); 651assertEquals ( document . querySelector < HTMLElement >( ".continuation" ) ! . hidden , true ); 652assertEquals ( document . querySelector < HTMLElement >( ".notice" ) ! . hidden , false ); 653}); 654}); 655 656Deno . test ( "forking from the tree creates a conversation after the chosen entry, and a refusal stays in the picker saying why" , async () => { 657await withConversation ( async ({ snapshot, tree, requests, settle, respond}) => { 658await snapshot ([], {}, {}, through ( 1 , 2 , 3 , 4 )); 659tree ( branches ); 660respond (() => Response . json ({ error :"Busy" }, { status :400 })); 661const dialog = await openTree ( "/fork" , settle ); 662const choose = async () => { 663document . querySelector < HTMLButtonElement >( '.tree-row[data-id="3"]' ) ! . click (); 664await settle (); 665}; 666await choose (); 667assertEquals ( dialog . open , true ); 668assertEquals ( dialog . querySelector ( '[role="alert"]' )?. textContent , "Busy" ); 669assertEquals ( location . hash , `#/ ${ tid } ` ); 670assertEquals ( document . querySelector < HTMLTextAreaElement >( ".composer textarea" ) ! . value , "" ); 671assertEquals ( document . querySelector < HTMLElement >( ".continuation" ) ! . hidden , true ); 672 673respond (() => ({ tid :"bbbbbbbbbbbbb" })); 674await choose (); 675assertEquals ( requests (). map (({ path, body}) => [ path , body ]), [ 676[ `/api/conversations/ ${ tid } /fork` , { entryId :3 }], 677[ `/api/conversations/ ${ tid } /fork` , { entryId :3 }], 678]); 679assertEquals ( location . hash , "#/bbbbbbbbbbbbb" ); 680assertEquals ( document . querySelector ( "dialog" ), null ); 681}); 682}); 683 684Deno . test ( "bang commands use shell execution only with code enabled, including the no-context form" , async () => { 685await withConversation ( async ({ snapshot, requests, settle}) => { 686const input = document . querySelector < HTMLTextAreaElement >( ".composer textarea" ) ! ; 687const send = document . querySelector < HTMLButtonElement >( ".composer button.primary" ) ! ; 688await snapshot ([]); 689fill ( input , "!literal message" ); 690send . click (); 691await settle (); 692assertEquals ( requests (). at ( - 1 ), { 693method :"POST" , 694path :`/api/conversations/ ${ tid } /messages` , 695body :{ content :"!literal message" }, 696}); 697await snapshot ([], {}, { extensions :[ "code" ] }); 698for ( 699const [ text , command , excludeFromContext ] of [ 700[ "!printf 'hello'" , "printf 'hello'" , false ], 701[ 702"!!printf 'private'\nprintf 'second line'" , 703"printf 'private'\nprintf 'second line'" , 704true , 705], 706] as const 707) { 708fill ( input , text ); 709send . click (); 710await settle (); 711assertEquals ( requests (). at ( - 1 ), { 712method :"POST" , 713path :`/api/conversations/ ${ tid } /shell` , 714body :{ command, excludeFromContext}, 715}); 716assertEquals ( input . value , "" ); 717} 718}); 719}); 720 721Deno . test ( "rejected shell commands keep the composer draft rather than steering the assistant" , async () => { 722await withConversation ( async ({ snapshot, requests, settle, respond}) => { 723await snapshot ([], { run}, { extensions :[ "code" ] }); 724respond (() => 725Promise . resolve ( Response . json ({ error :"Busy: let it finish" }, { status :400 })) 726); 727const input = document . querySelector < HTMLTextAreaElement >( ".composer textarea" ) ! ; 728fill ( input , "!touch should-not-run" ); 729const steer = [ ...document . querySelectorAll < HTMLButtonElement >( ".composer button" )] 730. find (( button ) => button . textContent === "steer" ) ! ; 731steer . click (); 732await settle (); 733assertEquals ( requests (). at ( - 1 )?. path , `/api/conversations/ ${ tid } /shell` ); 734assertEquals ( input . value , "!touch should-not-run" ); 735assertEquals ( document . querySelector ( ".notice" )?. textContent , "Busy: let it finish" ); 736}); 737}); 738 739Deno . test ( "live user shell execution is abortable and becomes one persistent transcript card" , async () => { 740await withConversation ( async ({ snapshot, shell, requests, settle}) => { 741await snapshot ([], {}, { extensions :[ "code" ] }); 742const execution = { 743taskId :17 , 744command :"printf hello" , 745excludeFromContext :true , 746status :"running" , 747result :null , 748details :{ rawOutput :"hello" }, 749error :null , 750startedAt :Date . now (), 751durationMs :null , 752}; 753await shell ({ run :execution }); 754assertEquals ( 755document . querySelector ( ".transcript .tool-header" )?. getAttribute ( "aria-expanded" ), 756"true" , 757); 758const abort = document . querySelector < HTMLButtonElement >( '[aria-label="Abort response"]' ) ! ; 759assertEquals ( abort . hidden , false ); 760abort . click (); 761await settle (); 762assertEquals ( requests (). at ( - 1 )?. path , `/api/conversations/ ${ tid } /abort` ); 763const entry = { 764id :99 , 765conversationId :1 , 766kind :"berry.shell" , 767data :{ 768 ...execution , 769status :"ok" , 770result :{ output :"hello" , exitCode :0 }, 771durationMs :50 , 772}, 773}; 774await snapshot ([], {}, { extensions :[ "code" ] }, [ entry ]); 775assertEquals ( 776document . querySelector ( ".transcript .tool-header" )?. getAttribute ( "aria-expanded" ), 777"true" , 778); 779assertEquals ( document . querySelectorAll ( ".transcript .tool" ). length , 1 ); 780await shell ({}); 781assertEquals ( abort . hidden , true ); 782assertEquals ( document . querySelectorAll ( ".transcript .tool" ). length , 1 ); 783assertEquals ( document . querySelector ( ".transcript" )?. textContent ?. includes ( "hello" ), true ); 784}); 785}); 786 787Deno . test ( "an idle message flushes the open view before sending, but a busy one does not" , async () => { 788await withBerry ( async ({ server, scope, settle}) => { 789const respond :() => unknown = () => ({}); 790server . respond (({ method, path}) => 791method !== "GET" ?respond () :path . endsWith ( "/tree" ) ?{} :[] 792); 793const shared :Shared = { 794extensions :new Signal ([]), 795models :new Signal ([]), 796enabled :new Signal ([]), 797machines :new Signal ([ "local" ]), 798projects :new Signal ({}), 799refreshModels () {}, 800}; 801const viewing = new Signal ( "diff" ); 802const { conversation} = await import ( "./conversation.tsx" ); 803document . body . append ( ...conversation ( scope , tid , shared , viewing )); 804const { syncRenderers} = await import ( "./renderers.ts" ); 805const module = `data:text/javascript, ${ 806encodeURIComponent ( 807`let context = null; 808export default { view: { label: "diff", create: () => { 809const element = document.createElement("section"); 810element.className = "flush-probe"; 811return { 812element, 813update: (next) => void (context = next), 814flush: async () => void await context.command("diff-finish", JSON.stringify({})), 815}; 816} } };` , 817) 818} ` ; 819try { 820shared . extensions . set ([{ 821name :"diff" , 822description :null , 823status :"loaded" , 824error :null , 825running :true , 826builtin :false , 827alwaysOn :true , 828defaultEnabled :false , 829web :null , 830formerly :[], 831promptProse :null , 832configuration :null , 833}]); 834await syncRenderers ([{ name :"diff" , formerly :[], web :{ module, styles :[] } }]); 835server . snapshot ( `conversation: ${ tid } ` , { 836entries :[], 837docs :{ "pi.live" :{}, "pi.agent" :{} }, 838}); 839await settle (); 840assertExists ( document . querySelector ( ".flush-probe" )); 841 842const input = document . querySelector < HTMLTextAreaElement >( ".composer textarea" ) ! ; 843fill ( input , "idle hello" ); 844press ( input , "Enter" ); 845await settle (); 846const sent = server . changes (). filter (({ method}) => method === "POST" ); 847const finish = sent . findIndex (({ path}) => path . endsWith ( "/commands/diff-finish" )); 848const message = sent . findIndex (({ path}) => path . endsWith ( "/messages" )); 849assertExists ( document . querySelector ( ".flush-probe" )); 850assert ( 851finish !== - 1 && message !== - 1 && finish < message , 852`flush before message: ${ sent . map (({ path }) => path )} ` , 853); 854assertEquals ( sent [ message ]. body , { content :"idle hello" }); 855 856const before = server . changes (). length ; 857server . snapshot ( `conversation: ${ tid } ` , { 858entries :[], 859docs :{ "pi.live" :{ run}, "pi.agent" :{} }, 860}); 861await settle (); 862fill ( input , "busy hello" ); 863press ( input , "Enter" ); 864await settle (); 865const after = server . changes (). slice ( before ); 866const busyMessage = after . find (({ path}) => path . endsWith ( "/messages" )); 867assertExists ( busyMessage ); 868assertEquals ( busyMessage . body , { content :"busy hello" , whenBusy :"steer" }); 869assertEquals ( after . some (({ path}) => path . endsWith ( "/commands/diff-finish" )), false ); 870} finally { 871await syncRenderers ([]); 872} 873}, { url :`http://berry.test/#/ ${ tid } ` }); 874});