/ src / components / chat / message-bubble.test.ts
message-bubble.test.ts
  1  import assert from 'node:assert/strict'
  2  import { describe, it } from 'node:test'
  3  import React from 'react'
  4  import { renderToStaticMarkup } from 'react-dom/server'
  5  
  6  describe('MessageBubble', () => {
  7    it('renders media-only assistant turns without an empty markdown body', async () => {
  8      const messageBubbleModule = await import('./message-bubble') as Record<string, unknown>
  9      const MessageBubble = (
 10        messageBubbleModule.MessageBubble
 11        || (messageBubbleModule.default as { MessageBubble?: unknown } | undefined)?.MessageBubble
 12        || (messageBubbleModule['module.exports'] as { MessageBubble?: unknown } | undefined)?.MessageBubble
 13      ) as typeof import('./message-bubble').MessageBubble | undefined
 14      assert.ok(MessageBubble)
 15      const html = renderToStaticMarkup(
 16        React.createElement(MessageBubble, {
 17          message: {
 18            role: 'assistant',
 19            text: '',
 20            time: Date.now(),
 21            kind: 'chat',
 22            toolEvents: [
 23              {
 24                name: 'browser',
 25                input: '{"action":"screenshot"}',
 26                output: '![Screenshot](/api/uploads/test-screenshot.png)',
 27              },
 28            ],
 29          },
 30          assistantName: 'Hal2k-3',
 31          agentName: 'Hal2k-3',
 32        }),
 33      )
 34  
 35      assert.match(html, /\/api\/uploads\/test-screenshot\.png/)
 36      assert.doesNotMatch(html, /msg-content text-\[15px]/)
 37      assert.doesNotMatch(html, /streaming-cursor/)
 38    })
 39  
 40    it('falls back to persisted streaming content when the live stream payload is temporarily empty', async () => {
 41      const messageBubbleModule = await import('./message-bubble') as Record<string, unknown>
 42      const MessageBubble = (
 43        messageBubbleModule.MessageBubble
 44        || (messageBubbleModule.default as { MessageBubble?: unknown } | undefined)?.MessageBubble
 45        || (messageBubbleModule['module.exports'] as { MessageBubble?: unknown } | undefined)?.MessageBubble
 46      ) as typeof import('./message-bubble').MessageBubble | undefined
 47      assert.ok(MessageBubble)
 48  
 49      const html = renderToStaticMarkup(
 50        React.createElement(MessageBubble, {
 51          message: {
 52            role: 'assistant',
 53            text: 'Recovered persisted partial text',
 54            time: Date.now(),
 55            kind: 'chat',
 56            streaming: true,
 57          },
 58          assistantName: 'Hal2k-3',
 59          agentName: 'Hal2k-3',
 60          liveStream: {
 61            active: true,
 62            phase: 'responding',
 63            toolName: '',
 64            text: '',
 65            thinking: '',
 66            toolEvents: [],
 67          },
 68        }),
 69      )
 70  
 71      assert.match(html, /Recovered persisted partial text/)
 72      assert.match(html, /streaming-cursor/)
 73    })
 74  
 75    it('renders upload-linked screenshots inline without duplicating them at the bottom', async () => {
 76      const messageBubbleModule = await import('./message-bubble') as Record<string, unknown>
 77      const MessageBubble = (
 78        messageBubbleModule.MessageBubble
 79        || (messageBubbleModule.default as { MessageBubble?: unknown } | undefined)?.MessageBubble
 80        || (messageBubbleModule['module.exports'] as { MessageBubble?: unknown } | undefined)?.MessageBubble
 81      ) as typeof import('./message-bubble').MessageBubble | undefined
 82      assert.ok(MessageBubble)
 83      const html = renderToStaticMarkup(
 84        React.createElement(MessageBubble, {
 85          message: {
 86            role: 'assistant',
 87            text: [
 88              "I've sent you two screenshots:",
 89              '',
 90              '1. **Sunflower** (Download: [screenshot-1.png](/api/uploads/1773570599000-screenshot-1.png))',
 91              '',
 92              '2. **Quantum** (Download: [screenshot-2.png](/api/uploads/1773570616255-screenshot-2.png))',
 93            ].join('\n'),
 94            time: Date.now(),
 95            kind: 'chat',
 96            toolEvents: [
 97              {
 98                name: 'browser',
 99                input: '{"action":"screenshot"}',
100                output: '![Screenshot](/api/uploads/screenshot-1.png)',
101              },
102              {
103                name: 'browser',
104                input: '{"action":"screenshot"}',
105                output: '![Screenshot](/api/uploads/screenshot-2.png)',
106              },
107              {
108                name: 'send_file',
109                input: '{"filePath":"/api/uploads/screenshot-1.png"}',
110                output: '[Download screenshot-1.png](/api/uploads/1773570599000-screenshot-1.png)',
111              },
112              {
113                name: 'send_file',
114                input: '{"filePath":"/api/uploads/screenshot-2.png"}',
115                output: '[Download screenshot-2.png](/api/uploads/1773570616255-screenshot-2.png)',
116              },
117            ],
118          },
119          assistantName: 'Hal2k-3',
120          agentName: 'Hal2k-3',
121        }),
122      )
123  
124      assert.match(html, /screenshot-1\.png/)
125      assert.match(html, /screenshot-2\.png/)
126      assert.equal((html.match(/<img /g) || []).length, 2)
127      assert.doesNotMatch(html, /flex flex-col gap-2 mt-3"><\/div>/)
128    })
129  
130    it('interleaves live streaming screenshots between paragraphs instead of only appending them at the bottom', async () => {
131      const messageBubbleModule = await import('./message-bubble') as Record<string, unknown>
132      const MessageBubble = (
133        messageBubbleModule.MessageBubble
134        || (messageBubbleModule.default as { MessageBubble?: unknown } | undefined)?.MessageBubble
135        || (messageBubbleModule['module.exports'] as { MessageBubble?: unknown } | undefined)?.MessageBubble
136      ) as typeof import('./message-bubble').MessageBubble | undefined
137      assert.ok(MessageBubble)
138  
139      const html = renderToStaticMarkup(
140        React.createElement(MessageBubble, {
141          message: {
142            role: 'assistant',
143            text: '',
144            time: Date.now(),
145            kind: 'chat',
146          },
147          assistantName: 'Hal2k',
148          agentName: 'Hal2k',
149          liveStream: {
150            active: true,
151            phase: 'responding',
152            toolName: '',
153            text: [
154              'First paragraph before the first screenshot.',
155              '',
156              'Second paragraph before the second screenshot.',
157            ].join('\n'),
158            thinking: '',
159            toolEvents: [
160              {
161                id: 'tool-1',
162                name: 'browser',
163                input: '{"action":"screenshot"}',
164                output: '![Screenshot](/api/uploads/first-stream.png)',
165                status: 'done',
166              },
167              {
168                id: 'tool-2',
169                name: 'browser',
170                input: '{"action":"screenshot"}',
171                output: '![Screenshot](/api/uploads/second-stream.png)',
172                status: 'done',
173              },
174            ],
175          },
176        }),
177      )
178  
179      const firstParagraphIndex = html.indexOf('First paragraph before the first screenshot.')
180      const firstImageIndex = html.indexOf('/api/uploads/first-stream.png')
181      const secondParagraphIndex = html.indexOf('Second paragraph before the second screenshot.')
182      const secondImageIndex = html.indexOf('/api/uploads/second-stream.png')
183  
184      assert.ok(firstParagraphIndex >= 0)
185      assert.ok(firstImageIndex > firstParagraphIndex)
186      assert.ok(secondParagraphIndex > firstImageIndex)
187      assert.ok(secondImageIndex > secondParagraphIndex)
188      assert.equal((html.match(/<img /g) || []).length, 2)
189    })
190  
191    it('interleaves live streaming screenshots for single-newline prose instead of waiting until the end', async () => {
192      const messageBubbleModule = await import('./message-bubble') as Record<string, unknown>
193      const MessageBubble = (
194        messageBubbleModule.MessageBubble
195        || (messageBubbleModule.default as { MessageBubble?: unknown } | undefined)?.MessageBubble
196        || (messageBubbleModule['module.exports'] as { MessageBubble?: unknown } | undefined)?.MessageBubble
197      ) as typeof import('./message-bubble').MessageBubble | undefined
198      assert.ok(MessageBubble)
199  
200      const html = renderToStaticMarkup(
201        React.createElement(MessageBubble, {
202          message: {
203            role: 'assistant',
204            text: '',
205            time: Date.now(),
206            kind: 'chat',
207          },
208          assistantName: 'Hal2k',
209          agentName: 'Hal2k',
210          liveStream: {
211            active: true,
212            phase: 'responding',
213            toolName: '',
214            text: [
215              'First live line before the first screenshot.',
216              'Second live line before the second screenshot.',
217            ].join('\n'),
218            thinking: '',
219            toolEvents: [
220              {
221                id: 'tool-1',
222                name: 'browser',
223                input: '{"action":"screenshot"}',
224                output: '![Screenshot](/api/uploads/first-single-line.png)',
225                status: 'done',
226              },
227              {
228                id: 'tool-2',
229                name: 'browser',
230                input: '{"action":"screenshot"}',
231                output: '![Screenshot](/api/uploads/second-single-line.png)',
232                status: 'done',
233              },
234            ],
235          },
236        }),
237      )
238  
239      const firstLineIndex = html.indexOf('First live line before the first screenshot.')
240      const firstImageIndex = html.indexOf('/api/uploads/first-single-line.png')
241      const secondLineIndex = html.indexOf('Second live line before the second screenshot.')
242      const secondImageIndex = html.indexOf('/api/uploads/second-single-line.png')
243  
244      assert.ok(firstLineIndex >= 0)
245      assert.ok(firstImageIndex > firstLineIndex)
246      assert.ok(secondLineIndex > firstImageIndex)
247      assert.ok(secondImageIndex > secondLineIndex)
248      assert.equal((html.match(/<img /g) || []).length, 2)
249    })
250  
251    it('renders connector-delivery transcript as the primary message content', async () => {
252      const messageBubbleModule = await import('./message-bubble') as Record<string, unknown>
253      const MessageBubble = (
254        messageBubbleModule.MessageBubble
255        || (messageBubbleModule.default as { MessageBubble?: unknown } | undefined)?.MessageBubble
256        || (messageBubbleModule['module.exports'] as { MessageBubble?: unknown } | undefined)?.MessageBubble
257      ) as typeof import('./message-bubble').MessageBubble | undefined
258      assert.ok(MessageBubble)
259  
260      const html = renderToStaticMarkup(
261        React.createElement(MessageBubble, {
262          message: {
263            role: 'assistant',
264            text: 'Message delivered.',
265            time: Date.now(),
266            kind: 'connector-delivery',
267            source: {
268              platform: 'telegram',
269              connectorId: 'connector-1',
270              connectorName: 'Telegram',
271              channelId: 'chat-1',
272              senderId: 'user-1',
273              senderName: 'Wayde',
274              deliveryTranscript: 'I tested the platform and sent the update through Telegram.',
275              deliveryMode: 'text',
276            },
277          },
278          assistantName: 'Hal2k',
279          agentName: 'Hal2k',
280        }),
281      )
282  
283      assert.match(html, /Delivered via connector/)
284      assert.match(html, /I tested the platform and sent the update through Telegram\./)
285      assert.doesNotMatch(html, />Message delivered\.<\/p>/)
286    })
287  })