Skip to content

Commit 68954f9

Browse files
committed
test: extract chat item builder coverage
1 parent 31d5452 commit 68954f9

4 files changed

Lines changed: 551 additions & 542 deletions

File tree

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
import { describe, expect, it } from "vitest";
2+
import type { MessageGroup } from "../types/chat-types.ts";
3+
import { buildChatItems, type BuildChatItemsProps } from "./build-chat-items.ts";
4+
5+
function createProps(overrides: Partial<BuildChatItemsProps> = {}): BuildChatItemsProps {
6+
return {
7+
sessionKey: "main",
8+
messages: [],
9+
toolMessages: [],
10+
streamSegments: [],
11+
stream: null,
12+
streamStartedAt: null,
13+
showToolCalls: true,
14+
...overrides,
15+
};
16+
}
17+
18+
function messageGroups(props: Partial<BuildChatItemsProps>): MessageGroup[] {
19+
return buildChatItems(createProps(props)).filter((item) => item.kind === "group");
20+
}
21+
22+
function firstMessageContent(group: MessageGroup): unknown[] {
23+
const message = group.messages[0]?.message as { content?: unknown };
24+
return Array.isArray(message.content) ? message.content : [];
25+
}
26+
27+
describe("buildChatItems", () => {
28+
it("keeps consecutive user messages from different senders in separate groups", () => {
29+
const groups = messageGroups({
30+
messages: [
31+
{
32+
role: "user",
33+
content: "first",
34+
senderLabel: "Iris",
35+
timestamp: 1000,
36+
},
37+
{
38+
role: "user",
39+
content: "second",
40+
senderLabel: "Joaquin De Rojas",
41+
timestamp: 1001,
42+
},
43+
],
44+
});
45+
46+
expect(groups).toHaveLength(2);
47+
expect(groups.map((group) => group.senderLabel)).toEqual(["Iris", "Joaquin De Rojas"]);
48+
});
49+
50+
it("attaches lifted canvas previews to the nearest assistant turn", () => {
51+
const groups = messageGroups({
52+
messages: [
53+
{
54+
id: "assistant-with-canvas",
55+
role: "assistant",
56+
content: [{ type: "text", text: "First reply." }],
57+
timestamp: 1_000,
58+
},
59+
{
60+
id: "assistant-without-canvas",
61+
role: "assistant",
62+
content: [{ type: "text", text: "Later unrelated reply." }],
63+
timestamp: 2_000,
64+
},
65+
],
66+
toolMessages: [
67+
{
68+
id: "tool-canvas-for-first-reply",
69+
role: "tool",
70+
toolCallId: "call-canvas-old",
71+
toolName: "canvas_render",
72+
content: JSON.stringify({
73+
kind: "canvas",
74+
view: {
75+
backend: "canvas",
76+
id: "cv_nearest_turn",
77+
url: "/__openclaw__/canvas/documents/cv_nearest_turn/index.html",
78+
title: "Nearest turn demo",
79+
preferred_height: 320,
80+
},
81+
presentation: {
82+
target: "assistant_message",
83+
},
84+
}),
85+
timestamp: 1_001,
86+
},
87+
],
88+
});
89+
90+
expect(firstMessageContent(groups[0]).some((block) => isCanvasBlock(block))).toBe(true);
91+
expect(firstMessageContent(groups[1]).some((block) => isCanvasBlock(block))).toBe(false);
92+
});
93+
94+
it("does not lift generic view handles from non-canvas payloads", () => {
95+
const groups = messageGroups({
96+
messages: [
97+
{
98+
id: "assistant-generic-inline",
99+
role: "assistant",
100+
content: [{ type: "text", text: "Rendered the item inline." }],
101+
timestamp: 1000,
102+
},
103+
],
104+
toolMessages: [
105+
{
106+
id: "tool-generic-inline",
107+
role: "tool",
108+
toolCallId: "call-generic-inline",
109+
toolName: "plugin_card_details",
110+
content: JSON.stringify({
111+
selected_item: {
112+
summary: {
113+
label: "Alpha",
114+
meaning: "Generic example",
115+
},
116+
view: {
117+
backend: "canvas",
118+
id: "cv_generic_inline",
119+
url: "/__openclaw__/canvas/documents/cv_generic_inline/index.html",
120+
title: "Inline generic preview",
121+
preferred_height: 420,
122+
},
123+
},
124+
}),
125+
timestamp: 1001,
126+
},
127+
],
128+
});
129+
130+
expect(firstMessageContent(groups[0]).some((block) => isCanvasBlock(block))).toBe(false);
131+
});
132+
133+
it("lifts streamed canvas toolresult blocks into the assistant bubble", () => {
134+
const groups = messageGroups({
135+
messages: [
136+
{
137+
id: "assistant-streamed-artifact",
138+
role: "assistant",
139+
content: [{ type: "text", text: "Done." }],
140+
timestamp: 1000,
141+
},
142+
],
143+
toolMessages: [
144+
{
145+
id: "tool-streamed-artifact",
146+
role: "assistant",
147+
toolCallId: "call_streamed_artifact",
148+
timestamp: 999,
149+
content: [
150+
{
151+
type: "toolcall",
152+
name: "canvas_render",
153+
arguments: { source: { type: "handle", id: "cv_streamed_artifact" } },
154+
},
155+
{
156+
type: "toolresult",
157+
name: "canvas_render",
158+
text: JSON.stringify({
159+
kind: "canvas",
160+
view: {
161+
backend: "canvas",
162+
id: "cv_streamed_artifact",
163+
url: "/__openclaw__/canvas/documents/cv_streamed_artifact/index.html",
164+
title: "Streamed demo",
165+
preferred_height: 320,
166+
},
167+
presentation: {
168+
target: "assistant_message",
169+
},
170+
}),
171+
},
172+
],
173+
},
174+
],
175+
});
176+
177+
const canvasBlocks = firstMessageContent(groups[0]).filter((block) => isCanvasBlock(block));
178+
expect(canvasBlocks).toHaveLength(1);
179+
expect(canvasBlocks[0]).toMatchObject({
180+
preview: {
181+
viewId: "cv_streamed_artifact",
182+
title: "Streamed demo",
183+
},
184+
});
185+
});
186+
});
187+
188+
function isCanvasBlock(block: unknown): boolean {
189+
return (
190+
Boolean(block) &&
191+
typeof block === "object" &&
192+
(block as { type?: unknown; preview?: { kind?: unknown } }).type === "canvas" &&
193+
(block as { preview?: { kind?: unknown } }).preview?.kind === "canvas"
194+
);
195+
}

0 commit comments

Comments
 (0)