Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5c1b9816d4 | |||
| 2ba4244e9e |
@@ -372,3 +372,64 @@ test layer.
|
|||||||
- Issue #7 (mid-stream robustness, `MalformedSseData`) — landed; #6's
|
- Issue #7 (mid-stream robustness, `MalformedSseData`) — landed; #6's
|
||||||
pre/in-alt-screen split is orthogonal to #7's empty-data/malformed
|
pre/in-alt-screen split is orthogonal to #7's empty-data/malformed
|
||||||
distinction (different error layers entirely).
|
distinction (different error layers entirely).
|
||||||
|
|
||||||
|
## Amendment 2026-06-30 — startup session picker (v1 coverage-audit, slice b2)
|
||||||
|
|
||||||
|
The v1 coverage-audit found `list_sessions` had **no caller** — the startup
|
||||||
|
session picker (design-brief §4: "single-session-per-launch, with a startup
|
||||||
|
picker invoked when more than one session exists ... plus flags `--session`/
|
||||||
|
`--new` to skip it") was never built. Bare TUI mode (neither `--session` nor
|
||||||
|
`--new`) was a hard usage error. This adds the picker as a pre-alt-screen
|
||||||
|
resolution step in `_resolve_then_run`, mirroring the existing `AgentPickerApp`.
|
||||||
|
|
||||||
|
**Locked design (design-brief §4):** the picker is **resume-only** (§4 negative
|
||||||
|
clause "no in-app session creation — `--new` flag only"); shown only when **>1**
|
||||||
|
session exists (exactly 1 auto-resumes; the launch intent is "resume the last
|
||||||
|
session I was poking at"). `--agent` stays a `--new` companion (forbidden in bare
|
||||||
|
mode). **bare + 0 sessions → error** `[no_sessions]` directing the operator to
|
||||||
|
`--new` (honors the "no in-app creation" clause; the friendlier
|
||||||
|
auto-fall-through-to-new alternative is deferred pending operator confirmation).
|
||||||
|
|
||||||
|
### `_parse` validation relaxation (ratatoskr.cli._parse)
|
||||||
|
|
||||||
|
- Bare TUI mode (`send is None` AND no `--session` AND no `--new`) is now VALID
|
||||||
|
→ triggers the picker. (Previously `raise UsageError("pass exactly one of
|
||||||
|
--session or --new")` unconditionally.)
|
||||||
|
- `--send` mode still requires exactly one of `--session`/`--new` (non-
|
||||||
|
interactive: no picker can open) → `UsageError("--send requires --session or
|
||||||
|
--new")`.
|
||||||
|
- `--session` + `--new` stays mutually exclusive.
|
||||||
|
- `--agent` in bare mode → `UsageError` (`--agent` belongs to `--new`).
|
||||||
|
|
||||||
|
```contract
|
||||||
|
FN SessionPickerApp.__init__(self, sessions: list[SessionInfo]) -> None
|
||||||
|
BRIEF: Textual App[str | None] startup session picker (mirrors AgentPickerApp, issue #8). Opens before RatatoskrApp when bare TUI mode resolves >1 session. `run_async()` returns the chosen session_id (str) or None on Esc/Ctrl-D/Ctrl-C dismissal. Architecturally separate from RatatoskrApp (list_sessions failures + dismissal land before any alt-screen — preserves #6 INV-001).
|
||||||
|
PRE: [PRE-001 hard] sessions is non-empty -- assert sessions (caller resolves 0-session and 1-session cases BEFORE constructing the picker)
|
||||||
|
POST: [POST-001 return_value] run_async() returns sessions[i].session_id for the highlighted row on `pick`, or None on dismiss -- assert result in {s.session_id for s in sessions} | {None}
|
||||||
|
STEPS:
|
||||||
|
1. [setup, prescriptive] Store sessions; register the Australis theme (mirror AgentPickerApp).
|
||||||
|
2. [sequential, prescriptive] compose: Header + prompt Static + ListView of one ListItem per session (id-short + agent_id + last_active/name lines) + Footer.
|
||||||
|
3. [sequential, prescriptive] BINDINGS: enter→action_pick, escape/ctrl+d/ctrl+c→action_dismiss.
|
||||||
|
4. [branch, prescriptive] action_pick: read ListView.index; if None return (nothing highlighted); else exit(sessions[index].session_id). action_dismiss: exit(None).
|
||||||
|
TESTS:
|
||||||
|
pick_returns_session_id [happy,tracer]: SessionPickerApp([s0, s1]); pilot highlights row 1 + press enter → run_async() returns s1.session_id.
|
||||||
|
dismiss_returns_none [happy]: press escape → run_async() returns None.
|
||||||
|
ctrl_d_dismisses [adversarial]: press ctrl+d → None.
|
||||||
|
|
||||||
|
FN _resolve_then_run(args) — bare-mode extension (session picker)
|
||||||
|
BRIEF: Before the existing new/resume branches, resolve bare TUI mode (not args.new AND args.session_id is None) via list_sessions + the picker. Sets a local `effective_new` and `resolved_session_id`; the existing branches then run unchanged on those locals.
|
||||||
|
STEPS (inserted at the top of the `async with client` block):
|
||||||
|
1. [setup, prescriptive] SET effective_new = args.new; resolved_session_id = args.session_id.
|
||||||
|
2. [branch, prescriptive] IF (not args.new) AND (args.session_id is None): # bare mode
|
||||||
|
a. CALL list_sessions(client) → page; ON SessionApiFailed → stderr `[session_api_failed]` + return 20; ON network error → `[network_error]` + return 21.
|
||||||
|
b. IF not page.items: stderr `[no_sessions] no sessions to resume; launch with --new --agent <id>` + return 14.
|
||||||
|
c. ELIF len(page.items) == 1: SET resolved_session_id = page.items[0].session_id. # §4: picker only when >1
|
||||||
|
d. ELSE: SET resolved_session_id = await SessionPickerApp(page.items).run_async(); IF None → return 0 (Esc/Ctrl-D clean exit).
|
||||||
|
3. [sequential, prescriptive] Replace the two `if args.new` predicates with `if effective_new`; the resume `else` branch asserts + uses `resolved_session_id`.
|
||||||
|
TESTS (in the `_resolve_then_run` block):
|
||||||
|
bare_zero_sessions_errors [error]: bare args; list_sessions → 0 items → stderr contains `[no_sessions]`; return 14; NO POST /sessions, NO picker.
|
||||||
|
bare_one_session_auto_resumes [scenario]: bare args; list_sessions → 1 item (sid="s-solo") → RatatoskrApp constructed with session_id="s-solo"; NO picker shown.
|
||||||
|
bare_multi_opens_picker [scenario,tracer]: bare args; list_sessions → 2 items; picker returns items[1].session_id → RatatoskrApp constructed with that session_id.
|
||||||
|
bare_picker_dismiss_exits_zero [scenario]: bare args; 2 items; picker returns None → return 0; RatatoskrApp NOT constructed.
|
||||||
|
bare_list_sessions_api_failure [error]: bare args; list_sessions raises SessionApiFailed(500) → stderr `[session_api_failed]`; return 20.
|
||||||
|
```
|
||||||
|
|||||||
@@ -149,7 +149,9 @@ decision. Captures rationale that won't be obvious from code alone.
|
|||||||
- `[2026-06-30]` **Finding P-1 (pin drift) + pin-remediation PENDING.** We vendor the PROSE markdown (`docs/conversation-api-spec.md`), which is **byte-identical to live WT's** but frozen at v0.35.16-era content (last WT edit 2026-05-31) — it does NOT capture b2: 7 new endpoints (admin/keys/bulk, admin/persona/{archive,erase}, admin/usage, embed, judgments, me/usage), the 409/503 on messages-POST (#331), the unified error envelope (#328), or the SSE schema. **WT's authoritative v1 truth is now the FROZEN OpenAPI 2.2.0 + SSE-schema JSON** (`Worldtree/docs/v1-schema-freeze-manifest.md`). So the previously-deferred "re-vendor markdown to b2" is a **near-no-op** (markdown content identical). **Pending operator nod:** re-pin to the machine-readable artifacts (recommended — drift-checkable via `canonical_drift.py`, makes the coverage map reproducible vs a frozen diffable target) vs markdown-only. Deferred (not auto-applied) because it adds vendored artifacts + a canonical-sync pin = substrate change with CI-gating reach. **→ RESOLVED 2026-06-30 (operator: "a then b").** Vendored `conversation-api-openapi.json` (2.2.0) + `conversation-api-sse-events.schema.json` + re-copied the prose markdown; pinned all three in `.corviduo-canonicals.toml` (OpenAPI+SSE = strict drift gates, markdown = `tolerate_drift` reference); advanced `worldtree-spec-rev` f1b59f8→5810a26 + `worldtree-version` v0.29.0(STALE, never bumped from the v0.35.16 pin)→v1.0.0b2 + `pinned-on`→2026-06-30; SPEC-PIN.md history row added. `canonical_drift.py` green (10/10). `pin:`-only, no version bump (no client-facing code change; the b2 409/503 + error-envelope were already consumed in v0.18.3/.4).
|
- `[2026-06-30]` **Finding P-1 (pin drift) + pin-remediation PENDING.** We vendor the PROSE markdown (`docs/conversation-api-spec.md`), which is **byte-identical to live WT's** but frozen at v0.35.16-era content (last WT edit 2026-05-31) — it does NOT capture b2: 7 new endpoints (admin/keys/bulk, admin/persona/{archive,erase}, admin/usage, embed, judgments, me/usage), the 409/503 on messages-POST (#331), the unified error envelope (#328), or the SSE schema. **WT's authoritative v1 truth is now the FROZEN OpenAPI 2.2.0 + SSE-schema JSON** (`Worldtree/docs/v1-schema-freeze-manifest.md`). So the previously-deferred "re-vendor markdown to b2" is a **near-no-op** (markdown content identical). **Pending operator nod:** re-pin to the machine-readable artifacts (recommended — drift-checkable via `canonical_drift.py`, makes the coverage map reproducible vs a frozen diffable target) vs markdown-only. Deferred (not auto-applied) because it adds vendored artifacts + a canonical-sync pin = substrate change with CI-gating reach. **→ RESOLVED 2026-06-30 (operator: "a then b").** Vendored `conversation-api-openapi.json` (2.2.0) + `conversation-api-sse-events.schema.json` + re-copied the prose markdown; pinned all three in `.corviduo-canonicals.toml` (OpenAPI+SSE = strict drift gates, markdown = `tolerate_drift` reference); advanced `worldtree-spec-rev` f1b59f8→5810a26 + `worldtree-version` v0.29.0(STALE, never bumped from the v0.35.16 pin)→v1.0.0b2 + `pinned-on`→2026-06-30; SPEC-PIN.md history row added. `canonical_drift.py` green (10/10). `pin:`-only, no version bump (no client-facing code change; the b2 409/503 + error-envelope were already consumed in v0.18.3/.4).
|
||||||
|
|
||||||
- `[2026-06-30]` **(b) Tier-1 frontier SCOPED, ready for a contract-first TDD cycle (next focused work).** The primitives already exist + are contracted + tested; the gap is PRESENTER-level wiring. Two slices: **(b1) SSE-resume** — contract #1 (`ratatoskr.sse_client`) DELIBERATELY makes resume caller-owned ("on `SseConnectionDropped`, the caller MAY invoke `reconnect_turn`"); `reconnect_turn` (sse_client.py:524) has NO caller. Gap = a SHARED resume-orchestration wrapper (catch `SseConnectionDropped` → track last-seen `sse_id` → `reconnect_turn` → continue), consumed by all 3 presenters per design-brief §8b "share the consumer, branch the presenter" (NOT per-presenter — that forks the consumer). New function block → **amend contract #1** (additive FN, e.g. `stream_turn_resilient`) then TDD (RED: drop-mid-stream→resume continuity; GREEN: wrapper; wire `cli --send` first as the tracer). Resume design pre-locked: in-process Last-Event-ID only, cross-process deferred to v2 (design-brief §8d). **(b2) session-picker** — `list_sessions` (sessions.py:198) has NO caller; add a Textual DataTable startup picker (>1 session) + `--session <id>`/`--new` CLI flags (design-brief §4, decisions pre-locked). Both pre-locked → heid-contract-review likely skippable as ceremony (small additive amendments to mature specs); heid-code-review still valuable. **#11 AdminEvents stays BLOCKED** on `admin.events.read` scope (infra-ops).
|
- `[2026-06-30]` **(b) Tier-1 frontier SCOPED, ready for a contract-first TDD cycle (next focused work).** The primitives already exist + are contracted + tested; the gap is PRESENTER-level wiring. Two slices: **(b1) SSE-resume** — contract #1 (`ratatoskr.sse_client`) DELIBERATELY makes resume caller-owned ("on `SseConnectionDropped`, the caller MAY invoke `reconnect_turn`"); `reconnect_turn` (sse_client.py:524) has NO caller. Gap = a SHARED resume-orchestration wrapper (catch `SseConnectionDropped` → track last-seen `sse_id` → `reconnect_turn` → continue), consumed by all 3 presenters per design-brief §8b "share the consumer, branch the presenter" (NOT per-presenter — that forks the consumer). New function block → **amend contract #1** (additive FN, e.g. `stream_turn_resilient`) then TDD (RED: drop-mid-stream→resume continuity; GREEN: wrapper; wire `cli --send` first as the tracer). Resume design pre-locked: in-process Last-Event-ID only, cross-process deferred to v2 (design-brief §8d). **(b2) session-picker** — `list_sessions` (sessions.py:198) has NO caller; add a Textual DataTable startup picker (>1 session) + `--session <id>`/`--new` CLI flags (design-brief §4, decisions pre-locked). Both pre-locked → heid-contract-review likely skippable as ceremony (small additive amendments to mature specs); heid-code-review still valuable. **#11 AdminEvents stays BLOCKED** on `admin.events.read` scope (infra-ops).
|
||||||
- `[2026-06-30]` **(b1) SSE-resume SHIPPED (`v0.18.5`) — `stream_turn_resilient` (sse_client.py).** The shared resume-orchestration surface (design-brief §8b): wraps `stream_turn`+`reconnect_turn`, catches `SseConnectionDropped` (mid-stream drop OR clean-EOF-before-terminal) → resumes from last-seen `sse_id` via `reconnect_turn` (Last-Event-ID), up to `max_reconnects` (default 5); non-drop reconnect failures (412/410/400/TurnIdFlip/SseConnectFailed) PROPAGATE per contract #1's "surface, not recover". `last_seen` persists ACROSS attempts (a zero-event reconnect drop falls back to the prior attempt's id). Direct in-session TDD against a contract-#1 amendment (8 cases incl. two-drops, max-reconnects-exhausted, zero-budget, buffer-expired-propagates, unresumable-zero-event). Wired `cli --send` (`cli.py:396`, swapped `stream_turn`→`stream_turn_resilient`; **tui/web still on bare `stream_turn` — follow-up to route them through the wrapper**). Suite 518 green; ruff+mypy clean on touched code (pre-existing cli.py:400/543 mypy warts left untouched per surgical rule); contract #1 validates OK. **heid-code-review NOT run** (small additive well-TDD'd wrapper; offered to operator). **b2 (session-picker + `--session`/`--new` flags) still pending.**
|
- `[2026-06-30]` **(b1) SSE-resume SHIPPED (`v0.18.5`) — `stream_turn_resilient` (sse_client.py).** The shared resume-orchestration surface (design-brief §8b): wraps `stream_turn`+`reconnect_turn`, catches `SseConnectionDropped` (mid-stream drop OR clean-EOF-before-terminal) → resumes from last-seen `sse_id` via `reconnect_turn` (Last-Event-ID), up to `max_reconnects` (default 5); non-drop reconnect failures (412/410/400/TurnIdFlip/SseConnectFailed) PROPAGATE per contract #1's "surface, not recover". `last_seen` persists ACROSS attempts (a zero-event reconnect drop falls back to the prior attempt's id). Direct in-session TDD against a contract-#1 amendment (8 cases incl. two-drops, max-reconnects-exhausted, zero-budget, buffer-expired-propagates, unresumable-zero-event). Wired ALL THREE presenters through it (`v0.18.6`): `cli --send` (`cli.py:396`), TUI (`tui.py:1321`), web (`web/server.py:294`) — each a name-for-name `stream_turn`→`stream_turn_resilient` swap (the §8b "all presenters share the consumer" promise, fully kept; the TUI is the primary resume beneficiary — long-lived sessions / laptop-suspend). Suite 518 green; ruff+mypy clean on touched code (pre-existing cli.py:400/543 mypy warts left untouched per surgical rule); contract #1 validates OK. **heid-code-review NOT run** (small additive well-TDD'd wrapper; offered to operator). **b2 (session-picker + `--session`/`--new` flags) still pending.**
|
||||||
|
|
||||||
|
- `[2026-06-30]` **(b2) session-picker SHIPPED (`v0.18.7`) — bare TUI mode → startup picker (design-brief §4).** `list_sessions` had NO caller; now bare TUI mode (no `--session`/`--new`) resolves via `list_sessions` in `_resolve_then_run`: **0 sessions → `[no_sessions]` error, exit 14** (resume-only, honors §4 "no in-app session creation — `--new` flag only"); **exactly 1 → auto-resume** (§4 "picker only when >1"); **≥2 → new `SessionPickerApp`** (Textual `App[str|None]`, mirrors `AgentPickerApp`; ListView of sessions) → resume the pick (Esc/Ctrl-D → exit 0). cli `_parse` relaxed: bare TUI now VALID (was "pass exactly one" error); `--send` still requires one flag (non-interactive, no picker); `--agent` forbidden in bare mode; `run_tui` PRE-002 XOR→"not both". Direct in-session TDD (contract #6 amendment, validated OK): 3 widget pilot tests + 5 `_resolve_then_run` resolution tests + 3 cli validation tests. Suite **528 green**; touched code ruff-clean (mypy: only the `BINDINGS` list-invariance warning every App in tui.py already carries — consistent). **DESIGN NOTE — bare+0-sessions → error (clause-consistent). The friendlier auto-fall-through-to-new alternative is DEFERRED pending operator preference (it would create a session without `--new`, against the §4 negative clause).** **Frontier now: `GET /capabilities`+`GET /me` → BifrostState/Tools widgets (`GET /admin/sessions/{id}/{bifrost,tools}`, admin-key) → #11 AdminEvents (BLOCKED on `admin.events.read`).** heid-code-review NOT run on b1 or b2 (offered).
|
||||||
|
|
||||||
_41 older entries (2026-05-* — the original debug-TUI/web build era) archived to archival-memory.md._
|
_41 older entries (2026-05-* — the original debug-TUI/web build era) archived to archival-memory.md._
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "ratatoskr"
|
name = "ratatoskr"
|
||||||
version = "0.18.5"
|
version = "0.18.7"
|
||||||
description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard"
|
description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
|
|||||||
@@ -142,9 +142,15 @@ def _parse_args(argv: list[str] | None) -> ParsedArgs:
|
|||||||
if ns.end_user_id is not None and not ns.end_user_id:
|
if ns.end_user_id is not None and not ns.end_user_id:
|
||||||
raise UsageError("--end-user-id must be non-empty when passed")
|
raise UsageError("--end-user-id must be non-empty when passed")
|
||||||
if ns.session and ns.new:
|
if ns.session and ns.new:
|
||||||
raise UsageError("--session and --new are mutually exclusive; pass exactly one")
|
raise UsageError("--session and --new are mutually exclusive")
|
||||||
if not ns.session and not ns.new:
|
if not ns.session and not ns.new:
|
||||||
raise UsageError("pass exactly one of --session or --new")
|
# Bare TUI mode → startup session picker (design-brief §4). --send is
|
||||||
|
# non-interactive (no picker can open), so it still requires one flag;
|
||||||
|
# --agent belongs with --new (bare mode resumes, it doesn't create).
|
||||||
|
if ns.send is not None:
|
||||||
|
raise UsageError("--send requires --session or --new (no interactive picker)")
|
||||||
|
if ns.agent:
|
||||||
|
raise UsageError("--agent belongs with --new; bare TUI mode opens the session picker")
|
||||||
if ns.session and ns.agent:
|
if ns.session and ns.agent:
|
||||||
raise UsageError("--agent is required with --new and forbidden with --session")
|
raise UsageError("--agent is required with --new and forbidden with --session")
|
||||||
if ns.new and not ns.agent and ns.send is not None:
|
if ns.new and not ns.agent and ns.send is not None:
|
||||||
|
|||||||
+160
-6
@@ -42,9 +42,11 @@ from ratatoskr.sessions import (
|
|||||||
BifrostHandshakeFailed,
|
BifrostHandshakeFailed,
|
||||||
PersonaNotConfigured,
|
PersonaNotConfigured,
|
||||||
SessionApiFailed,
|
SessionApiFailed,
|
||||||
|
SessionInfo,
|
||||||
create_session,
|
create_session,
|
||||||
get_persona_state,
|
get_persona_state,
|
||||||
list_agents,
|
list_agents,
|
||||||
|
list_sessions,
|
||||||
)
|
)
|
||||||
from ratatoskr.sse_client import (
|
from ratatoskr.sse_client import (
|
||||||
AffectUpdate,
|
AffectUpdate,
|
||||||
@@ -68,7 +70,7 @@ from ratatoskr.sse_client import (
|
|||||||
TurnIdFlip,
|
TurnIdFlip,
|
||||||
WorkerPhase,
|
WorkerPhase,
|
||||||
cancel_turn,
|
cancel_turn,
|
||||||
stream_turn,
|
stream_turn_resilient,
|
||||||
)
|
)
|
||||||
|
|
||||||
# ---- Australis theme (https://github.com/lkraven/australis) ------------------
|
# ---- Australis theme (https://github.com/lkraven/australis) ------------------
|
||||||
@@ -798,6 +800,128 @@ class AgentPickerApp(App[str | None]):
|
|||||||
self.exit(None)
|
self.exit(None)
|
||||||
|
|
||||||
|
|
||||||
|
def _session_desc(s: SessionInfo) -> str:
|
||||||
|
"""One-line session summary for the picker's second row."""
|
||||||
|
tail = f"session {s.session_id} · last active {s.last_active}"
|
||||||
|
if s.message_count is not None:
|
||||||
|
tail += f" · {s.message_count} msgs"
|
||||||
|
return tail
|
||||||
|
|
||||||
|
|
||||||
|
class SessionPickerApp(App[str | None]):
|
||||||
|
"""Startup session picker (design-brief §4, slice b2). Opens before
|
||||||
|
RatatoskrApp when bare TUI mode resolves >1 session. `run_async()` returns
|
||||||
|
the chosen session_id (str) or None on Esc/Ctrl-D/Ctrl-C dismissal.
|
||||||
|
|
||||||
|
Resume-only (design-brief §4 negative clause "no in-app session creation —
|
||||||
|
--new flag only"): the picker chooses among EXISTING sessions; starting a
|
||||||
|
fresh one is the --new flag's job. Architecturally separate from
|
||||||
|
RatatoskrApp (mirrors AgentPickerApp): list_sessions failures + dismissal
|
||||||
|
land before any alt-screen opens (preserves #6 INV-001).
|
||||||
|
"""
|
||||||
|
|
||||||
|
DEFAULT_CSS = """
|
||||||
|
Header, HeaderIcon, HeaderTitle, HeaderClock {
|
||||||
|
background: $surface;
|
||||||
|
color: $au-bright-blue;
|
||||||
|
}
|
||||||
|
Footer {
|
||||||
|
background: $surface;
|
||||||
|
}
|
||||||
|
ListView {
|
||||||
|
scrollbar-background: $background;
|
||||||
|
scrollbar-background-hover: $background;
|
||||||
|
scrollbar-background-active: $background;
|
||||||
|
scrollbar-color: $au-dark-50;
|
||||||
|
scrollbar-color-hover: $au-dark-60;
|
||||||
|
scrollbar-color-active: $au-bright-cyan;
|
||||||
|
}
|
||||||
|
#picker-prompt {
|
||||||
|
dock: top;
|
||||||
|
height: 1;
|
||||||
|
padding: 0 1;
|
||||||
|
color: $au-bright-cyan;
|
||||||
|
background: $surface;
|
||||||
|
}
|
||||||
|
#session-list {
|
||||||
|
height: 1fr;
|
||||||
|
background: $background;
|
||||||
|
}
|
||||||
|
#session-list > ListItem {
|
||||||
|
height: auto;
|
||||||
|
padding: 1 1;
|
||||||
|
background: $background;
|
||||||
|
}
|
||||||
|
#session-list:focus ListItem.-highlight {
|
||||||
|
background: $primary;
|
||||||
|
}
|
||||||
|
#session-list:focus ListItem.-highlight .session-id-line {
|
||||||
|
color: $au-bright-white;
|
||||||
|
text-style: bold;
|
||||||
|
}
|
||||||
|
#session-list:focus ListItem.-highlight .session-desc {
|
||||||
|
color: $au-bright-80;
|
||||||
|
}
|
||||||
|
.session-id-line {
|
||||||
|
color: $au-bright-blue;
|
||||||
|
text-style: bold;
|
||||||
|
}
|
||||||
|
.session-desc {
|
||||||
|
color: $au-bright-70;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
BINDINGS: ClassVar[list[Binding]] = [
|
||||||
|
Binding("enter", "pick", "Resume", priority=True),
|
||||||
|
Binding("escape", "dismiss", "Cancel", priority=True),
|
||||||
|
Binding("ctrl+d", "dismiss", "Cancel", priority=True),
|
||||||
|
Binding("ctrl+c", "dismiss", "Cancel", priority=True),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self, sessions: list[SessionInfo]) -> None:
|
||||||
|
super().__init__()
|
||||||
|
# PRE-001: caller (_resolve_then_run) resolves the 0-session and
|
||||||
|
# 1-session cases BEFORE constructing the picker.
|
||||||
|
assert sessions
|
||||||
|
self.sessions = sessions
|
||||||
|
self.register_theme(AUSTRALIS_THEME)
|
||||||
|
self.theme = "australis"
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield Header()
|
||||||
|
yield Static(
|
||||||
|
"Pick a session to resume (relaunch with --new for a fresh one):",
|
||||||
|
id="picker-prompt",
|
||||||
|
)
|
||||||
|
yield ListView(
|
||||||
|
*[
|
||||||
|
ListItem(
|
||||||
|
Static(
|
||||||
|
f"{s.name or s.session_id} · {s.agent_id}",
|
||||||
|
classes="session-id-line",
|
||||||
|
),
|
||||||
|
Static(_session_desc(s), classes="session-desc"),
|
||||||
|
)
|
||||||
|
for s in self.sessions
|
||||||
|
],
|
||||||
|
id="session-list",
|
||||||
|
)
|
||||||
|
yield Footer()
|
||||||
|
|
||||||
|
async def on_mount(self) -> None:
|
||||||
|
self.query_one("#session-list", ListView).focus()
|
||||||
|
|
||||||
|
def action_pick(self) -> None:
|
||||||
|
lv = self.query_one("#session-list", ListView)
|
||||||
|
idx = lv.index
|
||||||
|
if idx is None:
|
||||||
|
return # nothing highlighted; ignore
|
||||||
|
self.exit(self.sessions[idx].session_id)
|
||||||
|
|
||||||
|
def action_dismiss(self) -> None:
|
||||||
|
self.exit(None)
|
||||||
|
|
||||||
|
|
||||||
class RatatoskrApp(App[int]):
|
class RatatoskrApp(App[int]):
|
||||||
"""Textual TUI shell — single chat pane."""
|
"""Textual TUI shell — single chat pane."""
|
||||||
|
|
||||||
@@ -1318,7 +1442,7 @@ class RatatoskrApp(App[int]):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async for event in stream_turn(self.client, self.session_id, content):
|
async for event in stream_turn_resilient(self.client, self.session_id, content):
|
||||||
if self.active_turn_id is None:
|
if self.active_turn_id is None:
|
||||||
self.active_turn_id = event.sse_id.turn_id
|
self.active_turn_id = event.sse_id.turn_id
|
||||||
self._write_turn_headers(self.active_turn_id)
|
self._write_turn_headers(self.active_turn_id)
|
||||||
@@ -1430,8 +1554,9 @@ def run_tui(args: ParsedArgs) -> int:
|
|||||||
"""
|
"""
|
||||||
# PRE-001: TUI-mode marker (issue #4 contract)
|
# PRE-001: TUI-mode marker (issue #4 contract)
|
||||||
assert isinstance(args, ParsedArgs) and args.send_content is None
|
assert isinstance(args, ParsedArgs) and args.send_content is None
|
||||||
# PRE-002: Exactly one of session_id / new must be set (xor)
|
# PRE-002 (slice b2): --session and --new are mutually exclusive, but NEITHER
|
||||||
assert bool(args.session_id) != bool(args.new)
|
# is now valid — bare TUI mode opens the startup session picker (§4).
|
||||||
|
assert not (args.session_id and args.new)
|
||||||
return asyncio.run(_resolve_then_run(args))
|
return asyncio.run(_resolve_then_run(args))
|
||||||
|
|
||||||
|
|
||||||
@@ -1467,6 +1592,35 @@ async def _resolve_then_run(args: ParsedArgs) -> int:
|
|||||||
# agent_id (remote wins on conflict, since a server-listed agent
|
# agent_id (remote wins on conflict, since a server-listed agent
|
||||||
# is the authoritative source).
|
# is the authoritative source).
|
||||||
chosen_agent_id: str | None = args.agent_id
|
chosen_agent_id: str | None = args.agent_id
|
||||||
|
# slice b2: bare TUI mode (no --session, no --new) → startup session
|
||||||
|
# picker (design-brief §4). Resolve into a concrete session_id BEFORE
|
||||||
|
# the new/resume branches. Resume-only: bare + 0 sessions is an error
|
||||||
|
# (creating a session is the --new flag's job).
|
||||||
|
resolved_session_id: str | None = args.session_id
|
||||||
|
if not args.new and args.session_id is None:
|
||||||
|
try:
|
||||||
|
page = await list_sessions(client)
|
||||||
|
except SessionApiFailed as exc:
|
||||||
|
sys.stderr.write(
|
||||||
|
f"[session_api_failed] status={exc.status} body={exc.body!r}\n"
|
||||||
|
)
|
||||||
|
return 20
|
||||||
|
except (httpx.ConnectError, httpx.ReadTimeout, httpx.TransportError) as exc:
|
||||||
|
sys.stderr.write(f"[network_error] {type(exc).__name__}: {exc}\n")
|
||||||
|
return 21
|
||||||
|
if not page.items:
|
||||||
|
sys.stderr.write(
|
||||||
|
"[no_sessions] no sessions to resume; "
|
||||||
|
"launch with --new --agent <id>\n"
|
||||||
|
)
|
||||||
|
return 14
|
||||||
|
if len(page.items) == 1:
|
||||||
|
# §4: picker only when >1 — a single session auto-resumes.
|
||||||
|
resolved_session_id = page.items[0].session_id
|
||||||
|
else:
|
||||||
|
resolved_session_id = await SessionPickerApp(page.items).run_async()
|
||||||
|
if resolved_session_id is None:
|
||||||
|
return 0 # Esc / Ctrl-D — clean exit, no session opened
|
||||||
if args.new and args.agent_id is None:
|
if args.new and args.agent_id is None:
|
||||||
try:
|
try:
|
||||||
agents = await list_agents(client)
|
agents = await list_agents(client)
|
||||||
@@ -1551,8 +1705,8 @@ async def _resolve_then_run(args: ParsedArgs) -> int:
|
|||||||
session_id = info.session_id
|
session_id = info.session_id
|
||||||
agent_id: str | None = info.agent_id
|
agent_id: str | None = info.agent_id
|
||||||
else:
|
else:
|
||||||
assert args.session_id is not None
|
assert resolved_session_id is not None
|
||||||
session_id = args.session_id
|
session_id = resolved_session_id
|
||||||
agent_id = args.agent_id # may be None — INV-002 carve-out preserved
|
agent_id = args.agent_id # may be None — INV-002 carve-out preserved
|
||||||
app = RatatoskrApp(args, session_id=session_id, agent_id=agent_id, client=client)
|
app = RatatoskrApp(args, session_id=session_id, agent_id=agent_id, client=client)
|
||||||
exit_code = await app.run_async()
|
exit_code = await app.run_async()
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ from ratatoskr.sse_client import (
|
|||||||
SseConnectionDropped,
|
SseConnectionDropped,
|
||||||
TurnIdFlip,
|
TurnIdFlip,
|
||||||
cancel_turn,
|
cancel_turn,
|
||||||
stream_turn,
|
stream_turn_resilient,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -291,7 +291,7 @@ async def _stream_turn_endpoint(request: Request) -> StreamingResponse:
|
|||||||
try:
|
try:
|
||||||
handle.status = "streaming"
|
handle.status = "streaming"
|
||||||
try:
|
try:
|
||||||
async for event in stream_turn(client, session_id, handle.content):
|
async for event in stream_turn_resilient(client, session_id, handle.content):
|
||||||
# v0.16.0: capture the upstream (Worldtree-assigned)
|
# v0.16.0: capture the upstream (Worldtree-assigned)
|
||||||
# turn_id from the first event so cancel paths target
|
# turn_id from the first event so cancel paths target
|
||||||
# the real upstream turn, not our local counter.
|
# the real upstream turn, not our local counter.
|
||||||
|
|||||||
+30
-6
@@ -175,10 +175,29 @@ class TestParseArgs:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def test_usage_neither_session_nor_new(self) -> None:
|
def test_usage_neither_session_nor_new(self) -> None:
|
||||||
"""usage_neither_session_nor_new: neither flag → UsageError('pass exactly one')."""
|
"""usage_neither_session_nor_new: --send with neither flag → UsageError.
|
||||||
with pytest.raises(UsageError, match="pass exactly one"):
|
|
||||||
|
--send is non-interactive (no picker can open), so a session must be
|
||||||
|
named. Bare TUI mode (no --send) is now valid → session picker (§4).
|
||||||
|
"""
|
||||||
|
with pytest.raises(UsageError, match="--send requires"):
|
||||||
_parse_args(["--send", "hi", "--api-key", "k"])
|
_parse_args(["--send", "hi", "--api-key", "k"])
|
||||||
|
|
||||||
|
def test_bare_tui_mode_accepted(self) -> None:
|
||||||
|
"""bare_tui_mode (slice b2): no --send, no --session, no --new → valid;
|
||||||
|
_resolve_then_run drives the startup session picker (design-brief §4)."""
|
||||||
|
args = _parse_args(["--api-key", "k"])
|
||||||
|
assert args.send_content is None
|
||||||
|
assert args.session_id is None
|
||||||
|
assert args.new is False
|
||||||
|
assert args.agent_id is None
|
||||||
|
|
||||||
|
def test_usage_bare_tui_with_agent(self) -> None:
|
||||||
|
"""bare_tui_with_agent (slice b2): bare TUI + --agent → UsageError
|
||||||
|
(--agent belongs with --new; bare mode opens the resume picker)."""
|
||||||
|
with pytest.raises(UsageError, match="belongs with --new"):
|
||||||
|
_parse_args(["--agent", "mimir", "--api-key", "k"])
|
||||||
|
|
||||||
def test_usage_send_new_without_agent(self) -> None:
|
def test_usage_send_new_without_agent(self) -> None:
|
||||||
"""send_new_without_agent (issue #8): --send --new without --agent → UsageError.
|
"""send_new_without_agent (issue #8): --send --new without --agent → UsageError.
|
||||||
|
|
||||||
@@ -1319,10 +1338,15 @@ class TestMain:
|
|||||||
rc = main(["--send", "hi", "--new", "--agent", "m", "--api-key", "k"])
|
rc = main(["--send", "hi", "--new", "--agent", "m", "--api-key", "k"])
|
||||||
assert rc == 0
|
assert rc == 0
|
||||||
|
|
||||||
def test_usage_error_no_send(
|
def test_empty_argv_fails_on_auth(
|
||||||
self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||||
) -> None:
|
) -> None:
|
||||||
"""usage_error_no_send: empty argv → exit 10; stderr [usage_error]; _amain never called."""
|
"""empty argv → exit 11 [auth_error]; _amain never called.
|
||||||
|
|
||||||
|
Since slice b2 bare TUI mode (no --send/--session/--new) is VALID (it
|
||||||
|
opens the session picker), so empty argv is no longer a usage error —
|
||||||
|
it now fails on the missing API key instead (still before _amain).
|
||||||
|
"""
|
||||||
amain_calls: list[int] = []
|
amain_calls: list[int] = []
|
||||||
|
|
||||||
async def fake_amain(args: ParsedArgs) -> int:
|
async def fake_amain(args: ParsedArgs) -> int:
|
||||||
@@ -1331,8 +1355,8 @@ class TestMain:
|
|||||||
|
|
||||||
monkeypatch.setattr(cli_mod, "_amain", fake_amain)
|
monkeypatch.setattr(cli_mod, "_amain", fake_amain)
|
||||||
rc = main([])
|
rc = main([])
|
||||||
assert rc == 10
|
assert rc == 11
|
||||||
assert "[usage_error]" in capsys.readouterr().err
|
assert "[auth_error]" in capsys.readouterr().err
|
||||||
assert amain_calls == []
|
assert amain_calls == []
|
||||||
|
|
||||||
def test_usage_error_both_session_and_new(
|
def test_usage_error_both_session_and_new(
|
||||||
|
|||||||
@@ -2945,3 +2945,225 @@ class TestTuiBifrostBind:
|
|||||||
err = capsys.readouterr().err
|
err = capsys.readouterr().err
|
||||||
assert "bifrost: status=bound" in err
|
assert "bifrost: status=bound" in err
|
||||||
assert "plane=memory" in err
|
assert "plane=memory" in err
|
||||||
|
|
||||||
|
|
||||||
|
class TestSessionPickerApp:
|
||||||
|
"""docs/contracts/issues/6.contract.md FN SessionPickerApp (amendment slice b2)."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _two():
|
||||||
|
from ratatoskr.sessions import SessionInfo
|
||||||
|
|
||||||
|
return [
|
||||||
|
SessionInfo(
|
||||||
|
session_id="s-first-0001", agent_id="mimir", created_at="t0",
|
||||||
|
last_active="t1", metadata={}, message_count=3, name=None,
|
||||||
|
archived=False, tags=[],
|
||||||
|
),
|
||||||
|
SessionInfo(
|
||||||
|
session_id="s-second-002", agent_id="echo", created_at="t0",
|
||||||
|
last_active="t2", metadata={}, message_count=None, name="probe",
|
||||||
|
archived=False, tags=[],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_pick_returns_session_id(self) -> None:
|
||||||
|
"""pick_returns_session_id [happy,tracer]: idx 1 + Enter → exit value == that session_id."""
|
||||||
|
from ratatoskr.tui import SessionPickerApp
|
||||||
|
|
||||||
|
app = SessionPickerApp(self._two())
|
||||||
|
|
||||||
|
async def drive() -> str | None:
|
||||||
|
async with app.run_test() as pilot:
|
||||||
|
from textual.widgets import ListView
|
||||||
|
|
||||||
|
lv = app.query_one("#session-list", ListView)
|
||||||
|
lv.index = 1
|
||||||
|
await pilot.pause()
|
||||||
|
await pilot.press("enter")
|
||||||
|
await pilot.pause()
|
||||||
|
return app.return_value
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
assert asyncio.run(drive()) == "s-second-002"
|
||||||
|
|
||||||
|
def test_esc_returns_none(self) -> None:
|
||||||
|
"""esc_returns_none [happy]: Esc → exit value is None (dismiss, resume nothing)."""
|
||||||
|
from ratatoskr.tui import SessionPickerApp
|
||||||
|
|
||||||
|
app = SessionPickerApp(self._two())
|
||||||
|
|
||||||
|
async def drive() -> str | None:
|
||||||
|
async with app.run_test() as pilot:
|
||||||
|
await pilot.press("escape")
|
||||||
|
await pilot.pause()
|
||||||
|
return app.return_value
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
assert asyncio.run(drive()) is None
|
||||||
|
|
||||||
|
def test_ctrl_d_returns_none(self) -> None:
|
||||||
|
"""ctrl_d_returns_none [adversarial]: Ctrl-D → None."""
|
||||||
|
from ratatoskr.tui import SessionPickerApp
|
||||||
|
|
||||||
|
app = SessionPickerApp(self._two())
|
||||||
|
|
||||||
|
async def drive() -> str | None:
|
||||||
|
async with app.run_test() as pilot:
|
||||||
|
await pilot.press("ctrl+d")
|
||||||
|
await pilot.pause()
|
||||||
|
return app.return_value
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
assert asyncio.run(drive()) is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestBareSessionPicker:
|
||||||
|
"""docs/contracts/issues/6.contract.md amendment (slice b2): _resolve_then_run bare mode."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _bare_args() -> ParsedArgs:
|
||||||
|
return ParsedArgs(
|
||||||
|
send_content=None, session_id=None, new=False, agent_id=None,
|
||||||
|
api_key="k", server_url="https://w.example", raw=False,
|
||||||
|
end_user_id=None, bifrost=None, bifrost_plane=None, consumer_key=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _sess(sid: str, agent: str = "mimir"):
|
||||||
|
from ratatoskr.sessions import SessionInfo
|
||||||
|
|
||||||
|
return SessionInfo(
|
||||||
|
session_id=sid, agent_id=agent, created_at="t0", last_active="t1",
|
||||||
|
metadata={}, message_count=1, name=None, archived=False, tags=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_bare_zero_sessions_errors(
|
||||||
|
self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
"""bare_zero_sessions_errors [error]: 0 sessions → exit 14 [no_sessions]; App not opened."""
|
||||||
|
import ratatoskr.tui as tui_mod
|
||||||
|
from ratatoskr.sessions import SessionPage
|
||||||
|
|
||||||
|
async def fake_list(client, **kw):
|
||||||
|
return SessionPage(items=[], next_cursor=None)
|
||||||
|
|
||||||
|
monkeypatch.setattr(tui_mod, "list_sessions", fake_list)
|
||||||
|
opened: list[int] = []
|
||||||
|
|
||||||
|
async def spy(self, *a, **k):
|
||||||
|
opened.append(1)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
monkeypatch.setattr(RatatoskrApp, "run_async", spy)
|
||||||
|
rc = run_tui(self._bare_args())
|
||||||
|
assert rc == 14
|
||||||
|
assert "[no_sessions]" in capsys.readouterr().err
|
||||||
|
assert not opened
|
||||||
|
|
||||||
|
def test_bare_one_session_auto_resumes(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""bare_one_session_auto_resumes: exactly 1 → auto-resume, no picker (§4 >1 rule)."""
|
||||||
|
import ratatoskr.tui as tui_mod
|
||||||
|
from ratatoskr.sessions import SessionPage
|
||||||
|
from ratatoskr.tui import SessionPickerApp
|
||||||
|
|
||||||
|
async def fake_list(client, **kw):
|
||||||
|
return SessionPage(items=[self._sess("s-solo")], next_cursor=None)
|
||||||
|
|
||||||
|
monkeypatch.setattr(tui_mod, "list_sessions", fake_list)
|
||||||
|
picker_used: list[int] = []
|
||||||
|
|
||||||
|
async def spy_picker(self, *a, **k):
|
||||||
|
picker_used.append(1)
|
||||||
|
return None
|
||||||
|
|
||||||
|
monkeypatch.setattr(SessionPickerApp, "run_async", spy_picker)
|
||||||
|
snap: dict = {}
|
||||||
|
|
||||||
|
async def cap(self, *a, **k):
|
||||||
|
snap["sid"] = self.session_id
|
||||||
|
return 0
|
||||||
|
|
||||||
|
monkeypatch.setattr(RatatoskrApp, "run_async", cap)
|
||||||
|
rc = run_tui(self._bare_args())
|
||||||
|
assert rc == 0
|
||||||
|
assert snap["sid"] == "s-solo"
|
||||||
|
assert not picker_used
|
||||||
|
|
||||||
|
def test_bare_multi_opens_picker(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""bare_multi_opens_picker [scenario,tracer]: >1 → picker; its choice resumes."""
|
||||||
|
import ratatoskr.tui as tui_mod
|
||||||
|
from ratatoskr.sessions import SessionPage
|
||||||
|
from ratatoskr.tui import SessionPickerApp
|
||||||
|
|
||||||
|
async def fake_list(client, **kw):
|
||||||
|
return SessionPage(items=[self._sess("s-a"), self._sess("s-b")], next_cursor=None)
|
||||||
|
|
||||||
|
monkeypatch.setattr(tui_mod, "list_sessions", fake_list)
|
||||||
|
|
||||||
|
async def pick_b(self, *a, **k):
|
||||||
|
return "s-b"
|
||||||
|
|
||||||
|
monkeypatch.setattr(SessionPickerApp, "run_async", pick_b)
|
||||||
|
snap: dict = {}
|
||||||
|
|
||||||
|
async def cap(self, *a, **k):
|
||||||
|
snap["sid"] = self.session_id
|
||||||
|
return 0
|
||||||
|
|
||||||
|
monkeypatch.setattr(RatatoskrApp, "run_async", cap)
|
||||||
|
rc = run_tui(self._bare_args())
|
||||||
|
assert rc == 0
|
||||||
|
assert snap["sid"] == "s-b"
|
||||||
|
|
||||||
|
def test_bare_picker_dismiss_exits_zero(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""bare_picker_dismiss_exits_zero [scenario]: picker None → exit 0; App not opened."""
|
||||||
|
import ratatoskr.tui as tui_mod
|
||||||
|
from ratatoskr.sessions import SessionPage
|
||||||
|
from ratatoskr.tui import SessionPickerApp
|
||||||
|
|
||||||
|
async def fake_list(client, **kw):
|
||||||
|
return SessionPage(items=[self._sess("s-a"), self._sess("s-b")], next_cursor=None)
|
||||||
|
|
||||||
|
monkeypatch.setattr(tui_mod, "list_sessions", fake_list)
|
||||||
|
|
||||||
|
async def pick_none(self, *a, **k):
|
||||||
|
return None
|
||||||
|
|
||||||
|
monkeypatch.setattr(SessionPickerApp, "run_async", pick_none)
|
||||||
|
opened: list[int] = []
|
||||||
|
|
||||||
|
async def spy(self, *a, **k):
|
||||||
|
opened.append(1)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
monkeypatch.setattr(RatatoskrApp, "run_async", spy)
|
||||||
|
rc = run_tui(self._bare_args())
|
||||||
|
assert rc == 0
|
||||||
|
assert not opened
|
||||||
|
|
||||||
|
def test_bare_list_sessions_api_failure(
|
||||||
|
self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
"""bare_list_sessions_api_failure [error]: list_sessions 500 → exit 20; App not opened."""
|
||||||
|
import ratatoskr.tui as tui_mod
|
||||||
|
from ratatoskr.sessions import SessionApiFailed
|
||||||
|
|
||||||
|
async def fake_list(client, **kw):
|
||||||
|
raise SessionApiFailed(status=500, body=b"boom")
|
||||||
|
|
||||||
|
monkeypatch.setattr(tui_mod, "list_sessions", fake_list)
|
||||||
|
opened: list[int] = []
|
||||||
|
|
||||||
|
async def spy(self, *a, **k):
|
||||||
|
opened.append(1)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
monkeypatch.setattr(RatatoskrApp, "run_async", spy)
|
||||||
|
rc = run_tui(self._bare_args())
|
||||||
|
assert rc == 20
|
||||||
|
assert "[session_api_failed]" in capsys.readouterr().err
|
||||||
|
assert not opened
|
||||||
|
|||||||
Reference in New Issue
Block a user