Merge PR #3894: fix(webui): accept end/error phases in tool trace rendering

fix(webui): accept end/error phases in tool trace rendering
This commit is contained in:
Xubin Ren 2026-05-19 23:29:16 +08:00 committed by GitHub
commit 3f321179eb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 172 additions and 14 deletions

View File

@ -99,17 +99,39 @@ def tool_trace_lines_from_events(events: Any) -> list[str]:
if not isinstance(events, list):
return []
lines: list[str] = []
seen: set[str] = set()
for event in events:
if not event or not isinstance(event, dict):
continue
if event.get("phase") != "start":
if event.get("phase") not in {"start", "end", "error"}:
continue
call_id = event.get("call_id")
if isinstance(call_id, str) and call_id:
if call_id in seen:
continue
seen.add(call_id)
t = _format_tool_call_trace(event)
if t:
lines.append(t)
return lines
def _merge_unique_tool_trace_lines(
previous_traces: list[str],
lines: list[str],
) -> tuple[list[str], bool]:
seen_lines = set(previous_traces)
traces = list(previous_traces)
added = False
for line in lines:
if line in seen_lines:
continue
seen_lines.add(line)
traces.append(line)
added = True
return traces, added
def replay_transcript_to_ui_messages(
lines: list[dict[str, Any]],
*,
@ -478,13 +500,19 @@ def replay_transcript_to_ui_messages(
and (last.get("activitySegmentId") in (None, segment))
):
prev_traces = list(last.get("traces") or [last.get("content")])
merged_traces = prev_traces + trace_lines
messages[-1] = {
if structured:
merged_traces, added = _merge_unique_tool_trace_lines(prev_traces, structured)
if not added:
continue
else:
merged_traces = prev_traces + trace_lines
merged = {
**last,
"traces": merged_traces,
"content": trace_lines[-1],
"content": merged_traces[-1],
"activitySegmentId": last.get("activitySegmentId") or segment,
}
messages[-1] = merged
else:
messages.append(
{

View File

@ -98,6 +98,53 @@ def test_replay_file_edit_event_creates_file_activity(tmp_path, monkeypatch) ->
assert msgs[2]["activitySegmentId"] != msgs[1]["activitySegmentId"]
def test_replay_tool_events_dedupes_finish_after_start() -> None:
msgs = replay_transcript_to_ui_messages([
{
"event": "message",
"chat_id": "t-tool",
"text": 'exec({"cmd":"ls"})',
"kind": "tool_hint",
"tool_events": [
{
"phase": "start",
"call_id": "call-exec",
"name": "exec",
"arguments": {"cmd": "ls"},
},
],
},
{
"event": "message",
"chat_id": "t-tool",
"text": "",
"kind": "progress",
"tool_events": [
{
"phase": "end",
"call_id": "call-exec",
"name": "exec",
"arguments": {"cmd": "ls"},
"result": "ok",
},
{
"phase": "end",
"call_id": "call-read",
"name": "read_file",
"arguments": {"path": "notes.md"},
"result": "done",
},
],
},
])
assert len(msgs) == 1
assert msgs[0]["traces"] == [
'exec({"cmd": "ls"})',
'read_file({"path": "notes.md"})',
]
def test_replay_file_edit_progress_merges_after_interleaved_activity(tmp_path, monkeypatch) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
key = "websocket:t-file-progress"

View File

@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { useClient } from "@/providers/ClientProvider";
import { toMediaAttachment } from "@/lib/media";
import { toolTraceLinesFromEvents } from "@/lib/tool-traces";
import { mergeUniqueToolTraceLines, toolTraceLinesFromEvents } from "@/lib/tool-traces";
import type { StreamError } from "@/lib/nanobot-client";
import type {
InboundEvent,
@ -678,10 +678,16 @@ export function useNanobotStream(
: last.content
? [last.content]
: [];
const mergedLines = structuredLines.length > 0
? mergeUniqueToolTraceLines(previousTraces, structuredLines)
: null;
if (mergedLines && !mergedLines.added) return prev;
const merged: UIMessage = {
...last,
traces: [...previousTraces, ...lines],
content: lines[lines.length - 1],
traces: mergedLines ? mergedLines.traces : [...previousTraces, ...lines],
content: mergedLines
? mergedLines.traces[mergedLines.traces.length - 1]
: lines[lines.length - 1],
activitySegmentId: last.activitySegmentId ?? segmentId,
};
return [...prev.slice(0, -1), merged];

View File

@ -39,13 +39,40 @@ export function formatToolCallTrace(call: unknown): string | null {
return `${name}()`;
}
const VALID_PHASES = new Set(["start", "end", "error"]);
export function toolTraceLinesFromEvents(events: unknown): string[] {
if (!Array.isArray(events)) return [];
return events
.filter((event) => {
if (!event || typeof event !== "object") return false;
return (event as { phase?: unknown }).phase === "start";
})
.map(formatToolCallTrace)
.filter((trace): trace is string => !!trace);
const seen = new Set<string>();
const lines: string[] = [];
for (const event of events) {
if (!event || typeof event !== "object") continue;
const phase = (event as { phase?: unknown }).phase;
if (!(phase && typeof phase === "string" && VALID_PHASES.has(phase))) continue;
const callId = (event as { call_id?: unknown }).call_id;
if (callId && typeof callId === "string") {
if (seen.has(callId)) continue;
seen.add(callId);
}
const line = formatToolCallTrace(event);
if (!line) continue;
lines.push(line);
}
return lines;
}
export function mergeUniqueToolTraceLines(
previousTraces: string[],
lines: string[],
): { traces: string[]; added: boolean } {
const seen = new Set(previousTraces);
const traces = [...previousTraces];
let added = false;
for (const line of lines) {
if (seen.has(line)) continue;
seen.add(line);
traces.push(line);
added = true;
}
return { traces, added };
}

View File

@ -308,6 +308,56 @@ describe("useNanobotStream", () => {
);
});
it("dedupes finish-phase tool events after their start trace", () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-tool-finish", EMPTY_MESSAGES), {
wrapper: wrap(fake.client),
});
act(() => {
fake.emit("chat-tool-finish", {
event: "message",
chat_id: "chat-tool-finish",
text: 'exec({"cmd":"ls"})',
kind: "tool_hint",
tool_events: [{
phase: "start",
call_id: "call-exec",
name: "exec",
arguments: { cmd: "ls" },
}],
});
fake.emit("chat-tool-finish", {
event: "message",
chat_id: "chat-tool-finish",
text: "",
kind: "progress",
tool_events: [
{
phase: "end",
call_id: "call-exec",
name: "exec",
arguments: { cmd: "ls" },
result: "ok",
},
{
phase: "error",
call_id: "call-read",
name: "read_file",
arguments: { path: "notes.md" },
error: "missing",
},
],
});
});
expect(result.current.messages).toHaveLength(1);
expect(result.current.messages[0].traces).toEqual([
'exec({"cmd":"ls"})',
'read_file({"path":"notes.md"})',
]);
});
it("renders live file_edit events as their own activity trace", () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-file-edit", EMPTY_MESSAGES), {