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,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
  })
@@ -6,13 +6,16 @@ import type { NextConfig } from 'next'
6
6
 
7
7
  const nextConfig: NextConfig = {
8
8
  // libfx loads a native binary for the platform, falling back to its
9
- // WebAssembly, by paths the bundler cannot follow; Node loads both from
10
- // node_modules at runtime.
11
- serverExternalPackages: ['libfx'],
9
+ // WebAssembly, and photon, which Pi resizes images with, loads its
10
+ // WebAssembly, by paths the bundler cannot follow; Node loads them from
11
+ // node_modules at runtime. Pi stays bundled (externalized, it makes
12
+ // Turbopack panic on its WebAssembly), so it finds photon through this
13
+ // app: package.json depends on photon at the version Pi pins, and the two
14
+ // move together.
15
+ serverExternalPackages: ['libfx', '@silvia-odwyer/photon-node'],
12
16
  turbopack: {
13
17
  // WebAssembly Pi's code reaches that the bundler cannot trace.
14
18
  resolveAlias: {
15
- '@silvia-odwyer/photon-node': './app/a0/no-photon.cjs',
16
19
  'quickjs-wasi': './app/a0/no-quickjs.cjs',
17
20
  'quickjs-wasi/quickjs.wasm': './app/a0/no-quickjs.cjs',
18
21
  },
@@ -22,13 +22,15 @@
22
22
  "@earendil-works/pi-ai": "1.0.3",
23
23
  "@earendil-works/pi-coding-agent": "1.0.2",
24
24
  "@opentelemetry/api": "^1.9.0",
25
+ "@silvia-odwyer/photon-node": "0.3.4",
26
+ "@vercel/blob": "^2.8.0",
25
27
  "@vercel/functions": "^3.9.3",
26
28
  "@vercel/otel": "^2.1.0",
27
29
  "@vercel/queue": "^0.2.0",
28
30
  "@vercel/sandbox": "^3.0.0",
29
31
  "ai": "^7.0.58",
30
32
  "codemirror": "^6.0.2",
31
- "experimental-a2": "0.18.1",
33
+ "experimental-a2": "0.20.0",
32
34
  "geist": "^1.7.2",
33
35
  "ioredis": "^5.9.0",
34
36
  "libfx": "0.0.12",
@@ -54,6 +56,7 @@
54
56
  "@types/react-dom": "^19.0.0",
55
57
  "@types/ws": "^8.18.1",
56
58
  "typescript": "^7.0.2",
59
+ "undici": "^8.10.2",
57
60
  "vitest": "^4.1.10"
58
61
  }
59
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>>
@@ -0,0 +1,149 @@
1
+ /**
2
+ * The model behind an agent under test. Agents take AI Gateway model IDs;
3
+ * the AI SDK's global provider serves every one of them from this model,
4
+ * whose steps the test writes part by part.
5
+ */
6
+
7
+ import { MockLanguageModelV4 } from 'ai/test'
8
+ import { vi } from 'vitest'
9
+
10
+ export type StreamPart =
11
+ Awaited<
12
+ ReturnType<MockLanguageModelV4['doStream']>
13
+ >['stream'] extends ReadableStream<infer Part>
14
+ ? Part
15
+ : never
16
+
17
+ export type CallOptions = MockLanguageModelV4['doStreamCalls'][number]
18
+
19
+ /** One model step, open until the test ends it or the agent aborts it. */
20
+ export type ModelStep = {
21
+ /** What the agent sent the model for this step. */
22
+ options: CallOptions
23
+ write(...parts: StreamPart[]): void
24
+ /** Ends the step the way a model does: with a finish part. */
25
+ finish(reason?: 'stop' | 'tool-calls'): void
26
+ }
27
+
28
+ const usage = {
29
+ inputTokens: { total: 1, noCache: 1, cacheRead: 0, cacheWrite: 0 },
30
+ outputTokens: { total: 1, text: 1, reasoning: 0 },
31
+ }
32
+
33
+ /** A text part, whole. */
34
+ export const say = ({ id, text }: { id: string; text: string }) =>
35
+ [
36
+ { type: 'text-start', id },
37
+ { type: 'text-delta', id, delta: text },
38
+ { type: 'text-end', id },
39
+ ] satisfies StreamPart[]
40
+
41
+ /** A tool call whose input streams in as the given chunks of its JSON. */
42
+ export const toolCall = ({
43
+ id,
44
+ name,
45
+ chunks,
46
+ }: {
47
+ id: string
48
+ name: string
49
+ chunks: string[]
50
+ }) =>
51
+ [
52
+ { type: 'tool-input-start', id, toolName: name },
53
+ ...chunks.map((delta) => ({
54
+ type: 'tool-input-delta' as const,
55
+ id,
56
+ delta,
57
+ })),
58
+ { type: 'tool-input-end', id },
59
+ {
60
+ type: 'tool-call',
61
+ toolCallId: id,
62
+ toolName: name,
63
+ input: chunks.join(''),
64
+ },
65
+ ] satisfies StreamPart[]
66
+
67
+ let models = 0
68
+
69
+ /**
70
+ * Installs a model the test drives step by step, served for `id` alone, so
71
+ * an agent left over from an earlier test can't take its steps. Remove it
72
+ * with `vi.unstubAllGlobals()`.
73
+ */
74
+ export function testModel({
75
+ id = `test-model-${++models}`,
76
+ }: {
77
+ /** The model ID the agent asks for. */
78
+ id?: string
79
+ } = {}): {
80
+ /** The agent's `model`. */
81
+ id: string
82
+ /** Every step the agent asked for, in order. */
83
+ steps: ModelStep[]
84
+ /** The `n`th step (from 0), once the agent asks for it. */
85
+ step(n: number): Promise<ModelStep>
86
+ /** Ends every step still open, and each one asked for from now on. */
87
+ close(): void
88
+ } {
89
+ const steps: ModelStep[] = []
90
+ const arrivals: PromiseWithResolvers<ModelStep>[] = []
91
+ const arrival = (n: number): PromiseWithResolvers<ModelStep> =>
92
+ (arrivals[n] ??= Promise.withResolvers<ModelStep>())
93
+ const ends: (() => void)[] = []
94
+ let closed = false
95
+ const model = new MockLanguageModelV4({
96
+ modelId: id,
97
+ doStream: async (options) => {
98
+ let controller!: ReadableStreamDefaultController<StreamPart>
99
+ let open = true
100
+ const end = (): void => {
101
+ if (!open) return
102
+ open = false
103
+ controller.close()
104
+ }
105
+ const stream = new ReadableStream<StreamPart>({
106
+ start(value) {
107
+ controller = value
108
+ controller.enqueue({ type: 'stream-start', warnings: [] })
109
+ },
110
+ })
111
+ options.abortSignal?.addEventListener('abort', end, { once: true })
112
+ const step: ModelStep = {
113
+ options,
114
+ write(...parts) {
115
+ for (const part of parts) if (open) controller.enqueue(part)
116
+ },
117
+ finish(reason = 'stop') {
118
+ step.write({
119
+ type: 'finish',
120
+ finishReason: { unified: reason, raw: reason },
121
+ usage,
122
+ })
123
+ end()
124
+ },
125
+ }
126
+ ends.push(end)
127
+ arrival(steps.length).resolve(step)
128
+ steps.push(step)
129
+ if (closed) step.finish()
130
+ return { stream }
131
+ },
132
+ })
133
+ vi.stubGlobal('AI_SDK_DEFAULT_PROVIDER', {
134
+ specificationVersion: 'v4',
135
+ languageModel: (requested: string) => {
136
+ if (requested !== id) throw new Error(`no test model '${requested}'`)
137
+ return model
138
+ },
139
+ })
140
+ return {
141
+ id,
142
+ steps,
143
+ step: (n) => arrival(n).promise,
144
+ close: () => {
145
+ closed = true
146
+ for (const end of ends) end()
147
+ },
148
+ }
149
+ }