diff --git a/docs/architecture/stable-assistant-turn-anchor-phase0.md b/docs/architecture/stable-assistant-turn-anchor-phase0.md index 8e79fb54e..b09ef65ae 100644 --- a/docs/architecture/stable-assistant-turn-anchor-phase0.md +++ b/docs/architecture/stable-assistant-turn-anchor-phase0.md @@ -309,9 +309,13 @@ must not compete with an anchor-owned turn. | Historical / non-anchor transcripts | No anchor owner is available. | Raw `content[]`, persisted `session.tool_calls`, role=`tool` rows, and partial tool-call snapshots continue to recover visible tool/prose history. | None. These paths remain until replay/runtime-journal coverage proves the same transcript shapes hydrate through anchors. | | Live reattach / session switch | `renderLiveAnchorActivityScene()` consumes the projected live scene, and session switch first attempts runtime-journal anchor scene restore. | Saved live DOM snapshots and `INFLIGHT` tool replay. | Snapshot fallback only runs when no usable live anchor scene can be rendered. | -This matrix is an audit baseline, not permission to delete fallbacks. Fallback -removal should wait for replay/runtime-journal parity tests that prove all -supported settled transcript sources hydrate through anchors. +This matrix is the current settled-render contract, backed by runtime-journal +hydration parity and behavior-level renderer ownership coverage. Fallback paths +are compatibility-only rebuilds for historical or genuinely non-anchor +transcripts, and the renderer must gate them by explicit raw transcript indexes +so object identity or duplicate message references cannot let fallback rows +compete with an anchor-owned turn. Removing a compatibility path still requires +new evidence that the supported transcript shape hydrates through anchors. ## Source Event Classification diff --git a/static/ui.js b/static/ui.js index 13815be19..c5064caa2 100644 --- a/static/ui.js +++ b/static/ui.js @@ -11773,6 +11773,14 @@ function _transparentStreamOrderedParts(message){ } return hasText&&hasTool?ordered:null; } +function _legacySettledFallbackHasToolMetadata(message){ + if(!message||message.role!=='assistant'||message._anchor_activity_scene) return false; + return !!( + (Array.isArray(message.tool_calls)&&message.tool_calls.length>0)|| + (Array.isArray(message._partial_tool_calls)&&message._partial_tool_calls.length>0)|| + (Array.isArray(message.content)&&message.content.some(part=>part&&typeof part==='object'&&part.type==='tool_use')) + ); +} function _transparentOrderedDisplayText(text){ return _stripWorkspaceDisplayPrefix( _stripAttachedFilesMarkerForDisplay( @@ -12614,12 +12622,8 @@ function renderMessages(options){ // tracking, or runs that didn't go through the normal streaming path), build // a display list from per-message tool_calls (OpenAI format) stored in each // assistant message. This covers the reload case described in issue #140. - const hasMessageToolMetadata=!S.busy&&Array.isArray(S.messages)&&S.messages.some(m=> - m&&m.role==='assistant'&&!anchorOwnedAssistantRawIdxs.has(S.messages.indexOf(m))&&( - (Array.isArray(m.tool_calls)&&m.tool_calls.length>0)|| - (Array.isArray(m._partial_tool_calls)&&m._partial_tool_calls.length>0)|| - (Array.isArray(m.content)&&m.content.some(p=>p&&typeof p==='object'&&p.type==='tool_use')) - ) + const hasMessageToolMetadata=!S.busy&&Array.isArray(S.messages)&&S.messages.some((m,rawIdx)=> + !anchorOwnedAssistantRawIdxs.has(rawIdx)&&_legacySettledFallbackHasToolMetadata(m) ); if(!S.busy && (hasMessageToolMetadata||!S.toolCalls||!S.toolCalls.length)){ // Index tool outputs by tool_call_id / tool_use_id so the @@ -12664,10 +12668,7 @@ function renderMessages(options){ } if(m.role==='assistant'){ if(anchorOwnedAssistantRawIdxs.has(rawIdx)) return; - const hasTopLevelToolCalls=Array.isArray(m.tool_calls)&&m.tool_calls.length>0; - const hasPartialToolCalls=Array.isArray(m._partial_tool_calls)&&m._partial_tool_calls.length>0; - const hasContentToolUse=Array.isArray(m.content)&&m.content.some(p=>p&&typeof p==='object'&&p.type==='tool_use'); - if(hasTopLevelToolCalls||hasContentToolUse||hasPartialToolCalls) fallbackToolSources.push({m,rawIdx}); + if(_legacySettledFallbackHasToolMetadata(m)) fallbackToolSources.push({m,rawIdx}); } }); const derived=[]; diff --git a/tests/test_anchor_fallback_ownership.py b/tests/test_anchor_fallback_ownership.py index d39523477..041fda845 100644 --- a/tests/test_anchor_fallback_ownership.py +++ b/tests/test_anchor_fallback_ownership.py @@ -264,7 +264,9 @@ def test_phase0_doc_records_settled_fallback_ownership_matrix(): assert "| Settled Compact Worklog activity |" in doc assert "| Settled Transparent Stream activity |" in doc assert "| Historical / non-anchor transcripts |" in doc - assert "This matrix is an audit baseline, not permission to delete fallbacks." in doc + assert "This matrix is the current settled-render contract" in doc + assert "compatibility-only rebuilds" in doc + assert "explicit raw transcript indexes" in doc def test_function_extractor_handles_nested_template_literal_interpolation(): @@ -404,6 +406,9 @@ def test_render_messages_keeps_anchor_owned_turn_out_of_legacy_activity_rebuilds render_source = _function_source(_ui_js(), "renderMessages") transparent_source = _function_source(_ui_js(), "_transparentStreamOrderedParts") + legacy_metadata_source = _function_source( + _ui_js(), "_legacySettledFallbackHasToolMetadata" + ) script = textwrap.dedent( f""" class FakeClassList {{ @@ -677,6 +682,7 @@ def test_render_messages_keeps_anchor_owned_turn_out_of_legacy_activity_rebuilds }} eval({json.dumps(transparent_source)}); + eval({json.dumps(legacy_metadata_source)}); eval({json.dumps(render_source)}); const toolResult = {{ role: 'tool', tool_call_id: 'toolu_1', content: 'tool result' }}; @@ -767,11 +773,58 @@ def test_render_messages_keeps_anchor_owned_turn_out_of_legacy_activity_rebuilds sToolCalls: S.toolCalls.length, }}; + elements.msgInner = new FakeElement('div'); + legacyCards = []; + const duplicateAnchorTool = {{ type: 'tool_use', id: 'toolu_anchor_dup', name: 'terminal', input: {{ cmd: 'anchor' }} }}; + const duplicateHistoricalTool = {{ type: 'tool_use', id: 'toolu_hist_dup', name: 'terminal', input: {{ cmd: 'history' }} }}; + const duplicateAnchorCall = {{ + id: 'toolu_anchor_dup', + function: {{ name: 'terminal', arguments: '{{"cmd":"anchor"}}' }}, + }}; + const duplicateAnchorOwned = {{ + role: 'assistant', + content: [{{ type: 'text', text: 'Duplicate answer' }}, duplicateAnchorTool], + tool_calls: [duplicateAnchorCall], + _anchor_activity_scene: {{ + version: 'activity_scene_v1', + activity_rows: [{{ id: 'row-dup', kind: 'tool', role: 'tool', tool: {{ name: 'terminal' }} }}], + final_answer: 'Duplicate answer', + }}, + }}; + const duplicateHistorical = {{ + role: 'assistant', + content: [{{ type: 'text', text: 'Duplicate answer' }}, duplicateHistoricalTool], + }}; + S = {{ + session: {{ + session_id: 's4', + tool_calls: [{{ tid: 'toolu_hist_dup', snippet: 'historical persisted result' }}], + }}, + messages: [ + {{ role: 'user', content: 'anchor turn' }}, + duplicateAnchorOwned, + {{ role: 'user', content: 'historical turn' }}, + duplicateHistorical, + toolResult, + ], + toolCalls: [{{ tid: 'toolu_anchor_dup', assistant_msg_idx: 1, name: 'terminal', snippet: 'anchor session fallback' }}], + busy: false, + }}; + renderMessages(); + const duplicateReferenceSummary = {{ + anchorGroups: elements.msgInner.querySelectorAll('[data-anchor-settled-scene-owner]').length, + legacyGroups: elements.msgInner.querySelectorAll('[data-legacy-fallback-owner]').length, + legacyRows: elements.msgInner.querySelectorAll('.tool-card-row').length, + legacyCards, + sToolCalls: S.toolCalls.length, + }}; + console.log(JSON.stringify({{ selectorSanity, anchorSummary, historicalSummary, rawHistoricalSummary, + duplicateReferenceSummary, }})); """ ) @@ -810,6 +863,15 @@ def test_render_messages_keeps_anchor_owned_turn_out_of_legacy_activity_rebuilds assert raw_snippets["partial_1"] == "partial result" assert raw_snippets["content_1"] == "persisted content result" + duplicate_cards = result["duplicateReferenceSummary"]["legacyCards"] + assert result["duplicateReferenceSummary"]["anchorGroups"] == 1 + assert result["duplicateReferenceSummary"]["legacyGroups"] == 1 + assert result["duplicateReferenceSummary"]["legacyRows"] >= 1 + assert result["duplicateReferenceSummary"]["sToolCalls"] >= 1 + assert {card["tid"] for card in duplicate_cards} == {"toolu_hist_dup"} + assert "toolu_anchor_dup" not in {card["tid"] for card in duplicate_cards} + assert duplicate_cards[0]["snippet"] == "historical persisted result" + def test_settled_legacy_tool_rebuild_excludes_anchor_owned_turns(): render = _function_body(_ui_js(), "renderMessages") @@ -821,7 +883,9 @@ def test_settled_legacy_tool_rebuild_excludes_anchor_owned_turns(): source_collect = render.index("fallbackToolSources.push({m,rawIdx});") assert set_decl < collect_segments < metadata_scan < fallback_sources < source_collect - assert "!anchorOwnedAssistantRawIdxs.has(S.messages.indexOf(m))" in render + assert "S.messages.indexOf(m)" not in render + assert "S.messages.some((m,rawIdx)=>" in render + assert "!anchorOwnedAssistantRawIdxs.has(rawIdx)&&_legacySettledFallbackHasToolMetadata(m)" in render assert "if(anchorOwnedAssistantRawIdxs.has(rawIdx)) return;" in render diff --git a/tests/test_issue401.py b/tests/test_issue401.py index d82f7650e..b7dcffe81 100644 --- a/tests/test_issue401.py +++ b/tests/test_issue401.py @@ -86,8 +86,9 @@ def test_rendermessages_treats_partial_toolcall_assistants_as_visible(): def test_rendermessages_rebuilds_tool_cards_from_partial_tool_calls(): """Fallback reconstruction should include private `_partial_tool_calls` rows.""" - assert "const hasPartialToolCalls=Array.isArray(m._partial_tool_calls)&&m._partial_tool_calls.length>0;" in UI_JS - assert "if(hasTopLevelToolCalls||hasContentToolUse||hasPartialToolCalls) fallbackToolSources.push({m,rawIdx});" in UI_JS + assert "function _legacySettledFallbackHasToolMetadata(message)" in UI_JS + assert "Array.isArray(message._partial_tool_calls)&&message._partial_tool_calls.length>0" in UI_JS + assert "if(_legacySettledFallbackHasToolMetadata(m)) fallbackToolSources.push({m,rawIdx});" in UI_JS assert "if(Array.isArray(m._partial_tool_calls)){" in UI_JS assert "tc.snippet||tc.result||tc.output||tc.preview" in UI_JS assert "done:true" in UI_JS diff --git a/tests/test_live_to_final_anchor_visible_order.py b/tests/test_live_to_final_anchor_visible_order.py index 6f399ad0f..c175fde77 100644 --- a/tests/test_live_to_final_anchor_visible_order.py +++ b/tests/test_live_to_final_anchor_visible_order.py @@ -771,7 +771,12 @@ def test_anchor_owned_settled_turn_skips_legacy_worklog_rebuild(): assert "anchorOwnedAssistantRawIdxs.add(idx)" in render assert "if(anchorOwnedAssistantRawIdxs.has(aIdx)) continue;" in render assert "if(anchorOwnedAssistantRawIdxs.has(rawIdx)) return;" in render - assert "!anchorOwnedAssistantRawIdxs.has(S.messages.indexOf(m))" in render + assert "S.messages.indexOf(m)" not in render + assert "S.messages.some((m,rawIdx)=>" in render + assert ( + "!anchorOwnedAssistantRawIdxs.has(rawIdx)" + "&&_legacySettledFallbackHasToolMetadata(m)" + ) in render def test_transparent_stream_renders_persisted_anchor_scene_after_reload():