experimental-a2 0.18.1 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +44 -0
- package/dist/actor.js +1 -1
- package/dist/{ai-BMLlI2Ff.d.ts → ai-Cz3hqVhk.d.ts} +33 -16
- package/dist/ai-Cz3hqVhk.d.ts.map +1 -0
- package/dist/ai-client.d.ts +1 -1
- package/dist/ai-client.js +1 -1
- package/dist/ai-control-CvLbW10B.js +46 -0
- package/dist/ai-control-CvLbW10B.js.map +1 -0
- package/dist/ai-fx.d.ts +1 -1
- package/dist/ai-fx.d.ts.map +1 -1
- package/dist/ai-fx.js +63 -13
- package/dist/ai-fx.js.map +1 -1
- package/dist/{ai-harness-B2suP2Qh.d.ts → ai-harness-CV6rXbB3.d.ts} +2 -2
- package/dist/ai-harness-CV6rXbB3.d.ts.map +1 -0
- package/dist/{ai-harness-BuLh_EYi.js → ai-harness-Clbg5ei7.js} +88 -15
- package/dist/ai-harness-Clbg5ei7.js.map +1 -0
- package/dist/{ai-D6mGlO8j.js → ai-mMiWul1T.js} +116 -77
- package/dist/ai-mMiWul1T.js.map +1 -0
- package/dist/ai-pi.d.ts +1 -1
- package/dist/ai-pi.d.ts.map +1 -1
- package/dist/ai-pi.js +56 -11
- package/dist/ai-pi.js.map +1 -1
- package/dist/ai-react.d.ts +1 -1
- package/dist/ai-react.js +1 -1
- package/dist/ai-server.d.ts +2 -2
- package/dist/ai-server.d.ts.map +1 -1
- package/dist/ai-server.js +123 -53
- package/dist/ai-server.js.map +1 -1
- package/dist/ai.d.ts +2 -2
- package/dist/ai.js +1 -1
- package/dist/internal-CscSY7K_.js.map +1 -1
- package/dist/scheduler-qstash.d.ts.map +1 -1
- package/dist/scheduler-qstash.js +5 -5
- package/dist/scheduler-qstash.js.map +1 -1
- package/dist/scheduler-vercel.d.ts.map +1 -1
- package/dist/scheduler-vercel.js +5 -7
- package/dist/scheduler-vercel.js.map +1 -1
- package/dist/server-DYAATFuT.d.ts.map +1 -1
- package/dist/{server-D-Y4pqwu.js → server-qyb5aJJG.js} +39 -25
- package/dist/server-qyb5aJJG.js.map +1 -0
- package/dist/server.js +1 -1
- package/dist/session-clear-DGI4jTQ5.js.map +1 -1
- package/dist/store-postgres.d.ts.map +1 -1
- package/dist/store-postgres.js +38 -136
- package/dist/store-postgres.js.map +1 -1
- package/docs/01-quickstart.mdx +7 -4
- package/docs/actors/01-introduction.mdx +1 -1
- package/docs/actors/meta.ts +1 -1
- package/docs/agents/02-tools.mdx +1 -1
- package/docs/agents/03-conversations.mdx +26 -14
- package/docs/agents/04-harnesses.mdx +51 -5
- package/docs/agents/meta.ts +1 -1
- package/docs/client/meta.ts +1 -1
- package/docs/contract/03-sessions.mdx +2 -2
- package/docs/index.mdx +2 -2
- package/docs/reference/01-core.mdx +8 -8
- package/docs/reference/02-server.mdx +3 -3
- package/docs/reference/04-ai.mdx +36 -19
- package/docs/reference/05-telemetry.mdx +132 -0
- package/docs/reference/06-errors.mdx +1 -1
- package/docs/reference/meta.ts +1 -1
- package/docs/schedulers/01-overview.mdx +112 -0
- package/docs/schedulers/02-vercel-queues.mdx +87 -0
- package/docs/schedulers/03-qstash.mdx +123 -0
- package/docs/schedulers/meta.ts +1 -0
- package/docs/server/01-handlers.mdx +29 -2
- package/docs/server/02-timers.mdx +3 -3
- package/docs/server/06-guarantees.mdx +6 -3
- package/docs/stores/01-overview.mdx +37 -0
- package/docs/stores/02-redis.mdx +49 -0
- package/docs/stores/03-redis-http.mdx +56 -0
- package/docs/stores/04-postgres.mdx +121 -0
- package/docs/stores/05-sqlite.mdx +34 -0
- package/docs/stores/06-memory.mdx +22 -0
- package/docs/stores/meta.ts +1 -0
- package/examples/playground/DEPLOY.md +19 -5
- package/examples/playground/app/a0/a0.css +244 -5
- package/examples/playground/app/a0/app-view.tsx +2 -1
- package/examples/playground/app/a0/apps/[appId]/route.ts +6 -10
- package/examples/playground/app/a0/attachments.ts +133 -0
- package/examples/playground/app/a0/chat.test.tsx +595 -0
- package/examples/playground/app/a0/chat.tsx +623 -63
- package/examples/playground/app/a0/context-menu.tsx +38 -11
- package/examples/playground/app/a0/model.test.ts +28 -0
- package/examples/playground/app/a0/model.ts +30 -0
- package/examples/playground/app/a0/server.ts +5 -0
- package/examples/playground/app/a0/shell.tsx +1 -1
- package/examples/playground/app/a0/uploads/route.test.ts +59 -0
- package/examples/playground/app/a0/uploads/route.ts +29 -0
- package/examples/playground/app/agent/[agentId]/agent-client.tsx +2 -1
- package/examples/playground/app/agent/[agentId]/agent-queue.test.tsx +116 -89
- package/examples/playground/app/agent/[agentId]/compaction-panel.test.tsx +107 -76
- package/examples/playground/app/agent/ingress.test.ts +41 -17
- package/examples/playground/app/agent/model-input.test.tsx +92 -43
- package/examples/playground/app/agent/model.test.ts +12 -3
- package/examples/playground/app/chat/[chatId]/agent-state.test.ts +1 -5
- package/examples/playground/app/chat/[chatId]/agent-stream-drawer.test.tsx +2 -2
- package/examples/playground/app/chat/server.test.ts +38 -65
- package/examples/playground/app/components/activity-history.test.tsx +60 -18
- package/examples/playground/app/components/fast-link.test.tsx +3 -5
- package/examples/playground/app/initial-state.test.tsx +86 -45
- package/examples/playground/next.config.ts +7 -4
- package/examples/playground/package.json +4 -1
- package/examples/playground/test/agent.ts +35 -0
- package/examples/playground/test/model.ts +149 -0
- package/examples/playground/test/serve.ts +184 -0
- package/examples/playground/test/server-only.ts +1 -0
- package/examples/playground/vitest.config.ts +27 -0
- package/package.json +3 -1
- package/sql/postgres/notifications.sql +24 -0
- package/sql/postgres/schema.sql +137 -0
- package/src/ai-agent.ts +9 -4
- package/src/ai-client-state.ts +21 -9
- package/src/ai-context-schema.ts +28 -4
- package/src/ai-context.ts +83 -3
- package/src/ai-control-server.ts +70 -43
- package/src/ai-control-state.ts +3 -4
- package/src/ai-control.ts +59 -10
- package/src/ai-coordinator.ts +7 -6
- package/src/ai-fx.ts +95 -23
- package/src/ai-harness.ts +171 -29
- package/src/ai-message-shape.ts +41 -0
- package/src/ai-pi.ts +88 -32
- package/src/ai-stored-state.ts +1 -3
- package/src/ai.ts +116 -77
- package/src/errors.ts +1 -1
- package/src/internal.ts +2 -1
- package/src/libfx.d.ts +8 -1
- package/src/postgres-notifications.ts +0 -22
- package/src/postgres-resources.ts +6 -9
- package/src/postgres-schema.ts +80 -0
- package/src/scheduler-qstash.ts +12 -5
- package/src/scheduler-vercel.ts +11 -16
- package/src/server.ts +66 -32
- package/src/store-postgres.ts +5 -123
- package/dist/ai-BMLlI2Ff.d.ts.map +0 -1
- package/dist/ai-D6mGlO8j.js.map +0 -1
- package/dist/ai-harness-B2suP2Qh.d.ts.map +0 -1
- package/dist/ai-harness-BuLh_EYi.js.map +0 -1
- package/dist/server-D-Y4pqwu.js.map +0 -1
- package/docs/reference/05-adapters.mdx +0 -351
- package/docs/server/05-production.mdx +0 -323
- package/examples/playground/app/a0/no-photon.cjs +0 -4
|
@@ -1,35 +1,77 @@
|
|
|
1
1
|
// @vitest-environment jsdom
|
|
2
|
-
import {
|
|
3
|
-
|
|
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
|
-
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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.
|
|
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(
|
|
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
|
-
|
|
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('
|
|
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 {
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
expect(
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
)
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
50
|
-
})) as ReactElement<{
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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,
|
|
10
|
-
//
|
|
11
|
-
|
|
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.
|
|
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
|
+
}
|