okami.mom/albedo

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

Aly Raffaufrefactor(tests): share repeated E2E fixtures5bdc0ee

main
7.2 KiB180 linesraw
1"""Long cells detach without losing output, deadlines, or completion wakes."""
2
3import json
4import time
5import unittest
6
7from harness import Albedo, Provider, Reply, exclusive, latest_user, python, text
8
9
10def wait_for(predicate, timeout=40):
11    deadline = time.monotonic() + timeout
12    while time.monotonic() < deadline:
13        if result := predicate():
14            return result
15        time.sleep(0.1)
16    raise AssertionError("background cell did not wake session")
17
18
19@exclusive
20class CellBackgroundTests(unittest.TestCase):
21    def setUp(self):
22        self.results = []
23        self.read_before_idle = False
24        self.deadline = False
25        self.synchronous = False
26        self.cell_id = None
27
28        def script(request):
29            last = request["messages"][-1]
30            user = latest_user(request)
31            if last.get("role") == "tool":
32                result = json.loads(last["content"])
33                self.results.append(result)
34                if result.get("status") == "backgrounded":
35                    self.cell_id = result["cell_id"]
36                return text("waiting for the background cell")
37            if "background python cells finished" in user:
38                return text("completion received")
39            if "start cell" in user:
40                code = (
41                    "import asyncio\n"
42                    "cell_gate = asyncio.Event()\n"
43                    "cell_done = asyncio.Event()\n"
44                    "print('before detach')\n"
45                    "await cell_gate.wait()\n"
46                    "print('after detach')\n"
47                    "cell_done.set()\n"
48                    "{'answer': 42}"
49                )
50                if self.synchronous:
51                    code = "import threading\nprint('before detach')\nthreading.Event().wait(10)\nprint('after detach')"
52                if self.deadline:
53                    return Reply(
54                        "python", tool_arguments={"code": code, "timeout_ms": 1800}
55                    )
56                return python(code)
57            if "release cell" in user:
58                code = "print('other cell output')\ncell_gate.set()"
59                if self.read_before_idle:
60                    code += f"\nawait cell_done.wait()\nprint(output.read({self.cell_id!r}))"
61                return python(code)
62            if "cancel cell" in user:
63                return python(f"print(await cells.cancel({self.cell_id!r}))")
64            if "inspect cell" in user:
65                return python(
66                    f"print(await cells.info({self.cell_id!r}))\nprint(output.read({self.cell_id!r}))"
67                )
68            return text("done")
69
70        def prepare(app):
71            app.env["ALBEDO_CELL_BACKGROUND_SECONDS"] = "0.5"
72            app.env["ALBEDO_IDLE_SECONDS"] = "2"
73
74        self.provider = Provider(script)
75        self.addCleanup(self.provider.close)
76        self.app = Albedo(self.provider, prepare=prepare)
77        self.app.__enter__()
78        self.addCleanup(self.app.__exit__, None, None, None)
79
80    def prompt(self, session, message):
81        self.app.prompt(session, message).close()
82        self.app.idle(session)
83
84    def wakes(self):
85        return [
86            record
87            for record in self.provider.requests
88            if "background python cells finished" in latest_user(record["request"])
89        ]
90
91    def start(self):
92        session = self.app.session()
93        self.prompt(session, "start cell")
94        result = self.results[0]
95        self.assertEqual(result["status"], "backgrounded")
96        self.assertIn("before detach", result["output"])
97        self.assertIn("do NOT poll or sleep", result["output"])
98        self.assertIn("wake automatically", result["output"])
99        return session
100
101    def test_cell_wakes_once_and_keeps_its_capture_separate(self):
102        session = self.start()
103        self.prompt(session, "inspect cell")
104        self.assertIn("'status': 'started'", self.results[-1]["output"])
105        self.assertIn("before detach", self.results[-1]["output"])
106        self.prompt(session, "release cell")
107        self.assertEqual(self.results[-1]["output"].strip(), "other cell output")
108        wait_for(lambda: self.wakes())
109        self.app.idle(session)
110        self.assertEqual(len(self.wakes()), 1)
111        notes = [
112            entry
113            for entry in self.app.history(session)["items"]
114            if entry["kind"] == "note"
115            and any(
116                part["kind"] == "text" and "cell finished" in part["text"]
117                for part in entry["content"]
118            )
119        ]
120        self.assertEqual(len(notes), 1)
121        shown = next(
122            part["text"] for part in notes[0]["content"] if part["kind"] == "text"
123        )
124        self.assertTrue(shown.startswith("cell finished (status=ok"), shown)
125        # the transcript names the code, the model's notice the cell id
126        self.assertTrue(shown.endswith("import asyncio"), shown)
127        self.assertNotIn("<system-note>", shown)
128        self.assertIn(self.cell_id, latest_user(self.wakes()[0]["request"]))
129        self.prompt(session, "inspect cell")
130        self.assertIn("'status': 'ok'", self.results[-1]["output"])
131        self.assertIn("after detach", self.results[-1]["output"])
132        self.assertIn("{'answer': 42}", self.results[-1]["output"])
133        self.assertNotIn("other cell output", self.results[-1]["output"])
134        time.sleep(2.5)
135        self.assertEqual(len(self.wakes()), 1)
136
137    def test_reading_finished_output_suppresses_the_wake(self):
138        self.read_before_idle = True
139        session = self.start()
140        self.prompt(session, "release cell")
141        self.assertIn("after detach", self.results[1]["output"])
142        time.sleep(2.5)
143        self.assertEqual(self.wakes(), [])
144
145    def test_original_deadline_interrupts_the_background_cell(self):
146        self.deadline = True
147        session = self.start()
148        wait_for(lambda: self.wakes())
149        self.app.idle(session)
150        self.assertIn("status=interrupted", latest_user(self.wakes()[0]["request"]))
151        self.prompt(session, "inspect cell")
152        self.assertIn("'status': 'interrupted'", self.results[-1]["output"])
153
154    def test_background_cell_survives_idle_reaping_and_daemon_restart(self):
155        session = self.start()
156        time.sleep(4)
157        self.app.restart()
158        self.prompt(session, "release cell")
159        wait_for(lambda: self.wakes())
160        self.app.idle(session)
161        self.assertEqual(len(self.wakes()), 1)
162        self.assertIn("status=ok", latest_user(self.wakes()[0]["request"]))
163
164    def test_cancelled_background_cell_does_not_wake(self):
165        session = self.start()
166        self.prompt(session, "cancel cell")
167        self.assertEqual(self.results[1]["output"].strip(), "True")
168        time.sleep(2.5)
169        self.prompt(session, "inspect cell")
170        self.assertIn("'status': 'interrupted'", self.results[-1]["output"])
171        self.assertEqual(self.wakes(), [])
172
173    def test_synchronous_cell_returns_early_and_keeps_its_deadline(self):
174        self.synchronous = True
175        self.deadline = True
176        session = self.start()
177        wait_for(lambda: self.wakes())
178        self.app.idle(session)
179        self.assertEqual(len(self.wakes()), 1)
180        self.assertIn("status=interrupted", latest_user(self.wakes()[0]["request"]))