Stage 365: PR #2348 — Suppress interim text echoes in Thinking cards by @franksong2702

This commit is contained in:
Hermes Agent
2026-05-16 04:08:42 +00:00
4 changed files with 63 additions and 2 deletions
+2
View File
@@ -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:<host>-<port>` / `custom:<host>:<port>` 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.
+21 -2
View File
@@ -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 _stripLiveVisibleAssistantEchoFromThinking(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=_stripLiveVisibleAssistantEchoFromThinking(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();
});
+13
View File
@@ -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 &gt; 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');
+27
View File
@@ -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 "_stripLiveVisibleAssistantEchoFromThinking" 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, (