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.
- package/CHANGELOG.md +16 -0
- package/dist/actor.js +1 -1
- package/dist/ai-fx.d.ts.map +1 -1
- package/dist/ai-fx.js +34 -4
- package/dist/ai-fx.js.map +1 -1
- package/dist/ai-harness-CV6rXbB3.d.ts.map +1 -1
- package/dist/{ai-harness-BuuC-gJ3.js → ai-harness-Clbg5ei7.js} +11 -2
- package/dist/ai-harness-Clbg5ei7.js.map +1 -0
- package/dist/ai-pi.js +2 -2
- package/dist/ai-server.d.ts.map +1 -1
- package/dist/ai-server.js +6 -4
- package/dist/ai-server.js.map +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 +4 -3
- 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 +1 -1
- package/docs/agents/meta.ts +1 -1
- package/docs/client/meta.ts +1 -1
- package/docs/contract/03-sessions.mdx +1 -1
- 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 +9 -1
- 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 +2 -2
- 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 +12 -7
- package/examples/playground/app/a0/apps/[appId]/route.ts +6 -10
- package/examples/playground/app/a0/chat.test.tsx +421 -330
- package/examples/playground/app/a0/shell.tsx +1 -1
- package/examples/playground/app/a0/uploads/route.test.ts +59 -0
- package/examples/playground/app/agent/[agentId]/agent-queue.test.tsx +116 -90
- 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/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/package.json +2 -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 +5 -4
- package/src/ai-fx.ts +37 -3
- package/src/ai-harness.ts +18 -0
- package/src/errors.ts +1 -1
- package/src/internal.ts +2 -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 +10 -4
- package/src/server.ts +66 -32
- package/src/store-postgres.ts +5 -123
- package/dist/ai-harness-BuuC-gJ3.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
|
@@ -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
|
|
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
|
|
13
|
-
import {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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:
|
|
29
|
+
useRouter: () => ({ replace: () => {} }),
|
|
29
30
|
useSearchParams: () => new URLSearchParams(),
|
|
30
31
|
}))
|
|
31
|
-
|
|
32
|
-
vi.mock('@vercel/blob/client', () => ({
|
|
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
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
<
|
|
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
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
109
|
-
expect.
|
|
110
|
-
|
|
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
|
-
|
|
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
|
-
|
|
121
|
-
expect.
|
|
122
|
-
|
|
203
|
+
await waitFor(async () =>
|
|
204
|
+
expect(await controls()).toContainEqual(
|
|
205
|
+
expect.objectContaining({
|
|
123
206
|
action: 'steer',
|
|
124
|
-
turnId
|
|
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
|
-
|
|
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
|
-
|
|
143
|
-
expect.
|
|
144
|
-
|
|
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
|
-
|
|
151
|
-
|
|
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
|
-
|
|
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(
|
|
173
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
chat()
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
fireEvent.click(
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
type: 'tool-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
|
|
282
|
-
expect
|
|
283
|
-
|
|
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
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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 =
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
-
|
|
325
|
-
type: 'tool-edit',
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
'-
|
|
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
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
430
|
-
...
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
{
|
|
443
|
-
|
|
444
|
-
|
|
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
|
-
|
|
459
|
-
|
|
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
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
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
|
|
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
|
-
|
|
490
|
-
chat()
|
|
578
|
+
await chat()
|
|
491
579
|
const transcript = screen.getByRole('list', { name: 'Conversation' })
|
|
492
580
|
dropOn(transcript, shot())
|
|
493
581
|
await uploaded()
|
|
494
|
-
|
|
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
|
-
|
|
500
|
-
|
|
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
|
})
|