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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
63 changes: 63 additions & 0 deletions static/ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
// 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.
Expand Down
89 changes: 89 additions & 0 deletions tests/test_issue3875_blank_transcript_failsafe.py
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +57 to +89

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Tests are anchored to string literals, not behaviour

All three tests pass as long as the key identifier strings (_turnHasVisibleContent, tool-call-group-collapsed, Fail-safe invariant (#3875), etc.) appear somewhere inside the renderMessages body — they cannot catch a logic inversion, a wrong selector, or the revealed=true being moved inside the if(collapsed) branch. The docstring acknowledges this trade-off ("static source-structure assertions"), which is reasonable for a quick regression anchor, but it is worth noting that any refactor that renames the inner helper or rewrites the comment tag would silently break the gate without a functional regression, while a logic bug that preserves the string content would silently pass.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Loading