From dd5f3ff9b5e3b2db392a77f664fa4ff25a828da4 Mon Sep 17 00:00:00 2001 From: Frank Song Date: Sat, 16 May 2026 10:52:33 +0800 Subject: [PATCH 1/2] Suppress interim text echoes in Thinking cards --- CHANGELOG.md | 2 ++ static/messages.js | 23 +++++++++++++++++++++-- static/ui.js | 13 +++++++++++++ tests/test_ui_tool_call_cleanup.py | 27 +++++++++++++++++++++++++++ 4 files changed, 63 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bf05c1b..0069a1b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,8 @@ ### Fixed +- **PR TBD** by @franksong2702 — Thinking cards now suppress exact snippets that are already shown as user-visible interim assistant text, avoiding duplicated progress lines when an agent emits the same sentence through both reasoning and interim-assistant callbacks. + - **PR #2322** by @Michaelyklam (refs #2271) — LAN Ollama models selected from endpoint-discovered `custom:-` / `custom::` picker entries now route through the configured `ollama` provider and base URL instead of surfacing a missing `CUSTOM_*_API_KEY` error. The picker still surfaces endpoint-discovered entries; the fix is to recognize them as UI routing hints matching the configured local-server base URL and resolve them via the actual `ollama` provider. - **PR #2326** by @Michaelyklam (closes #2232) — Legacy `hermes` CLI toolset alias is now normalized to `hermes-cli` + `hermes-api-server` when WebUI resolves CLI toolsets from shared Hermes config. Modern Hermes Agent exposes the composite under those two names; older configs that still contain the legacy `hermes` toolset name no longer surface as "unknown toolset" warnings. diff --git a/static/messages.js b/static/messages.js index 2e037943..fea980df 100644 --- a/static/messages.js +++ b/static/messages.js @@ -432,6 +432,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ let assistantText=''; let reasoningText=''; let liveReasoningText=''; + let visibleInterimSnippets=[]; let _latestGoalStatus=null; let _pendingGoalContinuation=null; let assistantRow=null; @@ -527,6 +528,19 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ function _closeSource(){ closeLiveStream(activeSid, streamId); } + function _stripVisibleAssistantEchoFromThinking(text, snippets){ + let out=String(text||''); + (Array.isArray(snippets)?snippets:[]).forEach(snippet=>{ + const visible=String(snippet||'').trim(); + if(visible.length<20) return; + out=out.split(visible).join(''); + }); + return out.trim(); + } + function _liveThinkingText(){ + const clean=_stripVisibleAssistantEchoFromThinking(liveReasoningText, visibleInterimSnippets); + return clean || 'Thinking…'; + } function syncInflightAssistantMessage(){ const inflight=INFLIGHT[activeSid]; if(!inflight) return; @@ -1207,9 +1221,14 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ return; } assistantText+=visible; + visibleInterimSnippets.push(visible); syncInflightAssistantMessage(); if(!S.session||S.session.session_id!==activeSid) return; const parsed=_parseStreamState(); + if(window._showThinking!==false){ + if(typeof updateThinking==='function') updateThinking(_liveThinkingText()); + else appendThinking(_liveThinkingText()); + } if(String((parsed&&parsed.displayText)||'').trim()||assistantRow) ensureAssistantRow(); _scheduleRender(); }); @@ -1226,8 +1245,8 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ // finalizeThinkingCard(). The old rAF-only path caused a race where // the thinking row was still a spinner when finalized. if(window._showThinking!==false){ - if(typeof updateThinking==='function') updateThinking(liveReasoningText||'Thinking…'); - else appendThinking(liveReasoningText); + if(typeof updateThinking==='function') updateThinking(_liveThinkingText()); + else appendThinking(_liveThinkingText()); } _scheduleRender(); }); diff --git a/static/ui.js b/static/ui.js index 949bf67f..0704cc33 100644 --- a/static/ui.js +++ b/static/ui.js @@ -2308,6 +2308,16 @@ function _sanitizeThinkingDisplayText(text){ return stripped.trim(); } +function _stripVisibleAssistantEchoFromThinking(thinkingText, visibleText){ + let out=String(thinkingText||''); + const visible=String(visibleText||''); + if(!out||!visible) return out.trim(); + visible.split(/\n{2,}/).map(s=>s.trim()).filter(s=>s.length>=20).forEach(snippet=>{ + out=out.split(snippet).join(''); + }); + return out.trim(); +} + function renderMd(raw){ let s=(raw||'').replace(/\r\n/g,'\n').replace(/\r/g,'\n'); // ── Entity decode: must run FIRST so > lines become > for the blockquote @@ -5402,6 +5412,9 @@ function renderMessages(options){ content='**Error:** No response received after context compression. Please retry.'; } const displayContent=isUser?_stripWorkspaceDisplayPrefix(content):content; + if(thinkingText&&!isUser){ + thinkingText=_stripVisibleAssistantEchoFromThinking(thinkingText, displayContent); + } const isLastAssistant=!isUser&&vi===renderVisWithIdx.length-1; const nextRendered=renderVisWithIdx[vi+1]; const isTurnFinalAssistant=!isUser&&(!nextRendered||!nextRendered.m||nextRendered.m.role!=='assistant'); diff --git a/tests/test_ui_tool_call_cleanup.py b/tests/test_ui_tool_call_cleanup.py index b350379b..2dc06ba9 100644 --- a/tests/test_ui_tool_call_cleanup.py +++ b/tests/test_ui_tool_call_cleanup.py @@ -11,6 +11,7 @@ REPO = pathlib.Path(__file__).parent.parent UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8") BOOT_JS = (REPO / "static" / "boot.js").read_text(encoding="utf-8") CSS = (REPO / "static" / "style.css").read_text(encoding="utf-8") +MESSAGES_JS = (REPO / "static" / "messages.js").read_text(encoding="utf-8") def _function_body(src: str, name: str) -> str: @@ -233,6 +234,32 @@ class TestToolCallGroupingStatic: "Readable progress must not reintroduce the noisy secondary tool-name list." ) + def test_live_thinking_suppresses_visible_interim_echoes(self): + interim_match = re.search(r"source\.addEventListener\('interim_assistant',e=>\{(.*?)\n\s*\}\);", MESSAGES_JS, re.S) + assert interim_match, "interim_assistant listener not found" + interim_fn = interim_match.group(1) + live_thinking_fn = _function_body(MESSAGES_JS, "_liveThinkingText") + + assert "visibleInterimSnippets.push(visible)" in interim_fn, ( + "Visible interim commentary should be remembered so the live Thinking card does not echo it." + ) + assert "_stripVisibleAssistantEchoFromThinking" in live_thinking_fn, ( + "Live Thinking text should suppress exact visible interim commentary echoes." + ) + + def test_settled_thinking_suppresses_visible_assistant_echoes(self): + render_fn = _function_body(UI_JS, "renderMessages") + helper = _function_body(UI_JS, "_stripVisibleAssistantEchoFromThinking") + assert "_stripVisibleAssistantEchoFromThinking(thinkingText, displayContent)" in render_fn, ( + "Settled Thinking cards should not repeat text already rendered as visible assistant content." + ) + assert "s.length>=20" in helper, ( + "Thinking echo suppression should ignore tiny snippets to avoid over-stripping reasoning." + ) + assert "out.split(snippet).join('')" in helper, ( + "Thinking echo suppression should remove exact visible assistant snippets from reasoning display." + ) + def test_tools_and_thinking_share_one_collapsed_activity_dropdown(self): ui_min = re.sub(r"\s+", "", UI_JS) assert "functionensureActivityGroup(" in ui_min, ( From d94320b4bf7529aa495b8055d55ed5902c58fa79 Mon Sep 17 00:00:00 2001 From: Frank Song Date: Sat, 16 May 2026 10:58:59 +0800 Subject: [PATCH 2/2] Avoid duplicate Thinking echo helper names --- static/messages.js | 4 ++-- tests/test_ui_tool_call_cleanup.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/static/messages.js b/static/messages.js index fea980df..d21d1144 100644 --- a/static/messages.js +++ b/static/messages.js @@ -528,7 +528,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ function _closeSource(){ closeLiveStream(activeSid, streamId); } - function _stripVisibleAssistantEchoFromThinking(text, snippets){ + function _stripLiveVisibleAssistantEchoFromThinking(text, snippets){ let out=String(text||''); (Array.isArray(snippets)?snippets:[]).forEach(snippet=>{ const visible=String(snippet||'').trim(); @@ -538,7 +538,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ return out.trim(); } function _liveThinkingText(){ - const clean=_stripVisibleAssistantEchoFromThinking(liveReasoningText, visibleInterimSnippets); + const clean=_stripLiveVisibleAssistantEchoFromThinking(liveReasoningText, visibleInterimSnippets); return clean || 'Thinking…'; } function syncInflightAssistantMessage(){ diff --git a/tests/test_ui_tool_call_cleanup.py b/tests/test_ui_tool_call_cleanup.py index 2dc06ba9..29fe6457 100644 --- a/tests/test_ui_tool_call_cleanup.py +++ b/tests/test_ui_tool_call_cleanup.py @@ -243,7 +243,7 @@ class TestToolCallGroupingStatic: assert "visibleInterimSnippets.push(visible)" in interim_fn, ( "Visible interim commentary should be remembered so the live Thinking card does not echo it." ) - assert "_stripVisibleAssistantEchoFromThinking" in live_thinking_fn, ( + assert "_stripLiveVisibleAssistantEchoFromThinking" in live_thinking_fn, ( "Live Thinking text should suppress exact visible interim commentary echoes." )