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).
This commit is contained in:
vh
2026-05-23 14:34:53 -07:00
parent c713208585
commit 804c2df6eb
14 changed files with 1422 additions and 281 deletions
+105 -67
View File
@@ -89,7 +89,7 @@ The shell is the load-bearing primary surface. Together with `--send`, it makes
- **INV-004 [hard]**: Ctrl-D is bound to `app.exit(0)` unconditionally — immediate exit regardless of state. Abandons any in-flight turn (server-side stall watchdog handles the orphan per spec).
- **INV-005 [hard]**: Markdown rendering on agent output is default-on; `--raw` is the opt-out. With markdown enabled, `Text` event deltas stream as raw text appended to the RichLog as they arrive (no mid-stream markdown attempt — partial markdown like `**hel` would render ugly), and on `Done` a separator + the full markdown-rendered assistant message is appended below the streamed deltas. **This means the assistant's response visibly appears TWICE in the transcript by design — once as the streamed raw deltas, once as the post-Done markdown render — separated by a horizontal-rule separator.** This is the v1 accepted trade-off for streaming-visibility-without-mid-stream-markdown-ugliness; the cleaner Static-then-commit pattern (streaming into a replaceable widget, then committing the markdown version in place) is documented in `open_questions:` as the follow-up if the double-display proves empirically noisy. Implementers MUST NOT attempt the Static-then-commit pattern in this shell — it's deferred. With `--raw`, only the streamed deltas appear; no post-Done re-render; no double-display.
- **INV-006 [hard]**: User-prompt echo in the transcript MUST visibly distinguish user input from assistant output. Format: `❯ <content>` for user lines (with a literal `❯` prefix); assistant lines have no prefix. The prefix is also a screen-reader-friendly affordance.
- **INV-007 [hard]**: One `httpx.AsyncClient` per app lifetime — opened in `on_mount`, closed in `on_unmount` via the async-with context manager pattern. The client is NOT recreated per turn (would burn the TCP connection pool).
- **INV-007 [hard]**: One `httpx.AsyncClient` per app lifetime — opened by `run_tui`'s `async with` BEFORE `App.run_async()` is entered and closed by the same `async with` AFTER `App.run_async()` returns (per issue #6 INV-002). The App is a consumer of an externally-owned client; it MUST NOT call `self.client.aclose()`. The client is NOT recreated per turn (would burn the TCP connection pool).
- **INV-008 [hard]**: Mid-session network/protocol errors (`SseConnectionDropped`, `SseConnectFailed`, `MalformedSseId`, `MalformedSseData` (issue #7), `TurnIdFlip`) during a streaming turn render as error lines in the transcript and return the app to **idle** state — they do NOT exit the app. Only initial session-create errors exit (per Data flow exit codes).
- **INV-009 [hard]**: No `core.*` / `worldtree.*` imports. The boundary smoke (`tests/test_no_worldtree_imports.py`) covers `src/ratatoskr/` as a whole including the new tui.py.
@@ -129,48 +129,102 @@ ratatoskr <args> [shell entry, registered in pyp
└─ args.send_content is None ──► from ratatoskr.tui import run_tui [lazy import]
return run_tui(args)
│
└─ RatatoskrApp(args).run()
└─ asyncio.run(_resolve_then_run(args)) [issue #6 amendment]
│
├─ on_mount: open AsyncClient, create_session (if --new),
│ init footer with agent_id + session_id_tail8
├─ on_input_submitted: spawn _stream_turn_worker(content)
│ _stream_turn_worker:
│ for event in stream_turn(...):
│ _render_event_to_log(event, log)
│ on Done: if not raw, append separator + markdown render
│ transition: streaming → idle
├─ action_interrupt (ctrl+c): two-stage state machine per INV-003
├─ action_quit (ctrl+d): app.exit(0)
└─ on_unmount: close AsyncClient
└─ async with httpx.AsyncClient(...) as client:
│
├─ pre-flight session resolve (--new → create_session,
│ else attach args.session_id). Errors → real stderr,
│ return appropriate exit code (12 / 20 / 21) BEFORE
│ the alt-screen opens (issue #6 INV-001 / INV-006).
│
├─ app = RatatoskrApp(args, session_id=..., agent_id=...,
│ client=client)
└─ await app.run_async():
│
├─ on_mount: populate identity widget from pre-
│ resolved state; state=idle
├─ on_input_submitted: spawn _stream_turn_worker(content)
│ _stream_turn_worker:
│ for event in stream_turn(...):
│ _render_event_to_log(event, log)
│ on Done: if not raw, append separator + markdown
│ transition: streaming → idle
├─ action_interrupt (ctrl+c): two-stage state machine
├─ action_quit (ctrl+d): app.exit(0)
└─ on_unmount: no-op (client closed by async-with above)
```
---
```contract
FN run_tui(args: ParsedArgs) -> int
BRIEF: Sync entry point called from `ratatoskr.cli.main`'s lazy-import branch. Constructs the `RatatoskrApp` with the parsed args and runs it under Textual's loop; returns the App's exit code.
BRIEF: Sync entry point called from `ratatoskr.cli.main`'s lazy-import branch. Thin sync wrapper around `asyncio.run(_resolve_then_run(args))` — per issue #6, the actual work (AsyncClient open, pre-flight session resolution with stderr error routing, then App.run_async) happens inside the single async helper so everything runs in one event loop.
PRE: [PRE-001 hard] args is a ParsedArgs with args.send_content is None (TUI mode marker) -- assert isinstance(args, ParsedArgs) and args.send_content is None
PRE: [PRE-002 hard] exactly one of args.session_id / args.new is set -- assert bool(args.session_id) != bool(args.new) (post-_parse_args xor validation, hold-over assertion)
POST: [POST-001 return_value] returns the exit code from App.run() (0, 3, 12, 20, 21 per Data flow table)
POST: [POST-001 return_value] returns the exit code from _resolve_then_run (0, 3, 12, 20, 21 per Data flow table)
ERROR_ROUTING:
(none at this level — App.run() catches its own exceptions and surfaces them as exit codes; uncaught errors propagate as Python exceptions to the cli's main wrapper)
(none at this level — _resolve_then_run handles all exit codes; uncaught errors propagate as Python exceptions to the cli's main wrapper)
STEPS:
1. [setup, flexibility=prescriptive] Validate inputs per PRE-001..PRE-002
2. [sequential, flexibility=prescriptive] Construct app = RatatoskrApp(args)
3. [sequential, flexibility=prescriptive] RETURN app.run() — Textual's sync runner; manages its own asyncio loop
2. [sequential, flexibility=prescriptive] RETURN asyncio.run(_resolve_then_run(args))
TESTS:
happy_returns_zero_on_quit [happy,tracer]: construct args with --session s-1; monkeypatch RatatoskrApp.run to capture invocation and return 0; run_tui returns 0; the captured app was constructed with the passed args (verifies run_tui correctly wraps App.run). The piloted Ctrl-D exit path is covered separately by TestActionQuit.test_idle_ctrl_d_exits_zero — App.run() is sync and can't be driven by Pilot, so run_tui's wrapping behavior is tested via monkeypatch.
precondition_send_content_none [adversarial]: args with send_content="x" → AssertionError before run() (PRE-001 catches the misuse)
happy_returns_zero_on_quit [happy,tracer]: construct args with --session s-1; monkeypatch RatatoskrApp.run_async to capture invocation and return 0; run_tui returns 0; the captured app was constructed with the passed args (verifies run_tui correctly wraps app.run_async via _resolve_then_run).
precondition_send_content_none [adversarial]: args with send_content="x" → AssertionError before any asyncio.run (PRE-001 catches the misuse)
```
```contract
FN _resolve_then_run(args: ParsedArgs) -> int # NEW per issue #6
ASYNC: yes
BRIEF: Pre-flight session resolution then App.run_async() inside one event loop. Errors at this layer (AgentNotFound, SessionApiFailed, network errors) print to `sys.stderr` (the operator's real terminal) and short-circuit BEFORE the alt-screen opens (issue #6 INV-001). The label format + exit codes match `ratatoskr.cli._amain`'s verbatim (issue #6 INV-006), so operators see one vocabulary across `--send` and TUI modes. Owns the AsyncClient lifecycle via `async with` (issue #6 INV-002).
PRE: [PRE-001 hard] args is a ParsedArgs with args.send_content is None
POST: [POST-001 return_value] returns exit code (12 on AgentNotFound, 20 on SessionApiFailed, 21 on network error, OR the App's run_async return value)
POST: [POST-002 side_effect] the AsyncClient is opened BEFORE create_session and closed AFTER app.run_async returns (or after the error-routed return)
ERROR_ROUTING:
AgentNotFound (from create_session):
local_handling: sys.stderr.write(f"[agent_not_found] agent_id={exc.agent_id}\n")
flow_control: return 12 — alt-screen never opens
state_recovery: none
SessionApiFailed (from create_session):
local_handling: sys.stderr.write(f"[session_api_failed] status={exc.status} body={exc.body!r}\n")
flow_control: return 20 — alt-screen never opens
state_recovery: none
httpx.ConnectError | httpx.ReadTimeout | httpx.TransportError (from create_session):
local_handling: sys.stderr.write(f"[network_error] {type(exc).__name__}: {exc}\n")
flow_control: return 21 — alt-screen never opens
state_recovery: none
STEPS:
1. [setup, flexibility=prescriptive] OPEN httpx.AsyncClient(base_url=args.server_url, headers={"Authorization": f"Bearer {args.api_key}"}, timeout=httpx.Timeout(connect=10.0, read=None, write=10.0, pool=10.0)) via `async with` — read=None disables the SSE-killing 5s default (issue #1 [compatibility] constraint)
2. [branch, flexibility=prescriptive] IF args.new:
TRY: info = await create_session(client, args.agent_id, end_user_id=args.end_user_id) # issue #5: thread end_user_id when set; None preserves pre-#5 body shape
ON AgentNotFound | SessionApiFailed | httpx.ConnectError | httpx.ReadTimeout | httpx.TransportError: handle per ERROR_ROUTING (stderr + return code)
SET session_id = info.session_id; agent_id = info.agent_id
ELSE:
SET session_id = args.session_id; agent_id = args.agent_id # may be None — INV-002 carve-out preserved
3. [sequential, flexibility=prescriptive] Construct app = RatatoskrApp(args, session_id=session_id, agent_id=agent_id, client=client)
4. [sequential, flexibility=prescriptive] exit_code = await app.run_async() — Textual's async-runner; same event loop as the AsyncClient
5. [cleanup, flexibility=prescriptive] RETURN exit_code or 0
TESTS:
alt_screen_never_opens_on_resolve_error [trace]: monkeypatch RatatoskrApp.run_async to a sentinel that fails if called; respx → 404 from POST /sessions; assert run_tui returns 12; assert the sentinel was NEVER invoked (probes issue #6 INV-001).
agent_not_found_on_resolve [error]: --new + 404 → run_tui returns 12; capsys.readouterr().err contains "[agent_not_found]" + "agent_id=mimir".
session_api_failed_on_resolve [error]: --new + 500 → run_tui returns 20; capsys stderr contains "[session_api_failed]" + "status=500".
network_error_on_resolve [error]: --new + httpx.ConnectError → run_tui returns 21; capsys stderr contains "[network_error]" + "ConnectError".
stderr_label_format_matches_cli [trace]: cli._amain and _resolve_then_run produce identical stderr lines for AgentNotFound (probes issue #6 INV-006). Drive each path with respx → 404 and capsys-capture both stderr outputs; assert string equality and "[agent_not_found] agent_id=mimir\n" verbatim.
client_open_after_resolve [trace]: monkeypatch RatatoskrApp.run_async to capture self.client; assert client is not None and client.is_closed is False AT the moment run_async executes (probes that the App receives a live, open client from _resolve_then_run).
client_lifetime_owned_by_run_tui [trace]: monkeypatch RatatoskrApp.run_async to capture self.client and confirm it's open during run; after run_tui returns, assert the captured client.is_closed is True (probes issue #6 INV-002 — the async-with closes the client AFTER run_async, not on_unmount).
run_tui_closes_client_on_app_exit: same as client_lifetime_owned_by_run_tui — the async-with in _resolve_then_run is the closing site.
happy_new_with_end_user_id_resolve [happy, issue #5]: args.end_user_id="alice" → POST /sessions outbound body == {"agent_id": <agent>, "end_user_id": "alice"}. (Issue #5's contract names this `_mount`; post-#6 the equivalent site is `_resolve_then_run`.)
```
```contract
CLASS RatatoskrApp(textual.app.App[int])
BRIEF: Textual app — single chat pane shell. Holds the parsed args, the active session_id, the httpx.AsyncClient, and the Ctrl-C state machine. Exposes the bindings + the worker coordination for stream_turn / cancel_turn.
BRIEF: Textual app — single chat pane shell. Consumer of an externally-owned `httpx.AsyncClient` (passed at __init__ per issue #6 INV-002). Holds the parsed args, the pre-resolved session_id + agent_id, the client reference, and the Ctrl-C state machine. Exposes the bindings + the worker coordination for stream_turn / cancel_turn.
__init__: `def __init__(self, args: ParsedArgs, *, session_id: str, agent_id: str | None, client: httpx.AsyncClient) -> None` — per issue #6 INV-003, session_id and client are REQUIRED at construction; the App no longer mints anything in on_mount.
PROPERTIES:
args: ParsedArgs # passed in __init__
session_id: str | None # set in on_mount (after create_session if --new)
agent_id: str | None # set in on_mount
client: httpx.AsyncClient | None # opened in on_mount, closed in on_unmount
session_id: str # passed in __init__ (pre-resolved by _resolve_then_run; issue #6)
agent_id: str | None # passed in __init__ (may be None for --session without --agent; INV-002 carve-out)
client: httpx.AsyncClient # passed in __init__; lifecycle owned by run_tui's async-with (INV-007 amended)
state: Literal["idle", "streaming", "cancelling"] # INV-003 state machine
active_turn_id: int | None # set when first event of a turn yields; cleared on terminal
stream_worker: textual.worker.Worker | None # the current _stream_turn_worker task
@@ -184,52 +238,34 @@ COMPOSE shape (declarative — implementer chooses CSS file vs inline):
Static("", id="identity") # INV-002: visible session-identity strip; rendered by on_mount
Static(HINT_IDLE, id="hint") # INV-003: visible Ctrl-C state hint; updated on state transitions
Footer()
INV-WIRE-001: One AsyncClient lifecycle per app lifetime (INV-007).
INV-WIRE-001: AsyncClient lifetime owned by run_tui's async-with (INV-007 amended; issue #6 INV-002).
INV-WIRE-002: state transitions strictly idle ↔ streaming ↔ cancelling per INV-003.
```
```contract
FN RatatoskrApp.on_mount(self) -> None
BRIEF: Lifecycle hook. Opens the httpx.AsyncClient, mints or attaches the session, populates the footer with agent_id + session_id_tail8, sets state to "idle".
PRE: [PRE-001 hard] self.client is None (on_mount fires once per app instance) -- assert self.client is None
POST: [POST-001 state_change] self.client is an open httpx.AsyncClient bound to args.server_url with the Bearer auth header
POST: [POST-002 state_change] self.session_id is non-empty (either from args.session_id or from a successful create_session)
POST: [POST-003 state_change] self.agent_id is non-empty when args.new (from SessionInfo.agent_id after create_session) OR self.agent_id is the value of args.agent_id when args.session is used (may be None per INV-002 carve-out — `GET /sessions/{id}` agent lookup is explicitly out of scope for this shell)
POST: [POST-004 side_effect] Footer subtitle shows `<agent_id> · …<session_id[-8:]>` (INV-002 session-identity-always-visible)
POST: [POST-005 state_change] self.state == "idle"; the footer hint widget shows "Ctrl-C twice to exit"
BRIEF: Lifecycle hook. Per issue #6: NARROWED to identity-widget population from pre-resolved state. No more session-create branch (moved to _resolve_then_run); no more client-open (moved to run_tui's async-with). Just populates the identity widget + sets idle state.
PRE: [PRE-001 hard] self.client is not None (set in __init__ from _resolve_then_run; issue #6 INV-003) -- assert self.client is not None
PRE: [PRE-002 hard] self.session_id is not None (set in __init__ from _resolve_then_run) -- assert self.session_id is not None
POST: [POST-001 side_effect] Footer subtitle shows `<agent_id> · …<session_id[-8:]>` (INV-002 session-identity-always-visible)
POST: [POST-002 side_effect] The #identity Static widget renders the same identity string
POST: [POST-003 state_change] self.state == "idle"; the #hint Static widget shows "Ctrl-C twice to exit"
ERROR_ROUTING:
AgentNotFound (from create_session):
local_handling: append `[agent_not_found] agent_id={exc.agent_id}` to RichLog as an error line; call self.exit(12)
flow_control: abort the mount (the app exits before user can interact)
state_recovery: none
SessionApiFailed (from create_session):
local_handling: append `[session_api_failed] status={exc.status} body={exc.body!r}` to RichLog; self.exit(20)
flow_control: abort
state_recovery: none
httpx.ConnectError | httpx.ReadTimeout | httpx.TransportError (from create_session):
local_handling: append `[network_error] {type(exc).__name__}: {exc}` to RichLog; self.exit(21)
flow_control: abort
state_recovery: none
(none — session-resolution errors are handled at the _resolve_then_run layer BEFORE on_mount can be reached; on_mount is now error-free per issue #6 INV-001)
STEPS:
1. [setup, flexibility=prescriptive] Validate PRE-001
2. [sequential, flexibility=prescriptive] Open AsyncClient: self.client = httpx.AsyncClient(base_url=args.server_url, headers={"Authorization": f"Bearer {args.api_key}"}, timeout=httpx.Timeout(connect=10.0, read=None, write=10.0, pool=10.0)) — read=None disables the SSE-killing 5s default (issue #1 [compatibility] constraint)
3. [branch, flexibility=prescriptive] IF args.new:
TRY: info = await create_session(self.client, args.agent_id)
ON AgentNotFound | SessionApiFailed | httpx.ConnectError | httpx.ReadTimeout | httpx.TransportError: handle per ERROR_ROUTING (append + exit)
SET self.session_id = info.session_id; self.agent_id = info.agent_id
ELSE:
SET self.session_id = args.session_id; self.agent_id = args.agent_id (when args.session is used, --agent is forbidden per issue #3 INV-004 — agent_id may be empty here)
(When session_id is set but agent_id unknown: optionally GET /sessions/{id} for it; OUT OF SCOPE for this shell — display `<unknown> · …<tail>` instead. Single follow-up if it becomes noisy.)
4. [sequential, flexibility=prescriptive] Update footer: self.sub_title = f"{self.agent_id or '<unknown>'} · …{self.session_id[-8:]}"; render hint "Ctrl-C twice to exit"
5. [sequential, flexibility=prescriptive] SET self.state = "idle"
1. [setup, flexibility=prescriptive] Validate PRE-001..PRE-002
2. [sequential, flexibility=prescriptive] Compute identity: agent_slot = self.agent_id or "<unknown>"; identity = f"{agent_slot} · …{self.session_id[-8:]}"
3. [sequential, flexibility=prescriptive] Populate widgets: self.sub_title = identity (Header subtitle mirror); self.query_one("#identity", Static).update(identity)
4. [sequential, flexibility=prescriptive] SET self.state = "idle"; update #hint widget to HINT_IDLE ("Ctrl-C twice to exit")
TESTS:
happy_new_session_mount [happy,tracer]: --new --agent mimir; respx mocks POST /sessions → 201; Pilot.pause() → app.session_id is "s-new", app.agent_id is "mimir", footer text contains "mimir · …", state=="idle"
happy_existing_session_mount [happy]: --session s-1; no POST /sessions; Pilot.pause() → app.session_id is "s-1", footer shows agent (or <unknown>), state=="idle"
agent_not_found_on_mount [error]: --new; POST /sessions → 404 → app exits 12; RichLog contains "[agent_not_found]"
session_api_failed_on_mount [error]: --new; POST /sessions → 500 → app exits 20; RichLog contains "[session_api_failed]"
network_error_on_mount [error]: --new; POST /sessions raises httpx.ConnectError → app exits 21; RichLog contains "[network_error]"
footer_identity_visible_first_frame [trace]: INV-002 — after Pilot.pause(), query the footer and assert the agent_id + session_id_tail8 substring is present before any other interaction
client_open_after_mount [trace]: post-mount self.client is not None and is_closed is False
happy_new_session_mount [happy,tracer]: construct via _resolved_app(_args_new(), session_id="s-new12345", agent_id="mimir"); Pilot.pause() → app.session_id == "s-new12345", app.agent_id == "mimir", state == "idle", footer contains "mimir · …<tail>"
happy_existing_session_mount [happy]: construct via _resolved_app(_args_existing(session_id="s-existing-tail8x")); args.agent_id is None → app.agent_id is None → identity shows "<unknown> · …<tail>" (INV-002 carve-out)
footer_identity_visible_first_frame [trace]: INV-002 — after Pilot.pause(), the #identity Static widget renders the agent_slot + session_id_tail8 substring before any other interaction.
# Error tests moved to the _resolve_then_run TESTS block per issue #6:
# agent_not_found_on_mount → agent_not_found_on_resolve (capsys stderr)
# session_api_failed_on_mount → session_api_failed_on_resolve
# network_error_on_mount → network_error_on_resolve
# client_open_after_mount → client_open_after_resolve
```
```contract
@@ -394,14 +430,16 @@ TESTS:
```contract
FN RatatoskrApp.on_unmount(self) -> None
BRIEF: Lifecycle hook. Closes the httpx.AsyncClient cleanly. Textual fires on_unmount as part of app shutdown.
BRIEF: Lifecycle hook. Per issue #6 INV-002: NARROWED to a no-op. The client lifetime is managed by `run_tui`'s `async with`, NOT by this hook — calling `self.client.aclose()` here would close the client while `_resolve_then_run`'s async-with still holds it.
PRE: (none)
POST: [POST-001 state_change] self.client is closed (httpx.AsyncClient.is_closed is True) OR was already None (app never reached on_mount due to early failure)
POST: (none — no side effects)
STEPS:
1. [branch, flexibility=prescriptive] IF self.client is not None AND NOT self.client.is_closed:
await self.client.aclose()
1. [sequential, flexibility=prescriptive] return None
TESTS:
unmount_closes_client [happy,tracer]: --session s-1; Pilot.press("ctrl+d") to trigger shutdown; after exit, app.client.is_closed is True
# unmount_closes_client → moved to run_tui_closes_client_on_app_exit at the
# _resolve_then_run layer per issue #6: the close site is the async-with
# exiting AFTER app.run_async returns, not on_unmount.
(none — this hook is intentionally empty; coverage lives at the _resolve_then_run layer)
```
```contract