feat(viewer): stream DM narration incrementally to /chat - #394
Conversation
The 5-persona playtest on the built app (build_sha e6384e8) surfaced per-beat latency with no streaming as the #1 satisfaction-killer: a DM turn runs ~60-90s and the player saw a waiting indicator with NO content appearing, so impatient personas concluded "this feels broken" and quit (adversarial gave up 1m23s on the cold-open; narrative ~90s on turn 2). Bounded streaming-lite, NOT a rewrite. The DM logs each beat via log_event(kind=narration/dialogue) DURING its turn and the engine appends it to the per-session log immediately (store.append_log); the viewer's /events endpoint already tails that log with a cursor. So useLiveSession now polls /events alongside /chat: new narration/dialogue rows surface as live, time-stamped chronicle beats — a blank wait becomes prose visibly arriving — with no change to the resolver's blocking turn, no SSE, and no change to engine write-semantics (the engine stays the sole writer; this is a pure read of state it already wrote). - Turn-gating preserved: a streamed beat shows prose but KEEPS the "narrating…" indicator up (one move at a time); the turn resolves when its final line lands on /chat. Streamed progress resets the stall/recovery clock so a long-but-healthy streaming turn is never falsely declared "stuck". - Dedup across both sources (claimNarration, text-keyed, whitespace/case-normalized) so each paragraph shows exactly once; the /chat line still resolves a turn even when its prose was wholly deduped. - screen-table dedups recentEvents (the /session-surface tail of the same log) against the live tail so a streamed paragraph isn't shown in both bands. - New test_live_narration_stream.py (10 tests) transpiles the real JSX with the bundled Babel-standalone and drives useLiveSession under a deterministic React + scripted-fetch harness. 49/49 viewer tests green (10 new + 39 existing). The companion mitigation (dynamic/reassuring waiting state) already shipped in #385.
|
Caution Review failedPull request was closed or merged during review 📝 WalkthroughWalkthrough
ChangesLive narration streaming and dedup
Estimated code review effort🎯 4 (Complex) | ⏱️ ~75 minutes Possibly related issues
Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 3 | ❌ 2❌ Failed checks (2 warnings)
✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
Comment |
What & why
The 5-persona playtest on the BUILT app (
build_sha e6384e8) surfaced per-beat latency with no streaming as the #1 satisfaction-killer: a DM turn takes ~60–90s and the player sees a waiting indicator but no content appearing, so impatient personas conclude "this feels broken" and quit (adversarial gave up at 1m23s on the cold-open; narrative ~90s on turn 2). Fixes the play-loop perception blocker tracked in #393.Approach: streaming-lite (bounded), NOT a rewrite
I assessed whether incremental narration streaming was bounded — it is, because the live mid-turn stream already exists on disk and the viewer already reads it:
log_event(kind="narration"/"dialogue")during its turn, and the engine appends it tocampaigns/<id>/sessions/<sid>.jsonlimmediately (servers/engine/store.py::append_log— append-mode open/write/close per entry, undercampaign_lock). A 60–90s turn makes several of these in real time./eventsendpoint (viewer/server.py::_read_events) already tails that session log with a line cursor and handles session rotation./chatwas surfaced live in the chronicle, and the runners append the DM line to/chatonly at turn-END — so the poll saw nothing for the whole turn, then the complete beat (the gap is documented verbatim inapp.jsxlines 82–92).So this is resolver/viewer work, not a streaming rewrite: no change to the resolver's blocking
claude -pturn, no SSE, and no change to engine write-semantics (the engine stays the sole writer; the viewer purely reads state the engine already wrote).The companion mitigation (a dynamic/reassuring waiting state — rotating cold-open flavor, live elapsed clock, a11y proof-of-life) already shipped in #385, so the remaining high-impact bounded win was the streaming piece.
What changed
viewer/openworlds/app.jsx—useLiveSession:/eventspoll (3s, visibility-aware, best-effort) alongside the existing/chatpoll. Newnarration/dialoguerows arriving mid-turn become live, time-stamped chronicle beats — the scene visibly builds during the wait instead of a blank indicator./chat. Streamed progress resets the stall/stuckrecovery clock (notePendingProgress) so a long-but-healthy streaming turn is never falsely declared stuck. (I chose this over clearing the bar mid-turn: the give-up cause was seeing nothing, not being unable to act — so fix the perception without relaxing the one-move-at-a-time semantics, which is race-adjacent.)claimNarration, a shared text-keyed seen-set, whitespace/case-normalized): the same paragraph shows exactly once, from whichever source reached the player first (live, in practice). The turn-END/chatline still resolves the turn even when its prose was fully deduped (a turn whose entire beat streamed live still re-opens the bar — tracked via admLineArrivedflag).setPendingStatewriter that mirrors state into apendingRef, so the/eventspoll (whose deps deliberately excludepending) reads the current turn without a stale closure; timer side-effects live outside the state updater (StrictMode-safe).viewer/openworlds/screen-table.jsx: deduprecentEventsagainst the live tail —recentEvents(from/session-surface) is a trailing window of the same session log, so a streamed paragraph would otherwise appear in both the history band and the live band. Mechanics rows and genuine pre-session prose are untouched.viewer/tests/test_live_narration_stream.py(new, 10 tests): transpiles the real.jsxwith the bundled Babel-standalone and drivesuseLiveSessionunder a deterministic React + scripted-fetch harness (mirrorstest_recovery_timing.py) — mid-turn/eventsnarration + dialogue surface live; the turn stays gated while streaming; the/chatline resolves a streamed turn; whitespace/case-insensitive dedup; unstreamed/chatprose still renders; player echoes are never deduped; a fresh run resets the dedup set. 49/49 viewer tests green (10 new + 39 existing recovery-timing / cold-open / sanitize / static).Note: the OpenWorlds UI is transpiled in-browser (
<script type="text/babel">), so these JSX changes ship as-is — the Node+Babel test harness is the compile gate, and both files transpile cleanly.Honest impact estimate
This should substantially reduce the latency give-ups: the give-up cause was seeing nothing happen, and the player now watches the scene arrive within the first poll cycle (~3s) after the DM's first
log_event. It does not make the turn faster, and it relies on the DM logging narration incrementally (it does today; a turn that logged only at the very end would still feel slower — a candidate follow-up is nudging the DM prompt to log an opening line early). The action bar stays gated until turn-end by design.Known minor tradeoff: a verbatim-repeated paragraph much later in a session would be deduped (suppressed) — verbatim repeats are rare and usually undesirable, and this errs on "never double-render."
Verification
Do NOT close on merge — verify on the next full-arc playtest against the BUILT app (no persona gives up on beat latency; scene text is visibly appearing within ~15–30s of a turn starting, not a blank wait to turn-end). Per
WorldOS-OPERATING-GOAL.md§4.7, an issue is closed by a non-reproducing next build, not by a merged PR.Summary by CodeRabbit
Release Notes
New Features
Bug Fixes