okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
52bd519
main
1//// A broken extension must cost only itself. Albedo loads third-party 2//// extensions into the same session as its own, and a plugin that fails or 3//// raises used to take the whole session with it: no tools, no turn, no 4//// reply, and for a tool error a dead subagent. E2E cannot reach this, 5//// because the daemon only loads albedo's own registry, so the rule is 6//// checked here against deliberately broken extensions. 7 8import albedo/daemon/conversation 9import albedo/daemon/family 10import albedo/daemon/store 11import albedo/harness/extension 12import albedo/harness/extension/composition 13import albedo/harness/extensions 14import albedo/harness/runtime 15import albedo/harness/tool 16import albedo/openai_api/types 17import gleam/dynamic/decode 18import gleam/erlang/process 19import gleam/json 20import gleam/list 21import gleam/option .{ None , Some } 22import gleam/string 23import gleeunit/should 24 25/// A session over `installed`, every one of them enabled. 26fn host ( 27installed : List ( extension . Extension ), 28) -> #( runtime . Runtime , runtime . Session ) { 29let assert Ok ( host ) = 30runtime . start_with_config ( 31":memory:" , 32extensions . Config ( 33installed , 34list . map ( installed , fn ( extension ) { extension . name }), 35), 36) 37let ledger = runtime . ledger ( host ) 38let assert Ok ( _ ) = conversation . initialise ( ledger ) 39let assert Ok ( _ ) = family . initialise ( ledger ) 40let assert Ok ( _ ) = 41conversation . create ( 42ledger , 43conversation . Info ( 44"broken-extension-test" , 45"new session" , 46"/tmp" , 47"provider" , 48"model" , 49types . Responses , 50conversation . Idle , 51None , 52None , 53), 54) 55let assert Ok ( session ) = 56runtime . open_session ( host , "broken-extension-test" , "/tmp" ) 57#( host , session ) 58} 59 60/// An extension whose only tool answers, named after it. 61fn healthy ( name : String ) -> extension . Extension { 62extension . Extension ( 63name , 64"answers" , 65[], 66[ extension . ToolPlugin ( "" , [ answering ( name )], [], [])], 67extension . no_initialise , 68) 69} 70 71fn answering ( name : String ) -> extension . Tool { 72tool . text ( 73name , 74"answers" , 75False , 76[], 77[], 78decode . success ( Nil ), 79"usage" , 80fn ( _ , _ ) { Ok ( "answered" ) }, 81) 82} 83 84fn tool_only ( name : String , value : extension . Tool ) -> extension . Extension { 85extension . Extension ( 86name , 87"one tool" , 88[], 89[ extension . ToolPlugin ( "" , [ value ], [], [])], 90extension . no_initialise , 91) 92} 93 94fn plugin_only ( name : String , plugin : extension . Plugin ) -> extension . Extension { 95extension . Extension ( name , "one plugin" , [], [ plugin ], extension . no_initialise ) 96} 97 98fn tool_names ( session : runtime . Session ) -> List ( String ) { 99runtime . tools ( session ) |> list . map ( fn ( tool ) { tool . name }) 100} 101 102fn warned_about ( session : runtime . Session , name : String ) -> Bool { 103runtime . warnings ( session ) 104|> list . any ( fn ( warning ) { string . contains ( warning , name ) }) 105} 106 107fn answer ( 108host : runtime . Runtime , 109session : runtime . Session , 110name : String , 111) -> String { 112let assert Ok ( types . ToolOutput ( _ , body , _ )) = 113runtime . invoke ( 114host , 115session , 116types . ToolCall ( "call_1" , name , "{}" ), 117types . any_images , 118) 119body 120} 121 122pub fn a_failed_or_crashed_context_load_keeps_the_session_test () -> Nil { 123let #( host , session ) = 124host ([ 125plugin_only ( "sulky" , extension . ContextPlugin ( fn ( _ ) { Error ( "no root" ) })), 126plugin_only ( "exploding" , extension . ContextPlugin ( fn ( _ ) { panic as "bug" })), 127healthy ( "working" ), 128]) 129tool_names ( session ) |> should . equal ([ "working" ]) 130warned_about ( session , "sulky" ) |> should . be_true 131warned_about ( session , "exploding" ) |> should . be_true 132runtime . stop ( host ) 133} 134 135pub fn a_failed_or_crashed_prepare_keeps_the_session_test () -> Nil { 136let #( host , session ) = 137host ([ 138plugin_only ( 139"sulky" , 140extension . ManagedPlugin ( fn ( _ , _ , _ ) { Error ( "no server" ) }), 141), 142plugin_only ( 143"exploding" , 144extension . ManagedPlugin ( fn ( _ , _ , _ ) { panic as "bug" }), 145), 146healthy ( "working" ), 147]) 148tool_names ( session ) |> should . equal ([ "working" ]) 149warned_about ( session , "sulky" ) |> should . be_true 150warned_about ( session , "exploding" ) |> should . be_true 151runtime . stop ( host ) 152} 153 154/// A static collision is refused when the extension is selected; one a 155/// managed plugin only discovers while preparing, as an MCP server's tool 156/// list does, reaches the session and must lose there. 157pub fn a_colliding_extension_loses_only_itself_test () -> Nil { 158let #( host , session ) = 159host ([ 160healthy ( "echo" ), 161plugin_only ( 162"squatter" , 163extension . ManagedPlugin ( fn ( _ , _ , _ ) { 164Ok ( extension . Managed (.. extension . empty (), tools : [ answering ( "echo" )])) 165}), 166), 167]) 168tool_names ( session ) |> should . equal ([ "echo" ]) 169warned_about ( session , "squatter" ) |> should . be_true 170answer ( host , session , "echo" ) |> should . equal ( "answered" ) 171runtime . stop ( host ) 172} 173 174pub fn a_crashing_tool_answers_instead_of_ending_the_turn_test () -> Nil { 175let #( host , session ) = 176host ([ 177tool_only ( 178"exploding" , 179extension . Tool ( 180types . Tool ( "boom" , "crashes" , tool . schema ([], []), False ), 181fn ( _ , _ ) { panic as "tool bug" }, 182fn ( _ ) { None }, 183), 184), 185]) 186let reason = error_of ( answer ( host , session , "boom" )) 187string . contains ( reason , "boom crashed" ) |> should . be_true 188string . contains ( reason , "inspect" ) |> should . be_true 189runtime . stop ( host ) 190} 191 192pub fn a_fatal_tool_still_ends_the_turn_test () -> Nil { 193let #( host , session ) = 194host ([ 195tool_only ( 196"strict" , 197extension . Tool ( 198types . Tool ( "record" , "cannot record" , tool . schema ([], []), False ), 199fn ( _ , _ ) { Error ( extension . Fatal ( "transcript storage failed" )) }, 200fn ( _ ) { None }, 201), 202), 203]) 204runtime . invoke ( 205host , 206session , 207types . ToolCall ( "call_1" , "record" , "{}" ), 208types . any_images , 209) 210|> should . equal ( Error ( "transcript storage failed" )) 211runtime . stop ( host ) 212} 213 214pub fn a_crashing_observer_leaves_the_session_working_test () -> Nil { 215let #( host , session ) = 216host ([ 217plugin_only ( 218"exploding" , 219extension . ManagedPlugin ( fn ( _ , _ , _ ) { 220Ok ( 221extension . Managed (.. extension . empty (), observe : fn ( _ , _ ) { 222panic as "observer bug" 223}), 224) 225}), 226), 227healthy ( "working" ), 228]) 229runtime . observe ( 230session , 231extension . Session ( 232"broken-extension-test" , 233fn ( _ , _ ) { Error ( "no upstream" ) }, 234fn ( _ ) { Nil }, 235fn () { False }, 236), 237extension . Stirred , 238) 239answer ( host , session , "working" ) |> should . equal ( "answered" ) 240runtime . stop ( host ) 241} 242 243fn error_of ( body : String ) -> String { 244let assert Ok ( reason ) = 245json . parse ( body , decode . field ( "error" , decode . string , decode . success )) 246reason 247} 248 249/// An extension whose initialiser `fails`, for the quarantine cases. 250fn uninstallable ( name : String , fails : fn () -> Nil ) -> extension . Extension { 251extension . Extension ( 252name , 253"will not install" , 254[], 255[ extension . ToolPlugin ( "" , [ answering ( name )], [], [])], 256fn ( _ ) { 257fails () 258Error ( "no tables" ) 259}, 260) 261} 262 263fn summary ( 264host : runtime . Runtime , 265name : String , 266) -> Result ( extension . Summary , Nil ) { 267let assert Ok ( summaries ) = 268runtime . extension_summaries ( host , "broken-extension-test" ) 269list . find ( summaries , fn ( summary ) { summary . name == name }) 270} 271 272pub fn an_extension_that_cannot_install_is_quarantined_test () -> Nil { 273let #( host , session ) = 274host ([ 275uninstallable ( "sulky" , fn () { Nil }), 276uninstallable ( "exploding" , fn () { panic as "initialiser bug" }), 277healthy ( "working" ), 278]) 279tool_names ( session ) |> should . equal ([ "working" ]) 280let assert Ok ( extension . Summary ( quarantined : Some ( reason ), enabled : False , ..)) = 281summary ( host , "sulky" ) 282string . contains ( reason , "no tables" ) |> should . be_true 283let assert Ok ( extension . Summary ( quarantined : Some ( crash ), ..)) = 284summary ( host , "exploding" ) 285string . contains ( crash , "initialiser bug" ) |> should . be_true 286runtime . stop ( host ) 287} 288 289pub fn a_quarantined_requirement_takes_its_dependents_with_it_test () -> Nil { 290let #( host , session ) = 291host ([ 292uninstallable ( "engine" , fn () { Nil }), 293extension . Extension ( 294"rider" , 295"needs the engine" , 296[ "engine" ], 297[ extension . ToolPlugin ( "" , [ answering ( "rider" )], [], [])], 298extension . no_initialise , 299), 300healthy ( "working" ), 301]) 302tool_names ( session ) |> should . equal ([ "working" ]) 303let assert Ok ( extension . Summary ( quarantined : Some ( reason ), ..)) = 304summary ( host , "rider" ) 305string . contains ( reason , "it requires engine" ) |> should . be_true 306runtime . stop ( host ) 307} 308 309pub fn a_quarantined_extension_cannot_be_enabled_test () -> Nil { 310let #( host , _ ) = 311host ([ uninstallable ( "sulky" , fn () { Nil }), healthy ( "working" )]) 312let assert Error ( refusal ) = 313runtime . reload_extension ( 314host , 315"broken-extension-test" , 316"/tmp" , 317"sulky" , 318True , 319) 320string . contains ( refusal , "quarantined" ) |> should . be_true 321runtime . stop ( host ) 322} 323 324/// Mixed plugins and transitive failures require synthetic registry entries; 325/// the daemon only installs its compiled registry. 326pub fn a_failed_bundle_removes_all_capabilities_and_dependents_test () -> Nil { 327let assert Ok ( ledger ) = store . start ( ":memory:" , "" ) 328let closed = process . new_subject () 329let managed = fn ( name ) { 330extension . ManagedPlugin ( fn ( _ , _ , _ ) { 331Ok ( 332extension . Managed ( 333.. extension . empty (), 334tools : [ answering ( name )], 335close : fn () { process . send ( closed , name ) }, 336), 337) 338}) 339} 340let extensions = [ 341extension . Extension ( 342"dependent" , 343"dependent" , 344[ "broken" ], 345[ managed ( "dependent" )], 346extension . no_initialise , 347), 348extension . Extension ( 349"transitive" , 350"transitive" , 351[ "dependent" ], 352[ managed ( "transitive" )], 353extension . no_initialise , 354), 355extension . Extension ( 356"broken" , 357"mixed" , 358[], 359[ 360extension . ToolPlugin ( "must disappear" , [ answering ( "static" )], [], []), 361managed ( "prepared" ), 362extension . ManagedPlugin ( fn ( _ , _ , _ ) { Error ( "prepare refused" ) }), 363], 364extension . no_initialise , 365), 366healthy ( "working" ), 367] 368let composed = composition . compose ( extensions , ledger , "s" , "/tmp" ) 369composition . tools ( composed ) 370|> list . map ( fn ( tool ) { tool . definition . name }) 371|> should . equal ([ "working" ]) 372composition . extensions ( composed ) 373|> list . map ( fn ( item ) { item . name }) 374|> should . equal ([ "working" ]) 375composition . requested ( composed ) |> list . length |> should . equal ( 4 ) 376composition . inactive ( composed ) |> list . length |> should . equal ( 3 ) 377composition . instructions ( composed ) |> should . equal ( "" ) 378process . receive ( closed , 100 ) |> should . equal ( Ok ( "prepared" )) 379process . receive ( closed , 100 ) |> should . equal ( Ok ( "transitive" )) 380process . receive ( closed , 100 ) |> should . equal ( Ok ( "dependent" )) 381composition . close ( composed ) 382process . receive ( closed , 0 ) |> should . equal ( Error ( Nil )) 383store . close ( ledger ) 384} 385 386pub fn context_failure_never_prepares_the_rest_of_the_bundle_test () -> Nil { 387let assert Ok ( ledger ) = store . start ( ":memory:" , "" ) 388let prepared = process . new_subject () 389let composed = 390composition . compose ( 391[ 392extension . Extension ( 393"broken" , 394"context" , 395[], 396[ 397extension . ContextPlugin ( fn ( _ ) { Error ( "context refused" ) }), 398extension . ManagedPlugin ( fn ( _ , _ , _ ) { 399process . send ( prepared , Nil ) 400Ok ( extension . empty ()) 401}), 402], 403extension . no_initialise , 404), 405], 406ledger , 407"s" , 408"/tmp" , 409) 410composition . extensions ( composed ) |> should . equal ([]) 411process . receive ( prepared , 0 ) |> should . equal ( Error ( Nil )) 412composition . close ( composed ) 413store . close ( ledger ) 414} 415 416pub fn registry_order_resolves_whole_bundle_collisions_test () -> Nil { 417let assert Ok ( ledger ) = store . start ( ":memory:" , "" ) 418let closed = process . new_subject () 419let composed = 420composition . compose ( 421[ 422plugin_only ( 423"first" , 424extension . ManagedPlugin ( fn ( _ , _ , _ ) { 425Ok ( 426extension . Managed (.. extension . empty (), tools : [ 427answering ( "claimed" ), 428]), 429) 430}), 431), 432extension . Extension ( 433"loser" , 434"mixed" , 435[], 436[ 437extension . ToolPlugin ( 438"" , 439[ answering ( "claimed" ), answering ( "extra" )], 440[], 441[], 442), 443extension . ManagedPlugin ( fn ( _ , _ , _ ) { 444Ok ( 445extension . Managed (.. extension . empty (), close : fn () { 446process . send ( closed , Nil ) 447}), 448) 449}), 450], 451extension . no_initialise , 452), 453], 454ledger , 455"s" , 456"/tmp" , 457) 458composition . tools ( composed ) 459|> list . map ( fn ( tool ) { tool . definition . name }) 460|> should . equal ([ "claimed" ]) 461composition . extensions ( composed ) 462|> list . map ( fn ( item ) { item . name }) 463|> should . equal ([ "first" ]) 464process . receive ( closed , 100 ) |> should . equal ( Ok ( Nil )) 465composition . close ( composed ) 466process . receive ( closed , 0 ) |> should . equal ( Error ( Nil )) 467store . close ( ledger ) 468}