mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-25 19:20:16 +00:00
Merge pull request #2358 from nesquena/stage-365
Release v0.51.72 (stage-365) — 2-PR safe-lane batch
This commit is contained in:
@@ -2,6 +2,14 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [v0.51.72] — 2026-05-16 — Release AV (stage-365 — 2-PR safe-lane batch — #2354 recovered pending turn context fix + #2348 Thinking card interim-text echo suppression)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **PR #2354** by @franksong2702 (fixes #2353) — Stale stream recovery now keeps a recovered pending user turn in the model context (`context_messages`) as well as the visible transcript. Pre-fix, a server restart during an in-flight turn could restore the user's message in WebUI while omitting it from `context_messages`, so the next agent turn could forget a prompt that was visibly present just above it. The repair path now appends the recovered user turn to both surfaces with 8-message lookback dedup so already-checkpointed entries are not duplicated.
|
||||
|
||||
- **PR #2348** by @franksong2702 (fixes #2346) — 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. Tracks `_liveThinkingText` during the live stream to strip the visible echo from the live Thinking card display; applies the same suppression in the settled-transcript path so reload/session-switch sees the cleaned-up view too.
|
||||
|
||||
## [v0.51.71] — 2026-05-16 — Release AU (stage-364 — 3-PR batch — #2349 stale-stream cleanup non-touching + #2343 profiles vs workspaces help card + #2283 run-event journal replay [refs #1925 RFC slice 1] — with Opus-caught replay double-render fix)
|
||||
|
||||
### Added
|
||||
|
||||
+40
-11
@@ -203,6 +203,42 @@ def _active_stream_ids():
|
||||
return set(STREAMS.keys())
|
||||
|
||||
|
||||
def _append_recovered_turn_to_context(session, recovered: dict) -> None:
|
||||
context_messages = getattr(session, 'context_messages', None)
|
||||
if not isinstance(context_messages, list) or not context_messages:
|
||||
return
|
||||
recovered_text = " ".join(str(recovered.get('content') or '').split())
|
||||
if recovered_text:
|
||||
for existing in reversed(context_messages[-8:]):
|
||||
if not isinstance(existing, dict) or existing.get('role') != 'user':
|
||||
continue
|
||||
existing_text = " ".join(str(existing.get('content') or '').split())
|
||||
if existing_text == recovered_text:
|
||||
return
|
||||
context_entry = {k: v for k, v in recovered.items() if k != 'timestamp'}
|
||||
context_messages.append(context_entry)
|
||||
|
||||
|
||||
def _append_recovered_pending_turn(session, *, timestamp: int | None = None) -> dict | None:
|
||||
pending_text = str(session.pending_user_message or '')
|
||||
if not pending_text:
|
||||
return None
|
||||
recovered_ts = int(time.time())
|
||||
if isinstance(timestamp, (int, float)) and timestamp > 0:
|
||||
recovered_ts = int(timestamp)
|
||||
recovered: dict = {
|
||||
'role': 'user',
|
||||
'content': session.pending_user_message,
|
||||
'timestamp': recovered_ts,
|
||||
'_recovered': True,
|
||||
}
|
||||
if session.pending_attachments:
|
||||
recovered['attachments'] = list(session.pending_attachments)
|
||||
session.messages.append(recovered)
|
||||
_append_recovered_turn_to_context(session, recovered)
|
||||
return recovered
|
||||
|
||||
|
||||
def _is_streaming_session(active_stream_id, active_stream_ids):
|
||||
return bool(active_stream_id and active_stream_id in active_stream_ids)
|
||||
|
||||
@@ -695,15 +731,16 @@ def _apply_core_sync_or_error_marker(
|
||||
if isinstance(session.pending_started_at, (int, float)) and session.pending_started_at > 0:
|
||||
_recovered_ts = int(session.pending_started_at)
|
||||
if not _already_checkpointed:
|
||||
_append_recovered_pending_turn(session, timestamp=_recovered_ts)
|
||||
else:
|
||||
recovered = {
|
||||
'role': 'user',
|
||||
'content': session.pending_user_message,
|
||||
'timestamp': _recovered_ts,
|
||||
'_recovered': True,
|
||||
}
|
||||
if session.pending_attachments:
|
||||
recovered['attachments'] = list(session.pending_attachments)
|
||||
session.messages.append(recovered)
|
||||
_append_recovered_turn_to_context(session, recovered)
|
||||
session.active_stream_id = None
|
||||
session.pending_user_message = None
|
||||
session.pending_attachments = []
|
||||
@@ -752,15 +789,7 @@ def _apply_core_sync_or_error_marker(
|
||||
_recovered_ts = int(time.time())
|
||||
if isinstance(session.pending_started_at, (int, float)) and session.pending_started_at > 0:
|
||||
_recovered_ts = int(session.pending_started_at)
|
||||
recovered: dict = {
|
||||
'role': 'user',
|
||||
'content': session.pending_user_message,
|
||||
'timestamp': _recovered_ts,
|
||||
'_recovered': True,
|
||||
}
|
||||
if session.pending_attachments:
|
||||
recovered['attachments'] = list(session.pending_attachments)
|
||||
session.messages.append(recovered)
|
||||
_append_recovered_pending_turn(session, timestamp=_recovered_ts)
|
||||
session.active_stream_id = None
|
||||
session.pending_user_message = None
|
||||
session.pending_attachments = []
|
||||
|
||||
+21
-2
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -257,6 +257,44 @@ class TestDraftRecovery:
|
||||
f"got {user_msgs[0]['timestamp']}"
|
||||
)
|
||||
|
||||
def test_pending_message_recovered_into_context_messages(self, hermes_home, monkeypatch):
|
||||
"""A recovered pending prompt must remain visible to the next agent turn.
|
||||
|
||||
Sessions that have been auto-compressed feed context_messages to the
|
||||
model, not the full display transcript. If stale-stream repair appends
|
||||
the recovered user prompt only to messages, the user can see the prompt
|
||||
in WebUI but the next agent turn cannot.
|
||||
"""
|
||||
s = _make_session(
|
||||
messages=[{"role": "user", "content": "older visible turn"}],
|
||||
context_messages=[
|
||||
{"role": "user", "content": "older context turn"},
|
||||
{"role": "assistant", "content": "older context answer"},
|
||||
],
|
||||
)
|
||||
s.pending_user_message = "Clip this article https://example.com/post"
|
||||
s.active_stream_id = "stream_1"
|
||||
lock = config._get_session_agent_lock(s.session_id)
|
||||
|
||||
with lock:
|
||||
core_path = hermes_home / "sessions" / f"session_{s.session_id}.json"
|
||||
result = _apply_core_sync_or_error_marker(
|
||||
s, core_path, stream_id_for_recheck="stream_1",
|
||||
)
|
||||
|
||||
assert result is True
|
||||
assert any(
|
||||
m.get("role") == "user"
|
||||
and m.get("content") == "Clip this article https://example.com/post"
|
||||
and m.get("_recovered") is True
|
||||
for m in s.messages
|
||||
)
|
||||
assert any(
|
||||
m.get("role") == "user"
|
||||
and m.get("content") == "Clip this article https://example.com/post"
|
||||
for m in s.context_messages
|
||||
), "Recovered pending user turn must be included in model context."
|
||||
|
||||
def test_error_marker_no_preserved_as_draft(self, hermes_home, monkeypatch):
|
||||
"""Error marker text must NOT say 'preserved as a draft'."""
|
||||
s = _make_stale_session()
|
||||
|
||||
@@ -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, (
|
||||
|
||||
Reference in New Issue
Block a user