feat(sessions,cli,tui): issue #8 — startup agent picker (v0.3.0)

Adds GET /agents fetch + ListView picker for bare `--new` (TUI mode
without --agent). Three in-place amendments:

- ratatoskr.sessions: new `list_agents()` + `AgentInfo` frozen
  dataclass with omit-when-null/empty defaults mirroring SessionInfo's
  INV-001/INV-002 origin-conditional pattern. Non-200 responses raise
  the existing SessionApiFailed (no new exception).
- ratatoskr.cli: `_parse_args` softens `--agent` from absolute to
  mode-conditional — required for `--send --new`, optional for bare
  `--new`, forbidden with `--session` (unchanged INV-004).
- ratatoskr.tui: new `AgentPickerApp(App[str | None])` — separate
  Textual App (not Screen-within-RatatoskrApp) so list_agents errors
  land on real stderr before any alt-screen opens (preserves issue
  #6's INV-001). `_resolve_then_run` gains a pre-create branch:
  fetch agents → empty list → exit 13; non-200 → exit 20; network
  error → exit 21; picker dismissed → exit 0; otherwise thread chosen
  agent_id into create_session.

Contract: docs/contracts/issues/8.contract.md (drift-check clean).

Tests: +18 (227 total, was 209). Live smoke against personal Worldtree
(:8081) returned 12 agents; programmatic picker drive auto-picked lofn
and created a real session with `end_user_id="ratatoskr-tui"`.
This commit is contained in:
vh
2026-05-23 17:58:22 -07:00
parent a77a872810
commit d30be12deb
10 changed files with 986 additions and 43 deletions
+28 -2
View File
@@ -176,11 +176,37 @@ class TestParseArgs:
with pytest.raises(UsageError, match="pass exactly one"):
_parse_args(["--send", "hi", "--api-key", "k"])
def test_usage_new_without_agent(self) -> None:
"""usage_new_without_agent: --new without --agent → UsageError."""
def test_usage_send_new_without_agent(self) -> None:
"""send_new_without_agent (issue #8): --send --new without --agent → UsageError.
--send mode is non-interactive — cannot prompt; --agent stays required.
"""
with pytest.raises(UsageError, match="--agent is required when --new"):
_parse_args(["--send", "hi", "--new", "--api-key", "k"])
def test_parse_bare_new_without_agent_accepted(self) -> None:
"""bare_new_without_agent (issue #8): --new without --send or --agent → agent_id=None.
TUI mode CAN prompt; startup picker handles the choice. _parse_args
accepts None here and the TUI's _resolve_then_run drives the picker.
"""
args = _parse_args(["--new", "--api-key", "k"])
assert args.new is True
assert args.agent_id is None
assert args.send_content is None
def test_parse_bare_new_with_agent_accepted(self) -> None:
"""bare_new_with_agent (issue #8): --new --agent mimir (no --send) → picker skipped.
Existing TUI launch path with an explicit agent_id continues to work
— _resolve_then_run sees `args.agent_id is not None` and skips the
picker entirely.
"""
args = _parse_args(["--new", "--agent", "mimir", "--api-key", "k"])
assert args.new is True
assert args.agent_id == "mimir"
assert args.send_content is None
def test_usage_session_with_agent(self) -> None:
"""usage_session_with_agent: --session AND --agent → UsageError."""
with pytest.raises(UsageError, match="forbidden with --session"):