WebUI streaming messages lost on session switch; related API transcript change
Cluster 34703. Data as of
Nothing open on GitHub. Fix merged in #34804 on 29 May 2026. Check it covers your case.
The short answer
- Known?
- Yes. 3 related items grouped here, 2 issues already closed.
- Fix event
- A fix was merged to main in PR #34804. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
partially resolved, deciding PR #34804. That is separate from the fix event. - Still open
- 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.
Open issues0
Open PRs0
Fixes on record
-
#34804 fix(api_server): emit per-turn transcript on run.completed (#34703)
files:
gateway/platforms/api_server.py,tests/gateway/test_session_api.py,website/docs/user-guide/features/code-execution.mdShow the diff
-
gateway/platforms/api_server.py@@ -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, @@ -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: @@ -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@@ -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@@ -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
-
Closed issues · 2
How this cluster was grouped
-
Link evidence (
partly structural links): 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 item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the LabAt a glance
- Merged PRs
- #34804
- Closed issues
- 2
- Attempts
- 0 PRs closed without merging
- Fix link checked
- 29 May 2026