okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

nekomimi.petfeat: background cells, jobs page, and session list fixesf65a648

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