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
@@ -1,58 +1,107 @@
1
1
  // @vitest-environment jsdom
2
- import { cleanup, fireEvent, render, screen } from '@testing-library/react'
3
- import { afterEach, expect, it, vi } from 'vitest'
2
+ import {
3
+ cleanup,
4
+ fireEvent,
5
+ render,
6
+ screen,
7
+ waitFor,
8
+ } from '@testing-library/react'
9
+ import { afterEach, beforeEach, expect, it } from 'vitest'
10
+ import { serveAgent, type ServedAgent } from '@/test/agent'
11
+ import { AgentClient } from './[agentId]/agent-client'
4
12
  import type { ModelChoice } from './model'
5
- import { ModelInput } from './model-input'
6
13
 
7
- afterEach(cleanup)
14
+ let served: ServedAgent
15
+ let server: ServedAgent['server']
16
+ let probe: ServedAgent['probe']
17
+ let id: string
8
18
 
9
- const renderInput = (value: ModelChoice) => {
10
- const onChange = vi.fn<(settings: ModelChoice) => void>()
11
- const view = render(
12
- <ModelInput value={value} onChange={onChange} disabled={false} />,
19
+ beforeEach(async () => {
20
+ served = await serveAgent()
21
+ ;({ server, probe } = served)
22
+ id = crypto.randomUUID()
23
+ })
24
+
25
+ afterEach(async () => {
26
+ cleanup()
27
+ await served.close()
28
+ })
29
+
30
+ /** The agent page, after an earlier choice. */
31
+ const page = async (choice: ModelChoice) => {
32
+ await server
33
+ .session(id)
34
+ .append({ type: 'model.choice.updated', payload: choice })
35
+ render(
36
+ <AgentClient agentId={id} initial={await server.session(id).state()} />,
37
+ )
38
+ await waitFor(() =>
39
+ expect(ofSession().map(({ type }) => type)).toContain('stream'),
13
40
  )
14
- return {
15
- onChange,
16
- model: screen.getByRole('textbox') as HTMLInputElement,
17
- update: (next: ModelChoice) =>
18
- view.rerender(
19
- <ModelInput value={next} onChange={onChange} disabled={false} />,
20
- ),
21
- }
22
41
  }
23
42
 
24
- it('commits a typed model on blur or Enter, not per keystroke', () => {
25
- const { onChange, model, update } = renderInput({ reasoning: 'high' })
26
- fireEvent.change(model, { target: { value: 'openai/gpt' } })
27
- fireEvent.change(model, { target: { value: ' provider/model ' } })
28
- expect(onChange).not.toHaveBeenCalled()
29
- expect(model.value).toBe(' provider/model ')
30
- fireEvent.blur(model)
31
- expect(onChange).toHaveBeenCalledExactlyOnceWith({
32
- reasoning: 'high',
33
- model: 'provider/model',
34
- })
43
+ /** The page's own client serves every test's session. */
44
+ const ofSession = () =>
45
+ probe.operations.filter((operation) => operation.sessionId === id)
46
+
47
+ /** The choices the page pushed, in order. */
48
+ const pushed = (): unknown[] =>
49
+ ofSession().flatMap((operation) =>
50
+ operation.type === 'push'
51
+ ? operation.events.map((event) => [event.type, event.payload])
52
+ : [],
53
+ )
54
+
55
+ const chosen = async (): Promise<ModelChoice> =>
56
+ (await server.session(id).state()).state.modelChoice
35
57
 
36
- update({ reasoning: 'high', model: 'provider/model' })
37
- expect(model.value).toBe('provider/model')
38
- fireEvent.change(model, { target: { value: '' } })
39
- fireEvent.keyDown(model, { key: 'Enter' })
40
- expect(onChange).toHaveBeenLastCalledWith({ reasoning: 'high' })
58
+ const model = () =>
59
+ screen.getByRole('textbox', { name: 'Model' }) as HTMLInputElement
60
+
61
+ it('commits a typed model on blur or Enter, not per keystroke', async () => {
62
+ await page({ reasoning: 'high' })
63
+ fireEvent.change(model(), { target: { value: 'openai/gpt' } })
64
+ fireEvent.change(model(), { target: { value: ' provider/model ' } })
65
+ expect(model().value).toBe(' provider/model ')
66
+ fireEvent.blur(model())
67
+ await waitFor(async () =>
68
+ expect(await chosen()).toEqual({
69
+ reasoning: 'high',
70
+ model: 'provider/model',
71
+ }),
72
+ )
73
+ expect(pushed()).toEqual([
74
+ ['model.choice.updated', { reasoning: 'high', model: 'provider/model' }],
75
+ ])
76
+ expect(model().value).toBe('provider/model')
77
+
78
+ fireEvent.change(model(), { target: { value: '' } })
79
+ fireEvent.keyDown(model(), { key: 'Enter' })
80
+ await waitFor(async () =>
81
+ expect(await chosen()).toEqual({ reasoning: 'high' }),
82
+ )
83
+ expect(pushed()).toHaveLength(2)
41
84
  })
42
85
 
43
- it('commits reasoning and speed immediately and omits defaults', () => {
44
- const { onChange } = renderInput({
45
- model: 'provider/model',
46
- reasoning: 'low',
47
- })
86
+ it('commits reasoning and speed immediately and omits defaults', async () => {
87
+ await page({ model: 'provider/model', reasoning: 'low' })
48
88
  fireEvent.change(screen.getByTitle('Thinking level'), {
49
89
  target: { value: 'provider-default' },
50
90
  })
51
- expect(onChange).toHaveBeenLastCalledWith({ model: 'provider/model' })
91
+ await waitFor(async () =>
92
+ expect(await chosen()).toEqual({ model: 'provider/model' }),
93
+ )
52
94
  fireEvent.click(screen.getByRole('button', { name: 'Prefer fast' }))
53
- expect(onChange).toHaveBeenLastCalledWith({
54
- model: 'provider/model',
55
- reasoning: 'low',
56
- speed: 'fast',
57
- })
95
+ await waitFor(async () =>
96
+ expect(await chosen()).toEqual({ model: 'provider/model', speed: 'fast' }),
97
+ )
98
+ expect(pushed()).toEqual([
99
+ ['model.choice.updated', { model: 'provider/model' }],
100
+ ['model.choice.updated', { model: 'provider/model', speed: 'fast' }],
101
+ ])
102
+ expect(
103
+ screen
104
+ .getByRole('button', { name: 'Prefer fast' })
105
+ .getAttribute('aria-pressed'),
106
+ ).toBe('true')
58
107
  })
@@ -1,5 +1,5 @@
1
1
  import { renderToStaticMarkup } from 'react-dom/server'
2
- import { describe, expect, it, vi } from 'vitest'
2
+ import { describe, expect, it } from 'vitest'
3
3
  import type { ChatAgentMessage } from '../model'
4
4
  import { AgentStreamDrawer } from './agent-stream-drawer'
5
5
  import type { AgentMessageMetadata, AgentStreamSelection } from './chat-view'
@@ -89,7 +89,7 @@ describe('AgentStreamDrawer', () => {
89
89
  selection={selection}
90
90
  summary="ambient · 35 tokens · 2 model steps"
91
91
  status="completed"
92
- onClose={vi.fn<() => void>()}
92
+ onClose={() => {}}
93
93
  />,
94
94
  )
95
95
 
@@ -1,68 +1,13 @@
1
- import type { UIMessageChunk } from 'ai'
2
- import { expect, it, vi } from 'vitest'
1
+ import { afterEach, expect, it, vi } from 'vitest'
2
+ import { testModel, toolCall } from '@/test/model'
3
+ import { chatAgentServer, chatServer } from './server'
3
4
 
4
- vi.mock('server-only', () => ({}))
5
- vi.mock('@/lib/store', async () => {
6
- const { memory } = await import('experimental-a2/store-memory')
7
- return { store: memory() }
8
- })
9
-
10
- // The chat's real agent, with a scripted model: two `post` calls, then done.
11
- const { prompts, step } = vi.hoisted(() => ({
12
- prompts: [] as string[],
13
- step: (chunks: UIMessageChunk[]) =>
14
- new ReadableStream<UIMessageChunk>({
15
- start(controller) {
16
- for (const chunk of chunks) controller.enqueue(chunk)
17
- controller.close()
18
- },
19
- }),
20
- }))
21
- vi.mock('experimental-a2/ai/server', async (importActual) => {
22
- const actual =
23
- await importActual<typeof import('experimental-a2/ai/server')>()
24
- return {
25
- ...actual,
26
- agent: (options: Parameters<typeof actual.agent>[0]) =>
27
- actual.agent({
28
- ...options,
29
- harness: {
30
- ...(options.harness?.type === 'ai-sdk' ? options.harness : {}),
31
- type: 'ai-sdk',
32
- generate: ({
33
- event,
34
- messages,
35
- }: {
36
- event: { payload: { reason: string } }
37
- messages: { role: string; id: string }[]
38
- }) => {
39
- prompts.push(
40
- messages.map((message) => `${message.role}:${message.id}`).join(),
41
- )
42
- return step(
43
- event.payload.reason === 'message'
44
- ? [
45
- { type: 'start' },
46
- ...['Hello', 'there'].map(
47
- (message, call): UIMessageChunk => ({
48
- type: 'tool-input-available',
49
- toolCallId: `call-${call}`,
50
- toolName: 'post',
51
- input: { message },
52
- }),
53
- ),
54
- { type: 'finish', finishReason: 'tool-calls' },
55
- ]
56
- : [{ type: 'start' }, { type: 'finish', finishReason: 'stop' }],
57
- )
58
- },
59
- },
60
- } as never),
61
- }
5
+ afterEach(() => {
6
+ vi.unstubAllGlobals()
62
7
  })
63
8
 
64
9
  it('forwards room messages to the agent, whose posts land in the room', async () => {
65
- const { chatAgentServer, chatServer } = await import('./server')
10
+ const model = testModel({ id: 'openai/gpt-5.4' })
66
11
  const room = chatServer.session('room')
67
12
  const agent = chatAgentServer.session('room')
68
13
  await room.append({
@@ -74,6 +19,20 @@ it('forwards room messages to the agent, whose posts land in the room', async ()
74
19
  payload: { author: 'bob', text: '@agent say hi' },
75
20
  })
76
21
 
22
+ // The mention's step posts twice; the step after the posts ends the turn.
23
+ const posting = await model.step(0)
24
+ posting.write(
25
+ ...['Hello', 'there'].flatMap((message, call) =>
26
+ toolCall({
27
+ id: `call-${call}`,
28
+ name: 'post',
29
+ chunks: [JSON.stringify({ message })],
30
+ }),
31
+ ),
32
+ )
33
+ posting.finish('tool-calls')
34
+ ;(await model.step(1)).finish('stop')
35
+
77
36
  // Without a scheduler, appends run in background drains.
78
37
  await vi.waitFor(
79
38
  async () => {
@@ -92,19 +51,33 @@ it('forwards room messages to the agent, whose posts land in the room', async ()
92
51
  expect(state.posts[0]!.index).toBeGreaterThan(state.messages[1]!.index)
93
52
  // The passive message reached the agent as context without a reply of its
94
53
  // own; only the mention generated, and it saw both.
95
- expect(prompts).toHaveLength(2)
54
+ expect(model.steps).toHaveLength(2)
96
55
  expect(
97
- prompts[0]!.split(',').filter((id) => id.startsWith('user:')),
98
- ).toHaveLength(2)
56
+ JSON.stringify(
57
+ model.steps[0]!.options.prompt.filter(
58
+ (message) => message.role === 'user',
59
+ ),
60
+ ),
61
+ ).toMatch(/alice: just chatting.*bob: @agent say hi/)
99
62
  const messages = (await agent.state()).state.messages
100
63
  expect(messages.map((message) => message.role)).toEqual([
101
64
  'user',
102
65
  'user',
103
66
  'assistant',
104
67
  ])
68
+ // One response, both of its steps recorded on it.
69
+ expect(messages[2]!.metadata).toMatchObject({
70
+ kind: 'agent',
71
+ author: 'agent',
72
+ mode: 'mention',
73
+ steps: [
74
+ { model: 'openai/gpt-5.4', finishReason: 'tool-calls' },
75
+ { model: 'openai/gpt-5.4', finishReason: 'stop' },
76
+ ],
77
+ })
105
78
 
106
79
  // Replaying the post tool's append converges on the stored event.
107
- const [post] = (await room.history()).filter(
80
+ const post = (await room.history()).find(
108
81
  (event) => event.type === 'agent.message.posted',
109
82
  )
110
83
  await room.append({ type: post!.type, id: post!.id, payload: post!.payload })
@@ -1,35 +1,77 @@
1
1
  // @vitest-environment jsdom
2
- import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
3
- import { afterEach, expect, it, vi } from 'vitest'
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 } from 'experimental-a2/client'
13
+ import type { ReactNode } from 'react'
14
+ import { afterEach, expect, it } from 'vitest'
15
+ import { serveAgent, type ServedAgent } from '@/test/agent'
16
+ import { demoAgent, type AgentState } from '../agent/model'
4
17
  import { ActivityFeed } from './activity-feed'
5
18
 
6
- afterEach(cleanup)
19
+ let served: ServedAgent | undefined
20
+
21
+ afterEach(async () => {
22
+ cleanup()
23
+ await served?.close()
24
+ })
7
25
 
8
26
  it('loads an earlier page only on request and keeps failures retryable', async () => {
9
- const loadHistory = vi
10
- .fn<() => Promise<unknown>>()
11
- .mockRejectedValueOnce(new Error('Read failed'))
12
- .mockResolvedValueOnce([])
13
- render(
14
- <ActivityFeed
15
- events={[]}
16
- index={100}
17
- history={{ loading: false, complete: false, oldestLoaded: null }}
18
- loadHistory={loadHistory}
19
- />,
20
- )
21
- expect(loadHistory).not.toHaveBeenCalled()
27
+ served = await serveAgent()
28
+ const { server, probe } = served
29
+ const client = createClient({ contract: demoAgent, api: served.url })
30
+ const id = crypto.randomUUID()
31
+ await server.session(id).append({
32
+ type: 'compaction.settings.updated',
33
+ payload: { mode: 'disabled' },
34
+ })
35
+ const operations = () => probe.operations.map(({ type }) => type)
36
+
37
+ function Feed({
38
+ initial,
39
+ }: {
40
+ initial: InitialSnapshot<AgentState>
41
+ }): ReactNode {
42
+ const session = useAgent({ client, id, initial })
43
+ return (
44
+ <ActivityFeed
45
+ events={session.events}
46
+ index={session.index}
47
+ history={session.history}
48
+ loadHistory={session.loadHistory}
49
+ />
50
+ )
51
+ }
52
+
53
+ probe.deny(({ type }) => type === 'history')
54
+ render(<Feed initial={await server.session(id).state()} />)
55
+ await waitFor(() => expect(operations()).toContain('stream'))
56
+ expect(operations()).not.toContain('history')
22
57
  await act(async () =>
23
58
  fireEvent.click(
24
59
  screen.getByRole('button', { name: 'Load earlier events' }),
25
60
  ),
26
61
  )
27
- expect(screen.getByRole('alert').textContent).toBe('Read failed')
62
+ expect((await screen.findByRole('alert')).textContent).toBe(
63
+ 'the operation is not authorized',
64
+ )
28
65
  await act(async () =>
29
66
  fireEvent.click(
30
67
  screen.getByRole('button', { name: 'Load earlier events' }),
31
68
  ),
32
69
  )
70
+ await waitFor(() =>
71
+ expect(screen.getByRole('list').textContent).toContain(
72
+ 'compaction.settings.updated',
73
+ ),
74
+ )
33
75
  expect(screen.queryByRole('alert')).toBeNull()
34
- expect(loadHistory).toHaveBeenCalledTimes(2)
76
+ expect(operations().filter((type) => type === 'history')).toHaveLength(2)
35
77
  })
@@ -9,8 +9,8 @@ import {
9
9
  import { afterEach, beforeEach, expect, it, vi } from 'vitest'
10
10
  import { FastLink } from './fast-link'
11
11
 
12
- const push = vi.fn<(href: string) => void>()
13
- vi.mock('next/navigation', () => ({ useRouter: () => ({ push }) }))
12
+ // The App Router's router only exists inside a Next.js app.
13
+ vi.mock('next/navigation', () => ({ useRouter: () => ({ push: () => {} }) }))
14
14
 
15
15
  // Whether each click reached the browser's default; then stop jsdom's
16
16
  // unimplemented navigation.
@@ -20,7 +20,6 @@ const record = (event: MouseEvent): void => {
20
20
  event.preventDefault()
21
21
  }
22
22
  beforeEach(() => {
23
- push.mockReset()
24
23
  navigatedByClick = []
25
24
  document.addEventListener('click', record)
26
25
  })
@@ -40,10 +39,9 @@ const press = (): void => {
40
39
  fireEvent(link(), event)
41
40
  }
42
41
 
43
- it('navigates on the press and swallows only that click', () => {
42
+ it('swallows only the click that follows a press', () => {
44
43
  render(<FastLink href="/a0">Apps</FastLink>)
45
44
  press()
46
- expect(push).toHaveBeenCalledWith('/a0')
47
45
  fireEvent.click(link(), { detail: 1 })
48
46
  fireEvent.click(link(), { detail: 1 })
49
47
  expect(navigatedByClick).toEqual([false, true])
@@ -1,54 +1,95 @@
1
1
  import type { ReactElement } from 'react'
2
- import { beforeEach, expect, it, vi } from 'vitest'
2
+ import { afterEach, expect, it, vi, type Mock } from 'vitest'
3
3
  import AgentPage from './agent/[agentId]/page'
4
+ import { agentServer } from './agent/server'
4
5
  import ChatPage from './chat/[chatId]/page'
6
+ import { chatAgentServer, chatServer } from './chat/server'
5
7
 
6
- const agentReads = vi.hoisted(() => ({
7
- initial: { state: { agent: true }, index: 7 },
8
- state: vi.fn<() => Promise<{ state: { agent: boolean }; index: number }>>(),
9
- history: vi.fn<() => never>(),
10
- }))
11
- const reads = vi.hoisted(() => ({
12
- initial: { state: { marker: true }, index: 42 },
13
- state: vi.fn<() => Promise<{ state: { marker: boolean }; index: number }>>(),
14
- history: vi.fn<() => never>(),
15
- }))
16
- vi.mock('./agent/server', () => ({ agentServer: { session: () => reads } }))
17
- vi.mock('./chat/server', () => ({
18
- chatServer: { session: () => reads },
19
- chatAgentServer: { session: () => agentReads },
20
- }))
21
- vi.mock('./agent/[agentId]/agent-client', () => ({ AgentClient: () => null }))
22
- vi.mock('./chat/[chatId]/chat-client', () => ({ ChatClient: () => null }))
23
-
24
- beforeEach(() => {
25
- reads.state.mockReset().mockResolvedValue(reads.initial)
26
- agentReads.state.mockReset().mockResolvedValue(agentReads.initial)
27
- reads.history.mockReset().mockImplementation(() => {
28
- throw new Error('unexpected history read')
8
+ afterEach(() => {
9
+ vi.restoreAllMocks()
10
+ })
11
+
12
+ type Read = (...args: never[]) => unknown
13
+ type Handle = { state: Read; history: Read }
14
+ type Reads = { state: Mock<Read>; history: Mock<Read> }
15
+
16
+ /** The reads made through each session handle `server` hands out from now on. */
17
+ const readsOf = <H extends Handle>(server: {
18
+ session(id: string): H
19
+ }): Reads[] => {
20
+ const session = server.session.bind(server)
21
+ const handles: Reads[] = []
22
+ vi.spyOn(server, 'session').mockImplementation((id) => {
23
+ const handle = session(id)
24
+ const reads: Reads = {
25
+ state: vi.fn<Read>(handle.state),
26
+ history: vi.fn<Read>(handle.history),
27
+ }
28
+ handles.push(reads)
29
+ return { ...handle, ...reads }
30
+ })
31
+ return handles
32
+ }
33
+
34
+ const oneStateRead = (handles: Reads[]): void => {
35
+ expect(handles).toHaveLength(1)
36
+ expect(handles[0]!.state).toHaveBeenCalledExactlyOnceWith()
37
+ expect(handles[0]!.history).not.toHaveBeenCalled()
38
+ }
39
+
40
+ it('seeds agent from one state read without fetching raw history', async () => {
41
+ const agentId = crypto.randomUUID()
42
+ const session = agentServer.session(agentId)
43
+ await session.append({
44
+ type: 'compaction.settings.updated',
45
+ payload: { mode: 'manual', thresholdTokens: 1234 },
29
46
  })
47
+ await agentServer.drain(agentId)
48
+ const expected = await session.state()
49
+ expect(expected.state.compactionSettings).toEqual({
50
+ mode: 'manual',
51
+ thresholdTokens: 1234,
52
+ })
53
+
54
+ const reads = readsOf(agentServer)
55
+ const element = (await AgentPage({
56
+ params: Promise.resolve({ agentId }),
57
+ })) as ReactElement<{ initial: unknown; initialEvents?: unknown }>
58
+ oneStateRead(reads)
59
+ expect(element.props.initial).toEqual(expected)
60
+ expect(element.props.initialEvents).toBeUndefined()
30
61
  })
31
62
 
32
- it.each(['agent', 'chat'] as const)(
33
- 'seeds %s from one state read without fetching raw history',
34
- async (page) => {
35
- const element = (await (page === 'agent'
36
- ? AgentPage({ params: Promise.resolve({ agentId: 'one' }) })
37
- : ChatPage({
38
- params: Promise.resolve({ chatId: 'one' }),
39
- }))) as ReactElement<{ initial: unknown; initialEvents?: unknown }>
40
- expect(reads.state).toHaveBeenCalledExactlyOnceWith()
41
- expect(reads.history).not.toHaveBeenCalled()
42
- expect(element.props.initial).toBe(reads.initial)
43
- expect(element.props.initialEvents).toBeUndefined()
44
- },
45
- )
46
-
47
- it("seeds the chat's agent session from its own state read", async () => {
63
+ it('seeds chat and its agent session, each from one state read', async () => {
64
+ const chatId = crypto.randomUUID()
65
+ await chatServer.session(chatId).append({
66
+ type: 'message.sent',
67
+ payload: { author: 'alice', text: 'just chatting' },
68
+ })
69
+ // The room forwards the message to its agent in a background drain.
70
+ await vi.waitFor(async () => {
71
+ expect(
72
+ (await chatAgentServer.session(chatId).state()).state.messages,
73
+ ).toHaveLength(1)
74
+ })
75
+ await chatServer.drain(chatId)
76
+ await chatAgentServer.drain(chatId)
77
+ const room = await chatServer.session(chatId).state()
78
+ const agent = await chatAgentServer.session(chatId).state()
79
+ expect(room.state.messages).toHaveLength(1)
80
+
81
+ const roomReads = readsOf(chatServer)
82
+ const agentReads = readsOf(chatAgentServer)
48
83
  const element = (await ChatPage({
49
- params: Promise.resolve({ chatId: 'one' }),
50
- })) as ReactElement<{ children: ReactElement<{ agentInitial: unknown }> }>
51
- expect(agentReads.state).toHaveBeenCalledExactlyOnceWith()
52
- expect(agentReads.history).not.toHaveBeenCalled()
53
- expect(element.props.children.props.agentInitial).toBe(agentReads.initial)
84
+ params: Promise.resolve({ chatId }),
85
+ })) as ReactElement<{
86
+ initial: unknown
87
+ initialEvents?: unknown
88
+ children: ReactElement<{ agentInitial: unknown }>
89
+ }>
90
+ oneStateRead(roomReads)
91
+ oneStateRead(agentReads)
92
+ expect(element.props.initial).toEqual(room)
93
+ expect(element.props.initialEvents).toBeUndefined()
94
+ expect(element.props.children.props.agentInitial).toEqual(agent)
54
95
  })
@@ -30,7 +30,7 @@
30
30
  "@vercel/sandbox": "^3.0.0",
31
31
  "ai": "^7.0.58",
32
32
  "codemirror": "^6.0.2",
33
- "experimental-a2": "0.19.0",
33
+ "experimental-a2": "0.20.0",
34
34
  "geist": "^1.7.2",
35
35
  "ioredis": "^5.9.0",
36
36
  "libfx": "0.0.12",
@@ -56,6 +56,7 @@
56
56
  "@types/react-dom": "^19.0.0",
57
57
  "@types/ws": "^8.18.1",
58
58
  "typescript": "^7.0.2",
59
+ "undici": "^8.10.2",
59
60
  "vitest": "^4.1.10"
60
61
  }
61
62
  }
@@ -0,0 +1,35 @@
1
+ import { agent } from 'experimental-a2/ai/server'
2
+ import { memory } from 'experimental-a2/store-memory'
3
+ import { demoAgent } from '@/app/agent/model'
4
+ import { authorizer, serve } from './serve'
5
+
6
+ /**
7
+ * The playground's agent on a memory store, served where the page's own
8
+ * client finds it: `/agent/events` on the origin relative URLs resolve
9
+ * against. Its tests never start a turn, so its model never runs.
10
+ */
11
+ export async function serveAgent() {
12
+ const server = agent({
13
+ contract: demoAgent,
14
+ store: memory(),
15
+ model: 'test-model',
16
+ })
17
+ const probe = authorizer()
18
+ const served = await serve({
19
+ routes: {
20
+ '/agent/events': (request) =>
21
+ server.fetch(request, { authorize: probe.authorize }),
22
+ },
23
+ })
24
+ return {
25
+ server,
26
+ probe,
27
+ url: `${served.origin}/agent/events`,
28
+ close: async (): Promise<void> => {
29
+ probe.release()
30
+ await served.close()
31
+ },
32
+ }
33
+ }
34
+
35
+ export type ServedAgent = Awaited<ReturnType<typeof serveAgent>>