experimental-a2 0.19.0 → 0.20.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 (95) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/actor.js +1 -1
  3. package/dist/ai-fx.d.ts.map +1 -1
  4. package/dist/ai-fx.js +34 -4
  5. package/dist/ai-fx.js.map +1 -1
  6. package/dist/ai-harness-CV6rXbB3.d.ts.map +1 -1
  7. package/dist/{ai-harness-BuuC-gJ3.js → ai-harness-Clbg5ei7.js} +11 -2
  8. package/dist/ai-harness-Clbg5ei7.js.map +1 -0
  9. package/dist/ai-pi.js +2 -2
  10. package/dist/ai-server.d.ts.map +1 -1
  11. package/dist/ai-server.js +6 -4
  12. package/dist/ai-server.js.map +1 -1
  13. package/dist/internal-CscSY7K_.js.map +1 -1
  14. package/dist/scheduler-qstash.d.ts.map +1 -1
  15. package/dist/scheduler-qstash.js +5 -5
  16. package/dist/scheduler-qstash.js.map +1 -1
  17. package/dist/scheduler-vercel.d.ts.map +1 -1
  18. package/dist/scheduler-vercel.js +4 -3
  19. package/dist/scheduler-vercel.js.map +1 -1
  20. package/dist/server-DYAATFuT.d.ts.map +1 -1
  21. package/dist/{server-D-Y4pqwu.js → server-qyb5aJJG.js} +39 -25
  22. package/dist/server-qyb5aJJG.js.map +1 -0
  23. package/dist/server.js +1 -1
  24. package/dist/session-clear-DGI4jTQ5.js.map +1 -1
  25. package/dist/store-postgres.d.ts.map +1 -1
  26. package/dist/store-postgres.js +38 -136
  27. package/dist/store-postgres.js.map +1 -1
  28. package/docs/01-quickstart.mdx +7 -4
  29. package/docs/actors/01-introduction.mdx +1 -1
  30. package/docs/actors/meta.ts +1 -1
  31. package/docs/agents/02-tools.mdx +1 -1
  32. package/docs/agents/03-conversations.mdx +1 -1
  33. package/docs/agents/meta.ts +1 -1
  34. package/docs/client/meta.ts +1 -1
  35. package/docs/contract/03-sessions.mdx +1 -1
  36. package/docs/index.mdx +2 -2
  37. package/docs/reference/01-core.mdx +8 -8
  38. package/docs/reference/02-server.mdx +3 -3
  39. package/docs/reference/04-ai.mdx +9 -1
  40. package/docs/reference/05-telemetry.mdx +132 -0
  41. package/docs/reference/06-errors.mdx +1 -1
  42. package/docs/reference/meta.ts +1 -1
  43. package/docs/schedulers/01-overview.mdx +112 -0
  44. package/docs/schedulers/02-vercel-queues.mdx +87 -0
  45. package/docs/schedulers/03-qstash.mdx +123 -0
  46. package/docs/schedulers/meta.ts +1 -0
  47. package/docs/server/01-handlers.mdx +29 -2
  48. package/docs/server/02-timers.mdx +2 -2
  49. package/docs/server/06-guarantees.mdx +6 -3
  50. package/docs/stores/01-overview.mdx +37 -0
  51. package/docs/stores/02-redis.mdx +49 -0
  52. package/docs/stores/03-redis-http.mdx +56 -0
  53. package/docs/stores/04-postgres.mdx +121 -0
  54. package/docs/stores/05-sqlite.mdx +34 -0
  55. package/docs/stores/06-memory.mdx +22 -0
  56. package/docs/stores/meta.ts +1 -0
  57. package/examples/playground/DEPLOY.md +12 -7
  58. package/examples/playground/app/a0/apps/[appId]/route.ts +6 -10
  59. package/examples/playground/app/a0/chat.test.tsx +421 -330
  60. package/examples/playground/app/a0/shell.tsx +1 -1
  61. package/examples/playground/app/a0/uploads/route.test.ts +59 -0
  62. package/examples/playground/app/agent/[agentId]/agent-queue.test.tsx +116 -90
  63. package/examples/playground/app/agent/[agentId]/compaction-panel.test.tsx +107 -76
  64. package/examples/playground/app/agent/ingress.test.ts +41 -17
  65. package/examples/playground/app/agent/model-input.test.tsx +92 -43
  66. package/examples/playground/app/chat/[chatId]/agent-stream-drawer.test.tsx +2 -2
  67. package/examples/playground/app/chat/server.test.ts +38 -65
  68. package/examples/playground/app/components/activity-history.test.tsx +60 -18
  69. package/examples/playground/app/components/fast-link.test.tsx +3 -5
  70. package/examples/playground/app/initial-state.test.tsx +86 -45
  71. package/examples/playground/package.json +2 -1
  72. package/examples/playground/test/agent.ts +35 -0
  73. package/examples/playground/test/model.ts +149 -0
  74. package/examples/playground/test/serve.ts +184 -0
  75. package/examples/playground/test/server-only.ts +1 -0
  76. package/examples/playground/vitest.config.ts +27 -0
  77. package/package.json +3 -1
  78. package/sql/postgres/notifications.sql +24 -0
  79. package/sql/postgres/schema.sql +137 -0
  80. package/src/ai-agent.ts +5 -4
  81. package/src/ai-fx.ts +37 -3
  82. package/src/ai-harness.ts +18 -0
  83. package/src/errors.ts +1 -1
  84. package/src/internal.ts +2 -1
  85. package/src/postgres-notifications.ts +0 -22
  86. package/src/postgres-resources.ts +6 -9
  87. package/src/postgres-schema.ts +80 -0
  88. package/src/scheduler-qstash.ts +12 -5
  89. package/src/scheduler-vercel.ts +10 -4
  90. package/src/server.ts +66 -32
  91. package/src/store-postgres.ts +5 -123
  92. package/dist/ai-harness-BuuC-gJ3.js.map +0 -1
  93. package/dist/server-D-Y4pqwu.js.map +0 -1
  94. package/docs/reference/05-adapters.mdx +0 -351
  95. package/docs/server/05-production.mdx +0 -323
@@ -1,136 +1,218 @@
1
1
  // @vitest-environment jsdom
2
2
  import {
3
+ act,
3
4
  cleanup,
4
5
  createEvent,
5
6
  fireEvent,
6
7
  render,
7
8
  screen,
8
9
  waitFor,
10
+ within,
9
11
  } from '@testing-library/react'
10
- import type { ReactElement } from 'react'
12
+ import { tool } from 'ai'
13
+ import { handlerContext, inputs } from 'experimental-a2/ai'
14
+ import { agent } from 'experimental-a2/ai/server'
15
+ import type { A2Operation } from 'experimental-a2/server'
16
+ import { memory } from 'experimental-a2/store-memory'
11
17
  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
- >()
18
+ import { z } from 'zod'
19
+ import { say, testModel, toolCall, type ModelStep } from '@/test/model'
20
+ import { authorizer, serve, type Served } from '@/test/serve'
21
+ import { Chat } from './chat'
22
+ import { appAgent, type AppMessage } from './model'
23
+ import { SessionProvider } from './session'
26
24
 
25
+ const blobStore = vi.hoisted(() => ({ failure: null as Error | null }))
26
+
27
+ // The App Router's router only exists inside a Next.js app.
27
28
  vi.mock('next/navigation', () => ({
28
- useRouter: () => ({ replace: vi.fn<(href: string) => void>() }),
29
+ useRouter: () => ({ replace: () => {} }),
29
30
  useSearchParams: () => new URLSearchParams(),
30
31
  }))
31
- vi.mock('./session', () => ({ useSession: () => ({ state, push }) }))
32
- vi.mock('@vercel/blob/client', () => ({ upload }))
32
+ // Vercel Blob has no local stand-in: an upload lands at its path.
33
+ vi.mock('@vercel/blob/client', () => ({
34
+ upload: async (pathname: string, file: File) => {
35
+ if (blobStore.failure) throw blobStore.failure
36
+ return {
37
+ url: `https://store.public.blob.vercel-storage.com/${pathname}`,
38
+ contentType: file.type,
39
+ }
40
+ },
41
+ }))
33
42
 
34
- const { Chat } = await import('./chat')
43
+ /** Pi's tools, by name, without a sandbox to run in. */
44
+ const tools = {
45
+ write: tool({
46
+ inputSchema: z.object({ path: z.string(), content: z.string() }),
47
+ execute: () => ({ content: [] }),
48
+ }),
49
+ edit: tool({
50
+ inputSchema: z.object({
51
+ path: z.string(),
52
+ oldText: z.string(),
53
+ newText: z.string(),
54
+ }),
55
+ execute: () => ({ content: [] }),
56
+ }),
57
+ bash: tool({
58
+ inputSchema: z.object({ command: z.string() }),
59
+ execute: async () => {
60
+ const { signal } = handlerContext()
61
+ await new Promise<void>((resolve, reject) => {
62
+ void bashDone.promise.then(resolve)
63
+ signal.addEventListener('abort', () => reject(signal.reason), {
64
+ once: true,
65
+ })
66
+ })
67
+ return { content: [] }
68
+ },
69
+ }),
70
+ }
35
71
 
36
- const user = (id: string, text: string): AppMessage => ({
37
- id,
38
- role: 'user',
39
- parts: [{ type: 'text', text }],
72
+ /** An app's coding agent, on a memory store. */
73
+ const appServer = (model: string) =>
74
+ agent({ contract: appAgent, store: memory(), model, tools })
75
+
76
+ let appId: string
77
+ let model: ReturnType<typeof testModel>
78
+ let server: ReturnType<typeof appServer>
79
+ let probe: ReturnType<typeof authorizer>
80
+ let served: Served
81
+ /** Lets the bash call that waits on it finish. */
82
+ let bashDone: PromiseWithResolvers<void>
83
+
84
+ beforeEach(async () => {
85
+ appId = crypto.randomUUID()
86
+ model = testModel()
87
+ bashDone = Promise.withResolvers()
88
+ server = appServer(model.id)
89
+ probe = authorizer()
90
+ // The app's client follows `/a0/events` on this origin.
91
+ served = await serve({
92
+ routes: {
93
+ '/a0/events': (request) =>
94
+ server.fetch(request, { authorize: probe.authorize }),
95
+ },
96
+ })
97
+ blobStore.failure = null
98
+ Object.assign(URL, {
99
+ createObjectURL: () => 'blob:thumbnail',
100
+ revokeObjectURL: () => {},
101
+ })
102
+ // jsdom lays nothing out, so the transcript has nothing to follow.
103
+ vi.stubGlobal(
104
+ 'ResizeObserver',
105
+ class {
106
+ observe(): void {}
107
+ disconnect(): void {}
108
+ },
109
+ )
40
110
  })
41
111
 
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,
112
+ afterEach(async () => {
113
+ cleanup()
114
+ probe.release()
115
+ bashDone.resolve()
116
+ model.close()
117
+ // The agent's work ends with this test.
118
+ await vi.waitUntil(async () => (await server.drain(appId)).settled)
119
+ await served.close()
120
+ vi.unstubAllGlobals()
50
121
  })
51
122
 
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,
123
+ const user = (id: string, text: string): AppMessage => ({
124
+ id,
125
+ role: 'user',
126
+ parts: [{ type: 'text', text }],
59
127
  })
60
128
 
61
- const chat = () =>
129
+ const chat = async () =>
62
130
  render(
63
- <Chat appId={appId} harness="pi" onPrompt={() => {}} autoFocus={false} />,
131
+ <SessionProvider
132
+ sessionId={appId}
133
+ initial={await server.session(appId).state()}
134
+ >
135
+ <Chat appId={appId} harness="pi" onPrompt={() => {}} autoFocus={false} />
136
+ </SessionProvider>,
64
137
  )
65
138
 
139
+ /** A turn the model is in the middle of: its first step is open. */
140
+ const generating = async (): Promise<{ turnId: string; step: ModelStep }> => {
141
+ await server
142
+ .session(appId)
143
+ .append(...inputs.message(user('u1', 'Build a blog.')))
144
+ const step = await model.step(0)
145
+ const { state } = await server.session(appId).state()
146
+ return { turnId: state.active!.turnId, step }
147
+ }
148
+
66
149
  const write = (text: string): void => {
67
150
  const box = screen.getByRole('textbox', { name: 'Message' })
68
151
  fireEvent.change(box, { target: { value: text } })
69
152
  fireEvent.keyDown(box, { key: 'Enter' })
70
153
  }
71
154
 
72
- const pushed = (): unknown[] => push.mock.calls.flat()
155
+ /** The controls the app's log holds, in order. */
156
+ const controls = async (): Promise<unknown[]> =>
157
+ (await server.session(appId).history()).flatMap((event) =>
158
+ event.type === 'ai.control.requested' ? [event.payload] : [],
159
+ )
160
+
161
+ const isPush = (operation: A2Operation): boolean =>
162
+ operation.type === 'push' && operation.sessionId === appId
73
163
 
74
164
  const blob = (name: string): string =>
75
165
  `https://store.public.blob.vercel-storage.com/a0/${appId}/${name}`
76
166
 
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
- },
167
+ const paused = () => screen.findByText('Paused')
168
+
169
+ /** The queued message that reads `summary`. */
170
+ const queued = (summary: string) =>
171
+ within(
172
+ within(screen.getByRole('list', { name: 'Queued messages' }))
173
+ .getByText(summary)
174
+ .closest('li')!,
95
175
  )
96
- })
97
176
 
98
- afterEach(() => {
99
- cleanup()
100
- vi.unstubAllGlobals()
101
- })
177
+ const chevron = () => screen.getByRole('button', { name: 'Send mode' })
102
178
 
103
- it('sends a message between turns', () => {
104
- state = idle()
105
- chat()
179
+ it('sends a message between turns', async () => {
180
+ await chat()
106
181
  expect(screen.queryByRole('button', { name: 'Send mode' })).toBeNull()
107
182
  write('Build a blog.')
108
- expect(pushed()).toContainEqual(
109
- expect.objectContaining({
110
- payload: expect.objectContaining({ action: 'send' }),
111
- }),
183
+ await waitFor(async () =>
184
+ expect(await controls()).toEqual([
185
+ expect.objectContaining({
186
+ action: 'send',
187
+ message: expect.objectContaining({
188
+ parts: [{ type: 'text', text: 'Build a blog.' }],
189
+ }),
190
+ }),
191
+ ]),
192
+ )
193
+ expect(JSON.stringify((await model.step(0)).options.prompt)).toContain(
194
+ 'Build a blog.',
112
195
  )
113
196
  })
114
197
 
115
- it('steers the active turn by default, and queues behind it from the chevron', () => {
116
- state = generating()
117
- chat()
198
+ it('steers the active turn by default, and queues behind it from the chevron', async () => {
199
+ const { turnId } = await generating()
200
+ await chat()
118
201
  expect(screen.getByRole('button', { name: 'Steer' })).toBeTruthy()
119
202
  write('Use a dark theme.')
120
- expect(pushed()).toContainEqual(
121
- expect.objectContaining({
122
- payload: expect.objectContaining({
203
+ await waitFor(async () =>
204
+ expect(await controls()).toContainEqual(
205
+ expect.objectContaining({
123
206
  action: 'steer',
124
- turnId: 't1',
207
+ turnId,
125
208
  message: expect.objectContaining({
126
209
  parts: [{ type: 'text', text: 'Use a dark theme.' }],
127
210
  }),
128
211
  }),
129
- }),
212
+ ),
130
213
  )
131
214
 
132
- push.mockClear()
133
- fireEvent.click(screen.getByRole('button', { name: 'Send mode' }))
215
+ fireEvent.click(chevron())
134
216
  expect(
135
217
  screen
136
218
  .getByRole('menuitemradio', { name: 'Steer' })
@@ -139,208 +221,236 @@ it('steers the active turn by default, and queues behind it from the chevron', (
139
221
  fireEvent.click(screen.getByRole('menuitemradio', { name: 'Queue' }))
140
222
  expect(screen.getByRole('button', { name: 'Queue' })).toBeTruthy()
141
223
  write('Then add an about page.')
142
- expect(pushed()).toContainEqual(
143
- expect.objectContaining({
144
- payload: expect.objectContaining({ action: 'send' }),
145
- }),
224
+ await waitFor(async () =>
225
+ expect(await controls()).toContainEqual(
226
+ expect.objectContaining({
227
+ action: 'send',
228
+ message: expect.objectContaining({
229
+ parts: [{ type: 'text', text: 'Then add an about page.' }],
230
+ }),
231
+ }),
232
+ ),
146
233
  )
147
234
  })
148
235
 
149
- it('pauses, resumes, and stops the turn like a player', () => {
150
- state = generating()
151
- const { rerender } = chat()
236
+ it('pauses, resumes, and stops the turn like a player', async () => {
237
+ const { turnId } = await generating()
238
+ await chat()
152
239
  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()
240
+ await paused()
170
241
  expect(screen.queryByText('Thinking')).toBeNull()
171
242
  fireEvent.click(screen.getByRole('button', { name: 'Resume' }))
172
- expect(pushed()).toEqual([
173
- expect.objectContaining({ payload: { action: 'resume' } }),
174
- ])
243
+ await waitFor(() => expect(screen.queryByText('Paused')).toBeNull())
244
+ fireEvent.click(screen.getByRole('button', { name: 'Stop' }))
245
+ await waitFor(async () =>
246
+ expect((await controls()).slice(1)).toEqual([
247
+ { action: 'pause', when: 'now' },
248
+ { action: 'resume' },
249
+ { action: 'stop', turnId },
250
+ ]),
251
+ )
252
+ await waitFor(() =>
253
+ expect(screen.queryByRole('group', { name: 'Turn' })).toBeNull(),
254
+ )
175
255
  })
176
256
 
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
- ])
257
+ it('steers with a queued message, or removes it', async () => {
258
+ const { turnId } = await generating()
259
+ await server
260
+ .session(appId)
261
+ .append(
262
+ ...inputs.message(user('q1', 'Add tags.')),
263
+ ...inputs.message(user('q2', 'Add a footer.')),
264
+ )
265
+ await chat()
266
+ fireEvent.click(
267
+ queued('Add tags.').getByRole('button', { name: 'Steer with it now' }),
268
+ )
269
+ fireEvent.click(
270
+ queued('Add a footer.').getByRole('button', { name: 'Remove' }),
271
+ )
272
+ await waitFor(async () =>
273
+ expect((await controls()).slice(3)).toEqual([
274
+ { action: 'send-now', inputId: 'q1', turnId },
275
+ { action: 'remove', inputId: 'q2' },
276
+ ]),
277
+ )
196
278
  })
197
279
 
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() {}',
280
+ it('shows what a tool call writes while it streams, folded once it ran', async () => {
281
+ const { step } = await generating()
282
+ const { container } = await chat()
283
+ const call = (name: string) =>
284
+ container.querySelector(`[data-tool="${name}"]`)
285
+ step.write(
286
+ { type: 'tool-input-start', id: 'w1', toolName: 'write' },
287
+ {
288
+ type: 'tool-input-delta',
289
+ id: 'w1',
290
+ delta: '{"path":"app/page.tsx","content":"export default function',
226
291
  },
227
- output: { content: [] },
228
- })
229
- rerender(
230
- <Chat appId={appId} harness="pi" onPrompt={() => {}} autoFocus={false} />,
231
292
  )
232
- expect(details()?.hasAttribute('open')).toBe(false)
293
+ await waitFor(() =>
294
+ expect(call('write')?.querySelector('.a0-tool-text')?.textContent).toBe(
295
+ 'export default function',
296
+ ),
297
+ )
298
+ expect(call('write')?.querySelector('details')?.hasAttribute('open')).toBe(
299
+ true,
300
+ )
233
301
 
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} />,
302
+ step.write(
303
+ { type: 'tool-input-delta', id: 'w1', delta: ' Page() {}"}' },
304
+ { type: 'tool-input-end', id: 'w1' },
305
+ {
306
+ type: 'tool-call',
307
+ toolCallId: 'w1',
308
+ toolName: 'write',
309
+ input: JSON.stringify({
310
+ path: 'app/page.tsx',
311
+ content: 'export default function Page() {}',
312
+ }),
313
+ },
314
+ )
315
+ step.finish('tool-calls')
316
+ await waitFor(() =>
317
+ expect(call('write')?.querySelector('details')?.hasAttribute('open')).toBe(
318
+ false,
319
+ ),
243
320
  )
244
- expect(container.querySelector('.a0-tool')).toBeNull()
245
- expect(screen.getByText('ls')).toBeTruthy()
246
- })
247
321
 
248
- const again = (rerender: (ui: ReactElement) => void): void =>
249
- rerender(
250
- <Chat appId={appId} harness="pi" onPrompt={() => {}} autoFocus={false} />,
322
+ // A one-line command fits its step's line.
323
+ ;(await model.step(1)).write(
324
+ ...toolCall({ id: 'b1', name: 'bash', chunks: ['{"command":"ls"}'] }),
251
325
  )
326
+ await waitFor(() => expect(call('bash')?.textContent).toContain('ls'))
327
+ expect(call('bash')?.classList.contains('a0-step')).toBe(true)
328
+ expect(call('bash')?.querySelector('details')).toBeNull()
329
+ })
252
330
 
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)
331
+ it('closes the send-mode menu from its chevron, and drops it when the turn ends', async () => {
332
+ const { step } = await generating()
333
+ await chat()
334
+ fireEvent.click(chevron())
258
335
  // A press on the chevron is its own, not one outside the menu.
259
- fireEvent.pointerDown(chevron)
336
+ fireEvent.pointerDown(chevron())
260
337
  expect(screen.getAllByRole('menuitemradio')).toHaveLength(2)
261
- fireEvent.click(chevron)
338
+ fireEvent.click(chevron())
262
339
  expect(screen.queryByRole('menuitemradio')).toBeNull()
263
340
 
264
- fireEvent.click(chevron)
265
- state = idle()
266
- again(rerender)
267
- state = generating()
268
- again(rerender)
341
+ fireEvent.click(chevron())
342
+ step.write(...say({ id: 'done', text: 'Done.' }))
343
+ step.finish()
344
+ await waitFor(() =>
345
+ expect(screen.queryByRole('button', { name: 'Send mode' })).toBeNull(),
346
+ )
347
+ await server
348
+ .session(appId)
349
+ .append(...inputs.message(user('u2', 'Add a footer.')))
350
+ await screen.findByRole('button', { name: 'Send mode' })
269
351
  expect(screen.queryByRole('menuitemradio')).toBeNull()
270
352
  })
271
353
 
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()
354
+ it('ends the pause with a stop, so the next message goes out', async () => {
355
+ const { turnId } = await generating()
356
+ await chat()
357
+ fireEvent.click(screen.getByRole('button', { name: 'Pause' }))
358
+ await paused()
280
359
  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
- ])
360
+ await waitFor(async () =>
361
+ expect((await controls()).slice(2)).toEqual([
362
+ { action: 'stop', turnId },
363
+ { action: 'resume' },
364
+ ]),
365
+ )
366
+ await waitFor(() => expect(screen.queryByText('Paused')).toBeNull())
367
+ write('Start over.')
368
+ const next = await model.step(model.steps.length)
369
+ expect(JSON.stringify(next.options.prompt)).toContain('Start over.')
285
370
  })
286
371
 
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()
372
+ it('offers to steer with a queued message only while a turn runs', async () => {
373
+ await chat()
374
+ // The first message is on its way, and its turn has no id yet.
375
+ const sending = probe.hold(isPush)
376
+ write('Build a blog.')
377
+ await sending.arrived
378
+ write('Add tags.')
379
+ const queue = await screen.findByRole('list', { name: 'Queued messages' })
380
+ expect(queue.textContent).toContain('Add tags.')
297
381
  expect(screen.queryByRole('button', { name: 'Steer with it now' })).toBeNull()
298
382
  expect(screen.getByRole('button', { name: 'Remove' })).toBeTruthy()
383
+
384
+ sending.allow()
385
+ await screen.findByRole('button', { name: 'Steer with it now' })
299
386
  })
300
387
 
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()
388
+ it('folds a call that waits on a paused turn, and shows edits as a diff', async () => {
389
+ const { step } = await generating()
390
+ const { container } = await chat()
391
+ const call = (name: string) =>
392
+ container.querySelector(`[data-tool="${name}"]`)
393
+ step.write(
394
+ ...toolCall({
395
+ id: 'b1',
396
+ name: 'bash',
397
+ chunks: [JSON.stringify({ command: 'cat > a.txt <<EOF\nhi\nEOF' })],
398
+ }),
399
+ )
400
+ step.finish('tool-calls')
401
+ await waitFor(() =>
402
+ expect(call('bash')?.querySelector('.a0-spin')).toBeTruthy(),
403
+ )
404
+ expect(call('bash')?.querySelector('details')?.hasAttribute('open')).toBe(
405
+ true,
406
+ )
407
+ fireEvent.click(screen.getByRole('button', { name: 'Pause' }))
408
+ // Paused, not still pausing: the aborted call has settled in the log,
409
+ // neither done nor failed, so it runs again on Resume.
410
+ await waitFor(async () =>
411
+ expect((await server.session(appId).state()).state.active?.phase).toBe(
412
+ 'paused',
413
+ ),
414
+ )
415
+ const { state } = await server.session(appId).state()
318
416
  expect(
319
- container.querySelector('.a0-tool details')?.hasAttribute('open'),
320
- ).toBe(false)
321
- expect(container.querySelector('.a0-tool .a0-spin')).toBeNull()
417
+ state.messages
418
+ .flatMap((message) => message.parts)
419
+ .find((part) => 'toolCallId' in part && part.toolCallId === 'b1'),
420
+ ).toMatchObject({ state: 'input-available' })
421
+ await paused()
422
+ expect(call('bash')?.querySelector('details')?.hasAttribute('open')).toBe(
423
+ false,
424
+ )
425
+ expect(call('bash')?.querySelector('.a0-spin')).toBeNull()
426
+ expect(call('bash')?.querySelector('.a0-step-done')).toBeNull()
322
427
 
428
+ fireEvent.click(screen.getByRole('button', { name: 'Resume' }))
429
+ bashDone.resolve()
430
+ const next = await model.step(model.steps.length)
323
431
  // 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',
432
+ next.write(
433
+ { type: 'tool-input-start', id: 'e1', toolName: 'edit' },
434
+ {
435
+ type: 'tool-input-delta',
436
+ id: 'e1',
437
+ delta: '{"path":"a.txt","oldText":"hi","newText":"hello"',
438
+ },
439
+ // Nothing written yet: the step's one line, with no empty box.
440
+ { type: 'tool-input-start', id: 'w1', toolName: 'write' },
441
+ {
442
+ type: 'tool-input-delta',
443
+ id: 'w1',
444
+ delta: '{"path":"a.txt","content":""',
445
+ },
333
446
  )
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()
447
+ await waitFor(() =>
448
+ expect(call('edit')?.querySelector('.a0-tool-text')?.textContent).toBe(
449
+ '- hi\n+ hello',
450
+ ),
451
+ )
452
+ await waitFor(() => expect(call('write')).toBeTruthy())
453
+ expect(call('write')?.classList.contains('a0-step')).toBe(true)
344
454
  })
345
455
 
346
456
  const dropOn = (target: Element, ...files: File[]): void => {
@@ -359,9 +469,18 @@ const leave = (from: Element, to: Element | null): void => {
359
469
  fireEvent(from, event)
360
470
  }
361
471
 
472
+ /** Every attached file has finished uploading. */
473
+ const uploaded = () =>
474
+ waitFor(() =>
475
+ expect(
476
+ screen
477
+ .getByRole('list', { name: 'Attachments' })
478
+ .querySelector('[aria-label="Uploading"]'),
479
+ ).toBeNull(),
480
+ )
481
+
362
482
  it('sends images dropped anywhere on the chat with the next message', async () => {
363
- state = idle()
364
- chat()
483
+ await chat()
365
484
  const transcript = screen.getByRole('list', { name: 'Conversation' })
366
485
  fireEvent.dragEnter(transcript, {
367
486
  dataTransfer: { files: [], types: ['Files'] },
@@ -376,44 +495,27 @@ it('sends images dropped anywhere on the chat with the next message', async () =
376
495
  dropOn(transcript, shot())
377
496
  const attached = screen.getByRole('list', { name: 'Attachments' })
378
497
  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
- )
498
+ await uploaded()
391
499
 
392
500
  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
501
  expect(screen.queryByRole('list', { name: 'Attachments' })).toBeNull()
502
+ await waitFor(async () =>
503
+ expect(
504
+ (await server.session(appId).state()).state.messages[0]?.parts,
505
+ ).toEqual([
506
+ {
507
+ type: 'file',
508
+ mediaType: 'image/png',
509
+ filename: 'shot.png',
510
+ url: blob('shot.png'),
511
+ },
512
+ { type: 'text', text: 'Make it look like this.' },
513
+ ]),
514
+ )
412
515
  })
413
516
 
414
517
  it("puts a file that isn't an image into the message as its link", async () => {
415
- state = idle()
416
- chat()
518
+ await chat()
417
519
  const box = screen.getByRole('textbox', {
418
520
  name: 'Message',
419
521
  }) as HTMLTextAreaElement
@@ -425,29 +527,26 @@ it("puts a file that isn't an image into the message as its link", async () => {
425
527
  expect(screen.queryByRole('list', { name: 'Attachments' })).toBeNull()
426
528
  })
427
529
 
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()
530
+ it('shows a sent link as its file, and a sent image as itself', async () => {
531
+ await server.session(appId).append(
532
+ ...inputs.seed({
533
+ id: 'u1',
534
+ role: 'user',
535
+ parts: [
536
+ {
537
+ type: 'file',
538
+ mediaType: 'image/png',
539
+ filename: 'shot.png',
540
+ url: blob('shot.png'),
541
+ },
542
+ {
543
+ type: 'text',
544
+ text: `Use this copy.\ncopy.pdf: ${blob('copy.pdf')}`,
545
+ },
546
+ ],
547
+ }),
548
+ )
549
+ await chat()
451
550
  expect(screen.getByRole('img', { name: 'shot.png' })).toBeTruthy()
452
551
  expect(
453
552
  screen.getByRole('link', { name: 'copy.pdf' }).getAttribute('href'),
@@ -455,9 +554,8 @@ it('shows a sent link as its file, and a sent image as itself', () => {
455
554
  })
456
555
 
457
556
  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()
557
+ blobStore.failure = new Error('Vercel Blob: Failed to upload')
558
+ await chat()
461
559
  dropOn(screen.getByRole('list', { name: 'Conversation' }), shot())
462
560
  expect((await screen.findByRole('alert')).textContent).toBe(
463
561
  "Couldn't upload shot.png: Vercel Blob: Failed to upload",
@@ -466,39 +564,32 @@ it('says which upload failed, and drops the file', async () => {
466
564
  })
467
565
 
468
566
  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
- })
567
+ await chat()
568
+ const box = screen.getByRole('textbox', {
569
+ name: 'Message',
570
+ }) as HTMLTextAreaElement
571
+ fireEvent.paste(box, { clipboardData: { files: [shot()] } })
474
572
  expect(screen.getByRole('list', { name: 'Attachments' })).toBeTruthy()
475
- await waitFor(() => expect(upload).toHaveBeenCalled())
573
+ await uploaded()
574
+ expect(box.value).toBe('')
476
575
  })
477
576
 
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
577
  it('keeps the attachments of a send that failed, beside any attached since', async () => {
489
- state = idle()
490
- chat()
578
+ await chat()
491
579
  const transcript = screen.getByRole('list', { name: 'Conversation' })
492
580
  dropOn(transcript, shot())
493
581
  await uploaded()
494
- let fail!: (cause: Error) => void
495
- push.mockReturnValueOnce(new Promise((_, reject) => (fail = reject)))
582
+ const sending = probe.hold(isPush)
496
583
  write('Like this.')
497
584
  dropOn(transcript, new File(['png'], 'more.png', { type: 'image/png' }))
498
585
  await uploaded()
499
- fail(new Error('offline'))
500
- expect((await screen.findByRole('alert')).textContent).toBe('offline')
586
+ await sending.arrived
587
+ await act(async () => sending.deny())
588
+ expect((await screen.findByRole('alert')).textContent).toBe(
589
+ 'the operation is not authorized',
590
+ )
501
591
  expect(
502
592
  screen.getByRole('list', { name: 'Attachments' }).querySelectorAll('li'),
503
593
  ).toHaveLength(2)
594
+ expect(await controls()).toEqual([])
504
595
  })