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
25 changes: 24 additions & 1 deletion viewer/openworlds/app.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,15 @@ window.neutralizeMarkup = window.neutralizeMarkup || function neutralizeMarkup(r
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.
// #648: a JUST-armed narrating turn is protected from a SPURIOUS same-tick clear (the immediate
// post-armPending surface poll, a /chat cursor-reset re-reading the prior resolved turn's line as a
// fresh resolution, or a transient campaignId flip tripping the per-run reset) for this long — so the
// spinner can't be wiped milliseconds after submit, stranding the player with an enabled bar + no DM
// feedback for the whole ~150s beat (the #648 report). Far below a real DM beat (~100–150s) → a
// genuine resolution is never swallowed; above the 4s poll cycle → the one-shot post-arm clear can't
// beat it. Once /events prose streams (`streaming`) the guard lifts, and the 12-min backstop bypasses
// it entirely (it calls setPendingState(null) directly).
const PENDING_ARM_GRACE_MS = 10 * 1000;
// #348: the single source of truth for the recovery window, by turn position. Pure + exported
// (window.__PENDING_TIMING__ below) so the timing contract is unit-testable without reaching into
// the hook's internal beat counter. firstBeat ⇒ the longer cold-open window; else the snappy one.
Expand Down Expand Up @@ -365,7 +374,20 @@ function useLiveSession(state) {
});
}, []);

const clearPending = React.useCallback(() => { clearTimers(); setPendingState(null); }, [clearTimers, setPendingState]);
const clearPending = React.useCallback(() => {
// #648: keep a FRESHLY-armed, not-yet-streaming turn alive through a spurious same-tick clear
// (see PENDING_ARM_GRACE_MS). The real "Try again" retry path re-arms via armPending (which calls
// clearTimers itself), NOT clearPending, so this never blocks a legitimate re-arm; it only stops a
// poll/reset/flip from wiping the narrating spinner before the DM's first line could land. A real
// resolution arrives long past the grace (or after /events streamed), so it still clears normally.
const p = pendingRef.current;
if (p && !p.streaming && typeof p.since === "number"
&& (Date.now() - p.since) < PENDING_ARM_GRACE_MS) {
return;
}
clearTimers();
setPendingState(null);
}, [clearTimers, setPendingState]);
Comment on lines +377 to +390

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 | 🏗️ Heavy lift

Grace-only gating can block legitimate fast turn resolution.

At Line 384-Line 386, every non-streaming clear inside the 10s window is dropped. That also affects real /chat turn-end clears for fast, chat-only turns; if the resolving line is consumed once, pending can remain until recovery/backstop and keep actions gated.

Suggested direction (differentiate stale clear vs real resolution)
-  const clearPending = React.useCallback(() => {
+  const clearPending = React.useCallback((opts = {}) => {
+    const resolvedAtMs = Number.isFinite(opts.resolvedAtMs) ? opts.resolvedAtMs : null;
     const p = pendingRef.current;
-    if (p && !p.streaming && typeof p.since === "number"
-        && (Date.now() - p.since) < PENDING_ARM_GRACE_MS) {
+    const withinGrace = p && !p.streaming && typeof p.since === "number"
+      && (Date.now() - p.since) < PENDING_ARM_GRACE_MS;
+    const resolvesCurrentTurn = p && resolvedAtMs !== null && resolvedAtMs >= p.since;
+    if (withinGrace && !resolvesCurrentTurn) {
       return;
     }
     clearTimers();
     setPendingState(null);
   }, [clearTimers, setPendingState]);
-          if (dmLineArrived) {
+          if (dmLineArrived) {
+            const lastDm = [...items].reverse().find((it) => it.role === "dm");
+            const resolvedAtMs =
+              (lastDm && typeof lastDm.at === "number") ? (lastDm.at * 1000) : null;
             ...
-            clearPending();
+            clearPending({ resolvedAtMs });
           }
🤖 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 377 - 390, clearPending's current
grace check (using pendingRef.current and PENDING_ARM_GRACE_MS) suppresses
legitimate fast resolution clears; change clearPending to accept a boolean
(e.g., forceClear or isResolution) that, when true, bypasses the
PENDING_ARM_GRACE_MS gate, and update callers that perform real turn resolutions
(the code path that consumes a /chat turn or the event consumer that previously
called clearPending) to call clearPending(true); keep existing behavior for
spurious clears by leaving default calls unchanged; ensure symbols touched:
clearPending, pendingRef.current, PENDING_ARM_GRACE_MS, clearTimers,
setPendingState and any callers that currently invoke clearPending (replace with
clearPending(true) where the clear represents an actual resolved turn).


// #342 + #348: arm the narrating indicator + a recovery timeout. If a DM beat doesn't arrive within
// the recovery window the turn is flagged `stuck` (the bar re-enables with a "try again" hint)
Expand Down Expand Up @@ -650,6 +672,7 @@ window.__PENDING_TIMING__ = {
recoveryMs: PENDING_RECOVERY_MS,
recoveryFirstMs: PENDING_RECOVERY_FIRST_MS,
backstopMs: PENDING_BACKSTOP_MS,
armGraceMs: PENDING_ARM_GRACE_MS, // #648: the just-armed-turn protection window
};
// #402: expose the live-tail bound for tests/devtools introspection (purely additive — the hook
// closes over the consts directly; nothing in the running app reads these off window).
Expand Down
10 changes: 10 additions & 0 deletions viewer/tests/test_live_narration_stream.py
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,10 @@
caps: () => sandbox.window.__LIVE_TAIL_CAPS__,
beatCount: () => (reactHost.api().chatBeats || []).length,
drain,
// #648: advance the fake clock (Date.now reads NOW) so a test can resolve a turn AFTER the
// armPending grace window — a real DM beat lands ~100–150s post-submit, far past the guard, so a
// same-tick resolve is unrealistic (and now intentionally a no-op for the freshly-armed spinner).
advance: (ms) => { NOW += ms || 0; },
Comment on lines +244 to +247

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

Replace ambiguous EN DASH to satisfy Ruff RUF001.

Line 245 includes inside the harness string, which Ruff flags as ambiguous unicode.

Minimal fix
-  // armPending grace window — a real DM beat lands ~100–150s post-submit, far past the guard, so a
+  // armPending grace window - a real DM beat lands ~100-150s post-submit, far past the guard, so a
🧰 Tools
🪛 Ruff (0.15.15)

[warning] 245-245: String contains ambiguous (EN DASH). Did you mean - (HYPHEN-MINUS)?

(RUF001)

🤖 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` around lines 244 - 247, Replace
the ambiguous EN DASH character in the comment near the advance function so Ruff
RUF001 no longer flags it: edit the comment string that contains “–” (the line
describing advancing the fake clock before the advance: (ms) => { NOW += ms ||
0; }) and change the EN DASH to a normal ASCII hyphen-minus '-' (or an explicit
ASCII double hyphen) so the comment uses only ASCII punctuation.

};

// Each test's `script` is a sequence of statements ending in `return (<resultExpr>)`. The scripts
Expand Down Expand Up @@ -477,6 +481,9 @@ def test_resolved_beat_makes_next_turn_a_later_beat(self):
# 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 });"
# a real DM beat lands ~120s post-submit — advance past the #648 armPending grace so the
# turn-END line resolves the pending turn (a same-tick resolve is unrealistic + now guarded).
"h.advance(121000);"
"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).
Expand Down Expand Up @@ -906,6 +913,9 @@ def test_engine_logged_chat_reply_resolves_without_rendering_duplicate(self):
out = self._run(
"h.arm('ask the sergeant');"
"h.enqueue('/chat', { items: [{ role: 'dm', text: 'The sergeant refuses to name the captain.', engine_logged: true }], next: 1 });"
# advance past the #648 armPending grace (a real beat is ~120s) so the engine-logged
# turn-END line resolves the pending turn.
"h.advance(121000);"
"await h.tick();"
"var afterChat = h.narrationTexts();"
"var pendingAfterChat = h.pending();"
Expand Down
28 changes: 28 additions & 0 deletions viewer/tests/test_recovery_timing.py
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,9 @@ def test_clear_pending_resets_state_and_disarms_timers(self):
out = self._run(
"h.arm('do thing');"
"var armed = h.pending();"
# advance past the #648 arm-grace so this exercises a GENUINE clear (a real resolution
# lands long after submit); the same-tick protection is covered by the #648 test below.
"h.advance(h.constants().armGraceMs + 1000);"
"h.clear();"
"var cleared = h.pending();"
# advancing past every window after a manual clear must NOT resurrect a stuck flag —
Expand All @@ -286,3 +289,28 @@ def test_clear_pending_resets_state_and_disarms_timers(self):
self.assertTrue(out["armed_present"])
self.assertTrue(out["cleared_null"])
self.assertTrue(out["no_resurrect"], "cleared timers must not fire after clearPending (clean #344 retry re-arm)")

# --- #648: a freshly-armed narrating turn survives a SPURIOUS same-tick clear -------------
def test_armpending_survives_a_same_tick_clear_then_resolves(self):
"""#648 (the move-sink → pending-arm contract): after an Enter-submitted Do, a clearPending
can fire milliseconds later — the immediate post-armPending surface poll, a /chat cursor-reset
re-reading the prior resolved turn's line as a fresh resolution, or a transient campaignId
flip tripping the per-run reset. That MUST NOT wipe the just-armed narrating spinner (the
adversarial's '[MAJOR] no spinner, buttons enabled, no DM for 3+ min until I clicked
Continue'). The guard is bounded: a REAL resolution (~100–150s later, past the grace) still

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

Use ASCII hyphen in docstring to avoid Ruff RUF002 warning.

Line 300 uses an EN DASH () that Ruff flags as ambiguous.

Minimal fix
-        Continue'). The guard is bounded: a REAL resolution (~100–150s later, past the grace) still
+        Continue'). The guard is bounded: a REAL resolution (~100-150s later, past the grace) still
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
Continue'). The guard is bounded: a REAL resolution (~100150s later, past the grace) still
Continue'). The guard is bounded: a REAL resolution (~100-150s later, past the grace) still
🧰 Tools
🪛 Ruff (0.15.15)

[warning] 300-300: Docstring contains ambiguous (EN DASH). Did you mean - (HYPHEN-MINUS)?

(RUF002)

🤖 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` at line 300, Replace the EN DASH
character in the docstring snippet containing "100–150s" with an ASCII hyphen so
the text reads "100-150s"; locate the docstring that contains the substring
"100–150s" in the test_recovery_timing module and update that character to '-'
to satisfy Ruff RUF002.

clears the turn, so the spinner is never stuck on."""
out = self._run(
"h.arm('Stand down. That child is with me.');"
"var armed = !!h.pending();"
"h.clear();" # the spurious same-tick clear (must be a no-op)
"var p = h.pending();"
"var survived = !!p;"
"var narrating = !!(p && !p.stuck);"
"h.advance(h.constants().armGraceMs + 1000);" # the real DM beat lands well past the grace
"h.clear();" # genuine resolution → clears
"({ armed: armed, survives: survived, narrating: narrating, resolves_later: h.pending() === null })"
)
self.assertTrue(out["armed"], "armPending should arm a narrating turn")
self.assertTrue(out["survives"], "#648: a same-tick clear must NOT wipe the just-armed spinner")
self.assertTrue(out["narrating"], "the protected turn stays in the narrating (not stuck) state")
self.assertTrue(out["resolves_later"], "the protected turn still resolves on the real (post-grace) clear")
Loading