diff --git a/tests/test_coordinator_endpoints.py b/tests/test_coordinator_endpoints.py index 6054bd8b..bf583fec 100644 --- a/tests/test_coordinator_endpoints.py +++ b/tests/test_coordinator_endpoints.py @@ -1710,6 +1710,58 @@ def test_cluster_inspect_node_backed_success(storage): assert live["pending_approval"] is False +def test_cluster_inspect_node_backed_pending_approval_detail_passes_through(storage): + """Node /dashboard returns pending_approval_detail → live block carries + it through verbatim. Regression guard for the projection allowlist + `_CLUSTER_WS_LIVE_KEYS`: dropping the key from the tuple silently + breaks inline approve/deny buttons on remote-node child rows even + though coord rows still work via the in-process synthesis branch.""" + mgr = _build_mgr(storage) + ws_id = "f0" * 16 + _seed_node_workstream(storage, ws_id=ws_id, node_id="node-a") + detail = { + "call_id": "c-bash", + "judge_pending": False, + "items": [ + { + "call_id": "c-bash", + "header": "bash", + "preview": "$ rm -rf /tmp/x", + "func_name": "bash", + "approval_label": "bash", + "needs_approval": True, + "error": None, + "heuristic_verdict": None, + "judge_verdict": { + "recommendation": "deny", + "risk_level": "crit", + "tier": "llm", + }, + } + ], + } + payload = { + "workstreams": [ + { + "ws_id": ws_id, + "state": "attention", + "activity_state": "approval", + "activity": "awaiting approval", + "tokens": 100, + "pending_approval_detail": detail, + } + ] + } + client = _make_client(storage, coord_mgr=mgr, registry=_fake_registry()) + _install_collector_with_node(client, "node-a", "http://node-a") + _install_proxy_client(client, httpx.MockTransport(lambda r: httpx.Response(200, json=payload))) + resp = client.get(f"/v1/api/cluster/ws/{ws_id}/detail", headers=_CLUSTER_HEADERS) + assert resp.status_code == 200 + live = resp.json()["live"] + assert live["pending_approval"] is True # derived bool, existing behavior + assert live["pending_approval_detail"] == detail # full payload, new behavior + + def test_cluster_inspect_node_backed_pending_approval_synthesized(storage): """activity_state=='approval' from the node synthesizes pending_approval=True.""" mgr = _build_mgr(storage) diff --git a/tests/test_phase6_endpoints.py b/tests/test_phase6_endpoints.py index 8664d031..162908f6 100644 --- a/tests/test_phase6_endpoints.py +++ b/tests/test_phase6_endpoints.py @@ -228,6 +228,52 @@ def test_bulk_live_coordinator_row_uses_manager_snapshot(storage): live = body["results"][ws.id] assert live is not None assert "pending_approval" in live + # New field always present on the wire — None when no approval + # is pending so the JS can `key in row` without surprise. + assert "pending_approval_detail" in live + assert live["pending_approval_detail"] is None + + +def test_bulk_live_coordinator_row_includes_pending_approval_detail(storage): + """When _pending_approval is set on a coord UI, the live block + surfaces the merged items + judge_verdict payload through the + coord-pseudo-node path. End-to-end equivalent of the dashboard + test in test_server_authz, but for the console live-bulk + endpoint that the coord tree UI actually consumes.""" + mgr = _build_mgr(storage) + ws = mgr.create(user_id="user-1") + ws.ui._pending_approval = { + "type": "approve_request", + "items": [ + { + "call_id": "c-99", + "header": "spawn_workstream", + "preview": "{...}", + "func_name": "spawn_workstream", + "approval_label": "spawn_workstream", + "needs_approval": True, + } + ], + "judge_pending": False, + } + ws.ui._llm_verdicts["c-99"] = { + "recommendation": "approve", + "risk_level": "low", + "tier": "llm", + } + client = _make_client(storage, coord_mgr=mgr) + resp = client.get( + f"/v1/api/cluster/ws/live?ids={ws.id}", + headers=_OWNER_HEADERS, + ) + assert resp.status_code == 200 + live = resp.json()["results"][ws.id] + assert live["pending_approval"] is True # boolean derived flag + detail = live["pending_approval_detail"] + assert detail is not None + assert detail["call_id"] == "c-99" + assert detail["items"][0]["func_name"] == "spawn_workstream" + assert detail["items"][0]["judge_verdict"]["recommendation"] == "approve" # --------------------------------------------------------------------------- diff --git a/turnstone/console/server.py b/turnstone/console/server.py index 69ab848b..5a5bea69 100644 --- a/turnstone/console/server.py +++ b/turnstone/console/server.py @@ -607,6 +607,12 @@ _CLUSTER_WS_LIVE_KEYS = ( "model_alias", "title", "name", + # Carries the inline approve/deny payload (items + judge_verdict) + # so coord live-bulk callers can render row-level UI without a + # per-child round-trip. ``None`` when no approval is pending. + # Cross-tenant exposure follows the trusted-team posture documented + # on ``SessionUIBase.serialize_pending_approval_detail``. + "pending_approval_detail", ) @@ -717,6 +723,17 @@ def _coordinator_live_snapshot(ws: Any) -> dict[str, Any]: val = getattr(obj, name, "") if obj else "" return val if isinstance(val, str) else "" + # Coord rows synthesize the same ``pending_approval_detail`` shape + # the node-side dashboard produces — single source of truth via + # ``SessionUIBase.serialize_pending_approval_detail``. The console + # coord LLM judge isn't wired today (``coordinator_ui.py:138`` + # hardcodes ``judge_pending=False``), so ``judge_verdict`` will + # always be ``None`` for these rows; the coord-self stretch in + # the plan covers that follow-up. ``ui`` may be ``None`` in + # transient states (newly-created ws before activation); every + # active coord UI is a ``SessionUIBase`` and supports the method. + pending_approval_detail = ui.serialize_pending_approval_detail() if ui is not None else None + return { "state": ws.state.value if hasattr(ws.state, "value") else str(ws.state), "tokens": 0, @@ -729,6 +746,7 @@ def _coordinator_live_snapshot(ws: Any) -> dict[str, Any]: "title": "", "name": getattr(ws, "name", "") or "", "pending_approval": pending_approval, + "pending_approval_detail": pending_approval_detail, }