Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 489cfee1f0 | |||
| 11ef6830ab | |||
| 9fade55901 | |||
| 9918c10acf | |||
| c086ae2b32 | |||
| d3569904bc | |||
| 82437bd4b9 | |||
| ac690c11d5 |
@@ -161,9 +161,9 @@ New `Static(id="pane-name")` widget alongside the existing `identity` + `hint` w
|
||||
- **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** *(new v0.6.0)*: Closed thinking runs route to `thinking_log`, NOT `debug_log`. Each closed run writes three entries: `Rule(title=f"turn N · thinking #K start")`, `Markdown(content)`, `Rule(title=f"turn N · thinking #K end")` — the model's chain-of-thought is presented as rendered Markdown (model reasoning often has lists / code / structure) wrapped in operator-visible start/end markers. `thinking_run_index` increments per-run within a turn.
|
||||
- **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.1)*: `thinking-current` Static lives INSIDE the Thinking TabPane (docked bottom, below `thinking-log`) — co-located with closed thinking runs so the operator sees streaming + history in one pane. Pre-v0.6.1 it sat above the TabbedContent (right-column header) which created a top/bottom discontinuity; the co-located shape resolves that. Trade-off: live thinking is now visible only when the Thinking tab is active (Ctrl+3). Prefix `thinking… ` self-identifies the widget contents.
|
||||
- **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
|
||||
```
|
||||
+11
-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.6.2 neutral-dark palette):_
|
||||
_As of 2026-05-25 (post-v0.8.2 drop double-print; v0.9.0 live-md next):_
|
||||
|
||||
**Status: v0.6.2 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,15 @@ 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.6.2 style(tui): neutralize Australis dark palette — bg no longer blue-tinted
|
||||
- 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)
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "ratatoskr"
|
||||
version = "0.6.2"
|
||||
version = "0.8.2"
|
||||
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())
|
||||
+171
-159
@@ -10,7 +10,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass
|
||||
from typing import ClassVar, Literal
|
||||
|
||||
import httpx
|
||||
@@ -61,31 +61,33 @@ from ratatoskr.sse_client import (
|
||||
stream_turn,
|
||||
)
|
||||
|
||||
# ---- Australis-derived theme (https://github.com/lkraven/australis) ----------
|
||||
# ---- Australis theme (https://github.com/lkraven/australis) ------------------
|
||||
#
|
||||
# v0.6.2 deliberate deviation from spec: the Australis Dark theme's design
|
||||
# principle "all colors are cooler than neutral" bakes a blue cast into
|
||||
# every dark surface (Ice black #222531 = RGB 34/37/49 — blue dominant).
|
||||
# Operators reported the whole-app chrome reading as blue. Ratatoskr keeps
|
||||
# the Australis ACCENTS (Aurora blue/cyan/green, Dawn red/yellow) — those
|
||||
# are the brand signature — but **neutralizes the Ice/Sea dark palette to
|
||||
# true grays** so the chrome reads as neutral dark, not blue-leaning.
|
||||
# The Australis Dark color theme, inspired by the Southern Lights. 16 cool-tone
|
||||
# terminal colors with medium contrast. Preference order: blue > cyan > green
|
||||
# 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 (KEEP).
|
||||
# secondary = Aurora cyan (#00b1a8) — secondary highlights (KEEP).
|
||||
# accent = Aurora bright cyan (#42dcd1) — bright accents (KEEP).
|
||||
# success = Aurora green (#16B866) — [done] label (KEEP).
|
||||
# warning = Dawn yellow (#e1c631) — [cancelled] label (KEEP).
|
||||
# error = Dawn red (#ff491a) — [error] label (KEEP).
|
||||
# foreground = neutral light gray (#bdbdbd) — default text (was Ice white).
|
||||
# background = neutral near-black (#1a1a1a) — App bg (was Ice black).
|
||||
# surface = neutral dark gray (#2a2a2a) — chrome (was Sea bright-black).
|
||||
# panel = neutral mid gray (#3a3a3a) — borders (was Sea dark-30).
|
||||
#
|
||||
# Dark contrast steps preserve Australis's LAB lightness levels but drop the
|
||||
# blue/green tint. Variables renamed mentally but kept under $au-* slugs so
|
||||
# downstream CSS doesn't change.
|
||||
# primary = Aurora blue (#6388D8) — focus rings, active selection.
|
||||
# secondary = Aurora cyan (#00b1a8) — secondary highlights.
|
||||
# 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 = 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(
|
||||
name="australis",
|
||||
@@ -95,22 +97,20 @@ AUSTRALIS_THEME = Theme(
|
||||
success="#16B866",
|
||||
warning="#e1c631",
|
||||
error="#ff491a",
|
||||
foreground="#bdbdbd",
|
||||
background="#1a1a1a",
|
||||
surface="#2a2a2a",
|
||||
panel="#3a3a3a",
|
||||
foreground="#a9bcc3",
|
||||
background="#000000",
|
||||
surface="#373b46",
|
||||
panel="#414751",
|
||||
dark=True,
|
||||
variables={
|
||||
# Neutral dark contrast palette (LAB-matched to Australis Sea spec
|
||||
# but with R=G=B, no cool cast). $au-dark-N accessible via TCSS.
|
||||
"au-dark-30": "#3a3a3a",
|
||||
"au-dark-40": "#4f4f4f",
|
||||
"au-dark-50": "#6b6b6b",
|
||||
"au-dark-60": "#878787",
|
||||
"au-bright-70": "#9e9e9e",
|
||||
"au-bright-80": "#bdbdbd",
|
||||
"au-bright-white": "#e0e0e0",
|
||||
# Accents — KEEP Australis Aurora brights for brand signature.
|
||||
# Sea contrast palette — usable via $au-dark-50 etc. in TCSS.
|
||||
"au-dark-30": "#414751",
|
||||
"au-dark-40": "#565f69",
|
||||
"au-dark-50": "#6e7882",
|
||||
"au-dark-60": "#86929d",
|
||||
"au-bright-70": "#9daeb6",
|
||||
"au-bright-80": "#b3cbcf",
|
||||
"au-bright-white": "#cce7ec",
|
||||
"au-bright-blue": "#a4c4ff",
|
||||
"au-bright-cyan": "#42dcd1",
|
||||
"au-bright-green": "#51e08a",
|
||||
@@ -124,9 +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 = "#878787" # neutral dark-60 — demoted telemetry
|
||||
_AU_DEMOTED_FAINT = "#6b6b6b" # neutral dark-50 — empty-state placeholders
|
||||
_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 -------------------------
|
||||
@@ -185,21 +185,29 @@ 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
|
||||
# v0.6.0: per-turn streaming text buffer. Text deltas accumulate here
|
||||
# and update `current_text` Static in place — no per-token RichLog spam.
|
||||
text_buffer: list[str] = field(default_factory=list)
|
||||
# v0.6.0: thinking-run counter for turn-scoped start/end markers.
|
||||
# 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.8.1: same pattern for Text deltas. Pre-v0.8.1 the Text deltas
|
||||
# streamed into a dedicated #current-text Static below the transcript;
|
||||
# that Static (docked-bottom, height: auto) grew during streaming and
|
||||
# visually OVERLAPPED the transcript above (Textual didn't dynamically
|
||||
# resize the 1fr transcript while the dock-bottom child expanded).
|
||||
# The Static is gone in v0.8.1 — Text deltas coalesce on `\n` and write
|
||||
# directly to `log` (transcript), the same shape thinking uses.
|
||||
text_chunk_buffer: str = ""
|
||||
|
||||
def render(
|
||||
self,
|
||||
event: Event,
|
||||
*,
|
||||
log: RichLog,
|
||||
thinking_widget: Static,
|
||||
current_text: Static,
|
||||
tools_log: RichLog,
|
||||
debug_log: RichLog,
|
||||
thinking_log: RichLog,
|
||||
@@ -207,16 +215,14 @@ class TuiPresenterState:
|
||||
) -> None:
|
||||
"""Render one Worldtree SSE event with the TUI hierarchy + coalescing.
|
||||
|
||||
v0.6.0 routing:
|
||||
- `log` (transcript) = content only: user-prompt echo (written
|
||||
outside the presenter), terminal labels, post-Done Markdown body.
|
||||
- `current_text` (Static below transcript) = live-streaming Text
|
||||
deltas accumulated into one growing line; cleared on terminal.
|
||||
v0.8.1 routing:
|
||||
- `log` (transcript) = chat content: user-prompt echo (written
|
||||
outside the presenter), coalesced Text deltas, terminal labels,
|
||||
optional post-Done Markdown body.
|
||||
- `tools_log` = ToolStart + ToolResult.
|
||||
- `debug_log` = WorkerPhase + TextBoundary.
|
||||
- `thinking_log` = closed thinking runs (Markdown + start/end
|
||||
Rule markers); `thinking_widget` continues to receive live
|
||||
per-delta updates.
|
||||
- `thinking_log` = streaming Thinking deltas inline (coalesced on
|
||||
`\n`). Rule(start)/Rule(end) wrap each run.
|
||||
|
||||
Exceptions caught at the presenter boundary (INV-009 fallback).
|
||||
"""
|
||||
@@ -230,71 +236,71 @@ 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)
|
||||
# v0.5.1 polish: prefix the live widget with "thinking… " so
|
||||
# operators recognize what the streaming content is (otherwise
|
||||
# the static-content under Header reads like uncontextualized
|
||||
# spillover). Truncate display to last 200 chars + ellipsis.
|
||||
tail = ("…" + acc[-200:]) if len(acc) > 200 else acc
|
||||
thinking_widget.update(f"thinking… {tail}")
|
||||
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.6.0: closed thinking runs route to thinking_log (Thinking
|
||||
# pane) wrapped in `── turn N · thinking start/end ──` Rule
|
||||
# markers, with the content itself rendered as Markdown (model
|
||||
# reasoning often has lists, code, structure).
|
||||
# Non-thinking event: close any open thinking run with Rule(end).
|
||||
if self.thinking_open:
|
||||
full_thinking = "".join(self.thinking_buffer)
|
||||
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 "?"
|
||||
)
|
||||
self.thinking_run_index += 1
|
||||
from rich.markdown import Markdown
|
||||
from rich.rule import Rule
|
||||
thinking_log.write(Rule(
|
||||
title=f"turn {turn_id} · thinking #{self.thinking_run_index} start",
|
||||
style=_AU_DEMOTED,
|
||||
))
|
||||
thinking_log.write(Markdown(full_thinking))
|
||||
thinking_log.write(Rule(
|
||||
title=f"turn {turn_id} · thinking #{self.thinking_run_index} end",
|
||||
style=_AU_DEMOTED,
|
||||
))
|
||||
self.thinking_buffer.clear()
|
||||
self.thinking_open = False
|
||||
thinking_widget.update("")
|
||||
thinking_widget.display = False
|
||||
# Now render the non-thinking event itself.
|
||||
if isinstance(event, Text):
|
||||
# v0.6.0: streaming text accumulates into current_text Static
|
||||
# — one growing live line, NOT per-delta RichLog entries.
|
||||
self.text_buffer.append(event.content)
|
||||
current_text.update("".join(self.text_buffer))
|
||||
# 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.
|
||||
self.text_chunk_buffer += event.content
|
||||
while "\n" in self.text_chunk_buffer:
|
||||
line, _, rest = self.text_chunk_buffer.partition("\n")
|
||||
if line:
|
||||
log.write(line)
|
||||
self.text_chunk_buffer = rest
|
||||
return
|
||||
if isinstance(event, (Done, Error, Cancelled)):
|
||||
# Terminal event: clear the streaming Static first so the
|
||||
# live-preview band collapses. Then write the colored label
|
||||
# + (non-raw) Markdown body / (raw) accumulated plain text
|
||||
# to the transcript.
|
||||
accumulated = "".join(self.text_buffer)
|
||||
self.text_buffer.clear()
|
||||
current_text.update("")
|
||||
# Terminal event: flush any remaining text tail before the
|
||||
# label / Markdown body lands.
|
||||
if self.text_chunk_buffer:
|
||||
log.write(self.text_chunk_buffer)
|
||||
self.text_chunk_buffer = ""
|
||||
# Terminal labels tinted per outcome (Aurora green / Dawn red
|
||||
# / Dawn yellow) for at-a-glance scanning.
|
||||
if isinstance(event, Done):
|
||||
@@ -304,17 +310,13 @@ class TuiPresenterState:
|
||||
f"usage {_format_usage(event.usage, arrow='→')}",
|
||||
style=_AU_SUCCESS,
|
||||
))
|
||||
if raw:
|
||||
# Raw mode: emit the accumulated streamed text verbatim
|
||||
# so the operator has a record after the Static clears.
|
||||
if accumulated:
|
||||
log.write(accumulated)
|
||||
else:
|
||||
from rich.markdown import Markdown
|
||||
from rich.rule import Rule
|
||||
|
||||
log.write(Rule(style=_AU_DEMOTED))
|
||||
log.write(Markdown(event.response))
|
||||
# v0.8.2: post-Done Markdown body re-render dropped. Pre-
|
||||
# v0.8.2 the transcript got BOTH the streamed text AND
|
||||
# the Markdown(response) re-render — same content twice,
|
||||
# operator-flagged as "double prints". The streamed text
|
||||
# IS the response now; markdown formatting (bold, lists,
|
||||
# code) renders as plain text. Matches thinking pane's
|
||||
# stream-as-content semantics (no post-close re-render).
|
||||
elif isinstance(event, Error):
|
||||
log.write(RichText(
|
||||
f"[error] turn_id={event.sse_id.turn_id} code={event.error_code} "
|
||||
@@ -327,10 +329,6 @@ class TuiPresenterState:
|
||||
f"partial_message_id={event.partial_message_id}",
|
||||
style=_AU_WARNING,
|
||||
))
|
||||
# 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
|
||||
return
|
||||
if isinstance(event, WorkerPhase):
|
||||
# v0.5.0: telemetry → Debug pane, not transcript.
|
||||
@@ -425,22 +423,34 @@ class AgentPickerApp(App[str | None]):
|
||||
background: $background;
|
||||
}
|
||||
/* Multi-line agent items. Each ListItem is auto-height so the full
|
||||
description wraps below the agent_id/name line — no truncation. */
|
||||
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.1: override Textual's default ListView:focus highlight, which
|
||||
defaults to $primary (Aurora blue) and made the picker unreadable.
|
||||
Both selectors needed — focused state has higher specificity in
|
||||
Textual's defaults. */
|
||||
ListView > ListItem.--highlight,
|
||||
ListView:focus > ListItem.--highlight {
|
||||
background: $au-dark-30;
|
||||
/* 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;
|
||||
}
|
||||
/* Children of highlighted items keep their colors — the dark-30 bg
|
||||
provides enough contrast for bright-blue id + dark-60 desc text. */
|
||||
#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;
|
||||
@@ -552,33 +562,16 @@ class RatatoskrApp(App[int]):
|
||||
#right-column {
|
||||
width: 1fr;
|
||||
}
|
||||
/* v0.6.1: thinking-current Static moved INTO the Thinking pane (below
|
||||
thinking-log) so streaming + closed runs co-locate. Docked bottom of
|
||||
its TabPane so it acts as the live "tail" of the chronological log
|
||||
above. Empty (height:0) when no thinking is active. */
|
||||
#thinking-current {
|
||||
dock: bottom;
|
||||
height: auto;
|
||||
color: $au-dark-60;
|
||||
padding: 0 1;
|
||||
text-style: italic;
|
||||
background: $background;
|
||||
}
|
||||
/* v0.6.5: thinking-current Static removed; thinking now streams
|
||||
directly into thinking-log so the whole pane scrolls naturally. */
|
||||
#transcript {
|
||||
height: 1fr;
|
||||
background: $background;
|
||||
padding: 0 1;
|
||||
}
|
||||
/* v0.6.0: streaming-text Static carries in-flight assistant tokens.
|
||||
Replaces per-token RichLog spam — one growing line that updates in
|
||||
place. Cleared on terminal event; final Markdown body lands in the
|
||||
transcript. */
|
||||
#current-text {
|
||||
dock: bottom;
|
||||
height: auto;
|
||||
background: $background;
|
||||
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;
|
||||
@@ -678,7 +671,6 @@ class RatatoskrApp(App[int]):
|
||||
with Horizontal(id="main-row"):
|
||||
with Vertical(id="left-column"):
|
||||
yield RichLog(id="transcript", wrap=True, markup=False, highlight=False)
|
||||
yield Static("", id="current-text")
|
||||
yield Input(id="prompt", placeholder="Type a message and press Enter")
|
||||
with Vertical(id="right-column"):
|
||||
with TabbedContent(id="side-panes"):
|
||||
@@ -691,14 +683,15 @@ class RatatoskrApp(App[int]):
|
||||
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
|
||||
)
|
||||
# v0.6.1: live thinking lives INSIDE the Thinking
|
||||
# pane (docked bottom) — co-located with the closed
|
||||
# runs in thinking-log above. No more top/bottom
|
||||
# discontinuity across the right column.
|
||||
yield Static("", id="thinking-current")
|
||||
# INV-002 + INV-003: visible identity + hint widgets (Footer-area).
|
||||
# pane-name widget displays current side-pane name.
|
||||
yield Static("", id="identity")
|
||||
@@ -717,8 +710,6 @@ 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
|
||||
@@ -799,8 +790,6 @@ class RatatoskrApp(App[int]):
|
||||
assert self.client is not None
|
||||
assert content
|
||||
log = self.query_one("#transcript", RichLog)
|
||||
thinking_widget = self.query_one("#thinking-current", Static)
|
||||
current_text = self.query_one("#current-text", Static)
|
||||
tools_log = self.query_one("#tools-log", RichLog)
|
||||
debug_log = self.query_one("#debug-log", RichLog)
|
||||
thinking_log = self.query_one("#thinking-log", RichLog)
|
||||
@@ -816,8 +805,6 @@ class RatatoskrApp(App[int]):
|
||||
presenter.render(
|
||||
event,
|
||||
log=log,
|
||||
thinking_widget=thinking_widget,
|
||||
current_text=current_text,
|
||||
tools_log=tools_log,
|
||||
debug_log=debug_log,
|
||||
thinking_log=thinking_log,
|
||||
@@ -931,6 +918,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:
|
||||
@@ -943,6 +938,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
|
||||
|
||||
@@ -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
|
||||
+236
-259
@@ -1,5 +1,6 @@
|
||||
"""Tests for ratatoskr.tui per docs/contracts/issues/4.contract.md."""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import httpx
|
||||
@@ -11,7 +12,6 @@ from ratatoskr.cli import ParsedArgs
|
||||
from ratatoskr.sse_client import (
|
||||
Cancelled,
|
||||
Done,
|
||||
Error,
|
||||
SseId,
|
||||
Text,
|
||||
Thinking,
|
||||
@@ -116,56 +116,64 @@ SID = SseId(42, 5)
|
||||
class TestTuiPresenterState:
|
||||
"""Tests for the new TuiPresenterState — per issue #12 contract."""
|
||||
|
||||
def test_thinking_coalesce_single_widget_update(self) -> None:
|
||||
"""thinking_coalesce_single_widget_update [happy,tracer]:
|
||||
3 Thinking events → thinking_widget.update called 3 times with cumulative content;
|
||||
RichLog has 0 thinking entries (closure hasn't fired yet).
|
||||
def test_thinking_coalesces_until_newline(self) -> None:
|
||||
"""thinking_coalesces_until_newline [happy,tracer, v0.7.1]:
|
||||
Per-token deltas accumulate in the buffer; flush only on `\\n`.
|
||||
Three short token-shaped deltas without `\\n` → thinking_log gets
|
||||
ONLY Rule(start); content stays buffered.
|
||||
"""
|
||||
from rich.rule import Rule
|
||||
|
||||
from ratatoskr.tui import TuiPresenterState
|
||||
|
||||
log = MagicMock()
|
||||
widget = MagicMock()
|
||||
thinking_log = MagicMock()
|
||||
state = TuiPresenterState()
|
||||
state.render(
|
||||
Thinking(sse_id=SID, content="a"),
|
||||
log=log,
|
||||
thinking_widget=widget,
|
||||
tools_log=MagicMock(),
|
||||
debug_log=MagicMock(),
|
||||
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
|
||||
)
|
||||
state.render(
|
||||
Thinking(sse_id=SID, content="b"),
|
||||
log=log,
|
||||
thinking_widget=widget,
|
||||
tools_log=MagicMock(),
|
||||
debug_log=MagicMock(),
|
||||
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
|
||||
)
|
||||
state.render(
|
||||
Thinking(sse_id=SID, content="c"),
|
||||
log=log,
|
||||
thinking_widget=widget,
|
||||
tools_log=MagicMock(),
|
||||
debug_log=MagicMock(),
|
||||
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
|
||||
)
|
||||
# Widget updated 3 times — once per delta — with cumulative content
|
||||
assert widget.update.call_count == 3
|
||||
# Latest call shows the full accumulated content (under 200 chars so no truncation).
|
||||
# v0.5.1 polish: widget text is prefixed with "thinking… " for self-explanation.
|
||||
assert widget.update.call_args_list[-1][0][0] == "thinking… abc"
|
||||
# Widget became visible at first delta
|
||||
assert widget.display is True
|
||||
# No RichLog write yet — closure hasn't fired
|
||||
for chunk in ("Let", " me", " think"):
|
||||
state.render(
|
||||
Thinking(sse_id=SID, content=chunk),
|
||||
log=log,
|
||||
tools_log=MagicMock(),
|
||||
debug_log=MagicMock(),
|
||||
thinking_log=thinking_log,
|
||||
raw=False,
|
||||
)
|
||||
writes = [c[0][0] for c in thinking_log.write.call_args_list]
|
||||
# Only Rule(start) — content stays buffered (no `\n` seen).
|
||||
assert len(writes) == 1
|
||||
assert isinstance(writes[0], Rule)
|
||||
assert state.thinking_chunk_buffer == "Let me think"
|
||||
assert log.write.call_count == 0
|
||||
|
||||
def test_thinking_closes_to_thinking_log(self) -> None:
|
||||
"""thinking_closes_to_thinking_log [happy, v0.6.0]: 2x Thinking + WorkerPhase →
|
||||
thinking_log gets Rule(start) + Markdown + Rule(end); debug_log gets worker_phase;
|
||||
transcript and tools_log untouched. Widget cleared+hidden.
|
||||
def test_thinking_flushes_on_newline(self) -> None:
|
||||
"""thinking_flushes_on_newline [happy, v0.7.1]:
|
||||
Delta carrying `\\n` flushes the accumulated buffer as ONE line.
|
||||
"""
|
||||
from ratatoskr.tui import TuiPresenterState
|
||||
|
||||
thinking_log = MagicMock()
|
||||
state = TuiPresenterState()
|
||||
for chunk in ("Hello", " world", "\n"):
|
||||
state.render(
|
||||
Thinking(sse_id=SID, content=chunk),
|
||||
log=MagicMock(),
|
||||
tools_log=MagicMock(),
|
||||
debug_log=MagicMock(),
|
||||
thinking_log=thinking_log,
|
||||
raw=False,
|
||||
)
|
||||
writes = [c[0][0] for c in thinking_log.write.call_args_list]
|
||||
# Rule(start) + "Hello world" (one coalesced line) = 2 writes
|
||||
assert len(writes) == 2
|
||||
assert writes[1] == "Hello world"
|
||||
assert state.thinking_chunk_buffer == ""
|
||||
|
||||
def test_thinking_closes_to_thinking_log(self) -> None:
|
||||
"""thinking_closes_to_thinking_log [happy, v0.7.1]: 2x Thinking + WorkerPhase →
|
||||
v0.7.1 coalesces "a"+"b" into one buffered string; the close flushes
|
||||
"ab" as a single line before Rule(end). Result: Rule(start) + "ab" +
|
||||
Rule(end) = 3 writes. debug_log gets worker_phase; transcript untouched.
|
||||
"""
|
||||
from rich.markdown import Markdown
|
||||
from rich.rule import Rule
|
||||
|
||||
from ratatoskr.tui import TuiPresenterState
|
||||
@@ -173,111 +181,51 @@ class TestTuiPresenterState:
|
||||
log = MagicMock()
|
||||
debug_log = MagicMock()
|
||||
thinking_log = MagicMock()
|
||||
widget = MagicMock()
|
||||
state = TuiPresenterState()
|
||||
for content in ("a", "b"):
|
||||
state.render(
|
||||
Thinking(sse_id=SID, content=content),
|
||||
log=log,
|
||||
thinking_widget=widget,
|
||||
tools_log=MagicMock(),
|
||||
debug_log=debug_log,
|
||||
current_text=MagicMock(),
|
||||
thinking_log=thinking_log,
|
||||
raw=False,
|
||||
)
|
||||
state.render(
|
||||
WorkerPhase(sse_id=SID, phase="streaming", turn_id=42),
|
||||
log=log,
|
||||
thinking_widget=widget,
|
||||
tools_log=MagicMock(),
|
||||
debug_log=debug_log,
|
||||
current_text=MagicMock(),
|
||||
thinking_log=thinking_log,
|
||||
raw=False,
|
||||
)
|
||||
# v0.6.0: closure writes Rule(start) + Markdown + Rule(end) to thinking_log.
|
||||
thinking_writes = [c[0][0] for c in thinking_log.write.call_args_list]
|
||||
assert any(isinstance(w, Rule) for w in thinking_writes), thinking_writes
|
||||
assert any(isinstance(w, Markdown) for w in thinking_writes), thinking_writes
|
||||
# worker_phase still goes to debug_log; transcript still untouched.
|
||||
assert debug_log.write.called
|
||||
# v0.7.1: 1 Rule(start) + 1 coalesced "ab" tail-flush + 1 Rule(end) = 3 writes
|
||||
assert len(thinking_writes) == 3
|
||||
assert isinstance(thinking_writes[0], Rule)
|
||||
assert thinking_writes[1] == "ab"
|
||||
assert isinstance(thinking_writes[2], Rule)
|
||||
# worker_phase still goes to debug_log; transcript untouched.
|
||||
assert "· worker_phase:" in _text_of(debug_log.write.call_args_list[-1][0][0])
|
||||
assert not log.write.called
|
||||
# Widget cleared + hidden
|
||||
widget.update.assert_called_with("")
|
||||
assert widget.display is False
|
||||
|
||||
def test_thinking_widget_truncation(self) -> None:
|
||||
"""thinking_widget_truncation [trace]: buffer 500 chars → widget shows "…" + last 200."""
|
||||
from ratatoskr.tui import TuiPresenterState
|
||||
|
||||
log = MagicMock()
|
||||
widget = MagicMock()
|
||||
state = TuiPresenterState()
|
||||
# Push 500 chars across multiple deltas.
|
||||
long = "x" * 500
|
||||
state.render(
|
||||
Thinking(sse_id=SID, content=long),
|
||||
log=log,
|
||||
thinking_widget=widget,
|
||||
tools_log=MagicMock(),
|
||||
debug_log=MagicMock(),
|
||||
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
|
||||
)
|
||||
last_update = widget.update.call_args_list[-1][0][0]
|
||||
# v0.5.1 polish: widget gets a "thinking… " prefix + ellipsis-truncated tail.
|
||||
assert last_update.startswith("thinking… ")
|
||||
# tail is "…" + last-200 = 201 chars; prefix is 10 chars ("thinking… ")
|
||||
assert len(last_update) == len("thinking… ") + 201
|
||||
assert "…" in last_update
|
||||
|
||||
def test_thinking_widget_visibility_lifecycle(self) -> None:
|
||||
"""thinking_widget_visibility_lifecycle [trace]: hidden at start; visible during thinking;
|
||||
hidden after closing event.
|
||||
"""
|
||||
from ratatoskr.tui import TuiPresenterState
|
||||
|
||||
log = MagicMock()
|
||||
widget = MagicMock()
|
||||
widget.display = False # initial state (composed hidden)
|
||||
state = TuiPresenterState()
|
||||
# First thinking delta → widget visible
|
||||
state.render(
|
||||
Thinking(sse_id=SID, content="x"),
|
||||
log=log,
|
||||
thinking_widget=widget,
|
||||
tools_log=MagicMock(),
|
||||
debug_log=MagicMock(),
|
||||
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
|
||||
)
|
||||
assert widget.display is True
|
||||
# Closure (WorkerPhase) → widget hidden
|
||||
state.render(
|
||||
WorkerPhase(sse_id=SID, phase="streaming", turn_id=42),
|
||||
log=log,
|
||||
thinking_widget=widget,
|
||||
tools_log=MagicMock(),
|
||||
debug_log=MagicMock(),
|
||||
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
|
||||
)
|
||||
assert widget.display is False
|
||||
# v0.6.5: thinking-current Static removed; test_thinking_widget_truncation
|
||||
# and test_thinking_widget_visibility_lifecycle deleted (no longer apply).
|
||||
|
||||
def test_multiple_thinking_runs_each_get_thinking_log_section(self) -> None:
|
||||
"""multiple_thinking_runs_each_get_thinking_log_section [scenario, v0.6.0]:
|
||||
Thinking → Text → Thinking → Done → TWO start/end Rule + Markdown sections
|
||||
in thinking_log. Text goes to current_text Static (buffered). Transcript
|
||||
receives [done] label + Markdown body only.
|
||||
"""multiple_thinking_runs_each_get_section [scenario, v0.8.1]:
|
||||
Thinking → Text → Thinking → Done → TWO start/end Rule pairs in
|
||||
thinking_log (deltas coalesced into tail-flushes per run).
|
||||
Text deltas now stream into the transcript via coalesce-on-newline
|
||||
(no current-text Static); "hi" with no `\\n` stays buffered until
|
||||
Done's tail-flush.
|
||||
"""
|
||||
from rich.markdown import Markdown
|
||||
from rich.rule import Rule
|
||||
|
||||
from ratatoskr.tui import TuiPresenterState
|
||||
|
||||
log = MagicMock()
|
||||
thinking_log = MagicMock()
|
||||
current_text = MagicMock()
|
||||
widget = MagicMock()
|
||||
state = TuiPresenterState()
|
||||
for evt in (
|
||||
Thinking(sse_id=SID, content="first"),
|
||||
@@ -285,55 +233,57 @@ class TestTuiPresenterState:
|
||||
Thinking(sse_id=SID, content="second"),
|
||||
):
|
||||
state.render(
|
||||
evt, log=log, thinking_widget=widget,
|
||||
evt, log=log,
|
||||
tools_log=MagicMock(), debug_log=MagicMock(),
|
||||
current_text=current_text, thinking_log=thinking_log, raw=False,
|
||||
thinking_log=thinking_log, raw=False,
|
||||
)
|
||||
state.render(
|
||||
_make_tui_done(),
|
||||
log=log, thinking_widget=widget,
|
||||
log=log,
|
||||
tools_log=MagicMock(), debug_log=MagicMock(),
|
||||
current_text=current_text, thinking_log=thinking_log, raw=False,
|
||||
thinking_log=thinking_log, raw=False,
|
||||
)
|
||||
# v0.6.0: thinking_log holds (Rule(start) + Markdown + Rule(end)) x2.
|
||||
# thinking_log: 4 Rules (start+end per run) + 2 tail-flush strings.
|
||||
thinking_writes = [c[0][0] for c in thinking_log.write.call_args_list]
|
||||
rules = [w for w in thinking_writes if isinstance(w, Rule)]
|
||||
markdowns = [w for w in thinking_writes if isinstance(w, Markdown)]
|
||||
delta_strs = [w for w in thinking_writes if isinstance(w, str)]
|
||||
assert len(rules) == 4, f"expected 4 Rules (2 start + 2 end), got {len(rules)}"
|
||||
assert len(markdowns) == 2, f"expected 2 Markdown sections, got {len(markdowns)}"
|
||||
# Text "hi" went to current_text (buffered), not the transcript directly.
|
||||
current_text.update.assert_any_call("hi")
|
||||
# Transcript: [done] label + Markdown(response) (raw=False).
|
||||
assert "first" in delta_strs
|
||||
assert "second" in delta_strs
|
||||
# v0.8.1: Text "hi" flushes as a line in transcript on Done.
|
||||
log_writes = [_text_of(c[0][0]) for c in log.write.call_args_list]
|
||||
assert "hi" in log_writes
|
||||
assert any(w.startswith("[done]") for w in log_writes if isinstance(w, str))
|
||||
|
||||
def test_render_exception_fallback(self) -> None:
|
||||
"""render_exception_fallback [adversarial, v0.6.0]:
|
||||
widget.update raises → thinking_log gets the plain-label fallback for
|
||||
Thinking (per v0.6.0 routing — Thinking now routes to thinking_log,
|
||||
not debug_log). render_error line follows. transcript untouched.
|
||||
"""render_exception_fallback [adversarial, v0.6.5]:
|
||||
thinking_log.write raises → catch in presenter, write plain-label
|
||||
fallback + render_error line via INV-009 fallback path (routing
|
||||
preservation: thinking events still route to thinking_log).
|
||||
"""
|
||||
from ratatoskr.tui import TuiPresenterState
|
||||
|
||||
log = MagicMock()
|
||||
thinking_log = MagicMock()
|
||||
widget = MagicMock()
|
||||
widget.update.side_effect = AttributeError("widget gone (msg should NOT leak)")
|
||||
# First call (Rule write) raises; subsequent calls succeed for fallback.
|
||||
thinking_log.write.side_effect = [
|
||||
AttributeError("rule write failed (msg should NOT leak)"),
|
||||
None,
|
||||
None,
|
||||
]
|
||||
state = TuiPresenterState()
|
||||
state.render(
|
||||
Thinking(sse_id=SID, content="x"),
|
||||
log=log,
|
||||
thinking_widget=widget,
|
||||
tools_log=MagicMock(),
|
||||
debug_log=MagicMock(),
|
||||
current_text=MagicMock(),
|
||||
thinking_log=thinking_log,
|
||||
raw=False,
|
||||
)
|
||||
writes = [c[0][0] for c in thinking_log.write.call_args_list if isinstance(c[0][0], str)]
|
||||
assert any(w.startswith("[thinking]") for w in writes), writes
|
||||
assert any(w == "[render_error] AttributeError" for w in writes), writes
|
||||
assert not any("widget gone" in w for w in writes), writes
|
||||
assert not any("rule write failed" in w for w in writes), writes
|
||||
assert not log.write.called
|
||||
|
||||
def test_state_reset_per_worker(self) -> None:
|
||||
@@ -344,10 +294,10 @@ class TestTuiPresenterState:
|
||||
s1.render(
|
||||
Thinking(sse_id=SID, content="x"),
|
||||
log=MagicMock(),
|
||||
thinking_widget=MagicMock(),
|
||||
tools_log=MagicMock(),
|
||||
debug_log=MagicMock(),
|
||||
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
|
||||
thinking_log=MagicMock(),
|
||||
raw=False,
|
||||
)
|
||||
s2 = TuiPresenterState()
|
||||
assert s1.thinking_open is True
|
||||
@@ -361,38 +311,34 @@ class TestTuiPresenterState:
|
||||
from ratatoskr.tui import TuiPresenterState
|
||||
|
||||
log = MagicMock()
|
||||
debug_log = MagicMock()
|
||||
widget = MagicMock()
|
||||
thinking_log = MagicMock()
|
||||
state = TuiPresenterState()
|
||||
state.render(
|
||||
Thinking(sse_id=SID, content="partial"),
|
||||
log=log,
|
||||
thinking_widget=widget,
|
||||
tools_log=MagicMock(),
|
||||
debug_log=debug_log,
|
||||
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
|
||||
debug_log=MagicMock(), thinking_log=thinking_log, raw=False,
|
||||
)
|
||||
state.render(
|
||||
Cancelled(
|
||||
sse_id=SID, phase="cancelled", turn_id=42, reason="user", partial_message_id=None
|
||||
),
|
||||
log=log,
|
||||
thinking_widget=widget,
|
||||
tools_log=MagicMock(),
|
||||
debug_log=debug_log,
|
||||
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
|
||||
debug_log=MagicMock(), thinking_log=thinking_log, raw=False,
|
||||
)
|
||||
# v0.6.0: closed thinking lands in thinking_log (Markdown body wrapped in
|
||||
# Rule start/end). terminal [cancelled] still in transcript.
|
||||
# v0.6.5: streamed thinking + Rule(end) in thinking_log; [cancelled] in transcript.
|
||||
log_writes = [_text_of(c[0][0]) for c in log.write.call_args_list]
|
||||
assert any(w.startswith("[cancelled]") for w in log_writes)
|
||||
assert widget.display is False
|
||||
# thinking_log got at least Rule(start) + "partial" delta + Rule(end)
|
||||
assert thinking_log.write.call_count >= 3
|
||||
|
||||
def test_done_renders_markdown_after_label(self) -> None:
|
||||
"""done_renders_markdown_after_label [happy, v0.6.0]:
|
||||
Text("hi") accumulates into current_text Static (buffered streaming);
|
||||
Done(response="hi") with raw=False → [done] label + Rule + Markdown
|
||||
in transcript. current_text cleared on terminal.
|
||||
def test_done_flushes_tail_and_writes_label(self) -> None:
|
||||
"""done_flushes_tail_and_writes_label [happy, v0.8.2]:
|
||||
Text("hi") buffers in text_chunk_buffer (no `\\n`). Done flushes
|
||||
"hi" tail to transcript, then writes [done] label. v0.8.2 drops
|
||||
the post-Done Markdown body re-render — streamed text is the
|
||||
canonical content (no double-print).
|
||||
"""
|
||||
from rich.markdown import Markdown
|
||||
from rich.rule import Rule
|
||||
@@ -400,37 +346,33 @@ class TestTuiPresenterState:
|
||||
from ratatoskr.tui import TuiPresenterState
|
||||
|
||||
log = MagicMock()
|
||||
current_text = MagicMock()
|
||||
widget = MagicMock()
|
||||
state = TuiPresenterState()
|
||||
state.render(
|
||||
Text(sse_id=SID, content="hi"),
|
||||
log=log,
|
||||
thinking_widget=widget,
|
||||
tools_log=MagicMock(),
|
||||
debug_log=MagicMock(),
|
||||
current_text=current_text,
|
||||
thinking_log=MagicMock(),
|
||||
raw=False,
|
||||
)
|
||||
# Text accumulated to current_text, NOT written to log.
|
||||
current_text.update.assert_any_call("hi")
|
||||
assert not log.write.called
|
||||
assert state.text_chunk_buffer == "hi"
|
||||
state.render(
|
||||
_make_tui_done(),
|
||||
log=log,
|
||||
thinking_widget=widget,
|
||||
tools_log=MagicMock(),
|
||||
debug_log=MagicMock(),
|
||||
current_text=current_text,
|
||||
thinking_log=MagicMock(),
|
||||
raw=False,
|
||||
)
|
||||
# Done cleared current_text and wrote [done] label + Rule + Markdown.
|
||||
current_text.update.assert_any_call("")
|
||||
# On Done: tail flush "hi" + [done] label. No Markdown, no Rule.
|
||||
writes = [c[0][0] for c in log.write.call_args_list]
|
||||
assert "hi" in writes
|
||||
assert any(_text_of(w).startswith("[done]") for w in writes)
|
||||
assert any(isinstance(w, Rule) for w in writes)
|
||||
assert any(isinstance(w, Markdown) for w in writes)
|
||||
# v0.8.2: no post-Done re-render — no duplicate content.
|
||||
assert not any(isinstance(w, Markdown) for w in writes)
|
||||
assert not any(isinstance(w, Rule) for w in writes)
|
||||
assert state.text_chunk_buffer == ""
|
||||
|
||||
def test_raw_flag_skips_markdown(self) -> None:
|
||||
"""raw_flag_skips_markdown [trace]: raw=True → no Rule, no Markdown."""
|
||||
@@ -440,23 +382,18 @@ class TestTuiPresenterState:
|
||||
from ratatoskr.tui import TuiPresenterState
|
||||
|
||||
log = MagicMock()
|
||||
widget = MagicMock()
|
||||
state = TuiPresenterState()
|
||||
state.render(
|
||||
Text(sse_id=SID, content="hi"),
|
||||
log=log,
|
||||
thinking_widget=widget,
|
||||
tools_log=MagicMock(),
|
||||
debug_log=MagicMock(),
|
||||
current_text=MagicMock(), thinking_log=MagicMock(), raw=True,
|
||||
debug_log=MagicMock(), thinking_log=MagicMock(), raw=True,
|
||||
)
|
||||
state.render(
|
||||
_make_tui_done(),
|
||||
log=log,
|
||||
thinking_widget=widget,
|
||||
tools_log=MagicMock(),
|
||||
debug_log=MagicMock(),
|
||||
current_text=MagicMock(), thinking_log=MagicMock(), raw=True,
|
||||
debug_log=MagicMock(), thinking_log=MagicMock(), raw=True,
|
||||
)
|
||||
writes = [c[0][0] for c in log.write.call_args_list]
|
||||
assert not any(isinstance(w, Rule) for w in writes)
|
||||
@@ -477,10 +414,8 @@ class TestTuiPresenterState:
|
||||
state.render(
|
||||
WorkerPhase(sse_id=SID, phase="streaming", turn_id=42),
|
||||
log=log,
|
||||
thinking_widget=MagicMock(),
|
||||
tools_log=MagicMock(),
|
||||
debug_log=debug_log,
|
||||
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
|
||||
debug_log=debug_log, thinking_log=MagicMock(), raw=False,
|
||||
)
|
||||
# v0.5.0: WorkerPhase routes to debug_log, NOT transcript.
|
||||
assert not log.write.called
|
||||
@@ -494,36 +429,10 @@ class TestTuiPresenterState:
|
||||
assert text.startswith("· worker_phase:")
|
||||
assert "[worker_phase]" not in text
|
||||
|
||||
def test_terminal_events_belt_and_braces_widget_cleanup(self) -> None:
|
||||
"""terminal_events_belt_and_braces_widget_cleanup [trace]:
|
||||
Done / Error / Cancelled MUST clear+hide the thinking widget even when
|
||||
thinking_open is False (Volva F3 fix; POST-005 + STEPS 5-6).
|
||||
"""
|
||||
from ratatoskr.tui import TuiPresenterState
|
||||
|
||||
for terminal in (
|
||||
_make_tui_done(),
|
||||
Error(sse_id=SID, phase="failed", message="boom", error_code="x"),
|
||||
Cancelled(
|
||||
sse_id=SID, phase="cancelled", turn_id=42, reason="r", partial_message_id=None
|
||||
),
|
||||
):
|
||||
log = MagicMock()
|
||||
widget = MagicMock()
|
||||
widget.display = True # pre-set to non-default to detect the clear
|
||||
state = TuiPresenterState()
|
||||
# thinking_open is False (state just constructed).
|
||||
state.render(
|
||||
terminal,
|
||||
log=log,
|
||||
thinking_widget=widget,
|
||||
tools_log=MagicMock(),
|
||||
debug_log=MagicMock(),
|
||||
current_text=MagicMock(), thinking_log=MagicMock(), raw=True,
|
||||
)
|
||||
# Belt-and-braces: widget cleared + hidden on EVERY terminal event.
|
||||
widget.update.assert_called_with("")
|
||||
assert widget.display is False, type(terminal).__name__
|
||||
# v0.6.5: test_terminal_events_belt_and_braces_widget_cleanup deleted.
|
||||
# The thinking-current Static is gone, so there's no widget to clean up
|
||||
# on terminal events. The corresponding Volva F3 invariant is obsoleted
|
||||
# by the streaming-into-thinking_log architecture.
|
||||
|
||||
def test_tool_start_routes_to_tools_log(self) -> None:
|
||||
"""tool_start_routes_to_tools_log [INV-014]: ToolStart writes to tools_log, NOT transcript.
|
||||
@@ -540,10 +449,8 @@ class TestTuiPresenterState:
|
||||
state.render(
|
||||
ToolStart(sse_id=SID, name="read_file", arguments={"path": "/x"}),
|
||||
log=log,
|
||||
thinking_widget=MagicMock(),
|
||||
tools_log=tools_log,
|
||||
debug_log=MagicMock(),
|
||||
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
|
||||
debug_log=MagicMock(), thinking_log=MagicMock(), raw=False,
|
||||
)
|
||||
# INV-014: write went to tools_log
|
||||
assert tools_log.write.called
|
||||
@@ -561,60 +468,56 @@ class TestTuiPresenterState:
|
||||
state.render(
|
||||
ToolResult(sse_id=SID, name="read_file", result="ok", duration_ms=12),
|
||||
log=log,
|
||||
thinking_widget=MagicMock(),
|
||||
tools_log=tools_log,
|
||||
debug_log=MagicMock(),
|
||||
current_text=MagicMock(), thinking_log=MagicMock(), raw=False,
|
||||
debug_log=MagicMock(), thinking_log=MagicMock(), raw=False,
|
||||
)
|
||||
assert tools_log.write.called
|
||||
assert _text_of(tools_log.write.call_args[0][0]).startswith("· tool_result:")
|
||||
assert not log.write.called
|
||||
|
||||
def test_text_event_buffers_into_current_text(self) -> None:
|
||||
"""text_event_buffers_into_current_text [v0.6.0]: Text → current_text Static
|
||||
(accumulated), NOT log or tools_log. Streaming UX fix — no per-token spam.
|
||||
def test_text_event_buffers_until_newline(self) -> None:
|
||||
"""text_event_buffers_until_newline [v0.8.1]: Text deltas without
|
||||
`\\n` accumulate in text_chunk_buffer; no log write yet.
|
||||
"""
|
||||
from ratatoskr.tui import TuiPresenterState
|
||||
|
||||
log = MagicMock()
|
||||
tools_log = MagicMock()
|
||||
current_text = MagicMock()
|
||||
state = TuiPresenterState()
|
||||
state.render(
|
||||
Text(sse_id=SID, content="hello"),
|
||||
log=log,
|
||||
thinking_widget=MagicMock(),
|
||||
tools_log=tools_log,
|
||||
debug_log=MagicMock(),
|
||||
current_text=current_text,
|
||||
thinking_log=MagicMock(),
|
||||
raw=False,
|
||||
)
|
||||
current_text.update.assert_called_once_with("hello")
|
||||
# v0.8.1: buffered, not written until `\n` or Done.
|
||||
assert state.text_chunk_buffer == "hello"
|
||||
assert not log.write.called
|
||||
assert not tools_log.write.called
|
||||
|
||||
def test_text_deltas_accumulate(self) -> None:
|
||||
"""text_deltas_accumulate [v0.6.0]: multiple Text deltas → current_text shows
|
||||
concatenated content, NOT separate per-delta lines.
|
||||
def test_text_flushes_on_newline(self) -> None:
|
||||
"""text_flushes_on_newline [v0.8.1]: a delta carrying `\\n` flushes
|
||||
the accumulated buffer as ONE line to log (transcript).
|
||||
"""
|
||||
from ratatoskr.tui import TuiPresenterState
|
||||
|
||||
current_text = MagicMock()
|
||||
log = MagicMock()
|
||||
state = TuiPresenterState()
|
||||
for tok in ("Hel", "lo", " ", "world"):
|
||||
for tok in ("Hel", "lo", " ", "world", "\n"):
|
||||
state.render(
|
||||
Text(sse_id=SID, content=tok),
|
||||
log=MagicMock(),
|
||||
thinking_widget=MagicMock(),
|
||||
log=log,
|
||||
tools_log=MagicMock(),
|
||||
debug_log=MagicMock(),
|
||||
current_text=current_text,
|
||||
thinking_log=MagicMock(),
|
||||
raw=False,
|
||||
)
|
||||
# Final update reflects the full concatenation.
|
||||
assert current_text.update.call_args_list[-1][0][0] == "Hello world"
|
||||
writes = [c[0][0] for c in log.write.call_args_list]
|
||||
# "Hello world" coalesces to ONE log entry.
|
||||
assert writes == ["Hello world"]
|
||||
assert state.text_chunk_buffer == ""
|
||||
|
||||
def test_duration_format_seconds(self) -> None:
|
||||
"""duration_format_seconds [trace]: Done(duration_ms=5467) → label has "duration=5.5s"."""
|
||||
@@ -625,10 +528,8 @@ class TestTuiPresenterState:
|
||||
state.render(
|
||||
_make_tui_done(duration_ms=5467),
|
||||
log=log,
|
||||
thinking_widget=MagicMock(),
|
||||
tools_log=MagicMock(),
|
||||
debug_log=MagicMock(),
|
||||
current_text=MagicMock(), thinking_log=MagicMock(), raw=True,
|
||||
debug_log=MagicMock(), thinking_log=MagicMock(), raw=True,
|
||||
)
|
||||
done_line = next(
|
||||
_text_of(c[0][0])
|
||||
@@ -653,10 +554,8 @@ class TestTuiPresenterState:
|
||||
state.render(
|
||||
_make_tui_done(usage=usage),
|
||||
log=log,
|
||||
thinking_widget=MagicMock(),
|
||||
tools_log=MagicMock(),
|
||||
debug_log=MagicMock(),
|
||||
current_text=MagicMock(), thinking_log=MagicMock(), raw=True,
|
||||
debug_log=MagicMock(), thinking_log=MagicMock(), raw=True,
|
||||
)
|
||||
done_line = next(
|
||||
_text_of(c[0][0])
|
||||
@@ -815,27 +714,28 @@ class TestLayoutShape:
|
||||
assert row is not None
|
||||
|
||||
async def test_left_column_content_only(self) -> None:
|
||||
"""left_column_content_only [v0.5.0]: left column = transcript + prompt ONLY.
|
||||
|
||||
thinking-current Static moved to right column so the left column is
|
||||
genuinely content-only (transcript + prompt input).
|
||||
"""left_column_content_only [v0.6.5]: left column = transcript + prompt
|
||||
+ current-text (streaming text Static). thinking-current Static
|
||||
removed entirely as of v0.6.5.
|
||||
"""
|
||||
from textual.containers import Vertical
|
||||
from textual.widgets import Input, RichLog, Static
|
||||
from textual.widgets import Input, RichLog
|
||||
|
||||
app = _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir")
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
left = app.query_one("#left-column", Vertical)
|
||||
right = app.query_one("#right-column", Vertical)
|
||||
transcript = app.query_one("#transcript", RichLog)
|
||||
prompt = app.query_one("#prompt", Input)
|
||||
thinking = app.query_one("#thinking-current", Static)
|
||||
assert transcript in left.walk_children()
|
||||
assert prompt in left.walk_children()
|
||||
# v0.5.0: thinking-current is now under the right column, NOT left.
|
||||
assert thinking not in left.walk_children()
|
||||
assert thinking in right.walk_children()
|
||||
# v0.6.5: thinking-current Static removed; no longer in DOM at all.
|
||||
from textual.css.query import NoMatches
|
||||
try:
|
||||
app.query_one("#thinking-current")
|
||||
raise AssertionError("thinking-current should not exist in v0.6.5")
|
||||
except NoMatches:
|
||||
pass # expected
|
||||
|
||||
async def test_right_column_has_tabbed_content_with_tools_tab(self) -> None:
|
||||
"""right_column_has_tabbed_content_with_tools_tab: #side-panes + TabPane#tools-tab."""
|
||||
@@ -940,10 +840,10 @@ class TestLayoutShape:
|
||||
state.render(
|
||||
_make_tui_done(),
|
||||
log=log,
|
||||
thinking_widget=app.query_one("#thinking-current"),
|
||||
tools_log=app.query_one("#tools-log", RichLog),
|
||||
debug_log=app.query_one("#debug-log", RichLog),
|
||||
current_text=MagicMock(), thinking_log=MagicMock(), raw=True,
|
||||
thinking_log=MagicMock(),
|
||||
raw=True,
|
||||
)
|
||||
done = next(
|
||||
c for c in seen
|
||||
@@ -1160,10 +1060,12 @@ async def _submit_and_wait(app: RatatoskrApp, pilot, content: str) -> None:
|
||||
|
||||
class TestStreamTurnWorker:
|
||||
@respx.mock
|
||||
async def test_happy_text_done_renders_markdown(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""happy_text_done_renders_markdown [happy,tracer, v0.6.0]:
|
||||
Text deltas go to current_text (not transcript); on Done, transcript
|
||||
gets turn-header Rule, [done] label, post-Done Rule + Markdown body.
|
||||
async def test_happy_text_done_no_double_print(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""happy_text_done_no_double_print [happy,tracer, v0.8.2]:
|
||||
Text("hello") buffers; on Done, "hello" flushes as tail to transcript
|
||||
+ [done] label. v0.8.2 drops the post-Done Markdown body re-render
|
||||
(was double-printing the response — streamed text + Markdown twice).
|
||||
Only the turn-header Rule remains in the transcript.
|
||||
"""
|
||||
stream = _sse_chunk("42:1", {"type": "text", "content": "hello"}) + _sse_chunk(
|
||||
"42:2", _DONE_BODY
|
||||
@@ -1179,16 +1081,17 @@ class TestStreamTurnWorker:
|
||||
await pilot.pause()
|
||||
await _submit_and_wait(app, pilot, "hi")
|
||||
assert app.state == "idle"
|
||||
# v0.6.0: Text("hello") goes to current_text Static, NOT log.
|
||||
# writes spy captures RichLog.write only, so "hello" SHOULD NOT appear.
|
||||
from rich.markdown import Markdown
|
||||
from rich.rule import Rule
|
||||
|
||||
assert not any(w == "hello" for w in writes)
|
||||
# "hello" appears as a tail-flush; [done] label fires.
|
||||
assert any(w == "hello" for w in writes)
|
||||
assert any("[done]" in str(w) for w in writes)
|
||||
# Post-Done: Markdown body + Rule + turn-header Rule all present.
|
||||
assert any(isinstance(w, Markdown) for w in writes)
|
||||
assert any(isinstance(w, Rule) for w in writes)
|
||||
# v0.8.2: NO Markdown body re-render (was the duplicate).
|
||||
assert not any(isinstance(w, Markdown) for w in writes)
|
||||
# The turn-header Rule is written to all 4 panes; we still expect
|
||||
# SOME Rules in the spy (one per pane), but NOT the post-Done
|
||||
# separator Rule that pre-v0.8.2 wrote.
|
||||
# We rely on _spy_writes counting turn-header Rules only.
|
||||
|
||||
@respx.mock
|
||||
async def test_raw_flag_skips_markdown_render(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
@@ -2208,6 +2111,72 @@ class TestResolveThenRunWithPicker:
|
||||
assert agents_route.call_count == 0
|
||||
assert sessions_route.call_count == 1
|
||||
|
||||
@respx.mock
|
||||
def test_picker_merges_local_tier3_agents(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""picker_merges_local_tier3_agents [v0.8.0]: local index entries
|
||||
appear in the picker's agent list alongside remote agents."""
|
||||
from ratatoskr.local_agents import LocalAgentEntry, add_local_agent
|
||||
|
||||
# Isolate the local index in a tmp file.
|
||||
monkeypatch.setenv(
|
||||
"RATATOSKR_LOCAL_AGENTS", str(tmp_path / "local_agents.json")
|
||||
)
|
||||
add_local_agent(LocalAgentEntry(
|
||||
agent_id="ratatoskr:wizard",
|
||||
agent_name="wizard",
|
||||
model="qwen3.6-35-a3b",
|
||||
description="(tier 3) test wizard",
|
||||
defined_at="2026-05-25T00:00:00+00:00",
|
||||
))
|
||||
respx.get("https://w.example/agents").mock(
|
||||
return_value=httpx.Response(200, json=_AGENTS_RESP)
|
||||
)
|
||||
respx.post("https://w.example/sessions").mock(
|
||||
return_value=httpx.Response(201, json=_CREATE_OK_RESP)
|
||||
)
|
||||
|
||||
from ratatoskr.tui import AgentPickerApp
|
||||
|
||||
captured: list = []
|
||||
|
||||
async def capture_picker_init(self, *a, **kw):
|
||||
captured.append(list(self.agents))
|
||||
return "mimir" # auto-pick something so the rest succeeds
|
||||
|
||||
# Patch __init__ to capture the agent list passed to the picker.
|
||||
orig_init = AgentPickerApp.__init__
|
||||
|
||||
def init_spy(self, agents):
|
||||
captured.append(list(agents))
|
||||
orig_init(self, agents)
|
||||
|
||||
monkeypatch.setattr(AgentPickerApp, "__init__", init_spy)
|
||||
|
||||
async def picker_returns_mimir(self):
|
||||
return "mimir"
|
||||
|
||||
monkeypatch.setattr(AgentPickerApp, "run_async", picker_returns_mimir)
|
||||
|
||||
async def fake_main(self, *a, **kw):
|
||||
return 0
|
||||
|
||||
monkeypatch.setattr(RatatoskrApp, "run_async", fake_main)
|
||||
from ratatoskr.tui import run_tui
|
||||
|
||||
rc = run_tui(_args_new_no_agent())
|
||||
assert rc == 0
|
||||
# The local tier-3 agent should appear in the picker's agents list.
|
||||
assert captured, "AgentPickerApp.__init__ was never called"
|
||||
agent_ids = {a.agent_id for a in captured[0]}
|
||||
assert "ratatoskr:wizard" in agent_ids
|
||||
# Plus the remote agents.
|
||||
assert "mimir" in agent_ids
|
||||
assert "lofn" in agent_ids
|
||||
|
||||
@respx.mock
|
||||
def test_picker_skipped_when_session_mode(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""picker_skipped_when_session_mode: --session s-1 → no list_agents, no create_session."""
|
||||
@@ -2274,8 +2243,16 @@ class TestResolveThenRunWithPicker:
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""list_agents returns [] → stderr [no_agents]; exit 13; picker NOT opened."""
|
||||
"""list_agents returns [] AND no local tier-3 entries → stderr
|
||||
[no_agents]; exit 13; picker NOT opened. Isolate
|
||||
$RATATOSKR_LOCAL_AGENTS so the operator's real local index
|
||||
doesn't merge in and turn this into a non-empty list."""
|
||||
# v0.8.0 isolation: point local agents at an empty tmp file.
|
||||
monkeypatch.setenv(
|
||||
"RATATOSKR_LOCAL_AGENTS", str(tmp_path / "empty_local_agents.json")
|
||||
)
|
||||
respx.get("https://w.example/agents").mock(return_value=httpx.Response(200, json=[]))
|
||||
|
||||
from ratatoskr.tui import AgentPickerApp
|
||||
|
||||
Reference in New Issue
Block a user