Merge pull request #4600 from nesquena/stage-4587
Release v0.51.550 — Transparent Stream activity survives settle/reload (#4587/#4568)
This commit is contained in:
5 files changed
+194
-4
No files matched your search
@@ -3,6 +3,12 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [v0.51.550] — 2026-06-21 — Release TI (Transparent Stream keeps its activity after a turn settles or reloads)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **In Transparent Stream activity mode, a settled turn no longer loses its tool calls and thinking on reload (#4568).** Previously, once a turn finished — and on every rehydrate path after that (switching away and back, tab hide/show, window blur/focus, hard reload, mid-stream reload) — a turn that settled with a persisted activity scene showed only the final answer; all of its tool-call and thinking activity disappeared. The persisted activity scene is now rendered as transparent event rows on settle and reload (the same persisted scene both display modes already use), with the intermediate progress narration preserved and only the prose that duplicates the final answer suppressed. Compact Worklog mode is unchanged. Thanks @nesquena-hermes (building on @franksong2702's anchor-scene work).
|
||||
|
||||
## [v0.51.549] — 2026-06-21 — Release TH (hotfix: profile switching restored for single-user named profiles)
|
||||
|
||||
### Fixed
|
||||
|
||||
+10
-3
@@ -3132,9 +3132,16 @@ def _anchor_scene_row_looks_like_final_answer(row_text_key: str, final_key: str)
|
||||
return False
|
||||
if row_text_key == final_key:
|
||||
return True
|
||||
return len(row_text_key) >= 80 and (
|
||||
final_key.startswith(row_text_key) or row_text_key.startswith(final_key)
|
||||
)
|
||||
# #4587: align with the renderer's _anchorSceneProseMatchesFinalAnswer — a
|
||||
# prefix-like overlap only counts as "the final answer" when it's a
|
||||
# NEAR-complete match (ratio >= 0.9). A shorter intermediate-prose row that
|
||||
# is merely a prefix of the final answer is legitimate progress narration and
|
||||
# must be preserved in the persisted scene, not filtered out.
|
||||
if not (final_key.startswith(row_text_key) or row_text_key.startswith(final_key)):
|
||||
return False
|
||||
shorter = min(len(row_text_key), len(final_key))
|
||||
longer = max(len(row_text_key), len(final_key))
|
||||
return shorter >= 80 and longer > 0 and (shorter / longer) >= 0.9
|
||||
|
||||
|
||||
def _anchor_scene_text_has_long_overlap(text_key: str, final_key: str) -> bool:
|
||||
|
||||
+9
-1
@@ -2496,7 +2496,15 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
|
||||
function _anchorSceneRowLooksLikeFinalAnswer(rowTextKey, finalKey){
|
||||
if(!rowTextKey||!finalKey) return false;
|
||||
if(rowTextKey===finalKey) return true;
|
||||
return rowTextKey.length>=80&&(finalKey.startsWith(rowTextKey)||rowTextKey.startsWith(finalKey));
|
||||
// #4587: align with the renderer's _anchorSceneProseMatchesFinalAnswer — a
|
||||
// prefix-like overlap only counts as "the final answer" (and is dropped from
|
||||
// the scene) when it's a NEAR-complete match (ratio>=0.9). A shorter
|
||||
// intermediate-prose row that merely happens to be a prefix of the final
|
||||
// answer is legitimate progress narration and must be PRESERVED, not dropped.
|
||||
if(!(finalKey.startsWith(rowTextKey)||rowTextKey.startsWith(finalKey))) return false;
|
||||
const shorter=Math.min(rowTextKey.length,finalKey.length);
|
||||
const longer=Math.max(rowTextKey.length,finalKey.length);
|
||||
return shorter>=80&&longer>0&&(shorter/longer)>=0.9;
|
||||
}
|
||||
function _anchorSceneRowTextOverlapsExisting(rowTextKey, seenTextKeys){
|
||||
if(!rowTextKey||!Array.isArray(seenTextKeys)) return false;
|
||||
|
||||
+117
@@ -9071,6 +9071,83 @@ function _anchorSceneNodeForRow(row, opts){
|
||||
node.setAttribute('data-anchor-source-event-type',String(row.source_event_type||''));
|
||||
return node;
|
||||
}
|
||||
function _anchorSceneTransparentNodeForRow(row, opts){
|
||||
const settled=!!(opts&&opts.settled);
|
||||
if(!row) return null;
|
||||
let node=null;
|
||||
const meta={
|
||||
segmentSeq:row.segment_seq||row.segmentSeq||'',
|
||||
burstId:row.activity_burst_id||row.burst_id||row.burstId||'',
|
||||
};
|
||||
if(row.role==='prose'){
|
||||
// The settled assistant segment already owns the FINAL answer prose, so a
|
||||
// prose row whose text matches the final answer must be suppressed here to
|
||||
// avoid duplicating the answer. But INTERMEDIATE progress prose (the
|
||||
// between-tool narration from earlier rounds) is NOT the final answer and
|
||||
// belongs in the chronological transparent history — dropping it loses the
|
||||
// interleaving the user saw live (#4568: tools were restored but mid-turn
|
||||
// prose still vanished on reload). Render intermediate prose as an inline
|
||||
// assistant-segment (same shape _anchorSceneNodeForRow builds), skip only
|
||||
// the final-answer duplicate.
|
||||
const text=String(row.text||'').trim();
|
||||
if(!text) return null;
|
||||
const finalAnswer=String((opts&&opts.finalAnswer)||'').trim();
|
||||
if(finalAnswer&&_anchorSceneProseMatchesFinalAnswer(text,finalAnswer)) return null;
|
||||
node=_anchorSceneNodeForRow(row,{settled});
|
||||
if(!node) return null;
|
||||
node=_decorateTransparentEventRow(node,{type:'prose',text,preview:text,...meta});
|
||||
}else if(row.role==='thinking'){
|
||||
if(window._showThinking===false) return null;
|
||||
const text=String(row.text||row.thinking&&row.thinking.text||'').trim();
|
||||
if(!text) return null;
|
||||
node=_decorateTransparentEventRow(_thinkingActivityNode(text,false,row.row_id||row.local_id||'anchor-thinking'),{
|
||||
type:'thinking',
|
||||
text,
|
||||
preview:text,
|
||||
...meta,
|
||||
});
|
||||
}else if(row.role==='tool'){
|
||||
const toolCall=_anchorSceneToolCallFromRow(row,{settled});
|
||||
node=_decorateTransparentEventRow(buildToolCard(toolCall),{
|
||||
type:'tool',
|
||||
name:toolCall&&toolCall.name,
|
||||
status:_transparentToolStatus(toolCall,true),
|
||||
toolCall,
|
||||
...meta,
|
||||
});
|
||||
}else{
|
||||
node=_anchorSceneNodeForRow(row,{settled});
|
||||
node=_decorateTransparentEventRow(node,{
|
||||
type:String(row.role||'activity'),
|
||||
...meta,
|
||||
});
|
||||
}
|
||||
if(!node) return null;
|
||||
node.setAttribute('data-anchor-scene-row','1');
|
||||
node.setAttribute('data-anchor-settled-scene-row','1');
|
||||
node.setAttribute('data-anchor-row-id',String(row.row_id||row.local_id||''));
|
||||
node.setAttribute('data-anchor-row-role',String(row.role||'activity'));
|
||||
node.setAttribute('data-anchor-source-event-type',String(row.source_event_type||''));
|
||||
return node;
|
||||
}
|
||||
// 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
|
||||
// intermediate progress row that merely happens to be a prefix of the final
|
||||
// answer (e.g. "I found the issue and I'm applying the fix now." when the final
|
||||
// answer starts with that sentence). So require exact normalized equality, with
|
||||
// a prefix tolerance allowed ONLY when the two strings are near-equal length
|
||||
// (>=0.9 ratio, shorter >=80 chars) — i.e. genuine re-wrap/truncation of the
|
||||
// SAME text, never a short intermediate sentence that prefixes a long answer.
|
||||
function _anchorSceneProseMatchesFinalAnswer(proseText, finalAnswer){
|
||||
const norm=(s)=>String(s||'').replace(/\s+/g,' ').trim();
|
||||
const a=norm(proseText), b=norm(finalAnswer);
|
||||
if(!a||!b) return false;
|
||||
if(a===b) return true;
|
||||
if(!(a.startsWith(b)||b.startsWith(a))) return false;
|
||||
const shorter=Math.min(a.length,b.length), longer=Math.max(a.length,b.length);
|
||||
return shorter>=80 && (shorter/longer)>=0.9;
|
||||
}
|
||||
function _anchorSceneWorklogGroup(blocks, opts){
|
||||
if(!blocks) return null;
|
||||
const live=!!(opts&&opts.live);
|
||||
@@ -9254,8 +9331,48 @@ if(typeof window!=='undefined'){
|
||||
window._projectLiveAnchorActivitySceneForStream=_projectLiveAnchorActivitySceneForStream;
|
||||
window.isLiveAnchorActivitySceneOwner=isLiveAnchorActivitySceneOwner;
|
||||
}
|
||||
function _renderSettledAnchorSceneTransparentForMessage(message, segment, rawIdx){
|
||||
if(!message||!message._anchor_activity_scene||!segment) return false;
|
||||
const blocks=_assistantTurnBlocks(segment.closest('.assistant-turn'));
|
||||
if(!blocks) return false;
|
||||
const scene=message._anchor_activity_scene;
|
||||
const rows=_anchorSceneRowsForRendering(scene,{settled:true});
|
||||
if(!rows.length) return false;
|
||||
// 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(
|
||||
(scene&&typeof scene.final_answer==='string'&&scene.final_answer)
|
||||
|| _assistantAnchorSceneFinalAnswerText(message)
|
||||
|| (typeof msgContent==='function'?msgContent(message):'')
|
||||
|| ''
|
||||
);
|
||||
blocks.querySelectorAll('[data-anchor-settled-scene-row="1"],.transparent-event-row[data-anchor-scene-row="1"]').forEach(el=>el.remove());
|
||||
blocks.querySelectorAll('.assistant-segment[data-msg-idx]').forEach(node=>{
|
||||
const idx=Number(node.getAttribute('data-msg-idx'));
|
||||
if(Number.isFinite(idx)&&idx<rawIdx){
|
||||
node.classList.add('assistant-segment-worklog-source');
|
||||
node.setAttribute('aria-hidden','true');
|
||||
}
|
||||
});
|
||||
let wrote=false;
|
||||
for(const row of rows){
|
||||
const node=_anchorSceneTransparentNodeForRow(row,{settled:true,finalAnswer});
|
||||
if(!node) continue;
|
||||
if(segment.parentElement===blocks) blocks.insertBefore(node,segment);
|
||||
else blocks.appendChild(node);
|
||||
wrote=true;
|
||||
}
|
||||
if(wrote){
|
||||
const turn=segment.closest('.assistant-turn');
|
||||
if(turn) _syncTransparentEventControls(turn);
|
||||
}
|
||||
return wrote;
|
||||
}
|
||||
function _renderSettledAnchorSceneForMessage(message, segment, rawIdx){
|
||||
if(!message||!message._anchor_activity_scene||!segment) return false;
|
||||
if(typeof isTransparentStream==='function'&&isTransparentStream()){
|
||||
return _renderSettledAnchorSceneTransparentForMessage(message,segment,rawIdx);
|
||||
}
|
||||
if(typeof isCompactWorklogMode==='function'&&!isCompactWorklogMode()) return false;
|
||||
const blocks=_assistantTurnBlocks(segment.closest('.assistant-turn'));
|
||||
if(!blocks) return false;
|
||||
|
||||
@@ -673,6 +673,58 @@ def test_anchor_owned_settled_turn_skips_legacy_worklog_rebuild():
|
||||
assert "!anchorOwnedAssistantRawIdxs.has(S.messages.indexOf(m))" in render
|
||||
|
||||
|
||||
def test_transparent_stream_renders_persisted_anchor_scene_after_reload():
|
||||
settled = _function_body(UI_JS, "_renderSettledAnchorSceneForMessage")
|
||||
transparent = _function_body(UI_JS, "_renderSettledAnchorSceneTransparentForMessage")
|
||||
row = _function_body(UI_JS, "_anchorSceneTransparentNodeForRow")
|
||||
render = _function_body(UI_JS, "renderMessages")
|
||||
|
||||
assert "if(typeof isTransparentStream==='function'&&isTransparentStream())" in settled
|
||||
assert "return _renderSettledAnchorSceneTransparentForMessage(message,segment,rawIdx);" in settled
|
||||
assert "_anchorSceneRowsForRendering(scene,{settled:true})" in transparent
|
||||
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 "finalAnswer" in transparent
|
||||
assert "blocks.insertBefore(node,segment)" in transparent
|
||||
assert "_syncTransparentEventControls(turn)" in transparent
|
||||
# tool + thinking rows are rendered as transparent event rows
|
||||
assert "_decorateTransparentEventRow(_thinkingActivityNode" in row
|
||||
assert "_decorateTransparentEventRow(buildToolCard(toolCall)" in row
|
||||
assert "_transparentToolStatus(toolCall,true)" in row
|
||||
assert 'data-anchor-settled-scene-row' in row
|
||||
assert "if(anchorOwnedAssistantRawIdxs.has(aIdx)) continue;" in render
|
||||
|
||||
|
||||
def test_transparent_anchor_intermediate_prose_preserved_only_final_answer_suppressed():
|
||||
"""#4568 combined fix: intermediate between-tool progress prose must render in
|
||||
Transparent Stream reload (not be blanket-dropped like the first pass did);
|
||||
ONLY the prose row that duplicates the final answer is suppressed."""
|
||||
row = _function_body(UI_JS, "_anchorSceneTransparentNodeForRow")
|
||||
match = _function_body(UI_JS, "_anchorSceneProseMatchesFinalAnswer")
|
||||
# the prose branch must RENDER intermediate prose via the shared node builder,
|
||||
# gated only on the final-answer match — NOT an unconditional `return null`.
|
||||
assert "row.role==='prose'" in row
|
||||
assert "_anchorSceneProseMatchesFinalAnswer(text,finalAnswer)" in row
|
||||
assert "_anchorSceneNodeForRow(row,{settled})" in row, (
|
||||
"intermediate prose must be rendered as an inline assistant-segment node, "
|
||||
"not dropped"
|
||||
)
|
||||
assert "type:'prose'" in row
|
||||
# the matcher exists and compares whitespace-insensitively; the prefix
|
||||
# tolerance is length-ratio-guarded so a short intermediate sentence that
|
||||
# merely prefixes a long final answer is NOT suppressed (Codex #4568).
|
||||
assert "replace(/\\s+/g,' ')" in match
|
||||
assert "startsWith" in match
|
||||
assert "0.9" in match and ">=80" in match.replace(" ", ""), (
|
||||
"prefix tolerance must be guarded by a near-equal length ratio, not a bare >=40 floor"
|
||||
)
|
||||
# guard against the regression where ALL prose rows were dropped:
|
||||
assert "// avoid duplicating the answer" in row or "duplicates the final answer" in row
|
||||
|
||||
|
||||
|
||||
def test_settled_anchor_scene_final_answer_does_not_fold_into_worklog_source():
|
||||
belongs = _function_body(UI_JS, "_assistantMessageBelongsInWorklog")
|
||||
render = _function_body(UI_JS, "renderMessages")
|
||||
|
||||
Reference in new issue
Block a user