char-slop/berry
multipurpose self-modifiable agentic harness
git clone https://git.t4t.associates/char-slop/berry
5147173
main
1import { assert , assertEquals , assertStrictEquals } from "@std/assert" ; 2import type { ConversationId , EntryId , EntryRecord , LiveState } from "@earendil-works/pi-durable" ; 3import { fauxAssistantMessage , fauxText , fauxToolCall } from "@earendil-works/pi-ai/providers/faux" ; 4import { withDom } from "@berry/util/dom.ts" ; 5 6const record = ( id :number , kind :string , model :EntryRecord [ "model" ]) :EntryRecord => ({ 7id :id as EntryId , 8conversationId :1 as ConversationId , 9 kind, 10 model, 11}); 12const said = ( id :number ) => 13record ( id , "pi.user" , [{ role :"user" , content :`message ${ id } ` , timestamp :0 }]); 14 15Deno . test ( "a long history draws its newest entries, and more only as the reader scrolls up to them" , async () => { 16await withDom ( async () => { 17const { transcript} = await import ( "./transcript.tsx" ); 18const view = transcript (); 19const list = view . element . querySelector < HTMLElement >( ".entries" ) ! ; 20const texts = () => 21[ ...view . element . querySelectorAll ( ".message.user .text" )]. map (( m ) => m . textContent ); 22const entries = Array . from ({ length :200 }, ( _ , i ) => said ( i + 1 )); 23// what a scroller offers the content it holds, which has no layout of its own here 24const pane = { clientHeight :400 , scrollTop :0 , getBoundingClientRect :() => ({ top :0 }) }; 25try { 26view . update ( entries , {}); 27const newest = texts (); 28assert ( newest . length < entries . length ); 29assertEquals ( newest , entries . slice ( - newest . length ). map (( entry ) => `message ${ entry . id } ` )); 30// the history above what is drawn stands in as space, so the scrollbar knows how much there is 31assert ( parseFloat ( list . style . paddingTop ) > 0 ); 32 33const last = list . lastElementChild ; 34view . grow ( pane ); 35assertEquals ( texts (), entries . map (( entry ) => `message ${ entry . id } ` )); 36assertEquals ( list . style . paddingTop , "" ); 37assertStrictEquals ( list . lastElementChild , last ); 38 39// another branch from the middle opens at its own newest entries, which are not the ones it shares 40const branch = [ 41 ...entries . slice ( 0 , 100 ), 42 ...Array . from ({ length :50 }, ( _ , i ) => said ( i + 1000 )), 43]; 44view . update ( branch , {}); 45const drawn = texts (); 46assert ( drawn . length < branch . length ); 47assertEquals ( drawn , branch . slice ( - drawn . length ). map (( entry ) => `message ${ entry . id } ` )); 48} finally { 49view . dispose (); 50} 51}); 52}); 53 54Deno . test ( "message timestamps follow user messages and streaming responses through commit" , async () => { 55await withDom ( async () => { 56const { transcript} = await import ( "./transcript.tsx" ); 57const view = transcript (); 58const streamed = fauxAssistantMessage ([ fauxText ( "hello" )], { timestamp :10_000 }); 59try { 60view . update ([ said ( 1 )], { 61generation :{ attempt :0 , message :streamed }, 62} as unknown as LiveState ); 63const userTime = view . element . querySelector < HTMLTimeElement >( ".user time" ) ! ; 64const assistantTime = view . element . querySelector < HTMLTimeElement >( ".assistant time" ) ! ; 65assertEquals ( userTime . dateTime , new Date ( 0 ). toISOString ()); 66assertEquals ( assistantTime . dateTime , new Date ( 10_000 ). toISOString ()); 67assertEquals ( assistantTime . title , new Date ( 10_000 ). toLocaleString ()); 68const committed = { ...streamed , timestamp :20_000 }; 69view . update ([ said ( 1 ), record ( 2 , "pi.assistant" , [ committed ])], {}); 70assertStrictEquals ( view . element . querySelector ( ".assistant time" ), assistantTime ); 71assertEquals ( assistantTime . dateTime , new Date ( 20_000 ). toISOString ()); 72} finally { 73view . dispose (); 74} 75}); 76}); 77 78Deno . test ( "a response keeps its nodes, and the cards the reader opened, once it commits" , async () => { 79await withDom ( async () => { 80const { transcript} = await import ( "./transcript.tsx" ); 81const view = transcript (); 82const run = { taskId :2 , inputs :[] }; 83const streamed = fauxAssistantMessage ([ 84fauxText ( "let me look" ), 85fauxToolCall ( "read" , { path :"a.ts" }, { id :"call" }), 86], { stopReason :"toolUse" }); 87try { 88view . update ( 89[ said ( 1 )], 90{ run, generation :{ attempt :0 , message :streamed } } as unknown as LiveState , 91); 92const message = view . element . querySelector ( ".message.assistant" ); 93const card = view . element . querySelector < HTMLElement >( ".tool" ) ! ; 94assertEquals ( card . dataset . status , "streaming" ); 95card . querySelector < HTMLButtonElement >( ".tool-header" ) ! . click (); 96assertEquals ( card . dataset . view , "open" ); 97 98view . update ( 99[ said ( 1 ), record ( 2 , "pi.assistant" , [ streamed ])], 100{ 101 run, 102tools :[{ callId :"call" , name :"read" , status :"running" }], 103} as unknown as LiveState , 104); 105assertEquals ( view . element . querySelectorAll ( ".message.assistant" ). length , 1 ); 106assertStrictEquals ( view . element . querySelector ( ".entries .message.assistant" ), message ); 107assertStrictEquals ( view . element . querySelector ( ".tool" ), card ); 108assertEquals ( card . dataset . status , "running" ); 109assertEquals ( card . dataset . view , "open" ); 110} finally { 111view . dispose (); 112} 113}); 114}); 115 116Deno . test ( "tool time follows execution across navigation and progress, then stops at the reported duration" , async () => { 117await withDom ( async () => { 118const { transcript} = await import ( "./transcript.tsx" ); 119const originalNow = Date . now ; 120let now = 10_000 ; 121Date . now = () => now ; 122const history = [ record ( 1 , "pi.assistant" , [ 123fauxAssistantMessage ( fauxToolCall ( "hold" , {}, { id :"held" }), { stopReason :"toolUse" }), 124])]; 125const running = { 126tools :[{ callId :"held" , name :"hold" , status :"running" as const , startedAt :1_000 }], 127}; 128let view = transcript (); 129const time = () => view . element . querySelector < HTMLElement >( ".tool-time" ) ! ; 130try { 131view . update ( history , running ); 132assertEquals ( time (). textContent , "9s" ); 133view . dispose (); 134now = 13_000 ; 135view = transcript (); 136view . update ( history , running ); 137assertEquals ( time (). textContent , "12s" ); 138view . update ( history , { 139tools :[{ ...running . tools ! [ 0 ], details :[ "progress without timing" ] }], 140}); 141assertEquals ( time (). textContent , "12s" ); 142now = 30_000 ; 143const finished = [ 144 ...history , 145record ( 2 , "pi.tool-result" , [{ 146role :"toolResult" , 147toolCallId :"held" , 148toolName :"hold" , 149content :[{ type :"text" , text :"done" }], 150isError :false , 151timestamp :14_750 , 152durationMs :13_750 , 153}]), 154]; 155view . update ( finished , {}); 156assertEquals ( time (). textContent , "13s" ); 157view . dispose (); 158view = transcript (); 159view . update ( finished , {}); 160assertEquals ( time (). textContent , "13s" ); 161view . update ( 162history , 163{ tools :[{ callId :"held" , name :"hold" , status :"running" }] } as LiveState , 164); 165assertEquals ( time (). hidden , true ); 166} finally { 167Date . now = originalNow ; 168view . dispose (); 169} 170}); 171}); 172 173Deno . test ( "a call that succeeded but came to a failure says so in place of ok" , async () => { 174await withDom ( async () => { 175const { transcript} = await import ( "./transcript.tsx" ); 176const { syncRenderers} = await import ( "./renderers.ts" ); 177const module = `export default { tools: { probe: { 178outcome: (call) => call.result === "bad" ? "exit 3" : null, 179} } };` ; 180await syncRenderers ([{ 181name :"probe" , 182formerly :[], 183web :{ module :`data:text/javascript, ${ encodeURIComponent ( module )} ` , styles :[] }, 184}]); 185const view = transcript (); 186const finished = ( text :string ) => [ 187record ( 1 , "pi.assistant" , [ 188fauxAssistantMessage ( fauxToolCall ( "probe" , {}, { id :"call" }), { stopReason :"toolUse" }), 189]), 190record ( 2 , "pi.tool-result" , [{ 191role :"toolResult" , 192toolCallId :"call" , 193toolName :"probe" , 194content :[{ type :"text" , text}], 195isError :false , 196timestamp :0 , 197}]), 198]; 199try { 200view . update ( finished ( "bad" ), {}); 201const card = view . element . querySelector < HTMLElement >( ".tool" ) ! ; 202assertEquals ( card . dataset . status , "error" ); 203view . update ( finished ( "good" ), {}); 204assertEquals ( card . dataset . status , "ok" ); 205} finally { 206view . dispose (); 207await syncRenderers ([]); 208} 209}); 210});