diff --git a/ui/src/pages/chat/chat-pane-history.test.ts b/ui/src/pages/chat/chat-pane-history.test.ts index 3696e228e282..8b28789331e7 100644 --- a/ui/src/pages/chat/chat-pane-history.test.ts +++ b/ui/src/pages/chat/chat-pane-history.test.ts @@ -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 }); diff --git a/ui/src/pages/chat/chat-pane-shared.ts b/ui/src/pages/chat/chat-pane-shared.ts index 4dac2b306e8d..cea0cb0308d8 100644 --- a/ui/src/pages/chat/chat-pane-shared.ts +++ b/ui/src/pages/chat/chat-pane-shared.ts @@ -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; } diff --git a/ui/src/pages/chat/chat-thread.test.ts b/ui/src/pages/chat/chat-thread.test.ts index 2183008a50f9..0e4ecb432101 100644 --- a/ui/src/pages/chat/chat-thread.test.ts +++ b/ui/src/pages/chat/chat-thread.test.ts @@ -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([