diff --git a/CHANGELOG.md b/CHANGELOG.md index f171bb9e..b5f2b1f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,8 @@ ### Fixed +- **Settled transcripts with duplicate assistant turns no longer mis-attribute tool-output rendering.** When a reloaded conversation contained byte-identical assistant messages, the legacy tool-metadata fallback used `indexOf` to test anchor ownership, which always resolved to the *first* matching message — so an anchor-owned turn could be wrongly treated as a legacy fallback (or vice versa). Ownership is now gated by each message's actual raw index, and the shared check is consolidated into one helper used at both call sites. Thanks @franksong2702. (#5242) + - **The session sidebar stays fast with a very large archive, and archived search still finds everything.** Archived sidebar rows are now paged (capped per request) instead of all loaded at once, so an instance with thousands of archived sessions no longer pays to render them all. When you turn on "Show archived" and start a search, the sidebar transparently refetches without the archive cap so a title/id match beyond the first archived page is still reachable, then restores normal paging when the search clears. Default (non-search) paging keeps the existing 2000-row safety cap, and a render-generation guard prevents a slower capped/uncapped response from overwriting newer sidebar state. Thanks @santastabber. (#5200) - **Post-restart config reloads no longer stampede under concurrent traffic.** Right after a restart (or profile switch), several in-flight requests could all see the config cache as stale and each trigger a full `config.yaml` re-parse at once, briefly stalling the single-process server. Stale reads now route through a shared gate that re-checks freshness inside the config lock (double-checked locking), so concurrent stale readers collapse to a single refresh instead of a thundering herd. Forced reloads (writes, profile switches) and the in-memory-override guard keep their existing semantics. Thanks @rodboev. (#5235, fixes #5220) - **Fewer false "Connection Lost" banners on instances with large session state.** Under heavy session/sidebar state, transient slow responses and benign hidden-tab SSE churn could flap the offline banner even though the server was fine. The probe now waits for consecutive failures before declaring offline (while genuine signals — `navigator.onLine` going offline, startup failures — still surface immediately and bypass that delay), sidebar refreshes coalesce while one is in flight by draining the latest queued request instead of dropping it, the per-session SSE suspends/resumes cleanly across tab-visibility changes (reopening from a dedicated holder so it can't get stuck closed), and large-transcript message loads get a longer (120s) timeout so they don't trip the failure counter. A true outage still shows the banner and clears on recovery. Thanks @franksong2702. (#5201) diff --git a/docs/architecture/stable-assistant-turn-anchor-phase0.md b/docs/architecture/stable-assistant-turn-anchor-phase0.md index 8e79fb54..b09ef65a 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 13815be1..c5064caa 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 d3952347..041fda84 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 d82f7650..b7dcffe8 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 6f399ad0..c175fde7 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():