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: '', 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: '', 101 }, 102 { 103 name: 'browser', 104 input: '{"action":"screenshot"}', 105 output: '', 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: '', 165 status: 'done', 166 }, 167 { 168 id: 'tool-2', 169 name: 'browser', 170 input: '{"action":"screenshot"}', 171 output: '', 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: '', 225 status: 'done', 226 }, 227 { 228 id: 'tool-2', 229 name: 'browser', 230 input: '{"action":"screenshot"}', 231 output: '', 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 })