fix(ui): prevent duplicate assistant bubbles after history refresh (#126364)

* fix(ui): stabilize chat history dedupe identity

* fix(ui): narrow chat history dedupe identity
This commit is contained in:
Peter Steinberger
2026-08-19 10:48:33 -07:00
committed by GitHub
parent 7e353d750a
commit 55fb2c8606
3 changed files with 53 additions and 10 deletions
+40 -2
View File
@@ -8,6 +8,7 @@ import type { ApplicationContext } from "../../app/context.ts";
import type { SessionCapability } from "../../lib/sessions/index.ts";
import "./chat-pane.ts";
import { loadChatHistory } from "./chat-history.ts";
import { nativeHistoryMessageIdentity } from "./chat-pane-shared.ts";
import type { ChatPageHost } from "./chat-state-host.ts";
type TestChatPane = HTMLElement & {
@@ -516,8 +517,14 @@ describe("chat pane native history pagination", () => {
const client = { request: vi.fn() } as unknown as GatewayBrowserClient;
const { pane } = createTestChatPane({ client, sessions: {} as SessionCapability });
const projected = [
nativeHistoryMessage(1, "tool call"),
nativeHistoryMessage(1, "visible tool reply"),
{
...nativeHistoryMessage(1, "Same routed send"),
openclawMessageToolMirror: { toolName: "message", toolCallId: "call-a" },
},
{
...nativeHistoryMessage(1, "Same routed send"),
openclawMessageToolMirror: { toolName: "message", toolCallId: "call-b" },
},
];
expect(pane.prependUniqueNativeMessages(projected, [nativeHistoryMessage(2)])).toEqual([
@@ -530,6 +537,37 @@ describe("chat pane native history pagination", () => {
).toEqual([projected[0], projected[1], nativeHistoryMessage(2)]);
});
it("deduplicates byte-different live-event and history projections of one transcript row", () => {
const client = { request: vi.fn() } as unknown as GatewayBrowserClient;
const { pane } = createTestChatPane({ client, sessions: {} as SessionCapability });
const liveEventProjection = {
role: "assistant",
content: [{ type: "text", text: "One stored reply" }],
__openclaw: {
id: "assistant-message-42",
idempotencyKey: "run-42",
seq: 42,
},
};
const historyProjection = {
role: "assistant",
content: [{ type: "text", text: "One stored reply" }],
__openclaw: {
id: "assistant-message-42",
idempotencyKey: "run-42",
recordTimestampMs: 1_786_000_000_000,
seq: 42,
},
};
expect(nativeHistoryMessageIdentity(liveEventProjection)).toBe(
nativeHistoryMessageIdentity(historyProjection),
);
expect(pane.prependUniqueNativeMessages([historyProjection], [liveEventProjection])).toEqual([
liveEventProjection,
]);
});
it("deduplicates projected catalog transcript records by catalog message id", () => {
const client = { request: vi.fn() } as unknown as GatewayBrowserClient;
const { pane } = createTestChatPane({ client, sessions: {} as SessionCapability });
+5 -3
View File
@@ -213,10 +213,12 @@ export function nativeHistoryMessageIdentity(message: unknown): string | null {
if (!sourceIdentity) {
return null;
}
const { recordTimestampMs: _recordTimestampMs, ...projectionMetadata } = metadata ?? {};
const projection = metadata ? { ...record, __openclaw: projectionMetadata } : record;
try {
// One transcript record can project to multiple visible siblings. Include
// the projection bytes so partial page overlap removes the matching sibling.
return `${sourceIdentity}:${JSON.stringify(message)}`;
// History alone adds recordTimestampMs; delivery metadata is not projection identity.
// Keep every other projection byte so siblings from one transcript row stay distinct.
return `${sourceIdentity}:${JSON.stringify(projection)}`;
} catch {
return sourceIdentity;
}
+8 -5
View File
@@ -2221,18 +2221,21 @@ describe("buildCachedChatItems", () => {
expect(groupAt(groups, 0).messages).toHaveLength(1);
});
it("collapses consecutive duplicate text messages into one rendered item with a count", () => {
it("collapses two distinct persisted rows with identical text into one rendered item", () => {
const groups = messageGroups({
messages: [
assistantMessage([{ type: "text", text: "Same update" }], 1),
assistantMessage([{ type: "text", text: "Same update" }], 2),
assistantMessage([{ type: "text", text: "Same update" }], 3),
assistantMessage([{ type: "text", text: "Same update" }], 1, {
__openclaw: { seq: 7 },
}),
assistantMessage([{ type: "text", text: "Same update" }], 2, {
__openclaw: { seq: 8 },
}),
],
});
expect(groups).toHaveLength(1);
expect(groupAt(groups, 0).messages).toHaveLength(1);
expect(messageAt(groupAt(groups, 0), 0).duplicateCount).toBe(3);
expect(messageAt(groupAt(groups, 0), 0).duplicateCount).toBe(2);
});
it.each([