From 5c1b9816d45ebd8f74bc8dac5503b6d88d07d7c6 Mon Sep 17 00:00:00 2001 From: Vuong Hoang Date: Tue, 30 Jun 2026 22:05:14 -0700 Subject: [PATCH] feat(#6): startup session picker for bare TUI mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v1 coverage-audit slice b2. The audit found list_sessions had no caller — the startup session picker (design-brief §4) was never built; bare TUI mode was a hard usage error. Add SessionPickerApp (mirrors AgentPickerApp) and resolve bare mode in _resolve_then_run. - Bare TUI mode (no --session/--new) now valid → session picker. Resolution: 0 sessions -> [no_sessions] exit 14 (resume-only per §4 "no in-app creation, --new only"); exactly 1 -> auto-resume (§4 "picker only when >1"); >=2 -> SessionPickerApp -> resume pick (Esc/Ctrl-D -> exit 0). - cli._parse: bare TUI valid; --send still requires one flag; --agent forbidden in bare mode. run_tui PRE-002 xor -> mutually-exclusive. - Contract #6 amended (SessionPickerApp + bare-mode resolution) + validated. TDD: 3 picker pilot tests + 5 resolution tests + 3 cli validation tests. Suite 528 green; touched code ruff-clean. Design note: bare + 0 sessions errors (honors §4's no-in-app-creation clause); the friendlier auto-fall-through-to-new is deferred pending operator preference. --- docs/contracts/issues/6.contract.md | 61 ++++++++ persistent-memory.md | 2 + pyproject.toml | 2 +- src/ratatoskr/cli.py | 10 +- src/ratatoskr/tui.py | 162 +++++++++++++++++++- tests/test_cli.py | 36 ++++- tests/test_tui.py | 222 ++++++++++++++++++++++++++++ uv.lock | 2 +- 8 files changed, 483 insertions(+), 14 deletions(-) diff --git a/docs/contracts/issues/6.contract.md b/docs/contracts/issues/6.contract.md index 0806ed2..2e8fc40 100644 --- a/docs/contracts/issues/6.contract.md +++ b/docs/contracts/issues/6.contract.md @@ -372,3 +372,64 @@ test layer. - Issue #7 (mid-stream robustness, `MalformedSseData`) — landed; #6's pre/in-alt-screen split is orthogonal to #7's empty-data/malformed distinction (different error layers entirely). + +## Amendment 2026-06-30 — startup session picker (v1 coverage-audit, slice b2) + +The v1 coverage-audit found `list_sessions` had **no caller** — the startup +session picker (design-brief §4: "single-session-per-launch, with a startup +picker invoked when more than one session exists ... plus flags `--session`/ +`--new` to skip it") was never built. Bare TUI mode (neither `--session` nor +`--new`) was a hard usage error. This adds the picker as a pre-alt-screen +resolution step in `_resolve_then_run`, mirroring the existing `AgentPickerApp`. + +**Locked design (design-brief §4):** the picker is **resume-only** (§4 negative +clause "no in-app session creation — `--new` flag only"); shown only when **>1** +session exists (exactly 1 auto-resumes; the launch intent is "resume the last +session I was poking at"). `--agent` stays a `--new` companion (forbidden in bare +mode). **bare + 0 sessions → error** `[no_sessions]` directing the operator to +`--new` (honors the "no in-app creation" clause; the friendlier +auto-fall-through-to-new alternative is deferred pending operator confirmation). + +### `_parse` validation relaxation (ratatoskr.cli._parse) + +- Bare TUI mode (`send is None` AND no `--session` AND no `--new`) is now VALID + → triggers the picker. (Previously `raise UsageError("pass exactly one of + --session or --new")` unconditionally.) +- `--send` mode still requires exactly one of `--session`/`--new` (non- + interactive: no picker can open) → `UsageError("--send requires --session or + --new")`. +- `--session` + `--new` stays mutually exclusive. +- `--agent` in bare mode → `UsageError` (`--agent` belongs to `--new`). + +```contract +FN SessionPickerApp.__init__(self, sessions: list[SessionInfo]) -> None +BRIEF: Textual App[str | None] startup session picker (mirrors AgentPickerApp, issue #8). Opens before RatatoskrApp when bare TUI mode resolves >1 session. `run_async()` returns the chosen session_id (str) or None on Esc/Ctrl-D/Ctrl-C dismissal. Architecturally separate from RatatoskrApp (list_sessions failures + dismissal land before any alt-screen — preserves #6 INV-001). +PRE: [PRE-001 hard] sessions is non-empty -- assert sessions (caller resolves 0-session and 1-session cases BEFORE constructing the picker) +POST: [POST-001 return_value] run_async() returns sessions[i].session_id for the highlighted row on `pick`, or None on dismiss -- assert result in {s.session_id for s in sessions} | {None} +STEPS: + 1. [setup, prescriptive] Store sessions; register the Australis theme (mirror AgentPickerApp). + 2. [sequential, prescriptive] compose: Header + prompt Static + ListView of one ListItem per session (id-short + agent_id + last_active/name lines) + Footer. + 3. [sequential, prescriptive] BINDINGS: enter→action_pick, escape/ctrl+d/ctrl+c→action_dismiss. + 4. [branch, prescriptive] action_pick: read ListView.index; if None return (nothing highlighted); else exit(sessions[index].session_id). action_dismiss: exit(None). +TESTS: + pick_returns_session_id [happy,tracer]: SessionPickerApp([s0, s1]); pilot highlights row 1 + press enter → run_async() returns s1.session_id. + dismiss_returns_none [happy]: press escape → run_async() returns None. + ctrl_d_dismisses [adversarial]: press ctrl+d → None. + +FN _resolve_then_run(args) — bare-mode extension (session picker) +BRIEF: Before the existing new/resume branches, resolve bare TUI mode (not args.new AND args.session_id is None) via list_sessions + the picker. Sets a local `effective_new` and `resolved_session_id`; the existing branches then run unchanged on those locals. +STEPS (inserted at the top of the `async with client` block): + 1. [setup, prescriptive] SET effective_new = args.new; resolved_session_id = args.session_id. + 2. [branch, prescriptive] IF (not args.new) AND (args.session_id is None): # bare mode + a. CALL list_sessions(client) → page; ON SessionApiFailed → stderr `[session_api_failed]` + return 20; ON network error → `[network_error]` + return 21. + b. IF not page.items: stderr `[no_sessions] no sessions to resume; launch with --new --agent ` + return 14. + c. ELIF len(page.items) == 1: SET resolved_session_id = page.items[0].session_id. # §4: picker only when >1 + d. ELSE: SET resolved_session_id = await SessionPickerApp(page.items).run_async(); IF None → return 0 (Esc/Ctrl-D clean exit). + 3. [sequential, prescriptive] Replace the two `if args.new` predicates with `if effective_new`; the resume `else` branch asserts + uses `resolved_session_id`. +TESTS (in the `_resolve_then_run` block): + bare_zero_sessions_errors [error]: bare args; list_sessions → 0 items → stderr contains `[no_sessions]`; return 14; NO POST /sessions, NO picker. + bare_one_session_auto_resumes [scenario]: bare args; list_sessions → 1 item (sid="s-solo") → RatatoskrApp constructed with session_id="s-solo"; NO picker shown. + bare_multi_opens_picker [scenario,tracer]: bare args; list_sessions → 2 items; picker returns items[1].session_id → RatatoskrApp constructed with that session_id. + bare_picker_dismiss_exits_zero [scenario]: bare args; 2 items; picker returns None → return 0; RatatoskrApp NOT constructed. + bare_list_sessions_api_failure [error]: bare args; list_sessions raises SessionApiFailed(500) → stderr `[session_api_failed]`; return 20. +``` diff --git a/persistent-memory.md b/persistent-memory.md index 7d45052..0be6b6f 100644 --- a/persistent-memory.md +++ b/persistent-memory.md @@ -151,6 +151,8 @@ decision. Captures rationale that won't be obvious from code alone. - `[2026-06-30]` **(b) Tier-1 frontier SCOPED, ready for a contract-first TDD cycle (next focused work).** The primitives already exist + are contracted + tested; the gap is PRESENTER-level wiring. Two slices: **(b1) SSE-resume** — contract #1 (`ratatoskr.sse_client`) DELIBERATELY makes resume caller-owned ("on `SseConnectionDropped`, the caller MAY invoke `reconnect_turn`"); `reconnect_turn` (sse_client.py:524) has NO caller. Gap = a SHARED resume-orchestration wrapper (catch `SseConnectionDropped` → track last-seen `sse_id` → `reconnect_turn` → continue), consumed by all 3 presenters per design-brief §8b "share the consumer, branch the presenter" (NOT per-presenter — that forks the consumer). New function block → **amend contract #1** (additive FN, e.g. `stream_turn_resilient`) then TDD (RED: drop-mid-stream→resume continuity; GREEN: wrapper; wire `cli --send` first as the tracer). Resume design pre-locked: in-process Last-Event-ID only, cross-process deferred to v2 (design-brief §8d). **(b2) session-picker** — `list_sessions` (sessions.py:198) has NO caller; add a Textual DataTable startup picker (>1 session) + `--session `/`--new` CLI flags (design-brief §4, decisions pre-locked). Both pre-locked → heid-contract-review likely skippable as ceremony (small additive amendments to mature specs); heid-code-review still valuable. **#11 AdminEvents stays BLOCKED** on `admin.events.read` scope (infra-ops). - `[2026-06-30]` **(b1) SSE-resume SHIPPED (`v0.18.5`) — `stream_turn_resilient` (sse_client.py).** The shared resume-orchestration surface (design-brief §8b): wraps `stream_turn`+`reconnect_turn`, catches `SseConnectionDropped` (mid-stream drop OR clean-EOF-before-terminal) → resumes from last-seen `sse_id` via `reconnect_turn` (Last-Event-ID), up to `max_reconnects` (default 5); non-drop reconnect failures (412/410/400/TurnIdFlip/SseConnectFailed) PROPAGATE per contract #1's "surface, not recover". `last_seen` persists ACROSS attempts (a zero-event reconnect drop falls back to the prior attempt's id). Direct in-session TDD against a contract-#1 amendment (8 cases incl. two-drops, max-reconnects-exhausted, zero-budget, buffer-expired-propagates, unresumable-zero-event). Wired ALL THREE presenters through it (`v0.18.6`): `cli --send` (`cli.py:396`), TUI (`tui.py:1321`), web (`web/server.py:294`) — each a name-for-name `stream_turn`→`stream_turn_resilient` swap (the §8b "all presenters share the consumer" promise, fully kept; the TUI is the primary resume beneficiary — long-lived sessions / laptop-suspend). Suite 518 green; ruff+mypy clean on touched code (pre-existing cli.py:400/543 mypy warts left untouched per surgical rule); contract #1 validates OK. **heid-code-review NOT run** (small additive well-TDD'd wrapper; offered to operator). **b2 (session-picker + `--session`/`--new` flags) still pending.** +- `[2026-06-30]` **(b2) session-picker SHIPPED (`v0.18.7`) — bare TUI mode → startup picker (design-brief §4).** `list_sessions` had NO caller; now bare TUI mode (no `--session`/`--new`) resolves via `list_sessions` in `_resolve_then_run`: **0 sessions → `[no_sessions]` error, exit 14** (resume-only, honors §4 "no in-app session creation — `--new` flag only"); **exactly 1 → auto-resume** (§4 "picker only when >1"); **≥2 → new `SessionPickerApp`** (Textual `App[str|None]`, mirrors `AgentPickerApp`; ListView of sessions) → resume the pick (Esc/Ctrl-D → exit 0). cli `_parse` relaxed: bare TUI now VALID (was "pass exactly one" error); `--send` still requires one flag (non-interactive, no picker); `--agent` forbidden in bare mode; `run_tui` PRE-002 XOR→"not both". Direct in-session TDD (contract #6 amendment, validated OK): 3 widget pilot tests + 5 `_resolve_then_run` resolution tests + 3 cli validation tests. Suite **528 green**; touched code ruff-clean (mypy: only the `BINDINGS` list-invariance warning every App in tui.py already carries — consistent). **DESIGN NOTE — bare+0-sessions → error (clause-consistent). The friendlier auto-fall-through-to-new alternative is DEFERRED pending operator preference (it would create a session without `--new`, against the §4 negative clause).** **Frontier now: `GET /capabilities`+`GET /me` → BifrostState/Tools widgets (`GET /admin/sessions/{id}/{bifrost,tools}`, admin-key) → #11 AdminEvents (BLOCKED on `admin.events.read`).** heid-code-review NOT run on b1 or b2 (offered). + _41 older entries (2026-05-* — the original debug-TUI/web build era) archived to archival-memory.md._ _For per-issue TDD implementation notes, Volva findings, and contract amendments, see the git log — every per-issue commit carries a structured message capturing the trail._ diff --git a/pyproject.toml b/pyproject.toml index 06cd7da..353f014 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "ratatoskr" -version = "0.18.6" +version = "0.18.7" description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard" readme = "README.md" requires-python = ">=3.12" diff --git a/src/ratatoskr/cli.py b/src/ratatoskr/cli.py index b9b7031..43e5689 100644 --- a/src/ratatoskr/cli.py +++ b/src/ratatoskr/cli.py @@ -142,9 +142,15 @@ def _parse_args(argv: list[str] | None) -> ParsedArgs: if ns.end_user_id is not None and not ns.end_user_id: raise UsageError("--end-user-id must be non-empty when passed") if ns.session and ns.new: - raise UsageError("--session and --new are mutually exclusive; pass exactly one") + raise UsageError("--session and --new are mutually exclusive") if not ns.session and not ns.new: - raise UsageError("pass exactly one of --session or --new") + # Bare TUI mode → startup session picker (design-brief §4). --send is + # non-interactive (no picker can open), so it still requires one flag; + # --agent belongs with --new (bare mode resumes, it doesn't create). + if ns.send is not None: + raise UsageError("--send requires --session or --new (no interactive picker)") + if ns.agent: + raise UsageError("--agent belongs with --new; bare TUI mode opens the session picker") if ns.session and ns.agent: raise UsageError("--agent is required with --new and forbidden with --session") if ns.new and not ns.agent and ns.send is not None: diff --git a/src/ratatoskr/tui.py b/src/ratatoskr/tui.py index cf02697..4e062ff 100644 --- a/src/ratatoskr/tui.py +++ b/src/ratatoskr/tui.py @@ -42,9 +42,11 @@ from ratatoskr.sessions import ( BifrostHandshakeFailed, PersonaNotConfigured, SessionApiFailed, + SessionInfo, create_session, get_persona_state, list_agents, + list_sessions, ) from ratatoskr.sse_client import ( AffectUpdate, @@ -798,6 +800,128 @@ class AgentPickerApp(App[str | None]): self.exit(None) +def _session_desc(s: SessionInfo) -> str: + """One-line session summary for the picker's second row.""" + tail = f"session {s.session_id} · last active {s.last_active}" + if s.message_count is not None: + tail += f" · {s.message_count} msgs" + return tail + + +class SessionPickerApp(App[str | None]): + """Startup session picker (design-brief §4, slice b2). Opens before + RatatoskrApp when bare TUI mode resolves >1 session. `run_async()` returns + the chosen session_id (str) or None on Esc/Ctrl-D/Ctrl-C dismissal. + + Resume-only (design-brief §4 negative clause "no in-app session creation — + --new flag only"): the picker chooses among EXISTING sessions; starting a + fresh one is the --new flag's job. Architecturally separate from + RatatoskrApp (mirrors AgentPickerApp): list_sessions failures + dismissal + land before any alt-screen opens (preserves #6 INV-001). + """ + + DEFAULT_CSS = """ + Header, HeaderIcon, HeaderTitle, HeaderClock { + background: $surface; + color: $au-bright-blue; + } + Footer { + background: $surface; + } + ListView { + scrollbar-background: $background; + scrollbar-background-hover: $background; + scrollbar-background-active: $background; + scrollbar-color: $au-dark-50; + scrollbar-color-hover: $au-dark-60; + scrollbar-color-active: $au-bright-cyan; + } + #picker-prompt { + dock: top; + height: 1; + padding: 0 1; + color: $au-bright-cyan; + background: $surface; + } + #session-list { + height: 1fr; + background: $background; + } + #session-list > ListItem { + height: auto; + padding: 1 1; + background: $background; + } + #session-list:focus ListItem.-highlight { + background: $primary; + } + #session-list:focus ListItem.-highlight .session-id-line { + color: $au-bright-white; + text-style: bold; + } + #session-list:focus ListItem.-highlight .session-desc { + color: $au-bright-80; + } + .session-id-line { + color: $au-bright-blue; + text-style: bold; + } + .session-desc { + color: $au-bright-70; + } + """ + + BINDINGS: ClassVar[list[Binding]] = [ + Binding("enter", "pick", "Resume", priority=True), + Binding("escape", "dismiss", "Cancel", priority=True), + Binding("ctrl+d", "dismiss", "Cancel", priority=True), + Binding("ctrl+c", "dismiss", "Cancel", priority=True), + ] + + def __init__(self, sessions: list[SessionInfo]) -> None: + super().__init__() + # PRE-001: caller (_resolve_then_run) resolves the 0-session and + # 1-session cases BEFORE constructing the picker. + assert sessions + self.sessions = sessions + self.register_theme(AUSTRALIS_THEME) + self.theme = "australis" + + def compose(self) -> ComposeResult: + yield Header() + yield Static( + "Pick a session to resume (relaunch with --new for a fresh one):", + id="picker-prompt", + ) + yield ListView( + *[ + ListItem( + Static( + f"{s.name or s.session_id} · {s.agent_id}", + classes="session-id-line", + ), + Static(_session_desc(s), classes="session-desc"), + ) + for s in self.sessions + ], + id="session-list", + ) + yield Footer() + + async def on_mount(self) -> None: + self.query_one("#session-list", ListView).focus() + + def action_pick(self) -> None: + lv = self.query_one("#session-list", ListView) + idx = lv.index + if idx is None: + return # nothing highlighted; ignore + self.exit(self.sessions[idx].session_id) + + def action_dismiss(self) -> None: + self.exit(None) + + class RatatoskrApp(App[int]): """Textual TUI shell — single chat pane.""" @@ -1430,8 +1554,9 @@ def run_tui(args: ParsedArgs) -> int: """ # PRE-001: TUI-mode marker (issue #4 contract) assert isinstance(args, ParsedArgs) and args.send_content is None - # PRE-002: Exactly one of session_id / new must be set (xor) - assert bool(args.session_id) != bool(args.new) + # PRE-002 (slice b2): --session and --new are mutually exclusive, but NEITHER + # is now valid — bare TUI mode opens the startup session picker (§4). + assert not (args.session_id and args.new) return asyncio.run(_resolve_then_run(args)) @@ -1467,6 +1592,35 @@ async def _resolve_then_run(args: ParsedArgs) -> int: # agent_id (remote wins on conflict, since a server-listed agent # is the authoritative source). chosen_agent_id: str | None = args.agent_id + # slice b2: bare TUI mode (no --session, no --new) → startup session + # picker (design-brief §4). Resolve into a concrete session_id BEFORE + # the new/resume branches. Resume-only: bare + 0 sessions is an error + # (creating a session is the --new flag's job). + resolved_session_id: str | None = args.session_id + if not args.new and args.session_id is None: + try: + page = await list_sessions(client) + except SessionApiFailed as exc: + sys.stderr.write( + f"[session_api_failed] status={exc.status} body={exc.body!r}\n" + ) + return 20 + except (httpx.ConnectError, httpx.ReadTimeout, httpx.TransportError) as exc: + sys.stderr.write(f"[network_error] {type(exc).__name__}: {exc}\n") + return 21 + if not page.items: + sys.stderr.write( + "[no_sessions] no sessions to resume; " + "launch with --new --agent \n" + ) + return 14 + if len(page.items) == 1: + # §4: picker only when >1 — a single session auto-resumes. + resolved_session_id = page.items[0].session_id + else: + resolved_session_id = await SessionPickerApp(page.items).run_async() + if resolved_session_id is None: + return 0 # Esc / Ctrl-D — clean exit, no session opened if args.new and args.agent_id is None: try: agents = await list_agents(client) @@ -1551,8 +1705,8 @@ async def _resolve_then_run(args: ParsedArgs) -> int: session_id = info.session_id agent_id: str | None = info.agent_id else: - assert args.session_id is not None - session_id = args.session_id + assert resolved_session_id is not None + session_id = resolved_session_id agent_id = args.agent_id # may be None — INV-002 carve-out preserved app = RatatoskrApp(args, session_id=session_id, agent_id=agent_id, client=client) exit_code = await app.run_async() diff --git a/tests/test_cli.py b/tests/test_cli.py index cdef431..fc887e1 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -175,10 +175,29 @@ class TestParseArgs: ) def test_usage_neither_session_nor_new(self) -> None: - """usage_neither_session_nor_new: neither flag → UsageError('pass exactly one').""" - with pytest.raises(UsageError, match="pass exactly one"): + """usage_neither_session_nor_new: --send with neither flag → UsageError. + + --send is non-interactive (no picker can open), so a session must be + named. Bare TUI mode (no --send) is now valid → session picker (§4). + """ + with pytest.raises(UsageError, match="--send requires"): _parse_args(["--send", "hi", "--api-key", "k"]) + def test_bare_tui_mode_accepted(self) -> None: + """bare_tui_mode (slice b2): no --send, no --session, no --new → valid; + _resolve_then_run drives the startup session picker (design-brief §4).""" + args = _parse_args(["--api-key", "k"]) + assert args.send_content is None + assert args.session_id is None + assert args.new is False + assert args.agent_id is None + + def test_usage_bare_tui_with_agent(self) -> None: + """bare_tui_with_agent (slice b2): bare TUI + --agent → UsageError + (--agent belongs with --new; bare mode opens the resume picker).""" + with pytest.raises(UsageError, match="belongs with --new"): + _parse_args(["--agent", "mimir", "--api-key", "k"]) + def test_usage_send_new_without_agent(self) -> None: """send_new_without_agent (issue #8): --send --new without --agent → UsageError. @@ -1319,10 +1338,15 @@ class TestMain: rc = main(["--send", "hi", "--new", "--agent", "m", "--api-key", "k"]) assert rc == 0 - def test_usage_error_no_send( + def test_empty_argv_fails_on_auth( self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: - """usage_error_no_send: empty argv → exit 10; stderr [usage_error]; _amain never called.""" + """empty argv → exit 11 [auth_error]; _amain never called. + + Since slice b2 bare TUI mode (no --send/--session/--new) is VALID (it + opens the session picker), so empty argv is no longer a usage error — + it now fails on the missing API key instead (still before _amain). + """ amain_calls: list[int] = [] async def fake_amain(args: ParsedArgs) -> int: @@ -1331,8 +1355,8 @@ class TestMain: monkeypatch.setattr(cli_mod, "_amain", fake_amain) rc = main([]) - assert rc == 10 - assert "[usage_error]" in capsys.readouterr().err + assert rc == 11 + assert "[auth_error]" in capsys.readouterr().err assert amain_calls == [] def test_usage_error_both_session_and_new( diff --git a/tests/test_tui.py b/tests/test_tui.py index 66b4ad6..cb126e0 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -2945,3 +2945,225 @@ class TestTuiBifrostBind: err = capsys.readouterr().err assert "bifrost: status=bound" in err assert "plane=memory" in err + + +class TestSessionPickerApp: + """docs/contracts/issues/6.contract.md FN SessionPickerApp (amendment slice b2).""" + + @staticmethod + def _two(): + from ratatoskr.sessions import SessionInfo + + return [ + SessionInfo( + session_id="s-first-0001", agent_id="mimir", created_at="t0", + last_active="t1", metadata={}, message_count=3, name=None, + archived=False, tags=[], + ), + SessionInfo( + session_id="s-second-002", agent_id="echo", created_at="t0", + last_active="t2", metadata={}, message_count=None, name="probe", + archived=False, tags=[], + ), + ] + + def test_pick_returns_session_id(self) -> None: + """pick_returns_session_id [happy,tracer]: idx 1 + Enter → exit value == that session_id.""" + from ratatoskr.tui import SessionPickerApp + + app = SessionPickerApp(self._two()) + + async def drive() -> str | None: + async with app.run_test() as pilot: + from textual.widgets import ListView + + lv = app.query_one("#session-list", ListView) + lv.index = 1 + await pilot.pause() + await pilot.press("enter") + await pilot.pause() + return app.return_value + + import asyncio + + assert asyncio.run(drive()) == "s-second-002" + + def test_esc_returns_none(self) -> None: + """esc_returns_none [happy]: Esc → exit value is None (dismiss, resume nothing).""" + from ratatoskr.tui import SessionPickerApp + + app = SessionPickerApp(self._two()) + + async def drive() -> str | None: + async with app.run_test() as pilot: + await pilot.press("escape") + await pilot.pause() + return app.return_value + + import asyncio + + assert asyncio.run(drive()) is None + + def test_ctrl_d_returns_none(self) -> None: + """ctrl_d_returns_none [adversarial]: Ctrl-D → None.""" + from ratatoskr.tui import SessionPickerApp + + app = SessionPickerApp(self._two()) + + async def drive() -> str | None: + async with app.run_test() as pilot: + await pilot.press("ctrl+d") + await pilot.pause() + return app.return_value + + import asyncio + + assert asyncio.run(drive()) is None + + +class TestBareSessionPicker: + """docs/contracts/issues/6.contract.md amendment (slice b2): _resolve_then_run bare mode.""" + + @staticmethod + def _bare_args() -> ParsedArgs: + return ParsedArgs( + send_content=None, session_id=None, new=False, agent_id=None, + api_key="k", server_url="https://w.example", raw=False, + end_user_id=None, bifrost=None, bifrost_plane=None, consumer_key=None, + ) + + @staticmethod + def _sess(sid: str, agent: str = "mimir"): + from ratatoskr.sessions import SessionInfo + + return SessionInfo( + session_id=sid, agent_id=agent, created_at="t0", last_active="t1", + metadata={}, message_count=1, name=None, archived=False, tags=[], + ) + + def test_bare_zero_sessions_errors( + self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] + ) -> None: + """bare_zero_sessions_errors [error]: 0 sessions → exit 14 [no_sessions]; App not opened.""" + import ratatoskr.tui as tui_mod + from ratatoskr.sessions import SessionPage + + async def fake_list(client, **kw): + return SessionPage(items=[], next_cursor=None) + + monkeypatch.setattr(tui_mod, "list_sessions", fake_list) + opened: list[int] = [] + + async def spy(self, *a, **k): + opened.append(1) + return 0 + + monkeypatch.setattr(RatatoskrApp, "run_async", spy) + rc = run_tui(self._bare_args()) + assert rc == 14 + assert "[no_sessions]" in capsys.readouterr().err + assert not opened + + def test_bare_one_session_auto_resumes(self, monkeypatch: pytest.MonkeyPatch) -> None: + """bare_one_session_auto_resumes: exactly 1 → auto-resume, no picker (§4 >1 rule).""" + import ratatoskr.tui as tui_mod + from ratatoskr.sessions import SessionPage + from ratatoskr.tui import SessionPickerApp + + async def fake_list(client, **kw): + return SessionPage(items=[self._sess("s-solo")], next_cursor=None) + + monkeypatch.setattr(tui_mod, "list_sessions", fake_list) + picker_used: list[int] = [] + + async def spy_picker(self, *a, **k): + picker_used.append(1) + return None + + monkeypatch.setattr(SessionPickerApp, "run_async", spy_picker) + snap: dict = {} + + async def cap(self, *a, **k): + snap["sid"] = self.session_id + return 0 + + monkeypatch.setattr(RatatoskrApp, "run_async", cap) + rc = run_tui(self._bare_args()) + assert rc == 0 + assert snap["sid"] == "s-solo" + assert not picker_used + + def test_bare_multi_opens_picker(self, monkeypatch: pytest.MonkeyPatch) -> None: + """bare_multi_opens_picker [scenario,tracer]: >1 → picker; its choice resumes.""" + import ratatoskr.tui as tui_mod + from ratatoskr.sessions import SessionPage + from ratatoskr.tui import SessionPickerApp + + async def fake_list(client, **kw): + return SessionPage(items=[self._sess("s-a"), self._sess("s-b")], next_cursor=None) + + monkeypatch.setattr(tui_mod, "list_sessions", fake_list) + + async def pick_b(self, *a, **k): + return "s-b" + + monkeypatch.setattr(SessionPickerApp, "run_async", pick_b) + snap: dict = {} + + async def cap(self, *a, **k): + snap["sid"] = self.session_id + return 0 + + monkeypatch.setattr(RatatoskrApp, "run_async", cap) + rc = run_tui(self._bare_args()) + assert rc == 0 + assert snap["sid"] == "s-b" + + def test_bare_picker_dismiss_exits_zero(self, monkeypatch: pytest.MonkeyPatch) -> None: + """bare_picker_dismiss_exits_zero [scenario]: picker None → exit 0; App not opened.""" + import ratatoskr.tui as tui_mod + from ratatoskr.sessions import SessionPage + from ratatoskr.tui import SessionPickerApp + + async def fake_list(client, **kw): + return SessionPage(items=[self._sess("s-a"), self._sess("s-b")], next_cursor=None) + + monkeypatch.setattr(tui_mod, "list_sessions", fake_list) + + async def pick_none(self, *a, **k): + return None + + monkeypatch.setattr(SessionPickerApp, "run_async", pick_none) + opened: list[int] = [] + + async def spy(self, *a, **k): + opened.append(1) + return 0 + + monkeypatch.setattr(RatatoskrApp, "run_async", spy) + rc = run_tui(self._bare_args()) + assert rc == 0 + assert not opened + + def test_bare_list_sessions_api_failure( + self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] + ) -> None: + """bare_list_sessions_api_failure [error]: list_sessions 500 → exit 20; App not opened.""" + import ratatoskr.tui as tui_mod + from ratatoskr.sessions import SessionApiFailed + + async def fake_list(client, **kw): + raise SessionApiFailed(status=500, body=b"boom") + + monkeypatch.setattr(tui_mod, "list_sessions", fake_list) + opened: list[int] = [] + + async def spy(self, *a, **k): + opened.append(1) + return 0 + + monkeypatch.setattr(RatatoskrApp, "run_async", spy) + rc = run_tui(self._bare_args()) + assert rc == 20 + assert "[session_api_failed]" in capsys.readouterr().err + assert not opened diff --git a/uv.lock b/uv.lock index 2235fdd..793ebdc 100644 --- a/uv.lock +++ b/uv.lock @@ -1052,7 +1052,7 @@ wheels = [ [[package]] name = "ratatoskr" -version = "0.18.6" +version = "0.18.7" source = { editable = "." } dependencies = [ { name = "httpx" },