fix(viewer): render chronicle narration exactly once, in order, via a stable key (#405) - #407
Conversation
… stable key (#405) Root cause: the chronicle reconciled its TWO live narration sources by TEXT, which is fragile. The /events stream (the engine session log, where the DM streams each paragraph mid-turn via log_event) and the /chat reply (the runner's turn-END DM line, the whole turn's prose as one blob) carry the same beats but no shared id. The text-key dedup (claimNarration / dedupedRecent) broke two ways: 1. the DM rewording its turn-END reply (SKILL.md line 66 explicitly warns of this) hashed the two copies differently -> the beat showed twice; 2. even verbatim, /chat carries the whole turn as ONE blob while /events carries N per-paragraph rows -> the blob key matched no paragraph key -> the whole turn rendered again after its paragraphs already streamed = "opening appears 3 times". Ordering broke because the two sources interleaved. Fix — render each beat EXACTLY ONCE, in order, by a STABLE key: - Server stamps each /events entry (and the recentEvents history band) with its ABSOLUTE session-log line index as `seq` — the engine's sole-writer per-beat identity, independent of the prose (server.py: _read_events, _session_event_tail_from_dir, _session_recent_events). Purely additive. - /events (the session log) is the CANONICAL live-narration source; dedup + order key off `seq`, so a re-ingest (windowing / session-rotation cursor rewind) or a reworded copy can't duplicate, and live narration can't interleave out of order. - A /chat DM line is a turn-RESOLUTION signal (it still clears the pending indicator) but adds NO narration row when the current turn streamed via /events. It renders narration ONLY as a per-turn fallback when nothing streamed (a terse turn, or the human/native path where /chat is the sole source) — text-keyed, since a chat-only beat has no seq and there is no /events stream to collide with. - The chronicle merge/dedup/order is extracted to a pure, exported buildChronicleLog so the exactly-once + chronological contract is unit-testable. Windowing (<=50 rows), the bounded live tail, and auto-scroll are unchanged. Tests: 6 new hook/assembler tests reproduce the duplication (reword, blob-vs- paragraph, seq re-ingest, ordering, terse-after-streamed, recentEvents-by-seq) — each red before / green after; 2 new server tests assert monotonic absolute `seq` on /events + recentEvents. Full viewer suite green (208 passed, 1 skipped). Do NOT close on merge — verify on the next full-arc playtest.
📝 WalkthroughWalkthroughThis PR implements stable seq-based deduplication for narration across server and client. The server stamps absolute line indices into session log events; the client uses those seq values to deduplicate narration between recentEvents and live feeds, suppress /chat when /events already streamed per-turn, and order chronicle entries canonically. ChangesNarration Seq-Based Deduplication and Reconciliation
Sequence Diagram(s)sequenceDiagram
participant EventsFeed as /events Feed
participant AppSession as useLiveSession
participant ChatFeed as /chat Poll
participant Dedup as Dedup Sets<br/>(seenSeq, seenText)
participant TurnFlag as eventsStreamedThisTurnRef
EventsFeed->>AppSession: stream narration row
AppSession->>Dedup: claim by seq or text fallback
alt narration not seen
Dedup-->>AppSession: accepted
AppSession->>TurnFlag: mark = true
AppSession-->>EventsFeed: render narration beat
else narration already deduped
Dedup-->>AppSession: rejected
end
ChatFeed->>AppSession: DM line (turn resolution)
alt TurnFlag is true
AppSession-->>ChatFeed: suppress narration
else TurnFlag is false
ChatFeed->>Dedup: claim narration text
alt text not seen
Dedup-->>ChatFeed: accepted
ChatFeed-->>AppSession: render narration
end
end
ChatFeed->>AppSession: DM arrive signal
AppSession->>TurnFlag: reset = false
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 3 | ❌ 2❌ Failed checks (1 warning, 1 inconclusive)
✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
Comment |
There was a problem hiding this comment.
🧹 Nitpick comments (2)
viewer/openworlds/app.jsx (1)
461-461: 💤 Low valueMissing
claimNarrationSeqin useEffect dependencies.
claimNarrationSeqis called insidepollOnce(line 429) but is not listed in the dependency array. While it's stable (empty deps on the useCallback), this inconsistency withclaimNarrationbeing listed could trigger exhaustive-deps warnings and is a minor code smell.🔧 Suggested fix
- }, [campaignId, source, runId, notePendingProgress, claimNarration]); + }, [campaignId, source, runId, notePendingProgress, claimNarration, claimNarrationSeq]);🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@viewer/openworlds/app.jsx` at line 461, Add claimNarrationSeq to the useEffect dependency array that currently lists [campaignId, source, runId, notePendingProgress, claimNarration]; the function pollOnce (which calls claimNarrationSeq) should have claimNarrationSeq included to match its usage and the stable callback signature—update the dependency array accordingly so it reads include claimNarrationSeq alongside the existing dependencies.viewer/tests/test_live_narration_stream.py (1)
528-528: 💤 Low valueOptional: Replace ambiguous multiplication sign with 'x'.
Static analysis detected an ambiguous
×(MULTIPLICATION SIGN) character. Consider using 'x' or the phrase "3-4 times" for clarity.Suggested fix
- # `#405`: the narration-DUPLICATION regression fix. The chronicle showed DM narration 3-4× and + # `#405`: the narration-DUPLICATION regression fix. The chronicle showed DM narration 3-4x and🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@viewer/tests/test_live_narration_stream.py` at line 528, Replace the ambiguous MULTIPLICATION SIGN in the comment string "the chronicle showed DM narration 3-4× and" with a clear character or word (e.g., "3-4x" or "3-4 times") so the comment in viewer/tests/test_live_narration_stream.py is unambiguous; update the comment containing "narration 3-4×" accordingly.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@viewer/openworlds/app.jsx`:
- Line 461: Add claimNarrationSeq to the useEffect dependency array that
currently lists [campaignId, source, runId, notePendingProgress,
claimNarration]; the function pollOnce (which calls claimNarrationSeq) should
have claimNarrationSeq included to match its usage and the stable callback
signature—update the dependency array accordingly so it reads include
claimNarrationSeq alongside the existing dependencies.
In `@viewer/tests/test_live_narration_stream.py`:
- Line 528: Replace the ambiguous MULTIPLICATION SIGN in the comment string "the
chronicle showed DM narration 3-4× and" with a clear character or word (e.g.,
"3-4x" or "3-4 times") so the comment in
viewer/tests/test_live_narration_stream.py is unambiguous; update the comment
containing "narration 3-4×" accordingly.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: b4a84d23-a217-49c0-b507-4af78961e0d0
📒 Files selected for processing (5)
viewer/openworlds/app.jsxviewer/openworlds/screen-table.jsxviewer/server.pyviewer/tests/test_live_narration_stream.pyviewer/tests/test_session_surface.py
…n never wedge (#405) + streaming polish (#406) (#409) #405 (CRITICAL): the "building your universe" cover (z-9000) only cleared via the first-narration handoff or a 12-min backstop; its dismiss() callback had ZERO call sites. On a stalled/errored cold-open it wedged full-screen over the table's own recovery for up to 12 minutes. The overlay is only a COVER for the slow cold-open, so it now YIELDS to the table (live streaming + 180s narrating timeout + "Try again") on ANY of: a hard cold-open/session error (dismiss after a 3s stale-error grace), a FIXED ~120s stall ceiling (not the 12-min wall, not re-armed per beat), or a manual "Enter anyway ->" button surfaced after ~15s. dismiss() is wired in App (onEnterAnyway) and passed the bridge error. After dismiss the table is reachable + usable (cold-open action bar is enabled). Found + fixed a latent bug the new tests surfaced: the handoff flourish armed its 1400ms dismiss timer in the SAME effect that flipped `handoff`, whose cleanup cancelled the timer on the re-run -> the overlay never auto-dismissed on first narration. Split into flip + a separate handoff-keyed arm effect. #406 polish: 1. aria-modal: dropped the false role=dialog/aria-modal (no focus trap) -> aria-busy labeled container + the existing role=status announcement. 2. backstop re-arm: notePendingProgress now re-arms only the 'stuck' recovery timer, not the absolute backstop (armed once in armPending) -> a streaming-but-unresolved turn can't defer the 12-min cap forever. 3. dedup scope: #407 fixed the canonical seq-keyed path; a residual TEXT-key suppression remained for the /chat-only fallback across turns -> seenText now resets per turn (a repeated short line on a later turn renders again). 4. retried cold-open window: firstBeat now keys off resolvedTurnsRef (bumped only on /chat resolution), not streamed paragraphs -> a retried cold-open keeps the 4-min window instead of dropping to 180s. 5. headline freeze: added BUILDING_FLAVOR_LATE (calmer pool past ~42s) so the headline keeps fresh, on-arc copy for the full overlay lifetime. 6. untested lifecycle: added an effect-running harness + lifecycle tests (handoff, dismiss-on-error+grace, dismiss-on-ceiling, manual escape, table-reachable, negative disclosure) and 2 streaming-hook regression guards; rewrote the misleading min-display-floor comment. Co-authored-by: Eva <arncalso@gmail.com>
The bug (live full-arc playtest — MAJOR)
In the OpenWorlds Chronicle panel, DM narration appeared 4+ times and out of chronological order during a multi-beat session ("Opening narration appears 3 times"; "narrations 4+ times, entries out of chronological order").
Root cause
The chronicle reconciled its two live narration sources by TEXT, which is fragile:
/eventstails the engine session log (sessions/<sid>.jsonl), where the DM streams each paragraph mid-turn vialog_event(kind="narration")— one line per paragraph (the [playtest][P0] Per-beat DM latency (~60-90s) with no streaming → impatient players give up mid-session #393 give-up fix).recentEvents(the history band) is the same file's trailing window./chattails a separate file (<run>.chat.jsonl), one{"role":"dm","text":RESULT}line per turn — the DM's whole reply, often the entire turn's prose as one blob.The existing dedup (
claimNarration/dedupedRecent, whitespace+case-normalized text key) broke two ways:/chatreply hashes differently from the streamed copy, so the beat shows twice. The DM skill (skills/dungeon-master/SKILL.mdline 66) explicitly warns "a reworded reply defeats the de-dup and the player sees the beat twice." We can't stop the LLM rewording (the skill is out of scope)./chatblob's key (the whole turn) matches no single/eventsparagraph key, so the whole turn renders again after its paragraphs already streamed = "opening appears 3 times".Ordering broke because the two sources interleaved (a turn's
/chatblob lands after a later beat's paragraphs already streamed).The fix — render each beat EXACTLY ONCE, in order, by a STABLE key
/eventsentry (and therecentEventsband) with its absolute session-log line index asseq— the engine's sole-writer per-beat identity, independent of the prose. Purely additive (server.py:_read_events,_session_event_tail_from_dir,_session_recent_events)./events(the session log) is the canonical live-narration source; dedup + ordering key offseq. A/chatDM line is a turn-resolution signal (it still clears the "narrating…" indicator) but adds no narration row when the current turn streamed via/events. It renders narration only as a per-turn fallback when nothing streamed (a terse turn, or the human/native path where/chatis the sole source) — text-keyed, since a chat-only beat has noseqand there is no/eventsstream to collide with.seq. Live narration now shares the session-log line order, so the two sources structurally cannot interleave out of order.seq, it collapses to one row.buildChronicleLogso the contract is unit-testable.Why not a fully shared id across both files?
/chatis a different file written by the runner/skill (out of scope — wire contracts and the DM lane are owned elsewhere). A truly shared beat-id would need the runner to correlate both files. The "one canonical source + idempotent other" design (the task's preferred option) achieves exactly-once without that bigger change, so it is the right minimal fix.How I verified (actually exercised, not just read)
.jsx-via-Babel+Node harness + the server import test):/chatblob does not duplicate the per-paragraph stream; same-seqre-ingest shown once; chronicle orders by session-logseq; a terse turn after a streamed turn still renders;recentEventsdeduped against the live tail byseq;/events+recentEventscarry monotonic absoluteseq(stable across polls). Each duplication test is red before / green after.:8880, seeded session log):/eventsand/session-surfaceboth carryseqon the wire; the cursor-advance poll returns absoluteseq(not reset), and a freshly-appended line gets the next absolute index.useLiveSession+buildChronicleLog: the full multi-beat scenario (3 streamed paragraphs + a mechanics row + a reworded/chatblob + a windowing re-ingest + the recentEvents band, all carrying the same beats) renders exactly 3 narration rows, in order,pendingcleared, the mechanics row preserved.-p no:xdist).license_checkclean.Scope / invariants
Viewer-only (
viewer/openworlds/*.jsx+ a minimal additiveseqstamp inviewer/server.py). The engine stays the sole writer (seqis a pure read-projection of the line index it already wrote); no DM-skill /--resume/persist_beat/ wire-contract changes. Windowing (≤50 rows), the bounded live tail, and auto-scroll are unchanged._private/untouched.Do NOT close on merge — verify on the next full-arc playtest.
Summary by CodeRabbit
New Features
Bug Fixes
#405).Tests