diff --git a/static/messages.js b/static/messages.js index 9f1532f1..debd4e56 100644 --- a/static/messages.js +++ b/static/messages.js @@ -6490,6 +6490,12 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ return `${m.role}|${ts}|${body.slice(0,160)}`; } const _EPHEMERAL_TURN_FIELDS=['_turnUsage','_turnDuration','_turnTps','_gatewayRouting','_statusCard','_anchor_stream_id','_anchor_activity_scene']; + function _isHistoricalAnchorActivityScene(scene){ + if(!scene||typeof scene!=='object') return false; + const identity=scene.identity&&typeof scene.identity==='object'?scene.identity:null; + const turnId=identity&&typeof identity.turn_id==='string'?identity.turn_id:''; + return turnId.indexOf('historical:')===0; + } function _carryForwardEphemeralTurnFields(prevMessages, nextMessages){ if(!Array.isArray(prevMessages)||!Array.isArray(nextMessages)) return nextMessages; if(!prevMessages.length||!nextMessages.length) return nextMessages; @@ -6504,6 +6510,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ const k=_messageIdentityKey(nm); if(!k) continue; const pm=prevIdx.get(k); if(!pm) continue; for(const f of _EPHEMERAL_TURN_FIELDS){ + if(f==='_anchor_activity_scene'&&_isHistoricalAnchorActivityScene(pm[f])) continue; if(pm[f]!=null && nm[f]==null) nm[f]=pm[f]; } } diff --git a/static/ui.js b/static/ui.js index f4b77b60..79a0e837 100644 --- a/static/ui.js +++ b/static/ui.js @@ -14710,14 +14710,16 @@ function _toolArgsSnapshot(args, limit){ function _idLinkedHistoricalMessageText(message){ if(!message||typeof message!=='object') return ''; const content=message.content; - if(typeof content==='string') return content.trim(); + if(typeof content==='string') return content; if(!Array.isArray(content)) return ''; - return content.map(part=>{ - if(typeof part==='string') return part; + return content.filter(part=>part&&typeof part==='object'&&part.type==='text').map(part=>{ if(!part||typeof part!=='object') return ''; - if(part.type!=='text'&&part.type!=='input_text'&&part.type!=='output_text') return ''; return String(part.text||part.content||''); - }).join('').trim(); + }).join('\n'); +} + +function _idLinkedHistoricalMessageHasVisibleText(message){ + return _idLinkedHistoricalMessageText(message).trim()!==''; } function _idLinkedHistoricalMessageRef(message, rawIdx){ @@ -14802,17 +14804,17 @@ function _idLinkedHistoricalTurnScene(messages, turnStart, turnEnd, options){ if(message._anchor_activity_scene) return null; if(role==='assistant'){ assistantIndexes.push(rawIdx); - const visibleText=_idLinkedHistoricalMessageText(message); + const hasVisibleText=_idLinkedHistoricalMessageHasVisibleText(message); const reasoningText=_assistantReasoningPayloadText(message); - if(visibleText) visibleAssistantIndexes.push(rawIdx); + if(hasVisibleText) visibleAssistantIndexes.push(rawIdx); if(reasoningText) return null; if(_idLinkedHistoricalHasVisibleSidecar(message)) return null; if(Array.isArray(message._partial_tool_calls)&&message._partial_tool_calls.length) return null; if(Array.isArray(message.content)&&message.content.some(part=>part&&typeof part==='object'&&part.type==='tool_use')) return null; const toolCalls=Array.isArray(message.tool_calls)?message.tool_calls:[]; - if(toolCalls.length&&visibleText) return null; + if(toolCalls.length&&hasVisibleText) return null; if(!toolCalls.length){ - if(visibleText) continue; + if(hasVisibleText) continue; return null; } if(message.content!==undefined&&message.content!==null&&message.content!=='') return null; diff --git a/tests/test_issue5224_terminal_error_transcript_preserve.py b/tests/test_issue5224_terminal_error_transcript_preserve.py index f281f926..29ac384d 100644 --- a/tests/test_issue5224_terminal_error_transcript_preserve.py +++ b/tests/test_issue5224_terminal_error_transcript_preserve.py @@ -61,6 +61,7 @@ function installRuntimeHelpers() { "_filterRecoveryControlMessages", "_replaceMarkerOnlyAssistantWithStreamError", "_messageIdentityKey", + "_isHistoricalAnchorActivityScene", "_carryForwardEphemeralTurnFields", "_isTerminalStreamErrorMarkerMessage", "_ensureSingleTerminalStreamErrorMarker", diff --git a/tests/test_issue6220_id_linked_tool_anchor_hydration.py b/tests/test_issue6220_id_linked_tool_anchor_hydration.py index 0a3db73b..3feba674 100644 --- a/tests/test_issue6220_id_linked_tool_anchor_hydration.py +++ b/tests/test_issue6220_id_linked_tool_anchor_hydration.py @@ -83,6 +83,7 @@ for(const name of [ '_toolArgsSnapshot', '_assistantReasoningPayloadText', '_idLinkedHistoricalMessageText', + '_idLinkedHistoricalMessageHasVisibleText', '_idLinkedHistoricalMessageRef', '_idLinkedHistoricalToolArguments', '_idLinkedHistoricalToolResultRaw', @@ -91,7 +92,8 @@ for(const name of [ '_idLinkedHistoricalTurnScene', '_hydrateIdLinkedHistoricalToolScenes', ]){{ - vm.runInContext(extractFunc(name), sandbox, {{filename:'ui.js:' + name}}); + const fn = src.indexOf('function ' + name) >= 0 ? extractFunc(name) : null; + if(fn) vm.runInContext(fn, sandbox, {{filename:'ui.js:' + name}}); }} if({str(throw_helper).lower()}){{ @@ -135,6 +137,111 @@ console.log(JSON.stringify({{first, second, unchanged:afterFirst===JSON.stringif return json.loads(result.stdout) +def _run_carry_forward_then_hydration(messages: list[dict]) -> dict: + assert NODE, "node is required for Anchor hydration behavior tests" + script = f""" +const fs = require('fs'); +const vm = require('vm'); +const uiSrc = fs.readFileSync({json.dumps(str(ROOT / 'static' / 'ui.js'))}, 'utf8'); +const messagesSrc = fs.readFileSync({json.dumps(str(ROOT / 'static' / 'messages.js'))}, 'utf8'); +let src = uiSrc; +{_EXTRACT_FUNC_JS} + +function extractFuncFrom(source, name){{ + const previous = src; + src = source; + try{{ return extractFunc(name); }} + finally{{ src = previous; }} +}} + +const sandbox = {{console}}; +sandbox.window = sandbox; +sandbox.globalThis = sandbox; +sandbox._EPHEMERAL_TURN_FIELDS = [ + '_turnUsage', + '_turnDuration', + '_turnTps', + '_gatewayRouting', + '_statusCard', + '_anchor_stream_id', + '_anchor_activity_scene', +]; +vm.createContext(sandbox); +vm.runInContext( + fs.readFileSync({json.dumps(str(ANCHOR_JS))}, 'utf8'), + sandbox, + {{filename:'assistant_turn_anchors.js'}} +); +sandbox._redactToolTargetLabel = value => String(value); + +for(const name of [ + '_clipCliToolSnippet', + '_cliToolResultText', + '_cliLooksLikePatchDiff', + '_cliToolResultSnippet', + '_prefixedCliDiffLines', + '_firstOwnedValue', + '_cliPatchSnippetFromArgs', + '_cliToolCardSnippet', + '_cliToolCardHasDiffSnippet', + '_anchorSceneToolCallFromRow', + '_toolArgsSnapshot', + '_assistantReasoningPayloadText', + '_idLinkedHistoricalMessageText', + '_idLinkedHistoricalMessageHasVisibleText', + '_idLinkedHistoricalMessageRef', + '_idLinkedHistoricalToolArguments', + '_idLinkedHistoricalToolResultRaw', + '_idLinkedHistoricalRedactSnippet', + '_idLinkedHistoricalHasVisibleSidecar', + '_idLinkedHistoricalTurnScene', + '_hydrateIdLinkedHistoricalToolScenes', +]){{ + const fn = uiSrc.indexOf('function ' + name) >= 0 ? extractFuncFrom(uiSrc, name) : null; + if(fn) vm.runInContext(fn, sandbox, {{filename:'ui.js:' + name}}); +}} +for(const name of [ + '_messageIdentityKey', + '_isHistoricalAnchorActivityScene', + '_carryForwardEphemeralTurnFields', +]){{ + const fn = messagesSrc.indexOf('function ' + name) >= 0 ? extractFuncFrom(messagesSrc, name) : null; + if(fn) vm.runInContext(fn, sandbox, {{filename:'messages.js:' + name}}); +}} + +const original = {json.dumps(messages)}; +const prev = JSON.parse(JSON.stringify(original)); +sandbox.prev = prev; +vm.runInContext( + "_hydrateIdLinkedHistoricalToolScenes(prev, {{sessionId:'sid-6220', mode:'compact_worklog'}})", + sandbox +); +const next = JSON.parse(JSON.stringify(original)); +sandbox.next = next; +vm.runInContext("_carryForwardEphemeralTurnFields(prev, next)", sandbox); +const carried = next + .map((message, index) => message && message._anchor_activity_scene ? {{index, turn_id:message._anchor_activity_scene.identity.turn_id, tools:message._anchor_activity_scene.activity_rows.map(row => row.tool_call_id)}} : null) + .filter(Boolean); +const hydrated = vm.runInContext( + "_hydrateIdLinkedHistoricalToolScenes(next, {{sessionId:'sid-6220', mode:'compact_worklog'}})", + sandbox +); +const owners = next + .map((message, index) => message && message._anchor_activity_scene ? {{index, turn_id:message._anchor_activity_scene.identity.turn_id, tools:message._anchor_activity_scene.activity_rows.map(row => row.tool_call_id)}} : null) + .filter(Boolean); +console.log(JSON.stringify({{carried, hydrated, owners}})); +""" + result = subprocess.run( + [NODE, "-e", script], + cwd=ROOT, + text=True, + capture_output=True, + check=False, + ) + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + def _linked_turn(call_id: str = "call-1") -> list[dict]: return [ {"role": "user", "content": "Inspect the workspace"}, @@ -188,6 +295,32 @@ def test_id_linked_historical_tool_turn_hydrates_one_anchor_scene_idempotently() assert row["tool"]["done"] is True +@pytest.mark.skipif(NODE is None, reason="node is required for Anchor hydration tests") +def test_id_linked_historical_final_answer_preserves_raw_string_whitespace(): + messages = _linked_turn() + messages[-1]["content"] = " indented code\n" + + data = _run_hydration(messages) + + assert data["first"] == 1 + assert data["owners"][0]["scene"]["final_answer"] == " indented code\n" + + +@pytest.mark.skipif(NODE is None, reason="node is required for Anchor hydration tests") +def test_id_linked_historical_final_answer_joins_text_parts_like_renderer(): + messages = _linked_turn() + messages[-1]["content"] = [ + {"type": "text", "text": "First paragraph."}, + {"type": "input_text", "text": "ignored input"}, + {"type": "text", "text": "Second paragraph."}, + ] + + data = _run_hydration(messages) + + assert data["first"] == 1 + assert data["owners"][0]["scene"]["final_answer"] == "First paragraph.\nSecond paragraph." + + @pytest.mark.skipif(NODE is None, reason="node is required for Anchor hydration tests") def test_id_linked_historical_multi_tool_turn_preserves_declaration_order(): messages = [ @@ -441,6 +574,26 @@ def test_reused_ids_in_adjacent_complete_turns_remain_turn_scoped(): assert data["owners"][0]["scene"]["identity"]["turn_id"] != data["owners"][1]["scene"]["identity"]["turn_id"] +@pytest.mark.skipif(NODE is None, reason="node is required for Anchor hydration tests") +def test_duplicate_final_answers_rebuild_historical_scenes_after_refresh(): + first_turn = _linked_turn("call-first") + second_turn = _linked_turn("call-second") + first_turn[1]["tool_calls"][0]["function"]["name"] = "first_tool" + first_turn[2]["content"] = "first result" + first_turn[3]["content"] = "Done" + second_turn[1]["tool_calls"][0]["function"]["name"] = "second_tool" + second_turn[2]["content"] = "second result" + second_turn[3]["content"] = "Done" + + data = _run_carry_forward_then_hydration(first_turn + second_turn) + + assert data["carried"] == [] + assert data["hydrated"] == 2 + assert [owner["index"] for owner in data["owners"]] == [3, 7] + assert [owner["tools"] for owner in data["owners"]] == [["call-first"], ["call-second"]] + assert data["owners"][0]["turn_id"] != data["owners"][1]["turn_id"] + + def test_render_messages_hydrates_before_visibility_and_fallback_ownership_scan(): start = UI_JS.index("function renderMessages(options)") body = UI_JS[start : UI_JS.index("\nfunction ", start + 1)]