char-slop/berry
multipurpose self-modifiable agentic harness
git clone https://git.t4t.associates/char-slop/berry
2d39747
main
1import { assert , assertEquals , assertObjectMatch } from "@std/assert" ; 2import { BACKGROUND_CONTEXT as context } from "@earendil-works/chord/context" ; 3import { fauxAssistantMessage , fauxToolCall } from "@earendil-works/pi-ai/providers/faux" ; 4import type { Message } from "@earendil-works/pi-ai" ; 5import { type Berry , Conversations } from "./berry.ts" ; 6import { blocking , setup } from "./fixture.ts" ; 7import { summaries , type Summary } from "./summaries.ts" ; 8import { SettledDoc } from "./settled.ts" ; 9import { until } from "@berry/util" ; 10 11/** A conversation's summary, as a fresh feed first sees it. */ 12async function summary ( berry :Berry , tid :string ) :Promise < Summary > { 13const watched = await summaries ( berry ); 14try { 15return ( watched . feed (). value as Record < string , Summary >)[ tid ]; 16} finally { 17watched . stop (); 18} 19} 20 21Deno . test ( "a summary says whether a conversation is answering, how it began, and when it was last active" , async () => { 22await usingharness = await setup (); 23const { faux, berry, model, gate} = harness ; 24const blocker = blocking (); 25await berry . install ( blocker . extension ); 26const tid = await berry . create ({ extensions :{ gate :{}, blocking :{} }, model}); 27const conversation = ( await berry . conversation ( tid )) ! ; 28const created = ( await berry . harness . snapshot ( Conversations , context )) ! [ tid ]. created ; 29assertObjectMatch ( await summary ( berry , tid ), { 30busy :false , 31preview :"" , 32reply :"" , 33unread :false , 34updated :created , 35}); 36 37// a queued follow-up keeps it working until the final answer 38const followUp = { 39entered :Promise . withResolvers < void >(), 40release :Promise . withResolvers < void >(), 41}; 42faux . setResponses ([ 43fauxAssistantMessage ( fauxToolCall ( "hold" , {}), { stopReason :"toolUse" }), 44fauxAssistantMessage ( "first done" ), 45async () => { 46followUp . entered . resolve (); 47await followUp . release . promise ; 48return fauxAssistantMessage ( "follow-up done" ); 49}, 50]); 51await until (() => Date . now () > created ); 52const submitted = Date . now (); 53await conversation . submit ({ type :"input" , content :"hello\n there" }, context ); 54await gate . entered . promise ; 55await conversation . submit ({ type :"input" , content :"next" , whenBusy :"followUp" }, context ); 56const sending = await summary ( berry , tid ); 57assertObjectMatch ( sending , { busy :true , preview :"hello there" , unread :false , reply :"" }); 58assert ( sending . updated >= submitted ); 59gate . release . resolve ( "released" ); 60await followUp . entered . promise ; 61const queued = await summary ( berry , tid ); 62assertObjectMatch ( queued , { busy :true , unread :false , reply :"first done" }); 63assert ( queued . updated >= sending . updated ); 64let returned = Date . now (); 65followUp . release . resolve (); 66await conversation . waitForIdle ( context ); 67let current = await summary ( berry , tid ); 68assertObjectMatch ( current , { 69busy :false , 70preview :"hello there" , 71unread :true , 72reply :"follow-up done" , 73}); 74assert ( current . updated >= returned ); 75 76// interrupted tools return control without a new answer 77faux . setResponses ([ fauxAssistantMessage ( fauxToolCall ( "block" , {}), { stopReason :"toolUse" })]); 78await until (() => Date . now () > current . updated ); 79const resubmitted = Date . now (); 80await conversation . submit ({ type :"input" , content :"again" }, context ); 81await until (() => blocker . running > 0 ); 82const interrupting = await summary ( berry , tid ); 83assert ( interrupting . updated >= resubmitted ); 84assert ( interrupting . updated > current . updated ); 85await until (() => Date . now () > interrupting . updated ); 86returned = Date . now (); 87await conversation . abort ( context ); 88current = await summary ( berry , tid ); 89assertObjectMatch ( current , { busy :false }); 90assert ( current . updated >= returned ); 91 92// as does a failure before generation 93await berry . configure ( tid , { model :null }); 94await until (() => Date . now () > current . updated ); 95returned = Date . now (); 96const failed = await ( await conversation . submit ({ type :"input" , content :"go" }, context )) 97. wait ( context ); 98assertEquals ( failed . status , "unanswered" ); 99await conversation . waitForIdle ( context ); 100assert (( await summary ( berry , tid )). updated >= returned ); 101}); 102 103Deno . test ( "unread answers follow read receipts across feeds, forks, branches, and resets" , async () => { 104await usingharness = await setup (); 105const { faux, berry, model} = harness ; 106const tid = await berry . create ({ extensions :{}, model}); 107const watched = await summaries ( berry ); 108const feeds = [ watched . feed (), watched . feed ()]; 109const seen = feeds . map (( feed ) => feed . value as Record < string , Summary >); 110feeds . forEach (( feed , i ) => 111feed . start (( value ) => { 112seen [ i ] = value as Record < string , Summary >; 113return Promise . resolve (); 114}) 115); 116const unread = ( expected :boolean ) => 117until (() => seen . every (( value ) => value [ tid ]?. unread === expected )); 118let conversation = ( await berry . conversation ( tid )) ! ; 119const answer = async () => { 120faux . setResponses ([ fauxAssistantMessage ( "done" )]); 121await conversation . submit ({ type :"input" , content :"go" }, context ); 122await conversation . waitForIdle ( context ); 123await unread ( true ); 124return ( await conversation . context ( context )). entries . at ( - 1 ) ! . id ; 125}; 126try { 127const old = await answer (); 128await berry . markRead ( tid , old ); 129await unread ( false ); 130const latest = await answer (); 131// an older receipt cannot clear a newer answer 132await berry . markRead ( tid , old ); 133assertEquals (( await summary ( berry , tid )). unread , true ); 134await berry . markRead ( tid , latest ); 135await unread ( false ); 136await berry . markRead ( tid , old ); 137assertEquals (( await summary ( berry , tid )). unread , false ); 138 139const answered = ( await summary ( berry , tid )). updated ; 140const fork = await conversation . fork ( latest , { ownership :{ kind :"ownerless" } }, context ); 141assertObjectMatch ( await summary ( berry , await berry . tidOf ( fork . id )), { 142unread :false , 143updated :answered , 144}); 145 146const [ start ] = ( await conversation . context ( context )). entries ; 147await berry . branch ( tid , start . id ); 148conversation = ( await berry . conversation ( tid )) ! ; 149await answer (); 150const branched = ( await summary ( berry , tid )). updated ; 151await conversation . reset ( undefined , context ); 152assertObjectMatch ( await summary ( berry , tid ), { unread :false , updated :branched }); 153await berry . branch ( tid , latest ); 154assertEquals (( await summary ( berry , tid )). updated , answered ); 155} finally { 156for ( const feed of feeds ) await feed . stop (); 157watched . stop (); 158} 159}); 160 161Deno . test ( "legacy summaries find completed answers across history pages" , async () => { 162await usingharness = await setup (); 163const { berry, model} = harness ; 164const tid = await berry . create ({ extensions :{}, model}); 165const conversation = ( await berry . conversation ( tid )) ! ; 166await conversation . commit (( tx ) => tx . retireDoc ( SettledDoc , conversation . id ), context ); 167const created = ( await berry . harness . snapshot ( Conversations , context )) ! [ tid ]. created ; 168const messages :Message [] = [ 169{ role :"user" , content :"hello" , timestamp :1000 }, 170fauxAssistantMessage ( "done" , { timestamp :2000 }), 171]; 172for ( const message of messages ) { 173await conversation . commit ( async ( tx ) => { 174await tx . appendEntry ( conversation . id , { kind :`pi. ${ message . role } ` , model :[ message ] }); 175for ( let i = 0 ; i < 32 ; i ++ ) { 176await tx . appendEntry ( conversation . id , { 177kind :"berry.event" , 178data :{ ext :"dir" , event :{ type :"cd" , cwd :"/elsewhere" } }, 179}); 180} 181}, context ); 182assertEquals ( 183( await summary ( berry , tid )). updated , 184message . role === "assistant" ?message . timestamp :created , 185); 186} 187}); 188 189Deno . test ( "renaming one conversation leaves the others' summaries as they were" , async () => { 190await usingharness = await setup (); 191const { berry, model} = harness ; 192const renamed = await berry . create ({ extensions :{}, model}); 193const other = await berry . create ({ extensions :{}, model}); 194const watched = await summaries ( berry ); 195try { 196const before = watched . feed (). value as Record < string , Summary >; 197await berry . rename ( renamed , "named" ); 198await berry . archive ( renamed , true ); 199await until (() => watched . feed (). value !== before ); 200const after = watched . feed (). value as Record < string , Summary >; 201assert ( after [ other ] === before [ other ]); 202} finally { 203watched . stop (); 204} 205}); 206 207Deno . test ( "a busy background subagent's conversation gets a summary that leaves the feed once it settles" , async () => { 208await usingharness = await setup (); 209const { berry, faux, model} = harness ; 210const blocker = blocking (); 211await berry . install ( blocker . extension ); 212const tid = await berry . create ({ extensions :{ subagent :{}, blocking :{} }, model}); 213const parent = ( await berry . conversation ( tid )) ! ; 214const last = ( messages :readonly Message []) => messages . findLast (( m ) => m . role !== "system" ) ! ; 215const text = ( message :Message ) => 216typeof message . content === "string" 217 ?message . content 218 :message . content . flatMap (( block ) => block . type === "text" ?[ block . text ] :[]). join ( "\n" ); 219faux . setResponses ( Array . from ({ length :20 }, () => ( request ) => { 220const message = last ( request . messages ); 221if ( message . role === "user" && text ( message ) === "go" ) { 222return fauxAssistantMessage ( 223fauxToolCall ( "codemode" , { 224code :`return await tools.subagent({task:"child work", model: ${ 225JSON . stringify ( model ) 226} , background:true})` , 227}), 228{ stopReason :"toolUse" }, 229); 230} 231if ( message . role === "user" && text ( message ) === "child work" ) { 232return fauxAssistantMessage ( fauxToolCall ( "block" , {}), { stopReason :"toolUse" }); 233} 234if ( message . role === "toolResult" && message . toolName === "block" ) { 235return fauxAssistantMessage ( text ( message )); 236} 237return fauxAssistantMessage ( "done" ); 238})); 239await ( await parent . submit ({ type :"input" , content :"go" }, context )). wait ( context ); 240await until (() => blocker . running > 0 ); 241const index = ( await berry . harness . snapshot ( Conversations , context )) ! ; 242const [ child ] = Object . keys ( index ). filter (( key ) => index [ key ]. owner === tid ); 243assert ( child ); 244const busy = await summary ( berry , child ); 245assertObjectMatch ( busy , { busy :true , preview :"child work" }); 246blocker . release . resolve ( "child answer" ); 247for ( let i = 0 ;; i ++ ) { 248if ( i > 250 ) throw new Error ( "the settled subagent never left the feed" ); 249if ( await summary ( berry , child ) === undefined ) break ; 250await new Promise (( resolve ) => setTimeout ( resolve , 20 )); 251} 252});