























@@ -142,6 +142,274 @@ describe("buildChatItems", () => {
142142expect(groups[0].messages[0].duplicateCount).toBe(3);
143143});
144144145+it("deduplicates relay-labeled assistant copies by source message id", () => {
146+const groups = messageGroups({
147+messages: [
148+{
149+id: "reply-1",
150+role: "assistant",
151+content: [{ type: "text", text: "Parzival There it is." }],
152+senderLabel: "Parzival",
153+timestamp: 1,
154+},
155+{
156+id: "reply-1",
157+role: "assistant",
158+content: [{ type: "text", text: "There it is." }],
159+timestamp: 2,
160+},
161+],
162+});
163+164+expect(groups).toHaveLength(1);
165+expect(groups[0].senderLabel).toBeNull();
166+expect(groups[0].messages).toHaveLength(1);
167+expect(messageRecord(groups[0]).content).toStrictEqual([
168+{ type: "text", text: "There it is." },
169+]);
170+});
171+172+it("deduplicates relay-labeled assistant copies by event messageId", () => {
173+const groups = messageGroups({
174+messages: [
175+{
176+messageId: "reply-2",
177+role: "assistant",
178+content: [{ type: "text", text: "Parzival Found it." }],
179+senderLabel: "Parzival",
180+timestamp: 1,
181+},
182+{
183+messageId: "reply-2",
184+role: "assistant",
185+content: [{ type: "text", text: "Found it." }],
186+timestamp: 2,
187+},
188+],
189+});
190+191+expect(groups).toHaveLength(1);
192+expect(groups[0].senderLabel).toBeNull();
193+expect(groups[0].messages).toHaveLength(1);
194+expect(messageRecord(groups[0]).content).toStrictEqual([{ type: "text", text: "Found it." }]);
195+});
196+197+it("deduplicates relay-labeled assistant copies by OpenClaw transcript metadata id", () => {
198+const groups = messageGroups({
199+messages: [
200+{
201+__openclaw: { id: "reply-3" },
202+role: "assistant",
203+content: [{ type: "text", text: "Parzival On it." }],
204+senderLabel: "Parzival",
205+timestamp: 1,
206+},
207+{
208+__openclaw: { id: "reply-3" },
209+role: "assistant",
210+content: [{ type: "text", text: "On it." }],
211+timestamp: 2,
212+},
213+],
214+});
215+216+expect(groups).toHaveLength(1);
217+expect(groups[0].senderLabel).toBeNull();
218+expect(groups[0].messages).toHaveLength(1);
219+expect(messageRecord(groups[0]).content).toStrictEqual([{ type: "text", text: "On it." }]);
220+});
221+222+it("deduplicates relay-labeled assistant copies by OpenClaw metadata before surface ids", () => {
223+const groups = messageGroups({
224+messages: [
225+{
226+id: "relay-surface-copy",
227+__openclaw: { id: "reply-4" },
228+role: "assistant",
229+content: [{ type: "text", text: "Parzival Ship it." }],
230+senderLabel: "Parzival",
231+timestamp: 1,
232+},
233+{
234+id: "native-surface-copy",
235+__openclaw: { id: "reply-4" },
236+role: "assistant",
237+content: [{ type: "text", text: "Ship it." }],
238+timestamp: 2,
239+},
240+],
241+});
242+243+expect(groups).toHaveLength(1);
244+expect(groups[0].senderLabel).toBeNull();
245+expect(groups[0].messages).toHaveLength(1);
246+expect(messageRecord(groups[0]).content).toStrictEqual([{ type: "text", text: "Ship it." }]);
247+});
248+249+it("keeps native assistant updates separate when source message id repeats with new text", () => {
250+const groups = messageGroups({
251+messages: [
252+{
253+__openclaw: { id: "reply-5" },
254+role: "assistant",
255+content: [{ type: "text", text: "Draft one" }],
256+timestamp: 1,
257+},
258+{
259+__openclaw: { id: "reply-5" },
260+role: "assistant",
261+content: [{ type: "text", text: "Draft two" }],
262+timestamp: 2,
263+},
264+],
265+});
266+267+expect(groups).toHaveLength(1);
268+expect(groups[0].messages).toHaveLength(2);
269+expect(messageRecord(groups[0], 0).content).toStrictEqual([
270+{ type: "text", text: "Draft one" },
271+]);
272+expect(messageRecord(groups[0], 1).content).toStrictEqual([
273+{ type: "text", text: "Draft two" },
274+]);
275+});
276+277+it("keeps formatting-only assistant updates separate for the same source message", () => {
278+const groups = messageGroups({
279+messages: [
280+{
281+__openclaw: { id: "reply-formatted" },
282+role: "assistant",
283+content: [{ type: "text", text: "Parzival first\n\nsecond" }],
284+senderLabel: "Parzival",
285+timestamp: 1,
286+},
287+{
288+__openclaw: { id: "reply-formatted" },
289+role: "assistant",
290+content: [{ type: "text", text: "first second" }],
291+timestamp: 2,
292+},
293+],
294+});
295+296+expect(groups).toHaveLength(2);
297+expect(messageRecord(groups[0]).content).toStrictEqual([
298+{ type: "text", text: "Parzival first\n\nsecond" },
299+]);
300+expect(messageRecord(groups[1]).content).toStrictEqual([
301+{ type: "text", text: "first second" },
302+]);
303+});
304+305+it("keeps differently cased sender text separate for the same source message", () => {
306+const groups = messageGroups({
307+messages: [
308+{
309+__openclaw: { id: "reply-case-change" },
310+role: "assistant",
311+content: [{ type: "text", text: "PARZIVAL answer" }],
312+senderLabel: "Parzival",
313+timestamp: 1,
314+},
315+{
316+__openclaw: { id: "reply-case-change" },
317+role: "assistant",
318+content: [{ type: "text", text: "answer" }],
319+timestamp: 2,
320+},
321+],
322+});
323+324+expect(groups).toHaveLength(2);
325+expect(messageRecord(groups[0]).content).toStrictEqual([
326+{ type: "text", text: "PARZIVAL answer" },
327+]);
328+expect(messageRecord(groups[1]).content).toStrictEqual([{ type: "text", text: "answer" }]);
329+});
330+331+it("keeps relay-labeled assistant updates separate when source message id repeats with new text", () => {
332+const groups = messageGroups({
333+messages: [
334+{
335+__openclaw: { id: "reply-6" },
336+role: "assistant",
337+content: [{ type: "text", text: "Parzival Draft one" }],
338+senderLabel: "Parzival",
339+timestamp: 1,
340+},
341+{
342+__openclaw: { id: "reply-6" },
343+role: "assistant",
344+content: [{ type: "text", text: "Parzival Draft two" }],
345+senderLabel: "Parzival",
346+timestamp: 2,
347+},
348+],
349+});
350+351+expect(groups).toHaveLength(1);
352+expect(groups[0].senderLabel).toBe("Parzival");
353+expect(groups[0].messages).toHaveLength(2);
354+expect(messageRecord(groups[0], 0).content).toStrictEqual([
355+{ type: "text", text: "Parzival Draft one" },
356+]);
357+expect(messageRecord(groups[0], 1).content).toStrictEqual([
358+{ type: "text", text: "Parzival Draft two" },
359+]);
360+});
361+362+it("keeps identical assistant text separate when source message ids differ", () => {
363+const groups = messageGroups({
364+messages: [
365+{
366+id: "reply-7",
367+role: "assistant",
368+content: [{ type: "text", text: "Same update" }],
369+senderLabel: "Parzival",
370+timestamp: 1,
371+},
372+{
373+id: "reply-8",
374+role: "assistant",
375+content: [{ type: "text", text: "Same update" }],
376+senderLabel: "Parzival",
377+timestamp: 2,
378+},
379+],
380+});
381+382+expect(groups).toHaveLength(1);
383+expect(groups[0].messages).toHaveLength(2);
384+expect(groups[0].messages[0].duplicateCount).toBeUndefined();
385+expect(groups[0].messages[1].duplicateCount).toBeUndefined();
386+});
387+388+it("keeps same-id user relay copies separate so sender identity is preserved", () => {
389+const groups = messageGroups({
390+messages: [
391+{
392+__openclaw: { id: "user-1" },
393+role: "user",
394+content: [{ type: "text", text: "Alice hello" }],
395+senderLabel: "Alice",
396+timestamp: 1,
397+},
398+{
399+__openclaw: { id: "user-1" },
400+role: "user",
401+content: [{ type: "text", text: "hello" }],
402+timestamp: 2,
403+},
404+],
405+});
406+407+expect(groups).toHaveLength(2);
408+expect(groups.map((group) => group.senderLabel)).toEqual(["Alice", null]);
409+expect(groups[0].messages).toHaveLength(1);
410+expect(groups[1].messages).toHaveLength(1);
411+});
412+145413it("suppresses assistant HEARTBEAT_OK acknowledgements before rendering history", () => {
146414const groups = messageGroups({
147415messages: [
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。