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
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,29 @@

## [Unreleased]

## [v0.51.83] — 2026-05-17 — Release BG (stage-376 — 12-PR contributor batch — chat-start adapter parity + populated-core journal recovery + thinking card dedup + context metadata refresh + model cache fingerprint + stream fade cap + manual cron delivery + active-session spinner + email gateway label + thinking copy button + /theme i18n + compact activity semantics)

### Added

- **PR #2460** by @Michaelyklam (closes #2449) — Add a copy button to Thinking card headers so users can copy the card's reasoning text without selecting the `<pre>` manually. The button stops header-toggle propagation and shows the same short checkmark feedback pattern used by existing copy actions.

### Fixed

- **PR #2438** by @franksong2702 (fixes #2435) — Keep the default-off `HERMES_WEBUI_RUNTIME_ADAPTER=legacy-journal` chat-start path response-compatible with the legacy-direct path by not adding adapter-internal `run_id`, `status`, or `active_controls` fields to `/api/chat/start` responses. The adapter facade for the future #1925 runtime split stays an internal protocol-translator seam instead of expanding the public chat-start contract.
- **PR #2439** by @franksong2702 (fixes #2434) — Recover already-journaled visible assistant text and tool cards even when restart repair first syncs a populated Hermes core transcript into an otherwise empty WebUI sidecar. The core-sync branch now merges non-duplicate run-journal output before clearing stale stream state, closing the carve-out from PR #2427 where recoverable partial output could be silently skipped. Adds `_append_journaled_partial_output(..., dedupe_existing=True)` plus helpers `_run_journal_has_visible_output`, `_find_existing_assistant_for_journal_content`, and `_journal_tool_already_present`.
- **PR #2441** by @Michaelyklam (fixes #2440) — Compact live Thinking cards now reuse the same timeline card across sequential tool calls within a single assistant turn. `finalizeThinkingCard` clears the `data-thinking-active` marker by searching the entire assistant turn instead of only the tool group, and `appendThinking` reuses the most recent Thinking card when no active marker is set, preventing repeated Thinking cards from stacking as reasoning resumes between tool calls.
- **PR #2444** by @franksong2702 (fixes #2442) — Refresh session context-window metadata when a session's resolved model changes during deferred hydration or when the user switches models, so high-context models do not stay stuck on a stale prior window and trigger premature compression. Adds a shared `_resolve_context_length_for_session_model` helper, updates `GET /api/session?resolve_model=1` to refresh non-zero persisted windows from current model metadata, resets context metadata on `/api/session/update` model/provider changes, and applies returned `context_length`/`threshold_tokens`/`last_prompt_tokens` in the deferred client-side resolution path with an immediate context-indicator resync.
- **PR #2445** by @Michaelyklam (fixes #2443) — `/api/models` now fingerprints the in-module provider catalog plus the local Codex `models_cache.json` as part of its persisted cache metadata, so server-side catalog additions and Codex local catalog refreshes invalidate `models_cache.json` immediately on the next restart instead of waiting for the 24-hour TTL or manual cache deletion.
- **PR #2450** by @Michaelyklam (fixes #2447) — Cap the optional streaming word-fade drain after the final `done` SSE event so very large or bursty completed responses render from the canonical session promptly instead of keeping the chat in a live/working state until Stop is pressed. The existing caught-up path and per-token animation wait are preserved for normal responses.
- **PR #2452** by @Michaelyklam (fixes #2451) — Manual WebUI cron triggers now deliver the same final response or failure notice as scheduled cron runs. The manual-run wrapper reuses the scheduler delivery contract (`[SILENT]` skipping, separate `last_delivery_error` metadata, error-notice fallback) with a `TypeError` shim for legacy `mark_job_run` signatures used by older WebUI test doubles.
- **PR #2455** by @franksong2702 (fixes #2454) — Keep the sidebar spinner in sync with server session metadata when the currently open session has finished but the browser still has stale local busy state. A new `_reconcileActiveSessionIdleStateFromList` helper clears `S.busy`, `S.activeStreamId`, the `INFLIGHT` cache, and active-session stream metadata before optimistic merging can re-mark the row as streaming.
- **PR #2457** by @Michaelyklam (closes #2456) — Email gateway sessions imported from Hermes Agent `state.db` now normalize as messaging sessions and show an `Email` source label in the WebUI sidebar instead of falling through as unlabelled generic agent sessions. Keeps the Python source-normalization contract (`MESSAGING_SOURCES`, `SOURCE_LABELS`), gateway status platform labels, and frontend `static/sessions.js` whitelist in sync.
- **PR #2463** by @Michaelyklam (closes #2462) — Align `/theme` command help strings in Russian, German, Simplified Chinese, Traditional Chinese, and French with the current Theme × Skin contract. The localized command descriptions now mention `system/dark/light` plus the full skin list through `nous`, and French invalid-usage text now uses the actual `/theme ` slash command prefix instead of `/thème`. Supersedes the parallel-discovery duplicate at #2464 (closed in favor of this PR).

### Changed

- **PR #2466** by @franksong2702 (closes #2465) — Clarify `Compact tool activity` semantics in Preferences: the setting now describes compact inline activity that preserves the agent timeline, matching the current long-running turn behavior where thinking cards, visible progress notes, and tool Activity bursts stay in chronological order instead of being described as one top-of-turn collapsed block. Renderer behavior is unchanged; this is a description-only correction plus the `simplified_tool_calling` default comment and regression-test wording.

## [v0.51.82] — 2026-05-17 — Release BF (stage-375 — 2-PR batch — table renderer pipe protection + Catppuccin appearance skin)

### Added
Expand Down
2 changes: 2 additions & 0 deletions api/agent_sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

MESSAGING_SOURCES = {
'discord',
'email',
'slack',
'telegram',
'weixin',
Expand All @@ -22,6 +23,7 @@
'cli': 'CLI',
'cron': 'Cron',
'discord': 'Discord',
'email': 'Email',
'slack': 'Slack',
'telegram': 'Telegram',
'tool': 'Tool',
Expand Down
39 changes: 37 additions & 2 deletions api/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

import collections
import copy
import hashlib
import json
import logging
import os
Expand Down Expand Up @@ -2205,11 +2206,45 @@ def _models_cache_file_fingerprint(path: Path) -> dict:
return fingerprint


def _models_cache_catalog_fingerprint() -> dict:
"""Return non-secret model-catalog identity metadata for cache invalidation.

The /api/models payload is not only a function of user config/auth files.
It also depends on the provider/model catalog baked into this module and on
small local catalogs such as Codex's models_cache.json. Keep this cheap and
deterministic so a server restart after catalog changes does not keep
serving an otherwise-valid persisted models_cache.json until the 24h TTL
expires (#2443).
"""
catalog_payload = {
"provider_models": _PROVIDER_MODELS,
"provider_display": _PROVIDER_DISPLAY,
}
try:
encoded = json.dumps(
catalog_payload,
sort_keys=True,
separators=(",", ":"),
ensure_ascii=True,
default=str,
).encode("utf-8")
provider_catalog_sha = hashlib.sha256(encoded).hexdigest()
except Exception:
provider_catalog_sha = "unavailable"

codex_home = Path(os.getenv("CODEX_HOME", "").strip() or (HOME / ".codex")).expanduser()
return {
"provider_catalog_sha256": provider_catalog_sha,
"codex_models_cache": _models_cache_file_fingerprint(codex_home / "models_cache.json"),
}


def _models_cache_source_fingerprint() -> dict:
"""Return the current config/auth-store fingerprint for /api/models cache."""
"""Return the current config/auth/catalog fingerprint for /api/models cache."""
return {
"config_yaml": _models_cache_file_fingerprint(_get_config_path()),
"auth_json": _models_cache_file_fingerprint(_get_auth_store_path()),
"catalog": _models_cache_catalog_fingerprint(),
}


Expand Down Expand Up @@ -4057,7 +4092,7 @@ def _get_session_agent_lock(session_id: str) -> threading.Lock:
"rtl": False, # right-to-left chat layout (chat messages + composer only)
"notifications_enabled": False, # browser notification when tab is in background
"show_thinking": True, # show/hide thinking/reasoning blocks in chat view
"simplified_tool_calling": True, # group tools/thinking into one quiet activity disclosure
"simplified_tool_calling": True, # render tools/thinking as compact inline timeline activity
"api_redact_enabled": True, # redact sensitive data (API keys, secrets) from API responses
"sidebar_density": "compact", # compact | detailed
"auto_title_refresh_every": "0", # adaptive title refresh: 0=off, 5/10/20=every N exchanges
Expand Down
113 changes: 110 additions & 3 deletions api/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -718,7 +718,76 @@ def _truncate_journal_tool_args(args, limit: int = 4) -> dict:
return out


def _append_journaled_partial_output(session, stream_id: str | None) -> bool:
def _normalize_journal_recovery_text(value) -> str:
return " ".join(str(value or "").split())


def _find_existing_assistant_for_journal_content(session, content: str) -> int | None:
candidate = _normalize_journal_recovery_text(content)
if not candidate:
return None
for idx, message in enumerate(session.messages or []):
if not isinstance(message, dict) or message.get('role') != 'assistant':
continue
if message.get('_error'):
continue
existing = _normalize_journal_recovery_text(message.get('content'))
if not existing:
continue
if existing == candidate:
return idx
if len(candidate) >= 24 and candidate in existing:
return idx
return None


def _journal_tool_already_present(session, name: str, preview: str) -> bool:
candidate_name = str(name or '')
candidate_preview = _normalize_journal_recovery_text(preview)
for tool_call in session.tool_calls or []:
if not isinstance(tool_call, dict):
continue
if str(tool_call.get('name') or '') != candidate_name:
continue
existing_preview = _normalize_journal_recovery_text(
tool_call.get('preview') or tool_call.get('snippet') or ''
)
if existing_preview == candidate_preview:
return True
return False


def _run_journal_has_visible_output(session, stream_id: str | None) -> bool:
if not stream_id:
return False
try:
from api.run_journal import read_run_events
journal = read_run_events(session.session_id, stream_id)
except Exception:
return False
for event in journal.get('events') or []:
if not isinstance(event, dict):
continue
event_name = str(event.get('event') or event.get('type') or '')
payload = event.get('payload') if isinstance(event.get('payload'), dict) else {}
if event_name == 'token' and str(payload.get('text') or ''):
return True
if event_name == 'interim_assistant':
if payload.get('already_streamed'):
continue
if str(payload.get('text') or '').strip():
return True
if event_name == 'tool':
return True
return False


def _append_journaled_partial_output(
session,
stream_id: str | None,
*,
dedupe_existing: bool = False,
) -> bool:
"""Recover already-emitted visible output from a dead stream journal.

This repair path is intentionally conservative: it restores user-visible
Expand Down Expand Up @@ -757,6 +826,12 @@ def flush_assistant() -> int | None:
assistant_parts = []
if not content:
return current_assistant_idx
if dedupe_existing:
existing_idx = _find_existing_assistant_for_journal_content(session, content)
if existing_idx is not None:
current_assistant_idx = existing_idx
assistant_started_at = None
return existing_idx
timestamp = int(assistant_started_at or time.time())
session.messages.append({
'role': 'assistant',
Expand Down Expand Up @@ -821,6 +896,9 @@ def ensure_assistant_anchor(created_at: float | None = None) -> int:
anchor_idx = ensure_assistant_anchor(created_at)
name = str(payload.get('name') or 'tool')
preview = str(payload.get('preview') or '')
if dedupe_existing and _journal_tool_already_present(session, name, preview):
current_assistant_idx = anchor_idx
continue
recovered_tool_calls.append({
'name': name,
'preview': preview,
Expand Down Expand Up @@ -946,19 +1024,48 @@ def _apply_core_sync_or_error_marker(
core = json.load(f)
core_messages = core.get('messages', [])
if core_messages:
_stream_id = stream_id_for_recheck or session.active_stream_id
session.messages = core_messages
session.tool_calls = core.get('tool_calls', [])
for field in ('input_tokens', 'output_tokens', 'estimated_cost'):
if core.get(field) is not None:
setattr(session, field, core[field])
_pending_text = _normalize_journal_recovery_text(session.pending_user_message)
_already_checkpointed = False
if _pending_text and session.messages:
for _last_msg in reversed(session.messages):
if isinstance(_last_msg, dict) and _last_msg.get('role') == 'user':
_last_text = _normalize_journal_recovery_text(_last_msg.get('content'))
_already_checkpointed = _last_text == _pending_text
break
if (
_pending_text
and not _already_checkpointed
and _run_journal_has_visible_output(session, _stream_id)
):
_recovered_ts = int(time.time())
if isinstance(session.pending_started_at, (int, float)) and session.pending_started_at > 0:
_recovered_ts = int(session.pending_started_at)
_append_recovered_pending_turn(session, timestamp=_recovered_ts)
recovered_output = _append_journaled_partial_output(
session,
_stream_id,
dedupe_existing=True,
)
session.active_stream_id = None
session.pending_user_message = None
session.pending_attachments = []
session.pending_started_at = None
if recovered_output:
session.messages.append(
_interrupted_recovery_marker(recovered_output=True)
)
session.save(touch_updated_at=touch_updated_at)
logger.info(
"Session %s: synced %d messages from core transcript",
sid, len(core_messages),
"Session %s: synced %d messages from core transcript%s",
sid,
len(core_messages),
" and recovered journaled output" if recovered_output else "",
)
return True

Expand Down
Loading
Loading