21 Commits

Author SHA1 Message Date
vh 4e20030229 fix(#20): heid-bug-hunt fixups — CLI open-world container-type hardening (slice-5)
Panel (Gróa + Hulda + Regin, source-verified by Heid): adapter/route-map/
ConnectFailed-at-call-sites sound against the declared invariants; 4 real
robustness findings, all in the CLI open-world presenter/probe paths — the
container-type layer BELOW the null/element holes the code-review already fixed.

Fixed (findings 1-3):
- `_format_whoami` (`cli.py`): a non-iterable `scopes`/`allowed_roles` scalar
  (`{"scopes": 123}`) made `x or []` yield `123` → `for s in 123` TypeError. New
  `_display_seq` helper degrades any non-list (scalar / bare string / null / absent)
  to empty; applied to both `scopes` and `allowed_roles`.
- `_characters_probe` (`cli.py`): same class on the model catalog `items` (`{"items":
  123}`) — now guards `models` is a Mapping and `items` is a list before iterating.
- `_characters_probe`: the top-level open-world reads `created` / `state` are now
  `isinstance(_, Mapping)`-guarded before any `.get` — a non-mapping SDK passthrough
  (`created=[...]`) aborts cleanly (exit 20) / renders `pad=None` instead of an
  AttributeError.

Accepted (finding 4, documented in contract § slice-5 notes): the `--characters`
probe leaks its transient character on a mid-lifecycle failure. PRE-EXISTING (the
retired probe had the identical linear no-`finally` structure — cutover did not
worsen it), TTL-bounded, one-shot diagnostic; a `try/finally` would swallow a
happy-path delete-failure (delete is both teardown and a tested step). Gróa + Heid
concur accept is defensible.

Dismissed (finding 5): Hulda flagged `sessions.py` dropping `get_me`/etc. as a
caller-contract break — it is the intended DEC-3 no-backwards-compat migration (all
in-repo callers rewired same-diff); Heid labels it intended-surface-change.

Added CLI tests for the three hardened paths (scalar scopes/roles; scalar items +
non-mapping state; non-mapping create abort). Suite 488 green; ruff clean; live
smoke re-run clean (identical happy-path output). Patch bump 0.21.17 → 0.21.18.
2026-07-19 11:29:10 -07:00
vh d86d6df147 fix(#20): heid-code-review fixups — CLI presenter degrade-not-crash (slice-5)
Panel: Gróa + Regin returned zero (adapter/route-map/error-map faithful);
Hulda flagged two source-confirmed open-world-presenter crash holes — the same
class the slice-4 bug-hunt found in the agents presenters. Both fixed:

- `_format_whoami` scopes (`cli.py`): `', '.join(me.get('scopes', []))` crashes on
  a present-null `scopes` (`.get(k, [])` returns None, not the default) or a
  non-string element. Now `', '.join(str(s) for s in (me.get('scopes') or []))` —
  matching the `allowed_roles` hardening on the same function. The contract names
  `_format_whoami` as the degrade-not-crash exemplar (contract:144-146); the cited
  exemplar had an un-hardened line.
- `_characters_probe` model items (`cli.py`): the slice-5 `or []` guarded the
  list-level null but not each entry — `[None]` / `["x"]` / `[{"name":123}]` would
  raise. Now guards each item is a dict and str-coerces `name` (element-level
  completion of the list-level guard).

Hulda #3 (live-smoke not in the reviewed file set) → accept: the smoke WAS run and
is recorded in deab762 + coverage-map (artifact-only review couldn't see it).

Added CLI tests for both hardened paths (present-null/non-string scopes; malformed
model items). Suite 485 green; ruff clean; live smoke re-run clean (identical
happy-path output). Patch bump 0.21.16 → 0.21.17.
2026-07-19 11:11:12 -07:00
vh deab7627eb feat(#20): characters + me/capabilities/models onto the wt adapter (slice-5)
Slice-5 of the worldtree-sdk cutover: migrate the remaining consumer READS +
transient-character CRUD off the hand-rolled httpx wrappers onto the
`ratatoskr.wt` adapter over the SDK, and delete the retired path.

Adapter (`wt.py`): add `get_me` / `get_capabilities` / `list_character_models`
/ `create_character` / `get_character_state` / `delete_character` over
`client.me` / `client.capabilities` / `client.models` / `client.characters.*`.
All six are open-world reads/acks returned verbatim; none carries a
discriminated SDK error, so each maps any `ApiError` → the `SessionApiFailed`
default (INV-CUT-2) — exact parity with the retired path. No new Error-map rows.

Decisions (contract § slice-5 notes): `create_character` omits `state` when None
(SDK-idiomatic inline literal, server-equivalent to the retired explicit null);
`delete_character` returns the SDK's open ACK verbatim (`-> Mapping|None`, not
normalized to None).

CLI rewire (`cli.py`): `--whoami` (me + capabilities) and `--characters`
(models → create → state → delete) build a `wt.build_client` over the injected
probe transport and catch `wt.SessionApiFailed` + `ConnectFailed`. Open-world
degrade-not-crash carried (cumulative cutover foot-gun): `_characters_probe`
reads `items` null-safe and extracts `character_id` defensively (clean abort,
no hard-index KeyError); `_format_whoami` widened to `Mapping`.

Deleted the six hand-rolled `sessions.py` wrappers (net -5 mypy no-any-return);
`endpoint_for_plane` + `get_session_bifrost` (slice-6) + the exception classes
stay. Retired the corresponding `test_sessions.py` classes; added the slice-5
adapter tests + a CLI malformed-create-abort test.

LIVE SMOKE (:8081, b128) — INV-CUT-5 / DEC-4 cleared: `--whoami` rendered real
identity + capabilities; `--characters` drove the full lifecycle end-to-end
(char-rp catalog → created char_8c00006e… → PAD read-back → deleted).

Suite 483 green; ruff clean; mypy at the 2 pre-existing baseline errors.
Patch bump 0.21.15 → 0.21.16 (the cutover MINOR is deferred to slice-7, DEC-6).
2026-07-19 11:00:41 -07:00
vh fc256bbaa4 fix(#20): heid-bug-hunt fixups — probe ConnectFailed + adapter finite-PAD (slice-3)
The slice-3 heid-bug-hunt panel (3/3) caught a real regression the cutover
introduced, plus a chokepoint-invariant gap:

- ConnectFailed escaped both rewired CLI probes. When --set-persona-pad and
  --seed-first-message moved off raw httpx onto the wt adapter, transport failures
  changed class: the SDK normalizes any pre-response transport error to
  worldtree_sdk.ConnectFailed (request.py), a WorldtreeError (not ApiError), so it
  passed the adapter unmapped AND the probes' httpx-only except tuples → an uncaught
  traceback instead of the graceful [network_error] exit 21. _amain (slice-2) already
  handled it; the probes lagged. Fix: add ConnectFailed to both probe except tuples
  (mirrors _amain). Live-verified at a refused host → [network_error] exit 21.

- Finite-PAD enforced only at the CLI, not the adapter chokepoint. wt.set_persona_state
  delegated finiteness to the caller (documented), so a direct/non-CLI caller passing
  nan/inf got a raw SDK ConfigurationError. Fix: assert finiteness in the adapter
  precondition (consistent with its other precondition asserts) so the invariant holds
  at the chokepoint in ratatoskr's own terms; the CLI pre-check stays for the friendly
  usage error.

Triaged-and-declined (all correct per the panel + Heid's source-check): the deleted
sessions.py exports (intended no-shim cutover, zero un-migrated importers), the
session["session_id"] index (accept-known-risk, matches --new), and Regin's "web
indefinite block" (refuted — the seed is asyncio.wait_for-bounded). The concurrent
heid-code-review panel returned zero drift, no code change.

TDD: 3 RED tests (both probes' ConnectFailed → exit 21; adapter nan/inf/-inf →
AssertionError, never reaches the SDK) → GREEN. Suite 469; ruff clean; mypy no new errors.
2026-07-19 09:13:47 -07:00
vh aba17304bd fix(#20): heid-bug-hunt fixups — cutover edge-path robustness (slice-2)
Triaged the heid-bug-hunt panel (Gróa 8 / Hulda 6 / Regin 6; Heid source-checked +
refuted 2 Regin FPs). The lens pulled real weight — confirmed bugs the conformance
review structurally could not see.

Confirmed bugs fixed:
- SessionRetired (410) stream-open maps to wt.SessionApiFailed, but neither cli
  _run_turn nor web gen() caught it → crash / dropped SSE stream. Both presenters now
  catch it (cli → exit 20; web → labeled `event: error`). (Gróa#2) + cli regression test.
- cli forwarded consumer_key unconditionally; an UNBOUND create with the env key set
  would auth as the Bifrost consumer, not the default bearer. Guarded in the adapter
  (consumer_key only when bifrost is set). (Gróa#4 + Regin#4) + test.
- cli _turn_id_from_sse_id crashed on a None/non-str sse_id (web guarded, cli didn't)
  → now tolerant. (Gróa#1 + Hulda#2) + test.
- _cancel_and_log broadened to `except Exception` — after the code-review's ApiError
  default, a cancel could raise SessionApiFailed it didn't catch, breaking INV-009
  (never-raise). (Gróa#3, Heid-endorsed over Regin's refuted mechanism).

Open-world degrade-not-crash (contract posture): render hardened — float duration_ms
(_format_duration_safe), non-mapping usage/snapshot guards, unknown event type
degrades instead of asserting (Gróa#5/#6 + Hulda#3); web _event_to_browser_payload
guards a non-mapping `raw` (Hulda#4); web _wt_client bearer extraction is now
case-insensitive + whitespace-robust (Hulda#5 + Regin#5). + render-degrade test.

Rejected (verified): Regin#1 (httpx IS caught), Regin#2 (wtsdk IS worldtree_sdk),
Regin#3 (sse_client.AgentNotAvailable IS caught by SseConnectFailed) — all FPs;
Hulda#1 (deleted funcs "break callers") — grep-verified zero callers pre-deletion.
Accepted-known-risk: lenient sse_id parse, CancelFailed status=0, async-gen aclose
(pre-existing pattern, not a cutover regression).

Suite 497 green; wt/cli/web ruff + wt mypy clean. Patch.
2026-07-19 07:09:26 -07:00
vh e3a10ad80e feat(#20): rewire the CLI turn path onto the wt adapter (slice-2, part 2b-i)
The --send turn path (_amain create + _run_turn stream + _cancel_and_log) now goes
through ratatoskr.wt over the worldtree-sdk; external CLI behavior (output, exit
codes) is preserved. No hand-rolled path is deleted yet — web/server.py still uses
them (part 2b-ii), so the deletions + live smoke come after web is rewired.

- _amain builds one WorldtreeClient via wt.build_client over a ratatoskr-owned
  transport (INV-CUT-1); create → wt.create_session (reads the SDK's open create
  dict); the transport keeps the default bearer so the not-yet-migrated hand-rolled
  seed_preset_first_message (slice-3) still authenticates.
- _run_turn drives wt.stream_turn and consumes SDK TurnEvents; the mid-stream cancel
  target is parsed from the composite sse_id ("{turn}:{seq}") — the SDK's top-level
  turn_id is the body field and is absent on text/thinking frames.
- CliPresenterState.render consumes the SDK TurnEvent union with None-hardening on
  the now-optional fields (usage degrades to "(n/a)" rather than crashing).
- The SDK normalizes a pre-response transport failure to ConnectFailed(status=0);
  _amain (network → exit 21) and _cancel_and_log (swallow, INV-009) catch it.
- build_client gains max_reconnects (SDK default 5; tests pass 0 to surface drops
  immediately). test_cli: SDK-event factories keep the render-test bodies intact;
  client constructions wrap in build_client; cancel-race mocks carry the SDK's
  (status, error_code) pair.

Suite 570 green; cli.py + wt.py mypy + ruff clean (the pre-existing send_content
arg-type note is unchanged). Patch (internal; external CLI behavior preserved).
2026-07-19 06:07:50 -07:00
vh c7016f23a6 feat(#19): ephemeral-template (Echo) session creation
create_session could only mint foundational sessions; an ephemeral template
(agent_id="echo") returned 422 ephemeral_requires_config because ratatoskr never
sent the required config block — Echo was uncreatable, surfacing as an opaque
session_api_failed at the CLI. Thread an opaque, role/model-agnostic config
passthrough through the create path so Echo sessions are creatable.

- sessions.py: create_session(config=...) verbatim passthrough (PRE-004 Mapping /
  PRE-005 config-xor-bifrost guards); SessionInfo gains kind + config, captured
  defensively (.get) on both create and list.
- cli.py: --system-prompt flag builds config={"system_prompt": ...} (validation:
  non-empty, requires --new+--agent, xor bifrost); _amain surfaces kind=; the
  --whoami renderer now reads allowed_roles/default_role (was reading the dead
  allowed_models/default_model) and tolerates a malformed capabilities shape.
- contract #2 amended (Amendment 2026-07-18); Heid-panel contract-reviewed +
  diff-scoped bug-hunted (one whoami null-join gap found + fixed).

Canonical grounding: config.role, never config.model (worldtree-dev althing
01KXT976NN91DRBZBPXNZ2BVZR; ADR-0012 role cutover). Verified end-to-end against
the live v0.16.2 target. TDD across create + CLI; full suite green (534).

Closes #19.
2026-07-18 12:02:18 -07:00
vh 3f3a9f7b0f refactor(cli)!: remove deprecated textual TUI; web console is the interactive surface
The textual TUI (tui.py) is superseded by the web console (ratatoskr-web)
and is removed per the no-backwards-compat rule. The `ratatoskr` command
stays as a headless client: --send / --whoami / --characters /
--set-persona-pad / --seed-first-message still work; invoking it with no
--send now returns a usage error (rc 10) instead of launching the TUI.

Removed: src/ratatoskr/tui.py, tests/test_tui.py, the textual + textual-dev
deps, and cli.py's run_tui launch path. cli.py's shared exports (USER_AGENT,
ParsedArgs, formatters) stay — web/entrypoint.py and tier3.py depend on them.

BREAKING CHANGE: the interactive `ratatoskr --agent X` TUI is gone; use the
web console (ratatoskr-web) for interactive debugging, or --send for scripted.

Verified: full suite 520 passed; ratatoskr --help exit 0; no-send -> rc 10;
web/provider/tier3 import clean; textual absent from the lockfile.
2026-07-17 13:46:20 -07:00
vh e643d38f58 fix: persona_state SET body → canonical {pad:{pleasure,arousal,dominance}} + re-vendor Tier-3 prose (v0.19.7)
worldtree-dev landed the Tier-3 persona/memory/persona_state prose docs
(c9e59ec) — shapes that serialize as freeform Any in the OpenAPI, so the
prose markdown is their source of truth. Re-vendored docs/conversation-api-spec.md
(tolerate_drift markdown pin; worldtree-spec-rev 879cefe→c9e59ec).

Consumer alignment: --set-persona-pad / _set_persona_probe was building
{pad:[list]}, but the canonical POST /sessions/{id}/persona_state body (#317)
is {pad:{pleasure,arousal,dominance}} (named dict). Aligned the probe to the
named dict + a len!=3 guard; updated contract #2's note, the set_persona_state
docstring, and the tests. The set_persona_state wrapper was already correct
(freeform pass-through) — only the CLI probe's body construction drifted.

Suite 602 green. (Also this session: heid-code-review on the #347 slice
returned unanimous zero drift across all three panel arms.)
2026-07-06 09:56:46 -07:00
vh 6bf2a84ccd feat: authored-history-write consumer side (Worldtree #347) — v0.19.6
Consumer side of Worldtree's #347 authored-history-write (the SillyTavern
first-message primitive), shipped via direct in-session TDD:

- write_authored_history (POST /sessions/{id}/history): v1 author=assistant,
  effects=none, per-session idempotency; body server-pinned (AuthoredWriteRequest
  extra=forbid) so null effects/claimed_original_at are omitted; 200 replay /
  201 fresh both return the AuthoredTurnResponse dict.
- AuthoredHistoryUnavailable: the hide-existence 404 (feature-absent / ungranted
  / session-absent, indistinguishable by design — INV-347-1) raised DISTINCT from
  SessionApiFailed so callers branch feature-absent and never capability-probe.
- get_session_messages (GET /sessions/{id}/messages): un-deferred as the seed
  read-back — confirms a seed renders as a normal role=assistant turn
  (model-invisible provenance).
- --seed-first-message probe: create session -> seed -> read-back; a 404 reports
  a benign feature-absent result (exit 0), never a capability-probe.

Contract #2 amended (2 FNs, validated OK). 19 new tests (12 wrapper + 7 cli),
suite 601 green. Coverage-map re-converged: REST 19/41 (the #347 route + the
messages read-back close the one gap the 2.3.0 re-vendor opened).

Live-proof pending the session.history.write grant (requested infra-ops).
2026-07-06 09:40:42 -07:00
vh af07a2329a feat(#2): Tier-2 — transient characters + persona-state write; audit converges
v1 coverage-audit: the last in-scope client I/O points. The audit now
CONVERGES — REST 17/40 covered with zero in-scope gaps (23 excluded-by-
design), SSE 11/11, Bifrost planes 8/8.

- sessions.py: list_character_models / create_character / get_character_state
  / delete_character (#161, character.read/write) + set_persona_state
  (POST /sessions/{id}/persona_state — freeform body, unpinned in the
  frozen surface). 200/201 -> dict (or None on 204), off-status ->
  SessionApiFailed.
- cli.py: two one-shot probes (mirror --whoami): --characters (CRUD
  lifecycle report) + --set-persona-pad "p,a,d" (requires --session).
  New ParsedArgs.characters/set_persona_pad + probe mutual-exclusion.
- Contract #2 amended (5 FNs) + validated. TDD: 7 wrapper + 5 cli tests.
  Suite 573 green; touched code ruff-clean.
- Char read side live-proven (GET /models/available-for-characters -> 200).

Coverage-map: convergence frontier CLOSED — scope-A "done" (every frozen
I/O point classified) is met; ratatoskr cuts v1 when Worldtree tags 1.0.
2026-06-30 23:57:09 -07:00
vh 387ac4ab2c feat(#2): consume GET /me + GET /capabilities via --whoami one-shot
v1 coverage-audit slice (capabilities+me). Both endpoints had no
caller; add them as cheap boot-time debug primitives.

- sessions.py: get_me (GET /me — identity/whoami) + get_capabilities
  (GET /capabilities — Echo ephemeral-template discovery). Mirror
  get_persona_state: 200 -> parsed dict verbatim, non-200 ->
  SessionApiFailed. Freeform dicts (frozen OpenAPI types both as
  objects).
- cli.py: new --whoami one-shot mode (mirrors --send). Fetches both,
  prints an identity + capabilities report, exits. Standalone probe:
  mutually exclusive with --send/--session/--new/--agent; opens no
  session. New ParsedArgs.whoami field + main() dispatch.
- Contract #2 amended (2 FNs) + validated. TDD: 5 wrapper tests +
  5 cli tests (validation + mode + error). Coverage map: REST 9/40.
  Suite 538 green; touched code ruff-clean.

Audit note: /capabilities is the Echo ephemeral-template discovery
endpoint, not a generic server-caps endpoint (coverage-map framing
corrected). TUI-surfacing of /me + /capabilities deferred.
2026-06-30 22:21:59 -07:00
vh 5c1b9816d4 feat(#6): startup session picker for bare TUI mode
v1 coverage-audit slice b2. The audit found list_sessions had no
caller — the startup session picker (design-brief §4) was never built;
bare TUI mode was a hard usage error. Add SessionPickerApp (mirrors
AgentPickerApp) and resolve bare mode in _resolve_then_run.

- Bare TUI mode (no --session/--new) now valid → session picker.
  Resolution: 0 sessions -> [no_sessions] exit 14 (resume-only per
  §4 "no in-app creation, --new only"); exactly 1 -> auto-resume
  (§4 "picker only when >1"); >=2 -> SessionPickerApp -> resume pick
  (Esc/Ctrl-D -> exit 0).
- cli._parse: bare TUI valid; --send still requires one flag; --agent
  forbidden in bare mode. run_tui PRE-002 xor -> mutually-exclusive.
- Contract #6 amended (SessionPickerApp + bare-mode resolution) +
  validated. TDD: 3 picker pilot tests + 5 resolution tests + 3 cli
  validation tests. Suite 528 green; touched code ruff-clean.

Design note: bare + 0 sessions errors (honors §4's no-in-app-creation
clause); the friendlier auto-fall-through-to-new is deferred pending
operator preference.
2026-06-30 22:05:14 -07:00
vh 0bebad74ad feat(#17): CLI Bifrost-bind trigger (slice 3a of the INV-008 lockstep)
Slice 3a of issue #17 — the CLI surface of the bind trigger (TUI + web follow,
INV-008 lockstep). ratatoskr can now self-drive a bound session from the CLI:

- New flags: --bifrost-plane {memory,affect} (dev shortcut -> endpoint_for_plane
  over --bifrost-host / RATATOSKR_PROVIDER_VISIBLE_HOST) and --bifrost-url (the
  direct HTTPS/prod endpoint, bypassing the plane shortcut). Mutually exclusive;
  a binding is a session-CREATE concern (forbidden with --session).
- Consumer key resolved from RATATOSKR_BIFROST_CONSUMER_KEY only (the privileged
  handshake identity — never a CLI flag, distinct from the canary WORLDTREE_API_KEY).
- _amain threads bifrost + consumer_key into create_session and routes the bind
  failures: BifrostConsumerKeyMissing -> exit 22; BifrostHandshakeFailed -> exit
  23 with the 401-scoping hint ("use the consumer key, not WORLDTREE_API_KEY")
  keyed on bifrost_error == bifrost.auth_rejected.
- Bound-state indicator on success: ". bifrost: status=bound plane=... endpoint=..."
  — shows WHICH identity/endpoint bound, not a bare boolean.

Also fixes a pre-existing test-isolation bug: test_no_textual_import did a live
importlib.reload(ratatoskr.cli) that mutated the shared module in place, breaking
class identity (isinstance / pytest.raises) for every test after it. The real
check is the static source grep; the reload was vestigial and is removed.

9 new CLI bind tests; full suite 462 green; ruff clean (no new mypy errors).
2026-06-18 00:45:48 -07:00
vh d30be12deb feat(sessions,cli,tui): issue #8 — startup agent picker (v0.3.0)
Adds GET /agents fetch + ListView picker for bare `--new` (TUI mode
without --agent). Three in-place amendments:

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

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

Tests: +18 (227 total, was 209). Live smoke against personal Worldtree
(:8081) returned 12 agents; programmatic picker drive auto-picked lofn
and created a real session with `end_user_id="ratatoskr-tui"`.
2026-05-23 17:58:22 -07:00
vh 3b9c610587 feat(cli,tui): issue #12 — presenter contract semantics amendment (v0.2.0)
Replaces the stateless _render_event / _render_event_to_log helpers with
stateful per-turn presenters (CliPresenterState / TuiPresenterState).
Coalesces thinking-event deltas into a single growing display per run;
demotes telemetry events with editorial hierarchy; formats duration +
usage for human reading. Headline behavior change: a 50-token thinking
phase now renders as ONE coalesced growing line in CLI (or one closed
RichLog entry + per-delta live Static widget in TUI), not 50 lines of
[thinking] spam.

Editorial promotion line (issue #12 INV-002):
- Load-bearing (no demotion prefix): Text, Done, Error, Cancelled
- Demoted telemetry (`. ` ASCII prefix in CLI; dim `· ` in TUI):
  WorkerPhase, Thinking, TextBoundary, ToolStart, ToolResult

Stateful coalescing:
- Thinking deltas accumulate into thinking_buffer; first non-thinking
  event closes the run with a single \n boundary in CLI / one closed
  dim RichLog entry in TUI.
- TUI adds a dedicated Static(id="thinking-current") widget that shows
  the last ~200 chars of the active run, mirroring per-delta updates.
  Two-views-of-thinking decoupling per INV-004: chronological RichLog +
  always-visible widget.
- CLI INV-005: when stdout text was streamed mid-line,
  text_written_since_newline triggers a stdout flush + \n before the
  next stderr terminal label — guarantees [done] / [error] / [cancelled]
  land on their own line in a TTY without breaking pipe-to-file
  scripted consumers.

Formatting helpers (issue #12 INV-006 / INV-007):
- _format_duration_ms — autoscale `347ms` / `5.5s` / `1.2m`
- _format_usage — natural-language `6756 in -> 126 out (6882 total, 0
  cached)` with arrow="->" CLI / "→" TUI

Cross-frontier design pass (eitri-smithy-dev, althing
01KSBE52YZR5E3SPTKA672JE43) returned 16-of-16 confirmed decisions + 4
material divergences applied:
- ASCII `. ` prefix in CLI (`·` is U+00B7, not ASCII)
- RichLog one-closed-entry-per-run + Static per-delta updates (not
  inline-mirror as initially proposed)
- presenter-state object instead of pure-function rendering
- Framed as "contract semantics amendment", not "polish"

Volva paraphrase round (5 prose-precision fixes applied to
12.contract.md): INV-001 "growing display" semantics; single hide
mechanism for the Static widget (Textual reactive `display: bool`);
[render_error] security clause (type-only, no exception message);
text_written_since_newline `\n`-terminated text corner case;
[create_session] integration path (bypasses state.render — not an SSE
Event variant).

Volva code-review round (5 findings applied):
- F1 drift: render-exception fallback now writes BOTH a plain-label
  fallback line for the original event AND the `[render_error] <type>`
  line (was missing the fallback half).
- F2 drift: dim Rich style applied to all demoted-telemetry RichLog
  writes via `rich.text.Text(..., style="dim")` (was plain str).
- F3 drift: belt-and-braces widget clear+hide on EVERY terminal event
  (Done/Error/Cancelled), even when thinking_open was False.
- F4 precision: _format_usage gains PRE-001 assertion on the four
  expected usage keys.
- F5 precision: _run_turn signature amended in issue #3 contract to
  document the new `state: CliPresenterState | None = None` test-
  injection kwarg.

[create_session] lifecycle line demoted to `. create_session:` (written
directly by _amain; bypasses state.render since it's not a wire-level
SSE Event variant). Pre-amendment _render_event / _render_event_to_log
and their test classes removed under the no-backwards-compat rule.

Issues #3 and #4 contracts amended in-place: #3 (CliPresenterState
CLASS + FN block + helper FN blocks + _run_turn signature + _amain
create_session demotion); #4 (TuiPresenterState CLASS + FN block +
compose Static widget + _stream_turn_worker state construction).

209 tests GREEN; ruff clean. Bumps v0.1.0 → v0.2.0 (minor — output
shape change breaks pre-amendment grep patterns like `[thinking] '`;
no public API surface change beyond the rendering contract).

Persistent-memory commit-along: captures the issue #12 decision,
forward direction (require end_user_id for every access — declined
worldtree-dev's requires_end_user_id offer because we'll send it
universally), and the Heimdall scope-model foot-gun note (the
"per-Tier-1-agent scope add" diagnosis was a phantom ask resolved by
worldtree-dev's correction; agent.call:* baseline covers all Tier 1).
2026-05-23 16:13:55 -07:00
vh 804c2df6eb feat(sessions,cli,tui): issues #5 + #6 + worldtree-dev consumer-API follow-up
Issue #6 (TUI startup error visibility): restructure run_tui lifecycle so
pre-App.run() failures land on real stderr instead of getting eaten by
the alt-screen teardown. New _resolve_then_run async helper opens the
AsyncClient via async-with, does pre-flight session resolution, routes
AgentNotFound / SessionApiFailed / network errors to sys.stderr (verbatim
same labels + exit codes as cli._amain), then constructs RatatoskrApp
with pre-resolved state and awaits app.run_async(). RatatoskrApp.__init__
signature widens to (args, *, session_id, agent_id, client) — all three
required. on_mount narrows to identity-widget population; on_unmount
becomes a no-op (client lifetime owned by run_tui's async-with).

Issue #5 (--end-user-id for per-end-user agents): sessions.create_session
gains keyword-only end_user_id kwarg with PRE-003 non-empty assertion;
ParsedArgs.end_user_id field added (default None); --end-user-id flag
with non-empty validation; _amain + _resolve_then_run thread it to their
create_session calls. RATATOSKR_END_USER_ID env-var fallback
(flag > env > None) per the post-2026-05-23 amendment; env.sh (gitignored)
ships "ratatoskr-tui" as project-stable partition default.

Worldtree-dev consumer-API follow-up (althing 01KSBARG2B8M): User-Agent
header added (ratatoskr/<version> (vh@phasefinal.com), version pulled via
importlib.metadata) to both AsyncClient constructions so server logs can
distinguish ratatoskr traffic from other consumers.

Volva code-review (2 rounds on #6) found 8 test-precision gaps + 1 PRE
assertion drift, all Category 1 fixed: missing PRE-001 at
_resolve_then_run entry; Rule separator assertions on markdown render;
RichLog-write spy on empty submit; input-cleared + no-new-worker on
cancelling busy; worker.cancel observation on three force-exit paths;
on_unmount-no-close focused test (the prior client-lifetime test patched
run_async so on_unmount was never exercised); happy --new resolve test
verifying POST count + identity propagation.

Issues #2/#3/#4/#5 contracts amended in-place to reflect:
- create_session widened (PRE-003, body construction step, body shape POST)
- ParsedArgs description + _parse_args STEPS + _amain create_session call
  + new TESTS for end_user_id + env-var fallback
- _resolve_then_run STEPS + new TEST entries; on_mount narrowed;
  INV-007 amended for new client ownership
- Post-#6 adjustment note on issue #5 (_resolve_then_run replaces
  on_mount as the threading site since #6 moved session resolution out
  of the alt-screen)

188 tests GREEN; ruff clean. Bumps to v0.1.0 — first minor release, the
load-bearing reason is RatatoskrApp.__init__'s breaking signature change
(additive end_user_id alone wouldn't have triggered a minor pre-v1.x).

Files Gitea issues #9 (spec-pin refresh v0.19.0 → v0.22.1), #10 (track
Worldtree #196 subject:{type,id} migration), #11 (AdminEvents pane auth
prerequisite admin.events.read). Infra-ops pinged via althing for
agents.call:lofn scope add (broker pattern; they forwarded to
worldtree-dev because personal Worldtree exposes no public
scope-mutation endpoint).
2026-05-23 14:34:53 -07:00
vh c713208585 feat(sse_client,cli,tui): implement issue #7 — empty-data skip + MalformedSseData
Bundles initial TDD impl + Volva-code-review F1/F3 amendments.

sse_client.py:
- New MalformedSseData(raw) exception; truncates raw to 200 chars at
  __init__ (mirrors MalformedSseId.raw[:64] precedent).
- _iter_events gains `if sse.data == '': continue` BEFORE
  _parse_sse_id. Empty-data frames are silently skipped per issue #7
  INV-001 (keepalive semantics). Empty-data + bad-id is still a
  keepalive; intentional ordering, don't reorder.
- _iter_events json.loads(sse.data) now wrapped — JSONDecodeError →
  MalformedSseData(raw=sse.data).

cli.py:
- Imports MalformedSseData; _run_turn ERROR_ROUTING gains the case →
  stderr `[malformed_sse_data] raw={exc.raw!r}` + exit 22 (protocol-
  failure bucket, same as MalformedSseId/TurnIdFlip).

tui.py:
- Imports MalformedSseData; _stream_turn_worker ERROR_ROUTING gains
  the case → transcript label; finally block restores state→idle
  per INV-008 (mid-session errors don't exit the app).

Tests (6 new):
- test_sse_client.py: empty_data_skipped (tracer — 4 frames in, 3
  events out), malformed_data_raises, whitespace_data_raises,
  malformed_data_truncation, AND empty_data_skip_preserves_last_seen_sse_id
  (F1 from Volva code-review — drop-after-empty probes internal
  last_sse_id non-advancement via SseConnectionDropped.last_seen_sse_id).
- test_cli.py: malformed_sse_data (tightened to assert exact
  `[malformed_sse_data] raw='not-json'` shape per F3),
  malformed_sse_data_truncation (5000-char payload — verifies
  truncation carries through presenter rendering, F3).
- test_tui.py: malformed_sse_data_returns_to_idle (state→idle per
  INV-008; app does NOT exit).

Smoke validation (2026-05-22): the original crashing prompt
("what about system 1 and system 2 framing?") now completes cleanly
end-to-end. mimir streamed 3193 tokens (50 seconds, 374980-token
context), `[done] turn_id=96 duration_ms=50436`. Empty-data frames
somewhere in the stream silently skipped; no crash.

172/172 tests GREEN; ruff clean; all 5 issue contracts (#1, #3, #4,
#5, #7) drift-check clean.

Persistent-memory updated per the commit-along rule: status reflects
v0+#7 milestone; new dated decisions for #5/#6/#7 filing + #7
implementation; foot-gun entry for unguarded json.loads(sse.data).
2026-05-22 16:41:38 -07:00
vh dd89239c34 feat(tui): implement issue #4 contract via TDD; amend cli for TUI dispatch
47 contract-listed tests authored + GREEN (43 tui + 4 issue-#3
amendments). 164/164 tests GREEN suite-wide; ruff clean.

Vertical-slice ordering: _render_event_to_log → _cancel_via_sse →
CLI amendments → RatatoskrApp class + on_mount + on_unmount →
on_input_submitted → _stream_turn_worker → action_interrupt +
action_quit → run_tui.

Two in-flight contract amendments caught during TDD:
- PRE-002 of run_tui was `(args.session_id is None) != args.new` —
  backwards (fails when --session is set + new=False). Corrected to
  `bool(args.session_id) != bool(args.new)`.
- RichLog created with markup=False (contract drafted markup=True).
  Rich interprets `[xxx]` as style markup and strips it, which would
  break every labeled stderr-style line ([cancel_failed], [done],
  [error], etc.). The post-Done Markdown rendering still works
  because rich.markdown.Markdown is a Renderable and doesn't need
  widget-level markup.

Implementation notes:
- _stream_turn_worker takes the log widget as a parameter passed
  from on_input_submitted. Querying #transcript from inside a
  Textual worker context fails with NoMatches; capturing the
  reference once at handler-time and threading it through the
  worker sidesteps the issue.
- _spy_writes(monkeypatch) test helper records every RichLog.write
  call. RichLog's `.lines` Strip buffer isn't populated
  synchronously after .write() returns, which makes
  post-app-shutdown inspection unreliable; a write-spy gives
  deterministic verification.
- SIGINT-mid-stream tests use custom httpx.AsyncByteStream
  subclasses with asyncio.Event gates to make timing deterministic
  without sleep-based polling — the cancel-respx-mock sets the
  gate event when its endpoint is observed, releasing the next
  SSE chunk.
- _submit_and_wait test helper needs `await pilot.pause()` BEFORE
  the polling loop so the Input.Submitted message has a chance to
  dispatch. Discovered via debug-print trace; tracked in the test
  helper.

CLI amendments (per issue #4 in-place amendment of #3 contract):
- ParsedArgs.send_content: str | None (was str)
- ParsedArgs.raw: bool added
- _parse_args: --send default=None; empty-string still rejected;
  --raw added
- main: branches on args.send_content — None → lazy
  `from ratatoskr.tui import run_tui` + run_tui(args); else
  asyncio.run(_amain(args)). Lazy import preserves issue #3 INV-001.

Persistent-memory updated per the commit-along rule: tui module
landed, recent-decisions entries for #4 (contract + Volva + TDD),
next natural moves rotated to Volva code-review + manual smoke
against the personal Worldtree (key landed in env.sh per
infra-ops's earlier delivery).
2026-05-21 00:31:40 -07:00
vh 9717fb80e2 fix(cli): address Volva code-vs-contract drift (issue #3)
Volva code-review surfaced 5 findings against the TDD-passing
implementation; all 5 addressed.

Drift fixes (code):
- Add `assert argv is None or all(isinstance(a, str) for a in argv)`
  at both `main` and `_parse_args` entry points (PRE-001 was unenforced).
- `main` now catches `SystemExit` and returns `exc.code` verbatim —
  argparse's --help (SystemExit(0)) was escaping through main as an
  unhandled exception. Contract amended in-place to spell out the
  SystemExit-from-argparse-clean-exits passthrough in both
  `main` and `_parse_args` ERROR_ROUTING. New `help_exits_cleanly`
  test added per the contract amendment.
- Add the PRE-001 union-type assert at `_render_event` entry —
  unmatched Event variants would have silently no-op'd.
- `_run_turn` now awaits `cancel_task` in the `finally` block before
  returning. Under fast-stream + slow-cancel scenarios the
  `[cancel_failed]` line could miss being written before _run_turn
  returns, AND _amain could close the AsyncClient while the cancel
  POST was still in flight. `_cancel_and_log` swallows all errors
  per INV-009 so the await is safe.

Test gap fix:
- New `_FlushCountingIO` subclass counts flush() calls;
  `test_text_to_stdout_only` and `test_done_writes_newline_and_label`
  now assert `flush_count == 1` to verify INV-010 (per-chunk flush).
  Previously the tests would have passed even with flush removed.

Meta-note carried in persistent-memory: TDD caught central behavior
(stdout/stderr routing, exit-code mapping, create-session ordering,
SIGINT idempotence); the cross-model code review consistently catches
assert-boundary + observability-shape gaps across all three issues
(#1: 4 findings, #2: 3 findings, #3: 5 findings).

118/118 tests GREEN; ruff clean; drift check clean.
2026-05-20 22:59:26 -07:00
vh db27774c51 feat(cli): implement issue #3 contract via TDD
54 contract-listed tests authored + GREEN per the vertical-slice
ordering (_parse_args → _render_event → _cancel_and_log → _run_turn
→ _amain → main). 117/117 tests GREEN suite-wide; ruff clean.

The _run_turn race-loop is the load-bearing piece. Per iteration,
the await on the next event is raced against sigint_event.wait()
when NOT cancelling. Once SIGINT fires (with last_turn_id known),
_cancel_and_log is spawned, cancelling=True flips, and subsequent
iterations skip wait()-task creation entirely — the bug Volva
flagged in contract review would otherwise busy-wake on the
already-set event each iteration.

Implementation notes:
- _UsageErrorParser subclasses argparse.ArgumentParser and overrides
  error() to raise _ArgparseError instead of calling sys.exit;
  _parse_args catches and re-raises as UsageError per the contract's
  ERROR_ROUTING.
- _GatedStream test helper (custom httpx.AsyncByteStream that pauses
  on asyncio.Event entries) makes SIGINT-mid-stream tests deterministic
  without sleep-based timing — gates release via side-channels (the
  cancel-mock sets an event when its endpoint is observed).
- _sse_resp test helper wraps respx Response with the
  text/event-stream content-type, dedupes the boilerplate across the
  13 _run_turn tests.
- Strong-ref cancel_task local in _run_turn holds the fire-and-forget
  cancel task to suppress RUF006 / asyncio GC warning.

One in-flight contract amendment during TDD: no_busy_loop_after_cancel
test description originally said "exactly ONE wait()-shaped task" but
the natural race-loop shape produces 2 (iter 1 raced w/ text, iter 2
raced w/ sigint → flipped cancelling; iter 3+ skipped). Amended to
"TWO total wait() coroutines" with rationale; the busy-loop check is
preserved (iter 3+ MUST skip).

Persistent-memory updated per the commit-along rule: new module
landed, recent-decisions log entries for #3 (contract + Volva
paraphrase + TDD), next natural moves rotated to /volva-code-review
on the implementation.
2026-05-20 22:51:16 -07:00