fix(chat): preserve transparent stream prose order for mixed turns (#3397)
This commit is contained in:
1 parent
ada386acd4
commit
b590518cf4
2 files changed
+262
-1
No files matched your search
+159
-1
@@ -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
|
||||
Reference in new issue
Block a user