# Considering-state + transport timeout (M2) — design Date: 2026-07-10 Milestone: **M2 — prove aliveness** (roadmap). Replaces streaming as the M2 anti-freeze item; **polish, not proof**. Charter side (§2): **text/UI** — authored presentation while a call is in flight, plus a reliability fix. No game state; no AI; nothing mutates the canon log. ## Goal Make the model-call wait read as *the DM is speaking* rather than *the game is frozen* (§14) — by **reframing** the wait in-character instead of hiding it. While a `narrate()` / `speak()` call is in flight, the client shows a rotating, authored "the Master considers…" indicator (Claude-Code-style verb rotation on a ~2s cadence). When the response lands, the indicator clears and the prose appears. If the call hangs, a request **timeout** gives way to the authored fallback instead of spinning forever — so the wait is both **framed** (in-character) and **bounded** (never hangs). This meets §14's *goal* on the **proven, non-streaming loop**, with essentially no new failure surface. It is the counter-proposal to Narrator streaming (parked, `2026-07-10-narrator-streaming-design.md`), chosen because streaming is the POC's largest transport rework and adds mid-stream error/tag-boundary failure surface that a polish feature shouldn't carry while the aliveness question is already answered. ## What already exists (and is proven) - **Client loop:** `DmService.narrate(canon_log)` and `NpcService.speak(...)` are async; the harness disables its button, `await`s the call, then renders the result. On any failure the service already degrades to an authored fallback line (§13) via `FallbackLibrary` (loads `res://content/fallback/*.json`, returns a hardcoded constant if the file is missing/malformed). - **Transport:** `HttpTransport` wraps a Godot `HTTPRequest` node and maps any non-`RESULT_SUCCESS` completion — **including `RESULT_TIMEOUT`** — to `DmResponse.failed(...)`, which the service degrades on. But `HTTPRequest.timeout` is left at its default **`0` (disabled)**: a hung proxy never completes, so the await never returns. This is the deferred `_http.timeout=0` minor. - **Config:** `ProxyConfig` already exposes `base_url()` from a `ProjectSettings` key with a default — the pattern for the new timeout setting. - **Harnesses:** `narrate_harness.gd` and `npc_harness.gd` both build their UI in code and follow the disable-button → await → render shape. The two seams this needs (the fallback-as-content loader; the transport's non-success → degrade path) already work. The wait is currently just *blank*. ## Decisions Settled during brainstorming; each is load-bearing. 1. **Reframe the wait, don't hide it.** §14's goal is that latency not read as a freeze. A rotating in-character indicator achieves that by framing the pause as the GM *thinking* — arguably more in-voice, for a turn-based game, than text spraying onto the screen. Streaming (which genuinely hides latency) is parked behind this, revisited only if playtesting shows the wait hurts. 2. **Loading is content (§13 spirit).** The considering phrases are **authored content**, loaded like the fallback lines — a small pool of dry, on-voice lines (§3: the narrator is dry and never winks). Not a spinner asset, not a hardcoded "Loading…". A missing/malformed file degrades to one hardcoded constant, so the indicator itself cannot fail. 3. **Rotate through the pool on a ~2s cadence.** Like Claude Code cycling *thinking / considering / cooking*. A typical ~2s call shows one or two lines; a slow one keeps rotating until the response or the timeout. The **phrase selection is a pure unit**; the **timer + label wiring is a thin node shim** — the project's standard pure-core/node-shim split. 4. **The rotation index is instance state, not reset per call.** One indicator instance persists on the harness and is reused across turns; `next()` advances a running index that `start()`/`stop()` do **not** reset. So turn 2 continues where turn 1 left off — the first phrase varies turn to turn **without** randomness (keeps the core deterministic and unit-testable; no `randi()` in the tested path). 5. **The timeout fix lands here.** `ProxyConfig` gains `request_timeout_seconds()` (a `ProjectSettings` key, default **35.0** — just above the server's `OLLAMA_TIMEOUT_SECONDS=30` so the server's own error/degrade surfaces first when the *model* is slow, while still bounding a fully-hung proxy). `HttpTransport` sets `_http.timeout` to it. A hang → `RESULT_TIMEOUT` → the **existing** `failed()` → degrade path fires. This is why the timeout is essential *to this feature specifically*: without it the considering-state would rotate **forever** on a hang — worse than a frozen button, because it looks like progress. 6. **No server changes.** The proxy and the `/dm/narrate` · `/npc/speak` contracts are untouched. This is entirely client-side presentation + one client config value. 7. **Wire it into both harnesses.** Both prose surfaces (narrate + npc) get the indicator — same small integration, and the NPC surface is where the aliveness feel matters most. The component is reusable by the eventual real dialogue UI. ## Components - `client/scripts/ui/considering_phrases.gd` — `ConsideringPhrases` (`class_name`, pure `RefCounted`). `_init(path := DEFAULT_PATH)` loads a string pool from `res://content/considering.json` (`{"considering": ["…", …]}`) using the same robust instance-`JSON.parse()` loader `FallbackLibrary` uses (never `JSON.parse_string()` — the project's gutconfig turns its engine error into a false test failure). `next() -> String` returns the pool entry at a running index and advances it (wrapping); an empty/missing pool returns `LAST_RESORT` ("The Master considers your words."). Unit-tested. - `client/scripts/ui/considering_indicator.gd` — `ConsideringIndicator` extends `Node` (a repeating rotation needs a `Timer` in the tree, so it can't be a pure `RefCounted`). The harness `add_child()`s it; in `_ready` it creates a repeating `Timer` child (`ROTATE_SECONDS := 2.0`, `one_shot=false`). `start(label: Control)` sets the label to `phrases.next()` and starts the timer, whose timeout sets the label to `phrases.next()`; `stop()` stops the timer and clears the label. Holds one `ConsideringPhrases` for its lifetime (decision 4). The timer/tree wiring is the untested node shim (verified by the harness run); the phrase logic it drives is tested via `ConsideringPhrases`. - `client/content/considering.json` — authored pool. Starter lines (dry, §3), reviewed like writing: - "The Master considers your words." - "Somewhere, dice are gathered." - "The tale weighs its next turn." - "The dark shifts, thinking." - "A page is turned, unhurried." - `client/scripts/net/proxy_config.gd` — add `TIMEOUT_SETTING`, `TIMEOUT_DEFAULT := 35.0`, `request_timeout_seconds() -> float` (mirrors `base_url()`). - `client/scripts/net/http_transport.gd` — in `_init`, set `_http.timeout = ProxyConfig.request_timeout_seconds()`. No other change; the existing `result != RESULT_SUCCESS → failed()` path already handles the timeout. - `client/scripts/harness/narrate_harness.gd` and `.../npc_harness.gd` — build a `ConsideringIndicator`; call `start(status_label)` before `await`ing the service and `stop()` after it returns (before rendering the result). Add a small status `Label` if the harness lacks one to host the rotating text. ## Error handling - **Slow call** → the indicator rotates; on return it stops and the prose renders. - **Hung proxy / unreachable** → `HTTPRequest` times out at `request_timeout_seconds()` → `RESULT_TIMEOUT` → `DmResponse.failed(...)` → the service returns its degraded fallback; the harness `stop()`s the indicator and shows the authored fallback line. No infinite spin, no error string (§13). - **Missing/malformed `considering.json`** → `ConsideringPhrases` returns its hardcoded `LAST_RESORT`; the indicator still works. ## Testing - **`ConsideringPhrases` (pure, GUT):** cycles the pool in order and wraps; the running index is **not** reset between logical "calls" (decision 4); a missing/empty file yields `LAST_RESORT`; a well-formed file loads its pool. - **`ProxyConfig.request_timeout_seconds`:** returns `TIMEOUT_DEFAULT` with no setting; honours a `ProjectSettings` override. - **`HttpTransport` timeout wiring:** constructing `HttpTransport` with a real `HTTPRequest` sets `http.timeout == ProxyConfig.request_timeout_seconds()` (read the property — a light assertion on the otherwise-untested shim). - **Degrade-on-timeout is already covered:** the existing `test_transport_failure_degrades` service tests exercise `failed()` → degraded result; a timeout maps to `failed()`, so no new service test is needed (note it, don't duplicate). - **Indicator:** the `Timer`/tree wiring is verified by running the harness (see the rotation, see it stop when prose arrives, kill the proxy → see it rotate then yield to the fallback after the timeout). No unit test for the timer. ## Scope **In:** `ConsideringPhrases` (pure) + `considering.json`; `ConsideringIndicator` (node shim); `ProxyConfig.request_timeout_seconds` + `HttpTransport` timeout wiring; both harnesses showing the indicator around the await; the test set above. **Out:** streaming (parked, `2026-07-10-narrator-streaming-design.md`); any server change; real dialogue UI; combat flavor; companion-flavored considering lines (a nice later touch — Brannoc/Cadwyn voicing the wait — but not now).