char-slop/berry
multipurpose self-modifiable agentic harness
git clone https://git.t4t.associates/char-slop/berry
a6b9955
main
1import { assert , assertEquals , assertStringIncludes } from "@std/assert" ; 2import { press } from "@berry/util/dom.ts" ; 3import { withBerry } from "./testing.ts" ; 4import { tidDate } from "./ui.ts" ; 5 6Deno . test ( "project counts exclude archived subtrees from the main list and include their forks in the archive" , async () => { 7await withBerry ( async ({ server, scope}) => { 8const { app} = await import ( "./app.tsx" ); 9document . body . append ( app ( scope )); 10const active = "aaaaaaaaaaaaa" ; 11const collapsedFork = "bbbbbbbbbbbbb" ; 12const archived = "ccccccccccccc" ; 13const hiddenFork = "ddddddddddddd" ; 14const nestedFork = "eeeeeeeeeeeee" ; 15const crossProjectFork = "fffffffffffff" ; 16const index = { 17[ active ] :{ id :1 , created :1 }, 18[ collapsedFork ] :{ id :2 , created :2 , parent :{ tid :active , at :1 } }, 19[ archived ] :{ id :3 , created :3 , archived :4 }, 20[ hiddenFork ] :{ id :4 , created :4 , parent :{ tid :archived , at :1 } }, 21[ nestedFork ] :{ id :5 , created :5 , parent :{ tid :hiddenFork , at :1 } }, 22[ crossProjectFork ] :{ id :6 , created :6 , parent :{ tid :archived , at :1 } }, 23}; 24server . snapshot ( "projects" , { 25berry :{ 26name :"Berry" , 27defaultRoot :0 , 28roots :[{ environment :"local" , cwd :"/work/berry" , platform :"linux" }], 29}, 30other :{ 31name :"Other" , 32defaultRoot :0 , 33roots :[{ environment :"local" , cwd :"/work/other" , platform :"linux" }], 34}, 35}); 36server . snapshot ( 37"summaries" , 38Object . fromEntries ( 39Object . keys ( index ). map (( tid ) => [ tid , { 40environment :"local" , 41cwd :tid === crossProjectFork ?"/work/other" :"/work/berry" , 42busy :tid === nestedFork , 43unread :tid === nestedFork , 44updated :1 , 45preview :"hello" , 46}]), 47), 48); 49server . snapshot ( "conversations" , index ); 50const count = () => 51document . querySelector ( '[data-project="berry"] .project-count' ) ! . textContent ; 52const listed = () => 53[ ...document . querySelectorAll ( ".conversations .item > a[href]:not(.parent-link)" )]. map (( a ) => 54a . getAttribute ( "href" ) 55); 56const expand = ( tid :string ) => 57document . querySelector ( `.conversations a[href="#/ ${ tid } "]` ) ! . parentElement ! 58. querySelector < HTMLButtonElement >( ".chevron" ) ! . click (); 59assertEquals ( count (), "2" ); 60assertEquals ( listed (), [ `#/ ${ active } ` , `#/ ${ crossProjectFork } ` ]); 61assertEquals ( document . querySelector ( '[data-project="other"] .project-count' ) ! . textContent , "1" ); 62expand ( active ); 63assertEquals ( count (), "2" ); 64assertEquals ( listed (), [ `#/ ${ active } ` , `#/ ${ collapsedFork } ` , `#/ ${ crossProjectFork } ` ]); 65document . querySelector < HTMLButtonElement >( '[data-project="berry"] .project-toggle' ) ! . click (); 66assertEquals ( document . querySelector ( '[data-project="berry"] [title="answering"]' ), null ); 67assertEquals ( document . querySelector ( '[data-project="berry"] [title="unread"]' ), null ); 68document . querySelector < HTMLButtonElement >( ".archive-toggle" ) ! . click (); 69assertEquals ( count (), "3" ); 70assertEquals ( document . querySelector ( '[data-project="other"]' ), null ); 71// the archive's projects start folded, apart from how the main list folds them 72assertEquals ( listed (), []); 73document . querySelector < HTMLButtonElement >( '[data-project="berry"] .project-toggle' ) ! . click (); 74assertEquals ( listed (), [ `#/ ${ archived } ` ]); 75expand ( archived ); 76expand ( hiddenFork ); 77assertEquals ( listed (), [ `#/ ${ archived } ` , `#/ ${ hiddenFork } ` , `#/ ${ nestedFork } ` ]); 78document . querySelector < HTMLButtonElement >( ".archive-toggle" ) ! . click (); 79assertEquals ( listed (), [ `#/ ${ crossProjectFork } ` ]); 80}); 81}); 82 83Deno . test ( "sidebar follows latest message times while keeping forks nested" , async () => { 84const now = new Date ( 2025 , 5 , 1 , 12 ). getTime (); 85const originalNow = Date . now ; 86Date . now = () => now ; 87let clockCleared = false ; 88const clock = { 89setInterval :() => 1 , 90clearInterval :() => { 91clockCleared = true ; 92}, 93}; 94try { 95await withBerry ( async ({ window, server, scope, settle}) => { 96const { app} = await import ( "./app.tsx" ); 97document . body . append ( app ( scope )); 98const older = "aaaaaaaaaaaaa" ; 99const newer = "bbbbbbbbbbbbb" ; 100const created = new Date ( 2020 , 0 , 2 ). getTime (); 101server . snapshot ( "conversations" , { 102[ older ] :{ id :1 , created}, 103[ newer ] :{ id :2 , created :created + 1 }, 104}); 105const times = () => [ ...document . querySelectorAll < HTMLTimeElement >( ".conversations time" )]; 106const order = () => 107[ ...document . querySelectorAll ( ".conversations .item > a[href]" )]. map (( a ) => 108a . getAttribute ( "href" ) 109); 110assertEquals ( order (), [ `#/ ${ newer } ` , `#/ ${ older } ` ]); 111assertEquals ( times ()[ 1 ]. dateTime , tidDate ( older ). toISOString ()); 112const summary = { busy :false , preview :"hello" , unread :false , updated :created }; 113server . snapshot ( "summaries" , { [ older ] :summary , [ newer ] :summary }); 114assertEquals ( times ()[ 1 ]. dateTime , new Date ( created ). toISOString ()); 115const latest = new Date ( now - 2 * 60_000 ); 116server . snapshot ( "summaries" , { 117[ older ] :{ ...summary , updated :latest . getTime () }, 118[ newer ] :summary , 119}); 120assertEquals ( order (), [ `#/ ${ older } ` , `#/ ${ newer } ` ]); 121assertEquals ( times ()[ 0 ]. dateTime , latest . toISOString ()); 122 123const firstFork = "ccccccccccccc" ; 124const secondFork = "ddddddddddddd" ; 125const index = { 126[ older ] :{ id :1 , created}, 127[ newer ] :{ id :2 , created :created + 1 }, 128[ firstFork ] :{ id :3 , created :created + 2 , parent :{ tid :older , at :1 } }, 129[ secondFork ] :{ id :4 , created :created + 3 , parent :{ tid :older , at :1 } }, 130}; 131server . snapshot ( "conversations" , index ); 132server . snapshot ( "summaries" , { 133[ older ] :{ ...summary , updated :latest . getTime () }, 134[ newer ] :{ ...summary , updated :latest . getTime () + 1 }, 135[ firstFork ] :{ ...summary , updated :latest . getTime () + 3 }, 136[ secondFork ] :{ ...summary , updated :latest . getTime () + 2 }, 137}); 138document . querySelector < HTMLAnchorElement >( `a[href="#/ ${ older } "]` ) ! . parentElement ! 139. querySelector < HTMLButtonElement >( ".chevron" ) ! . click (); 140assertEquals ( order (), [ `#/ ${ newer } ` , `#/ ${ older } ` , `#/ ${ firstFork } ` , `#/ ${ secondFork } ` ]); 141server . snapshot ( 142"conversations" , 143Object . fromEntries ( 144Object . entries ( index ). map (([ tid , info ]) => [ tid , { ...info , archived :now }]), 145), 146); 147assertEquals ( order (), []); 148document . querySelector < HTMLButtonElement >( ".archive-toggle" ) ! . click (); 149assertEquals ( order (), [ `#/ ${ newer } ` , `#/ ${ older } ` , `#/ ${ firstFork } ` , `#/ ${ secondFork } ` ]); 150Object . defineProperty ( window . navigator , "maxTouchPoints" , { configurable :true , value :5 }); 151const item = document . querySelector ( `a[href="#/ ${ older } "]` ) ! . parentElement ! ; 152const link = item . querySelector ( "a" ) ! ; 153const editor = item . querySelector < HTMLInputElement >( ".conversation-name" ) ! ; 154link . dispatchEvent ( new window . MouseEvent ( "dblclick" ) as unknown as MouseEvent ); 155assertEquals ( editor . hidden , true ); 156const actions = item . querySelector < HTMLDetailsElement >( ".conversation-actions" ) ! ; 157const more = actions . querySelector ( "summary" ) ! ; 158more . click (); 159const menuButtons = actions . querySelectorAll < HTMLButtonElement >( "button" ); 160menuButtons [ 0 ]. click (); 161assertEquals ( actions . open , false ); 162assertEquals ( editor . hidden , false ); 163assertEquals ( document . activeElement , editor ); 164press ( editor , "Escape" ); 165more . click (); 166menuButtons [ 1 ]. click (); 167assertEquals ( actions . open , false ); 168assertEquals ( server . changes (). at ( - 1 ), { 169method :"PATCH" , 170path :`/api/conversations/ ${ older } ` , 171body :{ archived :false }, 172}); 173server . snapshot ( "conversations" , index ); 174document . querySelector < HTMLButtonElement >( ".archive-toggle" ) ! . click (); 175const activeActions = document . querySelector ( `a[href="#/ ${ older } "]` ) ! . parentElement ! 176. querySelector < HTMLDetailsElement >( ".conversation-actions" ) ! ; 177activeActions . querySelector ( "summary" ) ! . click (); 178activeActions . querySelectorAll < HTMLButtonElement >( "button" )[ 1 ]. click (); 179assertEquals ( server . changes (). at ( - 1 ), { 180method :"PATCH" , 181path :`/api/conversations/ ${ older } ` , 182body :{ archived :true }, 183}); 184await settle (); 185scope . dispose (); 186assertEquals ( clockCleared , true ); 187}, { globals :clock }); 188} finally { 189Date . now = originalNow ; 190} 191}); 192 193Deno . test ( "a row's menu closes once the reader goes elsewhere" , async () => { 194await withBerry ( async ({ window, server, scope}) => { 195const { app} = await import ( "./app.tsx" ); 196document . body . append ( app ( scope )); 197const info = { id :1 , created :1 }; 198server . snapshot ( "conversations" , { aaaaaaaaaaaaa :info , bbbbbbbbbbbbb :{ ...info , id :2 } }); 199const [ first , second ] = [ ...document . querySelectorAll < HTMLDetailsElement >( 200".conversation-actions" , 201)]; 202const pointAt = ( target :Element ) => 203target . dispatchEvent ( 204new window . PointerEvent ( "pointerdown" , { bubbles :true }) as unknown as PointerEvent , 205); 206first . open = true ; 207pointAt ( first . querySelector ( "button" ) ! ); 208assertEquals ( first . open , true ); 209pointAt ( second . querySelector ( "summary" ) ! ); 210assertEquals ( first . open , false ); 211first . open = true ; 212document . querySelector < HTMLAnchorElement >( ".conversations a" ) ! . focus (); 213assertEquals ( first . open , false ); 214}); 215}); 216 217Deno . test ( "a busy background subagent nests under its parent and is gone once idle" , async () => { 218const grandparent = "aaaaaaaaaaaaa" ; 219const parent = "bbbbbbbbbbbbb" ; 220const fork = "ccccccccccccc" ; 221const child = "ddddddddddddd" ; 222const elsewhere = "eeeeeeeeeeeee" ; 223const summary = { 224environment :"local" , 225cwd :"/work/berry" , 226busy :false , 227unread :false , 228updated :1 , 229preview :"hello" , 230}; 231// the selected subagent opens the way to itself, through its owner's fork chain 232await withBerry ( async ({ server, scope}) => { 233const { app} = await import ( "./app.tsx" ); 234document . body . append ( app ( scope )); 235server . snapshot ( "projects" , { 236berry :{ 237name :"Berry" , 238defaultRoot :0 , 239roots :[{ environment :"local" , cwd :"/work/berry" , platform :"linux" }], 240}, 241other :{ 242name :"Other" , 243defaultRoot :0 , 244roots :[{ environment :"local" , cwd :"/work/other" , platform :"linux" }], 245}, 246}); 247server . snapshot ( "summaries" , { 248[ grandparent ] :summary , 249[ parent ] :summary , 250[ fork ] :summary , 251[ child ] :{ ...summary , busy :true }, 252[ elsewhere ] :{ ...summary , cwd :"/work/other" , busy :true }, 253}); 254server . snapshot ( "conversations" , { 255[ grandparent ] :{ id :1 , created :1 }, 256[ parent ] :{ id :2 , created :2 , parent :{ tid :grandparent , at :1 } }, 257[ fork ] :{ id :3 , created :3 , parent :{ tid :parent , at :1 } }, 258[ child ] :{ id :4 , created :4 , owner :parent }, 259[ elsewhere ] :{ id :5 , created :5 , owner :parent }, 260}); 261const listed = () => 262[ ...document . querySelectorAll ( ".conversations .item > a[href]:not(.parent-link)" )]. map (( a ) => 263a . getAttribute ( "href" ) 264); 265const depthOf = ( tid :string ) => 266document . querySelector ( `.conversations a[href="#/ ${ tid } "]` ) ! . parentElement ! 267. style . getPropertyValue ( "--depth" ); 268const chevronOf = ( tid :string ) => 269document . querySelector ( `.conversations a[href="#/ ${ tid } "]` ) ! . parentElement ! 270. querySelector < HTMLButtonElement >( ".chevron" ) ! ; 271const delegated = ( tid :string ) => 272document . querySelector ( `.conversations a[href="#/ ${ tid } "]` ) ! . parentElement ! 273. querySelector ( '[aria-label="subagent"]' ); 274// nested under the parent without needing to expand it, and the chevron stays honest 275assertEquals ( listed (), [ 276`#/ ${ grandparent } ` , 277`#/ ${ parent } ` , 278`#/ ${ fork } ` , 279`#/ ${ child } ` , 280`#/ ${ elsewhere } ` , 281]); 282assertEquals ( depthOf ( child ), "2" ); 283// a subagent reads as delegated, where a fork keeps its chevron 284assertEquals ( delegated ( child )?. textContent , "↳" ); 285assertEquals ( delegated ( fork ), null ); 286assertEquals ( delegated ( parent ), null ); 287assertEquals ( chevronOf ( grandparent ). title , "1 fork" ); 288assertStringIncludes ( chevronOf ( parent ). title , "1 fork" ); 289assertStringIncludes ( chevronOf ( parent ). title , "1 live subagent" ); 290// a subagent owned across projects shows as a root with its parent link 291assertEquals ( 292document . querySelector ( '[data-project="other"] .project-count' ) ! . textContent , 293"" , 294); 295const away = document . querySelector ( `.conversations a[href="#/ ${ elsewhere } "]` ) ! . parentElement ! ; 296assertEquals ( depthOf ( elsewhere ), "0" ); 297assertEquals ( 298away . querySelector < HTMLAnchorElement >( ".parent-link" ) ! . getAttribute ( "href" ), 299`#/ ${ parent } ` , 300); 301// collapsing hides the fork but not the live subagent, and the count stays forks-only 302chevronOf ( parent ). click (); 303assertEquals ( listed (), [ `#/ ${ grandparent } ` , `#/ ${ parent } ` , `#/ ${ child } ` , `#/ ${ elsewhere } ` ]); 304assertEquals ( 305document . querySelector ( `.conversations a[href="#/ ${ parent } "]` ) ! . parentElement ! 306. querySelector ( ".forks" ) ! . textContent , 307"+1" , 308); 309// group counts leave the transient work out 310assertEquals ( document . querySelector ( '[data-project="berry"] .project-count' ) ! . textContent , "3" ); 311// once idle the subagent is gone, with no lingering row or count 312server . snapshot ( "summaries" , { 313[ grandparent ] :summary , 314[ parent ] :summary , 315[ fork ] :summary , 316[ child ] :summary , 317[ elsewhere ] :{ ...summary , cwd :"/work/other" }, 318}); 319assertEquals ( listed (), [ `#/ ${ grandparent } ` , `#/ ${ parent } ` ]); 320assert ( ! document . querySelector ( `.conversations a[href="#/ ${ child } "]` )); 321assertEquals ( chevronOf ( parent ). title , "1 fork" ); 322assert ( ! document . querySelector ( `.conversations a[href="#/ ${ elsewhere } "]` )); 323chevronOf ( parent ). click (); 324assertEquals ( listed (), [ `#/ ${ grandparent } ` , `#/ ${ parent } ` , `#/ ${ fork } ` ]); 325// and the archive never lists them 326server . snapshot ( "conversations" , { 327[ grandparent ] :{ id :1 , created :1 }, 328[ parent ] :{ id :2 , created :2 , parent :{ tid :grandparent , at :1 }, archived :2 }, 329[ fork ] :{ id :3 , created :3 , parent :{ tid :parent , at :1 } }, 330[ child ] :{ id :4 , created :4 , owner :parent }, 331[ elsewhere ] :{ id :5 , created :5 , owner :parent }, 332}); 333assert ( ! document . querySelector ( `.conversations a[href="#/ ${ parent } "]` )); 334assertEquals ( document . querySelector ( ".archive-toggle" ) ! . textContent , "archived (1)" ); 335document . querySelector < HTMLButtonElement >( ".archive-toggle" ) ! . click (); 336document . querySelector < HTMLButtonElement >( '[data-project="berry"] .project-toggle' ) ! . click (); 337assertEquals ( listed (), [ `#/ ${ parent } ` , `#/ ${ fork } ` ]); 338assertEquals ( document . querySelector ( ".archive-toggle" ) ! . textContent , "back to conversations" ); 339}, { url :"http://berry.test/#/ddddddddddddd" }); 340});