okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
cba5ff8
main
1"""Transcript paging and what the transcript keeps, through the real daemon.""" 2 3from contextlib import closing 4from dataclasses import dataclass 5import http .client 6import http .server 7from collections .abc import Callable 8import json 9import resource 10import socket 11import sqlite3 12import threading 13import time 14import unittest 15import urllib .error 16import urllib .parse 17 18from inspect_support import Inspection 19from harness import Albedo ,Provider ,Reply ,exclusive ,operation_id ,python ,text 20 21# More client connections than a macOS shell's default open-file limit. 22CONNECTIONS = 300 23 24 25def kill_listener_supervisor (app ): 26"""Kill the daemon's listener supervisor over Erlang distribution and 27return its pid: the keeper labels itself `albedo_listener`, and the 28supervisor is its one proc_lib-started link.""" 29expression = ( 30"[Keeper] = Call(lists, filter, [fun(P) -> " 31"case catch proc_lib:get_label(P) of {albedo_listener, _} -> true; _ -> false end " 32"end, Call(erlang, processes, [])]), " 33"{links, Links} = Call(erlang, process_info, [Keeper, links]), " 34"[Listener] = Call(lists, filter, " 35"[fun(P) -> proc_lib:initial_call(P) =/= false end, Links]), " 36"true = Call(erlang, exit, [Listener, kill]), " 37'io:format("~w", [Listener])' 38 ) 39return Inspection (app ).evaluate (expression ).strip () 40 41 42@dataclass 43class HeldCatalog : 44url :str 45release :Callable [[],None ] 46 47 48def held_catalog (): 49"""A local models catalog whose every request waits until it is released.""" 50released = threading .Event () 51 52class Handler (http .server .BaseHTTPRequestHandler ): 53def log_message (self ,format ,* args ): 54pass 55 56def do_GET (self ): 57released .wait () 58 59server = http .server .ThreadingHTTPServer (("127.0.0.1" ,0 ),Handler ) 60server .daemon_threads = True 61threading .Thread (target = server .serve_forever ,daemon = True ).start () 62 63def release (): 64released .set () 65server .shutdown () 66server .server_close () 67 68return HeldCatalog (f"http://127.0.0.1: { server . server_port } /api.json" ,release ) 69 70 71def read_response_details (connection ): 72"""Read one known-length HTTP response from a raw socket.""" 73head = b"" 74while b"\r\n\r\n" not in head : 75chunk = connection .recv (65536 ) 76if not chunk : 77raise AssertionError ("connection closed before a response" ) 78head += chunk 79lines = head .split (b"\r\n\r\n" ,1 ) 80body = lines [1 ]if len (lines )> 1 else b"" 81length = 0 82headers = {} 83for line in lines [0 ].split (b"\r\n" )[1 :]: 84name ,_ ,value = line .partition (b":" ) 85name = name .strip ().lower () 86headers [name .decode ()]= value .strip ().decode () 87if name == b"content-length" : 88length = int (value .strip ()) 89while len (body )< length : 90chunk = connection .recv (65536 ) 91if not chunk : 92raise AssertionError ("connection closed mid-body" ) 93body += chunk 94return int (lines [0 ].split (b" " )[1 ]),headers ,body 95 96 97def read_response (connection ): 98return read_response_details (connection )[0 ] 99 100 101def refused_request (app ,path ,headers ,* ,method = "PUT" ): 102"""Send only headers and require a refusal and closure within two seconds.""" 103address = urllib .parse .urlsplit (app .base ) 104head = ( 105f" { method } { path } HTTP/1.1\r\n" 106f"Host: { address . netloc } \r\n" 107+ "" .join (f" { name } : { value } \r\n" for name ,value in headers .items ()) 108+ "\r\n" 109 ).encode () 110with socket .create_connection ( 111 (address .hostname ,address .port ),timeout = 2 112 )as connection : 113connection .sendall (head ) 114result = read_response_details (connection ) 115try : 116closed = connection .recv (1 )== b"" 117except ConnectionResetError : 118closed = True 119if not closed : 120raise AssertionError ("refusal left the connection open" ) 121return result 122 123 124def delayed_request (app ,method ,path ,body ,headers ): 125"""Split the headers and delay the body, then reuse the connection.""" 126address = urllib .parse .urlsplit (app .base ) 127token = app .connection ["token" ] 128head = ( 129f" { method } { path } HTTP/1.1\r\n" 130f"Host: { address . netloc } \r\n" 131+ "" .join (f" { name } : { value } \r\n" for name ,value in headers .items ()) 132+ f"Content-Type: application/json\r\n" 133f"Content-Length: { len ( body ) } \r\n\r\n" 134 ).encode () 135connection = socket .create_connection ((address .hostname ,address .port ),timeout = 20 ) 136try : 137connection .sendall (head [:17 ]) 138time .sleep (0.01 ) 139connection .sendall (head [17 :]) 140time .sleep (0.05 ) 141connection .sendall (body ) 142first = read_response (connection ) 143connection .sendall ( 144 ( 145f"GET /server HTTP/1.1\r\n" 146f"Host: { address . netloc } \r\n" 147f"Authorization: Bearer { token } \r\n\r\n" 148 ).encode () 149 ) 150return first ,read_response (connection ) 151finally : 152connection .close () 153 154 155class DaemonTest (unittest .TestCase ): 156def test_history_pages_recover_older_turns_once (self ): 157provider = Provider (lambda _request :text ("answer" )) 158self .addCleanup (provider .close ) 159with Albedo (provider )as app : 160session = app .session () 161for message in ("oldest prompt" ,"middle prompt" ,"newest prompt" ): 162app .prompt (session ,message ).close () 163app .idle (session ) 164 165pages = [] 166next_page = None 167while True : 168query = {"limit" :2 } 169if next_page is not None : 170query ["next" ]= next_page 171with app .api ( 172f"/sessions/ { session } /history?" + urllib .parse .urlencode (query ) 173 )as response : 174page = json .load (response ) 175pages .append (page ) 176if page ["older" ]is None : 177break 178self .assertIsInstance (page ["older" ],str ) 179self .assertNotEqual (page ["older" ],next_page ) 180next_page = page ["older" ] 181prompts = [ 182 [ 183part ["text" ] 184for entry in page ["items" ] 185if entry ["kind" ]== "user" 186for part in entry ["content" ] 187if part ["kind" ]== "text" 188 ] 189for page in pages 190 ] 191self .assertEqual ( 192 [turn for turn in prompts if turn ], 193 [["newest prompt" ], ["middle prompt" ], ["oldest prompt" ]], 194 ) 195self .assertIsNone (pages [- 1 ]["older" ]) 196 197# exclusive: restarts the daemon 198@exclusive 199def test_a_clean_restart_leaves_no_registry_crash_loop (self ): 200# Shutdown closes the store before the VM halts, and the supervisor 201# may restart the registry inside that window; its init used to panic 202# on the closed store ten times before the supervisor gave up. What 203# holds the VM open long enough to lose the race is a models catalog 204# fetch still in flight, so the restart runs against a local catalog 205# that never answers; the fixture config itself stays, so later 206# fixtures keep their provider. 207provider = Provider (lambda _request :text ("answer" )) 208self .addCleanup (provider .close ) 209catalog = held_catalog () 210self .addCleanup (catalog .release ) 211with Albedo (provider )as app : 212settings = json .loads ((app .home / "extensions.json" ).read_text ()) 213settings ["models" ]= {"url" :catalog .url } 214 (app .home / "extensions.json" ).write_text (json .dumps (settings )) 215 (app .home / "models.json" ).unlink (missing_ok = True ) 216log = app .home / "daemon.log" 217before = log .stat ().st_size if log .exists ()else 0 218app .restart () 219app .restart () 220tail = log .read_text (errors = "replace" )[before :] 221for marker in ("Noproc" ,"reached_max_restart_intensity" ,"callee exited" ): 222self .assertNotIn (marker ,tail ) 223 224# exclusive: writes an unreadable transcript row and restarts the daemon 225@exclusive 226def test_an_unreadable_transcript_row_is_healed_at_startup (self ): 227# Startup indexes and classifies rows saved before those columns 228# existed. One it could not decode stopped the boot, and every later 229# boot at the same row. It now becomes a note, its bytes set aside. 230provider = Provider (lambda _request :text ("answer" )) 231self .addCleanup (provider .close ) 232unreadable = b"not a transcript item" 233with Albedo (provider )as app : 234healthy = app .session () 235app .prompt (healthy ,"hello" ).close () 236app .idle (healthy ) 237broken = app .session () 238with closing (sqlite3 .connect (app .home / "albedo.sqlite" ))as db : 239seq = db .execute ( 240"INSERT INTO transcript(session,payload) VALUES(?,?) RETURNING seq" , 241 (broken ,unreadable ), 242 ).fetchone ()[0 ] 243db .commit () 244app .restart () 245self .assertIn ("answer" ,json .dumps (app .history (healthy ))) 246self .assertIn ( 247f"could not be read and was set aside (row { seq } )" , 248json .dumps (app .history (broken )), 249 ) 250with closing (sqlite3 .connect (app .home / "albedo.sqlite" ))as db : 251kept = db .execute ( 252"SELECT seq,session,payload FROM transcript_quarantine" 253 ).fetchall () 254self .assertEqual (kept , [(seq ,broken ,unreadable )]) 255app .restart () 256self .assertIn ("answer" ,json .dumps (app .history (healthy ))) 257# The set-aside bytes belong to their session and go with it. 258resource = f"/sessions/ { broken } ?view=configuration" 259with app .api (resource )as response : 260validator = response .getheader ("ETag" ) 261response .read () 262with app .api ( 263resource ,method = "DELETE" ,headers = {"If-Match" :validator } 264 )as response : 265self .assertEqual (json .load (response )["state" ],"complete" ) 266with closing (sqlite3 .connect (app .home / "albedo.sqlite" ))as db : 267self .assertEqual ( 268db .execute ("SELECT COUNT(*) FROM transcript_quarantine" ).fetchone (), 269 (0 ,), 270 ) 271 272# exclusive: shuts down and restarts the daemon 273@exclusive 274def test_requests_during_a_restart_are_answered_not_crashed (self ): 275# Shutdown closes each session and then the store before the VM halts, 276# and requests keep arriving meanwhile: ones queued behind the drain 277# used to die with the registry ("callee exited"), and later ones 278# found no registry at all. Kernels make the drain take a while, a 279# held catalog fetch keeps the VM up past the store, and a second 280# shutdown must not start a second drain. 281def script (request ): 282called = any ( 283message .get ("role" )== "tool" for message in request ["messages" ] 284 ) 285return text ("done" )if called else python ("x = 1" ) 286 287provider = Provider (script ) 288self .addCleanup (provider .close ) 289catalog = held_catalog () 290self .addCleanup (catalog .release ) 291with Albedo (provider )as app : 292settings = json .loads ((app .home / "extensions.json" ).read_text ()) 293settings ["models" ]= {"url" :catalog .url } 294 (app .home / "extensions.json" ).write_text (json .dumps (settings )) 295 (app .home / "models.json" ).unlink (missing_ok = True ) 296sessions = [app .session ()for _ in range (3 )] 297for session in sessions : 298app .prompt (session ,"keep a variable" ).close () 299for session in sessions : 300app .idle (session ) 301log = app .home / "daemon.log" 302before = log .stat ().st_size if log .exists ()else 0 303 304stop = threading .Event () 305paths = [ 306 ("GET" ,"/sessions" ,None ), 307 ("GET" ,"/providers/fixture/models" ,None ), 308 ] 309for session in sessions : 310paths += [ 311 ("GET" ,f"/sessions/ { session } ?tail=0" ,None ), 312 ("GET" ,f"/sessions/ { session } /history?view=checkpoints" ,None ), 313 ("GET" ,f"/sessions?parent_id= { session } " ,None ), 314 ("GET" ,f"/sessions/ { session } ?view=configuration" ,None ), 315 ] 316 317# One kept-alive connection per worker, reopened only when the 318# daemon drops it: a fresh socket per request exhausts the host's 319# ephemeral ports within seconds, which fails every other 320# connection on the machine, live sessions included. 321def hammer (base ,token ): 322address = urllib .parse .urlsplit (base ) 323headers = { 324"Authorization" :"Bearer " + token , 325"Content-Type" :"application/json" , 326 } 327connection = None 328while not stop .is_set (): 329for method ,path ,body in paths : 330try : 331connection = connection or http .client .HTTPConnection ( 332address .hostname ,address .port ,timeout = 20 333 ) 334payload = None if body is None else json .dumps (body ) 335connection .request (method ,path ,payload ,headers ) 336connection .getresponse ().read () 337except (http .client .HTTPException ,OSError ): 338if connection : 339connection .close () 340connection = None 341stop .wait (0.01 ) 342if connection : 343connection .close () 344 345workers = [ 346threading .Thread ( 347target = hammer ,args = (app .base ,app .connection ["token" ]) 348 ) 349for _ in range (6 ) 350 ] 351for worker in workers : 352worker .start () 353try : 354# restart() asks again while the first drain runs. 355with app .api ("/server" )as response : 356instance_id = json .load (response )["instance_id" ] 357app .api ("/server/shutdown" , {"instance_id" :instance_id }).close () 358app .restart () 359finally : 360stop .set () 361for worker in workers : 362worker .join () 363tail = log .read_text (errors = "replace" )[before :] 364for marker in ( 365"Noproc" , 366"callee exited" , 367"Callee subject had no owner" , 368"reached_max_restart_intensity" , 369 ): 370self .assertNotIn (marker ,tail ) 371 372def test_a_late_request_body_keeps_the_keep_alive_connection (self ): 373# mist parses every later TCP read as the next keep-alive request, so 374# a route that answers without reading its request body leaves those 375# bytes to that parser: a body arriving after its headers reads as a 376# new request, fails, and the connection closes without a response, 377# taking the client's next request with it. The router reads the body 378# up front, so a split write cannot cost the connection. 379provider = Provider (lambda _request :text ("answer" )) 380self .addCleanup (provider .close ) 381with Albedo (provider )as app : 382token = app .connection ["token" ] 383session = app .session () 384interrupt = f"/sessions/ { session } /interrupt" 385 386# Every shape that answers without reading its body: a plain 387# route, an ignored POST body, an unknown route, and a route that 388# answers a session actor call. 389for method ,path ,body ,status in ( 390 ("GET" ,"/server" ,b"{}" ,200 ), 391 ("GET" ,"/nope" ,b"{}" ,404 ), 392 ("PUT" ,f"/sessions/ { session } /visits/ { operation_id () } " ,b"{}" ,200 ), 393 ("POST" ,"/nope" ,b"{}" ,404 ), 394 ("POST" ,interrupt ,b'{"run_id":null,"through_input_order":0}' ,200 ), 395 ): 396with self .subTest (f" { method } { path } " ): 397self .assertEqual ( 398delayed_request ( 399app , 400method , 401path , 402body , 403 {"Authorization" :"Bearer " + token }, 404 ), 405 (status ,200 ), 406 ) 407 408def test_empty_and_incomplete_bodies_are_handled_at_ingress (self ): 409with Albedo ()as app : 410token = {"Authorization" :"Bearer " + app .connection ["token" ]} 411session = app .session () 412before = app .history (session ) 413self .assertEqual ( 414delayed_request (app ,"GET" ,"/server" ,b"" ,token ), (200 ,200 ) 415 ) 416address = urllib .parse .urlsplit (app .base ) 417with socket .create_connection ( 418 (address .hostname ,address .port ),timeout = 2 419 )as connection : 420connection .sendall ( 421 ( 422f"PUT /sessions/ { session } /inputs/ { operation_id () } HTTP/1.1\r\n" 423f"Host: { address . netloc } \r\n" 424f"Authorization: { token [ 'Authorization' ] } \r\n" 425"Content-Length: 2\r\n\r\n{" 426 ).encode () 427 ) 428connection .shutdown (socket .SHUT_WR ) 429# Mist closes the socket when its body read encounters EOF. 430self .assertEqual (connection .recv (1 ),b"" ) 431self .assertEqual (app .history (session ),before ) 432 433def test_core_refuses_before_reading_withheld_bodies (self ): 434provider = Provider (lambda _request :text ("answer" )) 435self .addCleanup (provider .close ) 436with Albedo (provider )as app : 437token = {"Authorization" :"Bearer " + app .connection ["token" ]} 438session = app .session () 439before = app .history (session ) 440path = f"/sessions/ { session } /inputs/ { operation_id () } " 441for authorization in ({}, {"Authorization" :"Bearer wrong" }): 442for length in ("2" ,"72200001" ,"invalid" ): 443with self .subTest (authorization = authorization ,length = length ): 444status ,headers ,body = refused_request ( 445app ,path , {** authorization ,"Content-Length" :length } 446 ) 447self .assertEqual (status ,401 ) 448self .assertEqual (json .loads (body )["status" ],401 ) 449self .assertEqual (headers ["www-authenticate" ].lower (),"bearer" ) 450for framing in ( 451 {"Content-Length" :"72200001" }, 452 {"Content-Length" :"invalid" }, 453 {"Transfer-Encoding" :"chunked" }, 454 ): 455with self .subTest (origin_framing = framing ): 456status ,_ ,_ = refused_request ( 457app , 458path , 459 {** token ,** framing ,"Origin" :"https://attacker.example" }, 460 ) 461self .assertEqual (status ,403 ) 462self .assertEqual (app .history (session ),before ) 463self .assertFalse (provider .requests ) 464 465def test_core_rejects_unsupported_and_oversized_body_framing (self ): 466with Albedo ()as app : 467session = app .session () 468before = app .history (session ) 469token = {"Authorization" :"Bearer " + app .connection ["token" ]} 470for framing ,expected_status in ( 471 ({"Transfer-Encoding" :"chunked" ,"Content-Length" :"2" },400 ), 472 ({"Transfer-Encoding" :"identity" ,"Content-Length" :"0" },400 ), 473 ({"Content-Length" :"-1" },400 ), 474 ({"Content-Length" :"+1" },400 ), 475 ({"Content-Length" :"1x" },400 ), 476 ({"Content-Length" :"72200001" },413 ), 477 ): 478with self .subTest (framing = framing ): 479status ,_ ,body = refused_request ( 480app , 481f"/sessions/ { session } /inputs/ { operation_id () } " , 482 {** token ,** framing }, 483 ) 484self .assertEqual (status ,expected_status ) 485if framing .get ("Transfer-Encoding" )== "identity" : 486self .assertEqual (json .loads (body )["code" ],"invalid_request" ) 487self .assertEqual (app .history (session ),before ) 488 489input_id = operation_id () 490resource = f"/sessions/ { session } /inputs/ { input_id } " 491payload = {"kind" :"message" ,"text" :"identity encoded input" } 492status ,_ ,_ = refused_request ( 493app , 494resource , 495 {"Content-Encoding" :"gzip" ,"Content-Length" :"100" }, 496 ) 497self .assertEqual (status ,401 ) 498with self .assertRaises (urllib .error .HTTPError )as caught : 499app .api ( 500resource , 501payload , 502method = "PUT" , 503headers = {"Content-Encoding" :"gzip" }, 504 ) 505self .assertEqual (caught .exception .code ,415 ) 506self .assertEqual ( 507json .load (caught .exception )["code" ],"unsupported_encoding" 508 ) 509self .assertEqual (app .history (session ),before ) 510with app .api ( 511resource , 512payload , 513method = "PUT" , 514headers = {"Content-Encoding" :"identity" }, 515 )as response : 516self .assertEqual (response .status ,202 ) 517app .idle (session ) 518 519# exclusive: changes daemon startup open-file limits 520@exclusive 521def test_a_daemon_started_from_a_macos_shell_answers_hundreds_of_connections (self ): 522# macOS starts a shell at a soft limit of 256 open files. The CLI, like 523# any Go program, lifts its own limit but starts children on the 524# original one, and a few hundred client connections would then run 525# the daemon out of files and take its listener down mid-accept. 526hard = resource .getrlimit (resource .RLIMIT_NOFILE )[1 ] 527 528def prepare (app ): 529app .daemon .open_files = (256 ,hard ) 530 531with Albedo (prepare = prepare )as app : 532headers = {"Authorization" :"Bearer " + app .connection ["token" ]} 533connections = [] 534for _ in range (CONNECTIONS ): 535connection = http .client .HTTPConnection ( 536"127.0.0.1" ,app .connection ["port" ],timeout = 20 537 ) 538connection .connect () 539self .addCleanup (connection .close ) 540connections .append (connection ) 541for connection in connections : 542connection .request ("GET" ,"/server" ,headers = headers ) 543response = connection .getresponse () 544response .read () 545self .assertEqual (response .status ,200 ) 546 547# exclusive: changes daemon open-file limits and crashes its listener 548@exclusive 549def test_a_listener_out_of_files_comes_back_on_the_daemons_port (self ): 550# Accepts past the open-file limit crash glisten's acceptors until mist 551# restarts the whole listener, on the port it was built with. A port 552# the OS picks at each start would bring it back somewhere daemon.json 553# does not say, leaving a live daemon no client can reach. 554def prepare (app ): 555app .daemon .open_files = (128 ,128 ) 556 557with Albedo (prepare = prepare )as app : 558pid ,port = app .connection ["pid" ],app .connection ["port" ] 559flood = [] 560for _ in range (CONNECTIONS ): 561try : 562flood .append ( 563socket .create_connection (("127.0.0.1" ,port ),timeout = 20 ) 564 ) 565except OSError : 566pass 567# Nothing the daemon sends ends these sockets: each one ends when 568# the restarted listener's tree drops the connection it accepted, 569# or resets the one still waiting in its old socket's backlog. 570for connection in flood : 571try : 572self .assertEqual (connection .recv (1 ),b"" ) 573except ConnectionResetError : 574pass 575connection .close () 576 577record = json .loads ((app .home / "daemon.json" ).read_text ()) 578self .assertEqual ((record ["pid" ],record ["port" ]), (pid ,port )) 579deadline = time .monotonic ()+ 20 580while True : 581try : 582with app .api ("/server" )as response : 583self .assertEqual (response .status ,200 ) 584break 585except OSError : 586self .assertLess ( 587time .monotonic (),deadline ,"the daemon's port stayed closed" 588 ) 589time .sleep (0.1 ) 590 591# exclusive: enables daemon inspection and kills the listener's supervisor 592@exclusive 593def test_the_daemon_outlives_its_listener_supervisor (self ): 594# A listener tree that gives up (every acceptor crashing under a flood, 595# say) must not take the daemon with it: the keeper brings the listener 596# back on the same port while sessions and kernels carry on. 597with Albedo ( 598prepare = lambda app :app .daemon .env .update (ALBEDO_INSPECT = "1" ) 599 )as app : 600pid ,port = app .connection ["pid" ],app .connection ["port" ] 601killed = kill_listener_supervisor (app ) 602self .assertTrue (killed .startswith ("<" ),killed ) 603deadline = time .monotonic ()+ 15 604while True : 605try : 606with app .api ("/server" )as response : 607self .assertEqual (response .status ,200 ) 608break 609except OSError : 610self .assertLess ( 611time .monotonic (),deadline ,"the daemon's port stayed closed" 612 ) 613time .sleep (0.1 ) 614record = json .loads ((app .home / "daemon.json" ).read_text ()) 615self .assertEqual ((record ["pid" ],record ["port" ]), (pid ,port )) 616 617def test_a_thoughts_duration_is_kept_with_the_transcript (self ): 618# summarized thinking streams once it is written: here the response 619# opens, thinks 0.6s, and its summary comes 0.3s before the answer, 620# so the thought is timed from the opening, not the summary 621def chunk (delta ,finish = None ): 622return { 623"id" :"fixture" , 624"choices" : [{"index" :0 ,"delta" :delta ,"finish_reason" :finish }], 625 } 626 627reply = Reply ( 628"text" , 629delay = 0.3 , 630events = [ 631chunk ({"role" :"assistant" }), 632chunk ({"reasoning_content" :"weighing it" }), 633chunk ({"content" :"answer" }), 634chunk ({},"stop" ), 635 ], 636 ) 637provider = Provider (lambda _request :reply ) 638self .addCleanup (provider .close ) 639with Albedo (provider )as app : 640session = app .session () 641before = app .stream_page (session ) 642app .prompt (session ,"think first" ).close () 643app .idle (session ) 644 645replay = app .stream_page (session ,before ) 646thoughts = [ 647event ["data" ] 648for event in replay ["events" ] 649if event ["type" ]== "thinking" 650 ] 651self .assertEqual ( 652"" .join (thought ["text" ]for thought in thoughts ),"weighing it" 653 ) 654 [published ]= [ 655event ["data" ]["entry" ] 656for event in replay ["events" ] 657if event ["type" ]== "message" 658and event ["data" ]["entry" ]["kind" ]== "thinking" 659 ] 660self .assertGreaterEqual (published ["thinking_duration_ms" ],500 ) 661 [durable ]= [ 662entry 663for entry in app .history (session )["items" ] 664if entry ["kind" ]== "thinking" 665 ] 666self .assertEqual ( 667"" .join ( 668part ["text" ] 669for part in durable ["content" ] 670if part ["kind" ]== "text" 671 ), 672"weighing it" , 673 ) 674self .assertGreaterEqual (durable ["thinking_duration_ms" ],500 ) 675self .assertEqual (published ["id" ],durable ["id" ]) 676 677def test_chunked_input_admission_counts_decoded_body_and_keeps_connection (self ): 678provider = Provider (lambda _ :text ("chunked answer" )) 679self .addCleanup (provider .close ) 680with Albedo (provider )as app : 681session = app .session () 682input_id = operation_id () 683intent = {"kind" :"message" ,"text" :"chunked Café 😀" } 684payload = json .dumps (intent ,ensure_ascii = False ).encode () 685chunks = [payload [index :index + 3 ]for index in range (0 ,len (payload ),3 )] 686connection = http .client .HTTPConnection ( 687"127.0.0.1" ,app .connection ["port" ],timeout = 20 688 ) 689self .addCleanup (connection .close ) 690headers = { 691"Authorization" :"Bearer " + app .connection ["token" ], 692"Content-Type" :"application/json" , 693 } 694connection .request ( 695"PUT" , 696f"/sessions/ { session } /inputs/ { input_id } " , 697chunks , 698headers , 699encode_chunked = True , 700 ) 701response = connection .getresponse () 702self .assertEqual (response .status ,202 ) 703self .assertEqual (json .load (response )["id" ],input_id ) 704connection .request ("GET" ,"/server" ,headers = headers ) 705response = connection .getresponse () 706self .assertEqual (response .status ,200 ) 707self .assertEqual (json .load (response )["state" ],"ready" ) 708app .idle (session ) 709users = [ 710entry 711for entry in app .history (session )["items" ] 712if entry ["kind" ]== "user" 713 ] 714self .assertEqual ([entry ["input_id" ]for entry in users ], [input_id ]) 715self .assertEqual ( 716 [ 717part ["text" ] 718for entry in users 719for part in entry ["content" ] 720if part ["kind" ]== "text" 721 ], 722 [intent ["text" ]], 723 ) 724self .assertEqual (len (provider .requests ),1 )