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
|
@@ -106,7 +106,7 @@ export function A0Shell({
|
|
|
106
106
|
const remove = async (target: App): Promise<void> => {
|
|
107
107
|
if (
|
|
108
108
|
!confirm(
|
|
109
|
-
`Delete ${target.name}? This deletes its sandbox, with everything the agent built in it.`,
|
|
109
|
+
`Delete ${target.name}? This deletes its conversation and its sandbox, with everything the agent built in it.`,
|
|
110
110
|
)
|
|
111
111
|
)
|
|
112
112
|
return
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
|
2
|
+
import { POST } from './route'
|
|
3
|
+
|
|
4
|
+
const appId = crypto.randomUUID()
|
|
5
|
+
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
vi.stubEnv('BLOB_READ_WRITE_TOKEN', 'vercel_blob_rw_teststore_secret')
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
vi.unstubAllEnvs()
|
|
12
|
+
vi.restoreAllMocks()
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
/** What the browser's `upload()` asks its `handleUploadUrl` for first. */
|
|
16
|
+
const tokenRequest = (pathname: string, clientPayload: string | null) =>
|
|
17
|
+
POST(
|
|
18
|
+
new Request('http://test/a0/uploads', {
|
|
19
|
+
method: 'POST',
|
|
20
|
+
headers: { 'content-type': 'application/json' },
|
|
21
|
+
body: JSON.stringify({
|
|
22
|
+
type: 'blob.generate-client-token',
|
|
23
|
+
payload: { pathname, clientPayload, multipart: false },
|
|
24
|
+
}),
|
|
25
|
+
}),
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
/** The upload a client token allows, as Blob reads it from the token. */
|
|
29
|
+
const allowed = (clientToken: string): unknown => {
|
|
30
|
+
const [, encoded] = /^vercel_blob_client_teststore_(.+)$/.exec(clientToken)!
|
|
31
|
+
const signed = Buffer.from(encoded!, 'base64').toString()
|
|
32
|
+
const payload = signed.slice(signed.indexOf('.') + 1)
|
|
33
|
+
return JSON.parse(Buffer.from(payload, 'base64').toString())
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
it("signs an upload into the app's own folder, without a request of its own", async () => {
|
|
37
|
+
const fetch = vi.spyOn(globalThis, 'fetch')
|
|
38
|
+
const response = await tokenRequest(`a0/${appId}/shot.png`, appId)
|
|
39
|
+
expect(response.status).toBe(200)
|
|
40
|
+
const body = (await response.json()) as { type: string; clientToken: string }
|
|
41
|
+
expect(body.type).toBe('blob.generate-client-token')
|
|
42
|
+
expect(allowed(body.clientToken)).toMatchObject({
|
|
43
|
+
pathname: `a0/${appId}/shot.png`,
|
|
44
|
+
addRandomSuffix: true,
|
|
45
|
+
})
|
|
46
|
+
expect(fetch).not.toHaveBeenCalled()
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it.each([
|
|
50
|
+
['another app', `a0/${crypto.randomUUID()}/shot.png`, appId],
|
|
51
|
+
['no app', `a0/${appId}/shot.png`, null],
|
|
52
|
+
['something other than an app', 'a0/list/shot.png', 'list'],
|
|
53
|
+
])('refuses an upload for %s', async (_, pathname, clientPayload) => {
|
|
54
|
+
const response = await tokenRequest(pathname, clientPayload)
|
|
55
|
+
expect(response.status).toBe(400)
|
|
56
|
+
expect(await response.json()).toEqual({
|
|
57
|
+
error: "An attachment goes in its app's folder",
|
|
58
|
+
})
|
|
59
|
+
})
|
|
@@ -1,124 +1,150 @@
|
|
|
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 { inputs } from 'experimental-a2/ai'
|
|
11
|
+
import type { A2Operation } from 'experimental-a2/server'
|
|
12
|
+
import { afterEach, beforeEach, expect, it } from 'vitest'
|
|
13
|
+
import { serveAgent, type ServedAgent } from '@/test/agent'
|
|
14
|
+
import { AgentClient } from './agent-client'
|
|
6
15
|
|
|
7
|
-
|
|
16
|
+
let served: ServedAgent
|
|
17
|
+
let server: ServedAgent['server']
|
|
18
|
+
let probe: ServedAgent['probe']
|
|
19
|
+
let id: string
|
|
8
20
|
|
|
9
|
-
|
|
21
|
+
beforeEach(async () => {
|
|
22
|
+
served = await serveAgent()
|
|
23
|
+
;({ server, probe } = served)
|
|
24
|
+
id = crypto.randomUUID()
|
|
25
|
+
// Paused, so the message waits in the queue rather than starting a turn.
|
|
26
|
+
await server.session(id).append(
|
|
27
|
+
...inputs.pause({ when: 'now' }),
|
|
28
|
+
...inputs.message({
|
|
29
|
+
id: 'queued',
|
|
30
|
+
role: 'user',
|
|
31
|
+
parts: [{ type: 'text', text: 'Original' }],
|
|
32
|
+
}),
|
|
33
|
+
)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
afterEach(async () => {
|
|
37
|
+
cleanup()
|
|
38
|
+
await served.close()
|
|
39
|
+
})
|
|
10
40
|
|
|
11
|
-
const
|
|
41
|
+
const page = async () =>
|
|
12
42
|
render(
|
|
13
|
-
<
|
|
14
|
-
items={[
|
|
15
|
-
{
|
|
16
|
-
id: 'queued',
|
|
17
|
-
role: 'user',
|
|
18
|
-
message: {
|
|
19
|
-
id: 'queued',
|
|
20
|
-
role: 'user',
|
|
21
|
-
parts: [{ type: 'text', text: 'Original' }],
|
|
22
|
-
},
|
|
23
|
-
generate: true,
|
|
24
|
-
},
|
|
25
|
-
]}
|
|
26
|
-
paused
|
|
27
|
-
edit={edit}
|
|
28
|
-
move={vi.fn<() => void>()}
|
|
29
|
-
remove={vi.fn<() => Promise<void>>().mockResolvedValue(undefined)}
|
|
30
|
-
sendNow={vi.fn<() => Promise<void>>().mockResolvedValue(undefined)}
|
|
31
|
-
/>,
|
|
43
|
+
<AgentClient agentId={id} initial={await server.session(id).state()} />,
|
|
32
44
|
)
|
|
33
45
|
|
|
46
|
+
const isEdit = (operation: A2Operation): boolean =>
|
|
47
|
+
operation.type === 'push' &&
|
|
48
|
+
operation.sessionId === id &&
|
|
49
|
+
operation.events.some(
|
|
50
|
+
(event) =>
|
|
51
|
+
event.type === 'ai.control.requested' &&
|
|
52
|
+
(event.payload as { action?: unknown }).action === 'edit',
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
/** The texts each edit pushed to the server carried. */
|
|
56
|
+
const editsReceived = (): string[] =>
|
|
57
|
+
probe.operations.filter(isEdit).flatMap((operation) =>
|
|
58
|
+
operation.type === 'push'
|
|
59
|
+
? operation.events.map(
|
|
60
|
+
(event) =>
|
|
61
|
+
(
|
|
62
|
+
event.payload as {
|
|
63
|
+
message: { parts: { text: string }[] }
|
|
64
|
+
}
|
|
65
|
+
).message.parts[0]!.text,
|
|
66
|
+
)
|
|
67
|
+
: [],
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
const queuedText = async (): Promise<string | undefined> => {
|
|
71
|
+
const { state } = await server.session(id).state()
|
|
72
|
+
const part = state.inbox.items[0]?.message.parts[0]
|
|
73
|
+
return part?.type === 'text' ? part.text : undefined
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The queued message's text, as the queue shows it. */
|
|
77
|
+
const shownText = (): string | null | undefined =>
|
|
78
|
+
document.querySelector('.agent-queue-text')?.textContent
|
|
79
|
+
|
|
80
|
+
const editor = () =>
|
|
81
|
+
screen.queryByLabelText(
|
|
82
|
+
'Edit text for queued message 1',
|
|
83
|
+
) as HTMLTextAreaElement | null
|
|
84
|
+
const button = (name: string) =>
|
|
85
|
+
screen.getByRole('button', { name }) as HTMLButtonElement
|
|
86
|
+
|
|
34
87
|
const open = (): void => {
|
|
35
|
-
fireEvent.click(
|
|
88
|
+
fireEvent.click(button('Edit queued message 1'))
|
|
36
89
|
}
|
|
37
90
|
const save = (text: string): void => {
|
|
38
|
-
fireEvent.change(
|
|
39
|
-
fireEvent.click(
|
|
91
|
+
fireEvent.change(editor()!, { target: { value: text } })
|
|
92
|
+
fireEvent.click(button('Save'))
|
|
40
93
|
}
|
|
41
94
|
|
|
42
95
|
it('closes immediately and permits another edit while saves remain in flight', async () => {
|
|
43
|
-
|
|
44
|
-
const
|
|
45
|
-
const edit = vi
|
|
46
|
-
.fn<Edit>()
|
|
47
|
-
.mockImplementationOnce(() => first.promise)
|
|
48
|
-
.mockImplementationOnce(() => second.promise)
|
|
49
|
-
queue(edit)
|
|
96
|
+
await page()
|
|
97
|
+
const first = probe.hold(isEdit)
|
|
50
98
|
open()
|
|
51
99
|
save('First')
|
|
52
|
-
expect(
|
|
53
|
-
expect(
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
).disabled,
|
|
59
|
-
).toBe(false)
|
|
60
|
-
expect(
|
|
61
|
-
(
|
|
62
|
-
screen.getByRole('button', {
|
|
63
|
-
name: 'Send queued message 1 after current step',
|
|
64
|
-
}) as HTMLButtonElement
|
|
65
|
-
).disabled,
|
|
66
|
-
).toBe(false)
|
|
100
|
+
expect(editor()).toBeNull()
|
|
101
|
+
expect(button('Edit queued message 1').disabled).toBe(false)
|
|
102
|
+
expect(button('Send queued message 1 after current step').disabled).toBe(
|
|
103
|
+
false,
|
|
104
|
+
)
|
|
105
|
+
await first.arrived
|
|
67
106
|
open()
|
|
68
107
|
save('Second')
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
await act(async () => {
|
|
74
|
-
first.reject(new Error('Earlier save failed'))
|
|
75
|
-
await first.promise.catch(() => {})
|
|
76
|
-
})
|
|
77
|
-
expect(screen.queryByRole('textbox')).toBeNull()
|
|
108
|
+
first.deny()
|
|
109
|
+
await waitFor(async () => expect(await queuedText()).toBe('Second'))
|
|
110
|
+
expect(editsReceived()).toEqual(['First', 'Second'])
|
|
111
|
+
expect(editor()).toBeNull()
|
|
78
112
|
expect(screen.queryByRole('alert')).toBeNull()
|
|
79
|
-
|
|
80
|
-
second.resolve()
|
|
81
|
-
await second.promise
|
|
82
|
-
})
|
|
113
|
+
expect(shownText()).toBe('Second')
|
|
83
114
|
})
|
|
84
115
|
|
|
85
|
-
it('restores the latest submitted draft
|
|
86
|
-
|
|
87
|
-
|
|
116
|
+
it('restores the latest submitted draft when its save fails', async () => {
|
|
117
|
+
await page()
|
|
118
|
+
const pending = probe.hold(isEdit)
|
|
88
119
|
open()
|
|
89
120
|
save('Keep this draft')
|
|
90
|
-
expect(
|
|
91
|
-
await
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
'Keep this draft',
|
|
121
|
+
expect(editor()).toBeNull()
|
|
122
|
+
await pending.arrived
|
|
123
|
+
pending.deny()
|
|
124
|
+
await waitFor(() => expect(editor()?.value).toBe('Keep this draft'))
|
|
125
|
+
expect(screen.getByRole('alert').textContent).toBe(
|
|
126
|
+
'the operation is not authorized',
|
|
97
127
|
)
|
|
98
|
-
expect(
|
|
128
|
+
expect(await queuedText()).toBe('Original')
|
|
99
129
|
})
|
|
100
130
|
|
|
101
131
|
it.each(['open', 'cancel'] as const)(
|
|
102
132
|
'does not restore an old failed save after a newer draft is %s',
|
|
103
133
|
async (action) => {
|
|
104
|
-
|
|
105
|
-
|
|
134
|
+
await page()
|
|
135
|
+
const pending = probe.hold(isEdit)
|
|
106
136
|
open()
|
|
107
137
|
save('Old draft')
|
|
138
|
+
await pending.arrived
|
|
108
139
|
open()
|
|
109
|
-
fireEvent.change(
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
await act(
|
|
115
|
-
|
|
116
|
-
await pending.promise.catch(() => {})
|
|
117
|
-
})
|
|
118
|
-
expect(
|
|
119
|
-
(screen.queryByRole('textbox') as HTMLTextAreaElement | null)?.value ??
|
|
120
|
-
null,
|
|
121
|
-
).toBe(action === 'open' ? 'New draft' : null)
|
|
140
|
+
fireEvent.change(editor()!, { target: { value: 'New draft' } })
|
|
141
|
+
if (action === 'cancel') fireEvent.click(button('Cancel'))
|
|
142
|
+
pending.deny()
|
|
143
|
+
// The failed save leaves the queue; then its handler has had its turn.
|
|
144
|
+
await waitFor(() => expect(shownText()).toBe('Original'))
|
|
145
|
+
await act(() => new Promise((resolve) => setTimeout(resolve, 10)))
|
|
146
|
+
expect(editor()?.value ?? null).toBe(action === 'open' ? 'New draft' : null)
|
|
122
147
|
expect(screen.queryByRole('alert')).toBeNull()
|
|
148
|
+
expect(await queuedText()).toBe('Original')
|
|
123
149
|
},
|
|
124
150
|
)
|
|
@@ -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
|
})
|