fix(webui): keep multi-file apply_patch edits
This commit is contained in:
@@ -1087,9 +1087,20 @@ def _merge_tool_events(previous: Any, incoming: list[dict[str, Any]]) -> list[di
|
||||
def _file_edit_key(edit: dict[str, Any]) -> str:
|
||||
call_id = str(edit.get("call_id") or "")
|
||||
tool = str(edit.get("tool") or "")
|
||||
path = str(edit.get("path") or "")
|
||||
if call_id and path:
|
||||
return f"{call_id}|{tool}|{path}"
|
||||
if call_id:
|
||||
return f"{call_id}|{tool}"
|
||||
return f"{tool}|{edit.get('path') or ''}"
|
||||
return f"{tool}|{path}"
|
||||
|
||||
|
||||
def _file_edit_tool_event_key(edit: dict[str, Any]) -> str:
|
||||
call_id = str(edit.get("call_id") or "")
|
||||
tool = str(edit.get("tool") or "")
|
||||
if call_id:
|
||||
return f"{call_id}|{tool}"
|
||||
return _file_edit_key(edit)
|
||||
|
||||
|
||||
def _message_has_file_edit_for_tool_event(
|
||||
@@ -1102,7 +1113,10 @@ def _message_has_file_edit_for_tool_event(
|
||||
edits = message.get("fileEdits")
|
||||
if not isinstance(edits, list):
|
||||
return False
|
||||
return any(isinstance(edit, dict) and _file_edit_key(edit) == key for edit in edits)
|
||||
return any(
|
||||
isinstance(edit, dict) and _file_edit_tool_event_key(edit) == key
|
||||
for edit in edits
|
||||
)
|
||||
|
||||
|
||||
def _filter_covered_file_edit_tool_events(
|
||||
@@ -1123,7 +1137,7 @@ def _strip_covered_file_edit_tool_hints(
|
||||
edits: list[dict[str, Any]],
|
||||
) -> dict[str, Any]:
|
||||
incoming_keys = {
|
||||
_file_edit_key(edit)
|
||||
_file_edit_tool_event_key(edit)
|
||||
for edit in edits
|
||||
if isinstance(edit, dict)
|
||||
}
|
||||
@@ -1460,6 +1474,11 @@ def replay_transcript_to_ui_messages(
|
||||
edits: list[dict[str, Any]],
|
||||
) -> int | None:
|
||||
incoming_keys = {_file_edit_key(edit) for edit in edits if isinstance(edit, dict)}
|
||||
incoming_tool_event_keys = {
|
||||
_file_edit_tool_event_key(edit)
|
||||
for edit in edits
|
||||
if isinstance(edit, dict)
|
||||
}
|
||||
for i in range(len(messages) - 1, -1, -1):
|
||||
candidate = messages[i]
|
||||
if candidate.get("role") == "user":
|
||||
@@ -1471,7 +1490,16 @@ def replay_transcript_to_ui_messages(
|
||||
existing_edits = candidate.get("fileEdits")
|
||||
if isinstance(existing_edits, list):
|
||||
for existing in existing_edits:
|
||||
if isinstance(existing, dict) and _file_edit_key(existing) in incoming_keys:
|
||||
if not isinstance(existing, dict):
|
||||
continue
|
||||
if (
|
||||
_file_edit_key(existing) in incoming_keys
|
||||
or (
|
||||
not existing.get("path")
|
||||
and existing.get("pending")
|
||||
and _file_edit_tool_event_key(existing) in incoming_tool_event_keys
|
||||
)
|
||||
):
|
||||
return i
|
||||
existing_tool_events = candidate.get("toolEvents")
|
||||
if isinstance(existing_tool_events, list):
|
||||
@@ -1479,7 +1507,7 @@ def replay_transcript_to_ui_messages(
|
||||
if not isinstance(event, dict):
|
||||
continue
|
||||
key = _tool_event_file_edit_key(event)
|
||||
if key and key in incoming_keys:
|
||||
if key and key in incoming_tool_event_keys:
|
||||
return i
|
||||
return None
|
||||
|
||||
@@ -1535,12 +1563,24 @@ def replay_transcript_to_ui_messages(
|
||||
if not isinstance(edit, dict):
|
||||
continue
|
||||
key = _file_edit_key(edit)
|
||||
if key in index_by_key:
|
||||
pos = index_by_key[key]
|
||||
pos = index_by_key.get(key)
|
||||
if pos is None and edit.get("path"):
|
||||
event_key = _file_edit_tool_event_key(edit)
|
||||
for existing_pos, existing_edit in enumerate(existing):
|
||||
if (
|
||||
isinstance(existing_edit, dict)
|
||||
and not existing_edit.get("path")
|
||||
and existing_edit.get("pending")
|
||||
and _file_edit_tool_event_key(existing_edit) == event_key
|
||||
):
|
||||
pos = existing_pos
|
||||
break
|
||||
if pos is not None:
|
||||
merged = {**existing[pos], **edit}
|
||||
if edit.get("path") and not edit.get("pending"):
|
||||
merged.pop("pending", None)
|
||||
existing[pos] = merged
|
||||
index_by_key[key] = pos
|
||||
else:
|
||||
index_by_key[key] = len(existing)
|
||||
existing.append(dict(edit))
|
||||
|
||||
@@ -863,6 +863,58 @@ def test_replay_file_edit_absorbs_matching_write_tool_event() -> None:
|
||||
]
|
||||
|
||||
|
||||
def test_replay_keeps_every_file_from_one_apply_patch_call() -> None:
|
||||
msgs = replay_transcript_to_ui_messages([
|
||||
{
|
||||
"event": "message",
|
||||
"chat_id": "t-file",
|
||||
"text": "apply_patch()",
|
||||
"kind": "tool_hint",
|
||||
"tool_events": [
|
||||
{
|
||||
"phase": "start",
|
||||
"call_id": "call-patch",
|
||||
"name": "apply_patch",
|
||||
"arguments": {"edits": []},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"event": "file_edit",
|
||||
"chat_id": "t-file",
|
||||
"edits": [
|
||||
{
|
||||
"version": 1,
|
||||
"call_id": "call-patch",
|
||||
"tool": "apply_patch",
|
||||
"path": "USER.md",
|
||||
"phase": "end",
|
||||
"added": 0,
|
||||
"deleted": 3,
|
||||
"approximate": False,
|
||||
"status": "done",
|
||||
},
|
||||
{
|
||||
"version": 1,
|
||||
"call_id": "call-patch",
|
||||
"tool": "apply_patch",
|
||||
"path": "MEMORY.md",
|
||||
"phase": "end",
|
||||
"added": 0,
|
||||
"deleted": 4,
|
||||
"approximate": False,
|
||||
"status": "done",
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
assert len(msgs) == 1
|
||||
assert msgs[0]["traces"] == []
|
||||
assert "toolEvents" not in msgs[0]
|
||||
assert [edit["path"] for edit in msgs[0]["fileEdits"]] == ["USER.md", "MEMORY.md"]
|
||||
|
||||
|
||||
def test_replay_keeps_interrupted_pre_tool_text_in_activity() -> None:
|
||||
msgs = replay_transcript_to_ui_messages([
|
||||
{"event": "delta", "chat_id": "t-stream", "text": "I will inspect first."},
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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), {
|
||||
|
||||
Reference in New Issue
Block a user