diff --git a/pyproject.toml b/pyproject.toml index 0502641..347f329 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "ratatoskr" -version = "0.17.10" +version = "0.17.11" description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard" readme = "README.md" requires-python = ">=3.12" diff --git a/src/ratatoskr/tui.py b/src/ratatoskr/tui.py index 0e8845b..9d0a653 100644 --- a/src/ratatoskr/tui.py +++ b/src/ratatoskr/tui.py @@ -38,6 +38,8 @@ from ratatoskr.sessions import ( AgentNotAvailable, AgentNotFound, AuthScopeDenied, + BifrostConsumerKeyMissing, + BifrostHandshakeFailed, PersonaNotConfigured, SessionApiFailed, create_session, @@ -1504,11 +1506,33 @@ async def _resolve_then_run(args: ParsedArgs) -> int: assert chosen_agent_id is not None try: info = await create_session( - client, chosen_agent_id, end_user_id=args.end_user_id + client, + chosen_agent_id, + end_user_id=args.end_user_id, + bifrost=args.bifrost, + consumer_key=args.consumer_key, ) except AgentNotFound as exc: sys.stderr.write(f"[agent_not_found] agent_id={exc.agent_id}\n") return 12 + except BifrostConsumerKeyMissing as exc: + # INV-001/INV-002: bind failures land on real stderr BEFORE the + # alt-screen opens (mirrors cli._amain exit codes / vocab, INV-006). + sys.stderr.write( + f"[bifrost_consumer_key_missing] {exc} " + f"(set RATATOSKR_BIFROST_CONSUMER_KEY)\n" + ) + return 22 + except BifrostHandshakeFailed as exc: + sys.stderr.write( + f"[bifrost_handshake_failed] bifrost_error={exc.bifrost_error}\n" + ) + if exc.bifrost_error == "bifrost.auth_rejected": + sys.stderr.write( + " bound create requires the consumer key " + "(RATATOSKR_BIFROST_CONSUMER_KEY), not WORLDTREE_API_KEY\n" + ) + return 23 except SessionApiFailed as exc: sys.stderr.write( f"[session_api_failed] status={exc.status} body={exc.body!r}\n" @@ -1517,6 +1541,13 @@ async def _resolve_then_run(args: ParsedArgs) -> int: except (httpx.ConnectError, httpx.ReadTimeout, httpx.TransportError) as exc: sys.stderr.write(f"[network_error] {type(exc).__name__}: {exc}\n") return 21 + # Issue #17 bound-state indicator (pre-alt-screen, mirrors cli._amain). + if args.bifrost is not None: + plane = args.bifrost_plane or "direct" + sys.stderr.write( + f". bifrost: status=bound plane={plane} " + f"endpoint={args.bifrost.endpoint_url}\n" + ) session_id = info.session_id agent_id: str | None = info.agent_id else: diff --git a/tests/test_tui.py b/tests/test_tui.py index 6a707cb..66b4ad6 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -9,6 +9,7 @@ import respx from textual.widgets import RichLog from ratatoskr.cli import ParsedArgs +from ratatoskr.sessions import BifrostBinding from ratatoskr.sse_client import ( Cancelled, Done, @@ -2849,3 +2850,98 @@ class TestResolveThenRunWithPicker: err = capsys.readouterr().err assert "[no_agents]" in err assert picker_called is False + + +class TestTuiBifrostBind: + """Issue #17 slice 3b — TUI bind trigger: bind failures route to the real + stderr BEFORE the alt-screen opens (INV-002, mirrors issue #6; same exit + codes/vocabulary as cli._amain per INV-006).""" + + @respx.mock + async def test_handshake_failure_routes_pre_altscreen( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + from ratatoskr.tui import _resolve_then_run + + respx.post("https://w.example/sessions").mock( + return_value=httpx.Response( + 502, + json={ + "error_code": "bifrost_handshake_failed", + "detail": {"bifrost_error": "bifrost.auth_rejected"}, + }, + ) + ) + args = _args_new( + agent_id="ratatoskr:sindra", + bifrost=BifrostBinding(endpoint_url="http://10.100.10.50:8391"), + bifrost_plane="memory", + consumer_key="ck", + ) + rc = await _resolve_then_run(args) + assert rc == 23 + err = capsys.readouterr().err + assert "bifrost.auth_rejected" in err + assert "consumer key" in err # the 401-scoping hint + + @respx.mock + async def test_consumer_key_missing_routes_pre_altscreen( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + from ratatoskr.tui import _resolve_then_run + + args = _args_new( + agent_id="a", + bifrost=BifrostBinding(endpoint_url="http://x:8391"), + consumer_key=None, + ) + rc = await _resolve_then_run(args) + assert rc == 22 + assert "bifrost_consumer_key_missing" in capsys.readouterr().err + + @respx.mock + async def test_bound_create_carries_binding_and_consumer_key( + self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] + ) -> None: + """A successful bound create sends the bifrost body + the consumer-key + bearer and prints the bound-state indicator (run_async stubbed so no + alt-screen opens).""" + from ratatoskr import tui as tui_mod + from ratatoskr.tui import _resolve_then_run + + route = respx.post("https://w.example/sessions").mock( + return_value=httpx.Response( + 201, + json={ + "session_id": "s-bound", + "agent_id": "ratatoskr:sindra", + "message_count": 0, + "created_at": "2026-06-18T12:00:00+00:00", + "last_active": "2026-06-18T12:00:00+00:00", + "metadata": {}, + }, + ) + ) + + async def fake_run_async(self) -> int: + return 0 + + monkeypatch.setattr(tui_mod.RatatoskrApp, "run_async", fake_run_async) + args = _args_new( + agent_id="ratatoskr:sindra", + bifrost=BifrostBinding(endpoint_url="http://10.100.10.50:8391"), + bifrost_plane="memory", + consumer_key="ck", + ) + rc = await _resolve_then_run(args) + assert rc == 0 + import json as _json + + body = _json.loads(route.calls[0].request.content) + assert body["bifrost"] == { + "endpoint_url": "http://10.100.10.50:8391", "scope": None + } + assert route.calls[0].request.headers["Authorization"] == "Bearer ck" + err = capsys.readouterr().err + assert "bifrost: status=bound" in err + assert "plane=memory" in err diff --git a/uv.lock b/uv.lock index 8abe54b..3d5c56e 100644 --- a/uv.lock +++ b/uv.lock @@ -1052,7 +1052,7 @@ wheels = [ [[package]] name = "ratatoskr" -version = "0.17.10" +version = "0.17.11" source = { editable = "." } dependencies = [ { name = "httpx" },