From fbdbca332c5fd604b2786dab4ceb76bc2fdfc894 Mon Sep 17 00:00:00 2001 From: Frank Song Date: Sat, 18 Jul 2026 14:30:39 +0800 Subject: [PATCH] Live Stream: hydrate ID-linked historical tool turns --- .../stable-assistant-turn-anchor-phase0.md | 2 +- static/assistant_turn_anchors.js | 46 ++++ static/ui.js | 187 +++++++++++++ ...sue6220_id_linked_tool_anchor_hydration.py | 259 ++++++++++++++++++ 4 files changed, 493 insertions(+), 1 deletion(-) create mode 100644 tests/test_issue6220_id_linked_tool_anchor_hydration.py diff --git a/docs/architecture/stable-assistant-turn-anchor-phase0.md b/docs/architecture/stable-assistant-turn-anchor-phase0.md index ceea2023..3984bbd5 100644 --- a/docs/architecture/stable-assistant-turn-anchor-phase0.md +++ b/docs/architecture/stable-assistant-turn-anchor-phase0.md @@ -310,7 +310,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 17c69ba0..d0794279 100644 --- a/static/assistant_turn_anchors.js +++ b/static/assistant_turn_anchors.js @@ -1048,6 +1048,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', @@ -1648,6 +1693,7 @@ createAssistantTurnAnchorShadowSnapshot, projectAssistantTurnAnchorSettledMessageFinalAnswer, projectAssistantTurnAnchorActivityScene, + projectAssistantTurnAnchorHistoricalTranscriptScene, reconcileAssistantTurnAnchorActivityScene, createAssistantTurnAnchorRendererSnapshot, reconcileAssistantTurnAnchorRendererSnapshot, diff --git a/static/ui.js b/static/ui.js index 37c49249..6d93dd52 100644 --- a/static/ui.js +++ b/static/ui.js @@ -14560,6 +14560,189 @@ 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.trim(); + if(!Array.isArray(content)) return ''; + return content.map(part=>{ + if(typeof part==='string') return part; + if(!part||typeof part!=='object') return ''; + if(part.type!=='text'&&part.type!=='input_text'&&part.type!=='output_text') return ''; + return String(part.text||part.content||''); + }).join('').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 {}; + 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 ''; + const content=message.content; + if(typeof content==='string') return content; + if(content===undefined||content===null) return ''; + try{return JSON.stringify(content);} + catch(e){return String(content||'');} +} + +// 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&&visibleText) return null; + if(!toolCalls.length) continue; + 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(message.role!=='tool') continue; + 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; + const hydratedTurn=_idLinkedHistoricalTurnScene(list,turnStart,turnEnd,options); + 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); + +for(const name of [ + '_clipCliToolSnippet', + '_cliToolResultText', + '_cliLooksLikePatchDiff', + '_cliToolResultSnippet', + '_prefixedCliDiffLines', + '_firstOwnedValue', + '_cliPatchSnippetFromArgs', + '_cliToolCardSnippet', + '_toolArgsSnapshot', + '_assistantReasoningPayloadText', + '_idLinkedHistoricalMessageText', + '_idLinkedHistoricalMessageRef', + '_idLinkedHistoricalToolArguments', + '_idLinkedHistoricalToolResultRaw', + '_idLinkedHistoricalTurnScene', + '_hydrateIdLinkedHistoricalToolScenes', +]){{ + vm.runInContext(extractFunc(name), sandbox, {{filename:'ui.js:' + name}}); +}} + +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); +console.log(JSON.stringify({{first, second, unchanged:afterFirst===JSON.stringify(messages), 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") +@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"}, + ], + ], +) +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") +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"] + + +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)]