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
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
'use client'
|
|
2
|
-
import type { KeyboardEvent, ReactNode } from 'react'
|
|
2
|
+
import type { KeyboardEvent, ReactNode, RefObject } from 'react'
|
|
3
3
|
import {
|
|
4
4
|
useEffect,
|
|
5
5
|
useEffectEvent,
|
|
@@ -7,27 +7,38 @@ import {
|
|
|
7
7
|
useRef,
|
|
8
8
|
useState,
|
|
9
9
|
} from 'react'
|
|
10
|
+
import { Check } from 'lucide-react'
|
|
10
11
|
|
|
11
12
|
export type MenuItem = {
|
|
12
13
|
label: string
|
|
13
14
|
onSelect: () => void
|
|
14
15
|
danger?: boolean
|
|
16
|
+
/** One of a set of choices; a checked one is the current choice. */
|
|
17
|
+
checked?: boolean
|
|
15
18
|
}
|
|
16
19
|
|
|
20
|
+
const ITEMS = '[role="menuitem"], [role="menuitemradio"]'
|
|
21
|
+
|
|
17
22
|
// Keeps the menu this far inside the window's edges.
|
|
18
23
|
const MARGIN = 8
|
|
19
24
|
|
|
20
25
|
/**
|
|
21
|
-
* A menu at a point, as a right-click opens one.
|
|
22
|
-
*
|
|
23
|
-
*
|
|
26
|
+
* A menu at a point, as a right-click opens one. The point is the menu's top
|
|
27
|
+
* left corner, or its bottom right for a button near the window's foot. It
|
|
28
|
+
* takes focus, moves with the arrow keys, and closes on Escape, a press
|
|
29
|
+
* outside, or a pick; Escape and a pick hand focus back through `onClose`.
|
|
24
30
|
*/
|
|
25
31
|
export function ContextMenu({
|
|
26
32
|
at,
|
|
33
|
+
corner = 'top-left',
|
|
34
|
+
trigger,
|
|
27
35
|
items,
|
|
28
36
|
onClose,
|
|
29
37
|
}: {
|
|
30
38
|
at: { x: number; y: number }
|
|
39
|
+
corner?: 'top-left' | 'bottom-right'
|
|
40
|
+
/** The button that opens the menu; a press on it is its own, not outside. */
|
|
41
|
+
trigger?: RefObject<HTMLElement | null>
|
|
31
42
|
items: MenuItem[]
|
|
32
43
|
onClose: (closed: { returnFocus: boolean }) => void
|
|
33
44
|
}): ReactNode {
|
|
@@ -39,19 +50,26 @@ export function ContextMenu({
|
|
|
39
50
|
const element = menu.current
|
|
40
51
|
if (!element) return
|
|
41
52
|
const { width, height } = element.getBoundingClientRect()
|
|
53
|
+
const x = corner === 'top-left' ? at.x : at.x - width
|
|
54
|
+
const y = corner === 'top-left' ? at.y : at.y - height
|
|
42
55
|
setPosition({
|
|
43
|
-
x: Math.max(MARGIN, Math.min(
|
|
44
|
-
y: Math.max(MARGIN, Math.min(
|
|
56
|
+
x: Math.max(MARGIN, Math.min(x, innerWidth - width - MARGIN)),
|
|
57
|
+
y: Math.max(MARGIN, Math.min(y, innerHeight - height - MARGIN)),
|
|
45
58
|
})
|
|
46
|
-
element.querySelector<HTMLElement>(
|
|
47
|
-
}, [at])
|
|
59
|
+
element.querySelector<HTMLElement>(ITEMS)?.focus()
|
|
60
|
+
}, [at, corner])
|
|
48
61
|
|
|
49
62
|
const dismiss = useEffectEvent((returnFocus: boolean) =>
|
|
50
63
|
onClose({ returnFocus }),
|
|
51
64
|
)
|
|
52
65
|
useEffect(() => {
|
|
53
66
|
const outside = (event: PointerEvent): void => {
|
|
54
|
-
|
|
67
|
+
const target = event.target as Node
|
|
68
|
+
if (
|
|
69
|
+
!menu.current?.contains(target) &&
|
|
70
|
+
!trigger?.current?.contains(target)
|
|
71
|
+
)
|
|
72
|
+
dismiss(false)
|
|
55
73
|
}
|
|
56
74
|
const away = (): void => dismiss(false)
|
|
57
75
|
document.addEventListener('pointerdown', outside, true)
|
|
@@ -66,7 +84,7 @@ export function ContextMenu({
|
|
|
66
84
|
|
|
67
85
|
const move = (event: KeyboardEvent<HTMLDivElement>): void => {
|
|
68
86
|
const buttons = [
|
|
69
|
-
...event.currentTarget.querySelectorAll<HTMLElement>(
|
|
87
|
+
...event.currentTarget.querySelectorAll<HTMLElement>(ITEMS),
|
|
70
88
|
]
|
|
71
89
|
const focused = buttons.indexOf(document.activeElement as HTMLElement)
|
|
72
90
|
const next =
|
|
@@ -101,13 +119,22 @@ export function ContextMenu({
|
|
|
101
119
|
<button
|
|
102
120
|
key={item.label}
|
|
103
121
|
type="button"
|
|
104
|
-
role=
|
|
122
|
+
role={item.checked === undefined ? 'menuitem' : 'menuitemradio'}
|
|
123
|
+
aria-checked={item.checked}
|
|
105
124
|
data-danger={item.danger || undefined}
|
|
106
125
|
onClick={() => {
|
|
107
126
|
onClose({ returnFocus: true })
|
|
108
127
|
item.onSelect()
|
|
109
128
|
}}
|
|
110
129
|
>
|
|
130
|
+
{item.checked !== undefined && (
|
|
131
|
+
<Check
|
|
132
|
+
size={12}
|
|
133
|
+
className="a0-menu-check"
|
|
134
|
+
visibility={item.checked ? 'visible' : 'hidden'}
|
|
135
|
+
aria-hidden
|
|
136
|
+
/>
|
|
137
|
+
)}
|
|
111
138
|
{item.label}
|
|
112
139
|
</button>
|
|
113
140
|
))}
|
|
@@ -71,3 +71,31 @@ it("reads the app's harness from its URL, the AI SDK's by default", () => {
|
|
|
71
71
|
'a0-pi',
|
|
72
72
|
])
|
|
73
73
|
})
|
|
74
|
+
|
|
75
|
+
const sendWithFile = (url: string) =>
|
|
76
|
+
appAgent.events['ai.control.requested']['~standard'].validate({
|
|
77
|
+
action: 'send',
|
|
78
|
+
message: {
|
|
79
|
+
id: 'u1',
|
|
80
|
+
role: 'user',
|
|
81
|
+
parts: [
|
|
82
|
+
{ type: 'file', mediaType: 'image/png', url },
|
|
83
|
+
{ type: 'text', text: 'Like this.' },
|
|
84
|
+
],
|
|
85
|
+
},
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it("only takes a user's files from the playground's Blob store", () => {
|
|
89
|
+
expect(
|
|
90
|
+
sendWithFile(
|
|
91
|
+
'https://store.public.blob.vercel-storage.com/a0/app/shot-x1.png',
|
|
92
|
+
),
|
|
93
|
+
).not.toHaveProperty('issues')
|
|
94
|
+
for (const url of [
|
|
95
|
+
'data:image/png;base64,iVBORw0KGgo=',
|
|
96
|
+
'http://store.public.blob.vercel-storage.com/shot.png',
|
|
97
|
+
'http://169.254.169.254/latest/meta-data',
|
|
98
|
+
'https://example.com/shot.png',
|
|
99
|
+
])
|
|
100
|
+
expect(sendWithFile(url)).toHaveProperty('issues')
|
|
101
|
+
})
|
|
@@ -35,6 +35,35 @@ export const defaultModelChoice: ModelChoice = {
|
|
|
35
35
|
|
|
36
36
|
export type AppMessage = UIMessage
|
|
37
37
|
|
|
38
|
+
/** A file the user attached, uploaded to the playground's public Blob store. */
|
|
39
|
+
export function isAttachmentUrl(value: string): boolean {
|
|
40
|
+
try {
|
|
41
|
+
const { protocol, hostname } = new URL(value)
|
|
42
|
+
return (
|
|
43
|
+
protocol === 'https:' &&
|
|
44
|
+
hostname.endsWith('.public.blob.vercel-storage.com')
|
|
45
|
+
)
|
|
46
|
+
} catch {
|
|
47
|
+
return false
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* A user's files must be attachments. Whatever hands them to the model
|
|
53
|
+
* fetches them by URL, so the URL can't point anywhere else, and a message
|
|
54
|
+
* never holds a file's bytes.
|
|
55
|
+
*/
|
|
56
|
+
const messageSchema = z
|
|
57
|
+
.custom<AppMessage>()
|
|
58
|
+
.refine(
|
|
59
|
+
(message) =>
|
|
60
|
+
message.role !== 'user' ||
|
|
61
|
+
message.parts.every(
|
|
62
|
+
(part) => part.type !== 'file' || isAttachmentUrl(part.url),
|
|
63
|
+
),
|
|
64
|
+
"A message's files must be uploaded attachments",
|
|
65
|
+
)
|
|
66
|
+
|
|
38
67
|
/**
|
|
39
68
|
* The loop an app's agent runs: the AI SDK's, FX's, or Pi's. An
|
|
40
69
|
* app picks it before its first message and keeps it.
|
|
@@ -56,6 +85,7 @@ export const harnessFrom = (value: unknown): Harness =>
|
|
|
56
85
|
const appContract = (name: string) =>
|
|
57
86
|
agentContract({
|
|
58
87
|
name,
|
|
88
|
+
messageSchema,
|
|
59
89
|
events: { 'model.chosen': modelChoiceSchema },
|
|
60
90
|
reducer: {
|
|
61
91
|
name: 'a0.model-choice.v1',
|
|
@@ -5,6 +5,7 @@ import { handlerContext } from 'experimental-a2/ai'
|
|
|
5
5
|
import { fx } from 'experimental-a2/ai/fx'
|
|
6
6
|
import { pi } from 'experimental-a2/ai/pi'
|
|
7
7
|
import { agent } from 'experimental-a2/ai/server'
|
|
8
|
+
import { otel } from 'experimental-a2/otel'
|
|
8
9
|
import { createServer } from 'experimental-a2/server'
|
|
9
10
|
import { vercelQueues } from 'experimental-a2/scheduler-vercel'
|
|
10
11
|
import { z } from 'zod'
|
|
@@ -33,6 +34,7 @@ const instructions = (
|
|
|
33
34
|
- If the preview stops answering, read /tmp/next.log, then start next dev with \`${START_DEV}\`; it does nothing while one is already running.
|
|
34
35
|
- next.config allows the preview's *.vercel.run host (allowedDevOrigins); keep that when you edit it.
|
|
35
36
|
- Background anything long-running; a bash call stops after its timeout.
|
|
37
|
+
- The user can attach files. Images come with their message; any other file is a "name: URL" line in it. Download one with curl when you need its contents.
|
|
36
38
|
- Keep replies short and in plain text, without Markdown: what you changed.`
|
|
37
39
|
|
|
38
40
|
/** A tool's result, with how long it took; the chat shows it. */
|
|
@@ -118,10 +120,13 @@ function chosenModel({
|
|
|
118
120
|
return { model, reasoning, speed: fast ? 'fast' : 'standard' } as const
|
|
119
121
|
}
|
|
120
122
|
|
|
123
|
+
const telemetry = otel()
|
|
124
|
+
|
|
121
125
|
const shared = (harness: Harness) => ({
|
|
122
126
|
...(store ? { store } : {}),
|
|
123
127
|
...(appSchedulers[harness] ? { scheduler: appSchedulers[harness] } : {}),
|
|
124
128
|
contract: harnessAgents[harness],
|
|
129
|
+
telemetry,
|
|
125
130
|
maxSteps: 80,
|
|
126
131
|
})
|
|
127
132
|
|
|
@@ -106,7 +106,7 @@ export function A0Shell({
|
|
|
106
106
|
const remove = async (target: App): Promise<void> => {
|
|
107
107
|
if (
|
|
108
108
|
!confirm(
|
|
109
|
-
`Delete ${target.name}? This deletes its sandbox, with everything the agent built in it.`,
|
|
109
|
+
`Delete ${target.name}? This deletes its conversation and its sandbox, with everything the agent built in it.`,
|
|
110
110
|
)
|
|
111
111
|
)
|
|
112
112
|
return
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
|
2
|
+
import { POST } from './route'
|
|
3
|
+
|
|
4
|
+
const appId = crypto.randomUUID()
|
|
5
|
+
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
vi.stubEnv('BLOB_READ_WRITE_TOKEN', 'vercel_blob_rw_teststore_secret')
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
vi.unstubAllEnvs()
|
|
12
|
+
vi.restoreAllMocks()
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
/** What the browser's `upload()` asks its `handleUploadUrl` for first. */
|
|
16
|
+
const tokenRequest = (pathname: string, clientPayload: string | null) =>
|
|
17
|
+
POST(
|
|
18
|
+
new Request('http://test/a0/uploads', {
|
|
19
|
+
method: 'POST',
|
|
20
|
+
headers: { 'content-type': 'application/json' },
|
|
21
|
+
body: JSON.stringify({
|
|
22
|
+
type: 'blob.generate-client-token',
|
|
23
|
+
payload: { pathname, clientPayload, multipart: false },
|
|
24
|
+
}),
|
|
25
|
+
}),
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
/** The upload a client token allows, as Blob reads it from the token. */
|
|
29
|
+
const allowed = (clientToken: string): unknown => {
|
|
30
|
+
const [, encoded] = /^vercel_blob_client_teststore_(.+)$/.exec(clientToken)!
|
|
31
|
+
const signed = Buffer.from(encoded!, 'base64').toString()
|
|
32
|
+
const payload = signed.slice(signed.indexOf('.') + 1)
|
|
33
|
+
return JSON.parse(Buffer.from(payload, 'base64').toString())
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
it("signs an upload into the app's own folder, without a request of its own", async () => {
|
|
37
|
+
const fetch = vi.spyOn(globalThis, 'fetch')
|
|
38
|
+
const response = await tokenRequest(`a0/${appId}/shot.png`, appId)
|
|
39
|
+
expect(response.status).toBe(200)
|
|
40
|
+
const body = (await response.json()) as { type: string; clientToken: string }
|
|
41
|
+
expect(body.type).toBe('blob.generate-client-token')
|
|
42
|
+
expect(allowed(body.clientToken)).toMatchObject({
|
|
43
|
+
pathname: `a0/${appId}/shot.png`,
|
|
44
|
+
addRandomSuffix: true,
|
|
45
|
+
})
|
|
46
|
+
expect(fetch).not.toHaveBeenCalled()
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it.each([
|
|
50
|
+
['another app', `a0/${crypto.randomUUID()}/shot.png`, appId],
|
|
51
|
+
['no app', `a0/${appId}/shot.png`, null],
|
|
52
|
+
['something other than an app', 'a0/list/shot.png', 'list'],
|
|
53
|
+
])('refuses an upload for %s', async (_, pathname, clientPayload) => {
|
|
54
|
+
const response = await tokenRequest(pathname, clientPayload)
|
|
55
|
+
expect(response.status).toBe(400)
|
|
56
|
+
expect(await response.json()).toEqual({
|
|
57
|
+
error: "An attachment goes in its app's folder",
|
|
58
|
+
})
|
|
59
|
+
})
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { handleUpload, type HandleUploadBody } from '@vercel/blob/client'
|
|
2
|
+
import { isAppId } from '../apps'
|
|
3
|
+
|
|
4
|
+
/** Tokens for the browser to upload an app's attachments straight to Blob. */
|
|
5
|
+
export async function POST(request: Request): Promise<Response> {
|
|
6
|
+
const body = (await request.json()) as HandleUploadBody
|
|
7
|
+
try {
|
|
8
|
+
return Response.json(
|
|
9
|
+
await handleUpload({
|
|
10
|
+
request,
|
|
11
|
+
body,
|
|
12
|
+
onBeforeGenerateToken: async (pathname, appId) => {
|
|
13
|
+
if (
|
|
14
|
+
appId === null ||
|
|
15
|
+
!isAppId(appId) ||
|
|
16
|
+
!pathname.startsWith(`a0/${appId}/`)
|
|
17
|
+
)
|
|
18
|
+
throw new Error("An attachment goes in its app's folder")
|
|
19
|
+
return { addRandomSuffix: true }
|
|
20
|
+
},
|
|
21
|
+
}),
|
|
22
|
+
)
|
|
23
|
+
} catch (cause) {
|
|
24
|
+
return Response.json(
|
|
25
|
+
{ error: cause instanceof Error ? cause.message : String(cause) },
|
|
26
|
+
{ status: 400 },
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -393,10 +393,11 @@ export function AgentClient({
|
|
|
393
393
|
>
|
|
394
394
|
Stop
|
|
395
395
|
</button>
|
|
396
|
+
{/* With no turn to steer, steering would only send. */}
|
|
396
397
|
<button
|
|
397
398
|
type="button"
|
|
398
399
|
className="secondary"
|
|
399
|
-
disabled={!canSend || !prompt.trim()}
|
|
400
|
+
disabled={!canSend || !prompt.trim() || !state.active}
|
|
400
401
|
onClick={() => void send(undefined, true)}
|
|
401
402
|
>
|
|
402
403
|
Steer
|
|
@@ -1,123 +1,150 @@
|
|
|
1
1
|
// @vitest-environment jsdom
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
2
|
+
import {
|
|
3
|
+
act,
|
|
4
|
+
cleanup,
|
|
5
|
+
fireEvent,
|
|
6
|
+
render,
|
|
7
|
+
screen,
|
|
8
|
+
waitFor,
|
|
9
|
+
} from '@testing-library/react'
|
|
10
|
+
import { inputs } from 'experimental-a2/ai'
|
|
11
|
+
import type { A2Operation } from 'experimental-a2/server'
|
|
12
|
+
import { afterEach, beforeEach, expect, it } from 'vitest'
|
|
13
|
+
import { serveAgent, type ServedAgent } from '@/test/agent'
|
|
14
|
+
import { AgentClient } from './agent-client'
|
|
6
15
|
|
|
7
|
-
|
|
16
|
+
let served: ServedAgent
|
|
17
|
+
let server: ServedAgent['server']
|
|
18
|
+
let probe: ServedAgent['probe']
|
|
19
|
+
let id: string
|
|
8
20
|
|
|
9
|
-
|
|
21
|
+
beforeEach(async () => {
|
|
22
|
+
served = await serveAgent()
|
|
23
|
+
;({ server, probe } = served)
|
|
24
|
+
id = crypto.randomUUID()
|
|
25
|
+
// Paused, so the message waits in the queue rather than starting a turn.
|
|
26
|
+
await server.session(id).append(
|
|
27
|
+
...inputs.pause({ when: 'now' }),
|
|
28
|
+
...inputs.message({
|
|
29
|
+
id: 'queued',
|
|
30
|
+
role: 'user',
|
|
31
|
+
parts: [{ type: 'text', text: 'Original' }],
|
|
32
|
+
}),
|
|
33
|
+
)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
afterEach(async () => {
|
|
37
|
+
cleanup()
|
|
38
|
+
await served.close()
|
|
39
|
+
})
|
|
10
40
|
|
|
11
|
-
const
|
|
41
|
+
const page = async () =>
|
|
12
42
|
render(
|
|
13
|
-
<
|
|
14
|
-
items={[
|
|
15
|
-
{
|
|
16
|
-
id: 'queued',
|
|
17
|
-
message: {
|
|
18
|
-
id: 'queued',
|
|
19
|
-
role: 'user',
|
|
20
|
-
parts: [{ type: 'text', text: 'Original' }],
|
|
21
|
-
},
|
|
22
|
-
generate: true,
|
|
23
|
-
},
|
|
24
|
-
]}
|
|
25
|
-
paused
|
|
26
|
-
edit={edit}
|
|
27
|
-
move={vi.fn<() => void>()}
|
|
28
|
-
remove={vi.fn<() => Promise<void>>().mockResolvedValue(undefined)}
|
|
29
|
-
sendNow={vi.fn<() => Promise<void>>().mockResolvedValue(undefined)}
|
|
30
|
-
/>,
|
|
43
|
+
<AgentClient agentId={id} initial={await server.session(id).state()} />,
|
|
31
44
|
)
|
|
32
45
|
|
|
46
|
+
const isEdit = (operation: A2Operation): boolean =>
|
|
47
|
+
operation.type === 'push' &&
|
|
48
|
+
operation.sessionId === id &&
|
|
49
|
+
operation.events.some(
|
|
50
|
+
(event) =>
|
|
51
|
+
event.type === 'ai.control.requested' &&
|
|
52
|
+
(event.payload as { action?: unknown }).action === 'edit',
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
/** The texts each edit pushed to the server carried. */
|
|
56
|
+
const editsReceived = (): string[] =>
|
|
57
|
+
probe.operations.filter(isEdit).flatMap((operation) =>
|
|
58
|
+
operation.type === 'push'
|
|
59
|
+
? operation.events.map(
|
|
60
|
+
(event) =>
|
|
61
|
+
(
|
|
62
|
+
event.payload as {
|
|
63
|
+
message: { parts: { text: string }[] }
|
|
64
|
+
}
|
|
65
|
+
).message.parts[0]!.text,
|
|
66
|
+
)
|
|
67
|
+
: [],
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
const queuedText = async (): Promise<string | undefined> => {
|
|
71
|
+
const { state } = await server.session(id).state()
|
|
72
|
+
const part = state.inbox.items[0]?.message.parts[0]
|
|
73
|
+
return part?.type === 'text' ? part.text : undefined
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The queued message's text, as the queue shows it. */
|
|
77
|
+
const shownText = (): string | null | undefined =>
|
|
78
|
+
document.querySelector('.agent-queue-text')?.textContent
|
|
79
|
+
|
|
80
|
+
const editor = () =>
|
|
81
|
+
screen.queryByLabelText(
|
|
82
|
+
'Edit text for queued message 1',
|
|
83
|
+
) as HTMLTextAreaElement | null
|
|
84
|
+
const button = (name: string) =>
|
|
85
|
+
screen.getByRole('button', { name }) as HTMLButtonElement
|
|
86
|
+
|
|
33
87
|
const open = (): void => {
|
|
34
|
-
fireEvent.click(
|
|
88
|
+
fireEvent.click(button('Edit queued message 1'))
|
|
35
89
|
}
|
|
36
90
|
const save = (text: string): void => {
|
|
37
|
-
fireEvent.change(
|
|
38
|
-
fireEvent.click(
|
|
91
|
+
fireEvent.change(editor()!, { target: { value: text } })
|
|
92
|
+
fireEvent.click(button('Save'))
|
|
39
93
|
}
|
|
40
94
|
|
|
41
95
|
it('closes immediately and permits another edit while saves remain in flight', async () => {
|
|
42
|
-
|
|
43
|
-
const
|
|
44
|
-
const edit = vi
|
|
45
|
-
.fn<Edit>()
|
|
46
|
-
.mockImplementationOnce(() => first.promise)
|
|
47
|
-
.mockImplementationOnce(() => second.promise)
|
|
48
|
-
queue(edit)
|
|
96
|
+
await page()
|
|
97
|
+
const first = probe.hold(isEdit)
|
|
49
98
|
open()
|
|
50
99
|
save('First')
|
|
51
|
-
expect(
|
|
52
|
-
expect(
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
).disabled,
|
|
58
|
-
).toBe(false)
|
|
59
|
-
expect(
|
|
60
|
-
(
|
|
61
|
-
screen.getByRole('button', {
|
|
62
|
-
name: 'Send queued message 1 after current step',
|
|
63
|
-
}) as HTMLButtonElement
|
|
64
|
-
).disabled,
|
|
65
|
-
).toBe(false)
|
|
100
|
+
expect(editor()).toBeNull()
|
|
101
|
+
expect(button('Edit queued message 1').disabled).toBe(false)
|
|
102
|
+
expect(button('Send queued message 1 after current step').disabled).toBe(
|
|
103
|
+
false,
|
|
104
|
+
)
|
|
105
|
+
await first.arrived
|
|
66
106
|
open()
|
|
67
107
|
save('Second')
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
await act(async () => {
|
|
73
|
-
first.reject(new Error('Earlier save failed'))
|
|
74
|
-
await first.promise.catch(() => {})
|
|
75
|
-
})
|
|
76
|
-
expect(screen.queryByRole('textbox')).toBeNull()
|
|
108
|
+
first.deny()
|
|
109
|
+
await waitFor(async () => expect(await queuedText()).toBe('Second'))
|
|
110
|
+
expect(editsReceived()).toEqual(['First', 'Second'])
|
|
111
|
+
expect(editor()).toBeNull()
|
|
77
112
|
expect(screen.queryByRole('alert')).toBeNull()
|
|
78
|
-
|
|
79
|
-
second.resolve()
|
|
80
|
-
await second.promise
|
|
81
|
-
})
|
|
113
|
+
expect(shownText()).toBe('Second')
|
|
82
114
|
})
|
|
83
115
|
|
|
84
|
-
it('restores the latest submitted draft
|
|
85
|
-
|
|
86
|
-
|
|
116
|
+
it('restores the latest submitted draft when its save fails', async () => {
|
|
117
|
+
await page()
|
|
118
|
+
const pending = probe.hold(isEdit)
|
|
87
119
|
open()
|
|
88
120
|
save('Keep this draft')
|
|
89
|
-
expect(
|
|
90
|
-
await
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
'Keep this draft',
|
|
121
|
+
expect(editor()).toBeNull()
|
|
122
|
+
await pending.arrived
|
|
123
|
+
pending.deny()
|
|
124
|
+
await waitFor(() => expect(editor()?.value).toBe('Keep this draft'))
|
|
125
|
+
expect(screen.getByRole('alert').textContent).toBe(
|
|
126
|
+
'the operation is not authorized',
|
|
96
127
|
)
|
|
97
|
-
expect(
|
|
128
|
+
expect(await queuedText()).toBe('Original')
|
|
98
129
|
})
|
|
99
130
|
|
|
100
131
|
it.each(['open', 'cancel'] as const)(
|
|
101
132
|
'does not restore an old failed save after a newer draft is %s',
|
|
102
133
|
async (action) => {
|
|
103
|
-
|
|
104
|
-
|
|
134
|
+
await page()
|
|
135
|
+
const pending = probe.hold(isEdit)
|
|
105
136
|
open()
|
|
106
137
|
save('Old draft')
|
|
138
|
+
await pending.arrived
|
|
107
139
|
open()
|
|
108
|
-
fireEvent.change(
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
await act(
|
|
114
|
-
|
|
115
|
-
await pending.promise.catch(() => {})
|
|
116
|
-
})
|
|
117
|
-
expect(
|
|
118
|
-
(screen.queryByRole('textbox') as HTMLTextAreaElement | null)?.value ??
|
|
119
|
-
null,
|
|
120
|
-
).toBe(action === 'open' ? 'New draft' : null)
|
|
140
|
+
fireEvent.change(editor()!, { target: { value: 'New draft' } })
|
|
141
|
+
if (action === 'cancel') fireEvent.click(button('Cancel'))
|
|
142
|
+
pending.deny()
|
|
143
|
+
// The failed save leaves the queue; then its handler has had its turn.
|
|
144
|
+
await waitFor(() => expect(shownText()).toBe('Original'))
|
|
145
|
+
await act(() => new Promise((resolve) => setTimeout(resolve, 10)))
|
|
146
|
+
expect(editor()?.value ?? null).toBe(action === 'open' ? 'New draft' : null)
|
|
121
147
|
expect(screen.queryByRole('alert')).toBeNull()
|
|
148
|
+
expect(await queuedText()).toBe('Original')
|
|
122
149
|
},
|
|
123
150
|
)
|