Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 139771c8d8 | |||
| 489cfee1f0 | |||
| 11ef6830ab | |||
| 9fade55901 | |||
| 9918c10acf | |||
| c086ae2b32 | |||
| d3569904bc | |||
| 82437bd4b9 | |||
| ac690c11d5 | |||
| d845b20efd | |||
| 8463eb22ff | |||
| eb93e6d5f0 | |||
| cfee89ac1c | |||
| 7106af5c09 |
@@ -158,8 +158,12 @@ New `Static(id="pane-name")` widget alongside the existing `identity` + `hint` w
|
||||
- **INV-016**: Input retains keyboard focus across `Ctrl+1` / `Ctrl+2` tab switches.
|
||||
- **INV-017** *(amended v0.5.0)*: `thinking-current` Static docks to the top of the **right column** (above `TabbedContent`), not the left column. Live thinking visibility persists across tab switches. v0.5.0 moves it from left → right so the left column is genuinely content-only.
|
||||
- **INV-018**: CLI mode (`ratatoskr.cli._amain`) is unaffected. CLI keeps inline `· tool_start: …` / `· tool_result: …` rendering on stderr per issue #12 INV-005.
|
||||
- **INV-019** *(new v0.5.0)*: Two TabPanes in the right column: `Tools` (id `tools-tab`, contains `#tools-log`) + `Debug` (id `debug-tab`, contains `#debug-log`). Ctrl+1 activates Tools; Ctrl+2 activates Debug. `pane-name` Static reflects the active tab name dynamically.
|
||||
- **INV-020** *(new v0.5.0)*: Render-exception fallback (INV-009) preserves routing per event class: `ToolStart` / `ToolResult` fallback writes to `tools_log`; `WorkerPhase` / `Thinking` / `TextBoundary` fallback writes to `debug_log`; everything else falls back to `log`.
|
||||
- **INV-019** *(amended v0.6.0)*: Three TabPanes in the right column: `Tools` (id `tools-tab`, contains `#tools-log`) + `Debug` (id `debug-tab`, contains `#debug-log`) + `Thinking` (id `thinking-tab`, contains `#thinking-log`). Ctrl+1/Ctrl+2/Ctrl+3 activate respective tabs. `pane-name` Static reflects active tab name dynamically.
|
||||
- **INV-020** *(amended v0.6.0)*: Render-exception fallback (INV-009) preserves routing per event class: `ToolStart` / `ToolResult` → `tools_log`; `Thinking` → `thinking_log`; `WorkerPhase` / `TextBoundary` → `debug_log`; everything else → `log`.
|
||||
- **INV-021** *(new v0.6.0)*: `Text` events do NOT route to `log` per-delta. They accumulate into `TuiPresenterState.text_buffer` and update a single `current_text` Static (docked above the prompt). On terminal event (`Done`/`Error`/`Cancelled`), `current_text` is cleared and (raw mode) accumulated text or (non-raw) post-Done `Markdown(response)` is written to `log`. The pre-v0.6.0 per-token RichLog spam is retired.
|
||||
- **INV-022** *(amended v0.7.1)*: Thinking deltas COALESCE on `\n` boundaries before writing to `thinking_log`. The first delta of a run writes `Rule(title=f"turn N · thinking #K start")`; subsequent deltas accumulate in `TuiPresenterState.thinking_chunk_buffer`; whenever the buffer contains `\n`, the leading line(s) flush as RichLog entries (one entry per natural paragraph). The run closes on the next non-thinking event: any tail in the buffer flushes as a final line, then `Rule(title=f"turn N · thinking #K end")`. Pre-v0.7.1 per-delta-per-line caused token-spam (Worldtree emits thinking at token granularity); coalescing produces one log line per natural paragraph, not per token.
|
||||
- **INV-023** *(new v0.6.0)*: Turn-ID header `Rule(title=f"turn N")` is written to all four log panes (`log`, `tools_log`, `debug_log`, `thinking_log`) by `_stream_turn_worker` on the first event of each turn — enables cross-pane visual correlation during multi-turn debugging.
|
||||
- **INV-024** *(amended v0.6.5)*: `thinking-current` Static REMOVED. v0.6.1 placed it inside the Thinking pane (docked bottom); operators reported the bottom-docked Static "scrolling a little section at the bottom" (its 200-char tail acting as a scroll-window) instead of letting the whole pane scroll. v0.6.5 deletes the Static entirely and streams Thinking deltas directly into `thinking_log` (the scrollable RichLog) — the whole pane scrolls naturally as content arrives. The Rule(start) at the first delta of a run is now the live "thinking is happening" indicator.
|
||||
|
||||
## TESTS (additions / changes to test_tui.py)
|
||||
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
---
|
||||
contract_version: "2.1"
|
||||
target_module: "ratatoskr.tier3"
|
||||
scope: "New module `ratatoskr.tier3` exposing Worldtree's Tier 3 (consumer-defined) agent lifecycle: `define_agent` (POST /agents/define), `patch_agent` (PATCH /agents/<id>), `delete_agent` (DELETE /agents/<id>), plus `Tier3AgentInfo` frozen dataclass. Plus a thin CLI entry point (`python -m ratatoskr.tier3 <define|patch|delete>`) that mirrors `ratatoskr.cli`'s env-var posture (`WORLDTREE_API_URL`, `WORLDTREE_API_KEY`). Convention-aligned with `ratatoskr.sessions` (issue #2): caller-owned httpx.AsyncClient, no Worldtree imports, response parsing into frozen dataclass, exception `.body` truncated to `[:1024]`. Picker stays generic — agents with `:` in agent_id show in the list like any other per issue #8's out-of-scope clause. Goal: ratatoskr operators can define, mutate, and delete Tier 3 agents from the command line, then exercise the full session flow against them to observe how Tier 3 agent_ids (colon-containing) flow through the picker / session-create / SSE stream."
|
||||
depends_on:
|
||||
- "httpx"
|
||||
used_by: []
|
||||
language: "python"
|
||||
complexity: "low"
|
||||
estimated_loc: 250
|
||||
confidence: 0.9
|
||||
assumptions:
|
||||
- "Tier 3 endpoints land at the same `WORLDTREE_API_URL` as the rest of the Conversation API — no separate hostname / port. Auth via the same bearer key. The caller's user_id is derived server-side from the API key's owner; the agent's `agent_id` is constructed as `<auth_user_id>:<agent_name>`. Live probe against personal Worldtree (2026-05-25) confirmed: POST with `{agent_name: 'smoke-test', ...}` and `Authorization: Bearer <key>` returned `agent_id=ratatoskr:smoke-test`, `user_id=ratatoskr`."
|
||||
- "Per Worldtree spec §2576-2750: `agent_name` is a strict slug `[a-z][a-z0-9-]{2,63}` and immutable after definition. `user_id` is derived from the auth, must be slug-safe (`[a-z][a-z0-9-]{2,63}` per Phase 2.0 gate). PATCH accepts ONLY `system_prompt` and/or `model`; any other key (including the immutable `agent_name`, `user_id`, or layer fields `persona`/`motivational`/`valence`/`memory` — even with `null` value) returns 422 `field_not_mutable` BEFORE the DB lookup."
|
||||
- "**Layer fields are explicitly null** on define. Phase 2.0 ships baseline addressing + ownership + lifecycle only; `persona` / `motivational` / `valence` / `memory` are schema-reserved. Non-null on these → 422 `layer_deferred`. The module's `define_agent` does NOT expose these as parameters at all — sending them would require an amendment when a future Phase enables them."
|
||||
- "**`model` field is a provider model ID, not a profile alias.** Live probe found: `model='default'` (an llm_profiles profile name) returns 422 `model_not_available`; `model='qwen3.6-35-a3b'` (an actual provider model ID) returns 201. The CLI / module take the string verbatim and pass through — validation is server-side. Operators discover valid IDs via the model `metadata` on existing sessions or out-of-band."
|
||||
- "**Quota: 50 Tier 3 agents per Heimdall key.** 51st define → 429 `agent_quota_exceeded` with `Retry-After: 0`. The module raises `Tier3QuotaExceeded(retry_after=0)` — the retry_after field captures the header value verbatim for forward-compat if Worldtree later returns a non-zero throttle."
|
||||
- "**Key-revocation cascade is server-side.** When an API key is revoked (`DELETE /admin/keys/{key_id}`), every Tier 3 agent with `owner_key_hash` equal to the revoked key's hash is soft-deleted in the same SQL transaction. Active sessions on those agents return 401 `auth_revoked` on next message. The ratatoskr module doesn't track or simulate this — operators discover it via runtime 401s and the admin-side audit log."
|
||||
- "**Picker integration is implicit** — no changes to `ratatoskr.tui.AgentPickerApp` for this issue. Tier 3 agents appear in `GET /agents` if defined and the picker's existing format `{agent_id} · {name} — {description}` renders the colon-containing agent_id without special-casing. Per issue #8 out-of-scope clause, ratatoskr does not visually distinguish Tier 1 vs Tier 3 in the picker — same UX surface."
|
||||
- "**Session-create with colon-containing agent_id works unchanged.** Issue #5 already routes `end_user_id` into the POST /sessions body, which Tier 3 session-create requires from Phase 2.0 (per spec §2649-2664). No `ratatoskr.sessions` change needed."
|
||||
- "**CLI uses argparse with subparsers** (define / patch / delete). The subparsers entry point lives at `python -m ratatoskr.tier3` via `__main__.py`. Output on success: prints a one-line summary (`defined ratatoskr:wizard (qwen3.6-35-a3b)` / `patched ratatoskr:wizard` / `deleted ratatoskr:wizard`). Output on error: `[<error_code>] <message>` to stderr + non-zero exit. Exit codes mirror `ratatoskr.cli`: 0 happy / 10 usage / 11 auth / 20 api-failure / 21 network."
|
||||
- "**No `list` subcommand in v1.** A `tier3 list` operation would have to filter `GET /agents` by prefix-matching the caller's user_id, but that prefix isn't exposed in the response — only the agent_id is, and you'd have to introspect the auth's user_id. Operators discover their own Tier 3 agents by reading the `GET /agents` list (which the picker already surfaces) and looking for `<their-user-id>:*` entries. Add `list` in a follow-up if operators report friction."
|
||||
- "**Module is standalone**: does NOT import or interact with `ratatoskr.sessions` / `ratatoskr.sse_client` / `ratatoskr.tui` / `ratatoskr.cli` beyond reusing the `USER_AGENT` constant from `ratatoskr.cli`. Cross-module use is one-way (cli supplies the user-agent string; tier3 does not import sessions). This keeps the module surface minimal and testable in isolation."
|
||||
- "**The CLI's `python -m ratatoskr.tier3` entry point uses sys.argv handling that mirrors `ratatoskr.cli`** — a top-level `main(argv: list[str] | None = None) -> int` function that argparse-dispatches to subcommand handlers. Each subcommand handler is an async coroutine wrapped by `asyncio.run(...)`. Auth resolution: `--api-key` flag > `$WORLDTREE_API_KEY` env > `_AuthError` (exit 11). Server URL: `--server` > `$WORLDTREE_API_URL` > default `http://localhost:8000` (same default as `ratatoskr.cli`)."
|
||||
- "**Tests use `respx` for HTTP mocking** (same pattern as `tests/test_sessions.py`). New test file: `tests/test_tier3.py`. Cover all success + error response codes per the ERROR_ROUTING matrix below. No live network in unit tests — the live smoke is in the acceptance criteria, not the unit tests."
|
||||
open_questions:
|
||||
- "Should `define_agent` accept an optional `bifrost` parameter for Bifrost-bound Tier 3 sessions? The spec §2658 shows `bifrost` as a session-create field (not define-time). Draft: no — Bifrost binding is per-session; if a Tier 3 agent needs Bifrost on every session, that's an orthogonal feature on POST /sessions, not POST /agents/define. Issue #5's `--end-user-id` already covers the session-create-side parameters."
|
||||
- "Should the CLI also offer `--end-user-id` for sessions created via tier3 + ratatoskr-cli composition? Draft: no — once an agent is defined, operators use the main `ratatoskr --new --agent <id> --end-user-id <eid>` flow; tier3 CLI is define/patch/delete only."
|
||||
- "Should `delete_agent` support a `--force` flag for 'really delete even if active sessions exist'? Per spec §2634-2639, `DELETE` already cancels active sessions and revokes the per-resource scope grant on the owner — there's no soft fail. Draft: no — the spec's hard-delete-with-cascade behavior is the right shape; ratatoskr doesn't need to wrap it."
|
||||
prd:
|
||||
issue: 15
|
||||
issue_url: "https://gitea.phasefinal.com/vh/ratatoskr/issues/15"
|
||||
body_sha256_16: "03367d7b451ab17f"
|
||||
lock_in_comment_id: null
|
||||
lock_in_sha256_16: null
|
||||
lock_in_at: null
|
||||
pinned_at: "2026-05-25T03:21:38+00:00"
|
||||
dependencies:
|
||||
- issue: 2
|
||||
path: "src/ratatoskr/sessions.py"
|
||||
reason: "Convention dependency, not a code dependency. Issue #2 (`ratatoskr.sessions`) is the posture template: caller-owned httpx client, async-native, no Worldtree imports, response-parsing into frozen dataclasses, exception body truncation to [:1024]. `ratatoskr.tier3` follows the same shape verbatim."
|
||||
- issue: 3
|
||||
path: "src/ratatoskr/cli.py"
|
||||
reason: "Convention dependency only. `ratatoskr.tier3.__main__` mirrors `ratatoskr.cli`'s argparse + env-fallback + exit-code shape. Imports `USER_AGENT` from `ratatoskr.cli` so outbound HTTP carries the same identity string."
|
||||
---
|
||||
|
||||
# Tier 3 — Consumer-defined agent lifecycle module
|
||||
|
||||
## Context
|
||||
|
||||
Worldtree's Tier 3 (Phase 2.0, spec §2576-2750) lets the consumer define their own agents at `<user_id>:<agent_name>`. The agent's `user_id` is the auth's user identity (derived from the API key's owner); the `agent_name` is supplied at define-time. The lifecycle is owner-only — only the key that defined an agent can patch / delete it (modulo the key-revocation cascade).
|
||||
|
||||
`ratatoskr.tier3` exposes this lifecycle as a Python module + small CLI tool. Picker integration is implicit (Tier 3 agents already appear in `GET /agents` per issue #8). Session-create works unchanged through `ratatoskr.sessions.create_session` since the colon-containing agent_id is opaque to that layer.
|
||||
|
||||
## Public surface
|
||||
|
||||
```python
|
||||
@dataclass(frozen=True)
|
||||
class Tier3AgentInfo:
|
||||
"""Worldtree Tier 3 agent envelope returned by define / patch."""
|
||||
|
||||
agent_id: str # f"{user_id}:{agent_name}"
|
||||
user_id: str
|
||||
agent_name: str
|
||||
system_prompt: str
|
||||
model: str
|
||||
created_at: str # ISO 8601 with offset
|
||||
updated_at: str # ISO 8601 with offset
|
||||
|
||||
|
||||
async def define_agent(
|
||||
client: httpx.AsyncClient,
|
||||
*,
|
||||
agent_name: str,
|
||||
system_prompt: str,
|
||||
model: str,
|
||||
) -> Tier3AgentInfo:
|
||||
"""POST /agents/define → 201 with Tier3AgentInfo. See FN define_agent."""
|
||||
|
||||
|
||||
async def patch_agent(
|
||||
client: httpx.AsyncClient,
|
||||
agent_id: str,
|
||||
*,
|
||||
system_prompt: str | None = None,
|
||||
model: str | None = None,
|
||||
) -> Tier3AgentInfo:
|
||||
"""PATCH /agents/<id> → 200 with updated Tier3AgentInfo. See FN patch_agent."""
|
||||
|
||||
|
||||
async def delete_agent(client: httpx.AsyncClient, agent_id: str) -> None:
|
||||
"""DELETE /agents/<id> → 204. See FN delete_agent."""
|
||||
```
|
||||
|
||||
## Exception classes
|
||||
|
||||
```python
|
||||
class Tier3QuotaExceeded(Exception):
|
||||
"""429 agent_quota_exceeded — 50-agent cap reached on the Heimdall key."""
|
||||
def __init__(self, *, retry_after: int) -> None: ...
|
||||
retry_after: int
|
||||
|
||||
class Tier3UserIdUnsupported(Exception):
|
||||
"""403 tier3_user_id_unsupported — auth's user_id not slug-safe."""
|
||||
|
||||
class Tier3FieldNotMutable(Exception):
|
||||
"""422 field_not_mutable — PATCH carrying an immutable key."""
|
||||
def __init__(self, *, field: str | None) -> None: ...
|
||||
field: str | None
|
||||
|
||||
class Tier3LayerDeferred(Exception):
|
||||
"""422 layer_deferred — define carrying non-null layer field."""
|
||||
def __init__(self, *, field: str | None) -> None: ...
|
||||
field: str | None
|
||||
|
||||
class Tier3AgentNotFound(Exception):
|
||||
"""404 — patch/delete on non-existent agent."""
|
||||
def __init__(self, *, agent_id: str) -> None: ...
|
||||
agent_id: str
|
||||
|
||||
# Reused from ratatoskr.sessions (one-way import — sessions doesn't depend on tier3):
|
||||
# SessionApiFailed(status, body) for all other non-2xx responses.
|
||||
```
|
||||
|
||||
## Functions
|
||||
|
||||
### FN define_agent
|
||||
|
||||
```
|
||||
FN define_agent(
|
||||
client: httpx.AsyncClient,
|
||||
*, agent_name: str, system_prompt: str, model: str,
|
||||
) -> Tier3AgentInfo
|
||||
BRIEF: POST /agents/define → 201 with Tier3AgentInfo.
|
||||
|
||||
PRE-001: agent_name matches `[a-z][a-z0-9-]{2,63}` (slug guard — client-side
|
||||
assert; the server enforces too, but this prevents wire round-trip
|
||||
for trivially-bad input).
|
||||
PRE-002: system_prompt is non-empty.
|
||||
PRE-003: model is non-empty.
|
||||
|
||||
STEPS:
|
||||
1. assert PRE-001/002/003.
|
||||
2. body = {
|
||||
"agent_name": agent_name,
|
||||
"system_prompt": system_prompt,
|
||||
"model": model,
|
||||
}
|
||||
3. resp = await client.post("/agents/define", json=body)
|
||||
4. ROUTE response status:
|
||||
201 → parse body into Tier3AgentInfo, return.
|
||||
422 → inspect error_code:
|
||||
layer_deferred → raise Tier3LayerDeferred(field=err.get("field"))
|
||||
(others) → raise SessionApiFailed(status=422, body=resp.content)
|
||||
403 + tier3_user_id_unsupported → raise Tier3UserIdUnsupported
|
||||
429 → raise Tier3QuotaExceeded(retry_after=int(resp.headers.get("Retry-After", 0)))
|
||||
other → raise SessionApiFailed(status, body)
|
||||
|
||||
POST-001: returned Tier3AgentInfo has agent_id of shape "<user_id>:<agent_name>".
|
||||
```
|
||||
|
||||
### FN patch_agent
|
||||
|
||||
```
|
||||
FN patch_agent(
|
||||
client: httpx.AsyncClient, agent_id: str,
|
||||
*, system_prompt: str | None = None, model: str | None = None,
|
||||
) -> Tier3AgentInfo
|
||||
BRIEF: PATCH /agents/<id> → 200 with updated Tier3AgentInfo.
|
||||
|
||||
PRE-001: agent_id contains `:` (Tier 3 shape).
|
||||
PRE-002: at least one of system_prompt or model is non-None (no-op patches
|
||||
are still server-accepted but client-side assert avoids the round-trip).
|
||||
|
||||
STEPS:
|
||||
1. assert PRE-001/002.
|
||||
2. body = {}; if system_prompt is not None: body["system_prompt"] = system_prompt;
|
||||
if model is not None: body["model"] = model.
|
||||
3. resp = await client.patch(f"/agents/{agent_id}", json=body)
|
||||
4. ROUTE response status:
|
||||
200 → parse, return.
|
||||
404 → raise Tier3AgentNotFound(agent_id=agent_id)
|
||||
422 + field_not_mutable → raise Tier3FieldNotMutable(field=err.get("field"))
|
||||
other → raise SessionApiFailed(status, body)
|
||||
```
|
||||
|
||||
### FN delete_agent
|
||||
|
||||
```
|
||||
FN delete_agent(client: httpx.AsyncClient, agent_id: str) -> None
|
||||
BRIEF: DELETE /agents/<id> → 204.
|
||||
|
||||
PRE-001: agent_id contains `:` (Tier 3 shape).
|
||||
|
||||
STEPS:
|
||||
1. assert PRE-001.
|
||||
2. resp = await client.delete(f"/agents/{agent_id}")
|
||||
3. ROUTE response status:
|
||||
204 → return None.
|
||||
404 → raise Tier3AgentNotFound(agent_id=agent_id)
|
||||
other → raise SessionApiFailed(status, body)
|
||||
```
|
||||
|
||||
## CLI surface (`python -m ratatoskr.tier3`)
|
||||
|
||||
```
|
||||
$ python -m ratatoskr.tier3 define --name wizard \
|
||||
--system-prompt "You are a guided-elicitation wizard..." \
|
||||
--model qwen3.6-35-a3b
|
||||
defined ratatoskr:wizard (qwen3.6-35-a3b)
|
||||
|
||||
$ python -m ratatoskr.tier3 patch ratatoskr:wizard --system-prompt "New prompt"
|
||||
patched ratatoskr:wizard
|
||||
|
||||
$ python -m ratatoskr.tier3 delete ratatoskr:wizard
|
||||
deleted ratatoskr:wizard
|
||||
```
|
||||
|
||||
Auth + server URL: same env-var fallback as `ratatoskr.cli`. Exit codes: 0 / 10 (usage) / 11 (auth) / 20 (api-failure) / 21 (network).
|
||||
|
||||
## Invariants
|
||||
|
||||
- **INV-001**: `define_agent` request body carries exactly `{agent_name, system_prompt, model}` — no layer fields, no `bifrost`, no `metadata`. Phase 2.0 baseline shape only.
|
||||
- **INV-002**: `patch_agent` request body carries ONLY `system_prompt` and/or `model` — every other key is omitted. Server-side 422 `field_not_mutable` is the safety net; client-side body-construction is the first line.
|
||||
- **INV-003**: `delete_agent` is fire-and-confirm — no body, no retry, no soft-delete. Cascade handling is server-side; ratatoskr doesn't track it.
|
||||
- **INV-004**: All exceptions carry a `[:1024]` body cap (when applicable) per the issue #2 convention.
|
||||
- **INV-005**: CLI auth resolution mirrors `ratatoskr.cli`: `--api-key` flag > `$WORLDTREE_API_KEY` > exit 11.
|
||||
- **INV-006**: CLI server URL resolution mirrors `ratatoskr.cli`: `--server` > `$WORLDTREE_API_URL` > `http://localhost:8000`.
|
||||
- **INV-007**: Module never imports `ratatoskr.sessions` / `ratatoskr.sse_client` / `ratatoskr.tui` (one-way: only `cli.USER_AGENT` is imported, and only by `__main__.py` for the outbound User-Agent header).
|
||||
- **INV-008**: All HTTP through caller-owned `httpx.AsyncClient` — module never constructs its own client. (`__main__` constructs one for the CLI entry point per ratatoskr.cli's pattern.)
|
||||
|
||||
## TESTS (tests/test_tier3.py — new file)
|
||||
|
||||
```
|
||||
- test_define_happy: 201 + full response shape → Tier3AgentInfo populated.
|
||||
- test_define_quota_exceeded: 429 + Retry-After header → Tier3QuotaExceeded(retry_after=N).
|
||||
- test_define_user_id_unsupported: 403 tier3_user_id_unsupported → Tier3UserIdUnsupported.
|
||||
- test_define_layer_deferred_persona: 422 layer_deferred → Tier3LayerDeferred (would only fire if the body sent a layer field; the module never sends one, so this asserts server-side defense but reflecting a 422 we don't actually generate. Test exercises the response path, not the request).
|
||||
- test_define_bad_slug: PRE-001 assertion fires before HTTP for agent_name="X" (uppercase) or "ab" (too short).
|
||||
- test_define_empty_prompt: PRE-002 assertion fires for empty system_prompt.
|
||||
- test_define_other_5xx: 503 → SessionApiFailed(status=503).
|
||||
- test_patch_happy_both_fields: 200 + updated body → Tier3AgentInfo.
|
||||
- test_patch_happy_single_field: 200 with only system_prompt set; body omits model.
|
||||
- test_patch_field_not_mutable: 422 field_not_mutable → Tier3FieldNotMutable.
|
||||
- test_patch_404: 404 → Tier3AgentNotFound(agent_id=...).
|
||||
- test_patch_no_args: PRE-002 assertion fires (both None).
|
||||
- test_patch_non_tier3_id: PRE-001 assertion fires for agent_id without `:`.
|
||||
- test_delete_happy: 204 → returns None.
|
||||
- test_delete_404: 404 → Tier3AgentNotFound.
|
||||
- test_delete_non_tier3_id: PRE-001 assertion fires.
|
||||
- test_delete_other_5xx: 500 → SessionApiFailed.
|
||||
- test_cli_define_happy: argv → 201 mock → stdout="defined ratatoskr:wizard (qwen3.6-35-a3b)" + exit 0.
|
||||
- test_cli_patch_happy: argv → 200 mock → stdout="patched ratatoskr:wizard" + exit 0.
|
||||
- test_cli_delete_happy: argv → 204 mock → stdout="deleted ratatoskr:wizard" + exit 0.
|
||||
- test_cli_missing_auth: no API key → stderr "[auth_error]" + exit 11.
|
||||
- test_cli_api_failed: 500 mock → stderr "[api_failed]" + exit 20.
|
||||
```
|
||||
|
||||
## ERROR_ROUTING (module + CLI)
|
||||
|
||||
| HTTP shape | error_code | Exception (module) | CLI label | Exit |
|
||||
|---|---|---|---|---|
|
||||
| 201 / 200 / 204 | — | (none — happy) | one-line confirmation on stdout | 0 |
|
||||
| 429 | agent_quota_exceeded | `Tier3QuotaExceeded(retry_after=N)` | `[quota_exceeded] retry_after=N` | 20 |
|
||||
| 403 | tier3_user_id_unsupported | `Tier3UserIdUnsupported` | `[user_id_unsupported]` | 20 |
|
||||
| 404 | — | `Tier3AgentNotFound(agent_id=...)` | `[agent_not_found] <id>` | 20 |
|
||||
| 422 | field_not_mutable | `Tier3FieldNotMutable(field=...)` | `[field_not_mutable] field=...` | 20 |
|
||||
| 422 | layer_deferred | `Tier3LayerDeferred(field=...)` | `[layer_deferred] field=...` | 20 |
|
||||
| any other non-2xx | — | `SessionApiFailed(status, body)` | `[api_failed] status=N body=...` | 20 |
|
||||
| httpx.ConnectError / ReadTimeout / TransportError | — | propagates | `[network_error] T: M` | 21 |
|
||||
| PRE-001/002/003 assertion violation | — | `AssertionError` | `[usage_error] <msg>` | 10 |
|
||||
| no auth | — | `_AuthError` (reused from cli) | `[auth_error] no API key` | 11 |
|
||||
|
||||
## Layout after this module lands
|
||||
|
||||
```
|
||||
src/ratatoskr/
|
||||
__init__.py
|
||||
cli.py (existing, unchanged)
|
||||
sessions.py (existing, unchanged)
|
||||
sse_client.py (existing, unchanged)
|
||||
tui.py (existing, unchanged)
|
||||
tier3.py NEW
|
||||
__main__/ (no change — main cli still entry-point)
|
||||
|
||||
# CLI invocation:
|
||||
$ python -m ratatoskr.tier3 define --name wizard ...
|
||||
$ python -m ratatoskr.tier3 patch ratatoskr:wizard ...
|
||||
$ python -m ratatoskr.tier3 delete ratatoskr:wizard
|
||||
```
|
||||
+15
-3
@@ -32,9 +32,9 @@ separate dev team rather than an in-tree Worldtree tool.
|
||||
|
||||
## Current state / in-flight
|
||||
|
||||
_As of 2026-05-24 (post-v0.5.0 content-only main pane + Debug tab):_
|
||||
_As of 2026-05-25 (post-v0.8.2 drop double-print; v0.9.0 live-md next):_
|
||||
|
||||
**Status: v0.5.0 shipped.** Nine core issues complete (`sse_client`
|
||||
**Status: v0.8.2 shipped.** Eleven core features 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, §5 layout reshape + Tools pane #13)
|
||||
@@ -51,7 +51,19 @@ Static in the footer (static "Tools" v1; dynamic when more tabs
|
||||
land). CLI mode (--send) unaffected by design — INV-018.
|
||||
|
||||
Last commits on `main`:
|
||||
- v0.5.0 refactor(tui): content-only main pane + Debug tab + chrome dark
|
||||
- v0.8.2 fix(tui): drop post-Done Markdown body re-render (no double-print)
|
||||
- `11ef683` fix(tui,sse): inline Text streaming + empty-id keepalive skip (v0.8.1)
|
||||
- `9fade55` feat(local_agents): JSON-backed local tier-3 index + picker merge (v0.8.0)
|
||||
- `9918c10` fix(tui): coalesce thinking deltas on `\n` (v0.7.1)
|
||||
- `c086ae2` feat(tier3): ratatoskr.tier3 module + CLI (v0.7.0)
|
||||
- `d356990` refactor(tui): thinking streams into thinking-log (v0.6.5)
|
||||
- `82437bd` style(tui): picker highlighted item → Aurora blue (v0.6.4)
|
||||
- `ac690c1` style(tui): restore Australis palette, only $background → pure black (v0.6.3)
|
||||
- `d845b20` style(tui): neutralize Australis dark palette (v0.6.2, reverted)
|
||||
- `8463eb2` style(tui): kill remaining blue + thinking-current into pane (v0.6.1)
|
||||
- `cfee89a` refactor(tui): streaming + turn headers + Thinking pane (v0.6.0)
|
||||
- `7106af5` style(tui): UI polish pass — terminal label colors, placeholders (v0.5.1)
|
||||
- `ffd22fb` refactor(tui): content-only main pane + Debug tab + chrome dark (v0.5.0)
|
||||
- `2756f5f` style(tui): apply Australis theme to TUI chrome + widgets (v0.4.1)
|
||||
- `24e4371` feat(tui): issue #13 — §5 layout reshape + Tools pane (v0.4.0)
|
||||
- `d30be12` feat(sessions,cli,tui): issue #8 — startup agent picker (v0.3.0)
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "ratatoskr"
|
||||
version = "0.5.0"
|
||||
version = "0.9.0"
|
||||
description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
"""Local index of tier-3 agents defined via `python -m ratatoskr.tier3`.
|
||||
|
||||
Workaround for Worldtree's ``GET /agents`` not returning consumer-defined
|
||||
agents (the public list excludes tier-3 per-spec; see issue #15 smoke
|
||||
findings). Local file maintains a list of agent_ids + display metadata so
|
||||
the picker can show them alongside foundational agents.
|
||||
|
||||
Storage shape: JSON at ``$XDG_CONFIG_HOME/ratatoskr/local_agents.json``
|
||||
(default ``~/.config/ratatoskr/local_agents.json``). Override via
|
||||
``$RATATOSKR_LOCAL_AGENTS`` env var for tests / per-machine isolation.
|
||||
|
||||
If Worldtree later starts returning tier-3 agents in ``GET /agents``, this
|
||||
module's role narrows to redundant local cache; can be removed cleanly
|
||||
since the picker's dedup-by-agent-id keeps remote-wins behavior.
|
||||
|
||||
Failure modes are lenient: missing file → empty index; corrupt JSON or
|
||||
schema mismatch → empty index (no crash). The picker continues to show
|
||||
foundational agents either way; the local-tier-3 surface degrades to
|
||||
"operator passes --agent ratatoskr:<name> explicitly" — the
|
||||
pre-v0.8.0 workflow.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
|
||||
_SCHEMA_VERSION = 1
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LocalAgentEntry:
|
||||
"""One row in the local tier-3 agent index.
|
||||
|
||||
Schema:
|
||||
- ``agent_id``: full "user_id:agent_name" string (Worldtree-owned).
|
||||
- ``agent_name``: slug from define (display name).
|
||||
- ``model``: provider model ID at last define/patch.
|
||||
- ``description``: synthetic display string (typically derived from
|
||||
the system_prompt's first line + a "(tier 3)" prefix; the picker
|
||||
uses this in its ``{id} · {name} — {description}`` rendering).
|
||||
- ``defined_at``: ISO-8601 timestamp from the Tier3AgentInfo response.
|
||||
"""
|
||||
|
||||
agent_id: str
|
||||
agent_name: str
|
||||
model: str
|
||||
description: str
|
||||
defined_at: str
|
||||
|
||||
|
||||
def _local_agents_path() -> Path:
|
||||
"""Resolve the local index file path with XDG + env-var override."""
|
||||
override = os.environ.get("RATATOSKR_LOCAL_AGENTS")
|
||||
if override:
|
||||
return Path(override)
|
||||
xdg = os.environ.get("XDG_CONFIG_HOME")
|
||||
base = Path(xdg) if xdg else (Path.home() / ".config")
|
||||
return base / "ratatoskr" / "local_agents.json"
|
||||
|
||||
|
||||
def load_local_agents() -> list[LocalAgentEntry]:
|
||||
"""Read the local index. Returns ``[]`` on missing file, corrupt JSON,
|
||||
schema mismatch, or any read error — never raises.
|
||||
"""
|
||||
path = _local_agents_path()
|
||||
if not path.exists():
|
||||
return []
|
||||
try:
|
||||
raw = json.loads(path.read_text())
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return []
|
||||
if not isinstance(raw, dict) or raw.get("version") != _SCHEMA_VERSION:
|
||||
return []
|
||||
agents = raw.get("agents", [])
|
||||
if not isinstance(agents, list):
|
||||
return []
|
||||
out: list[LocalAgentEntry] = []
|
||||
for item in agents:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
try:
|
||||
out.append(LocalAgentEntry(**item))
|
||||
except TypeError:
|
||||
# Malformed row (missing/extra fields) — skip silently.
|
||||
continue
|
||||
return out
|
||||
|
||||
|
||||
def _save_local_agents(agents: list[LocalAgentEntry]) -> None:
|
||||
"""Persist the index. Creates parent dir as needed."""
|
||||
path = _local_agents_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload = {"version": _SCHEMA_VERSION, "agents": [asdict(a) for a in agents]}
|
||||
path.write_text(json.dumps(payload, indent=2))
|
||||
|
||||
|
||||
def add_local_agent(entry: LocalAgentEntry) -> None:
|
||||
"""Add (or replace) an agent in the local index. agent_id is the key."""
|
||||
agents = [a for a in load_local_agents() if a.agent_id != entry.agent_id]
|
||||
agents.append(entry)
|
||||
_save_local_agents(agents)
|
||||
|
||||
|
||||
def update_local_agent(entry: LocalAgentEntry) -> None:
|
||||
"""Update an existing entry. Identical semantics to ``add_local_agent``
|
||||
(agent_id is the dedup key), exposed separately so callers can
|
||||
self-document intent.
|
||||
"""
|
||||
add_local_agent(entry)
|
||||
|
||||
|
||||
def remove_local_agent(agent_id: str) -> None:
|
||||
"""Remove an entry by agent_id. No-op if absent (idempotent)."""
|
||||
agents = [a for a in load_local_agents() if a.agent_id != agent_id]
|
||||
_save_local_agents(agents)
|
||||
|
||||
|
||||
def make_description(system_prompt: str) -> str:
|
||||
"""Synthesize a one-line description for the picker from a system prompt.
|
||||
|
||||
Strategy: first non-empty line, stripped of leading markdown heading
|
||||
markers and whitespace, prefixed with "(tier 3) ", truncated to 80
|
||||
chars. Falls back to "(tier 3) custom system prompt" if the prompt is
|
||||
empty (defensive — define rejects empty prompts at PRE-002).
|
||||
"""
|
||||
for line in system_prompt.splitlines():
|
||||
stripped = line.lstrip("# ").strip()
|
||||
if stripped:
|
||||
label = f"(tier 3) {stripped}"
|
||||
return label[:80] + ("…" if len(label) > 80 else "")
|
||||
return "(tier 3) custom system prompt"
|
||||
@@ -309,6 +309,14 @@ async def _iter_events(
|
||||
# with a bad id is still a keepalive). Don't reorder.
|
||||
if sse.data == "":
|
||||
continue
|
||||
# v0.8.1: empty-id frames are also treated as keepalives. Worldtree
|
||||
# SOMETIMES emits events without an `id:` line (observed mid-stream
|
||||
# on the qwen3.6-35-a3b-heretic provider, 2026-05-25). Per the SSE
|
||||
# RFC, events without ids are legitimate (they just don't update
|
||||
# Last-Event-ID); the previous strict behavior crashed every turn
|
||||
# on the offending agent. Treat same as empty-data: skip silently.
|
||||
if sse.id == "":
|
||||
continue
|
||||
try:
|
||||
sse_id = _parse_sse_id(sse.id)
|
||||
except ValueError as exc:
|
||||
|
||||
@@ -0,0 +1,471 @@
|
||||
"""Worldtree Tier 3 (consumer-defined) agent lifecycle client.
|
||||
|
||||
Implements docs/contracts/issues/15.contract.md. Caller-owned httpx.AsyncClient
|
||||
posture (same as ratatoskr.sessions). Exposes three lifecycle operations:
|
||||
|
||||
- ``define_agent`` — POST /agents/define
|
||||
- ``patch_agent`` — PATCH /agents/<id>
|
||||
- ``delete_agent`` — DELETE /agents/<id>
|
||||
|
||||
Plus a frozen ``Tier3AgentInfo`` dataclass for the response shape. The picker
|
||||
already handles colon-containing agent_ids generically (issue #8); session
|
||||
creation works unchanged via ``ratatoskr.sessions.create_session``.
|
||||
|
||||
Spec reference: ``docs/conversation-api-spec.md`` §2576-2750 (Phase 2.0).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
|
||||
import httpx
|
||||
|
||||
from ratatoskr.sessions import SessionApiFailed
|
||||
|
||||
# Per spec §2627: agent_name + user_id slugs are `[a-z][a-z0-9-]{2,63}`.
|
||||
_SLUG_RE = re.compile(r"^[a-z][a-z0-9-]{2,63}$")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Tier3AgentInfo:
|
||||
"""Worldtree Tier 3 agent envelope returned by define / patch.
|
||||
|
||||
INV-001: ``agent_id`` is always shape ``"<user_id>:<agent_name>"`` —
|
||||
constructed server-side from the auth's user_id + the supplied agent_name.
|
||||
"""
|
||||
|
||||
agent_id: str
|
||||
user_id: str
|
||||
agent_name: str
|
||||
system_prompt: str
|
||||
model: str
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
|
||||
class Tier3QuotaExceeded(Exception):
|
||||
"""Raised on HTTP 429 ``agent_quota_exceeded`` — 50-agent cap reached
|
||||
on the Heimdall key. ``retry_after`` captures the Retry-After header
|
||||
verbatim (defaults to 0 per spec §2675; forward-compat for non-zero)."""
|
||||
|
||||
def __init__(self, *, retry_after: int) -> None:
|
||||
super().__init__(f"Tier 3 agent quota exceeded (retry_after={retry_after})")
|
||||
self.retry_after = retry_after
|
||||
|
||||
|
||||
class Tier3UserIdUnsupported(Exception):
|
||||
"""Raised on HTTP 403 ``tier3_user_id_unsupported`` — auth's user_id
|
||||
is not slug-safe per Phase 2.0 gate (spec §2626)."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__("tier3 caller user_id is not slug-safe")
|
||||
|
||||
|
||||
class Tier3FieldNotMutable(Exception):
|
||||
"""Raised on HTTP 422 ``field_not_mutable`` — PATCH request body
|
||||
carried a key that's immutable post-define (``agent_name``, ``user_id``,
|
||||
or any layer field). Server rejects BEFORE the DB lookup (spec §2644)."""
|
||||
|
||||
def __init__(self, *, field: str | None) -> None:
|
||||
super().__init__(f"field not mutable on Tier 3 patch: {field!r}")
|
||||
self.field = field
|
||||
|
||||
|
||||
class Tier3LayerDeferred(Exception):
|
||||
"""Raised on HTTP 422 ``layer_deferred`` — define request carried a
|
||||
non-null layer field (``persona`` / ``motivational`` / ``valence`` /
|
||||
``memory``). Phase 2.0 ships baseline only; layers are schema-reserved.
|
||||
|
||||
Note: ``define_agent`` never sends layer fields, so this exception is
|
||||
defense-against-server-side-changes / forward-compat. INV-001 in the
|
||||
request body construction is the first line of defense.
|
||||
"""
|
||||
|
||||
def __init__(self, *, field: str | None) -> None:
|
||||
super().__init__(f"tier3 layer field deferred: {field!r}")
|
||||
self.field = field
|
||||
|
||||
|
||||
class Tier3AgentNotFound(Exception):
|
||||
"""Raised on HTTP 404 — PATCH or DELETE on a non-existent agent_id
|
||||
(spec §2634 + §2641)."""
|
||||
|
||||
def __init__(self, *, agent_id: str) -> None:
|
||||
super().__init__(f"tier3 agent not found: {agent_id!r}")
|
||||
self.agent_id = agent_id
|
||||
|
||||
|
||||
def _extract_error_code(resp: httpx.Response) -> str | None:
|
||||
"""Pluck the ``detail.error_code`` from a Worldtree error envelope.
|
||||
|
||||
Worldtree wraps API errors in ``{"detail": {"error_code": "...", ...}}``
|
||||
per the spec. Returns None on shape mismatch (so callers fall through
|
||||
to the generic ``SessionApiFailed`` branch).
|
||||
"""
|
||||
try:
|
||||
body = resp.json()
|
||||
except ValueError:
|
||||
return None
|
||||
detail = body.get("detail") if isinstance(body, dict) else None
|
||||
if isinstance(detail, dict):
|
||||
code = detail.get("error_code")
|
||||
if isinstance(code, str):
|
||||
return code
|
||||
return None
|
||||
|
||||
|
||||
def _extract_error_field(resp: httpx.Response) -> str | None:
|
||||
"""Pluck ``detail.field`` from a Worldtree error envelope (used for
|
||||
``field_not_mutable`` and ``layer_deferred`` to surface which field
|
||||
triggered the rejection). Returns None on shape mismatch.
|
||||
"""
|
||||
try:
|
||||
body = resp.json()
|
||||
except ValueError:
|
||||
return None
|
||||
detail = body.get("detail") if isinstance(body, dict) else None
|
||||
if isinstance(detail, dict):
|
||||
field = detail.get("field")
|
||||
if isinstance(field, str):
|
||||
return field
|
||||
return None
|
||||
|
||||
|
||||
def _parse_tier3_agent_info(body: dict) -> Tier3AgentInfo:
|
||||
"""Parse a Worldtree Tier 3 agent JSON body into the frozen dataclass."""
|
||||
return Tier3AgentInfo(
|
||||
agent_id=body["agent_id"],
|
||||
user_id=body["user_id"],
|
||||
agent_name=body["agent_name"],
|
||||
system_prompt=body["system_prompt"],
|
||||
model=body["model"],
|
||||
created_at=body["created_at"],
|
||||
updated_at=body["updated_at"],
|
||||
)
|
||||
|
||||
|
||||
async def define_agent(
|
||||
client: httpx.AsyncClient,
|
||||
*,
|
||||
agent_name: str,
|
||||
system_prompt: str,
|
||||
model: str,
|
||||
) -> Tier3AgentInfo:
|
||||
"""POST /agents/define — create a Tier 3 agent.
|
||||
|
||||
See contract FN define_agent. Validates the agent_name slug client-side
|
||||
before the network round-trip; server-side validation is the safety net.
|
||||
Returns a fully populated Tier3AgentInfo on 201. Routes documented error
|
||||
codes to typed exceptions; unknown non-2xx → SessionApiFailed.
|
||||
"""
|
||||
assert client is not None
|
||||
assert _SLUG_RE.match(agent_name), (
|
||||
f"agent_name must match [a-z][a-z0-9-]{{2,63}}: {agent_name!r}"
|
||||
)
|
||||
assert system_prompt, "system_prompt must be non-empty"
|
||||
assert model, "model must be non-empty"
|
||||
|
||||
body = {
|
||||
"agent_name": agent_name,
|
||||
"system_prompt": system_prompt,
|
||||
"model": model,
|
||||
}
|
||||
resp = await client.post("/agents/define", json=body)
|
||||
|
||||
if resp.status_code == 201:
|
||||
return _parse_tier3_agent_info(resp.json())
|
||||
if resp.status_code == 429:
|
||||
# Spec §2675: 51st define → 429 with Retry-After: 0.
|
||||
try:
|
||||
retry_after = int(resp.headers.get("Retry-After", "0"))
|
||||
except (TypeError, ValueError):
|
||||
retry_after = 0
|
||||
raise Tier3QuotaExceeded(retry_after=retry_after)
|
||||
if resp.status_code == 403:
|
||||
if _extract_error_code(resp) == "tier3_user_id_unsupported":
|
||||
raise Tier3UserIdUnsupported()
|
||||
if resp.status_code == 422:
|
||||
code = _extract_error_code(resp)
|
||||
if code == "layer_deferred":
|
||||
raise Tier3LayerDeferred(field=_extract_error_field(resp))
|
||||
raise SessionApiFailed(status=resp.status_code, body=resp.content)
|
||||
|
||||
|
||||
async def patch_agent(
|
||||
client: httpx.AsyncClient,
|
||||
agent_id: str,
|
||||
*,
|
||||
system_prompt: str | None = None,
|
||||
model: str | None = None,
|
||||
) -> Tier3AgentInfo:
|
||||
"""PATCH /agents/<id> — mutate system_prompt and/or model.
|
||||
|
||||
See contract FN patch_agent. Per spec §2641: only system_prompt + model
|
||||
are mutable in Phase 2.0; any other key returns 422 field_not_mutable.
|
||||
"""
|
||||
assert client is not None
|
||||
assert ":" in agent_id, f"tier 3 agent_id must contain ':': {agent_id!r}"
|
||||
assert system_prompt is not None or model is not None, (
|
||||
"patch requires at least one of system_prompt or model"
|
||||
)
|
||||
|
||||
body: dict[str, str] = {}
|
||||
if system_prompt is not None:
|
||||
body["system_prompt"] = system_prompt
|
||||
if model is not None:
|
||||
body["model"] = model
|
||||
resp = await client.patch(f"/agents/{agent_id}", json=body)
|
||||
|
||||
if resp.status_code == 200:
|
||||
return _parse_tier3_agent_info(resp.json())
|
||||
if resp.status_code == 404:
|
||||
raise Tier3AgentNotFound(agent_id=agent_id)
|
||||
if resp.status_code == 422:
|
||||
code = _extract_error_code(resp)
|
||||
if code == "field_not_mutable":
|
||||
raise Tier3FieldNotMutable(field=_extract_error_field(resp))
|
||||
raise SessionApiFailed(status=resp.status_code, body=resp.content)
|
||||
|
||||
|
||||
async def delete_agent(client: httpx.AsyncClient, agent_id: str) -> None:
|
||||
"""DELETE /agents/<id> — owner hard-delete (cancels active sessions
|
||||
server-side per spec §2636).
|
||||
|
||||
See contract FN delete_agent. 204 on success; 404 if the agent_id
|
||||
doesn't exist; other non-2xx → SessionApiFailed.
|
||||
"""
|
||||
assert client is not None
|
||||
assert ":" in agent_id, f"tier 3 agent_id must contain ':': {agent_id!r}"
|
||||
|
||||
resp = await client.delete(f"/agents/{agent_id}")
|
||||
if resp.status_code == 204:
|
||||
return
|
||||
if resp.status_code == 404:
|
||||
raise Tier3AgentNotFound(agent_id=agent_id)
|
||||
raise SessionApiFailed(status=resp.status_code, body=resp.content)
|
||||
|
||||
|
||||
# ---- CLI (`python -m ratatoskr.tier3 <subcommand>`) ------------------------
|
||||
#
|
||||
# Auth + server URL resolution mirrors ratatoskr.cli verbatim. Exit codes
|
||||
# mirror ratatoskr.cli: 0 happy / 10 usage / 11 auth / 20 api-failure /
|
||||
# 21 network. Outbound requests carry the same User-Agent string.
|
||||
|
||||
|
||||
class _Tier3UsageError(Exception):
|
||||
"""Argparse usage violation → exit 10."""
|
||||
|
||||
|
||||
class _Tier3AuthError(Exception):
|
||||
"""No API key resolvable → exit 11."""
|
||||
|
||||
|
||||
def _build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="python -m ratatoskr.tier3",
|
||||
description="Worldtree Tier 3 (consumer-defined) agent lifecycle.",
|
||||
)
|
||||
parser.add_argument("--api-key", dest="api_key", default=None)
|
||||
parser.add_argument("--server", dest="server", default=None)
|
||||
sub = parser.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
p_define = sub.add_parser("define", help="Create a Tier 3 agent.")
|
||||
p_define.add_argument("--name", required=True, help="agent_name (slug).")
|
||||
p_define.add_argument(
|
||||
"--system-prompt", dest="system_prompt", required=True,
|
||||
help="System prompt the agent ships with.",
|
||||
)
|
||||
p_define.add_argument(
|
||||
"--model", required=True,
|
||||
help="Provider model ID (NOT a profile alias; e.g., qwen3.6-35-a3b).",
|
||||
)
|
||||
|
||||
p_patch = sub.add_parser("patch", help="Mutate system_prompt and/or model.")
|
||||
p_patch.add_argument("agent_id", help='Full "<user_id>:<agent_name>" form.')
|
||||
p_patch.add_argument("--system-prompt", dest="system_prompt", default=None)
|
||||
p_patch.add_argument("--model", default=None)
|
||||
|
||||
p_delete = sub.add_parser("delete", help="Hard-delete a Tier 3 agent.")
|
||||
p_delete.add_argument("agent_id", help='Full "<user_id>:<agent_name>" form.')
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def _resolve_auth(ns: argparse.Namespace) -> tuple[str, str]:
|
||||
"""Resolve API key + server URL with the same env-var fallback as cli.py."""
|
||||
import os
|
||||
|
||||
api_key = ns.api_key or os.environ.get("WORLDTREE_API_KEY") or ""
|
||||
if not api_key:
|
||||
raise _Tier3AuthError("no API key (set --api-key or WORLDTREE_API_KEY)")
|
||||
server_url = (
|
||||
ns.server or os.environ.get("WORLDTREE_API_URL") or "http://localhost:8000"
|
||||
)
|
||||
return api_key, server_url
|
||||
|
||||
|
||||
async def _run_define(ns: argparse.Namespace) -> int:
|
||||
api_key, server_url = _resolve_auth(ns)
|
||||
from ratatoskr.cli import USER_AGENT
|
||||
from ratatoskr.local_agents import (
|
||||
LocalAgentEntry,
|
||||
add_local_agent,
|
||||
make_description,
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
base_url=server_url,
|
||||
headers={
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"User-Agent": USER_AGENT,
|
||||
},
|
||||
timeout=httpx.Timeout(connect=10.0, read=30.0, write=10.0, pool=10.0),
|
||||
) as client:
|
||||
info = await define_agent(
|
||||
client,
|
||||
agent_name=ns.name,
|
||||
system_prompt=ns.system_prompt,
|
||||
model=ns.model,
|
||||
)
|
||||
# v0.8.0: persist to local index so the picker can show it.
|
||||
add_local_agent(
|
||||
LocalAgentEntry(
|
||||
agent_id=info.agent_id,
|
||||
agent_name=info.agent_name,
|
||||
model=info.model,
|
||||
description=make_description(info.system_prompt),
|
||||
defined_at=info.created_at,
|
||||
)
|
||||
)
|
||||
print(f"defined {info.agent_id} ({info.model})")
|
||||
return 0
|
||||
|
||||
|
||||
async def _run_patch(ns: argparse.Namespace) -> int:
|
||||
api_key, server_url = _resolve_auth(ns)
|
||||
from ratatoskr.cli import USER_AGENT
|
||||
from ratatoskr.local_agents import (
|
||||
LocalAgentEntry,
|
||||
make_description,
|
||||
update_local_agent,
|
||||
)
|
||||
|
||||
if ns.system_prompt is None and ns.model is None:
|
||||
raise _Tier3UsageError(
|
||||
"patch requires at least one of --system-prompt or --model"
|
||||
)
|
||||
async with httpx.AsyncClient(
|
||||
base_url=server_url,
|
||||
headers={
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"User-Agent": USER_AGENT,
|
||||
},
|
||||
timeout=httpx.Timeout(connect=10.0, read=30.0, write=10.0, pool=10.0),
|
||||
) as client:
|
||||
info = await patch_agent(
|
||||
client,
|
||||
ns.agent_id,
|
||||
system_prompt=ns.system_prompt,
|
||||
model=ns.model,
|
||||
)
|
||||
# v0.8.0: refresh local index with the post-patch state.
|
||||
update_local_agent(
|
||||
LocalAgentEntry(
|
||||
agent_id=info.agent_id,
|
||||
agent_name=info.agent_name,
|
||||
model=info.model,
|
||||
description=make_description(info.system_prompt),
|
||||
defined_at=info.updated_at,
|
||||
)
|
||||
)
|
||||
print(f"patched {info.agent_id}")
|
||||
return 0
|
||||
|
||||
|
||||
async def _run_delete(ns: argparse.Namespace) -> int:
|
||||
api_key, server_url = _resolve_auth(ns)
|
||||
from ratatoskr.cli import USER_AGENT
|
||||
from ratatoskr.local_agents import remove_local_agent
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
base_url=server_url,
|
||||
headers={
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"User-Agent": USER_AGENT,
|
||||
},
|
||||
timeout=httpx.Timeout(connect=10.0, read=30.0, write=10.0, pool=10.0),
|
||||
) as client:
|
||||
await delete_agent(client, ns.agent_id)
|
||||
# v0.8.0: drop from local index so the picker stops listing it.
|
||||
remove_local_agent(ns.agent_id)
|
||||
print(f"deleted {ns.agent_id}")
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
"""Sync entry point — argparse + dispatch + error → exit-code mapping.
|
||||
|
||||
Mirrors ratatoskr.cli.main()'s error-routing matrix:
|
||||
0 happy
|
||||
10 usage error
|
||||
11 auth error
|
||||
20 api-failure (typed exception or generic SessionApiFailed)
|
||||
21 network error
|
||||
"""
|
||||
import asyncio
|
||||
import sys
|
||||
|
||||
parser = _build_parser()
|
||||
try:
|
||||
ns = parser.parse_args(argv)
|
||||
except SystemExit as exc:
|
||||
return int(exc.code) if exc.code is not None else 0
|
||||
|
||||
handler = {
|
||||
"define": _run_define,
|
||||
"patch": _run_patch,
|
||||
"delete": _run_delete,
|
||||
}[ns.cmd]
|
||||
|
||||
try:
|
||||
return asyncio.run(handler(ns))
|
||||
except _Tier3UsageError as exc:
|
||||
sys.stderr.write(f"[usage_error] {exc}\n")
|
||||
return 10
|
||||
except _Tier3AuthError as exc:
|
||||
sys.stderr.write(f"[auth_error] {exc}\n")
|
||||
return 11
|
||||
except AssertionError as exc:
|
||||
sys.stderr.write(f"[usage_error] {exc}\n")
|
||||
return 10
|
||||
except Tier3QuotaExceeded as exc:
|
||||
sys.stderr.write(f"[quota_exceeded] retry_after={exc.retry_after}\n")
|
||||
return 20
|
||||
except Tier3UserIdUnsupported:
|
||||
sys.stderr.write("[user_id_unsupported]\n")
|
||||
return 20
|
||||
except Tier3AgentNotFound as exc:
|
||||
sys.stderr.write(f"[agent_not_found] {exc.agent_id}\n")
|
||||
return 20
|
||||
except Tier3FieldNotMutable as exc:
|
||||
sys.stderr.write(f"[field_not_mutable] field={exc.field}\n")
|
||||
return 20
|
||||
except Tier3LayerDeferred as exc:
|
||||
sys.stderr.write(f"[layer_deferred] field={exc.field}\n")
|
||||
return 20
|
||||
except SessionApiFailed as exc:
|
||||
sys.stderr.write(
|
||||
f"[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 __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
sys.exit(main())
|
||||
+465
-122
@@ -10,19 +10,18 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass
|
||||
from typing import ClassVar, Literal
|
||||
|
||||
import httpx
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.binding import Binding
|
||||
from textual.containers import Horizontal, Vertical
|
||||
from textual.containers import Horizontal, Vertical, VerticalScroll
|
||||
from textual.theme import Theme
|
||||
from textual.widgets import (
|
||||
Footer,
|
||||
Header,
|
||||
Input,
|
||||
Label,
|
||||
ListItem,
|
||||
ListView,
|
||||
RichLog,
|
||||
@@ -69,16 +68,25 @@ from ratatoskr.sse_client import (
|
||||
# for primary surfaces; Dawn accents (red/yellow/magenta) used sparingly for
|
||||
# terminal-event labels (error/cancelled).
|
||||
#
|
||||
# **v0.6.3 single deviation from spec**: `$background` is `#000000` (pure
|
||||
# black), NOT Australis Ice black `#222531`. The Ice black is RGB(34,37,49)
|
||||
# — blue dominant — and at App-wide scale the cumulative cast reads as
|
||||
# "the whole app is blue" to operators (even though no single surface is
|
||||
# "blue" in the strict-color sense). Pure black for the App background
|
||||
# kills that perception. EVERY OTHER Australis value — Aurora accents,
|
||||
# Sea darks for chrome (surface/panel), Ice white foreground, Dawn
|
||||
# accents — stays verbatim per spec.
|
||||
#
|
||||
# Mapping to Textual's Theme semantic tokens:
|
||||
# primary = Aurora blue (#6388D8) — focus rings, active selection.
|
||||
# secondary = Aurora cyan (#00b1a8) — secondary highlights.
|
||||
# accent = Aurora bright cyan (#42dcd1) — bright accents (pane-name, prompt echo).
|
||||
# accent = Aurora bright cyan (#42dcd1) — bright accents.
|
||||
# success = Aurora green (#16B866) — [done] label.
|
||||
# warning = Dawn yellow (#e1c631) — [cancelled] label.
|
||||
# error = Dawn red (#ff491a) — [error] label.
|
||||
# foreground = Ice white (#a9bcc3) — default text.
|
||||
# background = Ice black (#222531) — App background.
|
||||
# surface = Sea bright black (#373b46) — raised chrome (header/footer/input).
|
||||
# background = pure black (#000000) — App background (v0.6.3 deviation).
|
||||
# surface = Sea bright black (#373b46) — raised chrome.
|
||||
# panel = Sea dark 30 (#414751) — borders, separators.
|
||||
|
||||
AUSTRALIS_THEME = Theme(
|
||||
@@ -90,7 +98,7 @@ AUSTRALIS_THEME = Theme(
|
||||
warning="#e1c631",
|
||||
error="#ff491a",
|
||||
foreground="#a9bcc3",
|
||||
background="#222531",
|
||||
background="#000000",
|
||||
surface="#373b46",
|
||||
panel="#414751",
|
||||
dark=True,
|
||||
@@ -116,8 +124,9 @@ AUSTRALIS_THEME = Theme(
|
||||
_AU_SUCCESS = "#16B866"
|
||||
_AU_ERROR = "#ff491a"
|
||||
_AU_WARNING = "#e1c631"
|
||||
_AU_USER_ECHO = "#42dcd1" # bright cyan — operator's voice
|
||||
_AU_DEMOTED = "#86929d" # dark 60 — demoted telemetry (was bare "dim")
|
||||
_AU_USER_ECHO = "#42dcd1" # Aurora bright cyan — operator's voice
|
||||
_AU_DEMOTED = "#86929d" # Sea dark 60 — demoted telemetry
|
||||
_AU_DEMOTED_FAINT = "#6e7882" # Sea dark 50 — empty-state placeholders
|
||||
|
||||
|
||||
# ---- Issue #12 presenter contract semantics amendment -------------------------
|
||||
@@ -176,29 +185,46 @@ class TuiPresenterState:
|
||||
See `docs/contracts/issues/12.contract.md` for the full spec.
|
||||
"""
|
||||
|
||||
thinking_buffer: list[str] = field(default_factory=list)
|
||||
thinking_open: bool = False
|
||||
# Thinking-run counter for turn-scoped start/end markers.
|
||||
thinking_run_index: int = 0
|
||||
# v0.7.1: thinking-content accumulator. Worldtree emits Thinking deltas
|
||||
# at token granularity; flushing each delta as its own RichLog line
|
||||
# produces per-token-per-newline visual spam. Buffer here and flush
|
||||
# only on `\n` boundaries (one written line per natural paragraph) or
|
||||
# when the run closes (any leftover tail).
|
||||
thinking_chunk_buffer: str = ""
|
||||
# v0.9.0: Text accumulator for live Markdown rendering. Worldtree emits
|
||||
# Text deltas at token granularity; each delta appends to this buffer
|
||||
# and the current_response_widget re-renders Markdown(text_chunk_buffer)
|
||||
# in place. On terminal event the widget is finalized + reference clears.
|
||||
text_chunk_buffer: str = ""
|
||||
# v0.9.0: reference to the Static widget holding the current turn's
|
||||
# response Markdown Renderable. None between turns.
|
||||
current_response_widget: object = None
|
||||
|
||||
def render(
|
||||
self,
|
||||
event: Event,
|
||||
*,
|
||||
log: RichLog,
|
||||
thinking_widget: Static,
|
||||
transcript: "VerticalScroll",
|
||||
tools_log: RichLog,
|
||||
debug_log: RichLog,
|
||||
thinking_log: RichLog,
|
||||
raw: bool,
|
||||
) -> None:
|
||||
"""Render one Worldtree SSE event with the TUI hierarchy + coalescing.
|
||||
|
||||
v0.5.0 routing: main `log` (transcript) is CONTENT-ONLY — Text,
|
||||
terminal labels ([done] / [error] / [cancelled]), and the
|
||||
post-Done Markdown render. All telemetry (Thinking closed runs,
|
||||
WorkerPhase, TextBoundary) routes to `debug_log` (Debug tab); all
|
||||
tool activity (ToolStart, ToolResult) routes to `tools_log` (Tools
|
||||
tab). Live thinking deltas continue to update `thinking_widget`.
|
||||
v0.9.0 routing:
|
||||
- `transcript` (VerticalScroll) = chat content: each turn mounts
|
||||
child widgets (turn-header / prompt-echo / response Markdown /
|
||||
done-label). Live Markdown rendering during Text streaming.
|
||||
- `tools_log` (RichLog) = ToolStart + ToolResult.
|
||||
- `debug_log` (RichLog) = WorkerPhase + TextBoundary.
|
||||
- `thinking_log` (RichLog) = streaming Thinking deltas inline
|
||||
(coalesced on `\n`); Rule(start)/Rule(end) wrap each run.
|
||||
|
||||
Exceptions are caught at the presenter boundary (INV-009 fallback).
|
||||
Exceptions caught at the presenter boundary (INV-009 fallback).
|
||||
"""
|
||||
assert isinstance(
|
||||
event,
|
||||
@@ -210,69 +236,125 @@ class TuiPresenterState:
|
||||
from rich.text import Text as RichText
|
||||
|
||||
def _dim(s: str) -> RichText:
|
||||
"""Wrap a demoted-telemetry line in Australis dark-60 grey.
|
||||
|
||||
v0.4.1 retheme: was `style="dim"` (terminal-dim filter, varies by
|
||||
emulator); now explicit Australis Sea dark-60 (#86929d) so the
|
||||
shade renders consistently across terminals and stays anchored to
|
||||
the brand palette.
|
||||
"""
|
||||
"""Wrap a demoted-telemetry line in Australis Sea dark-60 grey."""
|
||||
return RichText(s, style=_AU_DEMOTED)
|
||||
|
||||
try:
|
||||
# Thinking events: accumulate into buffer, update widget per delta.
|
||||
# v0.7.1: Thinking deltas coalesce by newline before flushing.
|
||||
# Worldtree emits Thinking events at token granularity; per-delta
|
||||
# RichLog writes produce one visual line per token (per-token-per-
|
||||
# newline spam). Buffer the deltas and flush only on `\n` (one
|
||||
# written line per natural paragraph) or run close.
|
||||
if isinstance(event, Thinking):
|
||||
from rich.rule import Rule
|
||||
|
||||
if not self.thinking_open:
|
||||
thinking_widget.display = True
|
||||
self.thinking_run_index += 1
|
||||
turn_id = event.sse_id.turn_id
|
||||
thinking_log.write(Rule(
|
||||
title=f"turn {turn_id} · thinking #{self.thinking_run_index} start",
|
||||
style=_AU_DEMOTED,
|
||||
))
|
||||
self.thinking_open = True
|
||||
self.thinking_buffer.append(event.content)
|
||||
acc = "".join(self.thinking_buffer)
|
||||
display_text = ("…" + acc[-200:]) if len(acc) > 200 else acc
|
||||
thinking_widget.update(display_text)
|
||||
self.thinking_chunk_buffer += event.content
|
||||
# Flush every complete line in the buffer. Whatever's after
|
||||
# the final `\n` stays buffered for the next delta or close.
|
||||
while "\n" in self.thinking_chunk_buffer:
|
||||
line, _, rest = self.thinking_chunk_buffer.partition("\n")
|
||||
if line: # skip empty lines (blank paragraph separators)
|
||||
thinking_log.write(line)
|
||||
self.thinking_chunk_buffer = rest
|
||||
return
|
||||
# Non-thinking event: close any open thinking run.
|
||||
# v0.5.0: closed thinking runs land in debug_log (Debug pane), not
|
||||
# transcript — keeps the main pane content-only.
|
||||
# Non-thinking event: close any open thinking run with Rule(end).
|
||||
if self.thinking_open:
|
||||
full_thinking = "".join(self.thinking_buffer)
|
||||
debug_log.write(_dim(f"· thinking: {full_thinking}"))
|
||||
self.thinking_buffer.clear()
|
||||
from rich.rule import Rule
|
||||
|
||||
# Flush the tail (content with no trailing `\n`) before the
|
||||
# end rule so nothing gets lost on close.
|
||||
if self.thinking_chunk_buffer:
|
||||
thinking_log.write(self.thinking_chunk_buffer)
|
||||
self.thinking_chunk_buffer = ""
|
||||
turn_id = event.sse_id.turn_id if hasattr(event, "sse_id") else (
|
||||
event.turn_id if hasattr(event, "turn_id") else "?"
|
||||
)
|
||||
thinking_log.write(Rule(
|
||||
title=f"turn {turn_id} · thinking #{self.thinking_run_index} end",
|
||||
style=_AU_DEMOTED,
|
||||
))
|
||||
self.thinking_open = False
|
||||
thinking_widget.update("")
|
||||
thinking_widget.display = False
|
||||
# Now render the non-thinking event itself.
|
||||
if isinstance(event, Text):
|
||||
# Streamed text content — no prefix, no demotion.
|
||||
log.write(event.content)
|
||||
# v0.8.1: stream Text deltas into transcript directly,
|
||||
# coalesced on `\n`. Same pattern as Thinking (v0.7.1).
|
||||
# The pre-v0.8.1 #current-text Static is gone — its dock-
|
||||
# bottom growth was overlapping the transcript visually.
|
||||
#
|
||||
# v0.9.0: Text deltas accumulate in text_chunk_buffer and
|
||||
# the current_response_widget renders Markdown(buffer) in
|
||||
# place. First Text delta of the turn mounts a fresh Static
|
||||
# holding the Markdown Renderable; subsequent deltas update
|
||||
# the same widget. Live markdown rendering — no post-Done
|
||||
# re-render needed.
|
||||
from rich.markdown import Markdown
|
||||
|
||||
self.text_chunk_buffer += event.content
|
||||
# --raw bypasses Markdown rendering — useful for debugging
|
||||
# the raw text stream surface, and matches the pre-v0.9.0
|
||||
# --raw semantics (which dropped the post-Done Markdown re-
|
||||
# render). In raw mode the response widget holds plain str.
|
||||
rendered = (
|
||||
self.text_chunk_buffer if raw else Markdown(self.text_chunk_buffer)
|
||||
)
|
||||
if self.current_response_widget is None:
|
||||
self.current_response_widget = Static(
|
||||
rendered, classes="response-md"
|
||||
)
|
||||
transcript.mount(self.current_response_widget)
|
||||
else:
|
||||
self.current_response_widget.update(rendered)
|
||||
transcript.scroll_end(animate=False)
|
||||
return
|
||||
if isinstance(event, (Done, Error, Cancelled)):
|
||||
# Terminal events: load-bearing label (no demotion).
|
||||
# Terminal event: finalize the response widget (clear ref so
|
||||
# the next turn mounts a fresh one). The accumulated text is
|
||||
# already rendered as Markdown in the widget — no post-Done
|
||||
# re-render, no double-print.
|
||||
self.text_chunk_buffer = ""
|
||||
self.current_response_widget = None
|
||||
# Terminal labels mount as styled Statics. Tinted per outcome
|
||||
# (Aurora green / Dawn red / Dawn yellow) for at-a-glance
|
||||
# scanning.
|
||||
if isinstance(event, Done):
|
||||
log.write(
|
||||
f"[done] turn_id={event.sse_id.turn_id} model={event.model} "
|
||||
f"duration={_format_duration_ms(event.duration_ms)} "
|
||||
f"usage {_format_usage(event.usage, arrow='→')}"
|
||||
)
|
||||
if not raw:
|
||||
from rich.markdown import Markdown
|
||||
from rich.rule import Rule
|
||||
|
||||
log.write(Rule())
|
||||
log.write(Markdown(event.response))
|
||||
transcript.mount(Static(
|
||||
RichText(
|
||||
f"[done] turn_id={event.sse_id.turn_id} "
|
||||
f"model={event.model} "
|
||||
f"duration={_format_duration_ms(event.duration_ms)} "
|
||||
f"usage {_format_usage(event.usage, arrow='→')}",
|
||||
style=_AU_SUCCESS,
|
||||
),
|
||||
classes="done-label",
|
||||
))
|
||||
elif isinstance(event, Error):
|
||||
log.write(
|
||||
f"[error] turn_id={event.sse_id.turn_id} code={event.error_code} "
|
||||
f"message={event.message!r}"
|
||||
)
|
||||
transcript.mount(Static(
|
||||
RichText(
|
||||
f"[error] turn_id={event.sse_id.turn_id} "
|
||||
f"code={event.error_code} message={event.message!r}",
|
||||
style=_AU_ERROR,
|
||||
),
|
||||
classes="error-label",
|
||||
))
|
||||
else: # Cancelled
|
||||
log.write(
|
||||
f"[cancelled] turn_id={event.turn_id} reason={event.reason!r} "
|
||||
f"partial_message_id={event.partial_message_id}"
|
||||
)
|
||||
# Belt-and-braces (Volva F3): ensure widget cleared+hidden on EVERY
|
||||
# terminal event, even if thinking_open was False — per STEPS 5-6.
|
||||
thinking_widget.update("")
|
||||
thinking_widget.display = False
|
||||
transcript.mount(Static(
|
||||
RichText(
|
||||
f"[cancelled] turn_id={event.turn_id} "
|
||||
f"reason={event.reason!r} "
|
||||
f"partial_message_id={event.partial_message_id}",
|
||||
style=_AU_WARNING,
|
||||
),
|
||||
classes="cancelled-label",
|
||||
))
|
||||
transcript.scroll_end(animate=False)
|
||||
return
|
||||
if isinstance(event, WorkerPhase):
|
||||
# v0.5.0: telemetry → Debug pane, not transcript.
|
||||
@@ -304,19 +386,24 @@ class TuiPresenterState:
|
||||
# the original event AND a render_error line with the class name only
|
||||
# (NO exception message — security clause). Volva F1 fix.
|
||||
#
|
||||
# v0.5.0: routing-under-failure preservation — fallback writes go
|
||||
# to the same destination the successful render would have used:
|
||||
# - ToolStart/ToolResult → tools_log
|
||||
# - WorkerPhase/Thinking/TextBoundary → debug_log
|
||||
# - everything else (Text/Done/Error/Cancelled) → log
|
||||
# v0.9.0 routing-under-failure: panes (RichLog) still write Strip
|
||||
# lines; transcript (VerticalScroll) mounts a Static instead.
|
||||
if isinstance(event, (ToolStart, ToolResult)):
|
||||
target = tools_log
|
||||
elif isinstance(event, (WorkerPhase, Thinking, TextBoundary)):
|
||||
target = debug_log
|
||||
tools_log.write(_plain_label(event))
|
||||
tools_log.write(f"[render_error] {type(exc).__name__}")
|
||||
elif isinstance(event, Thinking):
|
||||
thinking_log.write(_plain_label(event))
|
||||
thinking_log.write(f"[render_error] {type(exc).__name__}")
|
||||
elif isinstance(event, (WorkerPhase, TextBoundary)):
|
||||
debug_log.write(_plain_label(event))
|
||||
debug_log.write(f"[render_error] {type(exc).__name__}")
|
||||
else:
|
||||
target = log
|
||||
target.write(_plain_label(event))
|
||||
target.write(f"[render_error] {type(exc).__name__}")
|
||||
# Transcript-bound event (Text / Done / Error / Cancelled).
|
||||
transcript.mount(Static(_plain_label(event), classes="error-label"))
|
||||
transcript.mount(
|
||||
Static(f"[render_error] {type(exc).__name__}", classes="error-label")
|
||||
)
|
||||
transcript.scroll_end(animate=False)
|
||||
|
||||
|
||||
class AgentPickerApp(App[str | None]):
|
||||
@@ -330,6 +417,28 @@ class AgentPickerApp(App[str | None]):
|
||||
"""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
/* v0.6.1: kill Textual's $primary-blue tints everywhere — Header sub-
|
||||
widgets (HeaderIcon etc.) have their own $primary tinting that the
|
||||
parent `Header { background: $surface }` rule alone doesn't cover.
|
||||
Sub-selectors force the cool palette down to every level. */
|
||||
Header, HeaderIcon, HeaderTitle, HeaderClock {
|
||||
background: $surface;
|
||||
color: $au-bright-blue;
|
||||
}
|
||||
Footer {
|
||||
background: $surface;
|
||||
}
|
||||
/* v0.6.1: scrollbar uses Textual's $primary-tint by default. Force
|
||||
Australis Sea darks so the scrollbar gutter doesn't read as a blue
|
||||
strip. Applied to ListView (the scrollable widget here). */
|
||||
ListView {
|
||||
scrollbar-background: $background;
|
||||
scrollbar-background-hover: $background;
|
||||
scrollbar-background-active: $background;
|
||||
scrollbar-color: $au-dark-50;
|
||||
scrollbar-color-hover: $au-dark-60;
|
||||
scrollbar-color-active: $au-bright-cyan;
|
||||
}
|
||||
#picker-prompt {
|
||||
dock: top;
|
||||
height: 1;
|
||||
@@ -341,9 +450,41 @@ class AgentPickerApp(App[str | None]):
|
||||
height: 1fr;
|
||||
background: $background;
|
||||
}
|
||||
#agent-list > ListItem.--highlight {
|
||||
/* Multi-line agent items. Each ListItem is auto-height so the full
|
||||
description wraps below the agent_id/name line — no truncation.
|
||||
v0.6.4: lock bg to $background so Textual's auto background-tint on
|
||||
focus doesn't bleed through unwanted color into the non-highlighted
|
||||
items. */
|
||||
#agent-list > ListItem {
|
||||
height: auto;
|
||||
padding: 1 1;
|
||||
background: $background;
|
||||
}
|
||||
/* v0.6.4: highlighted item gets Aurora blue background (Textual's
|
||||
default $block-cursor-background = $primary). Override only the
|
||||
text-color descendants so id-line/desc stay readable on blue. The
|
||||
background itself comes from Textual's default ListItem.-highlight
|
||||
rule — we removed our previous overriding selectors.
|
||||
|
||||
Textual's class is `-highlight` (single dash). Use plain descendant
|
||||
combinator to bypass internal DOM wrappers. */
|
||||
#agent-list:focus ListItem.-highlight {
|
||||
background: $primary;
|
||||
}
|
||||
#agent-list:focus ListItem.-highlight .agent-id-line {
|
||||
color: $au-bright-white;
|
||||
text-style: bold;
|
||||
}
|
||||
#agent-list:focus ListItem.-highlight .agent-desc {
|
||||
color: $au-bright-80;
|
||||
}
|
||||
/* Default (unhighlighted) item text styling. */
|
||||
.agent-id-line {
|
||||
color: $au-bright-blue;
|
||||
text-style: bold;
|
||||
}
|
||||
.agent-desc {
|
||||
color: $au-bright-70;
|
||||
}
|
||||
"""
|
||||
|
||||
@@ -366,9 +507,16 @@ class AgentPickerApp(App[str | None]):
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Header()
|
||||
yield Static("Pick an agent for the new session:", id="picker-prompt")
|
||||
# v0.6.0: each ListItem has two Static children — the id/name line
|
||||
# in bold blue + the wrapped description in muted dark-60. No
|
||||
# description truncation; tall items breathe so the operator can
|
||||
# actually read what each agent does.
|
||||
yield ListView(
|
||||
*[
|
||||
ListItem(Label(f"{a.agent_id} · {a.name} — {a.description}"))
|
||||
ListItem(
|
||||
Static(f"{a.agent_id} · {a.name}", classes="agent-id-line"),
|
||||
Static(a.description, classes="agent-desc"),
|
||||
)
|
||||
for a in self.agents
|
||||
],
|
||||
id="agent-list",
|
||||
@@ -411,13 +559,27 @@ class RatatoskrApp(App[int]):
|
||||
# Australis theme variables ($primary/$accent/$au-dark-60/$au-bright-cyan/
|
||||
# etc.) carry colors so a future theme swap rebinds centrally.
|
||||
DEFAULT_CSS = """
|
||||
Header {
|
||||
/* v0.6.1: kill Textual's default $primary-blue tinting on chrome —
|
||||
Header sub-widgets (HeaderIcon, HeaderTitle, HeaderClock) each carry
|
||||
their own $primary tint that the parent `Header { background }` rule
|
||||
doesn't override; sub-selectors force the cool palette down. */
|
||||
Header, HeaderIcon, HeaderTitle, HeaderClock {
|
||||
background: $surface;
|
||||
color: $au-bright-blue;
|
||||
}
|
||||
Footer {
|
||||
background: $surface;
|
||||
}
|
||||
/* v0.6.1: scrollbars default to $primary-tint blue. Force Sea darks
|
||||
on the scrollable widgets (RichLog instances). */
|
||||
RichLog {
|
||||
scrollbar-background: $background;
|
||||
scrollbar-background-hover: $background;
|
||||
scrollbar-background-active: $background;
|
||||
scrollbar-color: $au-dark-50;
|
||||
scrollbar-color-hover: $au-dark-60;
|
||||
scrollbar-color-active: $au-bright-cyan;
|
||||
}
|
||||
#main-row {
|
||||
height: 1fr;
|
||||
}
|
||||
@@ -428,28 +590,72 @@ class RatatoskrApp(App[int]):
|
||||
#right-column {
|
||||
width: 1fr;
|
||||
}
|
||||
#thinking-current {
|
||||
dock: top;
|
||||
height: auto;
|
||||
color: $au-dark-60;
|
||||
padding: 0 1;
|
||||
}
|
||||
#transcript {
|
||||
/* v0.6.5: thinking-current Static removed; thinking now streams
|
||||
directly into thinking-log so the whole pane scrolls naturally. */
|
||||
/* v0.9.0: transcript is a VerticalScroll container holding dynamically
|
||||
mounted Statics + Markdown widgets per turn. Live Markdown rendering
|
||||
replaces the v0.8.x RichLog approach which couldn't render Markdown
|
||||
in-flight (only on Done as a re-render → double-print bug). */
|
||||
#transcript-scroll {
|
||||
height: 1fr;
|
||||
background: $background;
|
||||
padding: 0 1;
|
||||
}
|
||||
#tools-log, #debug-log {
|
||||
/* Per-turn mounted widgets carry id-prefix conventions:
|
||||
- .turn-header "── turn N ──" (dim)
|
||||
- .prompt-echo "❯ user input" (aurora bright cyan)
|
||||
- .response-md Markdown(accumulated_text) — updated live
|
||||
- .done-label "[done] turn_id=…" (aurora green)
|
||||
- .error-label "[error] …" (dawn red)
|
||||
- .cancelled-label "[cancelled] …" (dawn yellow)
|
||||
*/
|
||||
.turn-header {
|
||||
height: auto;
|
||||
padding: 0 1;
|
||||
color: $au-dark-60;
|
||||
}
|
||||
.prompt-echo {
|
||||
height: auto;
|
||||
padding: 0 1;
|
||||
}
|
||||
.response-md {
|
||||
height: auto;
|
||||
padding: 0 1;
|
||||
}
|
||||
.done-label, .error-label, .cancelled-label {
|
||||
height: auto;
|
||||
padding: 0 1;
|
||||
}
|
||||
/* v0.8.1: #current-text Static removed. Streaming text now coalesces
|
||||
on `\n` and writes directly to #transcript (same pattern as v0.7.1
|
||||
thinking fix). Eliminates the dock-bottom-growth-overlap bug. */
|
||||
#tools-log, #debug-log, #thinking-log {
|
||||
background: $background;
|
||||
padding: 0 1;
|
||||
}
|
||||
#side-panes Tabs {
|
||||
/* Tab strip + active-tab underline — kill blue, use Australis cyan. */
|
||||
#side-panes > ContentTabs {
|
||||
background: $surface;
|
||||
}
|
||||
#side-panes ContentTab.-active {
|
||||
color: $au-bright-cyan;
|
||||
text-style: bold;
|
||||
}
|
||||
#side-panes Underline > .underline--bar {
|
||||
color: $au-bright-cyan;
|
||||
}
|
||||
#prompt {
|
||||
dock: bottom;
|
||||
border: tall $panel;
|
||||
}
|
||||
/* v0.6.0: focused border uses Australis bright-cyan instead of $primary
|
||||
(Aurora blue) — kills the lingering blue tint the user flagged. */
|
||||
#prompt:focus {
|
||||
border: tall $primary;
|
||||
border: tall $au-bright-cyan;
|
||||
}
|
||||
/* Placeholder text in the Input — dimmer than typed content. */
|
||||
#prompt > .input--placeholder {
|
||||
color: $au-dark-50;
|
||||
}
|
||||
#identity {
|
||||
dock: bottom;
|
||||
@@ -478,6 +684,7 @@ class RatatoskrApp(App[int]):
|
||||
# without losing Input focus (INV-016).
|
||||
Binding("ctrl+1", "focus_tools", "Tools tab", priority=False),
|
||||
Binding("ctrl+2", "focus_debug", "Debug tab", priority=False),
|
||||
Binding("ctrl+3", "focus_thinking", "Thinking tab", priority=False),
|
||||
]
|
||||
|
||||
HINT_IDLE = "Ctrl-C twice to exit"
|
||||
@@ -506,20 +713,28 @@ class RatatoskrApp(App[int]):
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Header()
|
||||
# v0.5.0 layout: left column is content-only (transcript + prompt).
|
||||
# Right column houses ALL telemetry — thinking-current live preview
|
||||
# docked above the TabbedContent; tabs cycle Tools / Debug.
|
||||
# markup=False on RichLog so labeled lines like "[cancel_failed] ..."
|
||||
# render verbatim; Rich would otherwise interpret bracket spans as
|
||||
# style markup. The post-Done markdown render uses Markdown() directly
|
||||
# which is a Rich Renderable and renders correctly without
|
||||
# widget-level markup=True.
|
||||
# v0.6.0 layout: left column is content-only (transcript + streaming
|
||||
# text Static + prompt). Right column hosts thinking-current live
|
||||
# preview above TabbedContent cycling Tools / Debug / Thinking.
|
||||
#
|
||||
# The current-text Static buffers in-flight assistant tokens so
|
||||
# streaming doesn't spam the RichLog with one line per delta —
|
||||
# the operator sees a single growing live line, then on Done the
|
||||
# Static clears and the final Markdown body lands in the transcript.
|
||||
#
|
||||
# markup=False on RichLog so labeled lines render verbatim; the
|
||||
# post-Done Markdown() / Rule() renders are Rich Renderables and
|
||||
# work without widget-level markup=True.
|
||||
with Horizontal(id="main-row"):
|
||||
with Vertical(id="left-column"):
|
||||
yield RichLog(id="transcript", wrap=True, markup=False, highlight=False)
|
||||
# v0.9.0: transcript is a VerticalScroll holding per-turn
|
||||
# mounted widgets (turn header, prompt echo, response Markdown,
|
||||
# done label). Live Markdown rendering happens via Static
|
||||
# widgets holding `Markdown` Renderables, updated as Text
|
||||
# deltas arrive.
|
||||
yield VerticalScroll(id="transcript-scroll")
|
||||
yield Input(id="prompt", placeholder="Type a message and press Enter")
|
||||
with Vertical(id="right-column"):
|
||||
yield Static("", id="thinking-current")
|
||||
with TabbedContent(id="side-panes"):
|
||||
with TabPane("Tools", id="tools-tab"):
|
||||
yield RichLog(
|
||||
@@ -529,6 +744,16 @@ class RatatoskrApp(App[int]):
|
||||
yield RichLog(
|
||||
id="debug-log", wrap=True, markup=False, highlight=False
|
||||
)
|
||||
with TabPane("Thinking", id="thinking-tab"):
|
||||
# v0.6.5: thinking streams directly into this
|
||||
# RichLog (no separate bottom Static). Each delta
|
||||
# writes a line; Rule(start)/Rule(end) mark run
|
||||
# boundaries. The whole pane scrolls naturally
|
||||
# as content arrives — no more "200-char tail
|
||||
# window scrolling at the bottom".
|
||||
yield RichLog(
|
||||
id="thinking-log", wrap=True, markup=False, highlight=False
|
||||
)
|
||||
# INV-002 + INV-003: visible identity + hint widgets (Footer-area).
|
||||
# pane-name widget displays current side-pane name.
|
||||
yield Static("", id="identity")
|
||||
@@ -547,11 +772,58 @@ class RatatoskrApp(App[int]):
|
||||
identity = f"{agent_slot} · …{self.session_id[-8:]}"
|
||||
self.sub_title = identity # mirror to Header subtitle for redundancy
|
||||
self.query_one("#identity", Static).update(identity)
|
||||
# Issue #12: thinking widget hidden until a thinking event fires.
|
||||
self.query_one("#thinking-current", Static).display = False
|
||||
# v0.5.1 polish: empty-state placeholder lines so the operator sees
|
||||
# the pane is intentionally empty (not broken) before any turn fires.
|
||||
# Wrapped in Australis dark-50 italic so they read distinctly as
|
||||
# placeholder text, not real telemetry. Disappear naturally as the
|
||||
# log fills with real events (the placeholders scroll off the top).
|
||||
from rich.text import Text as RichText
|
||||
placeholder_style = f"{_AU_DEMOTED_FAINT} italic"
|
||||
self.query_one("#tools-log", RichLog).write(
|
||||
RichText("(no tool events yet — start a turn that uses tools)",
|
||||
style=placeholder_style)
|
||||
)
|
||||
self.query_one("#debug-log", RichLog).write(
|
||||
RichText("(waiting for worker_phase + text_boundary telemetry)",
|
||||
style=placeholder_style)
|
||||
)
|
||||
self.query_one("#thinking-log", RichLog).write(
|
||||
RichText("(no chain-of-thought captured yet — start a turn)",
|
||||
style=placeholder_style)
|
||||
)
|
||||
self.state = "idle"
|
||||
self._set_hint(self.HINT_IDLE)
|
||||
|
||||
def _write_turn_headers(self, turn_id: int) -> None:
|
||||
"""v0.6.0: turn-ID headers across every pane for cross-pane
|
||||
correlation. v0.9.0: transcript is a VerticalScroll; mounts a
|
||||
Static with rule-style text instead of writing a Rule Renderable
|
||||
to RichLog. Other panes still use RichLog.write(Rule).
|
||||
"""
|
||||
from rich.rule import Rule
|
||||
from rich.text import Text as RichText
|
||||
|
||||
title = f"turn {turn_id}"
|
||||
rule = Rule(title=title, style=_AU_DEMOTED)
|
||||
try:
|
||||
# Transcript (VerticalScroll): mount a styled Static.
|
||||
transcript = self.query_one("#transcript-scroll", VerticalScroll)
|
||||
transcript.mount(
|
||||
Static(
|
||||
RichText(f"── turn {turn_id} ──", style=_AU_DEMOTED),
|
||||
classes="turn-header",
|
||||
)
|
||||
)
|
||||
# Other panes (RichLog): write the Rule Renderable.
|
||||
self.query_one("#tools-log", RichLog).write(rule)
|
||||
self.query_one("#debug-log", RichLog).write(rule)
|
||||
self.query_one("#thinking-log", RichLog).write(rule)
|
||||
transcript.scroll_end(animate=False)
|
||||
except Exception:
|
||||
# Defensive: widget tree may be tearing down — never let a
|
||||
# turn-header write block the SSE consumer.
|
||||
pass
|
||||
|
||||
def _set_hint(self, hint: str) -> None:
|
||||
"""Set the hint state attribute AND update the visible Static widget."""
|
||||
self.hint = hint
|
||||
@@ -562,12 +834,19 @@ class RatatoskrApp(App[int]):
|
||||
pass
|
||||
|
||||
async def on_input_submitted(self, event: Input.Submitted) -> None:
|
||||
"""Echo user prompt, spawn stream worker; busy notice if not idle."""
|
||||
"""Echo user prompt, spawn stream worker; busy notice if not idle.
|
||||
|
||||
v0.9.0: prompt echo mounts as a Static in the transcript VerticalScroll
|
||||
(was log.write to RichLog).
|
||||
"""
|
||||
if event.input.id != "prompt":
|
||||
return
|
||||
log = self.query_one("#transcript", RichLog)
|
||||
transcript = self.query_one("#transcript-scroll", VerticalScroll)
|
||||
if self.state != "idle":
|
||||
log.write("[busy] turn in flight; input ignored")
|
||||
transcript.mount(
|
||||
Static("[busy] turn in flight; input ignored", classes="error-label")
|
||||
)
|
||||
transcript.scroll_end(animate=False)
|
||||
event.input.value = ""
|
||||
return
|
||||
content = event.input.value.strip()
|
||||
@@ -576,7 +855,13 @@ class RatatoskrApp(App[int]):
|
||||
# v0.4.1 retheme: operator's voice gets Australis bright cyan so it
|
||||
# stands out against the default-foreground assistant text below it.
|
||||
from rich.text import Text as RichText
|
||||
log.write(RichText(f"❯ {content}", style=_AU_USER_ECHO)) # noqa: RUF001
|
||||
transcript.mount(
|
||||
Static(
|
||||
RichText(f"❯ {content}", style=_AU_USER_ECHO), # noqa: RUF001
|
||||
classes="prompt-echo",
|
||||
)
|
||||
)
|
||||
transcript.scroll_end(animate=False)
|
||||
event.input.value = ""
|
||||
self.state = "streaming"
|
||||
self._set_hint(self.HINT_STREAMING)
|
||||
@@ -585,40 +870,54 @@ class RatatoskrApp(App[int]):
|
||||
)
|
||||
|
||||
async def _stream_turn_worker(self, content: str) -> None:
|
||||
"""Drive stream_turn, render events via TuiPresenterState (issue #12)."""
|
||||
"""Drive stream_turn, render events via TuiPresenterState.
|
||||
|
||||
v0.9.0: transcript is a VerticalScroll; the presenter's `transcript`
|
||||
argument is the container, and the presenter mounts Static / Markdown-
|
||||
backed widgets directly. Wire-error labels mount as `error-label`
|
||||
Statics into the transcript-scroll.
|
||||
"""
|
||||
assert self.state == "streaming"
|
||||
assert self.client is not None
|
||||
assert content
|
||||
log = self.query_one("#transcript", RichLog)
|
||||
thinking_widget = self.query_one("#thinking-current", Static)
|
||||
# v0.5.0: separate panes for tools vs telemetry; transcript is content only.
|
||||
transcript = self.query_one("#transcript-scroll", VerticalScroll)
|
||||
tools_log = self.query_one("#tools-log", RichLog)
|
||||
debug_log = self.query_one("#debug-log", RichLog)
|
||||
thinking_log = self.query_one("#thinking-log", RichLog)
|
||||
presenter = TuiPresenterState()
|
||||
|
||||
def _mount_wire_error(label: str) -> None:
|
||||
try:
|
||||
transcript.mount(Static(label, classes="error-label"))
|
||||
transcript.scroll_end(animate=False)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
async for event in stream_turn(self.client, self.session_id, content):
|
||||
if self.active_turn_id is None:
|
||||
self.active_turn_id = event.sse_id.turn_id
|
||||
self._write_turn_headers(self.active_turn_id)
|
||||
presenter.render(
|
||||
event,
|
||||
log=log,
|
||||
thinking_widget=thinking_widget,
|
||||
transcript=transcript,
|
||||
tools_log=tools_log,
|
||||
debug_log=debug_log,
|
||||
thinking_log=thinking_log,
|
||||
raw=self.args.raw,
|
||||
)
|
||||
if isinstance(event, (Done, Error, Cancelled)):
|
||||
break
|
||||
except SseConnectFailed as exc:
|
||||
log.write(f"[sse_connect_failed] status={exc.status} body={exc.body!r}")
|
||||
_mount_wire_error(f"[sse_connect_failed] status={exc.status} body={exc.body!r}")
|
||||
except SseConnectionDropped as exc:
|
||||
log.write(f"[connection_dropped] last_seen={exc.last_seen_sse_id}")
|
||||
_mount_wire_error(f"[connection_dropped] last_seen={exc.last_seen_sse_id}")
|
||||
except MalformedSseId as exc:
|
||||
log.write(f"[malformed_sse_id] raw={exc.raw!r}")
|
||||
_mount_wire_error(f"[malformed_sse_id] raw={exc.raw!r}")
|
||||
except MalformedSseData as exc:
|
||||
log.write(f"[malformed_sse_data] raw={exc.raw!r}")
|
||||
_mount_wire_error(f"[malformed_sse_data] raw={exc.raw!r}")
|
||||
except TurnIdFlip as exc:
|
||||
log.write(f"[turn_id_flip] expected={exc.established} got={exc.got}")
|
||||
_mount_wire_error(f"[turn_id_flip] expected={exc.established} got={exc.got}")
|
||||
finally:
|
||||
self.state = "idle"
|
||||
self.active_turn_id = None
|
||||
@@ -641,9 +940,12 @@ class RatatoskrApp(App[int]):
|
||||
return
|
||||
self.state = "cancelling"
|
||||
self._set_hint(self.HINT_CANCELLING)
|
||||
log = self.query_one("#transcript", RichLog)
|
||||
transcript = self.query_one("#transcript-scroll", VerticalScroll)
|
||||
self.run_worker(
|
||||
_cancel_via_sse(self.client, self.session_id, self.active_turn_id, log=log)
|
||||
_cancel_via_sse(
|
||||
self.client, self.session_id, self.active_turn_id,
|
||||
transcript=transcript,
|
||||
)
|
||||
)
|
||||
elif self.state == "cancelling":
|
||||
if self.stream_worker is not None:
|
||||
@@ -672,6 +974,11 @@ class RatatoskrApp(App[int]):
|
||||
self.query_one("#side-panes", TabbedContent).active = "debug-tab"
|
||||
self.query_one("#pane-name", Static).update("Debug")
|
||||
|
||||
def action_focus_thinking(self) -> None:
|
||||
"""v0.6.0: Ctrl+3 activates the Thinking tab. INV-016 preserves Input focus."""
|
||||
self.query_one("#side-panes", TabbedContent).active = "thinking-tab"
|
||||
self.query_one("#pane-name", Static).update("Thinking")
|
||||
|
||||
|
||||
def run_tui(args: ParsedArgs) -> int:
|
||||
"""Sync entry point — delegates to the async resolve-then-run flow.
|
||||
@@ -710,6 +1017,14 @@ async def _resolve_then_run(args: ParsedArgs) -> int:
|
||||
# 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).
|
||||
#
|
||||
# v0.8.0: merge in local tier-3 agent index. Worldtree's GET /agents
|
||||
# doesn't return consumer-defined agents (issue #15 smoke finding);
|
||||
# ratatoskr keeps its own JSON-backed index of agents the operator
|
||||
# defined via `python -m ratatoskr.tier3 define`. Merged here so the
|
||||
# picker shows foundational + local-tier-3 in one list. Dedup by
|
||||
# agent_id (remote wins on conflict, since a server-listed agent
|
||||
# is the authoritative source).
|
||||
chosen_agent_id: str | None = args.agent_id
|
||||
if args.new and args.agent_id is None:
|
||||
try:
|
||||
@@ -722,6 +1037,23 @@ async def _resolve_then_run(args: ParsedArgs) -> int:
|
||||
except (httpx.ConnectError, httpx.ReadTimeout, httpx.TransportError) as exc:
|
||||
sys.stderr.write(f"[network_error] {type(exc).__name__}: {exc}\n")
|
||||
return 21
|
||||
# v0.8.0: append local tier-3 entries not already in the remote list.
|
||||
from ratatoskr.local_agents import load_local_agents
|
||||
|
||||
remote_ids = {a.agent_id for a in agents}
|
||||
for entry in load_local_agents():
|
||||
if entry.agent_id in remote_ids:
|
||||
continue
|
||||
agents.append(AgentInfo(
|
||||
agent_id=entry.agent_id,
|
||||
name=entry.agent_name,
|
||||
description=entry.description,
|
||||
version=None,
|
||||
capabilities=[],
|
||||
supported_models=[],
|
||||
persona_traits={},
|
||||
ui_hints={},
|
||||
))
|
||||
if not agents:
|
||||
sys.stderr.write("[no_agents] server returned empty agent list\n")
|
||||
return 13
|
||||
@@ -762,12 +1094,23 @@ async def _cancel_via_sse(
|
||||
session_id: str,
|
||||
turn_id: int,
|
||||
*,
|
||||
log: RichLog,
|
||||
transcript: VerticalScroll,
|
||||
) -> None:
|
||||
"""Fire-and-forget cancel; never raises (mirrors cli._cancel_and_log; #3 INV-009)."""
|
||||
"""Fire-and-forget cancel; never raises (mirrors cli._cancel_and_log; #3 INV-009).
|
||||
|
||||
v0.9.0: mounts a `[cancel_failed]` Static into the transcript-scroll
|
||||
container on failure (was log.write to RichLog).
|
||||
"""
|
||||
assert client is not None
|
||||
assert isinstance(turn_id, int) and turn_id > 0
|
||||
try:
|
||||
await cancel_turn(client, session_id, turn_id)
|
||||
except (CancelFailed, CancelTurnNotFound, CancelAlreadyCompleted, httpx.RequestError) as exc:
|
||||
log.write(f"[cancel_failed] {type(exc).__name__}: {exc}")
|
||||
try:
|
||||
transcript.mount(Static(
|
||||
f"[cancel_failed] {type(exc).__name__}: {exc}",
|
||||
classes="error-label",
|
||||
))
|
||||
transcript.scroll_end(animate=False)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
"""Tests for ratatoskr.local_agents.
|
||||
|
||||
Use ``$RATATOSKR_LOCAL_AGENTS`` env-var override + pytest tmp_path to
|
||||
isolate from the operator's real ``~/.config/ratatoskr/local_agents.json``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from ratatoskr.local_agents import (
|
||||
LocalAgentEntry,
|
||||
_local_agents_path,
|
||||
add_local_agent,
|
||||
load_local_agents,
|
||||
make_description,
|
||||
remove_local_agent,
|
||||
update_local_agent,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def local_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
"""Point $RATATOSKR_LOCAL_AGENTS at a fresh tmp file for the test."""
|
||||
path = tmp_path / "local_agents.json"
|
||||
monkeypatch.setenv("RATATOSKR_LOCAL_AGENTS", str(path))
|
||||
return path
|
||||
|
||||
|
||||
def _entry(
|
||||
agent_id: str = "ratatoskr:wizard",
|
||||
agent_name: str = "wizard",
|
||||
model: str = "qwen3.6-35-a3b",
|
||||
description: str = "(tier 3) test agent",
|
||||
defined_at: str = "2026-05-25T00:00:00+00:00",
|
||||
) -> LocalAgentEntry:
|
||||
return LocalAgentEntry(
|
||||
agent_id=agent_id,
|
||||
agent_name=agent_name,
|
||||
model=model,
|
||||
description=description,
|
||||
defined_at=defined_at,
|
||||
)
|
||||
|
||||
|
||||
class TestPathResolution:
|
||||
def test_env_override(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("RATATOSKR_LOCAL_AGENTS", "/tmp/custom-agents.json")
|
||||
assert _local_agents_path() == Path("/tmp/custom-agents.json")
|
||||
|
||||
def test_xdg_config_home(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("RATATOSKR_LOCAL_AGENTS", raising=False)
|
||||
monkeypatch.setenv("XDG_CONFIG_HOME", "/tmp/xdg-config")
|
||||
assert (
|
||||
_local_agents_path()
|
||||
== Path("/tmp/xdg-config/ratatoskr/local_agents.json")
|
||||
)
|
||||
|
||||
def test_default_home(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("RATATOSKR_LOCAL_AGENTS", raising=False)
|
||||
monkeypatch.delenv("XDG_CONFIG_HOME", raising=False)
|
||||
path = _local_agents_path()
|
||||
assert path == Path.home() / ".config" / "ratatoskr" / "local_agents.json"
|
||||
|
||||
|
||||
class TestLoadEmpty:
|
||||
def test_missing_file_returns_empty(self, local_path: Path) -> None:
|
||||
assert not local_path.exists()
|
||||
assert load_local_agents() == []
|
||||
|
||||
def test_corrupt_json_returns_empty(self, local_path: Path) -> None:
|
||||
local_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
local_path.write_text("not json at all")
|
||||
assert load_local_agents() == []
|
||||
|
||||
def test_wrong_schema_version_returns_empty(self, local_path: Path) -> None:
|
||||
local_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
local_path.write_text(json.dumps({"version": 999, "agents": []}))
|
||||
assert load_local_agents() == []
|
||||
|
||||
def test_missing_version_key_returns_empty(self, local_path: Path) -> None:
|
||||
local_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
local_path.write_text(json.dumps({"agents": []}))
|
||||
assert load_local_agents() == []
|
||||
|
||||
def test_malformed_row_skipped(self, local_path: Path) -> None:
|
||||
local_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
local_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"version": 1,
|
||||
"agents": [
|
||||
{"agent_id": "incomplete"}, # missing required fields
|
||||
{
|
||||
"agent_id": "ratatoskr:good",
|
||||
"agent_name": "good",
|
||||
"model": "m",
|
||||
"description": "d",
|
||||
"defined_at": "t",
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
)
|
||||
entries = load_local_agents()
|
||||
assert len(entries) == 1
|
||||
assert entries[0].agent_id == "ratatoskr:good"
|
||||
|
||||
|
||||
class TestAdd:
|
||||
def test_add_one(self, local_path: Path) -> None:
|
||||
add_local_agent(_entry())
|
||||
entries = load_local_agents()
|
||||
assert len(entries) == 1
|
||||
assert entries[0].agent_id == "ratatoskr:wizard"
|
||||
|
||||
def test_add_two_different(self, local_path: Path) -> None:
|
||||
add_local_agent(_entry(agent_id="ratatoskr:a", agent_name="a"))
|
||||
add_local_agent(_entry(agent_id="ratatoskr:b", agent_name="b"))
|
||||
ids = {e.agent_id for e in load_local_agents()}
|
||||
assert ids == {"ratatoskr:a", "ratatoskr:b"}
|
||||
|
||||
def test_add_replaces_same_id(self, local_path: Path) -> None:
|
||||
add_local_agent(_entry(model="old-model"))
|
||||
add_local_agent(_entry(model="new-model"))
|
||||
entries = load_local_agents()
|
||||
assert len(entries) == 1
|
||||
assert entries[0].model == "new-model"
|
||||
|
||||
def test_creates_parent_dirs(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
nested = tmp_path / "deep" / "nested" / "path" / "agents.json"
|
||||
monkeypatch.setenv("RATATOSKR_LOCAL_AGENTS", str(nested))
|
||||
add_local_agent(_entry())
|
||||
assert nested.exists()
|
||||
|
||||
|
||||
class TestUpdate:
|
||||
def test_update_changes_existing(self, local_path: Path) -> None:
|
||||
add_local_agent(_entry(model="v1"))
|
||||
update_local_agent(_entry(model="v2"))
|
||||
entries = load_local_agents()
|
||||
assert len(entries) == 1
|
||||
assert entries[0].model == "v2"
|
||||
|
||||
|
||||
class TestRemove:
|
||||
def test_remove_existing(self, local_path: Path) -> None:
|
||||
add_local_agent(_entry())
|
||||
remove_local_agent("ratatoskr:wizard")
|
||||
assert load_local_agents() == []
|
||||
|
||||
def test_remove_missing_is_noop(self, local_path: Path) -> None:
|
||||
add_local_agent(_entry())
|
||||
remove_local_agent("ratatoskr:doesnotexist")
|
||||
assert len(load_local_agents()) == 1
|
||||
|
||||
|
||||
class TestMakeDescription:
|
||||
def test_first_nonempty_line(self) -> None:
|
||||
prompt = "\n\n# IDENTITY\nYou are a test agent..."
|
||||
desc = make_description(prompt)
|
||||
assert desc.startswith("(tier 3) IDENTITY")
|
||||
|
||||
def test_strips_heading_markers(self) -> None:
|
||||
prompt = "# A nice heading\nMore prompt..."
|
||||
desc = make_description(prompt)
|
||||
assert "(tier 3) A nice heading" == desc
|
||||
|
||||
def test_truncates_long(self) -> None:
|
||||
prompt = "x" * 200
|
||||
desc = make_description(prompt)
|
||||
# 80 char cap including the prefix
|
||||
assert len(desc) == 81 # 80 + ellipsis char
|
||||
assert desc.endswith("…")
|
||||
|
||||
def test_empty_prompt_fallback(self) -> None:
|
||||
desc = make_description("")
|
||||
assert desc == "(tier 3) custom system prompt"
|
||||
|
||||
def test_whitespace_only_fallback(self) -> None:
|
||||
desc = make_description(" \n\n ")
|
||||
assert desc == "(tier 3) custom system prompt"
|
||||
@@ -711,6 +711,47 @@ def _sse_raw_chunk(sse_id: str, raw_data: str) -> bytes:
|
||||
return f"id: {sse_id}\ndata: {raw_data}\n\n".encode()
|
||||
|
||||
|
||||
def _sse_no_id_chunk(data: str) -> bytes:
|
||||
"""SSE frame with NO id line + arbitrary data (v0.8.1: keepalive shape)."""
|
||||
return f"data: {data}\n\n".encode()
|
||||
|
||||
|
||||
class TestEmptyIdSkipped:
|
||||
@respx.mock
|
||||
async def test_empty_id_on_first_event_skipped(self) -> None:
|
||||
"""empty_id_on_first_event_skipped [v0.8.1]: stream starts with an
|
||||
event carrying NO `id:` line → httpx_sse exposes sse.id == ''
|
||||
(no prior id to inherit). Pre-v0.8.1: MalformedSseId raw='' crashed
|
||||
the turn. v0.8.1: treat same as empty-data keepalive — skip silently.
|
||||
|
||||
Observed 2026-05-25 on Worldtree's qwen3.6-35-a3b-heretic provider:
|
||||
the first stream frame had no id line, every turn died with
|
||||
`[malformed_sse_id] raw=''`.
|
||||
"""
|
||||
from ratatoskr.sse_client import Done as _Done
|
||||
from ratatoskr.sse_client import Text as _Text
|
||||
|
||||
# First frame: no id line (httpx_sse → sse.id = ""). Skip it.
|
||||
# Subsequent frames have ids; normal processing resumes.
|
||||
stream = (
|
||||
_sse_no_id_chunk('{"type":"keepalive"}') # ← skipped (sse.id == "")
|
||||
+ _sse_chunk("42:1", {"type": "text", "content": "first"})
|
||||
+ _sse_chunk("42:2", _DONE_42_6)
|
||||
)
|
||||
respx.post("https://w.example/sessions/s1/messages").mock(
|
||||
return_value=httpx.Response(
|
||||
200, headers={"content-type": "text/event-stream"}, content=stream
|
||||
)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
events = [e async for e in stream_turn(client, "s1", "hi")]
|
||||
# 2 events — the no-id frame is invisible (no MalformedSseId crash).
|
||||
assert len(events) == 2
|
||||
assert isinstance(events[0], _Text)
|
||||
assert events[0].content == "first"
|
||||
assert isinstance(events[1], _Done)
|
||||
|
||||
|
||||
class TestEmptyDataSkipped:
|
||||
@respx.mock
|
||||
async def test_empty_data_skipped(self) -> None:
|
||||
|
||||
@@ -0,0 +1,491 @@
|
||||
"""Tests for ratatoskr.tier3 per docs/contracts/issues/15.contract.md."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
|
||||
from ratatoskr.sessions import SessionApiFailed
|
||||
from ratatoskr.tier3 import (
|
||||
Tier3AgentInfo,
|
||||
Tier3AgentNotFound,
|
||||
Tier3FieldNotMutable,
|
||||
Tier3LayerDeferred,
|
||||
Tier3QuotaExceeded,
|
||||
Tier3UserIdUnsupported,
|
||||
define_agent,
|
||||
delete_agent,
|
||||
main,
|
||||
patch_agent,
|
||||
)
|
||||
|
||||
_FULL_AGENT_RESP = {
|
||||
"agent_id": "ratatoskr:wizard",
|
||||
"user_id": "ratatoskr",
|
||||
"agent_name": "wizard",
|
||||
"system_prompt": "You are a wizard.",
|
||||
"model": "qwen3.6-35-a3b",
|
||||
"created_at": "2026-05-25T03:20:09.703601+00:00",
|
||||
"updated_at": "2026-05-25T03:20:09.703601+00:00",
|
||||
}
|
||||
|
||||
|
||||
class TestDefineAgent:
|
||||
@respx.mock
|
||||
async def test_happy_define(self) -> None:
|
||||
"""happy_define [happy,tracer]: 201 → fully populated Tier3AgentInfo."""
|
||||
respx.post("https://w.example/agents/define").mock(
|
||||
return_value=httpx.Response(201, json=_FULL_AGENT_RESP)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
info = await define_agent(
|
||||
client,
|
||||
agent_name="wizard",
|
||||
system_prompt="You are a wizard.",
|
||||
model="qwen3.6-35-a3b",
|
||||
)
|
||||
assert isinstance(info, Tier3AgentInfo)
|
||||
assert info.agent_id == "ratatoskr:wizard"
|
||||
assert info.user_id == "ratatoskr"
|
||||
assert info.agent_name == "wizard"
|
||||
assert info.model == "qwen3.6-35-a3b"
|
||||
|
||||
@respx.mock
|
||||
async def test_request_body_shape(self) -> None:
|
||||
"""request_body_shape [trace]: outbound JSON is exactly the three keys."""
|
||||
import json as _json
|
||||
|
||||
route = respx.post("https://w.example/agents/define").mock(
|
||||
return_value=httpx.Response(201, json=_FULL_AGENT_RESP)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
await define_agent(
|
||||
client,
|
||||
agent_name="wizard",
|
||||
system_prompt="You are a wizard.",
|
||||
model="qwen3.6-35-a3b",
|
||||
)
|
||||
body = _json.loads(route.calls[0].request.content)
|
||||
# INV-001: exactly these three keys — no layer fields, no metadata.
|
||||
assert body == {
|
||||
"agent_name": "wizard",
|
||||
"system_prompt": "You are a wizard.",
|
||||
"model": "qwen3.6-35-a3b",
|
||||
}
|
||||
|
||||
@respx.mock
|
||||
async def test_quota_exceeded(self) -> None:
|
||||
"""quota_exceeded [error]: 429 + Retry-After → Tier3QuotaExceeded."""
|
||||
respx.post("https://w.example/agents/define").mock(
|
||||
return_value=httpx.Response(
|
||||
429,
|
||||
headers={"Retry-After": "0"},
|
||||
json={"detail": {"error_code": "agent_quota_exceeded"}},
|
||||
)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(Tier3QuotaExceeded) as exc:
|
||||
await define_agent(
|
||||
client,
|
||||
agent_name="overflow",
|
||||
system_prompt="x",
|
||||
model="m",
|
||||
)
|
||||
assert exc.value.retry_after == 0
|
||||
|
||||
@respx.mock
|
||||
async def test_user_id_unsupported(self) -> None:
|
||||
"""user_id_unsupported [error]: 403 + error_code → Tier3UserIdUnsupported."""
|
||||
respx.post("https://w.example/agents/define").mock(
|
||||
return_value=httpx.Response(
|
||||
403, json={"detail": {"error_code": "tier3_user_id_unsupported"}}
|
||||
)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(Tier3UserIdUnsupported):
|
||||
await define_agent(
|
||||
client, agent_name="wizard", system_prompt="x", model="m"
|
||||
)
|
||||
|
||||
@respx.mock
|
||||
async def test_layer_deferred(self) -> None:
|
||||
"""layer_deferred [error]: 422 + layer_deferred → Tier3LayerDeferred(field)."""
|
||||
respx.post("https://w.example/agents/define").mock(
|
||||
return_value=httpx.Response(
|
||||
422,
|
||||
json={"detail": {"error_code": "layer_deferred", "field": "persona"}},
|
||||
)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(Tier3LayerDeferred) as exc:
|
||||
await define_agent(
|
||||
client, agent_name="wizard", system_prompt="x", model="m"
|
||||
)
|
||||
assert exc.value.field == "persona"
|
||||
|
||||
@respx.mock
|
||||
async def test_bad_slug_assert(self) -> None:
|
||||
"""bad_slug_assert [adversarial]: agent_name with uppercase → AssertionError, no HTTP."""
|
||||
route = respx.post("https://w.example/agents/define").mock(
|
||||
return_value=httpx.Response(201, json=_FULL_AGENT_RESP)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(AssertionError):
|
||||
await define_agent(
|
||||
client, agent_name="Wizard", system_prompt="x", model="m"
|
||||
)
|
||||
assert route.call_count == 0
|
||||
|
||||
@respx.mock
|
||||
async def test_short_slug_assert(self) -> None:
|
||||
"""short_slug_assert [adversarial]: agent_name len < 3 → AssertionError."""
|
||||
route = respx.post("https://w.example/agents/define").mock(
|
||||
return_value=httpx.Response(201, json=_FULL_AGENT_RESP)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(AssertionError):
|
||||
await define_agent(
|
||||
client, agent_name="ab", system_prompt="x", model="m"
|
||||
)
|
||||
assert route.call_count == 0
|
||||
|
||||
@respx.mock
|
||||
async def test_empty_prompt_assert(self) -> None:
|
||||
"""empty_prompt_assert [adversarial]: empty system_prompt → AssertionError."""
|
||||
route = respx.post("https://w.example/agents/define").mock(
|
||||
return_value=httpx.Response(201, json=_FULL_AGENT_RESP)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(AssertionError):
|
||||
await define_agent(
|
||||
client, agent_name="wizard", system_prompt="", model="m"
|
||||
)
|
||||
assert route.call_count == 0
|
||||
|
||||
@respx.mock
|
||||
async def test_other_5xx(self) -> None:
|
||||
"""other_5xx [error]: 503 → SessionApiFailed(status=503)."""
|
||||
respx.post("https://w.example/agents/define").mock(
|
||||
return_value=httpx.Response(503, content=b"upstream out")
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(SessionApiFailed) as exc:
|
||||
await define_agent(
|
||||
client, agent_name="wizard", system_prompt="x", model="m"
|
||||
)
|
||||
assert exc.value.status == 503
|
||||
|
||||
|
||||
class TestPatchAgent:
|
||||
@respx.mock
|
||||
async def test_happy_patch_both_fields(self) -> None:
|
||||
"""happy_patch_both_fields: both fields set → request body has both."""
|
||||
import json as _json
|
||||
|
||||
updated = {
|
||||
**_FULL_AGENT_RESP,
|
||||
"system_prompt": "new prompt",
|
||||
"model": "different-model",
|
||||
}
|
||||
route = respx.patch("https://w.example/agents/ratatoskr:wizard").mock(
|
||||
return_value=httpx.Response(200, json=updated)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
info = await patch_agent(
|
||||
client,
|
||||
"ratatoskr:wizard",
|
||||
system_prompt="new prompt",
|
||||
model="different-model",
|
||||
)
|
||||
body = _json.loads(route.calls[0].request.content)
|
||||
assert body == {"system_prompt": "new prompt", "model": "different-model"}
|
||||
assert info.system_prompt == "new prompt"
|
||||
assert info.model == "different-model"
|
||||
|
||||
@respx.mock
|
||||
async def test_happy_patch_single_field(self) -> None:
|
||||
"""happy_patch_single_field: omit model → body has system_prompt only."""
|
||||
import json as _json
|
||||
|
||||
updated = {**_FULL_AGENT_RESP, "system_prompt": "only this"}
|
||||
route = respx.patch("https://w.example/agents/ratatoskr:wizard").mock(
|
||||
return_value=httpx.Response(200, json=updated)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
await patch_agent(client, "ratatoskr:wizard", system_prompt="only this")
|
||||
body = _json.loads(route.calls[0].request.content)
|
||||
# INV-002: body omits the None-valued field entirely
|
||||
assert body == {"system_prompt": "only this"}
|
||||
|
||||
@respx.mock
|
||||
async def test_field_not_mutable(self) -> None:
|
||||
"""field_not_mutable [error]: 422 + error_code → Tier3FieldNotMutable(field)."""
|
||||
respx.patch("https://w.example/agents/ratatoskr:wizard").mock(
|
||||
return_value=httpx.Response(
|
||||
422,
|
||||
json={
|
||||
"detail": {"error_code": "field_not_mutable", "field": "agent_name"}
|
||||
},
|
||||
)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(Tier3FieldNotMutable) as exc:
|
||||
await patch_agent(
|
||||
client, "ratatoskr:wizard", system_prompt="x"
|
||||
)
|
||||
assert exc.value.field == "agent_name"
|
||||
|
||||
@respx.mock
|
||||
async def test_404(self) -> None:
|
||||
"""404 [error]: PATCH on non-existent agent → Tier3AgentNotFound."""
|
||||
respx.patch("https://w.example/agents/ratatoskr:ghost").mock(
|
||||
return_value=httpx.Response(404, content=b"")
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(Tier3AgentNotFound) as exc:
|
||||
await patch_agent(
|
||||
client, "ratatoskr:ghost", system_prompt="x"
|
||||
)
|
||||
assert exc.value.agent_id == "ratatoskr:ghost"
|
||||
|
||||
@respx.mock
|
||||
async def test_no_fields_assert(self) -> None:
|
||||
"""no_fields_assert [adversarial]: both None → AssertionError, no HTTP."""
|
||||
route = respx.patch("https://w.example/agents/ratatoskr:wizard").mock(
|
||||
return_value=httpx.Response(200, json=_FULL_AGENT_RESP)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(AssertionError):
|
||||
await patch_agent(client, "ratatoskr:wizard")
|
||||
assert route.call_count == 0
|
||||
|
||||
@respx.mock
|
||||
async def test_non_tier3_id_assert(self) -> None:
|
||||
"""non_tier3_id_assert [adversarial]: agent_id without `:` → AssertionError."""
|
||||
route = respx.patch("https://w.example/agents/mimir").mock(
|
||||
return_value=httpx.Response(200, json=_FULL_AGENT_RESP)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(AssertionError):
|
||||
await patch_agent(client, "mimir", system_prompt="x")
|
||||
assert route.call_count == 0
|
||||
|
||||
|
||||
class TestDeleteAgent:
|
||||
@respx.mock
|
||||
async def test_happy_delete(self) -> None:
|
||||
"""happy_delete [happy,tracer]: 204 → returns None."""
|
||||
respx.delete("https://w.example/agents/ratatoskr:wizard").mock(
|
||||
return_value=httpx.Response(204)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
result = await delete_agent(client, "ratatoskr:wizard")
|
||||
assert result is None
|
||||
|
||||
@respx.mock
|
||||
async def test_404(self) -> None:
|
||||
"""404 [error]: DELETE on non-existent agent → Tier3AgentNotFound."""
|
||||
respx.delete("https://w.example/agents/ratatoskr:ghost").mock(
|
||||
return_value=httpx.Response(404)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(Tier3AgentNotFound) as exc:
|
||||
await delete_agent(client, "ratatoskr:ghost")
|
||||
assert exc.value.agent_id == "ratatoskr:ghost"
|
||||
|
||||
@respx.mock
|
||||
async def test_non_tier3_id_assert(self) -> None:
|
||||
"""non_tier3_id_assert [adversarial]: agent_id without `:` → AssertionError."""
|
||||
route = respx.delete("https://w.example/agents/mimir").mock(
|
||||
return_value=httpx.Response(204)
|
||||
)
|
||||
async with httpx.AsyncClient(base_url="https://w.example") as client:
|
||||
with pytest.raises(AssertionError):
|
||||
await delete_agent(client, "mimir")
|
||||
assert route.call_count == 0
|
||||
|
||||
@respx.mock
|
||||
async def test_other_5xx(self) -> None:
|
||||
"""other_5xx [error]: 500 → SessionApiFailed."""
|
||||
respx.delete("https://w.example/agents/ratatoskr:wizard").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 exc:
|
||||
await delete_agent(client, "ratatoskr:wizard")
|
||||
assert exc.value.status == 500
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _isolated_local_agents(
|
||||
tmp_path: "Path", monkeypatch: pytest.MonkeyPatch
|
||||
) -> "Path":
|
||||
"""Isolate the v0.8.0 local-tier-3 index from the operator's real file."""
|
||||
path = tmp_path / "local_agents.json"
|
||||
monkeypatch.setenv("RATATOSKR_LOCAL_AGENTS", str(path))
|
||||
return path
|
||||
|
||||
|
||||
class TestCli:
|
||||
@respx.mock
|
||||
def test_cli_define_happy(
|
||||
self,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
_isolated_local_agents: "Path",
|
||||
) -> None:
|
||||
"""cli_define_happy [happy]: argv → 201 mock → stdout confirmation;
|
||||
local index updated with the new entry (v0.8.0 hook).
|
||||
"""
|
||||
from ratatoskr.local_agents import load_local_agents
|
||||
|
||||
monkeypatch.setenv("WORLDTREE_API_URL", "https://w.example")
|
||||
monkeypatch.setenv("WORLDTREE_API_KEY", "k")
|
||||
respx.post("https://w.example/agents/define").mock(
|
||||
return_value=httpx.Response(201, json=_FULL_AGENT_RESP)
|
||||
)
|
||||
rc = main([
|
||||
"define",
|
||||
"--name", "wizard",
|
||||
"--system-prompt", "You are a wizard.",
|
||||
"--model", "qwen3.6-35-a3b",
|
||||
])
|
||||
out = capsys.readouterr()
|
||||
assert rc == 0
|
||||
assert out.out.strip() == "defined ratatoskr:wizard (qwen3.6-35-a3b)"
|
||||
# v0.8.0: local index now has the new entry.
|
||||
entries = load_local_agents()
|
||||
assert len(entries) == 1
|
||||
assert entries[0].agent_id == "ratatoskr:wizard"
|
||||
assert entries[0].model == "qwen3.6-35-a3b"
|
||||
|
||||
@respx.mock
|
||||
def test_cli_patch_happy(
|
||||
self,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
_isolated_local_agents: "Path",
|
||||
) -> None:
|
||||
"""cli_patch_happy [happy]: argv → 200 mock → stdout confirmation;
|
||||
local index refreshed with the post-patch state.
|
||||
"""
|
||||
from ratatoskr.local_agents import load_local_agents
|
||||
|
||||
monkeypatch.setenv("WORLDTREE_API_URL", "https://w.example")
|
||||
monkeypatch.setenv("WORLDTREE_API_KEY", "k")
|
||||
respx.patch("https://w.example/agents/ratatoskr:wizard").mock(
|
||||
return_value=httpx.Response(200, json=_FULL_AGENT_RESP)
|
||||
)
|
||||
rc = main(["patch", "ratatoskr:wizard", "--system-prompt", "new"])
|
||||
out = capsys.readouterr()
|
||||
assert rc == 0
|
||||
assert out.out.strip() == "patched ratatoskr:wizard"
|
||||
entries = load_local_agents()
|
||||
assert len(entries) == 1
|
||||
assert entries[0].agent_id == "ratatoskr:wizard"
|
||||
|
||||
@respx.mock
|
||||
def test_cli_delete_happy(
|
||||
self,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
_isolated_local_agents: "Path",
|
||||
) -> None:
|
||||
"""cli_delete_happy [happy]: argv → 204 mock → stdout confirmation;
|
||||
local index entry removed (v0.8.0 hook).
|
||||
"""
|
||||
from ratatoskr.local_agents import (
|
||||
LocalAgentEntry,
|
||||
add_local_agent,
|
||||
load_local_agents,
|
||||
)
|
||||
|
||||
# Pre-populate so we can verify removal.
|
||||
add_local_agent(LocalAgentEntry(
|
||||
agent_id="ratatoskr:wizard",
|
||||
agent_name="wizard",
|
||||
model="m",
|
||||
description="d",
|
||||
defined_at="t",
|
||||
))
|
||||
monkeypatch.setenv("WORLDTREE_API_URL", "https://w.example")
|
||||
monkeypatch.setenv("WORLDTREE_API_KEY", "k")
|
||||
respx.delete("https://w.example/agents/ratatoskr:wizard").mock(
|
||||
return_value=httpx.Response(204)
|
||||
)
|
||||
rc = main(["delete", "ratatoskr:wizard"])
|
||||
out = capsys.readouterr()
|
||||
assert rc == 0
|
||||
assert out.out.strip() == "deleted ratatoskr:wizard"
|
||||
assert load_local_agents() == []
|
||||
|
||||
def test_cli_missing_auth(
|
||||
self, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""cli_missing_auth [error]: no api-key → stderr [auth_error] + exit 11."""
|
||||
monkeypatch.delenv("WORLDTREE_API_KEY", raising=False)
|
||||
rc = main([
|
||||
"define",
|
||||
"--name", "wizard",
|
||||
"--system-prompt", "x",
|
||||
"--model", "m",
|
||||
])
|
||||
err = capsys.readouterr().err
|
||||
assert rc == 11
|
||||
assert "[auth_error]" in err
|
||||
|
||||
@respx.mock
|
||||
def test_cli_api_failed(
|
||||
self, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""cli_api_failed [error]: 500 → stderr [api_failed] + exit 20."""
|
||||
monkeypatch.setenv("WORLDTREE_API_URL", "https://w.example")
|
||||
monkeypatch.setenv("WORLDTREE_API_KEY", "k")
|
||||
respx.post("https://w.example/agents/define").mock(
|
||||
return_value=httpx.Response(500, content=b"upstream out")
|
||||
)
|
||||
rc = main([
|
||||
"define",
|
||||
"--name", "wizard",
|
||||
"--system-prompt", "x",
|
||||
"--model", "m",
|
||||
])
|
||||
err = capsys.readouterr().err
|
||||
assert rc == 20
|
||||
assert "[api_failed]" in err
|
||||
|
||||
@respx.mock
|
||||
def test_cli_quota_exceeded(
|
||||
self, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""cli_quota_exceeded [error]: 429 → stderr [quota_exceeded] + exit 20."""
|
||||
monkeypatch.setenv("WORLDTREE_API_URL", "https://w.example")
|
||||
monkeypatch.setenv("WORLDTREE_API_KEY", "k")
|
||||
respx.post("https://w.example/agents/define").mock(
|
||||
return_value=httpx.Response(
|
||||
429,
|
||||
headers={"Retry-After": "0"},
|
||||
json={"detail": {"error_code": "agent_quota_exceeded"}},
|
||||
)
|
||||
)
|
||||
rc = main([
|
||||
"define",
|
||||
"--name", "wizard",
|
||||
"--system-prompt", "x",
|
||||
"--model", "m",
|
||||
])
|
||||
err = capsys.readouterr().err
|
||||
assert rc == 20
|
||||
assert "[quota_exceeded]" in err
|
||||
|
||||
def test_cli_patch_no_fields(
|
||||
self, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""cli_patch_no_fields [error]: patch with no flags → [usage_error] + exit 10."""
|
||||
monkeypatch.setenv("WORLDTREE_API_URL", "https://w.example")
|
||||
monkeypatch.setenv("WORLDTREE_API_KEY", "k")
|
||||
rc = main(["patch", "ratatoskr:wizard"])
|
||||
err = capsys.readouterr().err
|
||||
assert rc == 10
|
||||
assert "[usage_error]" in err
|
||||
+510
-376
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user