experimental-a2 0.18.1 → 0.19.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 (78) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/dist/{ai-BMLlI2Ff.d.ts → ai-Cz3hqVhk.d.ts} +33 -16
  3. package/dist/ai-Cz3hqVhk.d.ts.map +1 -0
  4. package/dist/ai-client.d.ts +1 -1
  5. package/dist/ai-client.js +1 -1
  6. package/dist/ai-control-CvLbW10B.js +46 -0
  7. package/dist/ai-control-CvLbW10B.js.map +1 -0
  8. package/dist/ai-fx.d.ts +1 -1
  9. package/dist/ai-fx.d.ts.map +1 -1
  10. package/dist/ai-fx.js +30 -10
  11. package/dist/ai-fx.js.map +1 -1
  12. package/dist/{ai-harness-BuLh_EYi.js → ai-harness-BuuC-gJ3.js} +79 -15
  13. package/dist/ai-harness-BuuC-gJ3.js.map +1 -0
  14. package/dist/{ai-harness-B2suP2Qh.d.ts → ai-harness-CV6rXbB3.d.ts} +2 -2
  15. package/dist/ai-harness-CV6rXbB3.d.ts.map +1 -0
  16. package/dist/{ai-D6mGlO8j.js → ai-mMiWul1T.js} +116 -77
  17. package/dist/ai-mMiWul1T.js.map +1 -0
  18. package/dist/ai-pi.d.ts +1 -1
  19. package/dist/ai-pi.d.ts.map +1 -1
  20. package/dist/ai-pi.js +55 -10
  21. package/dist/ai-pi.js.map +1 -1
  22. package/dist/ai-react.d.ts +1 -1
  23. package/dist/ai-react.js +1 -1
  24. package/dist/ai-server.d.ts +2 -2
  25. package/dist/ai-server.d.ts.map +1 -1
  26. package/dist/ai-server.js +118 -50
  27. package/dist/ai-server.js.map +1 -1
  28. package/dist/ai.d.ts +2 -2
  29. package/dist/ai.js +1 -1
  30. package/dist/scheduler-vercel.d.ts.map +1 -1
  31. package/dist/scheduler-vercel.js +1 -4
  32. package/dist/scheduler-vercel.js.map +1 -1
  33. package/docs/agents/03-conversations.mdx +25 -13
  34. package/docs/agents/04-harnesses.mdx +51 -5
  35. package/docs/contract/03-sessions.mdx +1 -1
  36. package/docs/reference/04-ai.mdx +27 -18
  37. package/docs/reference/05-adapters.mdx +2 -2
  38. package/docs/server/02-timers.mdx +1 -1
  39. package/docs/server/05-production.mdx +2 -2
  40. package/examples/playground/DEPLOY.md +9 -0
  41. package/examples/playground/app/a0/a0.css +244 -5
  42. package/examples/playground/app/a0/app-view.tsx +2 -1
  43. package/examples/playground/app/a0/attachments.ts +133 -0
  44. package/examples/playground/app/a0/chat.test.tsx +504 -0
  45. package/examples/playground/app/a0/chat.tsx +623 -63
  46. package/examples/playground/app/a0/context-menu.tsx +38 -11
  47. package/examples/playground/app/a0/model.test.ts +28 -0
  48. package/examples/playground/app/a0/model.ts +30 -0
  49. package/examples/playground/app/a0/server.ts +5 -0
  50. package/examples/playground/app/a0/uploads/route.ts +29 -0
  51. package/examples/playground/app/agent/[agentId]/agent-client.tsx +2 -1
  52. package/examples/playground/app/agent/[agentId]/agent-queue.test.tsx +1 -0
  53. package/examples/playground/app/agent/model.test.ts +12 -3
  54. package/examples/playground/app/chat/[chatId]/agent-state.test.ts +1 -5
  55. package/examples/playground/next.config.ts +7 -4
  56. package/examples/playground/package.json +3 -1
  57. package/package.json +1 -1
  58. package/src/ai-agent.ts +4 -0
  59. package/src/ai-client-state.ts +21 -9
  60. package/src/ai-context-schema.ts +28 -4
  61. package/src/ai-context.ts +83 -3
  62. package/src/ai-control-server.ts +70 -43
  63. package/src/ai-control-state.ts +3 -4
  64. package/src/ai-control.ts +59 -10
  65. package/src/ai-coordinator.ts +7 -6
  66. package/src/ai-fx.ts +58 -20
  67. package/src/ai-harness.ts +153 -29
  68. package/src/ai-message-shape.ts +41 -0
  69. package/src/ai-pi.ts +88 -32
  70. package/src/ai-stored-state.ts +1 -3
  71. package/src/ai.ts +116 -77
  72. package/src/libfx.d.ts +8 -1
  73. package/src/scheduler-vercel.ts +1 -12
  74. package/dist/ai-BMLlI2Ff.d.ts.map +0 -1
  75. package/dist/ai-D6mGlO8j.js.map +0 -1
  76. package/dist/ai-harness-B2suP2Qh.d.ts.map +0 -1
  77. package/dist/ai-harness-BuLh_EYi.js.map +0 -1
  78. package/examples/playground/app/a0/no-photon.cjs +0 -4
@@ -1,5 +1,5 @@
1
1
  'use client'
2
- import type { KeyboardEvent, ReactNode } from 'react'
2
+ import type { KeyboardEvent, ReactNode, RefObject } from 'react'
3
3
  import {
4
4
  useEffect,
5
5
  useEffectEvent,
@@ -7,27 +7,38 @@ import {
7
7
  useRef,
8
8
  useState,
9
9
  } from 'react'
10
+ import { Check } from 'lucide-react'
10
11
 
11
12
  export type MenuItem = {
12
13
  label: string
13
14
  onSelect: () => void
14
15
  danger?: boolean
16
+ /** One of a set of choices; a checked one is the current choice. */
17
+ checked?: boolean
15
18
  }
16
19
 
20
+ const ITEMS = '[role="menuitem"], [role="menuitemradio"]'
21
+
17
22
  // Keeps the menu this far inside the window's edges.
18
23
  const MARGIN = 8
19
24
 
20
25
  /**
21
- * A menu at a point, as a right-click opens one. It takes focus, moves with
22
- * the arrow keys, and closes on Escape, a press outside, or a pick; Escape
23
- * and a pick hand focus back through `onClose`.
26
+ * A menu at a point, as a right-click opens one. The point is the menu's top
27
+ * left corner, or its bottom right for a button near the window's foot. It
28
+ * takes focus, moves with the arrow keys, and closes on Escape, a press
29
+ * outside, or a pick; Escape and a pick hand focus back through `onClose`.
24
30
  */
25
31
  export function ContextMenu({
26
32
  at,
33
+ corner = 'top-left',
34
+ trigger,
27
35
  items,
28
36
  onClose,
29
37
  }: {
30
38
  at: { x: number; y: number }
39
+ corner?: 'top-left' | 'bottom-right'
40
+ /** The button that opens the menu; a press on it is its own, not outside. */
41
+ trigger?: RefObject<HTMLElement | null>
31
42
  items: MenuItem[]
32
43
  onClose: (closed: { returnFocus: boolean }) => void
33
44
  }): ReactNode {
@@ -39,19 +50,26 @@ export function ContextMenu({
39
50
  const element = menu.current
40
51
  if (!element) return
41
52
  const { width, height } = element.getBoundingClientRect()
53
+ const x = corner === 'top-left' ? at.x : at.x - width
54
+ const y = corner === 'top-left' ? at.y : at.y - height
42
55
  setPosition({
43
- x: Math.max(MARGIN, Math.min(at.x, innerWidth - width - MARGIN)),
44
- y: Math.max(MARGIN, Math.min(at.y, innerHeight - height - MARGIN)),
56
+ x: Math.max(MARGIN, Math.min(x, innerWidth - width - MARGIN)),
57
+ y: Math.max(MARGIN, Math.min(y, innerHeight - height - MARGIN)),
45
58
  })
46
- element.querySelector<HTMLElement>('[role="menuitem"]')?.focus()
47
- }, [at])
59
+ element.querySelector<HTMLElement>(ITEMS)?.focus()
60
+ }, [at, corner])
48
61
 
49
62
  const dismiss = useEffectEvent((returnFocus: boolean) =>
50
63
  onClose({ returnFocus }),
51
64
  )
52
65
  useEffect(() => {
53
66
  const outside = (event: PointerEvent): void => {
54
- if (!menu.current?.contains(event.target as Node)) dismiss(false)
67
+ const target = event.target as Node
68
+ if (
69
+ !menu.current?.contains(target) &&
70
+ !trigger?.current?.contains(target)
71
+ )
72
+ dismiss(false)
55
73
  }
56
74
  const away = (): void => dismiss(false)
57
75
  document.addEventListener('pointerdown', outside, true)
@@ -66,7 +84,7 @@ export function ContextMenu({
66
84
 
67
85
  const move = (event: KeyboardEvent<HTMLDivElement>): void => {
68
86
  const buttons = [
69
- ...event.currentTarget.querySelectorAll<HTMLElement>('[role="menuitem"]'),
87
+ ...event.currentTarget.querySelectorAll<HTMLElement>(ITEMS),
70
88
  ]
71
89
  const focused = buttons.indexOf(document.activeElement as HTMLElement)
72
90
  const next =
@@ -101,13 +119,22 @@ export function ContextMenu({
101
119
  <button
102
120
  key={item.label}
103
121
  type="button"
104
- role="menuitem"
122
+ role={item.checked === undefined ? 'menuitem' : 'menuitemradio'}
123
+ aria-checked={item.checked}
105
124
  data-danger={item.danger || undefined}
106
125
  onClick={() => {
107
126
  onClose({ returnFocus: true })
108
127
  item.onSelect()
109
128
  }}
110
129
  >
130
+ {item.checked !== undefined && (
131
+ <Check
132
+ size={12}
133
+ className="a0-menu-check"
134
+ visibility={item.checked ? 'visible' : 'hidden'}
135
+ aria-hidden
136
+ />
137
+ )}
111
138
  {item.label}
112
139
  </button>
113
140
  ))}
@@ -71,3 +71,31 @@ it("reads the app's harness from its URL, the AI SDK's by default", () => {
71
71
  'a0-pi',
72
72
  ])
73
73
  })
74
+
75
+ const sendWithFile = (url: string) =>
76
+ appAgent.events['ai.control.requested']['~standard'].validate({
77
+ action: 'send',
78
+ message: {
79
+ id: 'u1',
80
+ role: 'user',
81
+ parts: [
82
+ { type: 'file', mediaType: 'image/png', url },
83
+ { type: 'text', text: 'Like this.' },
84
+ ],
85
+ },
86
+ })
87
+
88
+ it("only takes a user's files from the playground's Blob store", () => {
89
+ expect(
90
+ sendWithFile(
91
+ 'https://store.public.blob.vercel-storage.com/a0/app/shot-x1.png',
92
+ ),
93
+ ).not.toHaveProperty('issues')
94
+ for (const url of [
95
+ 'data:image/png;base64,iVBORw0KGgo=',
96
+ 'http://store.public.blob.vercel-storage.com/shot.png',
97
+ 'http://169.254.169.254/latest/meta-data',
98
+ 'https://example.com/shot.png',
99
+ ])
100
+ expect(sendWithFile(url)).toHaveProperty('issues')
101
+ })
@@ -35,6 +35,35 @@ export const defaultModelChoice: ModelChoice = {
35
35
 
36
36
  export type AppMessage = UIMessage
37
37
 
38
+ /** A file the user attached, uploaded to the playground's public Blob store. */
39
+ export function isAttachmentUrl(value: string): boolean {
40
+ try {
41
+ const { protocol, hostname } = new URL(value)
42
+ return (
43
+ protocol === 'https:' &&
44
+ hostname.endsWith('.public.blob.vercel-storage.com')
45
+ )
46
+ } catch {
47
+ return false
48
+ }
49
+ }
50
+
51
+ /**
52
+ * A user's files must be attachments. Whatever hands them to the model
53
+ * fetches them by URL, so the URL can't point anywhere else, and a message
54
+ * never holds a file's bytes.
55
+ */
56
+ const messageSchema = z
57
+ .custom<AppMessage>()
58
+ .refine(
59
+ (message) =>
60
+ message.role !== 'user' ||
61
+ message.parts.every(
62
+ (part) => part.type !== 'file' || isAttachmentUrl(part.url),
63
+ ),
64
+ "A message's files must be uploaded attachments",
65
+ )
66
+
38
67
  /**
39
68
  * The loop an app's agent runs: the AI SDK's, FX's, or Pi's. An
40
69
  * app picks it before its first message and keeps it.
@@ -56,6 +85,7 @@ export const harnessFrom = (value: unknown): Harness =>
56
85
  const appContract = (name: string) =>
57
86
  agentContract({
58
87
  name,
88
+ messageSchema,
59
89
  events: { 'model.chosen': modelChoiceSchema },
60
90
  reducer: {
61
91
  name: 'a0.model-choice.v1',
@@ -5,6 +5,7 @@ import { handlerContext } from 'experimental-a2/ai'
5
5
  import { fx } from 'experimental-a2/ai/fx'
6
6
  import { pi } from 'experimental-a2/ai/pi'
7
7
  import { agent } from 'experimental-a2/ai/server'
8
+ import { otel } from 'experimental-a2/otel'
8
9
  import { createServer } from 'experimental-a2/server'
9
10
  import { vercelQueues } from 'experimental-a2/scheduler-vercel'
10
11
  import { z } from 'zod'
@@ -33,6 +34,7 @@ const instructions = (
33
34
  - If the preview stops answering, read /tmp/next.log, then start next dev with \`${START_DEV}\`; it does nothing while one is already running.
34
35
  - next.config allows the preview's *.vercel.run host (allowedDevOrigins); keep that when you edit it.
35
36
  - Background anything long-running; a bash call stops after its timeout.
37
+ - The user can attach files. Images come with their message; any other file is a "name: URL" line in it. Download one with curl when you need its contents.
36
38
  - Keep replies short and in plain text, without Markdown: what you changed.`
37
39
 
38
40
  /** A tool's result, with how long it took; the chat shows it. */
@@ -118,10 +120,13 @@ function chosenModel({
118
120
  return { model, reasoning, speed: fast ? 'fast' : 'standard' } as const
119
121
  }
120
122
 
123
+ const telemetry = otel()
124
+
121
125
  const shared = (harness: Harness) => ({
122
126
  ...(store ? { store } : {}),
123
127
  ...(appSchedulers[harness] ? { scheduler: appSchedulers[harness] } : {}),
124
128
  contract: harnessAgents[harness],
129
+ telemetry,
125
130
  maxSteps: 80,
126
131
  })
127
132
 
@@ -0,0 +1,29 @@
1
+ import { handleUpload, type HandleUploadBody } from '@vercel/blob/client'
2
+ import { isAppId } from '../apps'
3
+
4
+ /** Tokens for the browser to upload an app's attachments straight to Blob. */
5
+ export async function POST(request: Request): Promise<Response> {
6
+ const body = (await request.json()) as HandleUploadBody
7
+ try {
8
+ return Response.json(
9
+ await handleUpload({
10
+ request,
11
+ body,
12
+ onBeforeGenerateToken: async (pathname, appId) => {
13
+ if (
14
+ appId === null ||
15
+ !isAppId(appId) ||
16
+ !pathname.startsWith(`a0/${appId}/`)
17
+ )
18
+ throw new Error("An attachment goes in its app's folder")
19
+ return { addRandomSuffix: true }
20
+ },
21
+ }),
22
+ )
23
+ } catch (cause) {
24
+ return Response.json(
25
+ { error: cause instanceof Error ? cause.message : String(cause) },
26
+ { status: 400 },
27
+ )
28
+ }
29
+ }
@@ -393,10 +393,11 @@ export function AgentClient({
393
393
  >
394
394
  Stop
395
395
  </button>
396
+ {/* With no turn to steer, steering would only send. */}
396
397
  <button
397
398
  type="button"
398
399
  className="secondary"
399
- disabled={!canSend || !prompt.trim()}
400
+ disabled={!canSend || !prompt.trim() || !state.active}
400
401
  onClick={() => void send(undefined, true)}
401
402
  >
402
403
  Steer
@@ -14,6 +14,7 @@ const queue = (edit: Edit) =>
14
14
  items={[
15
15
  {
16
16
  id: 'queued',
17
+ role: 'user',
17
18
  message: {
18
19
  id: 'queued',
19
20
  role: 'user',
@@ -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 }) => {
@@ -6,13 +6,16 @@ import type { NextConfig } from 'next'
6
6
 
7
7
  const nextConfig: NextConfig = {
8
8
  // libfx loads a native binary for the platform, falling back to its
9
- // WebAssembly, by paths the bundler cannot follow; Node loads both from
10
- // node_modules at runtime.
11
- serverExternalPackages: ['libfx'],
9
+ // WebAssembly, and photon, which Pi resizes images with, loads its
10
+ // WebAssembly, by paths the bundler cannot follow; Node loads them from
11
+ // node_modules at runtime. Pi stays bundled (externalized, it makes
12
+ // Turbopack panic on its WebAssembly), so it finds photon through this
13
+ // app: package.json depends on photon at the version Pi pins, and the two
14
+ // move together.
15
+ serverExternalPackages: ['libfx', '@silvia-odwyer/photon-node'],
12
16
  turbopack: {
13
17
  // WebAssembly Pi's code reaches that the bundler cannot trace.
14
18
  resolveAlias: {
15
- '@silvia-odwyer/photon-node': './app/a0/no-photon.cjs',
16
19
  'quickjs-wasi': './app/a0/no-quickjs.cjs',
17
20
  'quickjs-wasi/quickjs.wasm': './app/a0/no-quickjs.cjs',
18
21
  },
@@ -22,13 +22,15 @@
22
22
  "@earendil-works/pi-ai": "1.0.3",
23
23
  "@earendil-works/pi-coding-agent": "1.0.2",
24
24
  "@opentelemetry/api": "^1.9.0",
25
+ "@silvia-odwyer/photon-node": "0.3.4",
26
+ "@vercel/blob": "^2.8.0",
25
27
  "@vercel/functions": "^3.9.3",
26
28
  "@vercel/otel": "^2.1.0",
27
29
  "@vercel/queue": "^0.2.0",
28
30
  "@vercel/sandbox": "^3.0.0",
29
31
  "ai": "^7.0.58",
30
32
  "codemirror": "^6.0.2",
31
- "experimental-a2": "0.18.1",
33
+ "experimental-a2": "0.19.0",
32
34
  "geist": "^1.7.2",
33
35
  "ioredis": "^5.9.0",
34
36
  "libfx": "0.0.12",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "experimental-a2",
3
- "version": "0.18.1",
3
+ "version": "0.19.0",
4
4
  "description": "Syncs your immutable event log, from server to browser: one contract, handlers, derived state, and a live client per session.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/ai-agent.ts CHANGED
@@ -1282,6 +1282,10 @@ export function createHandlers<
1282
1282
  throw new TypeError(
1283
1283
  `a harness runs tools without approval; '${name}' sets needsApproval`,
1284
1284
  )
1285
+ if (tool.type === 'provider')
1286
+ throw new TypeError(
1287
+ `a harness runs only tools A2 executes; '${name}' is provider-defined`,
1288
+ )
1285
1289
  }
1286
1290
  }
1287
1291
 
@@ -1,6 +1,11 @@
1
1
  import type { UIMessage } from 'ai'
2
2
  import type { AIState } from './ai.ts'
3
- import type { ControlCommand, InboxItem } from './ai-control.ts'
3
+ import {
4
+ steeringEnd,
5
+ steersOn,
6
+ type ControlCommand,
7
+ type InboxItem,
8
+ } from './ai-control.ts'
4
9
  import {
5
10
  interruptGenerationProjection,
6
11
  settleAbandonedCompaction,
@@ -92,10 +97,10 @@ export function projectAIClientState<M extends UIMessage>(
92
97
  const admit = (): void => {
93
98
  if (turnId !== null || paused || items.length === 0) return
94
99
  let count = 0
95
- for (const item of items) {
100
+ for (const [index, item] of items.entries()) {
96
101
  count += 1
97
102
  show(item.message)
98
- if (item.generate) {
103
+ if (item.generate && !steersOn(item, items.slice(index + 1))) {
99
104
  turnId = item.id
100
105
  starting = true
101
106
  suspended = false
@@ -118,18 +123,21 @@ export function projectAIClientState<M extends UIMessage>(
118
123
  ? { afterStepOf: command.turnId }
119
124
  : {}),
120
125
  id: command.message.id,
126
+ role: command.message.role,
121
127
  message: command.message,
122
128
  generate:
123
129
  command.message.role === 'user' &&
124
130
  (command.action === 'steer' || command.generate !== false),
125
131
  }
126
- items = command.action === 'steer' ? [item, ...items] : [...items, item]
132
+ if (command.action === 'steer') {
133
+ const at = steeringEnd(items, command.turnId)
134
+ items = [...items.slice(0, at), item, ...items.slice(at)]
135
+ } else items = [...items, item]
127
136
  break
128
137
  }
129
138
  case 'edit':
130
139
  items = items.map((item) =>
131
- item.id === command.message.id &&
132
- item.message.role === command.message.role
140
+ item.id === command.message.id && item.role === command.message.role
133
141
  ? { ...item, message: command.message }
134
142
  : item,
135
143
  )
@@ -147,20 +155,24 @@ export function projectAIClientState<M extends UIMessage>(
147
155
  case 'send-now': {
148
156
  if (turnId !== command.turnId) break
149
157
  const selected = items.find((item) => item.id === command.inputId)
150
- if (!selected || selected.message.role !== 'user') break
158
+ if (!selected || selected.role !== 'user') break
159
+ const { afterStepOf: _, ...promoted } = selected
151
160
  items = items.map((item) =>
152
161
  item.id === selected.id
153
162
  ? {
154
- ...item,
163
+ ...promoted,
155
164
  generate: true,
156
165
  ...(turnId === null ? {} : { afterStepOf: turnId }),
157
166
  }
158
167
  : item,
159
168
  )
169
+ const others = items.filter((item) => item.id !== selected.id)
160
170
  items = moveItem({
161
171
  items,
162
172
  inputId: command.inputId,
163
- beforeId: items[0]?.id ?? null,
173
+ beforeId:
174
+ others[turnId === null ? 0 : steeringEnd(others, turnId)]?.id ??
175
+ null,
164
176
  })
165
177
  break
166
178
  }
@@ -1,6 +1,7 @@
1
1
  import { assertMessageBase, restoreMessageBase } from './ai-message-base.ts'
2
2
  import type { UIMessage } from 'ai'
3
3
  import { events } from './ai.ts'
4
+ import { isUIMessage } from './ai-message-shape.ts'
4
5
  import type { AIContextState } from './ai-context.ts'
5
6
  import type { StandardSchemaV1 } from './standard-schema.ts'
6
7
  import { validateSync } from './validate.ts'
@@ -14,13 +15,34 @@ const id = (value: unknown): value is string =>
14
15
  const nullableId = (value: unknown): boolean => value === null || id(value)
15
16
  const matches = (schema: StandardSchemaV1<unknown>, value: unknown): boolean =>
16
17
  validateSync(schema, value, 'AI context checkpoint').issues === undefined
17
- const isMessage = (value: unknown): value is UIMessage =>
18
- matches(events['ai.message.created'], { message: value })
19
18
  const indexes = (value: unknown, messages: readonly UIMessage[]): boolean =>
20
19
  record(value) &&
21
20
  Object.values(value).every(index) &&
22
21
  messages.every((message) => Object.hasOwn(value, message.id))
23
22
 
23
+ const inputs = (value: unknown): boolean =>
24
+ record(value) &&
25
+ Object.keys(value).length === 2 &&
26
+ Array.isArray(value['commands']) &&
27
+ value['commands'].every(
28
+ (held) =>
29
+ record(held) &&
30
+ Object.keys(held).every((key) =>
31
+ ['commandId', 'inputId', 'message'].includes(key),
32
+ ) &&
33
+ id(held['commandId']) &&
34
+ id(held['inputId']) &&
35
+ (held['message'] === undefined || isUIMessage(held['message'])),
36
+ ) &&
37
+ Array.isArray(value['waiting']) &&
38
+ value['waiting'].every(
39
+ (input) =>
40
+ record(input) &&
41
+ Object.keys(input).length === 2 &&
42
+ id(input['inputId']) &&
43
+ isUIMessage(input['message']),
44
+ )
45
+
24
46
  const compaction = (value: unknown): boolean =>
25
47
  value === null ||
26
48
  (record(value) &&
@@ -109,7 +131,7 @@ const restorableBase = (options: {
109
131
  current: UIMessage | undefined
110
132
  }): boolean => {
111
133
  assertMessageBase(options.base)
112
- return isMessage(
134
+ return isUIMessage(
113
135
  restoreMessageBase({ current: options.current, base: options.base }),
114
136
  )
115
137
  }
@@ -230,6 +252,7 @@ export const contextSchema = <M extends UIMessage>(
230
252
  'compaction',
231
253
  'modelMetadata',
232
254
  'closed',
255
+ 'inputs',
233
256
  'authority',
234
257
  'generation',
235
258
  'activeProjection',
@@ -249,9 +272,10 @@ export const contextSchema = <M extends UIMessage>(
249
272
  const current = value['activeProjection']
250
273
  if (
251
274
  !Array.isArray(messages) ||
252
- !messages.every(isMessage) ||
275
+ !messages.every(isUIMessage) ||
253
276
  !compaction(value['compaction']) ||
254
277
  typeof value['closed'] !== 'boolean' ||
278
+ !inputs(value['inputs']) ||
255
279
  !authority(value['authority']) ||
256
280
  !record(metadata) ||
257
281
  !Object.entries(metadata).every(
package/src/ai-context.ts CHANGED
@@ -19,7 +19,12 @@ import type {
19
19
  ToolCalledPayload,
20
20
  ToolResultPayload,
21
21
  } from './ai.ts'
22
- import type { ControlCommit } from './ai-control.ts'
22
+ import {
23
+ type ControlCommand,
24
+ type ControlCommit,
25
+ type ControlReceipt,
26
+ unexplainedAdmission,
27
+ } from './ai-control.ts'
23
28
  import { contextSchema } from './ai-context-schema.ts'
24
29
  import type { Contract, ContractEvent, EventDefs } from './contract.ts'
25
30
  import {
@@ -49,8 +54,22 @@ export type AIModelContext<M extends UIMessage = UIMessage> = {
49
54
  modelMetadata: Record<string, ModelMetadataState>
50
55
  }
51
56
 
57
+ /**
58
+ * Inputs' content between their command and their admission: each input's
59
+ * content is stored only in the command that brought it in. Lists, not
60
+ * records: clients choose the ids, and a record would read an id such as
61
+ * `constructor` or `__proto__` from its prototype.
62
+ */
63
+ export type AIContextInputs<M extends UIMessage> = {
64
+ /** Undecided input commands; a remove holds no message. */
65
+ commands: { commandId: string; inputId: string; message?: M }[]
66
+ /** Queued inputs' current content. */
67
+ waiting: { inputId: string; message: M }[]
68
+ }
69
+
52
70
  export type AIContextState<M extends UIMessage> = AIModelContext<M> & {
53
71
  closed: boolean
72
+ inputs: AIContextInputs<M>
54
73
  authority: Pick<
55
74
  ControlCommit['view'],
56
75
  | 'requestId'
@@ -211,6 +230,7 @@ export const createContextReducer = <M extends UIMessage, D extends EventDefs>({
211
230
  compaction: null,
212
231
  modelMetadata: {},
213
232
  closed: false,
233
+ inputs: { commands: [], waiting: [] },
214
234
  authority: null,
215
235
  generation: null,
216
236
  activeProjection: null,
@@ -222,7 +242,7 @@ export const createContextReducer = <M extends UIMessage, D extends EventDefs>({
222
242
  // The application state rides in this checkpoint so every generation
223
243
  // reads it without another state read.
224
244
  name:
225
- app === undefined ? 'a2.ai.context.v6' : `${app.name}+a2.ai.context.v6`,
245
+ app === undefined ? 'a2.ai.context.v7' : `${app.name}+a2.ai.context.v7`,
226
246
  initialState,
227
247
  stateSchema: contextSchema<M>(app),
228
248
  }).fold((state, event) => {
@@ -265,19 +285,79 @@ const reduceContext = <M extends UIMessage, D extends EventDefs>(
265
285
  }
266
286
  return view.closed ? endGeneration(next) : next
267
287
  }
288
+ case 'ai.control.requested': {
289
+ const command = event.payload as ControlCommand<M>
290
+ const held =
291
+ command.action === 'send' ||
292
+ command.action === 'steer' ||
293
+ command.action === 'edit'
294
+ ? {
295
+ commandId: event.id,
296
+ inputId: command.message.id,
297
+ message: command.message,
298
+ }
299
+ : command.action === 'remove'
300
+ ? { commandId: event.id, inputId: command.inputId }
301
+ : undefined
302
+ if (held === undefined) return state
303
+ return {
304
+ ...state,
305
+ inputs: {
306
+ ...state.inputs,
307
+ commands: [...state.inputs.commands, held],
308
+ },
309
+ }
310
+ }
311
+ case 'ai.control.decided': {
312
+ const receipt = event.payload as ControlReceipt
313
+ const held = receipt.work
314
+ ? undefined
315
+ : state.inputs.commands.find(
316
+ (command) => command.commandId === receipt.commandId,
317
+ )
318
+ if (held === undefined) return state
319
+ const commands = state.inputs.commands.filter(
320
+ (command) => command !== held,
321
+ )
322
+ if (receipt.outcome !== 'applied')
323
+ return { ...state, inputs: { ...state.inputs, commands } }
324
+ const waiting = state.inputs.waiting.filter(
325
+ (input) => input.inputId !== held.inputId,
326
+ )
327
+ if (held.message !== undefined)
328
+ waiting.push({ inputId: held.inputId, message: held.message })
329
+ return { ...state, inputs: { commands, waiting } }
330
+ }
268
331
  case 'ai.session.closed':
269
332
  return endGeneration({
270
333
  ...state,
271
334
  closed: true,
335
+ inputs: { commands: [], waiting: [] },
272
336
  compaction:
273
337
  state.compaction?.status === 'running'
274
338
  ? (state.activeProjection?.baseCompaction ?? null)
275
339
  : state.compaction,
276
340
  })
277
341
  case 'ai.message.created': {
278
- const { message } = event.payload as MessageCreatedPayload<M>
342
+ const { messageId } = event.payload as MessageCreatedPayload
343
+ // A send or steer admitted with its own receipt is still undecided
344
+ // here; its command id is the input's.
345
+ const message =
346
+ state.inputs.waiting.find((input) => input.inputId === messageId)
347
+ ?.message ??
348
+ state.inputs.commands.find((command) => command.commandId === messageId)
349
+ ?.message
350
+ if (message === undefined) throw unexplainedAdmission(messageId)
279
351
  return {
280
352
  ...state,
353
+ inputs: {
354
+ commands: state.inputs.commands.filter(
355
+ (command) => command.commandId !== messageId,
356
+ ),
357
+ waiting: state.inputs.waiting.filter(
358
+ (input) => input.inputId !== messageId,
359
+ ),
360
+ },
281
361
  messages: upsertMessage(state.messages, message),
282
362
  activeProjection:
283
363
  state.activeProjection?.responseMessageId === message.id