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.
Files changed (143) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/dist/actor.js +1 -1
  3. package/dist/{ai-BMLlI2Ff.d.ts → ai-Cz3hqVhk.d.ts} +33 -16
  4. package/dist/ai-Cz3hqVhk.d.ts.map +1 -0
  5. package/dist/ai-client.d.ts +1 -1
  6. package/dist/ai-client.js +1 -1
  7. package/dist/ai-control-CvLbW10B.js +46 -0
  8. package/dist/ai-control-CvLbW10B.js.map +1 -0
  9. package/dist/ai-fx.d.ts +1 -1
  10. package/dist/ai-fx.d.ts.map +1 -1
  11. package/dist/ai-fx.js +63 -13
  12. package/dist/ai-fx.js.map +1 -1
  13. package/dist/{ai-harness-B2suP2Qh.d.ts → ai-harness-CV6rXbB3.d.ts} +2 -2
  14. package/dist/ai-harness-CV6rXbB3.d.ts.map +1 -0
  15. package/dist/{ai-harness-BuLh_EYi.js → ai-harness-Clbg5ei7.js} +88 -15
  16. package/dist/ai-harness-Clbg5ei7.js.map +1 -0
  17. package/dist/{ai-D6mGlO8j.js → ai-mMiWul1T.js} +116 -77
  18. package/dist/ai-mMiWul1T.js.map +1 -0
  19. package/dist/ai-pi.d.ts +1 -1
  20. package/dist/ai-pi.d.ts.map +1 -1
  21. package/dist/ai-pi.js +56 -11
  22. package/dist/ai-pi.js.map +1 -1
  23. package/dist/ai-react.d.ts +1 -1
  24. package/dist/ai-react.js +1 -1
  25. package/dist/ai-server.d.ts +2 -2
  26. package/dist/ai-server.d.ts.map +1 -1
  27. package/dist/ai-server.js +123 -53
  28. package/dist/ai-server.js.map +1 -1
  29. package/dist/ai.d.ts +2 -2
  30. package/dist/ai.js +1 -1
  31. package/dist/internal-CscSY7K_.js.map +1 -1
  32. package/dist/scheduler-qstash.d.ts.map +1 -1
  33. package/dist/scheduler-qstash.js +5 -5
  34. package/dist/scheduler-qstash.js.map +1 -1
  35. package/dist/scheduler-vercel.d.ts.map +1 -1
  36. package/dist/scheduler-vercel.js +5 -7
  37. package/dist/scheduler-vercel.js.map +1 -1
  38. package/dist/server-DYAATFuT.d.ts.map +1 -1
  39. package/dist/{server-D-Y4pqwu.js → server-qyb5aJJG.js} +39 -25
  40. package/dist/server-qyb5aJJG.js.map +1 -0
  41. package/dist/server.js +1 -1
  42. package/dist/session-clear-DGI4jTQ5.js.map +1 -1
  43. package/dist/store-postgres.d.ts.map +1 -1
  44. package/dist/store-postgres.js +38 -136
  45. package/dist/store-postgres.js.map +1 -1
  46. package/docs/01-quickstart.mdx +7 -4
  47. package/docs/actors/01-introduction.mdx +1 -1
  48. package/docs/actors/meta.ts +1 -1
  49. package/docs/agents/02-tools.mdx +1 -1
  50. package/docs/agents/03-conversations.mdx +26 -14
  51. package/docs/agents/04-harnesses.mdx +51 -5
  52. package/docs/agents/meta.ts +1 -1
  53. package/docs/client/meta.ts +1 -1
  54. package/docs/contract/03-sessions.mdx +2 -2
  55. package/docs/index.mdx +2 -2
  56. package/docs/reference/01-core.mdx +8 -8
  57. package/docs/reference/02-server.mdx +3 -3
  58. package/docs/reference/04-ai.mdx +36 -19
  59. package/docs/reference/05-telemetry.mdx +132 -0
  60. package/docs/reference/06-errors.mdx +1 -1
  61. package/docs/reference/meta.ts +1 -1
  62. package/docs/schedulers/01-overview.mdx +112 -0
  63. package/docs/schedulers/02-vercel-queues.mdx +87 -0
  64. package/docs/schedulers/03-qstash.mdx +123 -0
  65. package/docs/schedulers/meta.ts +1 -0
  66. package/docs/server/01-handlers.mdx +29 -2
  67. package/docs/server/02-timers.mdx +3 -3
  68. package/docs/server/06-guarantees.mdx +6 -3
  69. package/docs/stores/01-overview.mdx +37 -0
  70. package/docs/stores/02-redis.mdx +49 -0
  71. package/docs/stores/03-redis-http.mdx +56 -0
  72. package/docs/stores/04-postgres.mdx +121 -0
  73. package/docs/stores/05-sqlite.mdx +34 -0
  74. package/docs/stores/06-memory.mdx +22 -0
  75. package/docs/stores/meta.ts +1 -0
  76. package/examples/playground/DEPLOY.md +19 -5
  77. package/examples/playground/app/a0/a0.css +244 -5
  78. package/examples/playground/app/a0/app-view.tsx +2 -1
  79. package/examples/playground/app/a0/apps/[appId]/route.ts +6 -10
  80. package/examples/playground/app/a0/attachments.ts +133 -0
  81. package/examples/playground/app/a0/chat.test.tsx +595 -0
  82. package/examples/playground/app/a0/chat.tsx +623 -63
  83. package/examples/playground/app/a0/context-menu.tsx +38 -11
  84. package/examples/playground/app/a0/model.test.ts +28 -0
  85. package/examples/playground/app/a0/model.ts +30 -0
  86. package/examples/playground/app/a0/server.ts +5 -0
  87. package/examples/playground/app/a0/shell.tsx +1 -1
  88. package/examples/playground/app/a0/uploads/route.test.ts +59 -0
  89. package/examples/playground/app/a0/uploads/route.ts +29 -0
  90. package/examples/playground/app/agent/[agentId]/agent-client.tsx +2 -1
  91. package/examples/playground/app/agent/[agentId]/agent-queue.test.tsx +116 -89
  92. package/examples/playground/app/agent/[agentId]/compaction-panel.test.tsx +107 -76
  93. package/examples/playground/app/agent/ingress.test.ts +41 -17
  94. package/examples/playground/app/agent/model-input.test.tsx +92 -43
  95. package/examples/playground/app/agent/model.test.ts +12 -3
  96. package/examples/playground/app/chat/[chatId]/agent-state.test.ts +1 -5
  97. package/examples/playground/app/chat/[chatId]/agent-stream-drawer.test.tsx +2 -2
  98. package/examples/playground/app/chat/server.test.ts +38 -65
  99. package/examples/playground/app/components/activity-history.test.tsx +60 -18
  100. package/examples/playground/app/components/fast-link.test.tsx +3 -5
  101. package/examples/playground/app/initial-state.test.tsx +86 -45
  102. package/examples/playground/next.config.ts +7 -4
  103. package/examples/playground/package.json +4 -1
  104. package/examples/playground/test/agent.ts +35 -0
  105. package/examples/playground/test/model.ts +149 -0
  106. package/examples/playground/test/serve.ts +184 -0
  107. package/examples/playground/test/server-only.ts +1 -0
  108. package/examples/playground/vitest.config.ts +27 -0
  109. package/package.json +3 -1
  110. package/sql/postgres/notifications.sql +24 -0
  111. package/sql/postgres/schema.sql +137 -0
  112. package/src/ai-agent.ts +9 -4
  113. package/src/ai-client-state.ts +21 -9
  114. package/src/ai-context-schema.ts +28 -4
  115. package/src/ai-context.ts +83 -3
  116. package/src/ai-control-server.ts +70 -43
  117. package/src/ai-control-state.ts +3 -4
  118. package/src/ai-control.ts +59 -10
  119. package/src/ai-coordinator.ts +7 -6
  120. package/src/ai-fx.ts +95 -23
  121. package/src/ai-harness.ts +171 -29
  122. package/src/ai-message-shape.ts +41 -0
  123. package/src/ai-pi.ts +88 -32
  124. package/src/ai-stored-state.ts +1 -3
  125. package/src/ai.ts +116 -77
  126. package/src/errors.ts +1 -1
  127. package/src/internal.ts +2 -1
  128. package/src/libfx.d.ts +8 -1
  129. package/src/postgres-notifications.ts +0 -22
  130. package/src/postgres-resources.ts +6 -9
  131. package/src/postgres-schema.ts +80 -0
  132. package/src/scheduler-qstash.ts +12 -5
  133. package/src/scheduler-vercel.ts +11 -16
  134. package/src/server.ts +66 -32
  135. package/src/store-postgres.ts +5 -123
  136. package/dist/ai-BMLlI2Ff.d.ts.map +0 -1
  137. package/dist/ai-D6mGlO8j.js.map +0 -1
  138. package/dist/ai-harness-B2suP2Qh.d.ts.map +0 -1
  139. package/dist/ai-harness-BuLh_EYi.js.map +0 -1
  140. package/dist/server-D-Y4pqwu.js.map +0 -1
  141. package/docs/reference/05-adapters.mdx +0 -351
  142. package/docs/server/05-production.mdx +0 -323
  143. 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. It takes focus, moves with
22
- * the arrow keys, and closes on Escape, a press outside, or a pick; Escape
23
- * and a pick hand focus back through `onClose`.
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(at.x, innerWidth - width - MARGIN)),
44
- y: Math.max(MARGIN, Math.min(at.y, innerHeight - height - MARGIN)),
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>('[role="menuitem"]')?.focus()
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
- if (!menu.current?.contains(event.target as Node)) dismiss(false)
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>('[role="menuitem"]'),
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="menuitem"
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 { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
3
- import { afterEach, expect, it, vi } from 'vitest'
4
- import type { UIMessage } from 'ai'
5
- import { AgentQueue } from './agent-queue'
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
- afterEach(cleanup)
16
+ let served: ServedAgent
17
+ let server: ServedAgent['server']
18
+ let probe: ServedAgent['probe']
19
+ let id: string
8
20
 
9
- type Edit = (message: UIMessage) => Promise<void>
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 queue = (edit: Edit) =>
41
+ const page = async () =>
12
42
  render(
13
- <AgentQueue
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(screen.getByRole('button', { name: 'Edit queued message 1' }))
88
+ fireEvent.click(button('Edit queued message 1'))
35
89
  }
36
90
  const save = (text: string): void => {
37
- fireEvent.change(screen.getByRole('textbox'), { target: { value: text } })
38
- fireEvent.click(screen.getByRole('button', { name: 'Save' }))
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
- const first = Promise.withResolvers<void>()
43
- const second = Promise.withResolvers<void>()
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(screen.queryByRole('textbox')).toBeNull()
52
- expect(
53
- (
54
- screen.getByRole('button', {
55
- name: 'Edit queued message 1',
56
- }) as HTMLButtonElement
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
- expect(edit.mock.calls.map(([message]) => message.parts)).toEqual([
69
- [{ type: 'text', text: 'First' }],
70
- [{ type: 'text', text: 'Second' }],
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
- await act(async () => {
79
- second.resolve()
80
- await second.promise
81
- })
113
+ expect(shownText()).toBe('Second')
82
114
  })
83
115
 
84
- it('restores the latest submitted draft on transport failure', async () => {
85
- const pending = Promise.withResolvers<void>()
86
- queue(() => pending.promise)
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(screen.queryByRole('textbox')).toBeNull()
90
- await act(async () => {
91
- pending.reject(new Error('Save failed'))
92
- await pending.promise.catch(() => {})
93
- })
94
- expect((screen.getByRole('textbox') as HTMLTextAreaElement).value).toBe(
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(screen.getByRole('alert').textContent).toBe('Save failed')
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
- const pending = Promise.withResolvers<void>()
104
- queue(() => pending.promise)
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(screen.getByRole('textbox'), {
109
- target: { value: 'New draft' },
110
- })
111
- if (action === 'cancel')
112
- fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
113
- await act(async () => {
114
- pending.reject(new Error('Old save failed'))
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
  )