Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
761ffc5
fix(#5250): let settings search results escape the menu clip
rodboev Jun 30, 2026
c2a0472
test(#5250): harden settings menu selector contract
rodboev Jun 30, 2026
bb6487a
fix(sidebar): remove menu-deleted sessions optimistically
Jun 30, 2026
670105b
test(sidebar): relax menu delete hook assertion
Jun 30, 2026
44d881e
Merge origin/master into PR #5256
Jun 30, 2026
6ac5b9d
Fix transparent live anchor scene replay
Jun 30, 2026
0369e1e
Merge branch 'master' of https://github.com/nesquena/hermes-webui int…
Jun 30, 2026
e01d9ff
Merge origin/master into PR #5256
Jun 30, 2026
3d865e4
Merge origin/master into PR #5256
Jun 30, 2026
7e038aa
Merge branch 'master' into franksong2702/fix-transparent-live-snapshot
Jun 30, 2026
c2bb4c6
refactor(streaming): split terminal-failure transcript evaluator (#5141)
nankingjing Jun 30, 2026
7e0936f
fix(#5270): preserve first WebUI continuation context for CLI sessions
rodboev Jun 30, 2026
cbe36c0
fix(#5270): confine continuity repair to the chat path
rodboev Jun 30, 2026
85fac54
fix(#5273): sync active viewed count on done settle
rodboev Jun 30, 2026
5f07e12
fix(#5270): route the continuity refresh through chat start
rodboev Jun 30, 2026
22275c8
fix(#5270): keep chat-start auth tests compatible with route refresh
rodboev Jun 30, 2026
0c67db9
fix(#5270): keep cron reply tests compatible with route refresh
rodboev Jun 30, 2026
a13b8b9
Keep done-settle unread metadata aligned with compacted counts
rodboev Jun 30, 2026
2ac8162
Merge #5276 (pr/5273-active-done-viewed-sync) into stage-w1
nesquena-hermes Jun 30, 2026
a09bc4e
Merge #5274 (pr/5270-cli-webui-continuity) into stage-w1
nesquena-hermes Jun 30, 2026
b76264c
Merge #5272 (fix/5141-terminal-failure-transcript-evaluator) into sta…
nesquena-hermes Jun 30, 2026
90180bd
Merge #5256 (franksong2702/fix-session-menu-delete-delay) into stage-w1
nesquena-hermes Jun 30, 2026
9dc56a7
Merge #5254 (pr/5250-settings-search-menu-clip) into stage-w1
nesquena-hermes Jun 30, 2026
21233f2
Merge #5257 (franksong2702/fix-transparent-live-snapshot) into stage-w1
nesquena-hermes Jun 30, 2026
3a2f5cd
docs(changelog): wave-1 batch (#5276 #5274 #5272 #5256 #5254 #5257)
nesquena-hermes Jun 30, 2026
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@

### Fixed

- **The actively-viewed session no longer shows a stale unread badge after compaction.** The unread marker and the viewed marker were computed from two different message-count sources (`completedSession.message_count` vs `message_count ?? S.messages.length`), which diverged after a compaction, leaving the session you were actively looking at with a lingering unread dot. Both now read one unified `completedMessageCount` on done-settle, so the badge clears correctly. Thanks @rodboev. (#5276, fixes #5273)
- **A CLI-origin session continued in the WebUI no longer loses its immediate prior context.** When a session started in the Hermes CLI was continued in the WebUI, the stitched CLI transcript could be shadowed by a stale sidecar context prefix on chat-start. `_get_or_materialize_session` now refreshes the CLI messages from `get_cli_session_messages` on chat-start and keeps the stitched CLI transcript authoritative (the compaction/compression-anchor path is preserved). Thanks @rodboev. (#5274, fixes #5270)
- **Internal: split the terminal-failure transcript evaluator into a focused helper.** Behavior-preserving extraction of `_turn_transcript_lacks_final_assistant_answer` (operating on the already-merged transcript) with the original name kept as a thin delegating wrapper, so the terminal-failure settlement path is easier to reason about; the `error:''` silent-failure sentinel and `_terminal_failure` handling are unchanged. Thanks @nankingjing. (#5272, #5141)
- **Context-menu Delete now removes the sidebar session row immediately, matching swipe-delete.** Menu-delete now passes an immediate `beforeDelete` hook through the existing `deleteSession(sid, beforeDelete)` path so the row disappears optimistically instead of lingering until the server round-trip completes; failure-rollback is handled by the existing flow. Thanks @franksong2702. (#5256, fixes #5255)
- **Settings search results no longer get clipped by the panel boundary.** The settings side-menu buttons are wrapped in a scrolling `.settings-menu-items` container and `#settingsMenu` is set to `overflow: visible`, so the absolutely-positioned search-results dropdown escapes the scroll-clip instead of being cut off. Thanks @rodboev. (#5254, fixes #5250)
- **Transparent Streaming live-activity rows now replay in the correct anchor scene order.** Live-activity rows render into the live assistant turn before the compact gate and idempotently replace legacy live-activity surfaces as the source of truth (via `data-anchor-scene` attributes + a scroll-rebuild guard), fixing live→final ordering on transparent-streaming turns without the prior scroll jump. Thanks @franksong2702. (#5257)

- **The `/goal` loop now continues after a turn on the gateway chat backend, not just the local one.** When WebUI chat is routed through the Hermes Gateway backend, the gateway worker persisted the final assistant turn and ended the stream *before* the goal judge ran, so a standing `/goal` silently stopped after one turn. The gateway worker now evaluates the goal locally after the turn settles (the same `evaluate_goal_after_turn` path the local backend uses) and emits the existing `goal` / `goal_continue` events, so the browser continues the goal exactly as it does on the local backend. Evaluation only runs when the turn is goal-related and a goal is active, so ordinary gateway turns are unaffected. Thanks @rodboev. (#5251, fixes #5092)

- **Session-scoped API endpoints now enforce the active profile on every request-supplied session ID.** On a multi-profile box, endpoints that accept a `session_id` (in the query string or the JSON/multipart body) — session read/duplicate/rename/delete, file operations, uploads, and chat start — looked up the session without confirming it belongs to the request's active profile, so a caller could reference another profile's session by id. A central preflight now rejects a request-supplied session id that isn't visible to the active profile (404), wired across all the session verbs plus the upload and chat-start paths; stream IDs are authorized through a synchronously-registered owner map so the check doesn't depend on worker-startup timing. Single-profile / no-auth deployments and same-profile access are unchanged. Thanks @starship-s. (#5198)
Expand Down
61 changes: 44 additions & 17 deletions api/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -5627,6 +5627,17 @@ def _session_message_merge_key(msg: dict):
)


def _session_messages_have_prefix(messages, prefix) -> bool:
messages = list(messages or [])
prefix = list(prefix or [])
if len(prefix) > len(messages):
return False
for idx, expected in enumerate(prefix):
if _session_message_merge_key(messages[idx]) != _session_message_merge_key(expected):
return False
return True


_SESSION_MESSAGE_DISPLAY_METADATA_KEYS = (
"_turnDuration",
"_turnTps",
Expand Down Expand Up @@ -6457,23 +6468,39 @@ def reconciled_state_db_messages_for_session(
state_messages = get_state_db_session_messages(getattr(session, 'session_id', None))
if prefer_context and local_messages:
if using_context_messages:
compressed_context = _context_messages_include_compression_marker(local_messages)
anchor_key = getattr(session, "compression_anchor_message_key", None)
if compressed_context:
if not anchor_key:
logger.debug(
"Compressed context for session %s has no compression anchor; using context_messages only",
getattr(session, "session_id", None),
)
return list(local_messages)
anchor_index = _state_db_anchor_index(state_messages, anchor_key)
if anchor_index is None:
logger.debug(
"Compressed context for session %s has an unverifiable compression anchor; using context_messages only",
getattr(session, "session_id", None),
)
return list(local_messages)
state_messages = list(state_messages or [])[anchor_index + 1 :]
sidecar_messages = getattr(session, 'messages', None) or []
if (
getattr(session, 'is_cli_session', False)
and not getattr(session, 'read_only', False)
and sidecar_messages
and len(sidecar_messages) > len(local_messages)
and _session_messages_have_prefix(sidecar_messages, local_messages)
):
# A claimed CLI sidecar can carry a stale context prefix while the
# stitched CLI transcript already landed in session.messages. On the
# first WebUI follow-up, prefer that longer authoritative transcript
# unless context_messages intentionally diverged via compaction or
# another non-prefix transform.
local_messages = sidecar_messages
using_context_messages = False
if using_context_messages:
compressed_context = _context_messages_include_compression_marker(local_messages)
anchor_key = getattr(session, "compression_anchor_message_key", None)
if compressed_context:
if not anchor_key:
logger.debug(
"Compressed context for session %s has no compression anchor; using context_messages only",
getattr(session, "session_id", None),
)
return list(local_messages)
anchor_index = _state_db_anchor_index(state_messages, anchor_key)
if anchor_index is None:
logger.debug(
"Compressed context for session %s has an unverifiable compression anchor; using context_messages only",
getattr(session, "session_id", None),
)
return list(local_messages)
state_messages = list(state_messages or [])[anchor_index + 1 :]
state_messages = state_db_delta_after_context(local_messages, state_messages)
return merge_session_messages_append_only(
local_messages,
Expand Down
20 changes: 18 additions & 2 deletions api/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -4418,7 +4418,7 @@ def _handle_session_anchor_scene(handler, body):
return j(handler, {"ok": True, "message_index": idx, "message_ref": ref})


def _get_or_materialize_session(sid: str):
def _get_or_materialize_session(sid: str, *, refresh_cli_messages: bool = False):
"""Get a session, materializing from CLI/agent metadata if not in WebUI store.

Mirrors the fallback logic in /api/session/archive (routes.py:~8530).
Expand All @@ -4437,6 +4437,21 @@ def _get_or_materialize_session(sid: str):
# below (and the heuristic record-check would mis-trip on mock sessions).
if getattr(s, "read_only", False):
raise PermissionError("read-only imported session")
if refresh_cli_messages and getattr(s, "is_cli_session", False):
latest_messages = get_cli_session_messages(
sid,
profile=getattr(s, "profile", None),
)
current_messages = list(getattr(s, "messages", None) or [])
if (
latest_messages
and len(latest_messages) >= len(current_messages)
and _session_messages_have_prefix(latest_messages, current_messages)
):
# Keep the stitched CLI transcript authoritative on the first
# WebUI continuation path without clobbering later divergent
# WebUI-owned turns.
s.messages = list(latest_messages)
return s
except KeyError:
pass
Expand Down Expand Up @@ -7559,6 +7574,7 @@ def _keep_latest_messaging_session_per_source(
_active_stream_ids,
_merge_session_display_metadata,
_session_message_merge_key,
_session_messages_have_prefix,
_session_message_visible_key,
_message_timestamp_as_float,
_is_empty_partial_activity_message,
Expand Down Expand Up @@ -18116,7 +18132,7 @@ def _handle_chat_start(handler, body, diag=None):
return bad(handler, str(e))
diag.stage("get_session") if diag else None
try:
s = _get_or_materialize_session(body["session_id"])
s = _get_or_materialize_session(body["session_id"], refresh_cli_messages=True)
except KeyError:
# No WebUI sidecar. If this is a foreign-origin session (CLI,
# TUI, Desktop) with recoverable state.db messages, claim it by
Expand Down
41 changes: 30 additions & 11 deletions api/streaming.py
Original file line number Diff line number Diff line change
Expand Up @@ -5121,23 +5121,16 @@ def _session_lacks_final_assistant_answer(messages) -> bool:
return True


def _merged_transcript_lacks_final_assistant_answer(
def _turn_transcript_lacks_final_assistant_answer(
merged_messages,
previous_display,
previous_context,
result_messages,
msg_text,
source: str = "webui",
drop_replayed_assistant: bool = False,
) -> bool:
"""Return True when the current turn still lacks a final assistant answer."""
"""Return True when an already-merged transcript still lacks a final assistant answer."""
merged_messages = list(merged_messages or [])
previous_display = list(previous_display or [])
merged_messages = _merge_display_messages_after_agent_result(
previous_display,
previous_context,
_restore_reasoning_metadata(previous_display, result_messages),
msg_text,
source=source,
)
current_user_idx = _find_current_user_turn(merged_messages, msg_text)
if current_user_idx is None or current_user_idx < len(previous_display):
# The active turn lives after the durable transcript boundary. If the
Expand Down Expand Up @@ -5179,6 +5172,32 @@ def _merged_transcript_lacks_final_assistant_answer(
return _session_lacks_final_assistant_answer(filtered_messages)


def _merged_transcript_lacks_final_assistant_answer(
previous_display,
previous_context,
result_messages,
msg_text,
source: str = "webui",
drop_replayed_assistant: bool = False,
) -> bool:
"""Return True when the current turn still lacks a final assistant answer."""
previous_display = list(previous_display or [])
merged_messages = _merge_display_messages_after_agent_result(
previous_display,
previous_context,
_restore_reasoning_metadata(previous_display, result_messages),
msg_text,
source=source,
)
return _turn_transcript_lacks_final_assistant_answer(
merged_messages,
previous_display,
msg_text,
source=source,
drop_replayed_assistant=drop_replayed_assistant,
)


def _agent_result_terminal_failure(result) -> bool:
"""Return True for agent results that must not be finalized as done."""
if not isinstance(result, dict):
Expand Down
Loading
Loading