From 5953c04cc4fe1a8aa24b308bdb855ff936bf21f5 Mon Sep 17 00:00:00 2001 From: swftwolfzyq <118191116+swftwolfzyq@users.noreply.github.com> Date: Tue, 12 May 2026 22:38:29 +0800 Subject: [PATCH 001/138] Fix workspace-prefixed user turn leakage --- api/streaming.py | 74 +++++++++++++-- tests/test_issue1217_transcript_compaction.py | 91 +++++++++++++++++++ 2 files changed, 156 insertions(+), 9 deletions(-) diff --git a/api/streaming.py b/api/streaming.py index a1a8ac1b..6cdfcf61 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -633,6 +633,8 @@ def _message_text(value) -> str: _WORKSPACE_PREFIX_RE = re.compile(r'^\s*\[Workspace::v1:\s*(?:\\.|[^\]\\])+\]\s*') _LEGACY_WORKSPACE_PREFIX_RE = re.compile(r'^\s*\[Workspace:[^\]]+\]\s*') +_WORKSPACE_PREFIX_ANY_RE = re.compile(r'\[Workspace::v1:\s*(?:\\.|[^\]\\])+\]\s*') +_LEGACY_WORKSPACE_PREFIX_ANY_RE = re.compile(r'\[Workspace:[^\]]+\]\s*') def _escape_workspace_prefix_path(path: str) -> str: @@ -652,6 +654,27 @@ def _strip_workspace_prefix(text: str, *, include_legacy: bool = False) -> str: return stripped.strip() +def _looks_like_current_user_turn(msg, msg_text) -> bool: + """Match the current human turn even if an internal workspace tag leaked mid-text. + + Normal model-facing messages start with the workspace sentinel. A failed + retry/merge path can also return an optimistic draft followed by the + sentinel and the real prompt. Only treat that shape as the current turn + when the text after the sentinel exactly matches the submitted prompt. + """ + if not isinstance(msg, dict) or msg.get('role') != 'user': + return False + needle = " ".join(str(msg_text or '').split()) + if not needle: + return False + text = _message_text(msg.get('content', '')) + candidates = [_strip_workspace_prefix(text, include_legacy=True)] + for pattern in (_WORKSPACE_PREFIX_ANY_RE, _LEGACY_WORKSPACE_PREFIX_ANY_RE): + for match in pattern.finditer(text): + candidates.append(text[match.end():]) + return any(" ".join(str(candidate or '').split()) == needle for candidate in candidates) + + def _first_exchange_snippets(messages): """Return (first_user_text, first_assistant_text) snippets for title generation. @@ -1543,6 +1566,8 @@ def _find_current_user_turn(messages, msg_text): if not isinstance(msg, dict) or msg.get('role') != 'user': continue fallback = idx + if _looks_like_current_user_turn(msg, msg_text): + return idx text = " ".join( _strip_workspace_prefix( _message_text(msg.get('content', '')), @@ -1594,10 +1619,15 @@ def _merge_display_messages_after_agent_result(previous_display, previous_contex seen = {_message_identity(m) for m in merged} current_user_key = _message_identity({'role': 'user', 'content': msg_text}) current_user_in_candidates = any( - _message_identity(m) == current_user_key for m in candidates + _message_identity(m) == current_user_key or _looks_like_current_user_turn(m, msg_text) + for m in candidates ) current_user_already_checkpointed = bool( - merged and _message_identity(merged[-1]) == current_user_key + merged + and ( + _message_identity(merged[-1]) == current_user_key + or _looks_like_current_user_turn(merged[-1], msg_text) + ) ) if ( current_user_key is not None @@ -1622,11 +1652,14 @@ def _merge_display_messages_after_agent_result(previous_display, previous_contex for msg in candidates: key = _message_identity(msg) + is_current_user_turn = _looks_like_current_user_turn(msg, msg_text) if ( - key is not None - and key == current_user_key + ((key is not None and key == current_user_key) or is_current_user_turn) and merged - and _message_identity(merged[-1]) == key + and ( + _message_identity(merged[-1]) == current_user_key + or _looks_like_current_user_turn(merged[-1], msg_text) + ) ): # Eager session-save mode can checkpoint the current user turn # before the agent runs. When the agent returns that same user turn @@ -1636,7 +1669,11 @@ def _merge_display_messages_after_agent_result(previous_display, previous_contex if _is_context_compression_marker(msg) and key is not None and key in seen: continue display_msg = msg - if key is not None and key == current_user_key and isinstance(msg, dict) and msg.get('role') == 'user': + if ( + ((key is not None and key == current_user_key) or is_current_user_turn) + and isinstance(msg, dict) + and msg.get('role') == 'user' + ): display_msg = copy.deepcopy(msg) display_msg['content'] = msg_text merged.append(copy.deepcopy(display_msg)) @@ -1645,6 +1682,24 @@ def _merge_display_messages_after_agent_result(previous_display, previous_contex return merged +def _assistant_reply_added_after_current_turn(result_messages, previous_context, msg_text) -> bool: + """Return True only when the just-finished turn produced assistant text.""" + result_messages = list(result_messages or []) + previous_context = list(previous_context or []) + if _messages_have_prefix(result_messages, previous_context): + candidates = result_messages[len(previous_context):] + else: + current_user_idx = _find_current_user_turn(result_messages, msg_text) + candidates = result_messages[current_user_idx + 1:] if current_user_idx is not None else result_messages + return any( + isinstance(m, dict) + and m.get('role') == 'assistant' + and not m.get('_error') + and str(m.get('content') or '').strip() + for m in candidates + ) + + _TOOL_RESULT_SNIPPET_MAX = 4000 @@ -2782,9 +2837,10 @@ def _run_agent_streaming( # an empty final_response without raising — the stream would end with # a done event containing zero assistant messages, leaving the user with # no feedback. Emit an apperror so the client shows an inline error. - _assistant_added = any( - m.get('role') == 'assistant' and str(m.get('content') or '').strip() - for m in (result.get('messages') or []) + _assistant_added = _assistant_reply_added_after_current_turn( + result.get('messages') or [], + _previous_context_messages, + msg_text, ) # _token_sent tracks whether on_token() was called (any streamed text) if not _assistant_added and not _token_sent: diff --git a/tests/test_issue1217_transcript_compaction.py b/tests/test_issue1217_transcript_compaction.py index 7d14f124..fbe773ca 100644 --- a/tests/test_issue1217_transcript_compaction.py +++ b/tests/test_issue1217_transcript_compaction.py @@ -2,6 +2,7 @@ from api.models import Session import contextlib from api.streaming import ( + _assistant_reply_added_after_current_turn, _merge_display_messages_after_agent_result, _sanitize_messages_for_api, _session_context_messages, @@ -77,6 +78,96 @@ def test_workspace_prefixed_current_user_after_compaction_is_not_duplicated(): assert sum(1 for m in merged if m.get("role") == "user" and "Ok, mache weiter" in m.get("content", "")) == 1 +def test_embedded_workspace_prefixed_current_user_delta_is_deduped(): + """A failed provider path can echo draft text before the workspace tag.""" + current = "正常来说,chrome for testing 不是有独立的profile嘛,为什么会有 user-data-dir 冲突的问题?" + previous_display = [ + {"role": "user", "content": "older prompt"}, + {"role": "assistant", "content": "older answer"}, + {"role": "user", "content": "正常来说,chrome"}, + {"role": "user", "content": current}, + ] + previous_context = [ + {"role": "user", "content": "older prompt"}, + {"role": "assistant", "content": "older answer"}, + ] + result_messages = previous_context + [ + { + "role": "user", + "content": ( + "正常来说,chrome\n\n" + "[Workspace::v1: /mnt/e/vscode_workspace/hermes_workspace]\n" + f"{current}" + ), + }, + ] + + merged = _merge_display_messages_after_agent_result( + previous_display, + previous_context, + result_messages, + current, + ) + + assert merged == previous_display + assert all("Workspace::v1" not in str(m.get("content") or "") for m in merged) + + +def test_embedded_workspace_prefixed_current_user_delta_displays_clean_prompt(): + current = "正常来说,chrome for testing 不是有独立的profile嘛,为什么会有 user-data-dir 冲突的问题?" + previous_display = [ + {"role": "user", "content": "older prompt"}, + {"role": "assistant", "content": "older answer"}, + ] + previous_context = list(previous_display) + result_messages = previous_context + [ + { + "role": "user", + "content": ( + "正常来说,chrome\n\n" + "[Workspace::v1: /mnt/e/vscode_workspace/hermes_workspace]\n" + f"{current}" + ), + }, + {"role": "assistant", "content": "Chrome for Testing 本身没有固定独立 profile。"}, + ] + + merged = _merge_display_messages_after_agent_result( + previous_display, + previous_context, + result_messages, + current, + ) + + assert [m["content"] for m in merged[-2:]] == [ + current, + "Chrome for Testing 本身没有固定独立 profile。", + ] + assert all("Workspace::v1" not in str(m.get("content") or "") for m in merged) + + +def test_assistant_added_detection_ignores_prior_history(): + previous_context = [ + {"role": "user", "content": "older prompt"}, + {"role": "assistant", "content": "older answer"}, + ] + current = "new prompt" + result_messages = previous_context + [ + {"role": "user", "content": f"[Workspace::v1: /tmp/project]\n{current}"}, + ] + + assert not _assistant_reply_added_after_current_turn( + result_messages, + previous_context, + current, + ) + assert _assistant_reply_added_after_current_turn( + result_messages + [{"role": "assistant", "content": "new answer"}], + previous_context, + current, + ) + + def test_compacted_agent_result_keeps_old_prompts_and_appends_current_turn(): previous_display = [ {"role": "user", "content": "first prompt that must remain visible"}, From f2e5e4944263e8000487d60736ae28d5a5877aac Mon Sep 17 00:00:00 2001 From: swftwolfzyq <118191116+swftwolfzyq@users.noreply.github.com> Date: Tue, 12 May 2026 22:52:12 +0800 Subject: [PATCH 002/138] Track updates by release tags --- api/updates.py | 110 ++++++++++++++++++++++++++---- static/ui.js | 7 +- tests/test_update_banner_fixes.py | 61 ++++++++++++++++- tests/test_version_badge.py | 34 ++++++++- 4 files changed, 193 insertions(+), 19 deletions(-) diff --git a/api/updates.py b/api/updates.py index e3e025c2..746a7243 100644 --- a/api/updates.py +++ b/api/updates.py @@ -1,8 +1,8 @@ """ Hermes Web UI -- Self-update checker. -Checks if the webui and hermes-agent git repos are behind their upstream -branches. Results are cached server-side (30-min TTL) so git fetch runs +Checks if the webui and hermes-agent git repos are behind their latest +release tags. Results are cached server-side (30-min TTL) so git fetch runs at most twice per hour regardless of client count. Skips repos that are not git checkouts (e.g. Docker baked images where @@ -79,6 +79,24 @@ def _run_git(args, cwd, timeout=10): return f'git failed to start: {exc}', False +def _dirty_suffix(path: Path, timeout=1) -> str: + """Return a best-effort ``-dirty`` suffix without blocking version display.""" + out, ok = _run_git(['diff-index', '--quiet', 'HEAD', '--'], path, timeout=timeout) + if ok: + return "" + # diff-index exits 1 with no output for a dirty tree. Timeouts and real git + # failures include a diagnostic; skip the suffix so the base version remains. + return "-dirty" if not out else "" + + +def _describe_git_version(path: Path, *, timeout=5, dirty_timeout=1) -> str | None: + """Return a fast git version string for a checkout, if available.""" + out, ok = _run_git(['describe', '--tags', '--always'], path, timeout=timeout) + if not (ok and out): + return None + return out + _dirty_suffix(path, timeout=dirty_timeout) + + def _detect_webui_version() -> str: """Detect the running WebUI version from git or a baked-in fallback file. @@ -94,8 +112,8 @@ def _detect_webui_version() -> str: """ # Timeout capped at 3s: git describe on a healthy local repo is <50ms; # a 10s stall on import (NFS-mounted .git, broken git binary) is unacceptable. - out, ok = _run_git(['describe', '--tags', '--always', '--dirty'], REPO_ROOT, timeout=3) - if ok and out: + out = _describe_git_version(REPO_ROOT) + if out: return out # Docker / baked-image fallback: api/_version.py written by CI at build time. @@ -138,8 +156,8 @@ def _detect_agent_version() -> str: # Symmetric with _detect_webui_version() above — `--dirty` flags a # locally-modified checkout so operators can see when their agent has # uncommitted changes vs a clean tag. Per Opus advisor on stage-293. - out, ok = _run_git(['describe', '--tags', '--always', '--dirty'], _AGENT_DIR, timeout=3) - if ok and out: + out = _describe_git_version(Path(_AGENT_DIR)) + if out: return out return 'not detected' @@ -194,15 +212,65 @@ def _detect_default_branch(path): return 'master' -def _check_repo(path, name): - """Check if a git repo is behind its upstream. Returns dict or None.""" - if path is None or not (path / '.git').exists(): +def _release_tags(path): + """Return release tags newest-first, using the repo's version-sort order.""" + out, ok = _run_git(['tag', '--list', 'v*', '--sort=-v:refname'], path) + if not (ok and out): + return [] + return [line.strip() for line in out.splitlines() if line.strip()] + + +def _current_release_tag(path): + """Return the latest release tag reachable from HEAD, if one exists.""" + out, ok = _run_git(['describe', '--tags', '--abbrev=0'], path) + return out if ok and out else None + + +def _release_gap(tags, current, latest): + """Count release tags between current and latest in a newest-first list.""" + if not latest or current == latest: + return 0 + if current in tags: + return tags.index(current) + return 1 + + +def _check_repo_release(path, name): + """Check if a git repo is behind its latest published release tag.""" + tags = _release_tags(path) + if not tags: return None + latest_tag = tags[0] + current_tag = _current_release_tag(path) + behind = _release_gap(tags, current_tag, latest_tag) + + remote_url, _ = _run_git(['remote', 'get-url', 'origin'], path) + remote_url = _normalize_remote_url(remote_url) + + return { + 'name': name, + 'behind': behind, + # GitHub compare URLs accept tag names, and tag-to-tag links are the + # clearest "what changed in this release?" view for operators. + 'current_sha': current_tag, + 'latest_sha': latest_tag, + 'branch': latest_tag, + 'repo_url': remote_url, + 'release_based': True, + 'current_version': current_tag, + 'latest_version': latest_tag, + } + + +def _check_repo_branch(path, name, *, fetch=True): + """Fallback: check if a git repo is behind its upstream branch.""" + # Fetch latest from origin (network call, cached by TTL) - _, fetch_ok = _run_git(['fetch', 'origin', '--quiet'], path, timeout=15) - if not fetch_ok: - return {'name': name, 'behind': 0, 'error': 'fetch failed'} + if fetch: + _, fetch_ok = _run_git(['fetch', 'origin', '--quiet'], path, timeout=15) + if not fetch_ok: + return {'name': name, 'behind': 0, 'error': 'fetch failed'} # Use the current branch's upstream tracking branch, not the repo default. # This avoids false "N updates behind" alerts when the user is on a feature @@ -265,6 +333,24 @@ def _check_repo(path, name): } +def _check_repo(path, name): + """Check if a git repo is behind its latest release. Returns dict or None.""" + if path is None or not (path / '.git').exists(): + return None + + # Fetch tags first so update prompts track published releases, not every + # development commit that lands on master/main after the latest release. + _, fetch_ok = _run_git(['fetch', 'origin', '--quiet', '--tags'], path, timeout=15) + if not fetch_ok: + return {'name': name, 'behind': 0, 'error': 'fetch failed'} + + release_info = _check_repo_release(path, name) + if release_info is not None: + return release_info + + return _check_repo_branch(path, name, fetch=False) + + def check_for_updates(force=False): """Return cached update status for webui and agent repos.""" global _check_in_progress diff --git a/static/ui.js b/static/ui.js index 28f94817..b3a11e5f 100644 --- a/static/ui.js +++ b/static/ui.js @@ -3773,8 +3773,11 @@ async function refreshSession() { // ── Update banner ── function _formatUpdateTargetStatus(label,info){ if(!info||!(info.behind>0)) return null; - const branch=info.branch?` (${info.branch})`:''; - return `${label}${branch}: ${info.behind} update${info.behind>1?'s':''}`; + const release=(info.release_based&&info.latest_version) + ?` (${info.current_version||'unknown'} -> ${info.latest_version})` + :(info.branch?` (${info.branch})`:''); + const noun=info.release_based?'release':'update'; + return `${label}${release}: ${info.behind} ${noun}${info.behind>1?'s':''}`; } function _showUpdateBanner(data){ const parts=[]; diff --git a/tests/test_update_banner_fixes.py b/tests/test_update_banner_fixes.py index 5eaee61e..3a55796c 100644 --- a/tests/test_update_banner_fixes.py +++ b/tests/test_update_banner_fixes.py @@ -104,6 +104,61 @@ class TestUpdateChecker: assert result['repo_url'] == 'https://github.com/nesquena/hermes-webui' + def test_release_check_ignores_post_release_branch_commits(self, tmp_path, monkeypatch): + import api.updates as upd + + (tmp_path / '.git').mkdir() + + def fake_run(args, cwd, timeout=10): + if args[0] == 'fetch': + return '', True + if args[:3] == ['tag', '--list', 'v*']: + return 'v2026.5.7\nv2026.4.30', True + if args[:3] == ['describe', '--tags', '--abbrev=0']: + return 'v2026.5.7', True + if args[:2] == ['remote', 'get-url']: + return 'https://github.com/NousResearch/hermes-agent.git', True + if args[:2] == ['rev-parse', '--abbrev-ref']: + return 'origin/main', True + if args[:2] == ['rev-list', '--count']: + return '16', True + if args[0] == 'merge-base': + return '3800972dd', True + return '', False + + monkeypatch.setattr(upd, '_run_git', fake_run) + result = upd._check_repo(tmp_path, 'agent') + + assert result['release_based'] is True + assert result['current_version'] == 'v2026.5.7' + assert result['latest_version'] == 'v2026.5.7' + assert result['behind'] == 0 + + def test_release_check_counts_release_gap(self, tmp_path, monkeypatch): + import api.updates as upd + + (tmp_path / '.git').mkdir() + + def fake_run(args, cwd, timeout=10): + if args[0] == 'fetch': + return '', True + if args[:3] == ['tag', '--list', 'v*']: + return 'v0.51.35\nv0.51.34\nv0.51.33', True + if args[:3] == ['describe', '--tags', '--abbrev=0']: + return 'v0.51.34', True + if args[:2] == ['remote', 'get-url']: + return 'https://github.com/nesquena/hermes-webui.git', True + return '', False + + monkeypatch.setattr(upd, '_run_git', fake_run) + result = upd._check_repo(tmp_path, 'webui') + + assert result['release_based'] is True + assert result['current_version'] == 'v0.51.34' + assert result['latest_version'] == 'v0.51.35' + assert result['behind'] == 1 + assert result['branch'] == 'v0.51.35' + class TestConflictError: """#813 — conflict error must include flag + recovery command.""" @@ -475,10 +530,12 @@ class TestUiJsUpdateBanner: class TestUpdateBannerUx: - def test_update_banner_includes_repo_branch_labels(self): + def test_update_banner_includes_release_labels(self): src = read('static/ui.js') assert 'function _formatUpdateTargetStatus' in src - assert 'info.branch' in src + assert 'info.release_based' in src + assert 'info.current_version' in src + assert 'info.latest_version' in src assert "_formatUpdateTargetStatus('WebUI',data.webui)" in src assert "_formatUpdateTargetStatus('Agent',data.agent)" in src diff --git a/tests/test_version_badge.py b/tests/test_version_badge.py index 79fb8953..be53d5b5 100644 --- a/tests/test_version_badge.py +++ b/tests/test_version_badge.py @@ -87,8 +87,8 @@ class TestDetectWebUIVersion: ) assert result == 'unknown' - def test_git_uses_correct_describe_flags(self, tmp_path): - """git describe is called with --tags --always --dirty.""" + def test_git_uses_fast_describe_flags(self, tmp_path): + """git describe avoids --dirty so WSL /mnt checkouts do not stall.""" called_args = [] def capture(args, cwd, timeout=10): @@ -99,7 +99,35 @@ class TestDetectWebUIVersion: assert called_args, 'git was never called' assert '--tags' in called_args[0] assert '--always' in called_args[0] - assert '--dirty' in called_args[0] + assert '--dirty' not in called_args[0] + + def test_dirty_check_appends_suffix_when_fast(self, tmp_path): + """A dirty worktree still gets a suffix when the cheap probe returns quickly.""" + calls = [] + + def fake_run_git(args, cwd, timeout=10): + calls.append((args, timeout)) + if args[:3] == ['describe', '--tags', '--always']: + return ('v0.50.123', True) + if args[:2] == ['diff-index', '--quiet']: + return ('', False) + return ('unexpected', False) + + result = self._fresh_detect(mock_run_git=fake_run_git, tmp_path=tmp_path) + assert result == 'v0.50.123-dirty' + assert calls[1][0][:2] == ['diff-index', '--quiet'] + + def test_dirty_check_timeout_does_not_hide_base_version(self, tmp_path): + """If dirty detection times out, keep the base version instead of unknown.""" + def fake_run_git(args, cwd, timeout=10): + if args[:3] == ['describe', '--tags', '--always']: + return ('v0.50.123', True) + if args[:2] == ['diff-index', '--quiet']: + return ('git diff-index --quiet HEAD -- timed out after 1s', False) + return ('unexpected', False) + + result = self._fresh_detect(mock_run_git=fake_run_git, tmp_path=tmp_path) + assert result == 'v0.50.123' # --------------------------------------------------------------------------- From 690b6668878721914e3d9977799230faf6eb7a74 Mon Sep 17 00:00:00 2001 From: Michael Lam Date: Wed, 13 May 2026 07:42:33 -0700 Subject: [PATCH 003/138] Add OpenRouter cost history backend --- api/providers.py | 249 ++++++++++++++ api/routes.py | 12 +- tests/test_provider_cost_history.py | 484 ++++++++++++++++++++++++++++ 3 files changed, 744 insertions(+), 1 deletion(-) create mode 100644 tests/test_provider_cost_history.py diff --git a/api/providers.py b/api/providers.py index 86a31155..b3ddb4d1 100644 --- a/api/providers.py +++ b/api/providers.py @@ -1441,6 +1441,255 @@ def _provider_is_oauth(provider_id: str) -> bool: return provider_id in _OAUTH_PROVIDERS +# ── OpenRouter cost-history snapshot helpers (#692) ────────────────────────── + +_COST_SNAPSHOTS_DIR_NAME = "cost-snapshots" +_COST_SNAPSHOT_MAX_DAYS = 365 # hard cap to prevent unbounded growth + + +def _cost_snapshots_dir() -> Path: + """Return the directory for cost-snapshot JSON files. + + Uses the Hermes home directory (profile-aware) so snapshots are + isolated per profile, matching the existing STATE_DIR convention. + """ + return _get_hermes_home() / _COST_SNAPSHOTS_DIR_NAME + + +def _fetch_openrouter_key_usage(api_key: str) -> dict[str, Any] | None: + """Fetch current usage/limit from the OpenRouter ``/auth/key`` endpoint. + + Returns a dict with ``usage``, ``limit``, ``label`` on success, or + ``None`` on any failure. Never raises; callers handle the None case. + """ + req = urllib.request.Request( + _OPENROUTER_KEY_URL, + headers={ + "Authorization": f"Bearer {api_key}", + "Accept": "application/json", + }, + ) + try: + with urllib.request.urlopen(req, timeout=_PROVIDER_QUOTA_TIMEOUT_SECONDS) as resp: + raw = resp.read() + payload = json.loads(raw.decode("utf-8") if isinstance(raw, (bytes, bytearray)) else raw) + sanitized = _sanitize_openrouter_quota(payload) + label = None + if isinstance(payload, dict): + data = payload.get("data", payload) + if isinstance(data, dict): + label = str(data.get("label") or "").strip() or None + return { + "usage": sanitized.get("usage"), + "limit": sanitized.get("limit"), + "label": label, + } + except Exception: + logger.debug("OpenRouter key usage fetch failed for cost-history", exc_info=True) + return None + + +def _read_cost_snapshots(provider: str) -> list[dict[str, Any]]: + """Read persisted daily snapshots for *provider* from disk. + + Returns a list of ``{date, used, limit}`` dicts sorted by date + ascending. Returns an empty list if the file does not exist or is + corrupt. + """ + path = _cost_snapshots_dir() / f"{provider}.json" + if not path.exists(): + return [] + try: + raw = path.read_text(encoding="utf-8") + data = json.loads(raw) + except (OSError, json.JSONDecodeError, ValueError): + return [] + if not isinstance(data, dict): + return [] + snapshots = data.get("snapshots") + if not isinstance(snapshots, list): + return [] + # Validate and sort + valid = [] + for entry in snapshots: + if not isinstance(entry, dict): + continue + date = str(entry.get("date") or "").strip() + if not date: + continue + valid.append({ + "date": date, + "used": _quota_number(entry.get("used")), + "limit": _quota_number(entry.get("limit")), + }) + valid.sort(key=lambda e: e["date"]) + return valid + + +def _write_cost_snapshots(provider: str, snapshots: list[dict[str, Any]]) -> None: + """Persist daily snapshots for *provider* to disk atomically.""" + snap_dir = _cost_snapshots_dir() + snap_dir.mkdir(parents=True, exist_ok=True) + path = snap_dir / f"{provider}.json" + payload = {"provider": provider, "snapshots": snapshots} + body = json.dumps(payload, ensure_ascii=False, indent=2) + import tempfile as _tempfile + _tmp_fd, _tmp_path = _tempfile.mkstemp( + dir=str(snap_dir), prefix=f".{provider}_", suffix=".tmp" + ) + try: + with os.fdopen(_tmp_fd, "w", encoding="utf-8") as _f: + _f.write(body) + _f.flush() + os.fsync(_f.fileno()) + os.replace(_tmp_path, path) + except BaseException: + try: + os.unlink(_tmp_path) + except OSError: + pass + raise + + +def _append_cost_snapshot(provider: str, usage: int | float | None, limit: int | float | None) -> list[dict[str, Any]]: + """Append today's snapshot and return the updated list. + + If a snapshot for today already exists it is updated in-place so + repeated calls within the same day are idempotent. + """ + today = datetime.now(timezone.utc).strftime("%Y-%m-%d") + snapshots = _read_cost_snapshots(provider) + # Update or append today's entry + updated = False + for entry in snapshots: + if entry["date"] == today: + entry["used"] = usage + entry["limit"] = limit + updated = True + break + if not updated: + snapshots.append({"date": today, "used": usage, "limit": limit}) + snapshots.sort(key=lambda e: e["date"]) + # Cap to _COST_SNAPSHOT_MAX_DAYS entries (keep most recent) + if len(snapshots) > _COST_SNAPSHOT_MAX_DAYS: + snapshots = snapshots[-_COST_SNAPSHOT_MAX_DAYS:] + _write_cost_snapshots(provider, snapshots) + return snapshots + + +def _compute_deltas(snapshots: list[dict[str, Any]], window_days: int) -> list[dict[str, Any]]: + """Compute daily deltas from cumulative usage snapshots. + + Each snapshot carries cumulative ``used``; the delta for a day is + the difference between that day's cumulative value and the previous + day's. The oldest day in the window has ``delta=None`` (no + previous baseline). + """ + # Take only the last *window_days* entries + window = snapshots[-window_days:] if len(snapshots) > window_days else list(snapshots) + result: list[dict[str, Any]] = [] + for i, entry in enumerate(window): + delta = None + if i > 0 and entry.get("used") is not None and window[i - 1].get("used") is not None: + delta = float(entry["used"]) - float(window[i - 1]["used"]) + # Rounding: avoid -0.0 and tiny floating-point noise + if abs(delta) < 1e-9: + delta = 0.0 + else: + delta = round(delta, 6) + result.append({ + "date": entry["date"], + "used": entry.get("used"), + "delta": delta, + }) + return result + + +def get_provider_cost_history(provider_id: str | None = None, days: int = 7) -> dict[str, Any]: + """Return daily cost-history snapshots with deltas for a provider. + + Currently only ``openrouter`` is supported. On each call the + endpoint fetches the current cumulative usage from the OpenRouter + ``/auth/key`` endpoint, appends/updates today's snapshot, and + returns the last *days* snapshots with per-day deltas. + + Returns a dict matching the existing API style (``ok``, ``provider``, + ``status``, ``message``, …). + """ + provider = (provider_id or "").strip().lower() + if not provider: + return { + "ok": False, + "provider": None, + "status": "missing_provider", + "message": "Provider parameter is required. Use ?provider=openrouter", + } + + if provider != "openrouter": + display_name = _PROVIDER_DISPLAY.get(provider, provider.replace("-", " ").title()) + return { + "ok": False, + "provider": provider, + "display_name": display_name, + "supported": False, + "status": "unsupported", + "message": f"Cost history is not available for {display_name}. Only openrouter is supported in this release.", + } + + display_name = _PROVIDER_DISPLAY.get("openrouter", "OpenRouter") + api_key = _get_provider_api_key("openrouter") + if not api_key: + return { + "ok": False, + "provider": "openrouter", + "display_name": display_name, + "supported": True, + "status": "no_key", + "message": "OpenRouter cost history needs an OPENROUTER_API_KEY configured on the server.", + } + + # Fetch current cumulative usage from OpenRouter + key_info = _fetch_openrouter_key_usage(api_key) + if key_info is None: + # Upstream failure — still return any previously persisted snapshots + # so the chart degrades gracefully instead of going blank. + snapshots = _read_cost_snapshots("openrouter") + deltas = _compute_deltas(snapshots, days) + return { + "ok": False, + "provider": "openrouter", + "display_name": display_name, + "supported": True, + "status": "unavailable", + "window_days": days, + "snapshots": deltas, + "limit": None, + "label": None, + "message": "OpenRouter cost history is temporarily unavailable. Showing last known data.", + } + + # Persist today's snapshot + try: + snapshots = _append_cost_snapshot("openrouter", key_info["usage"], key_info["limit"]) + except Exception: + logger.debug("Failed to persist cost snapshot for openrouter", exc_info=True) + snapshots = _read_cost_snapshots("openrouter") + + deltas = _compute_deltas(snapshots, days) + return { + "ok": True, + "provider": "openrouter", + "display_name": display_name, + "supported": True, + "status": "available", + "window_days": days, + "snapshots": deltas, + "limit": key_info.get("limit"), + "label": key_info.get("label") or "OpenRouter credits", + "message": "OpenRouter cost history loaded.", + } + + # SECTION: Public API diff --git a/api/routes.py b/api/routes.py index 3ad444e9..dd25e76b 100644 --- a/api/routes.py +++ b/api/routes.py @@ -2033,7 +2033,7 @@ from api.run_journal import ( read_run_events, stale_interrupted_event, ) -from api.providers import get_providers, get_provider_quota, set_provider_key, remove_provider_key +from api.providers import get_providers, get_provider_quota, get_provider_cost_history, set_provider_key, remove_provider_key from api.onboarding import ( apply_onboarding_setup, get_onboarding_status, @@ -3323,6 +3323,16 @@ def handle_get(handler, parsed) -> bool: refresh = (query.get("refresh", [""])[0] or "").strip().lower() in {"1", "true", "yes", "on"} return j(handler, get_provider_quota(provider_id, refresh=refresh)) + if parsed.path == "/api/provider/cost-history": + query = parse_qs(parsed.query) + provider_id = (query.get("provider", [""])[0] or None) + days_raw = (query.get("days", ["7"])[0] or "7").strip() + try: + days = max(1, min(int(days_raw), 365)) + except (ValueError, TypeError): + days = 7 + return j(handler, get_provider_cost_history(provider_id, days)) + if parsed.path == "/api/settings": settings = load_settings() # Never expose the stored password hash to clients diff --git a/tests/test_provider_cost_history.py b/tests/test_provider_cost_history.py new file mode 100644 index 00000000..7ddf96c9 --- /dev/null +++ b/tests/test_provider_cost_history.py @@ -0,0 +1,484 @@ +"""Regression coverage for OpenRouter cost-history endpoint (#692). + +Tests cover: + - Happy-path snapshot append and delta computation + - Missing credentials (no_key) + - Unsupported provider (non-openrouter) + - Upstream failure (graceful degradation with stale data) + - Malformed / corrupt snapshot file on disk + - Idempotent same-day updates + - No real network calls or private credential leakage +""" + +from __future__ import annotations + +import json +import os +import sys +import types +import urllib.error +from datetime import datetime, timezone +from io import BytesIO +from pathlib import Path +from types import SimpleNamespace + +import api.config as config +import api.profiles as profiles + +ROOT = Path(__file__).resolve().parents[1] + + +class _FakeResponse: + """Minimal stand-in for urllib.request.urlopen context manager.""" + + def __init__(self, payload: bytes): + self._payload = payload + + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + def read(self): + return self._payload + + +def _with_config(model=None, providers=None): + old_cfg = dict(config.cfg) + old_mtime = config._cfg_mtime + config.cfg.clear() + config.cfg["model"] = model or {} + if providers is not None: + config.cfg["providers"] = providers + try: + config._cfg_mtime = config.Path(config._get_config_path()).stat().st_mtime + except Exception: + config._cfg_mtime = 0.0 + return old_cfg, old_mtime + + +def _restore_config(old_cfg, old_mtime): + config.cfg.clear() + config.cfg.update(old_cfg) + config._cfg_mtime = old_mtime + + +# ── Happy path: snapshot append + delta response ────────────────────────────── + + +def test_openrouter_cost_history_happy_path(monkeypatch, tmp_path): + """On-demand snapshot append returns deltas from cumulative usage.""" + monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: tmp_path) + monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) + (tmp_path / ".env").write_text("OPENROUTER_API_KEY=test-or-key\n", encoding="utf-8") + old_cfg, old_mtime = _with_config(model={"provider": "openrouter"}) + + import api.providers as providers + + call_count = {"n": 0} + + def fake_urlopen(req, timeout): + call_count["n"] += 1 + # Simulate cumulative usage of 5.0 credits used out of 20 limit + payload = { + "data": { + "limit_remaining": 15.0, + "usage": 5.0, + "limit": 20, + "label": "Test Label", + "key": "must-not-leak", + } + } + return _FakeResponse(json.dumps(payload).encode("utf-8")) + + monkeypatch.setattr(providers.urllib.request, "urlopen", fake_urlopen) + + # Freeze "today" so the test is deterministic + fake_today = "2030-04-15" + monkeypatch.setattr(providers, "datetime", type("DT", (), { + "now": staticmethod(lambda tz=None: datetime(2030, 4, 15, 12, 0, 0, tzinfo=tz or timezone.utc)), + "strftime": datetime.strftime, + })) + + try: + result = providers.get_provider_cost_history("openrouter", days=7) + finally: + _restore_config(old_cfg, old_mtime) + + assert result["ok"] is True + assert result["provider"] == "openrouter" + assert result["supported"] is True + assert result["status"] == "available" + assert result["window_days"] == 7 + assert result["limit"] == 20 + assert result["label"] == "Test Label" + assert result["message"] == "OpenRouter cost history loaded." + # One snapshot for today + assert len(result["snapshots"]) == 1 + snap = result["snapshots"][0] + assert snap["date"] == fake_today + assert snap["used"] == 5.0 + assert snap["delta"] is None # first entry has no previous baseline + # Verify the snapshot file was persisted + snap_file = tmp_path / "cost-snapshots" / "openrouter.json" + assert snap_file.exists() + persisted = json.loads(snap_file.read_text(encoding="utf-8")) + assert len(persisted["snapshots"]) == 1 + # No credential leakage + assert "test-or-key" not in repr(result) + assert "must-not-leak" not in repr(result) + + +def test_openrouter_cost_history_deltas_from_cumulative(monkeypatch, tmp_path): + """Deltas are computed as differences between consecutive cumulative values.""" + monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: tmp_path) + monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) + (tmp_path / ".env").write_text("OPENROUTER_API_KEY=test-or-key\n", encoding="utf-8") + old_cfg, old_mtime = _with_config(model={"provider": "openrouter"}) + + import api.providers as providers + + # Pre-seed two historical snapshots + snap_dir = tmp_path / "cost-snapshots" + snap_dir.mkdir(parents=True, exist_ok=True) + historical = { + "provider": "openrouter", + "snapshots": [ + {"date": "2030-04-13", "used": 3.0, "limit": 20}, + {"date": "2030-04-14", "used": 4.5, "limit": 20}, + ], + } + (snap_dir / "openrouter.json").write_text(json.dumps(historical), encoding="utf-8") + + call_count = {"n": 0} + + def fake_urlopen(req, timeout): + call_count["n"] += 1 + # Current cumulative usage is 7.0 + payload = {"data": {"usage": 7.0, "limit": 20, "label": "Credits"}} + return _FakeResponse(json.dumps(payload).encode("utf-8")) + + monkeypatch.setattr(providers.urllib.request, "urlopen", fake_urlopen) + + # Freeze "today" + monkeypatch.setattr(providers, "datetime", type("DT", (), { + "now": staticmethod(lambda tz=None: datetime(2030, 4, 15, 12, 0, 0, tzinfo=tz or timezone.utc)), + "strftime": datetime.strftime, + })) + + try: + result = providers.get_provider_cost_history("openrouter", days=7) + finally: + _restore_config(old_cfg, old_mtime) + + assert result["ok"] is True + snaps = result["snapshots"] + assert len(snaps) == 3 + # Day 1: no delta (baseline) + assert snaps[0]["date"] == "2030-04-13" + assert snaps[0]["used"] == 3.0 + assert snaps[0]["delta"] is None + # Day 2: delta = 4.5 - 3.0 = 1.5 + assert snaps[1]["date"] == "2030-04-14" + assert snaps[1]["used"] == 4.5 + assert snaps[1]["delta"] == 1.5 + # Day 3 (today): delta = 7.0 - 4.5 = 2.5 + assert snaps[2]["date"] == "2030-04-15" + assert snaps[2]["used"] == 7.0 + assert snaps[2]["delta"] == 2.5 + + +# ── Missing credentials ─────────────────────────────────────────────────────── + + +def test_openrouter_cost_history_no_key(monkeypatch, tmp_path): + """No API key → safe no_key response without network call.""" + monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: tmp_path) + monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) + old_cfg, old_mtime = _with_config(model={"provider": "openrouter"}) + + import api.providers as providers + + def explode(*_a, **_kw): + raise AssertionError("should not call network without a key") + + monkeypatch.setattr(providers.urllib.request, "urlopen", explode) + + try: + result = providers.get_provider_cost_history("openrouter", days=7) + finally: + _restore_config(old_cfg, old_mtime) + + assert result["ok"] is False + assert result["provider"] == "openrouter" + assert result["supported"] is True + assert result["status"] == "no_key" + assert "OPENROUTER_API_KEY" in result["message"] + + +# ── Unsupported provider ────────────────────────────────────────────────────── + + +def test_cost_history_unsupported_provider(monkeypatch, tmp_path): + """Non-openrouter providers return a clear unsupported response.""" + monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: tmp_path) + old_cfg, old_mtime = _with_config(model={"provider": "anthropic"}) + + import api.providers as providers + + try: + result = providers.get_provider_cost_history("anthropic", days=7) + finally: + _restore_config(old_cfg, old_mtime) + + assert result["ok"] is False + assert result["provider"] == "anthropic" + assert result["supported"] is False + assert result["status"] == "unsupported" + assert "openrouter" in result["message"].lower() + + +def test_cost_history_missing_provider_param(monkeypatch, tmp_path): + """Empty provider parameter returns a clear error.""" + monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: tmp_path) + + import api.providers as providers + + result = providers.get_provider_cost_history("", days=7) + assert result["ok"] is False + assert result["status"] == "missing_provider" + + result2 = providers.get_provider_cost_history(None, days=7) + assert result2["ok"] is False + assert result2["status"] == "missing_provider" + + +# ── Upstream failure / graceful degradation ──────────────────────────────────── + + +def test_openrouter_cost_history_upstream_failure_degrades_gracefully(monkeypatch, tmp_path): + """When OpenRouter API fails, previously persisted snapshots are still returned.""" + monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: tmp_path) + monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) + (tmp_path / ".env").write_text("OPENROUTER_API_KEY=test-or-key\n", encoding="utf-8") + old_cfg, old_mtime = _with_config(model={"provider": "openrouter"}) + + import api.providers as providers + + # Pre-seed a snapshot + snap_dir = tmp_path / "cost-snapshots" + snap_dir.mkdir(parents=True, exist_ok=True) + historical = { + "provider": "openrouter", + "snapshots": [ + {"date": "2030-04-13", "used": 3.0, "limit": 20}, + ], + } + (snap_dir / "openrouter.json").write_text(json.dumps(historical), encoding="utf-8") + + req = providers.urllib.request.Request("https://openrouter.ai/api/v1/key") + def fake_urlopen(_req, timeout=None): + raise urllib.error.HTTPError(req.full_url, 500, "Server Error", {}, BytesIO(b"error")) + + monkeypatch.setattr(providers.urllib.request, "urlopen", fake_urlopen) + + try: + result = providers.get_provider_cost_history("openrouter", days=7) + finally: + _restore_config(old_cfg, old_mtime) + + assert result["ok"] is False + assert result["status"] == "unavailable" + # Still returns previously persisted data + assert len(result["snapshots"]) == 1 + assert result["snapshots"][0]["date"] == "2030-04-13" + assert "temporarily unavailable" in result["message"].lower() + + +def test_openrouter_cost_history_timeout_is_safe(monkeypatch, tmp_path): + """Timeout from OpenRouter does not produce a traceback or leak secrets.""" + monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: tmp_path) + monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) + (tmp_path / ".env").write_text("OPENROUTER_API_KEY=test-or-key\n", encoding="utf-8") + old_cfg, old_mtime = _with_config(model={"provider": "openrouter"}) + + import api.providers as providers + + def fake_urlopen(_req, timeout=None): + raise TimeoutError("slow secret") + + monkeypatch.setattr(providers.urllib.request, "urlopen", fake_urlopen) + + try: + result = providers.get_provider_cost_history("openrouter", days=7) + finally: + _restore_config(old_cfg, old_mtime) + + assert result["ok"] is False + assert result["status"] == "unavailable" + assert "test-or-key" not in repr(result) + assert "secret" not in repr(result).lower() + + +# ── Malformed / corrupt snapshot file ───────────────────────────────────────── + + +def test_openrouter_cost_history_corrupt_snapshot_file(monkeypatch, tmp_path): + """A corrupt snapshot file on disk is handled gracefully (treated as empty).""" + monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: tmp_path) + monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) + (tmp_path / ".env").write_text("OPENROUTER_API_KEY=test-or-key\n", encoding="utf-8") + old_cfg, old_mtime = _with_config(model={"provider": "openrouter"}) + + import api.providers as providers + + # Write a corrupt file + snap_dir = tmp_path / "cost-snapshots" + snap_dir.mkdir(parents=True, exist_ok=True) + (snap_dir / "openrouter.json").write_text("NOT VALID JSON{{{{", encoding="utf-8") + + def fake_urlopen(req, timeout): + payload = {"data": {"usage": 2.0, "limit": 10, "label": "Credits"}} + return _FakeResponse(json.dumps(payload).encode("utf-8")) + + monkeypatch.setattr(providers.urllib.request, "urlopen", fake_urlopen) + + # Freeze "today" + monkeypatch.setattr(providers, "datetime", type("DT", (), { + "now": staticmethod(lambda tz=None: datetime(2030, 4, 15, 12, 0, 0, tzinfo=tz or timezone.utc)), + "strftime": datetime.strftime, + })) + + try: + result = providers.get_provider_cost_history("openrouter", days=7) + finally: + _restore_config(old_cfg, old_mtime) + + # Corrupt file is ignored; fresh snapshot is created + assert result["ok"] is True + assert len(result["snapshots"]) == 1 + assert result["snapshots"][0]["date"] == "2030-04-15" + + +# ── Idempotent same-day updates ─────────────────────────────────────────────── + + +def test_openrouter_cost_history_same_day_idempotent(monkeypatch, tmp_path): + """Repeated calls on the same day update the snapshot in-place.""" + monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: tmp_path) + monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) + (tmp_path / ".env").write_text("OPENROUTER_API_KEY=test-or-key\n", encoding="utf-8") + old_cfg, old_mtime = _with_config(model={"provider": "openrouter"}) + + import api.providers as providers + + call_count = {"n": 0} + + def fake_urlopen(req, timeout): + call_count["n"] += 1 + usage = 5.0 + call_count["n"] # usage grows each call + payload = {"data": {"usage": usage, "limit": 20, "label": "Credits"}} + return _FakeResponse(json.dumps(payload).encode("utf-8")) + + monkeypatch.setattr(providers.urllib.request, "urlopen", fake_urlopen) + + # Freeze "today" + monkeypatch.setattr(providers, "datetime", type("DT", (), { + "now": staticmethod(lambda tz=None: datetime(2030, 4, 15, 12, 0, 0, tzinfo=tz or timezone.utc)), + "strftime": datetime.strftime, + })) + + try: + r1 = providers.get_provider_cost_history("openrouter", days=7) + r2 = providers.get_provider_cost_history("openrouter", days=7) + finally: + _restore_config(old_cfg, old_mtime) + + # Both calls succeed; only one snapshot date (today) + assert r1["ok"] is True + assert r2["ok"] is True + assert len(r1["snapshots"]) == 1 + assert len(r2["snapshots"]) == 1 + # Second call updated the same day's used value + assert r2["snapshots"][0]["used"] == 7.0 # 5.0 + 2 (second call) + # Verify persisted file has only one entry for today + snap_file = tmp_path / "cost-snapshots" / "openrouter.json" + persisted = json.loads(snap_file.read_text(encoding="utf-8")) + assert len(persisted["snapshots"]) == 1 + + +# ── Window days parameter ───────────────────────────────────────────────────── + + +def test_openrouter_cost_history_window_days_truncation(monkeypatch, tmp_path): + """The window_days parameter limits how many snapshots are returned.""" + monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: tmp_path) + monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) + (tmp_path / ".env").write_text("OPENROUTER_API_KEY=test-or-key\n", encoding="utf-8") + old_cfg, old_mtime = _with_config(model={"provider": "openrouter"}) + + import api.providers as providers + + # Pre-seed 5 historical snapshots + snap_dir = tmp_path / "cost-snapshots" + snap_dir.mkdir(parents=True, exist_ok=True) + historical = { + "provider": "openrouter", + "snapshots": [ + {"date": f"2030-04-{d:02d}", "used": float(d), "limit": 20} + for d in range(10, 15) + ], + } + (snap_dir / "openrouter.json").write_text(json.dumps(historical), encoding="utf-8") + + def fake_urlopen(req, timeout): + payload = {"data": {"usage": 15.0, "limit": 20, "label": "Credits"}} + return _FakeResponse(json.dumps(payload).encode("utf-8")) + + monkeypatch.setattr(providers.urllib.request, "urlopen", fake_urlopen) + + # Freeze "today" + monkeypatch.setattr(providers, "datetime", type("DT", (), { + "now": staticmethod(lambda tz=None: datetime(2030, 4, 15, 12, 0, 0, tzinfo=tz or timezone.utc)), + "strftime": datetime.strftime, + })) + + try: + result = providers.get_provider_cost_history("openrouter", days=3) + finally: + _restore_config(old_cfg, old_mtime) + + # 5 historical + 1 today = 6 total, but window_days=3 returns last 3 + assert result["ok"] is True + assert result["window_days"] == 3 + assert len(result["snapshots"]) == 3 + # The returned snapshots are the most recent 3 + assert result["snapshots"][0]["date"] == "2030-04-13" + assert result["snapshots"][1]["date"] == "2030-04-14" + assert result["snapshots"][2]["date"] == "2030-04-15" + + +# ── No real network calls ───────────────────────────────────────────────────── + + +def test_cost_history_uses_no_real_network(monkeypatch, tmp_path): + """Every test path must monkeypatch urlopen; verify no real calls escape.""" + monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: tmp_path) + monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) + old_cfg, old_mtime = _with_config(model={"provider": "openrouter"}) + + import api.providers as providers + + # Without a key, no network call is made at all + def explode(*_a, **_kw): + raise AssertionError("real network call detected") + + monkeypatch.setattr(providers.urllib.request, "urlopen", explode) + + try: + result = providers.get_provider_cost_history("openrouter", days=7) + finally: + _restore_config(old_cfg, old_mtime) + + assert result["status"] == "no_key" \ No newline at end of file From 15513b81f47147b5a8a52318cab30a0e7dda53bd Mon Sep 17 00:00:00 2001 From: Michael Lam Date: Fri, 15 May 2026 17:17:48 -0700 Subject: [PATCH 004/138] fix: harden OpenRouter cost snapshots --- api/providers.py | 47 +++++++++++-------- tests/test_provider_cost_history.py | 71 +++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+), 18 deletions(-) diff --git a/api/providers.py b/api/providers.py index b3ddb4d1..804e112a 100644 --- a/api/providers.py +++ b/api/providers.py @@ -1445,6 +1445,7 @@ def _provider_is_oauth(provider_id: str) -> bool: _COST_SNAPSHOTS_DIR_NAME = "cost-snapshots" _COST_SNAPSHOT_MAX_DAYS = 365 # hard cap to prevent unbounded growth +_COST_SNAPSHOT_LOCK = threading.Lock() def _cost_snapshots_dir() -> Path: @@ -1558,23 +1559,28 @@ def _append_cost_snapshot(provider: str, usage: int | float | None, limit: int | repeated calls within the same day are idempotent. """ today = datetime.now(timezone.utc).strftime("%Y-%m-%d") - snapshots = _read_cost_snapshots(provider) - # Update or append today's entry - updated = False - for entry in snapshots: - if entry["date"] == today: - entry["used"] = usage - entry["limit"] = limit - updated = True - break - if not updated: - snapshots.append({"date": today, "used": usage, "limit": limit}) - snapshots.sort(key=lambda e: e["date"]) - # Cap to _COST_SNAPSHOT_MAX_DAYS entries (keep most recent) - if len(snapshots) > _COST_SNAPSHOT_MAX_DAYS: - snapshots = snapshots[-_COST_SNAPSHOT_MAX_DAYS:] - _write_cost_snapshots(provider, snapshots) - return snapshots + # Serialize the read-modify-write cycle. The atomic os.replace in + # _write_cost_snapshots protects the file write itself, but without this + # lock two concurrent requests can both read the same old snapshot list and + # race to replace it with stale data. + with _COST_SNAPSHOT_LOCK: + snapshots = _read_cost_snapshots(provider) + # Update or append today's entry + updated = False + for entry in snapshots: + if entry["date"] == today: + entry["used"] = usage + entry["limit"] = limit + updated = True + break + if not updated: + snapshots.append({"date": today, "used": usage, "limit": limit}) + snapshots.sort(key=lambda e: e["date"]) + # Cap to _COST_SNAPSHOT_MAX_DAYS entries (keep most recent) + if len(snapshots) > _COST_SNAPSHOT_MAX_DAYS: + snapshots = snapshots[-_COST_SNAPSHOT_MAX_DAYS:] + _write_cost_snapshots(provider, snapshots) + return snapshots def _compute_deltas(snapshots: list[dict[str, Any]], window_days: int) -> list[dict[str, Any]]: @@ -1583,7 +1589,10 @@ def _compute_deltas(snapshots: list[dict[str, Any]], window_days: int) -> list[d Each snapshot carries cumulative ``used``; the delta for a day is the difference between that day's cumulative value and the previous day's. The oldest day in the window has ``delta=None`` (no - previous baseline). + previous baseline). If the cumulative value drops, treat that day + as the start of a fresh series (for example after an API-key rotation) + and use the current value as that day's delta instead of emitting a + negative spend bar. """ # Take only the last *window_days* entries window = snapshots[-window_days:] if len(snapshots) > window_days else list(snapshots) @@ -1592,6 +1601,8 @@ def _compute_deltas(snapshots: list[dict[str, Any]], window_days: int) -> list[d delta = None if i > 0 and entry.get("used") is not None and window[i - 1].get("used") is not None: delta = float(entry["used"]) - float(window[i - 1]["used"]) + if delta < 0: + delta = float(entry["used"]) # Rounding: avoid -0.0 and tiny floating-point noise if abs(delta) < 1e-9: delta = 0.0 diff --git a/tests/test_provider_cost_history.py b/tests/test_provider_cost_history.py index 7ddf96c9..54bd9971 100644 --- a/tests/test_provider_cost_history.py +++ b/tests/test_provider_cost_history.py @@ -189,6 +189,77 @@ def test_openrouter_cost_history_deltas_from_cumulative(monkeypatch, tmp_path): assert snaps[2]["delta"] == 2.5 +def test_openrouter_cost_history_reset_uses_fresh_series_delta(monkeypatch, tmp_path): + """A lower cumulative value starts a fresh series instead of a negative bar.""" + monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: tmp_path) + monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) + (tmp_path / ".env").write_text("OPENROUTER_API_KEY=test-or-key\n", encoding="utf-8") + old_cfg, old_mtime = _with_config(model={"provider": "openrouter"}) + + import api.providers as providers + + snap_dir = tmp_path / "cost-snapshots" + snap_dir.mkdir(parents=True, exist_ok=True) + historical = { + "provider": "openrouter", + "snapshots": [ + {"date": "2030-04-13", "used": 9.0, "limit": 20}, + {"date": "2030-04-14", "used": 12.0, "limit": 20}, + ], + } + (snap_dir / "openrouter.json").write_text(json.dumps(historical), encoding="utf-8") + + def fake_urlopen(req, timeout): + # Simulate key rotation or provider reset: cumulative usage dropped. + payload = {"data": {"usage": 1.25, "limit": 20, "label": "Credits"}} + return _FakeResponse(json.dumps(payload).encode("utf-8")) + + monkeypatch.setattr(providers.urllib.request, "urlopen", fake_urlopen) + monkeypatch.setattr(providers, "datetime", type("DT", (), { + "now": staticmethod(lambda tz=None: datetime(2030, 4, 15, 12, 0, 0, tzinfo=tz or timezone.utc)), + "strftime": datetime.strftime, + })) + + try: + result = providers.get_provider_cost_history("openrouter", days=7) + finally: + _restore_config(old_cfg, old_mtime) + + assert result["ok"] is True + assert result["snapshots"][-1]["date"] == "2030-04-15" + assert result["snapshots"][-1]["used"] == 1.25 + assert result["snapshots"][-1]["delta"] == 1.25 + assert all(snap["delta"] is None or snap["delta"] >= 0 for snap in result["snapshots"]) + + +def test_cost_snapshot_append_uses_lock(monkeypatch, tmp_path): + """Snapshot append serializes the read-modify-write critical section.""" + monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: tmp_path) + + import api.providers as providers + + entered = {"count": 0} + + class RecordingLock: + def __enter__(self): + entered["count"] += 1 + return self + + def __exit__(self, *exc): + return False + + monkeypatch.setattr(providers, "_COST_SNAPSHOT_LOCK", RecordingLock()) + monkeypatch.setattr(providers, "datetime", type("DT", (), { + "now": staticmethod(lambda tz=None: datetime(2030, 4, 15, 12, 0, 0, tzinfo=tz or timezone.utc)), + "strftime": datetime.strftime, + })) + + snapshots = providers._append_cost_snapshot("openrouter", 4.0, 20.0) + + assert entered["count"] == 1 + assert snapshots == [{"date": "2030-04-15", "used": 4.0, "limit": 20.0}] + + # ── Missing credentials ─────────────────────────────────────────────────────── From faedcab7391e1242c1dccdc8eb587cf65ccdb790 Mon Sep 17 00:00:00 2001 From: Frank Song Date: Sat, 16 May 2026 10:50:16 +0800 Subject: [PATCH 005/138] Preserve live agent timeline across session switches --- CHANGELOG.md | 2 + .../after-live-timeline-preserved.png | Bin 0 -> 58630 bytes .../before-session-switch-rebuild.png | Bin 0 -> 71417 bytes static/messages.js | 43 +++-- static/sessions.js | 66 ++++++-- static/ui.js | 151 ++++++++++++++---- tests/test_auto_compression_card.py | 15 ++ tests/test_regressions.py | 39 ++++- tests/test_ui_tool_call_cleanup.py | 38 +++-- 9 files changed, 288 insertions(+), 66 deletions(-) create mode 100644 docs/pr-media/live-timeline-session-restore/after-live-timeline-preserved.png create mode 100644 docs/pr-media/live-timeline-session-restore/before-session-switch-rebuild.png diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fa25bab..79b39b1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,8 @@ ### Added +- **PR TBD** by @franksong2702 — Long tool-heavy streaming turns now preserve the live Thinking / assistant progress / Tool / Command timeline when the user switches away and back. The active stream keeps accumulating token and interim-assistant state while inactive, reloads the persisted transcript before merging the live tail, restores the live turn DOM snapshot instead of replaying tools into a flat list, and anchors automatic compression cards inside the active turn to avoid duplicate cards while an answer is still streaming. + - **PR #2332** by @Michaelyklam (refs #2290) — Cron run history/output cards now surface token/cost metadata when the underlying cron output markdown includes it. The backend parses optional model/token/cost/duration frontmatter from cron output files and returns it from `/api/crons/history` and `/api/crons/run`; the Tasks panel renders a compact usage strip beside run rows and below expanded output without affecting older outputs that lack usage metadata. ### Fixed diff --git a/docs/pr-media/live-timeline-session-restore/after-live-timeline-preserved.png b/docs/pr-media/live-timeline-session-restore/after-live-timeline-preserved.png new file mode 100644 index 0000000000000000000000000000000000000000..6a3ace147d54c4e9e660a31e2746dd97af5e0cbd GIT binary patch literal 58630 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∋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 @@ -1197,19 +1201,18 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ source.addEventListener('token',e=>{ if(_terminalStateReached||_streamFinalized) return; - if(!S.session||S.session.session_id!==activeSid) return; const d=JSON.parse(e.data); assistantText+=d.text; syncInflightAssistantMessage(); if(!S.session||S.session.session_id!==activeSid) return; const parsed=_parseStreamState(); + if(_freshSegment&&window._showThinking!==false) appendThinking(_liveThinkingText()); if(String((parsed&&parsed.displayText)||'').trim()||assistantRow) ensureAssistantRow(); _scheduleRender(); }); source.addEventListener('interim_assistant',e=>{ if(_terminalStateReached||_streamFinalized) return; - if(!S.session||S.session.session_id!==activeSid) return; const d=JSON.parse(e.data); const visible=String(d&&d.text?d.text:'').trim(); const alreadyStreamed=!!(d&&d.already_streamed); @@ -1217,19 +1220,19 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ return; } if(alreadyStreamed){ + if(!S.session||S.session.session_id!==activeSid) return; _resetAssistantSegment(); return; } - assistantText+=visible; + assistantText += assistantText ? `\n\n${visible}` : visible; visibleInterimSnippets.push(visible); syncInflightAssistantMessage(); if(!S.session||S.session.session_id!==activeSid) return; - const parsed=_parseStreamState(); if(window._showThinking!==false){ if(typeof updateThinking==='function') updateThinking(_liveThinkingText()); else appendThinking(_liveThinkingText()); } - if(String((parsed&&parsed.displayText)||'').trim()||assistantRow) ensureAssistantRow(); + ensureAssistantRow(true); _scheduleRender(); }); @@ -1274,6 +1277,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ liveReasoningText=''; const oldRow=$('toolRunningRow');if(oldRow)oldRow.remove(); appendLiveToolCard(tc); + snapshotLiveTurn(); // Reset the live assistant row reference so that any text tokens arriving // after this tool call create a NEW segment appended below the tool card, // rather than updating the old segment that sits above it in the DOM. @@ -1310,6 +1314,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ persistInflightState(); if(!S.session||S.session.session_id!==activeSid) return; appendLiveToolCard(tc); + snapshotLiveTurn(); scrollIfPinned(); }); @@ -1603,14 +1608,25 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ try{ d=JSON.parse(e.data||'{}')||{}; }catch(_){ d={}; } if(d.session_id&&d.session_id!==activeSid) return; if(typeof setCompressionUi==='function'){ - setCompressionUi({ + const state={ sessionId:activeSid, phase:'running', automatic:true, message:d.message||'Auto-compressing context...', - }); + }; + setCompressionUi(state); + const liveAnswerStarted=!!(assistantRow||String(((_parseStreamState&&_parseStreamState())||{}).displayText||'').trim()); + if(liveAnswerStarted&&typeof appendLiveCompressionCard==='function'&&appendLiveCompressionCard(state)){ + // The live card is now anchored in the turn. Keeping the same running + // state in global transient UI makes later renderMessages() calls insert + // a duplicate Automatic Compression card. + window._compressionUi=null; + snapshotLiveTurn(); + return; + } } if(typeof renderMessages==='function') renderMessages({preserveScroll:true}); + snapshotLiveTurn(); }); source.addEventListener('compressed',e=>{ @@ -1627,13 +1643,22 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ _syncCtxIndicator(S.lastUsage); } if(typeof setCompressionUi==='function'){ - setCompressionUi({ + const state={ sessionId:activeSid, phase:'done', automatic:true, message, summary:{headline:message}, - }); + }; + setCompressionUi(state); + const appended=typeof appendLiveCompressionCard==='function'&&appendLiveCompressionCard(state); + if(appended){ + // The live card is now anchored in the turn. Do not keep the automatic + // completion state as global transient UI, otherwise every subsequent + // render projects the same Auto Compression card again. + window._compressionUi=null; + snapshotLiveTurn(); + } } if(typeof _setCompressionSessionLock==='function') _setCompressionSessionLock(null); if(!S.busy&&typeof renderMessages==='function') renderMessages(); diff --git a/static/sessions.js b/static/sessions.js index 771e1d80..0019b6db 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -550,8 +550,10 @@ async function loadSession(sid){ return true; } - // Phase 2a: If session is streaming, restore from INFLIGHT cache before - // loading full messages (INFLIGHT state is self-contained and sufficient). + // Phase 2a: If session is streaming, restore the persisted transcript first, + // then merge the local INFLIGHT live tail. INFLIGHT is a recovery tail, not a + // complete transcript; treating it as the full source makes long sessions look + // like they lost history after switching away and back. if(!INFLIGHT[sid]&&activeStreamId&&typeof loadInflightState==='function'){ const stored=loadInflightState(sid, activeStreamId); if(stored){ @@ -565,8 +567,15 @@ async function loadSession(sid){ } if(INFLIGHT[sid]){ - // Streaming session: use cached INFLIGHT messages (already has pending assistant output). - S.messages=INFLIGHT[sid].messages; + const inflightMessages=INFLIGHT[sid].messages||[]; + S.messages=[]; + S.toolCalls=[]; + try { + await _ensureMessagesLoaded(sid); + } catch(e) { + S.messages=inflightMessages; + } + S.messages=_mergeInflightTailMessages(S.messages,inflightMessages); S.toolCalls=(INFLIGHT[sid].toolCalls||[]); if(_mergePendingSessionMessage(S.session,S.messages)){ INFLIGHT[sid].messages=S.messages; @@ -576,12 +585,17 @@ async function loadSession(sid){ // replaying persisted live tools so the compact Activity count survives // switching away from and back to an active chat (#1715). S.activeStreamId=activeStreamId; - syncTopbar();renderMessages();appendThinking();loadDir('.'); - clearLiveToolCards(); - if(typeof placeLiveToolCardsHost==='function') placeLiveToolCardsHost(); - for(const tc of (S.toolCalls||[])){ - if(tc&&tc.name) appendLiveToolCard(tc); + syncTopbar();renderMessages(); + const restoredLiveTurn=typeof restoreLiveTurnHtmlForSession==='function'&&restoreLiveTurnHtmlForSession(sid); + if(!restoredLiveTurn){ + appendThinking(); + clearLiveToolCards(); + if(typeof placeLiveToolCardsHost==='function') placeLiveToolCardsHost(); + for(const tc of (S.toolCalls||[])){ + if(tc&&tc.name) appendLiveToolCard(tc); + } } + loadDir('.'); setBusy(true);setComposerStatus(''); startApprovalPolling(sid); if(typeof startClarifyPolling==='function') startClarifyPolling(sid); @@ -1128,6 +1142,40 @@ async function _ensureMessagesLoaded(sid) { } } +function _messageComparableText(m){ + if(!m) return ''; + if(typeof msgContent==='function'){ + try{return String(msgContent(m)||'').trim();} + catch(_){} + } + return String(m.content||'').trim(); +} + +function _sameTranscriptMessage(a,b){ + return !!(a&&b) && + String(a.role||'')===String(b.role||'') && + _messageComparableText(a)===_messageComparableText(b); +} + +function _mergeInflightTailMessages(baseMessages, inflightMessages){ + const base=Array.isArray(baseMessages)?baseMessages:[]; + const inflight=Array.isArray(inflightMessages)?inflightMessages:[]; + let liveIdx=-1; + for(let i=inflight.length-1;i>=0;i--){ + if(inflight[i]&&inflight[i]._live){liveIdx=i;break;} + } + if(liveIdx<0) return base; + let start=liveIdx; + if(liveIdx>0&&inflight[liveIdx-1]&&inflight[liveIdx-1].role==='user') start=liveIdx-1; + const tail=inflight.slice(start).filter(m=>m&&m.role); + const merged=[...base]; + for(const msg of tail){ + const duplicate=merged.slice(-Math.max(5,tail.length+2)).some(existing=>_sameTranscriptMessage(existing,msg)); + if(!duplicate) merged.push(msg); + } + return merged; +} + // Load older messages when the user scrolls to the top of the conversation. // Prepends them to S.messages and re-renders, preserving scroll position. let _loadingOlder = false; diff --git a/static/ui.js b/static/ui.js index c050811c..a7594f02 100644 --- a/static/ui.js +++ b/static/ui.js @@ -3717,6 +3717,64 @@ function clearInflightState(sid){ }catch(_){ } } +function snapshotLiveTurnHtmlForSession(sid){ + if(!sid||!INFLIGHT[sid]) return; + const turn=$('liveAssistantTurn'); + if(!turn) return; + if(turn.dataset&&turn.dataset.sessionId&&turn.dataset.sessionId!==sid) return; + INFLIGHT[sid].liveTurnHtml=turn.outerHTML; +} + +function _liveAssistantSegmentTextLength(seg){ + if(!seg) return 0; + const body=seg.querySelector('.msg-body')||seg; + return String(body.textContent||'').trim().length; +} + +function _mergeRestoredLiveAssistantSegment(restored, existing){ + if(!restored||!existing) return; + const existingLive=existing.querySelector('[data-live-assistant="1"]'); + if(!existingLive) return; + const restoredLive=restored.querySelector('[data-live-assistant="1"]'); + const existingLen=_liveAssistantSegmentTextLength(existingLive); + const restoredLen=_liveAssistantSegmentTextLength(restoredLive); + if(existingLen<=restoredLen) return; + const replacement=existingLive.cloneNode(true); + if(restoredLive){ + restoredLive.replaceWith(replacement); + return; + } + const blocks=_assistantTurnBlocks(restored); + if(!blocks) return; + const anchor=Array.from(blocks.children).filter(el=> + el.matches('.tool-call-group,.tool-card-row,.agent-activity-thinking,.thinking-card-row,[data-live-assistant="1"]') + ).pop(); + if(anchor) anchor.insertAdjacentElement('afterend', replacement); + else blocks.appendChild(replacement); +} + +function restoreLiveTurnHtmlForSession(sid){ + const inflight=INFLIGHT[sid]; + if(!sid||!inflight||!inflight.liveTurnHtml) return false; + const inner=$('msgInner'); + if(!inner) return false; + const template=document.createElement('template'); + template.innerHTML=String(inflight.liveTurnHtml||'').trim(); + const restored=template.content.firstElementChild; + if(!restored) return false; + restored.id='liveAssistantTurn'; + if(S.session) restored.dataset.sessionId=S.session.session_id; + const existing=$('liveAssistantTurn'); + _mergeRestoredLiveAssistantSegment(restored, existing); + if(existing) existing.replaceWith(restored); + else inner.appendChild(restored); + const liveGroup=restored.querySelector('.tool-call-group[data-live-tool-call-group="1"]'); + if(liveGroup&&typeof _startActivityElapsedTimer==='function') _startActivityElapsedTimer(liveGroup); + if(typeof placeLiveToolCardsHost==='function') placeLiveToolCardsHost(); + requestAnimationFrame(()=>postProcessRenderedMessages(restored)); + return true; +} + function markInflight(sid, streamId) { localStorage.setItem(INFLIGHT_KEY, JSON.stringify({sid, streamId, ts: Date.now()})); } @@ -4543,17 +4601,18 @@ function _createAssistantTurn(tsTitle='', tpsText=''){ function _assistantTurnBlocks(turn){ return turn?turn.querySelector('.assistant-turn-blocks'):null; } -function _thinkingCardHtml(text){ +function _thinkingCardHtml(text, open){ const clean=_sanitizeThinkingDisplayText(text); - return `

${li('lightbulb',14)}${t('thinking')}${li('chevron-right',12)}
${esc(clean)}
`; + const openClass=open?' open':''; + return `
${li('lightbulb',14)}${t('thinking')}${li('chevron-right',12)}
${esc(clean)}
`; } function isSimplifiedToolCalling(){ return window._simplifiedToolCalling!==false; } -function _thinkingActivityNode(text){ +function _thinkingActivityNode(text, open){ const row=document.createElement('div'); row.className='agent-activity-thinking'; - row.innerHTML=_thinkingCardHtml(text); + row.innerHTML=_thinkingCardHtml(text, open); return row; } // ── Activity-group user expand intent (#1298) ────────────────────────────── @@ -4737,17 +4796,24 @@ function _compressionCardsHtml(state){ } function _autoCompressionCardsHtml(state){ const fallback='Context auto-compressed to continue the conversation'; - const detail=String(state.message||fallback).trim()||fallback; - const preview=String(state.summary?.headline||detail).trim()||detail; + const running=state&&state.phase==='running'; + const detail=running + ? (String(state.message||'Auto-compressing context...').trim()||'Auto-compressing context...') + : (String(state.message||fallback).trim()||fallback); + const preview=running + ? detail + : (String(state.summary?.headline||detail).trim()||detail); return `
${_compressionStatusCardHtml({ statusLabel: t('auto_compress_label'), previewText: preview, detail, - icon: li('check',13), - open: false, - variantClass: 'tool-card-compress-complete tool-card-compress-auto', + icon: running ? '' : li('check',13), + open: running, + variantClass: running + ? 'tool-card-compress-running tool-card-compress-auto' + : 'tool-card-compress-complete tool-card-compress-auto', })}
`; } @@ -4757,6 +4823,26 @@ function _compressionCardsNode(state){ wrap.innerHTML=`
${_compressionCardsHtml(state)}
`; return wrap; } +function appendLiveCompressionCard(state){ + if(!S.session||!S.activeStreamId||!state) return false; + let turn=$('liveAssistantTurn'); + if(!turn){ + turn=_createAssistantTurn(); + turn.id='liveAssistantTurn'; + if(S.session) turn.dataset.sessionId=S.session.session_id; + $('msgInner').appendChild(turn); + } + const inner=_assistantTurnBlocks(turn); + if(!inner) return false; + const node=_compressionCardsNode(state); + if(!node) return false; + node.setAttribute('data-live-compression-card','1'); + const existing=inner.querySelector('[data-live-compression-card="1"]'); + if(existing) existing.replaceWith(node); + else inner.appendChild(node); + if(typeof scrollIfPinned==='function') scrollIfPinned(); + return true; +} function _isHandoffSummaryToolPayload(value){ if(!value||typeof value!=='object'||Array.isArray(value)) return false; return value._handoff_summary_card === true; @@ -5705,14 +5791,18 @@ function renderMessages(options){ } if(!anchorRow) continue; const anchorParent=anchorRow.parentElement; - const insertAfterNode = anchorInsertAfter.get(anchorRow) || anchorRow; + let insertAfterNode = anchorInsertAfter.get(anchorRow) || anchorRow; + const thinkingText=assistantThinking.get(aIdx); + if(thinkingText){ + const thinkingNode=_thinkingActivityNode(thinkingText, false); + anchorParent.insertBefore(thinkingNode, anchorRow); + } + if(!cards.length) continue; const group=ensureActivityGroup(anchorParent,{collapsed:true,anchor:insertAfterNode,activityKey:`assistant:${aIdx}`}); const sourceMsg=S.messages[aIdx]||{}; if(sourceMsg._turnDuration!==undefined) group.setAttribute('data-turn-duration', String(sourceMsg._turnDuration)); const body=group&&group.querySelector('.tool-call-group-body'); if(!body) continue; - const thinkingText=assistantThinking.get(aIdx); - if(thinkingText) body.appendChild(_thinkingActivityNode(thinkingText)); for(const tc of cards){ body.appendChild(buildToolCard(tc)); } @@ -6857,31 +6947,28 @@ function appendThinking(text=''){ } return; } - if(!String(text||'').trim()){ - scrollIfPinned(); - return; - } - const allChildren=Array.from(blocks.children); - const anchor=allChildren.filter(el=> - el.id!=='toolRunningRow' && - el.matches('[data-live-assistant="1"],.tool-call-group,.tool-card-row,.agent-activity-thinking') - ).pop(); - const group=ensureActivityGroup(blocks,{live:true,collapsed:true,anchor,activityKey:_activityKeyForLiveTurn()}); - const body=group&&group.querySelector('.tool-call-group-body'); - if(!body) return; - let row=body.querySelector('.agent-activity-thinking[data-thinking-active="1"]'); + const thinkingText=String(text||'').trim()||'Thinking…'; + blocks.querySelectorAll('.tool-call-group[data-live-tool-call-group="1"][data-live-activity-current="1"]').forEach(group=>{ + group.removeAttribute('data-live-activity-current'); + }); + let row=blocks.querySelector('.agent-activity-thinking[data-thinking-active="1"]'); if(!row){ - row=document.createElement('div'); - row.className='agent-activity-thinking'; + row=_thinkingActivityNode(thinkingText, false); row.setAttribute('data-thinking-active','1'); - body.insertBefore(row, body.firstChild); + const allChildren=Array.from(blocks.children); + const anchor=allChildren.filter(el=> + el.id!=='toolRunningRow' && + el.matches('[data-live-assistant="1"],.tool-call-group,.tool-card-row,.agent-activity-thinking') + ).pop(); + if(anchor) anchor.insertAdjacentElement('afterend', row); + else blocks.appendChild(row); + }else{ + _renderThinkingInto(row,thinkingText); } - _renderThinkingInto(row,text); - _syncToolCallGroupSummary(group); scrollIfPinned(); if(_scrollPinned){ - const thinkingBody=row&&row.querySelector('.thinking-card-body'); - if(thinkingBody) thinkingBody.scrollTop=thinkingBody.scrollHeight; + const body=row&&row.querySelector('.thinking-card-body'); + if(body) body.scrollTop=body.scrollHeight; } } function updateThinking(text=''){appendThinking(text);} diff --git a/tests/test_auto_compression_card.py b/tests/test_auto_compression_card.py index 21d7d5d0..d092f615 100644 --- a/tests/test_auto_compression_card.py +++ b/tests/test_auto_compression_card.py @@ -67,6 +67,18 @@ def test_auto_compression_completion_transition_is_preserved_after_running_liste assert "phase:'done'" in _compressed_listener_block() +def test_auto_compression_does_not_rerender_over_live_answer_text(): + block = _compressing_listener_block() + src = _read("static/ui.js") + + assert "const liveAnswerStarted=" in block + assert "appendLiveCompressionCard(state)" in block + assert block.index("appendLiveCompressionCard(state)") < block.index("renderMessages({preserveScroll:true})") + assert "window._compressionUi=null;" in block + assert "function appendLiveCompressionCard(state)" in src + assert 'data-live-compression-card' in src + + def test_auto_compression_sse_uses_transient_card_not_fake_message(): """Auto compression must not inject display-only text into S.messages.""" src = _read("static/messages.js") @@ -78,6 +90,9 @@ def test_auto_compression_sse_uses_transient_card_not_fake_message(): assert "phase:'done'" in block assert "automatic:true" in block assert "_setCompressionSessionLock" in block + assert "const appended=typeof appendLiveCompressionCard==='function'&&appendLiveCompressionCard(state);" in block + assert "window._compressionUi=null;" in block + assert block.index("appendLiveCompressionCard(state)") < block.index("window._compressionUi=null;") def test_auto_compression_sse_keeps_inactive_and_malformed_paths_safe(): diff --git a/tests/test_regressions.py b/tests/test_regressions.py index 068afdcf..1de1ef34 100644 --- a/tests/test_regressions.py +++ b/tests/test_regressions.py @@ -7,6 +7,7 @@ Each test is tagged with the sprint/commit where the bug was found and fixed. import json import os import pathlib +import re import time import urllib.error import urllib.request @@ -582,10 +583,23 @@ def test_live_stream_tokens_persist_partial_assistant_for_session_switch(cleanup "messages.js must mark the persisted in-flight assistant row so renderMessages can re-anchor it" assert "syncInflightAssistantMessage();" in messages_src, \ "token handler must update INFLIGHT state before checking the active session" + token_match = re.search(r"source\.addEventListener\('token',e=>\{(.*?)\n\s*\}\);", messages_src, re.S) + assert token_match, "token listener not found" + token_fn = token_match.group(1) + assert token_fn.find("assistantText+=d.text") < token_fn.find("if(!S.session||S.session.session_id!==activeSid) return;"), ( + "token events must update the active stream's local state before DOM-only active-session guards" + ) + assert token_fn.find("syncInflightAssistantMessage();") < token_fn.find("if(!S.session||S.session.session_id!==activeSid) return;"), ( + "token events must persist INFLIGHT state even while another session is selected" + ) assert "assistantRow&&!assistantRow.isConnected" in messages_src, \ "live stream must drop stale detached assistant DOM references after session switches" assert "data-live-assistant" in ui_src, \ "renderMessages must preserve a live-assistant DOM anchor when rebuilding the thread" + assert "snapshotLiveTurnHtmlForSession(activeSid)" in messages_src, \ + "live turn DOM snapshots should preserve the interleaved timeline across session switches" + assert "restoreLiveTurnHtmlForSession(sid)" in (REPO_ROOT / "static/sessions.js").read_text(), \ + "loadSession should restore the live turn snapshot before replaying flat tool cards" def test_inflight_session_state_tracks_live_tool_cards_per_session(cleanup_test_sessions): @@ -612,13 +626,30 @@ def test_loadSession_inflight_sets_busy_before_renderMessages(cleanup_test_sessi assert inflight_idx >= 0, "INFLIGHT branch not found in loadSession" inflight_block = src[inflight_idx:inflight_idx+700] busy_pos = inflight_block.find("S.busy=true;") - render_pos = inflight_block.find("renderMessages();appendThinking();") + render_pos = inflight_block.find("renderMessages();") assert busy_pos >= 0, "loadSession INFLIGHT branch must set S.busy=true" assert render_pos >= 0, "loadSession INFLIGHT branch must call renderMessages()" assert busy_pos < render_pos, \ "loadSession must set S.busy=true before renderMessages() to avoid duplicate tool cards" +def test_loadSession_inflight_merges_tail_with_persisted_transcript(cleanup_test_sessions): + src = (REPO_ROOT / "static/sessions.js").read_text() + inflight_idx = src.find("if(INFLIGHT[sid]){") + assert inflight_idx >= 0, "INFLIGHT branch not found in loadSession" + inflight_block = src[inflight_idx:inflight_idx+1200] + + assert "await _ensureMessagesLoaded(sid);" in inflight_block, ( + "returning to an active stream should load the persisted transcript before adding the live tail" + ) + assert "_mergeInflightTailMessages(S.messages,inflightMessages)" in inflight_block, ( + "INFLIGHT messages should be merged as a tail, not replace the full transcript" + ) + assert "function _mergeInflightTailMessages" in src, ( + "sessions.js should centralize INFLIGHT tail merge logic for regression coverage" + ) + + def test_loadSession_inflight_sets_active_stream_before_replaying_live_tool_cards(cleanup_test_sessions): """#1715: returning to an active chat must replay persisted tool cards. @@ -630,7 +661,7 @@ def test_loadSession_inflight_sets_active_stream_before_replaying_live_tool_card src = (REPO_ROOT / "static/sessions.js").read_text() inflight_idx = src.find("if(INFLIGHT[sid]){") assert inflight_idx >= 0, "INFLIGHT branch not found in loadSession" - inflight_block = src[inflight_idx:inflight_idx+1000] + inflight_block = src[inflight_idx:inflight_idx+1600] active_pos = inflight_block.find("S.activeStreamId=activeStreamId;") replay_pos = inflight_block.find("appendLiveToolCard(tc);") attach_pos = inflight_block.find("attachLiveStream(sid, activeStreamId") @@ -769,8 +800,8 @@ def test_ui_js_does_not_hide_anchor_segments_that_contain_thinking(cleanup_test_ compact = src.replace(' ', '').replace('\n', '') assert "assistantThinking.set(rawIdx,thinkingText)" in compact, \ "renderMessages must preserve reasoning text before hiding empty anchor segments" - assert "_thinkingActivityNode(thinkingText)" in src, \ - "thinking-only assistant content should render inside the shared activity dropdown" + assert "_thinkingActivityNode(thinkingText, false)" in src, \ + "thinking-only assistant content should render as a collapsed timeline Thinking card" def test_messages_js_live_assistant_segment_reuses_live_turn_wrapper(cleanup_test_sessions): diff --git a/tests/test_ui_tool_call_cleanup.py b/tests/test_ui_tool_call_cleanup.py index 29fe6457..44204d00 100644 --- a/tests/test_ui_tool_call_cleanup.py +++ b/tests/test_ui_tool_call_cleanup.py @@ -260,35 +260,49 @@ class TestToolCallGroupingStatic: "Thinking echo suppression should remove exact visible assistant snippets from reasoning display." ) - def test_tools_and_thinking_share_one_collapsed_activity_dropdown(self): + def test_compact_activity_keeps_thinking_cards_after_session_switch(self): ui_min = re.sub(r"\s+", "", UI_JS) assert "functionensureActivityGroup(" in ui_min, ( - "Tool calls and thinking should share one agent-activity disclosure helper." + "Tool calls should still use the shared Activity disclosure helper." ) assert "data-agent-activity-group" in UI_JS, ( - "The shared tools/thinking disclosure needs a stable data-agent-activity-group hook." - ) - assert "agent-activity-thinking" in UI_JS, ( - "Thinking content should be nested inside the shared activity dropdown, not rendered separately." + "The Activity disclosure needs a stable data-agent-activity-group hook." ) render_fn = _function_body(UI_JS, "renderMessages") assert "isSimplifiedToolCalling()" in render_fn and "assistantThinking.set(rawIdx, thinkingText)" in render_fn, ( - "Settled thinking should move into the shared activity dropdown only when Compact tool activity is enabled." + "Compact settled transcript rendering should preserve Thinking cards after switching sessions." + ) + assert "_thinkingActivityNode(thinkingText, false)" in render_fn, ( + "Settled Thinking cards should render as collapsed timeline entries before related tools." + ) + assert "anchorParent.insertBefore(thinkingNode, anchorRow)" in render_fn, ( + "Settled Thinking cards should appear before their visible assistant process text." ) assert "seg.insertAdjacentHTML('beforeend', _thinkingCardHtml(thinkingText))" in render_fn, ( "The non-simplified path should preserve standalone settled thinking cards." ) - def test_live_thinking_uses_shared_activity_dropdown_only_when_simplified(self): + def test_live_thinking_is_shown_while_still_splitting_tool_bursts(self): live_thinking_fn = _function_body(UI_JS, "appendThinking") + live_tool_fn = _function_body(UI_JS, "appendLiveToolCard") + helper = _function_body(UI_JS, "ensureActivityGroup") assert "isSimplifiedToolCalling()" in live_thinking_fn, ( "Live thinking should branch on the Compact tool activity toggle." ) - assert "ensureActivityGroup" in live_thinking_fn, ( - "Compact live thinking should be inserted into the shared activity dropdown." + assert 'data-live-activity-current' in live_thinking_fn, ( + "Starting a new live thinking block should close the previous live tool burst." ) - assert "thinkingRow" in live_thinking_fn, ( - "The non-simplified live thinking path should preserve the upstream #thinkingRow card." + assert "body.insertBefore(row, body.firstChild)" not in live_thinking_fn, ( + "Live thinking should not be moved into the top Activity dropdown." + ) + assert "_thinkingActivityNode(thinkingText, false)" in live_thinking_fn, ( + "Compact live thinking should render a collapsed Thinking card in the timeline." + ) + assert '[data-live-activity-current="1"]' in live_thinking_fn, ( + "Starting a new Thinking card should mark the previous live tool burst as no longer current." + ) + assert "body.querySelector" in live_tool_fn and "data-live-tid" in live_tool_fn, ( + "tool_complete must still update its current live Activity burst by tool id." ) From 10db8b3bb6c9221b73e226db337749d3d1cb63b3 Mon Sep 17 00:00:00 2001 From: Frank Song Date: Sat, 16 May 2026 10:58:59 +0800 Subject: [PATCH 006/138] Preserve base Thinking card markup for animation tests --- static/ui.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/static/ui.js b/static/ui.js index a7594f02..994ad972 100644 --- a/static/ui.js +++ b/static/ui.js @@ -4603,8 +4603,9 @@ function _assistantTurnBlocks(turn){ } function _thinkingCardHtml(text, open){ const clean=_sanitizeThinkingDisplayText(text); - const openClass=open?' open':''; - return `
${li('lightbulb',14)}${t('thinking')}${li('chevron-right',12)}
${esc(clean)}
`; + return open + ? `
${li('lightbulb',14)}${t('thinking')}${li('chevron-right',12)}
${esc(clean)}
` + : `
${li('lightbulb',14)}${t('thinking')}${li('chevron-right',12)}
${esc(clean)}
`; } function isSimplifiedToolCalling(){ return window._simplifiedToolCalling!==false; From cdef03961368da9274ba91652767ecdfac42e63c Mon Sep 17 00:00:00 2001 From: Frank Song Date: Sat, 16 May 2026 11:02:33 +0800 Subject: [PATCH 007/138] Update inflight restore static test windows --- tests/test_regressions.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_regressions.py b/tests/test_regressions.py index 1de1ef34..624043a6 100644 --- a/tests/test_regressions.py +++ b/tests/test_regressions.py @@ -434,7 +434,7 @@ def test_loadSession_inflight_restores_live_tool_cards(cleanup_test_sessions): # INFLIGHT branch must call appendLiveToolCard inflight_idx = src.find("if(INFLIGHT[sid]){") assert inflight_idx >= 0, "INFLIGHT branch not found in loadSession" - inflight_block = src[inflight_idx:inflight_idx+900] + inflight_block = src[inflight_idx:inflight_idx+1600] assert "appendLiveToolCard" in inflight_block, "loadSession INFLIGHT branch must restore live tool cards via appendLiveToolCard" assert "clearLiveToolCards" in inflight_block, "loadSession INFLIGHT branch must clear old live cards before restoring" @@ -624,7 +624,7 @@ def test_loadSession_inflight_sets_busy_before_renderMessages(cleanup_test_sessi src = (REPO_ROOT / "static/sessions.js").read_text() inflight_idx = src.find("if(INFLIGHT[sid]){") assert inflight_idx >= 0, "INFLIGHT branch not found in loadSession" - inflight_block = src[inflight_idx:inflight_idx+700] + inflight_block = src[inflight_idx:inflight_idx+1600] busy_pos = inflight_block.find("S.busy=true;") render_pos = inflight_block.find("renderMessages();") assert busy_pos >= 0, "loadSession INFLIGHT branch must set S.busy=true" From 240fc42ad87b493df7008d479ac4f88eb94fe554 Mon Sep 17 00:00:00 2001 From: Frank Song Date: Sat, 16 May 2026 11:06:09 +0800 Subject: [PATCH 008/138] Align pending-user reattach test with live snapshot restore --- tests/test_issue2341_pending_user_reattach.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/test_issue2341_pending_user_reattach.py b/tests/test_issue2341_pending_user_reattach.py index 6239a1ac..b0a4faf3 100644 --- a/tests/test_issue2341_pending_user_reattach.py +++ b/tests/test_issue2341_pending_user_reattach.py @@ -25,7 +25,7 @@ def test_load_session_inflight_reattach_merges_pending_user_message_before_rende block = _load_session_inflight_branch() merge_pos = block.find("_mergePendingSessionMessage") - render_pos = block.find("renderMessages();appendThinking();") + render_pos = block.find("renderMessages();") assert merge_pos != -1, ( "loadSession's INFLIGHT reattach branch must merge pending_user_message " @@ -36,6 +36,10 @@ def test_load_session_inflight_reattach_merges_pending_user_message_before_rende "The pending user row must be present before renderMessages() rebuilds " "the active transcript" ) + assert "restoreLiveTurnHtmlForSession(sid)" in block, ( + "Session restore may keep a live DOM snapshot instead of always " + "recreating a fresh Thinking row after renderMessages()" + ) assert "INFLIGHT[sid].messages=S.messages;" in block, ( "After merging the pending user row, the INFLIGHT cache should be updated " "so later session switches keep the same visible turn" From e7e45fe98be058d42bdaf4e00efb36495696826f Mon Sep 17 00:00:00 2001 From: Frank Song Date: Sat, 16 May 2026 11:16:07 +0800 Subject: [PATCH 009/138] Stamp live assistant turns at creation --- static/ui.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/static/ui.js b/static/ui.js index 994ad972..ad3b0703 100644 --- a/static/ui.js +++ b/static/ui.js @@ -3718,6 +3718,9 @@ function clearInflightState(sid){ } function snapshotLiveTurnHtmlForSession(sid){ + // Keep the DOM snapshot memory-only. Persisted INFLIGHT state intentionally + // stores structured stream state, not outerHTML, so a hard reload still uses + // the safer flat replay path instead of reviving stale nodes/listeners. if(!sid||!INFLIGHT[sid]) return; const turn=$('liveAssistantTurn'); if(!turn) return; @@ -4595,6 +4598,7 @@ function _createAssistantTurn(tsTitle='', tpsText=''){ const row=document.createElement('div'); row.className='msg-row assistant-turn'; row.dataset.role='assistant'; + if(S.session) row.dataset.sessionId=S.session.session_id; row.innerHTML=`${_assistantRoleHtml(tsTitle, tpsText)}
`; return row; } From 98941571209f0eafa01119f508e7c8e094df733e Mon Sep 17 00:00:00 2001 From: Michael Lam Date: Fri, 15 May 2026 22:42:02 -0700 Subject: [PATCH 010/138] fix: hybridize background profile env routing --- CHANGELOG.md | 4 +++ api/profiles.py | 42 ++++++++++++++------------ tests/test_sprint46.py | 9 +++++- tests/test_title_aux_routing.py | 50 +++++++++++++++++++++++++++++++ tests/test_update_banner_fixes.py | 14 +++++++-- 5 files changed, 97 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fa25bab..df4a44a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Background profile workers now route broad runtime env through thread-local state while setting only `HERMES_HOME` under the narrow process-env lock, preserving `hermes_cli.config.load_config()` compatibility for non-default profiles without leaking profile-specific env keys process-wide. + ## [v0.51.74] — 2026-05-16 — Release AX (stage-367 — 4-PR safe-lane batch — #2362 table-cell spacing + #2363 run-state-consistency RFC + #2365 custom_providers list-format + #2367 settings sidebar i18n) ### Added diff --git a/api/profiles.py b/api/profiles.py index 22c73312..e637dcb7 100644 --- a/api/profiles.py +++ b/api/profiles.py @@ -697,7 +697,8 @@ def profile_env_for_background_worker( return try: - # Lazy import avoids a module-load cycle: streaming imports this helper. + # Lazy imports avoid a module-load cycle: streaming imports this helper. + from api.config import _clear_thread_env, _set_thread_env, _thread_ctx from api.streaming import _ENV_LOCK profile_home_path = Path(get_hermes_home_for_profile(profile)) @@ -712,23 +713,23 @@ def profile_env_for_background_worker( yield return - env_keys = set(runtime_env.keys()) | {"HERMES_HOME"} - # Stage-360 maintainer fix: narrow the _ENV_LOCK critical section to just - # the env mutation (and the env restoration). Pre-fix, this held _ENV_LOCK - # for the entire `yield` duration — i.e. the whole background worker's - # runtime (title generation, compression, update summary). That caused - # _ENV_LOCK to be held for many seconds, blocking ALL other sessions and - # surfacing as the QA `test_third_message_completes` timeout. The fix - # mirrors the narrow-lock pattern in _run_agent_streaming: acquire briefly - # to set env, run worker without holding the lock, reacquire to restore. - # See also QA `test_finally_restores_env_with_lock`. + thread_env = dict(runtime_env) + thread_env["HERMES_HOME"] = str(profile_home_path) + # Hybrid profile routing: keep the broad runtime env in WebUI's thread-local + # channel so provider/API-key overrides do not leak through process-global + # os.environ, but still set HERMES_HOME under the narrow _ENV_LOCK because + # hermes_cli.config.load_config() currently resolves homes from os.environ. + # Do not hold _ENV_LOCK across the worker body. skill_home_snapshot = None - old_env = {} + old_hermes_home = None + had_hermes_home = False + previous_thread_env = getattr(_thread_ctx, "env", {}).copy() try: + _set_thread_env(**thread_env) with _ENV_LOCK: - old_env = {key: os.environ.get(key) for key in env_keys} + had_hermes_home = "HERMES_HOME" in os.environ + old_hermes_home = os.environ.get("HERMES_HOME") skill_home_snapshot = snapshot_skill_home_modules() - os.environ.update(runtime_env) os.environ["HERMES_HOME"] = str(profile_home_path) try: patch_skill_home_modules(profile_home_path) @@ -741,12 +742,15 @@ def profile_env_for_background_worker( ) yield finally: + if previous_thread_env: + _set_thread_env(**previous_thread_env) + else: + _clear_thread_env() with _ENV_LOCK: - for key, old_value in old_env.items(): - if old_value is None: - os.environ.pop(key, None) - else: - os.environ[key] = old_value + if had_hermes_home: + os.environ["HERMES_HOME"] = old_hermes_home or "" + else: + os.environ.pop("HERMES_HOME", None) if skill_home_snapshot is not None: restore_skill_home_modules(skill_home_snapshot) diff --git a/tests/test_sprint46.py b/tests/test_sprint46.py index 0db0ffa2..cffe73e2 100644 --- a/tests/test_sprint46.py +++ b/tests/test_sprint46.py @@ -415,10 +415,15 @@ def test_manual_compress_worker_uses_session_profile_env(monkeypatch, tmp_path, seen_env = None def __init__(self, **kwargs): + from api.config import _thread_ctx + skill_module = sys.modules.get("tools.skills_tool") + thread_env = getattr(_thread_ctx, "env", {}) EnvAssertingAgent.seen_env = { "HERMES_HOME": os.environ.get("HERMES_HOME"), "HERMES_TEST_PROFILE_ENV": os.environ.get("HERMES_TEST_PROFILE_ENV"), + "THREAD_HERMES_HOME": thread_env.get("HERMES_HOME"), + "THREAD_HERMES_TEST_PROFILE_ENV": thread_env.get("HERMES_TEST_PROFILE_ENV"), "SKILL_MODULE_HOME": getattr(skill_module, "HERMES_HOME", None), "SKILL_MODULE_DIR": getattr(skill_module, "SKILLS_DIR", None), } @@ -460,7 +465,9 @@ def test_manual_compress_worker_uses_session_profile_env(monkeypatch, tmp_path, assert EnvAssertingAgent.seen_env == { "HERMES_HOME": str(profile_home), - "HERMES_TEST_PROFILE_ENV": "work-runtime", + "HERMES_TEST_PROFILE_ENV": None, + "THREAD_HERMES_HOME": str(profile_home), + "THREAD_HERMES_TEST_PROFILE_ENV": "work-runtime", "SKILL_MODULE_HOME": profile_home, "SKILL_MODULE_DIR": profile_home / "skills", } diff --git a/tests/test_title_aux_routing.py b/tests/test_title_aux_routing.py index a125d361..0acffbbb 100644 --- a/tests/test_title_aux_routing.py +++ b/tests/test_title_aux_routing.py @@ -502,6 +502,56 @@ class TestBackgroundTitleProfileRouting(unittest.TestCase): self.assertEqual(getattr(fake_skill_module, 'SKILLS_DIR'), 'default-home/skills') self.assertEqual(mock_session.title, 'Profile Routed Title') + def test_background_profile_env_routes_load_config_without_process_env_leak(self): + """Hybrid worker env must satisfy hermes_cli.load_config without leaking profile env keys.""" + import tempfile + + import pytest + + import api.profiles as profiles + from api.config import _thread_ctx + try: + from hermes_cli import config as hermes_config + except ModuleNotFoundError: + pytest.skip('hermes_cli is not installed in this CI environment') + + session = types.SimpleNamespace(profile='work') + captured = {} + + with tempfile.TemporaryDirectory() as tmp: + default_home = os.path.join(tmp, 'default-home') + profile_home = os.path.join(tmp, 'profile-home') + os.makedirs(default_home, exist_ok=True) + os.makedirs(profile_home, exist_ok=True) + with open(os.path.join(default_home, 'config.yaml'), 'w', encoding='utf-8') as f: + f.write('model:\n provider: default-provider\n default: default-model\n') + with open(os.path.join(profile_home, 'config.yaml'), 'w', encoding='utf-8') as f: + f.write('model:\n provider: profile-provider\n default: profile-model\n') + + with patch('api.profiles.get_hermes_home_for_profile', return_value=profile_home): + with patch('api.profiles.get_profile_runtime_env', return_value={'PROFILE_ONLY_KEY': 'profile-only'}): + with patch.dict(os.environ, {'HERMES_HOME': default_home}, clear=False): + os.environ.pop('PROFILE_ONLY_KEY', None) + hermes_config._LOAD_CONFIG_CACHE.clear() + with profiles.profile_env_for_background_worker(session, 'background title'): + loaded = hermes_config.load_config() + captured['loaded_provider'] = loaded.get('model', {}).get('provider') + captured['process_home'] = os.environ.get('HERMES_HOME') + captured['process_runtime_key'] = os.environ.get('PROFILE_ONLY_KEY') + captured['thread_home'] = getattr(_thread_ctx, 'env', {}).get('HERMES_HOME') + captured['thread_runtime_key'] = getattr(_thread_ctx, 'env', {}).get('PROFILE_ONLY_KEY') + captured['restored_home'] = os.environ.get('HERMES_HOME') + captured['restored_runtime_key'] = os.environ.get('PROFILE_ONLY_KEY') + hermes_config._LOAD_CONFIG_CACHE.clear() + + self.assertEqual(captured['loaded_provider'], 'profile-provider') + self.assertEqual(captured['process_home'], profile_home) + self.assertIsNone(captured['process_runtime_key']) + self.assertEqual(captured['thread_home'], profile_home) + self.assertEqual(captured['thread_runtime_key'], 'profile-only') + self.assertEqual(captured['restored_home'], default_home) + self.assertIsNone(captured['restored_runtime_key']) + class TestAuxTitleTimeoutEdgeCases(unittest.TestCase): """_aux_title_timeout must reject zero, negative, and non-numeric values.""" diff --git a/tests/test_update_banner_fixes.py b/tests/test_update_banner_fixes.py index bc3a2cba..210a1806 100644 --- a/tests/test_update_banner_fixes.py +++ b/tests/test_update_banner_fixes.py @@ -476,9 +476,12 @@ class TestUpdateSummaryRouteModelSelection: monkeypatch.setattr(cfg, 'get_effective_default_model', lambda: 'openai/test-main') def fake_resolve_model_provider(model): + thread_env = getattr(cfg._thread_ctx, 'env', {}) captured['model_resolution_env'] = { 'HERMES_HOME': os.environ.get('HERMES_HOME'), 'HERMES_TEST_PROFILE_ENV': os.environ.get('HERMES_TEST_PROFILE_ENV'), + 'THREAD_HERMES_HOME': thread_env.get('HERMES_HOME'), + 'THREAD_HERMES_TEST_PROFILE_ENV': thread_env.get('HERMES_TEST_PROFILE_ENV'), } return model, 'openai', 'https://example.test/v1' @@ -514,9 +517,12 @@ class TestUpdateSummaryRouteModelSelection: ) def fake_get_text_auxiliary_client(task, main_runtime=None): + thread_env = getattr(cfg._thread_ctx, 'env', {}) captured['aux_env'] = { 'HERMES_HOME': os.environ.get('HERMES_HOME'), 'HERMES_TEST_PROFILE_ENV': os.environ.get('HERMES_TEST_PROFILE_ENV'), + 'THREAD_HERMES_HOME': thread_env.get('HERMES_HOME'), + 'THREAD_HERMES_TEST_PROFILE_ENV': thread_env.get('HERMES_TEST_PROFILE_ENV'), 'SKILL_MODULE_HOME': getattr(fake_skill_module, 'HERMES_HOME'), 'SKILL_MODULE_DIR': getattr(fake_skill_module, 'SKILLS_DIR'), } @@ -563,11 +569,15 @@ class TestUpdateSummaryRouteModelSelection: assert captured['aux_task'] == 'compression' assert captured['model_resolution_env'] == { 'HERMES_HOME': str(profile_home), - 'HERMES_TEST_PROFILE_ENV': 'work-runtime', + 'HERMES_TEST_PROFILE_ENV': 'default-runtime', + 'THREAD_HERMES_HOME': str(profile_home), + 'THREAD_HERMES_TEST_PROFILE_ENV': 'work-runtime', } assert captured['aux_env'] == { 'HERMES_HOME': str(profile_home), - 'HERMES_TEST_PROFILE_ENV': 'work-runtime', + 'HERMES_TEST_PROFILE_ENV': 'default-runtime', + 'THREAD_HERMES_HOME': str(profile_home), + 'THREAD_HERMES_TEST_PROFILE_ENV': 'work-runtime', 'SKILL_MODULE_HOME': profile_home, 'SKILL_MODULE_DIR': profile_home / 'skills', } From 49bea3ad0187f600c622c8fabd665f9a1d345b84 Mon Sep 17 00:00:00 2001 From: Frank Song Date: Sat, 16 May 2026 14:29:58 +0800 Subject: [PATCH 011/138] Clarify interrupted turn recovery marker --- CHANGELOG.md | 4 ++++ api/models.py | 28 ++++++++++++++++------------ tests/test_session_sidecar_repair.py | 11 ++++++++--- 3 files changed, 28 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fa25bab..7adff893 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- **PR TBD** by @franksong2702 (fixes #2370) — Recovered interrupted turns now explain that the WebUI process restarted before the agent finished, that the user message above was preserved, and that no agent output was recovered. Pre-fix, stale pending-turn repair appended the vague `Previous turn did not complete` marker, which looked like an unexplained assistant response after a WebUI restart killed the in-process worker. + ## [v0.51.74] — 2026-05-16 — Release AX (stage-367 — 4-PR safe-lane batch — #2362 table-cell spacing + #2363 run-state-consistency RFC + #2365 custom_providers list-format + #2367 settings sidebar i18n) ### Added diff --git a/api/models.py b/api/models.py index f448f7a3..0f6397c3 100644 --- a/api/models.py +++ b/api/models.py @@ -679,6 +679,20 @@ def _get_profile_home(profile) -> Path: return Path(os.environ.get('HERMES_HOME') or '~/.hermes').expanduser() +def _interrupted_recovery_marker() -> dict: + return { + 'role': 'assistant', + 'content': ( + '**Response interrupted.**\n\n' + 'The WebUI process restarted before this turn finished. ' + 'The user message above was preserved, but no agent output was recovered.' + ), + 'timestamp': int(time.time()), + '_error': True, + 'type': 'interrupted', + } + + def _apply_core_sync_or_error_marker( session, core_path, @@ -745,12 +759,7 @@ def _apply_core_sync_or_error_marker( session.pending_user_message = None session.pending_attachments = [] session.pending_started_at = None - session.messages.append({ - 'role': 'assistant', - 'content': '**Previous turn did not complete.**', - 'timestamp': int(time.time()), - '_error': True, - }) + session.messages.append(_interrupted_recovery_marker()) session.save() logger.info( "Session %s: recovered pending user turn (messages non-empty), added error marker", @@ -794,12 +803,7 @@ def _apply_core_sync_or_error_marker( session.pending_user_message = None session.pending_attachments = [] session.pending_started_at = None - session.messages.append({ - 'role': 'assistant', - 'content': '**Previous turn did not complete.**', - 'timestamp': int(time.time()), - '_error': True, - }) + session.messages.append(_interrupted_recovery_marker()) session.save() logger.info("Session %s: no core transcript found, added error marker", sid) return True diff --git a/tests/test_session_sidecar_repair.py b/tests/test_session_sidecar_repair.py index 4d575125..10a599ba 100644 --- a/tests/test_session_sidecar_repair.py +++ b/tests/test_session_sidecar_repair.py @@ -231,7 +231,7 @@ class TestRepairStalePendingNoDeadlock: class TestDraftRecovery: """When no core transcript exists, the pending user message is restored as a recovered user turn (_recovered=True) and the error marker says - 'Previous turn did not complete.' — NOT 'preserved as a draft'.""" + a clear restart interruption marker — NOT 'preserved as a draft'.""" def test_pending_message_recovered_as_user_turn(self, hermes_home, monkeypatch): """When core transcript is missing, the pending_user_message is appended @@ -310,7 +310,10 @@ class TestDraftRecovery: assert "preserved as a draft" not in content, ( f"Error marker should not say 'preserved as a draft', got: {content}" ) - assert "Previous turn did not complete" in content + assert "Response interrupted" in content + assert "WebUI process restarted" in content + assert "user message above was preserved" in content + assert error_msgs[0].get("type") == "interrupted" def test_pending_attachments_recovered(self, hermes_home, monkeypatch): """Attachments on the pending message are carried over to the recovered turn.""" @@ -604,7 +607,9 @@ class TestNonEmptyMessagesPendingCleared: # Exactly one error marker error_msgs = [m for m in s.messages if m.get("_error")] assert len(error_msgs) == 1 - assert "Previous turn did not complete" in error_msgs[0]["content"] + assert "Response interrupted" in error_msgs[0]["content"] + assert "WebUI process restarted" in error_msgs[0]["content"] + assert error_msgs[0].get("type") == "interrupted" # Pending fields fully cleared assert s.pending_user_message is None From f87e32d0c76a5066b4ecc6ff175e8448fb436fdb Mon Sep 17 00:00:00 2001 From: BonyFish Date: Sat, 16 May 2026 14:57:01 +0800 Subject: [PATCH 012/138] fix: add i18n support to settings conversation page action buttons - Add data-i18n attributes to JSON, Import tooltip, and Clear button - Reuse existing 'clear' and 'import' i18n keys for button labels - Add 'export_session_json', 'export_session_json_tooltip', 'import_session_json_tooltip', 'clear_conversation_btn_tooltip' keys to all 11 locales (en, it, ja, ru, es, de, zh, zh-Hant, pt, ko, fr) --- static/i18n.js | 44 ++++++++++++++++++++++++++++++++++++++++++++ static/index.html | 6 +++--- 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/static/i18n.js b/static/i18n.js index 30c497f1..3ea54532 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -731,6 +731,10 @@ const LOCALES = { transcript: 'Transcript', download_transcript: 'Download as Markdown', import: 'Import', + export_session_json: 'JSON', + export_session_json_tooltip: 'Export full session as JSON', + import_session_json_tooltip: 'Import session from JSON', + clear_conversation_btn_tooltip: 'Clear all messages in this conversation', // Settings detail settings_label_sound: 'Notification sound', settings_desc_sound: 'Play a sound when the assistant finishes a response.', @@ -1913,6 +1917,10 @@ const LOCALES = { transcript: 'Trascrizione', download_transcript: 'Scarica come Markdown', import: 'Importa', + export_session_json: 'JSON', + export_session_json_tooltip: 'Esporta sessione completa come JSON', + import_session_json_tooltip: 'Importa sessione da JSON', + clear_conversation_btn_tooltip: 'Cancella tutti i messaggi in questa conversazione', // Settings detail settings_label_sound: 'Suono notifica', settings_desc_sound: 'Riproduci un suono quando l\'assistente termina una risposta.', @@ -3100,6 +3108,10 @@ const LOCALES = { transcript: 'トランスクリプト', download_transcript: 'Markdown としてダウンロード', import: 'インポート', + export_session_json: 'JSON', + export_session_json_tooltip: 'セッション全体をJSONとしてエクスポート', + import_session_json_tooltip: 'JSONからセッションをインポート', + clear_conversation_btn_tooltip: 'この会話のすべてのメッセージをクリア', // Settings detail settings_label_sound: '通知音', settings_desc_sound: 'アシスタントが応答を完了したときに音を鳴らします。', @@ -4072,6 +4084,10 @@ const LOCALES = { transcript: 'Транскрипт', download_transcript: 'Скачать как Markdown', import: 'Импорт', + export_session_json: 'JSON', + export_session_json_tooltip: 'Экспортировать сессию как JSON', + import_session_json_tooltip: 'Импортировать сессию из JSON', + clear_conversation_btn_tooltip: 'Очистить все сообщения в этой беседе', settings_label_sound: 'Звук уведомления', settings_desc_sound: 'Проигрывать звук, когда помощник завершает ответ.', settings_label_notifications: 'Уведомления браузера', @@ -5193,6 +5209,10 @@ const LOCALES = { transcript: 'Transcripción', download_transcript: 'Descargar como Markdown', import: 'Importar', + export_session_json: 'JSON', + export_session_json_tooltip: 'Exportar sesión completa como JSON', + import_session_json_tooltip: 'Importar sesión desde JSON', + clear_conversation_btn_tooltip: 'Borrar todos los mensajes de esta conversación', // Settings detail settings_label_sound: 'Sonido de notificación', settings_desc_sound: 'Reproduce un sonido cuando el asistente termina una respuesta.', @@ -6287,6 +6307,10 @@ const LOCALES = { transcript: 'Protokoll', download_transcript: 'Als Markdown herunterladen', import: 'Importieren', + export_session_json: 'JSON', + export_session_json_tooltip: 'Gesamte Sitzung als JSON exportieren', + import_session_json_tooltip: 'Sitzung aus JSON importieren', + clear_conversation_btn_tooltip: 'Alle Nachrichten in dieser Konversation löschen', // Settings detail settings_label_sound: 'Benachrichtigungston', settings_desc_sound: 'Spielt einen Ton ab, wenn der Assistent eine Antwort beendet.', @@ -7434,6 +7458,10 @@ const LOCALES = { transcript: '记录', download_transcript: '下载为 Markdown', import: '导入', + export_session_json: 'JSON', + export_session_json_tooltip: '将会话完整导出为 JSON', + import_session_json_tooltip: '从 JSON 导入会话', + clear_conversation_btn_tooltip: '清空此会话中的所有消息', editing: '编辑中', empty_title: '有什么可以帮您?', empty_subtitle: '随时提问、运行命令、浏览文件或管理定时任务。', @@ -8545,6 +8573,10 @@ const LOCALES = { transcript: '\u8a18\u9304', download_transcript: '\u4e0b\u8f09\u8a18\u9304', import: '\u5c0e\u5165', + export_session_json: 'JSON', + export_session_json_tooltip: '\u5c07\u6703\u8a71\u5b8c\u6574\u532f\u51fa\u70ba JSON', + import_session_json_tooltip: '\u5f9e JSON \u532f\u5165\u6703\u8a71', + clear_conversation_btn_tooltip: '\u6e05\u7a7a\u6b64\u6703\u8a71\u4e2d\u7684\u6240\u6709\u8a0a\u606f', editing: '\u7de8\u8f2f\u4e2d', empty_title: '有什麼可以幫忙?', empty_subtitle: '點擊上方按鈕開始對話', @@ -9834,6 +9866,10 @@ const LOCALES = { transcript: 'Transcrição', download_transcript: 'Baixar como Markdown', import: 'Importar', + export_session_json: 'JSON', + export_session_json_tooltip: 'Exportar sessão completa como JSON', + import_session_json_tooltip: 'Importar sessão de JSON', + clear_conversation_btn_tooltip: 'Limpar todas as mensagens nesta conversa', // Settings detail settings_label_sound: 'Som de notificação', settings_desc_sound: 'Tocar som quando assistente finalizar resposta.', @@ -10918,6 +10954,10 @@ const LOCALES = { transcript: '대화 기록', download_transcript: 'Download as Markdown', import: '가져오기', + export_session_json: 'JSON', + export_session_json_tooltip: '전체 세션을 JSON으로 내보내기', + import_session_json_tooltip: 'JSON에서 세션 가져오기', + clear_conversation_btn_tooltip: '이 대화의 모든 메시지 지우기', // Settings detail settings_label_sound: '알림음', settings_desc_sound: 'Assistant 응답이 끝나면 소리를 재생합니다.', @@ -12017,6 +12057,10 @@ const LOCALES = { transcript: 'Transcription', download_transcript: 'Télécharger en Markdown', import: 'Importer', + export_session_json: 'JSON', + export_session_json_tooltip: 'Exporter la session complète en JSON', + import_session_json_tooltip: 'Importer une session depuis JSON', + clear_conversation_btn_tooltip: 'Effacer tous les messages de cette conversation', settings_label_sound: 'Son de notification', settings_desc_sound: 'Jouez un son lorsque l\'assistant termine une réponse.', tts_listen: 'Écouter', diff --git a/static/index.html b/static/index.html index 884ca786..22a3e29d 100644 --- a/static/index.html +++ b/static/index.html @@ -816,9 +816,9 @@
- - - + + +
From 5bd1f1441de62076f42c7c475fe52408809247cc Mon Sep 17 00:00:00 2001 From: Michael Lam Date: Sat, 16 May 2026 01:02:19 -0700 Subject: [PATCH 013/138] fix: route background worker credentials through profile env --- CHANGELOG.md | 2 +- api/profiles.py | 16 ++++++++++++---- tests/test_sprint46.py | 2 +- tests/test_title_aux_routing.py | 18 +++++++++++++----- tests/test_update_banner_fixes.py | 4 ++-- 5 files changed, 29 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index df4a44a6..8abe1cad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Fixed -- Background profile workers now route broad runtime env through thread-local state while setting only `HERMES_HOME` under the narrow process-env lock, preserving `hermes_cli.config.load_config()` compatibility for non-default profiles without leaking profile-specific env keys process-wide. +- Background profile workers now route broad runtime env through thread-local state and mirror it into process env for the worker body, preserving `hermes_cli.config.load_config()` compatibility plus provider credential readers that still call `os.getenv()` directly, then restoring prior env values after the worker exits. ## [v0.51.74] — 2026-05-16 — Release AX (stage-367 — 4-PR safe-lane batch — #2362 table-cell spacing + #2363 run-state-consistency RFC + #2365 custom_providers list-format + #2367 settings sidebar i18n) diff --git a/api/profiles.py b/api/profiles.py index e637dcb7..763654e9 100644 --- a/api/profiles.py +++ b/api/profiles.py @@ -716,20 +716,23 @@ def profile_env_for_background_worker( thread_env = dict(runtime_env) thread_env["HERMES_HOME"] = str(profile_home_path) # Hybrid profile routing: keep the broad runtime env in WebUI's thread-local - # channel so provider/API-key overrides do not leak through process-global - # os.environ, but still set HERMES_HOME under the narrow _ENV_LOCK because - # hermes_cli.config.load_config() currently resolves homes from os.environ. - # Do not hold _ENV_LOCK across the worker body. + # channel for WebUI helpers, and also mirror it into process env for the + # worker body because several production Hermes readers still call + # os.getenv() directly for provider credentials. Keep the _ENV_LOCK scope + # narrow: serialize only setup/restore, not the whole worker body. skill_home_snapshot = None + old_runtime_env: dict[str, Optional[str]] = {} old_hermes_home = None had_hermes_home = False previous_thread_env = getattr(_thread_ctx, "env", {}).copy() try: _set_thread_env(**thread_env) with _ENV_LOCK: + old_runtime_env = {key: os.environ.get(key) for key in runtime_env} had_hermes_home = "HERMES_HOME" in os.environ old_hermes_home = os.environ.get("HERMES_HOME") skill_home_snapshot = snapshot_skill_home_modules() + os.environ.update(runtime_env) os.environ["HERMES_HOME"] = str(profile_home_path) try: patch_skill_home_modules(profile_home_path) @@ -747,6 +750,11 @@ def profile_env_for_background_worker( else: _clear_thread_env() with _ENV_LOCK: + for key, old_value in old_runtime_env.items(): + if old_value is None: + os.environ.pop(key, None) + else: + os.environ[key] = old_value if had_hermes_home: os.environ["HERMES_HOME"] = old_hermes_home or "" else: diff --git a/tests/test_sprint46.py b/tests/test_sprint46.py index cffe73e2..1b0856a5 100644 --- a/tests/test_sprint46.py +++ b/tests/test_sprint46.py @@ -465,7 +465,7 @@ def test_manual_compress_worker_uses_session_profile_env(monkeypatch, tmp_path, assert EnvAssertingAgent.seen_env == { "HERMES_HOME": str(profile_home), - "HERMES_TEST_PROFILE_ENV": None, + "HERMES_TEST_PROFILE_ENV": "work-runtime", "THREAD_HERMES_HOME": str(profile_home), "THREAD_HERMES_TEST_PROFILE_ENV": "work-runtime", "SKILL_MODULE_HOME": profile_home, diff --git a/tests/test_title_aux_routing.py b/tests/test_title_aux_routing.py index 0acffbbb..c9a1552d 100644 --- a/tests/test_title_aux_routing.py +++ b/tests/test_title_aux_routing.py @@ -502,8 +502,8 @@ class TestBackgroundTitleProfileRouting(unittest.TestCase): self.assertEqual(getattr(fake_skill_module, 'SKILLS_DIR'), 'default-home/skills') self.assertEqual(mock_session.title, 'Profile Routed Title') - def test_background_profile_env_routes_load_config_without_process_env_leak(self): - """Hybrid worker env must satisfy hermes_cli.load_config without leaking profile env keys.""" + def test_background_profile_env_routes_load_config_and_provider_credentials(self): + """Hybrid worker env must satisfy config and os.getenv provider-key readers.""" import tempfile import pytest @@ -529,8 +529,12 @@ class TestBackgroundTitleProfileRouting(unittest.TestCase): f.write('model:\n provider: profile-provider\n default: profile-model\n') with patch('api.profiles.get_hermes_home_for_profile', return_value=profile_home): - with patch('api.profiles.get_profile_runtime_env', return_value={'PROFILE_ONLY_KEY': 'profile-only'}): - with patch.dict(os.environ, {'HERMES_HOME': default_home}, clear=False): + runtime_env = { + 'PROFILE_ONLY_KEY': 'profile-only', + 'OPENROUTER_API_KEY': 'profile-openrouter-key', + } + with patch('api.profiles.get_profile_runtime_env', return_value=runtime_env): + with patch.dict(os.environ, {'HERMES_HOME': default_home, 'OPENROUTER_API_KEY': 'default-openrouter-key'}, clear=False): os.environ.pop('PROFILE_ONLY_KEY', None) hermes_config._LOAD_CONFIG_CACHE.clear() with profiles.profile_env_for_background_worker(session, 'background title'): @@ -538,19 +542,23 @@ class TestBackgroundTitleProfileRouting(unittest.TestCase): captured['loaded_provider'] = loaded.get('model', {}).get('provider') captured['process_home'] = os.environ.get('HERMES_HOME') captured['process_runtime_key'] = os.environ.get('PROFILE_ONLY_KEY') + captured['provider_credential'] = os.getenv('OPENROUTER_API_KEY') captured['thread_home'] = getattr(_thread_ctx, 'env', {}).get('HERMES_HOME') captured['thread_runtime_key'] = getattr(_thread_ctx, 'env', {}).get('PROFILE_ONLY_KEY') captured['restored_home'] = os.environ.get('HERMES_HOME') captured['restored_runtime_key'] = os.environ.get('PROFILE_ONLY_KEY') + captured['restored_provider_credential'] = os.environ.get('OPENROUTER_API_KEY') hermes_config._LOAD_CONFIG_CACHE.clear() self.assertEqual(captured['loaded_provider'], 'profile-provider') self.assertEqual(captured['process_home'], profile_home) - self.assertIsNone(captured['process_runtime_key']) + self.assertEqual(captured['process_runtime_key'], 'profile-only') + self.assertEqual(captured['provider_credential'], 'profile-openrouter-key') self.assertEqual(captured['thread_home'], profile_home) self.assertEqual(captured['thread_runtime_key'], 'profile-only') self.assertEqual(captured['restored_home'], default_home) self.assertIsNone(captured['restored_runtime_key']) + self.assertEqual(captured['restored_provider_credential'], 'default-openrouter-key') class TestAuxTitleTimeoutEdgeCases(unittest.TestCase): diff --git a/tests/test_update_banner_fixes.py b/tests/test_update_banner_fixes.py index 210a1806..1750668c 100644 --- a/tests/test_update_banner_fixes.py +++ b/tests/test_update_banner_fixes.py @@ -569,13 +569,13 @@ class TestUpdateSummaryRouteModelSelection: assert captured['aux_task'] == 'compression' assert captured['model_resolution_env'] == { 'HERMES_HOME': str(profile_home), - 'HERMES_TEST_PROFILE_ENV': 'default-runtime', + 'HERMES_TEST_PROFILE_ENV': 'work-runtime', 'THREAD_HERMES_HOME': str(profile_home), 'THREAD_HERMES_TEST_PROFILE_ENV': 'work-runtime', } assert captured['aux_env'] == { 'HERMES_HOME': str(profile_home), - 'HERMES_TEST_PROFILE_ENV': 'default-runtime', + 'HERMES_TEST_PROFILE_ENV': 'work-runtime', 'THREAD_HERMES_HOME': str(profile_home), 'THREAD_HERMES_TEST_PROFILE_ENV': 'work-runtime', 'SKILL_MODULE_HOME': profile_home, From 3cbe206832047389cf243c57ef788bc0931fcbbd Mon Sep 17 00:00:00 2001 From: Michael Lam Date: Sat, 16 May 2026 02:12:11 -0700 Subject: [PATCH 014/138] fix: keep markdown tables block-level --- CHANGELOG.md | 4 ++++ static/ui.js | 7 +++++-- tests/test_renderer_js_behaviour.py | 31 +++++++++++++++++++++++++++++ 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fa25bab..ae569eec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- **PR #2375** by @Michaelyklam (closes #2374) — Markdown tables rendered by chat messages now stay as block-level `` elements instead of being wrapped in paragraph tags by the renderer's final paragraph pass. This keeps CommonMark-style pipe tables visible as tables across browsers. + ## [v0.51.74] — 2026-05-16 — Release AX (stage-367 — 4-PR safe-lane batch — #2362 table-cell spacing + #2363 run-state-consistency RFC + #2365 custom_providers list-format + #2367 settings sidebar i18n) ### Added diff --git a/static/ui.js b/static/ui.js index c050811c..253dd86a 100644 --- a/static/ui.js +++ b/static/ui.js @@ -2614,7 +2614,10 @@ function renderMd(raw){ const parseHeader=r=>r.trim().replace(/^\|/,'').replace(/\|$/,'').split('|').map(c=>``).join(''); const header=`${parseHeader(rows[0])}`; const body=rows.slice(2).map(r=>`${parseRow(r)}`).join(''); - return `
${inlineMd(c.trim())}
${header}${body}
`; + // Surround with blank lines so the final paragraph splitter treats the + // generated table as its own block even when the regex consumes one of the + // markdown block's trailing newlines. + return `\n\n${header}${body}
\n\n`; }); // #487: Outer image pass — handles ![alt](url) in plain paragraphs (outside tables/lists). // Runs AFTER the table pass (images in table cells are handled by inlineMd() above). @@ -2757,7 +2760,7 @@ function renderMd(raw){ return '\x00E'+(_pre_stash.length-1)+'\x00'; }); const parts=s.split(/\n{2,}/); - s=parts.map(p=>{p=p.trim();if(!p)return '';if(/^<(h[1-6]|ul|ol|pre|hr|blockquote)|^\x00[EQ]/.test(p))return p;return `

${p.replace(/\n/g,'
')}

`;}).join('\n'); + s=parts.map(p=>{p=p.trim();if(!p)return '';if(/^<(h[1-6]|ul|ol|table|pre|hr|blockquote)|^\x00[EQ]/.test(p))return p;return `

${p.replace(/\n/g,'
')}

`;}).join('\n'); s=s.replace(/\x00E(\d+)\x00/g,(_,i)=>_pre_stash[+i]); // ── Restore MEDIA stash → inline images or download links ───────────────── s=s.replace(/\x00D(\d+)\x00/g,(_,i)=>{ diff --git a/tests/test_renderer_js_behaviour.py b/tests/test_renderer_js_behaviour.py index 22a831b7..509b1d72 100644 --- a/tests/test_renderer_js_behaviour.py +++ b/tests/test_renderer_js_behaviour.py @@ -187,6 +187,37 @@ class TestRendererSanitization: class TestCommonLLMShapes: + def test_commonmark_table_is_not_wrapped_in_paragraph(self, driver_path): + src = ( + "| 升级时段 | 人数 |\n" + "|---------|------|\n" + "| 5/15(发布当天) | ~30 人 |\n" + "| 5/16(今天) | ~10 人 |" + ) + out = _render(driver_path, src) + assert "" in out + assert "" in out + assert "" in out + assert "" in out + assert "

Before the table.

" in out + assert "
升级时段5/15(发布当天)~10 人
" in out + assert "

After the table.

" in out + assert "

" not in out + def test_strikethrough_outside_quote(self, driver_path): out = _render(driver_path, "This was ~~outdated~~ but is now fine.") assert "outdated" in out From 574ee36460d5563c4394aa93492e2b7121464385 Mon Sep 17 00:00:00 2001 From: Frank Song Date: Sat, 16 May 2026 17:52:35 +0800 Subject: [PATCH 015/138] Add run journal replay timeline parity checks --- CHANGELOG.md | 4 ++ .../webui-run-state-consistency-contract.md | 8 ++- tests/test_run_journal_frontend_static.py | 57 +++++++++++++++++++ 3 files changed, 67 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fa25bab..79d0f0f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- **PR TBD** by @franksong2702 (refs #2376, refs #1925, refs #2283, refs #2361, refs #2363) — Adds a focused run-journal replay parity check for long tool-heavy turns. The frontend regression coverage now pins that replayed `reasoning`, `interim_assistant`, `tool`, `tool_complete`, `compressing`, `compressed`, `metering`, and terminal events enter the same EventSource timeline handlers as live streaming, and that each user-visible long-task event advances the replay cursor to avoid duplicate replay. The run-state consistency RFC now calls out live/replay timeline parity explicitly. + ## [v0.51.74] — 2026-05-16 — Release AX (stage-367 — 4-PR safe-lane batch — #2362 table-cell spacing + #2363 run-state-consistency RFC + #2365 custom_providers list-format + #2367 settings sidebar i18n) ### Added diff --git a/docs/rfcs/webui-run-state-consistency-contract.md b/docs/rfcs/webui-run-state-consistency-contract.md index 96d9ff3b..b3329a25 100644 --- a/docs/rfcs/webui-run-state-consistency-contract.md +++ b/docs/rfcs/webui-run-state-consistency-contract.md @@ -78,7 +78,10 @@ while WebUI still has multiple overlapping state stores. assistant just acted. 5. **Replay is idempotent.** Replaying a run from a cursor must not duplicate transcript rows, thinking content, interim assistant text, tool cards, or - compression cards. + compression cards. Replayed long-task events should enter the same + browser-facing timeline renderer as live SSE events so recovery does not + downgrade a structured Thinking / progress / tool / compression turn into a + separate flattened presentation. 6. **Compression is not current intent.** Automatic compression summaries and reference cards are recovery/handoff material. They must not be treated as a new user request, active-turn content, or the default visible explanation for @@ -102,6 +105,8 @@ context reconstruction, or session metadata: - What happens after browser refresh, session switch, SSE reconnect, and WebUI restart? - Does replay rebuild the same scene without duplicates? +- Does replay use the same timeline-rendering path as live SSE for thinking, + interim assistant text, tool cards, compression cards, and terminal states? - Can this change move a session in the sidebar without meaningful user or assistant activity? - Can automatic compression or recovery text become visible active-turn content? @@ -147,4 +152,3 @@ The two documents should be read together: 4. If #1925 introduces a new adapter-backed runtime layer, update this RFC or replace it with the accepted implementation contract so these invariants do not live only in historical discussion. - diff --git a/tests/test_run_journal_frontend_static.py b/tests/test_run_journal_frontend_static.py index aaee3048..02d1b1e5 100644 --- a/tests/test_run_journal_frontend_static.py +++ b/tests/test_run_journal_frontend_static.py @@ -35,3 +35,60 @@ def test_frontend_replay_cursor_uses_eventsource_last_event_id(): assert "source.addEventListener(_runJournalEventName,_rememberRunJournalCursor)" in MESSAGES_SRC assert "after_seq=${encodeURIComponent(String(_runJournalReplayAfterSeq()))}" in MESSAGES_SRC assert "after_seq=0" not in MESSAGES_SRC + + +def test_replayed_long_task_events_enter_the_same_live_timeline_handlers(): + """Run-journal replay must not grow a parallel long-task renderer. + + The run-state consistency contract depends on replayed journal events + flowing through the same EventSource handlers as live streams. Otherwise a + live long task can render as Thinking -> progress text -> tool cards, while + the same journaled event sequence replays as a flattened or reordered scene. + """ + wire_pos = MESSAGES_SRC.index("function _wireSSE(source)") + wire_block = MESSAGES_SRC[wire_pos : MESSAGES_SRC.index("async function _restoreSettledSession", wire_pos)] + replay_events = [ + "reasoning", + "interim_assistant", + "tool", + "tool_complete", + "compressing", + "compressed", + "metering", + "done", + "apperror", + ] + + for event_name in replay_events: + assert f"source.addEventListener('{event_name}'" in wire_block, ( + f"{event_name} must be handled by the shared live/replay SSE pipeline" + ) + + assert "updateThinking(" in wire_block, "reasoning replay should use the live Thinking card path" + assert "appendLiveToolCard(tc)" in wire_block, "tool replay should use live tool-card rendering" + assert "setCompressionUi({" in wire_block, "compression replay should use the compression card path" + assert "_runJournalReplayParams()" in MESSAGES_SRC, "replay attachments should enter _wireSSE via EventSource" + + +def test_run_journal_cursor_tracks_every_long_task_timeline_event(): + """Every user-visible long-task event needs cursor tracking for parity replay.""" + cursor_loop_pos = MESSAGES_SRC.index("for(const _runJournalEventName of [") + cursor_loop = MESSAGES_SRC[cursor_loop_pos : cursor_loop_pos + 700] + timeline_events = [ + "token", + "interim_assistant", + "reasoning", + "tool", + "tool_complete", + "compressing", + "compressed", + "metering", + "done", + "apperror", + "cancel", + ] + + for event_name in timeline_events: + assert f"'{event_name}'" in cursor_loop, ( + f"{event_name} must advance the replay cursor to avoid duplicate timeline replay" + ) From 962b3840e6604b71b2c054a7db325caa218bc63f Mon Sep 17 00:00:00 2001 From: Michael Lam Date: Sat, 16 May 2026 03:54:28 -0700 Subject: [PATCH 016/138] fix: strip historical images in text mode --- CHANGELOG.md | 4 +++ api/streaming.py | 42 +++++++++++++++++++++++--- tests/test_native_image_attachments.py | 36 ++++++++++++++++++++++ 3 files changed, 78 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fa25bab..3e582476 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- **PR #2378** by @Michaelyklam (closes #2297) — Text-mode image handling now strips historical native `image_url` parts from provider-facing conversation replay. Current-turn uploads already respected `agent.image_input_mode: text`; this closes the remaining gap where an older image in the saved transcript could keep making text-only providers such as DeepSeek reject every later turn with `unknown variant image_url, expected text`. + ## [v0.51.74] — 2026-05-16 — Release AX (stage-367 — 4-PR safe-lane batch — #2362 table-cell spacing + #2363 run-state-consistency RFC + #2365 custom_providers list-format + #2367 settings sidebar i18n) ### Added diff --git a/api/streaming.py b/api/streaming.py index 18a32fc2..7ef8dc4c 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -1831,7 +1831,32 @@ def _maybe_schedule_title_refresh(session, put_event, agent): ).start() -def _sanitize_messages_for_api(messages): +def _strip_native_image_parts_from_content(content): + """Return provider-safe content with native image parts removed. + + Text-only provider endpoints (for example DeepSeek/OpenAI-compatible text + models) reject historical OpenAI-style ``image_url`` parts before the agent + can recover. When WebUI is configured for text-mode image handling, preserve + textual content from mixed content arrays and drop only the native image + blocks from replayed history. + """ + if not isinstance(content, list): + return content + clean_parts = [] + for part in content: + if not isinstance(part, dict): + continue + if part.get('type') == 'image_url' or 'image_url' in part: + continue + clean_parts.append(copy.deepcopy(part)) + if not clean_parts: + return '' + if len(clean_parts) == 1 and clean_parts[0].get('type') == 'text': + return str(clean_parts[0].get('text') or '') + return clean_parts + + +def _sanitize_messages_for_api(messages, *, cfg: dict = None): """Return a deep copy of messages with only API-safe fields. The webui stores extra metadata on messages (attachments, timestamp, _ts) @@ -1843,7 +1868,14 @@ def _sanitize_messages_for_api(messages): (Mercury-2/Inception, newer OpenAI models) reject histories containing dangling tool results with a 400 error: "Message has tool role, but there was no previous assistant message with a tool call." + + If ``agent.image_input_mode`` resolves to ``text``, native historical + ``image_url`` content parts are stripped too. Current-turn uploads already + respect text mode in ``_build_native_multimodal_message``; this closes the + remaining replay gap where an older native image in the saved transcript kept + causing 400s on every later text-only turn (#2297). """ + strip_native_images = cfg is not None and _resolve_image_input_mode(cfg) == "text" # First pass: collect all tool_call_ids declared by assistant messages. # Handles both OpenAI ('id') and Anthropic ('call_id') field names. valid_tool_call_ids: set = set() @@ -1872,6 +1904,8 @@ def _sanitize_messages_for_api(messages): # Orphaned tool result — skip to avoid 400 from strict providers. continue sanitized = {k: v for k, v in msg.items() if k in _API_SAFE_MSG_KEYS} + if strip_native_images and 'content' in sanitized: + sanitized['content'] = _strip_native_image_parts_from_content(sanitized.get('content')) if sanitized.get('role'): clean.append(sanitized) return clean @@ -3515,7 +3549,7 @@ def _run_agent_streaming( result = agent.run_conversation( user_message=user_message, system_message=workspace_system_msg, - conversation_history=_sanitize_messages_for_api(_previous_context_messages), + conversation_history=_sanitize_messages_for_api(_previous_context_messages, cfg=_cfg), task_id=session_id, persist_user_message=msg_text, ) @@ -3726,7 +3760,7 @@ def _run_agent_streaming( _heal_result = agent.run_conversation( user_message=user_message, system_message=workspace_system_msg, - conversation_history=_sanitize_messages_for_api(_previous_context_messages), + conversation_history=_sanitize_messages_for_api(_previous_context_messages, cfg=_cfg), task_id=session_id, persist_user_message=msg_text, ) @@ -4505,7 +4539,7 @@ def _run_agent_streaming( _heal_result = _heal_agent.run_conversation( user_message=user_message, system_message=workspace_system_msg, - conversation_history=_sanitize_messages_for_api(_previous_context_messages), + conversation_history=_sanitize_messages_for_api(_previous_context_messages, cfg=_cfg), task_id=session_id, persist_user_message=msg_text, ) diff --git a/tests/test_native_image_attachments.py b/tests/test_native_image_attachments.py index f6b04166..8c38aea5 100644 --- a/tests/test_native_image_attachments.py +++ b/tests/test_native_image_attachments.py @@ -17,6 +17,7 @@ from api.streaming import ( _attachment_name, _build_native_multimodal_message, _NATIVE_IMAGE_MAX_BYTES, + _sanitize_messages_for_api, ) from api.routes import _normalize_chat_attachments @@ -318,6 +319,41 @@ class TestBuildNativeMultimodalMessage: assert data_url.startswith('data:image/png;base64,') assert len(result) == 2 + def test_text_image_mode_strips_historical_image_url_parts(self): + """#2297: text-only providers must not replay old native image parts.""" + history = [ + { + 'role': 'user', + 'content': [ + {'type': 'text', 'text': 'what is in this image?'}, + {'type': 'image_url', 'image_url': {'url': 'data:image/png;base64,AAA='}}, + ], + 'attachments': [{'name': 'photo.png'}], + 'timestamp': 123, + }, + {'role': 'assistant', 'content': 'It is a chart.'}, + ] + cfg = {'agent': {'image_input_mode': 'text'}} + + sanitized = _sanitize_messages_for_api(history, cfg=cfg) + + assert sanitized[0] == {'role': 'user', 'content': 'what is in this image?'} + assert 'image_url' not in str(sanitized) + assert 'attachments' not in sanitized[0] + assert sanitized[1] == {'role': 'assistant', 'content': 'It is a chart.'} + + def test_native_image_mode_keeps_historical_image_url_parts(self): + """Vision-capable/native mode keeps existing multimodal history intact.""" + content = [ + {'type': 'text', 'text': 'describe'}, + {'type': 'image_url', 'image_url': {'url': 'data:image/png;base64,AAA='}}, + ] + cfg = {'agent': {'image_input_mode': 'native'}} + + sanitized = _sanitize_messages_for_api([{'role': 'user', 'content': content}], cfg=cfg) + + assert sanitized == [{'role': 'user', 'content': content}] + def test_fake_png_rejected_by_magic_bytes(self): """A file named .png that is not actually an image must be rejected.""" with TemporaryDirectory() as d: From 2284d42695311213b149d0eeda023f3966d007b8 Mon Sep 17 00:00:00 2001 From: Michael Lam Date: Sat, 16 May 2026 04:31:16 -0700 Subject: [PATCH 017/138] fix: interpolate German session time labels --- CHANGELOG.md | 4 ++++ static/i18n.js | 6 +++--- tests/test_session_sidebar_relative_time.py | 9 +++++++++ 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fa25bab..6434724c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- **PR #2381** by @Michaelyklam (fixes #2379) — German relative session-time labels now interpolate the elapsed value instead of rendering the literal `{n}` placeholder in the sidebar/header. The German locale now uses function-valued translations for minutes, hours, and days, matching the other locale bundles. + ## [v0.51.74] — 2026-05-16 — Release AX (stage-367 — 4-PR safe-lane batch — #2362 table-cell spacing + #2363 run-state-consistency RFC + #2365 custom_providers list-format + #2367 settings sidebar i18n) ### Added diff --git a/static/i18n.js b/static/i18n.js index 30c497f1..71860fd6 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -6581,9 +6581,9 @@ const LOCALES = { session_toolsets_cleared: 'Toolsets cleared — using global config', // TODO: translate session_toolsets_failed: 'Failed to update toolsets: ', // TODO: translate session_time_unknown: 'Unbekannt', - session_time_minutes_ago: 'Vor {n} Minuten', - session_time_hours_ago: 'Vor {n} Stunden', - session_time_days_ago: 'Vor {n} Tagen', + session_time_minutes_ago: (n) => `Vor ${n} Minuten`, + session_time_hours_ago: (n) => `Vor ${n} Stunden`, + session_time_days_ago: (n) => `Vor ${n} Tagen`, session_time_last_week: 'Letzte Woche', session_time_bucket_today: 'Heute', session_time_bucket_yesterday: 'Gestern', diff --git a/tests/test_session_sidebar_relative_time.py b/tests/test_session_sidebar_relative_time.py index 5aa95be8..84697dec 100644 --- a/tests/test_session_sidebar_relative_time.py +++ b/tests/test_session_sidebar_relative_time.py @@ -169,3 +169,12 @@ def test_relative_time_strings_are_localized_in_english_and_spanish_bundles(): "session_time_bucket_older", ): assert key in I18N_JS + + +def test_german_relative_time_translations_interpolate_numbers(): + assert "session_time_minutes_ago: (n) => `Vor ${n} Minuten`" in I18N_JS + assert "session_time_hours_ago: (n) => `Vor ${n} Stunden`" in I18N_JS + assert "session_time_days_ago: (n) => `Vor ${n} Tagen`" in I18N_JS + assert "session_time_minutes_ago: 'Vor {n} Minuten'" not in I18N_JS + assert "session_time_hours_ago: 'Vor {n} Stunden'" not in I18N_JS + assert "session_time_days_ago: 'Vor {n} Tagen'" not in I18N_JS From 11796fe7a87a187e54501f4e5d794dda18b41c3b Mon Sep 17 00:00:00 2001 From: Frank Song Date: Sat, 16 May 2026 20:05:19 +0800 Subject: [PATCH 018/138] Avoid magic cursor-loop test window --- tests/test_run_journal_frontend_static.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_run_journal_frontend_static.py b/tests/test_run_journal_frontend_static.py index 02d1b1e5..773b6618 100644 --- a/tests/test_run_journal_frontend_static.py +++ b/tests/test_run_journal_frontend_static.py @@ -73,7 +73,7 @@ def test_replayed_long_task_events_enter_the_same_live_timeline_handlers(): def test_run_journal_cursor_tracks_every_long_task_timeline_event(): """Every user-visible long-task event needs cursor tracking for parity replay.""" cursor_loop_pos = MESSAGES_SRC.index("for(const _runJournalEventName of [") - cursor_loop = MESSAGES_SRC[cursor_loop_pos : cursor_loop_pos + 700] + cursor_loop = MESSAGES_SRC[cursor_loop_pos : MESSAGES_SRC.index("]", cursor_loop_pos)] timeline_events = [ "token", "interim_assistant", From c415c843dfd77ddb859838d5f30ada1dcc6958c7 Mon Sep 17 00:00:00 2001 From: Frank Song Date: Sat, 16 May 2026 20:05:47 +0800 Subject: [PATCH 019/138] Update interrupted recovery comment wording --- api/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/models.py b/api/models.py index 0f6397c3..e0e52004 100644 --- a/api/models.py +++ b/api/models.py @@ -815,7 +815,7 @@ def _apply_core_sync_or_error_marker( # pending_user_message and STREAMS.pop(stream_id). Without this guard, any # fast turn (e.g. command approval) that exits the thread before the on-disk # pending clear has flushed gets misdiagnosed as a crashed turn, producing a -# spurious "Previous turn did not complete." marker. +# spurious "Response interrupted." marker. # # 30s covers the worst-case post-loop persistence window: LLM finishing a tool # batch + lock contention with the checkpoint thread + a multi-MB session.save. From e4dad1c25de1174fb2eeee89abd2e96235c6d531 Mon Sep 17 00:00:00 2001 From: Michael Lam Date: Sat, 16 May 2026 05:08:02 -0700 Subject: [PATCH 020/138] fix: serve raw chat attachments from inbox --- CHANGELOG.md | 4 ++++ api/routes.py | 28 ++++++++++++++++++++++++++-- tests/test_sprint2.py | 2 +- tests/test_sprint6.py | 33 ++++++++++++++++++++++++++++++++- 4 files changed, 63 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fa25bab..2e26fa92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- **PR #2382** by @Michaelyklam (closes #2380) — `api/file/raw` now falls back to the requesting session's attachment inbox when a chat-upload filename is not present in the workspace. This keeps existing `api/file/raw?session_id=...&path=` image URLs working after uploads moved under `~/.hermes/webui/attachments//`, while preserving traversal protection and cross-session isolation. + ## [v0.51.74] — 2026-05-16 — Release AX (stage-367 — 4-PR safe-lane batch — #2362 table-cell spacing + #2363 run-state-consistency RFC + #2365 custom_providers list-format + #2367 settings sidebar i18n) ### Added diff --git a/api/routes.py b/api/routes.py index 3ad444e9..d6697058 100644 --- a/api/routes.py +++ b/api/routes.py @@ -6360,10 +6360,34 @@ def _handle_media(handler, parsed): or html_inline_ok ) ) else "attachment" + # _serve_file_bytes sends Content-Security-Policy when csp is set. csp = "sandbox allow-scripts" if html_inline_ok else None return _serve_file_bytes(handler, target, mime, disposition, "private, max-age=3600", csp=csp) +def _file_raw_target(session, sid: str, rel: str) -> Path | None: + """Resolve /api/file/raw paths from the workspace or this session's uploads.""" + try: + target = safe_resolve(Path(session.workspace), rel) + except ValueError: + target = None + if target and target.exists() and target.is_file(): + return target + + # Chat uploads now live in a per-session attachment inbox outside the + # workspace. Keep the public URL stable while scoping fallback lookup to + # the requesting session's own attachment directory. + try: + from api.upload import _session_attachment_dir + + attachment_target = safe_resolve(_session_attachment_dir(sid), rel) + except Exception: + return None + if attachment_target.exists() and attachment_target.is_file(): + return attachment_target + return None + + def _handle_file_raw(handler, parsed): qs = parse_qs(parsed.query) sid = qs.get("session_id", [""])[0] @@ -6375,8 +6399,8 @@ def _handle_file_raw(handler, parsed): return bad(handler, "Session not found", 404) rel = qs.get("path", [""])[0] force_download = qs.get("download", [""])[0] == "1" - target = safe_resolve(Path(s.workspace), rel) - if not target.exists() or not target.is_file(): + target = _file_raw_target(s, sid, rel) + if target is None: return j(handler, {"error": "not found"}, status=404) ext = target.suffix.lower() mime = MIME_MAP.get(ext, "application/octet-stream") diff --git a/tests/test_sprint2.py b/tests/test_sprint2.py index aa5c5f2a..efb2085b 100644 --- a/tests/test_sprint2.py +++ b/tests/test_sprint2.py @@ -67,7 +67,7 @@ def test_raw_endpoint_path_traversal_blocked(cleanup_test_sessions): get_raw(f"/api/file/raw?session_id={sid}&path=../../etc/passwd") assert False except urllib.error.HTTPError as e: - assert e.code in (400, 500) + assert e.code in (400, 404, 500) def test_raw_endpoint_missing_file_returns_404(cleanup_test_sessions): sid, _ = make_session_tracked(cleanup_test_sessions) diff --git a/tests/test_sprint6.py b/tests/test_sprint6.py index 6c2ba1df..fcfbbe1e 100644 --- a/tests/test_sprint6.py +++ b/tests/test_sprint6.py @@ -1,5 +1,5 @@ """Sprint 6 tests: Escape from editor, Phase D validation, HTML extraction, cron create, session export.""" -import json, uuid, pathlib, urllib.request, urllib.error +import json, uuid, pathlib, urllib.parse, urllib.request, urllib.error REPO_ROOT = pathlib.Path(__file__).parent.parent.resolve() from tests._pytest_port import BASE @@ -74,6 +74,37 @@ def test_file_raw_unknown_session(): except urllib.error.HTTPError as e: assert e.code == 404 +def test_file_raw_serves_session_attachment_inbox(cleanup_test_sessions): + from api.upload import _session_attachment_dir + + sid, workspace = make_session_tracked(cleanup_test_sessions) + filename = f"uploaded-chat-image-{uuid.uuid4().hex}.png" + attachment_dir = _session_attachment_dir(sid) + attachment_dir.mkdir(parents=True, exist_ok=True) + payload = b"fake-png-bytes" + (attachment_dir / filename).write_bytes(payload) + + assert not (workspace / filename).exists(), "regression must exercise attachment fallback" + raw, headers, status = get_raw( + f"/api/file/raw?session_id={sid}&path={urllib.parse.quote(filename)}" + ) + assert status == 200 + assert raw == payload + assert "image/png" in headers.get("Content-Type", "") + +def test_file_raw_attachment_fallback_rejects_traversal(cleanup_test_sessions): + from api.upload import _session_attachment_dir + + sid, _ = make_session_tracked(cleanup_test_sessions) + attachment_dir = _session_attachment_dir(sid) + attachment_dir.mkdir(parents=True, exist_ok=True) + (attachment_dir / "safe.txt").write_text("safe", encoding="utf-8") + try: + get_raw(f"/api/file/raw?session_id={sid}&path={urllib.parse.quote('../../safe.txt')}") + assert False, "Expected 404" + except urllib.error.HTTPError as e: + assert e.code == 404 + # ── Cron create ── def test_cron_create_requires_prompt(): From 4899ae17b9dcf8296f0b4fc142c44a455bc8593b Mon Sep 17 00:00:00 2001 From: Frank Song Date: Sat, 16 May 2026 20:58:44 +0800 Subject: [PATCH 021/138] Keep fuller compression snapshots reachable --- CHANGELOG.md | 4 ++ api/models.py | 109 +++++++++++++++++++++++++++++++++++- tests/test_session_index.py | 38 +++++++++++++ 3 files changed, 150 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fa25bab..bd77735c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Sidebar session lists now keep the fuller transcript reachable when an auto-compression snapshot has more messages than the visible continuation segment. Ordinary pre-compression snapshots remain hidden, but if hiding the snapshot would leave the user on a shorter segment, the sidebar prefers the fuller row so recent conversation content does not appear to vanish. + ## [v0.51.74] — 2026-05-16 — Release AX (stage-367 — 4-PR safe-lane batch — #2362 table-cell spacing + #2363 run-state-consistency RFC + #2365 custom_providers list-format + #2367 settings sidebar i18n) ### Added diff --git a/api/models.py b/api/models.py index f448f7a3..fc85c75b 100644 --- a/api/models.py +++ b/api/models.py @@ -1012,7 +1012,110 @@ def _hide_from_default_sidebar(session: dict) -> bool: """Return True for internal/background sessions hidden from the default list.""" sid = str(session.get('session_id') or '') source = session.get('source_tag') or session.get('source') - return bool(session.get('pre_compression_snapshot')) or source == 'cron' or sid.startswith('cron_') + if source == 'cron' or sid.startswith('cron_'): + return True + if bool(session.get('pre_compression_snapshot')): + return not bool(session.get('_show_pre_compression_snapshot')) + return False + + +def _sidebar_message_count(session: dict) -> int: + for key in ('message_count', 'actual_message_count'): + try: + value = int(session.get(key) or 0) + except (TypeError, ValueError): + value = 0 + if value > 0: + return value + return 0 + + +def _sidebar_lineage_root_id(session: dict, sessions_by_id: dict[str, dict]) -> str: + sid = str(session.get('session_id') or '') + root = sid + parent = session.get('parent_session_id') + seen = {sid} + while parent and parent not in seen and parent in sessions_by_id: + root = str(parent) + seen.add(root) + parent = sessions_by_id.get(root, {}).get('parent_session_id') + return root + + +def _has_live_sidebar_state(session: dict) -> bool: + return bool( + session.get('active_stream_id') + or session.get('has_pending_user_message') + or session.get('pending_user_message') + ) + + +def _prefer_fuller_snapshots_for_sidebar(sessions: list[dict]) -> list[dict]: + """Expose a hidden snapshot when it is the fuller transcript for a lineage. + + Pre-compression snapshots are normally hidden so archived compression + segments do not duplicate the current continuation in the sidebar. If a + snapshot row has more messages than the visible continuation for the same + lineage, hiding it makes the conversation look truncated. In that case, + show the fuller snapshot and suppress the shorter inactive continuation. + """ + sessions_by_id = { + str(session.get('session_id')): session + for session in sessions + if session.get('session_id') + } + groups: dict[str, list[dict]] = {} + for session in sessions: + sid = str(session.get('session_id') or '') + source = session.get('source_tag') or session.get('source') + if source == 'cron' or sid.startswith('cron_'): + continue + root = _sidebar_lineage_root_id(session, sessions_by_id) + groups.setdefault(root, []).append(session) + + snapshot_ids_to_show: set[str] = set() + continuation_ids_to_hide: set[str] = set() + for group in groups.values(): + visible = [session for session in group if not session.get('pre_compression_snapshot')] + snapshots = [session for session in group if session.get('pre_compression_snapshot')] + if not visible or not snapshots: + continue + if any(_has_live_sidebar_state(session) for session in visible): + continue + + best_visible_count = max(_sidebar_message_count(session) for session in visible) + best_snapshot = max( + snapshots, + key=lambda session: (_sidebar_message_count(session), _session_sort_timestamp(session)), + ) + if _sidebar_message_count(best_snapshot) <= best_visible_count: + continue + + snapshot_ids_to_show.add(str(best_snapshot.get('session_id'))) + continuation_ids_to_hide.update( + str(session.get('session_id')) + for session in visible + if session.get('session_id') + ) + + if not snapshot_ids_to_show and not continuation_ids_to_hide: + return sessions + + out = [] + for session in sessions: + sid = str(session.get('session_id') or '') + if sid in continuation_ids_to_hide: + continue + if sid in snapshot_ids_to_show: + session = dict(session) + session['_show_pre_compression_snapshot'] = True + out.append(session) + return out + + +def _strip_sidebar_internal_flags(sessions: list[dict]) -> None: + for session in sessions: + session.pop('_show_pre_compression_snapshot', None) def _active_state_db_path() -> Path: @@ -1131,7 +1234,9 @@ def all_sessions(diag=None): and not s.get('has_pending_user_message') and not s.get('worktree_path') )] + result = _prefer_fuller_snapshots_for_sidebar(result) result = [s for s in result if not _hide_from_default_sidebar(s)] + _strip_sidebar_internal_flags(result) # Backfill: sessions created before Sprint 22 have no profile tag. # Attribute them to 'default' so the client profile filter works correctly. for s in result: @@ -1167,7 +1272,9 @@ def all_sessions(diag=None): and not s.pending_user_message and not getattr(s, 'worktree_path', None) )] + result = _prefer_fuller_snapshots_for_sidebar(result) result = [s for s in result if not _hide_from_default_sidebar(s)] + _strip_sidebar_internal_flags(result) for s in result: if not s.get('profile'): s['profile'] = 'default' diff --git a/tests/test_session_index.py b/tests/test_session_index.py index 6a9b5fca..6944da84 100644 --- a/tests/test_session_index.py +++ b/tests/test_session_index.py @@ -369,6 +369,44 @@ def test_pre_compression_snapshot_hidden_from_active_sidebar_but_file_remains(mo assert [row["session_id"] for row in rows] == ["new_sid"] +def test_fuller_pre_compression_snapshot_replaces_shorter_visible_segment(monkeypatch): + """If the hidden snapshot has the fuller transcript, keep it reachable. + + Auto-compression can leave a visible continuation segment in the sidebar + while the fuller transcript remains on disk marked as a pre-compression + snapshot. In that case the default session list should prefer the fuller + transcript so the conversation does not look like recent messages vanished. + """ + snapshot = Session( + session_id="full_parent", + title="Long Conversation", + messages=[ + {"role": "user", "content": "first"}, + {"role": "assistant", "content": "second"}, + {"role": "user", "content": "latest user"}, + {"role": "assistant", "content": "latest answer"}, + ], + pre_compression_snapshot=True, + updated_at=300.0, + ) + continuation = Session( + session_id="short_child", + title="Long Conversation", + messages=[{"role": "user", "content": "first"}], + parent_session_id="full_parent", + updated_at=400.0, + ) + snapshot.save() + continuation.save() + monkeypatch.setattr(models, "_enrich_sidebar_lineage_metadata", lambda _sessions: None) + + rows = models.all_sessions() + + assert [row["session_id"] for row in rows] == ["full_parent"] + assert rows[0]["message_count"] == 4 + assert rows[0]["pre_compression_snapshot"] is True + + def test_session_save_does_not_persist_metadata_message_count_hint(): s = Session( session_id="sess_private_hint", From 3bb8c7b276422138e9d6c3adfd9d3f02638b7622 Mon Sep 17 00:00:00 2001 From: Michael Lam Date: Sat, 16 May 2026 07:31:03 -0700 Subject: [PATCH 022/138] fix: guard localStorage quota writes --- CHANGELOG.md | 4 +++ static/boot.js | 2 +- static/commands.js | 2 +- static/messages.js | 4 +-- static/sessions.js | 4 +-- tests/test_issue2386_localstorage_quota.py | 34 ++++++++++++++++++++++ 6 files changed, 44 insertions(+), 6 deletions(-) create mode 100644 tests/test_issue2386_localstorage_quota.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fa25bab..88849105 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- **PR #2387** by @Michaelyklam (closes #2386) — Active-session and workspace-panel `localStorage` writes now degrade gracefully when browser storage quota is exhausted. The missed session/panel persistence writes now match the existing best-effort storage pattern so clicking sessions, creating chats, completing streams, and toggling the workspace panel no longer throw uncaught `QuotaExceededError` exceptions. + ## [v0.51.74] — 2026-05-16 — Release AX (stage-367 — 4-PR safe-lane batch — #2362 table-cell spacing + #2363 run-state-consistency RFC + #2365 custom_providers list-format + #2367 settings sidebar i18n) ### Added diff --git a/static/boot.js b/static/boot.js index 492b87e0..617bc171 100644 --- a/static/boot.js +++ b/static/boot.js @@ -101,7 +101,7 @@ function _setWorkspacePanelMode(mode){ // Persist open/closed across refreshes (browse/preview → open; closed → closed) // Do NOT overwrite the user's "keep open" preference — only track runtime state // so that toggleWorkspacePanel(false) from the toolbar doesn't clear the setting. - localStorage.setItem('hermes-webui-workspace-panel', open ? 'open' : 'closed'); + try{localStorage.setItem('hermes-webui-workspace-panel', open ? 'open' : 'closed');}catch(_){} layout.classList.toggle('workspace-panel-collapsed',!open); if(_isCompactWorkspaceViewport()){ panel.classList.toggle('mobile-open',open); diff --git a/static/commands.js b/static/commands.js index 45441279..95c0e429 100644 --- a/static/commands.js +++ b/static/commands.js @@ -424,7 +424,7 @@ async function _applyManualCompressionResult(data, focusTopic, visibleCount, com S.messages=data.session.messages||[]; S.toolCalls=data.session.tool_calls||[]; clearLiveToolCards(); - localStorage.setItem('hermes-webui-session',S.session.session_id); + try{localStorage.setItem('hermes-webui-session',S.session.session_id);}catch(_){} if(typeof _setActiveSessionUrl==='function') _setActiveSessionUrl(S.session.session_id); syncTopbar(); renderMessages(); diff --git a/static/messages.js b/static/messages.js index d21d1144..e673e528 100644 --- a/static/messages.js +++ b/static/messages.js @@ -1454,7 +1454,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ const _prevCost=(S.session&&S.session.estimated_cost)||0; S.session=d.session;S.messages=d.session.messages||[];if(typeof _messagesTruncated!=='undefined')_messagesTruncated=!!d.session._messages_truncated; if(S.session&&S.session.session_id){ - localStorage.setItem('hermes-webui-session',S.session.session_id); + try{localStorage.setItem('hermes-webui-session',S.session.session_id);}catch(_){} if(typeof _setActiveSessionUrl==='function') _setActiveSessionUrl(S.session.session_id); } const _markerOnlyAssistantError=_replaceMarkerOnlyAssistantWithStreamError(S.messages); @@ -1824,7 +1824,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ clearLiveToolCards();if(!assistantText)removeThinking(); S.session=session;S.messages=(session.messages||[]).filter(m=>m&&m.role); if(S.session&&S.session.session_id){ - localStorage.setItem('hermes-webui-session',S.session.session_id); + try{localStorage.setItem('hermes-webui-session',S.session.session_id);}catch(_){} if(typeof _setActiveSessionUrl==='function') _setActiveSessionUrl(S.session.session_id); } const _markerOnlyAssistantError=_replaceMarkerOnlyAssistantWithStreamError(S.messages); diff --git a/static/sessions.js b/static/sessions.js index 771e1d80..e1e5c6c6 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -416,7 +416,7 @@ async function newSession(flash, options={}){ S.session=data.session;S.messages=data.session.messages||[]; S.lastUsage={...(data.session.last_usage||{})}; if(flash)S.session._flash=true; - localStorage.setItem('hermes-webui-session',S.session.session_id); + try{localStorage.setItem('hermes-webui-session',S.session.session_id);}catch(_){} _setActiveSessionUrl(S.session.session_id); _setSessionViewedCount(S.session.session_id, S.session.message_count || 0); // Sync chat-header dropdown to the session's model so the UI reflects @@ -526,7 +526,7 @@ async function loadSession(sid){ if(typeof syncTopbar==='function') syncTopbar(); _setSessionViewedCount(S.session.session_id, Number(data.session.message_count || 0)); _clearSessionCompletionUnread(S.session.session_id); - localStorage.setItem('hermes-webui-session',S.session.session_id); + try{localStorage.setItem('hermes-webui-session',S.session.session_id);}catch(_){} _setActiveSessionUrl(S.session.session_id); const activeStreamId=S.session.active_stream_id||null; diff --git a/tests/test_issue2386_localstorage_quota.py b/tests/test_issue2386_localstorage_quota.py new file mode 100644 index 00000000..11775281 --- /dev/null +++ b/tests/test_issue2386_localstorage_quota.py @@ -0,0 +1,34 @@ +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +def _script(path): + return (ROOT / path).read_text() + + +def _assert_storage_setitem_guarded(src, needle): + matches = [line.strip() for line in src.splitlines() if needle in line] + assert matches, f"expected at least one {needle} write" + for line in matches: + assert line.startswith("try{localStorage.setItem("), ( + f"localStorage quota errors must not escape from {needle} writes: {line}" + ) + assert "catch(_)" in line or "catch(e)" in line or "catch{}" in line + + +def test_active_session_localstorage_writes_ignore_quota_errors(): + """Session persistence writes are best-effort when the browser quota is full (#2386).""" + for path in ["static/sessions.js", "static/commands.js", "static/messages.js"]: + _assert_storage_setitem_guarded( + _script(path), + "localStorage.setItem('hermes-webui-session'", + ) + + +def test_workspace_panel_localstorage_write_ignores_quota_errors(): + """Workspace panel state should not break UI toggles if localStorage throws (#2386).""" + _assert_storage_setitem_guarded( + _script("static/boot.js"), + "localStorage.setItem('hermes-webui-workspace-panel'", + ) From 2dfe3ffb4241efcaa1914b36202b02b7fa9a9932 Mon Sep 17 00:00:00 2001 From: Frank Song Date: Sat, 16 May 2026 23:36:30 +0800 Subject: [PATCH 023/138] Fix live progress activity grouping --- CHANGELOG.md | 4 ++++ api/streaming.py | 27 +++++++++++++++++++++--- static/messages.js | 14 +++++++++++- static/ui.js | 10 ++++----- tests/test_sprint42.py | 3 +++ tests/test_ui_tool_call_cleanup.py | 34 ++++++++++++++++++++++++------ 6 files changed, 77 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 79b39b1c..ca57d1b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- **PR #2390** by @franksong2702 (refs #2376, refs #2344) — Live long-task Activity groups now split only on user-visible interim assistant progress text, not on hidden reasoning updates or each tool start. This keeps Codex-like progress turns readable as "progress note -> grouped tools -> next progress note" instead of degrading into repeated one-tool Activity rows, and WebUI-created agent turns now receive an explicit progress contract that asks for concise user-visible updates before tool-heavy steps. + ## [v0.51.74] — 2026-05-16 — Release AX (stage-367 — 4-PR safe-lane batch — #2362 table-cell spacing + #2363 run-state-consistency RFC + #2365 custom_providers list-format + #2367 settings sidebar i18n) ### Added diff --git a/api/streaming.py b/api/streaming.py index 18a32fc2..68b37a47 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -137,6 +137,25 @@ def _clarify_timeout_seconds(default: int = 120) -> int: _CANCEL_MARKER_PATTERNS = ('task cancelled', 'task canceled', 'response interrupted') +_WEBUI_VISIBLE_PROGRESS_PROMPT = """ +WebUI progress contract: +- For multi-step work that uses tools, provide brief user-visible progress updates as normal assistant content before continuing with tool calls. +- Each update should say what you are about to check, what you just confirmed, or why the next tool call is needed. +- Keep updates concise, factual, and in the user's language. One or two short sentences are enough. +- Do not reveal hidden reasoning, chain-of-thought, private scratchpads, secrets, raw logs, or long tool output. +- For direct answers or very short tasks, skip progress updates and answer normally. +""".strip() + + +def _webui_ephemeral_system_prompt(personality_prompt: Optional[str]) -> str: + """Build WebUI-only runtime instructions that are not persisted to history.""" + parts = [] + if personality_prompt: + parts.append(str(personality_prompt).strip()) + parts.append(_WEBUI_VISIBLE_PROGRESS_PROMPT) + return "\n\n".join(part for part in parts if part) + + def _has_new_assistant_reply(all_messages: list, prev_count: int) -> bool: """Return True if *new* messages (beyond ``prev_count``) contain an assistant message with non-empty content. @@ -3449,9 +3468,11 @@ def _run_agent_streaming( _personality_prompt = '\n'.join(p for p in _parts if p) else: _personality_prompt = str(_pval) - # Pass personality via ephemeral_system_prompt (agent's own mechanism) - if _personality_prompt: - agent.ephemeral_system_prompt = _personality_prompt + # Pass WebUI-only runtime guidance via ephemeral_system_prompt + # (agent's own mechanism). This preserves any selected personality + # while making long tool runs emit real user-visible interim text + # through interim_assistant_callback instead of frontend guesses. + agent.ephemeral_system_prompt = _webui_ephemeral_system_prompt(_personality_prompt) _pending_started_at = getattr(s, 'pending_started_at', None) # Normal chat-start sets pending_started_at before spawning this thread; # fallback to now only for recovered/legacy flows where that marker is absent diff --git a/static/messages.js b/static/messages.js index 5bfdddaa..d7eb97a0 100644 --- a/static/messages.js +++ b/static/messages.js @@ -1094,7 +1094,18 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ }; step(); } + function _closeCurrentLiveActivityGroup(){ + const turn=$('liveAssistantTurn'); + if(turn){ + turn.querySelectorAll('.tool-call-group[data-live-tool-call-group="1"][data-live-activity-current="1"]').forEach(group=>{ + group.removeAttribute('data-live-activity-current'); + }); + } + } function _resetAssistantSegment(){ + const options=arguments[0]||{}; + const closeActivity=!!(options&&options.closeActivity); + if(closeActivity) _closeCurrentLiveActivityGroup(); assistantRow=null; assistantBody=null; segmentStart=assistantText.length; @@ -1221,7 +1232,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ } if(alreadyStreamed){ if(!S.session||S.session.session_id!==activeSid) return; - _resetAssistantSegment(); + _resetAssistantSegment({closeActivity:true}); return; } assistantText += assistantText ? `\n\n${visible}` : visible; @@ -1233,6 +1244,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ else appendThinking(_liveThinkingText()); } ensureAssistantRow(true); + _resetAssistantSegment({closeActivity:true}); _scheduleRender(); }); diff --git a/static/ui.js b/static/ui.js index ad3b0703..1f29f37b 100644 --- a/static/ui.js +++ b/static/ui.js @@ -4682,7 +4682,7 @@ function ensureActivityGroup(inner, opts){ if(!inner) return null; const live=!!opts.live; const activityKey=opts.activityKey||(live?_activityKeyForLiveTurn():null); - const selector=live?'.tool-call-group[data-live-tool-call-group="1"]':'.tool-call-group[data-agent-activity-group="1"]'; + const selector=live?'.tool-call-group[data-live-tool-call-group="1"][data-live-activity-current="1"]':'.tool-call-group[data-agent-activity-group="1"]'; let group=inner.querySelector(selector); if(!group){ group=document.createElement('div'); @@ -4699,7 +4699,10 @@ function ensureActivityGroup(inner, opts){ group.setAttribute('data-tool-call-group','1'); group.setAttribute('data-agent-activity-group','1'); if(activityKey) group.setAttribute('data-activity-disclosure-key',activityKey); - if(live) group.setAttribute('data-live-tool-call-group','1'); + if(live){ + group.setAttribute('data-live-tool-call-group','1'); + group.setAttribute('data-live-activity-current','1'); + } group.innerHTML=`
`; const anchor=opts.anchor||null; if(anchor&&anchor.parentElement===inner) anchor.insertAdjacentElement('afterend', group); @@ -6953,9 +6956,6 @@ function appendThinking(text=''){ return; } const thinkingText=String(text||'').trim()||'Thinking…'; - blocks.querySelectorAll('.tool-call-group[data-live-tool-call-group="1"][data-live-activity-current="1"]').forEach(group=>{ - group.removeAttribute('data-live-activity-current'); - }); let row=blocks.querySelector('.agent-activity-thinking[data-thinking-active="1"]'); if(!row){ row=_thinkingActivityNode(thinkingText, false); diff --git a/tests/test_sprint42.py b/tests/test_sprint42.py index 995a6614..e1889c75 100644 --- a/tests/test_sprint42.py +++ b/tests/test_sprint42.py @@ -299,6 +299,7 @@ class TestRuntimeRouteInjection(unittest.TestCase): self.ephemeral_system_prompt = None self._last_error = None self.interim_assistant_callback = interim_assistant_callback + captured["agent"] = self def run_conversation(self, **kwargs): if self.interim_assistant_callback: @@ -388,6 +389,8 @@ class TestRuntimeRouteInjection(unittest.TestCase): init_kwargs = captured["init_kwargs"] self.assertIsNotNone(init_kwargs["interim_assistant_callback"]) self.assertTrue(callable(init_kwargs["interim_assistant_callback"])) + self.assertIn("WebUI progress contract", captured["agent"].ephemeral_system_prompt) + self.assertIn("user-visible progress updates", captured["agent"].ephemeral_system_prompt) interim_events = [] while not fake_queue.empty(): diff --git a/tests/test_ui_tool_call_cleanup.py b/tests/test_ui_tool_call_cleanup.py index 44204d00..cee72ab3 100644 --- a/tests/test_ui_tool_call_cleanup.py +++ b/tests/test_ui_tool_call_cleanup.py @@ -282,28 +282,50 @@ class TestToolCallGroupingStatic: "The non-simplified path should preserve standalone settled thinking cards." ) - def test_live_thinking_is_shown_while_still_splitting_tool_bursts(self): + def test_live_visible_interim_text_splits_tool_bursts_not_thinking(self): live_thinking_fn = _function_body(UI_JS, "appendThinking") live_tool_fn = _function_body(UI_JS, "appendLiveToolCard") helper = _function_body(UI_JS, "ensureActivityGroup") assert "isSimplifiedToolCalling()" in live_thinking_fn, ( "Live thinking should branch on the Compact tool activity toggle." ) - assert 'data-live-activity-current' in live_thinking_fn, ( - "Starting a new live thinking block should close the previous live tool burst." - ) assert "body.insertBefore(row, body.firstChild)" not in live_thinking_fn, ( "Live thinking should not be moved into the top Activity dropdown." ) assert "_thinkingActivityNode(thinkingText, false)" in live_thinking_fn, ( "Compact live thinking should render a collapsed Thinking card in the timeline." ) - assert '[data-live-activity-current="1"]' in live_thinking_fn, ( - "Starting a new Thinking card should mark the previous live tool burst as no longer current." + assert "removeAttribute('data-live-activity-current')" not in live_thinking_fn, ( + "Reasoning/Thinking updates alone should not split consecutive tools into one-tool Activity rows." + ) + assert '.tool-call-group[data-live-tool-call-group="1"][data-live-activity-current="1"]' in helper, ( + "Live tool cards should only reuse the current Activity burst, not the first group in the turn." + ) + assert "group.setAttribute('data-live-activity-current','1')" in helper, ( + "New live Activity bursts must be marked current so later tools append to the right group." ) assert "body.querySelector" in live_tool_fn and "data-live-tid" in live_tool_fn, ( "tool_complete must still update its current live Activity burst by tool id." ) + close_activity_fn = _function_body(MESSAGES_JS, "_closeCurrentLiveActivityGroup") + assert "data-live-activity-current" in close_activity_fn, ( + "Visible interim assistant boundaries should close the previous live Activity burst." + ) + reset_fn = _function_body(MESSAGES_JS, "_resetAssistantSegment") + assert "closeActivity" in reset_fn and "_closeCurrentLiveActivityGroup()" in reset_fn, ( + "Assistant text reset and Activity burst closing should stay separate." + ) + interim_match = re.search(r"source\.addEventListener\('interim_assistant',e=>\{(.*?)\n\s*\}\);", MESSAGES_JS, re.S) + assert interim_match and "_resetAssistantSegment({closeActivity:true});" in interim_match.group(1), ( + "Visible interim assistant text should split the previous tool burst before the next tool starts." + ) + tool_start_segment = MESSAGES_JS.split("source.addEventListener('tool',e=>{", 1)[1].split("source.addEventListener('tool_complete'", 1)[0] + assert "_resetAssistantSegment();" in tool_start_segment, ( + "Tool starts should reset the next assistant text segment without closing the current Activity burst." + ) + assert "_resetAssistantSegment({closeActivity:true});" not in tool_start_segment, ( + "Tool starts must not split consecutive tools into one-tool Activity rows." + ) class TestToolCardDesignTokens: From 48b82d57dbb631db76916697c593eda21addb208 Mon Sep 17 00:00:00 2001 From: Michael Lam Date: Sat, 16 May 2026 08:44:04 -0700 Subject: [PATCH 024/138] fix: reduce browser storage pressure --- CHANGELOG.md | 4 ++ static/sessions.js | 14 ++++++ static/sw.js | 37 +++++++++------ tests/test_issue2389_storage_pressure.py | 59 ++++++++++++++++++++++++ 4 files changed, 99 insertions(+), 15 deletions(-) create mode 100644 tests/test_issue2389_storage_pressure.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fa25bab..23f9b8e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Service worker updates now delete old shell caches before creating the new versioned cache, reducing temporary Cache Storage pressure during frequent releases. Deleting sessions also prunes the localStorage maps that track viewed counts, completion unread state, and observed streaming state so stale per-session entries do not accumulate indefinitely. Closes #2389. + ## [v0.51.74] — 2026-05-16 — Release AX (stage-367 — 4-PR safe-lane batch — #2362 table-cell spacing + #2363 run-state-consistency RFC + #2365 custom_providers list-format + #2367 settings sidebar i18n) ### Added diff --git a/static/sessions.js b/static/sessions.js index 771e1d80..8ab50b58 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -170,6 +170,14 @@ function _clearSessionCompletionUnread(sid) { _saveSessionCompletionUnread(); } +function _clearSessionViewedCount(sid) { + if (!sid) return; + const counts = _getSessionViewedCounts(); + if (!Object.prototype.hasOwnProperty.call(counts, sid)) return; + delete counts[sid]; + _saveSessionViewedCounts(); +} + function _hasSessionCompletionUnread(sid) { if (!sid) return false; return Object.prototype.hasOwnProperty.call(_getSessionCompletionUnread(), sid); @@ -810,6 +818,12 @@ function _clearHandoffStorageForSession(sid) { _setHandoffStorageValue(sid, _HANDOFF_SUFFIX_DISMISSED_AT, null); _setHandoffStorageValue(sid, _HANDOFF_SUFFIX_SUMMARY_HANDLED_AT, null); } catch {} + // Session deletion should also prune per-session tracking maps. Otherwise + // heavy users accumulate one localStorage entry per deleted session forever, + // which increases quota pressure and can make future UI persistence fail. + try { _clearSessionViewedCount(sid); } catch {} + try { _clearSessionCompletionUnread(sid); } catch {} + try { _forgetObservedStreamingSession(sid); } catch {} } function _getHandoffDismissedAt(sid) { diff --git a/static/sw.js b/static/sw.js index ebfccf35..9ea770b1 100644 --- a/static/sw.js +++ b/static/sw.js @@ -39,28 +39,35 @@ const SHELL_ASSETS = [ './manifest.json', ]; -// Install: pre-cache the app shell +function deleteOldShellCaches() { + return caches.keys().then((keys) => + Promise.all( + keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)) + ) + ); +} + +// Install: prune old shell caches first, then pre-cache the app shell. Doing +// this before caches.open(CACHE_NAME) avoids a temporary double-cache window on +// quota-sensitive browsers during frequent version bumps. self.addEventListener('install', (event) => { event.waitUntil( - caches.open(CACHE_NAME).then((cache) => { - return cache.addAll(SHELL_ASSETS).catch((err) => { - // Non-fatal: if any asset fails, still activate - console.warn('[sw] Shell pre-cache partial failure:', err); - }); - }) + deleteOldShellCaches().then(() => + caches.open(CACHE_NAME).then((cache) => { + return cache.addAll(SHELL_ASSETS).catch((err) => { + // Non-fatal: if any asset fails, still activate + console.warn('[sw] Shell pre-cache partial failure:', err); + }); + }) + ) ); self.skipWaiting(); }); -// Activate: clean up old caches +// Activate: keep the old-cache cleanup as a safety net in case install was +// interrupted or an older worker was already waiting. self.addEventListener('activate', (event) => { - event.waitUntil( - caches.keys().then((keys) => - Promise.all( - keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)) - ) - ) - ); + event.waitUntil(deleteOldShellCaches()); self.clients.claim(); }); diff --git a/tests/test_issue2389_storage_pressure.py b/tests/test_issue2389_storage_pressure.py new file mode 100644 index 00000000..5ab8baac --- /dev/null +++ b/tests/test_issue2389_storage_pressure.py @@ -0,0 +1,59 @@ +"""Regression coverage for storage-pressure cleanup from issue #2389.""" +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +SW_SRC = (ROOT / "static" / "sw.js").read_text(encoding="utf-8") +SESSIONS_SRC = (ROOT / "static" / "sessions.js").read_text(encoding="utf-8") + + +def _function_block(src: str, name: str, window: int = 1600) -> str: + idx = src.find(f"function {name}(") + assert idx != -1, f"missing function {name}" + return src[idx : idx + window] + + +def test_service_worker_install_deletes_old_caches_before_opening_new_cache(): + install_idx = SW_SRC.find("self.addEventListener('install'") + assert install_idx != -1, "service worker must define an install handler" + install_block = SW_SRC[install_idx : SW_SRC.find("self.addEventListener('activate'", install_idx)] + cleanup_idx = install_block.find("deleteOldShellCaches().then") + open_idx = install_block.find("caches.open(CACHE_NAME)") + assert cleanup_idx != -1, "install must delete stale shell caches before pre-cache" + assert open_idx != -1, "install must still pre-cache the current shell cache" + assert cleanup_idx < open_idx, ( + "opening the new shell cache before deleting old ones creates a temporary " + "double-cache window that increases quota pressure" + ) + + +def test_service_worker_keeps_activate_cleanup_safety_net(): + activate_idx = SW_SRC.find("self.addEventListener('activate'") + assert activate_idx != -1, "service worker must define an activate handler" + activate_block = SW_SRC[activate_idx : activate_idx + 500] + assert "event.waitUntil(deleteOldShellCaches())" in activate_block + assert "self.clients.claim()" in activate_block + + +def test_deleted_sessions_prune_all_session_tracking_maps(): + assert "const SESSION_VIEWED_COUNTS_KEY = 'hermes-session-viewed-counts';" in SESSIONS_SRC + assert "const SESSION_COMPLETION_UNREAD_KEY = 'hermes-session-completion-unread';" in SESSIONS_SRC + assert "const SESSION_OBSERVED_STREAMING_KEY = 'hermes-session-observed-streaming';" in SESSIONS_SRC + assert "function _clearSessionViewedCount(sid)" in SESSIONS_SRC + + clear_block = _function_block(SESSIONS_SRC, "_clearHandoffStorageForSession") + assert "_clearSessionViewedCount(sid)" in clear_block + assert "_clearSessionCompletionUnread(sid)" in clear_block + assert "_forgetObservedStreamingSession(sid)" in clear_block + + +def test_session_viewed_count_prune_is_best_effort_and_persists_when_changed(): + viewed_block = _function_block(SESSIONS_SRC, "_clearSessionViewedCount") + assert "Object.prototype.hasOwnProperty.call(counts, sid)" in viewed_block + assert "delete counts[sid]" in viewed_block + assert "_saveSessionViewedCounts()" in viewed_block + + clear_block = _function_block(SESSIONS_SRC, "_clearHandoffStorageForSession") + assert "try { _clearSessionViewedCount(sid); } catch {}" in clear_block + assert "try { _clearSessionCompletionUnread(sid); } catch {}" in clear_block + assert "try { _forgetObservedStreamingSession(sid); } catch {}" in clear_block From 0b64e21264de3c3ff0566cf2b643019545b8c962 Mon Sep 17 00:00:00 2001 From: Michael Lam Date: Sat, 16 May 2026 10:26:41 -0700 Subject: [PATCH 025/138] fix: cap live chat stream transports --- CHANGELOG.md | 4 ++++ static/messages.js | 11 +++++++++++ tests/test_inflight_stream_reuse.py | 19 +++++++++++++++++++ 3 files changed, 34 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fa25bab..8b519f9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- **PR #2393** by @Michaelyklam (refs #2313) — Chat streaming now keeps only the selected conversation's live `/api/chat/stream` EventSource open. Switching into another active session closes background chat SSE transports and relies on existing session-list status plus reattach-on-select behavior, preventing many active sessions from accumulating one long-lived browser connection each. + ## [v0.51.74] — 2026-05-16 — Release AX (stage-367 — 4-PR safe-lane batch — #2362 table-cell spacing + #2363 run-state-consistency RFC + #2365 custom_providers list-format + #2367 settings sidebar i18n) ### Added diff --git a/static/messages.js b/static/messages.js index d21d1144..64f0e7db 100644 --- a/static/messages.js +++ b/static/messages.js @@ -410,6 +410,16 @@ function closeLiveStream(sessionId, streamId){ delete LIVE_STREAMS[sessionId]; } +function closeOtherLiveStreams(activeSid){ + // Keep the live token SSE connection scoped to the conversation pane the user + // is actually viewing. Background sessions still show running/finished state + // through the session list and can reattach when selected, but they should not + // keep one EventSource each and exhaust the browser connection pool (#2313). + for(const sid of Object.keys(LIVE_STREAMS)){ + if(sid!==activeSid) closeLiveStream(sid); + } +} + function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ if(!activeSid||!streamId) return; const reconnecting=!!options.reconnecting; @@ -427,6 +437,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ ){ return; } + closeOtherLiveStreams(activeSid); closeLiveStream(activeSid); let assistantText=''; diff --git a/tests/test_inflight_stream_reuse.py b/tests/test_inflight_stream_reuse.py index 70809cd8..403df9ec 100644 --- a/tests/test_inflight_stream_reuse.py +++ b/tests/test_inflight_stream_reuse.py @@ -45,6 +45,25 @@ def test_attach_live_stream_reuses_existing_same_stream_transport(): assert "return" in body[reuse_pos:close_pos] +def test_attach_live_stream_closes_other_session_streams_before_opening_new_one(): + """Only the selected conversation pane should hold an open chat SSE transport.""" + body = _function_body(MESSAGES_JS, "attachLiveStream") + helper = _function_body(MESSAGES_JS, "closeOtherLiveStreams") + + helper_compact = helper.replace(" ", "") + assert "Object.keys(LIVE_STREAMS)" in helper + assert "if(sid!==activeSid)closeLiveStream(sid)" in helper_compact + + reuse_pos = body.find("const existingLive=LIVE_STREAMS[activeSid]") + close_other_pos = body.find("closeOtherLiveStreams(activeSid)") + close_current_pos = body.find("\n closeLiveStream(activeSid);\n") + assert close_other_pos != -1, "attachLiveStream() should prune background chat EventSources" + assert reuse_pos < close_other_pos < close_current_pos, ( + "same-stream reuse should happen before pruning, and pruning should happen " + "before replacing the active session transport" + ) + + def test_attach_live_stream_updates_uploads_before_same_stream_reuse(): """Reusing transport must not skip per-session uploaded attachment state.""" body = _function_body(MESSAGES_JS, "attachLiveStream") From 727e3c9c8f7ef98376b835ea63368a97ad818938 Mon Sep 17 00:00:00 2001 From: starship-s <45587122+starship-s@users.noreply.github.com> Date: Sat, 16 May 2026 12:48:20 -0600 Subject: [PATCH 026/138] fix(streaming): preserve session agents for credential pools --- api/streaming.py | 164 ++++++++++++++++++++++++++++++++++++++++- tests/test_sprint42.py | 136 ++++++++++++++++++++++++++++++++++ 2 files changed, 298 insertions(+), 2 deletions(-) diff --git a/api/streaming.py b/api/streaming.py index 18a32fc2..c954ae8c 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -2430,6 +2430,145 @@ def _attempt_credential_self_heal( return None +def _agent_cache_api_key_sig(resolved_api_key, credential_pool) -> str: + """Return the cache-signature component for runtime credentials. + + Credential-pool providers can legitimately hand WebUI a different runtime + token on each request (round-robin pools, OAuth refresh, auth self-heal). + The AIAgent object is also where cross-turn memory-provider state lives, so + using the volatile token itself in the cache signature silently defeats the + per-session agent cache and drops warmed Hindsight prefetch results. + """ + if credential_pool is not None: + return 'credential-pool' + import hashlib as _hashlib + return _hashlib.sha256((resolved_api_key or '').encode()).hexdigest()[:16] + + +def _refresh_cached_agent_runtime(agent, agent_kwargs: dict) -> bool: + """Refresh volatile runtime credentials on a reused cached AIAgent. + + The cache key intentionally ignores credential-pool token churn, but the + cached agent's LLM client still needs the latest selected/refreshed runtime + key. Keep long-lived provider/session state (memory prefetch, turn counters, + tool state) while swapping only the runtime credential/client. + """ + if agent is None or not isinstance(agent_kwargs, dict): + return False + + new_pool = agent_kwargs.get('credential_pool') + if new_pool is not None: + try: + agent._credential_pool = new_pool + except Exception: + pass + + new_key = agent_kwargs.get('api_key') or '' + if not new_key: + return True + + new_base = agent_kwargs.get('base_url') or getattr(agent, 'base_url', '') or '' + if getattr(agent, '_fallback_activated', False): + # Avoid mixing a refreshed primary credential into a live fallback + # runtime. Rebuilding is safer than mutating a fallback-active agent + # whose restore/cooldown state has not run yet for this turn. + return False + + if new_key == (getattr(agent, 'api_key', '') or ''): + _refresh_cached_agent_primary_runtime_snapshot(agent) + return True + + try: + if getattr(agent, 'api_mode', None) == 'anthropic_messages': + # Native Anthropic-style clients have their own construction path; + # switch_model() already handles token/client refresh there. + if hasattr(agent, 'switch_model'): + agent.switch_model( + agent_kwargs.get('model') or getattr(agent, 'model', None), + agent_kwargs.get('provider') or getattr(agent, 'provider', None), + api_key=new_key, + base_url=new_base, + api_mode=agent_kwargs.get('api_mode') or getattr(agent, 'api_mode', ''), + ) + return True + return False + + if not hasattr(agent, '_client_kwargs') or not hasattr(agent, '_replace_primary_openai_client'): + # Test/fake-agent fallback: keep metadata accurate even if no real + # OpenAI client exists to rebuild. + agent.api_key = new_key + if new_base: + agent.base_url = new_base + _refresh_cached_agent_primary_runtime_snapshot(agent) + return True + + client_kwargs = dict(getattr(agent, '_client_kwargs', {}) or {}) + client_kwargs['api_key'] = new_key + if new_base: + client_kwargs['base_url'] = new_base + agent._client_kwargs = client_kwargs + agent.api_key = new_key + if new_base: + agent.base_url = new_base + if hasattr(agent, '_apply_client_headers_for_base_url'): + agent._apply_client_headers_for_base_url(agent.base_url) + rebuilt = bool(agent._replace_primary_openai_client(reason='webui_credential_refresh')) + if rebuilt: + _refresh_cached_agent_primary_runtime_snapshot(agent) + return rebuilt + except Exception: + logger.debug('[webui] Failed to refresh cached agent runtime credentials', exc_info=True) + return False + + +def _refresh_cached_agent_primary_runtime_snapshot(agent) -> None: + """Keep AIAgent's primary-runtime snapshot aligned with refreshed creds. + + Long-lived AIAgent instances use `_primary_runtime` to restore the preferred + provider after fallback/transport recovery. If WebUI refreshes a cached + agent's runtime token but leaves that snapshot stale, a later restore can + resurrect the old credential and undo the refresh. + """ + rt = getattr(agent, '_primary_runtime', None) + if not isinstance(rt, dict): + return + + base_url = getattr(agent, 'base_url', rt.get('base_url')) + api_key = getattr(agent, 'api_key', rt.get('api_key', '')) + client_kwargs = dict(getattr(agent, '_client_kwargs', None) or rt.get('client_kwargs', {}) or {}) + + rt['base_url'] = base_url + rt['api_key'] = api_key + rt['client_kwargs'] = client_kwargs + + # The default context compressor usually tracks the primary runtime too; + # keep both the live compressor fields and the fallback-restoration + # snapshot aligned when those attributes exist. + cc = getattr(agent, 'context_compressor', None) + if cc is not None: + if hasattr(cc, 'base_url'): + cc.base_url = base_url + if hasattr(cc, 'api_key'): + cc.api_key = api_key + if 'compressor_base_url' in rt: + rt['compressor_base_url'] = getattr(cc, 'base_url', base_url) + if 'compressor_api_key' in rt: + rt['compressor_api_key'] = getattr(cc, 'api_key', api_key) + else: + if 'compressor_base_url' in rt: + rt['compressor_base_url'] = base_url + if 'compressor_api_key' in rt: + rt['compressor_api_key'] = api_key + + if getattr(agent, 'api_mode', None) == 'anthropic_messages': + if hasattr(agent, '_anthropic_api_key'): + rt['anthropic_api_key'] = getattr(agent, '_anthropic_api_key') + if hasattr(agent, '_anthropic_base_url'): + rt['anthropic_base_url'] = getattr(agent, '_anthropic_base_url') + if hasattr(agent, '_is_anthropic_oauth'): + rt['is_anthropic_oauth'] = getattr(agent, '_is_anthropic_oauth') + + def _run_agent_streaming( session_id, msg_text, @@ -3315,11 +3454,16 @@ def _run_agent_streaming( import hashlib as _hashlib import json as _json from api.config import SESSION_AGENT_CACHE, SESSION_AGENT_CACHE_LOCK + _credential_pool = _rt.get('credential_pool') _sig_blob = _json.dumps([ resolved_model or '', - _hashlib.sha256((resolved_api_key or '').encode()).hexdigest()[:16], + _agent_cache_api_key_sig(resolved_api_key, _credential_pool), resolved_base_url or '', resolved_provider or '', + _rt.get('api_mode') or '', + _rt.get('command') or '', + _rt.get('args') or [], + bool(_credential_pool), _max_iterations_cfg or '', _max_tokens_cfg or '', _fallback_resolved or {}, @@ -3343,6 +3487,23 @@ def _run_agent_streaming( SESSION_AGENT_CACHE.move_to_end(session_id) # LRU: mark as recently used logger.debug('[webui] Reusing cached agent for session %s', session_id) + if agent is not None: + # Refresh volatile runtime credentials selected from provider + # pools without discarding cross-turn agent/provider state. + if not _refresh_cached_agent_runtime(agent, _agent_kwargs): + logger.warning( + '[webui] Cached agent runtime could not be safely refreshed; rebuilding agent for session %s', + session_id, + ) + try: + if getattr(agent, '_session_db', None) is not None: + agent._session_db.close() + except Exception: + pass + with SESSION_AGENT_CACHE_LOCK: + SESSION_AGENT_CACHE.pop(session_id, None) + agent = None + if agent is not None: # Refresh per-turn callbacks — these close over request-scoped # objects (put queue, cancel_event) that are new each request. @@ -3392,7 +3553,6 @@ def _run_agent_streaming( # released until GC finalizes the agent, which on a # long-running server may be never. Close it # explicitly so the WAL handles release immediately. - # (Opus pre-release follow-up to #1421.) try: _evicted_agent = evicted_entry[0] if isinstance(evicted_entry, tuple) else None if _evicted_agent is not None and getattr(_evicted_agent, '_session_db', None) is not None: diff --git a/tests/test_sprint42.py b/tests/test_sprint42.py index 995a6614..951367b4 100644 --- a/tests/test_sprint42.py +++ b/tests/test_sprint42.py @@ -874,3 +874,139 @@ class TestCredentialPoolBackwardCompat(unittest.TestCase): # Agent was constructed successfully self.assertIn("session_id", captured["init_kwargs"]) self.assertEqual(captured["init_kwargs"]["session_id"], "sess-compat-test") + + +class TestAgentCacheCredentialPoolStability(unittest.TestCase): + """Credential-pool token churn must not evict cached WebUI agents.""" + + def test_credential_pool_signature_ignores_volatile_runtime_token(self): + import api.streaming as streaming + + pool = object() + self.assertEqual( + streaming._agent_cache_api_key_sig('token-a', pool), + streaming._agent_cache_api_key_sig('token-b', pool), + ) + self.assertNotEqual( + streaming._agent_cache_api_key_sig('token-a', None), + streaming._agent_cache_api_key_sig('token-b', None), + ) + + def test_cached_agent_runtime_refresh_swaps_key_without_losing_agent_state(self): + import api.streaming as streaming + + class FakeAgent: + def __init__(self): + self.api_key = 'old-token' + self.base_url = 'https://chatgpt.com/backend-api/codex' + self.api_mode = 'codex_responses' + self._client_kwargs = { + 'api_key': 'old-token', + 'base_url': self.base_url, + 'default_headers': {'old': 'header'}, + } + self._credential_pool = 'old-pool' + self.context_compressor = type('Compressor', (), { + 'base_url': self.base_url, + 'api_key': 'old-token', + })() + self._primary_runtime = { + 'base_url': self.base_url, + 'api_key': 'old-token', + 'client_kwargs': dict(self._client_kwargs), + 'compressor_base_url': self.base_url, + 'compressor_api_key': 'old-token', + } + self.header_refreshes = [] + self.replacements = [] + self.prefetch_survives = object() + + def _apply_client_headers_for_base_url(self, base_url): + self.header_refreshes.append((base_url, self._client_kwargs['api_key'])) + self._client_kwargs['default_headers'] = {'refreshed-for': self._client_kwargs['api_key']} + + def _replace_primary_openai_client(self, *, reason): + self.replacements.append(reason) + return True + + agent = FakeAgent() + preserved = agent.prefetch_survives + changed = streaming._refresh_cached_agent_runtime(agent, { + 'api_key': 'new-token', + 'base_url': 'https://chatgpt.com/backend-api/codex', + 'credential_pool': 'new-pool', + }) + + self.assertTrue(changed) + self.assertIs(agent.prefetch_survives, preserved) + self.assertEqual(agent.api_key, 'new-token') + self.assertEqual(agent._client_kwargs['api_key'], 'new-token') + self.assertEqual(agent._credential_pool, 'new-pool') + self.assertEqual(agent._primary_runtime['api_key'], 'new-token') + self.assertEqual(agent._primary_runtime['client_kwargs']['api_key'], 'new-token') + self.assertEqual(agent._primary_runtime['compressor_api_key'], 'new-token') + self.assertEqual(getattr(agent.context_compressor, 'api_key'), 'new-token') + self.assertEqual(agent.header_refreshes, [('https://chatgpt.com/backend-api/codex', 'new-token')]) + self.assertEqual(agent.replacements, ['webui_credential_refresh']) + + def test_same_key_refresh_repairs_stale_primary_runtime_snapshot(self): + import api.streaming as streaming + + class FakeAgent: + api_key = 'current-token' + base_url = 'https://chatgpt.com/backend-api/codex' + api_mode = 'codex_responses' + _client_kwargs = { + 'api_key': 'current-token', + 'base_url': 'https://chatgpt.com/backend-api/codex', + } + _primary_runtime = { + 'api_key': 'old-token', + 'base_url': 'https://chatgpt.com/backend-api/codex', + 'client_kwargs': { + 'api_key': 'old-token', + 'base_url': 'https://chatgpt.com/backend-api/codex', + }, + } + + agent = FakeAgent() + ok = streaming._refresh_cached_agent_runtime(agent, {'api_key': 'current-token'}) + + self.assertTrue(ok) + self.assertEqual(agent._primary_runtime['api_key'], 'current-token') + self.assertEqual(agent._primary_runtime['client_kwargs']['api_key'], 'current-token') + + def test_fallback_active_refresh_requests_rebuild_without_mutating_fallback(self): + import api.streaming as streaming + + class FakeAgent: + api_key = 'fallback-token' + base_url = 'https://fallback.example/v1' + api_mode = 'codex_responses' + _fallback_activated = True + _client_kwargs = { + 'api_key': 'fallback-token', + 'base_url': 'https://fallback.example/v1', + } + _primary_runtime = { + 'api_key': 'old-primary-token', + 'base_url': 'https://chatgpt.com/backend-api/codex', + 'client_kwargs': { + 'api_key': 'old-primary-token', + 'base_url': 'https://chatgpt.com/backend-api/codex', + }, + 'compressor_api_key': 'old-primary-token', + 'compressor_base_url': 'https://chatgpt.com/backend-api/codex', + } + + agent = FakeAgent() + ok = streaming._refresh_cached_agent_runtime(agent, { + 'api_key': 'new-primary-token', + 'base_url': 'https://chatgpt.com/backend-api/codex', + }) + + self.assertFalse(ok) + self.assertEqual(agent.api_key, 'fallback-token') + self.assertEqual(agent._client_kwargs['api_key'], 'fallback-token') + self.assertEqual(agent._primary_runtime['api_key'], 'old-primary-token') + self.assertEqual(agent._primary_runtime['client_kwargs']['api_key'], 'old-primary-token') From 20bd845416fe99e0ed3aba6039465a1cd1bec363 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Sat, 16 May 2026 19:32:49 +0000 Subject: [PATCH 027/138] fix(tests): permanent os.execv guard to stop pytest self-restart loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit api.updates._schedule_restart() spawns a daemon thread that calls os.execv() after a short sleep. Tests in test_update_banner_fixes.py monkeypatch os.execv to a no-op, but monkeypatch teardown can win the race against the daemon thread — when the thread wakes up after teardown, the real os.execv is back, and it re-execs pytest with the original argv. From the outside this looked like pytest hanging at 99% and then restarting the entire suite from 0% in a loop. The fix shadows os.execv with a permanent no-op wrapper at conftest module-import time, so late-firing daemon threads can't escape. Tests that need to verify execv was called still patch it themselves; their patches sit on top of the wrapper for their lifetime. Also adds tests/test_pytest_execv_guard.py to pin the guard against future conftest refactors. --- CHANGELOG.md | 28 +++++++++++++++++++++++++ tests/conftest.py | 30 +++++++++++++++++++++++++++ tests/test_pytest_execv_guard.py | 35 ++++++++++++++++++++++++++++++++ 3 files changed, 93 insertions(+) create mode 100644 tests/test_pytest_execv_guard.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 6434724c..ddc5be00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,36 @@ ## [Unreleased] +## [v0.51.75] — 2026-05-16 — Release AY (stage-368 — 11-PR safe-lane batch — storage + i18n + run-journal parity + attachments + compression sidebar + restart-recovery + text-mode images + tables + settings i18n + German labels) + +### Test infrastructure + +- Stage-368 maintainer fix — pytest no longer self-loops on the `_schedule_restart` daemon thread. Several existing tests in `tests/test_update_banner_fixes.py` call `api.updates._schedule_restart()`, which spawns a daemon thread that eventually calls `os.execv()`. Those tests monkeypatch `os.execv` for the test scope, but monkeypatch teardown can win the race against the daemon thread, restoring the real `os.execv` before the thread fires it — at which point the daemon re-execs the entire pytest process with the original argv, looking from the outside like pytest hangs at 99 % then restarts the suite from 0 % in an infinite loop. `tests/conftest.py` now installs a permanent no-op wrapper on `os.execv` at module-import time so late-firing daemon threads cannot re-exec pytest. New `tests/test_pytest_execv_guard.py` pins the guard against future regressions. + +### Added + +- **PR #2377** by @franksong2702 (refs #2283, refs #2363, refs #1925) — Run-journal replay timeline parity checks. After #2283 shipped the first run-journal replay slice and #2363 documented the cross-layer state consistency contract, this PR adds explicit parity assertions over the replayed timeline so divergences between the journal and the visible transcript (Thinking → tool calls → assistant text) surface as test failures instead of silent drift. + ### Fixed +- **PR #2391** by @Michaelyklam (fixes #2389) — Reduce browser storage pressure during service-worker updates and over long-running sessions. `static/sw.js` now calls `deleteOldShellCaches()` BEFORE `caches.open(CACHE_NAME)` in the install handler so the new ~2.2 MB shell cache no longer overlaps the old one during a version bump (especially painful on shared-origin quota accounting). A new `_clearSessionViewedCount()` helper plus extended `_clearHandoffStorageForSession()` prune `hermes-session-viewed-counts`, `hermes-session-completion-unread`, and `hermes-session-observed-streaming` on every single-session delete and batch-delete so per-session tracking maps no longer grow unbounded. + +- **PR #2387** by @Michaelyklam (fixes #2386) — Guard `localStorage.setItem('hermes-webui-session', ...)` and workspace-panel runtime-state writes with `try { … } catch (_) {}` across `static/boot.js`, `static/sessions.js`, `static/commands.js`, and `static/messages.js`. These convenience writes were previously fatal UI operations on quota-exhausted browsers (especially Firefox public-domain setups where shared quota fills up after a service-worker shell rotation). + +- **PR #2368** by @Michaelyklam — Hybridize background profile env routing so background title generation, manual compression, and update-summary workers honor a session's non-default profile. The pure thread-local refactor for #2321 was reverted because `hermes_cli.config.load_config()` still reads `HERMES_HOME` from process env. This PR keeps the thread-local layer for WebUI helpers and adds an `os.environ.update(runtime_env)` mirror under a narrow `_ENV_LOCK` for the worker body, with proper restore of prior values. New test asserts `OPENROUTER_API_KEY` is visible from the worker against a non-default profile. + +- **PR #2382** by @Michaelyklam (fixes #2380) — Serve raw chat attachments from the per-session inbox in addition to the session workspace. Chat uploads were intentionally moved out of workspaces into a per-session attachment inbox in an earlier release; the transcript renderer still emits stable `api/file/raw?session_id=...&path=` URLs, but `_handle_file_raw` only checked `session.workspace` so inbox-backed uploads rendered as broken images. The URL surface is preserved and a session-attachment fallback is added with path-traversal guards intact. + +- **PR #2385** by @franksong2702 — Keep fuller compression snapshots reachable in the sidebar. The default behavior hides `pre_compression_snapshot: true` rows so archived compression segments do not duplicate the active continuation. A real long Kanban session exposed a narrower failure: the fuller transcript was still present on disk but remained marked as `pre_compression_snapshot`, so the sidebar surfaced a shorter row and the fuller transcript became unreachable. The fix preserves discoverability without re-introducing duplication in normal cases. + +- **PR #2371** by @franksong2702 — Clarify interrupted turn recovery after a WebUI restart. WebUI executes browser-originated agent turns inside the WebUI process; if that process restarts mid-turn, the worker dies with it. Run journal replay can only replay events that were already emitted, so the stale-pending repair path is now annotated and refined to make the post-restart state explicit (interrupted, recoverable, or terminal) instead of leaving the user with a half-rendered turn and no signal. + +- **PR #2378** by @Michaelyklam — Strip historical images in text-only mode. Current-turn uploads already respect `agent.image_input_mode: text`, but saved conversation history still passed native `image_url` content parts back into later provider calls, breaking text-only providers on replayed turns. `_sanitize_messages_for_api()` gains a `cfg=` keyword argument so the API-history sanitizer can strip historical native image parts when the mode is text. Default `cfg=None` preserves prior behavior for callers that don't pass the new argument. + +- **PR #2375** by @Michaelyklam — Keep Markdown tables block-level. Pipe tables were already converted to `
` markup, but the final paragraph pass did not treat generated tables as block-level output, occasionally wrapping them in `

` and breaking the surrounding layout. The fix isolates generated tables and adds `table` to the paragraph-wrap skip list so valid CommonMark tables render predictably. + +- **PR #2372** by @mccxj — Settings → Conversation page action buttons now respect locale selection. Pre-fix, the JSON export, MD export, and Copy buttons had hardcoded English labels/titles. Adds `data-i18n` / `data-i18n-title` attributes plus the missing translation keys so non-English locales no longer see English labels stuck in the middle of a translated screen. + - **PR #2381** by @Michaelyklam (fixes #2379) — German relative session-time labels now interpolate the elapsed value instead of rendering the literal `{n}` placeholder in the sidebar/header. The German locale now uses function-valued translations for minutes, hours, and days, matching the other locale bundles. ## [v0.51.74] — 2026-05-16 — Release AX (stage-367 — 4-PR safe-lane batch — #2362 table-cell spacing + #2363 run-state-consistency RFC + #2365 custom_providers list-format + #2367 settings sidebar i18n) diff --git a/tests/conftest.py b/tests/conftest.py index 6d4e7ecc..66cf0102 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -171,6 +171,36 @@ def pytest_configure(config): # imports trigger botocore initialisation. os.environ.setdefault("AWS_EC2_METADATA_DISABLED", "true") +# ── Permanent os.execv guard for the pytest session ──────────────────────── +# Several tests in tests/test_update_banner_fixes.py exercise +# api.updates._schedule_restart(), which spawns a DAEMON thread that sleeps +# for a short delay and then calls ``os.execv(sys.executable, sys.argv)``. +# Those tests monkeypatch ``os.execv`` to a no-op for the test scope, but +# monkeypatch teardown happens at test exit — if the daemon thread has not +# yet woken up by then (system load, GC pause, _apply_lock contention), the +# real ``os.execv`` is restored before the thread fires it. The daemon then +# REPLACES the pytest process image with a fresh ``pytest tests/ -q ...`` +# invocation, looking from the outside like pytest "hangs at 99%" and then +# restarts the entire suite from 0% — a self-perpetuating loop. +# +# Daemon threads cannot be reliably joined from a test fixture (they live in +# ``api.updates`` module scope), so the only safe answer is to render +# ``os.execv`` permanently inert for the pytest session. Production code is +# unaffected because production never imports this conftest. +# +# Tests that need to verify execv WAS called still monkeypatch it themselves +# — their patched version takes precedence over this no-op wrapper for the +# test's lifetime, and the no-op only kicks in after teardown for daemon +# threads that wake up late. +_real_execv = os.execv + +def _pytest_session_safe_execv(_exe, _args): # pragma: no cover — never called in prod + # Drop the call on the floor. A late-firing daemon thread from + # _schedule_restart() must not be able to re-exec the pytest process. + return None + +os.execv = _pytest_session_safe_execv + # ── Hermetic network isolation ───────────────────────────────────────────── # Tests must not reach the public internet. Outbound to Anthropic / OpenAI / # Amazon / OpenRouter / etc. is forbidden by default. The test suite already diff --git a/tests/test_pytest_execv_guard.py b/tests/test_pytest_execv_guard.py new file mode 100644 index 00000000..851ae3f3 --- /dev/null +++ b/tests/test_pytest_execv_guard.py @@ -0,0 +1,35 @@ +"""Regression guard for the pytest "hangs at 99% then restarts from 0%" loop. + +Root cause documented in tests/conftest.py — daemon threads spawned by +api.updates._schedule_restart() can fire os.execv() AFTER monkeypatch +teardown restores the real os.execv, which re-execs the entire pytest +process. The conftest installs a permanent no-op wrapper on os.execv that +shadows any late-firing daemon thread. + +This test pins the guard so a future conftest refactor can't silently +remove it. +""" +import os + + +def test_conftest_installs_permanent_execv_guard(): + """os.execv must be replaced by the conftest's safe no-op wrapper.""" + # The wrapper is named `_pytest_session_safe_execv` in conftest.py. + # Verify the module attribute now points to that wrapper, not the real + # libc-bound function. + assert os.execv.__name__ == '_pytest_session_safe_execv', ( + f"os.execv must be the conftest-installed pytest-safe no-op, but " + f"resolves to {os.execv!r}. Did a recent conftest refactor remove " + f"the guard? See conftest.py § 'Permanent os.execv guard for the " + f"pytest session' — without it, late-firing _schedule_restart " + f"daemon threads re-exec pytest and the suite loops forever." + ) + + +def test_safe_execv_returns_none_does_not_exec(): + """The wrapper must be a true no-op — it must not raise, exec, or block.""" + # Pass deliberately bogus args to confirm the wrapper drops them rather + # than passing them through to the real execv. + result = os.execv('/nonexistent/binary/path/that/should/not/be/executed', + ['/nonexistent/binary/path/that/should/not/be/executed']) + assert result is None From 9441e32adb64e2f5751acbbb371c5fe20ae32e4f Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Sat, 16 May 2026 20:11:06 +0000 Subject: [PATCH 028/138] test(stage-369): widen brittle setCompressionUi({ assertion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #2347 hoisted the inline state object to a `state` variable so the auto-compression handler could share it with appendLiveCompressionCard. Behavior is identical — same setCompressionUi() dispatch, same calm compression-card path — but tests/test_run_journal_frontend_static.py pinned the literal substring `setCompressionUi({` to verify the call site. Relax the assertion to accept either inline (`{...}`) or hoisted (`state`) argument form. Both forms route through the same compression card path; the over-specific substring was the bug. --- tests/test_run_journal_frontend_static.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tests/test_run_journal_frontend_static.py b/tests/test_run_journal_frontend_static.py index 773b6618..5b8ea81a 100644 --- a/tests/test_run_journal_frontend_static.py +++ b/tests/test_run_journal_frontend_static.py @@ -66,7 +66,15 @@ def test_replayed_long_task_events_enter_the_same_live_timeline_handlers(): assert "updateThinking(" in wire_block, "reasoning replay should use the live Thinking card path" assert "appendLiveToolCard(tc)" in wire_block, "tool replay should use live tool-card rendering" - assert "setCompressionUi({" in wire_block, "compression replay should use the compression card path" + # Compression replay must dispatch through setCompressionUi(...). The handler + # body may build the state object inline (`setCompressionUi({...})`) or hoist + # it into a `state` variable first (`setCompressionUi(state)`) — both forms + # use the same compression-card path, so accept either. Pinning the literal + # `{` after the open-paren was over-specific and broke in v0.51.76 when + # PR #2347 hoisted the state object to share it with `appendLiveCompressionCard`. + assert ("setCompressionUi({" in wire_block) or ("setCompressionUi(state)" in wire_block), ( + "compression replay should use the compression card path" + ) assert "_runJournalReplayParams()" in MESSAGES_SRC, "replay attachments should enter _wireSSE via EventSource" From 069503f0bf9c9e40c6fb87db4ab1059749fcca6d Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Sat, 16 May 2026 20:11:43 +0000 Subject: [PATCH 029/138] fix(stage-369): replace 'PR TBD' placeholder with #2347 in CHANGELOG MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opus advisor caught this on stage-369 review — PR #2347 left a 'PR TBD' placeholder in CHANGELOG that should reference its own number. One-line attribution fix, no behavior change. --- CHANGELOG.md | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 25281382..8a89a4a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ ## [Unreleased] +## [v0.51.76] — 2026-05-16 — Release AZ (stage-369 — 4-PR safe-lane batch — live timeline preservation + OpenRouter cost history + chat stream cap + credential pool cache) + +### Added + +- **PR #2195** by @Michaelyklam (refs #692) — OpenRouter cost history backend. New `GET /api/providers/openrouter/cost_history` endpoint backed by daily snapshots from OpenRouter's `/auth/key` cumulative spend. Process-local lock around the snapshot read-modify-write critical section so concurrent dashboard refreshes or multiple tabs cannot overwrite newer reads with stale ones. Delta computation handles cumulative-counter resets (key rotation, OpenRouter-side reset) by starting a fresh series and using the current value as that day's delta rather than emitting negative spend. Backend-only slice; the 7-day daily cost chart UI is a separate follow-up. + +### Fixed + +- **PR #2347** by @franksong2702 (fixes #2344) — Preserve live agent timeline across session switches. Previously, switching away from an active stream and returning rebuilt the turn from the persisted `INFLIGHT` tail, which is enough to reconnect the stream but is not a full-fidelity DOM timeline — Thinking/tool grouping flattened, interim assistant text moved away from its surrounding context, auto-compression cards could project twice. The restore path now snapshots the live assistant turn DOM during the active stream and, on return, loads the persisted transcript first then merges the live snapshot back in so the on-screen scene is preserved as the user left it. Stamping `row.dataset.sessionId` at turn creation prevents the new live-turn sites from re-triggering the lossy rebuild path. + +- **PR #2393** by @Michaelyklam (refs #2313) — Cap live chat stream transports to the selected conversation. Previously, keeping many sessions open accumulated one long-lived `/api/chat/stream` EventSource per session. New `closeOtherLiveStreams(activeSid)` helper in `static/messages.js`; `attachLiveStream()` now reuses an existing same-session transport first, closes other sessions' chat SSE transports, then opens or replaces the selected session's stream. Background sessions still reattach normally when the user selects them — only the SSE transport is pruned, not the server-side stream ownership. New regression test pins the ordering (reuse first, prune background streams next, replace active transport last). + +- **PR #2396** by @starship-s — Preserve session agents for credential pools. The per-session `AIAgent` cache signature previously mixed stable agent identity with the volatile resolved API key, so credential-pool providers (where each request can resolve a different runtime token even when provider/model config is unchanged) missed the cache every turn and rebuilt the agent — losing warmed cross-turn state such as memory-provider prefetch results for providers like Hindsight. New credential-aware cache-signature helper uses a stable sentinel for credential-pool routes while preserving hashed API-key identity for non-pool routes; reused cached agents refresh runtime credentials in place; `AIAgent._primary_runtime` stays aligned after refresh so fallback/transport recovery cannot resurrect an old token; agents still in fallback-active state rebuild rather than mutate to avoid mixed primary/fallback runtime state. Static non-pool API keys still participate in the cache signature so explicit credential changes continue to invalidate. + ## [v0.51.75] — 2026-05-16 — Release AY (stage-368 — 11-PR safe-lane batch — storage + i18n + run-journal parity + attachments + compression sidebar + restart-recovery + text-mode images + tables + settings i18n + German labels) ### Test infrastructure @@ -92,7 +106,7 @@ ### Added -- **PR TBD** by @franksong2702 — Long tool-heavy streaming turns now preserve the live Thinking / assistant progress / Tool / Command timeline when the user switches away and back. The active stream keeps accumulating token and interim-assistant state while inactive, reloads the persisted transcript before merging the live tail, restores the live turn DOM snapshot instead of replaying tools into a flat list, and anchors automatic compression cards inside the active turn to avoid duplicate cards while an answer is still streaming. +- **PR #2347** by @franksong2702 — Long tool-heavy streaming turns now preserve the live Thinking / assistant progress / Tool / Command timeline when the user switches away and back. The active stream keeps accumulating token and interim-assistant state while inactive, reloads the persisted transcript before merging the live tail, restores the live turn DOM snapshot instead of replaying tools into a flat list, and anchors automatic compression cards inside the active turn to avoid duplicate cards while an answer is still streaming. - **PR #2332** by @Michaelyklam (refs #2290) — Cron run history/output cards now surface token/cost metadata when the underlying cron output markdown includes it. The backend parses optional model/token/cost/duration frontmatter from cron output files and returns it from `/api/crons/history` and `/api/crons/run`; the Tasks panel renders a compact usage strip beside run rows and below expanded output without affecting older outputs that lack usage metadata. From a5f429c28f0e858e6967bddba2dc710f9979d737 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Sat, 16 May 2026 20:22:33 +0000 Subject: [PATCH 030/138] Stamp CHANGELOG for v0.51.77 (Release BA / stage-370) --- CHANGELOG.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a89a4a4..231f94f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,23 @@ ## [Unreleased] +## [v0.51.77] — 2026-05-16 — Release BA (stage-370 — 1-PR follow-up — live Activity grouping boundary fix) + +### Fixed + +- **PR #2390** by @franksong2702 (refs #2376, #2344, #2347, #2377) — Live progress Activity grouping no longer degrades consecutive tool calls into repeated `Activity: 1 tool` rows. The frontend was using one reset helper for two different jobs — resetting where the next assistant text segment should render, and closing the current live Activity group — but those are not the same operation. Tool starts now only reset the next-text-segment anchor; the live Activity group closes only when the model emits a visible `interim_assistant` progress update (the actual timeline boundary). The flow stays: + + ```text + Thinking card + visible progress note + Activity: N related tools + visible progress note + Activity: N related tools + final answer + ``` + + Adds a WebUI-only ephemeral progress contract in `api/streaming.py` that asks multi-step tool-heavy turns to emit concise visible progress notes in the user's language, while explicitly forbidding exposure of hidden reasoning, chain-of-thought, scratchpads, secrets, raw logs, or long tool output. Any selected personality prompt is preserved. New regressions cover the progress-contract reach-through, the interim-assistant split boundary, and the consecutive-tools-in-one-Activity-row invariant. + ## [v0.51.76] — 2026-05-16 — Release AZ (stage-369 — 4-PR safe-lane batch — live timeline preservation + OpenRouter cost history + chat stream cap + credential pool cache) ### Added From a4ab7d4d270b823c6f8411f5922478e39ac52169 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Sat, 16 May 2026 22:04:56 +0000 Subject: [PATCH 031/138] =?UTF-8?q?Stage=20371:=20PR=20#2409=20=E2=80=94?= =?UTF-8?q?=20Stuck-PR=20sweep:=20salvage=20RTL=20chat=20from=20#1721=20+?= =?UTF-8?q?=20override=20quota=20chip=20from=20#2082=20by=20@malulian=20an?= =?UTF-8?q?d=20@ai-ag2026?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: malulian Co-authored-by: ai-ag2026 --- api/config.py | 2 + static/boot.js | 1 + static/i18n.js | 22 ++++++ static/index.html | 23 ++++++ static/panels.js | 17 +++++ static/style.css | 77 ++++++++++++++++++++ static/ui.js | 69 ++++++++++++++++++ tests/test_issue1766_quota_topbar.py | 49 +++++++++++++ tests/test_pr1721_rtl_salvage.py | 101 +++++++++++++++++++++++++++ 9 files changed, 361 insertions(+) create mode 100644 tests/test_issue1766_quota_topbar.py create mode 100644 tests/test_pr1721_rtl_salvage.py diff --git a/api/config.py b/api/config.py index 5ab5043f..cdb27eb5 100644 --- a/api/config.py +++ b/api/config.py @@ -4035,6 +4035,7 @@ _SETTINGS_DEFAULTS = { "HERMES_WEBUI_BOT_NAME", "Hermes" ), # display name for the assistant "sound_enabled": False, # play notification sound when assistant finishes + "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 @@ -4158,6 +4159,7 @@ _SETTINGS_BOOL_KEYS = { "check_for_updates", "whats_new_summary_enabled", "sound_enabled", + "rtl", "notifications_enabled", "show_thinking", "simplified_tool_calling", diff --git a/static/boot.js b/static/boot.js index 617bc171..296729f1 100644 --- a/static/boot.js +++ b/static/boot.js @@ -1527,6 +1527,7 @@ function applyBotName(){ const _srch = document.getElementById('sessionSearch'); if (_srch) _srch.value = ''; // Initialize reasoning chip on boot (fixes #1103 — chip hidden until session load) if(typeof fetchReasoningChip==='function') fetchReasoningChip(); + if(typeof refreshProviderQuotaIndicator==='function') refreshProviderQuotaIndicator(); const urlSession=(typeof _sessionIdFromLocation==='function')?_sessionIdFromLocation():null; const savedLocal=localStorage.getItem('hermes-webui-session'); const saved=urlSession||savedLocal; diff --git a/static/i18n.js b/static/i18n.js index 700ff2a4..00fe82bc 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -736,6 +736,8 @@ const LOCALES = { import_session_json_tooltip: 'Import session from JSON', clear_conversation_btn_tooltip: 'Clear all messages in this conversation', // Settings detail + settings_label_rtl: 'Right-to-left chat layout', + settings_desc_rtl: 'Flips alignment of chat messages and the composer input for languages like Arabic or Hebrew. Affects only the chat area — sidebar and other panels stay left-to-right.', settings_label_sound: 'Notification sound', settings_desc_sound: 'Play a sound when the assistant finishes a response.', // TTS (#499) @@ -1922,6 +1924,8 @@ const LOCALES = { import_session_json_tooltip: 'Importa sessione da JSON', clear_conversation_btn_tooltip: 'Cancella tutti i messaggi in questa conversazione', // Settings detail + settings_label_rtl: 'Layout chat da destra a sinistra', + settings_desc_rtl: "Inverte l'allineamento dei messaggi e dell'input del compositore per lingue come arabo o ebraico. Influisce solo sull'area chat — la barra laterale e gli altri pannelli rimangono da sinistra a destra.", settings_label_sound: 'Suono notifica', settings_desc_sound: 'Riproduci un suono quando l\'assistente termina una risposta.', // TTS (#499) @@ -3113,6 +3117,8 @@ const LOCALES = { import_session_json_tooltip: 'JSONからセッションをインポート', clear_conversation_btn_tooltip: 'この会話のすべてのメッセージをクリア', // Settings detail + settings_label_rtl: 'チャットの右から左へのレイアウト', + settings_desc_rtl: 'アラビア語やヘブライ語のような言語向けに、チャットメッセージとコンポーザー入力の配置を反転します。チャット領域のみに影響し、サイドバーや他のパネルは左から右のままです。', settings_label_sound: '通知音', settings_desc_sound: 'アシスタントが応答を完了したときに音を鳴らします。', // TTS (#499) @@ -4088,6 +4094,8 @@ const LOCALES = { export_session_json_tooltip: 'Экспортировать сессию как JSON', import_session_json_tooltip: 'Импортировать сессию из JSON', clear_conversation_btn_tooltip: 'Очистить все сообщения в этой беседе', + settings_label_rtl: 'Раскладка чата справа налево', + settings_desc_rtl: 'Переворачивает выравнивание сообщений чата и поля ввода для языков вроде арабского или иврита. Влияет только на область чата — боковая панель и другие панели остаются слева направо.', settings_label_sound: 'Звук уведомления', settings_desc_sound: 'Проигрывать звук, когда помощник завершает ответ.', settings_label_notifications: 'Уведомления браузера', @@ -5214,6 +5222,8 @@ const LOCALES = { import_session_json_tooltip: 'Importar sesión desde JSON', clear_conversation_btn_tooltip: 'Borrar todos los mensajes de esta conversación', // Settings detail + settings_label_rtl: 'Diseño de chat de derecha a izquierda', + settings_desc_rtl: 'Invierte la alineación de los mensajes y la entrada del compositor para idiomas como árabe o hebreo. Afecta solo al área del chat — la barra lateral y otros paneles siguen de izquierda a derecha.', settings_label_sound: 'Sonido de notificación', settings_desc_sound: 'Reproduce un sonido cuando el asistente termina una respuesta.', settings_label_notifications: 'Notificaciones del navegador', @@ -6312,6 +6322,8 @@ const LOCALES = { import_session_json_tooltip: 'Sitzung aus JSON importieren', clear_conversation_btn_tooltip: 'Alle Nachrichten in dieser Konversation löschen', // Settings detail + settings_label_rtl: 'Chat-Layout von rechts nach links', + settings_desc_rtl: 'Kehrt die Ausrichtung von Chat-Nachrichten und Eingabefeld für Sprachen wie Arabisch oder Hebräisch um. Betrifft nur den Chat-Bereich — Seitenleiste und andere Panels bleiben von links nach rechts.', settings_label_sound: 'Benachrichtigungston', settings_desc_sound: 'Spielt einen Ton ab, wenn der Assistent eine Antwort beendet.', settings_label_notifications: 'Browser-Benachrichtigungen', @@ -7534,6 +7546,8 @@ const LOCALES = { password_env_var_locked: '当前已设置 HERMES_WEBUI_PASSWORD 环境变量并具有优先级。请取消该变量并重启服务器,才能在此管理密码。', password_env_var_locked_placeholder: '已锁定:已设置 HERMES_WEBUI_PASSWORD 环境变量', disable_auth: '停用认证', + settings_label_rtl: '从右到左聊天布局', + settings_desc_rtl: '为阿拉伯语或希伯来语等语言翻转聊天消息和编辑器输入的对齐方式。仅影响聊天区域 — 侧边栏和其他面板保持从左到右。', settings_label_sound: '通知声音', settings_label_notifications: '浏览器通知', settings_desc_sound: '助手完成回复时播放提示音。', @@ -8591,6 +8605,8 @@ const LOCALES = { password_env_var_locked: '\u76ee\u524d\u5df2\u8a2d\u5b9a HERMES_WEBUI_PASSWORD \u74b0\u5883\u8b8a\u6578\u4e14\u512a\u5148\u751f\u6548\u3002\u8acb\u53d6\u6d88\u8a2d\u5b9a\u4e26\u91cd\u65b0\u555f\u52d5\u4f3a\u670d\u5668\uff0c\u624d\u80fd\u5728\u6b64\u7ba1\u7406\u5bc6\u78bc\u3002', password_env_var_locked_placeholder: '\u5df2\u9396\u5b9a\uff1a\u5df2\u8a2d\u5b9a HERMES_WEBUI_PASSWORD \u74b0\u5883\u8b8a\u6578', disable_auth: '\u505c\u7528\u9a57\u8b49', + settings_label_rtl: '從右到左聊天版面', + settings_desc_rtl: '為阿拉伯語或希伯來語等語言翻轉聊天訊息和編輯器輸入的對齊方式。僅影響聊天區域 — 側邊欄和其他面板保持從左到右。', settings_label_sound: '\u901a\u77e5\u8072\u97f3', settings_label_notifications: '\u700f\u89bd\u901a\u77e5', settings_desc_sound: '助手完成回答時播放聲音。', @@ -9871,6 +9887,8 @@ const LOCALES = { import_session_json_tooltip: 'Importar sessão de JSON', clear_conversation_btn_tooltip: 'Limpar todas as mensagens nesta conversa', // Settings detail + settings_label_rtl: 'Layout de chat da direita para a esquerda', + settings_desc_rtl: 'Inverte o alinhamento das mensagens do chat e da entrada do compositor para idiomas como árabe ou hebraico. Afeta apenas a área do chat — a barra lateral e outros painéis permanecem da esquerda para a direita.', settings_label_sound: 'Som de notificação', settings_desc_sound: 'Tocar som quando assistente finalizar resposta.', settings_label_notifications: 'Notificações do navegador', @@ -10959,6 +10977,8 @@ const LOCALES = { import_session_json_tooltip: 'JSON에서 세션 가져오기', clear_conversation_btn_tooltip: '이 대화의 모든 메시지 지우기', // Settings detail + settings_label_rtl: '오른쪽에서 왼쪽 채팅 레이아웃', + settings_desc_rtl: '아랍어나 히브리어 같은 언어를 위해 채팅 메시지와 작성 입력의 정렬을 뒤집습니다. 채팅 영역에만 영향을 주며, 사이드바와 다른 패널은 왼쪽에서 오른쪽으로 유지됩니다.', settings_label_sound: '알림음', settings_desc_sound: 'Assistant 응답이 끝나면 소리를 재생합니다.', settings_label_notifications: '브라우저 알림', @@ -12061,6 +12081,8 @@ const LOCALES = { export_session_json_tooltip: 'Exporter la session complète en JSON', import_session_json_tooltip: 'Importer une session depuis JSON', clear_conversation_btn_tooltip: 'Effacer tous les messages de cette conversation', + settings_label_rtl: 'Mise en page du chat de droite à gauche', + settings_desc_rtl: "Inverse l'alignement des messages du chat et de la saisie du compositeur pour des langues comme l'arabe ou l'hébreu. N'affecte que la zone de chat — la barre latérale et les autres panneaux restent de gauche à droite.", settings_label_sound: 'Son de notification', settings_desc_sound: 'Jouez un son lorsque l\'assistant termine une réponse.', tts_listen: 'Écouter', diff --git a/static/index.html b/static/index.html index 22a3e29d..81919e88 100644 --- a/static/index.html +++ b/static/index.html @@ -62,6 +62,18 @@ }); } +

`).join('');}; const parseHeader=r=>{r=_protectPipes(r);return r.trim().replace(/^\|/,'').replace(/\|$/,'').split('|').map(c=>``).join('');}; diff --git a/tests/test_issue2428_table_pipe_protection.py b/tests/test_issue2428_table_pipe_protection.py new file mode 100644 index 00000000..c7044fae --- /dev/null +++ b/tests/test_issue2428_table_pipe_protection.py @@ -0,0 +1,217 @@ +"""Regression tests for PR #2428 — table renderer pipe protection. + +These pin three classes of input that previously mis-split into too many table +cells because `parseRow` / `parseHeader` ran a naive `split('|')` over the row +text: + +1. Single-pipe inside parens/brackets (e.g. ``(a|b)``, ``[int|float]``) — was + the original #2428 bug. +2. Multi-pipe inside one bracket pair (e.g. ``(a|b|c)``, ``Union[int|float|str]``) — + common Python type hint / regex character class shape; first version of the + #2428 fix protected only the first pipe per pair and still mis-split here. +3. Cells containing apostrophes alongside protected pipes (e.g. ``('a'|'b')``) — + the intermediate version of the fix added ``'`` to the negated character + classes which caused a regression on string-literal-in-union input. + +Tests drive the real `renderMd()` in `static/ui.js` via the same node-driver +pattern used by `test_renderer_js_behaviour.py` so the regex can't silently +regress in either direction. +""" +from __future__ import annotations + +import shutil +import subprocess +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).parent.parent.resolve() +UI_JS_PATH = REPO_ROOT / "static" / "ui.js" + +NODE = shutil.which("node") + +pytestmark = pytest.mark.skipif(NODE is None, reason="node not on PATH") + + +_DRIVER_SRC = r""" +const fs = require('fs'); +const src = fs.readFileSync(process.argv[2], 'utf8'); +global.window = {}; +global.document = { createElement: () => ({ innerHTML: '', textContent: '' }) }; +const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ( + {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); +const _IMAGE_EXTS=/\.(png|jpg|jpeg|gif|webp|bmp|ico|avif)$/i; +const _SVG_EXTS=/\.svg$/i; +const _AUDIO_EXTS=/\.(mp3|ogg|wav|m4a|aac|flac|wma|opus|webm)$/i; +const _VIDEO_EXTS=/\.(mp4|webm|mkv|mov|avi|ogv|m4v)$/i; + +function extractFunc(name) { + const re = new RegExp('function\\s+' + name + '\\s*\\('); + const start = src.search(re); + if (start < 0) throw new Error(name + ' not found'); + let i = src.indexOf('{', start); + let depth = 1; i++; + while (depth > 0 && i < src.length) { + if (src[i] === '{') depth++; + else if (src[i] === '}') depth--; + i++; + } + return src.slice(start, i); +} +eval(extractFunc('_matchBacktickFenceLine')); +eval(extractFunc('_isBacktickFenceClose')); +eval(extractFunc('renderMd')); + +let buf = ''; +process.stdin.on('data', c => { buf += c; }); +process.stdin.on('end', () => { process.stdout.write(renderMd(buf)); }); +""" + + +@pytest.fixture(scope="module") +def driver_path(tmp_path_factory): + p = tmp_path_factory.mktemp("table_pipe_protect_driver") / "driver.js" + p.write_text(_DRIVER_SRC, encoding="utf-8") + return str(p) + + +def _render(driver_path: str, markdown: str) -> str: + result = subprocess.run( + [NODE, driver_path, str(UI_JS_PATH)], + input=markdown, + capture_output=True, + text=True, + timeout=15, + ) + if result.returncode != 0: + raise RuntimeError(f"node driver failed: {result.stderr}") + return result.stdout + + +def _table_cells(html: str) -> list[str]: + """Extract td/th text content in document order. + + Returns the inner HTML of each `).join('');}; const parseHeader=r=>{r=_protectPipes(r);return r.trim().replace(/^\|/,'').replace(/\|$/,'').split('|').map(c=>``).join('');}; diff --git a/tests/test_issue2428_table_pipe_protection.py b/tests/test_issue2428_table_pipe_protection.py index c7044fae..064f689c 100644 --- a/tests/test_issue2428_table_pipe_protection.py +++ b/tests/test_issue2428_table_pipe_protection.py @@ -215,3 +215,31 @@ class TestKatexDollarInTableCell: f"`$5 | $10` must remain two cells, not collapse to one math span. " f"Got {len(cells)} cells: {cells!r}" ) + + +class TestComparisonOperatorsAcrossColumns: + """The first cut of #2428 included `<` and `>` in the protected-bracket + set. That caused tables containing comparison operators across adjacent + columns to mis-collapse: ``| x < 5 | y > 10 |`` matched `< … >` as a + bracket pair and stashed the inner pipe, producing one cell instead of + two. Stage-fix removed `<` / `>` from the bracket set because real LLM + output uses angle brackets as comparison operators far more often than + as a content-grouping pair.""" + + def test_less_than_greater_than_across_columns_stays_two_cells(self, driver_path): + md = "| x < 5 | y > 10 |\n|---|---|\n| less | more |" + cells = _table_cells(_render(driver_path, md)) + assert len(cells) == 4, ( + f"`| x < 5 | y > 10 |` must produce 4 cells (2 cols × 2 rows). " + f"Got {len(cells)} cells: {cells!r}" + ) + + def test_less_than_alone_in_cell(self, driver_path): + md = "| a < b | c |\n|---|---|\n| ok | y |" + cells = _table_cells(_render(driver_path, md)) + assert len(cells) == 4, f"got cells: {cells!r}" + + def test_greater_than_alone_in_cell(self, driver_path): + md = "| a > b | c |\n|---|---|\n| ok | y |" + cells = _table_cells(_render(driver_path, md)) + assert len(cells) == 4, f"got cells: {cells!r}" From 403e9582af49b551d2a1ff93c050170fe09a324d Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Sun, 17 May 2026 03:53:50 +0000 Subject: [PATCH 056/138] Stamp CHANGELOG for v0.51.82 (Release BF / stage-375) --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c9c2d62..93fb8cdd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,16 @@ ## [Unreleased] +## [v0.51.82] — 2026-05-17 — Release BF (stage-375 — 2-PR batch — table renderer pipe protection + Catppuccin appearance skin) + ### Added - **PR #2432** by @Michaelyklam (closes #2426) — Add a Catppuccin skin to Appearance settings. The single opt-in skin maps light mode to Catppuccin Latte and dark mode to Catppuccin Mocha, using Mauve as the accent while preserving the existing theme/skin persistence and no-build-step architecture. +### Fixed + +- **PR #2428** by @bengdan — Protect pipes inside parens / brackets / braces from naive `split('|')` in the Markdown table renderer. Cells like `` `(a|b)` ``, `` `Union[int|float]` ``, `` `(a|b|c)` ``, and `` `Union[int|float|str]` `` now stay in a single column instead of mis-splitting. The fix uses an iterative `_protectPipes` loop so all pipes inside one bracket pair are caught, not just the first. Also adds a `$...$` guard so a KaTeX inline-math span straddling ` | ` column separators is left alone instead of being stashed as math. Stage-fix on the contributor branch (a) swapped the literal `}` glyphs in the regex character classes for `\x7d` hex escapes (semantically identical, but the JS source no longer carries bare close-brace glyphs that confused the brace-counting `extractFunc` in `tests/test_renderer_js_behaviour.py`); (b) dropped a stray apostrophe stop that would have mis-split `('a'|'b')`-style string-literal unions; (c) dropped angle brackets `<` / `>` from the protected-bracket set, after Opus advisor flagged that `| x < 5 | y > 10 |` would otherwise collapse into a single cell (comparison-operator usage dominates content-grouping usage in real LLM table output); and (d) added `tests/test_issue2428_table_pipe_protection.py` with 12 regression cases covering single-pipe, multi-pipe-in-brackets, apostrophes-with-pipes, the KaTeX-in-table guard, and the angle-bracket comparison-operator case. + ## [v0.51.81] — 2026-05-17 — Release BE (stage-374 — 6-PR batch — cost-history POSIX lock + prompt-cache tokens + Plugins panel i18n + pending-placeholder chat + journal-replay partial recovery + default-off RuntimeAdapter Slice 2 seam) ### Added From 1731232a1201523a7a8be98470f2be0ba7cc1ed8 Mon Sep 17 00:00:00 2001 From: Frank Song Date: Sun, 17 May 2026 12:02:54 +0800 Subject: [PATCH 057/138] Preserve chat start response shape for adapter path --- CHANGELOG.md | 4 ++++ api/routes.py | 3 --- tests/test_runtime_adapter_seam.py | 19 +++++++++++++++++++ 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 93fb8cdd..c87c3056 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- 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. Fixes #2435. + ## [v0.51.82] — 2026-05-17 — Release BF (stage-375 — 2-PR batch — table renderer pipe protection + Catppuccin appearance skin) ### Added diff --git a/api/routes.py b/api/routes.py index 677a6677..779a0176 100644 --- a/api/routes.py +++ b/api/routes.py @@ -7800,9 +7800,6 @@ def _handle_chat_start(handler, body, diag=None): response = dict(result.payload) response.setdefault("stream_id", result.stream_id) response.setdefault("session_id", result.session_id) - response.setdefault("run_id", result.run_id) - response.setdefault("status", result.status) - response.setdefault("active_controls", result.active_controls) else: response = _start_chat_stream_for_session( s, diff --git a/tests/test_runtime_adapter_seam.py b/tests/test_runtime_adapter_seam.py index 296abd64..c261d54d 100644 --- a/tests/test_runtime_adapter_seam.py +++ b/tests/test_runtime_adapter_seam.py @@ -119,3 +119,22 @@ def test_chat_start_route_selects_adapter_only_when_flag_enabled(): assert "LegacyJournalRuntimeAdapter" in start_body assert "_start_chat_stream_for_session(" in start_body assert "HERMES_WEBUI_RUNTIME_ADAPTER" not in start_body, "route should use runtime_adapter_enabled(), not inline env checks" + + +def test_chat_start_adapter_path_preserves_legacy_response_shape(): + """The RuntimeAdapter seam must be invisible to /api/chat/start callers. + + The adapter can use run_id/status/controls internally, but the flagged + route must not add fields that the legacy-direct response does not expose. + """ + routes = importlib.import_module("api.routes") + src = (routes.Path(__file__).parent.parent / "api" / "routes.py").read_text(encoding="utf-8") + branch_start = src.index("if runtime_adapter_enabled():") + branch_end = src.index("else:", branch_start) + adapter_branch = src[branch_start:branch_end] + + assert 'response.setdefault("stream_id", result.stream_id)' in adapter_branch + assert 'response.setdefault("session_id", result.session_id)' in adapter_branch + assert 'response.setdefault("run_id", result.run_id)' not in adapter_branch + assert 'response.setdefault("status", result.status)' not in adapter_branch + assert 'response.setdefault("active_controls", result.active_controls)' not in adapter_branch From 4661a5e94e025a5c8dc054b81138c843f77df2d0 Mon Sep 17 00:00:00 2001 From: Frank Song Date: Sun, 17 May 2026 12:23:27 +0800 Subject: [PATCH 058/138] Recover journal output after core transcript sync --- CHANGELOG.md | 4 + api/models.py | 113 ++++++++++++++++++++++- tests/test_session_sidecar_repair.py | 128 +++++++++++++++++++++++++++ 3 files changed, 242 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 93fb8cdd..7f4a21f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- 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 rare #2427 carve-out where recoverable partial output could be silently skipped. Fixes #2434. + ## [v0.51.82] — 2026-05-17 — Release BF (stage-375 — 2-PR batch — table renderer pipe protection + Catppuccin appearance skin) ### Added diff --git a/api/models.py b/api/models.py index c45e33c0..0518b227 100644 --- a/api/models.py +++ b/api/models.py @@ -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 @@ -757,6 +826,12 @@ def _append_journaled_partial_output(session, stream_id: str | None) -> bool: 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', @@ -821,6 +896,9 @@ def _append_journaled_partial_output(session, stream_id: str | None) -> bool: 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, @@ -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 diff --git a/tests/test_session_sidecar_repair.py b/tests/test_session_sidecar_repair.py index d6f17938..900990b3 100644 --- a/tests/test_session_sidecar_repair.py +++ b/tests/test_session_sidecar_repair.py @@ -741,6 +741,134 @@ class TestNonEmptyMessagesPendingCleared: assert len(s.tool_calls) == 2 assert s.tool_calls[0]["assistant_msg_idx"] == s.tool_calls[1]["assistant_msg_idx"] + def test_core_sync_branch_recovers_visible_journal_output(self, hermes_home, monkeypatch): + """The empty-sidecar + populated-core repair branch should still restore + already-journaled visible output from the interrupted stream.""" + s = _make_session(messages=[]) + s.pending_user_message = "Check maintainer activity" + s.pending_started_at = time.time() - 120 + s.active_stream_id = "core_journal_stream" + s.save() + + core_messages = [ + {"role": "user", "content": "Earlier question"}, + {"role": "assistant", "content": "Earlier answer"}, + ] + core_path = _write_core_transcript(hermes_home, s.session_id, core_messages) + + append_run_event( + s.session_id, + "core_journal_stream", + "token", + {"text": "I will check GitHub first."}, + ) + append_run_event( + s.session_id, + "core_journal_stream", + "tool", + { + "name": "terminal", + "preview": "gh pr list --repo nesquena/hermes-webui", + "args": {"command": "gh pr list --repo nesquena/hermes-webui"}, + }, + ) + append_run_event( + s.session_id, + "core_journal_stream", + "tool_complete", + {"name": "terminal", "duration": 1.2, "is_error": False}, + ) + append_run_event( + s.session_id, + "core_journal_stream", + "token", + {"text": "The first check finished before the restart."}, + ) + + result = _apply_core_sync_or_error_marker( + s, + core_path, + stream_id_for_recheck="core_journal_stream", + ) + + assert result is True + contents = [m.get("content", "") for m in s.messages] + assert contents[:2] == [m["content"] for m in core_messages] + recovered_users = [m for m in s.messages if m.get("_recovered")] + assert len(recovered_users) == 1 + assert recovered_users[0]["role"] == "user" + assert recovered_users[0]["content"] == "Check maintainer activity" + assert any("I will check GitHub first." in c for c in contents) + assert any("The first check finished before the restart." in c for c in contents) + assert s.tool_calls, "journaled tool starts should become visible settled tool cards" + assert s.tool_calls[0]["name"] == "terminal" + error_msgs = [m for m in s.messages if m.get("_error")] + assert len(error_msgs) == 1 + assert "partial output above was recovered" in error_msgs[0]["content"] + assert s.pending_user_message is None + assert s.active_stream_id is None + + def test_core_sync_branch_does_not_duplicate_journal_output_already_in_core( + self, hermes_home, monkeypatch + ): + """If the core transcript already contains the same visible output, the + journal repair must not append a second copy.""" + s = _make_session(messages=[]) + s.pending_user_message = "Check maintainer activity" + s.pending_started_at = time.time() - 120 + s.active_stream_id = "duplicate_core_journal_stream" + s.save() + + core_messages = [ + {"role": "user", "content": "Check maintainer activity"}, + {"role": "assistant", "content": "I will check GitHub first."}, + ] + core_tool_calls = [ + { + "name": "terminal", + "preview": "gh pr list --repo nesquena/hermes-webui", + "snippet": "gh pr list --repo nesquena/hermes-webui", + "assistant_msg_idx": 1, + "done": True, + }, + ] + core_path = _write_core_transcript( + hermes_home, + s.session_id, + core_messages, + tool_calls=core_tool_calls, + ) + + append_run_event( + s.session_id, + "duplicate_core_journal_stream", + "token", + {"text": "I will check GitHub first."}, + ) + append_run_event( + s.session_id, + "duplicate_core_journal_stream", + "tool", + { + "name": "terminal", + "preview": "gh pr list --repo nesquena/hermes-webui", + "args": {"command": "gh pr list --repo nesquena/hermes-webui"}, + }, + ) + + result = _apply_core_sync_or_error_marker( + s, + core_path, + stream_id_for_recheck="duplicate_core_journal_stream", + ) + + assert result is True + contents = [m.get("content", "") for m in s.messages] + assert contents.count("I will check GitHub first.") == 1 + assert len(s.tool_calls) == 1 + assert s.tool_calls[0]["name"] == "terminal" + assert not [m for m in s.messages if m.get("_error")] + class TestLastResortSyncDelegation: """_last_resort_sync_from_core delegates to the shared helpers From 8ba16ab294d3696da9e7bf20731c03a518269e59 Mon Sep 17 00:00:00 2001 From: Michael Lam Date: Sat, 16 May 2026 21:44:18 -0700 Subject: [PATCH 059/138] fix: reuse compact live thinking card --- CHANGELOG.md | 4 ++++ static/ui.js | 7 ++++++- tests/test_ui_tool_call_cleanup.py | 7 +++++++ 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 93fb8cdd..3597d75b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Compact live Thinking cards now reuse the same timeline card across sequential tool calls, preventing repeated Thinking cards from stacking during one multi-tool turn. + ## [v0.51.82] — 2026-05-17 — Release BF (stage-375 — 2-PR batch — table renderer pipe protection + Catppuccin appearance skin) ### Added diff --git a/static/ui.js b/static/ui.js index 5a33723d..c8cf2662 100644 --- a/static/ui.js +++ b/static/ui.js @@ -7040,7 +7040,7 @@ function finalizeThinkingCard(){ const summary=group.querySelector('.tool-call-group-summary'); if(summary) summary.setAttribute('aria-expanded','false'); } - const active=group.querySelector('.agent-activity-thinking[data-thinking-active="1"]'); + const active=turn.querySelector('.agent-activity-thinking[data-thinking-active="1"]'); if(active) active.removeAttribute('data-thinking-active'); _syncToolCallGroupSummary(group); } @@ -7095,6 +7095,11 @@ function appendThinking(text='', options){ } const thinkingText=String(text||'').trim()||'Thinking…'; let row=blocks.querySelector('.agent-activity-thinking[data-thinking-active="1"]'); + if(!row){ + const thinkingCards=Array.from(blocks.querySelectorAll('.agent-activity-thinking')); + row=thinkingCards.filter(el=>el.closest('.assistant-turn-blocks')===blocks).pop()||null; + if(row) row.setAttribute('data-thinking-active','1'); + } if(!row){ row=_thinkingActivityNode(thinkingText, false); row.setAttribute('data-thinking-active','1'); diff --git a/tests/test_ui_tool_call_cleanup.py b/tests/test_ui_tool_call_cleanup.py index e647b353..d8d0a9d3 100644 --- a/tests/test_ui_tool_call_cleanup.py +++ b/tests/test_ui_tool_call_cleanup.py @@ -307,6 +307,13 @@ class TestToolCallGroupingStatic: assert "body.querySelector" in live_tool_fn and "data-live-tid" in live_tool_fn, ( "tool_complete must still update its current live Activity burst by tool id." ) + finalize_fn = _function_body(UI_JS, "finalizeThinkingCard") + assert "turn.querySelector('.agent-activity-thinking[data-thinking-active=\"1\"]')" in finalize_fn, ( + "Compact Thinking cards live directly in assistant-turn blocks, so finalization must clear the active marker from the whole turn, not only the tool group." + ) + assert "thinkingCards.filter" in live_thinking_fn and "setAttribute('data-thinking-active','1')" in live_thinking_fn, ( + "Compact live thinking should reactivate the latest existing Thinking card instead of stacking a new card after every tool boundary." + ) close_activity_fn = _function_body(MESSAGES_JS, "_closeCurrentLiveActivityGroup") assert "data-live-activity-current" in close_activity_fn, ( "Visible interim assistant boundaries should close the previous live Activity burst." From cdbb785037177cc8e91464c3c358bd99b01f892d Mon Sep 17 00:00:00 2001 From: Michael Lam Date: Sat, 16 May 2026 22:23:24 -0700 Subject: [PATCH 060/138] fix: invalidate model cache on catalog changes --- CHANGELOG.md | 4 ++ api/config.py | 37 ++++++++++++++++++- ...ssue1699_model_cache_source_fingerprint.py | 36 ++++++++++++++++++ 3 files changed, 76 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 93fb8cdd..56cfd53a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- **PR #2445** by @Michaelyklam (fixes #2443) — `/api/models` now fingerprints model-catalog inputs as part of its persisted cache metadata, so server-side catalog additions and Codex local catalog changes invalidate `models_cache.json` immediately instead of waiting for the 24-hour TTL or manual cache deletion. + ## [v0.51.82] — 2026-05-17 — Release BF (stage-375 — 2-PR batch — table renderer pipe protection + Catppuccin appearance skin) ### Added diff --git a/api/config.py b/api/config.py index ea890e63..302f898a 100644 --- a/api/config.py +++ b/api/config.py @@ -11,6 +11,7 @@ Discovery order for all paths: import collections import copy +import hashlib import json import logging import os @@ -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(), } diff --git a/tests/test_issue1699_model_cache_source_fingerprint.py b/tests/test_issue1699_model_cache_source_fingerprint.py index 30500eb5..0a06d3b8 100644 --- a/tests/test_issue1699_model_cache_source_fingerprint.py +++ b/tests/test_issue1699_model_cache_source_fingerprint.py @@ -142,3 +142,39 @@ def test_disk_models_cache_still_loads_when_auth_and_config_sources_are_unchange result = config.get_available_models() assert result == fresh_opencode + + +def test_memory_models_cache_invalidates_when_static_catalog_changes(tmp_path, monkeypatch): + _configure_isolated_sources(tmp_path, monkeypatch, "opencode-go") + stale_opencode = _valid_models_cache("opencode-go", "glm-5.1") + with config._available_models_cache_lock: + config._available_models_cache = stale_opencode + config._available_models_cache_ts = time.monotonic() + config._available_models_cache_source_fingerprint = config._models_cache_source_fingerprint() + + updated_models = list(config._PROVIDER_MODELS["opencode-go"]) + updated_models.append({"id": "new-catalog-model", "label": "New Catalog Model"}) + monkeypatch.setitem(config._PROVIDER_MODELS, "opencode-go", updated_models) + + result = config.get_available_models() + + opencode_group = next(g for g in result["groups"] if g.get("provider_id") == "opencode-go") + assert any(m.get("id") == "new-catalog-model" for m in opencode_group["models"]) + + +def test_disk_models_cache_invalidates_when_static_catalog_changes(tmp_path, monkeypatch): + _configure_isolated_sources(tmp_path, monkeypatch, "opencode-go") + stale_opencode = _valid_models_cache("opencode-go", "glm-5.1") + config._save_models_cache_to_disk(stale_opencode) + assert config._models_cache_path.exists() + + updated_models = list(config._PROVIDER_MODELS["opencode-go"]) + updated_models.append({"id": "new-disk-catalog-model", "label": "New Disk Catalog Model"}) + monkeypatch.setitem(config._PROVIDER_MODELS, "opencode-go", updated_models) + _reset_memory_cache() + + result = config.get_available_models() + + assert result != stale_opencode + opencode_group = next(g for g in result["groups"] if g.get("provider_id") == "opencode-go") + assert any(m.get("id") == "new-disk-catalog-model" for m in opencode_group["models"]) From fe55cf5b9eda12a0321b204ee52e6993cb53824f Mon Sep 17 00:00:00 2001 From: Frank Song Date: Sun, 17 May 2026 13:14:34 +0800 Subject: [PATCH 061/138] Refresh session context metadata on model changes --- CHANGELOG.md | 4 + api/routes.py | 119 ++++++++++++------ static/boot.js | 30 ++++- static/sessions.js | 19 ++- ...t_issue1436_context_indicator_load_path.py | 104 ++++++++++++++- ..._issue1896_context_length_fallback_args.py | 19 ++- tests/test_session_metadata_fast_path.py | 15 +++ 7 files changed, 251 insertions(+), 59 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 93fb8cdd..812aea51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Refresh context-window metadata when a session's resolved model changes during load or when the user switches models, so high-context models do not stay stuck on a stale prior window and trigger premature compression. Fixes #2442. + ## [v0.51.82] — 2026-05-17 — Release BF (stage-375 — 2-PR batch — table renderer pipe protection + Catppuccin appearance skin) ### Added diff --git a/api/routes.py b/api/routes.py index 677a6677..21bc8632 100644 --- a/api/routes.py +++ b/api/routes.py @@ -1630,6 +1630,57 @@ def _resolve_effective_session_model_provider_for_display(session) -> str | None return provider +def _resolve_context_length_for_session_model( + model: str | None, + provider: str | None = None, +) -> int: + """Best-effort current context window for a session model. + + Persisted session context metadata is a snapshot from a prior model call. + During session hydration/model switching, the current model metadata should + be allowed to replace that stale snapshot. + """ + model_for_lookup = str(model or "").strip() + if not model_for_lookup: + return 0 + try: + from agent.model_metadata import get_model_context_length as _get_cl + from api.config import get_config as _get_config_for_cl + + _cfg_for_cl = _get_config_for_cl() + _cfg_ctx_len_load = None + _cfg_custom_providers_load = None + try: + _model_cfg_load = _cfg_for_cl.get('model', {}) if isinstance(_cfg_for_cl, dict) else {} + if isinstance(_model_cfg_load, dict): + _raw_cfg_ctx_load = _model_cfg_load.get('context_length') + if _raw_cfg_ctx_load is not None: + try: + _parsed_load = int(_raw_cfg_ctx_load) + if _parsed_load > 0: + _cfg_ctx_len_load = _parsed_load + except (TypeError, ValueError): + pass + _raw_cp_load = _cfg_for_cl.get('custom_providers') if isinstance(_cfg_for_cl, dict) else None + if isinstance(_raw_cp_load, list): + _cfg_custom_providers_load = _raw_cp_load + except Exception: + pass + try: + return _get_cl( + model_for_lookup, + "", + config_context_length=_cfg_ctx_len_load, + provider=provider or "", + custom_providers=_cfg_custom_providers_load, + ) or 0 + except TypeError: + # Older hermes-agent builds: legacy 2-arg form. + return _get_cl(model_for_lookup, "") or 0 + except Exception: + return 0 + + def _session_model_state_from_request( model: str | None, requested_provider: str | None, @@ -3553,48 +3604,22 @@ def handle_get(handler, parsed) -> bool: # /api/session/get response — the same wrong-window display this # fix addresses on the streaming side. _persisted_cl = getattr(s, "context_length", 0) or 0 - if not _persisted_cl: + _threshold_tokens = getattr(s, "threshold_tokens", 0) or 0 + if (not _persisted_cl) or resolve_model: _model_for_lookup = ( - getattr(s, "model", "") or effective_model or "" + effective_model or getattr(s, "model", "") or "" ).strip() - if _model_for_lookup: - try: - from agent.model_metadata import get_model_context_length as _get_cl - from api.config import get_config as _get_config_for_cl - _cfg_for_cl = _get_config_for_cl() - _cfg_ctx_len_load = None - _cfg_custom_providers_load = None - try: - _model_cfg_load = _cfg_for_cl.get('model', {}) if isinstance(_cfg_for_cl, dict) else {} - if isinstance(_model_cfg_load, dict): - _raw_cfg_ctx_load = _model_cfg_load.get('context_length') - if _raw_cfg_ctx_load is not None: - try: - _parsed_load = int(_raw_cfg_ctx_load) - if _parsed_load > 0: - _cfg_ctx_len_load = _parsed_load - except (TypeError, ValueError): - pass - _raw_cp_load = _cfg_for_cl.get('custom_providers') if isinstance(_cfg_for_cl, dict) else None - if isinstance(_raw_cp_load, list): - _cfg_custom_providers_load = _raw_cp_load - except Exception: - pass - try: - _fb_cl = _get_cl( - _model_for_lookup, - "", - config_context_length=_cfg_ctx_len_load, - provider=effective_provider or "", - custom_providers=_cfg_custom_providers_load, - ) or 0 - except TypeError: - # Older hermes-agent builds: legacy 2-arg form. - _fb_cl = _get_cl(_model_for_lookup, "") or 0 - if _fb_cl: - _persisted_cl = _fb_cl - except Exception: - pass + _fb_cl = _resolve_context_length_for_session_model( + _model_for_lookup, + effective_provider or getattr(s, "model_provider", None) or "", + ) + if _fb_cl: + if _persisted_cl and _fb_cl != _persisted_cl: + # The old threshold belongs to the old window. Hiding it + # is less misleading than rendering a stale compression + # threshold against a freshly resolved context length. + _threshold_tokens = 0 + _persisted_cl = _fb_cl _session_tool_calls = getattr(s, "tool_calls", []) if load_messages else [] if ( load_messages @@ -3613,7 +3638,7 @@ def handle_get(handler, parsed) -> bool: "pending_attachments": getattr(s, "pending_attachments", []) if load_messages else [], "pending_started_at": getattr(s, "pending_started_at", None), "context_length": _persisted_cl, - "threshold_tokens": getattr(s, "threshold_tokens", 0) or 0, + "threshold_tokens": _threshold_tokens, "last_prompt_tokens": getattr(s, "last_prompt_tokens", 0) or 0, } if original_stream_id: @@ -4638,6 +4663,8 @@ def handle_post(handler, parsed) -> bool: except KeyError: return bad(handler, "Session not found", 404) old_ws = getattr(s, "workspace", "") + old_model = getattr(s, "model", None) + old_provider = getattr(s, "model_provider", None) try: new_ws = str(resolve_trusted_workspace(body.get("workspace", s.workspace))) except ValueError as e: @@ -4653,6 +4680,16 @@ def handle_post(handler, parsed) -> bool: if model is not None: s.model = model s.model_provider = provider + if ( + str(old_model or "") != str(getattr(s, "model", "") or "") + or str(old_provider or "") != str(getattr(s, "model_provider", "") or "") + ): + s.context_length = _resolve_context_length_for_session_model( + getattr(s, "model", None), + getattr(s, "model_provider", None), + ) + s.threshold_tokens = 0 + s.last_prompt_tokens = 0 s.save() if str(old_ws or "") != str(new_ws or ""): try: diff --git a/static/boot.js b/static/boot.js index d48123e1..9464f167 100644 --- a/static/boot.js +++ b/static/boot.js @@ -907,6 +907,25 @@ function clearPreview(opts={}){ } $('btnClearPreview').onclick=handleWorkspaceClose; // workspacePath click handler removed -- use topbar workspace chip dropdown instead +function _applySessionContextMetadataUpdate(data){ + if(!S.session||!data||!data.session)return; + S.session.context_length=data.session.context_length||0; + S.session.threshold_tokens=data.session.threshold_tokens||0; + S.session.last_prompt_tokens=data.session.last_prompt_tokens||0; + if(typeof _syncCtxIndicator==='function'){ + const u=S.lastUsage||{}; + const _pick=(latest,stored,dflt=0)=>latest!=null?latest:(stored!=null?stored:dflt); + _syncCtxIndicator({ + input_tokens:_pick(u.input_tokens,S.session.input_tokens), + output_tokens:_pick(u.output_tokens,S.session.output_tokens), + estimated_cost:_pick(u.estimated_cost,S.session.estimated_cost), + context_length:S.session.context_length||0, + last_prompt_tokens:_pick(u.last_prompt_tokens,S.session.last_prompt_tokens), + threshold_tokens:S.session.threshold_tokens||0, + }); + } +} + $('modelSelect').onchange=async()=>{ if(!S.session)return; const selectedModel=$('modelSelect').value; @@ -916,7 +935,11 @@ $('modelSelect').onchange=async()=>{ if(typeof closeModelDropdown==='function') closeModelDropdown(); if(typeof _writePersistedModelState==='function') _writePersistedModelState(modelState.model,modelState.model_provider); else try{localStorage.setItem('hermes-webui-model',modelState.model)}catch{} - await api('/api/session/update',{method:'POST',body:JSON.stringify({ + // Clarify scope: composer model changes are session-local, not the global default. + if(typeof showToast==='function'){ + showToast(t('model_scope_toast')||'Applies to this conversation from your next message.', 3000); + } + const data=await api('/api/session/update',{method:'POST',body:JSON.stringify({ session_id:S.session.session_id, workspace:S.session.workspace, model:modelState.model, @@ -926,10 +949,7 @@ $('modelSelect').onchange=async()=>{ S.session.model_provider=modelState.model_provider||null; if(typeof syncModelChip==='function') syncModelChip(); syncTopbar(); - // Clarify scope: composer model changes are session-local, not the global default. - if(typeof showToast==='function'){ - showToast(t('model_scope_toast')||'Applies to this conversation from your next message.', 3000); - } + _applySessionContextMetadataUpdate(data); // Warn if selected model belongs to a different provider than what Hermes is configured for if(typeof _checkProviderMismatch==='function'){ const warn=_checkProviderMismatch(selectedModel); diff --git a/static/sessions.js b/static/sessions.js index 1665ee8f..0e15f267 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -707,9 +707,9 @@ async function loadSession(sid){ input_tokens: _pick(u.input_tokens, _s.input_tokens), output_tokens: _pick(u.output_tokens, _s.output_tokens), estimated_cost: _pick(u.estimated_cost, _s.estimated_cost), - context_length: _pick(u.context_length, _s.context_length), + context_length: _pick(_s.context_length, u.context_length), last_prompt_tokens:_pick(u.last_prompt_tokens,_s.last_prompt_tokens), - threshold_tokens: _pick(u.threshold_tokens, _s.threshold_tokens), + threshold_tokens: _pick(_s.threshold_tokens, u.threshold_tokens), }); } if(typeof _renderPendingPromptsForActiveSession==='function') _renderPendingPromptsForActiveSession(); @@ -1102,8 +1102,23 @@ function _resolveSessionModelForDisplaySoon(sid){ if(!model||!S.session||S.session.session_id!==sid) return; S.session.model=model; S.session.model_provider=provider||null; + S.session.context_length=data.session.context_length||0; + S.session.threshold_tokens=data.session.threshold_tokens||0; + S.session.last_prompt_tokens=data.session.last_prompt_tokens||0; S.session._modelResolutionDeferred=false; syncTopbar(); + if(typeof _syncCtxIndicator==='function'){ + const u=S.lastUsage||{}; + const _pick=(latest,stored,dflt=0)=>latest!=null?latest:(stored!=null?stored:dflt); + _syncCtxIndicator({ + input_tokens:_pick(u.input_tokens,S.session.input_tokens), + output_tokens:_pick(u.output_tokens,S.session.output_tokens), + estimated_cost:_pick(u.estimated_cost,S.session.estimated_cost), + context_length:data.session.context_length||0, + last_prompt_tokens:_pick(u.last_prompt_tokens,S.session.last_prompt_tokens), + threshold_tokens:data.session.threshold_tokens||0, + }); + } }catch(_){ // Keep session switching non-blocking; the next load can try again. } diff --git a/tests/test_issue1436_context_indicator_load_path.py b/tests/test_issue1436_context_indicator_load_path.py index 59a11b0e..608f616e 100644 --- a/tests/test_issue1436_context_indicator_load_path.py +++ b/tests/test_issue1436_context_indicator_load_path.py @@ -103,7 +103,7 @@ class TestIssue1436BackendFallback: return captured def test_persisted_context_length_passed_through_unchanged(self): - """When Session.context_length is non-zero, return it as-is (no fallback).""" + """Fast metadata loads keep the persisted value to avoid catalog work.""" s = self._stub_session(context_length=200_000, model="claude-sonnet-4.6") result = self._invoke_get_session(s, fallback_returns=999_999) body = result["data"]["session"] @@ -112,6 +112,94 @@ class TestIssue1436BackendFallback: f"got {body['context_length']}" ) + def test_resolved_model_load_refreshes_stale_persisted_context_length(self): + """The deferred resolve_model=1 load must refresh stale context metadata. + + Session switching first asks for messages=0&resolve_model=0 for speed, + then follows with messages=0&resolve_model=1 to hydrate the final + model/provider display. That second path is also where a stale + context_length from a prior model must be corrected; otherwise a + resumed DeepSeek 1M session can stay stuck on an old 200k window until + the user manually toggles models. + """ + import api.routes as routes + + captured = {} + + def fake_j(h, data, status=200): + captured["data"] = data + + fake_module = MagicMock() + fake_module.get_model_context_length = MagicMock(return_value=1_000_000) + + s = self._stub_session(context_length=200_000, model="deepseek-v4-pro") + handler = MagicMock() + parsed = urlparse( + "/api/session?session_id=test-1436&messages=0&resolve_model=1" + ) + + with patch("api.routes.get_session", return_value=s), \ + patch("api.routes.j", side_effect=fake_j), \ + patch.dict("sys.modules", {"agent.model_metadata": fake_module}): + routes.handle_get(handler, parsed) + + body = captured["data"]["session"] + assert body["context_length"] == 1_000_000, ( + "resolve_model=1 must refresh stale persisted context_length from " + "current model metadata" + ) + + def test_session_model_update_refreshes_context_metadata(self): + """Changing the session model must not keep the prior model's window.""" + import api.routes as routes + + captured = {} + + def fake_j(h, data, status=200): + captured["data"] = data + + fake_module = MagicMock() + fake_module.get_model_context_length = MagicMock(return_value=1_000_000) + + s = self._stub_session(context_length=200_000, model="old-model") + s.model_provider = "old-provider" + s.workspace = "/tmp" + s.threshold_tokens = 100_000 + s.last_prompt_tokens = 80_000 + s.save = MagicMock() + s.compact.return_value = { + **s.compact.return_value, + "model": "deepseek-v4-pro", + "model_provider": "deepseek", + "context_length": 1_000_000, + "threshold_tokens": 0, + "last_prompt_tokens": 0, + } + handler = MagicMock() + parsed = urlparse("/api/session/update") + + body = { + "session_id": "test-1436", + "workspace": "/tmp", + "model": "deepseek-v4-pro", + "model_provider": "deepseek", + } + with patch("api.routes._check_csrf", return_value=True), \ + patch("api.routes.read_body", return_value=body), \ + patch("api.routes.get_session", return_value=s), \ + patch("api.routes.resolve_trusted_workspace", return_value="/tmp"), \ + patch("api.routes.j", side_effect=fake_j), \ + patch.dict("sys.modules", {"agent.model_metadata": fake_module}): + routes.handle_post(handler, parsed) + + assert s.model == "deepseek-v4-pro" + assert s.model_provider == "deepseek" + assert s.context_length == 1_000_000 + assert s.threshold_tokens == 0 + assert s.last_prompt_tokens == 0 + s.save.assert_called_once() + assert captured["data"]["session"]["context_length"] == 1_000_000 + def test_zero_context_length_falls_back_to_model_metadata(self): """Pre-#1318 sessions with context_length=0 must resolve via model_metadata.""" s = self._stub_session(context_length=0, model="claude-opus-4-7", @@ -276,13 +364,19 @@ class TestIssue1436SourceMarkers: def test_routes_load_path_imports_get_model_context_length(self): src = ROUTES.read_text(encoding="utf-8") - # The import must appear inside the GET /api/session load-path block. + # The session load path can call a helper, but the lazy import must + # remain in routes.py so WebUI still works with older/missing agent + # bundles by swallowing metadata-resolution failures. start = src.find('if parsed.path == "/api/session":') end = src.find('if parsed.path == "/api/projects":', start) block = src[start:end] - assert "from agent.model_metadata import get_model_context_length" in block, ( - "GET /api/session load-path block must lazy-import " - "get_model_context_length for the context_length=0 fallback (#1436)" + assert "_resolve_context_length_for_session_model" in block, ( + "GET /api/session load-path block must resolve model context " + "metadata for the context_length fallback (#1436)" + ) + assert "from agent.model_metadata import get_model_context_length" in src, ( + "routes.py must lazy-import get_model_context_length for the " + "context_length fallback (#1436)" ) def test_routes_load_path_marks_fix_with_issue_number(self): diff --git a/tests/test_issue1896_context_length_fallback_args.py b/tests/test_issue1896_context_length_fallback_args.py index 9e2af74f..d22ba1e5 100644 --- a/tests/test_issue1896_context_length_fallback_args.py +++ b/tests/test_issue1896_context_length_fallback_args.py @@ -202,25 +202,32 @@ def test_routes_session_load_fallback_passes_config_overrides(): anchor = "older sessions (pre-#1318) that have context_length=0 persisted" idx = ROUTES_PY.find(anchor) assert idx != -1, "session-load fallback comment moved/removed" - # Find the resolver callsite that follows. + # The route block may delegate the resolver details to a helper, but the + # session-load path must still call the helper and that helper must preserve + # the same kwargs as the streaming.py fix. block_end = ROUTES_PY.find("if _fb_cl:", idx) assert block_end != -1, "_fb_cl assignment not found after fallback comment" block = ROUTES_PY[idx:block_end] + helper_start = ROUTES_PY.find("def _resolve_context_length_for_session_model") + assert helper_start != -1, "context-length resolver helper not found" + helper_end = ROUTES_PY.find("\ndef ", helper_start + 1) + helper = ROUTES_PY[helper_start:helper_end if helper_end != -1 else len(ROUTES_PY)] + assert "_resolve_context_length_for_session_model" in block # Same kwargs as the streaming.py fix. - assert "config_context_length=" in block, ( + assert "config_context_length=" in helper, ( "session-load fallback in api/routes.py must pass config_context_length= " "so user-set model.context_length wins over the 256K default. See #1896." ) - assert "provider=effective_provider" in block, ( - "session-load fallback in api/routes.py must pass provider=effective_provider " + assert "provider=provider or" in helper, ( + "session-load fallback in api/routes.py must pass provider= " "so the registry lookup is provider-aware. See #1896." ) - assert "custom_providers=" in block, ( + assert "custom_providers=" in helper, ( "session-load fallback in api/routes.py must pass custom_providers= " "so the per-model override path applies. See #1896." ) # Legacy fallback for older hermes-agent builds that pre-date the kwargs. - assert "except TypeError:" in block, ( + assert "except TypeError:" in helper, ( "session-load fallback must catch TypeError to support older " "hermes-agent builds without the new kwargs." ) diff --git a/tests/test_session_metadata_fast_path.py b/tests/test_session_metadata_fast_path.py index c4e5e719..b3c3bacc 100644 --- a/tests/test_session_metadata_fast_path.py +++ b/tests/test_session_metadata_fast_path.py @@ -42,6 +42,21 @@ def test_session_switch_defers_model_resolution_without_blocking(): assert "if(fallback&&!deferModelCorrection)" in ui +def test_deferred_model_resolution_refreshes_context_metadata(): + src = (ROOT / "static" / "sessions.js").read_text(encoding="utf-8") + start = src.index("function _resolveSessionModelForDisplaySoon") + end = src.index("const _INITIAL_MSG_LIMIT", start) + block = src[start:end] + + assert "S.session.context_length" in block, ( + "deferred model resolution must also hydrate context_length so a " + "resumed high-context session does not keep the old model's limit" + ) + assert "S.session.threshold_tokens" in block + assert "_syncCtxIndicator" in block + assert "context_length:data.session.context_length||0" in block.replace(" ", "") + + def test_boot_does_not_block_session_restore_on_model_catalog(): src = (ROOT / "static" / "boot.js").read_text(encoding="utf-8") From 87e3b4e88ea48531b3902f1fc3e0117f6879fc72 Mon Sep 17 00:00:00 2001 From: Michael Lam Date: Sun, 17 May 2026 00:27:17 -0700 Subject: [PATCH 062/138] fix: cap stream fade done drain --- CHANGELOG.md | 4 ++++ static/messages.js | 12 ++++++++++++ tests/test_smooth_text_fade.py | 15 +++++++++++++++ 3 files changed, 31 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 93fb8cdd..6e45e26e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- **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 are rendered from the canonical session promptly instead of keeping the chat in a live/working state until Stop is pressed. + ## [v0.51.82] — 2026-05-17 — Release BF (stage-375 — 2-PR batch — table renderer pipe protection + Catppuccin appearance skin) ### Added diff --git a/static/messages.js b/static/messages.js index 7d40e2f9..0c77e89e 100644 --- a/static/messages.js +++ b/static/messages.js @@ -697,6 +697,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ const _STREAM_FADE_MAX_MS=350; const _STREAM_FADE_STAGGER_MS=16; const _STREAM_FADE_DONE_MAX_MS=320; + const _STREAM_FADE_DONE_DRAIN_MAX_MS=900; const _streamFadeEnabledForStream=window._fadeTextEffect===true; // rAF-throttled rendering: buffer tokens, render at most once per frame @@ -1086,6 +1087,8 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ : _stripXmlToolCalls(assistantText.slice(segmentStart)); } function _drainStreamFadeBeforeDone(onDone){ + const drainStartedAt=performance.now(); + let forcedDone=false; const step=()=>{ if(!assistantBody){onDone();return;} const target=_streamFadeCurrentDisplayText(); @@ -1101,6 +1104,15 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ setTimeout(onDone, Math.min(remainingAnimationMs, _STREAM_FADE_DONE_MAX_MS)); return; } + // Final SSE `done` means the canonical completed session is available. + // The optional word-fade playout must not keep that completed answer + // hidden behind the live Thinking state for large/bursty responses. + if(!forcedDone&&performance.now()-drainStartedAt>=_STREAM_FADE_DONE_DRAIN_MAX_MS){ + forcedDone=true; + if(_smdParser) _smdEndParser(); + onDone(); + return; + } setTimeout(()=>requestAnimationFrame(step), 33); }; step(); diff --git a/tests/test_smooth_text_fade.py b/tests/test_smooth_text_fade.py index ee5f8ff7..1faad4d6 100644 --- a/tests/test_smooth_text_fade.py +++ b/tests/test_smooth_text_fade.py @@ -82,6 +82,7 @@ const _STREAM_FADE_MS=200; const _STREAM_FADE_MAX_MS=350; const _STREAM_FADE_STAGGER_MS=16; const _STREAM_FADE_DONE_MAX_MS=320; +const _STREAM_FADE_DONE_DRAIN_MAX_MS=900; const performance={performance_stub}; {helpers} """ @@ -178,6 +179,20 @@ def test_stream_fade_uses_incremental_renderer_without_changing_default_path(): assert "_wrapStreamingFadeWords" not in MESSAGES_JS +def test_stream_fade_done_drain_has_hard_cap_for_large_buffered_responses(): + drain_block = function_block(MESSAGES_JS, "_drainStreamFadeBeforeDone") + assert "const _STREAM_FADE_DONE_DRAIN_MAX_MS=900" in MESSAGES_JS + assert_contains_all( + drain_block, + [ + "const drainStartedAt=performance.now();", + "performance.now()-drainStartedAt>=_STREAM_FADE_DONE_DRAIN_MAX_MS", + "if(_smdParser) _smdEndParser();", + "onDone();", + ], + ) + + def test_stream_fade_css_is_opacity_only_and_hides_live_cursor(): fade_css = STYLE_CSS[STYLE_CSS.index("OpenWebUI-style streaming word fade") :] assert "filter:" not in STYLE_CSS[STYLE_CSS.index("OpenWebUI-style streaming word fade") :].split( From 00295a916ac5680a4d81d49aedb8c6a0ea90cca5 Mon Sep 17 00:00:00 2001 From: Michael Lam Date: Sun, 17 May 2026 01:36:46 -0700 Subject: [PATCH 063/138] fix: deliver manual cron run results --- CHANGELOG.md | 4 + api/routes.py | 38 +++++++- tests/test_cron_manual_run_persistence.py | 114 ++++++++++++++++++---- 3 files changed, 133 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 93fb8cdd..7b618775 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- **PR #2452** by @Michaelyklam (fixes #2451) — Manual WebUI cron triggers now deliver the same final response or failure notice as scheduled cron runs, while still saving output files and recording delivery errors separately from job execution failures. + ## [v0.51.82] — 2026-05-17 — Release BF (stage-375 — 2-PR batch — table renderer pipe protection + Catppuccin appearance skin) ### Added diff --git a/api/routes.py b/api/routes.py index 677a6677..c6056d02 100644 --- a/api/routes.py +++ b/api/routes.py @@ -754,8 +754,15 @@ def _run_cron_tracked(job, profile_home=None, execution_profile_home=None): agent config/.env while running. When no job profile is selected, both homes are the same and legacy server-default behavior is preserved. """ + import importlib + from cron.jobs import mark_job_run, save_job_output + _cron_scheduler = importlib.import_module("cron.scheduler") + + _silent_marker = getattr(_cron_scheduler, "SILENT_MARKER", "[SILENT]") + _deliver_result = getattr(_cron_scheduler, "_deliver_result", None) + job_id = job.get("id", "") execution_profile_home = execution_profile_home or profile_home @@ -772,11 +779,29 @@ def _run_cron_tracked(job, profile_home=None, execution_profile_home=None): job, execution_profile_home ) - # Persist output and run metadata back to the job's owning cron store, - # even when the selected execution profile is different. + # Persist output, deliver the same content the scheduled cron path would + # send, and write run metadata back to the job's owning cron store even + # when the selected execution profile is different. def _persist_success(): save_job_output(job_id, output) + deliver_content = ( + final_response + if success + else f"⚠️ Cron job '{job.get('name', job_id)}' failed:\n{error}" + ) + should_deliver = bool(deliver_content) + if should_deliver and success and _silent_marker in deliver_content.strip().upper(): + should_deliver = False + + delivery_error = None + if should_deliver and _deliver_result is not None: + try: + delivery_error = _deliver_result(job, deliver_content) + except Exception as de: + delivery_error = str(de) + logger.error("Delivery failed for manual cron job %s: %s", job_id, de) + # Match the scheduled cron path: an apparently successful run with no # final response should not leave the job looking healthy. _success, _error = success, error @@ -784,7 +809,14 @@ def _run_cron_tracked(job, profile_home=None, execution_profile_home=None): _success = False _error = "Agent completed but produced empty response (model error, timeout, or misconfiguration)" - mark_job_run(job_id, _success, _error) + try: + mark_job_run(job_id, _success, _error, delivery_error=delivery_error) + except TypeError: + # Older/fake cron.jobs modules used by focused WebUI tests may + # not expose the newer delivery_error parameter. Real Hermes + # scheduler builds do, so this is only a compatibility shim for + # legacy test doubles and deployments. + mark_job_run(job_id, _success, _error) _with_cron_home(profile_home, _persist_success) except Exception as e: diff --git a/tests/test_cron_manual_run_persistence.py b/tests/test_cron_manual_run_persistence.py index 49943b63..7d281592 100644 --- a/tests/test_cron_manual_run_persistence.py +++ b/tests/test_cron_manual_run_persistence.py @@ -1,21 +1,32 @@ """Regression tests for manual WebUI cron runs.""" - -def test_manual_cron_run_saves_output_and_marks_job(monkeypatch): - import api.routes as routes - - calls = [] - +def _install_cron_fakes(monkeypatch, calls, deliver_result=None, silent_marker="[SILENT]"): cron_jobs = type("CronJobs", (), {})() cron_jobs.save_job_output = lambda job_id, output: calls.append( ("save", job_id, output) ) - cron_jobs.mark_job_run = lambda job_id, success, error=None: calls.append( - ("mark", job_id, success, error) + cron_jobs.mark_job_run = lambda job_id, success, error=None, delivery_error=None: calls.append( + ("mark", job_id, success, error, delivery_error) ) + cron_scheduler = type("CronScheduler", (), {})() + cron_scheduler.SILENT_MARKER = silent_marker + if deliver_result is None: + deliver_result = lambda job, content: calls.append( + ("deliver", job["id"], content) + ) or None + cron_scheduler._deliver_result = deliver_result + monkeypatch.setitem(__import__("sys").modules, "cron.jobs", cron_jobs) + monkeypatch.setitem(__import__("sys").modules, "cron.scheduler", cron_scheduler) + + +def test_manual_cron_run_saves_output_delivers_and_marks_job(monkeypatch): + import api.routes as routes + + calls = [] + _install_cron_fakes(monkeypatch, calls) monkeypatch.setattr( routes, "_run_cron_job_in_profile_subprocess", @@ -27,25 +38,17 @@ def test_manual_cron_run_saves_output_and_marks_job(monkeypatch): assert calls == [ ("save", "job123", "manual output"), - ("mark", "job123", True, None), + ("deliver", "job123", "done"), + ("mark", "job123", True, None, None), ] assert routes._is_cron_running("job123") == (False, 0.0) -def test_manual_cron_run_marks_empty_response_as_failure(monkeypatch): +def test_manual_cron_run_marks_empty_response_as_failure_without_delivery(monkeypatch): import api.routes as routes calls = [] - - cron_jobs = type("CronJobs", (), {})() - cron_jobs.save_job_output = lambda job_id, output: calls.append( - ("save", job_id, output) - ) - cron_jobs.mark_job_run = lambda job_id, success, error=None: calls.append( - ("mark", job_id, success, error) - ) - - monkeypatch.setitem(__import__("sys").modules, "cron.jobs", cron_jobs) + _install_cron_fakes(monkeypatch, calls) monkeypatch.setattr( routes, "_run_cron_job_in_profile_subprocess", @@ -58,4 +61,75 @@ def test_manual_cron_run_marks_empty_response_as_failure(monkeypatch): assert calls[0] == ("save", "job-empty", "manual output") assert calls[1][0:3] == ("mark", "job-empty", False) assert "empty response" in calls[1][3] + assert calls[1][4] is None assert routes._is_cron_running("job-empty") == (False, 0.0) + + +def test_manual_cron_run_records_delivery_errors_separately(monkeypatch): + import api.routes as routes + + calls = [] + + def fail_delivery(job, content): + calls.append(("deliver", job["id"], content)) + return "discord not configured" + + _install_cron_fakes(monkeypatch, calls, deliver_result=fail_delivery) + monkeypatch.setattr( + routes, + "_run_cron_job_in_profile_subprocess", + lambda job, execution_profile_home: (True, "manual output", "done", None), + ) + + routes._mark_cron_running("job-delivery-error") + routes._run_cron_tracked({"id": "job-delivery-error"}) + + assert calls == [ + ("save", "job-delivery-error", "manual output"), + ("deliver", "job-delivery-error", "done"), + ("mark", "job-delivery-error", True, None, "discord not configured"), + ] + assert routes._is_cron_running("job-delivery-error") == (False, 0.0) + + +def test_manual_cron_run_skips_silent_success_delivery(monkeypatch): + import api.routes as routes + + calls = [] + _install_cron_fakes(monkeypatch, calls) + monkeypatch.setattr( + routes, + "_run_cron_job_in_profile_subprocess", + lambda job, execution_profile_home: (True, "manual output", "[SILENT]", None), + ) + + routes._mark_cron_running("job-silent") + routes._run_cron_tracked({"id": "job-silent"}) + + assert calls == [ + ("save", "job-silent", "manual output"), + ("mark", "job-silent", True, None, None), + ] + assert routes._is_cron_running("job-silent") == (False, 0.0) + + +def test_manual_cron_run_delivers_failure_notice(monkeypatch): + import api.routes as routes + + calls = [] + _install_cron_fakes(monkeypatch, calls) + monkeypatch.setattr( + routes, + "_run_cron_job_in_profile_subprocess", + lambda job, execution_profile_home: (False, "manual output", "", "boom"), + ) + + routes._mark_cron_running("job-failed") + routes._run_cron_tracked({"id": "job-failed", "name": "Nightly check"}) + + assert calls[0] == ("save", "job-failed", "manual output") + assert calls[1][0:2] == ("deliver", "job-failed") + assert "Nightly check" in calls[1][2] + assert "boom" in calls[1][2] + assert calls[2] == ("mark", "job-failed", False, "boom", None) + assert routes._is_cron_running("job-failed") == (False, 0.0) From aecad0f4278a2eb65f0d859a715127016c40a8e8 Mon Sep 17 00:00:00 2001 From: starship-s <45587122+starship-s@users.noreply.github.com> Date: Sun, 17 May 2026 03:30:06 -0600 Subject: [PATCH 064/138] [verified] Fix WebUI memory session lifecycle commits --- api/config.py | 33 +- api/routes.py | 14 + api/session_lifecycle.py | 193 +++++++++ api/streaming.py | 73 +++- server.py | 6 + static/sessions.js | 1 + ...est_memory_session_lifecycle_generation.py | 388 ++++++++++++++++++ 7 files changed, 694 insertions(+), 14 deletions(-) create mode 100644 api/session_lifecycle.py create mode 100644 tests/test_memory_session_lifecycle_generation.py diff --git a/api/config.py b/api/config.py index ea890e63..1c8f8525 100644 --- a/api/config.py +++ b/api/config.py @@ -3983,9 +3983,38 @@ SESSION_AGENT_CACHE_LOCK = threading.Lock() def _evict_session_agent(session_id: str) -> None: - """Remove a cached agent for a session (on delete, clear, or model switch).""" + """Remove a cached agent for a session (on delete, clear, or model switch). + + Attempts a lifecycle commit before dropping the agent handle so that + batch-extraction memory providers can extract any pending work. If the + commit fails or there is uncommitted work with no successful commit, the + lifecycle entry is preserved (not unregistered) so a future commit can + retry. + """ + agent = None with SESSION_AGENT_CACHE_LOCK: - SESSION_AGENT_CACHE.pop(session_id, None) + entry = SESSION_AGENT_CACHE.pop(session_id, None) + if entry is not None: + agent = entry[0] if isinstance(entry, tuple) else None + if agent is None: + return + should_close = True + try: + from api.session_lifecycle import commit_session_memory, has_uncommitted_work, unregister_agent + if has_uncommitted_work(session_id): + commit_session_memory(session_id, agent=agent, wait=True) + if not has_uncommitted_work(session_id): + unregister_agent(session_id) + else: + should_close = False + except Exception: + should_close = False + logger.debug("Lifecycle commit on eviction failed for %s", session_id, exc_info=True) + if should_close and getattr(agent, '_session_db', None) is not None: + try: + agent._session_db.close() + except Exception: + logger.debug("Failed to close _session_db on eviction for %s", session_id, exc_info=True) # ── Thread-local env context ───────────────────────────────────────────────── _thread_ctx = threading.local() diff --git a/api/routes.py b/api/routes.py index 677a6677..2c7e493d 100644 --- a/api/routes.py +++ b/api/routes.py @@ -4340,6 +4340,20 @@ def handle_post(handler, parsed) -> bool: ) # Use the profile sent by the client tab (if any) so that two tabs on # different profiles never clobber each other via the process-level global. + # ── Memory lifecycle: commit the previous session before starting a new one ── + prev_session_id = body.get("prev_session_id") + if prev_session_id: + try: + from api.session_lifecycle import commit_session_memory + from api.config import SESSION_AGENT_CACHE, SESSION_AGENT_CACHE_LOCK + prev_agent = None + with SESSION_AGENT_CACHE_LOCK: + _cached = SESSION_AGENT_CACHE.get(prev_session_id) + if _cached: + prev_agent = _cached[0] + commit_session_memory(prev_session_id, agent=prev_agent) + except Exception: + logger.debug("Lifecycle commit for prev_session %s failed", prev_session_id, exc_info=True) s = new_session( workspace=workspace, model=model, diff --git a/api/session_lifecycle.py b/api/session_lifecycle.py new file mode 100644 index 00000000..8d96858b --- /dev/null +++ b/api/session_lifecycle.py @@ -0,0 +1,193 @@ +""" +Hermes WebUI memory-provider session lifecycle. + +Batch-extraction memory providers (OpenViking, Holographic) only extract memories +when AIAgent.commit_memory_session() invokes provider on_session_end(). WebUI +sessions can be reopened and continued many times, so the lifecycle must guarantee: + +1. Only completed, non-ephemeral turns are committable. +2. A commit finishing late must not erase work completed while it was in flight. +3. A failed commit preserves the uncommitted generation and owning agent handle. +4. Replacement/reopened agents cannot steal older dirty generations. +5. Overlapping commits are serialised via a per-session in-flight guard. + +The design uses a monotonic generation counter per session plus per-generation +agent ownership segments. mark_turn_completed() records which agent owns the new +generation. commit_session_memory() commits the earliest uncommitted segment and +compare-and-clears only that captured segment after success. +""" + +from __future__ import annotations + +import logging +import threading +import time + +logger = logging.getLogger(__name__) + +_lock = threading.Lock() +_condition = threading.Condition(_lock) + +_sessions: dict[str, dict] = {} + + +def _new_entry() -> dict: + return { + "generation": 0, + "committed_generation": 0, + "agent": None, + "in_flight": False, + "segments": [], + } + + +def _reset_for_tests() -> None: + with _condition: + _sessions.clear() + _condition.notify_all() + + +def register_agent(session_id: str, agent) -> None: + """Register the current agent handle for future completed generations. + + Existing dirty generations keep their original segment owner. This prevents + a rebuilt/reopened agent from overwriting the handle needed to retry older + failed memory-provider work. + """ + if not session_id: + return + with _condition: + entry = _sessions.setdefault(session_id, _new_entry()) + entry["agent"] = agent + _condition.notify_all() + + +def unregister_agent(session_id: str) -> None: + """Clear the current future-generation agent handle. + + Dirty segment owners are intentionally preserved so failed work remains + retryable even if the cache drops the current agent reference. + """ + if not session_id: + return + with _condition: + entry = _sessions.get(session_id) + if entry is not None: + entry["agent"] = None + _condition.notify_all() + + +def mark_turn_completed(session_id: str, *, agent=None) -> int: + if not session_id: + return 0 + with _condition: + entry = _sessions.setdefault(session_id, _new_entry()) + if agent is not None: + entry["agent"] = agent + owner = agent if agent is not None else entry.get("agent") + entry["generation"] += 1 + generation = entry["generation"] + segments = entry["segments"] + if segments and not entry["in_flight"] and segments[-1].get("agent") is owner: + segments[-1]["end"] = generation + else: + segments.append({"start": generation, "end": generation, "agent": owner}) + _condition.notify_all() + return generation + + +def has_uncommitted_work(session_id: str) -> bool: + if not session_id: + return False + with _lock: + entry = _sessions.get(session_id) + if entry is None: + return False + return entry["generation"] > entry["committed_generation"] + + +def _first_uncommitted_segment(entry: dict) -> dict | None: + committed = entry["committed_generation"] + for segment in entry["segments"]: + if segment["end"] > committed: + return segment + return None + + +def commit_session_memory(session_id: str, agent=None, *, wait: bool = False, timeout: float | None = None) -> bool: + if not session_id: + return False + deadline = time.monotonic() + timeout if timeout is not None else None + with _condition: + entry = _sessions.get(session_id) + if entry is None: + return False + while entry["in_flight"]: + if not wait: + return False + if deadline is None: + _condition.wait() + else: + remaining = deadline - time.monotonic() + if remaining <= 0: + return False + _condition.wait(remaining) + entry = _sessions.get(session_id) + if entry is None: + return False + if entry["generation"] <= entry["committed_generation"]: + return False + segment = _first_uncommitted_segment(entry) + if segment is None: + return False + effective_agent = segment.get("agent") + if effective_agent is None: + effective_agent = agent if agent is not None else entry.get("agent") + if effective_agent is not None: + segment["agent"] = effective_agent + if effective_agent is None: + return False + captured_generation = segment["end"] + entry["in_flight"] = True + + try: + effective_agent.commit_memory_session() + except Exception: + logger.exception("commit_memory_session() failed for session %s", session_id) + with _condition: + re_entry = _sessions.get(session_id) + if re_entry is not None: + re_entry["in_flight"] = False + _condition.notify_all() + return False + + with _condition: + re_entry = _sessions.get(session_id) + if re_entry is not None: + re_entry["in_flight"] = False + if captured_generation > re_entry["committed_generation"]: + re_entry["committed_generation"] = captured_generation + committed = re_entry["committed_generation"] + segments = re_entry["segments"] + while segments and segments[0]["end"] <= committed: + segments.pop(0) + if segments and segments[0]["start"] <= committed: + segments[0]["start"] = committed + 1 + _condition.notify_all() + return True + + +def drain_all_on_shutdown() -> None: + while True: + with _lock: + snapshot = [sid for sid, entry in _sessions.items() if entry["generation"] > entry["committed_generation"]] + if not snapshot: + return + + made_progress = False + for sid in snapshot: + if commit_session_memory(sid, wait=True): + made_progress = True + if not made_progress: + logger.debug("drain_all_on_shutdown: stopped with uncommitted sessions: %s", sorted(snapshot)) + return diff --git a/api/streaming.py b/api/streaming.py index ee3f714f..96d7978a 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -3627,6 +3627,13 @@ def _run_agent_streaming( agent = _cached[0] SESSION_AGENT_CACHE.move_to_end(session_id) # LRU: mark as recently used logger.debug('[webui] Reusing cached agent for session %s', session_id) + # Reopened/cache-hit sessions must register the agent + # so later lifecycle commits can find it. + try: + from api.session_lifecycle import register_agent + register_agent(session_id, agent) + except Exception: + logger.debug("Lifecycle register_agent failed for cached session %s", session_id, exc_info=True) if agent is not None: # Refresh volatile runtime credentials selected from provider @@ -3683,24 +3690,47 @@ def _run_agent_streaming( agent._interrupt_message = None else: agent = _AIAgent(**_agent_kwargs) + # Register the new agent with the memory lifecycle so + # its commit_memory_session() can be found later. + try: + from api.session_lifecycle import register_agent + register_agent(session_id, agent) + except Exception: + logger.debug("Lifecycle register_agent failed for new session %s", session_id, exc_info=True) + _evicted_items = [] with SESSION_AGENT_CACHE_LOCK: SESSION_AGENT_CACHE[session_id] = (agent, _agent_sig) SESSION_AGENT_CACHE.move_to_end(session_id) # LRU: mark as recently used from api.config import SESSION_AGENT_CACHE_MAX while len(SESSION_AGENT_CACHE) > SESSION_AGENT_CACHE_MAX: evicted_sid, evicted_entry = SESSION_AGENT_CACHE.popitem(last=False) - # Same FD-leak shape as the cached-agent reuse path - # in #1421: the evicted agent's _session_db won't be - # released until GC finalizes the agent, which on a - # long-running server may be never. Close it - # explicitly so the WAL handles release immediately. - try: - _evicted_agent = evicted_entry[0] if isinstance(evicted_entry, tuple) else None - if _evicted_agent is not None and getattr(_evicted_agent, '_session_db', None) is not None: - _evicted_agent._session_db.close() - except Exception: - pass - logger.debug('[webui] Evicted LRU agent from cache: %s', evicted_sid) + _evicted_items.append((evicted_sid, evicted_entry)) + # Commit and close evicted agents outside the cache lock so + # concurrent cache users are not blocked by provider I/O. + for _evicted_sid, _evicted_entry in _evicted_items: + try: + _evicted_agent = _evicted_entry[0] if isinstance(_evicted_entry, tuple) else None + _should_close_evicted_agent = True + if _evicted_agent is not None: + try: + from api.session_lifecycle import ( + commit_session_memory as _lifecycle_commit, + has_uncommitted_work as _lifecycle_has_uncommitted_work, + unregister_agent as _lifecycle_unregister_agent, + ) + _lifecycle_commit(_evicted_sid, agent=_evicted_agent, wait=True) + if not _lifecycle_has_uncommitted_work(_evicted_sid): + _lifecycle_unregister_agent(_evicted_sid) + else: + _should_close_evicted_agent = False + except Exception: + _should_close_evicted_agent = False + logger.debug("Lifecycle commit on eviction failed for %s", _evicted_sid, exc_info=True) + if _should_close_evicted_agent and _evicted_agent is not None and getattr(_evicted_agent, '_session_db', None) is not None: + _evicted_agent._session_db.close() + except Exception: + logger.debug("Failed to close evicted agent for session %s", _evicted_sid, exc_info=True) + logger.debug('[webui] Evicted LRU agent from cache: %s', _evicted_sid) logger.debug('[webui] Created new agent for session %s', session_id) # Store agent instance for cancel/interrupt propagation @@ -3885,6 +3915,8 @@ def _run_agent_streaming( logger.debug("Failed to append cancelled turn journal event", exc_info=True) put('cancel', {'message': 'Cancelled by user'}) return + _memory_lifecycle_commit_sid = None + _memory_lifecycle_commit_agent = None with _agent_lock: if not ephemeral and not _stream_writeback_is_current(s, stream_id): logger.info( @@ -4483,6 +4515,23 @@ def _run_agent_streaming( ) except Exception: logger.debug("Failed to append completed turn journal event", exc_info=True) + if not ephemeral: + _memory_lifecycle_commit_sid = s.session_id + _memory_lifecycle_commit_agent = agent + # ── Memory-provider lifecycle commit ── + # Mark the turn as completed and attempt a memory commit for + # batch-extraction providers (OpenViking, Holographic). This + # must happen AFTER s.save() succeeds, AFTER cancellation checks, + # and AFTER the completed-turn journal event so only genuinely + # completed, non-ephemeral turns are marked. The provider commit + # itself runs outside _agent_lock because it can perform I/O. + if _memory_lifecycle_commit_sid: + try: + from api.session_lifecycle import mark_turn_completed, commit_session_memory + mark_turn_completed(_memory_lifecycle_commit_sid, agent=_memory_lifecycle_commit_agent) + commit_session_memory(_memory_lifecycle_commit_sid, agent=_memory_lifecycle_commit_agent) + except Exception: + logger.debug("Memory lifecycle commit failed for session %s", _memory_lifecycle_commit_sid, exc_info=True) # Sync to state.db for /insights (opt-in setting) try: from api.config import load_settings as _load_settings diff --git a/server.py b/server.py index c61d09d4..5cfed32e 100644 --- a/server.py +++ b/server.py @@ -441,6 +441,12 @@ def main() -> None: stop_watcher() except Exception: logger.debug("Failed to stop gateway watcher during shutdown") + # Drain pending memory-provider lifecycle commits before exit + try: + from api.session_lifecycle import drain_all_on_shutdown + drain_all_on_shutdown() + except Exception: + logger.debug("Failed to drain lifecycle on shutdown", exc_info=True) if __name__ == '__main__': main() diff --git a/static/sessions.js b/static/sessions.js index 1665ee8f..2abd1bf5 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -418,6 +418,7 @@ async function newSession(flash, options={}){ workspace:inheritWs, profile:S.activeProfile||'default', }; + if(S.session&&S.session.session_id) reqBody.prev_session_id=S.session.session_id; if(options&&options.worktree) reqBody.worktree=true; if(_activeProject&&_activeProject!==NO_PROJECT_FILTER) reqBody.project_id=_activeProject; const data=await api('/api/session/new',{method:'POST',body:JSON.stringify(reqBody)}); diff --git a/tests/test_memory_session_lifecycle_generation.py b/tests/test_memory_session_lifecycle_generation.py new file mode 100644 index 00000000..1d31711a --- /dev/null +++ b/tests/test_memory_session_lifecycle_generation.py @@ -0,0 +1,388 @@ +"""Behavioral tests for WebUI memory-provider session lifecycle. + +Batch-extraction memory providers such as OpenViking and Holographic need a +clear lifecycle contract: only completed turns are committable, repeated commits +must be no-ops when nothing new happened, and a commit finishing late must not +erase work completed while it was in flight. +""" + +from __future__ import annotations + +import importlib +import threading +from pathlib import Path + + +REPO = Path(__file__).resolve().parents[1] + + +def _fresh_lifecycle(): + """Import/reload lifecycle module and clear process-global test state.""" + lifecycle = importlib.import_module("api.session_lifecycle") + lifecycle = importlib.reload(lifecycle) + reset = getattr(lifecycle, "_reset_for_tests", None) + if callable(reset): + reset() + return lifecycle + + +class RecordingAgent: + def __init__(self): + self.calls = 0 + self.entered = threading.Event() + self.release = threading.Event() + self.failures_remaining = 0 + self._session_db: object | None = None + + def commit_memory_session(self): + self.calls += 1 + self.entered.set() + self.release.wait(timeout=2) + if self.failures_remaining: + self.failures_remaining -= 1 + raise RuntimeError("commit failed") + + +class CloseTrackingDB: + def __init__(self): + self.close_calls = 0 + + def close(self): + self.close_calls += 1 + + +def test_commit_finishing_late_does_not_clear_newly_completed_turn(): + lifecycle = _fresh_lifecycle() + agent = RecordingAgent() + sid = "rapid-reopen" + + lifecycle.register_agent(sid, agent) + lifecycle.mark_turn_completed(sid, agent=agent) + + commit_thread = threading.Thread(target=lambda: lifecycle.commit_session_memory(sid)) + commit_thread.start() + assert agent.entered.wait(timeout=2) + + # A later turn completes while the previous commit is still running. The + # previous commit must not erase this newer generation when it finishes. + lifecycle.mark_turn_completed(sid, agent=agent) + agent.release.set() + commit_thread.join(timeout=2) + + assert agent.calls == 1 + assert lifecycle.has_uncommitted_work(sid) is True + + assert lifecycle.commit_session_memory(sid) is True + assert agent.calls == 2 + assert lifecycle.has_uncommitted_work(sid) is False + + +def test_failed_commit_preserves_uncommitted_work_and_agent_handle(): + lifecycle = _fresh_lifecycle() + agent = RecordingAgent() + agent.release.set() + agent.failures_remaining = 1 + sid = "commit-failure" + + lifecycle.register_agent(sid, agent) + lifecycle.mark_turn_completed(sid, agent=agent) + + assert lifecycle.commit_session_memory(sid) is False + assert agent.calls == 1 + assert lifecycle.has_uncommitted_work(sid) is True + + assert lifecycle.commit_session_memory(sid) is True + assert agent.calls == 2 + assert lifecycle.has_uncommitted_work(sid) is False + + +def test_failed_explicit_agent_commit_preserves_agent_handle_for_retry(): + lifecycle = _fresh_lifecycle() + agent = RecordingAgent() + agent.release.set() + agent.failures_remaining = 1 + sid = "explicit-agent-failure" + + lifecycle.mark_turn_completed(sid) + + assert lifecycle.commit_session_memory(sid, agent=agent) is False + assert agent.calls == 1 + assert lifecycle.has_uncommitted_work(sid) is True + + # Retry without passing an agent must still find the explicit agent supplied + # to the failed commit attempt. + assert lifecycle.commit_session_memory(sid) is True + assert agent.calls == 2 + assert lifecycle.has_uncommitted_work(sid) is False + + +def test_dirty_failed_commit_handle_survives_replacement_registration(): + lifecycle = _fresh_lifecycle() + old_agent = RecordingAgent() + old_agent.release.set() + old_agent.failures_remaining = 1 + new_agent = RecordingAgent() + new_agent.release.set() + sid = "preserve-failed-handle" + + lifecycle.register_agent(sid, old_agent) + lifecycle.mark_turn_completed(sid, agent=old_agent) + + assert lifecycle.commit_session_memory(sid) is False + assert old_agent.calls == 1 + assert lifecycle.has_uncommitted_work(sid) is True + + lifecycle.register_agent(sid, new_agent) + + assert lifecycle.commit_session_memory(sid) is True + assert old_agent.calls == 2 + assert new_agent.calls == 0 + assert lifecycle.has_uncommitted_work(sid) is False + + # Once the dirty generation is clean, the pending replacement may become the + # active handle for future completed work. + lifecycle.mark_turn_completed(sid) + assert lifecycle.commit_session_memory(sid) is True + assert new_agent.calls == 1 + + +def test_explicit_new_agent_commit_cannot_clear_old_dirty_segment(): + lifecycle = _fresh_lifecycle() + old_agent = RecordingAgent() + old_agent.release.set() + old_agent.failures_remaining = 1 + new_agent = RecordingAgent() + new_agent.release.set() + sid = "explicit-new-cannot-steal-old" + + lifecycle.register_agent(sid, old_agent) + lifecycle.mark_turn_completed(sid, agent=old_agent) + assert lifecycle.commit_session_memory(sid) is False + + lifecycle.register_agent(sid, new_agent) + lifecycle.mark_turn_completed(sid, agent=new_agent) + + # Even an explicit commit with the replacement agent must flush the oldest + # dirty segment with its preserved owner first. + assert lifecycle.commit_session_memory(sid, agent=new_agent) is True + assert old_agent.calls == 2 + assert new_agent.calls == 0 + assert lifecycle.has_uncommitted_work(sid) is True + + assert lifecycle.commit_session_memory(sid) is True + assert new_agent.calls == 1 + assert lifecycle.has_uncommitted_work(sid) is False + + +def test_registered_session_without_completed_turn_is_not_committed(): + lifecycle = _fresh_lifecycle() + agent = RecordingAgent() + agent.release.set() + sid = "registered-only" + + lifecycle.register_agent(sid, agent) + + assert lifecycle.commit_session_memory(sid) is False + assert agent.calls == 0 + assert lifecycle.has_uncommitted_work(sid) is False + + +def test_shutdown_drain_includes_dirty_sessions_even_if_pending_registry_was_cleared(): + lifecycle = _fresh_lifecycle() + agent = RecordingAgent() + agent.release.set() + sid = "dirty-cached-only" + + lifecycle.register_agent(sid, agent) + lifecycle.mark_turn_completed(sid, agent=agent) + lifecycle.unregister_agent(sid) + + # A lifecycle registry miss should not make completed work undiscoverable if + # the agent is still supplied/cached by the caller. + assert lifecycle.commit_session_memory(sid, agent=agent) is True + assert agent.calls == 1 + assert lifecycle.has_uncommitted_work(sid) is False + + +def test_shutdown_drain_waits_for_inflight_commit_and_flushes_new_generation(): + lifecycle = _fresh_lifecycle() + agent = RecordingAgent() + sid = "shutdown-waits" + + lifecycle.register_agent(sid, agent) + lifecycle.mark_turn_completed(sid, agent=agent) + first_commit = threading.Thread(target=lambda: lifecycle.commit_session_memory(sid)) + first_commit.start() + assert agent.entered.wait(timeout=2) + + lifecycle.mark_turn_completed(sid, agent=agent) + + drain_thread = threading.Thread(target=lifecycle.drain_all_on_shutdown) + drain_thread.start() + drain_thread.join(timeout=0.05) + assert drain_thread.is_alive() + assert agent.calls == 1 + + agent.release.set() + first_commit.join(timeout=2) + drain_thread.join(timeout=2) + + assert agent.calls == 2 + assert lifecycle.has_uncommitted_work(sid) is False + + +def test_frontend_new_session_sends_previous_session_id_boundary(): + src = (REPO / "static" / "sessions.js").read_text(encoding="utf-8") + start = src.index("async function newSession") + end = src.index("const data=await api('/api/session/new'", start) + body = src[start:end] + + assert "prev_session_id" in body + assert "S.session" in body and "session_id" in body + + +# ── Follow-up review fix tests ────────────────────────────────────────────── + + +def test_evict_session_agent_commits_before_dropping(): + """_evict_session_agent() must attempt a lifecycle commit before dropping + the cached agent handle, so batch-extraction providers can extract pending + work before the handle is lost.""" + lifecycle = _fresh_lifecycle() + agent = RecordingAgent() + agent.release.set() + sid = "evict-commit" + + lifecycle.register_agent(sid, agent) + lifecycle.mark_turn_completed(sid, agent=agent) + + import api.config as cfg + import importlib as _il + _il.reload(cfg) + + with cfg.SESSION_AGENT_CACHE_LOCK: + cfg.SESSION_AGENT_CACHE.clear() + cfg.SESSION_AGENT_CACHE[sid] = (agent, "sig") + + cfg._evict_session_agent(sid) + + assert agent.calls == 1, "evict should have committed before dropping" + assert lifecycle.has_uncommitted_work(sid) is False + with cfg.SESSION_AGENT_CACHE_LOCK: + assert sid not in cfg.SESSION_AGENT_CACHE + + # Successful eviction should unregister the cached agent handle. If the + # handle leaked, a future mark without supplying an agent could commit. + lifecycle.mark_turn_completed(sid) + assert lifecycle.commit_session_memory(sid) is False + + +def test_evict_session_agent_waits_for_inflight_commit_before_closing_db(): + lifecycle = _fresh_lifecycle() + agent = RecordingAgent() + db = CloseTrackingDB() + agent._session_db = db + sid = "evict-waits-for-inflight" + + lifecycle.register_agent(sid, agent) + lifecycle.mark_turn_completed(sid, agent=agent) + first_commit = threading.Thread(target=lambda: lifecycle.commit_session_memory(sid)) + first_commit.start() + assert agent.entered.wait(timeout=2) + + # A newer completed generation appears while the first commit is still in + # flight. Eviction must wait for the first commit and flush the newer one + # before closing the agent's DB resource. + lifecycle.mark_turn_completed(sid, agent=agent) + + import api.config as cfg + import importlib as _il + _il.reload(cfg) + with cfg.SESSION_AGENT_CACHE_LOCK: + cfg.SESSION_AGENT_CACHE.clear() + cfg.SESSION_AGENT_CACHE[sid] = (agent, "sig") + + evict_thread = threading.Thread(target=lambda: cfg._evict_session_agent(sid)) + evict_thread.start() + evict_thread.join(timeout=0.05) + assert evict_thread.is_alive() + assert db.close_calls == 0 + + agent.release.set() + first_commit.join(timeout=2) + evict_thread.join(timeout=2) + + assert agent.calls == 2 + assert lifecycle.has_uncommitted_work(sid) is False + assert db.close_calls == 1 + + +def test_lru_eviction_commits_outside_cache_lock(): + """LRU eviction must collect under SESSION_AGENT_CACHE_LOCK and commit only + after leaving that lock; provider extraction can be slow I/O.""" + import api.streaming as streaming_mod + + src = Path(streaming_mod.__file__).read_text(encoding="utf-8") + marker = "_evicted_items = []" + collect_start = src.index(marker) + lock_start = src.index("with SESSION_AGENT_CACHE_LOCK:", collect_start) + lock_end = src.index("# Commit and close evicted agents outside the cache lock", lock_start) + locked_section = src[lock_start:lock_end] + outside_section = src[lock_end:src.index("logger.debug('[webui] Created new agent", lock_end)] + + assert "commit_session_memory" not in locked_section + assert "_lifecycle_commit" not in locked_section + assert "SESSION_AGENT_CACHE.popitem" in locked_section + assert "_lifecycle_commit" in outside_section + assert "wait=True" in outside_section + assert "outside the cache lock" in outside_section + + +def test_post_turn_lifecycle_is_after_completed_journal_and_cancel_check(): + """Source-adjacent test: verify post-turn lifecycle marking is ordered + after save/cancel/completed-journal and that provider commit runs outside + the per-session agent lock.""" + import api.streaming as streaming_mod + src = Path(streaming_mod.__file__).read_text(encoding="utf-8") + + save_pos = src.index("s.save()") + lifecycle_marker = src.index("_memory_lifecycle_commit_sid = s.session_id", save_pos) + lifecycle_commit = src.index("commit_session_memory", lifecycle_marker) + cancel_check = src.index("cancel_event.is_set()", save_pos) + completed_journal = src.index('"completed"', save_pos) + sync_to_state_db = src.index("# Sync to state.db", save_pos) + + assert lifecycle_marker > cancel_check, ( + "mark_turn_completed must appear after the cancellation check" + ) + assert lifecycle_marker > completed_journal, ( + "mark_turn_completed must appear after the completed-turn journal event" + ) + assert lifecycle_marker < sync_to_state_db + assert lifecycle_commit < sync_to_state_db + + commit_line = next( + line for line in src.splitlines() + if "commit_session_memory(_memory_lifecycle_commit_sid" in line + ) + assert len(commit_line) - len(commit_line.lstrip()) == 20 + + +def test_empty_session_id_is_safe_noop(): + """All lifecycle API functions must no-op safely for falsy session IDs.""" + lifecycle = _fresh_lifecycle() + agent = RecordingAgent() + agent.release.set() + + assert lifecycle.register_agent("", agent) is None + assert lifecycle.unregister_agent("") is None + assert lifecycle.mark_turn_completed("") == 0 + assert lifecycle.has_uncommitted_work("") is False + assert lifecycle.commit_session_memory("") is False + assert lifecycle.commit_session_memory("", agent=agent) is False + + assert lifecycle.register_agent(None, agent) is None + assert lifecycle.unregister_agent(None) is None + assert lifecycle.mark_turn_completed(None) == 0 + assert lifecycle.has_uncommitted_work(None) is False + assert lifecycle.commit_session_memory(None) is False From bd9a4924bf5fd9c5561e1d9415dd1dfa7de05749 Mon Sep 17 00:00:00 2001 From: Frank Song Date: Sun, 17 May 2026 17:34:43 +0800 Subject: [PATCH 065/138] fix: clear stale active session spinner --- CHANGELOG.md | 4 ++ static/sessions.js | 32 +++++++++- .../test_issue2454_active_session_spinner.py | 62 +++++++++++++++++++ 3 files changed, 95 insertions(+), 3 deletions(-) create mode 100644 tests/test_issue2454_active_session_spinner.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 93fb8cdd..7c3792f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- 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 (#2454). + ## [v0.51.82] — 2026-05-17 — Release BF (stage-375 — 2-PR batch — table renderer pipe protection + Catppuccin appearance skin) ### Added diff --git a/static/sessions.js b/static/sessions.js index 1665ee8f..0e003f02 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -256,6 +256,31 @@ function _isSessionEffectivelyStreaming(s) { return Boolean(s && (s.is_streaming || _isSessionLocallyStreaming(s))); } +function _reconcileActiveSessionIdleStateFromList(serverRows) { + if (!S || !S.session || !S.session.session_id) return false; + if (typeof _sendInProgress !== 'undefined' && _sendInProgress) return false; + if (!Array.isArray(serverRows)) return false; + const sid=S.session.session_id; + const serverRow=serverRows.find(s=>s&&s.session_id===sid); + if (!serverRow) return false; + const serverRowIsIdle=!serverRow.is_streaming&&!serverRow.active_stream_id&&!serverRow.pending_user_message; + if (!serverRowIsIdle) return false; + let changed=false; + if (S.busy) { S.busy=false; changed=true; } + if (S.activeStreamId) { S.activeStreamId=null; changed=true; } + if (INFLIGHT&&INFLIGHT[sid]) { + delete INFLIGHT[sid]; + if (typeof clearInflightState==='function') clearInflightState(sid); + changed=true; + } + if (S.session) { + S.session.active_stream_id=null; + S.session.pending_user_message=null; + } + if (changed&&typeof updateSendBtn==='function') updateSendBtn(); + return changed; +} + function _purgeStaleInflightEntries() { // Clean up INFLIGHT entries for sessions the server confirms are NOT // streaming. This prevents the in-memory cache from growing unbounded @@ -1878,9 +1903,6 @@ function _applySessionListPayload(sessData, projData){ // active profile so the "Show N from other profiles" toggle can render // without a second round-trip. Stashed on the module for renderSessionListFromCache. _otherProfileCount = sessData.other_profile_count || 0; - _allSessions = _mergeOptimisticFirstTurnSessions(sessData.sessions||[]); - _clearLineageReportCache(); - _allProjects = projData.projects||[]; // Capture server clock for clock-skew compensation (issue #1144). // server_time is epoch seconds from the server's time.time(). // _serverTimeDelta = client - server, so (Date.now() - _serverTimeDelta) @@ -1891,6 +1913,10 @@ function _applySessionListPayload(sessData, projData){ if (typeof sessData.server_tz === 'string') { _serverTz = sessData.server_tz; } + _reconcileActiveSessionIdleStateFromList(sessData.sessions||[]); + _allSessions = _mergeOptimisticFirstTurnSessions(sessData.sessions||[]); + _clearLineageReportCache(); + _allProjects = projData.projects||[]; _markPollingCompletionUnreadTransitions(_allSessions); const isStreaming = _allSessions.some(s => Boolean(s && s.is_streaming)); if (isStreaming) { diff --git a/tests/test_issue2454_active_session_spinner.py b/tests/test_issue2454_active_session_spinner.py new file mode 100644 index 00000000..7db2d390 --- /dev/null +++ b/tests/test_issue2454_active_session_spinner.py @@ -0,0 +1,62 @@ +"""Regression coverage for #2454 active-session stale sidebar spinner. + +The backend can already reconcile stale stream state and return `/api/sessions` +rows with `is_streaming: false`, `active_stream_id: null`, and +`pending_user_message: null`. The remaining bug is frontend-local: the current +open session can keep `S.busy = true`, so `_isSessionLocallyStreaming()` still +makes the sidebar row render as streaming even after the server says idle. +""" + +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] +SESSIONS_SRC = (REPO / "static" / "sessions.js").read_text(encoding="utf-8") + + +def _function_body(src: str, signature: str) -> str: + start = src.find(signature) + assert start != -1, f"missing {signature}" + brace = src.find("{", start) + assert brace != -1, f"missing opening brace for {signature}" + depth = 0 + for i in range(brace, len(src)): + ch = src[i] + if ch == "{": + depth += 1 + elif ch == "}": + depth -= 1 + if depth == 0: + return src[brace + 1 : i] + raise AssertionError(f"could not extract function body for {signature}") + + +def test_active_session_idle_reconcile_clears_stale_busy_and_inflight_state(): + body = _function_body(SESSIONS_SRC, "function _reconcileActiveSessionIdleStateFromList(") + + assert "serverRows" in body, "reconcile must inspect raw /api/sessions rows before optimistic merging" + assert "S.session.session_id" in body, "reconcile must target the currently active session" + assert "_sendInProgress" in body, "cleanup must not interrupt a send that has not received stream_id yet" + assert "!serverRow.is_streaming" in body, "server idle metadata must gate the cleanup" + assert "!serverRow.active_stream_id" in body, "active stream id must be absent before cleanup" + assert "!serverRow.pending_user_message" in body, "pending user text must be absent before cleanup" + assert "S.busy=false" in body, "stale local busy state must be cleared" + assert "S.activeStreamId=null" in body, "stale active stream id must be cleared" + assert "delete INFLIGHT[sid]" in body, "stale active-session inflight cache must be purged" + assert "clearInflightState(sid)" in body, "persisted inflight cache must be cleared too" + assert "updateSendBtn()" in body, "composer controls must reflect the idle state after cleanup" + + +def test_session_list_payload_reconciles_active_idle_state_before_optimistic_merge_and_render(): + body = _function_body(SESSIONS_SRC, "function _applySessionListPayload(") + + reconcile_pos = body.find("_reconcileActiveSessionIdleStateFromList(sessData.sessions||[])") + merge_pos = body.find("_allSessions = _mergeOptimisticFirstTurnSessions") + render_pos = body.find("renderSessionListFromCache()") + + assert reconcile_pos != -1, "active-session idle reconciliation must run for refreshed rows" + assert merge_pos != -1, "session rows must still be applied from /api/sessions" + assert render_pos != -1, "payload application must still render from cache" + assert reconcile_pos < merge_pos < render_pos, ( + "local S.busy/INFLIGHT state must be reconciled against raw server rows " + "before optimistic merging can re-label a stale active session as streaming" + ) From f56ec1ec35dba1052ac91bac48bb1043d630fd33 Mon Sep 17 00:00:00 2001 From: Michael Lam Date: Sun, 17 May 2026 02:48:40 -0700 Subject: [PATCH 066/138] fix: label email gateway sessions --- CHANGELOG.md | 4 ++++ api/agent_sessions.py | 2 ++ api/routes.py | 1 + static/sessions.js | 3 ++- tests/test_gateway_sync.py | 9 +++++++++ 5 files changed, 18 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 93fb8cdd..002b487b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- **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, gateway status platform labels, and frontend handoff/sidebar whitelist in sync. + ## [v0.51.82] — 2026-05-17 — Release BF (stage-375 — 2-PR batch — table renderer pipe protection + Catppuccin appearance skin) ### Added diff --git a/api/agent_sessions.py b/api/agent_sessions.py index 41556fad..641e3a22 100644 --- a/api/agent_sessions.py +++ b/api/agent_sessions.py @@ -9,6 +9,7 @@ logger = logging.getLogger(__name__) MESSAGING_SOURCES = { 'discord', + 'email', 'slack', 'telegram', 'weixin', @@ -22,6 +23,7 @@ SOURCE_LABELS = { 'cli': 'CLI', 'cron': 'Cron', 'discord': 'Discord', + 'email': 'Email', 'slack': 'Slack', 'telegram': 'Telegram', 'tool': 'Tool', diff --git a/api/routes.py b/api/routes.py index 677a6677..d610e02f 100644 --- a/api/routes.py +++ b/api/routes.py @@ -4183,6 +4183,7 @@ def handle_get(handler, parsed) -> bool: "telegram": "Telegram", "discord": "Discord", "slack": "Slack", + "email": "Email", "web": "Web", "api": "API", } diff --git a/static/sessions.js b/static/sessions.js index 1665ee8f..3de27f4f 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -742,12 +742,13 @@ const _HANDOFF_THRESHOLD = 10; // conversation rounds const _HANDOFF_STORAGE_PREFIX = 'handoff:'; const _HANDOFF_SUFFIX_DISMISSED_AT = 'dismissed_at'; const _HANDOFF_SUFFIX_SUMMARY_HANDLED_AT = 'summary_handled_at'; -const _MESSAGING_RAW_SOURCES = new Set(['weixin', 'telegram', 'discord', 'slack']); +const _MESSAGING_RAW_SOURCES = new Set(['weixin', 'telegram', 'discord', 'slack', 'email']); const _MESSAGING_SOURCE_LABELS = { weixin: 'WeChat', telegram: 'Telegram', discord: 'Discord', slack: 'Slack', + email: 'Email', }; function _isMessagingSession(session) { diff --git a/tests/test_gateway_sync.py b/tests/test_gateway_sync.py index d364606e..75fedb2b 100644 --- a/tests/test_gateway_sync.py +++ b/tests/test_gateway_sync.py @@ -797,6 +797,7 @@ def test_agent_session_source_normalization_contract(): cases = { 'cli': ('cli', 'CLI'), + 'email': ('messaging', 'Email'), 'weixin': ('messaging', 'Weixin'), 'telegram': ('messaging', 'Telegram'), 'discord': ('messaging', 'Discord'), @@ -818,6 +819,14 @@ def test_agent_session_source_normalization_contract(): assert normalized['raw_source'] is None +def test_sessions_js_treats_email_as_messaging_source(): + """Email gateway sessions should receive the same sidebar metadata as other messaging channels.""" + src = (REPO_ROOT / "static" / "sessions.js").read_text(encoding="utf-8") + + assert "'email'" in src[src.find("_MESSAGING_RAW_SOURCES"):src.find("function _isMessagingSession")] + assert "email: 'Email'" in src[src.find("_MESSAGING_SOURCE_LABELS"):src.find("function _isMessagingSession")] + + def test_cross_source_parent_child_is_not_collapsed_into_root_metadata(cleanup_test_sessions): """A WebUI continuation from a messaging parent must keep WebUI metadata. From 2785065a09f04c43efb812bec630bdeaa21237e2 Mon Sep 17 00:00:00 2001 From: Michael Lam Date: Sun, 17 May 2026 03:57:07 -0700 Subject: [PATCH 067/138] feat: add thinking card copy button --- CHANGELOG.md | 4 ++++ docs/pr-media/2449/after-thinking-copy.png | Bin 0 -> 26514 bytes docs/pr-media/2449/before-thinking-copy.png | Bin 0 -> 26672 bytes static/style.css | 5 ++++- static/ui.js | 19 ++++++++++++++++--- tests/test_ui_card_animation.py | 16 ++++++++++++++-- 6 files changed, 38 insertions(+), 6 deletions(-) create mode 100644 docs/pr-media/2449/after-thinking-copy.png create mode 100644 docs/pr-media/2449/before-thinking-copy.png diff --git a/CHANGELOG.md b/CHANGELOG.md index 93fb8cdd..ee7c3c34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### 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 `
` manually. The button stops header-toggle propagation and shows the same short checkmark feedback pattern used by existing copy actions.
+
 ## [v0.51.82] — 2026-05-17 — Release BF (stage-375 — 2-PR batch — table renderer pipe protection + Catppuccin appearance skin)
 
 ### Added
diff --git a/docs/pr-media/2449/after-thinking-copy.png b/docs/pr-media/2449/after-thinking-copy.png
new file mode 100644
index 0000000000000000000000000000000000000000..789f13d23170b4d7666b63ff2d9d036d898357bf
GIT binary patch
literal 26514
zcmeFYRajd=7dA@0g#r~Q6e*>GyE_GnTXBa{+#QO9RB$cs8bZ+o3oZqUy9Q6uK!OJd
z1UupTemUnn|LwUuSJ``H&C0Bqwch=%neb0)^2CHxgaiZx#EJ?ZH3r(w^1NEqPm+FsjMvX?{7*m#nuPpQLG9-Rbwj(Y(7=&4*^E`DopCwwaA
z%QjDZjNjo;Gu-qY?dy+JiZ?In5psi(mR5d)}PHbdV$yWLsxUtzfFedU5YJvqgEygE-hB7^BeG~$`?&mzGz
zBtLfx8AI@YG`DWFar+MR3Uk2V~v0OBZ!D||6p5vQ+(_2Dcgg)qId9*PuN@z+&}zF
zz>h^hkD=swaF^%lO>ygzhR3(PG5q6)yT=bR?=(l_9|`jF9JY_HNB!SD3QQp&I1*&O
z9dG7O31*`r-S!|L=y*D-u9@$oqPRS9>0YS){T&tiwhn&Q!Vsj+5SSbf-~T)D`ib1-o14Xud-RahQp@Zi=+#ra{XZ0V
z4w2uT$bJaQddB?iS;#Gdu#i)EsULRgmts-f0!L4|@g>kdl93iq$sQG|UGhfB@sh_2
z-6Gh$6A+Lq|6Z_G7Iyq`8qs0<5%i|vIl;HUUJHUS{Se1UM~`r5PSDuHF1c|=0)l;}
z4#80@NZJlT0*ZSWd-oH5Cd$H0NanCwc4ow&Wm$#WcngIk_THV4m+95*1ziD{-87P1ABEyjK(U
zq*cIxckvAVl&TAq>6W)%;??SriHba)kQL`>&5<2nBL+#y)iE)))P||x?enE)Pu?S9
zuNXv5Aj6!<;Py_nn~Un2a_+^(SNvwWdm*+cfjZ#Z3`$FkXZr;pUT}K`eEMFCD3-WaDI&PrphrS-Pe1gdL)+q&z!G}GCe)r
zZzIj_kat9EHH!CseL=zFC-|1njEv?k-y4VWa~#*!J~9}
zt#NWC`lrFEAJ_52;SOfUmzR)v_3U@}ZTU??kxC&8nj)Q{kf<7;e!
z2e=c1Sfaq=34$Yb&3|!UipT;B9lUhwy^E5A@7?(H*M6iFQ8;W1ww0H5th9mH&Rd@qei%P*3}+R9q)<>
zF5}69hH(e60M)pCU%pnn`QC}xj>ZO&eGd)C|G~h4mqD7Ee!pX6Xo%m<-vU)4WHg!L^W$>1
zp7F;rOnS(P-mPFAA0Ic|%zc12?xC$iDOvP|dddfN3UPK0^BVTGxUBpAmh9{#cn_6}
zNRW=xrXjW+udR@im)Y*Ly&GS|H}%pcb8HURax?y(VxB^T-1qx^cLzY+_|2-A{{x+_
zF|ucI<7RCNK6!S(9M=2EVDU;d74KngY5_<80ZZ-Va-s+=x;!x|(aOglC;F%Z9-aAb
zw;uVT$??8UtUh1EUpM08t7sG5XoG@C%V+pff`3wn>tkQx6hD>oo!;FbuADiHX4MhA
zhaoBimh=}Nuf9DAqRsyj*Rf*w(uv6anKUzA*0	&{&7w2q{W@QALWP!i}Ss3Rid)
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

literal 0
HcmV?d00001

diff --git a/docs/pr-media/2449/before-thinking-copy.png b/docs/pr-media/2449/before-thinking-copy.png
new file mode 100644
index 0000000000000000000000000000000000000000..a3d5315dc18edf5c1eaa410056d666c2db3bfd83
GIT binary patch
literal 26672
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 #mainLogs{display:flex;} Only sub-element selectors that the consolidated block doesn't cover (label, toggle, rotate animation) are kept here. ── */ .thinking-card-label{font-weight:600;letter-spacing:.02em;} -.thinking-card-toggle{margin-left:auto;font-size:10px;display:inline-flex;align-items:center;justify-content:center;transform-origin:center;transition:transform .18s ease;will-change:transform;} +.thinking-card-btn-row{margin-left:auto;display:inline-flex;align-items:center;gap:6px;} +.thinking-copy-btn{display:inline-flex;align-items:center;justify-content:center;width:22px;height:22px;padding:0;border:0;border-radius:6px;background:transparent;color:var(--accent-text);opacity:.72;cursor:pointer;transition:background .15s,color .15s,opacity .15s;} +.thinking-copy-btn:hover,.thinking-copy-btn:focus-visible{background:var(--accent-bg-strong);opacity:1;outline:none;} +.thinking-card-toggle{font-size:10px;display:inline-flex;align-items:center;justify-content:center;transform-origin:center;transition:transform .18s ease;will-change:transform;} .thinking-card.open .thinking-card-toggle{transform:rotate(90deg);} .bg-error-banner{background:rgba(229,62,62,.15);border:1px solid rgba(229,62,62,.3);color:#fca5a5;padding:8px 16px;font-size:12px;display:flex;align-items:center;justify-content:space-between;gap:12px;border-radius:0;} diff --git a/static/ui.js b/static/ui.js index 5a33723d..eab0bdb0 100644 --- a/static/ui.js +++ b/static/ui.js @@ -3682,6 +3682,19 @@ function copyMsg(btn){ setTimeout(()=>{btn.innerHTML=orig;btn.style.color='';},1500); }).catch(()=>showToast(t('copy_failed'))); } +function _copyThinkingText(btn){ + const card=btn&&btn.closest?btn.closest('.thinking-card'):null; + if(!card)return; + const pre=card.querySelector('.thinking-card-body pre'); + const text=pre?pre.textContent:''; + if(!text)return; + _copyText(text).then(()=>{ + const orig=btn.innerHTML; + btn.innerHTML=li('check',12); + btn.style.color='var(--accent)'; + setTimeout(()=>{btn.innerHTML=orig;btn.style.color='';},1500); + }).catch(()=>showToast(t('copy_failed'))); +} // ── TTS: Text-to-Speech via Web Speech API (#499) ── // Strips markdown, code blocks, and MEDIA: paths for clean speech output. @@ -4732,9 +4745,9 @@ function _assistantTurnBlocks(turn){ } function _thinkingCardHtml(text, open){ const clean=_sanitizeThinkingDisplayText(text); - return open - ? `

${li('lightbulb',14)}${t('thinking')}${li('chevron-right',12)}
${esc(clean)}
` - : `
${li('lightbulb',14)}${t('thinking')}${li('chevron-right',12)}
${esc(clean)}
`; + const copyBtn=``; + const classes=`thinking-card${open?' open':''}`; + return `
${li('lightbulb',14)}${t('thinking')}${copyBtn}${li('chevron-right',12)}
${esc(clean)}
`; } function isSimplifiedToolCalling(){ return window._simplifiedToolCalling!==false; diff --git a/tests/test_ui_card_animation.py b/tests/test_ui_card_animation.py index 3af47bc0..3a84f4ac 100644 --- a/tests/test_ui_card_animation.py +++ b/tests/test_ui_card_animation.py @@ -27,7 +27,8 @@ def test_tool_card_detail_uses_transitionable_collapsed_state(): def test_thinking_card_toggle_and_body_use_animation_friendly_state(): - assert ".thinking-card-toggle{margin-left:auto;font-size:10px;display:inline-flex;" in COMPACT_CSS + assert ".thinking-card-btn-row{margin-left:auto;display:inline-flex;align-items:center;gap:6px;" in COMPACT_CSS + assert ".thinking-card-toggle{font-size:10px;display:inline-flex;" in COMPACT_CSS assert ".thinking-card-header{display:flex;align-items:center;gap:8px;" in COMPACT_CSS # Body uses div default (display:block); canonical rule lives in the # consolidated block. Open state caps at 260px (intentional "quieter" sizing). @@ -41,7 +42,18 @@ def test_thinking_card_toggle_and_body_use_animation_friendly_state(): def test_tool_card_toggle_uses_same_chevron_icon_markup_as_thinking_card(): assert "${li('chevron-right',12)}" in UI_JS assert "${li('chevron-right',12)}" in UI_JS - assert "
" in UI_JS + assert "
" in UI_JS + + +def test_thinking_card_header_includes_copy_button_that_does_not_toggle_card(): + assert "function _copyThinkingText(btn){" in UI_JS + assert "const copyBtn=`
+
+ +
Show older Discord, Telegram, Slack, and Weixin sessions that were replaced by reset or compression.
+
onload="renderMathInElement(document.body,{delimiters:[{left:'$$',right:'$$',display:true},{left:'\\[',right:'\\]',display:true},{left:'\\(',right:'\\)',display:false},{left:'$',right:'$',display:false}],throwOnError:false});">
${inlineMd(_restorePipes(c.trim()))}${inlineMd(_restorePipes(c.trim()))} or . Two-pass: match cells + and cells separately so the same regex can use a single fixed tag + name (avoiding the open-th-close-td mismatch that a `...` + pattern would allow). + """ + import re + out = [] + for tag in ("th", "td"): + # We need positions to preserve document order across the two tags. + for m in re.finditer(rf"<{tag}\b[^>]*>(.*?)", html, re.S): + out.append((m.start(), m.group(1).strip())) + out.sort(key=lambda p: p[0]) + return [c for _, c in out] + + +class TestSinglePipeInBracketsStaysInOneCell: + """Original #2428 bug: cells containing a single pipe inside parens or + brackets used to split into two cells. _protectPipes was added to handle + this. These tests pin the single-pipe case.""" + + def test_pipe_inside_parens_stays_in_one_cell(self, driver_path): + md = "| `(a|b)` | x |\n|---|---|\n| ok | y |" + cells = _table_cells(_render(driver_path, md)) + # 2 columns × 2 rows = 4 cells total (header row + 1 data row). + assert len(cells) == 4, f"expected 4 cells, got {len(cells)}: {cells!r}" + # The first header cell must contain the full (a|b) backtick span. + assert "(a|b)" in cells[0], ( + f"`(a|b)` inside backticks must stay in one cell. Got cells: {cells!r}" + ) + + def test_pipe_inside_square_brackets_stays_in_one_cell(self, driver_path): + md = "| `Union[int|float]` | bracket |\n|---|---|\n| ok | y |" + cells = _table_cells(_render(driver_path, md)) + assert len(cells) == 4, f"expected 4 cells, got {len(cells)}: {cells!r}" + assert "Union[int|float]" in cells[0], ( + f"`Union[int|float]` must stay in one cell. Got: {cells!r}" + ) + + +class TestMultiPipeInBracketsStaysInOneCell: + """First version of #2428 fix only protected the first pipe per bracket + pair. The shipped version uses a `do { ... } while (r !== prev)` loop so + iterative passes catch all pipes inside one pair. These tests pin that + iteration.""" + + def test_three_pipes_inside_parens(self, driver_path): + md = "| `(a|b|c)` | x |\n|---|---|\n| ok | y |" + cells = _table_cells(_render(driver_path, md)) + assert len(cells) == 4, ( + f"three-pipe paren cell must not split. Got {len(cells)} cells: {cells!r}" + ) + assert "(a|b|c)" in cells[0], f"got cells: {cells!r}" + + def test_three_pipes_inside_square_brackets(self, driver_path): + md = "| `Union[int|float|str]` | bracket |\n|---|---|\n| ok | y |" + cells = _table_cells(_render(driver_path, md)) + assert len(cells) == 4, ( + f"three-pipe square-bracket cell must not split. " + f"Got {len(cells)} cells: {cells!r}" + ) + assert "Union[int|float|str]" in cells[0], f"got cells: {cells!r}" + + def test_four_pipes_inside_parens(self, driver_path): + md = "| `(a|b|c|d)` | x |\n|---|---|\n| ok | y |" + cells = _table_cells(_render(driver_path, md)) + assert len(cells) == 4, ( + f"four-pipe paren cell must not split. Got {len(cells)} cells: {cells!r}" + ) + assert "(a|b|c|d)" in cells[0], f"got cells: {cells!r}" + + +class TestApostrophesInsideProtectedBrackets: + """The first iterative version of the #2428 fix added `'` to the negated + character classes (`[^)\\]}'>]`), which broke cells containing string + literals separated by pipes (e.g. `('a'|'b')` in Python type-union + examples). The shipped form drops the `'`-stop so apostrophes can live + inside protected content.""" + + def test_apostrophes_inside_parens_with_pipe(self, driver_path): + md = "| `('a'|'b')` | x |\n|---|---|\n| ok | y |" + cells = _table_cells(_render(driver_path, md)) + assert len(cells) == 4, ( + f"apostrophe-and-pipe-in-parens must not split. " + f"Got {len(cells)} cells: {cells!r}" + ) + # The apostrophes survive inlineMd's esc() pass as `'`, so look + # for either form depending on whether the cell content was escaped. + first = cells[0] + assert "'a'" in first or "'a'" in first, ( + f"cell content must contain the escaped or literal `'a'`. Got: {first!r}" + ) + + def test_apostrophes_inside_square_brackets_with_pipe(self, driver_path): + md = "| `Tuple['a'|'b']` | x |\n|---|---|\n| ok | y |" + cells = _table_cells(_render(driver_path, md)) + assert len(cells) == 4, ( + f"apostrophe-and-pipe-in-brackets must not split. " + f"Got {len(cells)} cells: {cells!r}" + ) + + def test_apostrophe_outside_bracket_pair(self, driver_path): + """Smoke test — apostrophes that are not part of a pipe-protect range + must still render normally.""" + md = "| `it's a test` | x |\n|---|---|\n| ok | y |" + cells = _table_cells(_render(driver_path, md)) + assert len(cells) == 4, f"plain apostrophe must not split. Got: {cells!r}" + + +class TestKatexDollarInTableCell: + """The second half of #2428 — KaTeX `$...$` math spans containing ` | ` + must not stash math when they straddle table column separators.""" + + def test_katex_with_pipe_separator_inside_does_not_stash_as_math(self, driver_path): + # Two cells, second cell starts with `$x` and first ends with `$`. + # Pre-fix, the inline-math regex would greedily match across `|` and + # turn the row into one mathy cell. Post-fix, the `m.includes(' | ')` + # guard skips the math stash for this shape. + md = "| $5 | $10 |\n|---|---|\n| ok | y |" + out = _render(driver_path, md) + cells = _table_cells(out) + assert len(cells) == 4, ( + f"`$5 | $10` must remain two cells, not collapse to one math span. " + f"Got {len(cells)} cells: {cells!r}" + ) From 573b1e12ad89e9738e26fb4f52305732d2738afd Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Sun, 17 May 2026 03:53:29 +0000 Subject: [PATCH 055/138] =?UTF-8?q?Stage=20375:=20PR=20#2428=20stage-fix?= =?UTF-8?q?=20#2=20=E2=80=94=20drop=20angle=20brackets=20from=20pipe-prote?= =?UTF-8?q?ct=20set=20(Opus=20catch)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opus advisor review of stage-375 flagged that the protected-bracket set including `<` and `>` caused tables containing comparison operators across adjacent columns to mis-collapse: `| x < 5 | y > 10 |` matched `< ... >` as a bracket pair and stashed the inner pipe, producing one cell instead of two. Real LLM table output uses angle brackets as comparison operators far more often than as content-grouping pairs, so the safer default is to NOT treat them as a matched pair. Dropped `<` from the opener class and `>` from both closer classes. Three regression tests added (`TestComparisonOperatorsAcrossColumns` class): `< … >` across columns, `<` alone, `>` alone. --- static/ui.js | 5 +++- tests/test_issue2428_table_pipe_protection.py | 28 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/static/ui.js b/static/ui.js index 1a0b5268..5a33723d 100644 --- a/static/ui.js +++ b/static/ui.js @@ -2724,7 +2724,10 @@ function renderMd(raw){ // are written as hex escapes (\x7b and \x7d) so the JS source contains no // bare brace glyphs that would confuse the brace-counting extractFunc in // tests/test_renderer_js_behaviour.py. Regex semantics are identical. - const _protectPipes=r=>{let prev;do{prev=r;r=r.replace(/([([\x7b<][^)\]\x7d>]*)[|]([^)\]\x7d>]*[)\]\x7d>])/g,(_,a,b)=>a+'\x00PIPE\x00'+b);}while(r!==prev);return r;}; + // Bracket set is paren / square / curly only -- NOT angle brackets, since + // angle brackets are overwhelmingly comparison operators in real LLM table + // output (`| x < 5 | y > 10 |`) and treating them as a pair collapses cells. + const _protectPipes=r=>{let prev;do{prev=r;r=r.replace(/([([\x7b][^)\]\x7d]*)[|]([^)\]\x7d]*[)\]\x7d])/g,(_,a,b)=>a+'\x00PIPE\x00'+b);}while(r!==prev);return r;}; const _restorePipes=s=>s.replace(/\x00PIPE\x00/g,'|'); const parseRow=r=>{r=_protectPipes(r);return r.trim().replace(/^\|/,'').replace(/\|$/,'').split('|').map(c=>`${inlineMd(_restorePipes(c.trim()))}${inlineMd(_restorePipes(c.trim()))}