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.
- package/CHANGELOG.md +16 -0
- package/dist/actor.js +1 -1
- package/dist/ai-fx.d.ts.map +1 -1
- package/dist/ai-fx.js +34 -4
- package/dist/ai-fx.js.map +1 -1
- package/dist/ai-harness-CV6rXbB3.d.ts.map +1 -1
- package/dist/{ai-harness-BuuC-gJ3.js → ai-harness-Clbg5ei7.js} +11 -2
- package/dist/ai-harness-Clbg5ei7.js.map +1 -0
- package/dist/ai-pi.js +2 -2
- package/dist/ai-server.d.ts.map +1 -1
- package/dist/ai-server.js +6 -4
- package/dist/ai-server.js.map +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 +4 -3
- 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 +1 -1
- package/docs/agents/meta.ts +1 -1
- package/docs/client/meta.ts +1 -1
- package/docs/contract/03-sessions.mdx +1 -1
- 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 +9 -1
- 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 +2 -2
- 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 +12 -7
- package/examples/playground/app/a0/apps/[appId]/route.ts +6 -10
- package/examples/playground/app/a0/chat.test.tsx +421 -330
- package/examples/playground/app/a0/shell.tsx +1 -1
- package/examples/playground/app/a0/uploads/route.test.ts +59 -0
- package/examples/playground/app/agent/[agentId]/agent-queue.test.tsx +116 -90
- 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/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/package.json +2 -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 +5 -4
- package/src/ai-fx.ts +37 -3
- package/src/ai-harness.ts +18 -0
- package/src/errors.ts +1 -1
- package/src/internal.ts +2 -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 +10 -4
- package/src/server.ts +66 -32
- package/src/store-postgres.ts +5 -123
- package/dist/ai-harness-BuuC-gJ3.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
|
@@ -1,58 +1,107 @@
|
|
|
1
1
|
// @vitest-environment jsdom
|
|
2
|
-
import {
|
|
3
|
-
|
|
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
|
-
|
|
14
|
+
let served: ServedAgent
|
|
15
|
+
let server: ServedAgent['server']
|
|
16
|
+
let probe: ServedAgent['probe']
|
|
17
|
+
let id: string
|
|
8
18
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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
|
-
|
|
91
|
+
await waitFor(async () =>
|
|
92
|
+
expect(await chosen()).toEqual({ model: 'provider/model' }),
|
|
93
|
+
)
|
|
52
94
|
fireEvent.click(screen.getByRole('button', { name: 'Prefer fast' }))
|
|
53
|
-
|
|
54
|
-
model: 'provider/model',
|
|
55
|
-
|
|
56
|
-
|
|
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
|
|
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={
|
|
92
|
+
onClose={() => {}}
|
|
93
93
|
/>,
|
|
94
94
|
)
|
|
95
95
|
|
|
@@ -1,68 +1,13 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
1
|
+
import { afterEach, expect, it, vi } from 'vitest'
|
|
2
|
+
import { testModel, toolCall } from '@/test/model'
|
|
3
|
+
import { chatAgentServer, chatServer } from './server'
|
|
3
4
|
|
|
4
|
-
|
|
5
|
-
vi.
|
|
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
|
|
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(
|
|
54
|
+
expect(model.steps).toHaveLength(2)
|
|
96
55
|
expect(
|
|
97
|
-
|
|
98
|
-
|
|
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
|
|
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 {
|
|
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
|
})
|
|
@@ -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.
|
|
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>>
|