From 37995b3d87398089853f45706688daf6de44c668 Mon Sep 17 00:00:00 2001 From: mmprotest Date: Mon, 29 Jun 2026 10:20:15 +1000 Subject: [PATCH] Fix Pi Villani UI status copy --- integrations/pi-villani/src/extension.test.ts | 18 +- integrations/pi-villani/src/render.test.ts | 100 +++++++++++ integrations/pi-villani/src/render.ts | 164 +++++++++++++----- tests/integrations/test_pi_bridge.py | 39 +++++ villani_code/integrations/pi_bridge.py | 47 ++++- 5 files changed, 314 insertions(+), 54 deletions(-) create mode 100644 integrations/pi-villani/src/render.test.ts diff --git a/integrations/pi-villani/src/extension.test.ts b/integrations/pi-villani/src/extension.test.ts index 8c7ab3e2..afaf822e 100644 --- a/integrations/pi-villani/src/extension.test.ts +++ b/integrations/pi-villani/src/extension.test.ts @@ -584,7 +584,7 @@ test("/villani keeps waiting after nonzero tool_finished and renders next model else process.env.VILLANI_USE_PI_MODEL = oldUsePi; } assert.doesNotMatch(notes.join("\n"), /Villani tool finished/); - assert.ok(statuses.includes("Villani is thinking...")); + assert.ok(statuses.some((s) => /^Villani|^Villani/.test(s))); assert.equal(sent.length, 1); }); @@ -600,10 +600,8 @@ test("repeated model_request_started updates status without notify spam", async }; await renderBridgeEvent({ type: "model_request_started" }, {}, ctx); await renderBridgeEvent({ type: "model_request_started" }, {}, ctx); - assert.deepEqual(statuses, [ - "Villani is thinking...", - "Villani is thinking...", - ]); + assert.equal(statuses.length, 2); + assert.ok(statuses.every((s) => /^Villani/.test(s))); assert.deepEqual(notes, []); }); @@ -745,11 +743,9 @@ test("tool and command events render readable English", async () => { await renderBridgeEvent({ type: "tool_started", tool: "Bash" }, {}, ctx); await renderBridgeEvent({ type: "command_started", command: "python -m pytest -v" }, {}, ctx); await renderBridgeEvent({ type: "command_finished", command: "python -m pytest -v", exit_code: 1, stderr_preview: "boom" }, {}, ctx); - assert.ok(notes.includes("Preparing command")); - assert.ok(notes.includes("Running command:\npython -m pytest -v")); - assert.ok(notes.includes("Command finished: exit 1\n\nstderr:\nboom")); - assert.doesNotMatch(notes.join("\n"), /tool_started|command_started|command_finished/); - assert.ok(statuses.includes("Running command...")); + assert.deepEqual(notes, ["Command finished: exit 1\n\nstderr:\nboom"]); + assert.doesNotMatch(notes.join("\n"), /tool_started|command_started|command_finished|Preparing command|Running command:/); + assert.ok(statuses.some((s) => /^Villani/.test(s))); assert.ok(widgets.some((w) => String(w).includes("python -m pytest -v"))); }); @@ -771,7 +767,7 @@ test("model request clears stale command widget and final clears widget", async await renderBridgeEvent({ type: "run_completed", summary: "final assistant summary" }, {}, ctx); assert.ok(widgets.some((w) => String(w).includes("Command finished: exit 0"))); assert.ok(widgets.some((w) => w === undefined)); - assert.ok(statuses.includes("Completed")); + assert.ok(statuses.some((s) => /^Villani/.test(s))); }); test("final message includes final assistant summary", async () => { diff --git a/integrations/pi-villani/src/render.test.ts b/integrations/pi-villani/src/render.test.ts new file mode 100644 index 00000000..dcc2a640 --- /dev/null +++ b/integrations/pi-villani/src/render.test.ts @@ -0,0 +1,100 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + cleanAssistantText, + reduceVillaniUiState, + renderBridgeEvent, + resetVillaniCopyCounters, + resetVillaniUiState, + toolStartedMessage, + villaniCopy, +} from './render.js'; +import { approvalMessage, approvalTitle } from './index.js'; + +function ctxRecorder() { + const statuses: string[] = []; + const widgets: any[] = []; + const notifications: string[] = []; + return { + statuses, + widgets, + notifications, + ctx: { ui: { + setStatus: async (_key: string, value: string) => { statuses.push(value); }, + setWidget: async (_key: string, value: any) => { widgets.push(value); }, + notify: async (message: string) => { notifications.push(message); }, + } }, + }; +} + +test('toolStartedMessage for Read with path does not say unknown', () => { + const msg = toolStartedMessage({ type: 'tool_started', tool: 'Read', path: 'src/foo.py' }); + assert.match(msg, /File: src\/foo.py/); + assert.doesNotMatch(msg, /unknown/i); +}); + +test('toolStartedMessage for Read without path does not say unknown', () => { + const msg = toolStartedMessage({ type: 'tool_started', tool: 'Read' }); + assert.equal(msg, 'Villani reads file. File nervous.'); + assert.doesNotMatch(msg, /unknown/i); +}); + +test('Read event sets reading Villani copy', () => { + resetVillaniCopyCounters(); + const state = reduceVillaniUiState({ phase: 'x' }, { type: 'tool_started', tool: 'Read', path: 'a.ts' }); + assert.equal(state.phase, 'Villani reads file. File nervous.'); +}); + +test('Write/Patch event sets writing Villani copy', () => { + resetVillaniCopyCounters(); + assert.equal(reduceVillaniUiState({ phase: 'x' }, { type: 'tool_started', tool: 'Write' }).phase, 'Villani makes file obey...'); + assert.equal(reduceVillaniUiState({ phase: 'x' }, { type: 'tool_started', tool: 'Patch' }).phase, 'Villanipatch imposed...'); +}); + +test('Bash command event sets running Villani copy', () => { + resetVillaniCopyCounters(); + const state = reduceVillaniUiState({ phase: 'x' }, { type: 'tool_started', tool: 'Bash', input: { command: 'echo hi' } }); + assert.equal(state.phase, 'Villani gives command...'); +}); + +test('pytest command event sets testing Villani copy', () => { + resetVillaniCopyCounters(); + const state = reduceVillaniUiState({ phase: 'x' }, { type: 'tool_started', tool: 'Bash', input: { command: 'python -m pytest -q' } }); + assert.equal(state.phase, 'Villani begins inspection...'); +}); + +test('run_completed uses complete Villani copy', () => { + resetVillaniCopyCounters(); + assert.equal(reduceVillaniUiState({ phase: 'x' }, { type: 'run_completed' }).phase, 'Villanified. Accept result.'); +}); + +test('run_failed uses failure Villani copy', () => { + resetVillaniCopyCounters(); + assert.equal(reduceVillaniUiState({ phase: 'x' }, { type: 'run_failed', error: 'boom' }).phase, 'Villani sees failure. Unacceptable.'); +}); + +test('villaniCopy rotates deterministically', () => { + resetVillaniCopyCounters(); + assert.equal(villaniCopy('thinking'), 'Villani is make plan...'); + assert.equal(villaniCopy('thinking'), 'Villaniplan forming...'); + assert.equal(villaniCopy('thinking'), 'Villani thinks. Nobody interrupt.'); +}); + +test('stream_text renders clean assistant text unchanged', async () => { + resetVillaniUiState(); + const rec = ctxRecorder(); + await renderBridgeEvent({ type: 'stream_text', text: "\n\nI'll start by exploring the repository structure and finding the failing tests.\n\n" }, {}, rec.ctx); + assert.deepEqual(rec.notifications, ["I'll start by exploring the repository structure and finding the failing tests."]); +}); + +test('cleanAssistantText trims without Villani prefix', () => { + assert.equal(cleanAssistantText('\n\nhello\n\n'), 'hello'); +}); + +test('approval message remains literal and readable', () => { + const request = { tool: 'Bash', summary: 'Run command', input: { command: 'python -m pytest -v' } }; + assert.equal(approvalTitle(request), 'Villani wants to run a shell command'); + const msg = approvalMessage(request); + assert.match(msg, /Command:\npython -m pytest -v/); + assert.doesNotMatch(msg, /\[object Object\]/); +}); diff --git a/integrations/pi-villani/src/render.ts b/integrations/pi-villani/src/render.ts index 5bb2d6e7..12dc543c 100644 --- a/integrations/pi-villani/src/render.ts +++ b/integrations/pi-villani/src/render.ts @@ -1,8 +1,48 @@ +export type VillaniCopyCategory = + | "thinking" + | "analysis" + | "reading" + | "writing" + | "running" + | "testing" + | "debugging" + | "review" + | "failure" + | "complete"; + +const VILLANI_COPY: Record = { + thinking: ["Villani is make plan...", "Villaniplan forming...", "Villani thinks. Nobody interrupt.", "Villani has doctrine now...", "Villanithoughts classified..."], + analysis: ["Villanalysis begins...", "Villani inspect problem...", "Villani finds weak logic...", "Villanicommission investigates...", "Villani determines blame..."], + reading: ["Villani reads file. File nervous.", "Villaniread begins...", "Villani opens file for questioning...", "Villanidossier opened...", "Villani checks file loyalty..."], + writing: ["Villani makes file obey...", "Villanipatch imposed...", "Villani writes new order...", "Villanification applied...", "Villani edits without remorse..."], + running: ["Villani gives command...", "Villanicommand issued...", "Villani demands output...", "Villanirun begins...", "Villani expects obedience..."], + testing: ["Villani begins inspection...", "Villanitest begins...", "Villani demands green tests...", "Villaniverdict pending...", "Villani checks for lies..."], + debugging: ["Villani hunts weak bug...", "Villanidebug begins...", "Villani asks bug hard questions...", "Villanistack confesses...", "Villani removes instability..."], + review: ["Villanireview begins...", "Villani judges patch...", "Villanicompliance checked...", "Villani approves, reluctantly...", "Villani checks for betrayal..."], + failure: ["Villani sees failure. Unacceptable.", "Villanifailure recorded...", "Villani prepares punishment...", "Villani blames weak implementation...", "Villani demands second attempt..."], + complete: ["Villanified. Accept result.", "Villani declares victory...", "Villani restores order...", "Villanivictory logged...", "Villani permits ship..."], +}; + +const copyCounters = new Map(); + +export function villaniCopy(category: VillaniCopyCategory): string { + const options = VILLANI_COPY[category]; + const current = copyCounters.get(category) ?? 0; + copyCounters.set(category, current + 1); + return options[current % options.length]; +} + +export function resetVillaniCopyCounters(): void { + copyCounters.clear(); +} + export type VillaniUiState = { phase: string; lastCommand?: string; lastCommandExitCode?: number; lastCommandPreview?: string; + lastToolPath?: string; + lastToolKind?: "reading" | "writing"; lastAssistantText?: string; finalSummary?: string; changedFiles?: string[]; @@ -12,6 +52,7 @@ export type VillaniUiState = { const USER_FACING_EVENT_TYPES = new Set([ "stream_text", + "phase", "tool_started", "command_started", "command_finished", @@ -26,6 +67,10 @@ const USER_FACING_EVENT_TYPES = new Set([ "proxy_request_started", "proxy_request_completed", "proxy_request_failed", + "verification_started", + "verification_finished", + "validation_started", + "validation_finished", ]); export function shouldRenderUserFacingEvent(event: any): boolean { @@ -149,6 +194,44 @@ function sanitizeDetails(value: any, seen = new WeakSet()): any { } return String(value); } + +function commandCategory(command: unknown): VillaniCopyCategory { + const text = String(command || ""); + return /pytest|\btest\b|coverage|tox|unittest/i.test(text) ? "testing" : "running"; +} + +function pathFromEvent(event: any): string | undefined { + const input = event.input && typeof event.input === "object" ? event.input : {}; + const value = event.path || event.file_path || event.filepath || event.filename || event.target_file || input.path || input.file_path || input.filepath || input.filename || input.target_file; + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function commandFromEvent(event: any): string | undefined { + const input = event.input && typeof event.input === "object" ? event.input : {}; + const value = event.command || input.command; + return typeof value === "string" && value.trim() ? value.slice(0, 500) : undefined; +} + +function categoryForEvent(event: any): VillaniCopyCategory | undefined { + const type = String(event.type || ""); + const phase = String(event.phase || ""); + const tool = String(event.tool || event.name || ""); + if (type === "model_request_started" || type === "proxy_request_started") return "thinking"; + if (type === "phase" && /diagnosis|planning/i.test(phase)) return "analysis"; + if (type === "tool_started") { + if (["Read", "GitStatus", "GitDiff", "GitLog"].includes(tool)) return "reading"; + if (["Write", "Patch", "Edit"].includes(tool)) return "writing"; + if (tool === "Bash") return commandCategory(commandFromEvent(event)); + } + if (type === "command_started") return commandCategory(commandFromEvent(event)); + if (type === "command_finished" && Number(event.exit_code ?? 0) !== 0) return "debugging"; + if (type === "validation_started" || type === "verification_started") return "testing"; + if (type === "validation_finished" || type === "verification_finished") return "review"; + if (type === "run_failed") return "failure"; + if (type === "run_completed") return "complete"; + return undefined; +} + function verificationStatus(event: any): string | undefined { const verification = event.verification_status ?? event.verificationStatus ?? event.verification; @@ -205,93 +288,96 @@ function preview(event: any) { parts.push(`output:\n${String(event.stdout_preview).slice(0, 500)}`); return parts.join("\n"); } -function toolStartedMessage(event: any): string { +export function toolStartedMessage(event: any): string { const tool = String(event.tool || event.name || "tool"); - const input = event.input && typeof event.input === "object" ? event.input : {}; - const path = event.path || input.path || input.file_path || event.file_path; - if (tool === "GitStatus") return "Checking repository status"; - if (tool === "GitDiff") return "Reading current changes"; - if (tool === "GitLog") return "Reading git history"; - if (tool === "Read") return `Reading file: ${path || "unknown"}`; - if (tool === "Write") return `Writing file: ${path || "unknown"}`; - if (tool === "Patch") return `Applying patch: ${path || "unknown"}`; - if (tool === "Bash") return "Preparing command"; - return "Villani is working..."; + const path = pathFromEvent(event); + if (tool === "GitStatus" || tool === "GitDiff" || tool === "GitLog" || tool === "Read") return path ? `Villani reads file. File nervous.\nFile: ${path}` : "Villani reads file. File nervous."; + if (tool === "Write" || tool === "Edit") return path ? `Villani makes file obey.\nFile: ${path}` : "Villani makes file obey."; + if (tool === "Patch") return path ? `Villanipatch imposed.\nFile: ${path}` : "Villanipatch imposed."; + if (tool === "Bash") return commandFromEvent(event) ? `Command:\n${commandFromEvent(event)}` : "Villani gives command..."; + return "Villani is make plan..."; } + export function reduceVillaniUiState( state: VillaniUiState, event: any, ): VillaniUiState { const next = { ...state, lastEventAt: Date.now() }; - if (event.type === "run_started") next.phase = "Starting Villani..."; + if (event.type === "run_started") next.phase = villaniCopy("thinking"); else if (event.type === "model_request_started") { - next.phase = "Villani is thinking..."; + next.phase = villaniCopy("thinking"); next.lastCommand = undefined; next.lastCommandExitCode = undefined; next.lastCommandPreview = undefined; } else if (event.type === "proxy_request_started") - next.phase = "Villani is thinking..."; + next.phase = villaniCopy("thinking"); else if (event.type === "model_request_completed") - next.phase = "Villani is thinking..."; + next.phase = villaniCopy("thinking"); else if (event.type === "proxy_request_completed") - next.phase = "Villani is thinking..."; + next.phase = villaniCopy("thinking"); else if (event.type === "approval_required") next.phase = "Waiting for approval..."; - else if (event.type === "approval_resolved") next.phase = "Villani is thinking..."; + else if (event.type === "approval_resolved") next.phase = villaniCopy("thinking"); else if (event.type === "tool_started") { const tool = String(event.tool || event.name || ""); - next.phase = - tool === "Read" || tool.startsWith("Git") - ? "Reading files..." - : tool === "Patch" || tool === "Write" - ? "Applying changes..." - : tool === "Bash" - ? "Running command..." - : "Villani is thinking..."; + const category = categoryForEvent(event) || "thinking"; + next.phase = villaniCopy(category); + next.lastToolPath = pathFromEvent(event); + next.lastToolKind = ["Read", "GitStatus", "GitDiff", "GitLog"].includes(tool) ? "reading" : (["Write", "Patch", "Edit"].includes(tool) ? "writing" : undefined); + if (tool === "Bash") { + next.lastCommand = commandFromEvent(event); + next.lastToolPath = undefined; + next.lastToolKind = undefined; + } } else if (event.type === "command_started") { - next.phase = "Running command..."; + next.phase = villaniCopy(commandCategory(event.command)); next.lastCommand = String(event.command || "").slice(0, 500); next.lastCommandExitCode = undefined; next.lastCommandPreview = undefined; } else if (event.type === "command_finished") { - next.phase = "Finished command"; + next.phase = villaniCopy(Number(event.exit_code ?? 0) !== 0 ? "debugging" : "review"); next.lastCommand = String(event.command || next.lastCommand || "").slice( 0, 500, ); next.lastCommandExitCode = event.exit_code; next.lastCommandPreview = preview(event); - } else if (event.type === "verification_started") - next.phase = "Villani is thinking..."; + } else if (event.type === "phase") { + next.phase = villaniCopy(categoryForEvent(event) || "thinking"); + } else if (event.type === "verification_started" || event.type === "validation_started") + next.phase = villaniCopy("testing"); + else if (event.type === "verification_finished" || event.type === "validation_finished") + next.phase = villaniCopy("review"); else if (event.type === "run_completed") { - next.phase = "Completed"; + next.phase = villaniCopy("complete"); next.finalSummary = event.summary; next.changedFiles = visibleChangedFiles( event.changed_files || event.changedFiles || next.changedFiles || [], ); next.transcriptPath = event.transcript_path || event.transcriptPath; } else if (event.type === "run_failed") { - next.phase = "Failed"; + next.phase = villaniCopy("failure"); next.finalSummary = event.summary || event.error; - } else if (event.type === "run_aborted") next.phase = "Failed"; + } else if (event.type === "run_aborted") next.phase = villaniCopy("failure"); else if ( event.type === "runner_heartbeat" && Date.now() - (state.lastEventAt || 0) > 15000 ) - next.phase = "Villani is thinking..."; + next.phase = villaniCopy("thinking"); return next; } export function widgetForState(state: VillaniUiState): any { - if (state.phase === "Running command...") - return ["Running command:", state.lastCommand || ""]; - if (state.phase === "Finished command") + if (state.lastToolPath && state.lastToolKind) + return ["File:", state.lastToolPath].join("\n"); + if (state.lastCommandExitCode !== undefined) return [ `Command finished: exit ${state.lastCommandExitCode ?? "unknown"}`, state.lastCommandPreview || "", ] .filter(Boolean) .join("\n"); - if (state.phase === "Completed" || state.phase === "Failed") return undefined; + if (state.lastCommand) + return ["Command:", state.lastCommand].join("\n"); if (state.changedFiles?.length) return `Changed files:\n${state.changedFiles.map((f) => `- ${f}`).join("\n")}`; return undefined; @@ -378,14 +464,12 @@ export async function renderBridgeEvent( if (event.type === "tool_started") { state = reduceVillaniUiState(state, event); await setStatus(ctx, state.phase); - await notify(ctx, toolStartedMessage(event), "info"); - await setWidget(ctx, widgetForState(state)); + await setWidget(ctx, widgetForState(state) || toolStartedMessage(event)); return; } if (event.type === "command_started") { state = reduceVillaniUiState(state, event); await setStatus(ctx, state.phase); - await notify(ctx, `Running command:\n${state.lastCommand || ""}`, "info"); await setWidget(ctx, widgetForState(state)); return; } diff --git a/tests/integrations/test_pi_bridge.py b/tests/integrations/test_pi_bridge.py index 2ce0a9b8..330aa9b7 100644 --- a/tests/integrations/test_pi_bridge.py +++ b/tests/integrations/test_pi_bridge.py @@ -282,3 +282,42 @@ def test_bridge_diagnostic_remains_diagnostic_event_for_render_suppression(): from villani_code.integrations.pi_bridge import map_runner_event ev=map_runner_event('r', {'type':'model_request_started'}) assert {'type':'bridge_diagnostic','id':'r','message':'model request started'} in ev + +def test_tool_started_read_preserves_input_file_path(): + from villani_code.integrations.pi_bridge import map_runner_event + ev=map_runner_event('r', {'type':'tool_started','name':'Read','input':{'file_path':'src/foo.py','content':'secret'}})[0] + assert ev['type']=='tool_started' + assert ev['input']=={'file_path':'src/foo.py'} + assert ev['path']=='src/foo.py' + + +def test_tool_started_read_preserves_event_path(): + from villani_code.integrations.pi_bridge import map_runner_event + ev=map_runner_event('r', {'type':'tool_started','name':'Read','path':'src/bar.py','input':{}})[0] + assert ev['path']=='src/bar.py' + assert ev['input']=={} + + +def test_tool_started_bash_preserves_input_command_and_command(): + from villani_code.integrations.pi_bridge import map_runner_event + ev=map_runner_event('r', {'type':'tool_started','name':'Bash','input':{'command':'pytest -q','cwd':'/tmp','env':{'SECRET':'x'}}})[0] + assert ev['input']=={'command':'pytest -q','cwd':'/tmp'} + assert ev['command']=='pytest -q' + + +def test_tool_result_preserves_safe_input_path_and_command(): + from villani_code.integrations.pi_bridge import map_runner_event + ev=map_runner_event('r', {'type':'tool_result','name':'Bash','input':{'command':'python -m pytest','path':'tests/test_x.py','headers':{'authorization':'x'}},'result':{'exit_code':1,'stderr':'bad'}})[0] + assert ev['input']=={'path':'tests/test_x.py','command':'python -m pytest'} + assert ev['path']=='tests/test_x.py' + assert ev['command']=='python -m pytest' + assert ev['stderr_preview']=='bad' + + +def test_tool_started_safe_input_excludes_sensitive_and_large_fields(): + from villani_code.integrations.pi_bridge import map_runner_event + ev=map_runner_event('r', {'type':'tool_started','name':'Patch','input':{'path':'a.py','content':'secret file','patch':'diff --git secret','env':{'API_KEY':'x'},'headers':{'authorization':'x'},'api_key':'x','secret':'x','command':'echo ok'}})[0] + assert ev['input']=={'path':'a.py','command':'echo ok'} + dumped=json.dumps(ev).lower() + for forbidden in ['content','diff --git','env','headers','api_key','secret file']: + assert forbidden not in dumped diff --git a/villani_code/integrations/pi_bridge.py b/villani_code/integrations/pi_bridge.py index 66cac561..b6805b60 100644 --- a/villani_code/integrations/pi_bridge.py +++ b/villani_code/integrations/pi_bridge.py @@ -13,6 +13,42 @@ def _cap(v:Any,n:int=2000)->str: return str(v)[:n] def _preview(v:Any,n:int=500)->str: text='' if v is None else str(v) return text[:n] + ('...' if len(text)>n else '') + +def safe_tool_input(value): + if not isinstance(value, dict): + return {} + allowed = {} + for key in ( + "path", + "file_path", + "filepath", + "filename", + "target_file", + "command", + "cwd", + "operation", + ): + if key in value and value[key] is not None: + allowed[key] = _cap(value[key], 500) + return allowed + +def extract_tool_path(event, input): + candidates = [ + event.get("path"), + event.get("file_path"), + event.get("filepath"), + event.get("filename"), + event.get("target_file"), + input.get("path"), + input.get("file_path"), + input.get("filepath"), + input.get("filename"), + input.get("target_file"), + ] + for candidate in candidates: + if isinstance(candidate, str) and candidate.strip(): + return _cap(candidate.strip(), 500) + return None def summarize_approval_request(tool_name:str, tool_input:dict[str,Any])->tuple[str,dict[str,Any]]: if tool_name=='Write': path=str(tool_input.get('path') or tool_input.get('file_path') or '') @@ -90,15 +126,20 @@ def map_runner_event(run_id:str,event:dict[str,Any])->list[dict[str,Any]]: elif t=='model_request_completed': out += [{'type':'model_request_completed','id':run_id},{'type':'bridge_diagnostic','id':run_id,'message':'model response received'}] elif t=='model_request_failed': out.append({'type':'bridge_diagnostic','id':run_id,'message':t}) elif t=='tool_started': - tool=str(event.get('name') or 'tool'); inp=event.get('input') if isinstance(event.get('input'),dict) else {}; command=inp.get('command') - out += [{'type':'tool_started','id':run_id,'tool':tool, **({'command':_cap(command,500)} if command else {})},{'type':'bridge_diagnostic','id':run_id,'message':f"tool started: {tool}".strip()}] + tool=str(event.get('name') or event.get('tool') or 'tool'); inp=event.get('input') if isinstance(event.get('input'),dict) else {} + safe_input=safe_tool_input(inp); path=extract_tool_path(event,safe_input); command=safe_input.get('command') + out += [{'type':'tool_started','id':run_id,'tool':tool,'input':safe_input, **({'path':path} if path else {}), **({'command':command} if command else {})},{'type':'bridge_diagnostic','id':run_id,'message':f"tool started: {tool}".strip()}] elif t in {'tool_result','tool_finished'}: tool=str(event.get('name') or event.get('tool') or 'tool') result=event.get('result') if isinstance(event.get('result'),dict) else {} exit_code=event.get('exit_code', result.get('exit_code', result.get('returncode'))) is_error=bool(event.get('is_error')) summary=summarize_tool_result(event) - base={'type':t,'id':run_id,'tool':tool,'ok':not is_error,'is_error':is_error,'summary':summary} + inp=event.get('input') if isinstance(event.get('input'),dict) else {} + safe_input=safe_tool_input(inp); path=extract_tool_path(event,safe_input); command=safe_input.get('command') + base={'type':t,'id':run_id,'tool':tool,'ok':not is_error,'is_error':is_error,'summary':summary,'input':safe_input} + if path: base['path']=path + if command: base['command']=command if exit_code is not None: base['exit_code']=exit_code for key in ('stdout_preview','stderr_preview','truncated'): if key in event: base[key]=event[key]