experimental-a2 0.18.1 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/dist/actor.js +1 -1
  3. package/dist/{ai-BMLlI2Ff.d.ts → ai-Cz3hqVhk.d.ts} +33 -16
  4. package/dist/ai-Cz3hqVhk.d.ts.map +1 -0
  5. package/dist/ai-client.d.ts +1 -1
  6. package/dist/ai-client.js +1 -1
  7. package/dist/ai-control-CvLbW10B.js +46 -0
  8. package/dist/ai-control-CvLbW10B.js.map +1 -0
  9. package/dist/ai-fx.d.ts +1 -1
  10. package/dist/ai-fx.d.ts.map +1 -1
  11. package/dist/ai-fx.js +63 -13
  12. package/dist/ai-fx.js.map +1 -1
  13. package/dist/{ai-harness-B2suP2Qh.d.ts → ai-harness-CV6rXbB3.d.ts} +2 -2
  14. package/dist/ai-harness-CV6rXbB3.d.ts.map +1 -0
  15. package/dist/{ai-harness-BuLh_EYi.js → ai-harness-Clbg5ei7.js} +88 -15
  16. package/dist/ai-harness-Clbg5ei7.js.map +1 -0
  17. package/dist/{ai-D6mGlO8j.js → ai-mMiWul1T.js} +116 -77
  18. package/dist/ai-mMiWul1T.js.map +1 -0
  19. package/dist/ai-pi.d.ts +1 -1
  20. package/dist/ai-pi.d.ts.map +1 -1
  21. package/dist/ai-pi.js +56 -11
  22. package/dist/ai-pi.js.map +1 -1
  23. package/dist/ai-react.d.ts +1 -1
  24. package/dist/ai-react.js +1 -1
  25. package/dist/ai-server.d.ts +2 -2
  26. package/dist/ai-server.d.ts.map +1 -1
  27. package/dist/ai-server.js +123 -53
  28. package/dist/ai-server.js.map +1 -1
  29. package/dist/ai.d.ts +2 -2
  30. package/dist/ai.js +1 -1
  31. package/dist/internal-CscSY7K_.js.map +1 -1
  32. package/dist/scheduler-qstash.d.ts.map +1 -1
  33. package/dist/scheduler-qstash.js +5 -5
  34. package/dist/scheduler-qstash.js.map +1 -1
  35. package/dist/scheduler-vercel.d.ts.map +1 -1
  36. package/dist/scheduler-vercel.js +5 -7
  37. package/dist/scheduler-vercel.js.map +1 -1
  38. package/dist/server-DYAATFuT.d.ts.map +1 -1
  39. package/dist/{server-D-Y4pqwu.js → server-qyb5aJJG.js} +39 -25
  40. package/dist/server-qyb5aJJG.js.map +1 -0
  41. package/dist/server.js +1 -1
  42. package/dist/session-clear-DGI4jTQ5.js.map +1 -1
  43. package/dist/store-postgres.d.ts.map +1 -1
  44. package/dist/store-postgres.js +38 -136
  45. package/dist/store-postgres.js.map +1 -1
  46. package/docs/01-quickstart.mdx +7 -4
  47. package/docs/actors/01-introduction.mdx +1 -1
  48. package/docs/actors/meta.ts +1 -1
  49. package/docs/agents/02-tools.mdx +1 -1
  50. package/docs/agents/03-conversations.mdx +26 -14
  51. package/docs/agents/04-harnesses.mdx +51 -5
  52. package/docs/agents/meta.ts +1 -1
  53. package/docs/client/meta.ts +1 -1
  54. package/docs/contract/03-sessions.mdx +2 -2
  55. package/docs/index.mdx +2 -2
  56. package/docs/reference/01-core.mdx +8 -8
  57. package/docs/reference/02-server.mdx +3 -3
  58. package/docs/reference/04-ai.mdx +36 -19
  59. package/docs/reference/05-telemetry.mdx +132 -0
  60. package/docs/reference/06-errors.mdx +1 -1
  61. package/docs/reference/meta.ts +1 -1
  62. package/docs/schedulers/01-overview.mdx +112 -0
  63. package/docs/schedulers/02-vercel-queues.mdx +87 -0
  64. package/docs/schedulers/03-qstash.mdx +123 -0
  65. package/docs/schedulers/meta.ts +1 -0
  66. package/docs/server/01-handlers.mdx +29 -2
  67. package/docs/server/02-timers.mdx +3 -3
  68. package/docs/server/06-guarantees.mdx +6 -3
  69. package/docs/stores/01-overview.mdx +37 -0
  70. package/docs/stores/02-redis.mdx +49 -0
  71. package/docs/stores/03-redis-http.mdx +56 -0
  72. package/docs/stores/04-postgres.mdx +121 -0
  73. package/docs/stores/05-sqlite.mdx +34 -0
  74. package/docs/stores/06-memory.mdx +22 -0
  75. package/docs/stores/meta.ts +1 -0
  76. package/examples/playground/DEPLOY.md +19 -5
  77. package/examples/playground/app/a0/a0.css +244 -5
  78. package/examples/playground/app/a0/app-view.tsx +2 -1
  79. package/examples/playground/app/a0/apps/[appId]/route.ts +6 -10
  80. package/examples/playground/app/a0/attachments.ts +133 -0
  81. package/examples/playground/app/a0/chat.test.tsx +595 -0
  82. package/examples/playground/app/a0/chat.tsx +623 -63
  83. package/examples/playground/app/a0/context-menu.tsx +38 -11
  84. package/examples/playground/app/a0/model.test.ts +28 -0
  85. package/examples/playground/app/a0/model.ts +30 -0
  86. package/examples/playground/app/a0/server.ts +5 -0
  87. package/examples/playground/app/a0/shell.tsx +1 -1
  88. package/examples/playground/app/a0/uploads/route.test.ts +59 -0
  89. package/examples/playground/app/a0/uploads/route.ts +29 -0
  90. package/examples/playground/app/agent/[agentId]/agent-client.tsx +2 -1
  91. package/examples/playground/app/agent/[agentId]/agent-queue.test.tsx +116 -89
  92. package/examples/playground/app/agent/[agentId]/compaction-panel.test.tsx +107 -76
  93. package/examples/playground/app/agent/ingress.test.ts +41 -17
  94. package/examples/playground/app/agent/model-input.test.tsx +92 -43
  95. package/examples/playground/app/agent/model.test.ts +12 -3
  96. package/examples/playground/app/chat/[chatId]/agent-state.test.ts +1 -5
  97. package/examples/playground/app/chat/[chatId]/agent-stream-drawer.test.tsx +2 -2
  98. package/examples/playground/app/chat/server.test.ts +38 -65
  99. package/examples/playground/app/components/activity-history.test.tsx +60 -18
  100. package/examples/playground/app/components/fast-link.test.tsx +3 -5
  101. package/examples/playground/app/initial-state.test.tsx +86 -45
  102. package/examples/playground/next.config.ts +7 -4
  103. package/examples/playground/package.json +4 -1
  104. package/examples/playground/test/agent.ts +35 -0
  105. package/examples/playground/test/model.ts +149 -0
  106. package/examples/playground/test/serve.ts +184 -0
  107. package/examples/playground/test/server-only.ts +1 -0
  108. package/examples/playground/vitest.config.ts +27 -0
  109. package/package.json +3 -1
  110. package/sql/postgres/notifications.sql +24 -0
  111. package/sql/postgres/schema.sql +137 -0
  112. package/src/ai-agent.ts +9 -4
  113. package/src/ai-client-state.ts +21 -9
  114. package/src/ai-context-schema.ts +28 -4
  115. package/src/ai-context.ts +83 -3
  116. package/src/ai-control-server.ts +70 -43
  117. package/src/ai-control-state.ts +3 -4
  118. package/src/ai-control.ts +59 -10
  119. package/src/ai-coordinator.ts +7 -6
  120. package/src/ai-fx.ts +95 -23
  121. package/src/ai-harness.ts +171 -29
  122. package/src/ai-message-shape.ts +41 -0
  123. package/src/ai-pi.ts +88 -32
  124. package/src/ai-stored-state.ts +1 -3
  125. package/src/ai.ts +116 -77
  126. package/src/errors.ts +1 -1
  127. package/src/internal.ts +2 -1
  128. package/src/libfx.d.ts +8 -1
  129. package/src/postgres-notifications.ts +0 -22
  130. package/src/postgres-resources.ts +6 -9
  131. package/src/postgres-schema.ts +80 -0
  132. package/src/scheduler-qstash.ts +12 -5
  133. package/src/scheduler-vercel.ts +11 -16
  134. package/src/server.ts +66 -32
  135. package/src/store-postgres.ts +5 -123
  136. package/dist/ai-BMLlI2Ff.d.ts.map +0 -1
  137. package/dist/ai-D6mGlO8j.js.map +0 -1
  138. package/dist/ai-harness-B2suP2Qh.d.ts.map +0 -1
  139. package/dist/ai-harness-BuLh_EYi.js.map +0 -1
  140. package/dist/server-D-Y4pqwu.js.map +0 -1
  141. package/docs/reference/05-adapters.mdx +0 -351
  142. package/docs/server/05-production.mdx +0 -323
  143. package/examples/playground/app/a0/no-photon.cjs +0 -4
@@ -1,30 +1,57 @@
1
1
  // @vitest-environment jsdom
2
- import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
3
- import { afterEach, beforeEach, expect, it, vi } from 'vitest'
4
- import { demoAgent } from '../model'
5
- import type { AgentSession } from '../session'
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
- const loadHistory = vi.fn<AgentSession['loadHistory']>()
9
- const push = vi.fn<AgentSession['push']>()
10
- let session: AgentSession
11
-
12
- beforeEach(() => {
13
- loadHistory.mockReset().mockResolvedValue([])
14
- push.mockReset()
15
- session = {
16
- state: demoAgent.reducer.initialState,
17
- hydrated: true,
18
- events: [],
19
- index: 10,
20
- connection: { status: 'live', reconnects: 0 },
21
- history: { complete: false, loading: false, oldestLoaded: null },
22
- loadHistory,
23
- push,
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
- afterEach(cleanup)
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
- session.state = {
38
- ...session.state,
39
- compactionSettings: { mode: 'manual', thresholdTokens: 1234 },
40
- }
41
- const view = render(<CompactionPanel session={session} />)
42
- expect((screen.getByLabelText('Mode') as HTMLSelectElement).value).toBe(
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
- expect(
46
- (screen.getByLabelText('Threshold in tokens') as HTMLInputElement).value,
47
- ).toBe('1234')
48
- expect(push).not.toHaveBeenCalled()
49
- expect(loadHistory).not.toHaveBeenCalled()
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(loadHistory).toHaveBeenCalledTimes(1)
83
+ await waitFor(() => expect(operations('history')).toBe(1))
54
84
  await toggle(panel, false)
55
85
  await toggle(panel, true)
56
- expect(loadHistory).toHaveBeenCalledTimes(1)
57
- session.state = {
58
- ...session.state,
59
- compactionSettings: { mode: 'disabled' },
60
- compactionSettingsId: 'new',
61
- }
62
- session.events = [
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
- loadHistory.mockRejectedValueOnce(new Error('History failed'))
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
- <CompactionPanel session={session} />
106
+ <Panel initial={initial} />
85
107
  </>,
86
108
  )
87
109
  await toggle(view.container.querySelector('details')!, true)
88
- expect(screen.getByRole('alert').textContent).toBe('History failed')
89
- expect(screen.getByLabelText('composer')).toBeDefined()
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(loadHistory).toHaveBeenCalledTimes(2)
99
- expect(screen.queryByRole('alert')).toBeNull()
100
- expect(push).not.toHaveBeenCalled()
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
- push.mockImplementationOnce(() =>
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
- expect(push).toHaveBeenCalledWith({
115
- type: 'compaction.settings.updated',
116
- payload: { mode: 'manual', thresholdTokens: 1 },
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, vi } from 'vitest'
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('forwards both HTTP and WebSocket entry requests to the agent', async () => {
85
- const fetch = vi
86
- .fn<(request: Request) => Promise<Response>>()
87
- .mockResolvedValue(new Response(null))
88
- vi.doMock('./server', () => ({ agentServer: { fetch } }))
89
- try {
90
- const route = await import('./events/route')
91
- const read = new Request('http://test/events', {
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
- const write = new Request('http://test/events', { method: 'POST' })
95
- await route.GET(read)
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 { cleanup, fireEvent, render, screen } from '@testing-library/react'
3
- import { afterEach, expect, it, vi } from 'vitest'
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
- afterEach(cleanup)
14
+ let served: ServedAgent
15
+ let server: ServedAgent['server']
16
+ let probe: ServedAgent['probe']
17
+ let id: string
8
18
 
9
- const renderInput = (value: ModelChoice) => {
10
- const onChange = vi.fn<(settings: ModelChoice) => void>()
11
- const view = render(
12
- <ModelInput value={value} onChange={onChange} disabled={false} />,
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
- it('commits a typed model on blur or Enter, not per keystroke', () => {
25
- const { onChange, model, update } = renderInput({ reasoning: 'high' })
26
- fireEvent.change(model, { target: { value: 'openai/gpt' } })
27
- fireEvent.change(model, { target: { value: ' provider/model ' } })
28
- expect(onChange).not.toHaveBeenCalled()
29
- expect(model.value).toBe(' provider/model ')
30
- fireEvent.blur(model)
31
- expect(onChange).toHaveBeenCalledExactlyOnceWith({
32
- reasoning: 'high',
33
- model: 'provider/model',
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
- update({ reasoning: 'high', model: 'provider/model' })
37
- expect(model.value).toBe('provider/model')
38
- fireEvent.change(model, { target: { value: '' } })
39
- fireEvent.keyDown(model, { key: 'Enter' })
40
- expect(onChange).toHaveBeenLastCalledWith({ reasoning: 'high' })
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
- const { onChange } = renderInput({
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
- expect(onChange).toHaveBeenLastCalledWith({ model: 'provider/model' })
91
+ await waitFor(async () =>
92
+ expect(await chosen()).toEqual({ model: 'provider/model' }),
93
+ )
52
94
  fireEvent.click(screen.getByRole('button', { name: 'Prefer fast' }))
53
- expect(onChange).toHaveBeenLastCalledWith({
54
- model: 'provider/model',
55
- reasoning: 'low',
56
- speed: 'fast',
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
- event(2, 'ai.message.created', {
33
- message: { id: 'user', role: 'user', parts: [] },
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, vi } from 'vitest'
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={vi.fn<() => void>()}
92
+ onClose={() => {}}
93
93
  />,
94
94
  )
95
95
 
@@ -1,68 +1,13 @@
1
- import type { UIMessageChunk } from 'ai'
2
- import { expect, it, vi } from 'vitest'
1
+ import { afterEach, expect, it, vi } from 'vitest'
2
+ import { testModel, toolCall } from '@/test/model'
3
+ import { chatAgentServer, chatServer } from './server'
3
4
 
4
- vi.mock('server-only', () => ({}))
5
- vi.mock('@/lib/store', async () => {
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 { chatAgentServer, chatServer } = await import('./server')
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(prompts).toHaveLength(2)
54
+ expect(model.steps).toHaveLength(2)
96
55
  expect(
97
- prompts[0]!.split(',').filter((id) => id.startsWith('user:')),
98
- ).toHaveLength(2)
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 [post] = (await room.history()).filter(
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 })