Skip to content

fix(viewer): render chronicle narration exactly once, in order, via a stable key (#405) - #407

Merged
100yenadmin merged 1 commit into
mainfrom
fix/chronicle-narration-dedup
May 30, 2026
Merged

fix(viewer): render chronicle narration exactly once, in order, via a stable key (#405)#407
100yenadmin merged 1 commit into
mainfrom
fix/chronicle-narration-dedup

Conversation

@100yenadmin

@100yenadmin 100yenadmin commented May 30, 2026

Copy link
Copy Markdown
Member

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:

The existing dedup (claimNarration / dedupedRecent, whitespace+case-normalized text key) broke two ways:

  1. Reword — the DM rewording its turn-END /chat reply hashes differently from the streamed copy, so the beat shows twice. The DM skill (skills/dungeon-master/SKILL.md line 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).
  2. Blob-vs-paragraph — even verbatim, the /chat blob's key (the whole turn) matches no single /events paragraph 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 /chat blob lands after a later beat's paragraphs already streamed).

The fix — render each beat EXACTLY ONCE, in order, by a STABLE key

  • Stable key, not text. The server now stamps every /events entry (and the recentEvents band) with its absolute session-log line index as seq — 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).
  • One canonical source per beat. /events (the session log) is the canonical live-narration source; dedup + ordering key off seq. A /chat DM 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 /chat is the sole source) — text-keyed, since a chat-only beat has no seq and there is no /events stream to collide with.
  • Chronological by seq. Live narration now shares the session-log line order, so the two sources structurally cannot interleave out of order.
  • Re-ingest immunity. A windowing re-mount or a session-rotation cursor rewind re-reads the same line; keyed by seq, it collapses to one row.
  • The chronicle merge/dedup/order is extracted to a pure, exported buildChronicleLog so the contract is unit-testable.

Why not a fully shared id across both files?

/chat is 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)

  • 8 new tests, single-process (extend the existing real-.jsx-via-Babel+Node harness + the server import test):
    • reword does not duplicate a streamed beat; /chat blob does not duplicate the per-paragraph stream; same-seq re-ingest shown once; chronicle orders by session-log seq; a terse turn after a streamed turn still renders; recentEvents deduped against the live tail by seq; /events + recentEvents carry monotonic absolute seq (stable across polls). Each duplication test is red before / green after.
  • Live server over HTTP (booted on :8880, seeded session log): /events and /session-surface both carry seq on the wire; the cursor-advance poll returns absolute seq (not reset), and a freshly-appended line gets the next absolute index.
  • End-to-end render through the real transpiled useLiveSession + buildChronicleLog: the full multi-beat scenario (3 streamed paragraphs + a mechanics row + a reworded /chat blob + a windowing re-ingest + the recentEvents band, all carrying the same beats) renders exactly 3 narration rows, in order, pending cleared, the mechanics row preserved.
  • Full viewer suite green: 208 passed, 1 skipped (single-process, -p no:xdist). license_check clean.

Scope / invariants

Viewer-only (viewer/openworlds/*.jsx + a minimal additive seq stamp in viewer/server.py). The engine stays the sole writer (seq is 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

    • Introduced stable sequence-based deduplication for narration entries across live event streams and chat responses.
  • Bug Fixes

    • Fixed duplicate narration appearing in live session chronicles (issue #405).
    • Resolved inconsistent narration ordering between streamed and chat-delivered content.
  • Tests

    • Added regression tests validating narration deduplication and chronological ordering in live sessions.

Review Change Stack

… 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.
@coderabbitai

coderabbitai Bot commented May 30, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

This 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.

Changes

Narration Seq-Based Deduplication and Reconciliation

Layer / File(s) Summary
Server-side seq stamping into session events
viewer/server.py
_session_recent_events, _session_event_tail_from_dir, and _read_events now compute and stamp stable integer seq values (absolute line indices within the session log) into each event dict, enabling consistent seq-keyed deduplication on the client.
Client-side chronicle helper extraction and integration
viewer/openworlds/screen-table.jsx
New pure function buildChronicleLog merges recentEvents, live chat beats, and local log entries into an ordered/deduped chronicle using seq keys (fallback to sanitized narration text). ScreenTable replaces inline merge/sort/dedup logic with calls to buildChronicleLog.
Live session narration dedup and /chat suppression
viewer/openworlds/app.jsx
useLiveSession introduces dual dedup sets (seq-keyed and text-fallback), a per-turn flag tracking whether /events has streamed narration, and suppresses /chat narration when the turn has already streamed via /events. The per-turn flag resets on /chat DM arrival so later terse turns can still render /chat-only prose.
Test infrastructure and seq/dedup validation
viewer/tests/test_session_surface.py, viewer/tests/test_live_narration_stream.py
New test helpers (_env context manager, chronicleNarration/chronicle accessors) and regression test suite (#405) validate seq stamping stability across server projections, seq-based deduplication, per-turn suppression, chronicle ordering, and dedup across recentEvents history and live tail.

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
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • electricsheephq/WorldOS#394: Extends the prior live-session narration merge logic in app.jsx and screen-table.jsx from text-key dedup to stable seq-based canonicalization for reconciling /events and /chat narration.
  • electricsheephq/WorldOS#349: Updates sanitizeNarration function that is now invoked by the new buildChronicleLog helper to strip DM scaffolding from narration text.
  • electricsheephq/WorldOS#128: Introduces the /session-surface log/read-model and recentEvents plumbing that this PR builds on for seq-based chronicle merging and server projection stamping.

Poem

🐰 A tale of narration, twice-told no more,
Seq marks each whisper at the session door.
Events stream freely, Chat claims the turn,
Dedup keys spin—no duplicates churn.
One truth, one order, one chronicle song! 🎭

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 16.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ❓ Inconclusive The description is comprehensive, covering the bug, root cause, fix strategy, verification, and scope. However, it is missing the required CLA and validation checkboxes from the template. Complete the description template by checking the CLA checkbox, confirming no confidential data was included, and listing all validation checks performed.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically summarizes the main fix: using a stable key (seq) to render chronicle narration exactly once and in order, directly addressing issue #405.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
viewer/openworlds/app.jsx (1)

461-461: 💤 Low value

Missing claimNarrationSeq in useEffect dependencies.

claimNarrationSeq is called inside pollOnce (line 429) but is not listed in the dependency array. While it's stable (empty deps on the useCallback), this inconsistency with claimNarration being 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 value

Optional: 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

📥 Commits

Reviewing files that changed from the base of the PR and between 6310750 and 9ca4e38.

📒 Files selected for processing (5)
  • viewer/openworlds/app.jsx
  • viewer/openworlds/screen-table.jsx
  • viewer/server.py
  • viewer/tests/test_live_narration_stream.py
  • viewer/tests/test_session_surface.py

@100yenadmin
100yenadmin merged commit 7221f7b into main May 30, 2026
9 checks passed
@100yenadmin
100yenadmin deleted the fix/chronicle-narration-dedup branch May 30, 2026 17:15
100yenadmin added a commit that referenced this pull request May 30, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant