fix(webui): keep multi-file apply_patch edits

This commit is contained in:
chengyongru
2026-06-24 20:04:39 +08:00
committed by Xubin Ren
parent c915e98c15
commit 943191f0c0
6 changed files with 227 additions and 12 deletions
@@ -1523,6 +1523,7 @@ function fileActivityManySummaryKey(editing: boolean, failed: boolean, deleted:
}
function fileEditCallKey(edit: UIFileEdit): string {
if (edit.call_id && edit.path) return `${edit.call_id}|${edit.tool}|${edit.path}`;
if (edit.call_id) return `${edit.call_id}|${edit.tool}`;
return `${edit.tool}|${edit.path}`;
}
+27 -5
View File
@@ -272,10 +272,16 @@ function absorbCompleteAssistantMessage(
}
function fileEditKey(edit: Pick<UIFileEdit, "call_id" | "tool" | "path">): string {
if (edit.call_id && edit.path) return `${edit.call_id}|${edit.tool}|${edit.path}`;
if (edit.call_id) return `${edit.call_id}|${edit.tool}`;
return `${edit.tool}|${edit.path}`;
}
function fileEditToolEventKey(edit: Pick<UIFileEdit, "call_id" | "tool" | "path">): string {
if (edit.call_id) return `${edit.call_id}|${edit.tool}`;
return fileEditKey(edit);
}
function toolEventFileEditKey(event: ToolProgressEvent): string | null {
const fn = (event as { function?: { name?: unknown } }).function;
const name = typeof event.name === "string"
@@ -292,7 +298,7 @@ function hasFileEditForToolEvent(messages: UIMessage[], event: ToolProgressEvent
const key = toolEventFileEditKey(event);
if (!key) return false;
return messages.some((message) =>
message.fileEdits?.some((edit) => fileEditKey(edit) === key),
message.fileEdits?.some((edit) => fileEditToolEventKey(edit) === key),
);
}
@@ -305,7 +311,7 @@ function filterCoveredFileEditToolEvents(
}
function stripCoveredFileEditToolHints(message: UIMessage, edits: UIFileEdit[]): UIMessage {
const incomingKeys = new Set(edits.map(fileEditKey));
const incomingKeys = new Set(edits.map(fileEditToolEventKey));
const events = message.toolEvents ?? [];
if (!events.length || incomingKeys.size === 0) return message;
@@ -367,7 +373,14 @@ function mergeFileEdits(existing: UIFileEdit[] | undefined, incoming: UIFileEdit
const edit = normalizeFileEdit(raw);
if (!edit) continue;
const key = fileEditKey(edit);
const existingIndex = indexByKey.get(key);
let existingIndex = indexByKey.get(key);
if (existingIndex === undefined && edit.path) {
const eventKey = fileEditToolEventKey(edit);
const pendingIndex = next.findIndex((existing) =>
!existing.path && existing.pending && fileEditToolEventKey(existing) === eventKey,
);
if (pendingIndex >= 0) existingIndex = pendingIndex;
}
if (existingIndex === undefined) {
indexByKey.set(key, next.length);
next.push(edit);
@@ -376,6 +389,7 @@ function mergeFileEdits(existing: UIFileEdit[] | undefined, incoming: UIFileEdit
const merged = { ...next[existingIndex], ...edit };
if (edit.path && !edit.pending) delete merged.pending;
next[existingIndex] = merged;
indexByKey.set(key, existingIndex);
}
return next;
}
@@ -386,17 +400,25 @@ function findFileEditTraceIndex(
incoming: UIFileEdit[],
): number | null {
const incomingKeys = new Set(incoming.map(fileEditKey));
const incomingToolEventKeys = new Set(incoming.map(fileEditToolEventKey));
for (let i = prev.length - 1; i >= 0; i -= 1) {
const candidate = prev[i];
if (candidate.role === "user") break;
if (candidate.kind !== "trace") continue;
if (segmentId && candidate.activitySegmentId === segmentId) return i;
for (const existing of candidate.fileEdits ?? []) {
if (incomingKeys.has(fileEditKey(existing))) return i;
if (
incomingKeys.has(fileEditKey(existing))
|| (
!existing.path
&& existing.pending
&& incomingToolEventKeys.has(fileEditToolEventKey(existing))
)
) return i;
}
for (const event of candidate.toolEvents ?? []) {
const key = toolEventFileEditKey(event);
if (key && incomingKeys.has(key)) return i;
if (key && incomingToolEventKeys.has(key)) return i;
}
}
return null;
@@ -513,6 +513,50 @@ describe("AgentActivityCluster", () => {
expect(screen.getByText("-3")).toBeInTheDocument();
});
it("renders every file from one apply_patch call", () => {
render(
<AgentActivityCluster
messages={[{
id: "t-file-many",
role: "tool",
kind: "trace",
content: "apply_patch()",
traces: ["apply_patch()"],
fileEdits: [
{
call_id: "call-patch",
tool: "apply_patch",
path: "USER.md",
phase: "end",
added: 0,
deleted: 3,
approximate: false,
status: "done",
},
{
call_id: "call-patch",
tool: "apply_patch",
path: "MEMORY.md",
phase: "end",
added: 0,
deleted: 4,
approximate: false,
status: "done",
},
],
createdAt: 3,
}]}
isTurnStreaming={false}
hasBodyBelow={false}
/>,
);
const fileRefs = screen.getAllByTestId("activity-file-reference");
expect(fileRefs).toHaveLength(2);
expect(fileRefs[0]).toHaveTextContent("USER.md");
expect(fileRefs[1]).toHaveTextContent("MEMORY.md");
});
it("renders CLI app runs as dedicated activity rows", () => {
const line = 'run_cli_app({"name":"blender","args":["--background","scene.blend"],"json":true})';
render(
+56
View File
@@ -596,6 +596,62 @@ describe("useNanobotStream", () => {
expect(result.current.messages[0].toolEvents).toBeUndefined();
});
it("keeps every file from one apply_patch call", () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-apply-patch-many", EMPTY_MESSAGES), {
wrapper: wrap(fake.client),
});
act(() => {
fake.emit("chat-apply-patch-many", {
event: "message",
chat_id: "chat-apply-patch-many",
text: "apply_patch()",
kind: "tool_hint",
tool_events: [{
phase: "start",
call_id: "call-patch",
name: "apply_patch",
arguments: { edits: [] },
}],
});
fake.emit("chat-apply-patch-many", {
event: "file_edit",
chat_id: "chat-apply-patch-many",
edits: [
{
call_id: "call-patch",
tool: "apply_patch",
path: "USER.md",
phase: "end",
added: 0,
deleted: 3,
approximate: false,
status: "done",
},
{
call_id: "call-patch",
tool: "apply_patch",
path: "MEMORY.md",
phase: "end",
added: 0,
deleted: 4,
approximate: false,
status: "done",
},
],
});
});
expect(result.current.messages).toHaveLength(1);
expect(result.current.messages[0].traces).toEqual([]);
expect(result.current.messages[0].toolEvents).toBeUndefined();
expect(result.current.messages[0].fileEdits?.map((edit) => edit.path)).toEqual([
"USER.md",
"MEMORY.md",
]);
});
it("upgrades pending file_edit placeholders when the path arrives", () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-file-edit-pending", EMPTY_MESSAGES), {