mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
3c7a3c1375
Long sessions (5000+ messages, several compactions) degraded steadily and could stop rendering entirely while the backend stayed healthy. Four hard failure mechanisms, each sufficient on its own: - Unguarded event pipeline: one throw escaping onmessage/handleEvent (e.g. renderMarkdown stack overflow on a few KB of nested "> ") stranded the streaming refs, so every later delta painted into the poisoned segment. stream_end now resets segment refs BEFORE the finalize render with a plain-text fallback (the coordinator pane's existing pattern); onmessage guards both parse and dispatch; renderMarkdown is depth-capped with throw-safe footnote-scope accounting; the streaming buffer is marked rendered only on success. - Rebuild-vs-live races: clear_ui/replay_truncated re-renders wiped events painted in the snapshot->replaceChildren window (never redelivered) and left deltas writing into detached nodes. Rebuilds now quiesce the event stream behind a token-owned queue flushed after the render; streaming refs reset on every rebuild path including refetch FAILURE; a mid-stream replay_truncated defers its re-sync to the idle edge instead of dropping the repair. - Ignored recovery floor: the global stream now handles node_snapshot and replay_truncated. Roster eviction (with a "Session ended" toast for open panes) happens only from the stream-ordered snapshot; the REST resync is merge-only and r.ok-gated so a mid-restart 503 body cannot read as an authoritative empty roster. - Unbounded growth: _agentCards released on rebuild — deliberately NOT on transport-only reconnects, which must preserve the maps or the next child event builds a duplicate card; orphan grace timers cancelled on full reload/destroy; toast queue capped with duplicate coalescing; diff previews capped at 400 rendered lines (the spread-append could throw RangeError before the approval gate painted) with the omission notice below the scroll box; raw results clamped at 64KiB. Per-event O(N) work removed from the hot paths: thinking-indicator instance ref; near-bottom cached from a passive scroll listener and re-checked at rAF pin time (a user scroll-up landing in the coalescing window wins; ResizeObserver re-engages follow after layout changes); rAF-coalesced outer and per-stream scroll pins; self-healing call_id->row/stream lookup caches; verdict lookup scoped to the row's batch; tracked retry holder; queue-controller Set replaces the whole-transcript idle sweep; rail renders rAF-coalesced; coordinator child_ws_state ticks routed to single-row updates (full render only on terminal-boundary crossings) with observer unobserve on replace. Also: the coordinator SSE-error 401 probe is un-deadened (raw fetch — authFetch never resolves a 401 — with the body inspected so a version_mismatch still takes auth.js's upgrade-reload path via the new noteVersionMismatch export); the console cluster-SSE reconnect timer is tracked across logout; the mermaid render chain is rejection-proof per link and paints errors on the containers the failing link had already claimed. Measured with scripts/livepass.py --perf (n=3000 history + 20-turn live storm): full replay 1060ms -> 238ms; re-render cycles 836-1071ms -> ~94ms flat; chunk path now flat vs transcript size; worst longtask 1080ms -> ~500ms; agent-card retention across rebuilds 4 -> 0. Known limit (needs a server-side event watermark on /history): a turn completing inside the refetch window can paint twice after the quiesce flush — rare, visible, and strictly better than the silent loss it replaces.
171 lines
6.7 KiB
Python
171 lines
6.7 KiB
Python
"""Guards for the shared conversational-pane module
|
|
(``turnstone/shared_static/conversation.js``).
|
|
|
|
Born in step 5e.1: the deduplicated substrate BOTH the interactive pane
|
|
(shared_static/interactive.js) and the coordinator pane
|
|
(console/static/coordinator/coordinator.js) import. These pin the exports plus
|
|
the load-bearing invariants (operator-context marker, null-safe ANSI strip, no
|
|
innerHTML) so a regression in the shared module fails loudly here rather than
|
|
silently in one pane.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
_CONVERSATION_JS = (
|
|
Path(__file__).resolve().parent.parent / "turnstone/shared_static/conversation.js"
|
|
)
|
|
|
|
|
|
def _body() -> str:
|
|
return _CONVERSATION_JS.read_text(encoding="utf-8")
|
|
|
|
|
|
def test_exports_the_shared_helpers() -> None:
|
|
"""The three helpers both panes import must be exported — drop one and the
|
|
importing pane module fails to load entirely."""
|
|
body = _body()
|
|
for name in ("stripAnsi", "buildWatchResultCard", "buildSystemNudgeMarker"):
|
|
assert f"export function {name}" in body, f"{name} must be exported"
|
|
|
|
|
|
def test_strip_ansi_is_null_safe() -> None:
|
|
"""Unified on the coordinator's null-safe variant: a non-string argument
|
|
coerces to "" rather than throwing (interactive's old copy did not guard,
|
|
so this is a strict-superset behaviour for its call sites)."""
|
|
body = _body()
|
|
assert 'String(s == null ? "" : s).replace(' in body, (
|
|
"stripAnsi must coerce its argument before .replace"
|
|
)
|
|
|
|
|
|
def test_watch_card_carries_operator_context_marker() -> None:
|
|
"""The watch-result card keeps the shared ``operator-context`` marker (the
|
|
retry-walk in both panes skips rows carrying it) and stays textContent-only."""
|
|
body = _body()
|
|
assert '"msg watch-result operator-context"' in body
|
|
assert 'setAttribute("data-ts-role", "watch")' in body
|
|
for part in (
|
|
"msg-watch-header",
|
|
"msg-watch-cmd",
|
|
"msg-watch-body",
|
|
"msg-watch-footer",
|
|
):
|
|
assert part in body, f"watch card missing {part}"
|
|
|
|
|
|
def test_nudge_marker_shape() -> None:
|
|
body = _body()
|
|
assert '"msg user system-nudge"' in body
|
|
assert 'setAttribute("data-source", "system_nudge")' in body
|
|
|
|
|
|
def test_no_inner_html() -> None:
|
|
"""House style: programmatic DOM only — no innerHTML *usage* in the shared
|
|
module (the header comment names it; guard the access pattern)."""
|
|
assert ".innerHTML" not in _body()
|
|
|
|
|
|
def test_normalize_risk_level_unknown_to_medium() -> None:
|
|
"""Unified canonical fallback (step 5e.1b): an unknown / unrecognized risk
|
|
normalizes to "medium" (the user's decision; the coordinator's old rank used
|
|
"high"). The crit/med abbreviations alias to critical/medium so a 'crit'
|
|
verdict no longer renders as medium (the latent interactive bug)."""
|
|
body = _body()
|
|
assert 'return RISK_LEVELS.indexOf(s) >= 0 ? s : "medium";' in body
|
|
assert 'crit: "critical"' in body and 'med: "medium"' in body
|
|
|
|
|
|
def test_risk_rank_and_max_severity_exported() -> None:
|
|
"""riskRank + maxSeverityItem (lifted from the coordinator's _riskRank /
|
|
_maxSeverityItem) are exported and build on the canonical normalize, so the
|
|
rank and the display can't disagree on the fallback. An item with no verdict
|
|
ranks below low so it never wins the max-severity pick."""
|
|
body = _body()
|
|
assert "export function riskRank(" in body
|
|
assert "export function maxSeverityItem(" in body
|
|
assert "? riskRank(v.risk_level) : -1;" in body
|
|
|
|
|
|
# --- step 5e.2b: the shared approval-card builders ---------------------------
|
|
|
|
|
|
def test_card_builders_exported() -> None:
|
|
"""The leaf DOM builders both panes' orchestration calls (5e.2c). Drop one
|
|
and the calling pane fails to construct its half of the converged card."""
|
|
body = _body()
|
|
for name in (
|
|
"buildConvBatchShell",
|
|
"buildConvRow",
|
|
"buildConvCmd",
|
|
"buildConvVerdict",
|
|
"buildConvWarning",
|
|
"buildConvButton",
|
|
"buildConvActions",
|
|
"buildConvStatus",
|
|
"buildConvResult",
|
|
):
|
|
assert f"export function {name}(" in body, f"{name} must be exported"
|
|
|
|
|
|
def test_builders_emit_conv_vocabulary() -> None:
|
|
"""The builders speak ONLY the neutral .conv-* vocabulary (conversation.css)
|
|
— no leaked .coord-tool-* / .ts-approval-* / .verdict-* class strings."""
|
|
body = _body()
|
|
for cls in (
|
|
'"conv-batch"',
|
|
'"conv-row"',
|
|
'"conv-row-call"',
|
|
'"conv-verdict"',
|
|
'"conv-warning conv-warning--"',
|
|
'"conv-actions"',
|
|
'"conv-btn conv-btn--"',
|
|
'"conv-status"',
|
|
'"conv-row-result"',
|
|
):
|
|
assert cls in body, f"builders missing {cls}"
|
|
for stale in ("coord-tool-", "ts-approval-", "verdict-badge"):
|
|
assert stale not in body, f"builders leaked stale vocab: {stale}"
|
|
|
|
|
|
def test_approve_all_label_unified() -> None:
|
|
"""Button language (BRIEFING): the persistent action reads 'Approve all'
|
|
(a dashed --ok ghost), NOT the coordinator's old 'Always'. The trio is
|
|
Approve / Deny / Approve all on the .conv-btn--{role} vocabulary."""
|
|
body = _body()
|
|
assert '"Approve all"' in body # unified persistent-action label
|
|
assert '"Always"' not in body # the coordinator's old label is gone
|
|
assert 'buildConvButton("approve", "Approve"' in body
|
|
assert 'buildConvButton("deny", "Deny"' in body
|
|
assert "conv-btn conv-btn--" in body
|
|
|
|
|
|
def test_warning_and_verdict_normalize_risk() -> None:
|
|
"""Both risk-bearing builders route risk through normalizeRiskLevel, so the
|
|
per-site `|| "medium"` fallbacks collapse onto the canonical unknown->medium
|
|
fold (5e.1b) and 'crit' aliases to 'critical'."""
|
|
body = _body()
|
|
assert "normalizeRiskLevel(verdict.risk_level)" in body, "verdict must normalize"
|
|
assert "normalizeRiskLevel(a.risk_level)" in body, "warning must normalize"
|
|
assert '"conv-warning conv-warning--" + risk' in body
|
|
assert 'badge.classList.add("conv-verdict--" + risk)' in body
|
|
|
|
|
|
def test_unbounded_render_inputs_are_capped() -> None:
|
|
"""Perf-audit P0: the two builders that used to render unbounded input.
|
|
The diff preview caps rendered lines and appends incrementally — the old
|
|
single ``diff.append(...nodes)`` spread threw RangeError past engine
|
|
spread-arity limits, killing the tool card (and the approval gate) for
|
|
the batch. The raw result body clamps at RAW_CAP so one multi-MB tool
|
|
output can't become a multi-MB pre-wrap text node rebuilt on every
|
|
re-render."""
|
|
body = _body()
|
|
assert "MAX_PREVIEW_LINES" in body
|
|
assert "diff.append(...nodes)" not in body, (
|
|
"preview nodes must append incrementally, not via one spread call"
|
|
)
|
|
assert "more preview lines not shown" in body
|
|
assert "RAW_CAP" in body
|
|
assert "truncated for display" in body
|