experimental-a2 0.19.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/actor.js +1 -1
  3. package/dist/ai-fx.d.ts.map +1 -1
  4. package/dist/ai-fx.js +34 -4
  5. package/dist/ai-fx.js.map +1 -1
  6. package/dist/ai-harness-CV6rXbB3.d.ts.map +1 -1
  7. package/dist/{ai-harness-BuuC-gJ3.js → ai-harness-Clbg5ei7.js} +11 -2
  8. package/dist/ai-harness-Clbg5ei7.js.map +1 -0
  9. package/dist/ai-pi.js +2 -2
  10. package/dist/ai-server.d.ts.map +1 -1
  11. package/dist/ai-server.js +6 -4
  12. package/dist/ai-server.js.map +1 -1
  13. package/dist/internal-CscSY7K_.js.map +1 -1
  14. package/dist/scheduler-qstash.d.ts.map +1 -1
  15. package/dist/scheduler-qstash.js +5 -5
  16. package/dist/scheduler-qstash.js.map +1 -1
  17. package/dist/scheduler-vercel.d.ts.map +1 -1
  18. package/dist/scheduler-vercel.js +4 -3
  19. package/dist/scheduler-vercel.js.map +1 -1
  20. package/dist/server-DYAATFuT.d.ts.map +1 -1
  21. package/dist/{server-D-Y4pqwu.js → server-qyb5aJJG.js} +39 -25
  22. package/dist/server-qyb5aJJG.js.map +1 -0
  23. package/dist/server.js +1 -1
  24. package/dist/session-clear-DGI4jTQ5.js.map +1 -1
  25. package/dist/store-postgres.d.ts.map +1 -1
  26. package/dist/store-postgres.js +38 -136
  27. package/dist/store-postgres.js.map +1 -1
  28. package/docs/01-quickstart.mdx +7 -4
  29. package/docs/actors/01-introduction.mdx +1 -1
  30. package/docs/actors/meta.ts +1 -1
  31. package/docs/agents/02-tools.mdx +1 -1
  32. package/docs/agents/03-conversations.mdx +1 -1
  33. package/docs/agents/meta.ts +1 -1
  34. package/docs/client/meta.ts +1 -1
  35. package/docs/contract/03-sessions.mdx +1 -1
  36. package/docs/index.mdx +2 -2
  37. package/docs/reference/01-core.mdx +8 -8
  38. package/docs/reference/02-server.mdx +3 -3
  39. package/docs/reference/04-ai.mdx +9 -1
  40. package/docs/reference/05-telemetry.mdx +132 -0
  41. package/docs/reference/06-errors.mdx +1 -1
  42. package/docs/reference/meta.ts +1 -1
  43. package/docs/schedulers/01-overview.mdx +112 -0
  44. package/docs/schedulers/02-vercel-queues.mdx +87 -0
  45. package/docs/schedulers/03-qstash.mdx +123 -0
  46. package/docs/schedulers/meta.ts +1 -0
  47. package/docs/server/01-handlers.mdx +29 -2
  48. package/docs/server/02-timers.mdx +2 -2
  49. package/docs/server/06-guarantees.mdx +6 -3
  50. package/docs/stores/01-overview.mdx +37 -0
  51. package/docs/stores/02-redis.mdx +49 -0
  52. package/docs/stores/03-redis-http.mdx +56 -0
  53. package/docs/stores/04-postgres.mdx +121 -0
  54. package/docs/stores/05-sqlite.mdx +34 -0
  55. package/docs/stores/06-memory.mdx +22 -0
  56. package/docs/stores/meta.ts +1 -0
  57. package/examples/playground/DEPLOY.md +12 -7
  58. package/examples/playground/app/a0/apps/[appId]/route.ts +6 -10
  59. package/examples/playground/app/a0/chat.test.tsx +421 -330
  60. package/examples/playground/app/a0/shell.tsx +1 -1
  61. package/examples/playground/app/a0/uploads/route.test.ts +59 -0
  62. package/examples/playground/app/agent/[agentId]/agent-queue.test.tsx +116 -90
  63. package/examples/playground/app/agent/[agentId]/compaction-panel.test.tsx +107 -76
  64. package/examples/playground/app/agent/ingress.test.ts +41 -17
  65. package/examples/playground/app/agent/model-input.test.tsx +92 -43
  66. package/examples/playground/app/chat/[chatId]/agent-stream-drawer.test.tsx +2 -2
  67. package/examples/playground/app/chat/server.test.ts +38 -65
  68. package/examples/playground/app/components/activity-history.test.tsx +60 -18
  69. package/examples/playground/app/components/fast-link.test.tsx +3 -5
  70. package/examples/playground/app/initial-state.test.tsx +86 -45
  71. package/examples/playground/package.json +2 -1
  72. package/examples/playground/test/agent.ts +35 -0
  73. package/examples/playground/test/model.ts +149 -0
  74. package/examples/playground/test/serve.ts +184 -0
  75. package/examples/playground/test/server-only.ts +1 -0
  76. package/examples/playground/vitest.config.ts +27 -0
  77. package/package.json +3 -1
  78. package/sql/postgres/notifications.sql +24 -0
  79. package/sql/postgres/schema.sql +137 -0
  80. package/src/ai-agent.ts +5 -4
  81. package/src/ai-fx.ts +37 -3
  82. package/src/ai-harness.ts +18 -0
  83. package/src/errors.ts +1 -1
  84. package/src/internal.ts +2 -1
  85. package/src/postgres-notifications.ts +0 -22
  86. package/src/postgres-resources.ts +6 -9
  87. package/src/postgres-schema.ts +80 -0
  88. package/src/scheduler-qstash.ts +12 -5
  89. package/src/scheduler-vercel.ts +10 -4
  90. package/src/server.ts +66 -32
  91. package/src/store-postgres.ts +5 -123
  92. package/dist/ai-harness-BuuC-gJ3.js.map +0 -1
  93. package/dist/server-D-Y4pqwu.js.map +0 -1
  94. package/docs/reference/05-adapters.mdx +0 -351
  95. package/docs/server/05-production.mdx +0 -323
@@ -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
+ })
@@ -1,124 +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
- role: 'user',
18
- message: {
19
- id: 'queued',
20
- role: 'user',
21
- parts: [{ type: 'text', text: 'Original' }],
22
- },
23
- generate: true,
24
- },
25
- ]}
26
- paused
27
- edit={edit}
28
- move={vi.fn<() => void>()}
29
- remove={vi.fn<() => Promise<void>>().mockResolvedValue(undefined)}
30
- sendNow={vi.fn<() => Promise<void>>().mockResolvedValue(undefined)}
31
- />,
43
+ <AgentClient agentId={id} initial={await server.session(id).state()} />,
32
44
  )
33
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
+
34
87
  const open = (): void => {
35
- fireEvent.click(screen.getByRole('button', { name: 'Edit queued message 1' }))
88
+ fireEvent.click(button('Edit queued message 1'))
36
89
  }
37
90
  const save = (text: string): void => {
38
- fireEvent.change(screen.getByRole('textbox'), { target: { value: text } })
39
- fireEvent.click(screen.getByRole('button', { name: 'Save' }))
91
+ fireEvent.change(editor()!, { target: { value: text } })
92
+ fireEvent.click(button('Save'))
40
93
  }
41
94
 
42
95
  it('closes immediately and permits another edit while saves remain in flight', async () => {
43
- const first = Promise.withResolvers<void>()
44
- const second = Promise.withResolvers<void>()
45
- const edit = vi
46
- .fn<Edit>()
47
- .mockImplementationOnce(() => first.promise)
48
- .mockImplementationOnce(() => second.promise)
49
- queue(edit)
96
+ await page()
97
+ const first = probe.hold(isEdit)
50
98
  open()
51
99
  save('First')
52
- expect(screen.queryByRole('textbox')).toBeNull()
53
- expect(
54
- (
55
- screen.getByRole('button', {
56
- name: 'Edit queued message 1',
57
- }) as HTMLButtonElement
58
- ).disabled,
59
- ).toBe(false)
60
- expect(
61
- (
62
- screen.getByRole('button', {
63
- name: 'Send queued message 1 after current step',
64
- }) as HTMLButtonElement
65
- ).disabled,
66
- ).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
67
106
  open()
68
107
  save('Second')
69
- expect(edit.mock.calls.map(([message]) => message.parts)).toEqual([
70
- [{ type: 'text', text: 'First' }],
71
- [{ type: 'text', text: 'Second' }],
72
- ])
73
- await act(async () => {
74
- first.reject(new Error('Earlier save failed'))
75
- await first.promise.catch(() => {})
76
- })
77
- 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()
78
112
  expect(screen.queryByRole('alert')).toBeNull()
79
- await act(async () => {
80
- second.resolve()
81
- await second.promise
82
- })
113
+ expect(shownText()).toBe('Second')
83
114
  })
84
115
 
85
- it('restores the latest submitted draft on transport failure', async () => {
86
- const pending = Promise.withResolvers<void>()
87
- queue(() => pending.promise)
116
+ it('restores the latest submitted draft when its save fails', async () => {
117
+ await page()
118
+ const pending = probe.hold(isEdit)
88
119
  open()
89
120
  save('Keep this draft')
90
- expect(screen.queryByRole('textbox')).toBeNull()
91
- await act(async () => {
92
- pending.reject(new Error('Save failed'))
93
- await pending.promise.catch(() => {})
94
- })
95
- expect((screen.getByRole('textbox') as HTMLTextAreaElement).value).toBe(
96
- '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',
97
127
  )
98
- expect(screen.getByRole('alert').textContent).toBe('Save failed')
128
+ expect(await queuedText()).toBe('Original')
99
129
  })
100
130
 
101
131
  it.each(['open', 'cancel'] as const)(
102
132
  'does not restore an old failed save after a newer draft is %s',
103
133
  async (action) => {
104
- const pending = Promise.withResolvers<void>()
105
- queue(() => pending.promise)
134
+ await page()
135
+ const pending = probe.hold(isEdit)
106
136
  open()
107
137
  save('Old draft')
138
+ await pending.arrived
108
139
  open()
109
- fireEvent.change(screen.getByRole('textbox'), {
110
- target: { value: 'New draft' },
111
- })
112
- if (action === 'cancel')
113
- fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
114
- await act(async () => {
115
- pending.reject(new Error('Old save failed'))
116
- await pending.promise.catch(() => {})
117
- })
118
- expect(
119
- (screen.queryByRole('textbox') as HTMLTextAreaElement | null)?.value ??
120
- null,
121
- ).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)
122
147
  expect(screen.queryByRole('alert')).toBeNull()
148
+ expect(await queuedText()).toBe('Original')
123
149
  },
124
150
  )
@@ -1,30 +1,57 @@
1
1
  // @vitest-environment jsdom
2
- import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
3
- import { afterEach, beforeEach, expect, it, vi } from 'vitest'
4
- import { demoAgent } from '../model'
5
- import type { AgentSession } from '../session'
2
+ import {
3
+ act,
4
+ cleanup,
5
+ fireEvent,
6
+ render,
7
+ screen,
8
+ waitFor,
9
+ } from '@testing-library/react'
10
+ import type { InitialSnapshot } from 'experimental-a2'
11
+ import { useAgent } from 'experimental-a2/ai/react'
12
+ import { createClient, type A2Client } from 'experimental-a2/client'
13
+ import type { ReactNode } from 'react'
14
+ import { afterEach, beforeEach, expect, it } from 'vitest'
15
+ import { serveAgent, type ServedAgent } from '@/test/agent'
16
+ import { demoAgent, type AgentState } from '../model'
6
17
  import { CompactionPanel } from './compaction-panel'
7
18
 
8
- const loadHistory = vi.fn<AgentSession['loadHistory']>()
9
- const push = vi.fn<AgentSession['push']>()
10
- let session: AgentSession
11
-
12
- beforeEach(() => {
13
- loadHistory.mockReset().mockResolvedValue([])
14
- push.mockReset()
15
- session = {
16
- state: demoAgent.reducer.initialState,
17
- hydrated: true,
18
- events: [],
19
- index: 10,
20
- connection: { status: 'live', reconnects: 0 },
21
- history: { complete: false, loading: false, oldestLoaded: null },
22
- loadHistory,
23
- push,
24
- reconnect: vi.fn<() => void>(),
25
- }
19
+ let served: ServedAgent
20
+ let server: ServedAgent['server']
21
+ let probe: ServedAgent['probe']
22
+ let client: A2Client<typeof demoAgent.events, AgentState>
23
+ let id: string
24
+
25
+ beforeEach(async () => {
26
+ served = await serveAgent()
27
+ ;({ server, probe } = served)
28
+ client = createClient({ contract: demoAgent, api: served.url })
29
+ id = crypto.randomUUID()
30
+ })
31
+
32
+ afterEach(async () => {
33
+ cleanup()
34
+ await served.close()
26
35
  })
27
- afterEach(cleanup)
36
+
37
+ /** The panel as the agent page mounts it: on the session's live state. */
38
+ function Panel({
39
+ initial,
40
+ }: {
41
+ initial: InitialSnapshot<AgentState>
42
+ }): ReactNode {
43
+ return <CompactionPanel session={useAgent({ client, id, initial })} />
44
+ }
45
+
46
+ const page = async (): Promise<InitialSnapshot<AgentState>> =>
47
+ server.session(id).state()
48
+
49
+ const operations = (type: 'stream' | 'history' | 'push'): number =>
50
+ probe.operations.filter(
51
+ (operation) => operation.type === type && operation.sessionId === id,
52
+ ).length
53
+
54
+ const connected = () => waitFor(() => expect(operations('stream')).toBe(1))
28
55
 
29
56
  async function toggle(panel: HTMLDetailsElement, open: boolean) {
30
57
  await act(async () => {
@@ -33,87 +60,91 @@ async function toggle(panel: HTMLDetailsElement, open: boolean) {
33
60
  })
34
61
  }
35
62
 
63
+ const field = (label: string) =>
64
+ screen.getByLabelText(label) as HTMLInputElement | HTMLSelectElement
65
+
36
66
  it('uses seeded settings immediately and loads diagnostic history only on open', async () => {
37
- session.state = {
38
- ...session.state,
39
- compactionSettings: { mode: 'manual', thresholdTokens: 1234 },
40
- }
41
- const view = render(<CompactionPanel session={session} />)
42
- expect((screen.getByLabelText('Mode') as HTMLSelectElement).value).toBe(
43
- 'manual',
67
+ // More history than one page, so a second open would have more to load.
68
+ await server.session(id).append(
69
+ ...Array.from({ length: 60 }, (_, at) => ({
70
+ type: 'compaction.settings.updated' as const,
71
+ payload: { mode: 'manual' as const, thresholdTokens: 1175 + at },
72
+ })),
44
73
  )
45
- expect(
46
- (screen.getByLabelText('Threshold in tokens') as HTMLInputElement).value,
47
- ).toBe('1234')
48
- expect(push).not.toHaveBeenCalled()
49
- expect(loadHistory).not.toHaveBeenCalled()
74
+ const view = render(<Panel initial={await page()} />)
75
+ expect(field('Mode').value).toBe('manual')
76
+ expect(field('Threshold in tokens').value).toBe('1234')
77
+ await connected()
78
+ expect(operations('history')).toBe(0)
79
+
50
80
  const panel =
51
81
  view.container.querySelector<HTMLDetailsElement>('.agent-compaction')!
52
82
  await toggle(panel, true)
53
- expect(loadHistory).toHaveBeenCalledTimes(1)
83
+ await waitFor(() => expect(operations('history')).toBe(1))
54
84
  await toggle(panel, false)
55
85
  await toggle(panel, true)
56
- expect(loadHistory).toHaveBeenCalledTimes(1)
57
- session.state = {
58
- ...session.state,
59
- compactionSettings: { mode: 'disabled' },
60
- compactionSettingsId: 'new',
61
- }
62
- session.events = [
63
- {
64
- id: 'old',
65
- sessionId: 'one',
66
- index: 2,
67
- createdAt: new Date(),
68
- type: 'compaction.settings.updated',
69
- payload: { mode: 'automatic' },
70
- },
71
- ]
72
- view.rerender(<CompactionPanel session={session} />)
73
- expect((screen.getByLabelText('Mode') as HTMLSelectElement).value).toBe(
74
- 'disabled',
75
- )
76
- expect(push).not.toHaveBeenCalled()
86
+
87
+ await server.session(id).append({
88
+ type: 'compaction.settings.updated',
89
+ payload: { mode: 'disabled' },
90
+ })
91
+ await waitFor(() => expect(field('Mode').value).toBe('disabled'))
92
+ expect(operations('history')).toBe(1)
93
+ expect(operations('push')).toBe(0)
77
94
  })
78
95
 
79
96
  it('keeps diagnostic read failures local and retries only when requested', async () => {
80
- loadHistory.mockRejectedValueOnce(new Error('History failed'))
97
+ await server.session(id).append({
98
+ type: 'compaction.settings.updated',
99
+ payload: { mode: 'automatic' },
100
+ })
101
+ probe.deny(({ type }) => type === 'history')
102
+ const initial = await page()
81
103
  const view = render(
82
104
  <>
83
105
  <input aria-label="composer" />
84
- <CompactionPanel session={session} />
106
+ <Panel initial={initial} />
85
107
  </>,
86
108
  )
87
109
  await toggle(view.container.querySelector('details')!, true)
88
- expect(screen.getByRole('alert').textContent).toBe('History failed')
89
- expect(screen.getByLabelText('composer')).toBeDefined()
90
- expect((screen.getByLabelText('Mode') as HTMLSelectElement).value).toBe(
91
- 'automatic',
110
+ expect((await screen.findByRole('alert')).textContent).toBe(
111
+ 'the operation is not authorized',
92
112
  )
113
+ expect(screen.getByLabelText('composer')).toBeDefined()
114
+ expect(field('Mode').value).toBe('automatic')
115
+
93
116
  await act(async () =>
94
117
  fireEvent.click(
95
118
  screen.getByRole('button', { name: 'Load earlier events' }),
96
119
  ),
97
120
  )
98
- expect(loadHistory).toHaveBeenCalledTimes(2)
99
- expect(screen.queryByRole('alert')).toBeNull()
100
- expect(push).not.toHaveBeenCalled()
121
+ await waitFor(() => expect(screen.queryByRole('alert')).toBeNull())
122
+ expect(operations('history')).toBe(2)
123
+ expect(operations('push')).toBe(0)
101
124
  })
102
125
 
103
126
  it('pushes the settings event and reports a failed save outside the form', async () => {
104
- push.mockImplementationOnce(() =>
105
- Object.assign(Promise.reject(new Error('Save failed')), {
106
- confirmed: Promise.resolve([]),
107
- }),
108
- )
109
- const view = render(<CompactionPanel session={session} />)
127
+ const view = render(<Panel initial={await page()} />)
110
128
  await toggle(view.container.querySelector('details')!, true)
111
129
  await act(async () =>
112
130
  fireEvent.click(screen.getByRole('button', { name: 'Set to 1 token' })),
113
131
  )
114
- expect(push).toHaveBeenCalledWith({
115
- type: 'compaction.settings.updated',
116
- payload: { mode: 'manual', thresholdTokens: 1 },
132
+ await waitFor(async () =>
133
+ expect((await server.session(id).state()).state.compactionSettings).toEqual(
134
+ { mode: 'manual', thresholdTokens: 1 },
135
+ ),
136
+ )
137
+ expect(screen.queryByRole('alert')).toBeNull()
138
+
139
+ probe.deny(({ type }) => type === 'push')
140
+ await act(async () =>
141
+ fireEvent.click(screen.getByRole('button', { name: 'Reset to automatic' })),
142
+ )
143
+ expect((await screen.findByRole('alert')).textContent).toBe(
144
+ 'the operation is not authorized',
145
+ )
146
+ expect((await server.session(id).state()).state.compactionSettings).toEqual({
147
+ mode: 'manual',
148
+ thresholdTokens: 1,
117
149
  })
118
- expect(screen.getByRole('alert').textContent).toBe('Save failed')
119
150
  })
@@ -1,8 +1,10 @@
1
- import { expect, it, vi } from 'vitest'
1
+ import { expect, it } from 'vitest'
2
2
  import { agent } from 'experimental-a2/ai/server'
3
3
  import { memory } from 'experimental-a2/store-memory'
4
4
  import { inputs } from 'experimental-a2/ai'
5
+ import { GET, POST } from './events/route'
5
6
  import { demoAgent } from './model'
7
+ import { agentServer as playgroundAgent } from './server'
6
8
 
7
9
  const message = { id: 'message', role: 'user' as const, parts: [] }
8
10
 
@@ -81,21 +83,43 @@ it('keeps lifecycle events server-only beside public controls', async () => {
81
83
  expect(accepted.status).toBe(200)
82
84
  })
83
85
 
84
- it('forwards both HTTP and WebSocket entry requests to the agent', async () => {
85
- const fetch = vi
86
- .fn<(request: Request) => Promise<Response>>()
87
- .mockResolvedValue(new Response(null))
88
- vi.doMock('./server', () => ({ agentServer: { fetch } }))
89
- try {
90
- const route = await import('./events/route')
91
- const read = new Request('http://test/events', {
86
+ it("serves the playground's agent over its events route", async () => {
87
+ const sessionId = crypto.randomUUID()
88
+ const pushed = await POST(
89
+ new Request('http://test/agent/events', {
90
+ method: 'POST',
91
+ headers: { 'content-type': 'application/json' },
92
+ body: JSON.stringify({
93
+ sessionId,
94
+ events: [
95
+ {
96
+ type: 'compaction.settings.updated',
97
+ payload: { mode: 'manual', thresholdTokens: 2 },
98
+ },
99
+ ],
100
+ }),
101
+ }),
102
+ )
103
+ expect(pushed.status).toBe(200)
104
+ await playgroundAgent.drain(sessionId)
105
+ const { state } = await playgroundAgent.session(sessionId).state()
106
+ expect(state.compactionSettings).toEqual({
107
+ mode: 'manual',
108
+ thresholdTokens: 2,
109
+ })
110
+
111
+ const stream = await GET(
112
+ new Request(`http://test/agent/events?sessionId=${sessionId}`),
113
+ )
114
+ expect(stream.status).toBe(200)
115
+ expect(stream.headers.get('content-type')).toContain('text/event-stream')
116
+ await stream.body?.cancel()
117
+
118
+ // The agent's browser client streams over HTTP; the route offers no upgrade.
119
+ const upgrade = await GET(
120
+ new Request(`http://test/agent/events?sessionId=${sessionId}`, {
92
121
  headers: { upgrade: 'websocket' },
93
- })
94
- const write = new Request('http://test/events', { method: 'POST' })
95
- await route.GET(read)
96
- await route.POST(write)
97
- expect(fetch.mock.calls).toEqual([[read], [write]])
98
- } finally {
99
- vi.doUnmock('./server')
100
- }
122
+ }),
123
+ )
124
+ expect(upgrade.status).toBe(426)
101
125
  })