Compare commits

...

1 Commits

Author SHA1 Message Date
vh 5c1b9816d4 feat(#6): startup session picker for bare TUI mode
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.
2026-06-30 22:05:14 -07:00
8 changed files with 483 additions and 14 deletions
+61
View File
@@ -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 <id>` + 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.
```
+2
View File
@@ -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 <id>`/`--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._
+1 -1
View File
@@ -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"
+8 -2
View File
@@ -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:
+158 -4
View File
@@ -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 <id>\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()
+30 -6
View File
@@ -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(
+222
View File
@@ -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
Generated
+1 -1
View File
@@ -1052,7 +1052,7 @@ wheels = [
[[package]]
name = "ratatoskr"
version = "0.18.6"
version = "0.18.7"
source = { editable = "." }
dependencies = [
{ name = "httpx" },