From 4f2190c500c2ba04d72eb740e275216c761d4caf Mon Sep 17 00:00:00 2001 From: Frank Song Date: Tue, 14 Jul 2026 08:25:56 +0800 Subject: [PATCH 1/7] fix(chat): preserve visible reasoning ownership --- static/messages.js | 28 +- static/ui.js | 3 +- tests/test_issue5720_reasoning_owner.py | 439 ++++++++++++++++++ tests/test_regressions.py | 2 +- ...t_stable_assistant_turn_anchor_registry.py | 2 +- tests/test_ui_tool_call_cleanup.py | 5 +- 6 files changed, 464 insertions(+), 15 deletions(-) create mode 100644 tests/test_issue5720_reasoning_owner.py diff --git a/static/messages.js b/static/messages.js index c3758371..7d5ff2fc 100644 --- a/static/messages.js +++ b/static/messages.js @@ -2380,8 +2380,11 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ burstId:_currentActivityBurstId, }; } - function _updateLiveThinkingCard(text){ - const opts=_liveThinkingPlacement(); + function _updateLiveThinkingCard(text, options){ + const opts={ + ..._liveThinkingPlacement(), + ...((options&&typeof options==='object')?options:{}), + }; if(typeof updateThinking==='function') updateThinking(text, opts); else appendThinking(text, opts); } @@ -2625,7 +2628,10 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ // in-closure SSE handlers). Explicit terminal-path calls above just expire it // sooner; this guarantees window._liveAnchorRegistries can't grow unbounded. _scheduleAnchorRegistryCleanup(600000); - function _applyToAnchor(sourceEventType, rawEventData, sseEvent){ + // Applying an event and painting it are separate outcomes. Reasoning uses the + // optional holder to decide whether a temporary visible fallback is needed. + function _applyToAnchor(sourceEventType, rawEventData, sseEvent, renderOutcome){ + if(renderOutcome&&typeof renderOutcome==='object') renderOutcome.rendered=false; if(!_anchorRegistry||!_anchorApi||typeof _anchorApi.applyAssistantTurnAnchorSourceEvent!=='function') return null; const raw=(rawEventData&&typeof rawEventData==='object')?rawEventData:{}; const eventId=(sseEvent&&sseEvent.lastEventId)||raw.event_id||raw.lastEventId||raw.last_event_id||''; @@ -2649,7 +2655,8 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ sourceEvent, {session_id:activeSid,stream_id:streamId} ); - _renderAnchorLiveScene(); + const rendered=_renderAnchorLiveScene(); + if(renderOutcome&&typeof renderOutcome==='object') renderOutcome.rendered=rendered; return result; }catch(err){ if(!_anchorShadowWarned&&typeof console!=='undefined'&&console.warn){ @@ -3795,9 +3802,9 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ status:options.sealed?'completed':'running', payload:{text:clean,activitySegmentSeq:segmentSeq,activityBurstId:_currentActivityBurstId}, }); - _renderAnchorLiveScene(); - return replaced; + return _renderAnchorLiveScene()?replaced:null; } + const renderOutcome={rendered:false}; _applyToAnchor('reasoning',{ text:clean, local_id:localId, @@ -3805,8 +3812,8 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ status:options.sealed?'completed':'running', activitySegmentSeq:segmentSeq, activityBurstId:_currentActivityBurstId, - },null); - return _findAnchorActivityEventByLocalId(localId,'reasoning'); + },null,renderOutcome); + return renderOutcome.rendered?_findAnchorActivityEventByLocalId(localId,'reasoning'):null; } function _compactVisibleEchoText(value){ return String(value||'').replace(/\s+/g,''); @@ -5340,16 +5347,17 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ source.addEventListener('reasoning',e=>{ if(_terminalStateReached||_streamFinalized) return; + if(!_ownsActiveStreamOrBackground()) return; const d=JSON.parse(e.data); const text=d.text||''; reasoningText += text; liveReasoningText += text; if(d.text&&S.session&&S.session.session_id===activeSid) _completeAutomaticCompressionOnLiveProgress(activeSid); syncInflightAssistantMessage(); - if(text&&S.session&&S.session.session_id===activeSid){ + if(text&&S.session&&S.session.session_id===activeSid&&S.activeStreamId===streamId){ const liveThinkingText=_liveThinkingText(); if(!_upsertAnchorReasoning(liveThinkingText)){ - _updateLiveThinkingCard(liveThinkingText); + _updateLiveThinkingCard(liveThinkingText,{anchorRenderFallback:true}); } } }); diff --git a/static/ui.js b/static/ui.js index 60c63997..3ff5d8d6 100644 --- a/static/ui.js +++ b/static/ui.js @@ -18435,9 +18435,10 @@ function appendThinking(text='', options){ // without this check they would pollute the new session's DOM. options=options||{}; const allowPendingPlaceholder=!!(options&&options.pending===true); + const anchorRenderFallback=!!(options&&options.anchorRenderFallback===true); if(typeof isFinalAnswerOnlyMode==='function'&&isFinalAnswerOnlyMode()) return; if(!S.session||(!S.activeStreamId&&!allowPendingPlaceholder)) return; - if(!allowPendingPlaceholder&&isLiveAnchorActivitySceneOwner(S.activeStreamId)){ + if(!allowPendingPlaceholder&&!anchorRenderFallback&&isLiveAnchorActivitySceneOwner(S.activeStreamId)){ _renderLiveAnchorActivitySceneForStream(S.activeStreamId, S.session.session_id); return; } diff --git a/tests/test_issue5720_reasoning_owner.py b/tests/test_issue5720_reasoning_owner.py new file mode 100644 index 00000000..94c5c62f --- /dev/null +++ b/tests/test_issue5720_reasoning_owner.py @@ -0,0 +1,439 @@ +"""Behavior regression for #5720 live reasoning presentation ownership.""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +from pathlib import Path + +import pytest + + +ROOT = Path(__file__).resolve().parents[1] +NODE = shutil.which("node") + + +def _run_reasoning_scene( + *, + fail_first_anchor_render: bool = False, + stale_active_stream: bool = False, + show_thinking: bool = True, +) -> dict: + assert NODE, "node is required for the #5720 browser-chain regression" + env = os.environ.copy() + env.setdefault("ISSUE5720_UI_JS", str(ROOT / "static" / "ui.js")) + env.setdefault("ISSUE5720_MESSAGES_JS", str(ROOT / "static" / "messages.js")) + env.setdefault( + "ISSUE5720_ANCHORS_JS", + str(ROOT / "static" / "assistant_turn_anchors.js"), + ) + if fail_first_anchor_render: + env["ISSUE5720_FAIL_FIRST_ANCHOR_RENDER"] = "1" + if stale_active_stream: + env["ISSUE5720_STALE_ACTIVE_STREAM"] = "1" + if not show_thinking: + env["ISSUE5720_SHOW_THINKING"] = "0" + result = subprocess.run( + [NODE, "-e", _NODE_SCENE], + cwd=ROOT, + env=env, + text=True, + capture_output=True, + timeout=10, + check=False, + ) + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +@pytest.mark.skipif(NODE is None, reason="node not on PATH") +def test_deepseek_reasoning_deltas_keep_one_live_row_owner(): + result = _run_reasoning_scene() + + assert result["first_text"] == "Plan" + assert result["second_text"] == "Plan step" + assert result["same_node"] is True + assert result["live_reasoning_rows"] == 1 + assert result["render_passes"] == [1, 2] + assert result["anchor_reasoning_events"] == 1 + assert result["anchor_reasoning_text"] == "Plan step" + assert result["inflight_reasoning_text"] == "Plan step" + + +@pytest.mark.skipif(NODE is None, reason="node not on PATH") +def test_reasoning_uses_visible_fallback_when_anchor_declines_first_paint(): + result = _run_reasoning_scene(fail_first_anchor_render=True) + + assert result["first_text"] is None + assert result["first_fallback_text"] == "Plan" + assert result["second_text"] == "Plan step" + assert result["live_reasoning_rows"] == 1 + assert result["fallback_rows_after_recovery"] == 0 + assert result["anchor_reasoning_events"] == 1 + assert result["anchor_reasoning_text"] == "Plan step" + assert result["inflight_reasoning_text"] == "Plan step" + + +@pytest.mark.skipif(NODE is None, reason="node not on PATH") +def test_stale_stream_reasoning_cannot_claim_the_active_turn(): + result = _run_reasoning_scene(stale_active_stream=True) + + assert result["anchor_render_attempts"] == 0 + assert result["live_reasoning_rows"] == 0 + assert result["fallback_rows_after_recovery"] == 0 + assert result["anchor_reasoning_events"] == 0 + assert result["inflight_reasoning_text"] == "" + + +@pytest.mark.skipif(NODE is None, reason="node not on PATH") +def test_reasoning_fallback_respects_show_thinking_false(): + result = _run_reasoning_scene( + fail_first_anchor_render=True, + show_thinking=False, + ) + + assert result["anchor_render_attempts"] == 0 + assert result["live_reasoning_rows"] == 0 + assert result["fallback_rows_after_recovery"] == 0 + assert result["anchor_reasoning_events"] == 0 + + +_NODE_SCENE = r""" +const fs = require('fs'); +const uiSrc = fs.readFileSync(process.env.ISSUE5720_UI_JS, 'utf8'); +const messagesSrc = fs.readFileSync(process.env.ISSUE5720_MESSAGES_JS, 'utf8'); +const anchorsSrc = fs.readFileSync(process.env.ISSUE5720_ANCHORS_JS, 'utf8'); + +function extractFunc(src, name){ + const start = src.indexOf('function ' + name); + if(start < 0){ + if(name==='isFinalAnswerOnlyMode'||name==='isHideAllActivityMode'){ + return `function ${name}(){ return false; }`; + } + if(name==='_anchorSceneRowTimestampSeconds'){ + return `function ${name}(){ return null; }`; + } + throw new Error(name + ' not found'); + } + const params = src.indexOf('(', start); + let depth = 0, close = -1; + for(let i=params; iself._classes.add(name)); }, + remove(...names){ names.forEach(name=>self._classes.delete(name)); }, + contains(name){ return self._classes.has(name); }, + toggle(name, force){ + if(force===true){ self._classes.add(name); return true; } + if(force===false){ self._classes.delete(name); return false; } + if(self._classes.has(name)){ self._classes.delete(name); return false; } + self._classes.add(name); return true; + }, + }; + } + get parentElement(){ return this.parentNode; } + get nextSibling(){ + if(!this.parentNode) return null; + const siblings=this.parentNode.children; + return siblings[siblings.indexOf(this)+1]||null; + } + get className(){ return Array.from(this._classes).join(' '); } + set className(value){ this._classes=new Set(String(value).split(/\s+/).filter(Boolean)); } + get textContent(){ + return this.children.length ? this.children.map(child=>child.textContent).join('') : this._text; + } + set textContent(value){ this._text=String(value??''); this.children=[]; } + get innerHTML(){ return this.textContent; } + set innerHTML(value){ this.textContent=value; } + setAttribute(name, value){ + const key=String(name), val=String(value); + this.attributes[key]=val; + if(key==='id') this.id=val; + if(key==='class') this.className=val; + if(key.startsWith('data-')){ + const dataKey=key.slice(5).replace(/-([a-z])/g,(_,c)=>c.toUpperCase()); + this.dataset[dataKey]=val; + } + } + getAttribute(name){ + return Object.prototype.hasOwnProperty.call(this.attributes,name)?this.attributes[name]:null; + } + getAttributeNames(){ return Object.keys(this.attributes); } + removeAttribute(name){ + delete this.attributes[name]; + if(name==='id') this.id=''; + if(name.startsWith('data-')){ + const dataKey=name.slice(5).replace(/-([a-z])/g,(_,c)=>c.toUpperCase()); + delete this.dataset[dataKey]; + } + } + appendChild(child){ + if(child.tagName==='#FRAGMENT'){ + child.children.slice().forEach(node=>this.appendChild(node)); + child.children=[]; + return child; + } + if(child.parentNode) child.remove(); + child.parentNode=this; + this.children.push(child); + return child; + } + insertBefore(child, ref){ + if(child.parentNode) child.remove(); + child.parentNode=this; + const idx=this.children.indexOf(ref); + if(idx<0) this.children.push(child); else this.children.splice(idx,0,child); + return child; + } + remove(){ + if(!this.parentNode) return; + const siblings=this.parentNode.children; + const idx=siblings.indexOf(this); + if(idx>=0) siblings.splice(idx,1); + this.parentNode=null; + } + matches(selector){ return matchesSelector(this,selector); } + querySelector(selector){ return this.querySelectorAll(selector)[0]||null; } + querySelectorAll(selector){ + const out=[]; + const walk=node=>node.children.forEach(child=>{ + if(matchesSelector(child,selector)) out.push(child); + walk(child); + }); + walk(this); + return out; + } + closest(selector){ + let node=this; + while(node){ if(matchesSelector(node,selector)) return node; node=node.parentNode; } + return null; + } +} + +function matchesSelector(el, selector){ + return String(selector).split(',').some(part=>matchesChain(el,part.trim())); +} +function matchesChain(el, selector){ + const parts=selector.split(/\s+/).filter(Boolean); + if(!parts.length||!matchesSimple(el,parts[parts.length-1])) return false; + let node=el.parentNode; + for(let i=parts.length-2;i>=0;i--){ + while(node&&!matchesSimple(node,parts[i])) node=node.parentNode; + if(!node) return false; + node=node.parentNode; + } + return true; +} +function matchesSimple(el, selector){ + selector=selector.replace(/^:scope\s*>\s*/,''); + const nots=[]; + selector=selector.replace(/:not\(([^)]+)\)/g,(_,inner)=>{nots.push(inner);return '';}); + if(nots.some(inner=>matchesSimple(el,inner))) return false; + const tag=selector.match(/^[A-Za-z][A-Za-z0-9-]*/); + if(tag&&el.tagName!==tag[0].toUpperCase()) return false; + const id=selector.match(/#([A-Za-z0-9_-]+)/); + if(id&&el.id!==id[1]) return false; + for(const match of selector.matchAll(/\.([A-Za-z0-9_-]+)/g)){ + if(!el.classList.contains(match[1])) return false; + } + for(const match of selector.matchAll(/\[([^\]=]+)(?:=["']?([^\]"']*)["']?)?\]/g)){ + const value=el.getAttribute(match[1]); + if(value===null) return false; + if(match[2]!==undefined&&String(value)!==String(match[2])) return false; + } + return true; +} + +global.window={ + _chatActivityDisplayMode:'transparent_stream', + _showThinking:process.env.ISSUE5720_SHOW_THINKING!=='0', + _simplifiedToolCalling:true, +}; +global.document={ + baseURI:'http://test.local/', + hidden:false, + createElement:tag=>new FakeElement(tag), + createTextNode:text=>{const node=new FakeElement('#text');node.textContent=text;return node;}, + createDocumentFragment:()=>new FakeElement('#fragment'), + querySelector:()=>null, +}; +global.location={href:'http://test.local/'}; +global.CSS={escape:value=>String(value)}; +global.requestAnimationFrame=fn=>{fn();return 1;}; +global.setTimeout=()=>1; +global.clearTimeout=()=>{}; + +const emptyState=new FakeElement('div'); +const msgInner=new FakeElement('div'); +const messages=new FakeElement('div'); +const turn=new FakeElement('div'); +turn.id='liveAssistantTurn'; +turn.className='assistant-turn'; +msgInner.appendChild(turn); +const byId={emptyState,msgInner,messages,liveAssistantTurn:turn}; +global.$=id=>byId[id]||null; +global._createAssistantTurn=()=>turn; +global._assistantTurnBlocks=()=>turn; +global._captureMessageScrollSnapshot=()=>({scrollHeight:1000}); +global._prepareLiveAnchorScrollRebuildGuard=()=>({readerAwayFromBottom:false,release:null}); +global._restoreMessageScrollSnapshotSameFrame=()=>{}; +global.scrollIfPinned=()=>{}; +global._moveLiveRunStatusToTurnEnd=()=>{}; +global._messageUserUnpinned=false; +global._syncTransparentEventControls=()=>{}; +global._thinkingActivityNode=text=>{ + const row=new FakeElement('div'); + row.className='agent-activity-thinking transparent-thinking-event'; + const card=new FakeElement('div'); + card.className='thinking-card open'; + const header=new FakeElement('div'); + header.className='thinking-card-header'; + const preview=new FakeElement('span'); + preview.className='transparent-event-thinking-preview'; + preview.textContent=text; + const body=new FakeElement('div'); + body.className='thinking-card-body'; + const pre=new FakeElement('pre'); + pre.textContent=text; + header.appendChild(preview); + body.appendChild(pre); + card.appendChild(header); + card.appendChild(body); + row.appendChild(card); + return row; +}; +global._decorateTransparentEventRow=(row,opts)=>{ + row.classList.add('transparent-event-row'); + row.setAttribute('data-transparent-event-row','1'); + row.setAttribute('data-event-type',String(opts&&opts.type||'activity')); + const preview=row.querySelector('.transparent-event-thinking-preview'); + if(preview) preview.textContent=String(opts&&opts.preview||opts&&opts.text||''); + return row; +}; +global._rehydrateTransparentLiveRow=()=>{}; +global._sanitizeThinkingDisplayText=value=>String(value||'').trim(); +global._firstValidTimestampSeconds=()=>null; + +eval(anchorsSrc); +for(const name of [ + 'chatActivityMode','isTransparentStream','isFinalAnswerOnlyMode','isCompactWorklogMode','isSimplifiedToolCalling', + '_anchorSceneIsSettledSuccessfulCompression','_anchorSceneRowsForRendering', + '_anchorSceneRowTimestampSeconds','_anchorSceneTransparentNodeForRow', + '_transparentLiveRowKey','_transparentLiveRowsCompatible', + '_transparentLiveRowAttributePairs','_transparentLiveRowInteractiveState', + '_refreshTransparentThinkingLiveRow','_refreshTransparentLiveRow', + 'isLiveAnchorActivitySceneOwner','_projectLiveAnchorActivitySceneForStream', + '_renderLiveAnchorActivitySceneTransparent','renderLiveAnchorActivityScene', + '_renderLiveAnchorActivitySceneForStream','appendThinking','updateThinking', +]) eval(extractFunc(uiSrc,name)); + +let transparentRenderPasses=0; +const realTransparentRender=_renderLiveAnchorActivitySceneTransparent; +_renderLiveAnchorActivitySceneTransparent=function(...args){ + transparentRenderPasses+=1; + return realTransparentRender(...args); +}; +const failFirstAnchorRender=process.env.ISSUE5720_FAIL_FIRST_ANCHOR_RENDER==='1'; +let anchorRenderAttempts=0; +window._renderLiveAnchorActivitySceneForStream=function(...args){ + anchorRenderAttempts+=1; + if(failFirstAnchorRender&&anchorRenderAttempts===1) return false; + return _renderLiveAnchorActivitySceneForStream(...args); +}; +window.isLiveAnchorActivitySceneOwner=isLiveAnchorActivitySceneOwner; + +const S=global.S={ + session:{session_id:'sid-1',pending_started_at:1}, + messages:[{role:'user',content:'question'}], + activeStreamId:'stream-1', +}; +const INFLIGHT=global.INFLIGHT={}; +const LIVE_STREAMS=global.LIVE_STREAMS={}; +const _STREAM_WAS_HIDDEN=global._STREAM_WAS_HIDDEN={}; +const _STREAM_NOTIFICATION_BACKGROUND=global._STREAM_NOTIFICATION_BACKGROUND={}; +const _desktopBackgroundedForNotifications=false; +global._bindStreamHiddenTracker=()=>{}; +global.closeOtherLiveStreams=()=>{}; +global.closeLiveStream=()=>{}; +global.resetTurnWorkspaceMutations=()=>{}; +global._resetStreamScrollFollow=()=>{}; +global._suspendSessionStreamForLiveChat=()=>{}; +global.ensureLiveWorklogShell=()=>null; +global._extractInlineThinkingFromContent=(content,reasoning)=>({content:String(content||''),reasoning:String(reasoning||'')}); + +class FakeEventSource { + static instances=[]; + static OPEN=1; + static CONNECTING=0; + constructor(){ this.listeners=Object.create(null);this.readyState=1;FakeEventSource.instances.push(this); } + addEventListener(name,fn){ (this.listeners[name]||(this.listeners[name]=[])).push(fn); } + emit(name,data){ for(const fn of this.listeners[name]||[]) fn({data:JSON.stringify(data),lastEventId:''}); } + close(){ this.readyState=2; } +} +global.EventSource=FakeEventSource; + +const attachStart=messagesSrc.indexOf('function attachLiveStream('); +const attachEnd=messagesSrc.indexOf('\nfunction transcript(){',attachStart); +if(attachStart<0||attachEnd<0) throw new Error('attachLiveStream source boundary not found'); +eval(messagesSrc.slice(attachStart,attachEnd)); +attachLiveStream('sid-1','stream-1'); +const source=FakeEventSource.instances[0]; +if(!source) throw new Error('attachLiveStream did not create EventSource'); +if(process.env.ISSUE5720_STALE_ACTIVE_STREAM==='1') S.activeStreamId='stream-new'; + +source.emit('reasoning',{text:'Plan '}); +const first=turn.querySelector('.transparent-event-row[data-event-type="thinking"][data-anchor-scene-row="1"]'); +const firstText=first&&first.querySelector('pre')&&first.querySelector('pre').textContent; +const firstFallback=turn.querySelector('.agent-activity-thinking[data-live-thinking="1"]'); +const firstFallbackText=firstFallback&&firstFallback.querySelector('pre')&&firstFallback.querySelector('pre').textContent; +const passAfterFirst=transparentRenderPasses; +source.emit('reasoning',{text:'step'}); +const second=turn.querySelector('.transparent-event-row[data-event-type="thinking"][data-anchor-scene-row="1"]'); +const secondText=second&&second.querySelector('pre')&&second.querySelector('pre').textContent; +const registry=window._liveAnchorRegistries&&window._liveAnchorRegistries.get('stream-1'); +const anchorReasoningEvents=registry&®istry.anchor&&Array.isArray(registry.anchor.activity_events) + ? registry.anchor.activity_events.filter(event=>event&&event.source_event_type==='reasoning') + : []; + +process.stdout.write(JSON.stringify({ + first_text:firstText, + first_fallback_text:firstFallbackText, + second_text:secondText, + same_node:first===second, + live_reasoning_rows:turn.querySelectorAll('.transparent-event-row[data-event-type="thinking"][data-anchor-scene-row="1"]').length, + fallback_rows_after_recovery:turn.querySelectorAll('.agent-activity-thinking[data-live-thinking="1"]').length, + render_passes:[passAfterFirst,transparentRenderPasses], + anchor_render_attempts:anchorRenderAttempts, + anchor_reasoning_events:anchorReasoningEvents.length, + anchor_reasoning_text:anchorReasoningEvents.length + ? String(anchorReasoningEvents[anchorReasoningEvents.length-1].payload&&anchorReasoningEvents[anchorReasoningEvents.length-1].payload.text||'') + : null, + inflight_reasoning_text:String(INFLIGHT['sid-1']&&INFLIGHT['sid-1'].lastReasoningText||''), +})); +""" diff --git a/tests/test_regressions.py b/tests/test_regressions.py index 2ca8bc9b..e8ef379c 100644 --- a/tests/test_regressions.py +++ b/tests/test_regressions.py @@ -940,7 +940,7 @@ def test_messages_js_supports_live_reasoning_and_tool_completion(cleanup_test_se "live reasoning SSE events must compute the current segment's Worklog Thinking Card text once" assert "if(!_upsertAnchorReasoning(liveThinkingText))" in src, \ "live reasoning SSE events must prefer the anchor renderer before falling back" - assert "_updateLiveThinkingCard(liveThinkingText)" in src, \ + assert "_updateLiveThinkingCard(liveThinkingText,{anchorRenderFallback:true})" in src, \ "live reasoning SSE events must keep the current segment's Worklog Thinking Card as fallback" assert "source.addEventListener('tool_complete'" in src or 'source.addEventListener("tool_complete"' in src, \ "messages.js must listen for live tool completion SSE events" diff --git a/tests/test_stable_assistant_turn_anchor_registry.py b/tests/test_stable_assistant_turn_anchor_registry.py index feb6d9a5..91f145a8 100644 --- a/tests/test_stable_assistant_turn_anchor_registry.py +++ b/tests/test_stable_assistant_turn_anchor_registry.py @@ -1308,7 +1308,7 @@ def test_slice6_live_shadow_feed_wires_anchor_scene_for_visible_order_handoff(): assert "_applyToAnchor" not in reasoning_body assert "const liveThinkingText=_liveThinkingText();" in reasoning_body assert "if(!_upsertAnchorReasoning(liveThinkingText))" in reasoning_body - assert "_updateLiveThinkingCard(liveThinkingText)" in reasoning_body + assert "_updateLiveThinkingCard(liveThinkingText,{anchorRenderFallback:true})" in reasoning_body assert "function _flushReasoningToAnchor()" in src assert "_upsertAnchorReasoning(reasoningText" in src assert "`live-reasoning:${streamId}:final`" in src diff --git a/tests/test_ui_tool_call_cleanup.py b/tests/test_ui_tool_call_cleanup.py index b69d8cef..a2e00258 100644 --- a/tests/test_ui_tool_call_cleanup.py +++ b/tests/test_ui_tool_call_cleanup.py @@ -689,15 +689,16 @@ class TestToolCallGroupingStatic: assert "_upsertAnchorReasoning(liveThinkingText)" in reasoning_fn, ( "Anchor reasoning must remain the primary renderer path." ) + fallback_call = "_updateLiveThinkingCard(liveThinkingText,{anchorRenderFallback:true})" assert reasoning_fn.index("_upsertAnchorReasoning(liveThinkingText)") < reasoning_fn.index( - "_updateLiveThinkingCard(liveThinkingText)" + fallback_call ), ( "The legacy thinking card should only run after anchor upsert fails." ) assert "if(!_upsertAnchorReasoning(liveThinkingText)){" in reasoning_fn, ( "The legacy thinking card should be a falsy-anchor fallback." ) - assert reasoning_fn.count("_updateLiveThinkingCard(liveThinkingText)") == 1, ( + assert reasoning_fn.count(fallback_call) == 1, ( "Reasoning SSE updates should call the live thinking card only in fallback." ) assert "_updateLiveThinkingCard(" in render_live_thinking_fn, ( From 54f43b37fa32d8cf50cdb94fe551e4565b01470c Mon Sep 17 00:00:00 2001 From: Frank Song Date: Tue, 14 Jul 2026 08:52:54 +0800 Subject: [PATCH 2/7] fix(chat): isolate reasoning fallback by session --- static/messages.js | 2 +- static/ui.js | 10 ++++++++++ tests/test_issue5720_reasoning_owner.py | 18 ++++++++++++++++++ tests/test_regressions.py | 2 +- ...st_stable_assistant_turn_anchor_registry.py | 2 +- tests/test_ui_tool_call_cleanup.py | 2 +- 6 files changed, 32 insertions(+), 4 deletions(-) diff --git a/static/messages.js b/static/messages.js index 7d5ff2fc..6efbe647 100644 --- a/static/messages.js +++ b/static/messages.js @@ -5357,7 +5357,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ if(text&&S.session&&S.session.session_id===activeSid&&S.activeStreamId===streamId){ const liveThinkingText=_liveThinkingText(); if(!_upsertAnchorReasoning(liveThinkingText)){ - _updateLiveThinkingCard(liveThinkingText,{anchorRenderFallback:true}); + _updateLiveThinkingCard(liveThinkingText,{anchorRenderFallback:true,sessionId:activeSid,streamId}); } } }); diff --git a/static/ui.js b/static/ui.js index 3ff5d8d6..8386c5e8 100644 --- a/static/ui.js +++ b/static/ui.js @@ -12360,6 +12360,10 @@ function renderLiveAnchorActivityScene(streamId, scene, opts){ const knownMode=(m)=>m==='compact_worklog'||m==='transparent_stream'||m==='hide_all_activity'; const sceneMode=knownMode(activeMode)?activeMode:(knownMode(requestedMode)?requestedMode:activeMode); if(sceneMode==='hide_all_activity') return false; + const existingTurn=$('liveAssistantTurn'); + const requestedSessionId=String(opts.sessionId||''); + const existingTurnSessionId=String(existingTurn&&existingTurn.dataset&&existingTurn.dataset.sessionId||''); + if(existingTurn&&requestedSessionId&&existingTurnSessionId&&existingTurnSessionId!==requestedSessionId) return false; if(sceneMode==='transparent_stream'){ return _renderLiveAnchorActivitySceneTransparent(streamId,scene,opts); } @@ -18438,6 +18442,12 @@ function appendThinking(text='', options){ const anchorRenderFallback=!!(options&&options.anchorRenderFallback===true); if(typeof isFinalAnswerOnlyMode==='function'&&isFinalAnswerOnlyMode()) return; if(!S.session||(!S.activeStreamId&&!allowPendingPlaceholder)) return; + if(options.sessionId&&String(options.sessionId)!==String(S.session.session_id||'')) return; + if(options.streamId&&String(options.streamId)!==String(S.activeStreamId||'')) return; + const existingLiveTurn=$('liveAssistantTurn'); + if(anchorRenderFallback&&existingLiveTurn&&existingLiveTurn.dataset&& + existingLiveTurn.dataset.sessionId&& + String(existingLiveTurn.dataset.sessionId)!==String(S.session.session_id||'')) return; if(!allowPendingPlaceholder&&!anchorRenderFallback&&isLiveAnchorActivitySceneOwner(S.activeStreamId)){ _renderLiveAnchorActivitySceneForStream(S.activeStreamId, S.session.session_id); return; diff --git a/tests/test_issue5720_reasoning_owner.py b/tests/test_issue5720_reasoning_owner.py index 94c5c62f..f17f9b40 100644 --- a/tests/test_issue5720_reasoning_owner.py +++ b/tests/test_issue5720_reasoning_owner.py @@ -19,6 +19,7 @@ def _run_reasoning_scene( *, fail_first_anchor_render: bool = False, stale_active_stream: bool = False, + stale_live_turn: bool = False, show_thinking: bool = True, ) -> dict: assert NODE, "node is required for the #5720 browser-chain regression" @@ -33,6 +34,8 @@ def _run_reasoning_scene( env["ISSUE5720_FAIL_FIRST_ANCHOR_RENDER"] = "1" if stale_active_stream: env["ISSUE5720_STALE_ACTIVE_STREAM"] = "1" + if stale_live_turn: + env["ISSUE5720_STALE_LIVE_TURN"] = "1" if not show_thinking: env["ISSUE5720_SHOW_THINKING"] = "0" result = subprocess.run( @@ -100,6 +103,20 @@ def test_reasoning_fallback_respects_show_thinking_false(): assert result["anchor_reasoning_events"] == 0 +@pytest.mark.skipif(NODE is None, reason="node not on PATH") +def test_reasoning_fallback_does_not_reuse_another_sessions_live_turn(): + result = _run_reasoning_scene( + fail_first_anchor_render=True, + stale_live_turn=True, + ) + + assert result["live_reasoning_rows"] == 0 + assert result["fallback_rows_after_recovery"] == 0 + assert result["anchor_reasoning_events"] == 1 + assert result["anchor_reasoning_text"] == "Plan step" + assert result["inflight_reasoning_text"] == "Plan step" + + _NODE_SCENE = r""" const fs = require('fs'); const uiSrc = fs.readFileSync(process.env.ISSUE5720_UI_JS, 'utf8'); @@ -295,6 +312,7 @@ const messages=new FakeElement('div'); const turn=new FakeElement('div'); turn.id='liveAssistantTurn'; turn.className='assistant-turn'; +turn.dataset.sessionId=process.env.ISSUE5720_STALE_LIVE_TURN==='1'?'sid-old':'sid-1'; msgInner.appendChild(turn); const byId={emptyState,msgInner,messages,liveAssistantTurn:turn}; global.$=id=>byId[id]||null; diff --git a/tests/test_regressions.py b/tests/test_regressions.py index e8ef379c..b28be9a3 100644 --- a/tests/test_regressions.py +++ b/tests/test_regressions.py @@ -940,7 +940,7 @@ def test_messages_js_supports_live_reasoning_and_tool_completion(cleanup_test_se "live reasoning SSE events must compute the current segment's Worklog Thinking Card text once" assert "if(!_upsertAnchorReasoning(liveThinkingText))" in src, \ "live reasoning SSE events must prefer the anchor renderer before falling back" - assert "_updateLiveThinkingCard(liveThinkingText,{anchorRenderFallback:true})" in src, \ + assert "_updateLiveThinkingCard(liveThinkingText,{anchorRenderFallback:true,sessionId:activeSid,streamId})" in src, \ "live reasoning SSE events must keep the current segment's Worklog Thinking Card as fallback" assert "source.addEventListener('tool_complete'" in src or 'source.addEventListener("tool_complete"' in src, \ "messages.js must listen for live tool completion SSE events" diff --git a/tests/test_stable_assistant_turn_anchor_registry.py b/tests/test_stable_assistant_turn_anchor_registry.py index 91f145a8..bc03eb05 100644 --- a/tests/test_stable_assistant_turn_anchor_registry.py +++ b/tests/test_stable_assistant_turn_anchor_registry.py @@ -1308,7 +1308,7 @@ def test_slice6_live_shadow_feed_wires_anchor_scene_for_visible_order_handoff(): assert "_applyToAnchor" not in reasoning_body assert "const liveThinkingText=_liveThinkingText();" in reasoning_body assert "if(!_upsertAnchorReasoning(liveThinkingText))" in reasoning_body - assert "_updateLiveThinkingCard(liveThinkingText,{anchorRenderFallback:true})" in reasoning_body + assert "_updateLiveThinkingCard(liveThinkingText,{anchorRenderFallback:true,sessionId:activeSid,streamId})" in reasoning_body assert "function _flushReasoningToAnchor()" in src assert "_upsertAnchorReasoning(reasoningText" in src assert "`live-reasoning:${streamId}:final`" in src diff --git a/tests/test_ui_tool_call_cleanup.py b/tests/test_ui_tool_call_cleanup.py index a2e00258..00951191 100644 --- a/tests/test_ui_tool_call_cleanup.py +++ b/tests/test_ui_tool_call_cleanup.py @@ -689,7 +689,7 @@ class TestToolCallGroupingStatic: assert "_upsertAnchorReasoning(liveThinkingText)" in reasoning_fn, ( "Anchor reasoning must remain the primary renderer path." ) - fallback_call = "_updateLiveThinkingCard(liveThinkingText,{anchorRenderFallback:true})" + fallback_call = "_updateLiveThinkingCard(liveThinkingText,{anchorRenderFallback:true,sessionId:activeSid,streamId})" assert reasoning_fn.index("_upsertAnchorReasoning(liveThinkingText)") < reasoning_fn.index( fallback_call ), ( From e79f3cf53e0f899c52b30fb923929ad3ef9a3336 Mon Sep 17 00:00:00 2001 From: Frank Song Date: Tue, 14 Jul 2026 14:10:41 +0800 Subject: [PATCH 3/7] fix(chat): recover reasoning after stale live turn --- static/ui.js | 24 ++++++++++++++++++++++-- tests/test_issue5720_reasoning_owner.py | 21 +++++++++++++++++++-- 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/static/ui.js b/static/ui.js index 8386c5e8..3be3ce3f 100644 --- a/static/ui.js +++ b/static/ui.js @@ -12344,6 +12344,22 @@ function _prepareLiveAnchorScrollRebuildGuard(scrollSnapshot){ }, }; } +function _resetMismatchedLiveAssistantTurnForSession(turn, sessionId){ + const sid=String(sessionId||''); + if(!turn||!sid||!turn.dataset) return false; + const existingSid=String(turn.dataset.sessionId||''); + if(!existingSid||existingSid===sid) return false; + const blocks=typeof _assistantTurnBlocks==='function' ? _assistantTurnBlocks(turn) : turn; + if(blocks){ + try{ + blocks.innerHTML=''; + }catch(_){ + while(blocks.firstChild) blocks.removeChild(blocks.firstChild); + } + } + turn.dataset.sessionId=sid; + return true; +} function renderLiveAnchorActivityScene(streamId, scene, opts){ opts=opts||{}; const requestedMode=opts.mode; @@ -12363,7 +12379,9 @@ function renderLiveAnchorActivityScene(streamId, scene, opts){ const existingTurn=$('liveAssistantTurn'); const requestedSessionId=String(opts.sessionId||''); const existingTurnSessionId=String(existingTurn&&existingTurn.dataset&&existingTurn.dataset.sessionId||''); - if(existingTurn&&requestedSessionId&&existingTurnSessionId&&existingTurnSessionId!==requestedSessionId) return false; + if(existingTurn&&requestedSessionId&&existingTurnSessionId&&existingTurnSessionId!==requestedSessionId){ + if(!_resetMismatchedLiveAssistantTurnForSession(existingTurn, requestedSessionId)) return false; + } if(sceneMode==='transparent_stream'){ return _renderLiveAnchorActivitySceneTransparent(streamId,scene,opts); } @@ -18447,7 +18465,9 @@ function appendThinking(text='', options){ const existingLiveTurn=$('liveAssistantTurn'); if(anchorRenderFallback&&existingLiveTurn&&existingLiveTurn.dataset&& existingLiveTurn.dataset.sessionId&& - String(existingLiveTurn.dataset.sessionId)!==String(S.session.session_id||'')) return; + String(existingLiveTurn.dataset.sessionId)!==String(S.session.session_id||'')){ + if(!_resetMismatchedLiveAssistantTurnForSession(existingLiveTurn, S.session.session_id)) return; + } if(!allowPendingPlaceholder&&!anchorRenderFallback&&isLiveAnchorActivitySceneOwner(S.activeStreamId)){ _renderLiveAnchorActivitySceneForStream(S.activeStreamId, S.session.session_id); return; diff --git a/tests/test_issue5720_reasoning_owner.py b/tests/test_issue5720_reasoning_owner.py index f17f9b40..f67b993e 100644 --- a/tests/test_issue5720_reasoning_owner.py +++ b/tests/test_issue5720_reasoning_owner.py @@ -104,13 +104,29 @@ def test_reasoning_fallback_respects_show_thinking_false(): @pytest.mark.skipif(NODE is None, reason="node not on PATH") -def test_reasoning_fallback_does_not_reuse_another_sessions_live_turn(): +def test_reasoning_rebuilds_when_another_sessions_live_turn_blocks_anchor(): + result = _run_reasoning_scene(stale_live_turn=True) + + assert result["first_text"] == "Plan" + assert result["second_text"] == "Plan step" + assert result["live_reasoning_rows"] == 1 + assert result["fallback_rows_after_recovery"] == 0 + assert result["anchor_reasoning_events"] == 1 + assert result["anchor_reasoning_text"] == "Plan step" + assert result["inflight_reasoning_text"] == "Plan step" + + +@pytest.mark.skipif(NODE is None, reason="node not on PATH") +def test_reasoning_fallback_rebuilds_from_another_sessions_live_turn(): result = _run_reasoning_scene( fail_first_anchor_render=True, stale_live_turn=True, ) - assert result["live_reasoning_rows"] == 0 + assert result["first_text"] is None + assert result["first_fallback_text"] == "Plan" + assert result["second_text"] == "Plan step" + assert result["live_reasoning_rows"] == 1 assert result["fallback_rows_after_recovery"] == 0 assert result["anchor_reasoning_events"] == 1 assert result["anchor_reasoning_text"] == "Plan step" @@ -366,6 +382,7 @@ for(const name of [ '_transparentLiveRowKey','_transparentLiveRowsCompatible', '_transparentLiveRowAttributePairs','_transparentLiveRowInteractiveState', '_refreshTransparentThinkingLiveRow','_refreshTransparentLiveRow', + '_resetMismatchedLiveAssistantTurnForSession', 'isLiveAnchorActivitySceneOwner','_projectLiveAnchorActivitySceneForStream', '_renderLiveAnchorActivitySceneTransparent','renderLiveAnchorActivityScene', '_renderLiveAnchorActivitySceneForStream','appendThinking','updateThinking', From a89a6be075a033d24ef0c1a38bc8ce972a23cdac Mon Sep 17 00:00:00 2001 From: Frank Song Date: Wed, 15 Jul 2026 04:43:09 +0800 Subject: [PATCH 4/7] fix(chat): avoid duplicate reasoning fallback owner --- static/ui.js | 55 ++++++++++++ tests/test_issue5720_reasoning_owner.py | 106 ++++++++++++++++++++++-- 2 files changed, 152 insertions(+), 9 deletions(-) diff --git a/static/ui.js b/static/ui.js index 3be3ce3f..30a4c910 100644 --- a/static/ui.js +++ b/static/ui.js @@ -12360,6 +12360,60 @@ function _resetMismatchedLiveAssistantTurnForSession(turn, sessionId){ turn.dataset.sessionId=sid; return true; } +function _liveAnchorReasoningRowForFallback(turn, opts){ + opts=opts||{}; + const blocks=typeof _assistantTurnBlocks==='function' ? _assistantTurnBlocks(turn) : turn; + if(!turn||!blocks||!blocks.querySelectorAll) return null; + const streamId=String(opts.streamId||S.activeStreamId||''); + const sessionId=String(opts.sessionId||(S.session&&S.session.session_id)||''); + const rows=blocks.querySelectorAll( + '[data-anchor-scene-row="1"][data-anchor-source-event-type="reasoning"],'+ + '[data-anchor-scene-row="1"][data-anchor-row-role="thinking"],'+ + '.wl-reason[data-worklog-anchor-reason="1"]' + ); + for(const row of Array.from(rows)){ + const rowStreamId=String(row.getAttribute&&row.getAttribute('data-anchor-stream-id')||''); + if(rowStreamId&&streamId&&rowStreamId!==streamId) continue; + const rowSessionId=String(row.getAttribute&&row.getAttribute('data-session-id')||''); + if(rowSessionId&&sessionId&&rowSessionId!==sessionId) continue; + return row; + } + return null; +} +function _updateLiveAnchorReasoningRowForFallback(turn, text, opts){ + const clean=_sanitizeThinkingDisplayText(text); + if(!clean||window._showThinking===false) return false; + const row=_liveAnchorReasoningRowForFallback(turn, opts); + if(!row) return false; + if(row.classList&&row.classList.contains('wl-reason')){ + if(typeof _renderWorklogReasonInto==='function') _renderWorklogReasonInto(row, clean); + else row.textContent=clean; + const group=row.closest&&row.closest('.tool-worklog-group,.tool-call-group,.live-worklog'); + if(group&&typeof _syncToolCallGroupSummary==='function') _syncToolCallGroupSummary(group); + }else if(row.classList&&row.classList.contains('transparent-event-row')){ + _renderThinkingInto(row, clean); + const eventAt=row.getAttribute&&row.getAttribute('data-event-at'); + const nextTs=typeof _firstValidTimestampSeconds==='function' + ? _firstValidTimestampSeconds(opts&&opts.ts, opts&&opts.timestamp, opts&&opts.created_at, eventAt) + : null; + if(typeof _decorateTransparentEventRow==='function'){ + _decorateTransparentEventRow(row,{ + type:'thinking', + text:clean, + preview:clean, + ts:nextTs||undefined, + live:true, + segmentSeq:opts&&opts.segmentSeq, + burstId:opts&&opts.burstId, + }); + } + }else{ + _renderThinkingInto(row, clean); + } + if(turn&&typeof _syncTransparentEventControls==='function') _syncTransparentEventControls(turn); + if(typeof scrollIfPinned==='function') scrollIfPinned(); + return true; +} function renderLiveAnchorActivityScene(streamId, scene, opts){ opts=opts||{}; const requestedMode=opts.mode; @@ -18468,6 +18522,7 @@ function appendThinking(text='', options){ String(existingLiveTurn.dataset.sessionId)!==String(S.session.session_id||'')){ if(!_resetMismatchedLiveAssistantTurnForSession(existingLiveTurn, S.session.session_id)) return; } + if(anchorRenderFallback&&existingLiveTurn&&_updateLiveAnchorReasoningRowForFallback(existingLiveTurn,text,options)) return; if(!allowPendingPlaceholder&&!anchorRenderFallback&&isLiveAnchorActivitySceneOwner(S.activeStreamId)){ _renderLiveAnchorActivitySceneForStream(S.activeStreamId, S.session.session_id); return; diff --git a/tests/test_issue5720_reasoning_owner.py b/tests/test_issue5720_reasoning_owner.py index f67b993e..f48e368c 100644 --- a/tests/test_issue5720_reasoning_owner.py +++ b/tests/test_issue5720_reasoning_owner.py @@ -17,7 +17,9 @@ NODE = shutil.which("node") def _run_reasoning_scene( *, + activity_mode: str = "transparent_stream", fail_first_anchor_render: bool = False, + fail_anchor_render_on: int | None = None, stale_active_stream: bool = False, stale_live_turn: bool = False, show_thinking: bool = True, @@ -30,8 +32,11 @@ def _run_reasoning_scene( "ISSUE5720_ANCHORS_JS", str(ROOT / "static" / "assistant_turn_anchors.js"), ) + env["ISSUE5720_ACTIVITY_MODE"] = activity_mode if fail_first_anchor_render: env["ISSUE5720_FAIL_FIRST_ANCHOR_RENDER"] = "1" + if fail_anchor_render_on is not None: + env["ISSUE5720_FAIL_ANCHOR_RENDER_ON"] = str(fail_anchor_render_on) if stale_active_stream: env["ISSUE5720_STALE_ACTIVE_STREAM"] = "1" if stale_live_turn: @@ -133,6 +138,43 @@ def test_reasoning_fallback_rebuilds_from_another_sessions_live_turn(): assert result["inflight_reasoning_text"] == "Plan step" +@pytest.mark.skipif(NODE is None, reason="node not on PATH") +@pytest.mark.parametrize("activity_mode", ["transparent_stream", "compact_worklog"]) +def test_later_deferred_anchor_paint_updates_existing_reasoning_owner(activity_mode): + result = _run_reasoning_scene( + activity_mode=activity_mode, + fail_anchor_render_on=2, + ) + + assert result["first_text"] == "Plan" + assert result["second_text"] == "Plan step" + assert result["same_node"] is True + assert result["live_reasoning_rows"] == 1 + assert result["fallback_rows_after_recovery"] == 0 + assert result["visible_reasoning_owners"] == 1 + assert result["anchor_render_attempts"] == 2 + assert result["anchor_reasoning_events"] == 1 + assert result["anchor_reasoning_text"] == "Plan step" + assert result["inflight_reasoning_text"] == "Plan step" + + +@pytest.mark.skipif(NODE is None, reason="node not on PATH") +def test_later_deferred_anchor_paint_remains_hidden_in_final_answer_only_mode(): + result = _run_reasoning_scene( + activity_mode="hide_all_activity", + fail_anchor_render_on=2, + ) + + assert result["first_text"] is None + assert result["second_text"] is None + assert result["live_reasoning_rows"] == 0 + assert result["fallback_rows_after_recovery"] == 0 + assert result["visible_reasoning_owners"] == 0 + assert result["anchor_render_attempts"] == 2 + assert result["anchor_reasoning_events"] == 1 + assert result["anchor_reasoning_text"] == "Plan step" + + _NODE_SCENE = r""" const fs = require('fs'); const uiSrc = fs.readFileSync(process.env.ISSUE5720_UI_JS, 'utf8'); @@ -304,7 +346,7 @@ function matchesSimple(el, selector){ } global.window={ - _chatActivityDisplayMode:'transparent_stream', + _chatActivityDisplayMode:process.env.ISSUE5720_ACTIVITY_MODE||'transparent_stream', _showThinking:process.env.ISSUE5720_SHOW_THINKING!=='0', _simplifiedToolCalling:true, }; @@ -341,6 +383,28 @@ global.scrollIfPinned=()=>{}; global._moveLiveRunStatusToTurnEnd=()=>{}; global._messageUserUnpinned=false; global._syncTransparentEventControls=()=>{}; +global._syncToolCallGroupSummary=()=>{}; +global._captureWorklogDetailDisclosureState=()=>null; +global._restoreWorklogDetailDisclosureState=()=>{}; +global._startActivityElapsedTimer=()=>{}; +global._dedupeLiveProcessedWorklogAnchors=()=>{}; +global._toolWorklogListEl=group=>group&&group.querySelector&&group.querySelector('.tool-worklog-list')||group; +global.ensureActivityGroup=(blocks,opts)=>{ + opts=opts||{}; + const key=String(opts.activityKey||''); + let group=key&&blocks.querySelector&&blocks.querySelector(`.tool-worklog-group[data-tool-worklog-key="${CSS.escape(key)}"]`); + if(group) return group; + group=new FakeElement('div'); + group.className='tool-worklog-group tool-call-group'; + group.setAttribute('data-tool-worklog-group','1'); + group.setAttribute('data-live-tool-call-group','1'); + group.setAttribute('data-tool-worklog-key',key); + const list=new FakeElement('div'); + list.className='tool-worklog-list'; + group.appendChild(list); + blocks.appendChild(group); + return group; +}; global._thinkingActivityNode=text=>{ const row=new FakeElement('div'); row.className='agent-activity-thinking transparent-thinking-event'; @@ -382,7 +446,10 @@ for(const name of [ '_transparentLiveRowKey','_transparentLiveRowsCompatible', '_transparentLiveRowAttributePairs','_transparentLiveRowInteractiveState', '_refreshTransparentThinkingLiveRow','_refreshTransparentLiveRow', + '_thinkingMarkup','_renderThinkingInto', '_resetMismatchedLiveAssistantTurnForSession', + '_liveAnchorReasoningRowForFallback','_updateLiveAnchorReasoningRowForFallback', + '_anchorSceneNodeForRow','_anchorSceneWorklogGroup','_renderAnchorSceneRowsIntoWorklog', 'isLiveAnchorActivitySceneOwner','_projectLiveAnchorActivitySceneForStream', '_renderLiveAnchorActivitySceneTransparent','renderLiveAnchorActivityScene', '_renderLiveAnchorActivitySceneForStream','appendThinking','updateThinking', @@ -395,10 +462,12 @@ _renderLiveAnchorActivitySceneTransparent=function(...args){ return realTransparentRender(...args); }; const failFirstAnchorRender=process.env.ISSUE5720_FAIL_FIRST_ANCHOR_RENDER==='1'; +const failAnchorRenderOn=Number(process.env.ISSUE5720_FAIL_ANCHOR_RENDER_ON||'0'); let anchorRenderAttempts=0; window._renderLiveAnchorActivitySceneForStream=function(...args){ anchorRenderAttempts+=1; if(failFirstAnchorRender&&anchorRenderAttempts===1) return false; + if(failAnchorRenderOn&&anchorRenderAttempts===failAnchorRenderOn) return false; return _renderLiveAnchorActivitySceneForStream(...args); }; window.isLiveAnchorActivitySceneOwner=isLiveAnchorActivitySceneOwner; @@ -442,15 +511,33 @@ const source=FakeEventSource.instances[0]; if(!source) throw new Error('attachLiveStream did not create EventSource'); if(process.env.ISSUE5720_STALE_ACTIVE_STREAM==='1') S.activeStreamId='stream-new'; +function anchorReasoningRows(){ + return turn.querySelectorAll( + '[data-anchor-scene-row="1"][data-anchor-source-event-type="reasoning"],'+ + '[data-anchor-scene-row="1"][data-anchor-row-role="thinking"]' + ); +} +function fallbackReasoningRows(){ + return turn.querySelectorAll('.agent-activity-thinking[data-live-thinking="1"]') + .filter(row=>row.getAttribute('data-anchor-scene-row')!=='1'); +} +function reasoningText(row){ + if(!row) return null; + const pre=row.querySelector&&row.querySelector('pre'); + return pre?pre.textContent:row.textContent; +} + source.emit('reasoning',{text:'Plan '}); -const first=turn.querySelector('.transparent-event-row[data-event-type="thinking"][data-anchor-scene-row="1"]'); -const firstText=first&&first.querySelector('pre')&&first.querySelector('pre').textContent; -const firstFallback=turn.querySelector('.agent-activity-thinking[data-live-thinking="1"]'); -const firstFallbackText=firstFallback&&firstFallback.querySelector('pre')&&firstFallback.querySelector('pre').textContent; +const first=anchorReasoningRows()[0]||null; +const firstText=reasoningText(first); +const firstFallback=fallbackReasoningRows()[0]||null; +const firstFallbackText=reasoningText(firstFallback); const passAfterFirst=transparentRenderPasses; source.emit('reasoning',{text:'step'}); -const second=turn.querySelector('.transparent-event-row[data-event-type="thinking"][data-anchor-scene-row="1"]'); -const secondText=second&&second.querySelector('pre')&&second.querySelector('pre').textContent; +const second=anchorReasoningRows()[0]||null; +const secondText=reasoningText(second); +const anchorRowsAfterRecovery=anchorReasoningRows(); +const fallbackRowsAfterRecovery=fallbackReasoningRows(); const registry=window._liveAnchorRegistries&&window._liveAnchorRegistries.get('stream-1'); const anchorReasoningEvents=registry&®istry.anchor&&Array.isArray(registry.anchor.activity_events) ? registry.anchor.activity_events.filter(event=>event&&event.source_event_type==='reasoning') @@ -461,8 +548,9 @@ process.stdout.write(JSON.stringify({ first_fallback_text:firstFallbackText, second_text:secondText, same_node:first===second, - live_reasoning_rows:turn.querySelectorAll('.transparent-event-row[data-event-type="thinking"][data-anchor-scene-row="1"]').length, - fallback_rows_after_recovery:turn.querySelectorAll('.agent-activity-thinking[data-live-thinking="1"]').length, + live_reasoning_rows:anchorRowsAfterRecovery.length, + fallback_rows_after_recovery:fallbackRowsAfterRecovery.length, + visible_reasoning_owners:anchorRowsAfterRecovery.length+fallbackRowsAfterRecovery.length, render_passes:[passAfterFirst,transparentRenderPasses], anchor_render_attempts:anchorRenderAttempts, anchor_reasoning_events:anchorReasoningEvents.length, From 90e9f04dabc01d97db576783a502333442eaf316 Mon Sep 17 00:00:00 2001 From: Frank Song Date: Wed, 15 Jul 2026 06:23:07 +0800 Subject: [PATCH 5/7] fix(chat): key reasoning fallback to exact anchor row --- static/messages.js | 17 ++++- static/ui.js | 15 +++- tests/test_issue5720_reasoning_owner.py | 92 +++++++++++++++++++++++++ 3 files changed, 118 insertions(+), 6 deletions(-) diff --git a/static/messages.js b/static/messages.js index 6efbe647..cd763ec0 100644 --- a/static/messages.js +++ b/static/messages.js @@ -3792,10 +3792,15 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ } function _upsertAnchorReasoning(text, options={}){ const clean=String(text||'').trim(); - if(!clean||!_anchorRegistry||window._showThinking===false) return null; const placement=_liveThinkingPlacement(); const segmentSeq=Number(options.segmentSeq||placement.segmentSeq||_anchorSegmentSeq()); const localId=String(options.localId||`live-reasoning:${streamId}:${segmentSeq}`); + if(options&&typeof options==='object'){ + options.anchorReasoningLocalId=localId; + options.segmentSeq=segmentSeq; + if(options.burstId===undefined) options.burstId=_currentActivityBurstId; + } + if(!clean||!_anchorRegistry||window._showThinking===false) return null; const existing=_findAnchorActivityEventByLocalId(localId,'reasoning'); if(existing){ const replaced=_replaceAnchorActivityEventByLocalId(localId,'reasoning',{ @@ -5356,8 +5361,14 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ syncInflightAssistantMessage(); if(text&&S.session&&S.session.session_id===activeSid&&S.activeStreamId===streamId){ const liveThinkingText=_liveThinkingText(); - if(!_upsertAnchorReasoning(liveThinkingText)){ - _updateLiveThinkingCard(liveThinkingText,{anchorRenderFallback:true,sessionId:activeSid,streamId}); + const anchorReasoningFallback={}; + if(!_upsertAnchorReasoning(liveThinkingText, anchorReasoningFallback)){ + _updateLiveThinkingCard(liveThinkingText,{ + ...anchorReasoningFallback, + anchorRenderFallback:true, + sessionId:activeSid, + streamId, + }); } } }); diff --git a/static/ui.js b/static/ui.js index 30a4c910..d7b6b836 100644 --- a/static/ui.js +++ b/static/ui.js @@ -12366,12 +12366,21 @@ function _liveAnchorReasoningRowForFallback(turn, opts){ if(!turn||!blocks||!blocks.querySelectorAll) return null; const streamId=String(opts.streamId||S.activeStreamId||''); const sessionId=String(opts.sessionId||(S.session&&S.session.session_id)||''); + const segmentSeq=opts.segmentSeq!==undefined&&opts.segmentSeq!==null + ? String(opts.segmentSeq).trim() + : ''; + const localId=String(opts.anchorReasoningLocalId||opts.localId||'').trim(); + const rowKey=localId||(streamId&&segmentSeq?`live-reasoning:${streamId}:${segmentSeq}`:''); + if(!rowKey) return null; const rows=blocks.querySelectorAll( - '[data-anchor-scene-row="1"][data-anchor-source-event-type="reasoning"],'+ - '[data-anchor-scene-row="1"][data-anchor-row-role="thinking"],'+ - '.wl-reason[data-worklog-anchor-reason="1"]' + '[data-anchor-scene-row="1"][data-anchor-row-id]' ); for(const row of Array.from(rows)){ + const anchorRowId=String(row.getAttribute&&row.getAttribute('data-anchor-row-id')||''); + if(anchorRowId!==rowKey&&!anchorRowId.startsWith(`${rowKey}:`)) continue; + const rowRole=String(row.getAttribute&&row.getAttribute('data-anchor-row-role')||''); + const rowSource=String(row.getAttribute&&row.getAttribute('data-anchor-source-event-type')||''); + if(rowRole!=='thinking'&&rowSource!=='reasoning') continue; const rowStreamId=String(row.getAttribute&&row.getAttribute('data-anchor-stream-id')||''); if(rowStreamId&&streamId&&rowStreamId!==streamId) continue; const rowSessionId=String(row.getAttribute&&row.getAttribute('data-session-id')||''); diff --git a/tests/test_issue5720_reasoning_owner.py b/tests/test_issue5720_reasoning_owner.py index f48e368c..4bd0b31c 100644 --- a/tests/test_issue5720_reasoning_owner.py +++ b/tests/test_issue5720_reasoning_owner.py @@ -20,6 +20,7 @@ def _run_reasoning_scene( activity_mode: str = "transparent_stream", fail_first_anchor_render: bool = False, fail_anchor_render_on: int | None = None, + multi_segment_fallback: bool = False, stale_active_stream: bool = False, stale_live_turn: bool = False, show_thinking: bool = True, @@ -37,6 +38,8 @@ def _run_reasoning_scene( env["ISSUE5720_FAIL_FIRST_ANCHOR_RENDER"] = "1" if fail_anchor_render_on is not None: env["ISSUE5720_FAIL_ANCHOR_RENDER_ON"] = str(fail_anchor_render_on) + if multi_segment_fallback: + env["ISSUE5720_MULTI_SEGMENT_FALLBACK"] = "1" if stale_active_stream: env["ISSUE5720_STALE_ACTIVE_STREAM"] = "1" if stale_live_turn: @@ -158,6 +161,24 @@ def test_later_deferred_anchor_paint_updates_existing_reasoning_owner(activity_m assert result["inflight_reasoning_text"] == "Plan step" +@pytest.mark.skipif(NODE is None, reason="node not on PATH") +@pytest.mark.parametrize("activity_mode", ["transparent_stream", "compact_worklog"]) +def test_later_deferred_anchor_paint_updates_exact_reasoning_owner(activity_mode): + result = _run_reasoning_scene( + activity_mode=activity_mode, + multi_segment_fallback=True, + ) + exact = result["multi_segment_exact_fallback"] + + assert exact["first_before"] == "Plan step" + assert exact["first_after"] == "Plan step" + assert exact["second_before"] == "Second draft" + assert exact["second_after"] == "Second updated" + assert exact["legacy_after"] == "Legacy unkeyed" + assert exact["live_reasoning_rows"] == 2 + assert exact["fallback_rows"] == 0 + + @pytest.mark.skipif(NODE is None, reason="node not on PATH") def test_later_deferred_anchor_paint_remains_hidden_in_final_answer_only_mode(): result = _run_reasoning_scene( @@ -405,6 +426,7 @@ global.ensureActivityGroup=(blocks,opts)=>{ blocks.appendChild(group); return group; }; +global.ensureLiveWorklogContainer=(blocks,opts)=>ensureActivityGroup(blocks,opts); global._thinkingActivityNode=text=>{ const row=new FakeElement('div'); row.className='agent-activity-thinking transparent-thinking-event'; @@ -526,6 +548,47 @@ function reasoningText(row){ const pre=row.querySelector&&row.querySelector('pre'); return pre?pre.textContent:row.textContent; } +function appendSyntheticAnchorReasoningRow(rowId, text){ + const row=_thinkingActivityNode(text, false, rowId); + row.setAttribute('data-anchor-scene-row','1'); + row.setAttribute('data-anchor-row-id',rowId); + row.setAttribute('data-anchor-row-role','thinking'); + row.setAttribute('data-anchor-source-event-type','reasoning'); + row.setAttribute('data-anchor-stream-id','stream-1'); + row.setAttribute('data-session-id','sid-1'); + if(isTransparentStream()){ + _decorateTransparentEventRow(row,{ + type:'thinking', + text, + preview:text, + live:true, + segmentSeq:'2', + burstId:'0', + }); + row.setAttribute('data-anchor-live-scene-row','1'); + row.setAttribute('data-live-stream-owned','1'); + turn.appendChild(row); + }else{ + const group=ensureActivityGroup(turn,{activityKey:'live:stream-1'}); + const list=_toolWorklogListEl(group); + (list||group||turn).appendChild(row); + } + return row; +} +function appendUnkeyedLegacyReasonRow(text){ + const row=new FakeElement('div'); + row.className='wl-reason'; + row.setAttribute('data-worklog-anchor-reason','1'); + row.textContent=text; + if(isTransparentStream()){ + turn.appendChild(row); + }else{ + const group=ensureActivityGroup(turn,{activityKey:'live:stream-1'}); + const list=_toolWorklogListEl(group); + (list||group||turn).appendChild(row); + } + return row; +} source.emit('reasoning',{text:'Plan '}); const first=anchorReasoningRows()[0]||null; @@ -542,6 +605,34 @@ const registry=window._liveAnchorRegistries&&window._liveAnchorRegistries.get('s const anchorReasoningEvents=registry&®istry.anchor&&Array.isArray(registry.anchor.activity_events) ? registry.anchor.activity_events.filter(event=>event&&event.source_event_type==='reasoning') : []; +let multiSegmentExactFallback=null; +if(process.env.ISSUE5720_MULTI_SEGMENT_FALLBACK==='1'){ + const firstReasoningRow=anchorReasoningRows()[0]||null; + const firstBefore=reasoningText(firstReasoningRow); + const secondId='live-reasoning:stream-1:2'; + const secondReasoningRow=appendSyntheticAnchorReasoningRow(`${secondId}:reasoning:1`,'Second draft'); + const secondBefore=reasoningText(secondReasoningRow); + const legacyRow=appendUnkeyedLegacyReasonRow('Legacy unkeyed'); + appendThinking('Second updated',{ + anchorRenderFallback:true, + sessionId:'sid-1', + streamId:'stream-1', + anchorReasoningLocalId:secondId, + segmentSeq:2, + burstId:0, + }); + multiSegmentExactFallback={ + first_row_id:firstReasoningRow&&firstReasoningRow.getAttribute('data-anchor-row-id'), + first_before:firstBefore, + first_after:reasoningText(firstReasoningRow), + second_row_id:secondReasoningRow.getAttribute('data-anchor-row-id'), + second_before:secondBefore, + second_after:reasoningText(secondReasoningRow), + legacy_after:reasoningText(legacyRow), + live_reasoning_rows:anchorReasoningRows().length, + fallback_rows:fallbackReasoningRows().length, + }; +} process.stdout.write(JSON.stringify({ first_text:firstText, @@ -558,5 +649,6 @@ process.stdout.write(JSON.stringify({ ? String(anchorReasoningEvents[anchorReasoningEvents.length-1].payload&&anchorReasoningEvents[anchorReasoningEvents.length-1].payload.text||'') : null, inflight_reasoning_text:String(INFLIGHT['sid-1']&&INFLIGHT['sid-1'].lastReasoningText||''), + multi_segment_exact_fallback:multiSegmentExactFallback, })); """ From e5c5fd63d4a1d3cb4eb6ccf0ed01557c30f7b80b Mon Sep 17 00:00:00 2001 From: Frank Song Date: Wed, 15 Jul 2026 06:28:34 +0800 Subject: [PATCH 6/7] test(chat): accept keyed reasoning fallback path --- tests/test_regressions.py | 8 ++++++-- .../test_stable_assistant_turn_anchor_registry.py | 9 +++++++-- tests/test_ui_tool_call_cleanup.py | 14 ++++++++------ 3 files changed, 21 insertions(+), 10 deletions(-) diff --git a/tests/test_regressions.py b/tests/test_regressions.py index b28be9a3..82725429 100644 --- a/tests/test_regressions.py +++ b/tests/test_regressions.py @@ -938,9 +938,13 @@ def test_messages_js_supports_live_reasoning_and_tool_completion(cleanup_test_se "live reasoning SSE events must update the active Worklog Thinking Card text" assert "const liveThinkingText=_liveThinkingText();" in src, \ "live reasoning SSE events must compute the current segment's Worklog Thinking Card text once" - assert "if(!_upsertAnchorReasoning(liveThinkingText))" in src, \ + assert "const anchorReasoningFallback={};" in src, \ + "live reasoning SSE events must capture the active anchor id for fallback" + assert "if(!_upsertAnchorReasoning(liveThinkingText, anchorReasoningFallback))" in src, \ "live reasoning SSE events must prefer the anchor renderer before falling back" - assert "_updateLiveThinkingCard(liveThinkingText,{anchorRenderFallback:true,sessionId:activeSid,streamId})" in src, \ + assert "_updateLiveThinkingCard(liveThinkingText,{" in src and "...anchorReasoningFallback" in src, \ + "live reasoning SSE events must carry anchor identity into the fallback renderer" + assert "anchorRenderFallback:true" in src and "sessionId:activeSid" in src and "streamId" in src, \ "live reasoning SSE events must keep the current segment's Worklog Thinking Card as fallback" assert "source.addEventListener('tool_complete'" in src or 'source.addEventListener("tool_complete"' in src, \ "messages.js must listen for live tool completion SSE events" diff --git a/tests/test_stable_assistant_turn_anchor_registry.py b/tests/test_stable_assistant_turn_anchor_registry.py index bc03eb05..4566baf8 100644 --- a/tests/test_stable_assistant_turn_anchor_registry.py +++ b/tests/test_stable_assistant_turn_anchor_registry.py @@ -1307,8 +1307,13 @@ def test_slice6_live_shadow_feed_wires_anchor_scene_for_visible_order_handoff(): reasoning_body = _event_listener_body(src, "reasoning") assert "_applyToAnchor" not in reasoning_body assert "const liveThinkingText=_liveThinkingText();" in reasoning_body - assert "if(!_upsertAnchorReasoning(liveThinkingText))" in reasoning_body - assert "_updateLiveThinkingCard(liveThinkingText,{anchorRenderFallback:true,sessionId:activeSid,streamId})" in reasoning_body + assert "const anchorReasoningFallback={};" in reasoning_body + assert "if(!_upsertAnchorReasoning(liveThinkingText, anchorReasoningFallback))" in reasoning_body + assert "_updateLiveThinkingCard(liveThinkingText,{" in reasoning_body + assert "...anchorReasoningFallback" in reasoning_body + assert "anchorRenderFallback:true" in reasoning_body + assert "sessionId:activeSid" in reasoning_body + assert "streamId" in reasoning_body assert "function _flushReasoningToAnchor()" in src assert "_upsertAnchorReasoning(reasoningText" in src assert "`live-reasoning:${streamId}:final`" in src diff --git a/tests/test_ui_tool_call_cleanup.py b/tests/test_ui_tool_call_cleanup.py index 00951191..fe889172 100644 --- a/tests/test_ui_tool_call_cleanup.py +++ b/tests/test_ui_tool_call_cleanup.py @@ -686,21 +686,23 @@ class TestToolCallGroupingStatic: assert "const liveThinkingText=_liveThinkingText();" in reasoning_fn, ( "Reasoning SSE updates should cache the live thinking text before routing." ) - assert "_upsertAnchorReasoning(liveThinkingText)" in reasoning_fn, ( + primary_call = "_upsertAnchorReasoning(liveThinkingText, anchorReasoningFallback)" + assert primary_call in reasoning_fn, ( "Anchor reasoning must remain the primary renderer path." ) - fallback_call = "_updateLiveThinkingCard(liveThinkingText,{anchorRenderFallback:true,sessionId:activeSid,streamId})" - assert reasoning_fn.index("_upsertAnchorReasoning(liveThinkingText)") < reasoning_fn.index( - fallback_call - ), ( + fallback_call = "_updateLiveThinkingCard(liveThinkingText,{" + assert reasoning_fn.index(primary_call) < reasoning_fn.index(fallback_call), ( "The legacy thinking card should only run after anchor upsert fails." ) - assert "if(!_upsertAnchorReasoning(liveThinkingText)){" in reasoning_fn, ( + assert "if(!_upsertAnchorReasoning(liveThinkingText, anchorReasoningFallback)){" in reasoning_fn, ( "The legacy thinking card should be a falsy-anchor fallback." ) assert reasoning_fn.count(fallback_call) == 1, ( "Reasoning SSE updates should call the live thinking card only in fallback." ) + assert "...anchorReasoningFallback" in reasoning_fn, ( + "The fallback renderer must receive the exact Anchor reasoning identity." + ) assert "_updateLiveThinkingCard(" in render_live_thinking_fn, ( "Inline parsed thinking still needs the live thinking card renderer." ) From e13ebd96c1d56aa4dba00d402f8720a0027437fc Mon Sep 17 00:00:00 2001 From: Frank Song Date: Wed, 15 Jul 2026 07:10:02 +0800 Subject: [PATCH 7/7] fix(chat): align reasoning fallback identity key --- static/ui.js | 14 +++-- tests/test_issue5720_reasoning_owner.py | 70 ++++++++++++++++--------- 2 files changed, 50 insertions(+), 34 deletions(-) diff --git a/static/ui.js b/static/ui.js index d7b6b836..fe13e0c1 100644 --- a/static/ui.js +++ b/static/ui.js @@ -12084,6 +12084,7 @@ function _anchorSceneNodeForRow(row, opts){ if(!node) return null; node.setAttribute('data-anchor-scene-row','1'); node.setAttribute('data-anchor-row-id',String(row.row_id||row.local_id||'')); + if(row.local_id) node.setAttribute('data-anchor-local-id',String(row.local_id)); node.setAttribute('data-anchor-row-role',String(row.role||'activity')); node.setAttribute('data-anchor-source-event-type',String(row.source_event_type||'')); return node; @@ -12153,6 +12154,7 @@ function _anchorSceneTransparentNodeForRow(row, opts){ if(settled) node.setAttribute('data-anchor-settled-scene-row','1'); if(live) node.setAttribute('data-anchor-live-scene-row','1'); node.setAttribute('data-anchor-row-id',String(row.row_id||row.local_id||'')); + if(row.local_id) node.setAttribute('data-anchor-local-id',String(row.local_id)); node.setAttribute('data-anchor-row-role',String(row.role||'activity')); node.setAttribute('data-anchor-source-event-type',String(row.source_event_type||'')); if(opts&&opts.streamId) node.setAttribute('data-anchor-stream-id',String(opts.streamId)); @@ -12366,18 +12368,14 @@ function _liveAnchorReasoningRowForFallback(turn, opts){ if(!turn||!blocks||!blocks.querySelectorAll) return null; const streamId=String(opts.streamId||S.activeStreamId||''); const sessionId=String(opts.sessionId||(S.session&&S.session.session_id)||''); - const segmentSeq=opts.segmentSeq!==undefined&&opts.segmentSeq!==null - ? String(opts.segmentSeq).trim() - : ''; const localId=String(opts.anchorReasoningLocalId||opts.localId||'').trim(); - const rowKey=localId||(streamId&&segmentSeq?`live-reasoning:${streamId}:${segmentSeq}`:''); - if(!rowKey) return null; + if(!localId) return null; const rows=blocks.querySelectorAll( - '[data-anchor-scene-row="1"][data-anchor-row-id]' + '[data-anchor-scene-row="1"][data-anchor-local-id]' ); for(const row of Array.from(rows)){ - const anchorRowId=String(row.getAttribute&&row.getAttribute('data-anchor-row-id')||''); - if(anchorRowId!==rowKey&&!anchorRowId.startsWith(`${rowKey}:`)) continue; + const anchorLocalId=String(row.getAttribute&&row.getAttribute('data-anchor-local-id')||''); + if(anchorLocalId!==localId) continue; const rowRole=String(row.getAttribute&&row.getAttribute('data-anchor-row-role')||''); const rowSource=String(row.getAttribute&&row.getAttribute('data-anchor-source-event-type')||''); if(rowRole!=='thinking'&&rowSource!=='reasoning') continue; diff --git a/tests/test_issue5720_reasoning_owner.py b/tests/test_issue5720_reasoning_owner.py index 4bd0b31c..8a0223e5 100644 --- a/tests/test_issue5720_reasoning_owner.py +++ b/tests/test_issue5720_reasoning_owner.py @@ -175,6 +175,9 @@ def test_later_deferred_anchor_paint_updates_exact_reasoning_owner(activity_mode assert exact["second_before"] == "Second draft" assert exact["second_after"] == "Second updated" assert exact["legacy_after"] == "Legacy unkeyed" + assert exact["anchor_run_id"] == "run-5720" + assert exact["second_row_id"] == "run-5720:2" + assert exact["second_local_id"] == "live-reasoning:stream-1:2" assert exact["live_reasoning_rows"] == 2 assert exact["fallback_rows"] == 0 @@ -548,31 +551,40 @@ function reasoningText(row){ const pre=row.querySelector&&row.querySelector('pre'); return pre?pre.textContent:row.textContent; } -function appendSyntheticAnchorReasoningRow(rowId, text){ - const row=_thinkingActivityNode(text, false, rowId); - row.setAttribute('data-anchor-scene-row','1'); - row.setAttribute('data-anchor-row-id',rowId); - row.setAttribute('data-anchor-row-role','thinking'); - row.setAttribute('data-anchor-source-event-type','reasoning'); - row.setAttribute('data-anchor-stream-id','stream-1'); - row.setAttribute('data-session-id','sid-1'); - if(isTransparentStream()){ - _decorateTransparentEventRow(row,{ - type:'thinking', - text, - preview:text, - live:true, - segmentSeq:'2', - burstId:'0', - }); - row.setAttribute('data-anchor-live-scene-row','1'); - row.setAttribute('data-live-stream-owned','1'); - turn.appendChild(row); - }else{ - const group=ensureActivityGroup(turn,{activityKey:'live:stream-1'}); - const list=_toolWorklogListEl(group); - (list||group||turn).appendChild(row); - } +function applyProductionAnchorEvent(sourceEventType, data){ + const registry=window._liveAnchorRegistries&&window._liveAnchorRegistries.get('stream-1'); + if(!registry) throw new Error('missing live anchor registry'); + window.HermesAssistantTurnAnchors.applyAssistantTurnAnchorSourceEvent( + registry, + { + source_event_type:sourceEventType, + run_id:'run-5720', + stream_id:'stream-1', + ...data, + }, + { + session_id:'sid-1', + stream_id:'stream-1', + run_id:'run-5720', + } + ); + return registry; +} +function appendProductionAnchorReasoningRow(localId, text, seq){ + applyProductionAnchorEvent('reasoning',{ + local_id:localId, + seq, + text, + activitySegmentSeq:seq, + activityBurstId:0, + }); + window._renderLiveAnchorActivitySceneForStream('stream-1','sid-1',{ + mode:process.env.ISSUE5720_ACTIVITY_MODE||'transparent_stream', + }); + const row=anchorReasoningRows().find(candidate=> + candidate.getAttribute('data-anchor-row-id')===`run-5720:${seq}` + ); + if(!row) throw new Error('production anchor reasoning row not rendered'); return row; } function appendUnkeyedLegacyReasonRow(text){ @@ -610,7 +622,11 @@ if(process.env.ISSUE5720_MULTI_SEGMENT_FALLBACK==='1'){ const firstReasoningRow=anchorReasoningRows()[0]||null; const firstBefore=reasoningText(firstReasoningRow); const secondId='live-reasoning:stream-1:2'; - const secondReasoningRow=appendSyntheticAnchorReasoningRow(`${secondId}:reasoning:1`,'Second draft'); + const registry=applyProductionAnchorEvent('interim_assistant',{ + seq:1, + text:'', + }); + const secondReasoningRow=appendProductionAnchorReasoningRow(secondId,'Second draft',2); const secondBefore=reasoningText(secondReasoningRow); const legacyRow=appendUnkeyedLegacyReasonRow('Legacy unkeyed'); appendThinking('Second updated',{ @@ -622,10 +638,12 @@ if(process.env.ISSUE5720_MULTI_SEGMENT_FALLBACK==='1'){ burstId:0, }); multiSegmentExactFallback={ + anchor_run_id:registry&®istry.anchor&®istry.anchor.identity&®istry.anchor.identity.run_id, first_row_id:firstReasoningRow&&firstReasoningRow.getAttribute('data-anchor-row-id'), first_before:firstBefore, first_after:reasoningText(firstReasoningRow), second_row_id:secondReasoningRow.getAttribute('data-anchor-row-id'), + second_local_id:secondReasoningRow.getAttribute('data-anchor-local-id'), second_before:secondBefore, second_after:reasoningText(secondReasoningRow), legacy_after:reasoningText(legacyRow),