{"repo":"NousResearch/hermes-agent","public_id":"34703","display_label":"WebUI streaming messages lost on session switch; related API transcript change","summary":"Two members are duplicate reports of a WebUI bug where assistant streaming messages ending in a colon appear to be lost when switching session pages. A third member concerns emitting a per-turn transcript on run.completed in the api_server, and its relationship to the WebUI reports is not established by the titles.","subsystem":null,"counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":2,"merged_prs":1,"attempt_prs":0},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":34804,"merged_at":"2026-05-29T19:27:50+00:00"},"resolution":{"state":"partially_resolved","by":"pr-34804"},"freshness":{"state":"verified","last_verified_at":"2026-05-29T19:27:50+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.4533333333333333},"fix_edges":[{"issue_number":34703,"pr_number":34804,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-05-29T19:27:50+00:00"}],"members":[{"kind":"issue","number":34703,"title":"[Bug]: WebUI: assistant colon-ended streaming messages lost when switching session pages","url":null,"state":"closed"},{"kind":"issue","number":34705,"title":"[Bug]: WebUI: assistant colon-ended streaming messages lost when switching session pages","url":null,"state":"closed"},{"kind":"pr","number":34804,"title":"fix(api_server): emit per-turn transcript on run.completed (#34703)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/api_server.py","tests/gateway/test_session_api.py","website/docs/user-guide/features/code-execution.md"],"fix_hunks":[{"file":"gateway/platforms/api_server.py","hunks":["@@ -1605,6 +1605,7 @@ async def _run_and_signal() -> None:\n                 )\n                 final_response = result.get(\"final_response\", \"\") if isinstance(result, dict) else \"\"\n                 effective_session_id = result.get(\"session_id\", session_id) if isinstance(result, dict) else session_id\n+                turn_messages = self._turn_transcript_messages(history, user_message, result) if isinstance(result, dict) else []\n                 await queue.put(_event_payload(\"assistant.completed\", {\n                     \"session_id\": effective_session_id,\n                     \"message_id\": message_id,","@@ -1617,6 +1618,7 @@ async def _run_and_signal() -> None:\n                     \"session_id\": effective_session_id,\n                     \"message_id\": message_id,\n                     \"completed\": True,\n+                    \"messages\": turn_messages,\n                     \"usage\": usage,\n                 }))\n             except Exception as exc:","@@ -3329,6 +3331,44 @@ def _response_messages_turn_start_index(\n             return len(prior)\n         return 0\n \n+    @classmethod\n+    def _turn_transcript_messages(\n+        cls,\n+        conversation_history: List[Dict[str, Any]],\n+        user_message: Any,\n+        result: Dict[str, Any],\n+    ) -> List[Dict[str, Any]]:\n+        \"\"\"Return this turn's assistant/tool messages in client-safe shape.\n+\n+        The streaming SSE contract delivers all assistant text as\n+        ``assistant.delta`` events under one ``message_id`` interleaved with\n+        ``tool.*`` events, and a single ``assistant.completed`` carrying only\n+        the final reply.  A client that accumulates deltas into one buffer\n+        cannot reconstruct *intermediate* assistant text segments that preceded\n+        tool calls — so when the page is re-opened mid/post-stream those\n+        segments appear lost, even though state.db persisted them correctly.\n+\n+        Emitting the authoritative per-turn transcript on ``run.completed`` lets\n+        any SSE consumer reconcile its live view against ground truth without a\n+        separate ``GET /messages`` round-trip.  Purely additive: clients that\n+        ignore the field are unaffected.  Refs #34703.\n+        \"\"\"\n+        agent_messages = result.get(\"messages\") if isinstance(result, dict) else None\n+        if not isinstance(agent_messages, list) or not agent_messages:\n+            return []\n+        start = cls._response_messages_turn_start_index(\n+            conversation_history, user_message, result\n+        )\n+        turn = agent_messages[start:]\n+        out: List[Dict[str, Any]] = []\n+        for msg in turn:\n+            if not isinstance(msg, dict):\n+                continue\n+            if msg.get(\"role\") not in {\"assistant\", \"tool\"}:\n+                continue\n+            out.append(cls._message_response(msg))\n+        return out\n+\n     @staticmethod\n     def _extract_output_items(result: Dict[str, Any], start_index: int = 0) -> List[Dict[str, Any]]:\n         \"\"\""]},{"file":"tests/gateway/test_session_api.py","hunks":["@@ -268,6 +268,75 @@ async def fake_run(**kwargs):\n     assert \"event: done\" in body\n \n \n[redacted:email]\n+async def test_session_chat_stream_run_completed_carries_turn_transcript(adapter, session_db):\n+    \"\"\"run.completed must include the full interleaved turn transcript so a\n+    client that lost intermediate (pre-tool-call) assistant text from the live\n+    delta stream can reconcile without a separate /messages fetch. Refs #34703.\n+    \"\"\"\n+    import json as _json\n+\n+    session_id = session_db.create_session(\"transcript-session\", \"api_server\")\n+\n+    async def fake_run(**kwargs):\n+        # Stream the intermediate planning text the way a real turn would.\n+        kwargs[\"stream_delta_callback\"](\"Let me search for that:\")\n+        kwargs[\"stream_delta_callback\"](\"Here is the summary.\")\n+        result = {\n+            \"final_response\": \"Here is the summary.\",\n+            \"session_id\": session_id,\n+            \"messages\": [\n+                {\"role\": \"user\", \"content\": \"search then summarize\"},\n+                {\n+                    \"role\": \"assistant\",\n+                    \"content\": \"Let me search for that:\",\n+                    \"tool_calls\": [\n+                        {\n+                            \"id\": \"call_1\",\n+                            \"type\": \"function\",\n+                            \"function\": {\"name\": \"web_search\", \"arguments\": \"{}\"},\n+                        }\n+                    ],\n+                },\n+                {\"role\": \"tool\", \"content\": \"results\", \"tool_call_id\": \"call_1\", \"tool_name\": \"web_search\"},\n+                {\"role\": \"assistant\", \"content\": \"Here is the summary.\"},\n+            ],\n+        }\n+        return result, {\"total_tokens\": 6}\n+\n+    app = _create_session_app(adapter)\n+    with patch.object(adapter, \"_run_agent\", side_effect=fake_run):\n+        async with TestClient(TestServer(app)) as cli:\n+            resp = await cli.post(\n+                f\"/api/sessions/{session_id}/chat/stream\",\n+                json={\"message\": \"search then summarize\"},\n+            )\n+            assert resp.status == 200\n+            body = await resp.text()\n+\n+    # Pull the run.completed event payload out of the SSE body.\n+    run_completed_payload = None\n+    for block in body.split(\"\\n\\n\"):\n+        if \"event: run.completed\" in block:\n+            for line in block.splitlines():\n+                if line.startswith(\"data: \"):\n+                    run_completed_payload = _json.loads(line[len(\"data: \"):])\n+            break\n+    assert run_completed_payload is not None, body\n+    messages = run_completed_payload.get(\"messages\")\n+    assert isinstance(messages, list) and messages, run_completed_payload\n+\n+    # The colon-ended intermediate text that preceded the tool call must be present.\n+    contents = [m.get(\"content\") for m in messages]\n+    assert \"Let me search for that:\" in contents\n+    assert \"Here is the summary.\" in contents\n+    # No prior-turn user message should leak into the per-turn slice.\n+    assert all(m.get(\"role\") in (\"assistant\", \"tool\") for m in messages)\n+    # The tool call is preserved alongside the intermediate text.\n+    assert any(m.get(\"tool_calls\") for m in messages)\n+\n+\n+\n @pytest.mark.asyncio\n async def test_session_endpoints_require_auth_when_key_configured(auth_adapter):\n     app = _create_session_app(auth_adapter)"]},{"file":"website/docs/user-guide/features/code-execution.md","hunks":["@@ -219,6 +219,62 @@ terminal:\n \n See the [Security guide](/user-guide/security#environment-variable-passthrough) for full details.\n \n+### `HERMES_*` variables in the child\n+\n+The child process receives only a small, fixed set of operational `HERMES_*`\n+variables by exact name:\n+\n+- `HERMES_HOME`\n+- `HERMES_PROFILE`\n+- `HERMES_CONFIG`\n+- `HERMES_ENV`\n+\n+(plus `HERMES_RPC_DIR` / `HERMES_RPC_SOCKET` / `TZ` / `HOME`, which Hermes\n+injects explicitly so the RPC channel works).\n+\n+:::note Behavior change\n+Earlier versions passed **any** variable whose name began with `HERMES_`\n+through to the child. That broad prefix was removed for security hardening: it\n+could leak `HERMES_*`-named configuration that doesn't match a secret substring\n+(for example `HERMES_BASE_URL`, `HERMES_KANBAN_DB`, or a `HERMES_*_WEBHOOK`\n+endpoint) into arbitrary sandboxed code.\n+\n+If an `execute_code` script — or a repo/plugin module it imports at import time\n+— relied on a `HERMES_*` variable outside the four operational names above, it\n+will now find that variable **unset** in the child. The drop is intentional,\n+not a bug.\n+:::\n+\n+**Workaround — opt the variable back in explicitly.** Both routes pass the\n+variable through `execute_code` *and* `terminal` children, and neither weakens\n+the secret-stripping guarantee (Hermes-managed provider credentials can never\n+be re-allowed this way):\n+\n+1. **Per-machine, in `config.yaml`** — add the exact variable name to the\n+   passthrough allowlist:\n+\n+   ```yaml\n+   terminal:\n+     env_passthrough:\n+       - HERMES_KANBAN_DB\n+       - HERMES_BASE_URL\n+   ```\n+\n+2. **Per-skill, in the skill's frontmatter** — declare it so it is registered\n+   automatically whenever that skill is loaded:\n+\n+   ```yaml\n+   required_environment_variables:\n+     - HERMES_KANBAN_DB\n+   ```\n+\n+**Diagnosing it.** When the child drops one or more non-allowlisted `HERMES_*`\n+variables, Hermes emits a one-line `debug` log naming them and pointing at the\n+`env_passthrough` escape hatch. Run with debug logging (`hermes logs --level\n+DEBUG`, or check `~/.hermes/logs/agent.log`) and look for\n+`execute_code: dropped N non-allowlisted HERMES_* var(s)` if a script behaves\n+as though a `HERMES_*` variable is missing.\n+\n Hermes always writes the script and the auto-generated `hermes_tools.py` RPC stub into a temp staging directory that is cleaned up after execution. In `strict` mode the script also *runs* there; in `project` mode it runs in the session's working directory (the staging directory stays on `PYTHONPATH` so imports still resolve). The child process runs in its own process group so it can be cleanly killed on timeout or interruption.\n \n ## execute_code vs terminal\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}