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
40 changes: 32 additions & 8 deletions viewer/openworlds/app.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,18 +82,29 @@ window.neutralizeMarkup = window.neutralizeMarkup || function neutralizeMarkup(r
// #348: the recovery 'stuck' timeout is ADAPTIVE by turn position, because the DM beat lands
// all-at-once (the /chat tail carries NO streaming/partial/heartbeat signal — the duo+human
// runners append ONE {"role":"dm",...} line only after the whole turn's `result` is in, so the
// poll sees zero new items for the entire turn then the complete beat). With no in-flight
// progress to reset on, a fixed wall-clock from submit was the only lever — and at 90s it
// PRE-EMPTED the legit Act-opening (the #324 narrative persona saw the cold-open take several
// minutes and still succeed → false 'stuck', narration lost at a cliffhanger, #348).
// poll sees zero new items for the entire turn then the complete beat). #393 added a live /events
// tail, BUT every shipped DM path persists a turn's narration in ONE batched write at turn-END
// (SKILL.md step 7 → persist_beat) — so the /events stream ALSO surfaces a beat only at turn-end
// today, and notePendingProgress() has nothing to reset on MID-turn. So a wall-clock from submit
// is still the operative lever for later beats, and at 90s it PRE-EMPTED both the legit Act-opening
// (#348) AND a content-rich beat 2–4 that legitimately ran 90–120s (#399 — the playtester's give-up).
// • FIRST beat of a session (the cold-open / Act-opening) gets a generous window — the engine
// is building the world + setting the scene; a blind newbie run saw this take 5–8 min.
// • LATER beats are quick (the old 90s was tuned for these); keep them snappy so a genuine
// mid-session stall still recovers fast.
// • LATER beats: #399 raises the window 90s → 180s to cover the worst-case ~120s turn with
// margin while still recovering a genuine mid-session stall within ~3 min.
// The 12-min hard backstop is UNCHANGED — a turn that blows even the first-beat window still
// gets force-cleared. "first beat?" = no DM narration has arrived this session yet (the hook's
// dmBeatCountRef, reset to 0 on every run change).
const PENDING_RECOVERY_MS = 90 * 1000; // #342: later-beat stall window (DM turns are ~35–60s).
// #399: later-beat stall window raised 90s → 180s. The duo/human/native DM paths ALL persist a
// turn's narration in ONE batched write at turn-END (SKILL.md step 7 → persist_beat), so NOTHING is
// written to the session log mid-turn — meaning the #393 /events stream surfaces a beat only at
// turn-end and notePendingProgress() can't reset this clock DURING the turn. With no mid-turn reset
// to lean on, the 90s window (tuned for the ~35–60s norm) pre-empted a content-rich beat 2–4 that
// legitimately ran 90–120s → a false 'stuck' on a working turn (the give-up the playtester filed).
// 180s covers the worst-case ~120s turn with margin while still recovering a GENUINE mid-session
// stall within ~3 min. (The adaptive reset below is KEPT — it's correct and starts helping the day a
// DM path does log beats incrementally; today it just rarely has anything to reset on mid-turn.)
const PENDING_RECOVERY_MS = 180 * 1000; // #399: later-beat stall window (worst-case DM turns run ~90–120s; was 90s/#342).
const PENDING_RECOVERY_FIRST_MS = 4 * 60 * 1000; // #348: first-beat (Act-opening) window — fits the multi-minute cold open.
const PENDING_BACKSTOP_MS = 12 * 60 * 1000; // …with the original hard backstop as a final net.
// #348: the single source of truth for the recovery window, by turn position. Pure + exported
Expand Down Expand Up @@ -215,8 +226,21 @@ function useLiveSession(state) {
if (p.stuck) setPendingState((q) => (q ? { ...q, stuck: false } : q));
}, [clearTimers, setPendingState]);

// #399: idempotent player echo. The #344 'Try again' recovery re-POSTs the EXACT stalled move
// (postMove → recordPlayerEcho again), which used to append a SECOND identical action row — the
// duplicated "Rolan—" the playtester saw in the chronicle. Skip the append when the last entry is
// already an identical action (same `who` + same trimmed text) so a retry never doubles the line.
// Only a back-to-back exact repeat is suppressed; a genuine "do X" then "do X again" two turns
// apart is separated by the DM's narration beat between them, so it is NOT deduped.
const recordPlayerEcho = React.useCallback((who, text) => {
setLog((l) => [...l, { kind: "action", who, text, at: nextLogSeq() }]); // #274: creation-order stamp
setLog((l) => {
const last = l[l.length - 1];
if (last && last.kind === "action" && last.who === who
&& String(last.text || "").trim() === String(text || "").trim()) {
return l; // identical to the row already showing (a Try-again re-POST) — no duplicate.
}
return [...l, { kind: "action", who, text, at: nextLogSeq() }]; // #274: creation-order stamp
});
}, []);
Comment on lines 235 to 244

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 | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Check if tests verify that repeated actions separated by DM narration are preserved.

# Search for test assertions involving recordPlayerEcho, log state, and narration
rg -nC5 'recordPlayerEcho|idempotent.*echo' viewer/tests/test_live_narration_stream.py

# Check if tests assert distinct actions are preserved
rg -nC3 'distinct|legitimate.*repeat|separated.*narration' viewer/tests/

Repository: electricsheephq/WorldOS

Length of output: 5678


🏁 Script executed:

#!/bin/bash
set -euo pipefail
FILE="viewer/tests/test_live_narration_stream.py"

python3 - <<'PY'
import itertools
path="viewer/tests/test_live_narration_stream.py"
start=430
end=490
with open(path,'r',encoding='utf-8') as f:
    for i,line in enumerate(f,1):
        if start<=i<=end:
            print(f"{i:4d}:{line.rstrip()}")
PY

Repository: electricsheephq/WorldOS

Length of output: 3032


🏁 Script executed:

#!/bin/bash
set -euo pipefail
FILE="viewer/tests/test_live_narration_stream.py"

rg -n "later turn|rephrase|different action|keep(s)? distinct|idempotent|Try again|chatBeats|narrat" "$FILE"

# Also locate all tests that call h.echo (player echo)
rg -n "h\.echo\(" "$FILE" | head -n 50

Repository: electricsheephq/WorldOS

Length of output: 4531


🏁 Script executed:

#!/bin/bash
set -euo pipefail
FILE="viewer/tests/test_live_narration_stream.py"
python3 - <<'PY'
path="viewer/tests/test_live_narration_stream.py"
for start,end in [(360,430),(370,410),(380,420)]:
    print("\n# --- lines",start,"-",end,"---")
    with open(path,'r',encoding='utf-8') as f:
        for i,line in enumerate(f,1):
            if start<=i<=end:
                print(f"{i:4d}:{line.rstrip()}")
PY

Repository: electricsheephq/WorldOS

Length of output: 10256


🏁 Script executed:

#!/bin/bash
set -euo pipefail
FILE="viewer/tests/test_live_narration_stream.py"

rg -n "h\.echo\(" "$FILE"

# Show a wider context around each h.echo call
rg -n -C8 "h\.echo\(" "$FILE"

Repository: electricsheephq/WorldOS

Length of output: 2334


🏁 Script executed:

#!/bin/bash
set -euo pipefail
FILE="viewer/openworlds/app.jsx"

python3 - <<'PY'
path="viewer/openworlds/app.jsx"
# Print the region around the snippet and the nearby comment that mentions narration beats.
for start,end in [(200,270),(270,340)]:
    print(f"\n# --- {start}-{end} ---")
    with open(path,'r',encoding='utf-8') as f:
        for i,line in enumerate(f,1):
            if start<=i<=end:
                print(f"{i:4d}:{line.rstrip()}")
PY

Repository: electricsheephq/WorldOS

Length of output: 9166


🏁 Script executed:

#!/bin/bash
set -euo pipefail
rg -n "recordPlayerEcho|setLog\(" viewer/openworlds/app.jsx

Repository: electricsheephq/WorldOS

Length of output: 366


🏁 Script executed:

#!/bin/bash
set -euo pipefail
rg -n "recordPlayerEcho\(" viewer/openworlds

Repository: electricsheephq/WorldOS

Length of output: 147


🏁 Script executed:

#!/bin/bash
set -euo pipefail
FILE="viewer/openworlds/screen-table.jsx"
start=360
end=430
python3 - <<PY
path="$FILE"
s=$start;e=$end
with open(path,'r',encoding='utf-8') as f:
    for i,line in enumerate(f,1):
        if s<=i<=e:
            print(f"{i:4d}:{line.rstrip()}")
PY

Repository: electricsheephq/WorldOS

Length of output: 3984


🏁 Script executed:

#!/bin/bash
set -euo pipefail
rg -n "setLog\\(" viewer/openworlds/app.jsx

Repository: electricsheephq/WorldOS

Length of output: 111


🏁 Script executed:

#!/bin/bash
set -euo pipefail
rg -n "setLog\\(|log\\s*:\\s*\\(|\\.log\\(" viewer/openworlds
rg -n "setLog\\(" viewer

Repository: electricsheephq/WorldOS

Length of output: 50381


🏁 Script executed:

#!/bin/bash
set -euo pipefail
FILE="viewer/tests/test_live_narration_stream.py"
python3 - <<'PY'
path="viewer/tests/test_live_narration_stream.py"
start=180
end=260
with open(path,'r',encoding='utf-8') as f:
    for i,line in enumerate(f,1):
        if start<=i<=end:
            print(f"{i:4d}:{line.rstrip()}")
PY

Repository: electricsheephq/WorldOS

Length of output: 4664


Dedup for recordPlayerEcho ignores narration beats; identical actions on a later turn can be suppressed.

  • viewer/openworlds/app.jsx recordPlayerEcho only checks the last entry in log (same who + same trimmed text); DM narration goes to chatBeats, not log, so narration does not “separate” two identical actions for this dedup.
  • This contradicts the in-code claim that “two turns apart … is NOT deduped” based on a narration beat between them.
  • Existing tests only cover: (1) immediate Try again exact re-post idempotence and (2) different actions; there’s no test for “same action again on the next turn after narration.”

Suggested fix: either correct the comment to match current behavior, or change the dedup to be scoped to the same retry/pending turn (and add a test for the next-turn-after-narration repeat).

🤖 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` around lines 235 - 244, recordPlayerEcho currently
dedups by only comparing against the very last entry in log, which ignores
intervening narration stored in chatBeats and therefore can suppress identical
player actions that occur across a narration beat; update recordPlayerEcho to
scope deduping to the most recent action entry since the last narration (or
equivalent turn boundary) rather than the absolute last log item: locate
recordPlayerEcho and inspect chatBeats/log to find the last narration beat (or
maintain a turn/pending marker), then compare the incoming (who, trimmed text)
against the last action entry after that narration boundary (using nextLogSeq as
before) so identical actions on a later turn are not suppressed; add a unit test
that posts an action, inserts a narration beat into chatBeats, then posts the
same action again and asserts it is appended (or alternatively correct the
function comment if you intentionally want current behavior).


React.useEffect(() => clearTimers, [clearTimers]);
Expand Down
9 changes: 6 additions & 3 deletions viewer/openworlds/screen-table.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -825,8 +825,9 @@ function LogEntry({ entry }) {
// #348: `firstBeat` makes the expectation HONEST. The DM beat lands all-at-once (no streaming),
// and the FIRST beat — the cold-open/Act-opening the engine spends minutes building — legitimately
// takes several minutes. Telling a first-timer "up to a minute" then re-opening the bar at 90s was
// the #348 false-stuck trap. For the opening we say "a few minutes"; later beats keep "up to a
// minute" (they really are ~35–60s). This copy mirrors the adaptive recovery window in app.jsx.
// the #348 false-stuck trap. For the opening we say "a few minutes"; later beats say "a minute or
// two" (the ~35–60s norm, but a content-rich beat 2–4 runs 90–120s — #399). This copy mirrors the
// adaptive recovery window in app.jsx (later-beat window raised 90s → 180s in #399).
// #385: the rotating "the world is being made" flavor lines for the COLD-OPEN only. The first beat
// legitimately takes minutes (the engine builds the world + sets the scene; no streaming), and the
// old single static line ("Setting the opening scene — …") read as FROZEN: it never changed, the
Expand Down Expand Up @@ -862,7 +863,9 @@ function DmNarratingBeat({ since, firstBeat }) {
: "The Dungeon Master is narrating";
const waitHint = firstBeat
? "The first beat of a session can take a few minutes — hang tight, your story is on its way."
: "Weaving the next beat — this can take up to a minute.";
// #399: a content-rich beat can run up to ~two minutes (the window is 180s); say "a minute or
// two" so a 90–120s wait reads as expected, not as the app having stalled.
: "Weaving the next beat — this can take a minute or two.";
// #385: a11y model for the cold-open. The frozen-app illusion came from the live region being the
// ONLY accessible text AND it never changing (the dots/shimmer/elapsed were all aria-hidden). Fix:
// • The visible label + elapsed are NO LONGER aria-hidden for the first beat, so they appear in
Expand Down
4 changes: 3 additions & 1 deletion viewer/tests/test_cold_open_progress.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@
• the per-second-changing text lives OUTSIDE the aria-live region (a separate
visually-hidden role="status" announces a STABLE reassurance ONCE) so a screen reader
isn't spammed every tick.
Later beats (the ~35–60s norm) keep the original #336 treatment unchanged.
Later beats keep the original #336 STRUCTURE (steady "narrating" label + aria-hidden ticking
elapsed); #399 only adjusted the later-beat wait-hint wording ("a minute or two") + raised the
recovery window to 180s (a content-rich beat 2–4 runs ~90–120s) — neither is asserted here.

These tests exercise the REAL component by transpiling the actual `screen-table.jsx` with the
SAME bundled Babel-standalone the browser uses and rendering `DmNarratingBeat` under a tiny
Expand Down
66 changes: 66 additions & 0 deletions viewer/tests/test_live_narration_stream.py
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,12 @@
// arm the "DM is narrating…" indicator exactly as a posted player move does (armPending is on
// the hook's returned api). Used to prove a streamed paragraph CLEARS it (the give-up fix).
arm: (text) => reactHost.api().armPending(text || 'open the scene'),
// #399: the hook's public player-echo append (the chronicle's optimistic "You: …" row). Idempotent
// so a #344 'Try again' re-POST of the exact stalled move doesn't double the line.
echo: (who, text) => reactHost.api().recordPlayerEcho(who, text),
log: () => (reactHost.api().log || []).map((e) => ({ kind: e.kind, who: e.who, text: e.text })),
// #399: the recovery-window selector by turn position (firstBeat ⇒ cold-open window, else later).
recoveryWindowMs: (firstBeat) => sandbox.window.recoveryWindowMs(firstBeat),
drain,
};

Expand Down Expand Up @@ -404,6 +410,66 @@ def test_new_run_resets_dedup(self):
self.assertEqual(out["run2"], ["A familiar refrain."],
"a new run must reset the dedup set so identical prose isn't wrongly suppressed across runs")

# --- #399: a resolved DM beat makes the NEXT turn a LATER beat (firstBeat=false) -----------
# The recovery window is turn-position-aware: the cold-open gets the generous 4-min window, but
# turns 2+ get the (now 180s, #399) later window. This proves the firstBeat flip happens after a
# real beat resolves on /chat — so the later-beat window genuinely governs beats 2–4 (the slow-
# but-working turns the playtester gave up on), NOT the cold-open window.
def test_resolved_beat_makes_next_turn_a_later_beat(self):
out = self._run(
# Turn 1: arm, then resolve it with a turn-END /chat DM line (bumps the internal beat count).
"h.arm('open the scene');"
"h.enqueue('/chat', { items: [{ role: 'dm', text: 'You stand at the gates of Baldur\\u2019s Gate.' }], next: 1 });"
"await h.tick();"
"var afterTurn1 = h.pending();" # JS string; afterTurn1 should be null (turn resolved)
# Turn 2: arm again — this pending must be a LATER beat (firstBeat:false).
"h.arm('walk through the gate');"
"var turn2 = h.pending();"
"return ({ turn1_resolved: afterTurn1 === null, turn2_firstBeat: !!(turn2 && turn2.firstBeat), turn2_active: !!(turn2 && !turn2.stuck) });"
)
self.assertTrue(out["turn1_resolved"], "the turn-END /chat line should resolve turn 1")
self.assertTrue(out["turn2_active"], "turn 2 should arm a fresh narrating indicator")
self.assertFalse(out["turn2_firstBeat"],
"turn 2 must be a LATER beat (firstBeat=false) → it uses the 180s later-beat window, not the cold-open window")

# --- #399: the later-beat recovery window is 180s (covers the worst-case ~120s turn) -------
def test_later_beat_window_is_180s(self):
out = self._run(
"return ({ first: h.recoveryWindowMs(true), later: h.recoveryWindowMs(false) });"
)
self.assertEqual(out["later"], 180 * 1000,
"the later-beat window must be 180s so a content-rich 90–120s beat 2–4 isn't falsely declared stuck (#399)")
self.assertEqual(out["first"], 4 * 60 * 1000, "the cold-open window is unchanged (4 min)")

# --- #399: the player echo is IDEMPOTENT (the 'Try again' re-POST doesn't duplicate) -------
# The #344 stuck-recovery re-POSTs the EXACT stalled move (postMove → recordPlayerEcho again),
# which used to append a SECOND identical action row — the duplicated "Rolan—" the playtester
# filed. A back-to-back identical (who, text) must NOT double the chronicle line.
def test_player_echo_is_idempotent_on_retry(self):
out = self._run(
"await h.drain();"
"h.echo('Rolan', 'Rolan\\u2014 hold the line');" # original submit
"var afterFirst = h.log();"
"h.echo('Rolan', 'Rolan\\u2014 hold the line');" # 'Try again' re-POST (exact same move)
"var afterRetry = h.log();"
"return ({ afterFirst: afterFirst, afterRetry: afterRetry });"
)
self.assertEqual(len(out["afterFirst"]), 1, "the first submit records one action row")
self.assertEqual(len(out["afterRetry"]), 1,
"a 'Try again' re-POST of the exact same move must NOT duplicate the chronicle action (#399)")
self.assertEqual(out["afterRetry"][0]["text"], "Rolan— hold the line")

# --- #399: a DIFFERENT action (a rephrase, or a later turn) is NOT deduped -----------------
def test_player_echo_keeps_distinct_actions(self):
out = self._run(
"await h.drain();"
"h.echo('Rolan', 'hold the line');"
"h.echo('Rolan', 'fall back to the bridge');" # a genuinely different move
"return ({ log: h.log() });"
)
self.assertEqual(len(out["log"]), 2,
"two distinct actions must both appear (idempotence only suppresses a back-to-back exact repeat)")


if __name__ == "__main__":
unittest.main()
24 changes: 21 additions & 3 deletions viewer/tests/test_recovery_timing.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,16 +204,34 @@ def test_constants_exported_and_ordered(self):
# and still strictly inside the hard backstop. This is the whole shape of the #348 fix.
self.assertLess(c["recoveryMs"], c["recoveryFirstMs"])
self.assertLess(c["recoveryFirstMs"], c["backstopMs"])
# Concretely: later = 90s, first = 4 min, backstop = 12 min.
self.assertEqual(c["recoveryMs"], 90 * 1000)
# Concretely: later = 180s (#399, was 90s), first = 4 min, backstop = 12 min.
self.assertEqual(c["recoveryMs"], 180 * 1000)
self.assertEqual(c["recoveryFirstMs"], 4 * 60 * 1000)
self.assertEqual(c["backstopMs"], 12 * 60 * 1000)
Comment on lines +207 to 210

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 | 🟡 Minor | ⚡ Quick win

Module docstring still documents the old 90s later-beat window.

The constant is now asserted at 180s here, but the docstring (Line 13: "the snappy PENDING_RECOVERY_MS (~90s)" and Line 24: "the LATER-beat branch (snappy 90s)") still says 90s, contradicting these assertions in the same file. Refresh both lines to 180s for consistency with #399.

📝 Suggested docstring updates (outside the changed range)
-  • LATER beats (the ~35–60s norm): the snappy PENDING_RECOVERY_MS (~90s).
+  • LATER beats (the ~35–60s norm, but content-rich beats can run ~90–120s):
+    PENDING_RECOVERY_MS (~180s, `#399`; was 90s).
-  • the LATER-beat branch (snappy 90s) — preserved for a genuine mid-session stall;
+  • the LATER-beat branch (180s, `#399`) — preserved for a genuine mid-session stall;
🤖 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_recovery_timing.py` around lines 207 - 210, Update the
module docstring references that still say "90s" to match the new later-beat
constant of 180s: change the phrases mentioning the snappy PENDING_RECOVERY_MS
(~90s) and the LATER-beat branch (snappy 90s) to 180s so the docstring aligns
with the test assertions (see PENDING_RECOVERY_MS and the assertions for
recoveryMs/recoveryFirstMs/backstopMs in test_recovery_timing.py).


# --- the pure selector: both branches (this is exactly what armPending calls) ---
def test_recovery_window_selector_both_branches(self):
out = self._run("({ first: h.recoveryWindowMs(true), later: h.recoveryWindowMs(false) })")
self.assertEqual(out["first"], 4 * 60 * 1000)
self.assertEqual(out["later"], 90 * 1000)
self.assertEqual(out["later"], 180 * 1000) # #399: was 90s

# --- #399 CORE: the FIRST-beat window covers a 120s turn without going stuck --------------
# The first beat already gets the generous 4-min window, so a 120s turn is comfortably inside it.
# (The LATER-beat 180s window can't be exercised in THIS harness — flipping firstBeat=false needs
# a real DM beat to arrive via the /chat poll, which is stubbed here; that path is covered in
# test_live_narration_stream.py::test_resolved_beat_makes_next_turn_a_later_beat. Here we lock
# that NEITHER window trips at 120s, the worst-case content-rich turn the playtester gave up on.)
def test_no_false_stuck_at_120s(self):
out = self._run(
"h.arm('open the scene');"
# 120s in — the OLD later-beat window (90s) would already be 'stuck'. The first-beat
# window (and the new 180s later window) must NOT be.
"h.advance(120 * 1000);"
"var p1 = h.pending();"
"({ stuck_at_120s: !!(p1 && p1.stuck), active_at_120s: !!(p1 && !p1.stuck) })"
)
self.assertFalse(out["stuck_at_120s"], "a 120s turn must not be falsely declared stuck (#399)")
self.assertTrue(out["active_at_120s"], "a 120s turn should still be narrating (pending, not stuck)")

# --- #348 CORE: the FIRST beat is NOT falsely declared stuck at 90s -------
def test_first_beat_survives_past_the_old_90s_threshold(self):
Expand Down
Loading