From 17d70759e0d201b4af72b01080400e7381dcc77d Mon Sep 17 00:00:00 2001 From: jenkins Date: Sun, 13 Sep 2026 14:25:25 -0500 Subject: [PATCH] hermes: show recorded tool activity in resumed chats --- .../scripts/patch_web_session_activity.py | 34 ++++++++++++- .../test_hermes_chat_session_continuity.py | 49 +++++++++++++++++++ 2 files changed, 81 insertions(+), 2 deletions(-) diff --git a/services/hermes/scripts/patch_web_session_activity.py b/services/hermes/scripts/patch_web_session_activity.py index ede66f76..42e2ea9f 100644 --- a/services/hermes/scripts/patch_web_session_activity.py +++ b/services/hermes/scripts/patch_web_session_activity.py @@ -51,6 +51,33 @@ HELPER_REPLACEMENT = '''def _run_activity_messages(session_id: str) -> List[Dict return entries[-1_000:] +def _present_activity_message(message: Dict[str, Any]) -> Dict[str, Any]: + """Give a persisted tool-only assistant turn an honest visible summary. + + Hermes deliberately stores a blank assistant ``content`` field when the + turn only invokes tools. The tool call records are retained separately, + but a transcript card with neither prose nor a summary looks like missing + history. This projection leaves the stored message intact and names only + the already-recorded tool functions. + """ + item = dict(message) + if item.get("role") != "assistant" or str(item.get("content") or "").strip(): + return item + calls = item.get("tool_calls") + if not isinstance(calls, list): + return item + names: List[str] = [] + for call in calls: + function = call.get("function") if isinstance(call, dict) else None + name = function.get("name") if isinstance(function, dict) else None + if isinstance(name, str) and name.strip(): + names.append(name.strip()[:80]) + if names: + item["content"] = "Calling " + ", ".join(names[:8]) + "." + item["observed"] = True + return item + + @app.get("/api/sessions/{session_id}/messages") async def get_session_messages( session_id: str, @@ -64,8 +91,11 @@ MESSAGES_BEFORE = ''' messages = db.get_messages(sid) ''' MESSAGES_AFTER = ''' messages = [ - *db.get_messages(sid), - *_run_activity_messages(sid), + _present_activity_message(item) + for item in [ + *db.get_messages(sid), + *_run_activity_messages(sid), + ] ] messages.sort(key=lambda item: float(item.get("timestamp") or 0.0)) total_messages = len(messages) diff --git a/testing/tests/test_hermes_chat_session_continuity.py b/testing/tests/test_hermes_chat_session_continuity.py index 8bd056ea..67be8178 100644 --- a/testing/tests/test_hermes_chat_session_continuity.py +++ b/testing/tests/test_hermes_chat_session_continuity.py @@ -2,9 +2,11 @@ from __future__ import annotations +import asyncio import importlib.util import json import sqlite3 +import textwrap from pathlib import Path import pytest @@ -290,6 +292,53 @@ def test_web_session_activity_patch_projects_bounded_events(tmp_path: Path): assert 'item[0].get("ended_at") is None' in patched +def test_activity_projection_preserves_user_text_and_labels_tool_only_turns(): + """A saved tool invocation cannot become a blank resumed-chat card.""" + module_path = HERMES / "scripts" / "patch_web_session_activity.py" + spec = importlib.util.spec_from_file_location("patch_web_activity_projection", module_path) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + helper = module.HELPER_REPLACEMENT.split('@app.get("/api/sessions/', 1)[0] + namespace: dict[str, object] = {} + exec("from typing import Any, Dict, List\n" + helper, namespace) + present = namespace["_present_activity_message"] + + first_user = {"role": "user", "content": "Please inspect the saved work."} + second_user = {"role": "user", "content": "Continue from the recorded findings."} + assert present(first_user) == first_user + assert present(second_user) == second_user + + tool_only = { + "role": "assistant", + "content": None, + # This is the parsed API shape for the serialized database column. + "tool_calls": [{"function": {"name": "terminal"}}], + } + projected = present(tool_only) + assert projected["content"] == "Calling terminal." + assert projected["observed"] is True + assert tool_only["content"] is None + + class FakeDB: + def get_messages(self, session_id): + assert session_id == "resume-fixture" + return [first_user, tool_only, second_user] + + response_source = "async def project_messages(db, sid, limit=None):\n" + textwrap.indent( + textwrap.dedent(module.MESSAGES_AFTER), " " + ) + exec(response_source, namespace) + namespace["_run_activity_messages"] = lambda _session_id: [] + payload = asyncio.run(namespace["project_messages"](FakeDB(), "resume-fixture")) + assert [item["content"] for item in payload["messages"]] == [ + "Please inspect the saved work.", + "Calling terminal.", + "Continue from the recorded findings.", + ] + assert payload["total_messages"] == 3 + def test_web_session_lineage_returns_to_resumed_parent(): """An ended reviewer must not strand the live view away from its parent.""" module_path = HERMES / "scripts" / "patch_web_session_activity.py"