From 8757a8fbf6761f3bb2ff211d4ab014368f8b09b7 Mon Sep 17 00:00:00 2001
From: AJV20 <24819659+AJV20@users.noreply.github.com>
Date: Wed, 27 May 2026 22:44:55 -0400
Subject: [PATCH 01/21] fix: align AI-recent notes with WebUI prefill hook
---
CHANGELOG.md | 4 ++++
api/routes.py | 28 +++++++++++++++++++++++---
tests/test_webui_notes_sources.py | 33 +++++++++++++++++++++++++++++++
3 files changed, 62 insertions(+), 3 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index dab8e62b4ce..1693520ad85 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,10 @@
## [Unreleased]
+### Fixed
+
+- The third-party notes drawer's “Recently used by AI” Joplin list now follows the WebUI-specific `webui_prefill_messages_script` hook when configured, including argv-style hooks such as `[python3, /path/to/recall.py]`, before falling back to the legacy generic `prefill_messages_script`.
+
## [v0.51.150] — 2026-05-28 — Release DV (stage-batch32 — single-PR reasoning-effort agent metadata)
### Fixed
diff --git a/api/routes.py b/api/routes.py
index 7e829475b0e..dda7f6d1dc9 100644
--- a/api/routes.py
+++ b/api/routes.py
@@ -12879,17 +12879,39 @@ def _joplin_get_note(note_id: str) -> dict:
]
-def _joplin_prefill_script_path() -> Path | None:
- cfg = get_config()
- path_value = cfg.get("prefill_messages_script") if isinstance(cfg, dict) else None
+def _script_path_from_config_value(path_value) -> Path | None:
+ """Return the likely recall script path from a string or argv-style hook."""
if not path_value:
return None
try:
+ if isinstance(path_value, (list, tuple)):
+ candidates = [str(part).strip() for part in path_value if str(part).strip()]
+ # Hooks commonly use [python, /path/to/script.py]. Prefer the first
+ # Python-ish script argument over the interpreter so AI-recent notes
+ # reflect the configured recall source rather than "python3".
+ for candidate in candidates:
+ if candidate.endswith((".py", ".sh", ".bash")):
+ return Path(candidate).expanduser()
+ if candidates:
+ return Path(candidates[-1]).expanduser()
+ return None
return Path(str(path_value)).expanduser()
except Exception:
return None
+def _joplin_prefill_script_path() -> Path | None:
+ cfg = get_config()
+ if not isinstance(cfg, dict):
+ return None
+ # The browser notes drawer should mirror the WebUI-specific recall hook when
+ # configured. Fall back to the legacy generic session prefill script only for
+ # deployments that have not opted into WebUI dynamic recall.
+ return _script_path_from_config_value(
+ cfg.get("webui_prefill_messages_script") or cfg.get("prefill_messages_script")
+ )
+
+
def _joplin_recall_note_refs(script_path: Path | None = None) -> list[dict]:
"""Find stable Joplin note IDs referenced by the configured recall script.
diff --git a/tests/test_webui_notes_sources.py b/tests/test_webui_notes_sources.py
index 1e9e195988d..7e4d038bdba 100644
--- a/tests/test_webui_notes_sources.py
+++ b/tests/test_webui_notes_sources.py
@@ -200,6 +200,39 @@ def fake_get(path, params=None):
assert all(note["used_reason"] == "automatic_recall" for note in notes)
+def test_joplin_recent_ai_notes_prefers_webui_prefill_script_hook(monkeypatch, tmp_path):
+ from api import routes
+
+ legacy_script = tmp_path / "legacy_context.py"
+ legacy_script.write_text('CURRENT_CONTEXT_ID = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"\n', encoding="utf-8")
+ webui_script = tmp_path / "webui_context.py"
+ webui_script.write_text(
+ 'CURRENT_CONTEXT_ID = "5ba9ab822c344115939205ca4e8eaec0"\n'
+ 'OPEN_ISSUES_ID = "623aeb6e55cb4aa39a0541f2ac09aa36"\n',
+ encoding="utf-8",
+ )
+ monkeypatch.setattr(routes, "get_config", lambda: {
+ "prefill_messages_script": str(legacy_script),
+ "webui_prefill_messages_script": ["python3", str(webui_script)],
+ })
+
+ def fake_get(path, params=None):
+ note_id = path.rsplit("/", 1)[-1]
+ assert note_id != "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+ titles = {
+ "5ba9ab822c344115939205ca4e8eaec0": "Current Context",
+ "623aeb6e55cb4aa39a0541f2ac09aa36": "Open Issues",
+ }
+ return {"id": note_id, "title": titles[note_id], "updated_time": 123, "parent_id": "folder"}
+
+ monkeypatch.setattr(routes, "_joplin_api_get", fake_get)
+
+ notes = routes._joplin_recent_ai_notes(limit=2)
+
+ assert [note["title"] for note in notes] == ["Current Context", "Open Issues"]
+
+
+
def test_external_notes_ui_uses_minimal_lucide_icons_for_ai_recent_notes():
from pathlib import Path
From 5f42e87aa910f737efad4ccb9d431adffe78e31d Mon Sep 17 00:00:00 2001
From: ai-ag2026 <261867348+ai-ag2026@users.noreply.github.com>
Date: Thu, 28 May 2026 07:52:31 +0200
Subject: [PATCH 02/21] fix: skip stale repair for compression parents
---
CHANGELOG.md | 4 +
api/models.py | 78 +++++++++++++++++++
api/streaming.py | 8 ++
...test_compression_snapshot_runtime_clear.py | 19 +++++
.../test_session_lost_response_regression.py | 40 ++++++++++
5 files changed, 149 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a990d204abc..6c36c7719cc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,10 @@
## [Unreleased]
+### Fixed
+
+- Compression parent sessions are no longer repaired as stale interrupted turns when a continuation already exists, preventing false "Response interrupted" markers and hidden continuation rows after auto-compression session rotation. (Refs #2361)
+
## [v0.51.152] — 2026-05-28 — Release DX (stage-batch34 — single-PR optional gateway-backed browser chat)
### Added
diff --git a/api/models.py b/api/models.py
index 4ae8d965f52..b9da5ffa875 100644
--- a/api/models.py
+++ b/api/models.py
@@ -1850,6 +1850,72 @@ def _apply_core_sync_or_error_marker(
_REPAIR_STALE_PENDING_GRACE_SECONDS = 30
+def _has_compression_continuation(session) -> bool:
+ """Return True when ``session`` is an archived compression parent.
+
+ Context compression rotates the live WebUI session id: the old sidecar is
+ preserved for lineage while the new child owns the running/completed turn.
+ Stale-pending repair must not append an interruption marker to that old
+ parent just because its stream bookkeeping disappeared after the rotation.
+ """
+ sid = getattr(session, 'session_id', None)
+ if not sid:
+ return False
+
+ def _row_is_continuation(row) -> bool:
+ if not isinstance(row, dict):
+ return False
+ child_sid = row.get('session_id')
+ if not child_sid or child_sid == sid:
+ return False
+ if row.get('parent_session_id') != sid:
+ return False
+ # Any child row is enough evidence that this pending state belongs to a
+ # compression lineage, not a dead standalone turn. The child may itself
+ # temporarily carry a bad pre_compression_snapshot flag from older code;
+ # do not filter it out here or the guard misses the exact regression.
+ return True
+
+ try:
+ with LOCK:
+ for child in SESSIONS.values():
+ if getattr(child, 'session_id', None) == sid:
+ continue
+ if getattr(child, 'parent_session_id', None) == sid:
+ return True
+ except Exception:
+ pass
+
+ try:
+ if SESSION_INDEX_FILE.exists():
+ entries = json.loads(SESSION_INDEX_FILE.read_text(encoding='utf-8'))
+ if isinstance(entries, list) and any(_row_is_continuation(e) for e in entries):
+ return True
+ except Exception:
+ logger.debug("Failed to inspect session index for compression continuation", exc_info=True)
+
+ # Index rows can lag behind rapid compression/save races. Fall back to a
+ # shallow JSON metadata scan; session files write parent_session_id before
+ # the messages array, so this avoids loading multi-MB transcripts.
+ try:
+ needle = f'"parent_session_id": "{sid}"'
+ for path in SESSION_DIR.glob('*.json'):
+ if path.name.startswith('_') or path.stem == sid:
+ continue
+ try:
+ head = path.read_text(encoding='utf-8', errors='ignore')[:4096]
+ except TypeError:
+ head = path.read_text(encoding='utf-8')[:4096]
+ except OSError:
+ continue
+ if needle in head:
+ return True
+ except Exception:
+ logger.debug("Failed to scan session files for compression continuation", exc_info=True)
+
+ return False
+
+
def _repair_stale_pending(session) -> bool:
"""Recover a sidecar stuck with messages=[] and stale pending state.
@@ -1872,6 +1938,18 @@ def _repair_stale_pending(session) -> bool:
or not _seen_stream_id
or _seen_stream_id in _active_stream_ids()):
return False
+ if getattr(session, 'pre_compression_snapshot', False):
+ logger.debug(
+ "_repair_stale_pending: skipping pre-compression snapshot %s",
+ getattr(session, 'session_id', '?'),
+ )
+ return False
+ if _has_compression_continuation(session):
+ logger.debug(
+ "_repair_stale_pending: skipping compression parent %s with continuation",
+ getattr(session, 'session_id', '?'),
+ )
+ return False
# Grace-period guard: bail if the turn is too fresh to be a real crash.
# Falsy pending_started_at (None, 0, missing) means "old enough" — preserve
diff --git a/api/streaming.py b/api/streaming.py
index 6c70234dbb2..7800ed06835 100644
--- a/api/streaming.py
+++ b/api/streaming.py
@@ -5197,6 +5197,14 @@ def _periodic_checkpoint():
# the write when the file already contains up-to-date data
# (i.e. it was just saved by a checkpoint).
_preserve_pre_compression_snapshot(s, old_sid)
+ # The continuation is the live/tip session, not another archived
+ # snapshot. If the in-memory object was itself loaded from a
+ # pre-compression snapshot (possible on repeated compression chains
+ # or stale-cache repair paths), _preserve_pre_compression_snapshot()
+ # intentionally restores that old flag; clear it before saving the
+ # new continuation so sidebar/discoverability code does not hide the
+ # session that owns the completed turn.
+ s.pre_compression_snapshot = False
# Always link the continuation session to its immediate predecessor
# (the preserved snapshot). This OVERRIDES any prior
# parent_session_id because the new continuation IS the next link
diff --git a/tests/test_compression_snapshot_runtime_clear.py b/tests/test_compression_snapshot_runtime_clear.py
index 6ee5bd50635..56ca8debb25 100644
--- a/tests/test_compression_snapshot_runtime_clear.py
+++ b/tests/test_compression_snapshot_runtime_clear.py
@@ -95,3 +95,22 @@ def test_preserve_pre_compression_snapshot_load_and_mark_branch_clears_runtime_f
assert saved["pending_user_message"] is None
assert saved["pending_attachments"] == []
assert saved["pending_started_at"] is None
+
+
+def test_preserve_pre_compression_snapshot_does_not_leave_continuation_marked_as_snapshot(tmp_path, monkeypatch):
+ """A continuation loaded from an old snapshot must not remain hidden."""
+ monkeypatch.setattr(streaming, "SESSION_DIR", tmp_path)
+ (tmp_path / "old_session.json").write_text(json.dumps({"messages": []}), encoding="utf-8")
+ session = FakeSession()
+ session.pre_compression_snapshot = True
+
+ streaming._preserve_pre_compression_snapshot(session, "old_session")
+ # The helper archives the parent and restores the incoming object state.
+ # The streaming compression path must clear this before saving the child.
+ assert session.pre_compression_snapshot is True
+
+ session.pre_compression_snapshot = False
+ session.save(touch_updated_at=False)
+ continuation = json.loads((tmp_path / "new_session.json").read_text(encoding="utf-8"))
+ assert continuation["pre_compression_snapshot"] is False
+
diff --git a/tests/test_session_lost_response_regression.py b/tests/test_session_lost_response_regression.py
index 3c9707b9e05..9f8b63c7293 100644
--- a/tests/test_session_lost_response_regression.py
+++ b/tests/test_session_lost_response_regression.py
@@ -536,3 +536,43 @@ def append_should_not_run(*args, **kwargs):
assert marker["content"] == models._INTERRUPTED_NEUTRAL_WORDING
_assert_retry_meta_removed(marker)
assert append_calls == 0
+
+
+def test_repair_stale_pending_skips_pre_compression_snapshot_parent(hermes_home):
+ """Archived compression parents must not get synthetic interrupt markers."""
+ s = _make_dead_stream_session("compressed_parent", stream_id="dead-stream")
+ s.pre_compression_snapshot = True
+ original_messages = list(s.messages)
+
+ assert models._repair_stale_pending(s) is False
+
+ assert s.messages == original_messages
+ assert s.active_stream_id == "dead-stream"
+ assert s.pending_user_message
+
+
+def test_repair_stale_pending_skips_parent_when_continuation_exists(hermes_home):
+ """Compression old→new rotation owns the turn in the child, not the old parent."""
+ parent = _make_dead_stream_session("compression_parent", stream_id="rotated-stream")
+ child = Session(
+ session_id="compression_child",
+ title="Continuation",
+ parent_session_id="compression_parent",
+ # Pin the production regression: older code could accidentally save the
+ # child with pre_compression_snapshot=True, but its parent link still
+ # proves the parent must not be repaired as a lost standalone turn.
+ pre_compression_snapshot=True,
+ messages=[
+ {"role": "user", "content": "ok, push beide", "timestamp": 10},
+ {"role": "assistant", "content": "done", "timestamp": 11},
+ ],
+ )
+ child.save()
+ original_messages = list(parent.messages)
+
+ assert models._repair_stale_pending(parent) is False
+
+ assert parent.messages == original_messages
+ assert parent.active_stream_id == "rotated-stream"
+ assert parent.pending_user_message
+
From f879fd6bc39dc08f4857f168cfe527ccb8e5c1b4 Mon Sep 17 00:00:00 2001
From: ai-ag2026 <261867348+ai-ag2026@users.noreply.github.com>
Date: Thu, 28 May 2026 08:19:49 +0200
Subject: [PATCH 03/21] fix: add dry-run discoverability safe repair
---
CHANGELOG.md | 3 +
api/session_discoverability.py | 196 ++++++++++++++++++-
tests/test_session_discoverability_repair.py | 175 +++++++++++++++++
3 files changed, 372 insertions(+), 2 deletions(-)
create mode 100644 tests/test_session_discoverability_repair.py
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a990d204abc..3ac7e23e1d5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,9 @@
## [Unreleased]
+### Added
+- Session discoverability audit now has a default-dry-run `--repair-safe` routine for deterministic cleanup: stale persisted WebUI-as-CLI flags can be cleared from sidecars/index entries, and messageful WebUI rows present only in `state.db` can be materialized into sidecars/index entries when `--apply --backup-dir
` is explicitly provided.
+
## [v0.51.152] — 2026-05-28 — Release DX (stage-batch34 — single-PR optional gateway-backed browser chat)
### Added
diff --git a/api/session_discoverability.py b/api/session_discoverability.py
index 0f218561bc5..a04ca753517 100644
--- a/api/session_discoverability.py
+++ b/api/session_discoverability.py
@@ -12,6 +12,8 @@
import argparse
import json
+import os
+import shutil
import sqlite3
from collections import Counter
from pathlib import Path
@@ -373,6 +375,184 @@ def audit_session_discoverability(
}
+def _atomic_write_json(path: Path, payload) -> None:
+ tmp = path.with_suffix(path.suffix + f".tmp.{os.getpid()}")
+ tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
+ os.replace(tmp, path)
+
+
+def _backup_file(path: Path, backup_dir: Path, backed_up: dict[Path, str]) -> str | None:
+ if not path.exists():
+ return None
+ resolved = path.resolve()
+ if resolved in backed_up:
+ return backed_up[resolved]
+ backup_dir.mkdir(parents=True, exist_ok=True)
+ target = backup_dir / path.name
+ if target.exists():
+ stem = target.name
+ i = 1
+ while (backup_dir / f"{stem}.{i}").exists():
+ i += 1
+ target = backup_dir / f"{stem}.{i}"
+ shutil.copy2(path, target)
+ backed_up[resolved] = str(target)
+ return str(target)
+
+
+def _plan_discoverability_repairs(report: dict) -> list[dict]:
+ actions: list[dict] = []
+ for item in report.get("items") or []:
+ sid = str(item.get("session_id") or "")
+ if not sid:
+ continue
+ if item.get("kind") == "persisted_source_flag_stale":
+ if item.get("sidecar_is_cli_session") is True:
+ actions.append({"session_id": sid, "action": "clear_sidecar_cli_flag"})
+ if item.get("index_is_cli_session") is True:
+ actions.append({"session_id": sid, "action": "clear_index_cli_flag"})
+ elif item.get("kind") == "state_db_messageful_missing_sidecar":
+ actions.append({"session_id": sid, "action": "materialize_sidecar_from_state_db"})
+ return actions
+
+
+def _clear_sidecar_cli_flag(session_dir: Path, sid: str, backup_dir: Path, backed_up: dict[Path, str]) -> dict:
+ path = session_dir / f"{sid}.json"
+ payload = _read_json(path)
+ if not isinstance(payload, dict):
+ return {"session_id": sid, "action": "clear_sidecar_cli_flag", "applied": False, "error": "sidecar_unreadable"}
+ if not _webui_origin(payload):
+ return {"session_id": sid, "action": "clear_sidecar_cli_flag", "applied": False, "skipped": "not_webui_origin"}
+ if payload.get("is_cli_session") is not True:
+ return {"session_id": sid, "action": "clear_sidecar_cli_flag", "applied": False, "skipped": "already_clear"}
+ backup = _backup_file(path, backup_dir, backed_up)
+ payload["is_cli_session"] = False
+ _atomic_write_json(path, payload)
+ return {"session_id": sid, "action": "clear_sidecar_cli_flag", "applied": True, "backup": backup}
+
+
+def _clear_index_cli_flag(session_dir: Path, sid: str, backup_dir: Path, backed_up: dict[Path, str]) -> dict:
+ path = session_dir / "_index.json"
+ payload = _read_json(path)
+ if not isinstance(payload, list):
+ return {"session_id": sid, "action": "clear_index_cli_flag", "applied": False, "error": "index_unreadable"}
+ changed = False
+ for entry in payload:
+ if not isinstance(entry, dict):
+ continue
+ if str(entry.get("session_id") or "") != sid:
+ continue
+ if not _webui_origin(entry):
+ continue
+ if entry.get("is_cli_session") is True:
+ entry["is_cli_session"] = False
+ changed = True
+ if not changed:
+ return {"session_id": sid, "action": "clear_index_cli_flag", "applied": False, "skipped": "already_clear_or_missing"}
+ backup = _backup_file(path, backup_dir, backed_up)
+ _atomic_write_json(path, payload)
+ return {"session_id": sid, "action": "clear_index_cli_flag", "applied": True, "backup": backup}
+
+
+def _materialize_sidecar_from_state_db(session_dir: Path, state_db_path: Path | None, sid: str, backup_dir: Path, backed_up: dict[Path, str]) -> dict:
+ if state_db_path is None:
+ return {"session_id": sid, "action": "materialize_sidecar_from_state_db", "applied": False, "error": "state_db_required"}
+ target = session_dir / f"{sid}.json"
+ if target.exists():
+ return {"session_id": sid, "action": "materialize_sidecar_from_state_db", "applied": False, "skipped": "sidecar_exists"}
+ try:
+ from api.session_recovery import _read_state_db_missing_sidecar_rows, _state_db_row_to_sidecar
+ except Exception as exc:
+ return {"session_id": sid, "action": "materialize_sidecar_from_state_db", "applied": False, "error": f"recovery_import_failed:{exc}"}
+ rows = {str(row.get("id") or ""): row for row in _read_state_db_missing_sidecar_rows(session_dir, state_db_path)}
+ row = rows.get(sid)
+ if not row:
+ return {"session_id": sid, "action": "materialize_sidecar_from_state_db", "applied": False, "skipped": "state_row_not_repairable"}
+ payload = _state_db_row_to_sidecar(row)
+ _backup_file(state_db_path, backup_dir, backed_up)
+ session_dir.mkdir(parents=True, exist_ok=True)
+ tmp = target.with_suffix(target.suffix + f".tmp.{os.getpid()}")
+ tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
+ try:
+ os.link(str(tmp), str(target))
+ except FileExistsError:
+ return {"session_id": sid, "action": "materialize_sidecar_from_state_db", "applied": False, "skipped": "sidecar_appeared_during_repair"}
+ finally:
+ try:
+ tmp.unlink(missing_ok=True)
+ except OSError:
+ pass
+ index_updated = False
+ index_path = session_dir / "_index.json"
+ index_payload = _read_json(index_path)
+ if not isinstance(index_payload, list):
+ index_payload = []
+ if not any(isinstance(entry, dict) and str(entry.get("session_id") or "") == sid for entry in index_payload):
+ _backup_file(index_path, backup_dir, backed_up)
+ index_entry = {key: value for key, value in payload.items() if key not in {"messages", "tool_calls"}}
+ index_payload.append(index_entry)
+ _atomic_write_json(index_path, index_payload)
+ index_updated = True
+ return {
+ "session_id": sid,
+ "action": "materialize_sidecar_from_state_db",
+ "applied": True,
+ "messages": len(payload.get("messages") or []),
+ "index_updated": index_updated,
+ "backup": str((backup_dir / state_db_path.name)) if (backup_dir / state_db_path.name).exists() else None,
+ }
+
+
+def repair_session_discoverability(
+ session_dir: Path,
+ state_db_path: Path | None = None,
+ *,
+ api_sessions: Iterable[dict] | None = None,
+ dry_run: bool = True,
+ backup_dir: Path | None = None,
+) -> dict:
+ """Plan or apply deterministic discoverability repairs.
+
+ Default mode is read-only. Applying mutations requires ``backup_dir`` and is
+ limited to stale persisted WebUI-as-CLI flags plus materializing WebUI
+ messageful sidecars from canonical state.db rows.
+ """
+ before = audit_session_discoverability(session_dir, state_db_path=state_db_path, api_sessions=api_sessions)
+ planned = _plan_discoverability_repairs(before)
+ if dry_run:
+ return {"ok": True, "dry_run": True, "planned": planned, "applied": [], "before": before, "after": before}
+ if backup_dir is None:
+ return {"ok": False, "dry_run": False, "error": "backup_dir_required_for_apply", "planned": planned, "applied": [], "before": before}
+
+ session_dir = Path(session_dir)
+ backup_dir = Path(backup_dir)
+ backed_up: dict[Path, str] = {}
+ applied: list[dict] = []
+ for action in planned:
+ sid = str(action.get("session_id") or "")
+ name = action.get("action")
+ try:
+ if name == "clear_sidecar_cli_flag":
+ applied.append(_clear_sidecar_cli_flag(session_dir, sid, backup_dir, backed_up))
+ elif name == "clear_index_cli_flag":
+ applied.append(_clear_index_cli_flag(session_dir, sid, backup_dir, backed_up))
+ elif name == "materialize_sidecar_from_state_db":
+ applied.append(_materialize_sidecar_from_state_db(session_dir, state_db_path, sid, backup_dir, backed_up))
+ except Exception as exc:
+ applied.append({"session_id": sid, "action": name, "applied": False, "error": str(exc)})
+ after = audit_session_discoverability(session_dir, state_db_path=state_db_path, api_sessions=api_sessions)
+ errors = [item for item in applied if item.get("error")]
+ return {
+ "ok": not errors,
+ "dry_run": False,
+ "planned": planned,
+ "applied": applied,
+ "backups": sorted(set(backed_up.values())),
+ "before": before,
+ "after": after,
+ }
+
+
def render_discoverability_markdown(report: dict) -> str:
lines = [
"# WebUI Session Discoverability Audit",
@@ -416,11 +596,23 @@ def _main() -> int:
parser.add_argument("--session-dir", type=Path, required=True)
parser.add_argument("--state-db", type=Path, default=None)
parser.add_argument("--format", choices=("json", "markdown"), default="json")
+ parser.add_argument("--repair-safe", action="store_true", help="Plan/apply deterministic discoverability repairs")
+ parser.add_argument("--apply", action="store_true", help="Apply --repair-safe changes; default is dry-run")
+ parser.add_argument("--backup-dir", type=Path, default=None, help="Required with --repair-safe --apply")
parser.add_argument("--out", type=Path, default=None)
args = parser.parse_args()
- report = audit_session_discoverability(args.session_dir, state_db_path=args.state_db)
- text = render_discoverability_markdown(report) if args.format == "markdown" else json.dumps(report, sort_keys=True)
+ if args.repair_safe:
+ report = repair_session_discoverability(
+ args.session_dir,
+ state_db_path=args.state_db,
+ dry_run=not args.apply,
+ backup_dir=args.backup_dir,
+ )
+ text = json.dumps(report, sort_keys=True)
+ else:
+ report = audit_session_discoverability(args.session_dir, state_db_path=args.state_db)
+ text = render_discoverability_markdown(report) if args.format == "markdown" else json.dumps(report, sort_keys=True)
if args.out:
args.out.parent.mkdir(parents=True, exist_ok=True)
args.out.write_text(text, encoding="utf-8")
diff --git a/tests/test_session_discoverability_repair.py b/tests/test_session_discoverability_repair.py
new file mode 100644
index 00000000000..c43c9fc11b3
--- /dev/null
+++ b/tests/test_session_discoverability_repair.py
@@ -0,0 +1,175 @@
+import json
+import sqlite3
+import subprocess
+import sys
+from pathlib import Path
+
+from api.session_discoverability import repair_session_discoverability
+
+
+def _write_sidecar(session_dir: Path, sid: str, *, messages=1, **metadata):
+ payload = {
+ "session_id": sid,
+ "id": sid,
+ "title": metadata.pop("title", sid),
+ "messages": [{"role": "user", "content": f"message {i}"} for i in range(messages)],
+ **metadata,
+ }
+ path = session_dir / f"{sid}.json"
+ path.write_text(json.dumps(payload), encoding="utf-8")
+ return path
+
+
+def _write_index(session_dir: Path, *entries):
+ (session_dir / "_index.json").write_text(json.dumps(list(entries)), encoding="utf-8")
+
+
+def _state_db(session_dir: Path, rows, message_counts=None):
+ db = session_dir / "state.db"
+ message_counts = message_counts or {}
+ with sqlite3.connect(db) as conn:
+ conn.execute(
+ """
+ create table sessions (
+ id text primary key,
+ source text,
+ title text,
+ parent_session_id text,
+ message_count integer,
+ started_at real,
+ model text,
+ workspace text
+ )
+ """
+ )
+ conn.execute("create table messages (id integer primary key, session_id text, role text, content text, timestamp real)")
+ for row in rows:
+ conn.execute(
+ """
+ insert into sessions (id, source, title, parent_session_id, message_count, started_at, model, workspace)
+ values (?, ?, ?, ?, ?, ?, ?, ?)
+ """,
+ (
+ row["id"],
+ row.get("source"),
+ row.get("title") or row["id"],
+ row.get("parent_session_id"),
+ row.get("message_count", message_counts.get(row["id"], 0)),
+ row.get("started_at", 10.0),
+ row.get("model", "gpt-test"),
+ row.get("workspace", "/tmp/workspace"),
+ ),
+ )
+ for i in range(message_counts.get(row["id"], 0)):
+ conn.execute(
+ "insert into messages (session_id, role, content, timestamp) values (?, 'user', ?, ?)",
+ (row["id"], f"message {i}", 10.0 + i),
+ )
+ return db
+
+
+def test_repair_discoverability_dry_run_plans_without_mutating_files(tmp_path):
+ stale = "webui-stale-cli-flag"
+ missing = "state-only-messageful"
+ _write_sidecar(tmp_path, stale, messages=3, source_tag="webui", session_source="webui", is_cli_session=True)
+ _write_index(tmp_path, {"session_id": stale, "message_count": 3, "source_tag": "webui", "session_source": "webui", "is_cli_session": True})
+ db = _state_db(
+ tmp_path,
+ [
+ {"id": stale, "source": "webui", "message_count": 3},
+ {"id": missing, "source": "webui", "message_count": 2},
+ ],
+ {stale: 3, missing: 2},
+ )
+
+ result = repair_session_discoverability(tmp_path, state_db_path=db, dry_run=True, backup_dir=tmp_path / "backup")
+
+ assert result["dry_run"] is True
+ assert result["applied"] == []
+ assert {action["action"] for action in result["planned"]} == {
+ "clear_sidecar_cli_flag",
+ "clear_index_cli_flag",
+ "materialize_sidecar_from_state_db",
+ }
+ assert json.loads((tmp_path / f"{stale}.json").read_text())["is_cli_session"] is True
+ assert json.loads((tmp_path / "_index.json").read_text())[0]["is_cli_session"] is True
+ assert not (tmp_path / f"{missing}.json").exists()
+ assert not (tmp_path / "backup").exists()
+
+
+def test_repair_discoverability_apply_requires_backup_dir(tmp_path):
+ sid = "webui-stale-cli-flag"
+ _write_sidecar(tmp_path, sid, messages=1, source_tag="webui", session_source="webui", is_cli_session=True)
+ _write_index(tmp_path, {"session_id": sid, "message_count": 1, "source_tag": "webui", "session_source": "webui", "is_cli_session": True})
+ db = _state_db(tmp_path, [{"id": sid, "source": "webui", "message_count": 1}], {sid: 1})
+
+ result = repair_session_discoverability(tmp_path, state_db_path=db, dry_run=False)
+
+ assert result["ok"] is False
+ assert result["error"] == "backup_dir_required_for_apply"
+ assert json.loads((tmp_path / f"{sid}.json").read_text())["is_cli_session"] is True
+
+
+def test_repair_discoverability_apply_backs_up_and_repairs_safe_findings(tmp_path):
+ stale = "webui-stale-cli-flag"
+ missing = "state-only-messageful"
+ _write_sidecar(tmp_path, stale, messages=3, source_tag="webui", session_source="webui", is_cli_session=True)
+ _write_index(tmp_path, {"session_id": stale, "message_count": 3, "source_tag": "webui", "session_source": "webui", "is_cli_session": True})
+ db = _state_db(
+ tmp_path,
+ [
+ {"id": stale, "source": "webui", "message_count": 3},
+ {"id": missing, "source": "webui", "message_count": 2, "title": "Recovered From State"},
+ ],
+ {stale: 3, missing: 2},
+ )
+
+ result = repair_session_discoverability(tmp_path, state_db_path=db, dry_run=False, backup_dir=tmp_path / "backup")
+
+ assert result["ok"] is True
+ assert result["dry_run"] is False
+ assert {action["action"] for action in result["applied"]} == {
+ "clear_sidecar_cli_flag",
+ "clear_index_cli_flag",
+ "materialize_sidecar_from_state_db",
+ }
+ assert json.loads((tmp_path / f"{stale}.json").read_text())["is_cli_session"] is False
+ assert json.loads((tmp_path / "_index.json").read_text())[0]["is_cli_session"] is False
+ index_rows = json.loads((tmp_path / "_index.json").read_text())
+ assert {row["session_id"] for row in index_rows} == {stale, missing}
+ recovered = json.loads((tmp_path / f"{missing}.json").read_text())
+ assert recovered["title"] == "Recovered From State"
+ assert recovered["message_count"] == 2
+ assert len(recovered["messages"]) == 2
+ backed_up = {p.name for p in (tmp_path / "backup").iterdir()}
+ assert f"{stale}.json" in backed_up
+ assert "_index.json" in backed_up
+ assert "state.db" in backed_up
+
+
+def test_repair_discoverability_cli_defaults_to_dry_run(tmp_path):
+ sid = "webui-stale-cli-flag"
+ _write_sidecar(tmp_path, sid, messages=1, source_tag="webui", session_source="webui", is_cli_session=True)
+ _write_index(tmp_path, {"session_id": sid, "message_count": 1, "source_tag": "webui", "session_source": "webui", "is_cli_session": True})
+ db = _state_db(tmp_path, [{"id": sid, "source": "webui", "message_count": 1}], {sid: 1})
+
+ completed = subprocess.run(
+ [
+ sys.executable,
+ "-m",
+ "api.session_discoverability",
+ "--repair-safe",
+ "--session-dir",
+ str(tmp_path),
+ "--state-db",
+ str(db),
+ ],
+ check=True,
+ text=True,
+ capture_output=True,
+ )
+
+ result = json.loads(completed.stdout)
+ assert result["dry_run"] is True
+ assert [action["action"] for action in result["planned"]] == ["clear_sidecar_cli_flag", "clear_index_cli_flag"]
+ assert json.loads((tmp_path / f"{sid}.json").read_text())["is_cli_session"] is True
From 2ee249112a4c5a3da4f1c85b402012383c769aef Mon Sep 17 00:00:00 2001
From: ai-ag2026 <261867348+ai-ag2026@users.noreply.github.com>
Date: Thu, 28 May 2026 08:49:26 +0200
Subject: [PATCH 04/21] fix: defer streaming KaTeX for pending equations
---
CHANGELOG.md | 4 ++
static/messages.js | 2 +-
static/ui.js | 20 ++++++-
tests/test_katex_streaming.py | 63 +++++++++++++++++++++++
tests/test_streaming_katex_live_render.py | 5 +-
5 files changed, 90 insertions(+), 4 deletions(-)
create mode 100644 tests/test_katex_streaming.py
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a990d204abc..5822405f384 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,10 @@
## [Unreleased]
+### Fixed
+
+- Streaming KaTeX render passes now skip parser-owned equation placeholders that may still be receiving text, preventing long equations from being marked rendered before the final parser flush completes. (#2976)
+
## [v0.51.152] — 2026-05-28 — Release DX (stage-batch34 — single-PR optional gateway-backed browser chat)
### Added
diff --git a/static/messages.js b/static/messages.js
index c21951506cf..0d4f6ca18b4 100644
--- a/static/messages.js
+++ b/static/messages.js
@@ -1067,7 +1067,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
if(_streamingKatexTimer) return;
_streamingKatexTimer=setTimeout(()=>{
_streamingKatexTimer=null;
- if(assistantBody&&typeof renderKatexBlocks==='function') renderKatexBlocks(assistantBody);
+ if(assistantBody&&typeof renderKatexBlocks==='function') renderKatexBlocks(assistantBody,{streaming:true});
},150);
}
// Helper: feed new displayText delta to the smd parser.
diff --git a/static/ui.js b/static/ui.js
index a4bab419a75..cf50bb75b6b 100644
--- a/static/ui.js
+++ b/static/ui.js
@@ -7754,8 +7754,25 @@ function renderMermaidBlocks(container){
let _katexLoading=false;
let _katexReady=false;
-function renderKatexBlocks(container){
+function _isStreamingEquationPending(el,root){
+ const tagName=(el&&el.tagName||'').toLowerCase();
+ if(tagName!=='equation-block'&&tagName!=='equation-inline') return false;
+ // streaming-markdown fills custom equation elements while the parser owns the
+ // open node. If the equation is currently the last descendant of the live
+ // assistant body, we cannot tell whether more TeX is still coming. Skip it
+ // during live debounce passes so a partial source is not permanently marked
+ // data-rendered before the final parser_end flush.
+ let node=el;
+ while(node&&node!==root){
+ if(node.nextSibling) return false;
+ node=node.parentNode;
+ }
+ return Boolean(node===root);
+}
+
+function renderKatexBlocks(container,options){
const root=container||document;
+ const streaming=Boolean(options&&options.streaming);
const blocks=root.querySelectorAll(
'.katex-block:not([data-rendered]),.katex-inline:not([data-rendered]),'+
'equation-block:not([data-rendered]),equation-inline:not([data-rendered])'
@@ -7779,6 +7796,7 @@ function renderKatexBlocks(container){
return;
}
blocks.forEach(el=>{
+ if(streaming&&_isStreamingEquationPending(el,root)) return;
el.dataset.rendered='true';
const src=el.textContent||'';
const tagName=(el.tagName||'').toLowerCase();
diff --git a/tests/test_katex_streaming.py b/tests/test_katex_streaming.py
new file mode 100644
index 00000000000..8876c08fbe4
--- /dev/null
+++ b/tests/test_katex_streaming.py
@@ -0,0 +1,63 @@
+"""Regression coverage for streaming KaTeX rendering (#2976)."""
+from __future__ import annotations
+
+from pathlib import Path
+
+REPO = Path(__file__).resolve().parents[1]
+UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
+MESSAGES_JS = (REPO / "static" / "messages.js").read_text(encoding="utf-8")
+
+
+def _extract_function(src: str, name: str) -> str:
+ marker = f"function {name}"
+ start = src.find(marker)
+ assert start >= 0, f"{name} not found"
+ brace = src.find("{", start)
+ assert brace >= 0, f"{name} body not found"
+ depth = 1
+ i = brace + 1
+ while i < len(src) and depth:
+ ch = src[i]
+ if ch == "{":
+ depth += 1
+ elif ch == "}":
+ depth -= 1
+ i += 1
+ assert depth == 0, f"{name} body did not close"
+ return src[start:i]
+
+
+def test_streaming_katex_scheduler_marks_live_pass_as_streaming():
+ """The live 150ms KaTeX debounce must identify streaming passes.
+
+ Without the explicit streaming flag, renderKatexBlocks() cannot distinguish a
+ live parser-owned that is still being filled from a settled
+ DOM node, so it may mark partial math as data-rendered permanently.
+ """
+ fn = _extract_function(MESSAGES_JS, "_scheduleStreamingKatex")
+ assert "renderKatexBlocks(assistantBody,{streaming:true})" in fn
+
+
+def test_render_katex_blocks_skips_pending_streaming_equation_before_rendered_flag():
+ """Streaming equation placeholders must be skipped before data-rendered.
+
+ The guard has to run before `el.dataset.rendered='true'`; otherwise a long
+ equation that is still receiving text becomes permanently ineligible for the
+ final complete KaTeX render.
+ """
+ fn = _extract_function(UI_JS, "renderKatexBlocks")
+ assert "function _isStreamingEquationPending" in UI_JS
+ pending_idx = fn.find("_isStreamingEquationPending")
+ rendered_idx = fn.find("el.dataset.rendered='true'")
+ assert pending_idx != -1, "renderKatexBlocks must check pending streaming equations"
+ assert rendered_idx != -1, "renderKatexBlocks must still set data-rendered when rendering"
+ assert pending_idx < rendered_idx, "pending guard must run before data-rendered is set"
+
+
+def test_final_katex_render_keeps_default_non_streaming_path():
+ """Final renderKatexBlocks() calls must still render all math placeholders."""
+ fn = _extract_function(UI_JS, "renderKatexBlocks")
+ assert "const streaming=Boolean" in fn
+ assert "if(streaming&&_isStreamingEquationPending" in fn
+ done_fn = _extract_function(MESSAGES_JS, "_smdEndParser")
+ assert "renderKatexBlocks" not in done_fn, "done rendering remains in done handler after parser_end"
diff --git a/tests/test_streaming_katex_live_render.py b/tests/test_streaming_katex_live_render.py
index 992814e4785..88f48c30fad 100644
--- a/tests/test_streaming_katex_live_render.py
+++ b/tests/test_streaming_katex_live_render.py
@@ -11,7 +11,7 @@ def test_live_smd_writes_schedule_incremental_katex_rendering():
assert "let _streamingKatexTimer=null" in MESSAGES_JS
assert "function _scheduleStreamingKatex()" in MESSAGES_JS
assert "setTimeout(()=>{" in MESSAGES_JS
- assert "renderKatexBlocks(assistantBody)" in MESSAGES_JS
+ assert "renderKatexBlocks(assistantBody,{streaming:true})" in MESSAGES_JS
smd_write_idx = MESSAGES_JS.index("function _smdWrite(displayText, fade=false){")
done_idx = MESSAGES_JS.index("source.addEventListener('done'")
@@ -28,8 +28,9 @@ def test_streaming_katex_timer_is_cleared_when_smd_parser_ends():
def test_katex_renderer_scans_live_and_settled_unrendered_nodes_under_container():
- assert "function renderKatexBlocks(container){" in UI_JS
+ assert "function renderKatexBlocks(container,options){" in UI_JS
assert "const root=container||document;" in UI_JS
+ assert "const streaming=Boolean(options&&options.streaming);" in UI_JS
assert ".katex-block:not([data-rendered]),.katex-inline:not([data-rendered])," in UI_JS
assert "equation-block:not([data-rendered]),equation-inline:not([data-rendered])" in UI_JS
assert "const tagName=(el.tagName||'').toLowerCase();" in UI_JS
From 9190ab44494948433766d116c418e53f15b6ef61 Mon Sep 17 00:00:00 2001
From: Frank Song
Date: Thu, 28 May 2026 15:30:49 +0800
Subject: [PATCH 05/21] Fix empty partial activity tail recency
---
CHANGELOG.md | 8 +++
api/models.py | 23 ++++++++
api/routes.py | 11 ++--
api/streaming.py | 8 ++-
tests/test_empty_partial_activity_restore.py | 53 +++++++++++++++++++
tests/test_session_index.py | 25 +++++++++
..._session_message_window_renderable_tail.py | 22 ++++++++
7 files changed, 145 insertions(+), 5 deletions(-)
create mode 100644 tests/test_empty_partial_activity_restore.py
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a990d204abc..a0d2da602bc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,14 @@
## [Unreleased]
+### Fixed
+
+- Empty partial activity rows preserved from cancelled turns no longer define
+ sidebar recency, anchor the initial paginated message window, or get restored
+ after newer completed turns. Long sessions with old activity-only partials
+ after recent replies now stay grouped by their latest real message and open on
+ the recent readable transcript. (#3057)
+
## [v0.51.152] — 2026-05-28 — Release DX (stage-batch34 — single-PR optional gateway-backed browser chat)
### Added
diff --git a/api/models.py b/api/models.py
index 4ae8d965f52..3ec08239cef 100644
--- a/api/models.py
+++ b/api/models.py
@@ -369,12 +369,35 @@ def _message_timestamp(message):
return None
+def _is_empty_partial_activity_message(message):
+ """Return True for cancelled/recovered activity rows with no reply text."""
+ if not isinstance(message, dict):
+ return False
+ if message.get('role') != 'assistant' or not message.get('_partial'):
+ return False
+ content = message.get('content', '')
+ if isinstance(content, str):
+ return not content.strip()
+ if isinstance(content, list):
+ for part in content:
+ if isinstance(part, dict):
+ if part.get('type') == 'text' and str(part.get('text') or part.get('content') or '').strip():
+ return False
+ continue
+ if str(part or '').strip():
+ return False
+ return True
+ return not str(content or '').strip()
+
+
def _last_message_timestamp(messages):
if not isinstance(messages, list):
return None
for message in reversed(messages):
if isinstance(message, dict) and message.get('role') == 'tool':
continue
+ if _is_empty_partial_activity_message(message):
+ continue
ts = _message_timestamp(message)
if ts:
return ts
diff --git a/api/routes.py b/api/routes.py
index 8ee764dfd1d..0d7def47093 100644
--- a/api/routes.py
+++ b/api/routes.py
@@ -2167,13 +2167,15 @@ def _message_counts_as_renderable_for_window(message) -> bool:
"""Return true when a paginated window should include this transcript row.
Tool result rows are rendered through their assistant anchor or hidden as raw
- tool output. A tail page containing only tool rows makes the frontend set
- ``S.messages`` to a non-empty array while the visible transcript and topbar
- count stay empty. Anchor small tail windows on the newest non-tool row so
- long sessions do not open to a blank chat with only transient metadata.
+ tool output. Empty partial activity rows can be preserved after cancellation
+ to keep thinking/tool details inspectable, but they are not reply text. A
+ tail page containing only transient metadata makes the frontend open to
+ collapsed activity while newer real replies sit behind "load older messages".
"""
if not isinstance(message, dict):
return False
+ if _is_empty_partial_activity_message(message):
+ return False
role = str(message.get("role") or "").strip().lower()
return bool(role and role != "tool")
@@ -2551,6 +2553,7 @@ def _keep_latest_messaging_session_per_source(
get_state_db_session_summary,
merge_session_messages_append_only,
_session_message_merge_key,
+ _is_empty_partial_activity_message,
prune_session_from_index,
ensure_cron_project,
is_cron_session,
diff --git a/api/streaming.py b/api/streaming.py
index 6c70234dbb2..43208aa202f 100644
--- a/api/streaming.py
+++ b/api/streaming.py
@@ -43,7 +43,11 @@
from api.run_journal import RunJournalWriter
from api.turn_journal import append_turn_journal_event_for_stream
from api.usage import prompt_cache_hit_percent
-from api.models import get_state_db_session_messages, reconciled_state_db_messages_for_session
+from api.models import (
+ _is_empty_partial_activity_message,
+ get_state_db_session_messages,
+ reconciled_state_db_messages_for_session,
+)
# Global lock for os.environ writes. Per-session locks (_agent_lock) prevent
# concurrent runs of the SAME session, but two DIFFERENT sessions can still
@@ -2448,6 +2452,8 @@ def _restore_display_reasoning_metadata(previous_messages, updated_messages):
safe_indices = {idx for idx, _ in prev_safe}
inserted_reasoning_only = 0
for prev_idx, prev_msg in enumerate(previous_messages):
+ if _is_empty_partial_activity_message(prev_msg):
+ continue
if prev_idx in safe_indices or not _is_reasoning_only_assistant_message(prev_msg):
continue
safe_pos = sum(1 for idx, _ in prev_safe if idx < prev_idx) + inserted_reasoning_only
diff --git a/tests/test_empty_partial_activity_restore.py b/tests/test_empty_partial_activity_restore.py
new file mode 100644
index 00000000000..ab2b20872f1
--- /dev/null
+++ b/tests/test_empty_partial_activity_restore.py
@@ -0,0 +1,53 @@
+from api.streaming import _restore_display_reasoning_metadata
+
+
+def test_restore_display_reasoning_skips_empty_partial_activity_rows():
+ previous = [
+ {"role": "user", "content": "old turn", "timestamp": 1},
+ {
+ "role": "assistant",
+ "content": "",
+ "_partial": True,
+ "timestamp": 2,
+ "reasoning": "cancelled thinking",
+ "_partial_tool_calls": [{"name": "terminal", "done": True}],
+ },
+ {"role": "user", "content": "new turn", "timestamp": 3},
+ {"role": "assistant", "content": "new answer", "timestamp": 4},
+ ]
+ updated = [
+ {"role": "user", "content": "old turn"},
+ {"role": "user", "content": "new turn"},
+ {"role": "assistant", "content": "new answer"},
+ ]
+
+ restored = _restore_display_reasoning_metadata(previous, updated)
+
+ assert [m.get("content") for m in restored] == [
+ "old turn",
+ "new turn",
+ "new answer",
+ ]
+ assert not any(m.get("_partial") for m in restored)
+
+
+def test_restore_display_reasoning_keeps_non_partial_thinking_rows():
+ previous = [
+ {"role": "user", "content": "old turn", "timestamp": 1},
+ {
+ "role": "assistant",
+ "content": "",
+ "timestamp": 2,
+ "reasoning": "visible thinking card",
+ },
+ {"role": "assistant", "content": "old answer", "timestamp": 3},
+ ]
+ updated = [
+ {"role": "user", "content": "old turn"},
+ {"role": "assistant", "content": "old answer"},
+ ]
+
+ restored = _restore_display_reasoning_metadata(previous, updated)
+
+ assert restored[1]["reasoning"] == "visible thinking card"
+ assert restored[2]["content"] == "old answer"
diff --git a/tests/test_session_index.py b/tests/test_session_index.py
index 91db78c128e..d4b7ca88284 100644
--- a/tests/test_session_index.py
+++ b/tests/test_session_index.py
@@ -83,6 +83,31 @@ def test_compact_exposes_last_message_at_from_message_timestamp():
assert compact["last_message_at"] == 200.0
+def test_compact_ignores_empty_partial_activity_for_last_message_at():
+ s = Session(
+ session_id="sess_partial_tail",
+ title="Partial tail",
+ updated_at=300.0,
+ messages=[
+ {"role": "user", "content": "today question", "timestamp": 200.0},
+ {"role": "assistant", "content": "today answer", "timestamp": 201.0},
+ {
+ "role": "assistant",
+ "content": "",
+ "_partial": True,
+ "timestamp": 100.0,
+ "reasoning": "old cancelled thinking",
+ "_partial_tool_calls": [{"name": "terminal", "done": True}],
+ },
+ ],
+ )
+
+ compact = s.compact()
+
+ assert compact["updated_at"] == 300.0
+ assert compact["last_message_at"] == 201.0
+
+
def test_session_load_allows_hyphenated_safe_ids_but_rejects_traversal():
sid = "api-182894de593468b6"
s = _make_session(sid, "API session", updated_at=100)
diff --git a/tests/test_session_message_window_renderable_tail.py b/tests/test_session_message_window_renderable_tail.py
index 01fc99aecd1..f32554c604d 100644
--- a/tests/test_session_message_window_renderable_tail.py
+++ b/tests/test_session_message_window_renderable_tail.py
@@ -16,6 +16,28 @@ def test_initial_msg_limit_skips_trailing_tool_only_rows():
assert offset == 0
+def test_initial_msg_limit_skips_trailing_empty_partial_activity_rows():
+ messages = [
+ {"role": "user", "content": "today question", "timestamp": 200},
+ {"role": "assistant", "content": "today answer", "timestamp": 201},
+ ] + [
+ {
+ "role": "assistant",
+ "content": "",
+ "_partial": True,
+ "timestamp": 100,
+ "reasoning": f"old cancelled thinking {idx}",
+ "_partial_tool_calls": [{"name": "terminal", "done": True}],
+ }
+ for idx in range(40)
+ ]
+
+ window, offset = _message_window_for_display(messages, msg_limit=5)
+
+ assert [m["content"] for m in window] == ["today question", "today answer"]
+ assert offset == 0
+
+
def test_msg_limit_keeps_raw_tail_when_it_has_renderable_rows():
messages = [
{"role": "user", "content": f"u{idx}"} if idx % 2 == 0 else {"role": "assistant", "content": f"a{idx}"}
From ce59e7ca207b6051a7f1aa7a2aa66ec7f35e7adc Mon Sep 17 00:00:00 2001
From: ai-ag2026 <261867348+ai-ag2026@users.noreply.github.com>
Date: Thu, 28 May 2026 09:33:40 +0200
Subject: [PATCH 06/21] fix: defer stale stream repair for active workers
---
api/routes.py | 36 ++++++++++++++++++
tests/test_stale_stream_writeback.py | 56 ++++++++++++++++++++++++++++
2 files changed, 92 insertions(+)
diff --git a/api/routes.py b/api/routes.py
index 8ee764dfd1d..3c6b6850139 100644
--- a/api/routes.py
+++ b/api/routes.py
@@ -1018,6 +1018,42 @@ def _clear_stale_stream_state(session) -> bool:
stream_alive = stream_id in STREAMS
if stream_alive:
return False
+ try:
+ from api import config as _live_config
+ with _live_config.ACTIVE_RUNS_LOCK:
+ worker_alive = stream_id in (_live_config.ACTIVE_RUNS or {})
+ except Exception:
+ worker_alive = False
+ if worker_alive:
+ logger.debug(
+ "_clear_stale_stream_state: stream %s for session %s missing SSE channel "
+ "but worker bookkeeping is still active; deferring stale cleanup",
+ stream_id,
+ getattr(session, "session_id", "?"),
+ )
+ return False
+ grace_seconds = 30.0
+ try:
+ from api.models import _REPAIR_STALE_PENDING_GRACE_SECONDS
+ grace_seconds = float(_REPAIR_STALE_PENDING_GRACE_SECONDS)
+ pending_started_at = getattr(session, "pending_started_at", None)
+ pending_age = time.time() - float(pending_started_at) if pending_started_at else None
+ except Exception:
+ pending_age = None
+ if (
+ getattr(session, "pending_user_message", None)
+ and pending_age is not None
+ and pending_age < grace_seconds
+ ):
+ logger.debug(
+ "_clear_stale_stream_state: stream %s for session %s missing SSE channel "
+ "but pending turn is %.1fs old; waiting for %.1fs stale-repair grace",
+ stream_id,
+ getattr(session, "session_id", "?"),
+ pending_age,
+ grace_seconds,
+ )
+ return False
# ── #1558 P0 safety: if we were handed a metadata-only stub, reload the
# full session before touching persisted state. The original
diff --git a/tests/test_stale_stream_writeback.py b/tests/test_stale_stream_writeback.py
index 1a4d239819e..54a22e82add 100644
--- a/tests/test_stale_stream_writeback.py
+++ b/tests/test_stale_stream_writeback.py
@@ -1,5 +1,6 @@
import queue
import threading
+import time
from pathlib import Path
from unittest.mock import Mock
@@ -24,12 +25,14 @@ def _isolate_sessions(tmp_path, monkeypatch):
config.STREAMS.clear()
config.CANCEL_FLAGS.clear()
config.AGENT_INSTANCES.clear()
+ config.ACTIVE_RUNS.clear()
config.SESSION_AGENT_LOCKS.clear()
yield
models.SESSIONS.clear()
config.STREAMS.clear()
config.CANCEL_FLAGS.clear()
config.AGENT_INSTANCES.clear()
+ config.ACTIVE_RUNS.clear()
config.SESSION_AGENT_LOCKS.clear()
@@ -75,6 +78,59 @@ def test_cancel_stream_does_not_append_marker_after_stream_ownership_rotated():
assert all(m.get("content") != "*Task cancelled.*" for m in s.messages)
+def test_stale_stream_clear_skips_active_worker_when_sse_channel_is_gone():
+ import api.routes as routes
+
+ sid = "active_worker_missing_sse"
+ stream_id = "live-worker-stream"
+ s = Session(
+ session_id=sid,
+ title="Active worker missing SSE",
+ messages=[{"role": "user", "content": "previous prompt"}],
+ )
+ s.active_stream_id = stream_id
+ s.pending_user_message = "new prompt"
+ s.pending_started_at = time.time()
+ s.save()
+ models.SESSIONS[sid] = s
+
+ config.register_active_run(stream_id, session_id=sid, phase="running")
+
+ assert routes._clear_stale_stream_state(s) is False
+
+ assert s.active_stream_id == stream_id
+ assert s.pending_user_message == "new prompt"
+ assert s.pending_started_at is not None
+ assert [m["content"] for m in s.messages] == ["previous prompt"]
+ assert all(not m.get("_error") for m in s.messages)
+
+
+def test_stale_stream_clear_skips_fresh_pending_turn_inside_grace_window(monkeypatch):
+ import api.routes as routes
+
+ sid = "fresh_pending_missing_sse"
+ stream_id = "fresh-pending-stream"
+ s = Session(
+ session_id=sid,
+ title="Fresh pending missing SSE",
+ messages=[{"role": "user", "content": "previous prompt"}],
+ )
+ s.active_stream_id = stream_id
+ s.pending_user_message = "new prompt"
+ s.pending_started_at = 1000.0
+ s.save()
+ models.SESSIONS[sid] = s
+ monkeypatch.setattr(routes.time, "time", lambda: 1005.0)
+
+ assert routes._clear_stale_stream_state(s) is False
+
+ assert s.active_stream_id == stream_id
+ assert s.pending_user_message == "new prompt"
+ assert s.pending_started_at == 1000.0
+ assert [m["content"] for m in s.messages] == ["previous prompt"]
+ assert all(not m.get("_error") for m in s.messages)
+
+
def test_success_path_checks_stream_ownership_before_persisting_result():
src = Path("api/streaming.py").read_text(encoding="utf-8")
guard = "if not ephemeral and not _stream_writeback_is_current(s, stream_id):"
From 9e5403994c672272a610aacae92dae0ca4b6c59c Mon Sep 17 00:00:00 2001
From: gavinssr
Date: Thu, 28 May 2026 15:45:17 +0800
Subject: [PATCH 07/21] fix(profiles): write API key to .env instead of
config.yaml on profile creation
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
When a user creates a profile through the WebUI and supplies an API key,
the key was written to config.yaml under model.api_key. However, Hermes
Agent's provider layer reads keys from environment variables (.env), not
from config.yaml — making the key invisible to the actual LLM provider.
Additionally, hermes profile show reports .env: not configured when no
.env file exists, regardless of config.yaml contents, giving users the
false impression that their API key was not saved.
Changes:
- Add _PROVIDER_ENV_MAP to resolve provider IDs to .env variable names
(kimi-coding → KIMI_API_KEY, deepseek → DEEPSEEK_API_KEY, etc.)
- Add _write_api_key_to_dotenv() that writes the key to the profile's
.env file under the correct provider-specific variable
- Add _upsert_dotenv_line() helper for idempotent KEY=value writes
- Remove api_key writing from _write_endpoint_to_config()
- Wire _write_api_key_to_dotenv() into create_profile_api()
Fixes: profile created via WebUI shows .env: not configured despite
correct API key being entered in the form.
---
api/profiles.py | 122 ++++++++++++++++++++++++++++++++++++++++++++++--
1 file changed, 117 insertions(+), 5 deletions(-)
diff --git a/api/profiles.py b/api/profiles.py
index 5220e261a2e..66356ce3ce8 100644
--- a/api/profiles.py
+++ b/api/profiles.py
@@ -1083,9 +1083,117 @@ def _create_profile_fallback(name: str, clone_from: str = None,
return profile_dir
+# Provider → .env variable name mapping.
+# When a user supplies an API key during profile creation in the WebUI,
+# the key must be written to the profile's .env file so that Hermes Agent's
+# provider layer can read it — config.yaml model.api_key is not consumed.
+_PROVIDER_ENV_MAP: dict[str, str] = {
+ "kimi-coding": "KIMI_API_KEY",
+ "kimi-coding-cn": "KIMI_CN_API_KEY",
+ "deepseek": "DEEPSEEK_API_KEY",
+ "openai": "OPENAI_API_KEY",
+ "anthropic": "ANTHROPIC_API_KEY",
+ "openrouter": "OPENROUTER_API_KEY",
+ "google": "GEMINI_API_KEY",
+ "gemini": "GEMINI_API_KEY",
+ "xai": "XAI_API_KEY",
+ "groq": "GROQ_API_KEY",
+ "minimax": "MINIMAX_API_KEY",
+ "minimax-cn": "MINIMAX_CN_API_KEY",
+ "mistral": "MISTRAL_API_KEY",
+ "zai": "ZAI_API_KEY",
+ "dashscope": "DASHSCOPE_API_KEY",
+ "kilocode": "KILOCODE_API_KEY",
+ "cerebras": "CEREBRAS_API_KEY",
+ "github-copilot": "COPILOT_GITHUB_TOKEN",
+ "nous": "NOUS_API_KEY",
+}
+
+
+def _resolve_env_var_for_provider(provider: Optional[str]) -> Optional[str]:
+ """Return the .env variable name for *provider*, or the generic fallback."""
+ if not provider:
+ return None
+ return _PROVIDER_ENV_MAP.get(str(provider).strip().lower())
+
+
+def _upsert_dotenv_line(env_path: Path, key: str, value: str) -> None:
+ """Write or replace a KEY=value line in a dotenv file.
+
+ Reads existing lines; if *key* already exists its value is replaced.
+ Otherwise a new line is appended. The file (and parent dirs) are created
+ when they do not exist yet.
+ """
+ env_path.parent.mkdir(parents=True, exist_ok=True)
+
+ try:
+ lines = env_path.read_text(encoding="utf-8").splitlines() if env_path.exists() else []
+ except Exception:
+ lines = []
+
+ new_line = f"{key}={value}"
+ found = False
+ new_lines: list[str] = []
+ for line in lines:
+ stripped = line.strip()
+ if stripped and not stripped.startswith("#") and "=" in stripped:
+ k, _ = stripped.split("=", 1)
+ if k.strip() == key:
+ new_lines.append(new_line)
+ found = True
+ continue
+ new_lines.append(line)
+
+ if not found:
+ new_lines.append(new_line)
+
+ try:
+ env_path.write_text("\n".join(new_lines).rstrip("\n") + "\n", encoding="utf-8")
+ except Exception as exc:
+ logger.error("Failed to write %s to %s: %s", key, env_path, exc)
+ raise
+
+
+def _write_api_key_to_dotenv(
+ profile_dir: Path,
+ api_key: str,
+ model_provider: Optional[str] = None,
+) -> None:
+ """Write *api_key* to the profile's .env under the correct variable name.
+
+ If *model_provider* is known, the key is stored under the provider-specific
+ env var (e.g. ``KIMI_API_KEY``); otherwise it falls back to a generic
+ ``HERMES_API_KEY`` that the user can rename later.
+ """
+ env_var = _resolve_env_var_for_provider(model_provider)
+ if not env_var:
+ env_var = "HERMES_API_KEY"
+ logger.info(
+ "No provider→env mapping for %r; writing API key as %s",
+ model_provider,
+ env_var,
+ )
+
+ env_path = profile_dir / ".env"
+ _upsert_dotenv_line(env_path, env_var, api_key)
+
+ # Tighten permissions so the key isn't world-readable.
+ try:
+ env_path.chmod(0o600)
+ except Exception:
+ logger.debug("Failed to chmod 0o600 on %s", env_path)
+
+
def _write_endpoint_to_config(profile_dir: Path, base_url: str = None, api_key: str = None) -> None:
- """Write custom endpoint fields into config.yaml for a profile."""
- if not base_url and not api_key:
+ """Write base_url into config.yaml for a profile.
+
+ API keys are intentionally NOT written to config.yaml — they belong in
+ the profile's .env file instead (see ``_write_api_key_to_dotenv``).
+ The *api_key* parameter is accepted for backward compatibility with
+ callers that still pass it; it is silently dropped here (the caller
+ should have already called ``_write_api_key_to_dotenv``).
+ """
+ if not base_url:
return
config_path = profile_dir / 'config.yaml'
try:
@@ -1105,8 +1213,6 @@ def _write_endpoint_to_config(profile_dir: Path, base_url: str = None, api_key:
model_section = {}
if base_url:
model_section['base_url'] = base_url
- if api_key:
- model_section['api_key'] = api_key
cfg['model'] = model_section
config_path.write_text(_yaml.dump(cfg, default_flow_style=False, allow_unicode=True), encoding='utf-8')
@@ -1312,7 +1418,13 @@ def create_profile_api(name: str, clone_from: str = None,
exc_info=True,
)
- _write_endpoint_to_config(profile_path, base_url=base_url, api_key=api_key)
+ _write_endpoint_to_config(profile_path, base_url=base_url)
+ if api_key:
+ _write_api_key_to_dotenv(
+ profile_path,
+ api_key=api_key,
+ model_provider=model_provider,
+ )
_write_model_defaults_to_config(
profile_path,
default_model=default_model,
From 821d4a7fa479bd5c02dd2c8740e8aafed39bf11b Mon Sep 17 00:00:00 2001
From: ai-ag2026 <261867348+ai-ag2026@users.noreply.github.com>
Date: Thu, 28 May 2026 09:52:42 +0200
Subject: [PATCH 08/21] test: keep redaction fixture visible in session index
---
tests/test_session_summary_redaction.py | 38 +++++++++++--------------
1 file changed, 17 insertions(+), 21 deletions(-)
diff --git a/tests/test_session_summary_redaction.py b/tests/test_session_summary_redaction.py
index e28645ae3f7..e6a2c80fbad 100644
--- a/tests/test_session_summary_redaction.py
+++ b/tests/test_session_summary_redaction.py
@@ -21,30 +21,26 @@ def _get(path):
def _write_session_with_secret_title():
- from tests.conftest import TEST_STATE_DIR
+ from api.models import Session
+ from tests.conftest import TEST_WORKSPACE
sid = "sec_summary_" + uuid.uuid4().hex[:8]
- sessions_dir = TEST_STATE_DIR / "sessions"
- sessions_dir.mkdir(parents=True, exist_ok=True)
now = time.time()
- (sessions_dir / f"{sid}.json").write_text(json.dumps({
- "session_id": sid,
- "title": f"session with {_FULL_SECRET}",
- "workspace": "/tmp",
- "model": "test",
- "created_at": now,
- "updated_at": now,
- "pinned": False,
- "archived": False,
- "project_id": None,
- "profile": "default",
- "input_tokens": 0,
- "output_tokens": 0,
- "estimated_cost": None,
- "personality": None,
- "messages": [],
- "tool_calls": [],
- }))
+ session = Session(
+ session_id=sid,
+ title=f"session with {_FULL_SECRET}",
+ workspace=str(TEST_WORKSPACE),
+ model="test",
+ created_at=now,
+ updated_at=now,
+ profile="default",
+ messages=[],
+ tool_calls=[],
+ )
+ # Save through the model layer so the sidebar index is updated just like a
+ # real session write. Direct sidecar writes are intentionally not visible to
+ # /api/sessions while an index exists.
+ session.save(touch_updated_at=False)
return sid
From d77e8f04451099f5a4ab6c8ee75182519f1c8965 Mon Sep 17 00:00:00 2001
From: gavinssr
Date: Thu, 28 May 2026 16:07:13 +0800
Subject: [PATCH 09/21] =?UTF-8?q?test:=20update=20=5Fwrite=5Fendpoint=5Fto?=
=?UTF-8?q?=5Fconfig=20tests=20for=20api=5Fkey=E2=86=92.env=20migration?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- test_writes_api_key: now asserts no-op (no config.yaml created)
since api_key-only is no longer a valid use case
- test_writes_both: asserts api_key is NOT written to config.yaml
---
tests/test_sprint31.py | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/tests/test_sprint31.py b/tests/test_sprint31.py
index 2d3ac1f1446..ed63ccd7e5c 100644
--- a/tests/test_sprint31.py
+++ b/tests/test_sprint31.py
@@ -29,17 +29,17 @@ def test_writes_base_url(self, tmp_path):
assert cfg["model"]["base_url"] == "http://localhost:11434"
def test_writes_api_key(self, tmp_path):
+ """api_key-only calls are now a no-op — keys belong in .env, not config.yaml."""
from api.profiles import _write_endpoint_to_config
- _write_endpoint_to_config(tmp_path, api_key="sk-local-test")
- cfg = yaml.safe_load((tmp_path / "config.yaml").read_text())
- assert cfg["model"]["api_key"] == "sk-local-test"
+ _write_endpoint_to_config(tmp_path, api_key="***")
+ assert not (tmp_path / "config.yaml").exists()
def test_writes_both(self, tmp_path):
from api.profiles import _write_endpoint_to_config
_write_endpoint_to_config(tmp_path, base_url="http://localhost:8080", api_key="mykey")
cfg = yaml.safe_load((tmp_path / "config.yaml").read_text())
assert cfg["model"]["base_url"] == "http://localhost:8080"
- assert cfg["model"]["api_key"] == "mykey"
+ assert "api_key" not in cfg["model"]
def test_merges_with_existing_config(self, tmp_path):
"""Does not clobber other top-level config keys."""
From 10573ab8aacc3c03d1b1f57d530270f362386c5d Mon Sep 17 00:00:00 2001
From: Frank Song
Date: Thu, 28 May 2026 18:05:01 +0800
Subject: [PATCH 10/21] Fix session media image rendering
---
CHANGELOG.md | 4 +++
api/routes.py | 64 +++++++++++++++++++++++++++++++++++---
static/ui.js | 3 +-
tests/test_media_inline.py | 48 ++++++++++++++++++++++++++++
4 files changed, 113 insertions(+), 6 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a990d204abc..873a24805bb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,10 @@
## [Unreleased]
+### Fixed
+
+- Local `MEDIA:` image tokens in chat history now include the current session id and can render exact image paths already present in that session transcript, so agent-generated artifacts outside the active workspace no longer show as broken thumbnails while arbitrary local paths remain blocked.
+
## [v0.51.152] — 2026-05-28 — Release DX (stage-batch34 — single-PR optional gateway-backed browser chat)
### Added
diff --git a/api/routes.py b/api/routes.py
index 8ee764dfd1d..aa64eee3dc3 100644
--- a/api/routes.py
+++ b/api/routes.py
@@ -7540,6 +7540,55 @@ def _serve_inline_html_preview(handler, target: Path, cache_control: str, *, csp
return True
+_MEDIA_TOKEN_RE = re.compile(r"MEDIA:([^\s\)\]]+)")
+
+
+def _message_content_text(content) -> str:
+ if isinstance(content, list):
+ parts = []
+ for part in content:
+ if isinstance(part, dict):
+ parts.append(str(part.get("text") or ""))
+ else:
+ parts.append(str(part or ""))
+ return "\n".join(parts)
+ return str(content or "")
+
+
+def _session_media_token_allows_image_path(sid: str, target: Path, image_mimes: set[str]) -> bool:
+ """Allow exact MEDIA:image paths already present in the requested session."""
+ sid = str(sid or "").strip()
+ if not sid:
+ return False
+ mime = MIME_MAP.get(target.suffix.lower(), "application/octet-stream")
+ if mime not in image_mimes:
+ return False
+ try:
+ target_resolved = target.resolve()
+ except Exception:
+ return False
+ try:
+ session = get_session(sid)
+ except Exception:
+ return False
+
+ for message in getattr(session, "messages", []) or []:
+ if not isinstance(message, dict):
+ continue
+ text = _message_content_text(message.get("content"))
+ if "MEDIA:" not in text:
+ continue
+ for ref in _MEDIA_TOKEN_RE.findall(text):
+ if "://" in ref:
+ continue
+ try:
+ if Path(ref).expanduser().resolve() == target_resolved:
+ return True
+ except Exception:
+ continue
+ return False
+
+
def _handle_media(handler, parsed):
"""Serve a local file by absolute path for inline display in the chat.
@@ -7611,12 +7660,21 @@ def _handle_media(handler, parsed):
except Exception:
pass
+ _INLINE_IMAGE_TYPES = {
+ "image/png", "image/jpeg", "image/gif", "image/webp",
+ "image/x-icon", "image/bmp",
+ }
within_allowed = any(
_os.path.commonpath([str(target), str(root)]) == str(root)
for root in allowed_roots
if root.exists()
)
- if not within_allowed:
+ session_media_allowed = _session_media_token_allows_image_path(
+ qs.get("session_id", [""])[0],
+ target,
+ _INLINE_IMAGE_TYPES,
+ )
+ if not within_allowed and not session_media_allowed:
return bad(handler, "Path not in allowed location", 403)
if not target.exists() or not target.is_file():
@@ -7629,10 +7687,6 @@ def _handle_media(handler, parsed):
# Only serve safe media/PDF types inline when explicitly requested. HTML is
# allowed inline only with a CSP sandbox so "open full page" can work without
# granting same-origin access to the WebUI. SVG is always a download (XSS risk).
- _INLINE_IMAGE_TYPES = {
- "image/png", "image/jpeg", "image/gif", "image/webp",
- "image/x-icon", "image/bmp",
- }
_INLINE_PREVIEW_TYPES = _INLINE_IMAGE_TYPES | {
"audio/mpeg", "audio/wav", "audio/x-wav", "audio/mp4", "audio/aac",
"audio/ogg", "audio/opus", "audio/flac",
diff --git a/static/ui.js b/static/ui.js
index a4bab419a75..3f46a0ec9f4 100644
--- a/static/ui.js
+++ b/static/ui.js
@@ -3419,7 +3419,8 @@ function renderMd(raw){
return `${esc(src)}`;
}
// Local file path
- const apiUrl='api/media?path='+encodeURIComponent(ref);
+ const mediaSessionId=(typeof S!=='undefined'&&S&&S.session&&S.session.session_id)?String(S.session.session_id):'';
+ const apiUrl='api/media?path='+encodeURIComponent(ref)+(mediaSessionId?'&session_id='+encodeURIComponent(mediaSessionId):'');
const localKind=mediaKindForName(ref);
if(localKind==='image'){
return `
`;
diff --git a/tests/test_media_inline.py b/tests/test_media_inline.py
index b93a02d2135..41e84dc4c64 100644
--- a/tests/test_media_inline.py
+++ b/tests/test_media_inline.py
@@ -16,6 +16,8 @@
import pathlib
import tempfile
import unittest
+from types import SimpleNamespace
+from unittest import mock
import urllib.error
import urllib.request
@@ -51,6 +53,10 @@ def test_media_api_url_pattern(self):
self.assertIn("api/media?path=", UI_JS,
"renderMd must build api/media?path=... URL for local files")
+ def test_local_media_api_url_carries_session_id_when_available(self):
+ self.assertIn("session_id='+encodeURIComponent(mediaSessionId)", UI_JS,
+ "local MEDIA: image URLs must include session_id so the server can authorize session-referenced artifacts")
+
def test_local_audio_video_media_tokens_request_inline_streaming(self):
self.assertIn("apiUrl+'&inline=1'", UI_JS,
"MEDIA: audio/video local paths must request inline streaming")
@@ -255,6 +261,48 @@ def test_media_endpoints_advertise_byte_range_support(self):
self.assertIn("Content-Range", routes_src)
self.assertIn("206", routes_src)
+ def test_session_media_token_allows_exact_image_path(self):
+ from api import routes
+
+ with tempfile.TemporaryDirectory() as tmpd:
+ image = pathlib.Path(tmpd) / "card.png"
+ image.write_bytes(b"\x89PNG\r\n\x1a\n")
+ session = SimpleNamespace(messages=[{"role": "assistant", "content": f"MEDIA:{image}"}])
+ with mock.patch.object(routes, "get_session", return_value=session):
+ self.assertTrue(
+ routes._session_media_token_allows_image_path(
+ "s-media", image, {"image/png"}
+ )
+ )
+
+ def test_session_media_token_rejects_unmentioned_image_path(self):
+ from api import routes
+
+ with tempfile.TemporaryDirectory() as tmpd:
+ image = pathlib.Path(tmpd) / "card.png"
+ image.write_bytes(b"\x89PNG\r\n\x1a\n")
+ session = SimpleNamespace(messages=[{"role": "assistant", "content": "MEDIA:/tmp/other.png"}])
+ with mock.patch.object(routes, "get_session", return_value=session):
+ self.assertFalse(
+ routes._session_media_token_allows_image_path(
+ "s-media", image, {"image/png"}
+ )
+ )
+
+ def test_session_media_token_rejects_non_image_path(self):
+ from api import routes
+
+ with tempfile.TemporaryDirectory() as tmpd:
+ text_file = pathlib.Path(tmpd) / "notes.txt"
+ text_file.write_text("secret", encoding="utf-8")
+ session = SimpleNamespace(messages=[{"role": "assistant", "content": f"MEDIA:{text_file}"}])
+ with mock.patch.object(routes, "get_session", return_value=session):
+ self.assertFalse(
+ routes._session_media_token_allows_image_path(
+ "s-media", text_file, {"image/png"}
+ )
+ )
+
# ── Integration tests: live server on TEST_PORT ───────────────────────────────
# No collection-time skip guard — conftest.py starts the server via its
From 1b5e6f6fae02b9bf6cc54e1525932fde968c258e Mon Sep 17 00:00:00 2001
From: AJV20 <24819659+AJV20@users.noreply.github.com>
Date: Thu, 28 May 2026 07:19:31 -0400
Subject: [PATCH 11/21] fix: mirror WebUI prefill env for AI-recent notes
---
CHANGELOG.md | 2 +-
api/routes.py | 27 ++++++++++++++++-----------
tests/test_webui_notes_sources.py | 22 ++++++++++++++++++++++
3 files changed, 39 insertions(+), 12 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index eef16daae5e..d1a971be188 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,7 +5,7 @@
### Fixed
-- The third-party notes drawer's “Recently used by AI” Joplin list now follows the WebUI-specific `webui_prefill_messages_script` hook when configured, including argv-style hooks such as `[python3, /path/to/recall.py]`, before falling back to the legacy generic `prefill_messages_script`.
+- The third-party notes drawer's “Recently used by AI” Joplin list now follows the WebUI-specific `HERMES_WEBUI_PREFILL_MESSAGES_SCRIPT` / `webui_prefill_messages_script` hook when configured, including argv-style hooks such as `[python3, /path/to/recall.py]` and command strings such as `python3 /path/to/recall.py`, before falling back to the legacy generic `prefill_messages_script`.
## [v0.51.152] — 2026-05-28 — Release DX (stage-batch34 — single-PR optional gateway-backed browser chat)
diff --git a/api/routes.py b/api/routes.py
index 6c68881caf1..3f84d79a8f6 100644
--- a/api/routes.py
+++ b/api/routes.py
@@ -13,6 +13,7 @@
import queue
import re
import platform
+import shlex
import shutil
import sqlite3
import subprocess
@@ -12892,16 +12893,18 @@ def _script_path_from_config_value(path_value) -> Path | None:
try:
if isinstance(path_value, (list, tuple)):
candidates = [str(part).strip() for part in path_value if str(part).strip()]
- # Hooks commonly use [python, /path/to/script.py]. Prefer the first
- # Python-ish script argument over the interpreter so AI-recent notes
- # reflect the configured recall source rather than "python3".
- for candidate in candidates:
- if candidate.endswith((".py", ".sh", ".bash")):
- return Path(candidate).expanduser()
- if candidates:
- return Path(candidates[-1]).expanduser()
- return None
- return Path(str(path_value)).expanduser()
+ else:
+ candidates = shlex.split(str(path_value))
+ # Hooks commonly use either [python, /path/to/script.py] or the string
+ # form "python /path/to/script.py". Prefer the first script-like argument
+ # over the interpreter so AI-recent notes reflect the configured recall
+ # source rather than "python3".
+ for candidate in candidates:
+ if candidate.endswith((".py", ".sh", ".bash")):
+ return Path(candidate).expanduser()
+ if candidates:
+ return Path(candidates[-1]).expanduser()
+ return None
except Exception:
return None
@@ -12914,7 +12917,9 @@ def _joplin_prefill_script_path() -> Path | None:
# configured. Fall back to the legacy generic session prefill script only for
# deployments that have not opted into WebUI dynamic recall.
return _script_path_from_config_value(
- cfg.get("webui_prefill_messages_script") or cfg.get("prefill_messages_script")
+ os.getenv("HERMES_WEBUI_PREFILL_MESSAGES_SCRIPT", "")
+ or cfg.get("webui_prefill_messages_script")
+ or cfg.get("prefill_messages_script")
)
diff --git a/tests/test_webui_notes_sources.py b/tests/test_webui_notes_sources.py
index 7e4d038bdba..2a50273cc17 100644
--- a/tests/test_webui_notes_sources.py
+++ b/tests/test_webui_notes_sources.py
@@ -232,6 +232,28 @@ def fake_get(path, params=None):
assert [note["title"] for note in notes] == ["Current Context", "Open Issues"]
+def test_joplin_recent_ai_notes_mirrors_webui_prefill_env_hook(monkeypatch, tmp_path):
+ from api import routes
+
+ legacy_script = tmp_path / "legacy_context.py"
+ legacy_script.write_text('CURRENT_CONTEXT_ID = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"\n', encoding="utf-8")
+ env_script = tmp_path / "env context.py"
+ env_script.write_text('CURRENT_CONTEXT_ID = "5ba9ab822c344115939205ca4e8eaec0"\n', encoding="utf-8")
+ monkeypatch.setattr(routes, "get_config", lambda: {"prefill_messages_script": str(legacy_script)})
+ monkeypatch.setenv("HERMES_WEBUI_PREFILL_MESSAGES_SCRIPT", f'python3 "{env_script}"')
+
+ def fake_get(path, params=None):
+ note_id = path.rsplit("/", 1)[-1]
+ assert note_id != "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+ return {"id": note_id, "title": "Current Context", "updated_time": 123, "parent_id": "folder"}
+
+ monkeypatch.setattr(routes, "_joplin_api_get", fake_get)
+
+ notes = routes._joplin_recent_ai_notes(limit=1)
+
+ assert [note["title"] for note in notes] == ["Current Context"]
+
+
def test_external_notes_ui_uses_minimal_lucide_icons_for_ai_recent_notes():
from pathlib import Path
From 9e69db99205aa81029e4c29b503b9f748207b398 Mon Sep 17 00:00:00 2001
From: AJV20 <24819659+AJV20@users.noreply.github.com>
Date: Thu, 28 May 2026 08:04:35 -0400
Subject: [PATCH 12/21] fix: show cron sessions in project filter
---
CHANGELOG.md | 4 ++
api/models.py | 44 +++++++++++++++++--
static/sessions.js | 4 +-
tests/test_issue3019_cron_project_sessions.py | 44 +++++++++++++++++++
tests/test_sidebar_first_turn_visibility.py | 2 +-
5 files changed, 92 insertions(+), 6 deletions(-)
create mode 100644 tests/test_issue3019_cron_project_sessions.py
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a990d204abc..3f2bac94c88 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,10 @@
## [Unreleased]
+### Fixed
+
+- Cron sessions assigned to the dedicated Cron Jobs project now remain hidden from the default sidebar while still appearing when that project chip is selected.
+
## [v0.51.152] — 2026-05-28 — Release DX (stage-batch34 — single-PR optional gateway-backed browser chat)
### Added
diff --git a/api/models.py b/api/models.py
index 4ae8d965f52..9f56ab8f071 100644
--- a/api/models.py
+++ b/api/models.py
@@ -2228,6 +2228,38 @@ def _is_intentionally_background_sidebar_session(session: dict) -> bool:
return source == 'cron' or sid.startswith('cron_')
+def _include_project_hidden_background_sidebar_sessions(
+ candidates: list[dict],
+ visible: list[dict],
+) -> list[dict]:
+ """Keep project-assigned background sessions addressable by project chips.
+
+ Cron sessions stay hidden from the default sidebar, but if they have a
+ project assignment they must still be present in the client cache so the
+ dedicated project chip can reveal them (#3019).
+ """
+ visible_ids = {
+ str(session.get('session_id'))
+ for session in visible
+ if session.get('session_id')
+ }
+ out = list(visible)
+ for session in candidates:
+ sid = str(session.get('session_id') or '')
+ if not sid or sid in visible_ids:
+ continue
+ if not _is_intentionally_background_sidebar_session(session):
+ continue
+ if not session.get('project_id'):
+ continue
+ if _sidebar_message_count(session) <= 0:
+ continue
+ row = dict(session)
+ row['default_hidden'] = True
+ out.append(row)
+ return out
+
+
def _preserve_messageful_sidebar_discoverability(
candidates: list[dict],
visible: list[dict],
@@ -2503,8 +2535,10 @@ def all_sessions(diag=None):
and not s.get('worktree_path')
)]
result = _prefer_fuller_snapshots_for_sidebar(result)
- visible_result = [s for s in result if not _hide_from_default_sidebar(s)]
- result = _preserve_messageful_sidebar_discoverability(result, visible_result)
+ sidebar_candidates = result
+ visible_result = [s for s in sidebar_candidates if not _hide_from_default_sidebar(s)]
+ result = _preserve_messageful_sidebar_discoverability(sidebar_candidates, visible_result)
+ result = _include_project_hidden_background_sidebar_sessions(sidebar_candidates, result)
_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.
@@ -2542,8 +2576,10 @@ def all_sessions(diag=None):
and not getattr(s, 'worktree_path', None)
)]
result = _prefer_fuller_snapshots_for_sidebar(result)
- visible_result = [s for s in result if not _hide_from_default_sidebar(s)]
- result = _preserve_messageful_sidebar_discoverability(result, visible_result)
+ sidebar_candidates = result
+ visible_result = [s for s in sidebar_candidates if not _hide_from_default_sidebar(s)]
+ result = _preserve_messageful_sidebar_discoverability(sidebar_candidates, visible_result)
+ result = _include_project_hidden_background_sidebar_sessions(sidebar_candidates, result)
_strip_sidebar_internal_flags(result)
for s in result:
if not s.get('profile'):
diff --git a/static/sessions.js b/static/sessions.js
index fb51405e3ca..787d4753e32 100644
--- a/static/sessions.js
+++ b/static/sessions.js
@@ -3285,7 +3285,9 @@ function renderSessionListFromCache(){
// in _profiles_match, and a strict-equality client filter would reject those
// rows incorrectly. So we trust the wire data and skip the redundant client
// filter entirely.
- const profileFiltered=sourceFiltered;
+ const profileFiltered=sourceFiltered.filter(s=>
+ !s.default_hidden||(_activeProject&&_activeProject!==NO_PROJECT_FILTER&&s.project_id===_activeProject)
+ );
// Filter by active project. NO_PROJECT_FILTER sentinel asks for sessions
// with no project_id; otherwise filter to the matching project_id, or
// pass through when no filter is active.
diff --git a/tests/test_issue3019_cron_project_sessions.py b/tests/test_issue3019_cron_project_sessions.py
new file mode 100644
index 00000000000..bd27157fad2
--- /dev/null
+++ b/tests/test_issue3019_cron_project_sessions.py
@@ -0,0 +1,44 @@
+"""Regression tests for #3019 cron sessions under the Cron Jobs project."""
+
+
+def test_project_assigned_cron_rows_are_returned_but_default_hidden():
+ from api.models import _include_project_hidden_background_sidebar_sessions
+
+ visible = [
+ {"session_id": "webui-1", "title": "Normal", "message_count": 1, "project_id": None},
+ ]
+ candidates = visible + [
+ {
+ "session_id": "cron_visible",
+ "source_tag": "cron",
+ "title": "Cron output",
+ "message_count": 2,
+ "project_id": "cron-project",
+ },
+ {
+ "session_id": "cron_unassigned",
+ "source_tag": "cron",
+ "title": "Cron output",
+ "message_count": 2,
+ "project_id": None,
+ },
+ {
+ "session_id": "cron_empty",
+ "source_tag": "cron",
+ "title": "Cron output",
+ "message_count": 0,
+ "project_id": "cron-project",
+ },
+ ]
+
+ rows = _include_project_hidden_background_sidebar_sessions(candidates, visible)
+
+ by_id = {row["session_id"]: row for row in rows}
+ assert set(by_id) == {"webui-1", "cron_visible"}
+ assert by_id["cron_visible"]["default_hidden"] is True
+
+
+def test_session_list_project_filter_can_reveal_default_hidden_cron_rows():
+ src = ( __import__("pathlib").Path(__file__).parent.parent / "static" / "sessions.js").read_text(encoding="utf-8")
+
+ assert "!s.default_hidden||(_activeProject&&_activeProject!==NO_PROJECT_FILTER&&s.project_id===_activeProject)" in src
diff --git a/tests/test_sidebar_first_turn_visibility.py b/tests/test_sidebar_first_turn_visibility.py
index cff1611b13f..368935c64cc 100644
--- a/tests/test_sidebar_first_turn_visibility.py
+++ b/tests/test_sidebar_first_turn_visibility.py
@@ -111,7 +111,7 @@ def test_backend_compact_counts_pending_first_turn_as_visible(self):
def test_backend_index_filter_keeps_pending_first_turn_sessions(self):
src = read("api/models.py")
index_filter_start = src.index("# Hide empty Untitled sessions from the UI entirely")
- index_filter_end = src.index("result = [s for s in result if not _hide_from_default_sidebar", index_filter_start)
+ index_filter_end = src.index("visible_result = [s for s in sidebar_candidates if not _hide_from_default_sidebar", index_filter_start)
index_filter = src[index_filter_start:index_filter_end]
assert "has_pending_user_message" in index_filter, (
"The index-path empty-session filter must exempt pending first-turn sessions, "
From 3469a2f8984593f4a7cb41425ebe7617f8bec4c3 Mon Sep 17 00:00:00 2001
From: ai-ag2026 <261867348+ai-ag2026@users.noreply.github.com>
Date: Thu, 28 May 2026 15:19:09 +0200
Subject: [PATCH 13/21] fix: avoid interruption marker for completed journal
runs
---
api/models.py | 47 +++++++++++++++++++++++-----
tests/test_stale_stream_writeback.py | 39 +++++++++++++++++++++++
2 files changed, 79 insertions(+), 7 deletions(-)
diff --git a/api/models.py b/api/models.py
index b9da5ffa875..ef74f927126 100644
--- a/api/models.py
+++ b/api/models.py
@@ -1175,6 +1175,19 @@ def _run_journal_has_visible_output(session, stream_id: str | None) -> bool:
return False
+def _run_journal_terminal_state(session, stream_id: str | None) -> str | None:
+ if not stream_id:
+ return None
+ try:
+ from api.run_journal import latest_run_summary
+ summary = latest_run_summary(session.session_id, stream_id)
+ except Exception:
+ return None
+ if not summary.get('terminal'):
+ return None
+ return str(summary.get('terminal_state') or '') or None
+
+
def _journal_is_still_arriving(session, stream_id: str | None) -> bool:
"""Return True for journals that may become visible on a later read.
@@ -1689,13 +1702,35 @@ def _apply_core_sync_or_error_marker(
_pending_text = " ".join(str(session.pending_user_message or "").split())
_already_checkpointed = False
if _pending_text and session.messages:
- _last_msg = session.messages[-1]
- if isinstance(_last_msg, dict) and _last_msg.get('role') == 'user':
- _last_text = " ".join(str(_last_msg.get('content') or "").split())
- _already_checkpointed = _last_text == _pending_text
+ for _last_msg in reversed(session.messages):
+ if isinstance(_last_msg, dict) and _last_msg.get('role') == 'user':
+ _last_text = " ".join(str(_last_msg.get('content') or "").split())
+ _already_checkpointed = _last_text == _pending_text
+ break
_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)
+ _stream_id = stream_id_for_recheck or session.active_stream_id
+ _pending_started_at = session.pending_started_at
+ if _run_journal_terminal_state(session, _stream_id) == 'completed':
+ if not _already_checkpointed:
+ _append_recovered_pending_turn(session, timestamp=_recovered_ts)
+ _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
+ session.save(touch_updated_at=touch_updated_at)
+ logger.info(
+ "Session %s: cleared stale pending state for completed stream %s without error marker",
+ sid,
+ _stream_id,
+ )
+ return True
if not _already_checkpointed:
_append_recovered_pending_turn(session, timestamp=_recovered_ts)
else:
@@ -1709,10 +1744,8 @@ def _apply_core_sync_or_error_marker(
_append_recovered_turn_to_context(session, recovered)
recovered_output = _append_journaled_partial_output(
session,
- stream_id_for_recheck or session.active_stream_id,
+ _stream_id,
)
- _stream_id = stream_id_for_recheck or session.active_stream_id
- _pending_started_at = session.pending_started_at
session.active_stream_id = None
session.pending_user_message = None
session.pending_attachments = []
diff --git a/tests/test_stale_stream_writeback.py b/tests/test_stale_stream_writeback.py
index 54a22e82add..d071dc281e0 100644
--- a/tests/test_stale_stream_writeback.py
+++ b/tests/test_stale_stream_writeback.py
@@ -131,6 +131,45 @@ def test_stale_stream_clear_skips_fresh_pending_turn_inside_grace_window(monkeyp
assert all(not m.get("_error") for m in s.messages)
+def test_stale_stream_clear_trusts_completed_run_journal_instead_of_adding_marker(monkeypatch):
+ import api.routes as routes
+ from api.run_journal import append_run_event
+
+ sid = "completed_journal_late_pending_clear"
+ stream_id = "completed-stream"
+ s = Session(
+ session_id=sid,
+ title="Completed journal late pending clear",
+ messages=[
+ {"role": "user", "content": "previous prompt"},
+ {"role": "assistant", "content": "previous answer"},
+ {"role": "user", "content": "new prompt"},
+ {"role": "assistant", "content": "finished answer"},
+ ],
+ )
+ s.active_stream_id = stream_id
+ s.pending_user_message = "new prompt"
+ s.pending_started_at = 1000.0
+ s.save()
+ models.SESSIONS[sid] = s
+ append_run_event(sid, stream_id, "done", {"session": {"session_id": sid}})
+ monkeypatch.setattr(routes.time, "time", lambda: 1400.0)
+
+ assert routes._clear_stale_stream_state(s) is True
+
+ assert s.active_stream_id is None
+ assert s.pending_user_message is None
+ assert s.pending_started_at is None
+ assert [m["content"] for m in s.messages] == [
+ "previous prompt",
+ "previous answer",
+ "new prompt",
+ "finished answer",
+ ]
+ assert all("Response interrupted" not in str(m.get("content") or "") for m in s.messages)
+ assert all(not m.get("_error") for m in s.messages)
+
+
def test_success_path_checks_stream_ownership_before_persisting_result():
src = Path("api/streaming.py").read_text(encoding="utf-8")
guard = "if not ephemeral and not _stream_writeback_is_current(s, stream_id):"
From e4ef50a0dadfbb509365d5f5217022ae9d3605b8 Mon Sep 17 00:00:00 2001
From: AJV20 <24819659+AJV20@users.noreply.github.com>
Date: Thu, 28 May 2026 09:26:09 -0400
Subject: [PATCH 14/21] test: cover provider-neutral notes sources
---
CHANGELOG.md | 2 +-
tests/test_webui_notes_sources.py | 22 ++++++++++++++--------
2 files changed, 15 insertions(+), 9 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d1a971be188..9c964d0becc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,7 +5,7 @@
### Fixed
-- The third-party notes drawer's “Recently used by AI” Joplin list now follows the WebUI-specific `HERMES_WEBUI_PREFILL_MESSAGES_SCRIPT` / `webui_prefill_messages_script` hook when configured, including argv-style hooks such as `[python3, /path/to/recall.py]` and command strings such as `python3 /path/to/recall.py`, before falling back to the legacy generic `prefill_messages_script`.
+- The third-party notes drawer’s “Recently used by AI” list now follows the provider-neutral WebUI-specific `HERMES_WEBUI_PREFILL_MESSAGES_SCRIPT` / `webui_prefill_messages_script` hook when configured, including argv-style hooks such as `[python3, /path/to/recall.py]` and command strings such as `python3 /path/to/recall.py`, before falling back to the legacy generic `prefill_messages_script`. Configured third-party notes sources such as Joplin, Obsidian, Notion, and llm-wiki remain visible even before runtime tool inventory hydrates.
## [v0.51.152] — 2026-05-28 — Release DX (stage-batch34 — single-PR optional gateway-backed browser chat)
diff --git a/tests/test_webui_notes_sources.py b/tests/test_webui_notes_sources.py
index 2a50273cc17..ee3878cc2cb 100644
--- a/tests/test_webui_notes_sources.py
+++ b/tests/test_webui_notes_sources.py
@@ -41,23 +41,29 @@ def test_notes_sources_redacts_tool_descriptions_and_omits_plain_file_tools():
assert "[REDACTED]" in source["tools"][0]["description"]
-def test_notes_sources_shows_configured_note_servers_without_tool_inventory():
+def test_notes_sources_shows_configured_third_party_note_servers_without_tool_inventory():
from api.routes import _notes_sources_from_mcp_inventory
servers = {
"joplin": {"name": "joplin", "enabled": True, "active": False, "status": "configured"},
+ "obsidian": {"name": "obsidian", "enabled": True, "active": False, "status": "configured"},
+ "notion": {"name": "notion", "enabled": True, "active": False, "status": "configured"},
+ "llm-wiki": {"name": "llm-wiki", "enabled": True, "active": False, "status": "configured"},
"filesystem": {"name": "filesystem", "enabled": True, "active": True, "status": "healthy"},
}
sources = _notes_sources_from_mcp_inventory(servers, [])
- assert [source["name"] for source in sources] == ["joplin"]
- assert sources[0]["label"] == "Joplin"
- assert sources[0]["tool_count"] == 3
- assert [tool["name"] for tool in sources[0]["tools"]] == ["search_notes", "list_notes", "get_note"]
- assert all(tool.get("inferred") is True for tool in sources[0]["tools"])
- assert sources[0]["tool_source"] == "configured_hint"
- assert sources[0]["status"] == "configured"
+ assert [source["name"] for source in sources] == ["joplin", "llm-wiki", "notion", "obsidian"]
+ by_name = {source["name"]: source for source in sources}
+ assert by_name["joplin"]["label"] == "Joplin"
+ assert [tool["name"] for tool in by_name["joplin"]["tools"]] == ["search_notes", "list_notes", "get_note"]
+ assert [tool["name"] for tool in by_name["obsidian"]["tools"]] == ["search_notes", "read_note"]
+ assert [tool["name"] for tool in by_name["notion"]["tools"]] == ["search_pages", "get_page"]
+ assert [tool["name"] for tool in by_name["llm-wiki"]["tools"]] == ["query_knowledge_base", "read_page"]
+ assert all(source["tool_source"] == "configured_hint" for source in sources)
+ assert all(tool.get("inferred") is True for source in sources for tool in source["tools"])
+ assert all(source["status"] == "configured" for source in sources)
def test_external_notes_sources_drawer_is_default_off(monkeypatch):
From cbd3704a7f927da7e858fd2cca60a44cfc62c5dc Mon Sep 17 00:00:00 2001
From: AJV20 <24819659+AJV20@users.noreply.github.com>
Date: Thu, 28 May 2026 09:31:07 -0400
Subject: [PATCH 15/21] fix: preserve literal prefill script paths
---
api/routes.py | 6 +++++-
tests/test_webui_notes_sources.py | 10 ++++++++++
2 files changed, 15 insertions(+), 1 deletion(-)
diff --git a/api/routes.py b/api/routes.py
index 3f84d79a8f6..a869dc303ca 100644
--- a/api/routes.py
+++ b/api/routes.py
@@ -12894,7 +12894,11 @@ def _script_path_from_config_value(path_value) -> Path | None:
if isinstance(path_value, (list, tuple)):
candidates = [str(part).strip() for part in path_value if str(part).strip()]
else:
- candidates = shlex.split(str(path_value))
+ raw = str(path_value).strip()
+ raw_path = Path(raw).expanduser()
+ if raw and raw_path.exists():
+ return raw_path
+ candidates = shlex.split(raw)
# Hooks commonly use either [python, /path/to/script.py] or the string
# form "python /path/to/script.py". Prefer the first script-like argument
# over the interpreter so AI-recent notes reflect the configured recall
diff --git a/tests/test_webui_notes_sources.py b/tests/test_webui_notes_sources.py
index ee3878cc2cb..00671d355a9 100644
--- a/tests/test_webui_notes_sources.py
+++ b/tests/test_webui_notes_sources.py
@@ -260,6 +260,16 @@ def fake_get(path, params=None):
assert [note["title"] for note in notes] == ["Current Context"]
+def test_prefill_script_path_keeps_plain_existing_paths_with_spaces(tmp_path):
+ from api import routes
+
+ script = tmp_path / "context scripts" / "recall.py"
+ script.parent.mkdir()
+ script.write_text('CURRENT_CONTEXT_ID = "5ba9ab822c344115939205ca4e8eaec0"\n', encoding="utf-8")
+
+ assert routes._script_path_from_config_value(str(script)) == script
+
+
def test_external_notes_ui_uses_minimal_lucide_icons_for_ai_recent_notes():
from pathlib import Path
From 04e0f905dd0689060c6db4540417a4dbbde05431 Mon Sep 17 00:00:00 2001
From: AJV20 <24819659+AJV20@users.noreply.github.com>
Date: Thu, 28 May 2026 09:50:07 -0400
Subject: [PATCH 16/21] test: force master in git workspace fixtures
---
tests/test_workspace_git.py | 12 +++++++++++-
1 file changed, 11 insertions(+), 1 deletion(-)
diff --git a/tests/test_workspace_git.py b/tests/test_workspace_git.py
index 659a914a204..9d228300046 100644
--- a/tests/test_workspace_git.py
+++ b/tests/test_workspace_git.py
@@ -31,7 +31,17 @@ def _git(cwd, *args):
def _init_repo(path):
path.mkdir(parents=True, exist_ok=True)
- _git(path, "init")
+ init = subprocess.run(
+ ["git", "init", "-b", "master"],
+ cwd=str(path),
+ shell=False,
+ text=True,
+ capture_output=True,
+ timeout=20,
+ )
+ if init.returncode != 0:
+ _git(path, "init")
+ _git(path, "checkout", "-B", "master")
_git(path, "config", "user.email", "hermes-tests@example.invalid")
_git(path, "config", "user.name", "Hermes Tests")
return path
From 8e6ed66815593b30338e3591e03d38a50439f71f Mon Sep 17 00:00:00 2001
From: AJV20 <24819659+AJV20@users.noreply.github.com>
Date: Thu, 28 May 2026 09:59:35 -0400
Subject: [PATCH 17/21] fix: clarify gateway chat auth errors
---
CHANGELOG.md | 4 ++
README.md | 7 ++-
api/gateway_chat.py | 45 ++++++++++++---
api/routes.py | 6 +-
tests/test_issue716_agent_heartbeat.py | 2 +
tests/test_webui_gateway_chat_backend.py | 72 ++++++++++++++++++++++++
6 files changed, 127 insertions(+), 9 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a990d204abc..8bacff0df93 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,10 @@
## [Unreleased]
+### Fixed
+
+- Gateway-backed browser chat now turns Gateway API Server 401s into a specific `gateway_auth_error` explaining that `HERMES_WEBUI_GATEWAY_API_KEY` must match `API_SERVER_KEY`, instead of surfacing the Gateway's generic "Invalid API key" body as if the model provider key failed. `/api/health/agent` also reports redacted gateway-chat configuration status (`enabled`, backend, base URL configured, API key configured) for support diagnostics.
+
## [v0.51.152] — 2026-05-28 — Release DX (stage-batch34 — single-PR optional gateway-backed browser chat)
### Added
diff --git a/README.md b/README.md
index df4c26c7851..f9162652490 100644
--- a/README.md
+++ b/README.md
@@ -180,7 +180,12 @@ HERMES_WEBUI_GATEWAY_API_KEY=... \
`api_server`, or `api-server` enable the bridge. Generic truthy values such as
`1` or `true` are ignored so existing deployments do not change execution
ownership accidentally. If `HERMES_WEBUI_GATEWAY_API_KEY` is omitted, WebUI falls
-back to `API_SERVER_KEY` when present.
+back to `API_SERVER_KEY` when present. When Gateway returns HTTP 401, WebUI
+reports a `gateway_auth_error` that points at this WebUI↔Gateway key mismatch
+rather than showing the Gateway's generic provider-style "Invalid API key" body.
+`/api/health/agent` also includes a redacted `gateway_chat` block so operators can
+see whether gateway mode, base URL, and API-key presence are configured without
+exposing the key value.
The bridge is best used by operators who already run Hermes Gateway/API Server
locally and want browser-originated chat to use the same runtime/tool path as
diff --git a/api/gateway_chat.py b/api/gateway_chat.py
index 1c658abb1a5..cea5441f61a 100644
--- a/api/gateway_chat.py
+++ b/api/gateway_chat.py
@@ -79,6 +79,40 @@ def _gateway_api_key(environ: dict[str, str] | None = None) -> str:
).strip()
+def gateway_chat_config_status(config_data=None, environ: dict[str, str] | None = None) -> dict:
+ """Return redacted Gateway-backed chat configuration status."""
+ mode = webui_chat_backend_mode(config_data, environ)
+ base_url = _gateway_base_url(config_data, environ)
+ return {
+ "enabled": mode == "gateway",
+ "backend": mode,
+ "base_url_configured": bool(base_url),
+ "api_key_configured": bool(_gateway_api_key(environ)),
+ }
+
+
+def _gateway_http_error_event(exc: urllib.error.HTTPError, err_body: str, *, api_key_configured: bool) -> dict:
+ safe = _redact_text(err_body or str(exc))[:500]
+ if exc.code == 401:
+ return {
+ "label": "Gateway authentication failed",
+ "type": "gateway_auth_error",
+ "message": "Gateway rejected the WebUI API key (HTTP 401).",
+ "hint": (
+ "Set HERMES_WEBUI_GATEWAY_API_KEY to the same value as the Hermes Gateway "
+ "API_SERVER_KEY, or disable HERMES_WEBUI_CHAT_BACKEND=gateway."
+ if not api_key_configured
+ else "Check that HERMES_WEBUI_GATEWAY_API_KEY matches the Hermes Gateway API_SERVER_KEY."
+ ),
+ }
+ return {
+ "label": "Gateway request failed",
+ "type": "gateway_http_error",
+ "message": f"Gateway returned HTTP {exc.code}.",
+ "hint": safe or "Check the configured Gateway API server.",
+ }
+
+
def _gateway_sse_delta(payload: dict) -> str:
"""Extract assistant text from an OpenAI-compatible streaming chunk."""
try:
@@ -287,13 +321,10 @@ def put_gateway_event(event, data):
err_body = exc.read(2048).decode("utf-8", errors="replace")
except Exception:
err_body = ""
- safe = _redact_text(err_body or str(exc))[:500]
- put_gateway_event("apperror", {
- "label": "Gateway request failed",
- "type": "gateway_http_error",
- "message": f"Gateway returned HTTP {exc.code}.",
- "hint": safe or "Check the configured Gateway API server.",
- })
+ put_gateway_event(
+ "apperror",
+ _gateway_http_error_event(exc, err_body, api_key_configured=bool(_gateway_api_key())),
+ )
except Exception as exc:
safe = _redact_text(str(exc))[:500]
put_gateway_event("apperror", {
diff --git a/api/routes.py b/api/routes.py
index 8ee764dfd1d..e67758db600 100644
--- a/api/routes.py
+++ b/api/routes.py
@@ -981,6 +981,7 @@ def _clear_live_models_cache() -> None:
_redact_text,
)
from api.agent_health import build_agent_health_payload
+from api.gateway_chat import gateway_chat_config_status
from api.request_diagnostics import RequestDiagnostics
from api.system_health import build_system_health_payload
@@ -3954,7 +3955,10 @@ def handle_get(handler, parsed) -> bool:
return _handle_health(handler, parsed)
if parsed.path == "/api/health/agent":
- return j(handler, build_agent_health_payload())
+ payload = build_agent_health_payload()
+ payload["gateway_chat"] = gateway_chat_config_status()
+ j(handler, payload)
+ return True
if parsed.path == "/api/system/health":
j(handler, build_system_health_payload())
diff --git a/tests/test_issue716_agent_heartbeat.py b/tests/test_issue716_agent_heartbeat.py
index b0cdb0a7bdb..e7bbed1320a 100644
--- a/tests/test_issue716_agent_heartbeat.py
+++ b/tests/test_issue716_agent_heartbeat.py
@@ -166,6 +166,8 @@ def test_agent_health_payload_unknown_when_gateway_is_not_configured(monkeypatch
def test_agent_health_route_is_registered_with_tri_state_payload_shape():
assert 'parsed.path == "/api/health/agent"' in ROUTES_PY
assert "build_agent_health_payload()" in ROUTES_PY
+ assert "gateway_chat_config_status()" in ROUTES_PY
+ assert 'payload["gateway_chat"]' in ROUTES_PY
src = (REPO_ROOT / "api" / "agent_health.py").read_text(encoding="utf-8")
assert '"alive"' in src
assert '"checked_at"' in src
diff --git a/tests/test_webui_gateway_chat_backend.py b/tests/test_webui_gateway_chat_backend.py
index e2bed32617b..32db93abe4c 100644
--- a/tests/test_webui_gateway_chat_backend.py
+++ b/tests/test_webui_gateway_chat_backend.py
@@ -1,12 +1,16 @@
from collections import OrderedDict
+from email.message import Message
+import urllib.error
import api.gateway_chat as gateway_chat
import api.models as models
from api.config import STREAMS, create_stream_channel
from api.models import new_session
from api.gateway_chat import (
+ _gateway_http_error_event,
_gateway_sse_delta,
_gateway_stream_usage,
+ gateway_chat_config_status,
webui_chat_backend_mode,
webui_gateway_chat_enabled,
)
@@ -31,6 +35,36 @@ def test_gateway_chat_backend_can_be_enabled_from_config_without_env():
assert webui_chat_backend_mode({"webui_chat_backend": "api_server"}, {}) == "gateway"
+def test_gateway_chat_config_status_is_redacted_and_reports_missing_key():
+ status = gateway_chat_config_status(
+ {},
+ {
+ "HERMES_WEBUI_CHAT_BACKEND": "gateway",
+ "HERMES_WEBUI_GATEWAY_BASE_URL": "http://gateway.local",
+ },
+ )
+
+ assert status == {
+ "enabled": True,
+ "backend": "gateway",
+ "base_url_configured": True,
+ "api_key_configured": False,
+ }
+
+
+def test_gateway_chat_config_status_reports_fallback_api_server_key_without_exposing_value():
+ status = gateway_chat_config_status(
+ {},
+ {
+ "HERMES_WEBUI_CHAT_BACKEND": "gateway",
+ "API_SERVER_KEY": "secret-token",
+ },
+ )
+
+ assert status["api_key_configured"] is True
+ assert "secret-token" not in repr(status)
+
+
def test_gateway_chat_backend_env_wins_over_config_and_stays_safe():
assert webui_chat_backend_mode(
{"webui_chat_backend": "gateway"},
@@ -58,6 +92,44 @@ def test_gateway_stream_usage_normalizes_token_names():
assert _gateway_stream_usage({}) == {}
+def test_gateway_http_401_reports_gateway_auth_not_provider_key():
+ exc = urllib.error.HTTPError(
+ "http://gateway.local/v1/chat/completions",
+ 401,
+ "Unauthorized",
+ hdrs=Message(),
+ fp=None,
+ )
+
+ event = _gateway_http_error_event(
+ exc,
+ '{"error":{"message":"Invalid API key","code":"invalid_api_key"}}',
+ api_key_configured=False,
+ )
+
+ assert event["label"] == "Gateway authentication failed"
+ assert event["type"] == "gateway_auth_error"
+ assert "HTTP 401" in event["message"]
+ assert "HERMES_WEBUI_GATEWAY_API_KEY" in event["hint"]
+ assert "API_SERVER_KEY" in event["hint"]
+ assert "Invalid API key" not in event["hint"]
+
+
+def test_gateway_http_401_with_key_suggests_key_mismatch():
+ exc = urllib.error.HTTPError(
+ "http://gateway.local/v1/chat/completions",
+ 401,
+ "Unauthorized",
+ hdrs=Message(),
+ fp=None,
+ )
+
+ event = _gateway_http_error_event(exc, "", api_key_configured=True)
+
+ assert event["type"] == "gateway_auth_error"
+ assert event["hint"] == "Check that HERMES_WEBUI_GATEWAY_API_KEY matches the Hermes Gateway API_SERVER_KEY."
+
+
def test_gateway_chat_worker_translates_sse_and_persists_session(tmp_path, monkeypatch):
session_dir = tmp_path / "sessions"
session_dir.mkdir()
From 790fc70e871ee520e5b0a12c6f5921da74af0f1a Mon Sep 17 00:00:00 2001
From: AJV20 <24819659+AJV20@users.noreply.github.com>
Date: Thu, 28 May 2026 10:37:38 -0400
Subject: [PATCH 18/21] test: keep bare git fixtures on master
---
tests/test_workspace_git.py | 23 +++++++++++++++++------
1 file changed, 17 insertions(+), 6 deletions(-)
diff --git a/tests/test_workspace_git.py b/tests/test_workspace_git.py
index 9d228300046..0d4b7dbfa0f 100644
--- a/tests/test_workspace_git.py
+++ b/tests/test_workspace_git.py
@@ -47,6 +47,20 @@ def _init_repo(path):
return path
+def _init_bare_repo(path):
+ init = subprocess.run(
+ ["git", "init", "--bare", "-b", "master", str(path)],
+ shell=False,
+ text=True,
+ capture_output=True,
+ timeout=20,
+ )
+ if init.returncode != 0:
+ _git(path.parent, "init", "--bare", str(path))
+ _git(path, "symbolic-ref", "HEAD", "refs/heads/master")
+ return path
+
+
def _commit_all(path, message="initial"):
_git(path, "add", ".")
_git(path, "commit", "-m", message)
@@ -513,8 +527,7 @@ def test_staged_commit_message_prompt_uses_only_staged_diff(tmp_path):
def test_git_fetch_pull_and_push_with_upstream(tmp_path):
from api.workspace_git import git_fetch, git_pull, git_push, git_status
- remote = tmp_path / "remote.git"
- _git(tmp_path, "init", "--bare", str(remote))
+ remote = _init_bare_repo(tmp_path / "remote.git")
origin = _init_repo(tmp_path / "origin")
(origin / "tracked.txt").write_text("one\n", encoding="utf-8")
@@ -550,8 +563,7 @@ def test_git_fetch_pull_and_push_with_upstream(tmp_path):
def test_git_branches_lists_local_remote_and_upstream(tmp_path):
from api.workspace_git import git_branches
- remote = tmp_path / "remote.git"
- _git(tmp_path, "init", "--bare", str(remote))
+ remote = _init_bare_repo(tmp_path / "remote.git")
origin = _init_repo(tmp_path / "origin")
(origin / "tracked.txt").write_text("one\n", encoding="utf-8")
_commit_all(origin)
@@ -575,8 +587,7 @@ def test_git_branches_lists_local_remote_and_upstream(tmp_path):
def test_git_checkout_local_new_remote_dirty_and_invalid_refs(tmp_path):
from api.workspace_git import GitWorkspaceError, git_branches, git_checkout
- remote = tmp_path / "remote.git"
- _git(tmp_path, "init", "--bare", str(remote))
+ remote = _init_bare_repo(tmp_path / "remote.git")
origin = _init_repo(tmp_path / "origin")
(origin / "tracked.txt").write_text("one\n", encoding="utf-8")
_commit_all(origin)
From 923b719ed10328e3fb15007789a38db13becad0d Mon Sep 17 00:00:00 2001
From: AJV20 <24819659+AJV20@users.noreply.github.com>
Date: Thu, 28 May 2026 11:12:58 -0400
Subject: [PATCH 19/21] fix: surface gateway auth errors in browser
---
CHANGELOG.md | 2 +-
README.md | 4 ++-
static/i18n.js | 12 +++++++
static/messages.js | 3 +-
tests/test_webui_gateway_chat_backend.py | 40 ++++++++++++++++++++++++
5 files changed, 58 insertions(+), 3 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8bacff0df93..7f9940e5700 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,7 +5,7 @@
### Fixed
-- Gateway-backed browser chat now turns Gateway API Server 401s into a specific `gateway_auth_error` explaining that `HERMES_WEBUI_GATEWAY_API_KEY` must match `API_SERVER_KEY`, instead of surfacing the Gateway's generic "Invalid API key" body as if the model provider key failed. `/api/health/agent` also reports redacted gateway-chat configuration status (`enabled`, backend, base URL configured, API key configured) for support diagnostics.
+- Gateway-backed browser chat now turns Gateway API Server 401s into a specific `gateway_auth_error` explaining that `HERMES_WEBUI_GATEWAY_API_KEY` must match `API_SERVER_KEY`, instead of surfacing the Gateway's generic "Invalid API key" body as if the model provider key failed. The browser error renderer recognizes this event type as "Gateway authentication failed" instead of falling back to a generic "Error" heading. `/api/health/agent` also reports redacted gateway-chat configuration status (`enabled`, backend, base URL configured, API key configured) as an operator diagnostic payload; it is not currently rendered as a user-facing health banner.
## [v0.51.152] — 2026-05-28 — Release DX (stage-batch34 — single-PR optional gateway-backed browser chat)
diff --git a/README.md b/README.md
index f9162652490..d5208b55300 100644
--- a/README.md
+++ b/README.md
@@ -185,7 +185,9 @@ reports a `gateway_auth_error` that points at this WebUI↔Gateway key mismatch
rather than showing the Gateway's generic provider-style "Invalid API key" body.
`/api/health/agent` also includes a redacted `gateway_chat` block so operators can
see whether gateway mode, base URL, and API-key presence are configured without
-exposing the key value.
+exposing the key value. That `gateway_chat` field is an operator diagnostic
+payload only; it is not currently rendered as a user-facing health banner in the
+browser UI.
The bridge is best used by operators who already run Hermes Gateway/API Server
locally and want browser-originated chat to use the same runtime/tool path as
diff --git a/static/i18n.js b/static/i18n.js
index e234c038caa..d5bb75888cc 100644
--- a/static/i18n.js
+++ b/static/i18n.js
@@ -167,6 +167,7 @@ const LOCALES = {
model_unavailable_title: 'This model is no longer in your current provider list',
provider_mismatch_warning: (m,p)=>`"${m}" may not work with your configured provider (${p}). Send anyway, or run \`hermes model\` in your terminal to switch.`,
provider_mismatch_label: 'Provider mismatch',
+ gateway_auth_label: 'Gateway authentication failed',
model_not_found_label: 'Model not found',
model_custom_label: 'Custom model ID',
model_custom_placeholder: 'e.g. openai/gpt-5.4',
@@ -1459,6 +1460,7 @@ const LOCALES = {
model_unavailable_title: 'Questo modello non è più nella lista provider attuale',
provider_mismatch_warning: (m,p)=>`"${m}" potrebbe non funzionare con il provider configurato (${p}). Invia comunque, o esegui \`hermes model\` nel terminale per cambiare.`,
provider_mismatch_label: 'Provider non corrispondente',
+ gateway_auth_label: 'Autenticazione Gateway non riuscita',
model_not_found_label: 'Modello non trovato',
model_custom_label: 'ID modello personalizzato',
model_custom_placeholder: 'es. openai/gpt-5.4',
@@ -2743,6 +2745,7 @@ const LOCALES = {
model_unavailable_title: 'このモデルは現在のプロバイダ一覧に含まれていません',
provider_mismatch_warning: (m,p)=>`"${m}" は設定されたプロバイダ (${p}) で動作しない可能性があります。このまま送信するか、ターミナルで \`hermes model\` を実行して切り替えてください。`,
provider_mismatch_label: 'プロバイダ不一致',
+ gateway_auth_label: 'ゲートウェイ認証に失敗しました',
model_not_found_label: 'モデルが見つかりません',
model_custom_label: 'カスタムモデルID',
model_custom_placeholder: '例: openai/gpt-5.4',
@@ -4011,6 +4014,7 @@ const LOCALES = {
provider_mismatch_warning: (m, p) =>
`"${m}" может не работать с вашим настроенным провайдером (${p}). Всё равно отправить или запустите \`hermes model\` в терминале, чтобы переключиться.`,
provider_mismatch_label: 'Несовпадение провайдера',
+ gateway_auth_label: 'Сбой аутентификации Gateway',
model_not_found_label: 'Модель не найдена',
model_custom_label: 'Пользовательский ID модели',
model_custom_placeholder: 'например, openai/gpt-5.4',
@@ -5235,6 +5239,7 @@ const LOCALES = {
model_unavailable_title: 'Este modelo ya no está en tu lista actual de proveedores',
provider_mismatch_warning: (m,p)=>`"${m}" puede no funcionar con tu proveedor configurado (${p}). Envía de todas formas, o ejecuta \`hermes model\` en la terminal para cambiar.`,
provider_mismatch_label: 'Proveedor incompatible',
+ gateway_auth_label: 'Error de autenticación de Gateway',
model_not_found_label: 'Modelo no encontrado',
model_custom_label: 'ID de modelo personalizado',
model_custom_placeholder: 'p. ej. openai/gpt-5.4',
@@ -6456,6 +6461,7 @@ const LOCALES = {
model_unavailable_title: 'Dieses Modell ist nicht mehr in Ihrer aktuellen Provider-Liste',
provider_mismatch_warning: (m,p)=>`"${m}" funktioniert möglicherweise nicht mit Ihrem konfigurierten Provider (${p}). Trotzdem senden, oder \`hermes model\` im Terminal ausführen.`,
provider_mismatch_label: 'Provider-Konflikt',
+ gateway_auth_label: 'Gateway-Authentifizierung fehlgeschlagen',
model_not_found_label: 'Modell nicht gefunden',
// commands.js
cmd_help: 'Verfügbare Befehle auflisten',
@@ -7681,6 +7687,7 @@ const LOCALES = {
model_unavailable_title: '这个模型已经不在当前 provider 列表中',
provider_mismatch_warning: (m,p)=>`\"${m}\" 可能无法在当前配置的提供商 (${p}) 下工作。直接发送,或在终端运行 \`hermes model\` 切换。`,
provider_mismatch_label: '提供商不匹配',
+ gateway_auth_label: 'Gateway 身份验证失败',
model_not_found_label: '未找到模型',
model_custom_label: '自定义模型 ID',
model_custom_placeholder: '例如 openai/gpt-5.4',
@@ -8890,6 +8897,7 @@ const LOCALES = {
model_unavailable_title: '\u6b64\u6a21\u578b\u5df2\u7d93\u4e0d\u5728\u7576\u524d provider \u5217\u8868\u4e2d',
provider_mismatch_warning: (m,p)=>`\"${m}\" \u53ef\u80fd\u7121\u6cd5\u5728\u7576\u524d\u914d\u7f6e\u7684\u63d0\u4f9b\u8005 (${p}) \u4e0b\u904b\u4f5c\u3002\u5c1a\u9001\uff0c\u6216\u5728\u7d42\u7aef\u57f7\u884c \`hermes model\` \u5207\u63db\u3002`,
provider_mismatch_label: '\u63d0\u4f9b\u8005\u4e0d\u76f8\u7b26',
+ gateway_auth_label: 'Gateway 驗證失敗',
model_not_found_label: '\u672a\u627e\u5230\u6a21\u578b',
// commands.js
cmd_help: '\u67e5\u770b\u53ef\u7528\u547d\u4ee4',
@@ -10132,6 +10140,7 @@ const LOCALES = {
model_unavailable_title: 'Este modelo não está mais na sua lista de provedores',
provider_mismatch_warning: (m,p)=>`"${m}" pode não funcionar com seu provedor configurado (${p}). Enviar assim mesmo, ou execute \`hermes model\` no terminal para trocar.`,
provider_mismatch_label: 'Provedor incompatível',
+ gateway_auth_label: 'Falha na autenticação do Gateway',
model_not_found_label: 'Modelo não encontrado',
composer_mobile_workspace: 'Workspace',
composer_mobile_model: 'Modelo',
@@ -11301,6 +11310,7 @@ const LOCALES = {
model_unavailable_title: 'This model is no longer in your current provider list',
provider_mismatch_warning: (m,p)=>`"${m}" may not work with your configured provider (${p}). Send anyway, or run \`hermes model\` in your terminal to switch.`,
provider_mismatch_label: 'Provider mismatch',
+ gateway_auth_label: 'Gateway 인증 실패',
model_not_found_label: 'Model not found',
model_custom_label: 'Custom model ID',
model_custom_placeholder: 'e.g. openai/gpt-5.4',
@@ -12588,6 +12598,7 @@ const LOCALES = {
model_unavailable: '(indisponible)',
model_unavailable_title: 'Ce modèle ne figure plus dans votre liste de fournisseurs actuelle',
provider_mismatch_label: 'Inadéquation des fournisseurs',
+ gateway_auth_label: 'Échec de l’authentification Gateway',
model_not_found_label: 'Modèle introuvable',
model_custom_label: 'ID de modèle personnalisé',
model_custom_placeholder: 'par ex. openai/gpt-5.4',
@@ -13806,6 +13817,7 @@ const LOCALES = {
model_unavailable_title: 'Bu model artık mevcut sağlayıcı listenizde değil',
provider_mismatch_warning: (m,p) => `"${m}" yapılandırılmış sağlayıcınızla (${p}) çalışmayabilir. Yine de gönderin veya geçiş yapmak için terminalinizde \`hermes model\` komutunu çalıştırın.`,
provider_mismatch_label: 'Sağlayıcı uyumsuzluğu',
+ gateway_auth_label: 'Gateway kimlik doğrulaması başarısız',
model_not_found_label: 'Model bulunamadı',
model_custom_label: 'Özel model kimliği',
model_custom_placeholder: 'örneğin openai/gpt-5.4',
diff --git a/static/messages.js b/static/messages.js
index c21951506cf..3dd1519b806 100644
--- a/static/messages.js
+++ b/static/messages.js
@@ -2107,11 +2107,12 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
const isRateLimit=d.type==='rate_limit';
const isQuotaExhausted=d.type==='quota_exhausted';
const isAuthMismatch=d.type==='auth_mismatch';
+ const isGatewayAuthError=d.type==='gateway_auth_error';
const isModelNotFound=d.type==='model_not_found';
const isCancelled=d.type==='cancelled';
const isInterrupted=d.type==='interrupted';
const isNoResponse=d.type==='no_response'||d.type==='silent_failure';
- const label=isCancelled?'Task cancelled':isInterrupted?'Response interrupted':isQuotaExhausted?'Out of credits':isRateLimit?'Rate limit reached':isAuthMismatch?(typeof t==='function'?t('provider_mismatch_label'):'Provider mismatch'):isModelNotFound?(typeof t==='function'?t('model_not_found_label'):'Model not found'):isNoResponse?'No response from provider':'Error';
+ const label=isCancelled?'Task cancelled':isInterrupted?'Response interrupted':isQuotaExhausted?'Out of credits':isRateLimit?'Rate limit reached':isGatewayAuthError?(typeof t==='function'?t('gateway_auth_label'):'Gateway authentication failed'):isAuthMismatch?(typeof t==='function'?t('provider_mismatch_label'):'Provider mismatch'):isModelNotFound?(typeof t==='function'?t('model_not_found_label'):'Model not found'):isNoResponse?'No response from provider':'Error';
const hint=d.hint?`\n\n*${d.hint}*`:'';
const details=d.details?String(d.details).replace(/```/g,'`\u200b``'):'';
const detailsLabel=isCancelled?'Cancellation details':isInterrupted?'Interruption details':undefined;
diff --git a/tests/test_webui_gateway_chat_backend.py b/tests/test_webui_gateway_chat_backend.py
index 32db93abe4c..8b66676f8ab 100644
--- a/tests/test_webui_gateway_chat_backend.py
+++ b/tests/test_webui_gateway_chat_backend.py
@@ -1,5 +1,7 @@
from collections import OrderedDict
from email.message import Message
+from pathlib import Path
+import re
import urllib.error
import api.gateway_chat as gateway_chat
@@ -130,6 +132,44 @@ def test_gateway_http_401_with_key_suggests_key_mismatch():
assert event["hint"] == "Check that HERMES_WEBUI_GATEWAY_API_KEY matches the Hermes Gateway API_SERVER_KEY."
+def test_frontend_renders_gateway_auth_error_with_specific_label():
+ src = Path("static/messages.js").read_text(encoding="utf-8")
+ start = src.find("source.addEventListener('apperror'")
+ end = src.find("source.addEventListener('warning'", start)
+ assert start != -1 and end != -1, "apperror handler not found"
+ block = src[start:end]
+
+ assert "d.type==='gateway_auth_error'" in block
+ assert "isGatewayAuthError" in block
+ assert "gateway_auth_label" in block
+ assert "Gateway authentication failed" in block
+ assert "isGatewayAuthError?(typeof t==='function'?t('gateway_auth_label'):'Gateway authentication failed'):isAuthMismatch" in block, (
+ "Gateway API key failures should use their own label before generic provider mismatch handling."
+ )
+
+
+def test_gateway_auth_label_i18n_key_exists_for_every_locale():
+ src = Path("static/i18n.js").read_text(encoding="utf-8")
+ locale_names = [
+ match.group("quoted") or match.group("plain")
+ for match in re.finditer(
+ r"^\s{2}(?:'(?P[A-Za-z0-9-]+)'|(?P[A-Za-z0-9-]+))\s*:\s*\{",
+ src,
+ re.MULTILINE,
+ )
+ ]
+ assert src.count("gateway_auth_label") >= len(locale_names)
+
+
+def test_gateway_chat_health_payload_is_documented_as_operator_diagnostic_only():
+ readme = Path("README.md").read_text(encoding="utf-8")
+ changelog = Path("CHANGELOG.md").read_text(encoding="utf-8")
+ for text in (readme, changelog):
+ assert "gateway_chat" in text
+ assert "operator diagnostic" in text
+ assert "not currently rendered as a user-facing health banner" in text
+
+
def test_gateway_chat_worker_translates_sse_and_persists_session(tmp_path, monkeypatch):
session_dir = tmp_path / "sessions"
session_dir.mkdir()
From 371f77c9b901decad3b3f4452eec4d4186e45953 Mon Sep 17 00:00:00 2001
From: nesquena-hermes <[email protected]>
Date: Thu, 28 May 2026 18:04:24 +0000
Subject: [PATCH 20/21] stage-batch36: stamp v0.51.154 / Release DZ
9-PR medium-risk cleanup:
- #3037 routes.py: argv-style prefill hook + env-var override for notes drawer
- #3046 models.py: compression parent not repaired as stale interrupted turn
- #3048 session_discoverability.py: --repair-safe CLI with default dry-run
- #3053 ui.js: streaming KaTeX guard for parser-owned equations
- #3059 models.py: empty partial activity rows excluded from sidebar recency
- #3060 profiles.py: API key writes to .env (chmod 600), not config.yaml
- #3064 routes.py: MEDIA: image tokens allow exact session-referenced paths
- #3069 models.py: cron sessions with project_id surface via Cron Jobs chip
- #3077 gateway_chat.py: HTTP 401 maps to gateway_auth_error event
---
CHANGELOG.md | 43 ++++++++++++++++++-------------------------
1 file changed, 18 insertions(+), 25 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1267977fbc1..6985e2950ae 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,12 +3,30 @@
## [Unreleased]
+## [v0.51.154] — 2026-05-28 — Release DZ (stage-batch36 — 9-PR medium-risk cleanup: cron project chip + KaTeX streaming + recovery + .env keys + discoverability repair + media MEDIA tokens + gateway 401 + notes prefill + cron filter)
+
### Added
- Session discoverability audit now has a default-dry-run `--repair-safe` routine for deterministic cleanup: stale persisted WebUI-as-CLI flags can be cleared from sidecars/index entries, and messageful WebUI rows present only in `state.db` can be materialized into sidecars/index entries when `--apply --backup-dir ` is explicitly provided.
### Changed
+- The third-party notes drawer's "Recently used by AI" list now follows the provider-neutral WebUI-specific `HERMES_WEBUI_PREFILL_MESSAGES_SCRIPT` / `webui_prefill_messages_script` hook when configured, including argv-style hooks such as `[python3, /path/to/recall.py]` and command strings such as `python3 /path/to/recall.py`, before falling back to the legacy generic `prefill_messages_script`. Configured third-party notes sources such as Joplin, Obsidian, Notion, and llm-wiki remain visible even before runtime tool inventory hydrates.
+
+### Fixed
+
+- Streaming KaTeX render passes now skip parser-owned equation placeholders that may still be receiving text, preventing long equations from being marked rendered before the final parser flush completes. (#2976)
+- Cron sessions assigned to the dedicated Cron Jobs project now remain hidden from the default sidebar while still appearing when that project chip is selected.
+- Compression parent sessions are no longer repaired as stale interrupted turns when a continuation already exists, preventing false "Response interrupted" markers and hidden continuation rows after auto-compression session rotation. (Refs #2361)
+- Empty partial activity rows preserved from cancelled turns no longer define sidebar recency, anchor the initial paginated message window, or get restored after newer completed turns. Long sessions with old activity-only partials after recent replies now stay grouped by their latest real message and open on the recent readable transcript. (#3057)
+- Local `MEDIA:` image tokens in chat history now include the current session id and can render exact image paths already present in that session transcript, so agent-generated artifacts outside the active workspace no longer show as broken thumbnails while arbitrary local paths remain blocked.
+- Gateway-backed browser chat now turns Gateway API Server 401s into a specific `gateway_auth_error` explaining that `HERMES_WEBUI_GATEWAY_API_KEY` must match `API_SERVER_KEY`, instead of surfacing the Gateway's generic "Invalid API key" body as if the model provider key failed. The browser error renderer recognizes this event type as "Gateway authentication failed" instead of falling back to a generic "Error" heading. `/api/health/agent` also reports redacted gateway-chat configuration status (`enabled`, backend, base URL configured, API key configured) as an operator diagnostic payload; it is not currently rendered as a user-facing health banner.
+- New profiles with an API key supplied at create time now write the key to the profile's `.env` under the correct provider-specific variable (e.g. `KIMI_API_KEY`, `DEEPSEEK_API_KEY`) at mode 0o600, instead of writing it to `config.yaml` where Hermes Agent never reads it.
+
+## [v0.51.153] — 2026-05-28 — Release DY (stage-batch35 — 11-PR low-risk cleanup: title-language + clarify SSE + upload filename + discoverability + SSE reconnect + gateway image + docker docs)
+
+### Changed
+
- Local fallback title generation no longer has a German-only `Session Bilder` special case; it now uses the same generic topic extraction path as other fallback titles. (Refs #3040)
- Title-generation prompts now use the same language-neutral "match the user language" instruction for every locale instead of adding German-only exemplars. (Refs #3040)
- Session discoverability audit findings for stale persisted WebUI-as-CLI flags now report whether an API-visible lineage representative already covers the hidden snapshot, including the representative session id in JSON and Markdown output.
@@ -23,31 +41,6 @@
- New chat sessions reset `_messagesTruncated` / `_oldestIdx` so a fresh conversation never displays the stale "Scroll up or click to load older messages" indicator inherited from a previously-paginated session.
- `openai-codex` reasoning-effort resolution now lets the existing `models.dev` metadata pass set the supported levels (including `xhigh`) instead of being silently clipped through the Copilot model heuristic.
-
-- The third-party notes drawer’s “Recently used by AI” list now follows the provider-neutral WebUI-specific `HERMES_WEBUI_PREFILL_MESSAGES_SCRIPT` / `webui_prefill_messages_script` hook when configured, including argv-style hooks such as `[python3, /path/to/recall.py]` and command strings such as `python3 /path/to/recall.py`, before falling back to the legacy generic `prefill_messages_script`. Configured third-party notes sources such as Joplin, Obsidian, Notion, and llm-wiki remain visible even before runtime tool inventory hydrates.
-
-
-- Streaming KaTeX render passes now skip parser-owned equation placeholders that may still be receiving text, preventing long equations from being marked rendered before the final parser flush completes. (#2976)
-
-
-- Cron sessions assigned to the dedicated Cron Jobs project now remain hidden from the default sidebar while still appearing when that project chip is selected.
-
-
-- Compression parent sessions are no longer repaired as stale interrupted turns when a continuation already exists, preventing false "Response interrupted" markers and hidden continuation rows after auto-compression session rotation. (Refs #2361)
-
-
-- Empty partial activity rows preserved from cancelled turns no longer define
- sidebar recency, anchor the initial paginated message window, or get restored
- after newer completed turns. Long sessions with old activity-only partials
- after recent replies now stay grouped by their latest real message and open on
- the recent readable transcript. (#3057)
-
-
-- Local `MEDIA:` image tokens in chat history now include the current session id and can render exact image paths already present in that session transcript, so agent-generated artifacts outside the active workspace no longer show as broken thumbnails while arbitrary local paths remain blocked.
-
-
-- Gateway-backed browser chat now turns Gateway API Server 401s into a specific `gateway_auth_error` explaining that `HERMES_WEBUI_GATEWAY_API_KEY` must match `API_SERVER_KEY`, instead of surfacing the Gateway's generic "Invalid API key" body as if the model provider key failed. The browser error renderer recognizes this event type as "Gateway authentication failed" instead of falling back to a generic "Error" heading. `/api/health/agent` also reports redacted gateway-chat configuration status (`enabled`, backend, base URL configured, API key configured) as an operator diagnostic payload; it is not currently rendered as a user-facing health banner.
-
### Documentation
- Clarify two Docker onboarding traps: `sudo docker compose` can mount `/root/.hermes` instead of the user's Hermes home on Linux, and Linux Docker Engine users should use a `host-gateway` alias such as `api.local` for host-local model servers instead of configuring `localhost` inside the container. (#3006, #3012)
From 0a2dabc730659938710a2ef57e2736fa3dec9b90 Mon Sep 17 00:00:00 2001
From: nesquena-hermes <[email protected]>
Date: Thu, 28 May 2026 18:20:25 +0000
Subject: [PATCH 21/21] stage-batch36: tighten #3064 MEDIA: token gate to
non-user-role messages
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Per Opus advisor on stage-batch36: skip role='user' messages in
_session_media_token_allows_image_path so a user-injected MEDIA: token
cannot mint an allow-list entry for the user's own request. Preserves
the original use case (assistant/tool emitted artifacts outside the
active workspace) while making the implicit threat model explicit.
Defense-in-depth — the single-user WebUI scope means same-origin user
input already had the same effective access, but multi-user / shared
WebUI deployments would benefit from the restriction.
---
api/routes.py | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/api/routes.py b/api/routes.py
index c5da3146b1d..33fbf2c51e1 100644
--- a/api/routes.py
+++ b/api/routes.py
@@ -7619,6 +7619,13 @@ def _session_media_token_allows_image_path(sid: str, target: Path, image_mimes:
for message in getattr(session, "messages", []) or []:
if not isinstance(message, dict):
continue
+ # Only honor MEDIA: tokens that the assistant/tool emitted. User-authored
+ # content cannot mint allow-list entries even if it contains a MEDIA:
+ # token — keeps the implicit threat model (assistant-emitted artifacts
+ # only) explicit.
+ role = str(message.get("role") or "").strip().lower()
+ if role == "user":
+ continue
text = _message_content_text(message.get("content"))
if "MEDIA:" not in text:
continue