diff --git a/docs/architecture/stable-assistant-turn-anchor-phase0.md b/docs/architecture/stable-assistant-turn-anchor-phase0.md index ab4a90a6..e731c95a 100644 --- a/docs/architecture/stable-assistant-turn-anchor-phase0.md +++ b/docs/architecture/stable-assistant-turn-anchor-phase0.md @@ -317,7 +317,7 @@ must not compete with an anchor-owned turn. | --- | --- | --- | --- | | Settled Compact Worklog activity | `_renderSettledAnchorSceneForMessage()` renders `activity_scene_v1` rows before the final answer. | Legacy `S.toolCalls`, `tool_calls`, `_partial_tool_calls`, and raw `content[].tool_use` rebuilds. | `anchorOwnedAssistantRawIdxs` excludes the anchor-owned turn from message metadata scans, fallback source collection, tool buckets, thinking buckets, and worklog-source mirroring. | | Settled Transparent Stream activity | `_renderSettledAnchorSceneTransparentForMessage()` renders the same scene rows as transparent event rows or inline prose before the final answer segment. | `_transparentStreamOrderedParts()` rebuilds raw `content[]` order for historical messages without an anchor scene. | `_transparentStreamOrderedParts()` returns `null` when `message._anchor_activity_scene` exists, leaving the dedicated anchor renderer in charge. | -| 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. | +| Historical / non-anchor transcripts | Complete, user-bounded transcripts with unique top-level `assistant.tool_calls[].id` declarations, exactly one matching role=`tool` `tool_call_id` result per declaration, and a later final assistant answer hydrate an in-memory `activity_scene_v1` through the Anchor module adapter before settled rendering. | Raw `content[]`, persisted `session.tool_calls`, role=`tool` rows, and partial tool-call snapshots continue to recover ambiguous, incomplete, or richer historical shapes. | The ID-linked shape exits fallback ownership only after strict turn-local validation; missing or duplicate IDs/results, cross-turn matches, process prose/reasoning, and other tool schemas fail closed to the legacy renderer. | | 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 the current settled-render contract, backed by runtime-journal diff --git a/static/assistant_turn_anchors.js b/static/assistant_turn_anchors.js index a237b7af..657a0b35 100644 --- a/static/assistant_turn_anchors.js +++ b/static/assistant_turn_anchors.js @@ -928,6 +928,7 @@ ); const done=_activityRowToolDone(kind,status,payload); const isError=_activityRowToolIsError(status,payload); + const isDiff=_own(payload,'is_diff')===true||_own(payload,'isDiff')===true; const signatureParts=[ toolName, toolCallId||'', @@ -943,6 +944,7 @@ output:_sanitizePayload(_own(payload,'output'))??null, done, is_error:isError, + ...(isDiff?{is_diff:true}:{}), duration:_activityPayloadFirst(payload,['duration','duration_seconds','elapsed'])??null, started_at:_activityPayloadFirst(payload,['started_at','startedAt'])??null, signature:signatureParts.join('|'), @@ -1058,6 +1060,51 @@ }); } + function projectAssistantTurnAnchorHistoricalTranscriptScene(input, options){ + const item=(input&&typeof input==='object')?input:{}; + const sessionId=_cleanString(_own(item,'session_id')); + const turnId=_cleanString(_own(item,'turn_id')); + const localId=_cleanString(_own(item,'local_id')); + const activityEvents=Array.isArray(_own(item,'activity_events'))?_own(item,'activity_events'):[]; + const settledMessage=_own(item,'settled_message'); + if(!sessionId||!turnId||!localId||!activityEvents.length + ||!settledMessage||typeof settledMessage!=='object') return null; + const runId=_cleanString(_own(item,'run_id'))||null; + const streamId=_cleanString(_own(item,'stream_id'))||null; + const sourceMessageRefs=Array.isArray(_own(item,'source_message_refs')) + ?_own(item,'source_message_refs').map(_cleanString).filter(Boolean) + :[]; + const registry=createAssistantTurnAnchorRegistry({ + session_id:sessionId, + turn_id:turnId, + run_id:runId, + stream_id:streamId, + local_id:localId, + source_message_refs:sourceMessageRefs, + }); + const context={ + session_id:sessionId, + turn_id:turnId, + run_id:runId, + stream_id:streamId, + }; + for(const event of activityEvents){ + const result=applyAssistantTurnAnchorSourceEvent(registry,event,context); + if(!result.applied||result.normalized.classification!=='activity') return null; + } + const settledPayload={...settledMessage,role:_cleanString(_own(settledMessage,'role'))||'assistant'}; + const settled=applyAssistantTurnAnchorSourceEvent(registry,{ + source_type:'settled_message', + seq:activityEvents.length+1, + local_id:localId, + payload:settledPayload, + },context); + if(!settled.applied) return null; + const opts=(options&&typeof options==='object')?options:{}; + const requestedMode=_cleanString(_own(opts,'mode'))||_cleanString(_own(item,'mode')); + return projectAssistantTurnAnchorActivityScene(registry,{mode:requestedMode}); + } + const ACTIVITY_RECONCILIATION_DEFAULT_FIELDS=Object.freeze([ 'kind', 'role', @@ -1658,6 +1705,7 @@ createAssistantTurnAnchorShadowSnapshot, projectAssistantTurnAnchorSettledMessageFinalAnswer, projectAssistantTurnAnchorActivityScene, + projectAssistantTurnAnchorHistoricalTranscriptScene, reconcileAssistantTurnAnchorActivityScene, createAssistantTurnAnchorRendererSnapshot, reconcileAssistantTurnAnchorRendererSnapshot, diff --git a/static/messages.js b/static/messages.js index 514c5899..b67a95e8 100644 --- a/static/messages.js +++ b/static/messages.js @@ -6504,6 +6504,12 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ return `${m.role}|${ts}|${body.slice(0,160)}`; } const _EPHEMERAL_TURN_FIELDS=['_turnUsage','_turnDuration','_turnTps','_gatewayRouting','_statusCard','_anchor_stream_id','_anchor_activity_scene']; + function _isHistoricalAnchorActivityScene(scene){ + if(!scene||typeof scene!=='object') return false; + const identity=scene.identity&&typeof scene.identity==='object'?scene.identity:null; + const turnId=identity&&typeof identity.turn_id==='string'?identity.turn_id:''; + return turnId.indexOf('historical:')===0; + } function _carryForwardEphemeralTurnFields(prevMessages, nextMessages){ if(!Array.isArray(prevMessages)||!Array.isArray(nextMessages)) return nextMessages; if(!prevMessages.length||!nextMessages.length) return nextMessages; @@ -6518,6 +6524,7 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ const k=_messageIdentityKey(nm); if(!k) continue; const pm=prevIdx.get(k); if(!pm) continue; for(const f of _EPHEMERAL_TURN_FIELDS){ + if(f==='_anchor_activity_scene'&&_isHistoricalAnchorActivityScene(pm[f])) continue; if(pm[f]!=null && nm[f]==null) nm[f]=pm[f]; } } diff --git a/static/ui.js b/static/ui.js index 3e92cd34..79a0e837 100644 --- a/static/ui.js +++ b/static/ui.js @@ -12309,6 +12309,7 @@ function _anchorSceneToolCallFromRow(row, opts){ )||'', done:settled?true:(tool.done!==null&&tool.done!==undefined?tool.done:(row.status!=='running'&&row.status!=='pending')), is_error:!!(tool.is_error||payload.is_error||row.status==='error'||row.status==='failed'), + is_diff:!!(tool.is_diff||payload.is_diff||payload.isDiff), duration:tool.duration||payload.duration||payload.duration_seconds, started_at:firstValidTimestampSeconds(tool.started_at, payload.started_at, rowTs), created_at:firstValidTimestampSeconds(tool.created_at, payload.created_at, rowTs), @@ -14706,6 +14707,226 @@ function _toolArgsSnapshot(args, limit){ return out; } +function _idLinkedHistoricalMessageText(message){ + if(!message||typeof message!=='object') return ''; + const content=message.content; + if(typeof content==='string') return content; + if(!Array.isArray(content)) return ''; + return content.filter(part=>part&&typeof part==='object'&&part.type==='text').map(part=>{ + if(!part||typeof part!=='object') return ''; + return String(part.text||part.content||''); + }).join('\n'); +} + +function _idLinkedHistoricalMessageHasVisibleText(message){ + return _idLinkedHistoricalMessageText(message).trim()!==''; +} + +function _idLinkedHistoricalMessageRef(message, rawIdx){ + if(message&&typeof message==='object'){ + for(const key of ['message_id','id','local_id']){ + const value=message[key]; + if(typeof value==='string'&&value.trim()) return value.trim(); + if(typeof value==='number'&&Number.isFinite(value)) return String(value); + } + } + return `raw_idx:${rawIdx}`; +} + +function _idLinkedHistoricalToolArguments(toolCall){ + if(!toolCall||typeof toolCall!=='object') return null; + const fn=toolCall.function; + if(!fn||typeof fn!=='object'||Array.isArray(fn)) return null; + const raw=fn.arguments; + if(raw===undefined||raw===null||raw==='') return null; + if(raw&&typeof raw==='object'&&!Array.isArray(raw)) return raw; + if(typeof raw!=='string') return null; + try{ + const parsed=JSON.parse(raw); + return parsed&&typeof parsed==='object'&&!Array.isArray(parsed)?parsed:null; + }catch(e){ + return null; + } +} + +function _idLinkedHistoricalToolResultRaw(message){ + if(!message||typeof message!=='object') return null; + const content=message.content; + return typeof content==='string'?content:null; +} + +function _idLinkedHistoricalRedactSnippet(value){ + let text=String(value||''); + if(!text) return ''; + if(typeof _redactToolTargetLabel==='function'){ + try{text=_redactToolTargetLabel(text);} + catch(e){} + } + return text; +} + +function _idLinkedHistoricalHasVisibleSidecar(message){ + if(!message||typeof message!=='object') return false; + const visibleKeys=['attachments','_attachments','_statusCard','status_card','statusCard','card','cards','artifact','artifacts','files','images','media']; + for(const key of visibleKeys){ + if(!Object.prototype.hasOwnProperty.call(message,key)) continue; + const value=message[key]; + if(value===undefined||value===null||value===false) continue; + if(Array.isArray(value)&&value.length===0) continue; + if(typeof value==='object'&&!Array.isArray(value)&&Object.keys(value).length===0) continue; + return true; + } + return false; +} + +// Claim legacy settled ownership only when the transcript itself proves a +// complete, user-bounded declaration/result/final-answer chain. +function _idLinkedHistoricalTurnScene(messages, turnStart, turnEnd, options){ + const list=Array.isArray(messages)?messages:[]; + const start=Math.max(0,Number(turnStart)||0); + const end=Math.min(list.length,Math.max(start,Number(turnEnd)||0)); + const opts=options&&typeof options==='object'?options:{}; + const sessionId=String(opts.sessionId||opts.session_id||'').trim(); + const api=(typeof window!=='undefined')?window.HermesAssistantTurnAnchors:null; + if(!sessionId||!api||typeof api.projectAssistantTurnAnchorHistoricalTranscriptScene!=='function') return null; + + const declarations=[]; + const declarationIds=new Set(); + const declarationRefs=[]; + const visibleAssistantIndexes=[]; + const assistantIndexes=[]; + const resultsById=new Map(); + for(let rawIdx=start;rawIdxpart&&typeof part==='object'&&part.type==='tool_use')) return null; + const toolCalls=Array.isArray(message.tool_calls)?message.tool_calls:[]; + if(toolCalls.length&&hasVisibleText) return null; + if(!toolCalls.length){ + if(hasVisibleText) continue; + return null; + } + if(message.content!==undefined&&message.content!==null&&message.content!=='') return null; + const messageRef=_idLinkedHistoricalMessageRef(message,rawIdx); + if(!declarationRefs.includes(messageRef)) declarationRefs.push(messageRef); + for(const toolCall of toolCalls){ + const callId=String(toolCall&&toolCall.id||'').trim(); + const fn=toolCall&&toolCall.function; + const name=String(fn&&fn.name||'').trim(); + const args=_idLinkedHistoricalToolArguments(toolCall); + if(!callId||!name||args===null||declarationIds.has(callId)) return null; + declarationIds.add(callId); + declarations.push({callId,name,args,rawIdx,messageRef}); + } + continue; + } + if(role!=='tool') return null; + const callId=String(message.tool_call_id||'').trim(); + if(!callId||!declarationIds.has(callId)) return null; + const matches=resultsById.get(callId)||[]; + matches.push({message,rawIdx}); + resultsById.set(callId,matches); + } + + if(!declarations.length||visibleAssistantIndexes.length!==1) return null; + const ownerIndex=visibleAssistantIndexes[0]; + if(ownerIndex!==assistantIndexes[assistantIndexes.length-1]) return null; + const owner=list[ownerIndex]; + if(Array.isArray(owner.tool_calls)&&owner.tool_calls.length) return null; + const ownerRef=_idLinkedHistoricalMessageRef(owner,ownerIndex); + for(const declaration of declarations){ + const matches=resultsById.get(declaration.callId)||[]; + if(matches.length!==1||matches[0].rawIdx<=declaration.rawIdx||matches[0].rawIdx>=ownerIndex) return null; + } + if(resultsById.size!==declarations.length) return null; + + const sourceRefs=declarationRefs.concat(ownerRef).filter((value,index,array)=>array.indexOf(value)===index); + const turnId=['historical',sessionId,declarationRefs[0],ownerRef].join(':'); + const activityEvents=[]; + for(let index=0;index{ + if(turnStart<0||turnEnd<=turnStart+1) return; + let hydratedTurn; + try{hydratedTurn=_idLinkedHistoricalTurnScene(list,turnStart,turnEnd,options);} + catch(e){return;} + if(!hydratedTurn) return; + const owner=list[hydratedTurn.ownerIndex]; + try{owner._anchor_activity_scene=hydratedTurn.scene;} + catch(e){return;} + if(owner._anchor_activity_scene===hydratedTurn.scene) hydrated+=1; + }; + for(let rawIdx=0;rawIdx +``role=tool.tool_call_id`` turn may move from the legacy settled renderer into +an Assistant Turn Anchor scene. Ambiguous or richer historical shapes stay on +the compatibility path. +""" + +from __future__ import annotations + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + + +ROOT = Path(__file__).resolve().parents[1] +UI_JS = (ROOT / "static" / "ui.js").read_text(encoding="utf-8") +ANCHOR_JS = ROOT / "static" / "assistant_turn_anchors.js" +NODE = shutil.which("node") + + +_EXTRACT_FUNC_JS = r""" +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 dict: + assert NODE, "node is required for Anchor hydration behavior tests" + script = f""" +const fs = require('fs'); +const vm = require('vm'); +const src = fs.readFileSync({json.dumps(str(ROOT / 'static' / 'ui.js'))}, 'utf8'); +{_EXTRACT_FUNC_JS} + +const sandbox = {{console}}; +sandbox.window = sandbox; +sandbox.globalThis = sandbox; +vm.createContext(sandbox); +vm.runInContext( + fs.readFileSync({json.dumps(str(ANCHOR_JS))}, 'utf8'), + sandbox, + {{filename:'assistant_turn_anchors.js'}} +); +sandbox._redactToolTargetLabel = value => String(value) + .replace(/([A-Za-z0-9_]*(?:TOKEN|API[_-]?KEY|SECRET|PASSWORD)[A-Za-z0-9_]*\\s*=\\s*)(?:"[^"]*"|'[^']*'|\\S+)/gi, '$1[redacted]'); + +for(const name of [ + '_clipCliToolSnippet', + '_cliToolResultText', + '_cliLooksLikePatchDiff', + '_cliToolResultSnippet', + '_prefixedCliDiffLines', + '_firstOwnedValue', + '_cliPatchSnippetFromArgs', + '_cliToolCardSnippet', + '_cliToolCardHasDiffSnippet', + '_anchorSceneToolCallFromRow', + '_toolArgsSnapshot', + '_assistantReasoningPayloadText', + '_idLinkedHistoricalMessageText', + '_idLinkedHistoricalMessageHasVisibleText', + '_idLinkedHistoricalMessageRef', + '_idLinkedHistoricalToolArguments', + '_idLinkedHistoricalToolResultRaw', + '_idLinkedHistoricalRedactSnippet', + '_idLinkedHistoricalHasVisibleSidecar', + '_idLinkedHistoricalTurnScene', + '_hydrateIdLinkedHistoricalToolScenes', +]){{ + const fn = src.indexOf('function ' + name) >= 0 ? extractFunc(name) : null; + if(fn) vm.runInContext(fn, sandbox, {{filename:'ui.js:' + name}}); +}} + +if({str(throw_helper).lower()}){{ + vm.runInContext( + "_toolArgsSnapshot = function(){{ throw new Error('synthetic event build failure'); }}", + sandbox + ); +}} + +const messages = {json.dumps(messages)}; +sandbox.messages = messages; +const first = vm.runInContext( + "_hydrateIdLinkedHistoricalToolScenes(messages, {{sessionId:'sid-6220', mode:'compact_worklog'}})", + sandbox +); +const afterFirst = JSON.stringify(messages); +const second = {str(repeat).lower()} + ? vm.runInContext( + "_hydrateIdLinkedHistoricalToolScenes(messages, {{sessionId:'sid-6220', mode:'compact_worklog'}})", + sandbox + ) + : null; +const owners = messages + .map((message, index) => message && message._anchor_activity_scene ? {{index, scene:message._anchor_activity_scene}} : null) + .filter(Boolean); +sandbox.owners = owners; +const toolCards = vm.runInContext( + "owners.map(owner => owner.scene.activity_rows.map(row => _anchorSceneToolCallFromRow(row, {{settled:true}})))", + sandbox +); +console.log(JSON.stringify({{first, second, unchanged:afterFirst===JSON.stringify(messages), owners, toolCards}})); +""" + result = subprocess.run( + [NODE, "-e", script], + cwd=ROOT, + text=True, + capture_output=True, + check=False, + ) + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +def _run_carry_forward_then_hydration(messages: list[dict]) -> dict: + assert NODE, "node is required for Anchor hydration behavior tests" + script = f""" +const fs = require('fs'); +const vm = require('vm'); +const uiSrc = fs.readFileSync({json.dumps(str(ROOT / 'static' / 'ui.js'))}, 'utf8'); +const messagesSrc = fs.readFileSync({json.dumps(str(ROOT / 'static' / 'messages.js'))}, 'utf8'); +let src = uiSrc; +{_EXTRACT_FUNC_JS} + +function extractFuncFrom(source, name){{ + const previous = src; + src = source; + try{{ return extractFunc(name); }} + finally{{ src = previous; }} +}} + +const sandbox = {{console}}; +sandbox.window = sandbox; +sandbox.globalThis = sandbox; +sandbox._EPHEMERAL_TURN_FIELDS = [ + '_turnUsage', + '_turnDuration', + '_turnTps', + '_gatewayRouting', + '_statusCard', + '_anchor_stream_id', + '_anchor_activity_scene', +]; +vm.createContext(sandbox); +vm.runInContext( + fs.readFileSync({json.dumps(str(ANCHOR_JS))}, 'utf8'), + sandbox, + {{filename:'assistant_turn_anchors.js'}} +); +sandbox._redactToolTargetLabel = value => String(value); + +for(const name of [ + '_clipCliToolSnippet', + '_cliToolResultText', + '_cliLooksLikePatchDiff', + '_cliToolResultSnippet', + '_prefixedCliDiffLines', + '_firstOwnedValue', + '_cliPatchSnippetFromArgs', + '_cliToolCardSnippet', + '_cliToolCardHasDiffSnippet', + '_anchorSceneToolCallFromRow', + '_toolArgsSnapshot', + '_assistantReasoningPayloadText', + '_idLinkedHistoricalMessageText', + '_idLinkedHistoricalMessageHasVisibleText', + '_idLinkedHistoricalMessageRef', + '_idLinkedHistoricalToolArguments', + '_idLinkedHistoricalToolResultRaw', + '_idLinkedHistoricalRedactSnippet', + '_idLinkedHistoricalHasVisibleSidecar', + '_idLinkedHistoricalTurnScene', + '_hydrateIdLinkedHistoricalToolScenes', +]){{ + const fn = uiSrc.indexOf('function ' + name) >= 0 ? extractFuncFrom(uiSrc, name) : null; + if(fn) vm.runInContext(fn, sandbox, {{filename:'ui.js:' + name}}); +}} +for(const name of [ + '_messageIdentityKey', + '_isHistoricalAnchorActivityScene', + '_carryForwardEphemeralTurnFields', +]){{ + const fn = messagesSrc.indexOf('function ' + name) >= 0 ? extractFuncFrom(messagesSrc, name) : null; + if(fn) vm.runInContext(fn, sandbox, {{filename:'messages.js:' + name}}); +}} + +const original = {json.dumps(messages)}; +const prev = JSON.parse(JSON.stringify(original)); +sandbox.prev = prev; +vm.runInContext( + "_hydrateIdLinkedHistoricalToolScenes(prev, {{sessionId:'sid-6220', mode:'compact_worklog'}})", + sandbox +); +const next = JSON.parse(JSON.stringify(original)); +sandbox.next = next; +vm.runInContext("_carryForwardEphemeralTurnFields(prev, next)", sandbox); +const carried = next + .map((message, index) => message && message._anchor_activity_scene ? {{index, turn_id:message._anchor_activity_scene.identity.turn_id, tools:message._anchor_activity_scene.activity_rows.map(row => row.tool_call_id)}} : null) + .filter(Boolean); +const hydrated = vm.runInContext( + "_hydrateIdLinkedHistoricalToolScenes(next, {{sessionId:'sid-6220', mode:'compact_worklog'}})", + sandbox +); +const owners = next + .map((message, index) => message && message._anchor_activity_scene ? {{index, turn_id:message._anchor_activity_scene.identity.turn_id, tools:message._anchor_activity_scene.activity_rows.map(row => row.tool_call_id)}} : null) + .filter(Boolean); +console.log(JSON.stringify({{carried, hydrated, owners}})); +""" + result = subprocess.run( + [NODE, "-e", script], + cwd=ROOT, + text=True, + capture_output=True, + check=False, + ) + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +def _linked_turn(call_id: str = "call-1") -> list[dict]: + return [ + {"role": "user", "content": "Inspect the workspace"}, + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": call_id, + "type": "function", + "function": { + "name": "terminal", + "arguments": '{"command":"pwd"}', + }, + } + ], + }, + { + "role": "tool", + "tool_call_id": call_id, + "content": '{"output":"/workspace"}', + }, + {"role": "assistant", "content": "The workspace is `/workspace`."}, + ] + + +@pytest.mark.skipif(NODE is None, reason="node is required for Anchor hydration tests") +def test_id_linked_historical_tool_turn_hydrates_one_anchor_scene_idempotently(): + data = _run_hydration(_linked_turn(), repeat=True) + + assert data["first"] == 1 + assert data["second"] == 0 + assert data["unchanged"] is True + assert [owner["index"] for owner in data["owners"]] == [3] + + scene = data["owners"][0]["scene"] + assert scene["version"] == "activity_scene_v1" + assert scene["final_answer"] == "The workspace is `/workspace`." + assert scene["final_message_ref"] == "raw_idx:3" + assert scene["identity"]["session_id"] == "sid-6220" + assert scene["identity"]["source_message_refs"] == ["raw_idx:1", "raw_idx:3"] + assert len(scene["activity_rows"]) == 1 + + row = scene["activity_rows"][0] + assert row["role"] == "tool" + assert row["source_event_type"] == "tool_complete" + assert row["tool_call_id"] == "call-1" + assert row["tool"]["name"] == "terminal" + assert row["tool"]["args"] == {"command": "pwd"} + assert row["tool"]["snippet"] == "/workspace" + assert row["tool"]["done"] is True + + +@pytest.mark.skipif(NODE is None, reason="node is required for Anchor hydration tests") +def test_id_linked_historical_final_answer_preserves_raw_string_whitespace(): + messages = _linked_turn() + messages[-1]["content"] = " indented code\n" + + data = _run_hydration(messages) + + assert data["first"] == 1 + assert data["owners"][0]["scene"]["final_answer"] == " indented code\n" + + +@pytest.mark.skipif(NODE is None, reason="node is required for Anchor hydration tests") +def test_id_linked_historical_final_answer_joins_text_parts_like_renderer(): + messages = _linked_turn() + messages[-1]["content"] = [ + {"type": "text", "text": "First paragraph."}, + {"type": "input_text", "text": "ignored input"}, + {"type": "text", "text": "Second paragraph."}, + ] + + data = _run_hydration(messages) + + assert data["first"] == 1 + assert data["owners"][0]["scene"]["final_answer"] == "First paragraph.\nSecond paragraph." + + +@pytest.mark.skipif(NODE is None, reason="node is required for Anchor hydration tests") +def test_id_linked_historical_multi_tool_turn_preserves_declaration_order(): + messages = [ + {"role": "user", "content": "Inspect both files"}, + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "call-first", + "type": "function", + "function": { + "name": "terminal", + "arguments": '{"command":"pwd"}', + }, + }, + { + "id": "call-second", + "type": "function", + "function": { + "name": "read_file", + "arguments": '{"path":"README.md"}', + }, + }, + ], + }, + {"role": "tool", "tool_call_id": "call-second", "content": "readme body"}, + {"role": "tool", "tool_call_id": "call-first", "content": "/workspace"}, + {"role": "assistant", "content": "Both checks completed."}, + ] + + data = _run_hydration(messages, repeat=True) + + assert data["first"] == 1 + assert data["second"] == 0 + rows = data["owners"][0]["scene"]["activity_rows"] + assert [row["tool_call_id"] for row in rows] == ["call-first", "call-second"] + assert [row["tool"]["name"] for row in rows] == ["terminal", "read_file"] + assert [row["tool"]["snippet"] for row in rows] == ["/workspace", "readme body"] + + +@pytest.mark.skipif(NODE is None, reason="node is required for Anchor hydration tests") +@pytest.mark.parametrize( + "messages", + [ + [ + {"role": "user", "content": "Run it"}, + {"role": "assistant", "content": None, "tool_calls": [{"function": {"name": "terminal", "arguments": "{}"}}]}, + {"role": "tool", "tool_call_id": "call-1", "content": "ok"}, + {"role": "assistant", "content": "Done"}, + ], + [ + {"role": "user", "content": "Run it"}, + {"role": "assistant", "content": None, "tool_calls": [ + {"id": "call-1", "function": {"name": "one", "arguments": "{}"}}, + {"id": "call-1", "function": {"name": "two", "arguments": "{}"}}, + ]}, + {"role": "tool", "tool_call_id": "call-1", "content": "ok"}, + {"role": "assistant", "content": "Done"}, + ], + [ + {"role": "user", "content": "Run it"}, + {"role": "assistant", "content": None, "tool_calls": [{"id": "call-1", "function": {"name": "terminal", "arguments": "{}"}}]}, + {"role": "assistant", "content": "Done"}, + ], + [ + {"role": "user", "content": "Run it"}, + {"role": "assistant", "content": None, "tool_calls": [{"id": "call-1", "function": {"name": "terminal", "arguments": "{}"}}]}, + {"role": "tool", "tool_call_id": "call-1", "content": "first"}, + {"role": "tool", "tool_call_id": "call-1", "content": "second"}, + {"role": "assistant", "content": "Done"}, + ], + [ + {"role": "user", "content": "Run it"}, + {"role": "assistant", "content": "I will inspect it first.", "tool_calls": [{"id": "call-1", "function": {"name": "terminal", "arguments": "{}"}}]}, + {"role": "tool", "tool_call_id": "call-1", "content": "ok"}, + {"role": "assistant", "content": "Done"}, + ], + [ + {"role": "user", "content": "Run it"}, + {"role": "assistant", "content": None, "reasoning": "Need to inspect it.", "tool_calls": [{"id": "call-1", "function": {"name": "terminal", "arguments": "{}"}}]}, + {"role": "tool", "tool_call_id": "call-1", "content": "ok"}, + {"role": "assistant", "content": "Done"}, + ], + [ + {"role": "user", "content": "Run it"}, + {"role": "assistant", "content": None, "tool_calls": [{"id": "call-1", "function": {"name": "terminal", "arguments": "{}"}}]}, + {"role": "user", "content": "What happened?"}, + {"role": "tool", "tool_call_id": "call-1", "content": "ok"}, + {"role": "assistant", "content": "Done"}, + ], + [ + {"role": "user", "content": "Run it"}, + {"role": "assistant", "content": [{"type": "tool_use", "id": "tool-1", "name": "terminal", "input": {}}]}, + {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "tool-1", "content": "ok"}]}, + {"role": "assistant", "content": "Done"}, + ], + [ + {"role": "user", "content": "Run it"}, + {"role": "assistant", "content": None, "tool_calls": [{"id": "call-1", "function": {"name": "terminal", "arguments": "{not-json"}}]}, + {"role": "tool", "tool_call_id": "call-1", "content": "ok"}, + {"role": "assistant", "content": "Done"}, + ], + [ + {"role": "user", "content": "Run it"}, + { + "role": "assistant", + "content": None, + "tool_calls": [{"id": "call-1", "function": {"name": "terminal", "arguments": "{}"}}], + "_partial_tool_calls": [{"name": "terminal"}], + }, + {"role": "tool", "tool_call_id": "call-1", "content": "ok"}, + {"role": "assistant", "content": "Done"}, + ], + ], +) +def test_ambiguous_or_richer_historical_tool_shapes_keep_legacy_ownership(messages): + data = _run_hydration(messages) + + assert data["first"] == 0 + assert data["owners"] == [] + + +@pytest.mark.skipif(NODE is None, reason="node is required for Anchor hydration tests") +@pytest.mark.parametrize("content", [ + {"stdout": "...", "env": "API_KEY=result-secret-123"}, + [{"stdout": "ok"}], + None, +]) +def test_non_string_historical_tool_results_keep_legacy_ownership(content): + messages = _linked_turn() + messages[2]["content"] = content + + data = _run_hydration(messages) + + assert data["first"] == 0 + assert data["owners"] == [] + + +@pytest.mark.skipif(NODE is None, reason="node is required for Anchor hydration tests") +def test_string_historical_tool_result_is_redacted_before_anchor_scene(): + messages = _linked_turn() + messages[2]["content"] = '{"output":"API_KEY=result-secret-123"}' + + data = _run_hydration(messages) + + assert data["first"] == 1 + row = data["owners"][0]["scene"]["activity_rows"][0] + assert "result-secret-123" not in json.dumps(row) + assert "API_KEY=[redacted]" in row["tool"]["snippet"] + + +@pytest.mark.skipif(NODE is None, reason="node is required for Anchor hydration tests") +@pytest.mark.parametrize( + "messages", + [ + [ + {"role": "user", "content": "Run it"}, + {"role": "assistant", "content": None, "tool_calls": [{"id": "call-1", "function": {"name": "terminal", "arguments": "{}"}}]}, + {"role": "system", "content": "visible system status"}, + {"role": "tool", "tool_call_id": "call-1", "content": "ok"}, + {"role": "assistant", "content": "Done"}, + ], + [ + {"role": "user", "content": "Run it"}, + {"role": "assistant", "content": None, "tool_calls": [{"id": "call-1", "function": {"name": "terminal", "arguments": "{}"}}]}, + {"role": "developer", "content": "visible developer note"}, + {"role": "tool", "tool_call_id": "call-1", "content": "ok"}, + {"role": "assistant", "content": "Done"}, + ], + [ + {"role": "user", "content": "Run it"}, + {"role": "assistant", "content": None, "tool_calls": [{"id": "call-1", "function": {"name": "terminal", "arguments": "{}"}}]}, + {"role": "function", "name": "legacy_func", "content": "legacy function output"}, + {"role": "tool", "tool_call_id": "call-1", "content": "ok"}, + {"role": "assistant", "content": "Done"}, + ], + [ + {"role": "user", "content": "Run it"}, + {"role": "assistant", "content": None, "tool_calls": [{"id": "call-1", "function": {"name": "terminal", "arguments": "{}"}}]}, + {"role": "assistant", "content": None, "tool_calls": []}, + {"role": "tool", "tool_call_id": "call-1", "content": "ok"}, + {"role": "assistant", "content": "Done"}, + ], + [ + {"role": "user", "content": "Run it"}, + { + "role": "assistant", + "content": None, + "attachments": [{"name": "evidence.txt"}], + "tool_calls": [{"id": "call-1", "function": {"name": "terminal", "arguments": "{}"}}], + }, + {"role": "tool", "tool_call_id": "call-1", "content": "ok"}, + {"role": "assistant", "content": "Done"}, + ], + [ + {"role": "user", "content": "Run it"}, + { + "role": "assistant", + "content": None, + "_statusCard": {"title": "Visible status"}, + "tool_calls": [{"id": "call-1", "function": {"name": "terminal", "arguments": "{}"}}], + }, + {"role": "tool", "tool_call_id": "call-1", "content": "ok"}, + {"role": "assistant", "content": "Done"}, + ], + [ + {"role": "user", "content": "Run it"}, + {"role": "assistant", "content": None, "tool_calls": [{"id": "call-1", "function": {"name": "terminal", "arguments": ""}}]}, + {"role": "tool", "tool_call_id": "call-1", "content": "ok"}, + {"role": "assistant", "content": "Done"}, + ], + ], +) +def test_non_whitelisted_intermediate_records_keep_legacy_ownership(messages): + data = _run_hydration(messages) + + assert data["first"] == 0 + assert data["owners"] == [] + + +@pytest.mark.skipif(NODE is None, reason="node is required for Anchor hydration tests") +def test_diff_historical_tool_result_preserves_diff_card_projection(): + messages = _linked_turn() + messages[2]["content"] = "diff --git a/demo.txt b/demo.txt\n@@\n-old\n+new\n" + + data = _run_hydration(messages) + + assert data["first"] == 1 + row = data["owners"][0]["scene"]["activity_rows"][0] + assert row["payload"]["is_diff"] is True + assert row["tool"]["is_diff"] is True + assert data["toolCards"][0][0]["is_diff"] is True + + +@pytest.mark.skipif(NODE is None, reason="node is required for Anchor hydration tests") +def test_event_building_helper_exceptions_keep_legacy_ownership(): + data = _run_hydration(_linked_turn(), throw_helper=True) + + assert data["first"] == 0 + assert data["owners"] == [] + + +@pytest.mark.skipif(NODE is None, reason="node is required for Anchor hydration tests") +def test_reused_ids_in_adjacent_complete_turns_remain_turn_scoped(): + data = _run_hydration(_linked_turn("call-reused") + _linked_turn("call-reused")) + + assert data["first"] == 2 + assert [owner["index"] for owner in data["owners"]] == [3, 7] + assert [owner["scene"]["activity_rows"][0]["tool_call_id"] for owner in data["owners"]] == ["call-reused", "call-reused"] + assert data["owners"][0]["scene"]["identity"]["turn_id"] != data["owners"][1]["scene"]["identity"]["turn_id"] + + +@pytest.mark.skipif(NODE is None, reason="node is required for Anchor hydration tests") +def test_duplicate_final_answers_rebuild_historical_scenes_after_refresh(): + first_turn = _linked_turn("call-first") + second_turn = _linked_turn("call-second") + first_turn[1]["tool_calls"][0]["function"]["name"] = "first_tool" + first_turn[2]["content"] = "first result" + first_turn[3]["content"] = "Done" + second_turn[1]["tool_calls"][0]["function"]["name"] = "second_tool" + second_turn[2]["content"] = "second result" + second_turn[3]["content"] = "Done" + + data = _run_carry_forward_then_hydration(first_turn + second_turn) + + assert data["carried"] == [] + assert data["hydrated"] == 2 + assert [owner["index"] for owner in data["owners"]] == [3, 7] + assert [owner["tools"] for owner in data["owners"]] == [["call-first"], ["call-second"]] + assert data["owners"][0]["turn_id"] != data["owners"][1]["turn_id"] + + +def test_render_messages_hydrates_before_visibility_and_fallback_ownership_scan(): + start = UI_JS.index("function renderMessages(options)") + body = UI_JS[start : UI_JS.index("\nfunction ", start + 1)] + + hydrate = "_hydrateIdLinkedHistoricalToolScenes(S.messages" + visible = "const visWithIdx=_getVisibleMessagesWithIdx();" + fallback = "const anchorOwnedAssistantRawIdxs=new Set();" + + assert hydrate in body + assert body.index(hydrate) < body.index(visible) < body.index(fallback) + assert "if(!S.busy" in body[: body.index(visible)]