experimental-a2 0.8.0 → 0.8.1

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 (94) hide show
  1. package/AGENTS.md +11 -0
  2. package/CHANGELOG.md +8 -0
  3. package/README.md +29 -0
  4. package/docs/guides/07-examples.mdx +56 -0
  5. package/docs/index.mdx +13 -0
  6. package/examples/README.md +15 -0
  7. package/examples/playground/AGENTS.md +11 -0
  8. package/examples/playground/DEPLOY.md +106 -0
  9. package/examples/playground/README.md +19 -0
  10. package/examples/playground/activity-feed.test.ts +10 -0
  11. package/examples/playground/app/agent/[agentId]/agent-client.tsx +376 -0
  12. package/examples/playground/app/agent/[agentId]/page.tsx +29 -0
  13. package/examples/playground/app/agent/events/route.ts +4 -0
  14. package/examples/playground/app/agent/model.ts +3 -0
  15. package/examples/playground/app/agent/new-agent-session.tsx +98 -0
  16. package/examples/playground/app/agent/page.tsx +25 -0
  17. package/examples/playground/app/agent/scheduler/route.ts +5 -0
  18. package/examples/playground/app/agent/server.ts +153 -0
  19. package/examples/playground/app/agent/session.ts +13 -0
  20. package/examples/playground/app/canvas/[canvasId]/canvas-client.tsx +682 -0
  21. package/examples/playground/app/canvas/[canvasId]/canvas-replay.test.ts +68 -0
  22. package/examples/playground/app/canvas/[canvasId]/canvas-replay.ts +19 -0
  23. package/examples/playground/app/canvas/[canvasId]/page.tsx +22 -0
  24. package/examples/playground/app/canvas/[canvasId]/session.ts +19 -0
  25. package/examples/playground/app/canvas/events/route.ts +13 -0
  26. package/examples/playground/app/canvas/model.ts +94 -0
  27. package/examples/playground/app/canvas/open-canvas.tsx +40 -0
  28. package/examples/playground/app/canvas/page.tsx +20 -0
  29. package/examples/playground/app/canvas/server.ts +9 -0
  30. package/examples/playground/app/chat/[chatId]/agent-stream-drawer.test.tsx +118 -0
  31. package/examples/playground/app/chat/[chatId]/agent-stream-drawer.tsx +316 -0
  32. package/examples/playground/app/chat/[chatId]/chat-client.tsx +922 -0
  33. package/examples/playground/app/chat/[chatId]/chat-view.test.ts +152 -0
  34. package/examples/playground/app/chat/[chatId]/chat-view.ts +101 -0
  35. package/examples/playground/app/chat/[chatId]/composer.test.ts +44 -0
  36. package/examples/playground/app/chat/[chatId]/composer.ts +30 -0
  37. package/examples/playground/app/chat/[chatId]/page.tsx +30 -0
  38. package/examples/playground/app/chat/[chatId]/session.ts +7 -0
  39. package/examples/playground/app/chat/events/route.ts +7 -0
  40. package/examples/playground/app/chat/model.test.ts +155 -0
  41. package/examples/playground/app/chat/model.ts +310 -0
  42. package/examples/playground/app/chat/new-conversation.tsx +16 -0
  43. package/examples/playground/app/chat/page.tsx +25 -0
  44. package/examples/playground/app/chat/scheduler/route.ts +5 -0
  45. package/examples/playground/app/chat/server.ts +184 -0
  46. package/examples/playground/app/components/activity-feed.tsx +54 -0
  47. package/examples/playground/app/components/connection-pill.tsx +29 -0
  48. package/examples/playground/app/counter/counter-client.tsx +72 -0
  49. package/examples/playground/app/counter/events/route.ts +4 -0
  50. package/examples/playground/app/counter/model.test.ts +36 -0
  51. package/examples/playground/app/counter/model.ts +31 -0
  52. package/examples/playground/app/counter/page.tsx +24 -0
  53. package/examples/playground/app/counter/server.ts +9 -0
  54. package/examples/playground/app/counter/session.ts +13 -0
  55. package/examples/playground/app/documents/[documentId]/code-editor.tsx +80 -0
  56. package/examples/playground/app/documents/[documentId]/document-client.tsx +525 -0
  57. package/examples/playground/app/documents/[documentId]/page.tsx +23 -0
  58. package/examples/playground/app/documents/[documentId]/session.ts +7 -0
  59. package/examples/playground/app/documents/events/route.ts +4 -0
  60. package/examples/playground/app/documents/model.ts +55 -0
  61. package/examples/playground/app/documents/open-document.tsx +40 -0
  62. package/examples/playground/app/documents/page.tsx +22 -0
  63. package/examples/playground/app/documents/server.ts +9 -0
  64. package/examples/playground/app/globals.css +2078 -0
  65. package/examples/playground/app/layout.tsx +44 -0
  66. package/examples/playground/app/orders/[orderId]/order-client.tsx +140 -0
  67. package/examples/playground/app/orders/[orderId]/page.tsx +29 -0
  68. package/examples/playground/app/orders/[orderId]/session.ts +11 -0
  69. package/examples/playground/app/orders/create/route.ts +30 -0
  70. package/examples/playground/app/orders/events/route.ts +4 -0
  71. package/examples/playground/app/orders/model.ts +79 -0
  72. package/examples/playground/app/orders/new-order-form.tsx +98 -0
  73. package/examples/playground/app/orders/page.tsx +22 -0
  74. package/examples/playground/app/orders/scheduler/route.ts +5 -0
  75. package/examples/playground/app/orders/server.ts +50 -0
  76. package/examples/playground/app/page.tsx +102 -0
  77. package/examples/playground/app/recovery/[recoveryId]/page.tsx +31 -0
  78. package/examples/playground/app/recovery/[recoveryId]/recovery-client.tsx +144 -0
  79. package/examples/playground/app/recovery/[recoveryId]/session.ts +7 -0
  80. package/examples/playground/app/recovery/events/route.ts +3 -0
  81. package/examples/playground/app/recovery/model.ts +55 -0
  82. package/examples/playground/app/recovery/new-recovery-session.tsx +20 -0
  83. package/examples/playground/app/recovery/page.tsx +22 -0
  84. package/examples/playground/app/recovery/scheduler/route.ts +7 -0
  85. package/examples/playground/app/recovery/server.ts +53 -0
  86. package/examples/playground/app/recovery/start/route.ts +41 -0
  87. package/examples/playground/css.d.ts +4 -0
  88. package/examples/playground/lib/store.ts +15 -0
  89. package/examples/playground/next-env.d.ts +5 -0
  90. package/examples/playground/next.config.ts +10 -0
  91. package/examples/playground/package.json +46 -0
  92. package/examples/playground/tsconfig.json +37 -0
  93. package/examples/playground/vercel.json +40 -0
  94. package/package.json +5 -2
@@ -0,0 +1,68 @@
1
+ import type { ContractEvent } from 'experimental-a2'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { canvas, type CanvasState } from '../model'
4
+ import { confirmedCanvasEventsSince, replayCanvas } from './canvas-replay'
5
+
6
+ type CanvasEvents = typeof canvas.events
7
+ type CanvasEvent<
8
+ K extends keyof CanvasEvents & string = keyof CanvasEvents & string,
9
+ > = ContractEvent<CanvasEvents, K>
10
+
11
+ const event = <K extends keyof CanvasEvents & string>(
12
+ index: number,
13
+ type: K,
14
+ payload: CanvasEvent<K>['payload'],
15
+ ): CanvasEvent<K> =>
16
+ ({
17
+ id: `event-${index}`,
18
+ type,
19
+ payload,
20
+ index,
21
+ sessionId: 'canvas-1',
22
+ createdAt: new Date(index * 1_000),
23
+ }) as CanvasEvent<K>
24
+
25
+ describe('canvas replay', () => {
26
+ it('replays confirmed events after the server snapshot', () => {
27
+ const snapshot: CanvasState = {
28
+ shapes: {
29
+ square: {
30
+ id: 'square',
31
+ kind: 'rect',
32
+ x: 10,
33
+ y: 10,
34
+ w: 80,
35
+ h: 60,
36
+ color: 'teal',
37
+ movedAt: 4,
38
+ },
39
+ },
40
+ }
41
+ const observed: CanvasEvent[] = [
42
+ event(4, 'shape.moved', { id: 'square', x: 99, y: 99 }),
43
+ event(5, 'shape.moved', { id: 'square', x: 20, y: 30 }),
44
+ event(6, 'shape.deleted', { id: 'square' }),
45
+ event(7, 'shape.created', {
46
+ id: 'optimistic',
47
+ kind: 'ellipse',
48
+ x: 0,
49
+ y: 0,
50
+ w: 50,
51
+ h: 50,
52
+ color: 'rose',
53
+ }),
54
+ ]
55
+
56
+ const confirmed = confirmedCanvasEventsSince(observed, 4, 6)
57
+
58
+ expect(confirmed.map(({ index }) => index)).toEqual([5, 6])
59
+ expect(replayCanvas(snapshot, confirmed, 0)).toEqual(snapshot)
60
+ expect(replayCanvas(snapshot, confirmed, 1).shapes['square']).toEqual({
61
+ ...snapshot.shapes['square'],
62
+ x: 20,
63
+ y: 30,
64
+ movedAt: 5,
65
+ })
66
+ expect(replayCanvas(snapshot, confirmed, 2).shapes).toEqual({})
67
+ })
68
+ })
@@ -0,0 +1,19 @@
1
+ import type { ContractEvent } from 'experimental-a2'
2
+ import { canvas, canvasReducer, type CanvasState } from '../model'
3
+
4
+ type CanvasEvent = ContractEvent<(typeof canvas)['events']>
5
+
6
+ export const confirmedCanvasEventsSince = (
7
+ events: CanvasEvent[],
8
+ snapshotIndex: number,
9
+ frontier: number,
10
+ ): CanvasEvent[] =>
11
+ events.filter(
12
+ (event) => event.index > snapshotIndex && event.index <= frontier,
13
+ )
14
+
15
+ export const replayCanvas = (
16
+ snapshot: CanvasState,
17
+ events: CanvasEvent[],
18
+ through: number,
19
+ ): CanvasState => events.slice(0, through).reduce(canvasReducer.fold, snapshot)
@@ -0,0 +1,22 @@
1
+ import type { ReactNode } from 'react'
2
+ import { canvasReducer } from '../model'
3
+ import { canvasServer } from '../server'
4
+ import { CanvasSession } from './canvas-client'
5
+
6
+ export default async function CanvasSessionPage({
7
+ params,
8
+ }: {
9
+ params: Promise<{ canvasId: string }>
10
+ }): Promise<ReactNode> {
11
+ const { canvasId } = await params
12
+ const session = canvasServer.session(canvasId)
13
+ const { state, index } = await session.state(canvasReducer)
14
+
15
+ return (
16
+ <CanvasSession
17
+ canvasId={canvasId}
18
+ initialState={state}
19
+ initialIndex={index}
20
+ />
21
+ )
22
+ }
@@ -0,0 +1,19 @@
1
+ 'use client'
2
+ import { createClient } from 'experimental-a2/client'
3
+ import { createReact } from 'experimental-a2/react'
4
+ import { canvasReducer } from '../model'
5
+
6
+ // One route, two wires: on Vercel (production and previews), pushes
7
+ // and presence ride one WebSocket. Anywhere else, including `next
8
+ // dev` (which drops the upgrade handshake before the route runs) and
9
+ // a local `next start`, the same route serves the SSE + POST pair.
10
+ export const canvasClient = createClient({
11
+ reducer: canvasReducer,
12
+ api: process.env.NEXT_PUBLIC_VERCEL_ENV
13
+ ? { type: 'ws', url: '/canvas/events' }
14
+ : '/canvas/events',
15
+ })
16
+
17
+ export const { SessionProvider, useSession } = createReact({
18
+ client: canvasClient,
19
+ })
@@ -0,0 +1,13 @@
1
+ import { experimental_upgradeWebSocket } from '@vercel/functions'
2
+ import { canvasServer } from '../server'
3
+
4
+ export const POST = canvasServer.fetch
5
+
6
+ export function GET(request: Request): Promise<Response> {
7
+ return canvasServer.fetch(request, {
8
+ upgradeWebSocket: (attach) =>
9
+ experimental_upgradeWebSocket(attach, {
10
+ maxPayload: 4 * 1024 * 1024,
11
+ }),
12
+ })
13
+ }
@@ -0,0 +1,94 @@
1
+ import * as a2 from 'experimental-a2'
2
+ import { z } from 'zod'
3
+
4
+ const paletteSchema = z.enum(['teal', 'amber', 'blue', 'violet', 'rose'])
5
+
6
+ export type PaletteColor = z.output<typeof paletteSchema>
7
+
8
+ export const canvas = a2.contract({
9
+ name: 'canvas',
10
+ events: {
11
+ 'shape.created': z.object({
12
+ id: z.string().min(1),
13
+ kind: z.enum(['rect', 'ellipse']),
14
+ x: z.number(),
15
+ y: z.number(),
16
+ w: z.number().positive(),
17
+ h: z.number().positive(),
18
+ color: paletteSchema,
19
+ }),
20
+ 'shape.moved': z.object({
21
+ id: z.string().min(1),
22
+ x: z.number(),
23
+ y: z.number(),
24
+ }),
25
+ 'shape.deleted': z.object({ id: z.string().min(1) }),
26
+ },
27
+ presence: {
28
+ profile: z.object({
29
+ name: z.string().min(1).max(40),
30
+ color: paletteSchema,
31
+ }),
32
+ cursor: z.object({ x: z.number(), y: z.number() }),
33
+ dragging: z.object({ shapeId: z.string(), x: z.number(), y: z.number() }),
34
+ },
35
+ })
36
+
37
+ const canvasShapeSchema = z.object({
38
+ id: z.string(),
39
+ kind: z.enum(['rect', 'ellipse']),
40
+ x: z.number(),
41
+ y: z.number(),
42
+ w: z.number(),
43
+ h: z.number(),
44
+ color: paletteSchema,
45
+ movedAt: z.number(),
46
+ })
47
+
48
+ const canvasState = z.object({
49
+ shapes: z.record(z.string(), canvasShapeSchema),
50
+ })
51
+
52
+ export type CanvasState = z.output<typeof canvasState>
53
+ export type CanvasShape = CanvasState['shapes'][string]
54
+
55
+ export const canvasReducer = canvas
56
+ .reducer({
57
+ name: 'canvas-scene',
58
+ initialState: { shapes: {} },
59
+ stateSchema: canvasState,
60
+ })
61
+ .fold((state, event) => {
62
+ switch (event.type) {
63
+ case 'shape.created':
64
+ return {
65
+ shapes: {
66
+ ...state.shapes,
67
+ [event.payload.id]: { ...event.payload, movedAt: event.index },
68
+ },
69
+ }
70
+ case 'shape.moved': {
71
+ const shape = state.shapes[event.payload.id]
72
+ if (!shape) return state
73
+ return {
74
+ shapes: {
75
+ ...state.shapes,
76
+ [shape.id]: {
77
+ ...shape,
78
+ x: event.payload.x,
79
+ y: event.payload.y,
80
+ movedAt: event.index,
81
+ },
82
+ },
83
+ }
84
+ }
85
+ case 'shape.deleted': {
86
+ if (!state.shapes[event.payload.id]) return state
87
+ const shapes = { ...state.shapes }
88
+ delete shapes[event.payload.id]
89
+ return { shapes }
90
+ }
91
+ default:
92
+ return state
93
+ }
94
+ })
@@ -0,0 +1,40 @@
1
+ 'use client'
2
+ import type { FormEvent, ReactNode } from 'react'
3
+ import { useState } from 'react'
4
+ import { useRouter } from 'next/navigation'
5
+
6
+ export function OpenCanvas(): ReactNode {
7
+ const router = useRouter()
8
+ const [canvasId, setCanvasId] = useState('')
9
+
10
+ const open = (event: FormEvent): void => {
11
+ event.preventDefault()
12
+ const id = canvasId.trim()
13
+ if (id) router.push(`/canvas/${encodeURIComponent(id)}`)
14
+ }
15
+
16
+ return (
17
+ <form className="document-picker" onSubmit={open}>
18
+ <button
19
+ type="button"
20
+ onClick={() =>
21
+ router.push(`/canvas/${crypto.randomUUID().slice(0, 8)}`)
22
+ }
23
+ >
24
+ New canvas
25
+ </button>
26
+ <span className="hint">or</span>
27
+ <label>
28
+ Open a canvas ID
29
+ <input
30
+ value={canvasId}
31
+ onChange={(event) => setCanvasId(event.target.value)}
32
+ placeholder="moodboard"
33
+ />
34
+ </label>
35
+ <button className="secondary" type="submit" disabled={!canvasId.trim()}>
36
+ Open
37
+ </button>
38
+ </form>
39
+ )
40
+ }
@@ -0,0 +1,20 @@
1
+ import type { ReactNode } from 'react'
2
+ import { OpenCanvas } from './open-canvas'
3
+
4
+ export default function CanvasPage(): ReactNode {
5
+ return (
6
+ <article>
7
+ <h1>A multiplayer canvas on two planes</h1>
8
+ <p className="lede">
9
+ Shapes are durable events: create, drag, delete, then scrub the events
10
+ this page receives to watch the reducer replay from its server snapshot.
11
+ Cursors and drag ghosts are presence — replicated live, stored never.
12
+ </p>
13
+ <OpenCanvas />
14
+ <p className="hint">
15
+ Open the same canvas in two tabs. While a shape is mid-drag you are
16
+ watching presence; the moment it drops you are watching the log.
17
+ </p>
18
+ </article>
19
+ )
20
+ }
@@ -0,0 +1,9 @@
1
+ import 'server-only'
2
+ import { createServer } from 'experimental-a2/server'
3
+ import { store } from '@/lib/store'
4
+ import { canvas } from './model'
5
+
6
+ export const canvasServer = createServer({
7
+ ...(store ? { store } : {}),
8
+ contract: canvas,
9
+ })
@@ -0,0 +1,118 @@
1
+ import { renderToStaticMarkup } from 'react-dom/server'
2
+ import { describe, expect, it, vi } from 'vitest'
3
+ import type { ChatAgentMessage } from '../model'
4
+ import { AgentStreamDrawer } from './agent-stream-drawer'
5
+ import type { AgentMessageMetadata, AgentStreamSelection } from './chat-view'
6
+
7
+ const usage = (
8
+ inputTokens: number,
9
+ outputTokens: number,
10
+ reasoningTokens: number,
11
+ ) => ({
12
+ inputTokens,
13
+ inputTokenDetails: {
14
+ noCacheTokens: inputTokens - 2,
15
+ cacheReadTokens: 2,
16
+ cacheWriteTokens: 0,
17
+ },
18
+ outputTokens,
19
+ outputTokenDetails: {
20
+ textTokens: outputTokens - reasoningTokens,
21
+ reasoningTokens,
22
+ },
23
+ totalTokens: inputTokens + outputTokens,
24
+ })
25
+
26
+ describe('AgentStreamDrawer', () => {
27
+ it('renders the complete ordered UIMessage stream and completed metadata', () => {
28
+ const parts: ChatAgentMessage['parts'] = [
29
+ { type: 'step-start' },
30
+ {
31
+ type: 'reasoning',
32
+ text: 'Consider whether this would help the room.',
33
+ state: 'done',
34
+ },
35
+ {
36
+ type: 'text',
37
+ text: 'Private model text that was not posted.',
38
+ state: 'done',
39
+ },
40
+ {
41
+ type: 'tool-post',
42
+ toolCallId: 'call-1',
43
+ state: 'output-available',
44
+ input: { message: 'Visible room message from the post tool.' },
45
+ output: { posted: true },
46
+ },
47
+ {
48
+ type: 'custom',
49
+ kind: 'openai.trace',
50
+ providerMetadata: { openai: { traceId: 'trace-1' } },
51
+ },
52
+ ]
53
+ const metadata: AgentMessageMetadata = {
54
+ kind: 'agent',
55
+ author: 'agent',
56
+ mode: 'ambient',
57
+ steps: [
58
+ {
59
+ generationId: 'generation-1',
60
+ model: 'openai/gpt-first',
61
+ finishReason: 'tool-calls',
62
+ usage: usage(12, 5, 2),
63
+ completedAt: '2026-08-16T12:00:00.000Z',
64
+ },
65
+ {
66
+ generationId: 'generation-2',
67
+ model: 'openai/gpt-second',
68
+ finishReason: 'stop',
69
+ usage: usage(15, 3, 0),
70
+ completedAt: '2026-08-16T12:00:01.000Z',
71
+ },
72
+ ],
73
+ }
74
+ const message: ChatAgentMessage = {
75
+ id: 'assistant-1',
76
+ role: 'assistant',
77
+ metadata,
78
+ parts,
79
+ }
80
+ const selection: AgentStreamSelection = {
81
+ message,
82
+ parts,
83
+ metadata,
84
+ }
85
+
86
+ const markup = renderToStaticMarkup(
87
+ <AgentStreamDrawer
88
+ responseMessageId={message.id}
89
+ selection={selection}
90
+ summary="ambient · 35 tokens · 2 model steps"
91
+ status="completed"
92
+ onClose={vi.fn<() => void>()}
93
+ />,
94
+ )
95
+
96
+ const orderedMarkers = [
97
+ 'Model step',
98
+ 'Consider whether this would help the room.',
99
+ 'Private model text that was not posted.',
100
+ 'Visible room message from the post tool.',
101
+ 'openai.trace',
102
+ ]
103
+ const positions = orderedMarkers.map((marker) => markup.indexOf(marker))
104
+ expect(positions.every((position) => position >= 0)).toBe(true)
105
+ expect(positions).toEqual(
106
+ [...positions].toSorted((left, right) => left - right),
107
+ )
108
+
109
+ expect(markup).toContain('openai/gpt-first')
110
+ expect(markup).toContain('tool-calls')
111
+ expect(markup).toContain('generation-1')
112
+ expect(markup).toContain('openai/gpt-second')
113
+ expect(markup).toContain('generation-2')
114
+ expect(markup).toContain('Full AI SDK usage')
115
+ expect(markup).toContain('cacheReadTokens')
116
+ expect(markup).toContain('Raw extended UIMessage')
117
+ })
118
+ })