Restore formatted response streaming

Render root responses with safe Markdown and LaTeX throughout live updates, removing the raw monospace-to-formatted swap at completion. Update the stream contract and focused regressions while preserving the existing realtime synchronization optimizations.

Alessandro committed Aug 27, 2026 at 07:04 UTC 4f16db46504e1a66d2186c5e507704a8eec225e1
6 files changed +8 -13
extensions/python/response_stream/AGENTS.md
-2
@@ -13,8 +13,6 @@
13 - Keep streaming output synchronized with UI log items.
14 - Treat parsed stream snapshots as partial data; nested tool fields may be `None`
15 until their values arrive.
16 -- Live root responses carry `finished: false` until the response tool completes
17 - them so the WebUI can defer expensive Markdown rendering while content grows.
16 - Preserve include-alias replacement semantics where prompts/tools rely on them.
17 - Do not expose unmasked secrets in live responses.
18
extensions/python/response_stream/_20_live_response.py
-1
@@ -41,7 +41,6 @@ class LiveResponse(Extension):
41 type="response",
42 heading=f"icon://chat {self.agent.agent_name}: Responding",
43 id=shared_id,
44 - finished=False,
44 )
45 )
46
tests/test_plain_response_logging.py
+1 -2
@@ -130,7 +130,7 @@ def test_responses_plain_text_completion_does_not_replace_live_response_log():
130
131
132 @pytest.mark.asyncio
133 -async def test_live_response_renders_single_unfinished_action_wrapper():
133 +async def test_live_response_renders_single_action_wrapper():
134 log = Log()
135 generating = log.log(type="agent", id="msg-1")
136 loop_data = SimpleNamespace(params_temporary={"log_item_generating": generating})
@@ -152,7 +152,6 @@ async def test_live_response_renders_single_unfinished_action_wrapper():
152 assert response.type == "response"
153 assert response.content == "wrapper works"
154 assert response.id == "msg-1"
155 - assert response.kvps["finished"] is False
155
156
157 @pytest.mark.asyncio
tests/test_webui_message_ordering_static.py
+4 -4
@@ -19,12 +19,12 @@ def test_full_log_replays_replace_existing_message_dom():
19 assert "normalized.sort(" in messages_js
20
21
22 -def test_unfinished_root_responses_defer_markdown_rendering():
22 +def test_root_responses_render_markdown_during_streaming():
23 messages_js = read("webui", "js", "messages.js")
24
25 - assert "const renderMarkdown = kvps?.finished !== false;" in messages_js
26 - assert "markdown: renderMarkdown," in messages_js
27 - assert "latex: renderMarkdown," in messages_js
25 + assert "const renderMarkdown = kvps?.finished !== false;" not in messages_js
26 + assert "markdown: true," in messages_js
27 + assert "latex: true," in messages_js
28
29
30 def test_message_ordering_uses_a_bounded_tail_first_renderer_cache():
webui/js/AGENTS.md
+1 -1
@@ -54,7 +54,7 @@
54 - Do not expose secrets in localStorage, console logs, URLs, or WebSocket payloads.
55 - Full message snapshots that start at backend log `no` 0 must replace the current message DOM before rendering; incremental snapshots should keep patching existing messages.
56 - The state request builder advertises collection-delta support. An incremental snapshot may carry `contexts: null` and `tasks: null`; retain the current stores and skip sidebar selection/fallback reconciliation in that case. Extension hooks still receive the cached full collections so existing plugin contracts remain list-shaped.
57 -- Root responses with explicit `finished: false` render as escaped plain text while streaming, then switch to Markdown and LaTeX when finished; legacy responses without the flag remain formatted.
57 +- Root responses render with safe Markdown and LaTeX throughout live updates and completion.
58 - User sends render optimistically with their backend message ID; the matching log update must merge into that existing row rather than duplicate it.
59 - Incremental message rendering must update standard action handlers without remounting unchanged button nodes or removing extension-owned actions.
60 - Long histories stay cached as raw log data but render a contiguous tail-first DOM window. The initial base view contains one 60-entry page; after paging, the base window contains two aligned pages, retaining the adjacent page and discarding only the far page in either direction. Visible boundaries expand to whole logical process groups so a page never reconstructs a partial group; the unit classifier must include plugin-backed process steps such as `code_exe`, and oversized groups use their own 50-step incremental window. A capped process group rebuilds only when a newly added step advances that window; updates to an existing step patch it in place. Paging must preserve a visible anchor and occur at the scroll boundary after user intent, using passive loading indicators rather than count-bearing controls. Live entries and late content growth follow the tail until the reader deliberately moves away; historical window rebuilds must cancel pending auto-scroll effects, render in an off-screen staging history, and atomically swap fully laid-out content into the live scroller before restoring its anchor.
webui/js/messages.js
+2 -3
@@ -1994,7 +1994,6 @@ export function drawMessageResponse({
1994 // no container or valid process group, create new container
1995 if (!container) container = getOrCreateMessageContainer(id, "left");
1996
1997 - const renderMarkdown = kvps?.finished !== false;
1997 const messageDiv = _drawMessage({
1998 messageContainer: container,
1999 heading: undefined,
@@ -2002,8 +2001,8 @@ export function drawMessageResponse({
2001 kvps: undefined,
2002 messageClasses: [],
2003 contentClasses: [],
2005 - markdown: renderMarkdown,
2006 - latex: renderMarkdown,
2004 + markdown: true,
2005 + latex: true,
2006 mainClass: "message-agent-response",
2007 smoothStream: false, // smooth render disabled, not reliable yet !isMassRender(), // stream smoothly if not in mass render mode
2008 });