char-slop/berry
multipurpose self-modifiable agentic harness
git clone https://git.t4t.associates/char-slop/berry
b4fc6cf
main
1import { assert , assertEquals , assertStringIncludes } from "@std/assert" ; 2import { encodeBase64 } from "@std/encoding/base64" ; 3import { applyImmutable , type Op } from "@earendil-works/chord/delta" ; 4import { BACKGROUND_CONTEXT as context } from "@earendil-works/chord/context" ; 5import { type ImageContent , type Message , Type } from "@earendil-works/pi-ai" ; 6import { createModels } from "@earendil-works/pi-ai/models" ; 7import { 8fauxAssistantMessage , 9fauxProvider , 10fauxToolCall , 11} from "@earendil-works/pi-ai/providers/faux" ; 12import { InMemoryCredentialStore } from "@earendil-works/pi-ai" ; 13import { InboxDoc , LiveDoc , MemoryStorage } from "@earendil-works/pi-durable" ; 14import { MAX_MESSAGE_BYTES } from "@berry/api/images.ts" ; 15import { PNG , tempDir , until } from "@berry/util" ; 16import { defineExtension } from "@berry/api" ; 17import { type Host , openBerry } from "./berry.ts" ; 18import { createHandler } from "./http.ts" ; 19import { extensionLoader } from "./loader.ts" ; 20import { extensionWeb } from "./web.ts" ; 21import type { ServerMessage } from "./pubsub.ts" ; 22import { blocking , gated } from "./fixture.ts" ; 23 24const TOKEN = "test-token" ; 25 26const dir = defineExtension <{ cwd :string }, { cwd :string }>({ 27name :"dir" , 28initial :() => ({ cwd :"/" }), 29apply :( _ , event ) => ({ cwd :event . cwd }), 30setup ( ext ) { 31ext . configuration ({ 32parameters :Type . Object ({ cwd :Type . String ({ default :"/" }) }), 33values :({ cwd}) => ({ cwd}), 34events :({ cwd}) => { 35if ( ! cwd . startsWith ( "/" )) throw new Error ( `not absolute: ${ cwd } ` ); 36return [{ cwd}]; 37}, 38}); 39ext . tool ({ 40name :"noop" , 41description :"nothing" , 42parameters :Type . Object ({}), 43execute :() => undefined , 44}); 45ext . channel ({ 46name :"echo" , 47parameters :Type . Object ({ prefix :Type . String ({ default :"" }) }), 48open ({ prefix}, channel , ctx ) { 49if ( prefix === "refuse" ) throw new Error ( "not today" ); 50void ( async () => { 51for await ( const message of channel . messages ) { 52if ( message === "bye" ) channel . close (); 53else if ( message instanceof Uint8Array ) channel . send ( message . reverse ()); 54else channel . send ({ echo :message , prefix, cwd :ctx . state . cwd }); 55} 56})(); 57}, 58}); 59ext . handle ({ 60name :"where" , 61parameters :Type . Object ({ under :Type . String ({ default :"" }) }), 62execute :({ under}, ctx ) => ctx . state . cwd + under , 63}); 64return ( conv ) => 65conv . command ({ 66name :"cd" , 67description :"change directory" , 68async execute ( path , ctx ) { 69if ( ! path . startsWith ( "/" )) throw new Error ( `not absolute: ${ path } ` ); 70await ctx . emit ({ cwd :path }); 71return path ; 72}, 73}); 74}, 75}); 76 77async function serve ( host ?:Host , supportsImages = true ) { 78const faux = fauxProvider ( 79supportsImages ?undefined :{ models :[{ id :"text" , input :[ "text" ] }] }, 80); 81const credentials = new InMemoryCredentialStore (); 82const models = createModels ({ credentials}); 83models . setProvider ( faux . provider ); 84// a provider whose login asks for a key, as most do 85models . setProvider ({ 86 ...faux . provider , 87id :"keyed" , 88name :"Keyed" , 89auth :{ 90apiKey :{ 91name :"Keyed API key" , 92login :async ( interaction ) => { 93const key = await interaction . prompt ({ type :"secret" , message :"your key?" }); 94interaction . notify ({ type :"info" , message :"thanks" }); 95return { type :"api_key" , key}; 96}, 97resolve :({ credential}) => 98Promise . resolve ( 99credential ?. key 100 ?{ auth :{ apiKey :credential . key }, source :"stored credential" } 101 :undefined , 102), 103}, 104}, 105}); 106const berry = await openBerry ( new MemoryStorage (), models ); 107await berry . install ( dir ); 108await berry . enableModels ([{ provider :"faux" , modelId :faux . getModel (). id }]); 109const web = extensionWeb (); 110const server = Deno . serve ( 111{ port :0 , onListen () {} }, 112createHandler ({ 113 berry, 114 models, 115 credentials, 116 web, 117loader :extensionLoader ({ berry, web, root :await Deno . makeTempDir () }), 118 host, 119token :TOKEN , 120}), 121); 122const base = `http://127.0.0.1: ${ server . addr . port } ` ; 123const api = async ( method :string , path :string , body ?:unknown ) => { 124const response = await fetch ( base + path , { 125 method, 126headers :{ authorization :`Bearer ${ TOKEN } ` , "content-type" :"application/json" }, 127 ...( body === undefined ?{} :{ body :JSON . stringify ( body ) }), 128}); 129return { status :response . status , json :await response . json () }; 130}; 131return { 132 berry, 133 faux, 134 base, 135 api, 136model :{ provider :"faux" , modelId :faux . getModel (). id }, 137async close () { 138await server . shutdown (); 139await berry . close (); 140}, 141[ Symbol . asyncDispose ]() { 142return this . close (); 143}, 144}; 145} 146 147Deno . test ( "the model list refreshes extension catalogs before returning available models" , async () => { 148await usingserver = await serve (); 149const faux = fauxProvider (); 150let source = [{ ...faux . getModel (), provider :"dynamic" , id :"first" }]; 151let catalog = source ; 152await server . berry . install ( defineExtension ({ 153name :"provider" , 154initial :() => null , 155setup ( ext ) { 156ext . provider ({ 157 ...faux . provider , 158id :"dynamic" , 159getModels :() => catalog , 160async refreshModels ( ctx ) { 161if ( ctx . allowNetwork ) { 162await ctx . publish ({ update :() => void ( catalog = source ) }); 163} 164}, 165}); 166}, 167})); 168source = [{ ...source [ 0 ], id :"discovered" }]; 169const result = await server . api ( "GET" , "/api/models" ); 170assertEquals ( result . status , 200 ); 171assertEquals ( 172( result . json as { provider :string ; id :string }[]). filter (( model ) => 173model . provider === "dynamic" 174) 175. map (( model ) => model . id ), 176[ "discovered" ], 177); 178}); 179 180Deno . test ( "settings accept partial prompt edits and reject invalid fields without saving them" , async () => { 181await usingserver = await serve (); 182const original = ( await server . api ( "GET" , "/api/settings" )). json ; 183const saved = await server . api ( "PATCH" , "/api/settings" , { 184systemPrompt :{ userName :"Jordan" , generalGuidelines :"" }, 185}); 186assertEquals ( saved . status , 200 ); 187assertEquals ( saved . json . systemPrompt , { 188 ...original . systemPrompt , 189userName :"Jordan" , 190generalGuidelines :"" , 191promptProse :{}, 192}); 193assertEquals ( saved . json . models , original . models ); 194const prose = await server . api ( "PATCH" , "/api/settings" , { 195systemPrompt :{ promptProse :{ code :"Keep it small." , other :"" } }, 196}); 197assertEquals ( prose . status , 200 ); 198assertEquals ( prose . json . systemPrompt . promptProse , { code :"Keep it small." , other :"" }); 199assertEquals ( prose . json . systemPrompt . userName , "Jordan" ); 200const kept = await server . api ( "PATCH" , "/api/settings" , { 201systemPrompt :{ promptProse :{ other :"Changed." } }, 202}); 203assertEquals ( kept . json . systemPrompt . promptProse , { code :"Keep it small." , other :"Changed." }); 204const invalid = await server . api ( "PATCH" , "/api/settings" , { 205systemPrompt :{ promptProse :{ code :[ "not text" ] } }, 206}); 207assertEquals ( invalid . status , 400 ); 208assertEquals (( await server . api ( "GET" , "/api/settings" )). json , kept . json ); 209}); 210 211Deno . test ( "settings can disable and re-enable local without replacing other settings" , async () => { 212await usingserver = await serve (); 213const original = ( await server . api ( "GET" , "/api/settings" )). json ; 214const saved = await server . api ( "PATCH" , "/api/settings" , { allowLocalEnvironment :false }); 215assertEquals ( saved . status , 200 ); 216assertEquals ( saved . json , { ...original , allowLocalEnvironment :false }); 217const invalid = await server . api ( "PATCH" , "/api/settings" , { allowLocalEnvironment :"false" }); 218assertEquals ( invalid . status , 400 ); 219assertEquals (( await server . api ( "GET" , "/api/settings" )). json . allowLocalEnvironment , false ); 220const restored = await server . api ( "PATCH" , "/api/settings" , { allowLocalEnvironment :true }); 221assertEquals ( restored . json . allowLocalEnvironment , true ); 222}); 223 224/** Replays every topic from its snapshot and ops, as the frontend will. */ 225async function subscriber ( base :string ) { 226const socket = new WebSocket ( base . replace ( "http" , "ws" ) + "/ws" ); 227const values = new Map < string , unknown >(); 228const errors :string [] = []; 229let changed = () => {}; 230socket . onmessage = ( event ) => { 231const message = JSON . parse ( event . data ) as ServerMessage ; 232if ( message . t === "snapshot" ) values . set ( message . topic , message . value ); 233if ( message . t === "older" ) { 234const value = values . get ( message . topic ) as { entries :unknown [] }; 235values . set ( message . topic , { ...value , entries :[ ...message . entries , ...value . entries ] }); 236} 237if ( message . t === "ops" ) { 238values . set ( message . topic , applyImmutable ( values . get ( message . topic ), message . ops as Op [])); 239} 240if ( message . t === "error" ) errors . push ( message . message ); 241changed (); 242}; 243await new Promise (( resolve ) => ( socket . onopen = resolve )); 244socket . send ( JSON . stringify ({ t :"auth" , token :TOKEN })); 245return { 246 values, 247 errors, 248sub :( topic :string ) => socket . send ( JSON . stringify ({ t :"sub" , topic})), 249async until < T > ( topic :string , check :( value :T ) => boolean ) :Promise < T > { 250while ( ! ( values . has ( topic ) && check ( values . get ( topic ) as T ))) { 251await new Promise < void >(( resolve , reject ) => { 252const timeout = setTimeout ( 253() => reject ( new Error ( `timed out waiting on ${ topic } ` )), 2545000 , 255); 256changed = () => ( clearTimeout ( timeout ), resolve ()); 257}); 258} 259return values . get ( topic ) as T ; 260}, 261close :() => new Promise (( resolve ) => (( socket . onclose = resolve ), socket . close ())), 262}; 263} 264 265Deno . test ( "api requests need the bearer token and a valid body, which is refused by its failing path" , async () => { 266await usingserver = await serve (); 267const { api, base} = server ; 268assertEquals (( await fetch ( base + "/api/conversations" )). status , 401 ); 269const wrong = await fetch ( base + "/api/conversations" , { 270headers :{ authorization :"Bearer nope" }, 271}); 272assertEquals ( wrong . status , 401 ); 273await wrong . body ?. cancel (); 274const response = await api ( "POST" , "/api/conversations" , { extensions :{ dir :"nope" } }); 275assertEquals ( response . status , 400 ); 276assert ( String ( response . json . error ). includes ( "/extensions/dir" )); 277assertEquals ( 278( await api ( "POST" , "/api/conversations" , { extensions :{ missing :{} } })). status , 279400 , 280); 281}); 282 283Deno . test ( "restart refuses code that fails its check, and restarts after responding when it passes" , async () => { 284let failures :string | null = "error: TS2304 nope" ; 285let restarts = 0 ; 286const restarted = Promise . withResolvers < void >(); 287await usingserver = await serve ({ 288root :"/berry" , 289check :() => Promise . resolve ( failures ), 290restart :() => { 291restarts ++ ; 292restarted . resolve (); 293}, 294extensions :() => [], 295reload :() => Promise . reject ( new Error ( "no extensions" )), 296}); 297const { api} = server ; 298assertEquals ( await api ( "POST" , "/api/restart" ), { 299status :400 , 300json :{ error :failures }, 301}); 302assertEquals ( restarts , 0 ); 303failures = null ; 304assertEquals ( await api ( "POST" , "/api/restart" ), { status :200 , json :{} }); 305await restarted . promise ; 306assertEquals ( restarts , 1 ); 307}); 308 309Deno . test ( "topics replay to the server's state over the websocket" , async () => { 310await usingserver = await serve (); 311const { api, base, faux, model} = server ; 312const client = await subscriber ( base ); 313 314client . sub ( "conversations" ); 315const { json :{ tid} } = await api ( "POST" , "/api/conversations" , { 316extensions :{ dir :{} }, 317 model, 318}); 319await client . until < Record < string , unknown >>( "conversations" , ( index ) => tid in index ); 320 321client . sub ( `conversation: ${ tid } ` ); 322client . sub ( `state: ${ tid } :dir` ); 323await client . until ( `conversation: ${ tid } ` , () => true ); 324assertEquals ( await client . until ( `state: ${ tid } :dir` , () => true ), { cwd :"/" }); 325 326faux . setResponses ([ fauxAssistantMessage ( "hello there" )]); 327await api ( "POST" , `/api/conversations/ ${ tid } /messages` , { content :"hi" }); 328type View = { 329entries :{ kind :string ; model ?:{ content :{ type :string ; text ?:string }[] }[] }[]; 330}; 331const view = await client . until < View >( 332`conversation: ${ tid } ` , 333( view ) => view . entries . some (( entry ) => entry . kind === "pi.assistant" ), 334); 335const answer = view . entries . find (( entry ) => entry . kind === "pi.assistant" ) ! . model ! [ 0 ]. content [ 0 ]; 336assertEquals ( answer . text , "hello there" ); 337 338await api ( "POST" , `/api/conversations/ ${ tid } /events` , { 339ext :"dir" , 340event :{ cwd :"/elsewhere" }, 341}); 342await client . until <{ cwd :string }>( `state: ${ tid } :dir` , ( state ) => state . cwd === "/elsewhere" ); 343 344client . sub ( "nonsense" ); 345await client . until ( "conversations" , () => client . errors . length > 0 ); 346assertEquals ( client . errors , [ "no topic nonsense" ]); 347 348await client . close (); 349}); 350 351Deno . test ( "a conversation's configuration, commands, and handlers are served over http" , async () => { 352await usingserver = await serve (); 353const { base, api, model} = server ; 354const client = await subscriber ( base ); 355try { 356const { tid} = 357( await api ( "POST" , "/api/conversations" , { extensions :{ dir :{} }, model})). json ; 358const conversation = `/api/conversations/ ${ tid } ` ; 359const topic = `configuration: ${ tid } :dir` ; 360const until = ( cwd :string ) => 361client . until <{ cwd :string }>( topic , ( values ) => values . cwd === cwd ); 362client . sub ( topic ); 363await until ( "/" ); 364const configuration = ` ${ conversation } /configuration/dir` ; 365assertEquals ( await api ( "PATCH" , configuration , { cwd :"/edited" }), { 366status :200 , 367json :{ cwd :"/edited" }, 368}); 369await until ( "/edited" ); 370assertEquals (( await api ( "PATCH" , configuration , { cwd :5 })). status , 400 ); 371assertEquals ( await api ( "PATCH" , configuration , { cwd :"relative" }), { 372status :400 , 373json :{ error :"not absolute: relative" }, 374}); 375assertEquals (( await api ( "PATCH" , configuration , { hidden :true })). status , 400 ); 376 377assertEquals (( await api ( "GET" , ` ${ conversation } /commands` )). json , [ 378{ name :"cd" , extension :"dir" , description :"change directory" }, 379]); 380assertEquals ( await api ( "POST" , ` ${ conversation } /commands/cd` , { args :"/x" }), { 381status :200 , 382json :{ result :"/x" }, 383}); 384await until ( "/x" ); 385assertEquals ( await api ( "POST" , ` ${ conversation } /commands/cd` , { args :"x" }), { 386status :400 , 387json :{ error :"not absolute: x" }, 388}); 389assertEquals (( await api ( "POST" , ` ${ conversation } /commands/nope` , {})). status , 400 ); 390 391const where = ( body :unknown ) => api ( "POST" , ` ${ conversation } /extensions/dir/where` , body ); 392assertEquals ( await where ({}), { status :200 , json :{ result :"/x" } }); 393assertEquals ( await where ({ args :{ under :"/y" } }), { status :200 , json :{ result :"/x/y" } }); 394assertEquals ( await where ({ args :{ under :1 } }), { 395status :400 , 396json :{ error :"under: must be string" }, 397}); 398assertEquals (( await api ( "POST" , ` ${ conversation } /extensions/dir/nope` , {})). status , 400 ); 399// a long answer is gzipped for a client that accepts it 400const under = "/y" . repeat ( 5000 ); 401const fetchWhere = ( encoding :string ) => 402fetch ( ` ${ base } ${ conversation } /extensions/dir/where` , { 403method :"POST" , 404headers :{ 405authorization :`Bearer ${ TOKEN } ` , 406"content-type" :"application/json" , 407"accept-encoding" :encoding , 408}, 409body :JSON . stringify ({ args :{ under} }), 410}); 411const gzipped = await fetchWhere ( "gzip, br" ); 412assertEquals ( gzipped . headers . get ( "content-encoding" ), "gzip" ); 413assertEquals ( await gzipped . json (), { result :`/x ${ under } ` }); 414const plain = await fetchWhere ( "identity" ); 415assertEquals ( plain . headers . get ( "content-encoding" ), null ); 416assertEquals ( await plain . json (), { result :`/x ${ under } ` }); 417 418await api ( "PATCH" , ` ${ conversation } /agent` , { extensions :[] }); 419assertEquals (( await api ( "PATCH" , configuration , { cwd :"/disabled" })). status , 400 ); 420} finally { 421await client . close (); 422} 423}); 424 425Deno . test ( "an extension's channels carry json and bytes both ways once the first message authorizes them" , async () => { 426await usingserver = await serve (); 427const { base, api, model} = server ; 428const { json :{ tid} } = await api ( "POST" , "/api/conversations" , { 429extensions :{ dir :{ cwd :"/x" } }, 430 model, 431}); 432const open = ( name :string , first :unknown ) => { 433const socket = new WebSocket ( 434` ${ base . replace ( "http" , "ws" )} /api/conversations/ ${ tid } /extensions/dir/ ${ name } ` , 435); 436socket . binaryType = "arraybuffer" ; 437const received :unknown [] = []; 438const closed = new Promise <{ code :number ; reason :string }>(( resolve ) => 439socket . onclose = ({ code, reason}) => resolve ({ code, reason}) 440); 441socket . onmessage = ({ data}) => 442received . push ( typeof data === "string" ?JSON . parse ( data ) :[ ...new Uint8Array ( data )]); 443socket . onopen = () => socket . send ( JSON . stringify ( first )); 444return { socket, received, closed}; 445}; 446 447const echo = open ( "echo" , { token :TOKEN , args :{ prefix :">" } }); 448await new Promise (( resolve ) => echo . socket . addEventListener ( "open" , resolve )); 449echo . socket . send ( JSON . stringify ({ hello :1 })); 450echo . socket . send ( new Uint8Array ([ 1 , 2 , 3 ])); 451echo . socket . send ( JSON . stringify ( "bye" )); 452assertEquals ( await echo . closed , { code :1000 , reason :"" }); 453assertEquals ( echo . received , [{ echo :{ hello :1 }, prefix :">" , cwd :"/x" }, [ 3 , 2 , 1 ]]); 454 455assertEquals (( await open ( "echo" , { token :"wrong" }). closed ). code , 4001 ); 456assertEquals ( await open ( "echo" , { token :TOKEN , args :{ prefix :"refuse" } }). closed , { 457code :4400 , 458reason :"not today" , 459}); 460assertEquals ( 461( await open ( "nope" , { token :TOKEN }). closed ). reason , 462"dir has no channel named nope" , 463); 464assertEquals (( await open ( "echo" , { token :TOKEN , args :{ prefix :1 } }). closed ). code , 4400 ); 465}); 466 467Deno . test ( "a conversation forks at an entry or at its newest, and branches in place with its topic following" , async () => { 468await usingserver = await serve (); 469const { api, base, faux, model} = server ; 470const client = await subscriber ( base ); 471try { 472const { json :{ tid} } = await api ( "POST" , "/api/conversations" , { extensions :{}, model}); 473type View = { entries :{ id :number ; kind :string }[] }; 474const topic = `conversation: ${ tid } ` ; 475const has = ( kind :string ) => ( view :View ) => view . entries . some (( entry ) => entry . kind === kind ); 476client . sub ( topic ); 477faux . setResponses ([ fauxAssistantMessage ( "hi" )]); 478await api ( "POST" , `/api/conversations/ ${ tid } /messages` , { content :"hello" }); 479const { entries} = await client . until < View >( topic , has ( "pi.assistant" )); 480const [ start , user ] = entries ; 481 482const { json :{ tid :copy } } = await api ( "POST" , `/api/conversations/ ${ tid } /fork` , {}); 483const { json :{ tid :edit } } = await api ( "POST" , `/api/conversations/ ${ tid } /fork` , { 484entryId :start . id , 485}); 486const index = ( await api ( "GET" , "/api/conversations" )). json ; 487assertEquals ( index [ copy ]. parent , { tid, at :entries . at ( - 1 ) ! . id }); 488assertEquals ( index [ edit ]. parent , { tid, at :start . id }); 489const forked = ( await api ( "GET" , `/api/conversations/ ${ edit } /tree` )). json ; 490assertEquals ( forked . branches , [{ 491id :index [ edit ]. id , 492created :index [ edit ]. created , 493tip :start . id , 494}]); 495// what a fork at an entry starts with: the entries through it 496const context = await api ( "GET" , `/api/conversations/ ${ tid } /context?at= ${ user . id } ` ); 497assertEquals ( context . json . entries . map (( entry :{ id :number }) => entry . id ), [ 498start . id , 499user . id , 500]); 501assertEquals (( await api ( "GET" , `/api/conversations/ ${ tid } /context` )). status , 400 ); 502// the edit's history ends before the first message 503assertEquals ( await api ( "GET" , `/api/conversations/ ${ edit } /context?at= ${ user . id } ` ), { 504status :400 , 505json :{ error :`no entry ${ user . id } in ${ edit } ` }, 506}); 507assertEquals ( 508( await api ( "POST" , `/api/conversations/ ${ edit } /fork` , { entryId :user . id })). status , 509400 , 510); 511 512// before the first message, then back to where it was 513assertEquals ( await api ( "POST" , `/api/conversations/ ${ tid } /branch` , { entryId :start . id }), { 514status :200 , 515json :{}, 516}); 517await client . until < View >( topic , ( view ) => ! has ( "pi.user" )( view )); 518const tree = ( await api ( "GET" , `/api/conversations/ ${ tid } /tree` )). json ; 519assertEquals ( tree . branches . length , 2 ); 520assertEquals ( tree . head , tree . branches [ 1 ]. id ); 521await api ( "POST" , `/api/conversations/ ${ tid } /branch` , { entryId :entries . at ( - 1 ) ! . id }); 522await client . until < View >( topic , has ( "pi.assistant" )); 523assertEquals ( 524( await api ( "GET" , `/api/conversations/ ${ tid } /tree` )). json . head , 525tree . branches [ 0 ]. id , 526); 527assertEquals ( 528( await api ( "POST" , `/api/conversations/ ${ tid } /branch` , { entryId :1e9 })). status , 529400 , 530); 531} finally { 532await client . close (); 533} 534}); 535 536Deno . test ( "logins ask through their topic and are answered over http" , async () => { 537await usingserver = await serve (); 538const { api, base} = server ; 539const client = await subscriber ( base ); 540type Provider = { 541id :string ; 542apiKey :{ name :string } | null ; 543auth :{ type :string } | null ; 544stored :boolean ; 545}; 546const keyed = async () => 547(( await api ( "GET" , "/api/providers" )). json as Provider []). find (( provider ) => 548provider . id === "keyed" 549) ! ; 550assertEquals (( await keyed ()). stored , false ); 551 552type Login = { 553status :string ; 554prompt :{ id :number ; type :string ; message :string } | null ; 555events :{ message ?:string }[]; 556}; 557const { json :{ login} } = await api ( "POST" , "/api/providers/keyed/login" , { type :"api_key" }); 558client . sub ( `login: ${ login } ` ); 559const asked = await client . until < Login >( `login: ${ login } ` , ( state ) => state . prompt !== null ); 560assertEquals ( asked . prompt , { id :asked . prompt ! . id , type :"secret" , message :"your key?" }); 561await api ( "POST" , `/api/logins/ ${ login } /answer` , { prompt :asked . prompt ! . id , value :"sekrit" }); 562const done = await client . until < Login >( `login: ${ login } ` , ( state ) => state . status === "done" ); 563assertEquals ( done . events . map (( event ) => event . message ), [ "thanks" ]); 564assertEquals (( await keyed ()). stored , true ); 565assertEquals (( await keyed ()). auth ?. type , "api_key" ); 566// the question is gone once answered 567assertEquals ( 568( await api ( "POST" , `/api/logins/ ${ login } /answer` , { prompt :asked . prompt ! . id , value :"x" })) 569. status , 570400 , 571); 572 573await api ( "POST" , "/api/providers/keyed/logout" ); 574assertEquals (( await keyed ()). stored , false ); 575assertEquals (( await keyed ()). auth , null ); 576 577const { json :{ login :again } } = await api ( "POST" , "/api/providers/keyed/login" , { 578type :"api_key" , 579}); 580client . sub ( `login: ${ again } ` ); 581await client . until < Login >( `login: ${ again } ` , ( state ) => state . prompt !== null ); 582await api ( "POST" , `/api/logins/ ${ again } /cancel` ); 583const cancelled = await client . until < Login >( 584`login: ${ again } ` , 585( state ) => state . status !== "running" , 586); 587assertEquals ([ cancelled . status , cancelled . prompt ], [ "cancelled" , null ]); 588assertEquals (( await keyed ()). stored , false ); 589 590assertEquals (( await api ( "POST" , "/api/providers/keyed/login" , { type :"oauth" })). status , 400 ); 591await client . close (); 592}); 593 594Deno . test ( "a conversation's read receipt, name, and archive state change independently over http" , async () => { 595await usingserver = await serve (); 596const { api, berry, faux, model} = server ; 597const { json :{ tid} } = await api ( "POST" , "/api/conversations" , { extensions :{}, model}); 598const path = `/api/conversations/ ${ tid } ` ; 599const info = async () => ( await api ( "GET" , "/api/conversations" )). json [ tid ]; 600faux . setResponses ([ fauxAssistantMessage ( "done" )]); 601await api ( "POST" , ` ${ path } /messages` , { content :"go" }); 602const current = ( await berry . conversation ( tid )) ! ; 603await current . waitForIdle ( context ); 604const { entries} = await current . context ( context ); 605const entryId = entries . findLast (( entry ) => entry . kind === "pi.assistant" ) ! . id ; 606const read = ( target :string , entryId :number ) => 607api ( "POST" , `/api/conversations/ ${ target } /read` , { entryId}); 608assertEquals (( await read ( tid , entryId )). status , 200 ); 609assertEquals (( await info ()). readThrough , entryId ); 610assertEquals (( await read ( tid , 1.5 )). status , 400 ); 611const { json :{ tid :other } } = await api ( "POST" , "/api/conversations" , { extensions :{} }); 612assertEquals (( await read ( other , entryId )). status , 400 ); 613assertEquals (( await read ( "aaaaaaaaaaaaa" , entryId )). status , 404 ); 614 615assertEquals (( await api ( "PATCH" , path , { title :" planning 🦊 " })). status , 200 ); 616assertEquals (( await info ()). title , "planning 🦊" ); 617await api ( "PATCH" , path , { archived :true }); 618const archived = ( await info ()). archived ; 619assertEquals ( typeof archived , "number" ); 620await api ( "PATCH" , path , { title :"another name" }); 621const renamed = await info (); 622assertEquals ([ renamed . title , renamed . archived , renamed . readThrough ], [ 623"another name" , 624archived , 625entryId , 626]); 627await api ( "PATCH" , path , { title :" " , archived :false }); 628assertEquals ([( await info ()). title , ( await info ()). archived ], [ undefined , undefined ]); 629assertEquals (( await api ( "PATCH" , path , { title :5 })). status , 400 ); 630assertEquals ( 631( await api ( "PATCH" , "/api/conversations/aaaaaaaaaaaaa" , { archived :true })). status , 632404 , 633); 634}); 635 636Deno . test ( "an image message reaches the model and its history whole, and invalid ones are refused" , async () => { 637await usingserver = await serve (); 638const { api, berry, faux, model} = server ; 639const image :ImageContent = { type :"image" , mimeType :"image/png" , data :PNG }; 640const seen :Message [][] = []; 641faux . setResponses ([( request ) => { 642seen . push ([ ...request . messages ]); 643return fauxAssistantMessage ( "I see the image" ); 644}]); 645const { json :{ tid} } = await api ( "POST" , "/api/conversations" , { extensions :{}, model}); 646const messages = `/api/conversations/ ${ tid } /messages` ; 647const refused = async ( content :unknown ) => ( await api ( "POST" , messages , { content})). status ; 648assertEquals ( await refused ([{ ...image , data :"not base64!" }]), 400 ); 649assertEquals ( await refused ([{ ...image , mimeType :"image/svg+xml" }]), 400 ); 650assertEquals ( await refused ( Array . from ({ length :5 }, () => image )), 400 ); 651const large = encodeBase64 ( new Uint8Array ( 5 * 1024 * 1024 + 1 )); 652assertEquals ( await refused ([{ ...image , data :large }]), 413 ); 653assertEquals ( await refused ( "x" . repeat ( 31 * 1024 * 1024 )), 413 ); 654 655const content = [{ type :"text" as const , text :"What is this?" }, image , image ]; 656await api ( "POST" , messages , { content}); 657const current = ( await berry . conversation ( tid )) ! ; 658await current . waitForIdle ( context ); 659const users = ( messages :readonly Message []) => 660messages . filter (( message ) => message . role === "user" ). map (( message ) => message . content ); 661assertEquals ( users ( seen . at ( - 1 ) ! ), [ content ]); 662assertEquals ( users (( await current . context ( context )). messages ), [ content ]); 663const { json :tree } = await api ( "GET" , `/api/conversations/ ${ tid } /tree` ); 664const user = tree . entries . find (( entry :{ kind :string }) => entry . kind === "pi.user" ); 665assertEquals ([ user . text , user . imageCount ], [ "What is this?" , 2 ]); 666 667await usingtextOnly = await serve ( undefined , false ); 668const { json :{ tid :text } } = await textOnly . api ( "POST" , "/api/conversations" , { 669extensions :{}, 670model :textOnly . model , 671}); 672const response = await textOnly . api ( "POST" , `/api/conversations/ ${ text } /messages` , { 673content :[ image ], 674}); 675assertEquals ( response . status , 400 ); 676assertStringIncludes ( response . json . error , "supports image input" ); 677}); 678 679Deno . test ( "abort returns edited queued inputs with attachments, not the input already in the transcript" , async () => { 680await usingserver = await serve (); 681const { api, berry, faux, model} = server ; 682const blocker = blocking (); 683await berry . install ( blocker . extension ); 684faux . setResponses ([ 685fauxAssistantMessage ( fauxToolCall ( "block" , {}), { stopReason :"toolUse" }), 686]); 687const tid = await berry . create ({ extensions :{ blocking :{} }, model}); 688const current = ( await berry . conversation ( tid )) ! ; 689const base = `/api/conversations/ ${ tid } ` ; 690try { 691await current . submit ({ type :"input" , content :"already placed" }, context ); 692await until (() => blocker . running > 0 ); 693const image :ImageContent = { type :"image" , mimeType :"image/png" , data :PNG }; 694const first = await api ( "POST" , ` ${ base } /messages` , { 695content :[{ type :"text" , text :"old" }, image ], 696whenBusy :"steer" , 697}); 698await api ( "PATCH" , ` ${ base } /inbox/ ${ first . json . submission } ` , { text :"latest" }); 699await api ( "POST" , ` ${ base } /messages` , { content :"later" , whenBusy :"followUp" }); 700const queued = ( await berry . harness . snapshot ( InboxDoc , current . id , context )) ! ; 701const response = await api ( "POST" , ` ${ base } /abort` ); 702assertEquals ( response , { status :200 , json :{ inputs :queued . items } }); 703assertEquals (( await berry . harness . snapshot ( InboxDoc , current . id , context )) ! . items , []); 704assertEquals (( await api ( "POST" , ` ${ base } /abort` )). json , { inputs :[] }); 705} finally { 706blocker . release . resolve ( "released" ); 707} 708}); 709 710Deno . test ( "queued input edits keep identity, mode, order, attachments, and original receipts" , async () => { 711const whenBusy = "followUp" ; 712await usingserver = await serve (); 713const { api, berry, faux, model} = server ; 714const { gate, extension} = gated (); 715await berry . install ( extension ); 716const image :ImageContent = { type :"image" , mimeType :"image/png" , data :PNG }; 717const seen :Message [][] = []; 718const record = ( request :{ messages :readonly Message [] }) => { 719seen . push ([ ...request . messages ]); 720return fauxAssistantMessage ( "done" ); 721}; 722try { 723const tid = await berry . create ({ extensions :{ gate :{} }, model}); 724const current = ( await berry . conversation ( tid )) ! ; 725const base = `/api/conversations/ ${ tid } ` ; 726faux . setResponses ([ 727fauxAssistantMessage ( fauxToolCall ( "hold" , {}), { stopReason :"toolUse" }), 728record , 729record , 730record , 731]); 732const active = await current . submit ({ type :"input" , content :"wait" }, context ); 733await gate . entered . promise ; 734const contents = [ 735[{ type :"text" , text :"old" }, image , { type :"text" , text :"also old" }, image ], 736"retract me" , 737"old tail" , 738]; 739const receipts = []; 740for ( const [ index , content ] of contents . entries ()) { 741const response = await api ( "POST" , ` ${ base } /messages` , { 742 content, 743 whenBusy, 744requestId :`queued- ${ index } ` , 745}); 746assertEquals ( response . status , 200 ); 747receipts . push (( await berry . harness . submission ( response . json . submission , context )) ! ); 748} 749const before = await berry . harness . snapshot ( InboxDoc , current . id , context ); 750const live = await berry . harness . snapshot ( LiveDoc , current . id , context ); 751const original = await Promise . all ( receipts . map (( receipt ) => receipt . status ( context ))); 752assertEquals ( 753before ! . items . map (({ id, mode}) => ({ id, mode})), 754receipts . map (({ id}) => ({ id, mode :whenBusy })), 755); 756const withdrawn = receipts [ 1 ]. wait ( context ); 757assertEquals ( await api ( "PATCH" , ` ${ base } /inbox/ ${ receipts [ 0 ]. id } ` , { text :"edited" }), { 758status :200 , 759json :{}, 760}); 761assertEquals ( 762( await api ( "PATCH" , ` ${ base } /inbox/ ${ receipts [ 2 ]. id } ` , { 763text :"edited tail" , 764})). status , 765200 , 766); 767assertEquals ( await api ( "DELETE" , ` ${ base } /inbox/ ${ receipts [ 1 ]. id } ` ), { 768status :200 , 769json :{}, 770}); 771const edited = [{ type :"text" as const , text :"edited" }, image , image ]; 772assert ( before ! . items [ 0 ]. mode !== "write" && before ! . items [ 2 ]. mode !== "write" ); 773assertEquals ( await berry . harness . snapshot ( InboxDoc , current . id , context ), { 774items :[ 775{ ...before ! . items [ 0 ], content :edited }, 776{ ...before ! . items [ 2 ], content :"edited tail" }, 777], 778}); 779assert ( original [ 1 ]. status === "queued" ); 780assertEquals ( await withdrawn , { ...original [ 1 ], status :"unanswered" , reason :"aborted" }); 781assertEquals ( await receipts [ 0 ]. status ( context ), original [ 0 ]); 782assertEquals ( await receipts [ 2 ]. status ( context ), original [ 2 ]); 783assertEquals ( await berry . harness . snapshot ( LiveDoc , current . id , context ), live ); 784assertEquals (( await active . status ( context )). status , "placed" ); 785const duplicate = await api ( "POST" , ` ${ base } /messages` , { 786content :"must not replace the edit" , 787requestId :"queued-0" , 788}); 789assertEquals ( duplicate . json . submission , receipts [ 0 ]. id ); 790gate . release . resolve ( "released" ); 791await current . waitForIdle ( context ); 792assertEquals ( 793seen . at ( - 1 ) ! . filter (( message ) => message . role === "user" ). map (( message ) => message . content ), 794[ "wait" , edited , "edited tail" ], 795); 796assertEquals (( await active . wait ( context )). status , "done" ); 797for ( const receipt of [ receipts [ 0 ], receipts [ 2 ]]) { 798const settled = await receipt . wait ( context ); 799assertEquals ( settled . id , receipt . id ); 800assertEquals ( settled . status , "done" ); 801} 802const late = ` ${ base } /inbox/ ${ receipts [ 0 ]. id } ` ; 803assertEquals (( await api ( "PATCH" , late , { text :"late" })). status , 409 ); 804assertEquals (( await api ( "DELETE" , late )). status , 409 ); 805} finally { 806gate . release . resolve ( "released" ); 807} 808}); 809 810Deno . test ( "queued input routes refuse invalid edits and other conversations' inputs and writes, and bound image edits" , async () => { 811await usingserver = await serve (); 812const { api, berry, faux, model} = server ; 813const { gate, extension} = gated (); 814await berry . install ( extension ); 815const image :ImageContent = { type :"image" , mimeType :"image/png" , data :PNG }; 816try { 817const tid = await berry . create ({ extensions :{ gate :{}, dir :{} }, model}); 818const other = await berry . create ({ extensions :{}, model}); 819const current = ( await berry . conversation ( tid )) ! ; 820const base = `/api/conversations/ ${ tid } /inbox` ; 821faux . setResponses ([ 822fauxAssistantMessage ( fauxToolCall ( "hold" , {}), { stopReason :"toolUse" }), 823fauxAssistantMessage ( "done" ), 824fauxAssistantMessage ( "done" ), 825]); 826const active = await current . submit ({ type :"input" , content :"wait" }, context ); 827await gate . entered . promise ; 828const queued = await current . submit ({ type :"input" , content :"original" }, context ); 829const pictured = await current . submit ({ type :"input" , content :[ image ] }, context ); 830const write = await berry . emit ( tid , "dir" , { cwd :"/kept" }); 831const before = await berry . harness . snapshot ( InboxDoc , current . id , context ); 832const change = ( path :string , text = "change" ) => api ( "PATCH" , path , { text}); 833assertEquals (( await change ( ` ${ base } /1junk` )). status , 400 ); 834assertEquals (( await change ( ` ${ base } / ${ queued . id } ` , " \n\t" )). status , 400 ); 835assertEquals (( await change ( ` ${ base } / ${ write . id } ` )). status , 404 ); 836assertEquals (( await change ( `/api/conversations/ ${ other } /inbox/ ${ queued . id } ` )). status , 404 ); 837assertEquals (( await change ( ` ${ base } / ${ active . id } ` )). status , 409 ); 838// two bytes a character, so only the image takes it over the limit 839const text = "é" . repeat (( MAX_MESSAGE_BYTES - 100 ) / 2 ); 840assertEquals (( await change ( ` ${ base } / ${ pictured . id } ` , text )). status , 413 ); 841assertEquals ( await berry . harness . snapshot ( InboxDoc , current . id , context ), before ); 842// an image needs no text 843assertEquals (( await change ( ` ${ base } / ${ pictured . id } ` , "" )). status , 200 ); 844const [, picture ] = ( await berry . harness . snapshot ( InboxDoc , current . id , context )) ! . items ; 845assertEquals ( picture , { id :pictured . id , mode :"followUp" , content :[ image ] }); 846 847gate . release . resolve ( "released" ); 848await current . waitForIdle ( context ); 849const start = ( await current . context ( context )). entries [ 0 ]. id ; 850assertEquals ( 851( await api ( "POST" , `/api/conversations/ ${ tid } /branch` , { entryId :start })). status , 852200 , 853); 854assertEquals (( await change ( ` ${ base } / ${ queued . id } ` )). status , 404 ); 855} finally { 856gate . release . resolve ( "released" ); 857} 858}); 859 860Deno . test ( "references and the shell follow the conversation's directory while code is selected" , async () => { 861const { default :code } = await import ( "../extensions/code/mod.ts" ); 862await usingrootDir = await tempDir (); 863const root = rootDir . path ; 864await usings = await serve (); 865await s . berry . install ( code ); 866await Deno . mkdir ( ` ${ root } /other` ); 867await Deno . writeTextFile ( ` ${ root } /first.ts` , "" ); 868await Deno . writeTextFile ( ` ${ root } /other/second.ts` , "" ); 869// before a conversation works there, as a new one's composer asks 870assertEquals ( 871await s . api ( "GET" , `/api/references?cwd= ${ encodeURIComponent ( root )} &prefix=f` ), 872{ status :200 , json :[ "first.ts" ] }, 873); 874const created = await s . api ( "POST" , "/api/conversations" , { 875extensions :{ environment :{ cwd :root }, code :{} }, 876model :s . model , 877}); 878const tid = created . json . tid ; 879const base = `/api/conversations/ ${ tid } ` ; 880const conv = ( await s . berry . conversation ( tid )) ! ; 881const entries = await conv . entries ({ order :"descending" }, 1 , undefined , context ); 882const at = entries . items [ 0 ]. id ; 883assertEquals ( await s . api ( "GET" , ` ${ base } /references?prefix=f` ), { 884status :200 , 885json :[ "first.ts" ], 886}); 887await s . api ( "PATCH" , ` ${ base } /configuration/environment` , { cwd :` ${ root } /other` }); 888assertEquals ( await s . api ( "GET" , ` ${ base } /references?prefix=s` ), { 889status :200 , 890json :[ "second.ts" ], 891}); 892assertEquals ( await s . api ( "GET" , ` ${ base } /references?prefix=f&at= ${ at } ` ), { 893status :200 , 894json :[ "first.ts" ], 895}); 896const { json :{ task} } = await s . api ( "POST" , ` ${ base } /shell` , { command :"touch made" }); 897await s . berry . harness . waitForTask ( task , context ); 898await Deno . stat ( ` ${ root } /other/made` ); 899 900await s . api ( "PATCH" , ` ${ base } /agent` , { extensions :[] }); 901assertEquals ( await s . api ( "GET" , ` ${ base } /references` ), { status :200 , json :[] }); 902}); 903 904Deno . test ( "projects publish their edits, and folders are suggested over http" , async () => { 905await usingrootDir = await tempDir (); 906const root = rootDir . path ; 907await usingserver = await serve (); 908const { api, base} = server ; 909await Deno . mkdir ( ` ${ root } /workspace` ); 910const socket = await subscriber ( base ); 911try { 912const folders = `/api/folders?cwd= ${ encodeURIComponent ( root )} &prefix=work` ; 913assertEquals ( await api ( "GET" , folders ), { status :200 , json :[ "workspace/" ] }); 914socket . sub ( "projects" ); 915const { json :{ id} } = await api ( "POST" , "/api/projects" , { 916name :"Berry" , 917defaultRoot :0 , 918roots :[{ environment :"local" , cwd :root }], 919}); 920type Index = Record < string , { name :string }>; 921await socket . until < Index >( "projects" , ( value ) => value [ id ]?. name === "Berry" ); 922socket . sub ( "project-order" ); 923await api ( "PUT" , "/api/project-order" , { ids :[ "" , id ] }); 924await socket . until <{ ids :string [] }>( "project-order" , ( value ) => value . ids [ 0 ] === "" ); 925await api ( "DELETE" , `/api/projects/ ${ id } ` ); 926await socket . until < Index >( "projects" , ( value ) => ! ( id in value )); 927} finally { 928await socket . close (); 929} 930});