From b590518cf4f3ed8099e075e2a077946e56553da3 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Thu, 25 Jun 2026 13:11:27 -0400 Subject: [PATCH] fix(chat): preserve transparent stream prose order for mixed turns (#3397) --- static/ui.js | 160 +++++++++++++++++- ...e3397_transparent_stream_prose_segments.py | 103 +++++++++++ 2 files changed, 262 insertions(+), 1 deletion(-) create mode 100644 tests/test_issue3397_transparent_stream_prose_segments.py diff --git a/static/ui.js b/static/ui.js index e3fb3c4c..a82ea543 100644 --- a/static/ui.js +++ b/static/ui.js @@ -11120,6 +11120,87 @@ function _renderMessagesWithScrollSnapshot(options){ _restoreMessageScrollSnapshotSameFrame(scrollSnapshot); } let _assistantTurnAnchorSettledFinalAnswerWarned=false; +function _transparentStreamOrderedParts(message){ + if(typeof isTransparentStream==='function'&&!isTransparentStream()) return null; + if(!message||message.role!=='assistant'||message._live||!Array.isArray(message.content)) return null; + const ordered=[]; + let hasText=false; + let hasTool=false; + for(const part of message.content){ + if(!part||typeof part!=='object') continue; + if(part.type==='text'){ + const text=typeof part.text==='string'?part.text:(typeof part.content==='string'?part.content:''); + if(!String(text||'').trim()) continue; + ordered.push({kind:'text', text}); + hasText=true; + continue; + } + if(part.type==='tool_use'){ + const toolUseId=String(part.id||'').trim(); + if(!toolUseId) return null; + ordered.push({ + kind:'tool', + toolUseId, + name:part.name||'tool', + input:(part.input&&typeof part.input==='object')?part.input:{}, + }); + hasTool=true; + } + } + return hasText&&hasTool?ordered:null; +} +function _collectToolResultSnippetsByTid(messages){ + const resultsByTid={}; + for(const message of (messages||[])){ + if(!message) continue; + if(message.role==='tool'){ + const tid=message.tool_call_id||message.tool_use_id||''; + if(tid) resultsByTid[tid]=_cliToolResultSnippet(message.content); + continue; + } + if(!Array.isArray(message.content)) continue; + for(const part of message.content){ + if(!part||typeof part!=='object'||part.type!=='tool_result') continue; + const tid=part.tool_use_id||''; + if(!tid) continue; + const raw=typeof part.content==='string' + ? part.content + : Array.isArray(part.content) + ? part.content.map(c=>c&&c.text?c.text:'').join('') + : ''; + resultsByTid[tid]=_cliToolResultSnippet(raw); + } + } + return resultsByTid; +} +function _transparentOrderedToolCall(part, rawIdx, toolCallsByTid, resultsByTid){ + const tid=String(part&&part.toolUseId||'').trim(); + const liveTool=tid&&toolCallsByTid&&toolCallsByTid.get(tid); + if(liveTool){ + const next={...liveTool}; + if((next.snippet===undefined||next.snippet===null||next.snippet==='')&&resultsByTid&&resultsByTid[tid]){ + const patchSnippet=_cliPatchSnippetFromArgs(next.name||part.name||'tool', next.args||part.input||{}); + next.snippet=_cliToolCardSnippet(resultsByTid[tid],patchSnippet); + next.is_diff=_cliToolCardHasDiffSnippet(resultsByTid[tid],patchSnippet); + } + if(next.done===undefined) next.done=true; + return next; + } + const name=part&&part.name||'tool'; + const args=(part&&part.input&&typeof part.input==='object')?part.input:{}; + const patchSnippet=_cliPatchSnippetFromArgs(name,args); + const resultSnippet=(resultsByTid&&tid&&resultsByTid[tid])||''; + return { + name, + tid, + id:tid, + assistant_msg_idx:rawIdx, + args:_toolArgsSnapshot(args), + snippet:_cliToolCardSnippet(resultSnippet,patchSnippet), + is_diff:_cliToolCardHasDiffSnippet(resultSnippet,patchSnippet), + done:true, + }; +} function _assistantTurnAnchorSettledFinalAnswer(message, content, context){ const sceneFinal=_assistantAnchorSceneFinalAnswerText(message); const effectiveContent=String(content||'').trim()?content:sceneFinal; @@ -11463,6 +11544,16 @@ function renderMessages(options){ } } } + const transparentOrderedToolIds=new Set(); + const transparentOrderedToolCallsByTid=new Map(); + if(Array.isArray(S.toolCalls)){ + for(const tc of S.toolCalls){ + if(!tc||typeof tc!=='object') continue; + const tid=tc.tid||tc.id||tc.tool_call_id||tc.tool_use_id||tc.call_id||''; + if(tid&&!transparentOrderedToolCallsByTid.has(tid)) transparentOrderedToolCallsByTid.set(tid,tc); + } + } + const transparentToolResultsByTid=_collectToolResultSnippetsByTid(S.messages); // Windowed render loop replaces the legacy full loop: // for(let vi=0;vip&&p.type==='text').map(p=>p.text||p.content||'').join('\n'); } @@ -11496,7 +11588,17 @@ function renderMessages(options){ session_id:sid, raw_idx:rawIdx, }); - if(anchorFinal!==null) content=anchorFinal; + if(anchorFinal!==null){ + content=anchorFinal; + if(Array.isArray(orderedTransparentParts)){ + for(let i=orderedTransparentParts.length-1;i>=0;i--){ + if(orderedTransparentParts[i]&&orderedTransparentParts[i].kind==='text'){ + orderedTransparentParts[i]={...orderedTransparentParts[i], text:anchorFinal}; + break; + } + } + } + } } if(typeof content==='string'){ if(typeof window!=='undefined'&&typeof window._extractInlineThinkingFromContentForRender==='function'){ @@ -11639,6 +11741,60 @@ function renderMessages(options){ currentAssistantTurn.dataset.recycleKey=rawIdx; inner.appendChild(currentAssistantTurn); } + if(Array.isArray(orderedTransparentParts)&&orderedTransparentParts.length){ + const blocks=_assistantTurnBlocks(currentAssistantTurn); + const sessionMsgIdx=_messageSessionIndexForRawIdx(rawIdx); + const messageAnchorKey=_messageViewportAnchorKeyForMessage(m); + const lastTextPartIdx=(()=>{ + for(let i=orderedTransparentParts.length-1;i>=0;i--){ + if(orderedTransparentParts[i]&&orderedTransparentParts[i].kind==='text') return i; + } + return -1; + })(); + let firstSeg=null; + if(thinkingText&&window._showThinking!==false){ + if((isCompactWorklogMode()||isTransparentStream())&&_assistantThinkingBelongsInWorklog(m, rawIdx, toolCallAssistantIdxs)) assistantThinking.set(rawIdx, thinkingText); + } + orderedTransparentParts.forEach((part, partIdx)=>{ + if(!part) return; + if(part.kind==='tool'){ + const toolCall=_transparentOrderedToolCall(part, rawIdx, transparentOrderedToolCallsByTid, transparentToolResultsByTid); + const toolRow=_decorateTransparentEventRow(buildToolCard(toolCall),{ + type:'tool', + name:toolCall&&toolCall.name, + status:_transparentToolStatus(toolCall,true), + toolCall, + segmentSeq:toolCall&&toolCall.activitySegmentSeq, + burstId:(toolCall&&toolCall.activityBurstId)||m._activityBurstId, + }); + blocks.appendChild(toolRow); + if(part.toolUseId) transparentOrderedToolIds.add(part.toolUseId); + return; + } + const seg=document.createElement('div'); + seg.className='assistant-segment'; + seg.dataset.msgIdx=rawIdx; + seg.dataset.sessionMsgIdx=sessionMsgIdx; + seg.dataset.messageAnchorKey=messageAnchorKey; + seg.dataset.rawText=String(part.text||'').trim(); + if(m._activityBurstId!==undefined&&m._activityBurstId!==null) seg.setAttribute('data-activity-burst-id',String(m._activityBurstId)); + if(Number.isFinite(Number(m._liveSegmentSeq))) seg.setAttribute('data-live-segment-seq',String(Number(m._liveSegmentSeq))); + if(_ERR_MSG_RE.test(String(part.text||'').trim())) seg.dataset.error='1'; + if(!firstSeg&&thinkingText&&window._showThinking!==false&&!((isCompactWorklogMode()||isTransparentStream())&&_assistantThinkingBelongsInWorklog(m, rawIdx, toolCallAssistantIdxs))){ + seg.insertAdjacentHTML('beforeend', _thinkingCardHtml(thinkingText)); + } + const isLastTextPart=partIdx===lastTextPartIdx; + const partBodyHtml=_getCachedRender(part.text,false); + if(isLastTextPart&&statusHtml){ + seg.insertAdjacentHTML('beforeend', statusHtml); + } + seg.insertAdjacentHTML('beforeend', `${isLastTextPart?filesHtml:''}
${partBodyHtml}
${isLastTextPart?footHtml:''}`); + blocks.appendChild(seg); + if(!firstSeg) firstSeg=seg; + }); + assistantSegments.set(rawIdx, firstSeg||null); + continue; + } const seg=document.createElement('div'); seg.className='assistant-segment'; seg.dataset.msgIdx=rawIdx; @@ -12046,6 +12202,8 @@ function renderMessages(options){ for(const s of assistantSegments.values()) if(s){const b=s.getAttribute('data-activity-burst-id');if(b)knownBurstIds.add(b);} for(const tc of (S.toolCalls||[])){ if(!tc) continue; + const tid=tc.tid||tc.id||tc.tool_call_id||tc.tool_use_id||tc.call_id||''; + if(tid&&transparentOrderedToolIds.has(tid)) continue; const aIdx=tc.assistant_msg_idx!==undefined?parseInt(tc.assistant_msg_idx):-1; if(anchorOwnedAssistantRawIdxs.has(aIdx)) continue; if(virtualWindow.virtualized&&renderableRawIdxs.has(aIdx)&&!renderedRawIdxs.has(aIdx)) continue; diff --git a/tests/test_issue3397_transparent_stream_prose_segments.py b/tests/test_issue3397_transparent_stream_prose_segments.py new file mode 100644 index 00000000..0a5b542e --- /dev/null +++ b/tests/test_issue3397_transparent_stream_prose_segments.py @@ -0,0 +1,103 @@ +"""Regression tests for issue #3397 Transparent Stream settled prose segments.""" + +from __future__ import annotations + +import json +import shutil +import subprocess +import tempfile +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +UI_JS_PATH = ROOT / "static" / "ui.js" +UI_JS = UI_JS_PATH.read_text(encoding="utf-8") +NODE = shutil.which("node") + +_DRIVER_SRC = r""" +const fs = require('fs'); +const src = fs.readFileSync(process.argv[2], 'utf8'); +const transparent = process.argv[3] === '1'; +const message = JSON.parse(process.argv[4]); + +function extractFunc(name) { + const re = new RegExp('function\\s+' + name + '\\s*\\('); + const start = src.search(re); + if (start < 0) throw new Error(name + ' not found'); + let i = src.indexOf('{', start); + let depth = 1; i++; + while (depth > 0 && i < src.length) { + if (src[i] === '{') depth++; + else if (src[i] === '}') depth--; + i++; + } + return src.slice(start, i); +} + +function isTransparentStream() { return transparent; } +eval(extractFunc('_transparentStreamOrderedParts')); +process.stdout.write(JSON.stringify(_transparentStreamOrderedParts(message))); +""" + + +def _run_helper(message: dict, transparent: bool) -> object: + if NODE is None: + pytest.skip("node not on PATH") + with tempfile.NamedTemporaryFile("w", suffix=".js", encoding="utf-8", delete=False) as handle: + handle.write(_DRIVER_SRC) + script_path = Path(handle.name) + try: + result = subprocess.run( + [NODE, str(script_path), str(UI_JS_PATH), "1" if transparent else "0", json.dumps(message)], + capture_output=True, + text=True, + timeout=30, + check=False, + ) + finally: + script_path.unlink(missing_ok=True) + if result.returncode != 0: + raise RuntimeError(f"node driver failed: {result.stderr}") + return json.loads(result.stdout) + + +@pytest.mark.skipif(NODE is None, reason="node not on PATH") +def test_transparent_stream_ordered_parts_preserve_text_tool_text_sequence(): + message = { + "role": "assistant", + "content": [ + {"type": "text", "text": "Let me search first."}, + {"type": "tool_use", "id": "toolu_1", "name": "grep", "input": {"pattern": "TODO"}}, + {"type": "text", "text": "Found it, here's the fix."}, + ], + } + + ordered = _run_helper(message, transparent=True) + + assert [part["kind"] for part in ordered] == ["text", "tool", "text"] + assert ordered[0]["text"] == "Let me search first." + assert ordered[1]["toolUseId"] == "toolu_1" + assert ordered[2]["text"] == "Found it, here's the fix." + + +@pytest.mark.skipif(NODE is None, reason="node not on PATH") +def test_compact_mode_keeps_ordered_parts_helper_off(): + message = { + "role": "assistant", + "content": [ + {"type": "text", "text": "Before"}, + {"type": "tool_use", "id": "toolu_1", "name": "grep", "input": {}}, + {"type": "text", "text": "After"}, + ], + } + + assert _run_helper(message, transparent=False) is None + + +def test_render_messages_wires_ordered_parts_into_transparent_stream_and_skips_duplicate_tool_rows(): + assert "let orderedTransparentParts=_transparentStreamOrderedParts(m);" in UI_JS + assert "const transparentOrderedToolIds=new Set();" in UI_JS + assert "const toolCall=_transparentOrderedToolCall(part, rawIdx, transparentOrderedToolCallsByTid, transparentToolResultsByTid);" in UI_JS + assert "if(part.toolUseId) transparentOrderedToolIds.add(part.toolUseId);" in UI_JS + assert "if(tid&&transparentOrderedToolIds.has(tid)) continue;" in UI_JS