diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d1a6fddd7..47168fd7ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,8 +3,11 @@ ## [Unreleased] +## [v0.51.342] — 2026-06-09 — Release LF (transcript + sidebar reliability: blank-transcript, missing-index stall, stale watermark) + ### Fixed +- **Chat transcript no longer renders as just a stack of date separators with no messages (#3875).** The live-to-final / Worklog redesign (#3401) folds intermediate assistant segments into a collapsed Worklog card and hides the source segment. When a turn's *only* content was folded into a collapsed Worklog (e.g. an autonomous or interrupted run whose final assistant message is empty, or a reload where the session's tool-call metadata did not hydrate), every segment was hidden and the turn painted as nothing — leaving the transcript a bare column of `SUNDAY` / `SATURDAY` / date dividers. `renderMessages()` now enforces a fail-safe invariant: a settled assistant turn never renders with zero visible content. When a turn has no visible segment, its folded Worklog group is expanded (or, as a last resort, its hidden segments are un-hidden) so the content is never silently swallowed. Turns that already show a visible answer are untouched, so the intended collapsed-Worklog UX is preserved. (#3875) - **Sidebar no longer stalls for seconds on a state.db that is missing the agent's message index.** The CLI-session scan orders candidate sessions by a correlated `MAX(timestamp)` subquery over `messages`, which collapses to a full per-session table scan when `idx_messages_session ON messages(session_id, timestamp)` is absent — stalling `/api/sessions` for several seconds on every refresh (the 5s cache TTL never settles, so repeated "Slow WebUI request still running" warnings appear). A normally-migrated hermes-agent state.db has this index, but a db that lost its migrations (older hermes-agent, or a hand-rebuilt/reimported db) does not. WebUI now primes the index with `CREATE INDEX IF NOT EXISTS` before the scan — a no-op when it already exists, and a ~20ms self-heal otherwise (measured 13.3s → 0.009s on a no-index 8k-session db). Best-effort: degrades silently on a read-only db, a locked db, or a minimal schema without a `timestamp` column. (#3887) - **A stale truncation watermark no longer hides messages sent after a retry/undo/edit.** `retry`, `undo`, and the Edit-truncate handler set a `truncation_watermark` to suppress the *replaced* tail from the append-only state.db merge, but nothing cleared it when the user then sent a genuinely new turn — so it froze at the old edit boundary. A frozen watermark then dropped the post-edit turns whenever the sidecar was later reconstructed empty (recovery/reconcile), permanently losing them from the WebUI transcript even though state.db still had the full data. WebUI now retires the watermark once the new turn is durably committed to `session.messages` — at the agent-result merge, the eager user-message checkpoint, or the cold-load recovery commit (the edit boundary is historical once a real turn lands). Cleared to `None`, distinct from the `0.0` truncate-to-empty sentinel, so the #2914 "don't replay a deleted transcript" behavior is unchanged. (#3831) diff --git a/static/ui.js b/static/ui.js index 81399b3827..8033eb5622 100644 --- a/static/ui.js +++ b/static/ui.js @@ -8726,6 +8726,69 @@ function renderMessages(options){ } } } + // Fail-safe invariant (#3875): a settled assistant turn must never render with + // ZERO visible content. The Worklog redesign (#3401) folds intermediate + // assistant segments into a collapsed Worklog card and hides the source segment + // (`assistant-segment-worklog-source` → display:none). That is correct WHEN the + // turn also has a visible final answer. But when a turn's ONLY content is folded + // into a collapsed Worklog (e.g. an autonomous/interrupted run whose final + // assistant message is empty, or a reload where S.toolCalls didn't hydrate so the + // worklog card built with no expandable tool steps), every segment is hidden and + // the turn paints as nothing — leaving the transcript a bare stack of date + // separators (#3875 brick). Reveal such turns so their content is never silently + // swallowed: expand the turn's Worklog group(s) when the turn has no other + // visible content. This NEVER touches a turn that has any visible segment, so the + // intended collapsed-Worklog UX is preserved whenever a visible answer exists. + // The live turn is excluded by its `liveAssistantTurn` id (it drives its own + // state during a stream), so this sweep is safe to run even while busy — a + // historical blank turn must not re-paint blank during a follow-up stream + // (Opus advisor, stage-342). + { + const _turnHasVisibleContent=(turn)=>{ + const segs=turn.querySelectorAll('.assistant-segment'); + for(const seg of segs){ + // A segment shows real content only when it is NOT worklog-folded AND its + // body/files/status actually painted (the anchor-only placeholder class + // carries no visible body). + if(seg.classList.contains('assistant-segment-worklog-source')) continue; + if(seg.classList.contains('assistant-segment-anchor')) continue; + if((seg.textContent||'').trim()) return true; + } + return false; + }; + for(const turn of inner.querySelectorAll('.assistant-turn')){ + if(turn.id==='liveAssistantTurn') continue; // live turn drives its own state + if(_turnHasVisibleContent(turn)) continue; + // No visible content — surface the folded Worklog so the turn isn't blank. + const groups=turn.querySelectorAll('.tool-worklog-group,.tool-call-group'); + let revealed=false; + for(const group of groups){ + if(!(group.textContent||'').trim()) continue; // empty group can't help + if(group.classList.contains('tool-call-group-collapsed')){ + group.classList.remove('tool-call-group-collapsed'); + group.classList.add('open'); + const summary=group.querySelector('.tool-call-group-summary,.activity-summary'); + if(summary) summary.setAttribute('aria-expanded','true'); + } + // `revealed` means "this turn has a non-empty Worklog group that the user + // can see" — NOT "we just expanded something". An already-open non-empty + // group is itself visible (it slips past _turnHasVisibleContent only + // because that check inspects .assistant-segment nodes, not group bodies), + // so the turn isn't truly blank and the last-resort un-hide below is + // unnecessary. Keep this assignment OUTSIDE the if(collapsed) branch. + revealed=true; + } + // Last resort: no usable worklog group either, but hidden worklog-source + // segments carry the real text — un-hide them so nothing is lost. + if(!revealed){ + for(const seg of turn.querySelectorAll('.assistant-segment-worklog-source')){ + if(!(seg.textContent||'').trim()) continue; + seg.classList.remove('assistant-segment-worklog-source'); + seg.removeAttribute('aria-hidden'); + } + } + } + } // Only force-scroll when not actively streaming — mid-stream re-renders // (tool completion, session switch) must not override the user's scroll position. // scrollIfPinned() respects _scrollPinned, so it's a no-op if user scrolled up. diff --git a/tests/test_issue3875_blank_transcript_failsafe.py b/tests/test_issue3875_blank_transcript_failsafe.py new file mode 100644 index 0000000000..550150d374 --- /dev/null +++ b/tests/test_issue3875_blank_transcript_failsafe.py @@ -0,0 +1,89 @@ +"""Regression coverage for #3875 — chat transcript renders as only date separators. + +#3875 (BUG: messages stopped displaying correctly): the chat transcript rendered as +nothing but a stack of date-change separators (SUNDAY / SATURDAY / dates) with no +message bodies between them. Two users confirmed it; only a restart appeared to help +(it didn't — it was a render-state bug, not server state). + +Root cause: the live-to-final / Worklog redesign (#3401, v0.51.294) folds intermediate +assistant segments into a collapsed Worklog card and hides the source segment via the +``assistant-segment-worklog-source`` class (``display:none``). That is correct WHEN the +turn also has a visible final answer. But when a turn's ONLY content is folded into a +collapsed Worklog — e.g. an autonomous/interrupted run whose final assistant message is +empty, or a reload where ``S.toolCalls`` did not hydrate so the Worklog card has no +expandable tool steps — every segment is hidden and the turn paints as nothing, leaving +the transcript a bare stack of date separators. + +The fix is a defensive fail-safe at the END of ``renderMessages``: a settled assistant +turn must never render with ZERO visible content. When a turn has no visible segment, +its folded Worklog group is expanded (or, as a last resort, its hidden worklog-source +segments are un-hidden) so the content is never silently swallowed. The fail-safe never +touches a turn that already has any visible segment, so the intended collapsed-Worklog +UX is preserved whenever a visible answer exists. + +These are static source-structure assertions over the shipped ``renderMessages`` so the +invariant cannot silently regress. +""" +from __future__ import annotations + +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8") + + +def _function_body(src: str, name: str) -> str: + marker = f"function {name}(" + start = src.find(marker) + assert start != -1, f"{name} not found" + brace = src.find("{", start) + assert brace != -1, f"{name} body not found" + depth = 0 + for idx in range(brace, len(src)): + ch = src[idx] + if ch == "{": + depth += 1 + elif ch == "}": + depth -= 1 + if depth == 0: + return src[brace + 1 : idx] + raise AssertionError(f"{name} body not closed") + + +def test_render_messages_has_blank_turn_failsafe(): + """#3875: renderMessages must carry the no-blank-turn fail-safe invariant.""" + body = _function_body(UI_JS, "renderMessages") + # The fail-safe is anchored by its issue tag so it is greppable + intentional. + assert "Fail-safe invariant (#3875)" in body, ( + "the #3875 no-blank-turn fail-safe is missing from renderMessages" + ) + + +def test_failsafe_reveals_folded_worklog_for_blank_turns(): + """The fail-safe must expand the folded Worklog group when a turn has no visible content.""" + body = _function_body(UI_JS, "renderMessages") + # It must scan turns and skip any turn that already has visible content. + assert "_turnHasVisibleContent" in body + # A turn is only acted on when it lacks visible content (the skip-guard). + assert "if(_turnHasVisibleContent(turn)) continue;" in body + # The reveal action removes the collapsed class on the Worklog group. + assert "tool-call-group-collapsed" in body + assert "removeAttribute('aria-hidden')" in body + + +def test_failsafe_preserves_collapsed_worklog_when_visible_answer_exists(): + """The fail-safe must NOT touch turns that already render visible content. + + The skip-guard (`if(_turnHasVisibleContent(turn)) continue;`) is what preserves the + intended collapsed-Worklog UX: a turn with any visible answer is left untouched, so + this fix only ever ADDS visibility to otherwise-blank turns and can never re-expand a + Worklog the user expects collapsed. + """ + body = _function_body(UI_JS, "renderMessages") + # The visible-content check skips worklog-source (folded) + anchor-only placeholder + # segments, and treats any other non-empty segment as "visible". + failsafe = body[body.find("Fail-safe invariant (#3875)") :] + assert "assistant-segment-worklog-source" in failsafe + assert "assistant-segment-anchor" in failsafe + # The live turn drives its own state and must be excluded from the sweep. + assert "liveAssistantTurn" in failsafe