Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 31 additions & 16 deletions viewer/openworlds/app.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -302,15 +302,22 @@ function useLiveSession(state) {

// sanitizeNarration lives in screen-table.jsx (loaded first); fall back to identity if absent.
const sanitize = (txt) => (typeof window.sanitizeNarration === "function" ? window.sanitizeNarration(txt) : (txt || ""));
// #405: claim a narration beat by its STABLE session-log `seq` (the server-stamped absolute line
// index). First-seen returns true (show it + record the id); any later arrival of the same line —
// a windowing re-mount, a session-rotation cursor rewind, or the recentEvents history band
// overlapping the live tail — returns false and is suppressed. Keyed by id, so it is immune to the
// DM rewording the prose between its streamed copy and its reply.
const claimNarrationSeq = React.useCallback((seq) => {
if (typeof seq !== "number" || !Number.isFinite(seq)) return false;
if (seenSeq.current.has(seq)) return false;
seenSeq.current.add(seq);
// #405/BUG2: claim a narration beat by its STABLE, SESSION-SCOPED key — the composite
// `${sid}:${seq}` (the server-stamped session id + the absolute session-log line index). First-seen
// returns true (show it + record the id); any later arrival of the same line — a windowing re-mount,
// a session-rotation cursor rewind, or the recentEvents history band overlapping the live tail —
// returns false and is suppressed. Keyed by id, so it is immune to the DM rewording the prose
// between its streamed copy and its reply.
// BUG2 root cause: the bare `seq` is only a PER-SESSION-LOG line index — it carries NO session
// scope. When the engine ROTATES the session log (cold-open start_session + the DM-turn-retry
// re-mint, 5e71f77) the new session's narration restarts at seq 0,1,2 — the SAME values the
// cold-open already claimed — so the post-move reply was wrongly suppressed here (and dropped by
// buildChronicleLog's matching seq set). Composing the session id makes the key globally unique
// across rotations, while preserving within-session monotonicity for the order tiebreak.
const claimNarrationSeq = React.useCallback((key) => {
if (typeof key !== "string" || !key) return false;
if (seenSeq.current.has(key)) return false;
seenSeq.current.add(key);
return true;
}, []);
// #393/#405: the TEXT-key fallback for narration with no seq (a /chat-only beat). Whitespace-
Expand Down Expand Up @@ -559,6 +566,12 @@ function useLiveSession(state) {
if (!resp.ok) return;
const payload = await resp.json();
const entries = Array.isArray(payload.entries) ? payload.entries : [];
// BUG2: the server now stamps the resolved session id on the /events response so the client
// can SESSION-SCOPE each `seq`. The composite `${sid}:${seq}` is globally unique across a
// session rotation (where the bare line index restarts at 0,1,2 and collided with the prior
// session's cold-open). Empty sid (legacy server) degrades to ":${seq}", still unique within
// the single session it serves.
const sid = (payload && typeof payload.sid === "string") ? payload.sid : "";
if (!cancelled && entries.length) {
// Only player-facing prose streams live: narration + dialogue. Roll/system/combat rows are
// mechanics the chronicle surfaces elsewhere — folding them in here would read as noise
Expand All @@ -569,17 +582,19 @@ function useLiveSession(state) {
if (kind !== "narration" && kind !== "dialogue") return null;
const clean = sanitize(e && (e.text || e.detail));
if (!clean) return null;
// #405: dedup by the STABLE session-log `seq` the server stamps on each entry — NOT by
// #405/BUG2: dedup by the STABLE, SESSION-SCOPED composite key `${sid}:${seq}` — NOT by
// prose. So a paragraph re-ingested by a windowing re-mount or a session-rotation cursor
// rewind collapses to one row, and the dedup can't be defeated by a reworded copy. A
// legacy entry with no seq (older server) falls back to the text key. The seq doubles as
// the chronological order key: `orderSeq` keeps the engine's session-log line order so
// live narration can never interleave out of order with the (now-removed) /chat source.
// rewind collapses to one row, the dedup can't be defeated by a reworded copy, AND a
// post-rotation beat (a fresh session's seq 0,1,2) is no longer suppressed by collision
// with a prior session's seq 0,1,2 (BUG2). A legacy entry with no seq (older server)
// falls back to the text key. `orderSeq` carries the SAME composite as the chronological
// order key; compareChronicle parses its numeric tail for the within-session tiebreak.
const seq = (e && typeof e.seq === "number") ? e.seq : null;
const fresh = (seq !== null) ? claimNarrationSeq(seq) : claimNarration(clean);
const seqKey = (seq !== null) ? `${sid}:${seq}` : null;
const fresh = (seqKey !== null) ? claimNarrationSeq(seqKey) : claimNarration(clean);
if (!fresh) return null;
eventsStreamedThisTurnRef.current = true; // the current turn HAS streamed live narration
return { kind: "narration", text: clean, at: nextLogSeq(), orderSeq: seq, eventAt: e && e.t };
return { kind: "narration", text: clean, at: nextLogSeq(), orderSeq: seqKey, eventAt: e && e.t };
})
.filter(Boolean);
if (beats.length) {
Expand Down
41 changes: 34 additions & 7 deletions viewer/openworlds/screen-table.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -323,17 +323,40 @@ function buildChronicleLog(recentEvents, chatBeats, log) {
const label = QUICK_ACTION_REPLAY_LABELS[key];
return label ? { ...entry, kind: "action", text: label } : entry;
};
const orderOf = (e) => {
if (e && typeof e.orderSeq === "number") return e.orderSeq;
if (e && typeof e.seq === "number") return e.seq;
// #405/BUG2: the STABLE, SESSION-SCOPED beat identity — the composite `${sid}:${seq}`. Live beats
// carry it on `orderSeq` (app.jsx composed it from the /events response sid). A recentEvents history
// row carries `seq` (the absolute session-log line index) + `sid` separately; compose the SAME key
// here so the two bands de-dup against ONE id space. A bare line index is NOT unique across a
// session ROTATION (the new session restarts at 0,1,2), which is BUG2 — the composite fixes it.
// Tolerates a legacy live beat that already carries a numeric orderSeq (older client) by coercing
// it to the same ":N" shape an empty-sid server produces.
const seqKeyOf = (e) => {
if (e && typeof e.orderSeq === "string" && e.orderSeq) return e.orderSeq;
if (e && typeof e.orderSeq === "number") return `:${e.orderSeq}`;
if (e && typeof e.seq === "number") return `${(typeof e.sid === "string" && e.sid) ? e.sid : ""}:${e.seq}`;
if (e && typeof e.seq === "string" && e.seq) return e.seq;
return null;
};
// Parse a composite key into { sid, num } for the ORDER tiebreak. The seq tiebreak only needs
// WITHIN-session monotonicity (eventAt is the primary cross-session sort), so we compare the
// numeric tail ONLY when two rows share a session id; across sessions (or an unparseable key) we
// defer to the monotonic creation-order `.at`. Splitting on the LAST ':' keeps a sid that itself
// contains ':' intact.
const orderOf = (e) => {
const key = seqKeyOf(e);
if (key === null) return null;
const idx = key.lastIndexOf(":");
const sidPart = idx >= 0 ? key.slice(0, idx) : "";
const num = Number(idx >= 0 ? key.slice(idx + 1) : key);
return Number.isFinite(num) ? { sid: sidPart, num } : null;
};
const timeOf = (e) => (e && typeof e.eventAt === "number") ? e.eventAt : null;
const compareChronicle = (a, b) => {
const ta = timeOf(a), tb = timeOf(b);
if (ta !== null && tb !== null && ta !== tb) return ta - tb;
const sa = orderOf(a), sb = orderOf(b);
if (sa !== null && sb !== null && sa !== sb) return sa - sb;
// Within-session numeric tiebreak (monotonic by construction); cross-session falls to `.at`.
if (sa !== null && sb !== null && sa.sid === sb.sid && sa.num !== sb.num) return sa.num - sb.num;
return (a?.at || 0) - (b?.at || 0);
};
// The live tail can carry the player's move twice: once as the optimistic local echo
Expand All @@ -350,8 +373,12 @@ function buildChronicleLog(recentEvents, chatBeats, log) {
const mergedTail = [...dedupedBeats.map(projectPlayerReplay), ...echoes].sort((a, b) => {
return compareChronicle(a, b);
});
// #405/BUG2: the set of live narration beats keyed by the SESSION-SCOPED composite `${sid}:${seq}`
// (via seqKeyOf). A recentEvents row that shares a live beat's composite is the same session-log
// line and must be dropped — prose-independent. (Previously a bare int, which collided across a
// session rotation; the composite is globally unique.)
const liveSeqs = new Set(
mergedTail.filter((b) => b && b.kind === "narration" && typeof b.orderSeq === "number").map((b) => b.orderSeq),
mergedTail.filter((b) => b && b.kind === "narration").map((b) => seqKeyOf(b)).filter((k) => k !== null),
);
const liveNarrationKeys = new Set(
mergedTail.filter((b) => b && b.kind === "narration").map((b) => narrationKey(b.text)).filter(Boolean),
Expand All @@ -360,8 +387,8 @@ function buildChronicleLog(recentEvents, chatBeats, log) {
const kind = (row && (row.kind || row.type)) || "narration";
if (kind === "system") return false; // engine bookkeeping; never player-facing
if (kind !== "narration" && kind !== "dialogue") return true; // gameplay mechanic rows kept
const seq = row && row.seq;
if (typeof seq === "number") return !liveSeqs.has(seq); // stable-id match (prose-independent)
const seqKey = seqKeyOf(row); // composes `${sid}:${seq}` for the history-band row (BUG2)
if (seqKey !== null) return !liveSeqs.has(seqKey); // stable-id match (prose-independent)
const key = narrationKey(row && (row.text || row.detail));
return !key || !liveNarrationKeys.has(key);
});
Expand Down
30 changes: 28 additions & 2 deletions viewer/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -1514,9 +1514,17 @@ def _session_recent_events(raw_events: list[dict] | None) -> list[dict]:
# viewer's leading history band (recentEvents) de-dups against the live /events tail by ID,
# not by prose. _session_event_tail_from_dir stamps it; an older snapshot/path without it
# simply omits the key and the viewer falls back to its text-key dedup for that row.
# BUG2: a bare line index is NOT unique across a session ROTATION (cold-open start_session +
# the DM-turn-retry re-mint, 5e71f77) — the new session's log restarts at 0,1,2, the same
# indices the prior session already claimed. So carry the resolved session id (`sid`) too; the
# viewer composes `${sid}:${seq}` as the globally-unique dedup/order key, so a fresh session's
# narration is no longer suppressed by collision with a prior session's seq 0,1,2.
seq = row.get("seq")
if isinstance(seq, int) and not isinstance(seq, bool):
item["seq"] = seq
sid = row.get("sid")
if isinstance(sid, str) and sid:
item["sid"] = sid
event_at = row.get("t")
if isinstance(event_at, (int, float)) and not isinstance(event_at, bool):
item["eventAt"] = event_at
Expand Down Expand Up @@ -1596,6 +1604,9 @@ def _session_event_tail_from_dir(campaign_dir: Path, snapshot: dict, limit: int
continue
if isinstance(row, dict):
row.setdefault("seq", base + offset)
# BUG2: stamp the resolved session id so recentEvents composes the SAME `${sid}:${seq}`
# key the live /events tail does — a bare line index collides across a session rotation.
row.setdefault("sid", sid)
out.append(row)
return out

Expand Down Expand Up @@ -5623,6 +5634,16 @@ def _app_status_payload(*, port: int, attached_campaign_id: str, viewed_campaign
}


def _active_session_id(campaign_id: str) -> str:
"""Resolve the campaign's current session id (the session-log basename the /events feed tails).
BUG2: this is the namespace the viewer composes onto each `seq` (`${sid}:${seq}`) so the dedup/
order key is globally unique across a session ROTATION — without it a fresh session's narration
(which restarts at line 0,1,2) collides with the prior session's seq 0,1,2 and is suppressed."""
snap = _read_snapshot(campaign_id)
sid = snap.get("active_session_id") or (snap.get("session_ids") or [None])[-1]
return sid if isinstance(sid, str) else ""


def _read_events(campaign_id: str, since: int) -> tuple[list[dict], int]:
"""Return (new story entries after line `since`, new line count). Drops a
trailing partial line defensively (append-only writes can exceed PIPE_BUF)."""
Expand Down Expand Up @@ -6576,8 +6597,13 @@ def do_GET(self) -> None: # noqa: N802
elif route == "/events":
qs = parse_qs(parsed.query)
since = int((qs.get("since") or ["0"])[0])
entries, nxt = _read_events(self._view_campaign(qs), since)
self._json({"entries": entries, "next": nxt})
view_cid = self._view_campaign(qs)
entries, nxt = _read_events(view_cid, since)
# BUG2: include the resolved session id so the client composes a globally-unique
# `${sid}:${seq}` dedup/order key — a bare per-session line index collides across a
# session rotation (cold-open + DM-turn-retry re-mint), suppressing the new session's
# post-move narration (seq 0,1,2 already claimed by the prior session's cold-open).
self._json({"entries": entries, "next": nxt, "sid": _active_session_id(view_cid)})
Comment on lines +6601 to +6606

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Use one session-id source for entries and response sid

Line 6601 and Line 6606 resolve session state via separate snapshot reads, so a rotation between them can return entries from one session and sid from another. That miskeys ${sid}:${seq} on the client and can still drop/duplicate narration around rotation boundaries.

Suggested fix
-def _read_events(campaign_id: str, since: int) -> tuple[list[dict], int]:
+def _read_events(campaign_id: str, since: int) -> tuple[list[dict], int, str]:
@@
-    if not sid:
-        return [], since
+    if not sid:
+        return [], since, ""
@@
-    if not log.exists():
-        return [], since
+    if not log.exists():
+        return [], since, sid
@@
-    return out, consumed
+    return out, consumed, sid
-            entries, nxt = _read_events(view_cid, since)
+            entries, nxt, sid = _read_events(view_cid, since)
@@
-            self._json({"entries": entries, "next": nxt, "sid": _active_session_id(view_cid)})
+            self._json({"entries": entries, "next": nxt, "sid": sid})
🤖 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/server.py` around lines 6601 - 6606, The entries and response sid are
read from two separate snapshots causing a race; ensure both come from the same
session snapshot by reading the active session id once and using that same id
when fetching entries (or modify _read_events to return the session id along
with entries and nxt). Concretely, call _active_session_id(view_cid) first (or
update _read_events(view_cid, since) to return (entries, nxt, sid)) and then use
that single sid value in the JSON response instead of calling _active_session_id
again.

elif route == "/activity":
qs = parse_qs(parsed.query)
since = int((qs.get("since") or ["0"])[0])
Expand Down
56 changes: 56 additions & 0 deletions viewer/tests/test_live_narration_stream.py
Original file line number Diff line number Diff line change
Expand Up @@ -609,6 +609,62 @@ def test_same_seq_reingested_is_shown_once(self):
self.assertEqual(out["second"], ["Lightning splits the sky."],
"a re-ingested session-log line (same seq) must collapse to one row, immune to its prose (#405)")

# --- BUG2 (critical): a NEW session's narration after a session ROTATION must RENDER, not be
# suppressed by a `seq` COLLISION with a prior session's seq 0,1,2. -----------------------------
# The engine rotates the session log on a cold-open start_session AND on a DM-turn-retry re-mint
# (5e71f77): the new session's log restarts at line 0,1,2 — the SAME bare indices the cold-open
# already claimed. The bare-seq dedup (server + client) had NO session scope, so post-move
# narration from the new session was BOTH suppressed by claimNarrationSeq AND dropped by
# buildChronicleLog's seq match — the player saw "Composing…" then nothing. The server now stamps
# the resolved session id on the /events response, and the client composes a SESSION-SCOPED key
# `${sid}:${seq}`; "session_a:0" and "session_b:0" are distinct, so the rotated session renders.
# This is the cross-session twin of test_same_seq_reingested_is_shown_once (same sid:seq STILL
# collapses; a DIFFERENT sid with the same seq does NOT).
def test_new_session_seq_collision_after_rotation_still_renders(self):
out = self._run(
# Session A (the cold-open) streams its opening beats at seq 0,1,2 — claimed under sid A.
"h.enqueue('/events', { sid: 'session_a', entries: ["
" { kind: 'narration', text: 'You wake in a cold cell.', seq: 0 },"
" { kind: 'narration', text: 'A torch gutters in the corridor.', seq: 1 },"
" { kind: 'narration', text: 'Footsteps approach.', seq: 2 }"
"], next: 3 });"
"await h.tick();"
"var coldOpen = h.narrationTexts();"
# The engine ROTATES the session (DM-turn-retry re-mint). The viewer's cursor is re-read
# from the new file's top, so the post-move reply arrives as a FRESH session's seq 0,1,2 —
# the SAME bare indices session A already claimed. Under the bug these were suppressed.
"h.enqueue('/events', { sid: 'session_b', entries: ["
" { kind: 'narration', text: 'The guard hauls you to your feet.', seq: 0 },"
" { kind: 'narration', text: 'The cell door clangs open.', seq: 1 }"
"], next: 2 });"
"await h.tick();"
"return ({ coldOpen: coldOpen, afterRotation: h.narrationTexts(), chronicle: h.chronicleNarration() });"
)
self.assertEqual(
out["coldOpen"],
["You wake in a cold cell.", "A torch gutters in the corridor.", "Footsteps approach."],
"session A's cold-open beats stream live",
)
self.assertEqual(
out["afterRotation"],
[
"You wake in a cold cell.", "A torch gutters in the corridor.", "Footsteps approach.",
"The guard hauls you to your feet.", "The cell door clangs open.",
],
"post-rotation narration (a NEW session's seq 0,1) must NOT be suppressed by collision with "
"session A's seq 0,1 — the session-scoped `${sid}:${seq}` key keeps them distinct (BUG2)",
)
# And the ASSEMBLED chronicle (the other half of BUG2 — buildChronicleLog dropped it too) must
# render all five beats in order, the two sessions' identical seq numbers no longer colliding.
self.assertEqual(
out["chronicle"],
[
"You wake in a cold cell.", "A torch gutters in the corridor.", "Footsteps approach.",
"The guard hauls you to your feet.", "The cell door clangs open.",
],
"buildChronicleLog must render the rotated session's beats too (no cross-session seq drop) (BUG2)",
)

# --- #405: CHRONOLOGICAL ORDER — beats that stream out of arrival order still render in session-log
# (seq) order in the assembled chronicle. -------------------------------------------------------
def test_chronicle_orders_by_session_log_seq(self):
Expand Down
Loading