clawport-ui 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. package/.env.example +35 -0
  2. package/BRANDING.md +131 -0
  3. package/CLAUDE.md +252 -0
  4. package/README.md +262 -0
  5. package/SETUP.md +337 -0
  6. package/app/agents/[id]/page.tsx +727 -0
  7. package/app/api/agents/route.ts +12 -0
  8. package/app/api/chat/[id]/route.ts +139 -0
  9. package/app/api/cron-runs/route.ts +13 -0
  10. package/app/api/crons/route.ts +12 -0
  11. package/app/api/kanban/chat/[id]/route.ts +119 -0
  12. package/app/api/kanban/chat-history/[ticketId]/route.ts +36 -0
  13. package/app/api/memory/route.ts +12 -0
  14. package/app/api/transcribe/route.ts +37 -0
  15. package/app/api/tts/route.ts +42 -0
  16. package/app/chat/[id]/page.tsx +10 -0
  17. package/app/chat/page.tsx +200 -0
  18. package/app/crons/page.tsx +870 -0
  19. package/app/docs/page.tsx +399 -0
  20. package/app/favicon.ico +0 -0
  21. package/app/globals.css +692 -0
  22. package/app/kanban/page.tsx +327 -0
  23. package/app/layout.tsx +45 -0
  24. package/app/memory/page.tsx +685 -0
  25. package/app/page.tsx +817 -0
  26. package/app/providers.tsx +37 -0
  27. package/app/settings/page.tsx +901 -0
  28. package/app/settings-provider.tsx +209 -0
  29. package/components/AgentAvatar.tsx +54 -0
  30. package/components/AgentNode.tsx +122 -0
  31. package/components/Breadcrumbs.tsx +126 -0
  32. package/components/DynamicFavicon.tsx +62 -0
  33. package/components/ErrorState.tsx +97 -0
  34. package/components/FeedView.tsx +494 -0
  35. package/components/GlobalSearch.tsx +571 -0
  36. package/components/GridView.tsx +532 -0
  37. package/components/ManorMap.tsx +157 -0
  38. package/components/MobileSidebar.tsx +251 -0
  39. package/components/NavLinks.tsx +271 -0
  40. package/components/OnboardingWizard.tsx +1067 -0
  41. package/components/Sidebar.tsx +115 -0
  42. package/components/ThemeToggle.tsx +108 -0
  43. package/components/chat/AgentList.tsx +537 -0
  44. package/components/chat/ConversationView.tsx +1047 -0
  45. package/components/chat/FileAttachment.tsx +140 -0
  46. package/components/chat/MediaPreview.tsx +111 -0
  47. package/components/chat/VoiceMessage.tsx +139 -0
  48. package/components/crons/PipelineGraph.tsx +327 -0
  49. package/components/crons/WeeklySchedule.tsx +630 -0
  50. package/components/docs/AgentsSection.tsx +209 -0
  51. package/components/docs/ApiReferenceSection.tsx +256 -0
  52. package/components/docs/ArchitectureSection.tsx +221 -0
  53. package/components/docs/ComponentsSection.tsx +253 -0
  54. package/components/docs/CronSystemSection.tsx +235 -0
  55. package/components/docs/DocSection.tsx +346 -0
  56. package/components/docs/GettingStartedSection.tsx +169 -0
  57. package/components/docs/ThemingSection.tsx +257 -0
  58. package/components/docs/TroubleshootingSection.tsx +200 -0
  59. package/components/kanban/AgentPicker.tsx +321 -0
  60. package/components/kanban/CreateTicketModal.tsx +333 -0
  61. package/components/kanban/KanbanBoard.tsx +70 -0
  62. package/components/kanban/KanbanColumn.tsx +166 -0
  63. package/components/kanban/TicketCard.tsx +245 -0
  64. package/components/kanban/TicketDetailPanel.tsx +850 -0
  65. package/components/ui/badge.tsx +48 -0
  66. package/components/ui/button.tsx +64 -0
  67. package/components/ui/card.tsx +92 -0
  68. package/components/ui/dialog.tsx +158 -0
  69. package/components/ui/scroll-area.tsx +58 -0
  70. package/components/ui/separator.tsx +28 -0
  71. package/components/ui/skeleton.tsx +27 -0
  72. package/components/ui/tabs.tsx +91 -0
  73. package/components/ui/tooltip.tsx +57 -0
  74. package/components.json +23 -0
  75. package/docs/API.md +648 -0
  76. package/docs/COMPONENTS.md +1059 -0
  77. package/docs/THEMING.md +795 -0
  78. package/lib/agents-registry.ts +35 -0
  79. package/lib/agents.json +282 -0
  80. package/lib/agents.test.ts +367 -0
  81. package/lib/agents.ts +32 -0
  82. package/lib/anthropic.test.ts +422 -0
  83. package/lib/anthropic.ts +220 -0
  84. package/lib/api-error.ts +16 -0
  85. package/lib/audio-recorder.test.ts +72 -0
  86. package/lib/audio-recorder.ts +169 -0
  87. package/lib/conversations.test.ts +331 -0
  88. package/lib/conversations.ts +117 -0
  89. package/lib/cron-pipelines.test.ts +69 -0
  90. package/lib/cron-pipelines.ts +58 -0
  91. package/lib/cron-runs.test.ts +118 -0
  92. package/lib/cron-runs.ts +67 -0
  93. package/lib/cron-utils.test.ts +222 -0
  94. package/lib/cron-utils.ts +160 -0
  95. package/lib/crons.test.ts +502 -0
  96. package/lib/crons.ts +114 -0
  97. package/lib/env.test.ts +44 -0
  98. package/lib/env.ts +14 -0
  99. package/lib/kanban/automation.test.ts +245 -0
  100. package/lib/kanban/automation.ts +143 -0
  101. package/lib/kanban/chat-store.test.ts +149 -0
  102. package/lib/kanban/chat-store.ts +81 -0
  103. package/lib/kanban/store.test.ts +238 -0
  104. package/lib/kanban/store.ts +98 -0
  105. package/lib/kanban/types.ts +50 -0
  106. package/lib/kanban/useAgentWork.ts +78 -0
  107. package/lib/memory.ts +45 -0
  108. package/lib/multimodal.test.ts +219 -0
  109. package/lib/multimodal.ts +68 -0
  110. package/lib/pipeline.integration.test.ts +343 -0
  111. package/lib/sanitize.ts +194 -0
  112. package/lib/settings.test.ts +137 -0
  113. package/lib/settings.ts +94 -0
  114. package/lib/styles.ts +24 -0
  115. package/lib/themes.ts +9 -0
  116. package/lib/transcribe.test.ts +141 -0
  117. package/lib/transcribe.ts +111 -0
  118. package/lib/types.ts +66 -0
  119. package/lib/utils.ts +6 -0
  120. package/lib/validation.test.ts +132 -0
  121. package/lib/validation.ts +80 -0
  122. package/next.config.ts +7 -0
  123. package/package.json +56 -0
  124. package/postcss.config.mjs +7 -0
  125. package/public/file.svg +1 -0
  126. package/public/globe.svg +1 -0
  127. package/public/next.svg +1 -0
  128. package/public/vercel.svg +1 -0
  129. package/public/window.svg +1 -0
  130. package/scripts/setup.mjs +215 -0
  131. package/tsconfig.json +34 -0
  132. package/vitest.config.ts +17 -0
@@ -0,0 +1,219 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { buildApiContent } from './multimodal'
3
+ import type { Message, MediaAttachment } from './conversations'
4
+
5
+ function msg(overrides: Partial<Message> = {}): Message {
6
+ return {
7
+ id: 'msg-1',
8
+ role: 'user',
9
+ content: 'hello',
10
+ timestamp: 1000,
11
+ ...overrides,
12
+ }
13
+ }
14
+
15
+ function imageAttachment(overrides: Partial<MediaAttachment> = {}): MediaAttachment {
16
+ return {
17
+ type: 'image',
18
+ url: 'data:image/png;base64,iVBORw0KGgoAAAA',
19
+ name: 'photo.png',
20
+ mimeType: 'image/png',
21
+ ...overrides,
22
+ }
23
+ }
24
+
25
+ function fileAttachment(overrides: Partial<MediaAttachment> = {}): MediaAttachment {
26
+ return {
27
+ type: 'file',
28
+ url: 'data:application/pdf;base64,JVBERi0',
29
+ name: 'report.pdf',
30
+ mimeType: 'application/pdf',
31
+ size: 245000,
32
+ ...overrides,
33
+ }
34
+ }
35
+
36
+ function audioAttachment(overrides: Partial<MediaAttachment> = {}): MediaAttachment {
37
+ return {
38
+ type: 'audio',
39
+ url: 'data:audio/webm;base64,GkXfo59C',
40
+ name: 'Voice message',
41
+ mimeType: 'audio/webm',
42
+ duration: 5,
43
+ waveform: Array(50).fill(0.3),
44
+ ...overrides,
45
+ }
46
+ }
47
+
48
+ // --- plain text messages ---
49
+
50
+ describe('buildApiContent — plain text', () => {
51
+ it('returns string content when no media is attached', () => {
52
+ const result = buildApiContent(msg({ content: 'just text' }))
53
+ expect(result).toBe('just text')
54
+ })
55
+
56
+ it('returns string content when media array is empty', () => {
57
+ const result = buildApiContent(msg({ content: 'text', media: [] }))
58
+ expect(result).toBe('text')
59
+ })
60
+
61
+ it('returns empty string for empty content with no media', () => {
62
+ const result = buildApiContent(msg({ content: '' }))
63
+ expect(result).toBe('')
64
+ })
65
+ })
66
+
67
+ // --- image attachments ---
68
+
69
+ describe('buildApiContent — images', () => {
70
+ it('converts a message with one image to content parts array', () => {
71
+ const result = buildApiContent(msg({
72
+ content: 'what is this?',
73
+ media: [imageAttachment()],
74
+ }))
75
+ expect(Array.isArray(result)).toBe(true)
76
+ const parts = result as Array<{ type: string }>
77
+ expect(parts).toHaveLength(2)
78
+ expect(parts[0]).toEqual({ type: 'text', text: 'what is this?' })
79
+ expect(parts[1]).toEqual({ type: 'image_url', image_url: { url: 'data:image/png;base64,iVBORw0KGgoAAAA' } })
80
+ })
81
+
82
+ it('includes multiple images as separate image_url parts', () => {
83
+ const result = buildApiContent(msg({
84
+ content: 'compare these',
85
+ media: [
86
+ imageAttachment({ url: 'data:image/png;base64,AAA' }),
87
+ imageAttachment({ url: 'data:image/png;base64,BBB' }),
88
+ ],
89
+ }))
90
+ const parts = result as Array<{ type: string }>
91
+ expect(parts).toHaveLength(3) // 1 text + 2 images
92
+ expect(parts.filter(p => p.type === 'image_url')).toHaveLength(2)
93
+ })
94
+
95
+ it('handles image-only message with no text content', () => {
96
+ const result = buildApiContent(msg({
97
+ content: '',
98
+ media: [imageAttachment()],
99
+ }))
100
+ const parts = result as Array<{ type: string }>
101
+ // No text part since content is empty, just the image
102
+ expect(parts).toHaveLength(1)
103
+ expect(parts[0].type).toBe('image_url')
104
+ })
105
+ })
106
+
107
+ // --- file attachments ---
108
+
109
+ describe('buildApiContent — files', () => {
110
+ it('adds a text label for binary files (PDF)', () => {
111
+ const result = buildApiContent(msg({
112
+ content: 'here is a doc',
113
+ media: [fileAttachment()],
114
+ }))
115
+ const parts = result as Array<{ type: string; text?: string }>
116
+ expect(parts).toHaveLength(2)
117
+ expect(parts[1].type).toBe('text')
118
+ expect(parts[1].text).toContain('report.pdf')
119
+ expect(parts[1].text).toContain('239 KB') // 245000 / 1024 ≈ 239
120
+ })
121
+
122
+ it('inlines text file content from base64 data URL', () => {
123
+ const textContent = 'Hello, world!'
124
+ const base64 = btoa(textContent)
125
+ const result = buildApiContent(msg({
126
+ content: 'check this file',
127
+ media: [fileAttachment({
128
+ name: 'notes.txt',
129
+ mimeType: 'text/plain',
130
+ url: `data:text/plain;base64,${base64}`,
131
+ })],
132
+ }))
133
+ const parts = result as Array<{ type: string; text?: string }>
134
+ expect(parts).toHaveLength(2)
135
+ expect(parts[1].text).toContain('Hello, world!')
136
+ expect(parts[1].text).toContain('Contents of notes.txt')
137
+ })
138
+
139
+ it('inlines JSON file based on extension even without text/ mimeType', () => {
140
+ const jsonContent = '{"key": "value"}'
141
+ const base64 = btoa(jsonContent)
142
+ const result = buildApiContent(msg({
143
+ content: '',
144
+ media: [fileAttachment({
145
+ name: 'config.json',
146
+ mimeType: 'application/json',
147
+ url: `data:application/json;base64,${base64}`,
148
+ })],
149
+ }))
150
+ const parts = result as Array<{ type: string; text?: string }>
151
+ expect(parts).toHaveLength(1)
152
+ expect(parts[0].text).toContain('"key": "value"')
153
+ })
154
+
155
+ it('falls back to label when file has no name', () => {
156
+ const result = buildApiContent(msg({
157
+ content: '',
158
+ media: [fileAttachment({ name: undefined })],
159
+ }))
160
+ const parts = result as Array<{ type: string; text?: string }>
161
+ expect(parts[0].text).toContain('unknown')
162
+ })
163
+ })
164
+
165
+ // --- audio attachments ---
166
+
167
+ describe('buildApiContent — audio', () => {
168
+ it('returns plain string for audio-only message (transcript from Whisper)', () => {
169
+ const result = buildApiContent(msg({
170
+ content: 'Hello this is my transcribed message',
171
+ media: [audioAttachment()],
172
+ }))
173
+ // Audio is skipped and no other non-text parts exist, so return plain string
174
+ // to avoid wrapping in ContentPart[] which the gateway may not handle
175
+ expect(result).toBe('Hello this is my transcribed message')
176
+ })
177
+
178
+ it('returns plain string when audio is the only attachment', () => {
179
+ const result = buildApiContent(msg({
180
+ content: 'transcribed words',
181
+ media: [audioAttachment()],
182
+ }))
183
+ expect(typeof result).toBe('string')
184
+ expect(result).toBe('transcribed words')
185
+ })
186
+ })
187
+
188
+ // --- mixed media ---
189
+
190
+ describe('buildApiContent — mixed media', () => {
191
+ it('handles image + file together', () => {
192
+ const result = buildApiContent(msg({
193
+ content: 'look at both',
194
+ media: [
195
+ imageAttachment(),
196
+ fileAttachment(),
197
+ ],
198
+ }))
199
+ const parts = result as Array<{ type: string }>
200
+ expect(parts).toHaveLength(3) // text + image + file label
201
+ expect(parts[0].type).toBe('text')
202
+ expect(parts[1].type).toBe('image_url')
203
+ expect(parts[2].type).toBe('text')
204
+ })
205
+
206
+ it('handles image + audio (audio skipped, text preserved)', () => {
207
+ const result = buildApiContent(msg({
208
+ content: 'here is what I see',
209
+ media: [
210
+ imageAttachment(),
211
+ audioAttachment(),
212
+ ],
213
+ }))
214
+ const parts = result as Array<{ type: string }>
215
+ expect(parts).toHaveLength(2) // text + image (audio skipped)
216
+ expect(parts[0].type).toBe('text')
217
+ expect(parts[1].type).toBe('image_url')
218
+ })
219
+ })
@@ -0,0 +1,68 @@
1
+ import type { Message, MediaAttachment } from './conversations'
2
+ import type { ContentPart, MessageContent } from './validation'
3
+
4
+ /**
5
+ * Convert a Message (with optional MediaAttachments) into the API content format.
6
+ * - No media → plain string (backward compatible)
7
+ * - Images → ContentPart[] with image_url entries (OpenAI vision format)
8
+ * - Text files → content inlined as text parts
9
+ * - Binary files → descriptive label
10
+ * - Audio → skipped (transcript is already in msg.content from Whisper)
11
+ */
12
+ export function buildApiContent(msg: Message): MessageContent {
13
+ const media = msg.media
14
+ if (!media || media.length === 0) return msg.content
15
+
16
+ const parts: ContentPart[] = []
17
+ let attachmentAdded = false
18
+
19
+ if (msg.content) {
20
+ parts.push({ type: 'text', text: msg.content })
21
+ }
22
+
23
+ for (const attachment of media) {
24
+ if (attachment.type === 'image') {
25
+ parts.push({ type: 'image_url', image_url: { url: attachment.url } })
26
+ attachmentAdded = true
27
+ } else if (attachment.type === 'file') {
28
+ const label = attachment.name || 'unknown'
29
+ const sizeNote = attachment.size ? ` (${Math.round(attachment.size / 1024)} KB)` : ''
30
+ // Attempt to inline text file content from base64 data URL
31
+ const inlined = tryExtractText(attachment)
32
+ if (inlined) {
33
+ parts.push({ type: 'text', text: `--- Contents of ${label} ---\n${inlined}\n--- End of file ---` })
34
+ } else {
35
+ parts.push({ type: 'text', text: `[Attached file: ${label}${sizeNote}]` })
36
+ }
37
+ attachmentAdded = true
38
+ }
39
+ // Audio: transcript already in msg.content via Whisper — skip binary
40
+ }
41
+
42
+ // If no attachment actually contributed to parts (e.g., audio-only message
43
+ // where the transcript is already in msg.content), return a plain string
44
+ // so the gateway doesn't receive an unnecessary ContentPart[] wrapper.
45
+ if (!attachmentAdded) return msg.content
46
+
47
+ return parts.length > 0 ? parts : msg.content
48
+ }
49
+
50
+ const TEXT_EXTENSIONS = ['txt', 'csv', 'json', 'md', 'log', 'xml', 'yaml', 'yml', 'toml']
51
+
52
+ function isTextFile(att: MediaAttachment): boolean {
53
+ if (att.mimeType?.startsWith('text/')) return true
54
+ const ext = att.name?.split('.').pop()?.toLowerCase() || ''
55
+ return TEXT_EXTENSIONS.includes(ext)
56
+ }
57
+
58
+ function tryExtractText(att: MediaAttachment): string | null {
59
+ if (!isTextFile(att)) return null
60
+ if (!att.url.startsWith('data:')) return null
61
+ try {
62
+ const base64 = att.url.split(',')[1]
63
+ if (!base64) return null
64
+ return atob(base64)
65
+ } catch {
66
+ return null
67
+ }
68
+ }
@@ -0,0 +1,343 @@
1
+ /**
2
+ * Integration tests for the full image pipeline:
3
+ * buildApiContent → JSON serialize → parse → validateChatMessages → OpenAI message mapping
4
+ *
5
+ * These tests verify that image data survives the complete journey from
6
+ * user attachment to the format sent to the OpenAI SDK.
7
+ */
8
+ import { describe, it, expect } from 'vitest'
9
+ import { buildApiContent } from './multimodal'
10
+ import { validateChatMessages, validateMessages } from './validation'
11
+ import type { Message, MediaAttachment } from './conversations'
12
+ import type { ContentPart, MessageContent } from './validation'
13
+
14
+ // --- Helpers ---
15
+
16
+ function userMessage(overrides: Partial<Message> = {}): Message {
17
+ return {
18
+ id: 'msg-1',
19
+ role: 'user',
20
+ content: 'what is this?',
21
+ timestamp: Date.now(),
22
+ ...overrides,
23
+ }
24
+ }
25
+
26
+ function assistantMessage(overrides: Partial<Message> = {}): Message {
27
+ return {
28
+ id: 'msg-0',
29
+ role: 'assistant',
30
+ content: 'Hello, how can I help?',
31
+ timestamp: Date.now(),
32
+ ...overrides,
33
+ }
34
+ }
35
+
36
+ function imageAttachment(overrides: Partial<MediaAttachment> = {}): MediaAttachment {
37
+ return {
38
+ type: 'image',
39
+ url: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk',
40
+ name: 'screenshot.png',
41
+ mimeType: 'image/png',
42
+ ...overrides,
43
+ }
44
+ }
45
+
46
+ // --- Full pipeline simulation ---
47
+
48
+ /**
49
+ * Simulates the exact pipeline:
50
+ * 1. ConversationView builds apiMessages via buildApiContent
51
+ * 2. JSON.stringify for fetch body
52
+ * 3. Server parses with request.json()
53
+ * 4. Server validates with validateChatMessages
54
+ * 5. Server maps messages for OpenAI SDK
55
+ */
56
+ function simulateFullPipeline(conversationMessages: Message[]) {
57
+ // Step 1: Client-side — buildApiContent for each message (ConversationView line 368-371)
58
+ const apiMessages = conversationMessages.map(m => ({
59
+ role: m.role,
60
+ content: buildApiContent(m),
61
+ }))
62
+
63
+ // Step 2: Client-side — JSON.stringify for fetch body (ConversationView line 377)
64
+ const jsonBody = JSON.stringify({ messages: apiMessages })
65
+
66
+ // Step 3: Server-side — request.json() (route.ts line 29)
67
+ const parsedBody = JSON.parse(jsonBody)
68
+
69
+ // Step 4: Server-side — validateChatMessages (route.ts line 37)
70
+ const result = validateChatMessages(parsedBody)
71
+
72
+ if (!result.ok) {
73
+ throw new Error(`Validation failed: ${result.error}`)
74
+ }
75
+
76
+ // Step 5: Server-side — map for OpenAI SDK (route.ts line 55-58)
77
+ const systemPrompt = 'You are a helpful assistant.'
78
+ const openaiMessages = [
79
+ { role: 'system' as const, content: systemPrompt },
80
+ ...result.messages.map(m => ({ role: m.role, content: m.content })),
81
+ ]
82
+
83
+ return { apiMessages, jsonBody, parsedBody, validatedMessages: result.messages, openaiMessages }
84
+ }
85
+
86
+ // =============================================================
87
+ // Pipeline integration tests
88
+ // =============================================================
89
+
90
+ describe('full image pipeline — end-to-end', () => {
91
+ it('preserves image_url data through the entire pipeline', () => {
92
+ const img = imageAttachment()
93
+ const msgs: Message[] = [
94
+ assistantMessage(),
95
+ userMessage({ media: [img] }),
96
+ ]
97
+
98
+ const { openaiMessages } = simulateFullPipeline(msgs)
99
+
100
+ // The last message should be the user message with multimodal content
101
+ const userMsg = openaiMessages[openaiMessages.length - 1]
102
+ expect(userMsg.role).toBe('user')
103
+
104
+ // content must be an array (multimodal), NOT a string
105
+ expect(Array.isArray(userMsg.content)).toBe(true)
106
+
107
+ const parts = userMsg.content as ContentPart[]
108
+ // Must have text + image_url parts
109
+ const imageParts = parts.filter(p => p.type === 'image_url')
110
+ expect(imageParts.length).toBeGreaterThanOrEqual(1)
111
+
112
+ // The image_url must contain the actual data URL, not "[object Object]" or empty
113
+ const imgPart = imageParts[0] as { type: 'image_url'; image_url: { url: string } }
114
+ expect(imgPart.image_url.url).toBe(img.url)
115
+ expect(imgPart.image_url.url).toContain('data:image/png;base64,')
116
+ })
117
+
118
+ it('preserves image data through JSON serialization round-trip', () => {
119
+ const img = imageAttachment()
120
+ const msg = userMessage({ media: [img] })
121
+ const content = buildApiContent(msg)
122
+
123
+ // Simulate JSON round-trip (fetch body → request.json())
124
+ const serialized = JSON.stringify({ role: 'user', content })
125
+ const deserialized = JSON.parse(serialized)
126
+
127
+ // content must still be an array after round-trip
128
+ expect(Array.isArray(deserialized.content)).toBe(true)
129
+ expect(deserialized.content).toHaveLength(2) // text + image
130
+
131
+ // image_url part must survive
132
+ const imgPart = deserialized.content.find((p: ContentPart) => p.type === 'image_url')
133
+ expect(imgPart).toBeDefined()
134
+ expect(imgPart.image_url.url).toBe(img.url)
135
+ })
136
+
137
+ it('validation preserves multimodal content arrays — does not stringify them', () => {
138
+ const imageUrl = 'data:image/png;base64,iVBORw0KGgoAAAA'
139
+ const messages = [
140
+ {
141
+ role: 'user',
142
+ content: [
143
+ { type: 'text', text: 'what is this?' },
144
+ { type: 'image_url', image_url: { url: imageUrl } },
145
+ ],
146
+ },
147
+ ]
148
+
149
+ const validated = validateMessages(messages)
150
+ expect(validated).toHaveLength(1)
151
+
152
+ // content must be the ARRAY, not a stringified version
153
+ const content = validated[0].content
154
+ expect(Array.isArray(content)).toBe(true)
155
+ expect(typeof content).not.toBe('string')
156
+
157
+ const parts = content as ContentPart[]
158
+ expect(parts).toHaveLength(2)
159
+
160
+ const imgPart = parts[1] as { type: 'image_url'; image_url: { url: string } }
161
+ expect(imgPart.type).toBe('image_url')
162
+ expect(imgPart.image_url.url).toBe(imageUrl)
163
+ })
164
+
165
+ it('route message mapping preserves multimodal content as-is', () => {
166
+ // Simulate what the route does: map validated messages
167
+ const imageUrl = 'data:image/jpeg;base64,/9j/4AAQSkZJRg'
168
+ const validatedMessages = [
169
+ {
170
+ role: 'user' as const,
171
+ content: [
172
+ { type: 'text' as const, text: 'describe this image' },
173
+ { type: 'image_url' as const, image_url: { url: imageUrl } },
174
+ ] as ContentPart[],
175
+ },
176
+ ]
177
+
178
+ // This is what the route does on line 57
179
+ const mapped = validatedMessages.map(m => ({ role: m.role, content: m.content }))
180
+
181
+ // content must still be the array
182
+ expect(Array.isArray(mapped[0].content)).toBe(true)
183
+ const parts = mapped[0].content as ContentPart[]
184
+ const imgPart = parts[1] as { type: 'image_url'; image_url: { url: string } }
185
+ expect(imgPart.image_url.url).toBe(imageUrl)
186
+ })
187
+
188
+ it('handles image-only message (no text) through full pipeline', () => {
189
+ // When user sends image without typing anything, content becomes a label
190
+ const img = imageAttachment()
191
+ const msgs: Message[] = [
192
+ userMessage({ content: '[screenshot.png]', media: [img] }),
193
+ ]
194
+
195
+ const { openaiMessages } = simulateFullPipeline(msgs)
196
+ const userMsg = openaiMessages[1] // index 0 is system
197
+
198
+ expect(Array.isArray(userMsg.content)).toBe(true)
199
+ const parts = userMsg.content as ContentPart[]
200
+
201
+ // Should have text part (the label) + image_url part
202
+ const imageParts = parts.filter(p => p.type === 'image_url')
203
+ expect(imageParts.length).toBe(1)
204
+
205
+ const imgPart = imageParts[0] as { type: 'image_url'; image_url: { url: string } }
206
+ expect(imgPart.image_url.url).toBe(img.url)
207
+ })
208
+
209
+ it('handles multiple images in a single message', () => {
210
+ const img1 = imageAttachment({ url: 'data:image/png;base64,AAA', name: 'first.png' })
211
+ const img2 = imageAttachment({ url: 'data:image/jpeg;base64,BBB', name: 'second.jpg' })
212
+ const msgs: Message[] = [
213
+ userMessage({ content: 'compare these', media: [img1, img2] }),
214
+ ]
215
+
216
+ const { openaiMessages } = simulateFullPipeline(msgs)
217
+ const userMsg = openaiMessages[1]
218
+
219
+ expect(Array.isArray(userMsg.content)).toBe(true)
220
+ const parts = userMsg.content as ContentPart[]
221
+ const imageParts = parts.filter(p => p.type === 'image_url') as Array<{ type: 'image_url'; image_url: { url: string } }>
222
+
223
+ expect(imageParts).toHaveLength(2)
224
+ expect(imageParts[0].image_url.url).toBe('data:image/png;base64,AAA')
225
+ expect(imageParts[1].image_url.url).toBe('data:image/jpeg;base64,BBB')
226
+ })
227
+
228
+ it('plain text messages remain strings through the pipeline (not wrapped in array)', () => {
229
+ const msgs: Message[] = [
230
+ assistantMessage(),
231
+ userMessage({ content: 'just text, no images' }),
232
+ ]
233
+
234
+ const { openaiMessages } = simulateFullPipeline(msgs)
235
+
236
+ // Assistant message (index 1, after system) should be a string
237
+ expect(typeof openaiMessages[1].content).toBe('string')
238
+
239
+ // User message (index 2) should also be a string (no media)
240
+ expect(typeof openaiMessages[2].content).toBe('string')
241
+ expect(openaiMessages[2].content).toBe('just text, no images')
242
+ })
243
+
244
+ it('conversation with mix of plain and multimodal messages', () => {
245
+ const img = imageAttachment()
246
+ const msgs: Message[] = [
247
+ assistantMessage({ content: 'Hi there!' }),
248
+ userMessage({ id: 'u1', content: 'hello' }), // plain
249
+ assistantMessage({ id: 'a1', content: 'How can I help?' }),
250
+ userMessage({ id: 'u2', content: 'what is this?', media: [img] }), // multimodal
251
+ ]
252
+
253
+ const { openaiMessages } = simulateFullPipeline(msgs)
254
+
255
+ // system (0), assistant (1), user plain (2), assistant (3), user multimodal (4)
256
+ expect(openaiMessages).toHaveLength(5)
257
+
258
+ // Plain user message
259
+ expect(typeof openaiMessages[2].content).toBe('string')
260
+
261
+ // Multimodal user message
262
+ expect(Array.isArray(openaiMessages[4].content)).toBe(true)
263
+ const parts = openaiMessages[4].content as ContentPart[]
264
+ expect(parts.some(p => p.type === 'image_url')).toBe(true)
265
+ })
266
+ })
267
+
268
+ // =============================================================
269
+ // Specific regression tests for common bugs
270
+ // =============================================================
271
+
272
+ describe('regression — content must not become [object Object]', () => {
273
+ it('buildApiContent result does not stringify to [object Object]', () => {
274
+ const msg = userMessage({ media: [imageAttachment()] })
275
+ const content = buildApiContent(msg)
276
+
277
+ // If content were accidentally coerced to string, it would be "[object Object]"
278
+ const asString = String(content)
279
+ expect(asString).not.toBe('[object Object]')
280
+
281
+ // The actual content should be an array
282
+ expect(Array.isArray(content)).toBe(true)
283
+ })
284
+
285
+ it('multimodal content survives template literal (catches accidental string coercion)', () => {
286
+ const msg = userMessage({ media: [imageAttachment()] })
287
+ const content = buildApiContent(msg)
288
+
289
+ // This simulates an accidental `${content}` in code
290
+ if (Array.isArray(content)) {
291
+ // Verify the array is properly structured, not accidentally stringified
292
+ const jsonStr = JSON.stringify(content)
293
+ expect(jsonStr).not.toContain('[object Object]')
294
+ expect(jsonStr).toContain('image_url')
295
+ expect(jsonStr).toContain('data:image/png;base64,')
296
+ }
297
+ })
298
+ })
299
+
300
+ describe('regression — validation must not strip image_url parts', () => {
301
+ it('validates and returns all content part types', () => {
302
+ const result = validateChatMessages({
303
+ messages: [{
304
+ role: 'user',
305
+ content: [
306
+ { type: 'text', text: 'look at this' },
307
+ { type: 'image_url', image_url: { url: 'data:image/png;base64,abc' } },
308
+ ],
309
+ }],
310
+ })
311
+
312
+ expect(result.ok).toBe(true)
313
+ if (!result.ok) return
314
+
315
+ const content = result.messages[0].content
316
+ expect(Array.isArray(content)).toBe(true)
317
+ const parts = content as ContentPart[]
318
+
319
+ // Both parts must survive validation
320
+ expect(parts).toHaveLength(2)
321
+ expect(parts[0].type).toBe('text')
322
+ expect(parts[1].type).toBe('image_url')
323
+ })
324
+
325
+ it('preserves the exact image URL through validation', () => {
326
+ const longBase64Url = 'data:image/png;base64,' + 'A'.repeat(1000)
327
+ const result = validateChatMessages({
328
+ messages: [{
329
+ role: 'user',
330
+ content: [
331
+ { type: 'image_url', image_url: { url: longBase64Url } },
332
+ ],
333
+ }],
334
+ })
335
+
336
+ expect(result.ok).toBe(true)
337
+ if (!result.ok) return
338
+
339
+ const parts = result.messages[0].content as ContentPart[]
340
+ const imgPart = parts[0] as { type: 'image_url'; image_url: { url: string } }
341
+ expect(imgPart.image_url.url).toBe(longBase64Url)
342
+ })
343
+ })