experimental-a2 0.18.1 → 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.
- package/CHANGELOG.md +44 -0
- package/dist/actor.js +1 -1
- package/dist/{ai-BMLlI2Ff.d.ts → ai-Cz3hqVhk.d.ts} +33 -16
- package/dist/ai-Cz3hqVhk.d.ts.map +1 -0
- package/dist/ai-client.d.ts +1 -1
- package/dist/ai-client.js +1 -1
- package/dist/ai-control-CvLbW10B.js +46 -0
- package/dist/ai-control-CvLbW10B.js.map +1 -0
- package/dist/ai-fx.d.ts +1 -1
- package/dist/ai-fx.d.ts.map +1 -1
- package/dist/ai-fx.js +63 -13
- package/dist/ai-fx.js.map +1 -1
- package/dist/{ai-harness-B2suP2Qh.d.ts → ai-harness-CV6rXbB3.d.ts} +2 -2
- package/dist/ai-harness-CV6rXbB3.d.ts.map +1 -0
- package/dist/{ai-harness-BuLh_EYi.js → ai-harness-Clbg5ei7.js} +88 -15
- package/dist/ai-harness-Clbg5ei7.js.map +1 -0
- package/dist/{ai-D6mGlO8j.js → ai-mMiWul1T.js} +116 -77
- package/dist/ai-mMiWul1T.js.map +1 -0
- package/dist/ai-pi.d.ts +1 -1
- package/dist/ai-pi.d.ts.map +1 -1
- package/dist/ai-pi.js +56 -11
- package/dist/ai-pi.js.map +1 -1
- package/dist/ai-react.d.ts +1 -1
- package/dist/ai-react.js +1 -1
- package/dist/ai-server.d.ts +2 -2
- package/dist/ai-server.d.ts.map +1 -1
- package/dist/ai-server.js +123 -53
- package/dist/ai-server.js.map +1 -1
- package/dist/ai.d.ts +2 -2
- package/dist/ai.js +1 -1
- package/dist/internal-CscSY7K_.js.map +1 -1
- package/dist/scheduler-qstash.d.ts.map +1 -1
- package/dist/scheduler-qstash.js +5 -5
- package/dist/scheduler-qstash.js.map +1 -1
- package/dist/scheduler-vercel.d.ts.map +1 -1
- package/dist/scheduler-vercel.js +5 -7
- package/dist/scheduler-vercel.js.map +1 -1
- package/dist/server-DYAATFuT.d.ts.map +1 -1
- package/dist/{server-D-Y4pqwu.js → server-qyb5aJJG.js} +39 -25
- package/dist/server-qyb5aJJG.js.map +1 -0
- package/dist/server.js +1 -1
- package/dist/session-clear-DGI4jTQ5.js.map +1 -1
- package/dist/store-postgres.d.ts.map +1 -1
- package/dist/store-postgres.js +38 -136
- package/dist/store-postgres.js.map +1 -1
- package/docs/01-quickstart.mdx +7 -4
- package/docs/actors/01-introduction.mdx +1 -1
- package/docs/actors/meta.ts +1 -1
- package/docs/agents/02-tools.mdx +1 -1
- package/docs/agents/03-conversations.mdx +26 -14
- package/docs/agents/04-harnesses.mdx +51 -5
- package/docs/agents/meta.ts +1 -1
- package/docs/client/meta.ts +1 -1
- package/docs/contract/03-sessions.mdx +2 -2
- package/docs/index.mdx +2 -2
- package/docs/reference/01-core.mdx +8 -8
- package/docs/reference/02-server.mdx +3 -3
- package/docs/reference/04-ai.mdx +36 -19
- package/docs/reference/05-telemetry.mdx +132 -0
- package/docs/reference/06-errors.mdx +1 -1
- package/docs/reference/meta.ts +1 -1
- package/docs/schedulers/01-overview.mdx +112 -0
- package/docs/schedulers/02-vercel-queues.mdx +87 -0
- package/docs/schedulers/03-qstash.mdx +123 -0
- package/docs/schedulers/meta.ts +1 -0
- package/docs/server/01-handlers.mdx +29 -2
- package/docs/server/02-timers.mdx +3 -3
- package/docs/server/06-guarantees.mdx +6 -3
- package/docs/stores/01-overview.mdx +37 -0
- package/docs/stores/02-redis.mdx +49 -0
- package/docs/stores/03-redis-http.mdx +56 -0
- package/docs/stores/04-postgres.mdx +121 -0
- package/docs/stores/05-sqlite.mdx +34 -0
- package/docs/stores/06-memory.mdx +22 -0
- package/docs/stores/meta.ts +1 -0
- package/examples/playground/DEPLOY.md +19 -5
- package/examples/playground/app/a0/a0.css +244 -5
- package/examples/playground/app/a0/app-view.tsx +2 -1
- package/examples/playground/app/a0/apps/[appId]/route.ts +6 -10
- package/examples/playground/app/a0/attachments.ts +133 -0
- package/examples/playground/app/a0/chat.test.tsx +595 -0
- package/examples/playground/app/a0/chat.tsx +623 -63
- package/examples/playground/app/a0/context-menu.tsx +38 -11
- package/examples/playground/app/a0/model.test.ts +28 -0
- package/examples/playground/app/a0/model.ts +30 -0
- package/examples/playground/app/a0/server.ts +5 -0
- package/examples/playground/app/a0/shell.tsx +1 -1
- package/examples/playground/app/a0/uploads/route.test.ts +59 -0
- package/examples/playground/app/a0/uploads/route.ts +29 -0
- package/examples/playground/app/agent/[agentId]/agent-client.tsx +2 -1
- package/examples/playground/app/agent/[agentId]/agent-queue.test.tsx +116 -89
- package/examples/playground/app/agent/[agentId]/compaction-panel.test.tsx +107 -76
- package/examples/playground/app/agent/ingress.test.ts +41 -17
- package/examples/playground/app/agent/model-input.test.tsx +92 -43
- package/examples/playground/app/agent/model.test.ts +12 -3
- package/examples/playground/app/chat/[chatId]/agent-state.test.ts +1 -5
- package/examples/playground/app/chat/[chatId]/agent-stream-drawer.test.tsx +2 -2
- package/examples/playground/app/chat/server.test.ts +38 -65
- package/examples/playground/app/components/activity-history.test.tsx +60 -18
- package/examples/playground/app/components/fast-link.test.tsx +3 -5
- package/examples/playground/app/initial-state.test.tsx +86 -45
- package/examples/playground/next.config.ts +7 -4
- package/examples/playground/package.json +4 -1
- package/examples/playground/test/agent.ts +35 -0
- package/examples/playground/test/model.ts +149 -0
- package/examples/playground/test/serve.ts +184 -0
- package/examples/playground/test/server-only.ts +1 -0
- package/examples/playground/vitest.config.ts +27 -0
- package/package.json +3 -1
- package/sql/postgres/notifications.sql +24 -0
- package/sql/postgres/schema.sql +137 -0
- package/src/ai-agent.ts +9 -4
- package/src/ai-client-state.ts +21 -9
- package/src/ai-context-schema.ts +28 -4
- package/src/ai-context.ts +83 -3
- package/src/ai-control-server.ts +70 -43
- package/src/ai-control-state.ts +3 -4
- package/src/ai-control.ts +59 -10
- package/src/ai-coordinator.ts +7 -6
- package/src/ai-fx.ts +95 -23
- package/src/ai-harness.ts +171 -29
- package/src/ai-message-shape.ts +41 -0
- package/src/ai-pi.ts +88 -32
- package/src/ai-stored-state.ts +1 -3
- package/src/ai.ts +116 -77
- package/src/errors.ts +1 -1
- package/src/internal.ts +2 -1
- package/src/libfx.d.ts +8 -1
- package/src/postgres-notifications.ts +0 -22
- package/src/postgres-resources.ts +6 -9
- package/src/postgres-schema.ts +80 -0
- package/src/scheduler-qstash.ts +12 -5
- package/src/scheduler-vercel.ts +11 -16
- package/src/server.ts +66 -32
- package/src/store-postgres.ts +5 -123
- package/dist/ai-BMLlI2Ff.d.ts.map +0 -1
- package/dist/ai-D6mGlO8j.js.map +0 -1
- package/dist/ai-harness-B2suP2Qh.d.ts.map +0 -1
- package/dist/ai-harness-BuLh_EYi.js.map +0 -1
- package/dist/server-D-Y4pqwu.js.map +0 -1
- package/docs/reference/05-adapters.mdx +0 -351
- package/docs/server/05-production.mdx +0 -323
- package/examples/playground/app/a0/no-photon.cjs +0 -4
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import {
|
|
3
|
+
act,
|
|
4
|
+
cleanup,
|
|
5
|
+
createEvent,
|
|
6
|
+
fireEvent,
|
|
7
|
+
render,
|
|
8
|
+
screen,
|
|
9
|
+
waitFor,
|
|
10
|
+
within,
|
|
11
|
+
} from '@testing-library/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'
|
|
17
|
+
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
|
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'
|
|
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.
|
|
28
|
+
vi.mock('next/navigation', () => ({
|
|
29
|
+
useRouter: () => ({ replace: () => {} }),
|
|
30
|
+
useSearchParams: () => new URLSearchParams(),
|
|
31
|
+
}))
|
|
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
|
+
}))
|
|
42
|
+
|
|
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
|
+
}
|
|
71
|
+
|
|
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
|
+
)
|
|
110
|
+
})
|
|
111
|
+
|
|
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()
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
const user = (id: string, text: string): AppMessage => ({
|
|
124
|
+
id,
|
|
125
|
+
role: 'user',
|
|
126
|
+
parts: [{ type: 'text', text }],
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
const chat = async () =>
|
|
130
|
+
render(
|
|
131
|
+
<SessionProvider
|
|
132
|
+
sessionId={appId}
|
|
133
|
+
initial={await server.session(appId).state()}
|
|
134
|
+
>
|
|
135
|
+
<Chat appId={appId} harness="pi" onPrompt={() => {}} autoFocus={false} />
|
|
136
|
+
</SessionProvider>,
|
|
137
|
+
)
|
|
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
|
+
|
|
149
|
+
const write = (text: string): void => {
|
|
150
|
+
const box = screen.getByRole('textbox', { name: 'Message' })
|
|
151
|
+
fireEvent.change(box, { target: { value: text } })
|
|
152
|
+
fireEvent.keyDown(box, { key: 'Enter' })
|
|
153
|
+
}
|
|
154
|
+
|
|
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
|
|
163
|
+
|
|
164
|
+
const blob = (name: string): string =>
|
|
165
|
+
`https://store.public.blob.vercel-storage.com/a0/${appId}/${name}`
|
|
166
|
+
|
|
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')!,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
const chevron = () => screen.getByRole('button', { name: 'Send mode' })
|
|
178
|
+
|
|
179
|
+
it('sends a message between turns', async () => {
|
|
180
|
+
await chat()
|
|
181
|
+
expect(screen.queryByRole('button', { name: 'Send mode' })).toBeNull()
|
|
182
|
+
write('Build a blog.')
|
|
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.',
|
|
195
|
+
)
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('steers the active turn by default, and queues behind it from the chevron', async () => {
|
|
199
|
+
const { turnId } = await generating()
|
|
200
|
+
await chat()
|
|
201
|
+
expect(screen.getByRole('button', { name: 'Steer' })).toBeTruthy()
|
|
202
|
+
write('Use a dark theme.')
|
|
203
|
+
await waitFor(async () =>
|
|
204
|
+
expect(await controls()).toContainEqual(
|
|
205
|
+
expect.objectContaining({
|
|
206
|
+
action: 'steer',
|
|
207
|
+
turnId,
|
|
208
|
+
message: expect.objectContaining({
|
|
209
|
+
parts: [{ type: 'text', text: 'Use a dark theme.' }],
|
|
210
|
+
}),
|
|
211
|
+
}),
|
|
212
|
+
),
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
fireEvent.click(chevron())
|
|
216
|
+
expect(
|
|
217
|
+
screen
|
|
218
|
+
.getByRole('menuitemradio', { name: 'Steer' })
|
|
219
|
+
.getAttribute('aria-checked'),
|
|
220
|
+
).toBe('true')
|
|
221
|
+
fireEvent.click(screen.getByRole('menuitemradio', { name: 'Queue' }))
|
|
222
|
+
expect(screen.getByRole('button', { name: 'Queue' })).toBeTruthy()
|
|
223
|
+
write('Then add an about page.')
|
|
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
|
+
),
|
|
233
|
+
)
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
it('pauses, resumes, and stops the turn like a player', async () => {
|
|
237
|
+
const { turnId } = await generating()
|
|
238
|
+
await chat()
|
|
239
|
+
fireEvent.click(screen.getByRole('button', { name: 'Pause' }))
|
|
240
|
+
await paused()
|
|
241
|
+
expect(screen.queryByText('Thinking')).toBeNull()
|
|
242
|
+
fireEvent.click(screen.getByRole('button', { name: 'Resume' }))
|
|
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
|
+
)
|
|
255
|
+
})
|
|
256
|
+
|
|
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
|
+
)
|
|
278
|
+
})
|
|
279
|
+
|
|
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',
|
|
291
|
+
},
|
|
292
|
+
)
|
|
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
|
+
)
|
|
301
|
+
|
|
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
|
+
),
|
|
320
|
+
)
|
|
321
|
+
|
|
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"}'] }),
|
|
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
|
+
})
|
|
330
|
+
|
|
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())
|
|
335
|
+
// A press on the chevron is its own, not one outside the menu.
|
|
336
|
+
fireEvent.pointerDown(chevron())
|
|
337
|
+
expect(screen.getAllByRole('menuitemradio')).toHaveLength(2)
|
|
338
|
+
fireEvent.click(chevron())
|
|
339
|
+
expect(screen.queryByRole('menuitemradio')).toBeNull()
|
|
340
|
+
|
|
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' })
|
|
351
|
+
expect(screen.queryByRole('menuitemradio')).toBeNull()
|
|
352
|
+
})
|
|
353
|
+
|
|
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()
|
|
359
|
+
fireEvent.click(screen.getByRole('button', { name: 'Stop' }))
|
|
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.')
|
|
370
|
+
})
|
|
371
|
+
|
|
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.')
|
|
381
|
+
expect(screen.queryByRole('button', { name: 'Steer with it now' })).toBeNull()
|
|
382
|
+
expect(screen.getByRole('button', { name: 'Remove' })).toBeTruthy()
|
|
383
|
+
|
|
384
|
+
sending.allow()
|
|
385
|
+
await screen.findByRole('button', { name: 'Steer with it now' })
|
|
386
|
+
})
|
|
387
|
+
|
|
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()
|
|
416
|
+
expect(
|
|
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()
|
|
427
|
+
|
|
428
|
+
fireEvent.click(screen.getByRole('button', { name: 'Resume' }))
|
|
429
|
+
bashDone.resolve()
|
|
430
|
+
const next = await model.step(model.steps.length)
|
|
431
|
+
// Pi's older edit input: the edit's fields on the call itself.
|
|
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
|
+
},
|
|
446
|
+
)
|
|
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)
|
|
454
|
+
})
|
|
455
|
+
|
|
456
|
+
const dropOn = (target: Element, ...files: File[]): void => {
|
|
457
|
+
const dataTransfer = { files, types: ['Files'], dropEffect: 'none' }
|
|
458
|
+
fireEvent.dragEnter(target, { dataTransfer })
|
|
459
|
+
fireEvent.dragOver(target, { dataTransfer })
|
|
460
|
+
fireEvent.drop(target, { dataTransfer })
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const shot = () => new File(['png'], 'shot.png', { type: 'image/png' })
|
|
464
|
+
|
|
465
|
+
/** jsdom has no DragEvent, so the event carries where the files went itself. */
|
|
466
|
+
const leave = (from: Element, to: Element | null): void => {
|
|
467
|
+
const event = createEvent.dragLeave(from)
|
|
468
|
+
Object.defineProperty(event, 'relatedTarget', { value: to })
|
|
469
|
+
fireEvent(from, event)
|
|
470
|
+
}
|
|
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
|
+
|
|
482
|
+
it('sends images dropped anywhere on the chat with the next message', async () => {
|
|
483
|
+
await chat()
|
|
484
|
+
const transcript = screen.getByRole('list', { name: 'Conversation' })
|
|
485
|
+
fireEvent.dragEnter(transcript, {
|
|
486
|
+
dataTransfer: { files: [], types: ['Files'] },
|
|
487
|
+
})
|
|
488
|
+
expect(screen.getByText('Drop to attach')).toBeTruthy()
|
|
489
|
+
// Into the composer, still on the chat, then out of the window.
|
|
490
|
+
leave(transcript, screen.getByRole('textbox', { name: 'Message' }))
|
|
491
|
+
expect(screen.getByText('Drop to attach')).toBeTruthy()
|
|
492
|
+
leave(transcript, null)
|
|
493
|
+
expect(screen.queryByText('Drop to attach')).toBeNull()
|
|
494
|
+
|
|
495
|
+
dropOn(transcript, shot())
|
|
496
|
+
const attached = screen.getByRole('list', { name: 'Attachments' })
|
|
497
|
+
expect(attached.querySelector('[aria-label="Uploading"]')).toBeTruthy()
|
|
498
|
+
await uploaded()
|
|
499
|
+
|
|
500
|
+
write('Make it look like this.')
|
|
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
|
+
)
|
|
515
|
+
})
|
|
516
|
+
|
|
517
|
+
it("puts a file that isn't an image into the message as its link", async () => {
|
|
518
|
+
await chat()
|
|
519
|
+
const box = screen.getByRole('textbox', {
|
|
520
|
+
name: 'Message',
|
|
521
|
+
}) as HTMLTextAreaElement
|
|
522
|
+
fireEvent.change(box, { target: { value: 'Use this copy.' } })
|
|
523
|
+
dropOn(box, new File(['%PDF'], 'copy.pdf', { type: 'application/pdf' }))
|
|
524
|
+
await waitFor(() =>
|
|
525
|
+
expect(box.value).toBe(`Use this copy.\ncopy.pdf: ${blob('copy.pdf')}`),
|
|
526
|
+
)
|
|
527
|
+
expect(screen.queryByRole('list', { name: 'Attachments' })).toBeNull()
|
|
528
|
+
})
|
|
529
|
+
|
|
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()
|
|
550
|
+
expect(screen.getByRole('img', { name: 'shot.png' })).toBeTruthy()
|
|
551
|
+
expect(
|
|
552
|
+
screen.getByRole('link', { name: 'copy.pdf' }).getAttribute('href'),
|
|
553
|
+
).toBe(blob('copy.pdf'))
|
|
554
|
+
})
|
|
555
|
+
|
|
556
|
+
it('says which upload failed, and drops the file', async () => {
|
|
557
|
+
blobStore.failure = new Error('Vercel Blob: Failed to upload')
|
|
558
|
+
await chat()
|
|
559
|
+
dropOn(screen.getByRole('list', { name: 'Conversation' }), shot())
|
|
560
|
+
expect((await screen.findByRole('alert')).textContent).toBe(
|
|
561
|
+
"Couldn't upload shot.png: Vercel Blob: Failed to upload",
|
|
562
|
+
)
|
|
563
|
+
expect(screen.queryByRole('list', { name: 'Attachments' })).toBeNull()
|
|
564
|
+
})
|
|
565
|
+
|
|
566
|
+
it('attaches a pasted image instead of pasting it as text', async () => {
|
|
567
|
+
await chat()
|
|
568
|
+
const box = screen.getByRole('textbox', {
|
|
569
|
+
name: 'Message',
|
|
570
|
+
}) as HTMLTextAreaElement
|
|
571
|
+
fireEvent.paste(box, { clipboardData: { files: [shot()] } })
|
|
572
|
+
expect(screen.getByRole('list', { name: 'Attachments' })).toBeTruthy()
|
|
573
|
+
await uploaded()
|
|
574
|
+
expect(box.value).toBe('')
|
|
575
|
+
})
|
|
576
|
+
|
|
577
|
+
it('keeps the attachments of a send that failed, beside any attached since', async () => {
|
|
578
|
+
await chat()
|
|
579
|
+
const transcript = screen.getByRole('list', { name: 'Conversation' })
|
|
580
|
+
dropOn(transcript, shot())
|
|
581
|
+
await uploaded()
|
|
582
|
+
const sending = probe.hold(isPush)
|
|
583
|
+
write('Like this.')
|
|
584
|
+
dropOn(transcript, new File(['png'], 'more.png', { type: 'image/png' }))
|
|
585
|
+
await uploaded()
|
|
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
|
+
)
|
|
591
|
+
expect(
|
|
592
|
+
screen.getByRole('list', { name: 'Attachments' }).querySelectorAll('li'),
|
|
593
|
+
).toHaveLength(2)
|
|
594
|
+
expect(await controls()).toEqual([])
|
|
595
|
+
})
|