okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
5bdc0ee
main
1"""Agent families: spawn, forwarding, addressing, snapshots, and routing.""" 2 3import http .client 4import json 5import socket 6import sqlite3 7import tempfile 8import threading 9import time 10import unittest 11import urllib .error 12import urllib .request 13import urllib .parse 14from pathlib import Path 15 16from harness import Albedo ,Provider ,exclusive ,latest_user ,operation_id ,python ,text 17from stream_support import StreamProbe 18 19 20def wait_for (predicate ,timeout = 40 ): 21deadline = time .monotonic ()+ timeout 22while time .monotonic ()< deadline : 23value = predicate () 24if value : 25return value 26time .sleep (0.05 ) 27raise AssertionError ("agent event never arrived" ) 28 29 30class AgentsTests (unittest .TestCase ): 31def setUp (self ): 32def answer (request ): 33user = latest_user (request ) 34line = user .splitlines ()[- 2 if "</mail>" in user else - 1 ] 35return text ("answered: " + line ) 36 37self .provider = Provider (answer ) 38self .alpha = f"alpha- { self . provider . route } " 39self .beta = f"beta- { self . provider . route } " 40providers = { 41profile : { 42"extension" :"openai" , 43"baseUrl" :self .provider .url + f"/ { name } /v1" , 44"apiKey" :"key" , 45"model" :f"fixture- { name } - { self . provider . route } " , 46"protocol" :"chat_completions" , 47 } 48for profile ,name in ((self .alpha ,"alpha" ), (self .beta ,"beta" )) 49 } 50 51# The cached catalog lists a model for the fixture endpoint that no profile names. 52# models.json is daemon-wide, so only the exclusive catalog test writes it. 53def prepare (app ): 54if ( 55self ._testMethodName 56== "test_captured_subtree_deletion_blocks_new_child_admission" 57 ): 58app .env ["ALBEDO_INSPECT" ]= "1" 59return 60 (app .home / "models.json" ).write_text ( 61json .dumps ( 62 { 63"fixture-gateway" : { 64"api" :"http://127.0.0.1/v1" , 65"env" : [], 66"models" : {"fixture-listed" : {"id" :"fixture-listed" }}, 67 } 68 } 69 ) 70 ) 71 72catalog = ( 73self ._testMethodName 74== "test_generic_profile_lists_its_endpoints_catalog_models" 75 ) 76self .app = Albedo ( 77self .provider , 78providers = providers , 79prepare = prepare 80if catalog 81or self ._testMethodName 82== "test_captured_subtree_deletion_blocks_new_child_admission" 83else None , 84 ) 85self .app .__enter__ () 86self .addCleanup (self .provider .close ) 87self .addCleanup (self .app .__exit__ ,None ,None ,None ) 88 89def api (self ,path ,body = None ,** options ): 90with self .app .api (path ,body ,** options )as response : 91return json .load (response ) 92 93def snapshot (self ,session ): 94return self .api (f"/sessions/ { session } ?tail=0" ) 95 96def change (self ,session ,patch ): 97with self .app .api (f"/sessions/ { session } ?view=configuration" )as response : 98json .load (response ) 99revision = response .headers ["ETag" ] 100return self .api ( 101f"/sessions/ { session } ?view=configuration" , 102patch , 103method = "PATCH" , 104headers = {"If-Match" :revision }, 105 )["session" ] 106 107def move (self ,session ,workspace ): 108snapshot = self .snapshot (session ) 109return self .change ( 110session , 111 { 112"workspace" :str (workspace ), 113"family_revision" :snapshot ["family_revision" ], 114 }, 115 ) 116 117def family (self ,session ): 118root = self .snapshot (session )["root_id" ] 119return self .api (f"/sessions?scope=all&family_id= { root } " )["items" ] 120 121def asked (self ,fragment ): 122return [ 123content 124for record in self .provider .requests 125for item in record ["request" ].get ("messages" , []) 126if item .get ("role" )== "user" 127for content in [item .get ("content" ,"" )] 128if isinstance (content ,str )and fragment in content 129 ] 130 131def spawn (self ,parent ,name = "coder" ,task = "map every wake path" ,model = None ): 132session = operation_id () 133body = { 134"kind" :"child" , 135"parent_id" :parent , 136"address" :name , 137"name" :name , 138"initial_input_id" :operation_id (), 139"task" :task , 140 } 141if model is not None : 142body ["model" ]= model 143return self .api ( 144f"/sessions/ { session } " ,body ,method = "PUT" ,headers = {"If-None-Match" :"*" } 145 ) 146 147def send_mail (self ,sender ,target ,body ,* ,rejected = False ): 148marker = "dispatch fixture mail " + operation_id () 149answer = self .provider .script 150 151def dispatch (request ): 152if ( 153request ["messages" ][- 1 ].get ("role" )== "user" 154and latest_user (request )== marker 155 ): 156return python ( 157"import json\n" 158f"receipt = await mail.submit( { target !r } , { body !r } )\n" 159"print(json.dumps(receipt))" 160 ) 161return answer (request ) 162 163self .provider .script = dispatch 164try : 165self .send (sender ,marker ) 166self .app .idle (sender ) 167finally : 168self .provider .script = answer 169 [result ]= [ 170entry 171for entry in self .app .history (sender )["items" ] 172if entry ["kind" ]== "tool_result" 173 ][- 1 :] 174execution = next ( 175json .loads (part ["value" ]) 176for part in result ["content" ] 177if part ["kind" ]== "json" and part ["field" ]== "result" 178 ) 179self .assertEqual (execution ["status" ],"error" if rejected else "ok" ,execution ) 180return execution if rejected else json .loads (execution ["output" ]) 181 182def send (self ,session ,message ): 183return self .api ( 184f"/sessions/ { session } /inputs/ { operation_id () } " , 185 {"kind" :"message" ,"text" :message }, 186method = "PUT" , 187 ) 188 189def listen (self ): 190heard = [] 191ready ,stopped = threading .Event (),threading .Event () 192address = urllib .parse .urlsplit (self .app .base ) 193connection = http .client .HTTPConnection ( 194address .hostname ,address .port ,timeout = 30 195 ) 196 197def consume (): 198try : 199connection .request ( 200"GET" , 201"/sessions?scope=all" , 202headers = { 203"Authorization" :"Bearer " + self .app .connection ["token" ], 204"Accept" :"text/event-stream" , 205 }, 206 ) 207with connection .getresponse ()as response : 208for raw in response : 209if raw .startswith (b"data: " ): 210heard .extend (json .loads (raw [6 :])["events" ]) 211ready .set () 212except (OSError ,ValueError ): 213if not stopped .is_set (): 214raise 215 216thread = threading .Thread (target = consume ,daemon = True ) 217thread .start () 218self .assertTrue (ready .wait (timeout = 5 ),"collection stream did not send ready" ) 219 220def close (): 221stopped .set () 222if connection .sock is not None : 223connection .sock .shutdown (socket .SHUT_RDWR ) 224thread .join (timeout = 2 ) 225connection .close () 226self .assertFalse (thread .is_alive (),"collection reader did not stop" ) 227 228self .addCleanup (close ) 229return heard 230 231def test_spawn_task_and_unreviewed_forward (self ): 232parent = self .app .session () 233child = self .spawn (parent ) 234self .assertEqual ( 235 (child ["parent_id" ],child ["address" ],child ["depth" ],child ["closed" ]), 236 (parent ,"coder" ,1 ,False ), 237 ) 238self .assertEqual (child ["name" ],"coder" ) 239resource = self .api (f"/sessions/ { child [ 'id' ] } " ) 240self .assertEqual ( 241resource ["creation" ]["resolved" ], 242 { 243key :resource [key ] 244for key in ("workspace" ,"provider_profile" ,"model" ,"effort" ) 245 }, 246 ) 247self .assertNotIn ( 248child ["id" ], [item ["id" ]for item in self .api ("/sessions" )["items" ]] 249 ) 250self .app .idle (child ["id" ]) 251task = self .asked ("map every wake path" )[0 ] 252self .assertIn ('kind="task"' ,task ) 253self .assertIn (f'session=" { parent } "' ,task ) 254self .assertIn ("map every wake path" ,task ) 255forwarded = wait_for (lambda :self .asked ('kind="unreviewed"' ))[0 ] 256self .assertIn ('from="coder"' ,forwarded ) 257self .assertIn ("answered: map every wake path" ,forwarded ) 258self .app .idle (parent ) 259self .app .idle (child ["id" ]) 260self .assertEqual ( 261 [ 262item ["address" ] 263for item in self .api (f"/sessions?scope=all&parent_id= { parent } " )["items" ] 264 ], 265 ["coder" ], 266 ) 267 268@exclusive 269def test_mail_attribution_and_agent_card_survive_reconnect_and_restart (self ): 270parent = self .app .session () 271self .change (parent , {"name" :"lead desk" }) 272child = self .spawn (parent ,task = "map wake paths" )["id" ] 273self .app .idle (child ) 274self .app .idle (parent ) 275task = next ( 276entry 277for entry in self .app .history (child )["items" ] 278if (entry .get ("mail" )or {}).get ("kind" )== "task" 279 ) 280self .assertEqual (task ["turn_type" ],"agent" ) 281self .assertEqual (task ["mail" ]["sender_label" ],"lead desk" ) 282self .assertEqual (task ["mail" ]["sender_session_id" ],parent ) 283self .assertEqual (task ["input_id" ],task ["mail" ]["mail_id" ]) 284self .assertEqual (task ["content" ][0 ]["text" ],"map wake paths" ) 285# Retrying creation must not admit the initial task twice. 286with self .assertRaises (urllib .error .HTTPError )as repeated : 287self .api ( 288f"/sessions/ { child } " , 289 { 290"kind" :"child" , 291"parent_id" :parent , 292"address" :"coder" , 293"name" :"coder" , 294"initial_input_id" :task ["input_id" ], 295"task" :"map wake paths" , 296 }, 297method = "PUT" , 298headers = {"If-None-Match" :"*" }, 299 ) 300self .assertEqual (repeated .exception .code ,412 ) 301self .assertEqual ( 302sum ( 303entry ["id" ]== task ["id" ]for entry in self .app .history (child )["items" ] 304 ), 3051 , 306 ) 307self .assertEqual ( 308self .snapshot (child )["activity" ]["current_request" ]["text" ], 309"map wake paths" , 310 ) 311before = self .app .stream_page (child ) 312answer = self .provider .script 313marker = "report bounded progress" 314 315def progress (request ): 316if ( 317request ["messages" ][- 1 ].get ("role" )== "user" 318and latest_user (request )== marker 319 ): 320return python ('await agents.progress("tracing delivery")' ) 321return answer (request ) 322 323self .provider .script = progress 324self .send (child ,marker ) 325self .app .idle (child ) 326self .app .idle (parent ) 327self .provider .script = answer 328self .assertEqual ( 329self .snapshot (child )["activity" ]["latest_progress" ],"tracing delivery" 330 ) 331# A new subscriber sees progress in its authoritative reset snapshot. 332reset = self .app .stream_page (child ) 333self .assertEqual ( 334reset ["snapshot" ]["activity" ]["latest_progress" ],"tracing delivery" 335 ) 336receipt = self .send_mail (parent ,child ,"also cover replay" ) 337self .app .idle (child ) 338self .app .idle (parent ) 339activity = self .snapshot (child )["activity" ] 340self .assertEqual ( 341activity ["current_request" ], 342 {"input_id" :receipt ["id" ],"text" :"also cover replay" }, 343 ) 344self .assertIsNone (activity ["latest_progress" ]) 345history = self .app .history (child )["items" ] 346followup = next ( 347entry for entry in history if entry ["input_id" ]== receipt ["id" ] 348 ) 349live = self .app .stream_page (child ,before ) 350self .assertEqual ( 351 [ 352event ["data" ]["entry" ] 353for event in live ["events" ] 354if event ["type" ]== "message" 355and event ["data" ]["entry" ]["id" ]== followup ["id" ] 356 ], 357 [followup ], 358 ) 359# Peer messages remain visible but cannot replace the parent's request. 360peer = self .app .session () 361self .send_mail (peer ,child ,"peer observation" ) 362self .app .idle (child ) 363self .app .idle (parent ) 364self .assertEqual ( 365self .snapshot (child )["activity" ]["current_request" ], 366activity ["current_request" ], 367 ) 368forwarded = next ( 369entry 370for entry in self .app .history (parent )["items" ] 371if (entry .get ("mail" )or {}).get ("kind" )== "unreviewed" 372 ) 373self .assertEqual (forwarded ["mail" ]["sender_label" ],"coder" ) 374self .app .restart () 375recovered = self .snapshot (child )["activity" ] 376self .assertEqual (recovered ["current_request" ],activity ["current_request" ]) 377self .assertIsNone (recovered ["latest_progress" ]) 378replayed = self .app .stream_page (child )["snapshot" ]["history" ]["items" ] 379self .assertEqual ( 380next (entry for entry in replayed if entry ["id" ]== followup ["id" ]),followup 381 ) 382 383def test_tree_snapshot_and_event_stream (self ): 384parent = self .app .session () 385heard = self .listen () 386child = self .spawn (parent )["id" ] 387wait_for (lambda :self .asked ('kind="unreviewed"' )) 388for asker in (parent ,child ): 389nodes = {item ["id" ]:item for item in self .family (asker )} 390self .assertEqual (set (nodes ), {parent ,child }) 391self .assertEqual ( 392 (nodes [parent ]["parent_id" ],nodes [parent ]["depth" ]), (None ,0 ) 393 ) 394self .assertEqual ( 395 ( 396nodes [child ]["parent_id" ], 397nodes [child ]["depth" ], 398nodes [child ]["address" ], 399 ), 400 (parent ,1 ,"coder" ), 401 ) 402wait_for ( 403lambda :any ( 404event ["type" ]== "invalidate" and child in event ["data" ]["session_ids" ] 405for event in list (heard ) 406 ) 407 ) 408wait_for ( 409lambda :any ( 410event ["type" ]== "activity" and event ["data" ]["session_id" ]== child 411for event in list (heard ) 412 ) 413 ) 414wait_for ( 415lambda :any ( 416event ["type" ]== "mail" 417and event ["data" ]["receiver_session_id" ]== parent 418for event in list (heard ) 419 ) 420 ) 421 422def test_rename_keeps_family_address_and_reports_stream_event (self ): 423parent = self .app .session () 424child = self .spawn (parent )["id" ] 425self .app .idle (child ) 426wait_for (lambda :self .asked ('kind="unreviewed"' )) 427self .app .idle (parent ) 428heard = self .listen () 429self .assertEqual ( 430self .change (child , {"name" :" wake path\naudit " })["name" ], 431"wake path audit" , 432 ) 433self .assertEqual ( 434self .change (parent , {"name" :"orchestration" })["name" ],"orchestration" 435 ) 436nodes = {item ["id" ]:item for item in self .family (parent )} 437self .assertEqual ( 438 (nodes [parent ]["name" ],nodes [parent ]["address" ]), ("orchestration" ,None ) 439 ) 440self .assertEqual ( 441 (nodes [child ]["name" ],nodes [child ]["address" ]), 442 ("wake path audit" ,"coder" ), 443 ) 444wait_for ( 445lambda :any ( 446event ["type" ]== "invalidate" and child in event ["data" ]["session_ids" ] 447for event in list (heard ) 448 ) 449 ) 450self .assertEqual (self .snapshot (child )["name" ],"wake path audit" ) 451with self .assertRaises (urllib .error .HTTPError )as caught : 452self .send ("no-such-session" ,"cannot deliver" ) 453self .assertEqual (caught .exception .code ,404 ) 454receipt = self .send_mail (parent ,"coder" ,"also cover schedules" ) 455self .assertEqual ((receipt ["to" ],receipt ["name" ]), (child ,"coder" )) 456wait_for (lambda :self .asked ("also cover schedules" )) 457wait_for ( 458lambda :any ( 459"also cover schedules" in message 460for message in self .asked ('kind="unreviewed"' ) 461 ) 462 ) 463self .app .idle (parent ) 464 465def test_reply_and_external_id_mail (self ): 466parent ,radio = self .app .session (),self .app .session () 467child = self .spawn (parent )["id" ] 468wait_for (lambda :self .asked ('kind="unreviewed"' )) 469self .app .idle (parent ) 470self .app .idle (child ) 471receipt = self .send_mail (child ,"parent" ,"schedules covered" ) 472self .assertEqual (receipt ["to" ],parent ) 473wait_for (lambda :self .asked ("schedules covered" )) 474self .app .idle (parent ) 475failed = self .send_mail (child ,"radio" ,"by unrelated name" ,rejected = True ) 476self .assertIn ("radio" ,failed ["output" ]) 477self .assertFalse (self .asked ("by unrelated name" )) 478receipt = self .send_mail (child ,radio ,"deploy when green" ) 479self .assertEqual (receipt ["to" ],radio ) 480wait_for (lambda :self .asked ("deploy when green" )) 481self .app .idle (radio ) 482for recipient ,body in ( 483 (parent ,"schedules covered" ), 484 (radio ,"deploy when green" ), 485 ): 486delivered = [ 487entry 488for entry in self .app .history (recipient )["items" ] 489if entry ["kind" ]== "user" 490and any ( 491part ["kind" ]== "text" and body in part ["text" ] 492for part in entry ["content" ] 493 ) 494 ] 495self .assertEqual (len (delivered ),1 ) 496 497def test_tree_deletion_and_renamed_workspace (self ): 498parent = self .app .session () 499child = self .spawn (parent )["id" ] 500self .app .idle (child ) 501wait_for (lambda :self .asked ('kind="unreviewed"' )) 502self .app .idle (parent ) 503with self .app .api (f"/sessions/ { parent } ?view=configuration" )as response : 504configuration = json .load (response ) 505revision = response .headers ["ETag" ] 506with self .assertRaises (urllib .error .HTTPError )as caught : 507self .api ( 508f"/sessions/ { parent } ?view=configuration" , 509method = "DELETE" , 510headers = {"If-Match" :revision }, 511 ) 512self .assertEqual (caught .exception .code ,409 ) 513self .assertIn ("child" ,json .load (caught .exception )["detail" ]) 514grandchild = self .spawn (child ,"deep" )["id" ] 515self .app .idle (grandchild ) 516wait_for (lambda :len (self .asked ('kind="unreviewed"' ))>= 2 ) 517self .app .idle (child ) 518self .app .idle (parent ) 519with self .assertRaises (urllib .error .HTTPError )as caught : 520self .api ( 521f"/sessions/ { parent } ?view=configuration&scope=subtree&family_revision= { configuration [ 'family_revision' ] } " , 522method = "DELETE" , 523headers = {"If-Match" :revision }, 524 ) 525self .assertEqual (caught .exception .code ,412 ) 526entered ,release = threading .Event (),threading .Event () 527self .addCleanup (release .set ) 528answer = self .provider .script 529 530def held (request ): 531if "hold subtree deletion" in latest_user (request ): 532entered .set () 533release .wait (30 ) 534return python ( 535"from pathlib import Path\nPath('deleted-run-executed').write_text('wrong')" 536 ) 537return answer (request ) 538 539self .provider .script = held 540receipt = self .send (child ,"hold subtree deletion" ) 541self .assertTrue (entered .wait (30 )) 542with self .app .api (f"/sessions/ { parent } ?view=configuration" )as response : 543configuration = json .load (response ) 544revision = response .headers ["ETag" ] 545outcome = self .api ( 546f"/sessions/ { parent } ?view=configuration&scope=subtree&family_revision= { configuration [ 'family_revision' ] } " , 547method = "DELETE" , 548headers = {"If-Match" :revision }, 549 ) 550self .assertEqual (outcome ["state" ],"complete" ) 551self .assertEqual (outcome ["deleted_ids" ], [grandchild ,child ,parent ]) 552terminal = self .api (f"/sessions/ { child } /inputs/ { receipt [ 'id' ] } " ) 553self .assertEqual (terminal ["delivery" ],"committed" ) 554self .assertEqual (terminal ["turn" ]["state" ],"interrupted" ) 555self .assertIsNotNone (terminal ["turn" ]["ended_at" ]) 556release .set () 557self .assertFalse ((self .app .workspace / "deleted-run-executed" ).exists ()) 558listed = [item ["id" ]for item in self .api ("/sessions?scope=all" )["items" ]] 559self .assertNotIn (parent ,listed ) 560self .assertNotIn (child ,listed ) 561radio = self .app .session () 562self .change (radio , {"name" :"radio desk" }) 563moved = self .app .root / "moved" 564moved .mkdir () 565self .assertEqual (self .move (radio ,moved )["name" ],"radio desk" ) 566self .assertEqual (self .snapshot (radio )["name" ],"radio desk" ) 567 568@exclusive 569def test_captured_subtree_deletion_blocks_new_child_admission (self ): 570parent = self .app .session () 571child = self .spawn (parent ,"captured" )["id" ] 572self .app .idle (child ) 573wait_for (lambda :self .asked ('kind="unreviewed"' )) 574self .app .idle (parent ) 575with self .app .api (f"/sessions/ { parent } ?view=configuration" )as response : 576configuration = json .load (response ) 577revision = response .headers ["ETag" ] 578probe = StreamProbe (self .app ) 579self .assertEqual ( 580probe .call ("suspend_session_json" ,f"[<< { json . dumps ( child ) } >>]" ), 581 {"suspended" :True }, 582 ) 583outcomes ,failures = [], [] 584 585def delete (): 586try : 587outcomes .append ( 588self .api ( 589f"/sessions/ { parent } ?view=configuration&scope=subtree&family_revision= { configuration [ 'family_revision' ] } " , 590method = "DELETE" , 591headers = {"If-Match" :revision }, 592 ) 593 ) 594except Exception as error : 595failures .append (error ) 596 597worker = threading .Thread (target = delete ) 598worker .start () 599try : 600 601def captured (): 602with sqlite3 .connect (self .app .home / "albedo.sqlite" )as db : 603rows = db .execute ( 604"SELECT id,deletion_id FROM sessions WHERE id IN (?,?)" , 605 (parent ,child ), 606 ).fetchall () 607return len (rows )== 2 and all (row [1 ]for row in rows ) 608 609wait_for (captured ,timeout = 15 ) 610self .assertTrue (worker .is_alive ()) 611with self .assertRaises (urllib .error .HTTPError )as caught : 612self .spawn (parent ,"too-late" ,"must not execute" ) 613self .assertEqual (caught .exception .code ,409 ) 614with sqlite3 .connect (self .app .home / "albedo.sqlite" )as db : 615admitted = { 616row [0 ] 617for row in db .execute ( 618"SELECT id FROM sessions WHERE id=? UNION ALL " 619"SELECT session FROM session_family WHERE parent=?" , 620 (parent ,parent ), 621 ) 622 } 623self .assertEqual (admitted , {parent ,child }) 624finally : 625self .assertEqual ( 626probe .call ("resume_session_json" ,f"[<< { json . dumps ( child ) } >>]" ), 627 {"resumed" :True }, 628 ) 629worker .join (35 ) 630self .assertFalse ( 631worker .is_alive (),"deletion did not finish after owner resumed" 632 ) 633self .assertEqual (failures , []) 634self .assertEqual (len (outcomes ),1 ) 635self .assertEqual (outcomes [0 ]["state" ],"complete" ) 636self .assertEqual (outcomes [0 ]["deleted_ids" ], [child ,parent ]) 637self .assertFalse (self .asked ("must not execute" )) 638 639@exclusive 640def test_subtree_deletion_reports_the_rows_a_failed_commit_leaves (self ): 641parent = self .app .session () 642failed = self .spawn (parent ,"failed" )["id" ] 643removed = self .spawn (parent ,"removed" )["id" ] 644for session in (failed ,removed ): 645self .app .idle (session ) 646wait_for (lambda :self .asked ('from="failed"' )and self .asked ('from="removed"' )) 647self .app .idle (parent ) 648with self .app .api (f"/sessions/ { parent } ?view=configuration" )as response : 649configuration = json .load (response ) 650revision = response .headers ["ETag" ] 651with sqlite3 .connect (self .app .home / "albedo.sqlite" )as db : 652db .execute ( 653"CREATE TRIGGER fail_observed_deletion BEFORE DELETE ON sessions " 654f"WHEN OLD.id=' { failed } ' BEGIN SELECT RAISE(ABORT,'injected deletion failure'); END" 655 ) 656self .addCleanup (self ._remove_deletion_failure ) 657outcome = self .api ( 658f"/sessions/ { parent } ?view=configuration&scope=subtree&family_revision= { configuration [ 'family_revision' ] } " , 659method = "DELETE" , 660headers = {"If-Match" :revision }, 661 ) 662self .assertEqual (outcome ["state" ],"partial" ) 663self .assertEqual (outcome ["deleted_count" ],1 ) 664self .assertEqual (outcome ["remaining_count" ],2 ) 665self .assertEqual (outcome ["deleted_ids" ], [removed ]) 666remaining = {row ["id" ]:row ["reason" ]for row in outcome ["remaining" ]} 667self .assertEqual (set (remaining ), {parent ,failed }) 668self .assertTrue (all (remaining .values ())) 669self .assertIn ("captured child" ,remaining [parent ]["detail" ]) 670self ._remove_deletion_failure () 671# Reading authoritative state never resumes a partially failed deletion. 672for _ in range (2 ): 673self .assertEqual ( 674 {row ["id" ]for row in self .family (parent )}, {parent ,failed } 675 ) 676with self .assertRaises (urllib .error .HTTPError )as caught : 677self .api ( 678f"/sessions/ { parent } ?view=configuration&scope=subtree&family_revision= { configuration [ 'family_revision' ] } " , 679method = "DELETE" , 680headers = {"If-Match" :revision }, 681 ) 682self .assertEqual (caught .exception .code ,412 ) 683self .assertEqual ({row ["id" ]for row in self .family (parent )}, {parent ,failed }) 684 685def _remove_deletion_failure (self ): 686with sqlite3 .connect (self .app .home / "albedo.sqlite" )as db : 687db .execute ("DROP TRIGGER IF EXISTS fail_observed_deletion" ) 688 689def test_children_follow_a_workspace_move (self ): 690release = threading .Event () 691self .addCleanup (release .set ) 692answer = self .provider .script 693 694def held (request ): 695user = latest_user (request ) 696if "hold this turn" in user or "probe moved child" in user : 697if request ["messages" ][- 1 ].get ("role" )== "tool" : 698return text ("workspace probe finished" ) 699if "hold this turn" in user : 700release .wait (30 ) 701marker = ( 702"old-child-cwd" if "hold this turn" in user else "new-child-cwd" 703 ) 704return python ( 705"import os\nfrom pathlib import Path\n" 706f"Path( { marker !r } ).write_text(os.getcwd())" 707 ) 708return answer (request ) 709 710self .provider .script = held 711parent = self .app .session () 712still = self .spawn (parent ,"still" )["id" ] 713away = self .spawn (parent ,"away" )["id" ] 714 715def heard (): 716return { 717entry ["mail" ]["sender_session_id" ] 718for entry in self .app .history (parent )["items" ] 719if entry .get ("mail" ) 720 } 721 722wait_for (lambda : {still ,away }<= heard ()) 723for session in (still ,away ,parent ): 724self .app .idle (session ) 725elsewhere = Path (tempfile .mkdtemp (prefix = "elsewhere-" ,dir = self .app .root )) 726self .move (away ,elsewhere ) 727busy = self .spawn (parent ,"busy" ,"hold this turn" )["id" ] 728wait_for (lambda :self .asked ("hold this turn" )) 729moved = Path (tempfile .mkdtemp (prefix = "moved-" ,dir = self .app .root )) 730self .assertEqual (self .move (parent ,moved )["workspace" ],str (moved )) 731 732def workspaces (): 733return {item ["id" ]:item ["workspace" ]for item in self .family (parent )} 734 735self .assertEqual ( 736workspaces (), 737 { 738parent :str (moved ), 739still :str (moved ), 740away :str (elsewhere ), 741busy :str (self .app .workspace ), 742 }, 743 ) 744latest = Path (tempfile .mkdtemp (prefix = "latest-" ,dir = self .app .root )) 745self .move (parent ,latest ) 746before_move_applied = self .app .stream_page (busy ) 747release .set () 748self .app .idle (busy ) 749self .assertEqual (workspaces ()[busy ],str (latest )) 750self .assertEqual ( 751 (self .app .workspace / "old-child-cwd" ).read_text (),str (self .app .workspace ) 752 ) 753self .assertFalse ((moved / "old-child-cwd" ).exists ()) 754self .send (busy ,"probe moved child" ) 755self .app .idle (busy ) 756self .assertEqual ((latest / "new-child-cwd" ).read_text (),str (latest )) 757self .assertFalse ((moved / "new-child-cwd" ).exists ()) 758events = self .app .stream_page (busy ,before_move_applied )["events" ] 759notes = [ 760event ["data" ]["text" ] 761for event in events 762if event ["type" ]== "note" and event ["data" ]["origin" ]== "workspace" 763 ] 764self .assertTrue ( 765any ( 766"python variables were cleared" in note 767and "transcript is intact" in note 768for note in notes 769 ), 770events , 771 ) 772 773@exclusive 774def test_failed_deferred_workspace_application_survives_restart (self ): 775entered ,release = threading .Event (),threading .Event () 776self .addCleanup (release .set ) 777answer = self .provider .script 778 779def held (request ): 780user = latest_user (request ) 781if "deferred workspace hold" in user : 782entered .set () 783release .wait (30 ) 784return text ("old turn completed" ) 785if "restored workspace probe" in user : 786if request ["messages" ][- 1 ].get ("role" )== "tool" : 787return text ("restored workspace completed" ) 788return python ( 789"import os\nfrom pathlib import Path\n" 790"Path('restored-child-cwd').write_text(os.getcwd())" 791 ) 792return answer (request ) 793 794self .provider .script = held 795parent = self .app .session () 796child = self .spawn (parent ,"deferred" ,"deferred workspace hold" )["id" ] 797self .assertTrue (entered .wait (30 ),"child never reached provider gate" ) 798destination = Path (tempfile .mkdtemp (prefix = "deferred-" ,dir = self .app .root )) 799self .move (parent ,destination ) 800destination .rmdir () 801release .set () 802self .app .idle (child ) 803 804def assert_pending (): 805snapshot = self .snapshot (child ) 806self .assertEqual (snapshot ["workspace" ],str (self .app .workspace )) 807change = snapshot ["workspace_change" ] 808self .assertEqual (change ["active" ],str (self .app .workspace )) 809self .assertEqual (change ["desired" ],str (destination )) 810return change ["revision" ] 811 812revision = assert_pending () 813self .app .restart (crash = True ) 814self .assertEqual (assert_pending (),revision ) 815destination .mkdir () 816self .send (child ,"restored workspace probe" ) 817self .app .idle (child ) 818self .assertEqual ( 819 (destination / "restored-child-cwd" ).read_text (),str (destination ) 820 ) 821snapshot = self .snapshot (child ) 822self .assertEqual (snapshot ["workspace" ],str (destination )) 823self .assertIsNone (snapshot ["workspace_change" ]) 824self .assertFalse ((self .app .workspace / "restored-child-cwd" ).exists ()) 825 826def test_cancel_all_stops_every_running_turn_beneath_the_caller (self ): 827entered = {"mid" :threading .Event (),"leaf" :threading .Event ()} 828release = threading .Event () 829self .addCleanup (release .set ) 830answer = self .provider .script 831 832def held (request ): 833user = latest_user (request ) 834for name ,gate in entered .items (): 835if f"hold { name } " in user : 836gate .set () 837release .wait (30 ) 838return text ("held turn ended" ) 839if request ["messages" ][- 1 ].get ("role" )== "user" and user == "stop all" : 840return python ("print(sorted(await agents.cancel_all()))" ) 841return answer (request ) 842 843self .provider .script = held 844parent = self .app .session () 845mid = self .spawn (parent ,"mid" ,"hold mid" )["id" ] 846leaf = self .spawn (mid ,"leaf" ,"hold leaf" )["id" ] 847for gate in entered .values (): 848self .assertTrue (gate .wait (30 ),"a held turn never reached the provider" ) 849self .send (parent ,"stop all" ) 850for session in (parent ,mid ,leaf ): 851self .app .idle (session ) 852self .assertFalse (release .is_set ()) 853result = next ( 854json .loads (part ["value" ]) 855for entry in reversed (self .app .history (parent )["items" ]) 856if entry ["kind" ]== "tool_result" 857for part in entry ["content" ] 858if part ["kind" ]== "json" and part ["field" ]== "result" 859 ) 860self .assertEqual (result ["status" ],"ok" ,result ) 861self .assertEqual (result ["output" ].strip (),str (sorted ([mid ,leaf ]))) 862 863def test_qualified_and_inferred_cross_provider_models (self ): 864parent = self .app .session () 865self .change (parent , {"name" :"radio desk" }) 866with self .assertRaises (urllib .error .HTTPError )as caught : 867self .spawn ( 868parent ,"missing" ,"should not run" ,f" { self . beta } /no-such-model" 869 ) 870self .assertEqual (caught .exception .code ,409 ) 871for name ,model in ( 872 ("explicit" ,f" { self . beta } /fixture-beta- { self . provider . route } " ), 873 ("inferred" ,f"fixture-beta- { self . provider . route } " ), 874 ): 875made = self .spawn (parent ,name ,"cross-provider task" ,model ) 876self .assertEqual (made ["provider_profile" ],self .beta ) 877self .assertEqual (made ["model" ],f"fixture-beta- { self . provider . route } " ) 878self .assertEqual (made ["parent_id" ],parent ) 879self .app .idle (made ["id" ]) 880tasks = wait_for ( 881lambda : ( 882self .asked ('kind="task">\ncross-provider' ) 883if len (self .asked ('kind="task">\ncross-provider' ))== 2 884else None 885 ) 886 ) 887self .assertTrue (all ('from="radio desk"' in task for task in tasks )) 888self .assertGreaterEqual ( 889sum ("/beta/" in record ["path" ]for record in self .provider .requests ),2 890 ) 891 892# exclusive: writes the daemon-wide models.json catalog 893@exclusive 894def test_generic_profile_lists_its_endpoints_catalog_models (self ): 895parent = self .app .session () 896made = self .spawn ( 897parent ,"listed" ,"catalog task" ,f" { self . beta } /fixture-listed" 898 ) 899self .assertEqual ( 900 (made ["provider_profile" ],made ["model" ]), (self .beta ,"fixture-listed" ) 901 ) 902self .app .idle (made ["id" ])