char-slop/berry
multipurpose self-modifiable agentic harness
git clone https://git.t4t.associates/char-slop/berry
32b51b2
main
1import { assertEquals , assertExists } from "@std/assert" ; 2import { fill , press } from "@berry/util/dom.ts" ; 3import type { ConfigurationContext } from "@berry/api/web.ts" ; 4import type { ExtensionInfo } from "./conversation.tsx" ; 5import type { ModelInfo } from "./settings.tsx" ; 6import { type Page , withBerry } from "./testing.ts" ; 7 8const tid = "aaaaaaaaaaaaa" ; 9const model = { provider :"test" , modelId :"model" }; 10const extension = ( 11name :string , 12{ promptProse= null , ...patch } :Partial < ExtensionInfo > = {}, 13) :ExtensionInfo => ({ 14 name, 15description :null , 16status :"loaded" , 17error :null , 18builtin :false , 19running :true , 20alwaysOn :false , 21defaultEnabled :false , 22configuration :null , 23 promptProse, 24web :null , 25formerly :[], 26 ...patch , 27}); 28const installed = [ 29extension ( "berry" , { builtin :true , alwaysOn :true }), 30extension ( "subagent" , { builtin :true }), 31extension ( "code" , { defaultEnabled :true }), 32extension ( "web" , { defaultEnabled :true }), 33]; 34const configurationSource = `export default { configuration() { 35const element = document.createElement("section"); 36element.className = "configuration-probe"; 37return { 38element, 39update(ctx) { 40element.context = ctx; 41element.hidden = ctx.model?.provider !== "openrouter"; 42if (!element.hidden && !element.firstChild) element.append(ctx.renderDefault()); 43}, 44dispose() { element.dataset.disposed = "true"; }, 45}; 46}};` ; 47const configuration = { properties :{ setting :{ type :"string" , default :"default" } } }; 48type ConfigurationProbe = HTMLElement & { context :ConfigurationContext }; 49 50const models :ModelInfo [] = [{ 51provider :model . provider , 52id :model . modelId , 53name :"Test model" , 54input :[ "text" ], 55reasoning :true , 56thinkingLevels :[ "off" , "high" ], 57contextWindow :100_000 , 58cost :{ input :0 , output :0 }, 59}]; 60 61function withApp ( hash :string , test :( page :Page ) => Promise < void >) { 62return withBerry ( async ( page ) => { 63page . server . respond (({ method, path}) => 64path === "/api/models" ?models :method === "POST" ?{ tid} :[] 65); 66const { app} = await import ( "./app.tsx" ); 67document . body . append ( app ( page . scope )); 68page . server . snapshot ( "settings" , { models :[ model ] }); 69page . server . snapshot ( "extensions" , installed ); 70await page . settle (); 71await test ( page ); 72}, { url :`http://berry.test/ ${ hash } ` }); 73} 74 75function checkbox ( selector :string , name :string ) :HTMLInputElement { 76const label = [ ...document . querySelectorAll ( ` ${ selector } label` )]. find (( label ) => 77label . textContent ?. trim () === name 78); 79assertExists ( label , ` ${ name } is selectable in ${ selector } ` ); 80const input = label . querySelector < HTMLInputElement >( 'input[type="checkbox"]' ); 81assertExists ( input ); 82assertEquals ( input . disabled , false ); 83return input ; 84} 85 86/** Sends the first message from the new-conversation page. */ 87async function start ( page :Page , text = "hello" ) { 88const input = document . querySelector < HTMLTextAreaElement >( ".composer textarea" ) ! ; 89fill ( input , text ); 90press ( input , "Enter" ); 91await page . settle (); 92} 93 94/** What the new-conversation page asked the server to create. */ 95const created = ( page :Page ) => 96page . server . changes (). find (({ method, path}) => 97method === "POST" && path === "/api/conversations" 98)?. body ; 99 100/** Shows the conversation with this agent document. */ 101async function agent ( page :Page , doc :object ) { 102page . server . snapshot ( `conversation: ${ tid } ` , { entries :[], docs :{ "pi.agent" :doc } }); 103await page . settle (); 104} 105 106/** Installs an always-on extension whose frontend is this module source. */ 107async function frontend ( page :Page , name :string , source :string , patch = {}) { 108const module = `data:text/javascript, ${ encodeURIComponent ( source )} ` ; 109await import ( module ); 110const installs = extension ( name , { alwaysOn :true , web :{ module, styles :[] }, ...patch }); 111page . server . snapshot ( "extensions" , [ ...installed , installs ]); 112await page . settle (); 113return installs ; 114} 115 116Deno . test ( "a new conversation starts with the extensions that ask to be, until one is chosen, which it remembers" , async () => { 117await withApp ( "#/new" , async ( page ) => { 118const subagent = checkbox ( ".start" , "subagent" ); 119assertEquals ( 120[ subagent . checked , checkbox ( ".start" , "code" ). checked , checkbox ( ".start" , "web" ). checked ], 121[ false , true , true ], 122); 123subagent . click (); 124await start ( page , "hello" ); 125assertEquals ( created ( page ), { 126extensions :{ subagent :{}, code :{}, web :{} }, 127 model, 128thinkingLevel :"high" , 129}); 130assertEquals ( page . server . changes (). at ( - 1 ), { 131method :"POST" , 132path :`/api/conversations/ ${ tid } /messages` , 133body :{ content :"hello" }, 134}); 135assertEquals ( location . hash , `#/ ${ tid } ` ); 136// the next one starts the way this one did 137location . hash = "#/new" ; 138await page . settle (); 139assertEquals ( 140[ 141checkbox ( ".start" , "subagent" ). checked , 142checkbox ( ".start" , "code" ). checked , 143checkbox ( ".start" , "web" ). checked , 144], 145[ true , true , true ], 146); 147}); 148}); 149 150Deno . test ( "a new conversation starts blank, its draft waiting in its composer rather than sent" , async () => { 151await withApp ( "#/new" , async ( page ) => { 152fill ( document . querySelector < HTMLTextAreaElement >( ".composer textarea" ) ! , "half a thought" ); 153document . querySelector < HTMLButtonElement >( ".start-blank" ) ! . click (); 154await page . settle (); 155assertEquals ( created ( page ), { 156extensions :{ code :{}, web :{} }, 157 model, 158thinkingLevel :"high" , 159}); 160assertEquals ( page . server . changes (). at ( - 1 )?. path , "/api/conversations" ); 161assertEquals ( location . hash , `#/ ${ tid } ` ); 162await agent ( page , { model, extensions :[{ name :"code" }, { name :"web" }] }); 163assertEquals ( 164document . querySelector < HTMLTextAreaElement >( ".composer textarea" ) ! . value , 165"half a thought" , 166); 167// it moved, rather than being left for the next new conversation too 168location . hash = "#/new" ; 169await page . settle (); 170assertEquals ( document . querySelector < HTMLTextAreaElement >( ".composer textarea" ) ! . value , "" ); 171}); 172}); 173 174Deno . test ( "a new conversation asks its extensions' questions in installed order, and starts in a chosen project" , async () => { 175await withApp ( "#/new" , async ( page ) => { 176const { server, settle} = page ; 177const ordered = [ 178extension ( "environment" , { 179alwaysOn :true , 180configuration :{ 181properties :{ 182environment :{ type :"string" , default :"local" }, 183cwd :{ type :"string" , default :"" }, 184}, 185}, 186}), 187extension ( "openrouter-routing" , { alwaysOn :true , configuration}), 188extension ( "code" , { configuration, defaultEnabled :true }), 189extension ( "web" , { configuration, defaultEnabled :true }), 190extension ( "subagent" , { configuration}), 191extension ( "other" , { alwaysOn :true , configuration}), 192]; 193server . snapshot ( "projects" , { 194berry :{ 195name :"Berry" , 196roots :[{ environment :"local" , cwd :"/work/berry" , platform :"linux" }], 197defaultRoot :0 , 198}, 199}); 200server . snapshot ( "extensions" , ordered ); 201await settle (); 202const setup = document . querySelector < HTMLElement >( ".start" ) ! ; 203const legends = () => [ ...setup . querySelectorAll ( "legend" )]. map (( legend ) => legend . textContent ); 204assertEquals ( legends (), [ "openrouter-routing" , "code" , "web" , "other" ]); 205const project = setup . querySelector < HTMLSelectElement >( '[aria-label="project"]' ) ! ; 206project . value = "berry" ; 207project . dispatchEvent ( new Event ( "change" )); 208const folder = () => setup . querySelector < HTMLInputElement >( ".start-environment input" ) ! ; 209assertEquals ( folder (). value , "/work/berry" ); 210const routing = () => setup . querySelector < HTMLInputElement >( ".start-options fieldset input" ) ! ; 211fill ( routing (), "routing" ); 212checkbox ( ".start" , "code" ). click (); 213assertEquals ( legends (), [ "openrouter-routing" , "web" , "other" ]); 214assertEquals ([ folder (). value , routing (). value ], [ "/work/berry" , "routing" ]); 215await start ( page ); 216assertEquals ( created ( page ), { 217extensions :{ 218environment :{ environment :"local" , cwd :"/work/berry" }, 219"openrouter-routing" :{ setting :"routing" }, 220web :{}, 221other :{}, 222}, 223 model, 224thinkingLevel :"high" , 225}); 226}); 227}); 228 229Deno . test ( "the header's subagent box follows legacy inheritance, explicit choices, and subagents' own conversations" , async () => { 230await withApp ( `#/ ${ tid } ` , async ( page ) => { 231const { server, settle} = page ; 232await agent ( page , { extensions :{ add :[] } }); 233const details = document . querySelector < HTMLDetailsElement >( ".conversation-header .extensions" ); 234assertExists ( details ); 235details . querySelector ( "summary" ) ! . click (); 236const subagent = checkbox ( ".conversation-header" , "subagent" ); 237const shown = () => [ subagent . checked , details . querySelector ( "summary" ) ! . textContent ]; 238const patched = () => server . changes (). at ( - 1 )?. body ; 239assertEquals ( shown (), [ true , "subagent" ]); 240subagent . click (); 241await settle (); 242assertEquals ( server . changes (), [{ 243method :"PATCH" , 244path :`/api/conversations/ ${ tid } /agent` , 245body :{ extensions :[] }, 246}]); 247await agent ( page , { extensions :{ add :[], remove :[ "subagent" ] } }); 248assertEquals ( shown (), [ false , "none" ]); 249subagent . click (); 250await settle (); 251assertEquals ( patched (), { extensions :[ "subagent" ] }); 252await agent ( page , { extensions :{ add :[ "subagent" ] } }); 253assertEquals ( shown (), [ true , "subagent" ]); 254assertEquals ( server . changes (). length , 2 ); 255 256// a subagent's own conversation does not inherit delegation, and a toggle keeps the others 257server . snapshot ( "conversations" , { 258[ tid ] :{ id :2 , created :1 , owner :"bbbbbbbbbbbbb" , parent :{ tid :"bbbbbbbbbbbbb" , at :1 } }, 259}); 260await agent ( page , { extensions :[ "code" ] }); 261assertEquals ( subagent . checked , false ); 262subagent . click (); 263await settle (); 264assertEquals ( patched (), { extensions :[ "code" , "subagent" ] }); 265}); 266}); 267 268Deno . test ( "header contributions honor selection and always-on status and invoke user commands" , async () => { 269await withApp ( `#/ ${ tid } ` , async ( page ) => { 270const { window, server, settle} = page ; 271const probe = await frontend ( 272page , 273"probe" , 274`export default { header() { 275const element = document.createElement("button"); 276element.className = "header-probe"; 277return { 278element, 279update(ctx) { 280element.textContent = ctx.model?.provider ?? "none"; 281element.onclick = () => void ctx.command("probe", "args"); 282}, 283dispose() { element.dataset.disposed = "true"; }, 284}; 285}};` , 286{ alwaysOn :false }, 287); 288const update = ( extensions :string [], provider = "anthropic" ) => 289agent ( page , { extensions, model :{ provider, modelId :"test" } }); 290await update ([]); 291assertEquals ( document . querySelector ( ".header-probe" ), null ); 292await update ([ "probe" ]); 293const first = document . querySelector < HTMLButtonElement >( ".header-probe" ) ! ; 294assertExists ( first ); 295assertEquals ( first . textContent , "anthropic" ); 296first . click (); 297await settle (); 298assertEquals ( server . changes (). at ( - 1 ), { 299method :"POST" , 300path :`/api/conversations/ ${ tid } /commands/probe` , 301body :{ args :"args" }, 302}); 303await update ([ "probe" ], "openai-codex" ); 304assertEquals ( first . textContent , "openai-codex" ); 305await update ([], "openai-codex" ); 306assertEquals ( first . dataset . disposed , "true" ); 307assertEquals ( document . querySelector ( ".header-probe" ), null ); 308server . snapshot ( "extensions" , [ ...installed , { ...probe , alwaysOn :true }]); 309await settle (); 310const always = document . querySelector < HTMLButtonElement >( ".header-probe" ) ! ; 311assertExists ( always ); 312assertEquals ( always . textContent , "openai-codex" ); 313window . location . hash = "#/settings" ; 314await settle (); 315assertEquals ( always . dataset . disposed , "true" ); 316}); 317}); 318 319Deno . test ( "configuration renderers follow the live model, keep saved values, and dispose on reload" , async () => { 320await withApp ( `#/ ${ tid } ` , async ( page ) => { 321const { server, settle} = page ; 322await frontend ( page , "probe" , configurationSource , { configuration}); 323const probe = document . querySelector < ConfigurationProbe >( 324".conversation-header .configuration-probe" , 325) ! ; 326assertExists ( probe ); 327assertEquals ( probe . hidden , true ); 328server . snapshot ( `configuration: ${ tid } :probe` , { setting :"saved" }); 329await agent ( page , { extensions :[], model :{ provider :"openrouter" , modelId :"lab/model" } }); 330assertEquals ( probe . hidden , false ); 331const form = probe . firstChild ; 332assertEquals ( probe . context . renderDefault (), form ); 333assertEquals ( probe . querySelector < HTMLInputElement >( "input" ) ! . value , "saved" ); 334assertEquals ( probe . context . extension , "probe" ); 335assertEquals ( probe . context . tid , tid ); 336await agent ( page , { extensions :[], model}); 337assertEquals ( probe . hidden , true ); 338await agent ( page , { extensions :[], model :{ provider :"openrouter" , modelId :"lab/other" } }); 339assertEquals ( probe . hidden , false ); 340assertEquals ( probe . firstChild , form ); 341assertEquals ( probe . querySelector < HTMLInputElement >( "input" ) ! . value , "saved" ); 342assertEquals ( server . changes (), []); 343 344server . respond (() => ({ result :"ok" })); 345assertEquals ( await probe . context . request ! ( "providers" , { model :"lab/other" }), "ok" ); 346assertEquals ( 347server . changes (). at ( - 1 )?. path , 348`/api/conversations/ ${ tid } /extensions/probe/providers` , 349); 350assertEquals ( await probe . context . command ! ( "choose" , "fast" ), "ok" ); 351assertEquals ( server . changes (). at ( - 1 )?. path , `/api/conversations/ ${ tid } /commands/choose` ); 352 353await frontend ( page , "probe" , configurationSource + "\nexport const revision = 2;" , { 354 configuration, 355}); 356assertEquals ( probe . dataset . disposed , "true" ); 357await agent ( page , { extensions :[], model :{ provider :"openrouter" , modelId :"lab/other" } }); 358server . snapshot ( `configuration: ${ tid } :probe` , { setting :"saved" }); 359const replacement = document . querySelector < ConfigurationProbe >( 360".conversation-header .configuration-probe" , 361) ! ; 362assertEquals ( replacement . hidden , false ); 363assertEquals ( replacement . querySelector < HTMLInputElement >( "input" ) ! . value , "saved" ); 364await settle (); 365}); 366}); 367 368Deno . test ( "custom configuration stages edits until dismissal and retains newer drafts through pending and failed writes" , async () => { 369await withApp ( `#/ ${ tid } ` , async ( page ) => { 370const { server, settle} = page ; 371await frontend ( page , "probe" , configurationSource , { configuration}); 372await agent ( page , { extensions :[], model :{ provider :"openrouter" , modelId :"lab/model" } }); 373server . snapshot ( `configuration: ${ tid } :probe` , { setting :"saved" }); 374await settle (); 375const probe = document . querySelector < ConfigurationProbe >( 376".conversation-header .configuration-probe" , 377) ! ; 378const dropdown = document . querySelector < HTMLDetailsElement >( 379".conversation-header .extensions" , 380) ! ; 381const open = async () => { 382dropdown . querySelector ( "summary" ) ! . click (); 383await settle (); 384}; 385await open (); 386await probe . context . save ({ setting :"updated" }); 387assertEquals ( probe . context . values , { setting :"updated" }); 388assertEquals ( server . changes (), []); 389const pending = Promise . withResolvers < unknown >(); 390server . respond (() => pending . promise ); 391document . body . click (); 392assertEquals ( dropdown . open , false ); 393await settle (); 394assertEquals ( probe . context . saveStatus , "saving" ); 395assertEquals ( server . changes (), [{ 396method :"PATCH" , 397path :`/api/conversations/ ${ tid } /configuration/probe` , 398body :{ setting :"updated" }, 399}]); 400await open (); 401await probe . context . save ({ setting :"newer" }); 402server . snapshot ( `configuration: ${ tid } :probe` , { setting :"updated" }); 403assertEquals ( probe . context . values , { setting :"newer" }); 404server . respond (() => Response . json ({ error :"invalid setting" }, { status :400 })); 405dropdown . querySelector ( "summary" ) ! . click (); 406await settle (); 407assertEquals ( server . changes (). length , 1 ); 408pending . resolve ({ setting :"updated" }); 409await settle (); 410assertEquals ( server . changes (). at ( - 1 )?. body , { setting :"newer" }); 411assertEquals ( probe . context . saveStatus , "error" ); 412assertEquals ( probe . context . error , "invalid setting" ); 413assertEquals ( probe . context . values , { setting :"newer" }); 414server . respond (({ body}) => body ); 415await open (); 416await probe . context . save ({ setting :"recovered" }); 417document . body . click (); 418await settle (); 419assertEquals ( probe . context . saveStatus , "idle" ); 420assertEquals ( probe . context . error , null ); 421assertEquals ( probe . querySelector < HTMLInputElement >( "input" ) ! . value , "recovered" ); 422}); 423}); 424 425Deno . test ( "new-conversation configuration renderers preserve answers across model switches and save before submit" , async () => { 426await withApp ( "#/new" , async ( page ) => { 427const { server} = page ; 428const routed = { provider :"openrouter" , modelId :"lab/model" }; 429server . snapshot ( "settings" , { models :[ model , routed ] }); 430await frontend ( page , "probe" , configurationSource , { configuration}); 431const probe = document . querySelector < ConfigurationProbe >( ".start .configuration-probe" ) ! ; 432const choose = ( key :string ) => { 433const select = document . querySelector < HTMLSelectElement >( ".start select.model" ) ! ; 434select . value = key ; 435select . dispatchEvent ( new Event ( "change" )); 436}; 437assertEquals ( probe . hidden , true ); 438choose ( "openrouter/lab/model" ); 439assertEquals ( probe . hidden , false ); 440assertEquals ( probe . context . tid , null ); 441assertEquals ( probe . context . request , undefined ); 442assertEquals ( probe . context . command , undefined ); 443assertEquals ( probe . querySelector < HTMLInputElement >( "input" ) ! . value , "default" ); 444await probe . context . save ({ setting :"custom" }); 445choose ( "test/model" ); 446assertEquals ( probe . hidden , true ); 447choose ( "openrouter/lab/model" ); 448assertEquals ( probe . querySelector < HTMLInputElement >( "input" ) ! . value , "custom" ); 449fill ( probe . querySelector < HTMLInputElement >( "input" ) ! , "typed" ); 450await start ( page ); 451assertEquals ( 452created ( page ), 453{ 454extensions :{ probe :{ setting :"typed" }, code :{}, web :{} }, 455model :routed , 456thinkingLevel :"high" , 457}, 458); 459}); 460}); 461 462Deno . test ( "a view opens from its labelled link, and navigation away waits for it, restoring the address if it refuses" , async () => { 463await withApp ( `#/ ${ tid } ` , async ( page ) => { 464const { window, settle} = page ; 465await frontend ( 466page , 467"probe" , 468`export default { view: { label: "term", create() { 469const element = document.createElement("div"); 470element.className = "view-leave-probe"; 471return { 472element, 473update(ctx) { element.dataset.route = ctx.route; }, 474beforeLeave(next) { 475element.dataset.destination = JSON.stringify(next); 476return new Promise(resolve => { element.resolveLeave = resolve; }); 477}, 478dispose() { element.dataset.disposed = "true"; }, 479}; 480} } };` , 481); 482await agent ( page , { extensions :[] }); 483const link = document . querySelector < HTMLAnchorElement >( ".view-links a" ) ! ; 484assertEquals ( link . textContent , "term" ); 485assertEquals ( link . getAttribute ( "href" ), `#/ ${ tid } /probe` ); 486window . location . hash = link . getAttribute ( "href" ) ! ; 487await settle (); 488const element = document . querySelector < HTMLElement >( ".view-leave-probe" ) as HTMLElement & { 489resolveLeave ( allowed :boolean ) :void ; 490}; 491assertExists ( element ); 492window . location . hash = "#/settings" ; 493await settle (); 494assertEquals ( element . dataset . disposed , undefined ); 495assertEquals ( document . querySelector ( ".view-leave-probe" ), element ); 496element . resolveLeave ( false ); 497await settle (); 498assertEquals ( window . location . hash , `#/ ${ tid } /probe` ); 499assertEquals ( element . dataset . disposed , undefined ); 500 501window . location . hash = `#/ ${ tid } /probe/a%20b` ; 502await settle (); 503assertEquals ( element . dataset . route , "" ); 504assertEquals ( JSON . parse ( element . dataset . destination ! ). route , "a b" ); 505element . resolveLeave ( true ); 506await settle (); 507assertEquals ( element . dataset . route , "a b" ); 508assertEquals ( element . dataset . disposed , undefined ); 509window . location . hash = "#/settings" ; 510await settle (); 511const obsolete = element . resolveLeave ; 512window . location . hash = `#/ ${ tid } /probe/a%20b` ; 513await settle (); 514assertEquals ( JSON . parse ( element . dataset . destination ! ). route , "a b" ); 515obsolete ( true ); 516await settle (); 517assertEquals ( element . dataset . disposed , undefined ); 518element . resolveLeave ( true ); 519await settle (); 520window . location . hash = "#/settings" ; 521await settle (); 522element . resolveLeave ( true ); 523await settle (); 524assertEquals ( element . dataset . disposed , "true" ); 525}); 526});