Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5c1b9816d4 |
@@ -372,3 +372,64 @@ test layer.
|
||||
- Issue #7 (mid-stream robustness, `MalformedSseData`) — landed; #6's
|
||||
pre/in-alt-screen split is orthogonal to #7's empty-data/malformed
|
||||
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.
|
||||
```
|
||||
|
||||
@@ -151,6 +151,8 @@ decision. Captures rationale that won't be obvious from code alone.
|
||||
- `[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 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._
|
||||
|
||||
_For per-issue TDD implementation notes, Volva findings, and contract amendments, see the git log — every per-issue commit carries a structured message capturing the trail._
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "ratatoskr"
|
||||
version = "0.18.6"
|
||||
version = "0.18.7"
|
||||
description = "Worldtree Conversation API debug TUI — multi-pane observability dashboard"
|
||||
readme = "README.md"
|
||||
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:
|
||||
raise UsageError("--end-user-id must be non-empty when passed")
|
||||
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:
|
||||
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:
|
||||
raise UsageError("--agent is required with --new and forbidden with --session")
|
||||
if ns.new and not ns.agent and ns.send is not None:
|
||||
|
||||
+158
-4
@@ -42,9 +42,11 @@ from ratatoskr.sessions import (
|
||||
BifrostHandshakeFailed,
|
||||
PersonaNotConfigured,
|
||||
SessionApiFailed,
|
||||
SessionInfo,
|
||||
create_session,
|
||||
get_persona_state,
|
||||
list_agents,
|
||||
list_sessions,
|
||||
)
|
||||
from ratatoskr.sse_client import (
|
||||
AffectUpdate,
|
||||
@@ -798,6 +800,128 @@ class AgentPickerApp(App[str | 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]):
|
||||
"""Textual TUI shell — single chat pane."""
|
||||
|
||||
@@ -1430,8 +1554,9 @@ def run_tui(args: ParsedArgs) -> int:
|
||||
"""
|
||||
# PRE-001: TUI-mode marker (issue #4 contract)
|
||||
assert isinstance(args, ParsedArgs) and args.send_content is None
|
||||
# PRE-002: Exactly one of session_id / new must be set (xor)
|
||||
assert bool(args.session_id) != bool(args.new)
|
||||
# PRE-002 (slice b2): --session and --new are mutually exclusive, but NEITHER
|
||||
# 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))
|
||||
|
||||
|
||||
@@ -1467,6 +1592,35 @@ async def _resolve_then_run(args: ParsedArgs) -> int:
|
||||
# agent_id (remote wins on conflict, since a server-listed agent
|
||||
# is the authoritative source).
|
||||
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:
|
||||
try:
|
||||
agents = await list_agents(client)
|
||||
@@ -1551,8 +1705,8 @@ async def _resolve_then_run(args: ParsedArgs) -> int:
|
||||
session_id = info.session_id
|
||||
agent_id: str | None = info.agent_id
|
||||
else:
|
||||
assert args.session_id is not None
|
||||
session_id = args.session_id
|
||||
assert resolved_session_id is not None
|
||||
session_id = resolved_session_id
|
||||
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)
|
||||
exit_code = await app.run_async()
|
||||
|
||||
+30
-6
@@ -175,10 +175,29 @@ class TestParseArgs:
|
||||
)
|
||||
|
||||
def test_usage_neither_session_nor_new(self) -> None:
|
||||
"""usage_neither_session_nor_new: neither flag → UsageError('pass exactly one')."""
|
||||
with pytest.raises(UsageError, match="pass exactly one"):
|
||||
"""usage_neither_session_nor_new: --send with neither flag → UsageError.
|
||||
|
||||
--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"])
|
||||
|
||||
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:
|
||||
"""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"])
|
||||
assert rc == 0
|
||||
|
||||
def test_usage_error_no_send(
|
||||
def test_empty_argv_fails_on_auth(
|
||||
self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> 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] = []
|
||||
|
||||
async def fake_amain(args: ParsedArgs) -> int:
|
||||
@@ -1331,8 +1355,8 @@ class TestMain:
|
||||
|
||||
monkeypatch.setattr(cli_mod, "_amain", fake_amain)
|
||||
rc = main([])
|
||||
assert rc == 10
|
||||
assert "[usage_error]" in capsys.readouterr().err
|
||||
assert rc == 11
|
||||
assert "[auth_error]" in capsys.readouterr().err
|
||||
assert amain_calls == []
|
||||
|
||||
def test_usage_error_both_session_and_new(
|
||||
|
||||
@@ -2945,3 +2945,225 @@ class TestTuiBifrostBind:
|
||||
err = capsys.readouterr().err
|
||||
assert "bifrost: status=bound" 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