experimental-a2 0.18.0 → 0.19.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 (152) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/dist/{actor-Bjce9_5U.d.ts → actor-C7b-1arn.d.ts} +3 -3
  3. package/dist/{actor-Bjce9_5U.d.ts.map → actor-C7b-1arn.d.ts.map} +1 -1
  4. package/dist/actor-client.d.ts +1 -1
  5. package/dist/actor-react.d.ts +1 -1
  6. package/dist/actor-react.js +1 -1
  7. package/dist/actor.d.ts +1 -1
  8. package/dist/actor.js +2 -2
  9. package/dist/{ai-DrUaMyRt.d.ts → ai-Cz3hqVhk.d.ts} +34 -17
  10. package/dist/ai-Cz3hqVhk.d.ts.map +1 -0
  11. package/dist/ai-client.d.ts +1 -1
  12. package/dist/ai-client.js +2 -2
  13. package/dist/ai-control-CvLbW10B.js +46 -0
  14. package/dist/ai-control-CvLbW10B.js.map +1 -0
  15. package/dist/ai-fx.d.ts +1 -1
  16. package/dist/ai-fx.d.ts.map +1 -1
  17. package/dist/ai-fx.js +122 -44
  18. package/dist/ai-fx.js.map +1 -1
  19. package/dist/{ai-harness-BuLh_EYi.js → ai-harness-BuuC-gJ3.js} +79 -15
  20. package/dist/ai-harness-BuuC-gJ3.js.map +1 -0
  21. package/dist/{ai-harness-DvsPeJeB.d.ts → ai-harness-CV6rXbB3.d.ts} +2 -2
  22. package/dist/ai-harness-CV6rXbB3.d.ts.map +1 -0
  23. package/dist/{ai-BpOGm-zB.js → ai-mMiWul1T.js} +117 -78
  24. package/dist/ai-mMiWul1T.js.map +1 -0
  25. package/dist/ai-pi.d.ts +1 -1
  26. package/dist/ai-pi.d.ts.map +1 -1
  27. package/dist/ai-pi.js +114 -23
  28. package/dist/ai-pi.js.map +1 -1
  29. package/dist/ai-react.d.ts +1 -1
  30. package/dist/ai-react.js +2 -2
  31. package/dist/ai-server.d.ts +3 -3
  32. package/dist/ai-server.d.ts.map +1 -1
  33. package/dist/ai-server.js +127 -55
  34. package/dist/ai-server.js.map +1 -1
  35. package/dist/ai-tool-input-DmSqMUQi.js +40 -0
  36. package/dist/ai-tool-input-DmSqMUQi.js.map +1 -0
  37. package/dist/ai.d.ts +2 -2
  38. package/dist/ai.js +1 -1
  39. package/dist/{client-BReKqwZC.js → client-BhHLoNKF.js} +3 -3
  40. package/dist/{client-BReKqwZC.js.map → client-BhHLoNKF.js.map} +1 -1
  41. package/dist/client.js +1 -1
  42. package/dist/index.d.ts +2 -2
  43. package/dist/{internal-B2T3U4Rq.js → internal-CscSY7K_.js} +4 -2
  44. package/dist/{internal-B2T3U4Rq.js.map → internal-CscSY7K_.js.map} +1 -1
  45. package/dist/react.js +1 -1
  46. package/dist/scheduler-qstash.d.ts +2 -2
  47. package/dist/scheduler-qstash.js +3 -3
  48. package/dist/scheduler-qstash.js.map +1 -1
  49. package/dist/scheduler-vercel.d.ts +7 -7
  50. package/dist/scheduler-vercel.d.ts.map +1 -1
  51. package/dist/scheduler-vercel.js +10 -14
  52. package/dist/scheduler-vercel.js.map +1 -1
  53. package/dist/{server-Dw_k_7kO.js → server-D-Y4pqwu.js} +37 -11
  54. package/dist/server-D-Y4pqwu.js.map +1 -0
  55. package/dist/{server-En7cbi3v.d.ts → server-DYAATFuT.d.ts} +11 -5
  56. package/dist/server-DYAATFuT.d.ts.map +1 -0
  57. package/dist/server.d.ts +2 -2
  58. package/dist/server.js +1 -1
  59. package/dist/{session-clear-Bo-XTFEy.d.ts → session-clear-C-QwTXV1.d.ts} +2 -2
  60. package/dist/session-clear-C-QwTXV1.d.ts.map +1 -0
  61. package/dist/{store-Djdv0bnZ.d.ts → store-CyLUy86v.d.ts} +23 -4
  62. package/dist/store-CyLUy86v.d.ts.map +1 -0
  63. package/dist/store-N8PXxDAS.js.map +1 -1
  64. package/dist/store-memory.d.ts +1 -1
  65. package/dist/store-memory.js +25 -6
  66. package/dist/store-memory.js.map +1 -1
  67. package/dist/{store-polling-B5FcwJfo.js → store-polling-7WJV0YWP.js} +2 -2
  68. package/dist/{store-polling-B5FcwJfo.js.map → store-polling-7WJV0YWP.js.map} +1 -1
  69. package/dist/store-postgres.d.ts +1 -1
  70. package/dist/store-postgres.d.ts.map +1 -1
  71. package/dist/store-postgres.js +55 -25
  72. package/dist/store-postgres.js.map +1 -1
  73. package/dist/{store-presence-polling-BAwS8RWn.js → store-presence-polling-B1ixeqIS.js} +3 -3
  74. package/dist/{store-presence-polling-BAwS8RWn.js.map → store-presence-polling-B1ixeqIS.js.map} +1 -1
  75. package/dist/store-redis-http.d.ts +1 -1
  76. package/dist/store-redis-http.js +3 -3
  77. package/dist/{store-redis-notify-BRITti_o.js → store-redis-notify-CM0AfLY0.js} +88 -37
  78. package/dist/store-redis-notify-CM0AfLY0.js.map +1 -0
  79. package/dist/store-redis.d.ts +1 -1
  80. package/dist/store-redis.js +1 -1
  81. package/dist/store-sqlite.d.ts +1 -1
  82. package/dist/store-sqlite.js +35 -20
  83. package/dist/store-sqlite.js.map +1 -1
  84. package/dist/{wire-rSUglYo8.js → wire-BKxH1vbP.js} +2 -2
  85. package/dist/{wire-rSUglYo8.js.map → wire-BKxH1vbP.js.map} +1 -1
  86. package/docs/agents/03-conversations.mdx +27 -15
  87. package/docs/agents/04-harnesses.mdx +51 -5
  88. package/docs/contract/03-sessions.mdx +1 -1
  89. package/docs/index.mdx +3 -3
  90. package/docs/reference/02-server.mdx +12 -7
  91. package/docs/reference/04-ai.mdx +41 -23
  92. package/docs/reference/05-adapters.mdx +6 -6
  93. package/docs/server/01-handlers.mdx +1 -1
  94. package/docs/server/02-timers.mdx +1 -1
  95. package/docs/server/03-cancellation.mdx +3 -2
  96. package/docs/server/05-production.mdx +4 -3
  97. package/docs/server/06-guarantees.mdx +20 -11
  98. package/examples/playground/DEPLOY.md +11 -4
  99. package/examples/playground/app/a0/[appId]/page.tsx +2 -8
  100. package/examples/playground/app/a0/a0.css +244 -5
  101. package/examples/playground/app/a0/app-view.tsx +2 -1
  102. package/examples/playground/app/a0/attachments.ts +133 -0
  103. package/examples/playground/app/a0/chat.test.tsx +504 -0
  104. package/examples/playground/app/a0/chat.tsx +623 -63
  105. package/examples/playground/app/a0/context-menu.tsx +38 -11
  106. package/examples/playground/app/a0/devtools.tsx +2 -8
  107. package/examples/playground/app/a0/model.test.ts +28 -0
  108. package/examples/playground/app/a0/model.ts +30 -0
  109. package/examples/playground/app/a0/server.ts +13 -19
  110. package/examples/playground/app/a0/uploads/route.ts +29 -0
  111. package/examples/playground/app/agent/[agentId]/agent-client.tsx +2 -1
  112. package/examples/playground/app/agent/[agentId]/agent-queue.test.tsx +1 -0
  113. package/examples/playground/app/agent/model.test.ts +12 -3
  114. package/examples/playground/app/chat/[chatId]/agent-state.test.ts +1 -5
  115. package/examples/playground/next.config.ts +7 -4
  116. package/examples/playground/package.json +3 -1
  117. package/package.json +1 -1
  118. package/src/ai-agent.ts +14 -3
  119. package/src/ai-client-state.ts +21 -9
  120. package/src/ai-context-schema.ts +28 -4
  121. package/src/ai-context.ts +83 -3
  122. package/src/ai-control-server.ts +70 -43
  123. package/src/ai-control-state.ts +3 -4
  124. package/src/ai-control.ts +59 -10
  125. package/src/ai-coordinator.ts +7 -6
  126. package/src/ai-fx.ts +184 -67
  127. package/src/ai-harness.ts +153 -29
  128. package/src/ai-message-shape.ts +41 -0
  129. package/src/ai-pi.ts +161 -46
  130. package/src/ai-stored-state.ts +1 -3
  131. package/src/ai-tool-input.ts +45 -0
  132. package/src/ai.ts +116 -77
  133. package/src/internal.ts +6 -0
  134. package/src/libfx.d.ts +8 -1
  135. package/src/scheduler-qstash.ts +5 -5
  136. package/src/scheduler-vercel.ts +15 -27
  137. package/src/server.ts +58 -12
  138. package/src/store-memory.ts +50 -9
  139. package/src/store-postgres.ts +79 -28
  140. package/src/store-redis-core.ts +112 -31
  141. package/src/store-sqlite.ts +61 -28
  142. package/src/store.ts +22 -3
  143. package/dist/ai-BpOGm-zB.js.map +0 -1
  144. package/dist/ai-DrUaMyRt.d.ts.map +0 -1
  145. package/dist/ai-harness-BuLh_EYi.js.map +0 -1
  146. package/dist/ai-harness-DvsPeJeB.d.ts.map +0 -1
  147. package/dist/server-Dw_k_7kO.js.map +0 -1
  148. package/dist/server-En7cbi3v.d.ts.map +0 -1
  149. package/dist/session-clear-Bo-XTFEy.d.ts.map +0 -1
  150. package/dist/store-Djdv0bnZ.d.ts.map +0 -1
  151. package/dist/store-redis-notify-BRITti_o.js.map +0 -1
  152. package/examples/playground/app/a0/no-photon.cjs +0 -4
@@ -0,0 +1,504 @@
1
+ // @vitest-environment jsdom
2
+ import {
3
+ cleanup,
4
+ createEvent,
5
+ fireEvent,
6
+ render,
7
+ screen,
8
+ waitFor,
9
+ } from '@testing-library/react'
10
+ import type { ReactElement } from 'react'
11
+ import { afterEach, beforeEach, expect, it, vi } from 'vitest'
12
+ import type { AppMessage } from './model'
13
+ import { defaultModelChoice } from './model'
14
+
15
+ const push = vi.fn<(...events: unknown[]) => Promise<unknown[]>>()
16
+ let state: Record<string, unknown>
17
+ const appId = '6f1c2a54-9a3e-4c51-8f0e-2b7d8c9e1a00'
18
+ const upload =
19
+ vi.fn<
20
+ (
21
+ pathname: string,
22
+ file: File,
23
+ options: object,
24
+ ) => Promise<Record<string, string>>
25
+ >()
26
+
27
+ vi.mock('next/navigation', () => ({
28
+ useRouter: () => ({ replace: vi.fn<(href: string) => void>() }),
29
+ useSearchParams: () => new URLSearchParams(),
30
+ }))
31
+ vi.mock('./session', () => ({ useSession: () => ({ state, push }) }))
32
+ vi.mock('@vercel/blob/client', () => ({ upload }))
33
+
34
+ const { Chat } = await import('./chat')
35
+
36
+ const user = (id: string, text: string): AppMessage => ({
37
+ id,
38
+ role: 'user',
39
+ parts: [{ type: 'text', text }],
40
+ })
41
+
42
+ const idle = () => ({
43
+ modelChoice: defaultModelChoice,
44
+ messages: [] as AppMessage[],
45
+ starting: false,
46
+ status: 'idle',
47
+ active: null,
48
+ inbox: { paused: false, items: [] as unknown[] },
49
+ error: null,
50
+ })
51
+
52
+ const turn = { turnId: 't1', phase: 'generating', input: {} }
53
+
54
+ const generating = () => ({
55
+ ...idle(),
56
+ messages: [user('u1', 'Build a blog.')],
57
+ status: 'generating',
58
+ active: turn,
59
+ })
60
+
61
+ const chat = () =>
62
+ render(
63
+ <Chat appId={appId} harness="pi" onPrompt={() => {}} autoFocus={false} />,
64
+ )
65
+
66
+ const write = (text: string): void => {
67
+ const box = screen.getByRole('textbox', { name: 'Message' })
68
+ fireEvent.change(box, { target: { value: text } })
69
+ fireEvent.keyDown(box, { key: 'Enter' })
70
+ }
71
+
72
+ const pushed = (): unknown[] => push.mock.calls.flat()
73
+
74
+ const blob = (name: string): string =>
75
+ `https://store.public.blob.vercel-storage.com/a0/${appId}/${name}`
76
+
77
+ beforeEach(() => {
78
+ push.mockReset().mockResolvedValue([])
79
+ // Blob stores each file under its path.
80
+ upload.mockReset().mockImplementation(async (pathname, file) => ({
81
+ url: blob(pathname.split('/').at(-1) ?? ''),
82
+ contentType: file.type,
83
+ }))
84
+ Object.assign(URL, {
85
+ createObjectURL: () => 'blob:thumbnail',
86
+ revokeObjectURL: () => {},
87
+ })
88
+ // jsdom lays nothing out, so the transcript has nothing to follow.
89
+ vi.stubGlobal(
90
+ 'ResizeObserver',
91
+ class {
92
+ observe(): void {}
93
+ disconnect(): void {}
94
+ },
95
+ )
96
+ })
97
+
98
+ afterEach(() => {
99
+ cleanup()
100
+ vi.unstubAllGlobals()
101
+ })
102
+
103
+ it('sends a message between turns', () => {
104
+ state = idle()
105
+ chat()
106
+ expect(screen.queryByRole('button', { name: 'Send mode' })).toBeNull()
107
+ write('Build a blog.')
108
+ expect(pushed()).toContainEqual(
109
+ expect.objectContaining({
110
+ payload: expect.objectContaining({ action: 'send' }),
111
+ }),
112
+ )
113
+ })
114
+
115
+ it('steers the active turn by default, and queues behind it from the chevron', () => {
116
+ state = generating()
117
+ chat()
118
+ expect(screen.getByRole('button', { name: 'Steer' })).toBeTruthy()
119
+ write('Use a dark theme.')
120
+ expect(pushed()).toContainEqual(
121
+ expect.objectContaining({
122
+ payload: expect.objectContaining({
123
+ action: 'steer',
124
+ turnId: 't1',
125
+ message: expect.objectContaining({
126
+ parts: [{ type: 'text', text: 'Use a dark theme.' }],
127
+ }),
128
+ }),
129
+ }),
130
+ )
131
+
132
+ push.mockClear()
133
+ fireEvent.click(screen.getByRole('button', { name: 'Send mode' }))
134
+ expect(
135
+ screen
136
+ .getByRole('menuitemradio', { name: 'Steer' })
137
+ .getAttribute('aria-checked'),
138
+ ).toBe('true')
139
+ fireEvent.click(screen.getByRole('menuitemradio', { name: 'Queue' }))
140
+ expect(screen.getByRole('button', { name: 'Queue' })).toBeTruthy()
141
+ write('Then add an about page.')
142
+ expect(pushed()).toContainEqual(
143
+ expect.objectContaining({
144
+ payload: expect.objectContaining({ action: 'send' }),
145
+ }),
146
+ )
147
+ })
148
+
149
+ it('pauses, resumes, and stops the turn like a player', () => {
150
+ state = generating()
151
+ const { rerender } = chat()
152
+ fireEvent.click(screen.getByRole('button', { name: 'Pause' }))
153
+ fireEvent.click(screen.getByRole('button', { name: 'Stop' }))
154
+ expect(pushed()).toEqual([
155
+ expect.objectContaining({ payload: { action: 'pause', when: 'now' } }),
156
+ expect.objectContaining({ payload: { action: 'stop', turnId: 't1' } }),
157
+ ])
158
+
159
+ push.mockClear()
160
+ state = {
161
+ ...generating(),
162
+ status: 'paused',
163
+ active: { ...turn, phase: 'paused' },
164
+ inbox: { paused: true, items: [] },
165
+ }
166
+ rerender(
167
+ <Chat appId={appId} harness="pi" onPrompt={() => {}} autoFocus={false} />,
168
+ )
169
+ expect(screen.getByText('Paused')).toBeTruthy()
170
+ expect(screen.queryByText('Thinking')).toBeNull()
171
+ fireEvent.click(screen.getByRole('button', { name: 'Resume' }))
172
+ expect(pushed()).toEqual([
173
+ expect.objectContaining({ payload: { action: 'resume' } }),
174
+ ])
175
+ })
176
+
177
+ it('steers with a queued message, or removes it', () => {
178
+ state = {
179
+ ...generating(),
180
+ inbox: {
181
+ paused: false,
182
+ items: [{ id: 'q1', message: user('q1', 'Add tags.'), generate: true }],
183
+ },
184
+ }
185
+ chat()
186
+ const queued = screen.getByRole('list', { name: 'Queued messages' })
187
+ expect(queued.textContent).toContain('Add tags.')
188
+ fireEvent.click(screen.getByRole('button', { name: 'Steer with it now' }))
189
+ fireEvent.click(screen.getByRole('button', { name: 'Remove' }))
190
+ expect(pushed()).toEqual([
191
+ expect.objectContaining({
192
+ payload: { action: 'send-now', inputId: 'q1', turnId: 't1' },
193
+ }),
194
+ expect.objectContaining({ payload: { action: 'remove', inputId: 'q1' } }),
195
+ ])
196
+ })
197
+
198
+ it('shows what a tool call writes while it streams, folded once it ran', () => {
199
+ const writing = (part: Record<string, unknown>) => ({
200
+ ...generating(),
201
+ messages: [
202
+ user('u1', 'Build a blog.'),
203
+ { id: 'a1', role: 'assistant', parts: [part] },
204
+ ],
205
+ })
206
+ state = writing({
207
+ type: 'tool-write',
208
+ toolCallId: 'w1',
209
+ state: 'input-streaming',
210
+ input: { path: 'app/page.tsx', content: 'export default function' },
211
+ })
212
+ const { container, rerender } = chat()
213
+ const details = () => container.querySelector('.a0-tool details')
214
+ expect(details()?.hasAttribute('open')).toBe(true)
215
+ expect(container.querySelector('.a0-tool-text')?.textContent).toBe(
216
+ 'export default function',
217
+ )
218
+
219
+ state = writing({
220
+ type: 'tool-write',
221
+ toolCallId: 'w1',
222
+ state: 'output-available',
223
+ input: {
224
+ path: 'app/page.tsx',
225
+ content: 'export default function Page() {}',
226
+ },
227
+ output: { content: [] },
228
+ })
229
+ rerender(
230
+ <Chat appId={appId} harness="pi" onPrompt={() => {}} autoFocus={false} />,
231
+ )
232
+ expect(details()?.hasAttribute('open')).toBe(false)
233
+
234
+ // A one-line command fits its step's line.
235
+ state = writing({
236
+ type: 'tool-bash',
237
+ toolCallId: 'b1',
238
+ state: 'input-available',
239
+ input: { command: 'ls' },
240
+ })
241
+ rerender(
242
+ <Chat appId={appId} harness="pi" onPrompt={() => {}} autoFocus={false} />,
243
+ )
244
+ expect(container.querySelector('.a0-tool')).toBeNull()
245
+ expect(screen.getByText('ls')).toBeTruthy()
246
+ })
247
+
248
+ const again = (rerender: (ui: ReactElement) => void): void =>
249
+ rerender(
250
+ <Chat appId={appId} harness="pi" onPrompt={() => {}} autoFocus={false} />,
251
+ )
252
+
253
+ it('closes the send-mode menu from its chevron, and drops it when the turn ends', () => {
254
+ state = generating()
255
+ const { rerender } = chat()
256
+ const chevron = screen.getByRole('button', { name: 'Send mode' })
257
+ fireEvent.click(chevron)
258
+ // A press on the chevron is its own, not one outside the menu.
259
+ fireEvent.pointerDown(chevron)
260
+ expect(screen.getAllByRole('menuitemradio')).toHaveLength(2)
261
+ fireEvent.click(chevron)
262
+ expect(screen.queryByRole('menuitemradio')).toBeNull()
263
+
264
+ fireEvent.click(chevron)
265
+ state = idle()
266
+ again(rerender)
267
+ state = generating()
268
+ again(rerender)
269
+ expect(screen.queryByRole('menuitemradio')).toBeNull()
270
+ })
271
+
272
+ it('ends the pause with a stop, so the next message goes out', () => {
273
+ state = {
274
+ ...generating(),
275
+ status: 'paused',
276
+ active: { ...turn, phase: 'paused' },
277
+ inbox: { paused: true, items: [] },
278
+ }
279
+ chat()
280
+ fireEvent.click(screen.getByRole('button', { name: 'Stop' }))
281
+ expect(pushed()).toEqual([
282
+ expect.objectContaining({ payload: { action: 'stop', turnId: 't1' } }),
283
+ expect.objectContaining({ payload: { action: 'resume' } }),
284
+ ])
285
+ })
286
+
287
+ it('offers to steer with a queued message only while a turn runs', () => {
288
+ state = {
289
+ ...idle(),
290
+ starting: true,
291
+ inbox: {
292
+ paused: false,
293
+ items: [{ id: 'q1', message: user('q1', 'Add tags.'), generate: true }],
294
+ },
295
+ }
296
+ chat()
297
+ expect(screen.queryByRole('button', { name: 'Steer with it now' })).toBeNull()
298
+ expect(screen.getByRole('button', { name: 'Remove' })).toBeTruthy()
299
+ })
300
+
301
+ it('folds a call that waits on a paused turn, and shows edits as a diff', () => {
302
+ const step = (part: Record<string, unknown>, status = 'generating') => ({
303
+ ...generating(),
304
+ status,
305
+ messages: [
306
+ user('u1', 'Build a blog.'),
307
+ { id: 'a1', role: 'assistant', parts: [part] },
308
+ ],
309
+ })
310
+ const bash = {
311
+ type: 'tool-bash',
312
+ toolCallId: 'b1',
313
+ state: 'input-available',
314
+ input: { command: 'cat > a.txt <<EOF\nhi\nEOF' },
315
+ }
316
+ state = step(bash, 'paused')
317
+ const { container, rerender } = chat()
318
+ expect(
319
+ container.querySelector('.a0-tool details')?.hasAttribute('open'),
320
+ ).toBe(false)
321
+ expect(container.querySelector('.a0-tool .a0-spin')).toBeNull()
322
+
323
+ // Pi's older edit input: the edit's fields on the call itself.
324
+ state = step({
325
+ type: 'tool-edit',
326
+ toolCallId: 'e1',
327
+ state: 'input-streaming',
328
+ input: { path: 'a.txt', oldText: 'hi', newText: 'hello' },
329
+ })
330
+ again(rerender)
331
+ expect(container.querySelector('.a0-tool-text')?.textContent).toBe(
332
+ '- hi\n+ hello',
333
+ )
334
+
335
+ // Nothing written yet: the step's one line, with no empty box.
336
+ state = step({
337
+ type: 'tool-write',
338
+ toolCallId: 'w1',
339
+ state: 'input-streaming',
340
+ input: { path: 'a.txt', content: '' },
341
+ })
342
+ again(rerender)
343
+ expect(container.querySelector('.a0-tool')).toBeNull()
344
+ })
345
+
346
+ const dropOn = (target: Element, ...files: File[]): void => {
347
+ const dataTransfer = { files, types: ['Files'], dropEffect: 'none' }
348
+ fireEvent.dragEnter(target, { dataTransfer })
349
+ fireEvent.dragOver(target, { dataTransfer })
350
+ fireEvent.drop(target, { dataTransfer })
351
+ }
352
+
353
+ const shot = () => new File(['png'], 'shot.png', { type: 'image/png' })
354
+
355
+ /** jsdom has no DragEvent, so the event carries where the files went itself. */
356
+ const leave = (from: Element, to: Element | null): void => {
357
+ const event = createEvent.dragLeave(from)
358
+ Object.defineProperty(event, 'relatedTarget', { value: to })
359
+ fireEvent(from, event)
360
+ }
361
+
362
+ it('sends images dropped anywhere on the chat with the next message', async () => {
363
+ state = idle()
364
+ chat()
365
+ const transcript = screen.getByRole('list', { name: 'Conversation' })
366
+ fireEvent.dragEnter(transcript, {
367
+ dataTransfer: { files: [], types: ['Files'] },
368
+ })
369
+ expect(screen.getByText('Drop to attach')).toBeTruthy()
370
+ // Into the composer, still on the chat, then out of the window.
371
+ leave(transcript, screen.getByRole('textbox', { name: 'Message' }))
372
+ expect(screen.getByText('Drop to attach')).toBeTruthy()
373
+ leave(transcript, null)
374
+ expect(screen.queryByText('Drop to attach')).toBeNull()
375
+
376
+ dropOn(transcript, shot())
377
+ const attached = screen.getByRole('list', { name: 'Attachments' })
378
+ expect(attached.querySelector('[aria-label="Uploading"]')).toBeTruthy()
379
+ await waitFor(() =>
380
+ expect(attached.querySelector('[aria-label="Uploading"]')).toBeNull(),
381
+ )
382
+ expect(upload).toHaveBeenCalledWith(
383
+ `a0/${appId}/shot.png`,
384
+ expect.any(File),
385
+ expect.objectContaining({
386
+ access: 'public',
387
+ handleUploadUrl: '/a0/uploads',
388
+ clientPayload: appId,
389
+ }),
390
+ )
391
+
392
+ write('Make it look like this.')
393
+ expect(pushed()).toContainEqual(
394
+ expect.objectContaining({
395
+ payload: expect.objectContaining({
396
+ action: 'send',
397
+ message: expect.objectContaining({
398
+ parts: [
399
+ {
400
+ type: 'file',
401
+ mediaType: 'image/png',
402
+ filename: 'shot.png',
403
+ url: blob('shot.png'),
404
+ },
405
+ { type: 'text', text: 'Make it look like this.' },
406
+ ],
407
+ }),
408
+ }),
409
+ }),
410
+ )
411
+ expect(screen.queryByRole('list', { name: 'Attachments' })).toBeNull()
412
+ })
413
+
414
+ it("puts a file that isn't an image into the message as its link", async () => {
415
+ state = idle()
416
+ chat()
417
+ const box = screen.getByRole('textbox', {
418
+ name: 'Message',
419
+ }) as HTMLTextAreaElement
420
+ fireEvent.change(box, { target: { value: 'Use this copy.' } })
421
+ dropOn(box, new File(['%PDF'], 'copy.pdf', { type: 'application/pdf' }))
422
+ await waitFor(() =>
423
+ expect(box.value).toBe(`Use this copy.\ncopy.pdf: ${blob('copy.pdf')}`),
424
+ )
425
+ expect(screen.queryByRole('list', { name: 'Attachments' })).toBeNull()
426
+ })
427
+
428
+ it('shows a sent link as its file, and a sent image as itself', () => {
429
+ state = {
430
+ ...idle(),
431
+ messages: [
432
+ {
433
+ id: 'u1',
434
+ role: 'user',
435
+ parts: [
436
+ {
437
+ type: 'file',
438
+ mediaType: 'image/png',
439
+ filename: 'shot.png',
440
+ url: blob('shot.png'),
441
+ },
442
+ {
443
+ type: 'text',
444
+ text: `Use this copy.\ncopy.pdf: ${blob('copy.pdf')}`,
445
+ },
446
+ ],
447
+ },
448
+ ],
449
+ }
450
+ chat()
451
+ expect(screen.getByRole('img', { name: 'shot.png' })).toBeTruthy()
452
+ expect(
453
+ screen.getByRole('link', { name: 'copy.pdf' }).getAttribute('href'),
454
+ ).toBe(blob('copy.pdf'))
455
+ })
456
+
457
+ it('says which upload failed, and drops the file', async () => {
458
+ state = idle()
459
+ upload.mockRejectedValue(new Error('Vercel Blob: Failed to upload'))
460
+ chat()
461
+ dropOn(screen.getByRole('list', { name: 'Conversation' }), shot())
462
+ expect((await screen.findByRole('alert')).textContent).toBe(
463
+ "Couldn't upload shot.png: Vercel Blob: Failed to upload",
464
+ )
465
+ expect(screen.queryByRole('list', { name: 'Attachments' })).toBeNull()
466
+ })
467
+
468
+ it('attaches a pasted image instead of pasting it as text', async () => {
469
+ state = idle()
470
+ chat()
471
+ fireEvent.paste(screen.getByRole('textbox', { name: 'Message' }), {
472
+ clipboardData: { files: [shot()] },
473
+ })
474
+ expect(screen.getByRole('list', { name: 'Attachments' })).toBeTruthy()
475
+ await waitFor(() => expect(upload).toHaveBeenCalled())
476
+ })
477
+
478
+ /** Every attached file has finished uploading. */
479
+ const uploaded = () =>
480
+ waitFor(() =>
481
+ expect(
482
+ screen
483
+ .getByRole('list', { name: 'Attachments' })
484
+ .querySelector('[aria-label="Uploading"]'),
485
+ ).toBeNull(),
486
+ )
487
+
488
+ it('keeps the attachments of a send that failed, beside any attached since', async () => {
489
+ state = idle()
490
+ chat()
491
+ const transcript = screen.getByRole('list', { name: 'Conversation' })
492
+ dropOn(transcript, shot())
493
+ await uploaded()
494
+ let fail!: (cause: Error) => void
495
+ push.mockReturnValueOnce(new Promise((_, reject) => (fail = reject)))
496
+ write('Like this.')
497
+ dropOn(transcript, new File(['png'], 'more.png', { type: 'image/png' }))
498
+ await uploaded()
499
+ fail(new Error('offline'))
500
+ expect((await screen.findByRole('alert')).textContent).toBe('offline')
501
+ expect(
502
+ screen.getByRole('list', { name: 'Attachments' }).querySelectorAll('li'),
503
+ ).toHaveLength(2)
504
+ })