From e0a97a01f3999ea562542a33c671b10ae83e55f4 Mon Sep 17 00:00:00 2001 From: User Date: Tue, 14 Jul 2026 22:46:22 +0200 Subject: [PATCH] feat(backend): expose sanitized tool activity --- backend/src/bridge/session-bridge.ts | 32 +++++++++++++- backend/test/session-bridge.test.ts | 63 ++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 2 deletions(-) diff --git a/backend/src/bridge/session-bridge.ts b/backend/src/bridge/session-bridge.ts index 175f319c..54a58778 100644 --- a/backend/src/bridge/session-bridge.ts +++ b/backend/src/bridge/session-bridge.ts @@ -1,9 +1,17 @@ import type { RpcClient } from "../rpc/rpc-client.js"; +export type ToolActivity = { + kind: "tool"; + toolCallId: string; + toolName: string; + status: "running" | "completed" | "failed"; +}; + export type ClientEvent = | { type: "ui_request"; ui_request: any } | { type: "text_delta"; text: string } | { type: "activity_delta"; text: string } + | { type: "activity_event"; activity: ToolActivity } | { type: "info"; [k: string]: any } | { type: "system_event"; [k: string]: any }; @@ -47,8 +55,11 @@ export class SessionBridge { this.fan({ type: "activity_delta", text: m.assistantMessageEvent.delta ?? "" }); } else if (m.type === "text_delta") { this.fan({ type: "text_delta", text: m.text ?? "" }); - // tool_execution_* events are intentionally NOT forwarded: they clutter - // the UI with raw bash/read output the user did not ask for. + } else if (m.type === "tool_execution_start") { + this.emitToolActivity(m, "start"); + } else if (m.type === "tool_execution_end") { + this.emitToolActivity(m, "end"); + // Tool updates and raw payloads remain intentionally dropped. } else if (m.type === "system_event") { this.fan(m as ClientEvent); } else if (m.type === "agent_end") { @@ -63,6 +74,23 @@ export class SessionBridge { }); } + private emitToolActivity(message: any, lifecycle: "start" | "end"): void { + const toolCallId = message.toolCallId; + if (typeof toolCallId !== "string" || toolCallId.trim() === "") return; + + const toolName = + typeof message.toolName === "string" && message.toolName.trim() !== "" + ? message.toolName + : "Tool"; + const status = + lifecycle === "start" ? "running" : message.isError === true ? "failed" : "completed"; + + this.fan({ + type: "activity_event", + activity: { kind: "tool", toolCallId, toolName, status }, + }); + } + private fan(e: ClientEvent) { for (const cb of this.cbs) cb(e); } turnState(): TurnState { return this.state; } diff --git a/backend/test/session-bridge.test.ts b/backend/test/session-bridge.test.ts index 693d0763..efd64cca 100644 --- a/backend/test/session-bridge.test.ts +++ b/backend/test/session-bridge.test.ts @@ -181,3 +181,66 @@ test("steer invia un comando steer e riattiva il turno", () => { expect(sent.at(-1)).toEqual({ type: "steer", message: "considera solo il 2024" }); expect(bridge.turnState()).toBe("running"); }); + +test("tool execution emits only a sanitized correlated lifecycle", () => { + const { rpc, fire } = fakeRpc(); + const bridge = new SessionBridge(rpc); + const seen: any[] = []; + bridge.onClientEvent((event) => seen.push(event)); + + fire({ + type: "tool_execution_start", + toolCallId: "tool-1", + toolName: "bash", + args: { command: "curl https://secret.invalid/?token=DO_NOT_LEAK_ARGS" }, + }); + fire({ + type: "tool_execution_update", + toolCallId: "tool-1", + toolName: "bash", + partialResult: { content: "DO_NOT_LEAK_PARTIAL" }, + }); + fire({ + type: "tool_execution_end", + toolCallId: "tool-1", + toolName: "bash", + result: { content: "DO_NOT_LEAK_RESULT" }, + isError: false, + }); + + expect(seen).toEqual([ + { + type: "activity_event", + activity: { kind: "tool", toolCallId: "tool-1", toolName: "bash", status: "running" }, + }, + { + type: "activity_event", + activity: { kind: "tool", toolCallId: "tool-1", toolName: "bash", status: "completed" }, + }, + ]); + expect(JSON.stringify(seen)).not.toMatch(/DO_NOT_LEAK|args|partialResult|result|command/); +}); + +test("failed tool completion is sanitized and invalid starts are ignored", () => { + const { rpc, fire } = fakeRpc(); + const bridge = new SessionBridge(rpc); + const seen: any[] = []; + bridge.onClientEvent((event) => seen.push(event)); + + fire({ type: "tool_execution_start", toolName: "read", args: { path: "DO_NOT_LEAK_PATH" } }); + fire({ + type: "tool_execution_end", + toolCallId: "tool-2", + toolName: 42, + result: { error: "DO_NOT_LEAK_ERROR" }, + isError: true, + }); + + expect(seen).toEqual([ + { + type: "activity_event", + activity: { kind: "tool", toolCallId: "tool-2", toolName: "Tool", status: "failed" }, + }, + ]); + expect(JSON.stringify(seen)).not.toMatch(/DO_NOT_LEAK|args|result|error|path/); +});