okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
79b55b3
main
1"""Session setup, ingress failures, workspace repair, and image inspection.""" 2 3import base64 4import json 5from pathlib import Path 6import sqlite3 7import time 8import urllib .error 9import urllib .parse 10 11from harness import exclusive ,operation_id 12 13from image_limits_test import png 14from integration_support import IntegrationScenario 15 16 17class IntegrationTest (IntegrationScenario ): 18# exclusive: changes scheduler timing and installs a global database trigger 19@exclusive 20def test_scheduler_reports_a_failed_occurrence_write_and_retries (self ): 21def prepare (app ): 22app .env ["ALBEDO_SCHEDULE_TICK_MS" ]= "50" 23 24app = self .app_for ("chat_completions" ,prepare = prepare ) 25session = app .session () 26with sqlite3 .connect (app .home / "albedo.sqlite" )as db : 27db .execute ( 28"CREATE TRIGGER reject_advance BEFORE DELETE ON schedules " 29"BEGIN SELECT RAISE(FAIL, 'fixture advance failed'); END" 30 ) 31db .execute ( 32"INSERT INTO schedules(session,kind,prompt,next_at) VALUES(?,'once',?,0)" , 33 (session ,"scheduled fixture" ), 34 ) 35deadline = time .monotonic ()+ 15 36while "fixture advance failed" not in (app .home / "daemon.log" ).read_text (): 37self .assertLess (time .monotonic (),deadline ) 38time .sleep (0.05 ) 39self .assertIn ( 40"schedule occurrence could not be advanced" , 41 (app .home / "daemon.log" ).read_text (), 42 ) 43with sqlite3 .connect (app .home / "albedo.sqlite" )as db : 44self .assertEqual ( 45db .execute ("SELECT COUNT(*) FROM schedules" ).fetchone ()[0 ],1 46 ) 47db .execute ("DROP TRIGGER reject_advance" ) 48while True : 49with sqlite3 .connect (app .home / "albedo.sqlite" )as db : 50if db .execute ("SELECT COUNT(*) FROM schedules" ).fetchone ()[0 ]== 0 : 51break 52self .assertLess (time .monotonic (),deadline ) 53time .sleep (0.05 ) 54app .idle (session ) 55self .assertTrue (self .provider .requests ) 56 57# exclusive: renames the global session_family table 58@exclusive 59def test_deletion_refuses_a_failed_child_lookup (self ): 60app = self .app_for ("chat_completions" ) 61session = app .session () 62with app .api (f"/sessions/ { session } ?view=configuration" )as response : 63configuration = json .load (response ) 64etag = response .headers ["ETag" ] 65with sqlite3 .connect (app .home / "albedo.sqlite" )as db : 66db .execute ("ALTER TABLE session_family RENAME TO unavailable_family" ) 67for scope in ("leaf" ,"subtree" ): 68with self .subTest (scope = scope ): 69with self .assertRaises (urllib .error .HTTPError )as caught : 70app .api ( 71f"/sessions/ { session } ?view=configuration&scope= { scope } " 72f"&family_revision= { configuration [ 'family_revision' ] } " , 73method = "DELETE" , 74headers = {"If-Match" :etag }, 75 ).close () 76self .assertGreaterEqual (caught .exception .code ,400 ) 77with sqlite3 .connect (app .home / "albedo.sqlite" )as db : 78self .assertIsNotNone ( 79db .execute ( 80"SELECT id FROM sessions WHERE id=?" , (session ,) 81 ).fetchone () 82 ) 83with sqlite3 .connect (app .home / "albedo.sqlite" )as db : 84db .execute ("ALTER TABLE unavailable_family RENAME TO session_family" ) 85with app .api ( 86f"/sessions/ { session } ?view=configuration&scope=subtree" 87f"&family_revision= { configuration [ 'family_revision' ] } " , 88method = "DELETE" , 89headers = {"If-Match" :etag }, 90 )as response : 91self .assertEqual (json .load (response )["deleted_ids" ], [session ]) 92 93# exclusive: tests unconfigured startup and legacy migration 94@exclusive 95def test_unconfigured_startup_and_legacy_provider_migration (self ): 96def prepare (app ): 97workspace = app .root / "legacy-workspace" 98workspace .mkdir (exist_ok = True ) 99with sqlite3 .connect (app .home / "albedo.sqlite" )as db : 100db .execute ( 101"CREATE TABLE sessions(id TEXT PRIMARY KEY,cwd TEXT NOT NULL,model TEXT NOT NULL,protocol TEXT NOT NULL,stage TEXT NOT NULL DEFAULT 'idle')" 102 ) 103for protocol in ("responses" ,"chat_completions" ): 104db .execute ( 105"INSERT INTO sessions(id,cwd,model,protocol) VALUES(?,?,?,?)" , 106 ( 107"legacy-" + protocol , 108str (workspace ), 109"legacy-model" , 110protocol , 111 ), 112 ) 113 114app = self .app_for ("responses" ,prepare = prepare ,providers = {}) 115health = self .read (app ,"/server" ) 116self .assertEqual (health ["state" ],"ready" ) 117unconfigured = self .read (app ,"/sessions?scope=all" )["items" ] 118self .assertEqual ( 119 {info ["id" ]for info in unconfigured }, 120 {"legacy-responses" ,"legacy-chat_completions" }, 121 ) 122for info in unconfigured : 123self .assertIsNone (info ["provider_profile" ]) 124snapshot = self .read (app ,f"/sessions/ { info [ 'id' ] } ?tail=0" ) 125self .assertIsNone (snapshot ["provider_profile" ]) 126resource = snapshot ["configuration_resource" ] 127self .assertIsNone (resource ["value" ]["provider_profile" ]) 128self .assertIsNone (self .read (app ,resource ["url" ])["provider_profile" ]) 129with self .assertRaises (urllib .error .HTTPError )as caught : 130app .api ( 131f"/sessions/ { operation_id () } " , 132 {"kind" :"new" ,"workspace" :str (app .workspace )}, 133method = "PUT" , 134headers = {"If-None-Match" :"*" }, 135 ).close () 136self .assertEqual (caught .exception .code ,409 ) 137self .assertIn ("/login" ,json .load (caught .exception )["detail" ]) 138for protocol in ("responses" ,"chat_completions" ): 139with self .subTest (protocol = protocol ): 140legacy = "legacy-" + protocol 141self .settings (app ,protocol ) 142config = json .loads ((app .home / "config.json" ).read_text ()) 143config ["providers" ]["default" ]= { 144"baseUrl" :self .provider .url + "/legacy/v1" , 145"apiKey" :"legacy-key" , 146"model" :"legacy-model" , 147"protocol" :protocol , 148 } 149 (app .home / "config.json" ).write_text (json .dumps (config )) 150start = len (self .provider .requests ) 151self .send (app ,legacy ,"inspect the legacy workspace" ) 152requests = self .records (start ) 153self .assertTrue (requests ) 154self .assertTrue (all ("/legacy/v1/" in r ["path" ]for r in requests )) 155self .assertTrue ( 156all (r ["authorization" ]== "Bearer legacy-key" for r in requests ) 157 ) 158session = self .default_session (app ) 159info = next ( 160s 161for s in self .read (app ,"/sessions?scope=all" )["items" ] 162if s ["id" ]== session 163 ) 164self .assertEqual ( 165 (info ["provider_profile" ],info ["automatic_name" ]), 166 ("alpha" ,"new session" ), 167 ) 168pid = app .connection ["pid" ] 169app .cli ("sessions" ) 170self .assertEqual ( 171json .loads ((app .home / "daemon.json" ).read_text ())["pid" ],pid 172 ) 173self .restart (app ) 174info = next ( 175s 176for s in self .read (app ,"/sessions?scope=all" )["items" ] 177if s ["id" ]== legacy 178 ) 179self .assertEqual ( 180 (info ["provider_profile" ],info ["automatic_name" ]), 181 ("default" ,"inspect the legacy workspace" ), 182 ) 183 184def test_image_metadata_is_verified_and_only_metadata_is_exposed (self ): 185for protocol in ("responses" ,"chat_completions" ): 186with self .subTest (protocol = protocol ): 187app = self .app_for (protocol ) 188session = app .session () 189png = "iVBORw0KGgoAAAANSUhEUgAAAAIAAAADCAIAAAA2iEnWAAAAAElFTkSuQmCC" 190with self .assertRaises (urllib .error .HTTPError )as caught : 191app .api ( 192f"/sessions/ { session } /inputs/ { operation_id () } " , 193 { 194"kind" :"message" , 195"text" :"reject this" , 196"images" : [ 197 {"mime_type" :"image/png" ,"data" :"not base64" } 198 ], 199 }, 200method = "PUT" , 201 ).close () 202self .assertEqual (caught .exception .code ,400 ) 203start = len (self .provider .requests ) 204app .api ( 205f"/sessions/ { session } /inputs/ { operation_id () } " , 206 { 207"kind" :"message" , 208"text" :"describe this image" , 209"images" : [{"mime_type" :"image/png" ,"data" :png }], 210 }, 211method = "PUT" , 212 ).close () 213app .idle (session ) 214self .send (app ,session ,"confirm image history" ) 215requests = self .records (start ) 216self .assertEqual (len (requests ),3 ) 217self .assertTrue ( 218all ( 219"data:image/png;base64," + png in json .dumps (r ["request" ]) 220for r in requests 221 ) 222 ) 223image = next ( 224part ["image" ] 225for e in self .history (app ,session ) 226for part in e ["content" ] 227if part ["kind" ]== "image" 228 ) 229self .assertEqual ( 230 ( 231image ["mime_type" ], 232image ["width" ], 233image ["height" ], 234image ["original_bytes" ], 235 ), 236 ("image/png" ,2 ,3 ,45 ), 237 ) 238self .assertNotIn (png ,json .dumps (image )) 239 240def test_message_images_follow_their_text_in_attachment_order (self ): 241app = self .app_for ("responses" ) 242session = app .session () 243first ,second = ( 244base64 .b64encode (png (width ,height )).decode () 245for width ,height in ((2 ,3 ), (4 ,5 )) 246 ) 247upload = [ 248 {"mime_type" :"image/png" ,"data" :first }, 249 {"mime_type" :"image/png" ,"data" :second }, 250 ] 251with self .assertRaises (urllib .error .HTTPError )as caught : 252app .api ( 253f"/sessions/ { session } /inputs/ { operation_id () } " , 254 {"kind" :"message" ,"text" :"too many" ,"images" :upload * 6 }, 255method = "PUT" , 256 ).close () 257self .assertEqual (caught .exception .code ,400 ) 258start = len (self .provider .requests ) 259text = "[Image #1] is before, then I wrote this, and [Image #2] is after" 260app .api ( 261f"/sessions/ { session } /inputs/ { operation_id () } " , 262 {"kind" :"message" ,"text" :text ,"images" :upload }, 263method = "PUT" , 264 ).close () 265app .idle (session ) 266request = json .dumps (self .records (start )[0 ]["request" ]) 267positions = [ 268request .index (needle ) 269for needle in (text ,"base64," + first ,"base64," + second ) 270 ] 271self .assertEqual (positions ,sorted (positions )) 272user = next (e for e in self .history (app ,session )if e ["kind" ]== "user" ) 273self .assertEqual (self .entry_text (user ),text ) 274self .assertEqual ( 275 [ 276 (part ["image" ]["width" ],part ["image" ]["height" ]) 277for part in user ["content" ] 278if part ["kind" ]== "image" 279 ], 280 [(2 ,3 ), (4 ,5 )], 281 ) 282 283def test_short_pastes_are_inlined_and_long_ones_saved_for_the_model (self ): 284app = self .app_for ("responses" ) 285session = app .session () 286short = "\n" .join (f"short line { n } " for n in range (20 )) 287long = "\n" .join (f"long line { n } " for n in range (150 )) 288text = "read [Paste #1, +20 lines] and then [Paste #2, +150 lines] please" 289start = len (self .provider .requests ) 290input_id = operation_id () 291app .api ( 292f"/sessions/ { session } /inputs/ { input_id } " , 293 {"kind" :"message" ,"text" :text ,"pastes" : [short ,long ]}, 294method = "PUT" , 295 ).close () 296app .idle (session ) 297saved = app .home / "pastes" / session / f" { input_id } -2.md" 298self .assertEqual (saved .read_text (),long ) 299request = json .dumps (self .records (start )[0 ]["request" ]) 300sent = f"read { short } and then [Paste #2, 150 lines, saved to { saved } ] please" 301self .assertIn (json .dumps (sent )[1 :- 1 ],request ) 302user = next (e for e in self .history (app ,session )if e ["kind" ]== "user" ) 303self .assertEqual (self .entry_text (user ),text ) 304 305def test_busy_messages_join_next_model_request_in_order (self ): 306app = self .app_for ("responses" ) 307session = app .session () 308start = len (self .provider .requests ) 309app .prompt (session ,"first task" ).close () 310self .wait_for_request (start ) 311app .prompt (session ,"queued direction" ).close () 312app .prompt (session ,"another direction" ).close () 313app .idle (session ) 314requests = self .records (start ) 315self .assertEqual (len (requests ),2 ) 316first ,second = (json .dumps (r ["request" ])for r in requests ) 317self .assertNotIn ("queued direction" ,first ) 318self .assertIn ("queued direction" ,second ) 319self .assertIn ("another direction" ,second ) 320self .assertLess ( 321second .index ("queued direction" ),second .index ("another direction" ) 322 ) 323self .assertTrue ( 324any ( 325e ["kind" ]== "user" and self .entry_text (e )== "queued direction" 326for e in self .history (app ,session ) 327 ) 328 ) 329 330# exclusive: restarts the daemon 331@exclusive 332def test_workspace_repair_validates_path_and_preserves_history (self ): 333app = self .app_for ("responses" ) 334old = app .root / "original-workspace" 335old .mkdir () 336moved = app .root / "moved-workspace" 337session = app .session (old ) 338self .send (app ,session ,"workspace probe before move" ) 339self .assertEqual (Path ((old / "cwd-probe" ).read_text ()).resolve (),old .resolve ()) 340old .rename (moved ) 341pid = app .connection ["pid" ] 342before = self .history (app ,session ) 343identity = operation_id () 344app .api ( 345f"/sessions/ { session } /inputs/ { identity } " , 346 {"kind" :"message" ,"text" :"workspace probe after move" }, 347method = "PUT" , 348 ).close () 349receipt = self .read (app ,f"/sessions/ { session } /inputs/ { identity } " ) 350self .assertEqual (receipt ["delivery" ],"pending" ) 351for invalid in ("relative" ,str (old ),str (moved / "cwd-probe" )): 352with self .assertRaises (urllib .error .HTTPError )as caught : 353self .change (app ,session , {"workspace" :invalid }) 354self .assertIn (caught .exception .code , (400 ,409 )) 355self .change (app ,session , {"workspace" :str (moved )}) 356self .assertEqual ( 357self .read (app ,f"/sessions/ { session } " )["workspace" ],str (moved ) 358 ) 359self .assertEqual (json .loads ((app .home / "daemon.json" ).read_text ())["pid" ],pid ) 360app .idle (session ,timeout = 35 ) 361receipt = self .read (app ,f"/sessions/ { session } /inputs/ { identity } " ) 362self .assertEqual (receipt ["delivery" ],"committed" ) 363self .assertEqual ( 364Path ((moved / "cwd-probe" ).read_text ()).resolve (),moved .resolve () 365 ) 366self .assertFalse (old .exists ()) 367after = self .history (app ,session ) 368self .assertEqual ( 369 [e for e in before if e ["kind" ]== "user" ], 370 [e for e in after if e ["kind" ]== "user" ][:- 1 ], 371 ) 372with sqlite3 .connect (app .home / "albedo.sqlite" )as db : 373self .assertEqual ( 374db .execute ( 375"SELECT cwd FROM sessions WHERE id=?" , (session ,) 376 ).fetchone ()[0 ], 377str (moved ), 378 ) 379self .restart (app ) 380self .assertEqual ( 381self .read (app ,f"/sessions/ { session } " )["workspace" ],str (moved ) 382 )