feat(backend): expose sanitized tool activity
This commit is contained in:
@@ -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; }
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user