fix(webui): preserve fork replies during history refresh

This commit is contained in:
Xubin Ren
2026-06-22 22:00:07 +08:00
parent 747104c9cc
commit f80a78d5a8
2 changed files with 115 additions and 2 deletions
+46 -2
View File
@@ -44,7 +44,9 @@ function projectWebuiThreadMessages(messages: UIMessage[]): UIMessage[] {
return scrubSubagentUiMessages(normalizeLegacyLongTaskMessages(messages));
}
function sameMessageShape(a: UIMessage, b: UIMessage): boolean {
type MessageShape = Pick<UIMessage, "role" | "kind" | "content">;
function sameMessageShape(a: MessageShape, b: MessageShape): boolean {
return (
a.role === b.role
&& (a.kind ?? "") === (b.kind ?? "")
@@ -52,9 +54,51 @@ function sameMessageShape(a: UIMessage, b: UIMessage): boolean {
);
}
function durableMessageShape(message: UIMessage): MessageShape | null {
if (message.kind === "trace") return null;
if (message.role !== "user" && message.role !== "assistant") return null;
if (message.role === "assistant" && !message.content.trim() && !message.media?.length) {
return null;
}
return {
role: message.role,
kind: message.kind,
content: message.content,
};
}
function preservesDurableMessages(current: UIMessage[], snapshot: UIMessage[]): boolean {
// Canonical history refreshes can race with live websocket messages after fork/send.
// Never accept a refreshed snapshot that drops a user/assistant message already shown.
const expected = current
.map(durableMessageShape)
.filter((message): message is MessageShape => message !== null);
if (expected.length === 0) return true;
const candidates = snapshot
.map(durableMessageShape)
.filter((message): message is MessageShape => message !== null);
let cursor = 0;
for (const message of expected) {
let found = false;
while (cursor < candidates.length) {
const candidate = candidates[cursor];
cursor += 1;
if (sameMessageShape(message, candidate)) {
found = true;
break;
}
}
if (!found) return false;
}
return true;
}
function isStaleThreadSnapshot(current: UIMessage[], snapshot: UIMessage[]): boolean {
if (current.length === 0 || snapshot.length >= current.length) return false;
if (current.length === 0) return false;
if (snapshot.length === 0) return true;
if (!preservesDurableMessages(current, snapshot)) return true;
if (snapshot.length >= current.length) return false;
return snapshot.every((message, index) => sameMessageShape(current[index], message));
}
+69
View File
@@ -931,6 +931,75 @@ describe("ThreadShell", () => {
await waitFor(() => expect(screen.getByText("live assistant reply")).toBeInTheDocument());
});
it("keeps live fork replies when a canonical refresh is missing an earlier assistant answer", async () => {
const client = makeClient();
let historyCalls = 0;
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes("websocket%3Achat-fork/webui-thread")) {
historyCalls += 1;
return httpJson(
transcriptFromSimpleMessages(
historyCalls === 1
? [{ role: "user", content: "first fork question" }]
: [
{ role: "user", content: "first fork question" },
{ role: "user", content: "second fork question" },
],
),
);
}
return {
ok: false,
status: 404,
json: async () => ({}),
};
}),
);
render(
wrap(
client,
<ThreadShell
session={session("chat-fork")}
title="Chat chat-fork"
onToggleSidebar={() => {}}
onNewChat={() => {}}
/>,
),
);
await waitFor(() => expect(screen.getByText("first fork question")).toBeInTheDocument());
await act(async () => {
client._emitChat("chat-fork", {
event: "message",
chat_id: "chat-fork",
text: "first fork answer",
});
});
expect(screen.getByText("first fork answer")).toBeInTheDocument();
fireEvent.change(screen.getByLabelText("Message input"), {
target: { value: "second fork question" },
});
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
await waitFor(() =>
expectSendMessageWithTurn(client, "chat-fork", "second fork question"),
);
expect(screen.getByText("second fork question")).toBeInTheDocument();
await act(async () => {
client._emitSessionUpdate("chat-fork");
});
await waitFor(() => expect(historyCalls).toBe(2));
expect(screen.getByText("first fork question")).toBeInTheDocument();
expect(screen.getByText("first fork answer")).toBeInTheDocument();
expect(screen.getByText("second fork question")).toBeInTheDocument();
});
it("does not refetch thread history on turn_end", async () => {
const client = makeClient();
let historyCalls = 0;