From 8cc2d6f8817f9949b9db0a7b86a7e2ccb155270f Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Thu, 9 Jul 2026 12:51:18 -0700 Subject: [PATCH] Release: model-picker Selected badge + Transparent Stream dupe-prefix suppression (#5757, #5758) (#5850) Two visible render PRs, gate-passed (deep-review round), Nathan-approved to ship. - #5757 (rodboev): mark active model with a Selected badge + open its provider group (#5748). Screenshot-verified (badge clear + correctly placed on active row). - #5758 (rodboev): suppress the live-token prose row that is a strict prefix of the final answer (#5749). Provenance-gated (live-prose:* token rows after last tool row only) so it can't eat legitimate narration; reproduction test proves the dupe is gone. Codex SAFE + full regression green (1576 render tests). 18 own tests pass together. Co-authored-by: t Co-authored-by: rodboev --- CHANGELOG.md | 4 + pytest.ini | 1 + static/i18n.js | 15 + static/messages.js | 31 +- static/style.css | 3 +- static/ui.js | 58 +- .../issue5749_captured_session_prefix.json | 36 + ..._5749_anchor_scene_client_prefix_dedupe.py | 41 ++ tests/test_anchor_fallback_ownership.py | 24 +- ...ssue5367_transparent_live_row_reconcile.py | 1 + ...t_issue5748_model_picker_selected_badge.py | 62 ++ ...ue5749_transparent_stream_prefix_dedupe.py | 674 ++++++++++++++++++ ...test_live_to_final_anchor_visible_order.py | 14 +- 13 files changed, 921 insertions(+), 43 deletions(-) create mode 100644 tests/fixtures/issue5749_captured_session_prefix.json create mode 100644 tests/test_5749_anchor_scene_client_prefix_dedupe.py create mode 100644 tests/test_issue5748_model_picker_selected_badge.py create mode 100644 tests/test_issue5749_transparent_stream_prefix_dedupe.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e6a21b93..0d531217 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ ### Fixed +- **The model picker now marks the active model with a "Selected" badge.** The currently-selected conversation model shows a distinct **Selected** pill (separate from the "Configured" state), and the picker opens with that model's provider group expanded and scrolled into view — so it's obvious at a glance which model is in use. Thanks @rodboev. (#5757, #5748) + +- **Transparent Stream no longer briefly renders a duplicate of the answer.** A live-token prose snapshot that was a strict prefix of the final answer escaped the existing dedupe guards (which only fired on near-complete matches) and rendered as a visible duplicate. A new provenance-gated guard suppresses only the ephemeral live-token accumulator row (role `prose` / kind `process_prose` / `token` source / `live-prose:*` id) when it's a strict shorter prefix of the settled answer and sits after the last tool row — so genuine intermediate narration is never suppressed. Thanks @rodboev. (#5758, #5749) + - **A dropped SSE connection can no longer grow the offline replay buffer without bound.** When a stream disconnected without being cancelled, the per-`StreamChannel` offline replay buffer grew for the whole turn (a per-abandoned-turn memory risk). It's now a bounded `deque` (drop-oldest); the reconnect tail is intact and any dropped frames are recovered through the run-journal `last_event_id` replay path (the client requests journal replay on reconnect; the server enforces journal gap coverage before draining the tail). Thanks @ai-ag2026. (#5788, #4633) - **The embedded terminal no longer leaks a file descriptor, reader thread, and shell process on exit.** When the terminal client disconnected or the shell exited, the PTY fd, its reader thread, and the shell process were left dangling — accumulating over a long uptime (part of the #4633 long-uptime-crash family). Teardown now closes the fd once, stops the reader thread, and reaps the shell on both the client-exit and shell-exit paths. Thanks @ai-ag2026. (#5835, #4633) diff --git a/pytest.ini b/pytest.ini index 1c9a4ee0..7261b5ed 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,3 +1,4 @@ [pytest] markers = integration: tests that hit the live test server or external integration surface + reproduction: tests that execute a direct issue reproduction fixture diff --git a/static/i18n.js b/static/i18n.js index d2576da9..5550b4a5 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -212,6 +212,7 @@ const LOCALES = { model_search_placeholder: 'Search models…', model_search_no_results: 'No models found', model_group_configured: 'Configured', + model_badge_selected: 'Selected', ws_search_placeholder: 'Search workspaces…', ws_no_results: 'No workspaces found', workspace_switcher_aria: 'Switch workspace. Current workspace: {0}.', @@ -1914,6 +1915,7 @@ const LOCALES = { model_search_placeholder: 'Cerca modelli…', model_search_no_results: 'Nessun modello trovato', model_group_configured: 'Configurato', + model_badge_selected: 'Selezionato', ws_search_placeholder: 'Cerca workspace…', ws_no_results: 'Nessun workspace trovato', workspace_switcher_aria: 'Cambia workspace. Workspace corrente: {0}.', @@ -3607,6 +3609,7 @@ const LOCALES = { model_search_placeholder: 'モデルを検索…', model_search_no_results: 'モデルが見つかりません', model_group_configured: '設定済み', + model_badge_selected: '選択中', ws_search_placeholder: 'ワークスペースを検索…', ws_no_results: 'ワークスペースが見つかりません', workspace_switcher_aria: 'ワークスペースを切り替えます。現在のワークスペース: {0}。', @@ -5395,6 +5398,7 @@ const LOCALES = { focus_label: 'Фокус', model_search_no_results: 'Модели не найдены', model_group_configured: 'Настроенные', + model_badge_selected: 'Выбрано', ws_search_placeholder: 'Поиск рабочих пространств…', ws_no_results: 'Рабочие пространства не найдены', workspace_switcher_aria: 'Переключить рабочее пространство. Текущее рабочее пространство: {0}.', @@ -6914,6 +6918,7 @@ const LOCALES = { model_search_placeholder: 'Buscar modelos…', model_search_no_results: 'No se encontraron modelos', model_group_configured: 'Configurados', + model_badge_selected: 'Seleccionado', ws_search_placeholder: 'Buscar espacios de trabajo…', ws_no_results: 'No se encontraron espacios de trabajo', workspace_switcher_aria: 'Cambiar espacio de trabajo. Espacio de trabajo actual: {0}.', @@ -9539,6 +9544,7 @@ const LOCALES = { model_search_placeholder: 'Modelle suchen…', model_search_no_results: 'Keine Modelle gefunden', model_group_configured: 'Konfiguriert', + model_badge_selected: 'Ausgewählt', ws_search_placeholder: 'Arbeitsbereiche suchen…', ws_no_results: 'Keine Arbeitsbereiche gefunden', workspace_switcher_aria: 'Workspace wechseln. Aktueller Workspace: {0}.', @@ -10176,6 +10182,7 @@ const LOCALES = { model_search_placeholder: '搜索模型…', model_search_no_results: '未找到模型', model_group_configured: '已配置', + model_badge_selected: '已选择', ws_search_placeholder: '搜索工作区…', ws_no_results: '未找到工作区', workspace_switcher_aria: '切换工作区。当前工作区:{0}。', @@ -11829,6 +11836,7 @@ const LOCALES = { model_search_placeholder: '搜尋模型…', model_search_no_results: '找不到模型', model_group_configured: '已設定', + model_badge_selected: '已選取', ws_search_placeholder: '搜尋工作區…', ws_no_results: '找不到工作區', workspace_switcher_aria: '切換工作區。目前工作區:{0}。', @@ -13458,6 +13466,7 @@ const LOCALES = { session_toolsets_failed: 'Falha ao atualizar os conjuntos de ferramentas: ', model_search_no_results: 'Nenhum modelo encontrado', model_group_configured: 'Configurados', + model_badge_selected: 'Selecionado', ws_search_placeholder: 'Buscar espaços de trabalho…', ws_no_results: 'Nenhum espaço de trabalho encontrado', workspace_switcher_aria: 'Alternar espaço de trabalho. Espaço de trabalho atual: {0}.', @@ -15027,6 +15036,7 @@ const LOCALES = { session_toolsets_failed: '도구 세트를 업데이트하지 못했습니다: ', model_search_no_results: 'No models found', model_group_configured: '구성됨', + model_badge_selected: '선택됨', ws_search_placeholder: '워크스페이스 검색…', ws_no_results: '워크스페이스를 찾을 수 없습니다', workspace_switcher_aria: '워크스페이스 전환. 현재 워크스페이스: {0}.', @@ -16713,6 +16723,7 @@ const LOCALES = { model_search_placeholder: 'Rechercher des modèles\u2026', model_search_no_results: 'Aucun modèle trouvé', model_group_configured: 'Configuré', + model_badge_selected: 'Sélectionné', ws_search_placeholder: 'Rechercher des espaces de travail\u2026', ws_no_results: 'Aucun espace de travail trouvé', workspace_switcher_aria: 'Changer d’espace de travail. Espace de travail actuel : {0}.', @@ -19582,6 +19593,7 @@ const LOCALES = { model_custom_placeholder: 'např. openai/gpt-5.4', model_did_you_mean: (m) => ` — měli jste na mysli „${m}“?`, model_group_configured: 'Nastavené', + model_badge_selected: 'Vybráno', model_not_found_label: 'Model nenalezen', model_scope_advisory: 'Vztahuje se na tuto konverzaci z vaší další zprávy.', model_scope_toast: 'Vztahuje se na tuto konverzaci z vaší další zprávy.', @@ -20047,6 +20059,7 @@ const LOCALES = { session_toolsets_failed: 'Araç setleri güncellenemedi: ', model_search_no_results: 'Hiçbir model bulunamadı', model_group_configured: 'Yapılandırılmış', + model_badge_selected: 'Seçili', ws_search_placeholder: 'Çalışma alanlarını arayın\u2026', ws_no_results: 'Çalışma alanı bulunamadı', workspace_switcher_aria: 'Çalışma alanını değiştir. Geçerli çalışma alanı: {0}.', @@ -21726,6 +21739,7 @@ const LOCALES = { model_search_placeholder: 'Szukaj modeli…', model_search_no_results: 'Nie znaleziono żadnych modeli', model_group_configured: 'Skonfigurowane', + model_badge_selected: 'Zaznaczony', ws_search_placeholder: 'Szukaj obszarów roboczych…', ws_no_results: 'Nie znaleziono żadnych obszarów roboczych', workspace_switcher_aria: 'Przełącz obszar roboczy. Bieżący obszar roboczy: {0}.', @@ -23391,6 +23405,7 @@ const LOCALES = { model_search_placeholder: 'Tìm model…', model_search_no_results: 'Không tìm thấy model nào', model_group_configured: 'Đã cấu hình', + model_badge_selected: 'Đã chọn', ws_search_placeholder: 'Tìm workspace…', ws_no_results: 'Không tìm thấy workspace nào', workspace_switcher_aria: 'Chuyển workspace. Workspace hiện tại: {0}.', diff --git a/static/messages.js b/static/messages.js index 5a435e43..81de2fb8 100644 --- a/static/messages.js +++ b/static/messages.js @@ -3495,11 +3495,27 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ const rows=[]; const seen=new Set(); const seenTextKeys=[]; - const pushRow=(row)=>{ + const projectedRows=Array.isArray(base.activity_rows)?base.activity_rows:[]; + const orderedRows=[]; + for(const row of projectedRows){ + if(row&&row.role==='terminal') continue; + orderedRows.push(row); + } + for(let idx=turnStart+1;idx<=lastAsstIndex;idx+=1){ + const bucket=messageRows.get(idx)||[]; + for(const row of bucket) orderedRows.push(row); + } + for(const row of projectedRows){ + if(row&&row.role==='terminal') orderedRows.push(row); + } + const lastNonTerminalWorkRowIndex=orderedRows.reduce((last,row,idx)=>(row&&row.role==='tool')?idx:last,-1); + const rowIsLiveTokenFinalPrefix=(row,textKey,finalSegmentEligible)=>finalSegmentEligible&&row&&row.role==='prose'&&row.kind==='process_prose'&&String(row.source_event_type||'')==='token'&&String(row.local_id||'').startsWith('live-prose:')&&textKey&&finalKey&&textKey.length{ if(!row||typeof row!=='object') return; row=_anchorSceneSettleLiveRunningRow(row,hasSettledThinking); if(!row||typeof row!=='object') return; const textKey=_anchorSceneTextKey(row.text); + if(rowIsLiveTokenFinalPrefix(row,textKey,rowIndex>lastNonTerminalWorkRowIndex)) return; const isTextual=row.role==='prose'||row.role==='thinking'; if(isTextual&&_anchorSceneRowLooksLikeFinalAnswer(textKey,finalKey)) return; if(isTextual&&_anchorSceneRowTextOverlapsExisting(textKey,seenTextKeys)) return; @@ -3514,18 +3530,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ seq:rows.length, }); }; - const projectedRows=Array.isArray(base.activity_rows)?base.activity_rows:[]; - for(const row of projectedRows){ - if(row&&row.role==='terminal') continue; - pushRow(row); - } - for(let idx=turnStart+1;idx<=lastAsstIndex;idx+=1){ - const bucket=messageRows.get(idx)||[]; - for(const row of bucket) pushRow(row); - } - for(const row of projectedRows){ - if(row&&row.role==='terminal') pushRow(row); - } + orderedRows.forEach((row,idx)=>pushRow(row,idx)); const scene={ ...base, version:'activity_scene_v1', diff --git a/static/style.css b/static/style.css index 16031711..e3b2419c 100644 --- a/static/style.css +++ b/static/style.css @@ -3237,7 +3237,7 @@ .ws-dropdown{display:none;position:absolute;bottom:calc(100% + 4px);left:0;right:0;min-width:200px;background:var(--surface);border:1px solid var(--border2);border-radius:10px;box-shadow:0 -4px 24px rgba(0,0,0,.4);z-index:200;overflow:hidden;max-height:320px;overflow-y:auto;} .ws-dropdown.open{display:block;} .ws-dropdown-footer{left:0;right:auto;bottom:calc(100% + 4px);min-width:280px;max-width:min(420px,calc(100vw - 32px));} -.model-dropdown{display:none;position:absolute;bottom:calc(100% + 4px);left:0;min-width:280px;max-width:min(420px,calc(100vw - 32px));background:var(--surface);border:1px solid var(--border2);border-radius:10px;box-shadow:0 -4px 24px rgba(0,0,0,.4);z-index:200;overflow:hidden;max-height:320px;overflow-y:auto;} +.model-dropdown{display:none;position:absolute;bottom:calc(100% + 4px);left:0;min-width:280px;max-width:min(420px,calc(100vw - 32px));background:var(--surface);border:1px solid var(--border2);border-radius:10px;box-shadow:0 -4px 24px rgba(0,0,0,.4);z-index:200;overflow:hidden;max-height:min(70vh,640px);overflow-y:auto;} .model-dropdown.open{display:block;} .model-scope-note{position:sticky;top:0;z-index:2;padding:9px 14px;border-bottom:1px solid var(--border);color:var(--text);font-size:11px;font-weight:650;line-height:1.4;background:color-mix(in srgb,var(--surface) 82%,var(--accent-bg));box-shadow:0 1px 0 rgba(0,0,0,.12);} .model-group{padding:8px 14px 4px;font-size:10px;font-weight:700;letter-spacing:.04em;color:var(--muted);text-transform:uppercase;border-top:1px solid var(--border2);margin-top:2px;}.model-group.collapsible{cursor:pointer;user-select:none}.model-group.collapsible:before{content:'\25b6';display:inline-block;font-size:8px;margin-right:5px;transition:transform .15s ease}.model-group.collapsible.open:before{transform:rotate(90deg)}.model-group-body:empty{display:none} @@ -3252,6 +3252,7 @@ .model-opt-badge{display:inline-flex;align-items:center;justify-content:center;padding:2px 7px;border-radius:999px;font-size:10px;font-weight:700;letter-spacing:.02em;text-transform:uppercase;border:1px solid transparent;} .model-opt-badge--primary{background:rgba(50,184,198,.16);border-color:rgba(50,184,198,.32);color:#8fe7ef;} .model-opt-badge--fallback{background:rgba(255,184,77,.14);border-color:rgba(255,184,77,.28);color:#ffd18a;} +.model-opt-badge--selected{background:rgba(99,179,255,.16);border-color:rgba(99,179,255,.34);color:#cfe8ff;} .model-opt-id{display:block;font-size:10px;color:var(--muted);line-height:1.3;opacity:.72;word-break:break-word;} .model-provider-hint{margin:2px 14px 6px;padding:7px 9px;border:1px solid var(--border2);border-radius:7px;background:color-mix(in srgb,var(--surface) 90%,var(--warning));color:var(--muted);font-size:11px;line-height:1.35;} .model-opt-provider{display:inline-flex;align-items:center;padding:1px 6px;border-radius:4px;font-size:9px;font-weight:600;letter-spacing:.03em;color:var(--muted);background:rgba(255,255,255,.05);border:1px solid var(--border2);margin-left:auto;white-space:nowrap;flex-shrink:0;} diff --git a/static/ui.js b/static/ui.js index 28374d4c..f7d11f15 100644 --- a/static/ui.js +++ b/static/ui.js @@ -3771,6 +3771,15 @@ function renderModelDropdown(){ } return 500; }; + const _selectedModelState=(typeof _modelStateForSelect==='function')?_modelStateForSelect(sel,sel.value):{model:sel&&sel.value||'',model_provider:null}; + const _modelProviderForSelectedBadge=(m)=>{ + const _provider=String((m&&m.providerId)||(m&&m.badge&&m.badge.provider)||((typeof _providerFromModelValue==='function')?_providerFromModelValue(m&&m.value):'')||'').trim(); + return (_provider&&_provider!=='default')?_provider:null; + }; + const _isSelectedModelRow=(m)=>String((m&&m.value)||'')===String((_selectedModelState&&_selectedModelState.model)||(sel&&sel.value)||'')&&String(_modelProviderForSelectedBadge(m)||'')===String((_selectedModelState&&_selectedModelState.model_provider)||''); + const _selectedModelBadge=(m)=>_isSelectedModelRow(m) + ?`${esc(t('model_badge_selected')||'Selected')}` + :''; const _renderProviderEndpointHint=(entry,parent)=>{ if(!entry||!entry.label||!entry.modelsEndpointError) return; const hint=document.createElement('div'); @@ -3780,13 +3789,13 @@ function renderModelDropdown(){ }; // Build a single model-option row (mirrors the main render loop's row markup), // used both by the main render and by the in-place overflow reveal below. - const _buildModelRow=(m,sel,withProviderChip)=>{ + const _buildModelRow=(m,withProviderChip)=>{ const row=document.createElement('div'); - row.className='model-opt'+(m.value===sel.value?' active':''); + row.className='model-opt'+(_isSelectedModelRow(m)?' active':''); const badgeHtml=m.badge?`${esc(m.badge.label||'Configured')}`:''; const _plainGroup=m.group?String(m.group).replace(/\s*\(\d+\s+of\s+\d+\)\s*$/,''):''; const providerChip=(_plainGroup&&withProviderChip)?`${esc(_plainGroup)}`:''; - row.innerHTML=`
${esc(m.name)}${badgeHtml}${providerChip}
${esc(m.id)}`; + row.innerHTML=`
${esc(m.name)}${badgeHtml}${_selectedModelBadge(m)}${providerChip}
${esc(m.id)}`; row.onclick=()=>selectFromDropdown(m.value,m.providerId||(m.badge&&m.badge.provider)||null); return row; }; @@ -3840,7 +3849,7 @@ function renderModelDropdown(){ for(const m of extraModels){ if(!m||!m.id) continue; if(_alreadyShown.has(esc(m.id))) continue; - const row=_buildModelRow({value:m.id,name:m.label||m.id,id:m.id,group:_plainLabel,groupKey,providerId:(og.dataset&&og.dataset.provider)||''},sel,false); + const row=_buildModelRow({value:m.id,name:m.label||m.id,id:m.id,group:_plainLabel,groupKey,providerId:(og.dataset&&og.dataset.provider)||''},false); wrap.insertBefore(row,moreEl); if(!firstNewRow) firstNewRow=row; } @@ -3895,17 +3904,17 @@ function renderModelDropdown(){ const _selectedGroupKey=(()=>{ const _selVal=String((sel&&sel.value)||''); if(!_selVal) return null; - const _hit=_modelData.find(m=>m&&!m.endpointErrorOnly&&String(m.value||'')===_selVal); + const _hit=_modelData.find(m=>m&&!m.endpointErrorOnly&&_isSelectedModelRow(m)) || _modelData.find(m=>m&&!m.endpointErrorOnly&&String(m.value||'')===_selVal); return _hit?_hit.groupKey:null; })(); - const _makeModelRow=(m,sel,shouldRenderHeading)=>{ + const _makeModelRow=(m,shouldRenderHeading)=>{ const row=document.createElement('div'); - row.className='model-opt'+(m.value===sel.value?' active':''); + row.className='model-opt'+(_isSelectedModelRow(m)?' active':''); const badgeHtml=m.badge?`${esc(m.badge.label||'Configured')}`:''; const _plainGroup=m.group?String(m.group).replace(/\s*\(\d+\s+of\s+\d+\)\s*$/,''):''; const _underOwnHeading=shouldRenderHeading&&!!(m.groupKey&&_groupWrappers[m.groupKey]); const providerChip=(_plainGroup&&!_underOwnHeading)?`${esc(_plainGroup)}`:''; - row.innerHTML=`
${esc(m.name)}${badgeHtml}${providerChip}
${esc(m.id)}`; + row.innerHTML=`
${esc(m.name)}${badgeHtml}${_selectedModelBadge(m)}${providerChip}
${esc(m.id)}`; row.onclick=()=>selectFromDropdown(m.value,m.providerId||(m.badge&&m.badge.provider)||null); return row; }; @@ -3990,7 +3999,7 @@ function renderModelDropdown(){ } for(const m of configuredModels){ const row=document.createElement('div'); - row.className='model-opt'+(m.value===sel.value?' active':''); + row.className='model-opt'+(_isSelectedModelRow(m)?' active':''); let badgeLabel = ''; let modelName = m.name; if (m.badge) { @@ -4004,7 +4013,7 @@ function renderModelDropdown(){ } } const badgeHtml=m.badge?`${esc(badgeLabel)}`:''; - row.innerHTML=`
${esc(modelName)}${badgeHtml}
${esc(m.id)}`; + row.innerHTML=`
${esc(modelName)}${badgeHtml}${_selectedModelBadge(m)}
${esc(m.id)}`; row.onclick=()=>selectFromDropdown(m.value,(m.badge&&m.badge.provider)||m.providerId||null); dd.appendChild(row); } @@ -4105,16 +4114,16 @@ function renderModelDropdown(){ }); wrapper.appendChild(subHeading); wrapper.appendChild(subWrapper); - for(const m of pfxRows) subWrapper.appendChild(_makeModelRow(m,sel,shouldRenderHeading)); + for(const m of pfxRows) subWrapper.appendChild(_makeModelRow(m,shouldRenderHeading)); } else { - for(const m of pfxRows) wrapper.appendChild(_makeModelRow(m,sel,shouldRenderHeading)); + for(const m of pfxRows) wrapper.appendChild(_makeModelRow(m,shouldRenderHeading)); } } } else { - for(const m of groupRows) wrapper.appendChild(_makeModelRow(m,sel,shouldRenderHeading)); + for(const m of groupRows) wrapper.appendChild(_makeModelRow(m,shouldRenderHeading)); } } else { - for(const m of groupRows) dd.appendChild(_makeModelRow(m,sel,shouldRenderHeading)); + for(const m of groupRows) dd.appendChild(_makeModelRow(m,shouldRenderHeading)); } if(!term&&hiddenCount){ const showAll=document.createElement('div'); @@ -4243,6 +4252,8 @@ async function toggleModelDropdown(){ renderModelDropdown(); dd.classList.add('open'); _positionModelDropdown(); + const activeRow=dd.querySelector('.model-opt.active'); + if(activeRow&&typeof activeRow.scrollIntoView==='function') activeRow.scrollIntoView({block:'nearest'}); chip.classList.add('active'); const mobileAction=$('composerMobileModelAction'); if(mobileAction) mobileAction.classList.add('active'); @@ -11513,6 +11524,7 @@ function _anchorSceneTransparentNodeForRow(row, opts){ const text=String(row.text||'').trim(); if(!text) return null; const finalAnswer=String((opts&&opts.finalAnswer)||'').trim(); + if(opts&&opts.liveTokenFinalPrefixEligible&&_anchorSceneLiveTokenFinalPrefix(row,text,finalAnswer)) return null; if(finalAnswer&&_anchorSceneProseMatchesFinalAnswer(text,finalAnswer)) return null; node=_anchorSceneNodeForRow(row,{settled}); if(!node) return null; @@ -11555,6 +11567,18 @@ function _anchorSceneTransparentNodeForRow(row, opts){ if(live) node.setAttribute('data-live-stream-owned','1'); return node; } +function _anchorSceneLiveTokenFinalPrefix(row, proseText, finalAnswer){ + if(!row||row.role!=='prose'||row.kind!=='process_prose') return false; + if(String(row.source_event_type||'')!=='token') return false; + if(!String(row.local_id||'').startsWith('live-prose:')) return false; + const norm=(s)=>String(s||'').replace(/\s+/g,' ').trim().toLowerCase(); + const rowKey=norm(proseText), finalKey=norm(finalAnswer); + return !!(rowKey&&finalKey&&rowKey.length(row&&row.role==='tool')?idx:last,-1); +} // Whitespace-insensitive compare so a scene prose row that IS the final answer // (possibly re-wrapped) is recognized and not duplicated against the segment. // Codex #4568: the prefix tolerance must NOT be able to suppress a DISTINCT @@ -12152,6 +12176,7 @@ function _renderSettledAnchorSceneTransparentForMessage(message, segment, rawIdx const scene=message._anchor_activity_scene; const rows=_anchorSceneRowsForRendering(scene,{settled:true}); if(!rows.length) return false; + const lastNonTerminalWorkRowIndex=_anchorSceneLastNonTerminalWorkRowIndex(rows); // The assistant segment owns the final answer; pass it so intermediate prose // rows render but the final-answer-duplicate prose row is suppressed. const finalAnswer=String( @@ -12170,8 +12195,9 @@ function _renderSettledAnchorSceneTransparentForMessage(message, segment, rawIdx } }); let wrote=false; - for(const row of rows){ - const node=_anchorSceneTransparentNodeForRow(row,{settled:true,finalAnswer}); + for(let idx=0;idxlastNonTerminalWorkRowIndex}); if(!node) continue; if(segment.parentElement===blocks) blocks.insertBefore(node,segment); else blocks.appendChild(node); diff --git a/tests/fixtures/issue5749_captured_session_prefix.json b/tests/fixtures/issue5749_captured_session_prefix.json new file mode 100644 index 00000000..007c564d --- /dev/null +++ b/tests/fixtures/issue5749_captured_session_prefix.json @@ -0,0 +1,36 @@ +{ + "source": "Issue #5749 captured session JSON shape, redacted in the public issue body.", + "messages": [ + { + "id": "assistant-5749", + "role": "assistant", + "content": "While reviewing the Transparent Stream replay, I found that the settled anchor scene kept a live token prose row from the final answer stream. That row was saved as progress even though it was only the beginning of the final response. On reload the renderer treated it as ordinary activity, so the reader saw the opening paragraph once above the answer and again inside the assistant message." + } + ], + "anchor_activity_scenes": { + "assistant-5749": { + "scene": { + "mode": "transparent_stream", + "final_answer": "While reviewing the Transparent Stream replay, I found that the settled anchor scene kept a live token prose row from the final answer stream. That row was saved as progress even though it was only the beginning of the final response. On reload the renderer treated it as ordinary activity, so the reader saw the opening paragraph once above the answer and again inside the assistant message.", + "lifecycle": { + "terminal_state": "done" + }, + "identity": { + "source_message_refs": [ + "assistant-5749" + ] + }, + "activity_rows": [ + { + "role": "prose", + "kind": "process_prose", + "source_event_type": "token", + "local_id": "live-prose:issue5749:1", + "text": "While reviewing the Transparent Stream replay, I found that the settled anchor scene kept a live token prose row from the final answer stream. That row was saved as progress", + "status": "running" + } + ] + } + } + } +} diff --git a/tests/test_5749_anchor_scene_client_prefix_dedupe.py b/tests/test_5749_anchor_scene_client_prefix_dedupe.py new file mode 100644 index 00000000..31058e96 --- /dev/null +++ b/tests/test_5749_anchor_scene_client_prefix_dedupe.py @@ -0,0 +1,41 @@ +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +MESSAGES_JS = (ROOT / "static" / "messages.js").read_text(encoding="utf-8") + + +def _function_body(src: str, name: str) -> str: + marker = f"function {name}" + start = src.index(marker) + brace = src.index("{", start) + depth = 0 + for idx in range(brace, len(src)): + if src[idx] == "{": + depth += 1 + elif src[idx] == "}": + depth -= 1 + if depth == 0: + return src[brace + 1 : idx] + raise AssertionError(f"{name} body not found") + + +def test_settled_scene_keys_live_token_prefix_dedupe_to_final_answer_identity(): + settle_body = _function_body(MESSAGES_JS, "_completeSettledAnchorSceneForTurn") + final_overlap_body = _function_body(MESSAGES_JS, "_anchorSceneRowLooksLikeFinalAnswer") + + assert "lastNonTerminalWorkRowIndex" in settle_body + assert "rowIsLiveTokenFinalPrefix(row,textKey,rowIndex>lastNonTerminalWorkRowIndex)" in settle_body + assert "rowHasNonLiveDuplicate" not in settle_body + assert "_anchorSceneRowLooksLikeFinalAnswer(textKey,finalKey)" in settle_body + assert "(shorter/longer)>=0.9" in final_overlap_body + + +def test_render_scene_passes_final_segment_eligibility_to_live_prefix_guard(): + ui_js = (ROOT / "static" / "ui.js").read_text(encoding="utf-8") + render_body = _function_body(ui_js, "_renderSettledAnchorSceneTransparentForMessage") + row_body = _function_body(ui_js, "_anchorSceneTransparentNodeForRow") + + assert "const lastNonTerminalWorkRowIndex=_anchorSceneLastNonTerminalWorkRowIndex(rows);" in render_body + assert "liveTokenFinalPrefixEligible:idx>lastNonTerminalWorkRowIndex" in render_body + assert "opts&&opts.liveTokenFinalPrefixEligible&&_anchorSceneLiveTokenFinalPrefix" in row_body diff --git a/tests/test_anchor_fallback_ownership.py b/tests/test_anchor_fallback_ownership.py index 2f388efd..700e59c4 100644 --- a/tests/test_anchor_fallback_ownership.py +++ b/tests/test_anchor_fallback_ownership.py @@ -11,6 +11,7 @@ from __future__ import annotations import json import shutil import subprocess +import tempfile import textwrap from pathlib import Path @@ -45,13 +46,19 @@ def _run_node_script(script: str) -> str: if not node: pytest.skip("node executable is required for JavaScript behavior checks") try: - result = subprocess.run( - [node, "-e", script], - cwd=ROOT, - text=True, - capture_output=True, - timeout=10, - ) + with tempfile.NamedTemporaryFile("w", suffix=".js", encoding="utf-8", delete=False) as handle: + handle.write(script) + script_path = handle.name + try: + result = subprocess.run( + [node, script_path], + cwd=ROOT, + text=True, + capture_output=True, + timeout=10, + ) + finally: + Path(script_path).unlink(missing_ok=True) except subprocess.TimeoutExpired as exc: pytest.fail( "node behavior check timed out" @@ -920,4 +927,5 @@ def test_anchor_settled_renderers_remain_the_primary_scene_path(): assert "if(!message||!message._anchor_activity_scene||!segment) return false;" in transparent assert "_anchorSceneRowsForRendering(scene,{settled:true})" in transparent - assert "_anchorSceneTransparentNodeForRow(row,{settled:true,finalAnswer})" in transparent + assert "const lastNonTerminalWorkRowIndex=_anchorSceneLastNonTerminalWorkRowIndex(rows);" in transparent + assert "liveTokenFinalPrefixEligible:idx>lastNonTerminalWorkRowIndex" in transparent diff --git a/tests/test_issue5367_transparent_live_row_reconcile.py b/tests/test_issue5367_transparent_live_row_reconcile.py index 509f727d..7017d05d 100644 --- a/tests/test_issue5367_transparent_live_row_reconcile.py +++ b/tests/test_issue5367_transparent_live_row_reconcile.py @@ -299,6 +299,7 @@ global._createAssistantTurn = () => turn; global._assistantTurnBlocks = () => turn; global._anchorSceneTransparentNodeForRow = (row) => null; +eval(extractFunc('_anchorSceneLiveTokenFinalPrefix')); eval(extractFunc('_anchorSceneTransparentNodeForRow')); eval(extractFunc('_transparentLiveRowKey')); eval(extractFunc('_transparentLiveRowsCompatible')); diff --git a/tests/test_issue5748_model_picker_selected_badge.py b/tests/test_issue5748_model_picker_selected_badge.py new file mode 100644 index 00000000..63c32f0c --- /dev/null +++ b/tests/test_issue5748_model_picker_selected_badge.py @@ -0,0 +1,62 @@ +"""Regression coverage for #5748 model picker selected badge and scroll state.""" + +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +STYLE_CSS = (ROOT / "static" / "style.css").read_text(encoding="utf-8") +UI_JS = (ROOT / "static" / "ui.js").read_text(encoding="utf-8") +I18N_JS = (ROOT / "static" / "i18n.js").read_text(encoding="utf-8") + + +def _body_between(src: str, start: str, end: str) -> str: + start_idx = src.index(start) + end_idx = src.index(end, start_idx) + return src[start_idx:end_idx] + + +def test_model_dropdown_uses_viewport_height_cap(): + assert "max-height:min(70vh,640px);overflow-y:auto;" in STYLE_CSS + + +def test_toggle_model_dropdown_scrolls_active_row_after_open(): + body = _body_between(UI_JS, "async function toggleModelDropdown()", "function closeModelDropdown") + + assert "_positionModelDropdown();" in body + assert "scrollIntoView({block:'nearest'})" in body + assert body.index("_positionModelDropdown();") < body.index("scrollIntoView({block:'nearest'})") + + +def test_model_picker_renders_selected_badge_without_replacing_configured_badge(): + assert "const _selectedModelBadge=(m)=>" in UI_JS + assert "model-opt-badge--selected" in UI_JS + assert "t('model_badge_selected')||'Selected'" in UI_JS + assert "_getConfiguredModelBadge(modelId,badgeMap,providerId)" in UI_JS + + +def test_selected_badge_is_keyed_to_current_model_value(): + assert "String((m&&m.value)||'')===String((_selectedModelState&&_selectedModelState.model)||(sel&&sel.value)||'')" in UI_JS + + +def test_selected_badge_is_keyed_to_current_model_provider(): + assert "const _selectedModelState=(typeof _modelStateForSelect==='function')?_modelStateForSelect(sel,sel.value)" in UI_JS + assert "const _modelProviderForSelectedBadge=(m)=>" in UI_JS + assert "return (_provider&&_provider!=='default')?_provider:null;" in UI_JS + assert "String(_modelProviderForSelectedBadge(m)||'')===String((_selectedModelState&&_selectedModelState.model_provider)||'')" in UI_JS + assert "const _isSelectedModelRow=(m)=>" in UI_JS + assert "row.className='model-opt'+(_isSelectedModelRow(m)?' active':'');" in UI_JS + + +def test_selected_group_key_prefers_provider_matched_row_before_value_fallback(): + assert "const _hit=_modelData.find(m=>m&&!m.endpointErrorOnly&&_isSelectedModelRow(m)) || _modelData.find(m=>m&&!m.endpointErrorOnly&&String(m.value||'')===_selVal);" in UI_JS + assert "_groupOpenState[groupKey]=(groupKey===_selectedGroupKey)" in UI_JS + + +def test_selected_badge_helper_does_not_accept_dead_select_parameter(): + assert "_selectedModelBadge(m,sel)" not in UI_JS + assert "_selectedModelBadge(m)" in UI_JS + assert "_buildModelRow=(m,sel" not in UI_JS + assert "_makeModelRow(m,sel" not in UI_JS + + +def test_selected_badge_label_has_locale_entries(): + assert I18N_JS.count("model_badge_selected:") >= 14 diff --git a/tests/test_issue5749_transparent_stream_prefix_dedupe.py b/tests/test_issue5749_transparent_stream_prefix_dedupe.py new file mode 100644 index 00000000..b12c6c68 --- /dev/null +++ b/tests/test_issue5749_transparent_stream_prefix_dedupe.py @@ -0,0 +1,674 @@ +"""Regression tests for issue #5749 Transparent Stream prefix dedupe.""" + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + + +ROOT = Path(__file__).resolve().parents[1] +MESSAGES_JS = (ROOT / "static" / "messages.js").read_text(encoding="utf-8") +UI_JS = (ROOT / "static" / "ui.js").read_text(encoding="utf-8") +NODE = shutil.which("node") +ISSUE5749_CAPTURED_SESSION = json.loads( + (ROOT / "tests" / "fixtures" / "issue5749_captured_session_prefix.json").read_text(encoding="utf-8") +) + + +def _run_node(src, script, tmp_path): + assert NODE, "node is required for issue #5749 regression tests" + script_path = tmp_path / "issue5749_node_script.js" + script_path.write_text(script, encoding="utf-8") + result = subprocess.run([NODE, str(script_path)], text=True, capture_output=True, check=False) + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +def _normalized_text(text): + return " ".join(str(text or "").split()).lower() + + +def _issue5749_captured_scene(): + scenes = ISSUE5749_CAPTURED_SESSION["anchor_activity_scenes"] + scene = next(iter(scenes.values()))["scene"] + rows = scene["activity_rows"] + assert len(rows) == 1 + row = rows[0] + assert row["role"] == "prose" + assert row["kind"] == "process_prose" + assert row["source_event_type"] == "token" + assert row["local_id"].startswith("live-prose:") + assert not any( + other is not row and other.get("text") == row["text"] and not str(other.get("local_id", "")).startswith("live-prose:") + for other in rows + ) + row_key = _normalized_text(row["text"]) + final_key = _normalized_text(scene["final_answer"]) + assert row_key and final_key.startswith(row_key) + assert 0.40 <= len(row_key) / len(final_key) <= 0.45 + return scene, row + + +@pytest.mark.reproduction +@pytest.mark.skipif(NODE is None, reason="node not on PATH") +def test_issue5749_reproduction_fixture_matches_lone_live_prefix_shape(): + scene, row = _issue5749_captured_scene() + final_key = _normalized_text(scene["final_answer"]) + row_key = _normalized_text(row["text"]) + + assert row_key != final_key + assert final_key.startswith(row_key) + assert scene["final_answer"] == ISSUE5749_CAPTURED_SESSION["messages"][0]["content"] + + +@pytest.mark.reproduction +@pytest.mark.skipif(NODE is None, reason="node not on PATH") +def test_issue5749_settlement_preserves_pre_tool_live_token_prefix_rows(tmp_path): + fixture_scene, fixture_row = _issue5749_captured_scene() + final_answer = fixture_scene["final_answer"] + prefix_text = fixture_row["text"] + script = f""" +const src = {json.dumps(MESSAGES_JS)}; +function extractFunc(name) {{ + const start = src.indexOf('function ' + name); + if (start === -1) throw new Error(name + ' not found'); + const params = src.indexOf('(', start); + let depth = 0, close = -1; + for (let i = params; i < src.length; i++) {{ + if (src[i] === '(') depth++; + else if (src[i] === ')') {{ + depth--; + if (depth === 0) {{ close = i; break; }} + }} + }} + const brace = src.indexOf('{{', close); + depth = 0; + for (let i = brace; i < src.length; i++) {{ + if (src[i] === '{{') depth++; + else if (src[i] === '}}') {{ + depth--; + if (depth === 0) return src.slice(start, i + 1); + }} + }} + throw new Error(name + ' body did not close'); +}} +global.window = {{ + chatActivityMode() {{ return 'transparent_stream'; }}, + _chatActivityDisplayMode: 'transparent_stream', + _transparentStream: true, +}}; +global.S = {{ session: {{}} }}; +eval(extractFunc('_anchorSceneCleanText')); +eval(extractFunc('_anchorSceneTextKey')); +eval(extractFunc('_anchorSceneExistingRowKey')); +eval(extractFunc('_anchorSceneRowHasLiveIdentity')); +eval(extractFunc('_anchorSceneSettleLiveRunningRow')); +eval(extractFunc('_anchorSceneRowLooksLikeFinalAnswer')); +eval(extractFunc('_anchorSceneRowTextOverlapsExisting')); +eval(extractFunc('_anchorSceneMessageRowsHaveThinking')); +eval(extractFunc('_completeSettledAnchorSceneForTurn')); +function _anchorSceneActiveMode() {{ return 'transparent_stream'; }} +function _anchorSceneFinalAnswerText(message) {{ return message && (message.final_answer || message.content || ''); }} +function _anchorSceneRowsByMessageIndex() {{ return new Map(); }} +function _anchorSceneMessageRef(message) {{ return String(message && message.id || ''); }} +function _anchorSceneTurnDurationForSettlement() {{ return 0; }} +function _anchorSceneRowDisplayHintForMode(row, sceneMode) {{ + const hints = row && typeof row === 'object' && row.display_hints && typeof row.display_hints === 'object' ? row.display_hints : null; + if (sceneMode === 'transparent_stream') return (hints && hints.transparent_stream) || 'chronological_activity'; + if (sceneMode === 'compact_worklog') return (hints && hints.compact_worklog) || row && row.display_hint || 'activity_row'; + return row && row.display_hint || 'activity_row'; +}} +const messages = [ + {{ role: 'user', content: 'Prompt', id: 'user-1' }}, + {{ role: 'assistant', content: {json.dumps(final_answer)}, id: 'assistant-1' }}, +]; +const scene = _completeSettledAnchorSceneForTurn(messages, 1, {{ + mode: 'transparent_stream', + final_answer: {json.dumps(final_answer)}, + lifecycle: {{ terminal_state: 'done' }}, + identity: {{ source_message_refs: ['legacy'] }}, + activity_rows: [ + {{ + role: 'prose', + kind: 'process_prose', + source_event_type: 'token', + local_id: {json.dumps(fixture_row["local_id"])}, + text: {json.dumps(prefix_text)}, + status: 'running', + attachments: [{{ id: 'attachment-1' }}], + }}, + {{ + role: 'tool', + kind: 'tool_result', + source_event_type: 'tool', + local_id: 'tool-row-1', + text: 'Fetched docs', + status: 'running', + }}, + ], +}}); +process.stdout.write(JSON.stringify({{ + final_answer: scene.final_answer, + rows: scene.activity_rows.map(row => ({{ + role: row.role, + kind: row.kind, + source_event_type: row.source_event_type, + local_id: row.local_id, + text: row.text, + status: row.status, + attachments: row.attachments || null, + }})), +}})); +""" + data = _run_node(MESSAGES_JS, script, tmp_path) + assert data["final_answer"] == final_answer + assert [row["local_id"] for row in data["rows"]] == [fixture_row["local_id"], "tool-row-1"] + assert data["rows"][0]["text"] == prefix_text + assert data["rows"][0]["role"] == "prose" + assert data["rows"][1]["role"] == "tool" + + +@pytest.mark.skipif(NODE is None, reason="node not on PATH") +def test_issue5749_distinct_live_token_prefix_rows_survive_settlement(tmp_path): + final_answer = "The solution is to preserve short legitimate live-token prefixes while still suppressing longer duplicated final-answer spans." + prefix_text = "Checking context" + script = f""" +const src = {json.dumps(MESSAGES_JS)}; +function extractFunc(name) {{ + const start = src.indexOf('function ' + name); + if (start === -1) throw new Error(name + ' not found'); + const params = src.indexOf('(', start); + let depth = 0, close = -1; + for (let i = params; i < src.length; i++) {{ + if (src[i] === '(') depth++; + else if (src[i] === ')') {{ + depth--; + if (depth === 0) {{ close = i; break; }} + }} + }} + const brace = src.indexOf('{{', close); + depth = 0; + for (let i = brace; i < src.length; i++) {{ + if (src[i] === '{{') depth++; + else if (src[i] === '}}') {{ + depth--; + if (depth === 0) return src.slice(start, i + 1); + }} + }} + throw new Error(name + ' body did not close'); +}} +global.window = {{ + chatActivityMode() {{ return 'transparent_stream'; }}, + _chatActivityDisplayMode: 'transparent_stream', + _transparentStream: true, +}}; +global.S = {{ session: {{}} }}; +eval(extractFunc('_anchorSceneCleanText')); +eval(extractFunc('_anchorSceneTextKey')); +eval(extractFunc('_anchorSceneExistingRowKey')); +eval(extractFunc('_anchorSceneRowHasLiveIdentity')); +eval(extractFunc('_anchorSceneSettleLiveRunningRow')); +eval(extractFunc('_anchorSceneRowLooksLikeFinalAnswer')); +eval(extractFunc('_anchorSceneRowTextOverlapsExisting')); +eval(extractFunc('_anchorSceneMessageRowsHaveThinking')); +eval(extractFunc('_completeSettledAnchorSceneForTurn')); +function _anchorSceneActiveMode() {{ return 'transparent_stream'; }} +function _anchorSceneFinalAnswerText(message) {{ return message && (message.final_answer || message.content || ''); }} +function _anchorSceneRowsByMessageIndex() {{ return new Map(); }} +function _anchorSceneMessageRef(message) {{ return String(message && message.id || ''); }} +function _anchorSceneTurnDurationForSettlement() {{ return 0; }} +function _anchorSceneRowDisplayHintForMode(row, sceneMode) {{ + const hints = row && typeof row === 'object' && row.display_hints && typeof row.display_hints === 'object' ? row.display_hints : null; + if (sceneMode === 'transparent_stream') return (hints && hints.transparent_stream) || 'chronological_activity'; + if (sceneMode === 'compact_worklog') return (hints && hints.compact_worklog) || row && row.display_hint || 'activity_row'; + return row && row.display_hint || 'activity_row'; +}} +const messages = [ + {{ role: 'user', content: 'Prompt', id: 'user-1' }}, + {{ role: 'assistant', content: {json.dumps(final_answer)}, id: 'assistant-1' }}, +]; +const scene = _completeSettledAnchorSceneForTurn(messages, 1, {{ + mode: 'transparent_stream', + final_answer: {json.dumps(final_answer)}, + lifecycle: {{ terminal_state: 'done' }}, + identity: {{ source_message_refs: ['legacy'] }}, + activity_rows: [ + {{ + role: 'prose', + kind: 'process_prose', + source_event_type: 'token', + local_id: 'live-prose:stream-short:1', + text: {json.dumps(prefix_text)}, + status: 'running', + }}, + ], +}}); +process.stdout.write(JSON.stringify(scene.activity_rows.map(row => row.text))); +""" + data = _run_node(MESSAGES_JS, script, tmp_path) + assert data == [prefix_text] + + +@pytest.mark.reproduction +@pytest.mark.skipif(NODE is None, reason="node not on PATH") +def test_issue5749_long_live_progress_prefix_is_suppressed_without_settled_duplicate(tmp_path): + fixture_scene, fixture_row = _issue5749_captured_scene() + final_answer = fixture_scene["final_answer"] + prefix_text = fixture_row["text"] + script = f""" +const src = {json.dumps(MESSAGES_JS)}; +function extractFunc(name) {{ + const start = src.indexOf('function ' + name); + if (start === -1) throw new Error(name + ' not found'); + const params = src.indexOf('(', start); + let depth = 0, close = -1; + for (let i = params; i < src.length; i++) {{ + if (src[i] === '(') depth++; + else if (src[i] === ')') {{ + depth--; + if (depth === 0) {{ close = i; break; }} + }} + }} + const brace = src.indexOf('{{', close); + depth = 0; + for (let i = brace; i < src.length; i++) {{ + if (src[i] === '{{') depth++; + else if (src[i] === '}}') {{ + depth--; + if (depth === 0) return src.slice(start, i + 1); + }} + }} + throw new Error(name + ' body did not close'); +}} +global.window = {{ + chatActivityMode() {{ return 'transparent_stream'; }}, + _chatActivityDisplayMode: 'transparent_stream', + _transparentStream: true, +}}; +global.S = {{ session: {{}} }}; +eval(extractFunc('_anchorSceneCleanText')); +eval(extractFunc('_anchorSceneTextKey')); +eval(extractFunc('_anchorSceneExistingRowKey')); +eval(extractFunc('_anchorSceneRowHasLiveIdentity')); +eval(extractFunc('_anchorSceneSettleLiveRunningRow')); +eval(extractFunc('_anchorSceneRowLooksLikeFinalAnswer')); +eval(extractFunc('_anchorSceneRowTextOverlapsExisting')); +eval(extractFunc('_anchorSceneMessageRowsHaveThinking')); +eval(extractFunc('_completeSettledAnchorSceneForTurn')); +function _anchorSceneActiveMode() {{ return 'transparent_stream'; }} +function _anchorSceneFinalAnswerText(message) {{ return message && (message.final_answer || message.content || ''); }} +function _anchorSceneRowsByMessageIndex() {{ return new Map(); }} +function _anchorSceneMessageRef(message) {{ return String(message && message.id || ''); }} +function _anchorSceneTurnDurationForSettlement() {{ return 0; }} +function _anchorSceneRowDisplayHintForMode(row, sceneMode) {{ + const hints = row && typeof row === 'object' && row.display_hints && typeof row.display_hints === 'object' ? row.display_hints : null; + if (sceneMode === 'transparent_stream') return (hints && hints.transparent_stream) || 'chronological_activity'; + if (sceneMode === 'compact_worklog') return (hints && hints.compact_worklog) || row && row.display_hint || 'activity_row'; + return row && row.display_hint || 'activity_row'; +}} +const messages = [ + {{ role: 'user', content: 'Prompt', id: 'user-1' }}, + {{ role: 'assistant', content: {json.dumps(final_answer)}, id: 'assistant-1' }}, +]; +const scene = _completeSettledAnchorSceneForTurn(messages, 1, {{ + mode: 'transparent_stream', + final_answer: {json.dumps(final_answer)}, + lifecycle: {{ terminal_state: 'done' }}, + identity: {{ source_message_refs: ['legacy'] }}, + activity_rows: [ + {{ + role: 'prose', + kind: 'process_prose', + source_event_type: 'token', + local_id: {json.dumps(fixture_row["local_id"])}, + text: {json.dumps(prefix_text)}, + status: 'running', + }}, + ], +}}); +process.stdout.write(JSON.stringify(scene.activity_rows.map(row => ({{ + local_id: row.local_id, + text: row.text, + status: row.status, +}})))); +""" + data = _run_node(MESSAGES_JS, script, tmp_path) + assert data == [] + + +@pytest.mark.reproduction +@pytest.mark.skipif(NODE is None, reason="node not on PATH") +def test_issue5749_render_fallback_suppresses_persisted_live_progress_prefix_rows(tmp_path): + fixture_scene, fixture_row = _issue5749_captured_scene() + final_answer = fixture_scene["final_answer"] + prefix_text = fixture_row["text"] + script = f""" +const src = {json.dumps(UI_JS)}; +function extractFunc(name) {{ + const start = src.indexOf('function ' + name); + if (start === -1) throw new Error(name + ' not found'); + const params = src.indexOf('(', start); + let depth = 0, close = -1; + for (let i = params; i < src.length; i++) {{ + if (src[i] === '(') depth++; + else if (src[i] === ')') {{ + depth--; + if (depth === 0) {{ close = i; break; }} + }} + }} + const brace = src.indexOf('{{', close); + depth = 0; + for (let i = brace; i < src.length; i++) {{ + if (src[i] === '{{') depth++; + else if (src[i] === '}}') {{ + depth--; + if (depth === 0) return src.slice(start, i + 1); + }} + }} + throw new Error(name + ' body did not close'); +}} +class FakeElement {{ + constructor(tag) {{ + this.tagName = String(tag || 'div').toUpperCase(); + this.attributes = Object.create(null); + this.dataset = Object.create(null); + }} + setAttribute(name, value) {{ + this.attributes[name] = String(value); + if (name.startsWith('data-')) {{ + const key = name.slice(5).replace(/-([a-z])/g, (_, c) => c.toUpperCase()); + this.dataset[key] = String(value); + }} + }} +}} +global.window = {{}}; +global.document = {{ createElement(tag) {{ return new FakeElement(tag); }} }}; +global._anchorSceneNodeForRow = () => new FakeElement('div'); +global._decorateTransparentEventRow = node => node; +global._anchorSceneToolCallFromRow = () => ({{}}); +global.buildToolCard = () => new FakeElement('div'); +global._thinkingActivityNode = () => new FakeElement('div'); +eval(extractFunc('_anchorSceneProseMatchesFinalAnswer')); +eval(extractFunc('_anchorSceneLiveTokenFinalPrefix')); +eval(extractFunc('_anchorSceneTransparentNodeForRow')); +const liveRow = {{ + role: 'prose', + kind: 'process_prose', + source_event_type: 'token', + local_id: {json.dumps(fixture_row["local_id"])}, + text: {json.dumps(prefix_text)}, +}}; +const liveResult = _anchorSceneTransparentNodeForRow(liveRow, {{ + settled: true, + finalAnswer: {json.dumps(final_answer)}, + liveTokenFinalPrefixEligible: true, +}}); +process.stdout.write(JSON.stringify({{ + liveResult: liveResult === null, +}})); +""" + data = _run_node(UI_JS, script, tmp_path) + assert data["liveResult"] is True + + +@pytest.mark.skipif(NODE is None, reason="node not on PATH") +def test_issue5749_render_fallback_suppresses_near_complete_live_prefix_rows(tmp_path): + final_answer = "I found the issue and I am fixing it by deduping live-token prefixes during settlement and render fallback now." + prefix_text = "I found the issue and I am fixing it by deduping live-token prefixes during settlement and render fallback" + script = f""" +const src = {json.dumps(UI_JS)}; +function extractFunc(name) {{ + const start = src.indexOf('function ' + name); + if (start === -1) throw new Error(name + ' not found'); + const params = src.indexOf('(', start); + let depth = 0, close = -1; + for (let i = params; i < src.length; i++) {{ + if (src[i] === '(') depth++; + else if (src[i] === ')') {{ + depth--; + if (depth === 0) {{ close = i; break; }} + }} + }} + const brace = src.indexOf('{{', close); + depth = 0; + for (let i = brace; i < src.length; i++) {{ + if (src[i] === '{{') depth++; + else if (src[i] === '}}') {{ + depth--; + if (depth === 0) return src.slice(start, i + 1); + }} + }} + throw new Error(name + ' body did not close'); +}} +class FakeElement {{ + constructor(tag) {{ + this.tagName = String(tag || 'div').toUpperCase(); + this.attributes = Object.create(null); + this.dataset = Object.create(null); + }} + setAttribute(name, value) {{ + this.attributes[name] = String(value); + if (name.startsWith('data-')) {{ + const key = name.slice(5).replace(/-([a-z])/g, (_, c) => c.toUpperCase()); + this.dataset[key] = String(value); + }} + }} +}} +global.window = {{}}; +global.document = {{ createElement(tag) {{ return new FakeElement(tag); }} }}; +global._anchorSceneNodeForRow = () => new FakeElement('div'); +global._decorateTransparentEventRow = node => node; +global._anchorSceneToolCallFromRow = () => ({{}}); +global.buildToolCard = () => new FakeElement('div'); +global._thinkingActivityNode = () => new FakeElement('div'); +eval(extractFunc('_anchorSceneProseMatchesFinalAnswer')); +eval(extractFunc('_anchorSceneLiveTokenFinalPrefix')); +eval(extractFunc('_anchorSceneTransparentNodeForRow')); +const liveRow = {{ + role: 'prose', + kind: 'process_prose', + source_event_type: 'token', + local_id: 'live-prose:stream-1:near', + text: {json.dumps(prefix_text)}, +}}; +const liveResult = _anchorSceneTransparentNodeForRow(liveRow, {{ + settled: true, + finalAnswer: {json.dumps(final_answer)}, + liveTokenFinalPrefixEligible: true, +}}); +process.stdout.write(JSON.stringify(liveResult === null)); +""" + data = _run_node(UI_JS, script, tmp_path) + assert data is True + + +@pytest.mark.reproduction +@pytest.mark.skipif(NODE is None, reason="node not on PATH") +def test_issue5749_render_fallback_preserves_pre_tool_live_token_prefix_rows(tmp_path): + fixture_scene, fixture_row = _issue5749_captured_scene() + final_answer = fixture_scene["final_answer"] + prefix_text = fixture_row["text"] + script = f""" +const src = {json.dumps(UI_JS)}; +function extractFunc(name) {{ + const start = src.indexOf('function ' + name); + if (start === -1) throw new Error(name + ' not found'); + const params = src.indexOf('(', start); + let depth = 0, close = -1; + for (let i = params; i < src.length; i++) {{ + if (src[i] === '(') depth++; + else if (src[i] === ')') {{ + depth--; + if (depth === 0) {{ close = i; break; }} + }} + }} + const brace = src.indexOf('{{', close); + depth = 0; + for (let i = brace; i < src.length; i++) {{ + if (src[i] === '{{') depth++; + else if (src[i] === '}}') {{ + depth--; + if (depth === 0) return src.slice(start, i + 1); + }} + }} + throw new Error(name + ' body did not close'); +}} +class FakeElement {{ + constructor(tag) {{ + this.tagName = String(tag || 'div').toUpperCase(); + this.attributes = Object.create(null); + this.dataset = Object.create(null); + }} + setAttribute(name, value) {{ + this.attributes[name] = String(value); + if (name.startsWith('data-')) {{ + const key = name.slice(5).replace(/-([a-z])/g, (_, c) => c.toUpperCase()); + this.dataset[key] = String(value); + }} + }} +}} +global.window = {{}}; +global.document = {{ createElement(tag) {{ return new FakeElement(tag); }} }}; +global._anchorSceneNodeForRow = () => new FakeElement('div'); +global._decorateTransparentEventRow = node => node; +global._anchorSceneToolCallFromRow = () => ({{}}); +global.buildToolCard = () => new FakeElement('div'); +global._thinkingActivityNode = () => new FakeElement('div'); +eval(extractFunc('_anchorSceneProseMatchesFinalAnswer')); +eval(extractFunc('_anchorSceneLiveTokenFinalPrefix')); +eval(extractFunc('_anchorSceneTransparentNodeForRow')); +const liveRow = {{ + role: 'prose', + kind: 'process_prose', + source_event_type: 'token', + local_id: {json.dumps(fixture_row["local_id"])}, + text: {json.dumps(prefix_text)}, +}}; +const liveResult = _anchorSceneTransparentNodeForRow(liveRow, {{ + settled: true, + finalAnswer: {json.dumps(final_answer)}, + liveTokenFinalPrefixEligible: false, +}}); +process.stdout.write(JSON.stringify({{ + visible: !!liveResult, + rowId: liveResult && liveResult.attributes && liveResult.attributes['data-anchor-row-id'] || '', +}})); +""" + data = _run_node(UI_JS, script, tmp_path) + assert data == {"visible": True, "rowId": fixture_row["local_id"]} + + +@pytest.mark.skipif(NODE is None, reason="node not on PATH") +def test_issue5749_non_live_prefix_rows_survive_mode_and_attachment_variants(tmp_path): + fixture_scene, fixture_row = _issue5749_captured_scene() + final_answer = fixture_scene["final_answer"] + prefix_text = fixture_row["text"] + script = f""" +const src = {json.dumps(UI_JS)}; +function extractFunc(name) {{ + const start = src.indexOf('function ' + name); + if (start === -1) throw new Error(name + ' not found'); + const params = src.indexOf('(', start); + let depth = 0, close = -1; + for (let i = params; i < src.length; i++) {{ + if (src[i] === '(') depth++; + else if (src[i] === ')') {{ + depth--; + if (depth === 0) {{ close = i; break; }} + }} + }} + const brace = src.indexOf('{{', close); + depth = 0; + for (let i = brace; i < src.length; i++) {{ + if (src[i] === '{{') depth++; + else if (src[i] === '}}') {{ + depth--; + if (depth === 0) return src.slice(start, i + 1); + }} + }} + throw new Error(name + ' body did not close'); +}} +class FakeElement {{ + constructor(tag) {{ + this.tagName = String(tag || 'div').toUpperCase(); + this.attributes = Object.create(null); + this.dataset = Object.create(null); + }} + setAttribute(name, value) {{ + this.attributes[name] = String(value); + if (name.startsWith('data-')) {{ + const key = name.slice(5).replace(/-([a-z])/g, (_, c) => c.toUpperCase()); + this.dataset[key] = String(value); + }} + }} +}} +global.window = {{}}; +global.document = {{ createElement(tag) {{ return new FakeElement(tag); }} }}; +global._anchorSceneNodeForRow = () => new FakeElement('div'); +global._decorateTransparentEventRow = node => node; +global._anchorSceneToolCallFromRow = () => ({{}}); +global.buildToolCard = () => new FakeElement('div'); +global._thinkingActivityNode = () => new FakeElement('div'); +eval(extractFunc('_anchorSceneProseMatchesFinalAnswer')); +eval(extractFunc('_anchorSceneLiveTokenFinalPrefix')); +eval(extractFunc('_anchorSceneTransparentNodeForRow')); +const variants = [ + {{ + label: 'full_dom', + row: {{ + role: 'prose', + kind: 'process_prose', + source_event_type: 'manual', + local_id: 'session-prose:stream-1:3', + text: {json.dumps(prefix_text)}, + attachments: [{{ id: 'attachment-1' }}], + }}, + opts: {{ settled: true, finalAnswer: {json.dumps(final_answer)} }}, + }}, + {{ + label: 'virtualized', + row: {{ + role: 'prose', + kind: 'process_prose', + source_event_type: 'manual', + local_id: 'session-prose:stream-1:4', + text: {json.dumps(prefix_text)}, + }}, + opts: {{ settled: false, finalAnswer: {json.dumps(final_answer)} }}, + }}, + {{ + label: 'attachments', + row: {{ + role: 'prose', + kind: 'process_prose', + source_event_type: 'manual', + local_id: 'session-prose:stream-1:5', + text: {json.dumps(prefix_text)}, + attachments: [{{ id: 'attachment-2' }}], + }}, + opts: {{ settled: true, finalAnswer: {json.dumps(final_answer)} }}, + }}, +]; +const rendered = variants.map(({{ + label, + row, + opts, +}}) => {{ + const node = _anchorSceneTransparentNodeForRow(row, opts); + return {{ + label, + visible: !!node, + rowId: node && node.attributes && node.attributes['data-anchor-row-id'] || '', + attachmentCount: Array.isArray(row.attachments) ? row.attachments.length : 0, + }}; +}}); +process.stdout.write(JSON.stringify(rendered)); +""" + data = _run_node(UI_JS, script, tmp_path) + assert data == [ + {"label": "full_dom", "visible": True, "rowId": "session-prose:stream-1:3", "attachmentCount": 1}, + {"label": "virtualized", "visible": True, "rowId": "session-prose:stream-1:4", "attachmentCount": 0}, + {"label": "attachments", "visible": True, "rowId": "session-prose:stream-1:5", "attachmentCount": 1}, + ] diff --git a/tests/test_live_to_final_anchor_visible_order.py b/tests/test_live_to_final_anchor_visible_order.py index a92feda5..ae83c5b5 100644 --- a/tests/test_live_to_final_anchor_visible_order.py +++ b/tests/test_live_to_final_anchor_visible_order.py @@ -924,10 +924,12 @@ def test_settled_anchor_scene_preserves_live_projected_order_before_backfill(): overlap = _function_body(MESSAGES_JS, "_anchorSceneRowTextOverlapsExisting") projected_idx = complete.index("const projectedRows=Array.isArray(base.activity_rows)?base.activity_rows:[];") - projected_push_idx = complete.index("for(const row of projectedRows){") - backfill_idx = complete.index("for(let idx=turnStart+1;idx<=lastAsstIndex;idx+=1)") - terminal_idx = complete.index("if(row&&row.role==='terminal') pushRow(row);", backfill_idx) - assert projected_idx < projected_push_idx < backfill_idx < terminal_idx + ordered_idx = complete.index("const orderedRows=[];", projected_idx) + projected_push_idx = complete.index("orderedRows.push(row);", ordered_idx) + backfill_idx = complete.index("for(let idx=turnStart+1;idx<=lastAsstIndex;idx+=1)", projected_push_idx) + terminal_idx = complete.index("if(row&&row.role==='terminal') orderedRows.push(row);", backfill_idx) + replay_idx = complete.index("orderedRows.forEach((row,idx)=>pushRow(row,idx));", terminal_idx) + assert projected_idx < ordered_idx < projected_push_idx < backfill_idx < terminal_idx < replay_idx assert "const seenTextKeys=[];" in complete assert "_anchorSceneRowTextOverlapsExisting(textKey,seenTextKeys)" in complete @@ -990,7 +992,8 @@ def test_transparent_stream_renders_persisted_anchor_scene_after_reload(): assert 'blocks.querySelectorAll(\'[data-anchor-settled-scene-row="1"],.transparent-event-row[data-anchor-scene-row="1"]\')' in transparent # combined fix: the final answer text is computed and threaded into the row # renderer so intermediate prose survives while the final-answer duplicate is dropped. - assert "_anchorSceneTransparentNodeForRow(row,{settled:true,finalAnswer})" in transparent + assert "const lastNonTerminalWorkRowIndex=_anchorSceneLastNonTerminalWorkRowIndex(rows);" in transparent + assert "liveTokenFinalPrefixEligible:idx>lastNonTerminalWorkRowIndex" in transparent assert "finalAnswer" in transparent assert "blocks.insertBefore(node,segment)" in transparent assert "_syncTransparentEventControls(turn)" in transparent @@ -1233,6 +1236,7 @@ global._renderAnchorSceneRowsIntoWorklog=(group, rows)=>{{ }}; global._syncToolCallGroupSummary=()=>{{}}; + eval(extractFunc('_anchorSceneLiveTokenFinalPrefix')); eval(extractFunc('_anchorSceneTransparentNodeForRow')); eval(extractFunc('renderLiveAnchorActivityScene')); eval(extractFunc('_transparentLiveRowKey'));