From 75788a951ae7c34dbbf8a85956a3184b5f1fd890 Mon Sep 17 00:00:00 2001 From: Edwin Date: Sun, 24 May 2026 00:11:43 -0700 Subject: [PATCH] fix(webui): aggregate headless assistant chunks --- crates/daemon/assets/index.html | 19 +++++++++++-- crates/e2e/tests/web_smoke.rs | 50 +++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/crates/daemon/assets/index.html b/crates/daemon/assets/index.html index b84e9297..d002b143 100644 --- a/crates/daemon/assets/index.html +++ b/crates/daemon/assets/index.html @@ -2888,10 +2888,25 @@ } function appendMessage(role, text) { + const normalizedRole = role || "assistant"; + if (normalizedRole === "assistant") { + const last = transcriptEl.lastElementChild; + if ( + last && + last.classList.contains("row") && + last.dataset.kind === "message" && + last.dataset.role === normalizedRole + ) { + last.querySelector(".bubble").textContent += text; + return; + } + } const row = document.createElement("div"); - row.className = `row role-${escape(role)}`; + row.className = `row role-${escape(normalizedRole)}`; + row.dataset.kind = "message"; + row.dataset.role = normalizedRole; row.innerHTML = ` -
${escape(role)}
+
${escape(normalizedRole)}
`; row.querySelector(".bubble").textContent = text; transcriptEl.appendChild(row); diff --git a/crates/e2e/tests/web_smoke.rs b/crates/e2e/tests/web_smoke.rs index 13e2e36c..1526e47f 100644 --- a/crates/e2e/tests/web_smoke.rs +++ b/crates/e2e/tests/web_smoke.rs @@ -800,6 +800,56 @@ async fn web_client_loads_and_websocket_connects() { "expected editor_state mirror content, got:\n{editor_text}" ); + // Headless zarvis streams assistant prose as many Message deltas. + // Chat-mode rendering should aggregate adjacent assistant deltas + // into one bubble, while a structured event boundary starts a new + // assistant bubble for later prose. + let chat_deltas: serde_json::Value = page + .evaluate( + r#" + (() => { + state.mode = 'chat'; + state.currentId = 's-chat-deltas'; + transcriptEl.innerHTML = ''; + renderEvent({ type: 'message', role: 'assistant', text: 'Hel' }); + renderEvent({ type: 'message', role: 'assistant', text: 'lo ' }); + renderEvent({ type: 'message', role: 'assistant', text: 'there' }); + renderEvent({ type: 'tool_use', tool: 'shell', args: { command: 'true' } }); + renderEvent({ type: 'message', role: 'assistant', text: 'Done' }); + renderEvent({ type: 'message', role: 'assistant', text: '.' }); + return Array.from(transcriptEl.children).map((row) => ({ + kind: row.dataset.kind || '', + role: row.dataset.role || '', + text: row.textContent.trim(), + })); + })() + "#, + ) + .await + .expect("evaluate chat delta aggregation") + .into_value::() + .expect("json array"); + let chat_rows = chat_deltas.as_array().cloned().unwrap_or_default(); + assert_eq!( + chat_rows.len(), + 3, + "expected assistant deltas to coalesce around tool boundary: {chat_deltas:?}" + ); + assert_eq!(chat_rows[0]["kind"].as_str(), Some("message")); + assert_eq!(chat_rows[0]["role"].as_str(), Some("assistant")); + assert!( + chat_rows[0]["text"] + .as_str() + .unwrap_or_default() + .contains("Hello there"), + "first assistant bubble should contain concatenated deltas: {chat_deltas:?}" + ); + let second_assistant = chat_rows[2]["text"].as_str().unwrap_or_default(); + assert!( + second_assistant.contains("Done."), + "second assistant bubble should aggregate after boundary: {chat_deltas:?}" + ); + // Tool-call rendering in terminal mode (issue #134): zarvis emits // tool calls as structured events, not PTY bytes, so the xterm view // showed nothing for them. `renderEvent` now synthesizes an inline