diff --git a/CHANGELOG.md b/CHANGELOG.md index 945cf609..78fd1a3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ - WebUI can now opt into a `webui_prefill_messages_script` / `HERMES_WEBUI_PREFILL_MESSAGES_SCRIPT` hook for dynamic browser-turn prefill context from local notes or recall systems. The script output is capped at 256 KiB, normalized to ephemeral prefill messages, and browser status still hides message bodies while redacting script errors. - Added a read-only WebUI/CLI session source switch in the chat sidebar when agent session sync is enabled. WebUI conversations stay in the default list, while imported CLI/agent sessions are surfaced under a separate `CLI sessions` tab with counts so large CLI histories do not clutter the normal conversation list. (Refs #2351) +### Fixed + +- Compact tool activity now keeps visible interim assistant progress in the live Session timeline instead of making that progress effectively collapsed-only inside Activity details. The interim assistant stream path creates and flushes a visible assistant segment before resetting for later tool/compression activity. + ## [v0.51.140] — 2026-05-26 — Release DL (stage-batch22 — 5-PR hold-bucket reassessment) ### Fixed diff --git a/docs/UIUX-GUIDE.md b/docs/UIUX-GUIDE.md index 78a728f3..63f66f9c 100644 --- a/docs/UIUX-GUIDE.md +++ b/docs/UIUX-GUIDE.md @@ -74,6 +74,11 @@ terse, for example `Activity: 4 tools`, and should not duplicate the thinking area, list every tool name in the summary, or add redundant trailing count badges. +Visible interim assistant progress is part of the live conversation timeline, +not raw debug detail. Compact Activity may collapse tool arguments, long tool +results, and low-level reasoning detail, but it must not make concise +user-visible progress text available only inside a collapsed disclosure. + The existing two-stage proposal in `docs/ui-ux/two-stage-proposal.html` records a compatible direction for long turns: live work can be grouped as a worklog, then settled history can collapse while the final answer reads as the calm diff --git a/docs/rfcs/webui-run-state-consistency-contract.md b/docs/rfcs/webui-run-state-consistency-contract.md index b3329a25..9fa365cd 100644 --- a/docs/rfcs/webui-run-state-consistency-contract.md +++ b/docs/rfcs/webui-run-state-consistency-contract.md @@ -82,6 +82,9 @@ while WebUI still has multiple overlapping state stores. browser-facing timeline renderer as live SSE events so recovery does not downgrade a structured Thinking / progress / tool / compression turn into a separate flattened presentation. + Visible interim assistant progress must remain visible timeline content; a + compact Activity disclosure may summarize adjacent tool/debug detail, but it + must not be the only place where the user can see emitted progress text. 6. **Compression is not current intent.** Automatic compression summaries and reference cards are recovery/handoff material. They must not be treated as a new user request, active-turn content, or the default visible explanation for diff --git a/static/messages.js b/static/messages.js index 82174640..86245d57 100644 --- a/static/messages.js +++ b/static/messages.js @@ -1379,9 +1379,10 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ }; step(); } - function _flushPendingSegmentRender(){ - if(!assistantBody||!_renderPending) return; - _cancelAnimationFramePendingStreamRender(); + function _flushPendingSegmentRender(options={}){ + const force=!!(options&&options.force); + if(!assistantBody||(!force&&!_renderPending)) return; + if(_renderPending) _cancelAnimationFramePendingStreamRender(); const displayText=segmentStart===0 ? _parseStreamState().displayText : _stripXmlToolCalls(assistantText.slice(segmentStart)); @@ -1539,8 +1540,9 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ if(typeof updateThinking==='function') updateThinking(_liveThinkingText()); else appendThinking(_liveThinkingText()); } - _flushPendingSegmentRender(); ensureAssistantRow(true); + _flushPendingSegmentRender({force:true}); + if(typeof closeCurrentLiveActivityGroup==='function') closeCurrentLiveActivityGroup(); _resetAssistantSegment(); _scheduleRender(); }); @@ -1592,7 +1594,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ // Reset the live assistant row reference so that any text tokens arriving // after this tool call create a NEW segment appended below the tool card, // rather than updating the old segment that sits above it in the DOM. - _flushPendingSegmentRender(); + _flushPendingSegmentRender({force:true}); _freshSegment=true; _smdEndParser(); _resetAssistantSegment(); diff --git a/static/ui.js b/static/ui.js index 6055ed71..8ce7745f 100644 --- a/static/ui.js +++ b/static/ui.js @@ -2449,7 +2449,7 @@ function _setActivityElapsedStartedAt(group){ } function _updateActiveActivityElapsedTimer(){ const group=_activityElapsedTimerGroup; - if(!group||!group.isConnected||group.getAttribute('data-live-tool-call-group')!=='1'){ + if(!group||!group.isConnected||group.getAttribute('data-live-tool-call-group')!=='1'||group.getAttribute('data-live-activity-current')!=='1'){ _clearActivityElapsedTimer(); return; } diff --git a/tests/test_issue2713_streaming_segment_flush.py b/tests/test_issue2713_streaming_segment_flush.py index 83259f9a..e93643e1 100644 --- a/tests/test_issue2713_streaming_segment_flush.py +++ b/tests/test_issue2713_streaming_segment_flush.py @@ -25,14 +25,14 @@ class TestFlushHelperExists: def test_flush_helper_declared(self): src = read("static/messages.js") - assert "function _flushPendingSegmentRender()" in src, ( + assert "function _flushPendingSegmentRender(options={})" in src, ( "_flushPendingSegmentRender helper must be declared in messages.js" ) def test_flush_helper_guards_on_assistant_body(self): src = read("static/messages.js") m = re.search( - r"function _flushPendingSegmentRender\(\)\{.*?\n \}", + r"function _flushPendingSegmentRender\(options=\{\}\)\{.*?\n \}", src, re.DOTALL, ) @@ -45,7 +45,7 @@ class TestFlushHelperExists: def test_flush_helper_guards_on_render_pending(self): src = read("static/messages.js") m = re.search( - r"function _flushPendingSegmentRender\(\)\{.*?\n \}", + r"function _flushPendingSegmentRender\(options=\{\}\)\{.*?\n \}", src, re.DOTALL, ) @@ -58,7 +58,7 @@ class TestFlushHelperExists: def test_flush_helper_cancels_pending_raf(self): src = read("static/messages.js") m = re.search( - r"function _flushPendingSegmentRender\(\)\{.*?\n \}", + r"function _flushPendingSegmentRender\(options=\{\}\)\{.*?\n \}", src, re.DOTALL, ) @@ -71,7 +71,7 @@ class TestFlushHelperExists: def test_flush_helper_uses_smd_write(self): src = read("static/messages.js") m = re.search( - r"function _flushPendingSegmentRender\(\)\{.*?\n \}", + r"function _flushPendingSegmentRender\(options=\{\}\)\{.*?\n \}", src, re.DOTALL, ) @@ -84,7 +84,7 @@ class TestFlushHelperExists: def test_flush_helper_has_render_md_fallback(self): src = read("static/messages.js") m = re.search( - r"function _flushPendingSegmentRender\(\)\{.*?\n \}", + r"function _flushPendingSegmentRender\(options=\{\}\)\{.*?\n \}", src, re.DOTALL, ) @@ -97,7 +97,7 @@ class TestFlushHelperExists: def test_flush_helper_has_esc_fallback(self): src = read("static/messages.js") m = re.search( - r"function _flushPendingSegmentRender\(\)\{.*?\n \}", + r"function _flushPendingSegmentRender\(options=\{\}\)\{.*?\n \}", src, re.DOTALL, ) @@ -137,15 +137,15 @@ class TestToolHandlerFlush: def test_tool_handler_calls_flush(self): src = read("static/messages.js") fn = _extract_handler(src, "tool") - assert "_flushPendingSegmentRender()" in fn, ( - "tool handler must call _flushPendingSegmentRender() before " + assert "_flushPendingSegmentRender({force:true})" in fn, ( + "tool handler must force _flushPendingSegmentRender() before " "_resetAssistantSegment()" ) def test_tool_handler_flush_before_reset(self): src = read("static/messages.js") fn = _extract_handler(src, "tool") - flush_pos = fn.index("_flushPendingSegmentRender()") + flush_pos = fn.index("_flushPendingSegmentRender({force:true})") reset_pos = fn.index("_resetAssistantSegment()") assert flush_pos < reset_pos, ( "_flushPendingSegmentRender must be called BEFORE " @@ -159,7 +159,7 @@ class TestInterimAssistantHandlerFlush: def test_interim_handler_calls_flush(self): src = read("static/messages.js") fn = _extract_handler(src, "interim_assistant") - assert "_flushPendingSegmentRender()" in fn, ( + assert "_flushPendingSegmentRender({force:true})" in fn, ( "interim_assistant handler must call _flushPendingSegmentRender() " "before _resetAssistantSegment()" ) @@ -169,10 +169,32 @@ class TestInterimAssistantHandlerFlush: the segment for new content (not the early alreadyStreamed branch).""" src = read("static/messages.js") fn = _extract_handler(src, "interim_assistant") - flush_pos = fn.index("_flushPendingSegmentRender()") + flush_pos = fn.index("_flushPendingSegmentRender({force:true})") # Find the _resetAssistantSegment call that comes AFTER the flush reset_pos = fn.index("_resetAssistantSegment()", flush_pos) assert flush_pos < reset_pos, ( "_flushPendingSegmentRender must be called BEFORE the final " "_resetAssistantSegment in the interim_assistant handler" ) + + def test_interim_handler_creates_visible_segment_before_forced_flush(self): + src = read("static/messages.js") + fn = _extract_handler(src, "interim_assistant") + ensure_pos = fn.index("ensureAssistantRow(true)") + flush_pos = fn.index("_flushPendingSegmentRender({force:true})") + reset_pos = fn.index("_resetAssistantSegment()", flush_pos) + assert ensure_pos < flush_pos < reset_pos, ( + "visible interim assistant progress must create a live assistant " + "segment, synchronously flush it, then reset for the next segment" + ) + + def test_interim_handler_closes_activity_after_visible_progress_boundary(self): + src = read("static/messages.js") + fn = _extract_handler(src, "interim_assistant") + flush_pos = fn.index("_flushPendingSegmentRender({force:true})") + close_pos = fn.index("closeCurrentLiveActivityGroup()", flush_pos) + reset_pos = fn.index("_resetAssistantSegment()", close_pos) + assert flush_pos < close_pos < reset_pos, ( + "visible interim assistant progress is timeline content; it must " + "close the current live Activity burst before later tools append" + ) diff --git a/tests/test_ui_tool_call_cleanup.py b/tests/test_ui_tool_call_cleanup.py index 860afa16..215d6713 100644 --- a/tests/test_ui_tool_call_cleanup.py +++ b/tests/test_ui_tool_call_cleanup.py @@ -285,7 +285,7 @@ class TestToolCallGroupingStatic: "The non-simplified path should preserve standalone settled thinking cards." ) - def test_live_visible_interim_text_keeps_single_activity_group(self): + def test_live_visible_interim_text_preserves_timeline_boundary(self): live_thinking_fn = _function_body(UI_JS, "appendThinking") live_tool_fn = _function_body(UI_JS, "appendLiveToolCard") helper = _function_body(UI_JS, "ensureActivityGroup") @@ -318,12 +318,22 @@ class TestToolCallGroupingStatic: "Compact live thinking should reactivate the latest existing Thinking card instead of stacking a new card after every tool boundary." ) reset_fn = _function_body(MESSAGES_JS, "_resetAssistantSegment") - assert "_closeCurrentLiveActivityGroup" not in MESSAGES_JS and "closeActivity" not in reset_fn, ( - "Assistant text resets should not carry a dead Activity-splitting path." + assert "function closeCurrentLiveActivityGroup()" in UI_JS, ( + "Visible interim assistant progress needs a shared helper to close the current Activity burst." ) interim_match = re.search(r"source\.addEventListener\('interim_assistant',e=>\{(.*?)\n\s*\}\);", MESSAGES_JS, re.S) - assert interim_match and "_resetAssistantSegment({closeActivity:true});" not in interim_match.group(1), ( - "Visible interim assistant text should not split Compact tool activity into multiple Activity rows." + assert interim_match and "closeCurrentLiveActivityGroup()" in interim_match.group(1), ( + "Visible interim assistant progress is timeline content and must split the current Activity burst." + ) + assert interim_match and "ensureAssistantRow(true)" in interim_match.group(1), ( + "Visible interim assistant progress must create a visible assistant timeline segment." + ) + assert interim_match and "_flushPendingSegmentRender({force:true})" in interim_match.group(1), ( + "Visible interim assistant progress must be synchronously rendered before the segment reset." + ) + timer_fn = _function_body(UI_JS, "_updateActiveActivityElapsedTimer") + assert "data-live-activity-current" in timer_fn, ( + "Elapsed timers should clear once an Activity group is no longer current." ) tool_start_segment = MESSAGES_JS.split("source.addEventListener('tool',e=>{", 1)[1].split("source.addEventListener('tool_complete'", 1)[0] assert "_resetAssistantSegment();" in tool_start_segment, (