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,30 +1,57 @@
|
|
|
1
1
|
// @vitest-environment jsdom
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
2
|
+
import {
|
|
3
|
+
act,
|
|
4
|
+
cleanup,
|
|
5
|
+
fireEvent,
|
|
6
|
+
render,
|
|
7
|
+
screen,
|
|
8
|
+
waitFor,
|
|
9
|
+
} from '@testing-library/react'
|
|
10
|
+
import type { InitialSnapshot } from 'experimental-a2'
|
|
11
|
+
import { useAgent } from 'experimental-a2/ai/react'
|
|
12
|
+
import { createClient, type A2Client } from 'experimental-a2/client'
|
|
13
|
+
import type { ReactNode } from 'react'
|
|
14
|
+
import { afterEach, beforeEach, expect, it } from 'vitest'
|
|
15
|
+
import { serveAgent, type ServedAgent } from '@/test/agent'
|
|
16
|
+
import { demoAgent, type AgentState } from '../model'
|
|
6
17
|
import { CompactionPanel } from './compaction-panel'
|
|
7
18
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
let
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
reconnect: vi.fn<() => void>(),
|
|
25
|
-
}
|
|
19
|
+
let served: ServedAgent
|
|
20
|
+
let server: ServedAgent['server']
|
|
21
|
+
let probe: ServedAgent['probe']
|
|
22
|
+
let client: A2Client<typeof demoAgent.events, AgentState>
|
|
23
|
+
let id: string
|
|
24
|
+
|
|
25
|
+
beforeEach(async () => {
|
|
26
|
+
served = await serveAgent()
|
|
27
|
+
;({ server, probe } = served)
|
|
28
|
+
client = createClient({ contract: demoAgent, api: served.url })
|
|
29
|
+
id = crypto.randomUUID()
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
afterEach(async () => {
|
|
33
|
+
cleanup()
|
|
34
|
+
await served.close()
|
|
26
35
|
})
|
|
27
|
-
|
|
36
|
+
|
|
37
|
+
/** The panel as the agent page mounts it: on the session's live state. */
|
|
38
|
+
function Panel({
|
|
39
|
+
initial,
|
|
40
|
+
}: {
|
|
41
|
+
initial: InitialSnapshot<AgentState>
|
|
42
|
+
}): ReactNode {
|
|
43
|
+
return <CompactionPanel session={useAgent({ client, id, initial })} />
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const page = async (): Promise<InitialSnapshot<AgentState>> =>
|
|
47
|
+
server.session(id).state()
|
|
48
|
+
|
|
49
|
+
const operations = (type: 'stream' | 'history' | 'push'): number =>
|
|
50
|
+
probe.operations.filter(
|
|
51
|
+
(operation) => operation.type === type && operation.sessionId === id,
|
|
52
|
+
).length
|
|
53
|
+
|
|
54
|
+
const connected = () => waitFor(() => expect(operations('stream')).toBe(1))
|
|
28
55
|
|
|
29
56
|
async function toggle(panel: HTMLDetailsElement, open: boolean) {
|
|
30
57
|
await act(async () => {
|
|
@@ -33,87 +60,91 @@ async function toggle(panel: HTMLDetailsElement, open: boolean) {
|
|
|
33
60
|
})
|
|
34
61
|
}
|
|
35
62
|
|
|
63
|
+
const field = (label: string) =>
|
|
64
|
+
screen.getByLabelText(label) as HTMLInputElement | HTMLSelectElement
|
|
65
|
+
|
|
36
66
|
it('uses seeded settings immediately and loads diagnostic history only on open', async () => {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
'manual',
|
|
67
|
+
// More history than one page, so a second open would have more to load.
|
|
68
|
+
await server.session(id).append(
|
|
69
|
+
...Array.from({ length: 60 }, (_, at) => ({
|
|
70
|
+
type: 'compaction.settings.updated' as const,
|
|
71
|
+
payload: { mode: 'manual' as const, thresholdTokens: 1175 + at },
|
|
72
|
+
})),
|
|
44
73
|
)
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
).toBe('1234')
|
|
48
|
-
|
|
49
|
-
expect(
|
|
74
|
+
const view = render(<Panel initial={await page()} />)
|
|
75
|
+
expect(field('Mode').value).toBe('manual')
|
|
76
|
+
expect(field('Threshold in tokens').value).toBe('1234')
|
|
77
|
+
await connected()
|
|
78
|
+
expect(operations('history')).toBe(0)
|
|
79
|
+
|
|
50
80
|
const panel =
|
|
51
81
|
view.container.querySelector<HTMLDetailsElement>('.agent-compaction')!
|
|
52
82
|
await toggle(panel, true)
|
|
53
|
-
expect(
|
|
83
|
+
await waitFor(() => expect(operations('history')).toBe(1))
|
|
54
84
|
await toggle(panel, false)
|
|
55
85
|
await toggle(panel, true)
|
|
56
|
-
|
|
57
|
-
session.
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
id: 'old',
|
|
65
|
-
sessionId: 'one',
|
|
66
|
-
index: 2,
|
|
67
|
-
createdAt: new Date(),
|
|
68
|
-
type: 'compaction.settings.updated',
|
|
69
|
-
payload: { mode: 'automatic' },
|
|
70
|
-
},
|
|
71
|
-
]
|
|
72
|
-
view.rerender(<CompactionPanel session={session} />)
|
|
73
|
-
expect((screen.getByLabelText('Mode') as HTMLSelectElement).value).toBe(
|
|
74
|
-
'disabled',
|
|
75
|
-
)
|
|
76
|
-
expect(push).not.toHaveBeenCalled()
|
|
86
|
+
|
|
87
|
+
await server.session(id).append({
|
|
88
|
+
type: 'compaction.settings.updated',
|
|
89
|
+
payload: { mode: 'disabled' },
|
|
90
|
+
})
|
|
91
|
+
await waitFor(() => expect(field('Mode').value).toBe('disabled'))
|
|
92
|
+
expect(operations('history')).toBe(1)
|
|
93
|
+
expect(operations('push')).toBe(0)
|
|
77
94
|
})
|
|
78
95
|
|
|
79
96
|
it('keeps diagnostic read failures local and retries only when requested', async () => {
|
|
80
|
-
|
|
97
|
+
await server.session(id).append({
|
|
98
|
+
type: 'compaction.settings.updated',
|
|
99
|
+
payload: { mode: 'automatic' },
|
|
100
|
+
})
|
|
101
|
+
probe.deny(({ type }) => type === 'history')
|
|
102
|
+
const initial = await page()
|
|
81
103
|
const view = render(
|
|
82
104
|
<>
|
|
83
105
|
<input aria-label="composer" />
|
|
84
|
-
<
|
|
106
|
+
<Panel initial={initial} />
|
|
85
107
|
</>,
|
|
86
108
|
)
|
|
87
109
|
await toggle(view.container.querySelector('details')!, true)
|
|
88
|
-
expect(screen.
|
|
89
|
-
|
|
90
|
-
expect((screen.getByLabelText('Mode') as HTMLSelectElement).value).toBe(
|
|
91
|
-
'automatic',
|
|
110
|
+
expect((await screen.findByRole('alert')).textContent).toBe(
|
|
111
|
+
'the operation is not authorized',
|
|
92
112
|
)
|
|
113
|
+
expect(screen.getByLabelText('composer')).toBeDefined()
|
|
114
|
+
expect(field('Mode').value).toBe('automatic')
|
|
115
|
+
|
|
93
116
|
await act(async () =>
|
|
94
117
|
fireEvent.click(
|
|
95
118
|
screen.getByRole('button', { name: 'Load earlier events' }),
|
|
96
119
|
),
|
|
97
120
|
)
|
|
98
|
-
expect(
|
|
99
|
-
expect(
|
|
100
|
-
expect(push).
|
|
121
|
+
await waitFor(() => expect(screen.queryByRole('alert')).toBeNull())
|
|
122
|
+
expect(operations('history')).toBe(2)
|
|
123
|
+
expect(operations('push')).toBe(0)
|
|
101
124
|
})
|
|
102
125
|
|
|
103
126
|
it('pushes the settings event and reports a failed save outside the form', async () => {
|
|
104
|
-
|
|
105
|
-
Object.assign(Promise.reject(new Error('Save failed')), {
|
|
106
|
-
confirmed: Promise.resolve([]),
|
|
107
|
-
}),
|
|
108
|
-
)
|
|
109
|
-
const view = render(<CompactionPanel session={session} />)
|
|
127
|
+
const view = render(<Panel initial={await page()} />)
|
|
110
128
|
await toggle(view.container.querySelector('details')!, true)
|
|
111
129
|
await act(async () =>
|
|
112
130
|
fireEvent.click(screen.getByRole('button', { name: 'Set to 1 token' })),
|
|
113
131
|
)
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
132
|
+
await waitFor(async () =>
|
|
133
|
+
expect((await server.session(id).state()).state.compactionSettings).toEqual(
|
|
134
|
+
{ mode: 'manual', thresholdTokens: 1 },
|
|
135
|
+
),
|
|
136
|
+
)
|
|
137
|
+
expect(screen.queryByRole('alert')).toBeNull()
|
|
138
|
+
|
|
139
|
+
probe.deny(({ type }) => type === 'push')
|
|
140
|
+
await act(async () =>
|
|
141
|
+
fireEvent.click(screen.getByRole('button', { name: 'Reset to automatic' })),
|
|
142
|
+
)
|
|
143
|
+
expect((await screen.findByRole('alert')).textContent).toBe(
|
|
144
|
+
'the operation is not authorized',
|
|
145
|
+
)
|
|
146
|
+
expect((await server.session(id).state()).state.compactionSettings).toEqual({
|
|
147
|
+
mode: 'manual',
|
|
148
|
+
thresholdTokens: 1,
|
|
117
149
|
})
|
|
118
|
-
expect(screen.getByRole('alert').textContent).toBe('Save failed')
|
|
119
150
|
})
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import { expect, it
|
|
1
|
+
import { expect, it } from 'vitest'
|
|
2
2
|
import { agent } from 'experimental-a2/ai/server'
|
|
3
3
|
import { memory } from 'experimental-a2/store-memory'
|
|
4
4
|
import { inputs } from 'experimental-a2/ai'
|
|
5
|
+
import { GET, POST } from './events/route'
|
|
5
6
|
import { demoAgent } from './model'
|
|
7
|
+
import { agentServer as playgroundAgent } from './server'
|
|
6
8
|
|
|
7
9
|
const message = { id: 'message', role: 'user' as const, parts: [] }
|
|
8
10
|
|
|
@@ -81,21 +83,43 @@ it('keeps lifecycle events server-only beside public controls', async () => {
|
|
|
81
83
|
expect(accepted.status).toBe(200)
|
|
82
84
|
})
|
|
83
85
|
|
|
84
|
-
it(
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
86
|
+
it("serves the playground's agent over its events route", async () => {
|
|
87
|
+
const sessionId = crypto.randomUUID()
|
|
88
|
+
const pushed = await POST(
|
|
89
|
+
new Request('http://test/agent/events', {
|
|
90
|
+
method: 'POST',
|
|
91
|
+
headers: { 'content-type': 'application/json' },
|
|
92
|
+
body: JSON.stringify({
|
|
93
|
+
sessionId,
|
|
94
|
+
events: [
|
|
95
|
+
{
|
|
96
|
+
type: 'compaction.settings.updated',
|
|
97
|
+
payload: { mode: 'manual', thresholdTokens: 2 },
|
|
98
|
+
},
|
|
99
|
+
],
|
|
100
|
+
}),
|
|
101
|
+
}),
|
|
102
|
+
)
|
|
103
|
+
expect(pushed.status).toBe(200)
|
|
104
|
+
await playgroundAgent.drain(sessionId)
|
|
105
|
+
const { state } = await playgroundAgent.session(sessionId).state()
|
|
106
|
+
expect(state.compactionSettings).toEqual({
|
|
107
|
+
mode: 'manual',
|
|
108
|
+
thresholdTokens: 2,
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
const stream = await GET(
|
|
112
|
+
new Request(`http://test/agent/events?sessionId=${sessionId}`),
|
|
113
|
+
)
|
|
114
|
+
expect(stream.status).toBe(200)
|
|
115
|
+
expect(stream.headers.get('content-type')).toContain('text/event-stream')
|
|
116
|
+
await stream.body?.cancel()
|
|
117
|
+
|
|
118
|
+
// The agent's browser client streams over HTTP; the route offers no upgrade.
|
|
119
|
+
const upgrade = await GET(
|
|
120
|
+
new Request(`http://test/agent/events?sessionId=${sessionId}`, {
|
|
92
121
|
headers: { upgrade: 'websocket' },
|
|
93
|
-
})
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
await route.POST(write)
|
|
97
|
-
expect(fetch.mock.calls).toEqual([[read], [write]])
|
|
98
|
-
} finally {
|
|
99
|
-
vi.doUnmock('./server')
|
|
100
|
-
}
|
|
122
|
+
}),
|
|
123
|
+
)
|
|
124
|
+
expect(upgrade.status).toBe(426)
|
|
101
125
|
})
|
|
@@ -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
|
})
|
|
@@ -29,9 +29,18 @@ it('folds compaction settings beside the AI state', () => {
|
|
|
29
29
|
)
|
|
30
30
|
state = reducer.fold(
|
|
31
31
|
state,
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
32
|
+
// A send's id is its message's.
|
|
33
|
+
{
|
|
34
|
+
...event(2, 'ai.control.requested', {
|
|
35
|
+
action: 'send',
|
|
36
|
+
message: { id: 'user', role: 'user', parts: [] },
|
|
37
|
+
}),
|
|
38
|
+
id: 'user',
|
|
39
|
+
},
|
|
40
|
+
)
|
|
41
|
+
state = reducer.fold(
|
|
42
|
+
state,
|
|
43
|
+
event(3, 'ai.message.created', { messageId: 'user', role: 'user' }),
|
|
35
44
|
)
|
|
36
45
|
expect(state.compactionSettings).toEqual({ mode: 'disabled' })
|
|
37
46
|
expect(state.compactionSettingsId).toBe('event-1')
|
|
@@ -19,11 +19,7 @@ function running() {
|
|
|
19
19
|
active: {
|
|
20
20
|
turnId: 'turn',
|
|
21
21
|
phase: 'generating',
|
|
22
|
-
input: {
|
|
23
|
-
id: 'input',
|
|
24
|
-
generate: true,
|
|
25
|
-
message: { id: 'input', role: 'user', parts: [] },
|
|
26
|
-
},
|
|
22
|
+
input: { id: 'input', role: 'user', generate: true },
|
|
27
23
|
},
|
|
28
24
|
}
|
|
29
25
|
const append = (event: { type: string; payload: unknown; id?: string }) => {
|
|
@@ -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 })
|