fix(chat): preserve transparent stream prose order for mixed turns (#3397)

This commit is contained in:
Rod Boev authored and nesquena-hermes committed 2026-06-25 21:11:55 +00:00
1 parent ada386acd4
commit b590518cf4
2 files changed
+262 -1

No files matched your search

+159 -1
View File
@@ -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;vi<visWithIdx.length;vi++)
for(let vi=0;vi<renderVisWithIdx.length;vi++){
@@ -11488,6 +11579,7 @@ function renderMessages(options){
}
let content=m.content||'';
let thinkingText='';
let orderedTransparentParts=_transparentStreamOrderedParts(m);
if(Array.isArray(content)){
content=content.filter(p=>p&&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:''}<div class="msg-body">${partBodyHtml}</div>${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;
@@ -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