Files
ratatoskr/docs/contracts/issues/8.contract.md
T
vh d30be12deb 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"`.
2026-05-23 17:58:22 -07:00

21 KiB

contract_version, target_module, scope, depends_on, used_by, language, complexity, estimated_loc, confidence, assumptions, open_questions, prd, dependencies
contract_version target_module scope depends_on used_by language complexity estimated_loc confidence assumptions open_questions prd dependencies
2.1 ratatoskr.sessions Startup agent picker for TUI mode when `--new` is passed without `--agent`. Small surface change distributed across three existing modules via in-place contract amendments: `ratatoskr.sessions` gains `list_agents()` (GET /agents) returning `list[AgentInfo]` (new frozen dataclass with omit-when-null defaults mirroring SessionInfo's INV-001/INV-002 pattern); `ratatoskr.cli` softens `--agent` requirement from absolute to mode-conditional (`--send --new` still requires it; bare `--new` accepts None; `--session` still forbids it); `ratatoskr.tui._resolve_then_run` gains a pre-create branch that, when `args.new and args.agent_id is None`, calls `list_agents(client)` then runs a dedicated tiny `AgentPickerApp` (separate Textual `App` instance, opens before the main `RatatoskrApp`) whose `run_async()` returns the chosen `agent_id` (or `None` on Esc/Ctrl-D for clean exit). No new files; no wire-level surface change beyond the new endpoint hit. Composes naturally with #5 (`--end-user-id`): both thread through `ParsedArgs` before any App opens.
httpx
textual
ratatoskr.cli
ratatoskr.tui
python low 200 0.85
Worldtree spec pin (`docs/conversation-api-spec.md` v0.19.0, repo SHA `55101e909abcd2219833266b6f905c5bc956e0f0`) declares `GET /agents` at §832: returns 200 with a JSON array of agent objects. Three fields always present (`agent_id`, `name`, `description`); five optional with omit-when-null/omit-when-empty semantics (`version`, `capabilities`, `supported_models`, `persona_traits`, `ui_hints`). No pagination, no filters, no auth-scope requirement beyond bearer-authenticated (worldtree-dev confirmed 2026-05-23: Tier 1 `agent.list` baseline scope covers it; same auth posture as POST /sessions).
`AgentInfo` is a frozen dataclass on `ratatoskr.sessions` (alongside `SessionInfo` / `SessionPage`) carrying all 8 fields. Origin-conditional defaults mirror INV-001/INV-002 from issue #2: required fields (`agent_id`, `name`, `description`) take the response value verbatim; optional fields default to `None` (scalar `version`) or empty container (`capabilities=[]`, `supported_models=[]`, `persona_traits={}`, `ui_hints={}`) when omitted from the response. Empty containers (NOT `None`) for collection-shaped optionals so caller code can branch on truthiness without `is None` ceremony.
`list_agents` uses the same caller-owned-client posture as `create_session` / `list_sessions`: takes `httpx.AsyncClient`, returns `list[AgentInfo]`, raises `SessionApiFailed(status, body)` on any non-200 response. No new exception type — list_agents' failure modes (auth, transport, server-side 5xx) all map cleanly to the existing `SessionApiFailed` shape. The module's posture against shared types with sse_client (`ratatoskr.sessions` issue #1 convention dependency) is preserved.
**CLI surface change is mode-conditional, not flag-removal**: `--agent` becomes optional ONLY when `--new` is passed AND `--send` is NOT passed (i.e., bare TUI-mode create). `--send --new` still raises `UsageError('--agent is required when --new is passed in --send mode')` because non-interactive --send mode has no way to prompt. `--session <id>` still forbids `--agent` (preserves issue #3 INV-004 mutual-exclusion). The single existing line `if ns.new and not ns.agent: raise UsageError(...)` in `_parse_args` STEP 3 splits into two conditionals that distinguish on `ns.send`.
**Picker is its own App, not a Screen within RatatoskrApp** (deliberate divergence from the issue body's 'pushed onto the App's screen stack' phrasing, which predated issue #6's refactor). Reason: issue #6's load-bearing invariant is that session-resolution + startup errors land on the operator's REAL stderr before any alt-screen opens. `list_agents` failures (network, auth, server error) need that same property. Doing it via a Screen inside RatatoskrApp re-introduces the alt-screen-eats-stderr problem #6 fixed. Doing it via a separate `AgentPickerApp` opened in `_resolve_then_run` (before `RatatoskrApp`) preserves #6's invariant: `list_agents` errors print to stderr and short-circuit BEFORE the picker's alt-screen opens; picker errors (which don't really exist — it's pure UI navigation) are bounded; chosen `agent_id` flows back through `app.run_async()`'s return value.
**Two alt-screen cycles is acceptable** (picker opens + closes; main RatatoskrApp opens). Textual's `App.run_async()` handles alt-screen entry + restoration cleanly per-instance. The visible-flicker cost is one quick alt-screen flash between picker dismissal and main App mount; the architectural cost of avoiding this (Screen-within-App, breaking #6) is higher than the cosmetic cost. If empirical operator feedback indicates the flicker is jarring, follow-up issue collapses to one App with two Screens AFTER re-engineering the stderr-error path.
**Picker Esc/Ctrl-D returns exit 0, NOT a `UsageError`**: when the operator dismisses the picker without choosing, the intent is 'never mind, exit cleanly' — same as Ctrl-D from the main chat pane in INV-002 of issue #4. `_resolve_then_run` returns 0 without calling `create_session` or `App.run_async()` on RatatoskrApp. No session is created server-side; no `agent_id` is required to satisfy this exit path.
**Picker layout uses ListView, not DataTable**: design-brief §5 mentions `DataTable` for the session picker but ListView is the right primitive for agent picking — single-column, keyboard-navigable, one row per agent rendered as `agent_id · name — description`. v1 picker is a flat list per the issue's out-of-scope clause (search/filter/sort/ui_hints rendering all deferred). DataTable's column-header + sortable-column ergonomics are wasted on this surface.
**Empty agent list is a clean exit, not an error**: if `GET /agents` returns `[]`, the picker writes `[no_agents] server returned empty agent list\n` to stderr and `_resolve_then_run` returns exit code 13 (new — see ERROR_ROUTING below). The picker UI never opens in this case; no point showing an empty list with no actionable rows.
**Single agent does NOT auto-select**: if `GET /agents` returns one agent, the picker still opens with one row. Auto-select would hide the choice (and the agent's description) from the operator. The cost is one keystroke; the benefit is transparency about what's about to happen.
**`AgentInfo` field order in the dataclass matches the spec's column order** (`agent_id`, `name`, `description`, `version`, `capabilities`, `supported_models`, `persona_traits`, `ui_hints`). Mirrors how readers scanning the dataclass map mental model from spec → code.
**`AgentInfo.persona_traits` / `ui_hints` are typed as `dict[str, Any]` not nested dataclasses**: v1 picker just displays `agent_id · name — description`; the inner shape (ocean object, icon, color_hint, vibe) is opaque to ratatoskr. Future polish that renders icon/color_hint would either parse on-demand or introduce nested dataclasses then. Keeping them as dict[str, Any] avoids paying a typing tax now for a display surface that's deferred.
**`list_agents` does NOT pass query params**: spec §832 declares no pagination, no filters. The request is a bare `GET /agents` with the bearer header from the caller-owned client. If Worldtree later adds filters (e.g., `?capability=foo`), `list_agents` gains them via amendment then.
Should the picker display `version` when available (e.g., `mimir v0.2.0 — Keeper of the Well of Knowledge`)? Draft: no for v1 — the issue body specifies `agent_id · name — description` exactly. Add in a follow-up if operators report ambiguity (two `mimir` rows from different deployments). Drift-check evidence first.
Should `AgentPickerApp.run_async()` return the chosen `AgentInfo` or just the `agent_id` string? Draft: just the `agent_id` string for v1 — that's all `create_session` needs. Returning the full `AgentInfo` would let `RatatoskrApp` show name/description in the identity widget without re-fetching, but the existing identity widget format is `<agent_id> · …<session_id>` so the extra metadata has no consumer yet. Defer until §5 side-panes work needs it.
Should the picker show a loading spinner while `list_agents` is in flight? Draft: no for v1 — list_agents runs BEFORE the picker App opens (per the architectural decision above), so there's no in-app loading state to show. Operator sees stderr label on failure; on success the picker opens with the list already populated. If the request latency turns out to be noticeable (e.g., >300ms), reconsider.
issue issue_url body_sha256_16 lock_in_comment_id lock_in_sha256_16 lock_in_at pinned_at
8 #8 c34f4878936a4edc null null null 2026-05-24T00:49:03+00:00
issue path reason
2 src/ratatoskr/sessions.py In-place contract amendment: new `list_agents()` function + new `AgentInfo` frozen dataclass added to the module's public surface. POST /sessions paths unchanged; no shared types with the new code beyond the module's existing `SessionApiFailed` exception (reused for non-200 responses).
issue path reason
3 src/ratatoskr/cli.py In-place contract amendment: `_parse_args` STEP 3 splits the single `if ns.new and not ns.agent` check into two conditionals — `--send --new` keeps the strict requirement; bare `--new` accepts `agent_id=None` for the TUI-picker case.
issue path reason
4 src/ratatoskr/tui.py In-place contract amendment: new `AgentPickerApp(App[str | None])` class with ListView + Enter/Esc bindings; `_resolve_then_run` gains a pre-create branch that runs the picker when `args.new and args.agent_id is None`; on chosen `agent_id`, threads it into `create_session(client, chosen, end_user_id=args.end_user_id)`. RatatoskrApp itself is unchanged.

Startup agent picker — GET /agents when --new without --agent (TUI)

Context

Today, ratatoskr --new requires --agent <id>. If omitted, _parse_args raises UsageError("--agent is required when --new is passed"). That's correct for --send --new (non-interactive — can't prompt) but wrong for the TUI (operator may not know which agents are available, would prefer to pick from a list at startup).

Worldtree's spec §832 exposes GET /agents. The endpoint returns a flat JSON array; required fields are agent_id, name, description; optional fields (version, capabilities, supported_models, persona_traits, ui_hints) follow omit-when-null/empty rules. No pagination, no filters, no special scope. Worldtree-dev confirmed 2026-05-23: Tier 1 baseline auth covers it.

This issue threads a small surface change through three existing modules in-place — no new files apart from this contract.

Data flow

Input:

  • httpx.AsyncClient (caller-owned, base_url + bearer auth on the client).
  • No request body, no query params.

Output (list_agents):

  • list[AgentInfo] — one entry per available agent, in server-declared order.

Output (AgentPickerApp.run_async()):

  • str | None — chosen agent_id, or None on Esc/Ctrl-D dismissal.

Public surface (ratatoskr.sessions amendment)

@dataclass(frozen=True)
class AgentInfo:
    """One agent's metadata from GET /agents.

    INV-005: Required fields (`agent_id`, `name`, `description`) take the
    response value verbatim. Optional fields default to None (`version`)
    or an empty container (`capabilities`, `supported_models`,
    `persona_traits`, `ui_hints`) when omitted from the server response,
    mirroring SessionInfo's INV-001/INV-002 origin-conditional pattern.
    """

    agent_id: str
    name: str
    description: str
    version: str | None
    capabilities: list[str]
    supported_models: list[str]
    persona_traits: dict[str, Any]
    ui_hints: dict[str, Any]


async def list_agents(client: httpx.AsyncClient) -> list[AgentInfo]:
    """GET /agents → list of available agents. See contract FN list_agents."""

Functions

FN list_agents

FN list_agents(client: httpx.AsyncClient) -> list[AgentInfo]
BRIEF: GET /agents → list of available agents. No pagination, no filters.

PRE-001: client is not None.

STEPS:
  1. resp = await client.get("/agents")
  2. IF resp.status_code != 200:
       raise SessionApiFailed(status=resp.status_code, body=resp.content)
  3. body = resp.json()  # expected: list[dict]
  4. items = [
       AgentInfo(
         agent_id      = item["agent_id"],
         name          = item["name"],
         description   = item["description"],
         version       = item.get("version"),
         capabilities  = item.get("capabilities") or [],
         supported_models = item.get("supported_models") or [],
         persona_traits   = item.get("persona_traits") or {},
         ui_hints         = item.get("ui_hints") or {},
       )
       for item in body
     ]
  5. RETURN items

POST-001: every item in the return list has required fields populated.
POST-002: optional fields default to None / [] / {} when absent from the response.

ERROR_ROUTING:
  - 200 with non-list body          → KeyError / TypeError propagates (server bug; not handled here).
  - non-200                         → SessionApiFailed(status=N, body=resp.content)
  - httpx.RequestError              → propagates (network failure; caller handles).

TESTS:
  - test_happy_full_shape: 200 + spec's full-shape mimir example → AgentInfo with all fields populated.
  - test_happy_minimum_shape: 200 + spec's minimum-shape "minimal" example → AgentInfo with required + defaulted optional.
  - test_happy_multi_agent: 200 + array of 3 agents → list of 3 AgentInfo in order.
  - test_happy_empty: 200 + [] → empty list (no error).
  - test_omit_capabilities: 200 + agent missing capabilities → AgentInfo.capabilities == [].
  - test_omit_persona_traits: 200 + agent missing persona_traits → AgentInfo.persona_traits == {}.
  - test_omit_ui_hints: 200 + agent missing ui_hints → AgentInfo.ui_hints == {}.
  - test_500_raises: 500 → SessionApiFailed with status=500.
  - test_401_raises: 401 → SessionApiFailed with status=401.

CLI surface change (ratatoskr.cli amendment)

_parse_args STEP 3, currently:

if ns.new and not ns.agent:
    raise UsageError("--agent is required when --new is passed")

becomes:

if ns.new and not ns.agent:
    if ns.send is not None:
        # --send --new mode: non-interactive, cannot prompt for choice.
        raise UsageError("--agent is required with --new in --send mode")
    # else: bare --new (TUI mode) — agent_id stays None, TUI runs picker.

INV-004 (--session + --agent mutual exclusion) is unchanged. Existing --send --new smoke flows that pass --agent continue to work unchanged.

ERROR_ROUTING (cli):

  • --send --new without --agentUsageError → exit 10 (unchanged from today).
  • bare --new without --agentParsedArgs.agent_id=None, TUI handles.

TESTS (additions to test_cli.py):

  • test_parse_send_new_without_agent_raises: --send "hi" --new (no --agent) → UsageError.
  • test_parse_bare_new_without_agent_accepted: --new (no --agent, no --send) → ParsedArgs with agent_id=None.
  • test_parse_bare_new_with_agent_accepted: --new --agent mimir → unchanged behavior, agent_id="mimir".

TUI surface change (ratatoskr.tui amendment)

New: AgentPickerApp

class AgentPickerApp(App[str | None]):
    """Single-purpose picker App. Opens before RatatoskrApp.

    `run_async()` returns the chosen agent_id (str) or None on Esc/Ctrl-D.
    """

    BINDINGS: ClassVar[list[Binding]] = [
        Binding("enter", "pick", "Pick", 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, agents: list[AgentInfo]) -> None:
        super().__init__()
        assert agents  # PRE-002 — caller guarantees non-empty
        self.agents = agents

    def compose(self) -> ComposeResult:
        yield Header()
        yield Static("Pick an agent for the new session:", id="picker-prompt")
        yield ListView(
            *[
                ListItem(Label(f"{a.agent_id} · {a.name}{a.description}"))
                for a in self.agents
            ],
            id="agent-list",
        )
        yield Footer()

    async def on_mount(self) -> None:
        self.query_one("#agent-list", ListView).focus()

    def action_pick(self) -> None:
        lv = self.query_one("#agent-list", ListView)
        idx = lv.index
        if idx is None:
            return  # no row highlighted; ignore
        self.exit(self.agents[idx].agent_id)

    def action_dismiss(self) -> None:
        self.exit(None)

Modified: _resolve_then_run

Insert a pre-create branch between the async with httpx.AsyncClient(...) and the existing if args.new: block:

async def _resolve_then_run(args: ParsedArgs) -> int:
    assert isinstance(args, ParsedArgs) and args.send_content is None
    async with httpx.AsyncClient(...) as client:
        # NEW (issue #8): startup agent picker when --new without --agent.
        chosen_agent_id: str | None = args.agent_id
        if args.new and args.agent_id is None:
            try:
                agents = await list_agents(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 agents:
                sys.stderr.write("[no_agents] server returned empty agent list\n")
                return 13
            picker = AgentPickerApp(agents)
            chosen_agent_id = await picker.run_async()
            if chosen_agent_id is None:
                return 0  # Esc/Ctrl-D — clean exit, no session created
        # EXISTING: create_session OR re-use --session id
        if args.new:
            assert chosen_agent_id is not None
            try:
                info = await create_session(
                    client, chosen_agent_id, end_user_id=args.end_user_id
                )
            ...
        else:
            ...
        app = RatatoskrApp(args, session_id=session_id, agent_id=agent_id, client=client)
        ...

ERROR_ROUTING (tui startup)

Failure Stderr label Exit
list_agentsSessionApiFailed [session_api_failed] status=N body=B 20
list_agents → network error [network_error] T: M 21
empty agent list (GET /agents returns []) [no_agents] server returned empty agent list 13 (new)
picker Esc/Ctrl-D dismissal (none — clean exit) 0
create_session post-pick → AgentNotFound [agent_not_found] agent_id=X 12 (unchanged from #4)

INV: exit code 13 is new (no-agents). Previously unused — does not collide with any existing exit code in _resolve_then_run or _amain.

TESTS (additions to test_tui.py)

- test_picker_happy_path: list_agents returns 2 agents; AgentPickerApp opens; user picks index 0; chosen agent_id flows into create_session; main App opens.
- test_picker_esc_clean_exit: list_agents returns 2 agents; picker opens; user presses Esc; _resolve_then_run returns 0; create_session NOT called; RatatoskrApp NOT opened.
- test_picker_skipped_when_agent_id_provided: bare --new --agent mimir → list_agents NOT called; picker NOT opened; create_session called with "mimir".
- test_picker_skipped_when_session_mode: --session s-1 → list_agents NOT called; picker NOT opened; no create_session.
- test_picker_list_agents_session_api_failed: list_agents raises SessionApiFailed → stderr [session_api_failed]; exit 20; picker NOT opened; create_session NOT called.
- test_picker_list_agents_network_error: list_agents raises ConnectError → stderr [network_error]; exit 21.
- test_picker_empty_list: list_agents returns [] → stderr [no_agents]; exit 13; picker NOT opened; create_session NOT called.
- test_agent_picker_app_renders_rows: AgentPickerApp with 3 agents → ListView has 3 ListItem children with expected text.
- test_agent_picker_app_pick_returns_agent_id: simulate Enter on highlighted row → exit value == agents[idx].agent_id.
- test_agent_picker_app_dismiss_returns_none: simulate Esc → exit value is None.

Invariants

  • INV-005: AgentInfo field defaults are origin-conditional (mirrors INV-001/002 from #2). Required → verbatim; optional → None / [] / {}.
  • INV-006: list_agents failure modes route through SessionApiFailed only — no new exception type introduced.
  • INV-007: Picker is a separate App, opened by _resolve_then_run BEFORE RatatoskrApp. Preserves issue #6's stderr-error invariant for list_agents failures.
  • INV-008: --agent CLI requirement is mode-conditional: required only when --send --new; bare --new accepts None; --session always forbids it.
  • INV-009: Picker dismissal (Esc/Ctrl-D) returns clean exit 0; no session created server-side.
  • INV-010: Empty agent list is a clean stderr exit (code 13), not an open picker.
  • INV-011: Single-agent response still opens the picker — no auto-select.
  • INV-012: Two alt-screen cycles (picker + main App) is the explicit architectural tradeoff for preserving INV-007.