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:
2026-05-23 17:58:22 -07:00
parent a77a872810
commit d30be12deb
10 changed files with 986 additions and 43 deletions
+323
View File
@@ -0,0 +1,323 @@
---
contract_version: "2.1"
target_module: "ratatoskr.sessions"
scope: "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."
depends_on:
- "httpx"
- "textual"
used_by:
- "ratatoskr.cli"
- "ratatoskr.tui"
language: "python"
complexity: "low"
estimated_loc: 200
confidence: 0.85
assumptions:
- "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."
open_questions:
- "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."
prd:
issue: 8
issue_url: "https://gitea.phasefinal.com/vh/ratatoskr/issues/8"
body_sha256_16: "c34f4878936a4edc"
lock_in_comment_id: null
lock_in_sha256_16: null
lock_in_at: null
pinned_at: "2026-05-24T00:49:03+00:00"
dependencies:
- issue: 2
path: "src/ratatoskr/sessions.py"
reason: "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: 3
path: "src/ratatoskr/cli.py"
reason: "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: 4
path: "src/ratatoskr/tui.py"
reason: "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)
```python
@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:
```python
if ns.new and not ns.agent:
raise UsageError("--agent is required when --new is passed")
```
becomes:
```python
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 `--agent``UsageError` → exit 10 (unchanged from today).
- bare `--new` without `--agent``ParsedArgs.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
```python
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:
```python
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_agents``SessionApiFailed` | `[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.
+28 -33
View File
@@ -1,6 +1,6 @@
# Persistent memory — ratatoskr
_Last updated: 2026-05-23_
_Last updated: 2026-05-24_
This file captures durable intent and supporting evidence (goals, decisions,
foot-gun warnings, in-flight state) across context resets. Read it at session
@@ -32,51 +32,47 @@ separate dev team rather than an in-tree Worldtree tool.
## Current state / in-flight
_As of 2026-05-23 (end of day, post-v0.2.1 layout fix):_
_As of 2026-05-24 (post-v0.3.0 startup agent picker):_
**Status: v0.2.0 + v0.2.1 shipped and pushed.** Seven core issues
complete (`sse_client` #1, `sessions` #2, `cli` #3, `tui` #4,
`--end-user-id` #5, TUI startup error visibility #6, presenter
contract semantics amendment #12) + robustness fix #7 (MalformedSseData
+ empty-skip) + v0.2.1 TUI layout fix on top. 209/209 tests GREEN;
ruff clean; working tree clean; both v0.2.0 and v0.2.1 tags on
`origin/main`.
**Status: v0.3.0 shipped.** Eight core issues complete (`sse_client`
#1, `sessions` #2, `cli` #3, `tui` #4, `--end-user-id` #5, TUI
startup error visibility #6, presenter contract semantics amendment
#12, startup agent picker #8) + robustness fix #7 (MalformedSseData
+ empty-skip) + v0.2.1 TUI layout fix. 227/227 tests GREEN; ruff
clean.
Last commits on `main`:
- v0.3.0 feat(sessions,cli,tui): issue #8 — startup agent picker
- `c85f6bd` fix(tui): anchor layout via dock so Input never moves (v0.2.1)
- `3b9c610` feat(cli,tui): issue #12 — presenter contract semantics amendment (v0.2.0)
- `8282156` snapshot: persistent-memory Heimdall scope-model foot-gun
- `804c2df` feat(sessions,cli,tui): issues #5 + #6 + worldtree-dev follow-up (v0.1.0)
**Smoke status:**
- `--send` v0.1.0 mimir smoke clean earlier today (`[done] turn_id=116
duration_ms=5467`).
- `--send` v0.2.0 mimir smoke survived a Worldtree-side stall (see
Tried-and-abandoned for the 2-events-then-silence diagnostic shape).
After worldtree-dev's operator-side `:8081` restart, mimir responded
cleanly with the new v0.2.0 rendering shape: coalesced thinking
runs, `. ` ASCII prefix for demoted telemetry, `duration=11.8s` /
`usage 40764 in -> 850 out (41614 total, 0 cached)` formatting.
- `--send --new --agent mimir` v0.3.0 smoke clean
(`[done] turn_id=141 model=qwen3.6-35-a3b duration=2.2s`).
- Live `list_agents` smoke against personal Worldtree returned 12
agents (actor, bragi, cara, domari, forseti, glados, leif, lofn,
mimir, soong, troi, saga).
- Picker end-to-end smoke against live Worldtree: bare `--new`
list_agents → picker (auto-picked lofn programmatically since
driving alt-screen interactively from CLI smoke isn't possible)
→ POST /sessions with end_user_id="ratatoskr-tui" succeeded;
RatatoskrApp constructed with agent_id="lofn".
- TUI v0.2.0 was visually broken (Input pane bouncing with thinking
runs); v0.2.1 fixed via dock-based layout. Operator confirmed
"a lot better" interactively.
- Lofn smoke is **auth-unblocked** (Tier 1 agent.call:* baseline
covers it; the phantom-scope-add diagnosis is settled in
Tried-and-abandoned). Not yet run.
**Outstanding operator-side todos:**
- **Post-v0.2.1 lofn smoke** — `source env.sh && uv run ratatoskr
--new --agent lofn --send "hello"`. Now unblocked on auth.
- **Interactive TUI picker eyeball** — `source env.sh && uv run
ratatoskr --new` (no flags after) should show the picker; pick
lofn; type a message; verify response streams cleanly. Auto-pick
smoke confirmed the wiring; visual confirmation pending.
- **Post-v0.2.1 TUI multi-turn eyeball** — confirm thinking-run
bouncing is gone across multiple turns; the layout fix has only
been confirmed for a single turn so far.
**Pending issues filed but not started:**
- **Issue #8 (startup agent picker)** — filed but unscaffolded.
Worldtree-dev confirmed `GET /agents` requires no special scope;
auth side is unblocked. Composes with #5 (both thread through
`ParsedArgs` → `_resolve_then_run`). **This is the next planned
feature** (see Recent decisions §5-sequencing).
- **Issue #9 (spec-pin refresh v0.19.0 → v0.22.1)** — filed
2026-05-23. Documentation debt; defer unless we need a v0.20.0+
capability.
@@ -101,15 +97,14 @@ Branch: `main` (clean). Remote:
**Next natural moves:**
1. **Lofn smoke** — quick operator-side eyeball.
2. **Issue #8 (startup agent picker)** — scaffold + contract +
TDD. Independent of layout work; gates §5 side-panes work per
the 2026-05-23 sequencing decision.
3. **§5 side-panes work** — Persona pane first per design-brief; the
1. **Interactive picker eyeball** — operator confirms the TUI
picker UX (rendering, Enter pick, Esc dismiss) against personal
Worldtree.
2. **§5 side-panes work** — Persona pane first per design-brief; the
collapsible Thinking pane + Debug pane proposals fold IN as
additional `TabbedContent` tabs alongside Persona/Tools/AdminEvents.
Reshapes layout from vertical-stack to Horizontal two-column.
4. **Issue #9 (spec-pin refresh)** — defer unless we need a v0.20.0+
3. **Issue #9 (spec-pin refresh)** — defer unless we need a v0.20.0+
capability (e.g., `memory_context` for Phase 2.1).
## Recent decisions
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "ratatoskr"
version = "0.2.1"
version = "0.3.0"
description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard"
readme = "README.md"
requires-python = ">=3.12"
+5 -2
View File
@@ -124,8 +124,11 @@ def _parse_args(argv: list[str] | None) -> ParsedArgs:
raise UsageError("pass exactly one of --session or --new")
if ns.session and ns.agent:
raise UsageError("--agent is required with --new and forbidden with --session")
if ns.new and not ns.agent:
raise UsageError("--agent is required when --new is passed")
if ns.new and not ns.agent and ns.send is not None:
# Issue #8: --agent stays required for --send --new (non-interactive,
# cannot prompt). Bare --new (TUI mode) accepts None — picker drives
# the choice via list_agents in _resolve_then_run.
raise UsageError("--agent is required when --new is passed in --send mode")
api_key = ns.api_key or os.environ.get("WORLDTREE_API_KEY") or ""
if not api_key:
+47
View File
@@ -39,6 +39,26 @@ class SessionPage:
next_cursor: str | None
@dataclass(frozen=True)
class AgentInfo:
"""Worldtree agent envelope from GET /agents (issue #8).
INV-005: required fields (`agent_id`, `name`, `description`) take the
response value verbatim. Optional fields default to None / [] / {} when
omitted by the server, mirroring SessionInfo's INV-001/INV-002
origin-conditional defaulting.
"""
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]
class AgentNotFound(Exception):
"""Raised on HTTP 404 from POST /sessions — unknown agent_id."""
@@ -153,3 +173,30 @@ async def create_session(
archived=False,
tags=[],
)
async def list_agents(client: httpx.AsyncClient) -> list[AgentInfo]:
"""GET /agents — list available agents. See contract FN list_agents (issue #8).
No request params, no pagination. Returns server-ordered list. Optional
fields are defaulted to None / [] / {} per INV-005.
"""
assert client is not None
resp = await client.get("/agents")
if resp.status_code != 200:
raise SessionApiFailed(status=resp.status_code, body=resp.content)
body = resp.json()
return [
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
]
+93 -4
View File
@@ -16,10 +16,16 @@ from typing import ClassVar, Literal
import httpx
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.widgets import Footer, Header, Input, RichLog, Static
from textual.widgets import Footer, Header, Input, Label, ListItem, ListView, RichLog, Static
from ratatoskr.cli import USER_AGENT, ParsedArgs, _format_duration_ms, _format_usage
from ratatoskr.sessions import AgentNotFound, SessionApiFailed, create_session
from ratatoskr.sessions import (
AgentInfo,
AgentNotFound,
SessionApiFailed,
create_session,
list_agents,
)
from ratatoskr.sse_client import (
CancelAlreadyCompleted,
CancelFailed,
@@ -211,6 +217,67 @@ class TuiPresenterState:
log.write(f"[render_error] {type(exc).__name__}")
class AgentPickerApp(App[str | None]):
"""Startup agent picker (issue #8). Opens before RatatoskrApp when --new
is passed without --agent. `run_async()` returns the chosen agent_id (str)
or None on Esc/Ctrl-D dismissal.
Architecturally separate from RatatoskrApp (deliberate per issue #8
INV-007): keeps list_agents failures landing on real stderr before any
alt-screen opens, preserving issue #6's invariant.
"""
DEFAULT_CSS = """
#picker-prompt {
dock: top;
height: 1;
padding: 0 1;
}
#agent-list {
height: 1fr;
}
"""
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__()
# PRE-002: caller (_resolve_then_run) checks for empty list and emits
# [no_agents] before constructing the picker.
assert agents
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 # nothing highlighted; ignore
self.exit(self.agents[idx].agent_id)
def action_dismiss(self) -> None:
self.exit(None)
class RatatoskrApp(App[int]):
"""Textual TUI shell — single chat pane."""
@@ -436,11 +503,33 @@ async def _resolve_then_run(args: ParsedArgs) -> int:
# Default 5s read timeout would kill mid-stream; disable it.
timeout=httpx.Timeout(connect=10.0, read=None, write=10.0, pool=10.0),
) as client:
# Issue #8: startup agent picker — fetch GET /agents and prompt when
# --new is passed without --agent. list_agents errors land on real
# stderr before any alt-screen opens (preserves issue #6 INV-001).
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
if args.new:
assert args.agent_id is not None
assert chosen_agent_id is not None
try:
info = await create_session(
client, args.agent_id, end_user_id=args.end_user_id
client, chosen_agent_id, end_user_id=args.end_user_id
)
except AgentNotFound as exc:
sys.stderr.write(f"[agent_not_found] agent_id={exc.agent_id}\n")
+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"):
+144
View File
@@ -5,11 +5,13 @@ import pytest
import respx
from ratatoskr.sessions import (
AgentInfo,
AgentNotFound,
InvalidCursor,
SessionApiFailed,
SessionPage,
create_session,
list_agents,
list_sessions,
)
@@ -412,3 +414,145 @@ class TestListSessions:
with pytest.raises(AssertionError):
await list_sessions(client, cursor="")
assert route.call_count == 0
# ---- Issue #8: list_agents + AgentInfo --------------------------------------
class TestListAgents:
@respx.mock
async def test_happy_full_shape(self) -> None:
"""happy_full_shape [happy,tracer]: spec full-shape mimir example → all fields."""
respx.get("https://w.example/agents").mock(
return_value=httpx.Response(
200,
json=[
{
"agent_id": "mimir",
"name": "Mimir",
"description": "Keeper of the Well of Knowledge.",
"version": "0.2.0",
"capabilities": ["knowledge_base", "semantic_search"],
"supported_models": ["default", "heavy"],
"persona_traits": {
"ocean": {
"openness": 0.7,
"conscientiousness": 0.9,
"extraversion": 0.1,
"agreeableness": 0.5,
"neuroticism": 0.3,
},
"vibe": "contemplative",
},
"ui_hints": {"icon": "well", "color_hint": "#5b8aa3"},
}
],
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
agents = await list_agents(client)
assert len(agents) == 1
a = agents[0]
assert isinstance(a, AgentInfo)
assert a.agent_id == "mimir"
assert a.name == "Mimir"
assert a.description == "Keeper of the Well of Knowledge."
assert a.version == "0.2.0"
assert a.capabilities == ["knowledge_base", "semantic_search"]
assert a.supported_models == ["default", "heavy"]
assert a.persona_traits["vibe"] == "contemplative"
assert a.ui_hints["icon"] == "well"
@respx.mock
async def test_happy_minimum_shape(self) -> None:
"""happy_minimum_shape: required-only agent → optional fields default."""
respx.get("https://w.example/agents").mock(
return_value=httpx.Response(
200,
json=[
{
"agent_id": "minimal",
"name": "Minimal Agent",
"description": "Just a sketch.",
}
],
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
agents = await list_agents(client)
a = agents[0]
assert a.agent_id == "minimal"
assert a.version is None
assert a.capabilities == []
assert a.supported_models == []
assert a.persona_traits == {}
assert a.ui_hints == {}
@respx.mock
async def test_happy_multi_agent(self) -> None:
"""happy_multi_agent: 3 agents preserve order."""
respx.get("https://w.example/agents").mock(
return_value=httpx.Response(
200,
json=[
{"agent_id": "a", "name": "A", "description": "x"},
{"agent_id": "b", "name": "B", "description": "y"},
{"agent_id": "c", "name": "C", "description": "z"},
],
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
agents = await list_agents(client)
assert [a.agent_id for a in agents] == ["a", "b", "c"]
@respx.mock
async def test_happy_empty(self) -> None:
"""happy_empty: 200 with [] returns empty list (no error)."""
respx.get("https://w.example/agents").mock(
return_value=httpx.Response(200, json=[])
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
agents = await list_agents(client)
assert agents == []
@respx.mock
async def test_omit_capabilities_empty_list(self) -> None:
"""omit_capabilities_empty: explicit [] from server still defaults to []."""
respx.get("https://w.example/agents").mock(
return_value=httpx.Response(
200,
json=[
{
"agent_id": "a",
"name": "A",
"description": "x",
"capabilities": [],
}
],
)
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
agents = await list_agents(client)
assert agents[0].capabilities == []
@respx.mock
async def test_500_raises_session_api_failed(self) -> None:
"""500 → SessionApiFailed with status=500."""
respx.get("https://w.example/agents").mock(
return_value=httpx.Response(500, content=b"oops")
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(SessionApiFailed) as excinfo:
await list_agents(client)
assert excinfo.value.status == 500
@respx.mock
async def test_401_raises_session_api_failed(self) -> None:
"""401 → SessionApiFailed with status=401."""
respx.get("https://w.example/agents").mock(
return_value=httpx.Response(401, content=b'{"error":"unauthorized"}')
)
async with httpx.AsyncClient(base_url="https://w.example") as client:
with pytest.raises(SessionApiFailed) as excinfo:
await list_agents(client)
assert excinfo.value.status == 401
+316
View File
@@ -1577,3 +1577,319 @@ class TestRunTui:
)
with pytest.raises(AssertionError):
run_tui(bad_args)
# ---- Issue #8: startup agent picker ----------------------------------------
def _args_new_no_agent(**overrides) -> ParsedArgs:
"""ParsedArgs for bare --new (no --agent) — TUI-mode picker entry."""
base = dict(
send_content=None,
session_id=None,
new=True,
agent_id=None, # Issue #8: bare --new, picker drives the choice
api_key="k",
server_url="https://w.example",
raw=False,
)
base.update(overrides)
return ParsedArgs(**base)
_AGENTS_RESP = [
{
"agent_id": "mimir",
"name": "Mimir",
"description": "Keeper of the Well of Knowledge.",
},
{
"agent_id": "lofn",
"name": "Lofn",
"description": "Mediator of secret affairs.",
},
]
class TestAgentPickerApp:
def test_picker_renders_rows(self) -> None:
"""picker_renders_rows: AgentPickerApp composes one ListItem per agent."""
from textual.widgets import ListView
from ratatoskr.sessions import AgentInfo
from ratatoskr.tui import AgentPickerApp
agents = [
AgentInfo(
agent_id="a", name="A", description="x",
version=None, capabilities=[], supported_models=[],
persona_traits={}, ui_hints={},
),
AgentInfo(
agent_id="b", name="B", description="y",
version=None, capabilities=[], supported_models=[],
persona_traits={}, ui_hints={},
),
]
app = AgentPickerApp(agents)
async def probe() -> None:
async with app.run_test() as pilot:
lv = app.query_one("#agent-list", ListView)
assert len(lv.children) == 2
await pilot.pause()
app.exit(None)
import asyncio
asyncio.run(probe())
def test_picker_pick_returns_agent_id(self) -> None:
"""picker_pick_returns_agent_id: highlight idx 1 + Enter → exit value == 'b'."""
from ratatoskr.sessions import AgentInfo
from ratatoskr.tui import AgentPickerApp
agents = [
AgentInfo(
agent_id="a", name="A", description="x",
version=None, capabilities=[], supported_models=[],
persona_traits={}, ui_hints={},
),
AgentInfo(
agent_id="b", name="B", description="y",
version=None, capabilities=[], supported_models=[],
persona_traits={}, ui_hints={},
),
]
app = AgentPickerApp(agents)
async def drive() -> str | None:
async with app.run_test() as pilot:
from textual.widgets import ListView
lv = app.query_one("#agent-list", ListView)
lv.index = 1
await pilot.pause()
await pilot.press("enter")
await pilot.pause()
return app.return_value
import asyncio
chosen = asyncio.run(drive())
assert chosen == "b"
def test_picker_esc_returns_none(self) -> None:
"""picker_esc_returns_none: Esc → exit value is None."""
from ratatoskr.sessions import AgentInfo
from ratatoskr.tui import AgentPickerApp
agents = [
AgentInfo(
agent_id="a", name="A", description="x",
version=None, capabilities=[], supported_models=[],
persona_traits={}, ui_hints={},
),
]
app = AgentPickerApp(agents)
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
chosen = asyncio.run(drive())
assert chosen is None
class TestResolveThenRunWithPicker:
"""Issue #8: picker integration in _resolve_then_run."""
@respx.mock
def test_picker_happy_path(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""picker_happy_path [happy,tracer]: agents fetched → picker exits with id → create_session.
Patches AgentPickerApp.run_async to return 'lofn' (simulating user pick);
asserts list_agents fired once, POST /sessions body carries agent_id=lofn,
and RatatoskrApp opens with the chosen identity.
"""
agents_route = respx.get("https://w.example/agents").mock(
return_value=httpx.Response(200, json=_AGENTS_RESP)
)
sessions_route = respx.post("https://w.example/sessions").mock(
return_value=httpx.Response(
201,
json={
**_CREATE_OK_RESP,
"agent_id": "lofn",
},
)
)
from ratatoskr.tui import AgentPickerApp
async def picker_returns_lofn(self, *a, **kw):
return "lofn"
monkeypatch.setattr(AgentPickerApp, "run_async", picker_returns_lofn)
snapshot: dict = {}
async def capture_main(self, *a, **kw):
snapshot["session_id"] = self.session_id
snapshot["agent_id"] = self.agent_id
return 0
monkeypatch.setattr(RatatoskrApp, "run_async", capture_main)
from ratatoskr.tui import run_tui
rc = run_tui(_args_new_no_agent())
assert rc == 0
assert agents_route.call_count == 1
assert sessions_route.call_count == 1
import json as _json
body = _json.loads(sessions_route.calls[0].request.content)
assert body == {"agent_id": "lofn"}
assert snapshot["agent_id"] == "lofn"
@respx.mock
def test_picker_esc_clean_exit(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""picker_esc_clean_exit: picker returns None → exit 0; no create_session; no main App."""
agents_route = respx.get("https://w.example/agents").mock(
return_value=httpx.Response(200, json=_AGENTS_RESP)
)
sessions_route = respx.post("https://w.example/sessions").mock(
return_value=httpx.Response(201, json=_CREATE_OK_RESP)
)
from ratatoskr.tui import AgentPickerApp
async def picker_dismissed(self, *a, **kw):
return None
monkeypatch.setattr(AgentPickerApp, "run_async", picker_dismissed)
main_called = False
async def sentinel(self, *a, **kw):
nonlocal main_called
main_called = True
return 0
monkeypatch.setattr(RatatoskrApp, "run_async", sentinel)
from ratatoskr.tui import run_tui
rc = run_tui(_args_new_no_agent())
assert rc == 0
assert agents_route.call_count == 1
assert sessions_route.call_count == 0
assert main_called is False
@respx.mock
def test_picker_skipped_when_agent_id_provided(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""picker_skipped_when_agent_id_provided: --new --agent mimir → list_agents NOT called."""
agents_route = respx.get("https://w.example/agents").mock(
return_value=httpx.Response(200, json=_AGENTS_RESP)
)
sessions_route = respx.post("https://w.example/sessions").mock(
return_value=httpx.Response(201, json=_CREATE_OK_RESP)
)
async def fake_main(self, *a, **kw):
return 0
monkeypatch.setattr(RatatoskrApp, "run_async", fake_main)
from ratatoskr.tui import run_tui
rc = run_tui(_args_new()) # agent_id="mimir"
assert rc == 0
assert agents_route.call_count == 0
assert sessions_route.call_count == 1
@respx.mock
def test_picker_skipped_when_session_mode(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""picker_skipped_when_session_mode: --session s-1 → no list_agents, no create_session."""
agents_route = respx.get("https://w.example/agents").mock(
return_value=httpx.Response(200, json=_AGENTS_RESP)
)
sessions_route = respx.post("https://w.example/sessions").mock(
return_value=httpx.Response(201, json=_CREATE_OK_RESP)
)
async def fake_main(self, *a, **kw):
return 0
monkeypatch.setattr(RatatoskrApp, "run_async", fake_main)
from ratatoskr.tui import run_tui
rc = run_tui(_args_existing())
assert rc == 0
assert agents_route.call_count == 0
assert sessions_route.call_count == 0
@respx.mock
def test_picker_list_agents_session_api_failed(
self,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
"""list_agents 500 → stderr [session_api_failed]; exit 20; picker NOT opened."""
respx.get("https://w.example/agents").mock(
return_value=httpx.Response(500, content=b"oops")
)
from ratatoskr.tui import AgentPickerApp
picker_called = False
async def sentinel(self, *a, **kw):
nonlocal picker_called
picker_called = True
return None
monkeypatch.setattr(AgentPickerApp, "run_async", sentinel)
main_called = False
async def main_sentinel(self, *a, **kw):
nonlocal main_called
main_called = True
return 0
monkeypatch.setattr(RatatoskrApp, "run_async", main_sentinel)
from ratatoskr.tui import run_tui
rc = run_tui(_args_new_no_agent())
assert rc == 20
err = capsys.readouterr().err
assert "[session_api_failed]" in err
assert "status=500" in err
assert picker_called is False
assert main_called is False
@respx.mock
def test_picker_empty_list(
self,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
"""list_agents returns [] → stderr [no_agents]; exit 13; picker NOT opened."""
respx.get("https://w.example/agents").mock(
return_value=httpx.Response(200, json=[])
)
from ratatoskr.tui import AgentPickerApp
picker_called = False
async def sentinel(self, *a, **kw):
nonlocal picker_called
picker_called = True
return None
monkeypatch.setattr(AgentPickerApp, "run_async", sentinel)
from ratatoskr.tui import run_tui
rc = run_tui(_args_new_no_agent())
assert rc == 13
err = capsys.readouterr().err
assert "[no_agents]" in err
assert picker_called is False
Generated
+1 -1
View File
@@ -968,7 +968,7 @@ wheels = [
[[package]]
name = "ratatoskr"
version = "0.2.1"
version = "0.3.0"
source = { editable = "." }
dependencies = [
{ name = "httpx" },