diff --git a/static/assistant_turn_anchors.js b/static/assistant_turn_anchors.js index 511a7f51..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('|'), diff --git a/static/ui.js b/static/ui.js index 6ccdb864..f4b77b60 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), @@ -14735,7 +14736,7 @@ function _idLinkedHistoricalToolArguments(toolCall){ 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===undefined||raw===null||raw==='') return null; if(raw&&typeof raw==='object'&&!Array.isArray(raw)) return raw; if(typeof raw!=='string') return null; try{ @@ -14747,12 +14748,33 @@ function _idLinkedHistoricalToolArguments(toolCall){ } function _idLinkedHistoricalToolResultRaw(message){ - if(!message||typeof message!=='object') return ''; + if(!message||typeof message!=='object') return null; 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||'');} + 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 @@ -14775,18 +14797,25 @@ function _idLinkedHistoricalTurnScene(messages, turnStart, turnEnd, options){ 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; + if(!toolCalls.length){ + if(visibleText) 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){ @@ -14800,7 +14829,7 @@ function _idLinkedHistoricalTurnScene(messages, turnStart, turnEnd, options){ } continue; } - if(message.role!=='tool') 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)||[]; @@ -14827,8 +14856,12 @@ function _idLinkedHistoricalTurnScene(messages, turnStart, turnEnd, options){ const declaration=declarations[index]; const resultEntry=resultsById.get(declaration.callId)[0]; const args=_toolArgsSnapshot(declaration.args); - const resultSnippet=_cliToolResultSnippet(_idLinkedHistoricalToolResultRaw(resultEntry.message)); + const resultRaw=_idLinkedHistoricalToolResultRaw(resultEntry.message); + if(resultRaw===null) return null; + const resultSnippet=_idLinkedHistoricalRedactSnippet(_cliToolResultSnippet(resultRaw)); const patchSnippet=_cliPatchSnippetFromArgs(declaration.name,args); + const isDiff=_cliToolCardHasDiffSnippet(resultSnippet,patchSnippet); + const snippet=_idLinkedHistoricalRedactSnippet(_cliToolCardSnippet(resultSnippet,patchSnippet)); const status=String(resultEntry.message.status||'').trim().toLowerCase(); const isError=resultEntry.message.is_error===true||status==='error'||status==='failed'||status==='failure'; activityEvents.push({ @@ -14842,9 +14875,10 @@ function _idLinkedHistoricalTurnScene(messages, turnStart, turnEnd, options){ name:declaration.name, args, command:String(args.command||args.cmd||''), - snippet:_cliToolCardSnippet(resultSnippet,patchSnippet), + snippet, done:true, is_error:isError, + is_diff:isDiff, assistant_msg_idx:declaration.rawIdx, }, }); diff --git a/tests/test_issue6220_id_linked_tool_anchor_hydration.py b/tests/test_issue6220_id_linked_tool_anchor_hydration.py index 42c4b460..0a3db73b 100644 --- a/tests/test_issue6220_id_linked_tool_anchor_hydration.py +++ b/tests/test_issue6220_id_linked_tool_anchor_hydration.py @@ -66,7 +66,8 @@ vm.runInContext( sandbox, {{filename:'assistant_turn_anchors.js'}} ); -sandbox._redactToolTargetLabel = value => String(value); +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', @@ -77,12 +78,16 @@ for(const name of [ '_firstOwnedValue', '_cliPatchSnippetFromArgs', '_cliToolCardSnippet', + '_cliToolCardHasDiffSnippet', + '_anchorSceneToolCallFromRow', '_toolArgsSnapshot', '_assistantReasoningPayloadText', '_idLinkedHistoricalMessageText', '_idLinkedHistoricalMessageRef', '_idLinkedHistoricalToolArguments', '_idLinkedHistoricalToolResultRaw', + '_idLinkedHistoricalRedactSnippet', + '_idLinkedHistoricalHasVisibleSidecar', '_idLinkedHistoricalTurnScene', '_hydrateIdLinkedHistoricalToolScenes', ]){{ @@ -112,7 +117,12 @@ const second = {str(repeat).lower()} 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}})); +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], @@ -301,6 +311,118 @@ def test_ambiguous_or_richer_historical_tool_shapes_keep_legacy_ownership(messag 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)