From e1bf1122a79d347419d4994612fcbb2ac552b852 Mon Sep 17 00:00:00 2001 From: Frank Song Date: Mon, 29 Jun 2026 10:23:58 +0800 Subject: [PATCH 1/4] Test anchor runtime journal parity --- tests/test_anchor_scene_persistence.py | 157 +++++++++++++++++++++++++ 1 file changed, 157 insertions(+) diff --git a/tests/test_anchor_scene_persistence.py b/tests/test_anchor_scene_persistence.py index 486b839b..d769cc33 100644 --- a/tests/test_anchor_scene_persistence.py +++ b/tests/test_anchor_scene_persistence.py @@ -21,6 +21,48 @@ def _client_anchor_scene_message_ref(message): return json.dumps(payload, ensure_ascii=False, separators=(",", ":")) +def _anchor_scene_visible_semantics(scene, *, include_terminal=False): + semantics = [] + for row in scene.get("activity_rows") or []: + role = row.get("role") + if role == "terminal" and not include_terminal: + continue + if role in {"prose", "thinking"}: + semantics.append( + { + "role": role, + "kind": row.get("kind"), + "text": " ".join(str(row.get("text") or "").split()), + } + ) + continue + if role == "tool": + tool = row.get("tool") if isinstance(row.get("tool"), dict) else {} + payload = row.get("payload") if isinstance(row.get("payload"), dict) else {} + semantics.append( + { + "role": role, + "kind": row.get("kind"), + "status": row.get("status"), + "tool_call_id": row.get("tool_call_id") or tool.get("id") or payload.get("tid"), + "name": tool.get("name") or payload.get("name"), + "args": tool.get("args") or payload.get("args") or {}, + "done": tool.get("done"), + } + ) + continue + if role == "terminal": + semantics.append( + { + "role": role, + "kind": row.get("kind"), + "source_event_type": row.get("source_event_type"), + "status": row.get("status"), + } + ) + return semantics + + def test_anchor_scene_persistence_round_trip_outside_provider_messages(tmp_path, monkeypatch): from api import models, routes from api.models import Session @@ -2029,6 +2071,121 @@ def test_anchor_scene_hydration_seals_unmatched_live_running_activity_rows(): assert [row.get("role") for row in activity_rows] == ["thinking", "prose", "tool"] +def test_runtime_journal_anchor_scene_matches_settled_hydrated_visible_semantics(tmp_path, monkeypatch): + """Runtime journal replay and settled read hydration must preserve the same + visible anchor activity semantics for one turn. + + Compared path: + _run_journal_live_snapshot(...).anchor_activity_scene + -> persisted anchor_activity_scenes record + -> _hydrate_anchor_activity_scenes(...)._anchor_activity_scene. + """ + from api import models, routes + from api.run_journal import RunJournalWriter + + session_dir = tmp_path / "sessions" + session_dir.mkdir() + monkeypatch.setattr(models, "SESSION_DIR", session_dir) + monkeypatch.setattr(routes, "SESSION_DIR", session_dir) + + session_id = "anchorparity1" + stream_id = "stream-parity-1" + process_before_tool = "first progress" + thinking = "thinking through plan" + process_after_tool = "checkpoint tail" + final_answer = "Final answer: keep the activity above this answer." + + writer = RunJournalWriter(session_id, stream_id, session_dir=session_dir) + writer.append_sse_event("token", {"text": process_before_tool}) + writer.append_sse_event("reasoning", {"text": thinking}) + writer.append_sse_event( + "tool", + {"name": "terminal", "tid": "call-1", "args": {"command": "pytest"}}, + ) + writer.append_sse_event( + "tool_complete", + {"name": "terminal", "tid": "call-1", "preview": "ok"}, + ) + writer.append_sse_event("token", {"text": f" {process_after_tool}"}) + + runtime_snapshot = routes._run_journal_live_snapshot(stream_id) + runtime_scene = runtime_snapshot["anchor_activity_scene"] + assert _anchor_scene_visible_semantics(runtime_scene) == [ + {"role": "prose", "kind": "process_prose", "text": process_before_tool}, + {"role": "thinking", "kind": "reasoning", "text": thinking}, + { + "role": "tool", + "kind": "tool_completed", + "status": "completed", + "tool_call_id": "call-1", + "name": "terminal", + "args": {"command": "pytest"}, + "done": True, + }, + {"role": "prose", "kind": "process_prose", "text": process_after_tool}, + ] + + persisted_scene = json.loads(json.dumps(runtime_scene)) + persisted_scene["activity_rows"].append( + { + "row_id": "terminal-done", + "role": "terminal", + "kind": "terminal_status", + "source_event_type": "done", + "status": "completed", + "text": "Response complete", + } + ) + messages = [ + {"role": "user", "content": "question"}, + { + "role": "assistant", + "content": process_before_tool, + "reasoning": thinking, + "tool_calls": [ + { + "id": "call-1", + "name": "terminal", + "args": {"command": "pytest"}, + "preview": "pytest", + "snippet": "ok", + } + ], + }, + {"role": "tool", "tool_call_id": "call-1", "content": "ok"}, + {"role": "assistant", "content": process_after_tool}, + {"role": "assistant", "content": final_answer}, + ] + records = { + "record": { + "version": "anchor_activity_scene_record_v1", + "message_index": 4, + "message_ref": routes._assistant_anchor_scene_message_ref(messages[4]), + "stream_id": stream_id, + "scene": persisted_scene, + } + } + + hydrated = routes._hydrate_anchor_activity_scenes(messages, records) + settled_scene = hydrated[4]["_anchor_activity_scene"] + + assert settled_scene["final_answer"] == final_answer + assert final_answer not in [ + row.get("text") for row in settled_scene["activity_rows"] if row.get("role") != "terminal" + ] + assert _anchor_scene_visible_semantics(settled_scene) == _anchor_scene_visible_semantics(runtime_scene) + assert _anchor_scene_visible_semantics(settled_scene, include_terminal=True)[-1] == { + "role": "terminal", + "kind": "terminal_status", + "source_event_type": "done", + "status": "completed", + } + assert not any( + row.get("role") in {"prose", "thinking", "tool"} and row.get("status") == "running" + for row in settled_scene["activity_rows"] + ) + + def test_runtime_journal_snapshot_includes_live_anchor_activity_scene(monkeypatch): from api import routes From e6703203375898a6131fb796d12d783fb5254341 Mon Sep 17 00:00:00 2001 From: Frank Song Date: Mon, 29 Jun 2026 10:44:56 +0800 Subject: [PATCH 2/4] Tighten anchor runtime journal parity test Co-Authored-By: Paperclip --- tests/test_anchor_scene_persistence.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/test_anchor_scene_persistence.py b/tests/test_anchor_scene_persistence.py index d769cc33..efda70e7 100644 --- a/tests/test_anchor_scene_persistence.py +++ b/tests/test_anchor_scene_persistence.py @@ -2109,7 +2109,9 @@ def test_runtime_journal_anchor_scene_matches_settled_hydrated_visible_semantics writer.append_sse_event("token", {"text": f" {process_after_tool}"}) runtime_snapshot = routes._run_journal_live_snapshot(stream_id) - runtime_scene = runtime_snapshot["anchor_activity_scene"] + assert runtime_snapshot is not None + runtime_scene = runtime_snapshot.get("anchor_activity_scene") + assert isinstance(runtime_scene, dict) assert _anchor_scene_visible_semantics(runtime_scene) == [ {"role": "prose", "kind": "process_prose", "text": process_before_tool}, {"role": "thinking", "kind": "reasoning", "text": thinking}, @@ -2156,11 +2158,12 @@ def test_runtime_journal_anchor_scene_matches_settled_hydrated_visible_semantics {"role": "assistant", "content": process_after_tool}, {"role": "assistant", "content": final_answer}, ] + message_ref = routes._assistant_anchor_scene_message_ref(messages[4]) records = { - "record": { + message_ref: { "version": "anchor_activity_scene_record_v1", "message_index": 4, - "message_ref": routes._assistant_anchor_scene_message_ref(messages[4]), + "message_ref": message_ref, "stream_id": stream_id, "scene": persisted_scene, } From 058372c15f300bc53b80bddfc53e0ed9b5fdb5cb Mon Sep 17 00:00:00 2001 From: Frank Song Date: Mon, 29 Jun 2026 12:22:41 +0800 Subject: [PATCH 3/4] Preserve empty anchor tool args in parity helper Co-Authored-By: Paperclip --- tests/test_anchor_scene_persistence.py | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/tests/test_anchor_scene_persistence.py b/tests/test_anchor_scene_persistence.py index efda70e7..a9787092 100644 --- a/tests/test_anchor_scene_persistence.py +++ b/tests/test_anchor_scene_persistence.py @@ -39,6 +39,9 @@ def _anchor_scene_visible_semantics(scene, *, include_terminal=False): if role == "tool": tool = row.get("tool") if isinstance(row.get("tool"), dict) else {} payload = row.get("payload") if isinstance(row.get("payload"), dict) else {} + args = tool.get("args") if "args" in tool else payload.get("args") + if args is None: + args = {} semantics.append( { "role": role, @@ -46,7 +49,7 @@ def _anchor_scene_visible_semantics(scene, *, include_terminal=False): "status": row.get("status"), "tool_call_id": row.get("tool_call_id") or tool.get("id") or payload.get("tid"), "name": tool.get("name") or payload.get("name"), - "args": tool.get("args") or payload.get("args") or {}, + "args": args, "done": tool.get("done"), } ) @@ -63,6 +66,23 @@ def _anchor_scene_visible_semantics(scene, *, include_terminal=False): return semantics +def test_anchor_scene_visible_semantics_preserves_empty_tool_args(): + scene = { + "activity_rows": [ + { + "role": "tool", + "kind": "tool_completed", + "status": "completed", + "tool_call_id": "call-1", + "tool": {"id": "call-1", "name": "terminal", "args": {}}, + "payload": {"tid": "call-1", "name": "terminal", "args": {"command": "stale"}}, + } + ] + } + + assert _anchor_scene_visible_semantics(scene)[0]["args"] == {} + + def test_anchor_scene_persistence_round_trip_outside_provider_messages(tmp_path, monkeypatch): from api import models, routes from api.models import Session From 744b6480cf41b182251417c47f4c73489dd372e5 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Mon, 29 Jun 2026 05:37:02 +0000 Subject: [PATCH 4/4] docs(changelog): runtime-journal anchor-scene parity regression test (#5183) --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6620a5bf..f18985e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,8 @@ ### Changed +- **Internal: added a runtime-journal ↔ settled-hydration parity regression test** for anchor activity scenes. The test drives a real `RunJournalWriter` through a full turn (token / reasoning / tool / tool_complete) and asserts that `_run_journal_live_snapshot(...).anchor_activity_scene` and the persisted-then-`_hydrate_anchor_activity_scenes(...)` settled scene expose identical visible semantics — locking in the invariant that a live-streaming turn and a reloaded settled turn render the same anchor activity. Test-only; no behavior change. Thanks @franksong2702. (#5183) + - **The new-conversation splash logo now breathes free instead of sitting in an app-icon tile.** On the empty-state hero, the caduceus mark dropped its glossy navy rounded-rect (which read like a misplaced macOS app-launcher tile — a metaphor mismatch, since nothing launches there) in favor of a bare, larger (~88px) mark over a soft radial "spirit" bloom, matching how Claude/ChatGPT/Gemini render their empty-state marks. The mark gradient and the bloom are tuned per theme: dark themes get the bright `#08EBF1 → #3889FD` mark with a luminous cyan bloom, light themes a deeper `#0AA6CC → #1E63D6` mark with a cooler, restrained bloom so it stays crisp on the pale background. A gentle entrance is gated behind `prefers-reduced-motion`. The titlebar icon and favicon family (which legitimately *are* app icons) keep the navy badge. Thanks @rodboev for the core call. - **New Hermes WebUI brand mark — a cyan→blue caduceus on a navy badge.** The titlebar icon and the full favicon family (`favicon.svg`/`favicon-512.svg`/`favicon.ico` + 32/192/512 PNGs + `apple-touch-icon.png`) are regenerated from a single clean vector mark (gradient `#08EBF1 → #3889FD` on a radial-depth navy tile). (The empty-state hero mark later dropped the navy tile — see the splash-logo entry above.) Five per-skin `.app-titlebar-icon` force-fill overrides (graphite, codex, terracotta, github, geist-contrast) that assumed the old monochrome caduceus were removed so the new full-color badge renders consistently across skins. Verified in dark + light themes.