okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
5bdc0ee
main
1"""Shared fixtures for offline end-to-end tests, run through ``run.py``. 2 3A provider is a scripted model: its callback receives each decoded request and 4returns ``text(...)``, ``python(...)``, ``error(...)``, or a custom ``Reply``. 5Both OpenAI streaming protocols are served. ``Provider(catalog=...)`` also serves 6a local models catalog. ``Albedo`` gives each fixture a separate workspace and 7provider route on one shared HTTP server, on the daemon the runner gave its 8test. Tests share one daemon and keep to their own profile; an ``@exclusive`` 9test, which may change global settings or restart the daemon, gets a fresh one 10that boots after its fixture's ``prepare(app)`` and is discarded afterwards, so 11it never restores what it changed. ``providers={}`` leaves it unconfigured. 12Setting ``app.daemon.open_files = (soft, hard)`` in ``prepare`` boots that 13daemon under an open-file limit. 14``write_extensions(settings)`` replaces extensions.json without letting the 15daemon reach the network, and ``store_secrets(section, value)`` writes a 16creds.json section such as the OAuth ``accounts`` or ``mcp`` server secrets. 17A response ``Reply(..., usage=None)`` omits provider usage. 18A ``python(...)`` cell that starts with ``LOOPBACK_REMOTE`` has ``rem`` bound to 19a remote connection whose ssh runs everything on this machine. 20 21Example:: 22 23from harness import Albedo, Provider, text 24 25provider = Provider(lambda request: text("hello")) 26try: 27with Albedo(provider) as app: 28session = app.session() 29app.prompt(session, "say hello").close() 30app.idle(session) 31assert provider.requests 32finally: 33provider.close() 34""" 35 36from __future__import annotations 37 38from collections .abc import Iterable 39from typing import Any 40 41import atexit 42import contextlib 43import errno 44from dataclasses import dataclass ,field 45import http .client 46import http .server 47import io 48import itertools 49import json 50import os 51from pathlib import Path 52import shutil 53import secrets 54import signal 55import subprocess 56import sys 57import tempfile 58import threading 59import time 60import urllib .error 61import urllib .parse 62import uuid 63 64from provider_events import chat_events ,responses_events 65 66ROOT = Path (__file__ ).resolve ().parents [2 ] 67sys .path .insert (0 ,str (ROOT / "test" )) 68import scratch # noqa: E402 69 70scratch .claim ("e2e" ) 71# A test daemon needs two schedulers, and busy-waiting ones make every boot 72# pin all cores of the machine. 73TEST_VM_FLAGS = "+S 2:2 +SDcpu 2:2 +sbwt none +sbwtdcpu none +sbwtdio none" 74# Credentials a provider reads from the environment. The daemon must not see 75# the developer's, or a catalog reload reaches the live provider. 76PROVIDER_ENVIRONMENT = ("ALIBABA_API_KEY" ,"DASHSCOPE_API_KEY" ,"ALIBABA_BASE_URL" ) 77# Extension settings that keep a daemon off the network: the models catalog 78# never refreshes by itself and the cache-TTL table fetches no remote copy. 79OFFLINE = {"models" : {"refreshHours" :0 },"cacheTtl" : {"url" :None }} 80 81 82@dataclass 83class Reply : 84"""A scripted assistant response and optional transport behavior.""" 85 86kind :str 87value :str = "" 88status :int = 200 89reasoning :str | None = None 90usage :dict | None = field ( 91default_factory = lambda : {"input_tokens" :10 ,"output_tokens" :20 } 92 ) 93delay :float = 0 94hang :bool = False 95tool_name :str = "python" 96tool_arguments :dict | None = None 97events :Iterable [dict ]| None = None 98 99 100def text ( 101value :str , 102* , 103reasoning :str | None = None , 104usage :dict | None = None , 105delay :float = 0 , 106hang :bool = False , 107)-> Reply : 108return Reply ( 109"text" , 110value , 111reasoning = reasoning , 112usage = usage or {"input_tokens" :10 ,"output_tokens" :20 }, 113delay = delay , 114hang = hang , 115 ) 116 117 118def python ( 119code :str ,* ,reasoning :str | None = None ,tool_name :str = "python" 120)-> Reply : 121return Reply ("python" ,code ,reasoning = reasoning ,tool_name = tool_name ) 122 123 124def error (status :int ,body :str = "fixture error" )-> Reply : 125return Reply ("error" ,body ,status = status ) 126 127 128# Guards the provider registry and the shared provider server. 129_config_lock = threading .RLock () 130 131 132def operation_id (timestamp = None ): 133"""Generate one UUIDv7 action identity, optionally at a fixture time.""" 134milliseconds = int (time .time ()* 1000 )if timestamp is None else timestamp 135bits = (milliseconds << 80 )| (7 << 76 )| secrets .randbits (76 ) 136bits = (bits & ~ (3 << 62 ))| (2 << 62 ) 137return str (uuid .UUID (int = bits )) 138 139 140def exclusive (target ): 141"""Run the test, or every test of the class, on a daemon of its own that 142boots after the fixture's prepare step and is discarded afterwards: it may 143change global settings or restart the daemon.""" 144target ._e2e_exclusive = True 145return target 146 147 148_providers = {} 149_provider_ids = itertools .count (1 ) 150_server = None 151 152 153def _provider_server (): 154global _server 155if _server is None : 156 157class Handler (http .server .BaseHTTPRequestHandler ): 158def log_message (self ,format ,* args ): 159pass 160 161def do_GET (self ): 162self .provider ()._get (self ) 163 164def do_POST (self ): 165self .provider ()._post (self ) 166 167def provider (self ): 168return _providers [self .path .split ("/" ,3 )[2 ]] 169 170_server = http .server .ThreadingHTTPServer (("127.0.0.1" ,0 ),Handler ) 171_server .daemon_threads = True 172threading .Thread (target = _server .serve_forever ,daemon = True ).start () 173return _server 174 175 176class Provider : 177"""Threaded fake provider that records requests and emits protocol SSE.""" 178 179def __init__ (self ,script ,* ,chunk_size :int = 20 ,catalog = None ): 180self .catalog = catalog 181self .script = script 182self .chunk_size = chunk_size 183self .requests :list [dict ]= [] 184self ._lock = threading .Lock () 185self .route = str (next (_provider_ids )) 186with _config_lock : 187_providers [self .route ]= self 188self .server = _provider_server () 189self .url = f"http://127.0.0.1: { self . server . server_port } /t/ { self . route } " 190 191def _get (owner ,self ): 192payload = json .dumps (owner .catalog ).encode () 193self .send_response (200 ) 194self .send_header ("Content-Type" ,"application/json" ) 195self .end_headers () 196self .wfile .write (payload ) 197 198def _post (owner ,self ): 199request = json .loads (self .rfile .read (int (self .headers ["Content-Length" ]))) 200record = { 201"path" :self .path , 202"authorization" :self .headers .get ("Authorization" ), 203"model" :request .get ("model" ), 204"request" :request , 205 } 206with owner ._lock : 207owner .requests .append (record ) 208index = len (owner .requests )- 1 209reply = owner .script (request ) 210if isinstance (reply , (list ,tuple )): 211reply = reply [min (index ,len (reply )- 1 )] 212if reply .kind == "error" : 213payload = json .dumps ({"error" : {"message" :reply .value }}).encode () 214self .send_response (reply .status ) 215self .send_header ("Content-Type" ,"application/json" ) 216self .end_headers () 217self .wfile .write (payload ) 218return 219self .send_response (200 ) 220self .send_header ("Content-Type" ,"text/event-stream" ) 221self .end_headers () 222 223def emit (value ): 224if reply .delay : 225time .sleep (reply .delay ) 226self .wfile .write (("data: " + json .dumps (value )+ "\n\n" ).encode ()) 227self .wfile .flush () 228 229try : 230if reply .hang : 231time .sleep (60 ) 232return 233if reply .events is not None : 234for event in reply .events : 235emit (event ) 236self .wfile .write (b"data: [DONE]\n\n" ) 237self .wfile .flush () 238return 239call_id = f"fixture-call- { index + 1 } " 240arguments = json .dumps ( 241reply .tool_arguments 242if reply .tool_arguments is not None 243else {"pattern" :reply .value } 244if reply .tool_name == "lcm_grep" 245else {"code" :reply .value ,"timeout_ms" :60000 } 246 ) 247events = ( 248chat_events (reply ,arguments ,call_id ,owner .chunk_size ) 249if self .path .endswith ("chat/completions" ) 250else responses_events (reply ,arguments ,call_id ,owner .chunk_size ) 251 ) 252for event in events : 253emit (event ) 254self .wfile .write (b"data: [DONE]\n\n" ) 255self .wfile .flush () 256except (BrokenPipeError ,ConnectionResetError ): 257pass 258 259def close (self ): 260# Sessions can finish background turns after their test returns. Keep 261# their route alive until the shared daemon has stopped. 262pass 263 264 265_daemons :list [Daemon ]= [] 266# The launcher of this run's daemon build; see snapshot_daemon(). 267_executable = None 268_daemons_lock = threading .RLock () 269_discarding :list [threading .Thread ]= [] 270_current = threading .local () 271daemon_boots = 0 272restart_seconds = 0.0 273# mist closes a keep-alive connection after 10 idle seconds; one idle for less 274# than this is reused, so a request never races the daemon closing it. 275KEEP_ALIVE_SECONDS = 5 276 277 278class _Connection (http .client .HTTPConnection ): 279"""A daemon connection that notes when it can carry the next request.""" 280 281def __init__ (self ,port ,token ): 282super ().__init__ ("127.0.0.1" ,port ,timeout = 20 ) 283self .token = token 284self .response_class = _Response 285self .used_at = time .monotonic () 286self .reusable = True 287self .response = None 288 289def idle (self ): 290"""Whether the next request may go out on this connection: its last 291response is done with and the daemon has not dropped it yet.""" 292return ( 293self .sock is not None 294and self .reusable 295and (self .response is None or self .response .isclosed ()) 296and time .monotonic ()- self .used_at < KEEP_ALIVE_SECONDS 297 ) 298 299 300class _Response (http .client .HTTPResponse ): 301"""Closing drains a short unread body so the connection can be reused; 302an event stream or a long body gives the connection up instead.""" 303 304connection = None 305 306def close (self ): 307if self .headers is None : 308# begin() died before any status line: there is no body to drain, 309# and reading headers here would mask the error that stopped it. 310super ().close () 311return 312connection = self .connection 313streaming = self .getheader ("content-type" ,"" ).startswith ("text/event-stream" ) 314if connection is not None and streaming : 315connection .reusable = False 316if connection is not None and not self .isclosed (): 317if streaming or self .length is None or self .length > 1 << 20 : 318connection .reusable = False 319else : 320try : 321self .read () 322except (OSError ,http .client .HTTPException ): 323connection .reusable = False 324super ().close () 325if connection is not None : 326connection .used_at = time .monotonic () 327if self .will_close or not connection .reusable : 328connection .close () 329 330 331class Daemon : 332"""One daemon and its hermetic home. A concurrent daemon is shared by 333tests that each keep to their own provider profile; any other serves one 334test, which may change its global state or restart it.""" 335 336def __init__ (self ,name ,* ,concurrent = False ): 337if not hasattr (sys .modules ["__main__" ],"__file__" ): 338# A daemon lives until the process that booted it exits, which a 339# long-lived interpreter or agent kernel never does. 340raise RuntimeError ( 341"run E2E tests through test/e2e/run.py, not an interactive interpreter" 342 ) 343self .concurrent = concurrent 344self .lock = threading .RLock () 345# Resolved, so a path the daemon reports through a symlink (macOS /tmp) matches. 346self .root = Path (tempfile .mkdtemp (prefix = f" { name } -" )).resolve () 347self .home = self .root / "home" 348self .home .mkdir () 349 (self .root / "user-home" ).mkdir () 350# Nothing of the developer's albedo setup reaches the daemon. 351self .env = dict ( 352 { 353key :value 354for key ,value in os .environ .items () 355if key not in PROVIDER_ENVIRONMENT 356and key not in ("HOME" ,"ERL_FLAGS" ) 357and not key .startswith ("ALBEDO_" ) 358 }, 359HOME = str (self .root / "user-home" ), 360ALBEDO_HOME = str (self .home ), 361ALBEDO_ROOT = str (ROOT ), 362ALBEDO_NO_BROWSER = "1" , 363ALBEDO_PARENT_PID = str (os .getpid ()), 364ERL_FLAGS = TEST_VM_FLAGS , 365ALBEDO_IDLE_SECONDS = "10" , 366# Kernels outlive a stopped daemon only this long; one whose home 367# is removed leaves at once. 368ALBEDO_KERNEL_GRACE_SECONDS = "30" , 369ALBEDO_MCP_SECRET = "configured-secret" , 370ALBEDO_MCP_CLOSED = str (self .root / "closed" ), 371ALBEDO_MCP_AMBIENT = "must-not-reach-the-server" , 372 ) 373if _executable : 374self .env ["ALBEDO_DAEMON" ]= _executable 375if "ALBEDO_TEST_CLI" in os .environ : 376self .env ["ALBEDO_TEST_CLI" ]= os .environ ["ALBEDO_TEST_CLI" ] 377# A (soft, hard) open-file limit the CLI, and so the daemon it boots, 378# starts under; None passes on the test process's own. 379self .open_files = None 380self .connection = None 381self .base = None 382self ._pid = None 383self ._local = threading .local () 384self .boot_action = None 385self ._launchers = [] 386self ._commands = [] 387with _daemons_lock : 388_daemons .append (self ) 389 390@property 391def booted (self ): 392return self ._pid is not None 393 394def boot (self ): 395"""Start the daemon; the first fixture boots it after its prepare.""" 396extensions = self .home / "extensions.json" 397if not extensions .exists (): 398extensions .write_text (json .dumps (OFFLINE )) 399if self .boot_action is not None : 400self .boot_action (self ) 401self .cli ("sessions" ) 402self ._refresh () 403 404def start_launcher (self ): 405"""Run the real launcher directly, retaining its log and process owner.""" 406with (self .home / "daemon.log" ).open ("ab" )as log : 407process = subprocess .Popen ( 408 [self .env ["ALBEDO_DAEMON" ]], 409cwd = ROOT , 410env = self .env , 411stdout = log , 412stderr = log , 413 ) 414self ._launchers .append (process ) 415# Direct launches are our children. Reap them as soon as they exit so 416# another CLI can observe that the old daemon is gone during upgrade. 417threading .Thread (target = process .wait ,daemon = True ).start () 418return process 419 420def await_ready (self ,timeout = 20 ): 421"""Adopt the published endpoint once its authenticated API answers.""" 422deadline = time .monotonic ()+ timeout 423while time .monotonic ()< deadline : 424try : 425self ._refresh () 426with self .api ("/server" )as response : 427if json .load (response )["state" ]== "ready" : 428return 429except (OSError ,ValueError ,http .client .HTTPException ): 430pass 431time .sleep (0.02 ) 432self ._fail ("launcher did not publish an authenticated endpoint" ) 433 434def start_cli (self ,* args ,** streams ): 435"""Start a CLI command whose input or cancellation the fixture controls.""" 436process = subprocess .Popen ( 437 [self .env .get ("ALBEDO_TEST_CLI" ,str (ROOT / "cli/bin/albedo" )),* args ], 438cwd = ROOT , 439env = self .env , 440** streams , 441 ) 442self ._commands .append (process ) 443return process 444 445def _refresh (self ): 446global daemon_boots 447self .connection = json .loads ((self .home / "daemon.json" ).read_text ()) 448self .base = f"http://127.0.0.1: { self . connection [ 'port' ] } " 449if self .connection ["pid" ]!= self ._pid : 450# A new PID is valid only after the old daemon has exited. 451if self ._pid is not None and alive (self ._pid ): 452raise AssertionError (f"a second daemon started in { self . home } " ) 453self ._pid = self .connection ["pid" ] 454with _daemons_lock : 455daemon_boots += 1 456 457def restart (self ,* ,crash = False ,prepare = None ): 458"""Replace the daemon under the same home, preparing storage between.""" 459global restart_seconds 460started = time .monotonic () 461if crash : 462assert self ._pid is not None 463os .kill (self ._pid ,signal .SIGKILL ) 464# SIGKILL is asynchronous: a CLI started before the process is gone 465# reads the dead daemon's record and finds nothing listening. 466if not self ._gone (timeout = 15 ): 467self ._fail ("daemon did not die" ) 468elif not self ._stop (timeout = 15 ): 469self ._fail ("daemon did not stop" ) 470if prepare is not None : 471prepare () 472self .cli ("sessions" ) 473self ._refresh () 474with _daemons_lock : 475restart_seconds += time .monotonic ()- started 476 477def _stop (self ,timeout ): 478try : 479with self .api ("/server" )as response : 480instance_id = json .load (response )["instance_id" ] 481self .api ( 482"/server/shutdown" , 483 { 484"instance_id" :instance_id , 485"timeout_ms" :min (timeout * 1000 ,30000 ), 486 }, 487 ).close () 488except (OSError ,http .client .HTTPException ): 489pass 490return self ._gone (timeout ) 491 492def _gone (self ,timeout ): 493deadline = time .monotonic ()+ timeout 494while alive (self ._pid ): 495if time .monotonic ()> deadline : 496return False 497time .sleep (0.05 ) 498return True 499 500def shutdown (self ): 501"""Stop the daemon, killing it if it will not stop, and drop the home.""" 502with self .lock : 503# A failed CLI boot may already have launched this private home's daemon. 504path = self .home / "daemon.json" 505if path .exists (): 506self .connection = json .loads (path .read_text ()) 507self .base = f"http://127.0.0.1: { self . connection [ 'port' ] } " 508self ._pid = self .connection ["pid" ] 509if self ._pid is not None : 510if alive (self ._pid )and not self ._stop (timeout = 10 ): 511try : 512os .kill (self ._pid ,signal .SIGKILL ) 513except ProcessLookupError : 514pass 515self ._pid = None 516for launcher in self ._launchers : 517if launcher .poll ()is None : 518launcher .kill () 519launcher .wait () 520for command in self ._commands : 521if command .poll ()is None : 522command .kill () 523command .wait () 524shutil .rmtree (self .root ,ignore_errors = True ) 525with _daemons_lock : 526if self in _daemons : 527_daemons .remove (self ) 528 529def kill (self ): 530"""SIGKILL the daemon without waiting for its lock, even mid-stop.""" 531pid = self ._pid 532if pid is not None : 533try : 534os .kill (pid ,signal .SIGKILL ) 535except ProcessLookupError : 536pass 537 538def discard (self ): 539"""Shut the daemon down in the background; the test that used it is 540done, and nothing waits on its teardown until the run ends.""" 541thread = threading .Thread (target = self .shutdown ) 542thread .start () 543with _daemons_lock : 544_discarding .append (thread ) 545 546def _fail (self ,message ): 547log = self .home / "daemon.log" 548raise AssertionError (message + ("\n" + log .read_text ()if log .exists ()else "" )) 549 550def _connection (self ): 551connection = getattr (self ._local ,"connection" ,None ) 552assert self .connection is not None 553port ,token = self .connection ["port" ],self .connection ["token" ] 554# A restarted daemon has a new token and may be back on the same port. 555if connection is None or connection .token != token or not connection .idle (): 556if connection is not None : 557# A response still being read, such as an event stream, keeps 558# its socket; closing it gives the connection up. 559connection .reusable = False 560if connection .response is None or connection .response .isclosed (): 561connection .close () 562connection = self ._local .connection = _Connection (port ,token ) 563return connection 564 565def api (self ,path ,body = None ,* ,method = None ,headers = None ): 566"""One daemon request over this thread's kept-alive connection. A 567socket per request, polled at test speed, leaves thousands of ports in 568TIME_WAIT; the host runs out and unrelated connections fail.""" 569connection = self ._connection () 570assert self .connection is not None 571connection .used_at = time .monotonic () 572payload = None if body is None else json .dumps (body ).encode () 573try : 574connection .request ( 575method or ("GET" if payload is None else "POST" ), 576path , 577payload , 578 { 579"Authorization" :"Bearer " + self .connection ["token" ], 580"Content-Type" :"application/merge-patch+json" 581if method == "PATCH" 582else "application/json" , 583** (headers or {}), 584 }, 585 ) 586response = connection .getresponse () 587except BaseException : 588connection .close () 589raise 590response .connection ,connection .response = connection ,response 591if response .status < 400 : 592return response 593payload = response .read () 594response .close () 595raise urllib .error .HTTPError ( 596self .base + path , 597response .status , 598f" { response . reason } ( { method } { path } ): " + payload .decode (errors = "replace" ), 599response .headers , 600io .BytesIO (payload ), 601 ) 602 603def cli (self ,* args ): 604command = [self .env .get ("ALBEDO_TEST_CLI" ,str (ROOT / "cli/bin/albedo" )),* args ] 605if self .open_files : 606command = [ 607sys .executable , 608"-c" , 609_WITH_OPEN_FILES , 610* map (str ,self .open_files ), 611* command , 612 ] 613result = subprocess .run ( 614command , 615cwd = ROOT , 616env = self .env , 617text = True , 618capture_output = True , 619timeout = 45 , 620 ) 621if result .returncode : 622self ._fail (result .stdout + result .stderr ) 623return result .stdout 624 625def install_shared_features (self ): 626"""Enable the additive global gateways concurrent tests rely on, once, 627before they start: none of them may change global state itself.""" 628settings = json .loads ((self .home / "extensions.json" ).read_text ()) 629settings .setdefault ("enabled" , {})["proxy" ]= True 630 (self .home / "extensions.json" ).write_text (json .dumps (settings )) 631with self .api ("/settings?group=extensions" )as response : 632validator = response .getheader ("ETag" ) 633response .read () 634self .api ( 635"/settings?group=extensions" , 636 {"defaults" : {"webhooks" :True }}, 637method = "PATCH" , 638headers = {"If-Match" :validator }, 639 ).close () 640 641 642# Runs a command under a (soft, hard) open-file limit, as a shell started with 643# that `ulimit -n` would. 644_WITH_OPEN_FILES = """import os, resource, sys 645resource.setrlimit(resource.RLIMIT_NOFILE, (int(sys.argv[1]), int(sys.argv[2]))) 646os.execv(sys.argv[3], sys.argv[3:])""" 647 648 649def latest_user (request ): 650"""Return the last user's raw content from either provider protocol.""" 651messages = request .get ("messages" ,request .get ("input" , [])) 652return next ( 653 ( 654item .get ("content" ,"" ) 655for item in reversed (messages ) 656if item .get ("role" )== "user" 657 ), 658"" , 659 ) 660 661 662def wait_until (condition ,seconds ,message ): 663deadline = time .monotonic ()+ seconds 664while time .monotonic ()< deadline : 665if condition (): 666return 667time .sleep (0.1 ) 668raise AssertionError (message ) 669 670 671def release_fifo (path ): 672"""Let a job blocked reading one byte from the fifo at `path` go on. True 673once a reader had it open and got its byte; False while none has it open 674yet, so callers retry: `wait_until(lambda: release_fifo(gate), ...)`.""" 675try : 676descriptor = os .open (path ,os .O_WRONLY | os .O_NONBLOCK ) 677except OSError as error : 678if error .errno == errno .ENXIO : 679return False 680raise 681try : 682os .write (descriptor ,b"x" ) 683finally : 684os .close (descriptor ) 685return True 686 687 688# An ssh that runs its command right here, under a login shell that does the 689# same, so a cell's remote.connect("loopback") boots, stages and talks to a 690# real remote kernel. 691_LOOPBACK_SSH = """#!/bin/sh 692while [ $# -gt 0 ]; do 693case "$1" in 694-o) shift 2 ;; 695-*) shift ;; 696*) break ;; 697esac 698done 699shift 700exec sh -c "$*" 701""" 702_LOOPBACK_LOGIN = '#!/bin/sh\n[ "$1" = -l ] && shift\nexec /bin/sh "$@"\n' 703# The start of a cell that binds `rem` to a loopback remote connection. 704LOOPBACK_REMOTE = f"""import os 705os.makedirs("bin", exist_ok=True) 706for name, script in (("ssh", { _LOOPBACK_SSH !r } ), ("loginsh", { _LOOPBACK_LOGIN !r } )): 707with open(f"bin/{{name}}", "w") as file: 708file.write(script) 709os.chmod(f"bin/{{name}}", 0o755) 710os.environ["SHELL"] = os.path.abspath("bin/loginsh") 711os.environ["PATH"] = os.path.abspath("bin") + os.pathsep + os.environ["PATH"] 712rem = await remote.connect("loopback") 713""" 714 715 716def alive (pid ): 717try : 718os .kill (pid ,0 ) 719except ProcessLookupError : 720return False 721return True 722 723 724def snapshot_daemon (): 725"""Compile the daemon once for the run and boot every test daemon from a 726copy, not through `gleam run` (see test/snapshot-daemon.sh). test.sh 727snapshots once for every suite and passes it as ALBEDO_TEST_DAEMON.""" 728global _executable 729_executable = os .environ .get ("ALBEDO_TEST_DAEMON" ) 730if _executable : 731return 732result = subprocess .run ( 733 [str (ROOT / "test/snapshot-daemon.sh" ),tempfile .mkdtemp (prefix = "daemon-" )], 734text = True , 735capture_output = True , 736 ) 737if result .returncode : 738raise RuntimeError ("building the daemon failed:\n" + result .stderr ) 739_executable = result .stdout .strip () 740 741 742def current_daemon (): 743"""The daemon the runner gave this thread.""" 744daemon = getattr (_current ,"daemon" ,None ) 745if daemon is None : 746raise RuntimeError ("run E2E tests through test/e2e/run.py" ) 747return daemon 748 749 750@ contextlib . contextmanager 751def on_daemon (daemon ): 752"""Run fixtures created on this thread against daemon.""" 753previous = getattr (_current ,"daemon" ,None ) 754_current .daemon = daemon 755try : 756yield daemon 757finally : 758_current .daemon = previous 759 760 761def shutdown (): 762"""Stop every daemon, then the provider server.""" 763global _server 764# The run is over and no test watches these daemons stop, so none is 765# drained: a graceful stop sits out OTP's one-second pause for buffered 766# output (user_sup:terminate/2), and the shared daemon's drain of every 767# session takes longer still. Their kernels exit with them. 768for daemon in list (_daemons ): 769daemon .kill () 770for thread in list (_discarding ): 771thread .join () 772_discarding .clear () 773for daemon in list (_daemons ): 774daemon .shutdown () 775if _server is not None : 776_server .shutdown () 777_server .server_close () 778_server = None 779with _config_lock : 780_providers .clear () 781 782 783atexit .register (shutdown ) 784 785 786def _write_config (path ,value ): 787with tempfile .NamedTemporaryFile (mode = "w" ,dir = path .parent ,delete = False )as output : 788json .dump (value ,output ) 789temporary = output .name 790os .replace (temporary ,path ) 791 792 793class Albedo : 794"""A test's workspace and provider profile on the daemon the runner gave it.""" 795 796def __init__ ( 797self , 798provider :Provider | None = None , 799* , 800protocol = "chat_completions" , 801providers = None , 802prepare = None , 803 ): 804self .provider = provider or Provider (lambda _request :text ("ok" )) 805self ._owns_provider = provider is None 806self .protocol = protocol 807self .providers = providers 808self .prepare = prepare 809 810def __enter__ (self ): 811self .daemon = daemon = current_daemon () 812with daemon .lock : 813self .root ,self .home ,self .env = daemon .root ,daemon .home ,daemon .env 814self .workspace = Path (tempfile .mkdtemp (prefix = "workspace-" ,dir = self .root )) 815self ._concurrent = daemon .concurrent 816if self .prepare : 817self .prepare (self ) 818if not (self .home / "extensions.json" ).exists (): 819self .write_extensions ({}) 820default_name = ( 821f"fixture- { self . provider . route } " if self ._concurrent else "fixture" 822 ) 823configured :dict [str ,Any ]= ( 824self .providers 825if self .providers is not None 826else { 827default_name : { 828"extension" :"openai" , 829"baseUrl" :self .provider .url , 830"apiKey" :"fixture-key" , 831"model" :"fixture-model" , 832"protocol" :self .protocol , 833 } 834 } 835 ) 836self .profile = next (iter (configured ),None ) 837if configured : 838current :dict [str ,Any ]= ( 839json .loads ((self .home / "config.json" ).read_text ()) 840if self ._concurrent and (self .home / "config.json" ).exists () 841else {"providers" : {}} 842 ) 843current ["providers" ].update (configured ) 844current ["active" ]= ( 845current .get ("active" ,self .profile ) 846if self ._concurrent 847else self .profile 848 ) 849_write_config (self .home / "config.json" ,current ) 850elif not self ._concurrent and (self .home / "config.json" ).exists (): 851 (self .home / "config.json" ).unlink () 852if not daemon .booted : 853daemon .boot () 854return self 855 856@property 857def connection (self ): 858return self .daemon .connection 859 860@property 861def base (self ): 862return self .daemon .base 863 864def restart (self ,* ,crash = False ,prepare = None ): 865"""Restart the daemon; optionally prepare offline storage fixtures.""" 866self .daemon .restart ( 867crash = crash ,prepare = None if prepare is None else lambda :prepare (self ) 868 ) 869 870def _fail (self ,message ): 871self .daemon ._fail (message ) 872 873def api (self ,path ,body = None ,* ,method = None ,headers = None ): 874return self .daemon .api (path ,body ,method = method ,headers = headers ) 875 876def cli (self ,* args ): 877return self .daemon .cli (* args ) 878 879def write_extensions (self ,settings ): 880"""Replace extensions.json, keeping the daemon offline in whatever 881catalog settings leave out.""" 882merged = { 883name : {** OFFLINE .get (name , {}),** value } 884if name in OFFLINE and isinstance (value ,dict ) 885else value 886for name ,value in {** OFFLINE ,** settings }.items () 887 } 888 (self .home / "extensions.json" ).write_text (json .dumps (merged )) 889 890def store_secrets (self ,section ,value ): 891"""Replaces one creds.json section, where the daemon keeps every secret.""" 892path = self .home / "creds.json" 893creds = json .loads (path .read_text ())if path .exists ()else {} 894creds [section ]= value 895path .write_text (json .dumps (creds )) 896path .chmod (0o600 ) 897 898def session (self ,workspace = None ): 899session_id = operation_id () 900with self .api ( 901f"/sessions/ { session_id } " , 902 { 903"kind" :"new" , 904"workspace" :str (workspace or self .workspace ), 905"provider_profile" :self .profile , 906 }, 907method = "PUT" , 908headers = {"If-None-Match" :"*" }, 909 )as response : 910created = json .load (response ) 911assert created ["id" ]== session_id ,created 912assert created ["provider_profile" ]== self .profile ,created 913return session_id 914 915def prompt (self ,session_id ,content ): 916return self .api ( 917f"/sessions/ { session_id } /inputs/ { operation_id () } " , 918 {"kind" :"message" ,"text" :content }, 919method = "PUT" , 920 ) 921 922def idle (self ,session_id ,timeout = 30 ): 923deadline = time .monotonic ()+ timeout 924snapshot = None 925while time .monotonic ()< deadline : 926try : 927with self .api (f"/sessions/ { session_id } ?tail=0" )as response : 928snapshot = json .load (response ) 929except urllib .error .HTTPError as error : 930if error .code != 503 : 931raise 932snapshot = json .load (error ) 933else : 934if ( 935snapshot ["status" ]["phase" ]== "idle" 936and not snapshot ["pending_inputs" ] 937 ): 938return snapshot 939time .sleep (0.05 ) 940self ._fail (f"session did not settle: { snapshot } " ) 941 942def stream_page (self ,session_id ,after = None ,* ,tail = None ): 943query = {} 944if after is not None : 945query = { 946"after_generation" :after ["generation" ], 947"after_seq" :after ["cursor" ], 948 } 949if tail is not None : 950query ["tail" ]= tail 951suffix = "?" + urllib .parse .urlencode (query )if query else "" 952with self .api ( 953f"/sessions/ { session_id } { suffix } " , 954headers = {"Accept" :"text/event-stream" }, 955 )as response : 956for line in response : 957if line .startswith (b"data: " ): 958return json .loads (line [6 :]) 959self ._fail ("stream closed before its first batch" ) 960 961def history (self ,session_id ): 962with self .api (f"/sessions/ { session_id } /history" )as response : 963return json .load (response ) 964 965def __exit__ (self ,* _exc ): 966if self ._owns_provider : 967self .provider .close ()