Compare commits

...

3 Commits

Author SHA1 Message Date
vh d30be12deb feat(sessions,cli,tui): issue #8 — startup agent picker (v0.3.0)
Adds GET /agents fetch + ListView picker for bare `--new` (TUI mode
without --agent). Three in-place amendments:

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

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

Tests: +18 (227 total, was 209). Live smoke against personal Worldtree
(:8081) returned 12 agents; programmatic picker drive auto-picked lofn
and created a real session with `end_user_id="ratatoskr-tui"`.
2026-05-23 17:58:22 -07:00
vh a77a872810 snapshot: persistent-memory — v0.2.1 layout fix + §5 sequencing decision + Worldtree-stall diagnostic
Captures three things accumulated since the v0.2.0 snapshot in 3b9c610:

1. v0.2.1 layout fix (c85f6bd) — Recent decision documenting the
   dock-anchored TUI chrome that fixed Input bouncing. Operator-verified
   "a lot better" interactively. Going-forward principle: TUI-layout
   patches ship + operator verifies (TTY is the load-bearing test
   surface; respx/Pilot can't catch screen-relative positioning bugs).

2. §5 sequencing decision — collapsible Thinking pane + Debug pane
   proposals fold into design-brief §5's TabbedContent column rather
   than ship as inline-Collapsibles first. Do issue #8 (startup agent
   picker) before §5. Avoids the build-inline-then-rebuild waste.

3. Worldtree-stall diagnostic shorthand — "2-events-then-silence"
   = upstream LLM-provider connection wedged, not ratatoskr.
   worldtree-dev confirmed via code-level walk-through (althing
   01KSBKTG096Q…). Future-self defense against bisecting ratatoskr
   code when this shape appears.

Also refreshes the in-flight section: v0.2.0 + v0.2.1 shipped + tagged;
lofn smoke now unblocked on auth (still pending operator); next planned
feature is issue #8 (startup agent picker), then §5 side-panes.

No version bump per CLAUDE.md SemVer etiquette (memory-snapshot
commits skip).
2026-05-23 17:45:21 -07:00
vh c85f6bd701 fix(tui): anchor layout via dock so Input never moves (v0.2.1)
Reported during v0.2.0 mimir smoke: the Input pane bounces up/down
mid-turn and streamed tokens land at shifting screen positions. Cause
is the v0.2.0 compose order — `Static(id="thinking-current")` was
yielded between hint and Footer in the auto-stacked flow, so each
display=True/False toggle per thinking-run shifted Input + identity +
hint vertically. RichLog growth from streaming text also drifted Input
downward in the auto-layout.

Fix: dock the chrome to the screen edges via DEFAULT_CSS:
- thinking-current docks top under Header (grows/shrinks above RichLog,
  doesn't affect Input position).
- transcript (RichLog) gets `height: 1fr` — absorbs all layout reflows
  internally via its scroll viewport.
- prompt (Input), identity, hint all dock bottom — locked above Footer.

Compose order moves thinking-current to position 2 (right after Header)
so its dock-top placement is visually adjacent to where Textual lays it
out. Old position (between hint and Footer) would still work with the
dock CSS, but the proximity reads more clearly.

Screen-relative positions are now stable: Input is anchored to the
bottom-dock stack; RichLog's content scrolls inside its bounded
viewport regardless of how much thinking-current expands. Tokens land
at the same screen position each delta.

No public API change; pure layout fix. 209/209 tests GREEN; ruff clean.
v0.2.0 → v0.2.1 (patch).

Cannot directly verify in TTY from a non-interactive session; operator
verification needed in real terminal.
2026-05-23 17:13:27 -07:00
10 changed files with 1055 additions and 70 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.
+62 -57
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,77 +32,79 @@ separate dev team rather than an in-tree Worldtree tool.
## Current state / in-flight
_As of 2026-05-23 (end of day, post-#12 implementation, pre-commit):_
_As of 2026-05-24 (post-v0.3.0 startup agent picker):_
**Status: issue #12 (presenter contract semantics amendment)
TDD-complete, in working tree, awaiting commit.** 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). 208/208 tests GREEN; ruff clean. pyproject.toml bumped
to v0.2.0; `uv.lock` refreshed. Working tree has 9 modified files +
the new `docs/contracts/issues/12.contract.md` (untracked); commit not
yet authored.
**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`:
- `8282156` snapshot: persistent-memory Heimdall scope-model foot-gun (post-v0.1.0)
- `804c2df` feat(sessions,cli,tui): issues #5 + #6 + worldtree-dev consumer-API follow-up (tagged v0.1.0)
- 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)
`--send` validated end-to-end against personal Worldtree at v0.1.0
(`http://10.250.50.152:8081`, mimir on qwen3.6-35-a3b, 2026-05-23 smoke
returned `[done] turn_id=116 duration_ms=5467`). Lofn smoke is
**auth-unblocked** as of 2026-05-23 — worldtree-dev confirmed our key
(`c990f0be`) already covers Tier 1 agents via the `agent.call:*`
baseline policy; the initial "scope-add needed" diagnosis was a phantom
ask (see Tried-and-abandoned). The actual lofn fix shipped as issue #5
(`--end-user-id` flag).
**Async cross-frontier activity in flight:**
- Issue #12 code-review consult posted to volva 2026-05-23 (althing
thread `01KSBH8GYH4G3H03T767X613W7`). Reply pending in inbox.
**Smoke status:**
- `--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.
**Outstanding operator-side todos:**
- **Commit issue #12 work** + tag v0.2.0 + push. 9 modified files +
new `12.contract.md` ready.
- **Post-v0.2.0 mimir smoke (the visual one)** — `source env.sh && uv
run ratatoskr --new --agent mimir --send "test"` to eyeball the new
rendering (`. thinking: ...` coalesce, `. worker_phase: ...` demotion,
`duration=5.5s` formatting, `usage 6756 in -> 126 out (...)` shape).
The v0.1.0 mimir smoke confirmed wire-level backwards compat but
did NOT exercise the v0.2.0 rendering.
- **Post-v0.2.0 lofn smoke** — `source env.sh && uv run ratatoskr
--new --agent lofn --send "hello"` (env.sh ships
`RATATOSKR_END_USER_ID="ratatoskr-tui"`). 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.
`GET /agents` is free to call (worldtree-dev confirmed); auth side
is unblocked. Depends on #5 composably (both thread through
`ParsedArgs` → `_resolve_then_run`).
- **Issue #9 (spec-pin refresh v0.19.0 → v0.22.1)** — filed
2026-05-23. Documentation debt. None of the v0.20.0/v0.21.0/v0.22.0
changes break ratatoskr's existing surface; the pin lies about
what we've committed to.
- **Issue #10 (subject:{type,id} migration)** — filed 2026-05-23 to
track Worldtree #196's LOCKED-but-not-shipped breaking change.
Don't pre-implement per worldtree-dev's explicit guidance.
2026-05-23. Documentation debt; defer unless we need a v0.20.0+
capability.
- **Issue #10 (subject:{type,id} migration)** — filed 2026-05-23
to track Worldtree #196. Don't pre-implement per worldtree-dev.
- **Issue #11 (AdminEvents pane auth prerequisite)** — filed
2026-05-23. Future side-pane requires `admin.events.read` scope.
2026-05-23. Future side-pane needs `admin.events.read` scope.
Branch: `main` (dirty with #12 work pending commit). Remote:
**Pending Worldtree-dev follow-up:**
- worldtree-dev committed (althing `01KSBKTG096Q…`) to file a
Worldtree-side issue for the stall-watchdog gap (cancel-check is
inside the engine-event loop, so a never-yielding first-LLM-call
bypasses the 300s watchdog). Will file after the immediate stall
is cleared.
- Ratatoskr-side companion (potential): a client-side stall watchdog
(e.g., 90s-no-events → `[server_stalled]` stderr label, keep
connection). Defer until recurrence; defense-in-depth regardless of
whether Worldtree fixes its own.
Branch: `main` (clean). Remote:
`origin → git@gitea.phasefinal.com:vh/ratatoskr.git`.
**Next natural moves:**
1. **Triage volva's #12 code-review** when the reply lands in the
inbox; apply tactical fixes inline, surface architectural calls.
2. **Commit + tag v0.2.0 + push.**
3. **Post-v0.2.0 smokes** — mimir (visual), lofn (newly unblocked).
4. **Issue #8 (startup agent picker)** — scaffold + contract, then
TDD. Composes with the forward end_user_id direction (see Recent
decisions).
5. **Side-pane issues** — Persona pane first (file-tail, cheap).
6. **Issue #9 (spec-pin refresh)** — defer unless we need a v0.20.0+
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.
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
@@ -134,6 +136,8 @@ decision. Captures rationale that won't be obvious from code alone.
- `[2026-05-23]` **Issue #5 (`--end-user-id`) implemented via TDD.** Small surface change across three modules (sessions, cli, tui): `create_session(client, agent_id, *, end_user_id=None)` widens with optional kwarg; body conditionally adds the field when non-None (INV-002: omitting != sending empty); PRE-003 asserts non-empty. `ParsedArgs.end_user_id: str | None = None` field; `--end-user-id` CLI flag with non-empty validation (mirrors `--send` check). `_amain` and `_resolve_then_run` thread `end_user_id=args.end_user_id` to their `create_session` calls. Post-#6 adjustment: the contract originally named `on_mount` as the TUI threading site, but #6 had moved session resolution to `_resolve_then_run` — same shape, different function. 7 new tests across the 3 modules.
- `[2026-05-23]` **Worldtree-dev consult landed authoritative consumer-API guidance** (althing thread `01KSBARG2B8M8C82H6AJGJWX1B`). Key takeaways shaped follow-on work: (1) `end_user_id` is a free-form partition key for long-term memory + persona/valence state; same value → same partition, different values → fully isolated. For Vuong-debugging-Worldtree the recommended posture is a project-stable default with `--end-user-id` override. (2) No programmatic `requires_end_user_id` discovery on `GET /agents` — "try and react to 422" remains the pattern. (3) Breaking-change #196 LOCKED but not shipped: `subject:{type,id}` replaces `end_user_id` at future v0.22.x or v0.23.0; don't pre-implement. (4) Spec pin (v0.19.0) is 3 minor versions stale (current v0.22.1); none of v0.20.0/v0.21.0/v0.22.0 break ratatoskr's surface but the pin lies about what we're committed to. (5) User-Agent header: send one (`ratatoskr/<version> (vh@phasefinal.com)`). (6) `agents.call:lofn` scope needed for lofn smoke. (7) `GET /agents` requires no special scope; issue #8 unblocked on auth.
- `[2026-05-23]` **Follow-up acted on:** User-Agent header added to both `_amain` and `_resolve_then_run` httpx.AsyncClient constructions (with `importlib.metadata` version lookup + fallback to `0.0.0`); `RATATOSKR_END_USER_ID` env-var fallback added to `_parse_args` (resolution: flag > env > None); env.sh ships `RATATOSKR_END_USER_ID="ratatoskr-tui"` as project-stable default. Original issue #5 posture rejected env-var fallback as "papering over isolation"; revised after worldtree-dev's guidance that the realistic single-operator use case wants partition continuity. Issue #5 + #3 contracts amended in-place to document the env-var fallback. Infra-ops pinged via althing for `agents.call:lofn` scope (broker pattern; they forwarded to worldtree-dev). Three Gitea issues filed: #9 (spec-pin refresh), #10 (subject:{type,id} migration tracking), #11 (AdminEvents pane auth prereq).
- `[2026-05-23]` **v0.2.1 layout fix: dock-anchored TUI chrome so Input never moves** (commit `c85f6bd`, tag `v0.2.1`). Reported during the v0.2.0 mimir TUI smoke: Input bouncing up/down throughout a turn, tokens landing at shifting screen positions. Cause: v0.2.0's `Static(id="thinking-current")` was yielded between `hint` and `Footer` in the auto-stacked vertical flow, so each `display=True/False` toggle per thinking-run shifted Input + identity + hint vertically; RichLog growth from streaming text also drifted Input downward. Fix: `RatatoskrApp.DEFAULT_CSS` docks the chrome to screen edges — `thinking-current` docks top under Header; `transcript` (RichLog) gets `height: 1fr` and absorbs all reflows internally via its scroll viewport; `prompt`, `identity`, `hint` all dock bottom (locked above Footer). Compose order moved `thinking-current` to position 2 (right after Header) so source-order matches the dock layout. **Operator-confirmed "a lot better"** interactively. Pure UI fix; no public API change; tests pass without modification. v0.2.0 → v0.2.1 (patch). I couldn't verify in a TTY from this non-interactive session — the design was sound enough to ship blind, with operator verification post-commit. Going forward: TUI-layout patches like this are "ship + operator verifies" since the TTY is the load-bearing test surface and respx + Pilot mocks can't catch screen-relative positioning bugs.
- `[2026-05-23]` **Sequencing decision: design-brief §5 side-panes work absorbs the inline collapsible-Thinking-pane + Debug-pane proposals; do issue #8 (startup agent picker) BEFORE §5.** Surfaced during the v0.2.1 follow-up discussion. The operator's proposal — "create a collapsible pane for all thinking tokens; text_boundary goes to a debug pane" — is exactly §5-shaped work (the design-brief proposes a `Horizontal` two-column layout with `TabbedContent` for Persona/Tools/AdminEvents/BifrostState/ServerLog). Building inline-Collapsibles now and then rebuilding as `TabbedContent` panes at §5 would be wasted work. So: do #8 first (independent surface, no layout overlap), then §5 (which folds in Thinking + Debug panes alongside the design-brief's named §5 panes). Interim acceptance: v0.2.1 fixes the structural layout-bouncing pain; transcript-dominated-by-thinking is still real but doesn't degrade further — operator can scroll back, Input doesn't move, tokens land predictably. The interim "noisy transcript" pain is real but bounded; §5 work resolves it cleanly.
- `[2026-05-23]` **Issue #12 (presenter contract semantics amendment) implemented via TDD.** Headline: thinking deltas render as ONE coalesced growing line (CLI) / one closed RichLog entry per run + live Static(id="thinking-current") widget per-delta (TUI), not 50 lines per turn. Introduced stateful per-turn presenters: `CliPresenterState` (cli.py) and `TuiPresenterState` (tui.py), both `@dataclass(slots=True)` with thinking_buffer + thinking_open (+ text_written_since_newline for CLI). Editorial promotion line settled: load-bearing = Text/Done/Error/Cancelled (no prefix); demoted telemetry = WorkerPhase/Thinking/TextBoundary/ToolStart/ToolResult (CLI `. ` ASCII prefix; TUI `· ` Unicode dim prefix). CLI stdout/stderr newline-boundary INV-005: when text was streamed mid-line, flush a `\n` to stdout before writing terminal labels to stderr; `text_written_since_newline = not event.content.endswith("\n")` per Volva F4 fix. Helpers `_format_duration_ms` (`347ms` / `5.5s` / `1.2m` autoscale) and `_format_usage` (`6756 in -> 126 out (6882 total, 0 cached)` with arrow="->" CLI or "→" TUI). Per Vor (eitri-smithy-dev cross-frontier consult, althing 01KSBE52YZR5) + Volva paraphrase (5 contract-text ambiguities all fixed in #12.contract.md). `[create_session]` lifecycle line demoted to `. create_session:` (written directly by `_amain`, bypasses state.render). Old `_render_event` / `_render_event_to_log` functions and their TestRenderEvent/TestRenderEventToLog classes removed (no-backwards-compat rule). Contracts amended: #3 (CliPresenterState block + `_run_turn` thread state + `_amain` create_session demotion + `_format_*` helper blocks), #4 (TuiPresenterState block + `_stream_turn_worker` state construction + `compose` Static widget addition). 39 new tests; 19 obsolete tests removed; net 208 GREEN. v0.1.0 → v0.2.0 (minor; pre-amendment output shape broken intentionally — scripts grepping `[thinking] '` no longer work; that's the intended cleanup). Cross-frontier design pass with eitri-smithy-dev returned 16-of-16 confirmed decisions + 4 material divergences applied (ASCII `· ` factual fix, RichLog-one-entry-per-run vs inline-mirror, presenter-state object vs stateless, "contract semantics amendment" framing not "polish"). Calibration note: eitri-smithy-dev's value here was *architectural* (state-object pattern + chronological-vs-live decoupling) not just *tactical*; the framing rename alone justified the consult. Volva paraphrase round added 5 prose-precision fixes (INV-001 "growing display" semantics, TUI hide mechanism unification, render_error security/readability tension, newline-tracking corner case, [create_session] integration path).
- `[2026-05-23]` **Forward direction: Ratatoskr will require `end_user_id` for EVERY access before too long.** Operator's call. Reasoning: even Tier 1 foundational agents (mimir, all Asgardians) that don't *require* `end_user_id` server-side currently fall back to a `_no_end_user` sentinel substrate partition — effectively pollution from a single-operator-debug-tool's perspective. The right shape is "every conversation has an explicit partition key." `RATATOSKR_END_USER_ID="ratatoskr-tui"` env-default in env.sh is the first step toward that posture; once we've validated the partition-isolation experience, the next move is making `end_user_id` mandatory (probably remove the `None`-default in `_parse_args`, fail-closed with a UsageError if neither flag nor env provides it). Consequence for cross-project asks: declined worldtree-dev's offer to ship `requires_end_user_id: bool` on `AgentInfoResponse` because we'd treat every value as true regardless; the try-and-react-to-422 pattern goes away from our side because we never send a request without the field. File a ratatoskr issue when scheduling the change — touches `_parse_args` validation + `_resolve_then_run` + `_amain` + tests + contract amendments to #3 / #5. Treat as a v0.2.0 minor (breaking: existing `--new --agent mimir` without env or flag would start failing). **Cross-frontier alignment (worldtree-dev ack 2026-05-23, althing 01KSBD9FPMCWJMBXNNS4B3MYBS):** the platform side agrees with this framing — `_no_end_user` is a substrate accommodation for identity-less transports, NOT a consumer model. The fallback's `_is_fallback=True` trap door (#185 INV-185-5/8) "could become operator-controlled later" per worldtree-dev, meaning Worldtree itself may tighten the substrate-fallback path. Ratatoskr's forward posture pre-empts that tightening — moving from "we send end_user_id when set" to "we never send a request without end_user_id" stays consumer-correct regardless of what Worldtree does with the fallback knob.
@@ -153,4 +157,5 @@ defense against re-attempting the same cul-de-sac.
- `[2026-05-21]` **TUI session-identity rendering via `self.sub_title` + `self.hint` plain attributes.** Stored state but never rendered to a visible widget. The contract's "session-identity-always-visible" invariant was satisfied at the state-attribute level but not the user-visible-widget level. Tests asserted the attributes (which passed); Volva code-review flagged the gap. Fix: dedicated `Static(id="identity")` + `Static(id="hint")` widgets in compose; `_set_hint()` helper mirrors state → widget. Calibration evidence for the "TDD catches state, code-review catches whether the user can see it" pattern.
- `[2026-05-23]` **Using the cross-model review agent's name directly in composed prose.** The peer review agent's name (the `althing` handle starting with "V-o-l-v-a") is one letter from a body-part term. Anthropic's content classifier does fuzzy matching and intermittently blocks responses mid-stream when the name appears in composed prose sentences (especially in meta-commentary about the agent's work). Direct-quoted tool output (e.g., the `althing-cli thread` body) passes through fine. Mitigation: use role descriptions ("the cross-model reviewer," "the paraphrase peer") in prose rather than the name; quote content via tool output. Confirmed by switching to Sonnet 4.6 for a test read — same raw content read cleanly when fetched via Bash rather than composed into an LLM response. This is a persistent environmental constraint, not a one-off.
- `[2026-05-22]` **`json.loads(sse.data)` unguarded against empty data.** `_iter_events` unconditionally called `json.loads` on every dispatched `ServerSentEvent`. When `httpx_sse` surfaced a frame with `id:` present but `data:` empty (a known library-vs-spec divergence — RFC says don't dispatch; httpx_sse is permissive), `json.loads('')` raised `JSONDecodeError` → propagated through Textual's worker → app crash. Crashed mimir conversation at turn 93/seq 1078 after 1077 successful events. Fix: `if sse.data == '': continue` BEFORE `_parse_sse_id` (empty-data event with a malformed id is still a keepalive — don't reorder). Non-empty malformed data raises new `MalformedSseData(raw[:200])`. Don't reintroduce unconditional `json.loads(sse.data)`; always pre-check for the empty case.
- `[2026-05-23]` **Diagnostic shorthand: "2-events-then-silence" = Worldtree-side LLM-call wedge, not ratatoskr.** If a mimir `--send` smoke shows exactly two stderr events — `. create_session: ...` followed by `. worker_phase: phase=BuildingPrompt ...` — and then nothing for >60s, the root cause is upstream of ratatoskr. Worldtree's `service.py:2560` gates the `CallingLLM` event on the engine yielding its first LLM-provider chunk; if that provider connection is wedged at the TCP level, the `async for` never iterates and the SSE stream stays silent forever. ratatoskr's `read=None` httpx timeout (the issue #1 + #4 INV-007 fix for "5s default killed mid-stream during mimir's thinking") waits patiently as designed; there's no client-side stall watchdog above the read-timeout layer. Worldtree's OWN stall watchdog (300s `_start_stall_timer`) exists but its cancel-check is INSIDE the engine-event loop, so a never-yielding first-LLM-call bypasses it. Confirmed by worldtree-dev (althing thread `01KSBKTG096Q07JVRG41JXA1DD`). **Don't waste time bisecting ratatoskr code when this shape appears** — diagnose the LLM-provider connection state at Worldtree's host. Restarting the Worldtree service (`:8081` in our case) cleared a wedged llama-swap connection. Future ratatoskr issue worth filing if recurrence: client-side stall watchdog (e.g., 90s-no-events → `[server_stalled]` stderr label, keep connection open). Also worth knowing: 10.250.50.152 hosts 3 Worldtree instances (`:8080`, `:8081`, `:8082`) — each with its own DB and key namespace. Our key is valid only on `:8081`.
- `[2026-05-23]` **Phantom "per-Tier-1-agent scope add" pattern.** Issue #5's lofn 422 was initially diagnosed (with worldtree-dev's first reply) as needing `agents.call:lofn` added to ratatoskr's existing key. Routed through infra-ops via althing per the credential-brokerage rule; infra-ops discovered no public scope-mutation endpoint on personal Worldtree, brokered to worldtree-dev for the actual mechanism. Worldtree-dev came back with a correction: their first answer conflated two distinct Heimdall scope namespaces. **Tier 1 foundational agents** (mimir, lofn, soong, all Asgardians) are covered by a blanket `agent.call:*` (singular) baseline rule in `config/policies.yaml > tiers.<tier>.scopes` for ALL authenticated tiers including `user`. There is no per-agent grant for Tier 1 — the baseline rule covers it. **Tier 3 consumer-defined agents** (IDs containing `:`, like `vh:custom-bot`) use the plural `agents.call:<owner>:<agent>` shape granted implicitly via owning a `consumer_agents` DB row, registered through `POST /agents/define`. The two notations differ by one letter and that was the source of the confusion. **The actual lofn fix was issue #5's `--end-user-id` flag — it was always a request-body validation, not an auth-scope gate.** Don't ping infra-ops for "per-Tier-1-agent scope adds" again; the pattern is a phantom ask. Real future infra-ops asks: admin-tier key for the AdminEvents pane (`admin.events.read` scope, different tier), and Tier 3 custom-agent registration (different flow entirely, requires `POST /agents/define`).
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "ratatoskr"
version = "0.2.0"
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
]
+128 -7
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,9 +217,97 @@ 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."""
# Issue #12 follow-up: anchor layout so Input never moves.
# Pre-fix: every widget was auto-stacked. RichLog grew with content,
# thinking-current toggled display 0↔N rows per thinking-run — both pushed
# Input around mid-turn. Fix: dock the chrome to the top/bottom edges and
# let RichLog (the only `1fr` widget) absorb all layout reflows internally
# via its scroll viewport, so screen-relative positions stay stable.
DEFAULT_CSS = """
#thinking-current {
dock: top;
height: auto;
}
#transcript {
height: 1fr;
}
#prompt {
dock: bottom;
}
#identity {
dock: bottom;
height: 1;
}
#hint {
dock: bottom;
height: 1;
}
"""
BINDINGS: ClassVar[list[Binding]] = [
Binding("ctrl+c", "interrupt", "Cancel / Exit", priority=True),
Binding("ctrl+d", "quit", "Exit immediately", priority=True),
@@ -243,6 +337,14 @@ class RatatoskrApp(App[int]):
def compose(self) -> ComposeResult:
yield Header()
# Issue #12 follow-up: thinking-current sits at the TOP under Header (via
# DEFAULT_CSS `dock: top`). Pre-fix it lived between hint and Footer in
# the auto-stacked flow, so its display=True/False toggle per
# thinking-run pushed Input + identity + hint up/down on every cycle.
# Docking top + RichLog filling middle stabilises Input's screen
# position; thinking-current grows/shrinks under Header where the
# reflow doesn't affect anything else.
yield Static("", id="thinking-current")
# markup=False so labeled lines like "[cancel_failed] ..." render verbatim
# (Rich would otherwise interpret square-bracket spans as style markup and
# strip them). The post-Done markdown render uses Markdown() directly which
@@ -255,9 +357,6 @@ class RatatoskrApp(App[int]):
# always-visible.
yield Static("", id="identity")
yield Static(self.HINT_IDLE, id="hint")
# Issue #12: live thinking widget — hidden by default, shown per-delta
# during a thinking run, cleared+hidden at turn terminal.
yield Static("", id="thinking-current")
yield Footer()
async def on_mount(self) -> None:
@@ -404,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.0"
version = "0.3.0"
source = { editable = "." }
dependencies = [
{ name = "httpx" },