fix(chat): raise pre-token guard from 120 s to 300 s
Build and Push Containers / build-and-push-app (push) Successful in 1m48s
Build and Push Containers / build-and-push-db (push) Successful in 15s

The client-side TURN_TIMEOUT_MS was the binding constraint: turns with
slow prompt processing (no first SSE frame within 120 s of visible time)
errored with the 'stuck' copy even though nginx (300 s) and
BOR_LLM_TIMEOUT (300 s) would have let them run. Raise the guard to
300 s so the upstream timeouts are reachable, and re-pin the tests:
the unit constant pins and the fake-clock E2E timeline (295 s hidden
+ 290 s after the re-arm = 585 s: past the original 300 s deadline,
short of the re-armed 595 s deadline).

Verified: tests/unit (full, 100% pass), tests/e2e/test_hidden_tab_stream.py
and tests/e2e/test_loading_feedback.py in isolation.
This commit is contained in:
2026-09-08 09:37:33 -04:00
parent 7cfe58fb21
commit 5abe8871e3
4 changed files with 39 additions and 39 deletions
+10 -10
View File
@@ -27,13 +27,13 @@ def _html() -> str:
return (FRONTEND / "index.html").read_text(encoding="utf-8")
def test_turn_timeout_constant_exported_at_120s() -> None:
"""The 120s client-side guard (PLAN §7.4) must be an *exported*
def test_turn_timeout_constant_exported_at_300s() -> None:
"""The 300s client-side guard (PLAN §7.4) must be an *exported*
constant — testable, and the single value the E2E timeout story keys
off."""
js = _js()
match = re.search(r"export\s+const\s+TURN_TIMEOUT_MS\s*=\s*120_?000\s*;", js)
assert match, "app.js must export `const TURN_TIMEOUT_MS = 120000`"
match = re.search(r"export\s+const\s+TURN_TIMEOUT_MS\s*=\s*300_?000\s*;", js)
assert match, "app.js must export `const TURN_TIMEOUT_MS = 300000`"
def test_state_machine_has_all_four_states() -> None:
@@ -69,7 +69,7 @@ def test_typing_indicator_contract_strings() -> None:
def test_error_banner_has_actionable_hint() -> None:
"""Every error path (SSE error event, non-2xx, 120s timeout) must
"""Every error path (SSE error event, non-2xx, 300s timeout) must
surface the same actionable retry hint (story AC4)."""
js = _js()
assert "check the LLM is reachable" in js
@@ -118,7 +118,7 @@ def test_busy_button_style_tokens() -> None:
def test_thinking_event_is_a_first_class_turn_branch() -> None:
"""Phase 17: `thinking` SSE frames stream live into the collapsible
Thinking block — the typing dots make way, the 120s pre-token guard
Thinking block — the typing dots make way, the 300s pre-token guard
clears (the stream is alive), and the text renders through the
escape-first markdown renderer (XSS-safe). While open, the stream is
pinned to the bottom of the block."""
@@ -129,7 +129,7 @@ def test_thinking_event_is_a_first_class_turn_branch() -> None:
branch = js[thinking_idx:delta_idx]
assert "thinkingAcc += ev.text" in branch
assert "sawThinking = true" in branch
assert "clearTurnTimeout()" in branch, "first thinking frame clears the 120s guard"
assert "clearTurnTimeout()" in branch, "first thinking frame clears the 300s guard"
assert "removeTyping()" in branch, "the live block replaces the typing dots"
assert "ensureThinkingBlock(wrap)" in branch
assert "renderMarkdown(thinkingAcc)" in branch, "escape-first renderer (XSS-safe)"
@@ -232,7 +232,7 @@ def test_in_flight_button_is_the_stop_control() -> None:
def test_abort_plumbing_owns_the_fetch() -> None:
"""The in-flight fetch is owned by an AbortController created at
turn start (module scope, cleared in the finally), passed to the
fetch as its signal; the 120s guard aborts the same controller as
fetch as its signal; the 300s guard aborts the same controller as
its backstop — with `aborted = true` FIRST, so the catch never reads
the guard's abort as a user stop (one owner, same outcome)."""
js = _js()
@@ -614,7 +614,7 @@ def test_retry_frame_is_a_first_class_branch_between_tool_and_delta() -> None:
"""Phase 67 (owner-locked A4, task 02's contract): a `retry` SSE
frame (the server restarted the LLM request before its first piece
— locked A2) is a first-class branch in runTurn's handler, ordered
BETWEEN the `tool` and `delta` branches. It clears the 120s guard
BETWEEN the `tool` and `delta` branches. It clears the 300s guard
(a frame arrived), resolves n/N from the frame, and writes the
owner-locked copy literal onto the EXISTING channels only — the
#send-status live region + the typing-indicator aria-label. No DOM
@@ -630,7 +630,7 @@ def test_retry_frame_is_a_first_class_branch_between_tool_and_delta() -> None:
)
assert js.count('ev.type === "retry"') == 1, "exactly one retry branch"
branch = js[retry_idx:delta_idx]
assert "clearTurnTimeout()" in branch, "a frame arrived — the 120s guard clears"
assert "clearTurnTimeout()" in branch, "a frame arrived — the 300s guard clears"
assert "Number(ev.attempt)" in branch, "n = the attempt about to be tried"
assert "Number(ev.max_attempts)" in branch, "N = the configured total"
assert (
+2 -2
View File
@@ -152,8 +152,8 @@ def test_real_navigation_behavior_is_unchanged() -> None:
# The phase-48 teardown contract (real close / navigation / Stop still
# aborts the fetch) is untouched.
assert "turnAbort?.abort()" in js, "the abort owner still aborts the fetch"
assert re.search(r"export\s+const\s+TURN_TIMEOUT_MS\s*=\s*120_?000\s*;", js), (
"the guard constant is still 120s (owner-locked phase-17/48 value)"
assert re.search(r"export\s+const\s+TURN_TIMEOUT_MS\s*=\s*300_?000\s*;", js), (
"the guard constant is still 300s (raised from the phase-17/48 value)"
)