okami.mom/albedo

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

Aly Raffaufrefactor(tests): share repeated E2E fixtures5bdc0ee

main
35.3 KiB902 linesraw
1"""Agent families: spawn, forwarding, addressing, snapshots, and routing."""
2
3import http.client
4import json
5import socket
6import sqlite3
7import tempfile
8import threading
9import time
10import unittest
11import urllib.error
12import urllib.request
13import urllib.parse
14from pathlib import Path
15
16from harness import Albedo, Provider, exclusive, latest_user, operation_id, python, text
17from stream_support import StreamProbe
18
19
20def wait_for(predicate, timeout=40):
21    deadline = time.monotonic() + timeout
22    while time.monotonic() < deadline:
23        value = predicate()
24        if value:
25            return value
26        time.sleep(0.05)
27    raise AssertionError("agent event never arrived")
28
29
30class AgentsTests(unittest.TestCase):
31    def setUp(self):
32        def answer(request):
33            user = latest_user(request)
34            line = user.splitlines()[-2 if "</mail>" in user else -1]
35            return text("answered: " + line)
36
37        self.provider = Provider(answer)
38        self.alpha = f"alpha-{self.provider.route}"
39        self.beta = f"beta-{self.provider.route}"
40        providers = {
41            profile: {
42                "extension": "openai",
43                "baseUrl": self.provider.url + f"/{name}/v1",
44                "apiKey": "key",
45                "model": f"fixture-{name}-{self.provider.route}",
46                "protocol": "chat_completions",
47            }
48            for profile, name in ((self.alpha, "alpha"), (self.beta, "beta"))
49        }
50
51        # The cached catalog lists a model for the fixture endpoint that no profile names.
52        # models.json is daemon-wide, so only the exclusive catalog test writes it.
53        def prepare(app):
54            if (
55                self._testMethodName
56                == "test_captured_subtree_deletion_blocks_new_child_admission"
57            ):
58                app.env["ALBEDO_INSPECT"] = "1"
59                return
60            (app.home / "models.json").write_text(
61                json.dumps(
62                    {
63                        "fixture-gateway": {
64                            "api": "http://127.0.0.1/v1",
65                            "env": [],
66                            "models": {"fixture-listed": {"id": "fixture-listed"}},
67                        }
68                    }
69                )
70            )
71
72        catalog = (
73            self._testMethodName
74            == "test_generic_profile_lists_its_endpoints_catalog_models"
75        )
76        self.app = Albedo(
77            self.provider,
78            providers=providers,
79            prepare=prepare
80            if catalog
81            or self._testMethodName
82            == "test_captured_subtree_deletion_blocks_new_child_admission"
83            else None,
84        )
85        self.app.__enter__()
86        self.addCleanup(self.provider.close)
87        self.addCleanup(self.app.__exit__, None, None, None)
88
89    def api(self, path, body=None, **options):
90        with self.app.api(path, body, **options) as response:
91            return json.load(response)
92
93    def snapshot(self, session):
94        return self.api(f"/sessions/{session}?tail=0")
95
96    def change(self, session, patch):
97        with self.app.api(f"/sessions/{session}?view=configuration") as response:
98            json.load(response)
99            revision = response.headers["ETag"]
100        return self.api(
101            f"/sessions/{session}?view=configuration",
102            patch,
103            method="PATCH",
104            headers={"If-Match": revision},
105        )["session"]
106
107    def move(self, session, workspace):
108        snapshot = self.snapshot(session)
109        return self.change(
110            session,
111            {
112                "workspace": str(workspace),
113                "family_revision": snapshot["family_revision"],
114            },
115        )
116
117    def family(self, session):
118        root = self.snapshot(session)["root_id"]
119        return self.api(f"/sessions?scope=all&family_id={root}")["items"]
120
121    def asked(self, fragment):
122        return [
123            content
124            for record in self.provider.requests
125            for item in record["request"].get("messages", [])
126            if item.get("role") == "user"
127            for content in [item.get("content", "")]
128            if isinstance(content, str) and fragment in content
129        ]
130
131    def spawn(self, parent, name="coder", task="map every wake path", model=None):
132        session = operation_id()
133        body = {
134            "kind": "child",
135            "parent_id": parent,
136            "address": name,
137            "name": name,
138            "initial_input_id": operation_id(),
139            "task": task,
140        }
141        if model is not None:
142            body["model"] = model
143        return self.api(
144            f"/sessions/{session}", body, method="PUT", headers={"If-None-Match": "*"}
145        )
146
147    def send_mail(self, sender, target, body, *, rejected=False):
148        marker = "dispatch fixture mail " + operation_id()
149        answer = self.provider.script
150
151        def dispatch(request):
152            if (
153                request["messages"][-1].get("role") == "user"
154                and latest_user(request) == marker
155            ):
156                return python(
157                    "import json\n"
158                    f"receipt = await mail.submit({target!r}, {body!r})\n"
159                    "print(json.dumps(receipt))"
160                )
161            return answer(request)
162
163        self.provider.script = dispatch
164        try:
165            self.send(sender, marker)
166            self.app.idle(sender)
167        finally:
168            self.provider.script = answer
169        [result] = [
170            entry
171            for entry in self.app.history(sender)["items"]
172            if entry["kind"] == "tool_result"
173        ][-1:]
174        execution = next(
175            json.loads(part["value"])
176            for part in result["content"]
177            if part["kind"] == "json" and part["field"] == "result"
178        )
179        self.assertEqual(execution["status"], "error" if rejected else "ok", execution)
180        return execution if rejected else json.loads(execution["output"])
181
182    def send(self, session, message):
183        return self.api(
184            f"/sessions/{session}/inputs/{operation_id()}",
185            {"kind": "message", "text": message},
186            method="PUT",
187        )
188
189    def listen(self):
190        heard = []
191        ready, stopped = threading.Event(), threading.Event()
192        address = urllib.parse.urlsplit(self.app.base)
193        connection = http.client.HTTPConnection(
194            address.hostname, address.port, timeout=30
195        )
196
197        def consume():
198            try:
199                connection.request(
200                    "GET",
201                    "/sessions?scope=all",
202                    headers={
203                        "Authorization": "Bearer " + self.app.connection["token"],
204                        "Accept": "text/event-stream",
205                    },
206                )
207                with connection.getresponse() as response:
208                    for raw in response:
209                        if raw.startswith(b"data: "):
210                            heard.extend(json.loads(raw[6:])["events"])
211                            ready.set()
212            except (OSError, ValueError):
213                if not stopped.is_set():
214                    raise
215
216        thread = threading.Thread(target=consume, daemon=True)
217        thread.start()
218        self.assertTrue(ready.wait(timeout=5), "collection stream did not send ready")
219
220        def close():
221            stopped.set()
222            if connection.sock is not None:
223                connection.sock.shutdown(socket.SHUT_RDWR)
224            thread.join(timeout=2)
225            connection.close()
226            self.assertFalse(thread.is_alive(), "collection reader did not stop")
227
228        self.addCleanup(close)
229        return heard
230
231    def test_spawn_task_and_unreviewed_forward(self):
232        parent = self.app.session()
233        child = self.spawn(parent)
234        self.assertEqual(
235            (child["parent_id"], child["address"], child["depth"], child["closed"]),
236            (parent, "coder", 1, False),
237        )
238        self.assertEqual(child["name"], "coder")
239        resource = self.api(f"/sessions/{child['id']}")
240        self.assertEqual(
241            resource["creation"]["resolved"],
242            {
243                key: resource[key]
244                for key in ("workspace", "provider_profile", "model", "effort")
245            },
246        )
247        self.assertNotIn(
248            child["id"], [item["id"] for item in self.api("/sessions")["items"]]
249        )
250        self.app.idle(child["id"])
251        task = self.asked("map every wake path")[0]
252        self.assertIn('kind="task"', task)
253        self.assertIn(f'session="{parent}"', task)
254        self.assertIn("map every wake path", task)
255        forwarded = wait_for(lambda: self.asked('kind="unreviewed"'))[0]
256        self.assertIn('from="coder"', forwarded)
257        self.assertIn("answered: map every wake path", forwarded)
258        self.app.idle(parent)
259        self.app.idle(child["id"])
260        self.assertEqual(
261            [
262                item["address"]
263                for item in self.api(f"/sessions?scope=all&parent_id={parent}")["items"]
264            ],
265            ["coder"],
266        )
267
268    @exclusive
269    def test_mail_attribution_and_agent_card_survive_reconnect_and_restart(self):
270        parent = self.app.session()
271        self.change(parent, {"name": "lead desk"})
272        child = self.spawn(parent, task="map wake paths")["id"]
273        self.app.idle(child)
274        self.app.idle(parent)
275        task = next(
276            entry
277            for entry in self.app.history(child)["items"]
278            if (entry.get("mail") or {}).get("kind") == "task"
279        )
280        self.assertEqual(task["turn_type"], "agent")
281        self.assertEqual(task["mail"]["sender_label"], "lead desk")
282        self.assertEqual(task["mail"]["sender_session_id"], parent)
283        self.assertEqual(task["input_id"], task["mail"]["mail_id"])
284        self.assertEqual(task["content"][0]["text"], "map wake paths")
285        # Retrying creation must not admit the initial task twice.
286        with self.assertRaises(urllib.error.HTTPError) as repeated:
287            self.api(
288                f"/sessions/{child}",
289                {
290                    "kind": "child",
291                    "parent_id": parent,
292                    "address": "coder",
293                    "name": "coder",
294                    "initial_input_id": task["input_id"],
295                    "task": "map wake paths",
296                },
297                method="PUT",
298                headers={"If-None-Match": "*"},
299            )
300        self.assertEqual(repeated.exception.code, 412)
301        self.assertEqual(
302            sum(
303                entry["id"] == task["id"] for entry in self.app.history(child)["items"]
304            ),
305            1,
306        )
307        self.assertEqual(
308            self.snapshot(child)["activity"]["current_request"]["text"],
309            "map wake paths",
310        )
311        before = self.app.stream_page(child)
312        answer = self.provider.script
313        marker = "report bounded progress"
314
315        def progress(request):
316            if (
317                request["messages"][-1].get("role") == "user"
318                and latest_user(request) == marker
319            ):
320                return python('await agents.progress("tracing delivery")')
321            return answer(request)
322
323        self.provider.script = progress
324        self.send(child, marker)
325        self.app.idle(child)
326        self.app.idle(parent)
327        self.provider.script = answer
328        self.assertEqual(
329            self.snapshot(child)["activity"]["latest_progress"], "tracing delivery"
330        )
331        # A new subscriber sees progress in its authoritative reset snapshot.
332        reset = self.app.stream_page(child)
333        self.assertEqual(
334            reset["snapshot"]["activity"]["latest_progress"], "tracing delivery"
335        )
336        receipt = self.send_mail(parent, child, "also cover replay")
337        self.app.idle(child)
338        self.app.idle(parent)
339        activity = self.snapshot(child)["activity"]
340        self.assertEqual(
341            activity["current_request"],
342            {"input_id": receipt["id"], "text": "also cover replay"},
343        )
344        self.assertIsNone(activity["latest_progress"])
345        history = self.app.history(child)["items"]
346        followup = next(
347            entry for entry in history if entry["input_id"] == receipt["id"]
348        )
349        live = self.app.stream_page(child, before)
350        self.assertEqual(
351            [
352                event["data"]["entry"]
353                for event in live["events"]
354                if event["type"] == "message"
355                and event["data"]["entry"]["id"] == followup["id"]
356            ],
357            [followup],
358        )
359        # Peer messages remain visible but cannot replace the parent's request.
360        peer = self.app.session()
361        self.send_mail(peer, child, "peer observation")
362        self.app.idle(child)
363        self.app.idle(parent)
364        self.assertEqual(
365            self.snapshot(child)["activity"]["current_request"],
366            activity["current_request"],
367        )
368        forwarded = next(
369            entry
370            for entry in self.app.history(parent)["items"]
371            if (entry.get("mail") or {}).get("kind") == "unreviewed"
372        )
373        self.assertEqual(forwarded["mail"]["sender_label"], "coder")
374        self.app.restart()
375        recovered = self.snapshot(child)["activity"]
376        self.assertEqual(recovered["current_request"], activity["current_request"])
377        self.assertIsNone(recovered["latest_progress"])
378        replayed = self.app.stream_page(child)["snapshot"]["history"]["items"]
379        self.assertEqual(
380            next(entry for entry in replayed if entry["id"] == followup["id"]), followup
381        )
382
383    def test_tree_snapshot_and_event_stream(self):
384        parent = self.app.session()
385        heard = self.listen()
386        child = self.spawn(parent)["id"]
387        wait_for(lambda: self.asked('kind="unreviewed"'))
388        for asker in (parent, child):
389            nodes = {item["id"]: item for item in self.family(asker)}
390            self.assertEqual(set(nodes), {parent, child})
391            self.assertEqual(
392                (nodes[parent]["parent_id"], nodes[parent]["depth"]), (None, 0)
393            )
394            self.assertEqual(
395                (
396                    nodes[child]["parent_id"],
397                    nodes[child]["depth"],
398                    nodes[child]["address"],
399                ),
400                (parent, 1, "coder"),
401            )
402        wait_for(
403            lambda: any(
404                event["type"] == "invalidate" and child in event["data"]["session_ids"]
405                for event in list(heard)
406            )
407        )
408        wait_for(
409            lambda: any(
410                event["type"] == "activity" and event["data"]["session_id"] == child
411                for event in list(heard)
412            )
413        )
414        wait_for(
415            lambda: any(
416                event["type"] == "mail"
417                and event["data"]["receiver_session_id"] == parent
418                for event in list(heard)
419            )
420        )
421
422    def test_rename_keeps_family_address_and_reports_stream_event(self):
423        parent = self.app.session()
424        child = self.spawn(parent)["id"]
425        self.app.idle(child)
426        wait_for(lambda: self.asked('kind="unreviewed"'))
427        self.app.idle(parent)
428        heard = self.listen()
429        self.assertEqual(
430            self.change(child, {"name": "  wake path\naudit "})["name"],
431            "wake path audit",
432        )
433        self.assertEqual(
434            self.change(parent, {"name": "orchestration"})["name"], "orchestration"
435        )
436        nodes = {item["id"]: item for item in self.family(parent)}
437        self.assertEqual(
438            (nodes[parent]["name"], nodes[parent]["address"]), ("orchestration", None)
439        )
440        self.assertEqual(
441            (nodes[child]["name"], nodes[child]["address"]),
442            ("wake path audit", "coder"),
443        )
444        wait_for(
445            lambda: any(
446                event["type"] == "invalidate" and child in event["data"]["session_ids"]
447                for event in list(heard)
448            )
449        )
450        self.assertEqual(self.snapshot(child)["name"], "wake path audit")
451        with self.assertRaises(urllib.error.HTTPError) as caught:
452            self.send("no-such-session", "cannot deliver")
453        self.assertEqual(caught.exception.code, 404)
454        receipt = self.send_mail(parent, "coder", "also cover schedules")
455        self.assertEqual((receipt["to"], receipt["name"]), (child, "coder"))
456        wait_for(lambda: self.asked("also cover schedules"))
457        wait_for(
458            lambda: any(
459                "also cover schedules" in message
460                for message in self.asked('kind="unreviewed"')
461            )
462        )
463        self.app.idle(parent)
464
465    def test_reply_and_external_id_mail(self):
466        parent, radio = self.app.session(), self.app.session()
467        child = self.spawn(parent)["id"]
468        wait_for(lambda: self.asked('kind="unreviewed"'))
469        self.app.idle(parent)
470        self.app.idle(child)
471        receipt = self.send_mail(child, "parent", "schedules covered")
472        self.assertEqual(receipt["to"], parent)
473        wait_for(lambda: self.asked("schedules covered"))
474        self.app.idle(parent)
475        failed = self.send_mail(child, "radio", "by unrelated name", rejected=True)
476        self.assertIn("radio", failed["output"])
477        self.assertFalse(self.asked("by unrelated name"))
478        receipt = self.send_mail(child, radio, "deploy when green")
479        self.assertEqual(receipt["to"], radio)
480        wait_for(lambda: self.asked("deploy when green"))
481        self.app.idle(radio)
482        for recipient, body in (
483            (parent, "schedules covered"),
484            (radio, "deploy when green"),
485        ):
486            delivered = [
487                entry
488                for entry in self.app.history(recipient)["items"]
489                if entry["kind"] == "user"
490                and any(
491                    part["kind"] == "text" and body in part["text"]
492                    for part in entry["content"]
493                )
494            ]
495            self.assertEqual(len(delivered), 1)
496
497    def test_tree_deletion_and_renamed_workspace(self):
498        parent = self.app.session()
499        child = self.spawn(parent)["id"]
500        self.app.idle(child)
501        wait_for(lambda: self.asked('kind="unreviewed"'))
502        self.app.idle(parent)
503        with self.app.api(f"/sessions/{parent}?view=configuration") as response:
504            configuration = json.load(response)
505            revision = response.headers["ETag"]
506        with self.assertRaises(urllib.error.HTTPError) as caught:
507            self.api(
508                f"/sessions/{parent}?view=configuration",
509                method="DELETE",
510                headers={"If-Match": revision},
511            )
512        self.assertEqual(caught.exception.code, 409)
513        self.assertIn("child", json.load(caught.exception)["detail"])
514        grandchild = self.spawn(child, "deep")["id"]
515        self.app.idle(grandchild)
516        wait_for(lambda: len(self.asked('kind="unreviewed"')) >= 2)
517        self.app.idle(child)
518        self.app.idle(parent)
519        with self.assertRaises(urllib.error.HTTPError) as caught:
520            self.api(
521                f"/sessions/{parent}?view=configuration&scope=subtree&family_revision={configuration['family_revision']}",
522                method="DELETE",
523                headers={"If-Match": revision},
524            )
525        self.assertEqual(caught.exception.code, 412)
526        entered, release = threading.Event(), threading.Event()
527        self.addCleanup(release.set)
528        answer = self.provider.script
529
530        def held(request):
531            if "hold subtree deletion" in latest_user(request):
532                entered.set()
533                release.wait(30)
534                return python(
535                    "from pathlib import Path\nPath('deleted-run-executed').write_text('wrong')"
536                )
537            return answer(request)
538
539        self.provider.script = held
540        receipt = self.send(child, "hold subtree deletion")
541        self.assertTrue(entered.wait(30))
542        with self.app.api(f"/sessions/{parent}?view=configuration") as response:
543            configuration = json.load(response)
544            revision = response.headers["ETag"]
545        outcome = self.api(
546            f"/sessions/{parent}?view=configuration&scope=subtree&family_revision={configuration['family_revision']}",
547            method="DELETE",
548            headers={"If-Match": revision},
549        )
550        self.assertEqual(outcome["state"], "complete")
551        self.assertEqual(outcome["deleted_ids"], [grandchild, child, parent])
552        terminal = self.api(f"/sessions/{child}/inputs/{receipt['id']}")
553        self.assertEqual(terminal["delivery"], "committed")
554        self.assertEqual(terminal["turn"]["state"], "interrupted")
555        self.assertIsNotNone(terminal["turn"]["ended_at"])
556        release.set()
557        self.assertFalse((self.app.workspace / "deleted-run-executed").exists())
558        listed = [item["id"] for item in self.api("/sessions?scope=all")["items"]]
559        self.assertNotIn(parent, listed)
560        self.assertNotIn(child, listed)
561        radio = self.app.session()
562        self.change(radio, {"name": "radio desk"})
563        moved = self.app.root / "moved"
564        moved.mkdir()
565        self.assertEqual(self.move(radio, moved)["name"], "radio desk")
566        self.assertEqual(self.snapshot(radio)["name"], "radio desk")
567
568    @exclusive
569    def test_captured_subtree_deletion_blocks_new_child_admission(self):
570        parent = self.app.session()
571        child = self.spawn(parent, "captured")["id"]
572        self.app.idle(child)
573        wait_for(lambda: self.asked('kind="unreviewed"'))
574        self.app.idle(parent)
575        with self.app.api(f"/sessions/{parent}?view=configuration") as response:
576            configuration = json.load(response)
577            revision = response.headers["ETag"]
578        probe = StreamProbe(self.app)
579        self.assertEqual(
580            probe.call("suspend_session_json", f"[<<{json.dumps(child)}>>]"),
581            {"suspended": True},
582        )
583        outcomes, failures = [], []
584
585        def delete():
586            try:
587                outcomes.append(
588                    self.api(
589                        f"/sessions/{parent}?view=configuration&scope=subtree&family_revision={configuration['family_revision']}",
590                        method="DELETE",
591                        headers={"If-Match": revision},
592                    )
593                )
594            except Exception as error:
595                failures.append(error)
596
597        worker = threading.Thread(target=delete)
598        worker.start()
599        try:
600
601            def captured():
602                with sqlite3.connect(self.app.home / "albedo.sqlite") as db:
603                    rows = db.execute(
604                        "SELECT id,deletion_id FROM sessions WHERE id IN (?,?)",
605                        (parent, child),
606                    ).fetchall()
607                return len(rows) == 2 and all(row[1] for row in rows)
608
609            wait_for(captured, timeout=15)
610            self.assertTrue(worker.is_alive())
611            with self.assertRaises(urllib.error.HTTPError) as caught:
612                self.spawn(parent, "too-late", "must not execute")
613            self.assertEqual(caught.exception.code, 409)
614            with sqlite3.connect(self.app.home / "albedo.sqlite") as db:
615                admitted = {
616                    row[0]
617                    for row in db.execute(
618                        "SELECT id FROM sessions WHERE id=? UNION ALL "
619                        "SELECT session FROM session_family WHERE parent=?",
620                        (parent, parent),
621                    )
622                }
623            self.assertEqual(admitted, {parent, child})
624        finally:
625            self.assertEqual(
626                probe.call("resume_session_json", f"[<<{json.dumps(child)}>>]"),
627                {"resumed": True},
628            )
629            worker.join(35)
630        self.assertFalse(
631            worker.is_alive(), "deletion did not finish after owner resumed"
632        )
633        self.assertEqual(failures, [])
634        self.assertEqual(len(outcomes), 1)
635        self.assertEqual(outcomes[0]["state"], "complete")
636        self.assertEqual(outcomes[0]["deleted_ids"], [child, parent])
637        self.assertFalse(self.asked("must not execute"))
638
639    @exclusive
640    def test_subtree_deletion_reports_the_rows_a_failed_commit_leaves(self):
641        parent = self.app.session()
642        failed = self.spawn(parent, "failed")["id"]
643        removed = self.spawn(parent, "removed")["id"]
644        for session in (failed, removed):
645            self.app.idle(session)
646        wait_for(lambda: self.asked('from="failed"') and self.asked('from="removed"'))
647        self.app.idle(parent)
648        with self.app.api(f"/sessions/{parent}?view=configuration") as response:
649            configuration = json.load(response)
650            revision = response.headers["ETag"]
651        with sqlite3.connect(self.app.home / "albedo.sqlite") as db:
652            db.execute(
653                "CREATE TRIGGER fail_observed_deletion BEFORE DELETE ON sessions "
654                f"WHEN OLD.id='{failed}' BEGIN SELECT RAISE(ABORT,'injected deletion failure'); END"
655            )
656        self.addCleanup(self._remove_deletion_failure)
657        outcome = self.api(
658            f"/sessions/{parent}?view=configuration&scope=subtree&family_revision={configuration['family_revision']}",
659            method="DELETE",
660            headers={"If-Match": revision},
661        )
662        self.assertEqual(outcome["state"], "partial")
663        self.assertEqual(outcome["deleted_count"], 1)
664        self.assertEqual(outcome["remaining_count"], 2)
665        self.assertEqual(outcome["deleted_ids"], [removed])
666        remaining = {row["id"]: row["reason"] for row in outcome["remaining"]}
667        self.assertEqual(set(remaining), {parent, failed})
668        self.assertTrue(all(remaining.values()))
669        self.assertIn("captured child", remaining[parent]["detail"])
670        self._remove_deletion_failure()
671        # Reading authoritative state never resumes a partially failed deletion.
672        for _ in range(2):
673            self.assertEqual(
674                {row["id"] for row in self.family(parent)}, {parent, failed}
675            )
676        with self.assertRaises(urllib.error.HTTPError) as caught:
677            self.api(
678                f"/sessions/{parent}?view=configuration&scope=subtree&family_revision={configuration['family_revision']}",
679                method="DELETE",
680                headers={"If-Match": revision},
681            )
682        self.assertEqual(caught.exception.code, 412)
683        self.assertEqual({row["id"] for row in self.family(parent)}, {parent, failed})
684
685    def _remove_deletion_failure(self):
686        with sqlite3.connect(self.app.home / "albedo.sqlite") as db:
687            db.execute("DROP TRIGGER IF EXISTS fail_observed_deletion")
688
689    def test_children_follow_a_workspace_move(self):
690        release = threading.Event()
691        self.addCleanup(release.set)
692        answer = self.provider.script
693
694        def held(request):
695            user = latest_user(request)
696            if "hold this turn" in user or "probe moved child" in user:
697                if request["messages"][-1].get("role") == "tool":
698                    return text("workspace probe finished")
699                if "hold this turn" in user:
700                    release.wait(30)
701                marker = (
702                    "old-child-cwd" if "hold this turn" in user else "new-child-cwd"
703                )
704                return python(
705                    "import os\nfrom pathlib import Path\n"
706                    f"Path({marker!r}).write_text(os.getcwd())"
707                )
708            return answer(request)
709
710        self.provider.script = held
711        parent = self.app.session()
712        still = self.spawn(parent, "still")["id"]
713        away = self.spawn(parent, "away")["id"]
714
715        def heard():
716            return {
717                entry["mail"]["sender_session_id"]
718                for entry in self.app.history(parent)["items"]
719                if entry.get("mail")
720            }
721
722        wait_for(lambda: {still, away} <= heard())
723        for session in (still, away, parent):
724            self.app.idle(session)
725        elsewhere = Path(tempfile.mkdtemp(prefix="elsewhere-", dir=self.app.root))
726        self.move(away, elsewhere)
727        busy = self.spawn(parent, "busy", "hold this turn")["id"]
728        wait_for(lambda: self.asked("hold this turn"))
729        moved = Path(tempfile.mkdtemp(prefix="moved-", dir=self.app.root))
730        self.assertEqual(self.move(parent, moved)["workspace"], str(moved))
731
732        def workspaces():
733            return {item["id"]: item["workspace"] for item in self.family(parent)}
734
735        self.assertEqual(
736            workspaces(),
737            {
738                parent: str(moved),
739                still: str(moved),
740                away: str(elsewhere),
741                busy: str(self.app.workspace),
742            },
743        )
744        latest = Path(tempfile.mkdtemp(prefix="latest-", dir=self.app.root))
745        self.move(parent, latest)
746        before_move_applied = self.app.stream_page(busy)
747        release.set()
748        self.app.idle(busy)
749        self.assertEqual(workspaces()[busy], str(latest))
750        self.assertEqual(
751            (self.app.workspace / "old-child-cwd").read_text(), str(self.app.workspace)
752        )
753        self.assertFalse((moved / "old-child-cwd").exists())
754        self.send(busy, "probe moved child")
755        self.app.idle(busy)
756        self.assertEqual((latest / "new-child-cwd").read_text(), str(latest))
757        self.assertFalse((moved / "new-child-cwd").exists())
758        events = self.app.stream_page(busy, before_move_applied)["events"]
759        notes = [
760            event["data"]["text"]
761            for event in events
762            if event["type"] == "note" and event["data"]["origin"] == "workspace"
763        ]
764        self.assertTrue(
765            any(
766                "python variables were cleared" in note
767                and "transcript is intact" in note
768                for note in notes
769            ),
770            events,
771        )
772
773    @exclusive
774    def test_failed_deferred_workspace_application_survives_restart(self):
775        entered, release = threading.Event(), threading.Event()
776        self.addCleanup(release.set)
777        answer = self.provider.script
778
779        def held(request):
780            user = latest_user(request)
781            if "deferred workspace hold" in user:
782                entered.set()
783                release.wait(30)
784                return text("old turn completed")
785            if "restored workspace probe" in user:
786                if request["messages"][-1].get("role") == "tool":
787                    return text("restored workspace completed")
788                return python(
789                    "import os\nfrom pathlib import Path\n"
790                    "Path('restored-child-cwd').write_text(os.getcwd())"
791                )
792            return answer(request)
793
794        self.provider.script = held
795        parent = self.app.session()
796        child = self.spawn(parent, "deferred", "deferred workspace hold")["id"]
797        self.assertTrue(entered.wait(30), "child never reached provider gate")
798        destination = Path(tempfile.mkdtemp(prefix="deferred-", dir=self.app.root))
799        self.move(parent, destination)
800        destination.rmdir()
801        release.set()
802        self.app.idle(child)
803
804        def assert_pending():
805            snapshot = self.snapshot(child)
806            self.assertEqual(snapshot["workspace"], str(self.app.workspace))
807            change = snapshot["workspace_change"]
808            self.assertEqual(change["active"], str(self.app.workspace))
809            self.assertEqual(change["desired"], str(destination))
810            return change["revision"]
811
812        revision = assert_pending()
813        self.app.restart(crash=True)
814        self.assertEqual(assert_pending(), revision)
815        destination.mkdir()
816        self.send(child, "restored workspace probe")
817        self.app.idle(child)
818        self.assertEqual(
819            (destination / "restored-child-cwd").read_text(), str(destination)
820        )
821        snapshot = self.snapshot(child)
822        self.assertEqual(snapshot["workspace"], str(destination))
823        self.assertIsNone(snapshot["workspace_change"])
824        self.assertFalse((self.app.workspace / "restored-child-cwd").exists())
825
826    def test_cancel_all_stops_every_running_turn_beneath_the_caller(self):
827        entered = {"mid": threading.Event(), "leaf": threading.Event()}
828        release = threading.Event()
829        self.addCleanup(release.set)
830        answer = self.provider.script
831
832        def held(request):
833            user = latest_user(request)
834            for name, gate in entered.items():
835                if f"hold {name}" in user:
836                    gate.set()
837                    release.wait(30)
838                    return text("held turn ended")
839            if request["messages"][-1].get("role") == "user" and user == "stop all":
840                return python("print(sorted(await agents.cancel_all()))")
841            return answer(request)
842
843        self.provider.script = held
844        parent = self.app.session()
845        mid = self.spawn(parent, "mid", "hold mid")["id"]
846        leaf = self.spawn(mid, "leaf", "hold leaf")["id"]
847        for gate in entered.values():
848            self.assertTrue(gate.wait(30), "a held turn never reached the provider")
849        self.send(parent, "stop all")
850        for session in (parent, mid, leaf):
851            self.app.idle(session)
852        self.assertFalse(release.is_set())
853        result = next(
854            json.loads(part["value"])
855            for entry in reversed(self.app.history(parent)["items"])
856            if entry["kind"] == "tool_result"
857            for part in entry["content"]
858            if part["kind"] == "json" and part["field"] == "result"
859        )
860        self.assertEqual(result["status"], "ok", result)
861        self.assertEqual(result["output"].strip(), str(sorted([mid, leaf])))
862
863    def test_qualified_and_inferred_cross_provider_models(self):
864        parent = self.app.session()
865        self.change(parent, {"name": "radio desk"})
866        with self.assertRaises(urllib.error.HTTPError) as caught:
867            self.spawn(
868                parent, "missing", "should not run", f"{self.beta}/no-such-model"
869            )
870        self.assertEqual(caught.exception.code, 409)
871        for name, model in (
872            ("explicit", f"{self.beta}/fixture-beta-{self.provider.route}"),
873            ("inferred", f"fixture-beta-{self.provider.route}"),
874        ):
875            made = self.spawn(parent, name, "cross-provider task", model)
876            self.assertEqual(made["provider_profile"], self.beta)
877            self.assertEqual(made["model"], f"fixture-beta-{self.provider.route}")
878            self.assertEqual(made["parent_id"], parent)
879            self.app.idle(made["id"])
880        tasks = wait_for(
881            lambda: (
882                self.asked('kind="task">\ncross-provider')
883                if len(self.asked('kind="task">\ncross-provider')) == 2
884                else None
885            )
886        )
887        self.assertTrue(all('from="radio desk"' in task for task in tasks))
888        self.assertGreaterEqual(
889            sum("/beta/" in record["path"] for record in self.provider.requests), 2
890        )
891
892    # exclusive: writes the daemon-wide models.json catalog
893    @exclusive
894    def test_generic_profile_lists_its_endpoints_catalog_models(self):
895        parent = self.app.session()
896        made = self.spawn(
897            parent, "listed", "catalog task", f"{self.beta}/fixture-listed"
898        )
899        self.assertEqual(
900            (made["provider_profile"], made["model"]), (self.beta, "fixture-listed")
901        )
902        self.app.idle(made["id"])