okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
f65a648
main
1"""The vent channel must record model complaints for user review, share one 2global ledger across sessions and workspaces, and let /paperclips triage 3reach the model — with a reply recorded on the vent and delivered to the 4session that filed it. 5 6Each scenario has an isolated daemon because the model's bounded global 7listing has no page cursor. Sessions within a scenario still share one ledger.""" 8 9import ast 10import json 11import sqlite3 12import unittest 13 14from harness import Albedo ,Provider ,exclusive ,python ,text 15 16VENT = ( 17"v = await vent('harness', 'a background job stopped responding'," 18" suggestion='report the blocked job', title='job stalled')\n" 19"print(v['id'])" 20) 21 22READ_BACK = "print([(v['id'], v['status']) for v in await vents()])" 23 24# Prints the vent this session filed, then the whole global listing: the 25# second line proves another session's vent is visible to the model. 26VENT_AND_LIST = ( 27"v = await vent('harness', 'a background job stopped responding'," 28" suggestion='report the blocked job', title='job stalled')\n" 29"print(v['id'])\n" 30"print([v['id'] for v in await vents()])" 31) 32 33READER_TURN = "print('the reader turns')" 34 35# Refused without a note, closed with one, then refused as already closed. 36RESOLVE = ( 37"vent_id = v['id']\n" 38"for note in (' ', 'the blocked job now reports its state', 'again'):\n" 39" try:\n" 40" v = await resolve_vent(vent_id, note)\n" 41" print('resolved:', v['status'], v['resolution'])\n" 42" except Exception as error:\n" 43" print('refused:', error)" 44) 45 46TITLELESS = ( 47"v = await vent('bug', 'wires crossed somewhere deep in the stack')\nprint(v['id'])" 48) 49 50REJECTED = ( 51"try:\n" 52" await vent('nonsense', 'no such topic')\n" 53"except Exception as error:\n" 54" print('rejected:', error)\n" 55"try:\n" 56" await vent('bug', ' ')\n" 57"except Exception as error:\n" 58" print('rejected:', error)\n" 59"print([v['id'] for v in await vents()" 60" if v['message'] in ('no such topic', ' ')])" 61) 62 63 64def scripting (cells ): 65"""Answers each user turn with the next scripted python cell.""" 66 67def reply (request ): 68if request ["messages" ][- 1 ].get ("role" )== "user" and cells : 69return python (cells .pop (0 )) 70return text ("done" ) 71 72return Provider (reply ) 73 74 75def python_results (app ,session ): 76return [ 77json .loads (part ["value" ]) 78for entry in app .history (session )["items" ] 79if entry ["kind" ]== "tool_result" and entry ["tool" ]["name" ]== "python" 80for part in entry ["content" ] 81if part ["kind" ]== "json" and part ["field" ]== "result" 82 ] 83 84 85def filed_id (app ,session ): 86"""The vent id the session's first cell printed.""" 87output = python_results (app ,session )[0 ]["output" ] 88return int (output .strip ()) 89 90 91def statuses (app ,session ): 92"""The id -> status map the session's latest listing printed.""" 93output = python_results (app ,session )[- 1 ]["output" ] 94return dict (ast .literal_eval (output .strip ())) 95 96 97def vent_row (page ,vent_id ): 98return next ( 99resource ["value" ] 100for resource in page ["items" ] 101if resource ["value" ]["id" ]== str (vent_id ) 102 ) 103 104 105def sidebar_extensions (app ,session ): 106with app .api (f"/sessions/ { session } ?tail=0" )as response : 107glances = json .load (response )["glances" ] 108return {glance ["extension" ]for glance in glances } 109 110 111def paperclips_page (app ): 112with app .api ("/extensions/paperclips/items?limit=200" )as response : 113return json .load (response ) 114 115 116def reply_to_vent (app ,vent_id ,message ): 117route = f"/extensions/paperclips/items/ { vent_id } " 118with app .api (route )as response : 119json .load (response ) 120revision = response .headers ["ETag" ] 121with app .api ( 122route , {"reply" :message },method = "PATCH" ,headers = {"If-Match" :revision } 123 )as response : 124return json .load (response ) 125 126 127def rename (app ,session ,name ): 128with app .api (f"/sessions/ { session } ?view=configuration" )as response : 129json .load (response ) 130revision = response .headers ["ETag" ] 131app .api ( 132f"/sessions/ { session } ?view=configuration" , 133 {"name" :name }, 134method = "PATCH" , 135headers = {"If-Match" :revision }, 136 ).close () 137 138 139@exclusive 140class PaperclipsTests (unittest .TestCase ): 141def remove_vents (self ,app ,session ,ids ): 142"""Keeps one test's vents out of the next test's global listing.""" 143for vent_id in ids : 144route = f"/extensions/paperclips/items/ { vent_id } " 145with app .api (route )as response : 146json .load (response ) 147revision = response .headers ["ETag" ] 148app .api (route ,method = "DELETE" ,headers = {"If-Match" :revision }).close () 149 150def test_vents_share_one_global_ledger (self ): 151provider = scripting ([VENT ,VENT_AND_LIST ]) 152self .addCleanup (provider .close ) 153with Albedo (provider )as app : 154sessions = [] 155ids = [] 156try : 157outputs = {} 158for name in ("vent-first" ,"vent-second" ): 159workspace = app .root / name 160workspace .mkdir () 161session = app .session (workspace ) 162sessions .append (session ) 163app .prompt (session ,"vent about the build" ).close () 164app .idle (session ) 165results = python_results (app ,session ) 166self .assertEqual (len (results ),1 ) 167self .assertEqual (results [0 ]["status" ],"ok" ,results [0 ]) 168outputs [name ]= results [0 ]["output" ].strip ().splitlines () 169ids .append (int (outputs [name ][0 ])) 170# the second session's python list already carries the 171# first's vent: the model sees every session's vents 172listed = ast .literal_eval (outputs ["vent-second" ][1 ]) 173self .assertIn (ids [0 ],listed ) 174# and the first session's own /paperclips page shows both 175page = paperclips_page (app ) 176rows = {item ["value" ]["id" ]for item in page ["items" ]} 177self .assertTrue ({str (vent_id )for vent_id in ids }<= rows ) 178# open vents stay out of the chat sidebar 179self .assertNotIn ("paperclips" ,sidebar_extensions (app ,sessions [0 ])) 180finally : 181if ids : 182self .remove_vents (app ,sessions [0 ],ids ) 183 184def test_user_triage_reaches_the_model (self ): 185provider = scripting ([VENT ,READ_BACK ]) 186self .addCleanup (provider .close ) 187with Albedo (provider )as app : 188workspace = app .root / "vent-triage" 189workspace .mkdir () 190session = app .session (workspace ) 191vent_id = None 192try : 193app .prompt (session ,"vent about the build" ).close () 194app .idle (session ) 195vent_id = filed_id (app ,session ) 196 197# a named session shows by name in the detail pane 198rename (app ,session ,"grumpy-venter" ) 199 200page = paperclips_page (app ) 201# the list shows the model's title; the detail carries the vent 202row = vent_row (page ,vent_id ) 203self .assertEqual (row ["title" ],"job stalled" ) 204self .assertEqual (row ["status" ],"open" ) 205self .assertEqual (row ["message" ],"a background job stopped responding" ) 206self .assertEqual (row ["suggestion" ],"report the blocked job" ) 207self .assertEqual (row ["session_id" ],session ) 208 209replied = reply_to_vent (app ,vent_id ,"the blocked job is now visible" ) 210self .assertEqual (replied ["notification" ]["state" ],"queued" ) 211self .assertEqual (replied ["resource" ]["value" ]["status" ],"acknowledged" ) 212self .assertEqual ( 213replied ["resource" ]["value" ]["reply" ], 214"the blocked job is now visible" , 215 ) 216 217page = paperclips_page (app ) 218self .assertEqual (vent_row (page ,vent_id )["status" ],"acknowledged" ) 219self .assertIn ( 220"the blocked job is now visible" , 221vent_row (page ,vent_id )["reply" ], 222 ) 223 224# the note reaches the model with its next turn; the ledger agrees 225app .prompt (session ,"check your vents" ).close () 226app .idle (session ) 227results = python_results (app ,session ) 228self .assertEqual (len (results ),2 ) 229self .assertEqual (statuses (app ,session )[vent_id ],"acknowledged" ) 230# the queued note reaches the model as its own user turn 231self .assertTrue ( 232any ( 233"answers: the blocked job is now visible" 234in str (message .get ("content" )) 235for request in provider .requests 236for message in request ["request" ]["messages" ] 237 ) 238 ) 239finally : 240if vent_id is not None : 241self .remove_vents (app ,session , [vent_id ]) 242 243def test_a_reply_reaches_the_session_that_vented (self ): 244provider = scripting ([VENT ,READ_BACK ,READER_TURN ]) 245self .addCleanup (provider .close ) 246with Albedo (provider )as app : 247sessions = {} 248for name in ("vent-filer" ,"vent-reader" ): 249workspace = app .root / name 250workspace .mkdir () 251sessions [name ]= app .session (workspace ) 252filer = sessions ["vent-filer" ] 253reader = sessions ["vent-reader" ] 254vent_id = None 255try : 256app .prompt (filer ,"vent about the build" ).close () 257app .idle (filer ) 258vent_id = filed_id (app ,filer ) 259 260# the reader sees the filer's vent, attributed to its workspace 261page = paperclips_page (app ) 262row = vent_row (page ,vent_id ) 263self .assertEqual (row ["status" ],"open" ) 264self .assertEqual (row ["session_id" ],filer ) 265self .assertIn ("vent-filer" ,row ["workspace" ]) 266 267replied = reply_to_vent (app ,vent_id ,"the reader answers" ) 268self .assertEqual (replied ["notification" ]["state" ],"queued" ) 269self .assertEqual (replied ["resource" ]["value" ]["status" ],"acknowledged" ) 270 271# the note lands on the filer's next turn... 272app .prompt (filer ,"check your vents" ).close () 273app .idle (filer ) 274results = python_results (app ,filer ) 275self .assertEqual (len (results ),2 ) 276self .assertEqual (statuses (app ,filer )[vent_id ],"acknowledged" ) 277self .assertIn ( 278"answers: the reader answers" , 279str (provider .requests [- 1 ]["request" ]["messages" ]), 280 ) 281# ...and not on the reader's, which turns afterwards. Each 282# python turn spans two requests (tool call, then its 283# result), so the requests carrying the note are identified 284# by the filer's prompt, not by position. 285app .prompt (reader ,"anything for me?" ).close () 286app .idle (reader ) 287noted = [ 288request 289for request in provider .requests 290if "answers: the reader answers" 291in str (request ["request" ]["messages" ]) 292 ] 293self .assertTrue (noted ) 294for request in noted : 295self .assertIn ( 296"check your vents" , 297str (request ["request" ]["messages" ]), 298 ) 299self .assertNotIn ( 300"anything for me?" , 301str (request ["request" ]["messages" ]), 302 ) 303finally : 304if vent_id is not None : 305self .remove_vents (app ,reader , [vent_id ]) 306 307# exclusive: restarts the daemon to load seeded vents 308@exclusive 309def test_an_undeliverable_reply_is_kept_on_the_vent (self ): 310"""A reply survives its note: the answer is durable on the vent even 311when the session that filed it is gone or was never recorded.""" 312provider = Provider (lambda request :text ("done" )) 313self .addCleanup (provider .close ) 314with Albedo (provider )as app : 315session = app .session () 316database = app .home / "albedo.sqlite" 317 318def prepare (_app ): 319with sqlite3 .connect (database )as db : 320db .execute ( 321"INSERT INTO paperclips(topic,message,cwd,session) " 322"VALUES('bug','ghost vent',?,'no-such-session')" , 323 (str (app .workspace ),), 324 ) 325db .execute ( 326"INSERT INTO paperclips(topic,message,cwd) " 327"VALUES('bug','sessionless vent',?)" , 328 (str (app .workspace ),), 329 ) 330 331app .restart (prepare = prepare ) 332ids = {} 333try : 334page = paperclips_page (app ) 335for text_label in ("ghost vent" ,"sessionless vent" ): 336row = next ( 337item ["value" ] 338for item in page ["items" ] 339if item ["value" ]["message" ]== text_label 340 ) 341self .assertEqual (row ["status" ],"open" ) 342ids [text_label ]= int (row ["id" ]) 343 344gone = reply_to_vent (app ,ids ["ghost vent" ],"for the ghost" ) 345self .assertNotEqual (gone ["notification" ]["state" ],"queued" ) 346self .assertEqual (gone ["resource" ]["value" ]["status" ],"acknowledged" ) 347self .assertEqual (gone ["resource" ]["value" ]["reply" ],"for the ghost" ) 348 349nobody = reply_to_vent (app ,ids ["sessionless vent" ],"for nobody" ) 350self .assertNotEqual (nobody ["notification" ]["state" ],"queued" ) 351self .assertEqual (nobody ["resource" ]["value" ]["status" ],"acknowledged" ) 352self .assertEqual (nobody ["resource" ]["value" ]["reply" ],"for nobody" ) 353 354# the answers are on the vents, and neither stays in the glance 355page = paperclips_page (app ) 356self .assertIn ( 357"for the ghost" , 358vent_row (page ,ids ["ghost vent" ])["reply" ], 359 ) 360self .assertIn ( 361"for nobody" , 362vent_row (page ,ids ["sessionless vent" ])["reply" ], 363 ) 364finally : 365self .remove_vents (app ,session ,list (ids .values ())) 366 367def test_the_model_resolves_a_vent_it_fixed_with_a_note (self ): 368provider = scripting ([VENT ,RESOLVE ]) 369self .addCleanup (provider .close ) 370with Albedo (provider )as app : 371session = app .session () 372vent_id = None 373try : 374app .prompt (session ,"vent about the build" ).close () 375app .idle (session ) 376vent_id = filed_id (app ,session ) 377app .prompt (session ,"you fixed it, close it" ).close () 378app .idle (session ) 379 380output = python_results (app ,session )[- 1 ]["output" ] 381lines = output .strip ().splitlines () 382self .assertEqual (len (lines ),3 ,output ) 383self .assertIn ("refused: say what fixed the vent" ,lines [0 ]) 384self .assertEqual ( 385lines [1 ],"resolved: resolved the blocked job now reports its state" 386 ) 387self .assertIn (f"refused: vent # { vent_id } is already resolved" ,lines [2 ]) 388 389# the user sees the closure and who made it; it leaves the glance 390rename (app ,session ,"fixer" ) 391page = paperclips_page (app ) 392row = vent_row (page ,vent_id ) 393self .assertEqual (row ["status" ],"resolved" ) 394self .assertEqual (row ["resolving_session_id" ],session ) 395self .assertEqual ( 396row ["resolution" ],"the blocked job now reports its state" 397 ) 398finally : 399if vent_id is not None : 400self .remove_vents (app ,session , [vent_id ]) 401 402def test_a_titleless_vent_takes_its_title_from_the_message (self ): 403provider = scripting ([TITLELESS ]) 404self .addCleanup (provider .close ) 405with Albedo (provider )as app : 406session = app .session () 407vent_id = None 408try : 409app .prompt (session ,"vent" ).close () 410app .idle (session ) 411vent_id = filed_id (app ,session ) 412row = vent_row (paperclips_page (app ),vent_id ) 413self .assertEqual ( 414row ["title" ],"wires crossed somewhere deep in the stack" 415 ) 416self .assertIn ("wires crossed" ,row ["message" ]) 417finally : 418if vent_id is not None : 419self .remove_vents (app ,session , [vent_id ]) 420 421def test_a_vent_needs_a_known_topic_and_a_message (self ): 422provider = scripting ([REJECTED ]) 423self .addCleanup (provider .close ) 424with Albedo (provider )as app : 425session = app .session () 426app .prompt (session ,"vent something" ).close () 427app .idle (session ) 428results = python_results (app ,session ) 429self .assertEqual (results [0 ]["status" ],"ok" ,results [0 ]) 430# both attempts were rejected — the whitespace message included 431self .assertEqual (results [0 ]["output" ].count ("rejected:" ),2 ) 432# and neither left a vent in the global ledger 433self .assertTrue (results [0 ]["output" ].strip ().endswith ("[]" ))