# NousResearch/hermes-agent · WebUI streaming messages lost on session switch; related API transcript change

## Open now

- Nothing open on GitHub.

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.

## TL;DR

- 3 member(s): 0 open issue(s), 2 closed issue(s), 0 open PR(s), 1 merged PR(s), 0 PR(s) closed without merging.
- A fix was merged to main in PR #34804. Verify it addresses your case.
- **Recommended:** read PR #34804 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `partially_resolved` · deciding PR(s): `pr-34804`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
- Members outside that structural core may belong to a different underlying problem.
- Pairwise link density `0.45`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #34703: \[Bug\]: WebUI: assistant colon-ended streaming messages lost when switching session pages
- #34705: \[Bug\]: WebUI: assistant colon-ended streaming messages lost when switching session pages

## Prior & competing PRs

- **#34804: fix(api_server): emit per-turn transcript on run.completed (#34703)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `gateway/platforms/api_server.py`, `tests/gateway/test_session_api.py`, `website/docs/user-guide/features/code-execution.md`
  - fix hunks:
  - `gateway/platforms/api_server.py`

    ```diff
    @@ -1605,6 +1605,7 @@ async def _run_and_signal() -> None:
                     )
                     final_response = result.get("final_response", "") if isinstance(result, dict) else ""
                     effective_session_id = result.get("session_id", session_id) if isinstance(result, dict) else session_id
    +                turn_messages = self._turn_transcript_messages(history, user_message, result) if isinstance(result, dict) else []
                     await queue.put(_event_payload("assistant.completed", {
                         "session_id": effective_session_id,
                         "message_id": message_id,
    ```

    ```diff
    @@ -1617,6 +1618,7 @@ async def _run_and_signal() -> None:
                         "session_id": effective_session_id,
                         "message_id": message_id,
                         "completed": True,
    +                    "messages": turn_messages,
                         "usage": usage,
                     }))
                 except Exception as exc:
    ```

    ```diff
    @@ -3329,6 +3331,44 @@ def _response_messages_turn_start_index(
                 return len(prior)
             return 0
     
    +    @classmethod
    +    def _turn_transcript_messages(
    +        cls,
    +        conversation_history: List[Dict[str, Any]],
    +        user_message: Any,
    +        result: Dict[str, Any],
    +    ) -> List[Dict[str, Any]]:
    +        """Return this turn's assistant/tool messages in client-safe shape.
    +
    +        The streaming SSE contract delivers all assistant text as
    +        ``assistant.delta`` events under one ``message_id`` interleaved with
    +        ``tool.*`` events, and a single ``assistant.completed`` carrying only
    +        the final reply.  A client that accumulates deltas into one buffer
    +        cannot reconstruct *intermediate* assistant text segments that preceded
    +        tool calls — so when the page is re-opened mid/post-stream those
    +        segments appear lost, even though state.db persisted them correctly.
    +
    +        Emitting the authoritative per-turn transcript on ``run.completed`` lets
    +        any SSE consumer reconcile its live view against ground truth without a
    +        separate ``GET /messages`` round-trip.  Purely additive: clients that
    +        ignore the field are unaffected.  Refs #34703.
    +        """
    +        agent_messages = result.get("messages") if isinstance(result, dict) else None
    +        if not isinstance(agent_messages, list) or not agent_messages:
    +            return []
    +        start = cls._response_messages_turn_start_index(
    +            conversation_history, user_message, result
    +        )
    +        turn = agent_messages[start:]
    +        out: List[Dict[str, Any]] = []
    +        for msg in turn:
    +            if not isinstance(msg, dict):
    +                continue
    +            if msg.get("role") not in {"assistant", "tool"}:
    +                continue
    +            out.append(cls._message_response(msg))
    +        return out
    +
         @staticmethod
         def _extract_output_items(result: Dict[str, Any], start_index: int = 0) -> List[Dict[str, Any]]:
             """
    ```
  - `tests/gateway/test_session_api.py`

    ```diff
    @@ -268,6 +268,75 @@ async def fake_run(**kwargs):
         assert "event: done" in body
     
     
    [redacted:email]
    +async def test_session_chat_stream_run_completed_carries_turn_transcript(adapter, session_db):
    +    """run.completed must include the full interleaved turn transcript so a
    +    client that lost intermediate (pre-tool-call) assistant text from the live
    +    delta stream can reconcile without a separate /messages fetch. Refs #34703.
    +    """
    +    import json as _json
    +
    +    session_id = session_db.create_session("transcript-session", "api_server")
    +
    +    async def fake_run(**kwargs):
    +        # Stream the intermediate planning text the way a real turn would.
    +        kwargs["stream_delta_callback"]("Let me search for that:")
    +        kwargs["stream_delta_callback"]("Here is the summary.")
    +        result = {
    +            "final_response": "Here is the summary.",
    +            "session_id": session_id,
    +            "messages": [
    +                {"role": "user", "content": "search then summarize"},
    +                {
    +                    "role": "assistant",
    +                    "content": "Let me search for that:",
    +                    "tool_calls": [
    +                        {
    +                            "id": "call_1",
    +                            "type": "function",
    +                            "function": {"name": "web_search", "arguments": "{}"},
    +                        }
    +                    ],
    +                },
    +                {"role": "tool", "content": "results", "tool_call_id": "call_1", "tool_name": "web_search"},
    +                {"role": "assistant", "content": "Here is the summary."},
    +            ],
    +        }
    +        return result, {"total_tokens": 6}
    +
    +    app = _create_session_app(adapter)
    +    with patch.object(adapter, "_run_agent", side_effect=fake_run):
    +        async with TestClient(TestServer(app)) as cli:
    +            resp = await cli.post(
    +                f"/api/sessions/{session_id}/chat/stream",
    +                json={"message": "search then summarize"},
    +            )
    +            assert resp.status == 200
    +            body = await resp.text()
    +
    +    # Pull the run.completed event payload out of the SSE body.
    +    run_completed_payload = None
    +    for block in body.split("\n\n"):
    +        if "event: run.completed" in block:
    +            for line in block.splitlines():
    +                if line.startswith("data: "):
    +                    run_completed_payload = _json.loads(line[len("data: "):])
    +            break
    +    assert run_completed_payload is not None, body
    +    messages = run_completed_payload.get("messages")
    +    assert isinstance(messages, list) and messages, run_completed_payload
    +
    +    # The colon-ended intermediate text that preceded the tool call must be present.
    +    contents = [m.get("content") for m in messages]
    +    assert "Let me search for that:" in contents
    +    assert "Here is the summary." in contents
    +    # No prior-turn user message should leak into the per-turn slice.
    +    assert all(m.get("role") in ("assistant", "tool") for m in messages)
    +    # The tool call is preserved alongside the intermediate text.
    +    assert any(m.get("tool_calls") for m in messages)
    +
    +
    +
     @pytest.mark.asyncio
     async def test_session_endpoints_require_auth_when_key_configured(auth_adapter):
         app = _create_session_app(auth_adapter)
    ```
  - `website/docs/user-guide/features/code-execution.md`

    ```diff
    @@ -219,6 +219,62 @@ terminal:
     
     See the [Security guide](/user-guide/security#environment-variable-passthrough) for full details.
     
    +### `HERMES_*` variables in the child
    +
    +The child process receives only a small, fixed set of operational `HERMES_*`
    +variables by exact name:
    +
    +- `HERMES_HOME`
    +- `HERMES_PROFILE`
    +- `HERMES_CONFIG`
    +- `HERMES_ENV`
    +
    +(plus `HERMES_RPC_DIR` / `HERMES_RPC_SOCKET` / `TZ` / `HOME`, which Hermes
    +injects explicitly so the RPC channel works).
    +
    +:::note Behavior change
    +Earlier versions passed **any** variable whose name began with `HERMES_`
    +through to the child. That broad prefix was removed for security hardening: it
    +could leak `HERMES_*`-named configuration that doesn't match a secret substring
    +(for example `HERMES_BASE_URL`, `HERMES_KANBAN_DB`, or a `HERMES_*_WEBHOOK`
    +endpoint) into arbitrary sandboxed code.
    +
    +If an `execute_code` script — or a repo/plugin module it imports at import time
    +— relied on a `HERMES_*` variable outside the four operational names above, it
    +will now find that variable **unset** in the child. The drop is intentional,
    +not a bug.
    +:::
    +
    +**Workaround — opt the variable back in explicitly.** Both routes pass the
    +variable through `execute_code` *and* `terminal` children, and neither weakens
    +the secret-stripping guarantee (Hermes-managed provider credentials can never
    +be re-allowed this way):
    +
    +1. **Per-machine, in `config.yaml`** — add the exact variable name to the
    +   passthrough allowlist:
    +
    +   ```yaml
    +   terminal:
    +     env_passthrough:
    +       - HERMES_KANBAN_DB
    +       - HERMES_BASE_URL
    +   ```
    +
    +2. **Per-skill, in the skill's frontmatter** — declare it so it is registered
    +   automatically whenever that skill is loaded:
    +
    +   ```yaml
    +   required_environment_variables:
    +     - HERMES_KANBAN_DB
    +   ```
    +
    +**Diagnosing it.** When the child drops one or more non-allowlisted `HERMES_*`
    +variables, Hermes emits a one-line `debug` log naming them and pointing at the
    +`env_passthrough` escape hatch. Run with debug logging (`hermes logs --level
    +DEBUG`, or check `~/.hermes/logs/agent.log`) and look for
    +`execute_code: dropped N non-allowlisted HERMES_* var(s)` if a script behaves
    +as though a `HERMES_*` variable is missing.
    +
     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.
     
     ## execute_code vs terminal
    ```

## Fix status

- Merged to main in PR #34804 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-05-29`.
