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:
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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"):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user