From 26fb71839e6b0a8a53876c8784ceb5a02eb92797 Mon Sep 17 00:00:00 2001 From: Frank Song Date: Tue, 26 May 2026 09:53:55 +0800 Subject: [PATCH 1/2] fix(chat): keep visible interim progress in timeline --- CHANGELOG.md | 4 ++ docs/UIUX-GUIDE.md | 5 +++ .../webui-run-state-consistency-contract.md | 3 ++ static/messages.js | 10 +++-- .../test_issue2713_streaming_segment_flush.py | 40 ++++++++++++++----- tests/test_ui_tool_call_cleanup.py | 16 +++++--- 6 files changed, 60 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5af702c4..323496ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ ## [Unreleased] +### 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.137] — 2026-05-25 — Release DI (stage-batch19 — 6-PR medium-risk batch) ### Added 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 7ce952dd..cef63e90 100644 --- a/static/messages.js +++ b/static/messages.js @@ -1352,9 +1352,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)); @@ -1512,8 +1513,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(); }); diff --git a/tests/test_issue2713_streaming_segment_flush.py b/tests/test_issue2713_streaming_segment_flush.py index 83259f9a..73c99ff0 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, ) @@ -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..25dfcde2 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,18 @@ 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." ) 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, ( From a9ea56040f294424afcb957ff47ed332b4956e69 Mon Sep 17 00:00:00 2001 From: Frank Song Date: Tue, 26 May 2026 11:32:34 +0800 Subject: [PATCH 2/2] Tighten interim progress activity boundaries --- static/messages.js | 2 +- static/ui.js | 2 +- tests/test_issue2713_streaming_segment_flush.py | 6 +++--- tests/test_ui_tool_call_cleanup.py | 4 ++++ 4 files changed, 9 insertions(+), 5 deletions(-) diff --git a/static/messages.js b/static/messages.js index cef63e90..0593e4c8 100644 --- a/static/messages.js +++ b/static/messages.js @@ -1567,7 +1567,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 81152feb..d59456d0 100644 --- a/static/ui.js +++ b/static/ui.js @@ -2297,7 +2297,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 73c99ff0..e93643e1 100644 --- a/tests/test_issue2713_streaming_segment_flush.py +++ b/tests/test_issue2713_streaming_segment_flush.py @@ -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 " diff --git a/tests/test_ui_tool_call_cleanup.py b/tests/test_ui_tool_call_cleanup.py index 25dfcde2..215d6713 100644 --- a/tests/test_ui_tool_call_cleanup.py +++ b/tests/test_ui_tool_call_cleanup.py @@ -331,6 +331,10 @@ class TestToolCallGroupingStatic: 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, ( "Tool starts should reset the next assistant text segment without closing the current Activity burst."