experimental-a2 0.7.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 (150) hide show
  1. package/AGENTS.md +11 -0
  2. package/CHANGELOG.md +27 -0
  3. package/README.md +29 -0
  4. package/dist/ai-server.d.ts +1 -1
  5. package/dist/ai-server.d.ts.map +1 -1
  6. package/dist/ai-server.js +13 -11
  7. package/dist/ai-server.js.map +1 -1
  8. package/dist/ai.d.ts +1 -1
  9. package/dist/index.d.ts +1 -1
  10. package/dist/scheduler-qstash.d.ts +2 -2
  11. package/dist/scheduler-qstash.js +1 -1
  12. package/dist/scheduler-vercel.d.ts +2 -2
  13. package/dist/scheduler-vercel.js +1 -1
  14. package/dist/{server-286j79Mt.js → server-B2XNevQA.js} +123 -53
  15. package/dist/server-B2XNevQA.js.map +1 -0
  16. package/dist/{server-DgXmORIq.d.ts → server-DjPhHnbI.d.ts} +7 -4
  17. package/dist/server-DjPhHnbI.d.ts.map +1 -0
  18. package/dist/server.d.ts +3 -3
  19. package/dist/server.js +1 -1
  20. package/dist/store-N8PXxDAS.js.map +1 -1
  21. package/dist/{store-flRz1OWh.d.ts → store-RJO35BMj.d.ts} +25 -8
  22. package/dist/store-RJO35BMj.d.ts.map +1 -0
  23. package/dist/store-memory.d.ts +1 -1
  24. package/dist/store-memory.d.ts.map +1 -1
  25. package/dist/store-memory.js +79 -19
  26. package/dist/store-memory.js.map +1 -1
  27. package/dist/store-postgres.d.ts +1 -1
  28. package/dist/store-postgres.d.ts.map +1 -1
  29. package/dist/store-postgres.js +230 -101
  30. package/dist/store-postgres.js.map +1 -1
  31. package/dist/{store-redis-core-DEYO8Ryv.js → store-redis-core-DT01r4GZ.js} +167 -29
  32. package/dist/store-redis-core-DT01r4GZ.js.map +1 -0
  33. package/dist/store-redis-http.d.ts +1 -1
  34. package/dist/store-redis-http.js +2 -2
  35. package/dist/store-redis-http.js.map +1 -1
  36. package/dist/store-redis.d.ts +1 -1
  37. package/dist/store-redis.js +2 -2
  38. package/dist/store-redis.js.map +1 -1
  39. package/dist/store-sqlite.d.ts +1 -1
  40. package/dist/store-sqlite.d.ts.map +1 -1
  41. package/dist/store-sqlite.js +103 -19
  42. package/dist/store-sqlite.js.map +1 -1
  43. package/docs/concepts/02-handlers.mdx +4 -0
  44. package/docs/concepts/04-state.mdx +57 -9
  45. package/docs/guides/06-ai-agents.mdx +2 -1
  46. package/docs/guides/07-examples.mdx +56 -0
  47. package/docs/index.mdx +13 -0
  48. package/docs/reference/01-api.mdx +39 -16
  49. package/examples/README.md +15 -0
  50. package/examples/playground/AGENTS.md +11 -0
  51. package/examples/playground/DEPLOY.md +106 -0
  52. package/examples/playground/README.md +19 -0
  53. package/examples/playground/activity-feed.test.ts +10 -0
  54. package/examples/playground/app/agent/[agentId]/agent-client.tsx +376 -0
  55. package/examples/playground/app/agent/[agentId]/page.tsx +29 -0
  56. package/examples/playground/app/agent/events/route.ts +4 -0
  57. package/examples/playground/app/agent/model.ts +3 -0
  58. package/examples/playground/app/agent/new-agent-session.tsx +98 -0
  59. package/examples/playground/app/agent/page.tsx +25 -0
  60. package/examples/playground/app/agent/scheduler/route.ts +5 -0
  61. package/examples/playground/app/agent/server.ts +153 -0
  62. package/examples/playground/app/agent/session.ts +13 -0
  63. package/examples/playground/app/canvas/[canvasId]/canvas-client.tsx +682 -0
  64. package/examples/playground/app/canvas/[canvasId]/canvas-replay.test.ts +68 -0
  65. package/examples/playground/app/canvas/[canvasId]/canvas-replay.ts +19 -0
  66. package/examples/playground/app/canvas/[canvasId]/page.tsx +22 -0
  67. package/examples/playground/app/canvas/[canvasId]/session.ts +19 -0
  68. package/examples/playground/app/canvas/events/route.ts +13 -0
  69. package/examples/playground/app/canvas/model.ts +94 -0
  70. package/examples/playground/app/canvas/open-canvas.tsx +40 -0
  71. package/examples/playground/app/canvas/page.tsx +20 -0
  72. package/examples/playground/app/canvas/server.ts +9 -0
  73. package/examples/playground/app/chat/[chatId]/agent-stream-drawer.test.tsx +118 -0
  74. package/examples/playground/app/chat/[chatId]/agent-stream-drawer.tsx +316 -0
  75. package/examples/playground/app/chat/[chatId]/chat-client.tsx +922 -0
  76. package/examples/playground/app/chat/[chatId]/chat-view.test.ts +152 -0
  77. package/examples/playground/app/chat/[chatId]/chat-view.ts +101 -0
  78. package/examples/playground/app/chat/[chatId]/composer.test.ts +44 -0
  79. package/examples/playground/app/chat/[chatId]/composer.ts +30 -0
  80. package/examples/playground/app/chat/[chatId]/page.tsx +30 -0
  81. package/examples/playground/app/chat/[chatId]/session.ts +7 -0
  82. package/examples/playground/app/chat/events/route.ts +7 -0
  83. package/examples/playground/app/chat/model.test.ts +155 -0
  84. package/examples/playground/app/chat/model.ts +310 -0
  85. package/examples/playground/app/chat/new-conversation.tsx +16 -0
  86. package/examples/playground/app/chat/page.tsx +25 -0
  87. package/examples/playground/app/chat/scheduler/route.ts +5 -0
  88. package/examples/playground/app/chat/server.ts +184 -0
  89. package/examples/playground/app/components/activity-feed.tsx +54 -0
  90. package/examples/playground/app/components/connection-pill.tsx +29 -0
  91. package/examples/playground/app/counter/counter-client.tsx +72 -0
  92. package/examples/playground/app/counter/events/route.ts +4 -0
  93. package/examples/playground/app/counter/model.test.ts +36 -0
  94. package/examples/playground/app/counter/model.ts +31 -0
  95. package/examples/playground/app/counter/page.tsx +24 -0
  96. package/examples/playground/app/counter/server.ts +9 -0
  97. package/examples/playground/app/counter/session.ts +13 -0
  98. package/examples/playground/app/documents/[documentId]/code-editor.tsx +80 -0
  99. package/examples/playground/app/documents/[documentId]/document-client.tsx +525 -0
  100. package/examples/playground/app/documents/[documentId]/page.tsx +23 -0
  101. package/examples/playground/app/documents/[documentId]/session.ts +7 -0
  102. package/examples/playground/app/documents/events/route.ts +4 -0
  103. package/examples/playground/app/documents/model.ts +55 -0
  104. package/examples/playground/app/documents/open-document.tsx +40 -0
  105. package/examples/playground/app/documents/page.tsx +22 -0
  106. package/examples/playground/app/documents/server.ts +9 -0
  107. package/examples/playground/app/globals.css +2078 -0
  108. package/examples/playground/app/layout.tsx +44 -0
  109. package/examples/playground/app/orders/[orderId]/order-client.tsx +140 -0
  110. package/examples/playground/app/orders/[orderId]/page.tsx +29 -0
  111. package/examples/playground/app/orders/[orderId]/session.ts +11 -0
  112. package/examples/playground/app/orders/create/route.ts +30 -0
  113. package/examples/playground/app/orders/events/route.ts +4 -0
  114. package/examples/playground/app/orders/model.ts +79 -0
  115. package/examples/playground/app/orders/new-order-form.tsx +98 -0
  116. package/examples/playground/app/orders/page.tsx +22 -0
  117. package/examples/playground/app/orders/scheduler/route.ts +5 -0
  118. package/examples/playground/app/orders/server.ts +50 -0
  119. package/examples/playground/app/page.tsx +102 -0
  120. package/examples/playground/app/recovery/[recoveryId]/page.tsx +31 -0
  121. package/examples/playground/app/recovery/[recoveryId]/recovery-client.tsx +144 -0
  122. package/examples/playground/app/recovery/[recoveryId]/session.ts +7 -0
  123. package/examples/playground/app/recovery/events/route.ts +3 -0
  124. package/examples/playground/app/recovery/model.ts +55 -0
  125. package/examples/playground/app/recovery/new-recovery-session.tsx +20 -0
  126. package/examples/playground/app/recovery/page.tsx +22 -0
  127. package/examples/playground/app/recovery/scheduler/route.ts +7 -0
  128. package/examples/playground/app/recovery/server.ts +53 -0
  129. package/examples/playground/app/recovery/start/route.ts +41 -0
  130. package/examples/playground/css.d.ts +4 -0
  131. package/examples/playground/lib/store.ts +15 -0
  132. package/examples/playground/next-env.d.ts +5 -0
  133. package/examples/playground/next.config.ts +10 -0
  134. package/examples/playground/package.json +46 -0
  135. package/examples/playground/tsconfig.json +37 -0
  136. package/examples/playground/vercel.json +40 -0
  137. package/package.json +5 -2
  138. package/src/ai-server.ts +27 -10
  139. package/src/server.ts +242 -87
  140. package/src/store-memory.ts +138 -20
  141. package/src/store-postgres.ts +355 -138
  142. package/src/store-redis-core.ts +201 -27
  143. package/src/store-redis-http.ts +1 -1
  144. package/src/store-redis.ts +1 -1
  145. package/src/store-sqlite.ts +191 -34
  146. package/src/store.ts +27 -9
  147. package/dist/server-286j79Mt.js.map +0 -1
  148. package/dist/server-DgXmORIq.d.ts.map +0 -1
  149. package/dist/store-flRz1OWh.d.ts.map +0 -1
  150. package/dist/store-redis-core-DEYO8Ryv.js.map +0 -1
@@ -0,0 +1,152 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import type { AgentPost, ChatAgentMessage, ChatMessage } from '../model'
3
+ import {
4
+ buildChatTranscript,
5
+ selectAgentStream,
6
+ shouldGroupMessage,
7
+ } from './chat-view'
8
+
9
+ const participant = (
10
+ index: number,
11
+ author: string,
12
+ at: string,
13
+ ): ChatMessage => ({
14
+ id: `message-${index}`,
15
+ index,
16
+ author,
17
+ text: `message ${index}`,
18
+ at,
19
+ })
20
+
21
+ const post = (
22
+ index: number,
23
+ responseMessageId: string,
24
+ at: string,
25
+ ): AgentPost => ({
26
+ generationId: `generation-${index}`,
27
+ responseMessageId,
28
+ toolCallId: `call-${index}`,
29
+ index,
30
+ text: `post ${index}`,
31
+ at,
32
+ })
33
+
34
+ describe('chat transcript view', () => {
35
+ it('groups only adjacent messages from the same author within 60 seconds', () => {
36
+ const transcript = buildChatTranscript(
37
+ [
38
+ participant(6, 'lovelace', '2026-08-16T12:01:04.000Z'),
39
+ participant(1, 'lovelace', '2026-08-16T11:59:59.000Z'),
40
+ participant(5, 'lovelace', '2026-08-16T12:00:04.000Z'),
41
+ participant(2, 'lovelace', '2026-08-16T12:00:01.000Z'),
42
+ ],
43
+ [
44
+ post(4, 'assistant-2', '2026-08-16T12:00:03.000Z'),
45
+ post(3, 'assistant-1', '2026-08-16T12:00:02.000Z'),
46
+ ],
47
+ )
48
+
49
+ expect(transcript.map((entry) => entry.index)).toEqual([1, 2, 3, 4, 5, 6])
50
+ expect(
51
+ transcript.map(({ author, showHeader }) => ({ author, showHeader })),
52
+ ).toEqual([
53
+ { author: 'lovelace', showHeader: true },
54
+ { author: 'lovelace', showHeader: false },
55
+ { author: 'agent', showHeader: true },
56
+ { author: 'agent', showHeader: false },
57
+ { author: 'lovelace', showHeader: true },
58
+ { author: 'lovelace', showHeader: true },
59
+ ])
60
+ })
61
+
62
+ it('does not group different authors, invalid time, or reversed time', () => {
63
+ const at = '2026-08-16T12:00:00.000Z'
64
+ expect(
65
+ shouldGroupMessage(
66
+ { kind: 'participant', author: 'lovelace', at },
67
+ { kind: 'participant', author: 'hopper', at },
68
+ ),
69
+ ).toBe(false)
70
+ expect(
71
+ shouldGroupMessage(
72
+ { kind: 'participant', author: 'lovelace', at },
73
+ { kind: 'participant', author: 'lovelace', at: 'not-a-date' },
74
+ ),
75
+ ).toBe(false)
76
+ expect(
77
+ shouldGroupMessage(
78
+ { kind: 'participant', author: 'lovelace', at },
79
+ {
80
+ kind: 'participant',
81
+ author: 'lovelace',
82
+ at: '2026-08-16T11:59:59.999Z',
83
+ },
84
+ ),
85
+ ).toBe(false)
86
+ })
87
+
88
+ it('does not group a participant named agent with the AI agent', () => {
89
+ const transcript = buildChatTranscript(
90
+ [participant(1, 'agent', '2026-08-16T12:00:00.000Z')],
91
+ [post(2, 'assistant-1', '2026-08-16T12:00:01.000Z')],
92
+ )
93
+
94
+ expect(transcript.map(({ showHeader }) => showHeader)).toEqual([true, true])
95
+ })
96
+ })
97
+
98
+ describe('agent stream selection', () => {
99
+ const firstParts: ChatAgentMessage['parts'] = [
100
+ { type: 'step-start' },
101
+ { type: 'reasoning', text: 'Consider whether a post helps.' },
102
+ {
103
+ type: 'tool-post',
104
+ toolCallId: 'call-1',
105
+ state: 'output-available',
106
+ input: { message: 'A useful reply' },
107
+ output: { posted: true },
108
+ },
109
+ { type: 'text', text: 'Private continuation' },
110
+ ]
111
+ const first: ChatAgentMessage = {
112
+ id: 'assistant-1',
113
+ role: 'assistant',
114
+ metadata: {
115
+ kind: 'agent',
116
+ author: 'agent',
117
+ mode: 'mention',
118
+ steps: [],
119
+ },
120
+ parts: firstParts,
121
+ }
122
+ const second: ChatAgentMessage = {
123
+ id: 'assistant-2',
124
+ role: 'assistant',
125
+ metadata: {
126
+ kind: 'agent',
127
+ author: 'agent',
128
+ mode: 'ambient',
129
+ steps: [],
130
+ },
131
+ parts: [{ type: 'text', text: 'A different response' }],
132
+ }
133
+
134
+ it('returns the exact response message with all parts in stream order', () => {
135
+ const selection = selectAgentStream([second, first], first.id)
136
+
137
+ expect(selection).toEqual({
138
+ message: first,
139
+ parts: firstParts,
140
+ metadata: first.metadata,
141
+ })
142
+ expect(selection?.message).toBe(first)
143
+ expect(selection?.parts).toBe(firstParts)
144
+ })
145
+
146
+ it('isolates response ids and returns null when the response is absent', () => {
147
+ expect(selectAgentStream([first, second], second.id)?.parts).toEqual([
148
+ { type: 'text', text: 'A different response' },
149
+ ])
150
+ expect(selectAgentStream([first, second], 'assistant-missing')).toBeNull()
151
+ })
152
+ })
@@ -0,0 +1,101 @@
1
+ import type {
2
+ AgentPost,
3
+ ChatAgentMessage,
4
+ ChatMessage,
5
+ ChatMessageMetadata,
6
+ } from '../model'
7
+
8
+ const MESSAGE_BURST_MS = 60_000
9
+
10
+ export type ChatTranscriptMoment = {
11
+ kind: 'participant' | 'agent'
12
+ author: string
13
+ at: string
14
+ }
15
+
16
+ export const shouldGroupMessage = (
17
+ previous: ChatTranscriptMoment,
18
+ current: ChatTranscriptMoment,
19
+ ): boolean => {
20
+ if (previous.kind !== current.kind || previous.author !== current.author) {
21
+ return false
22
+ }
23
+ const elapsed = Date.parse(current.at) - Date.parse(previous.at)
24
+ return Number.isFinite(elapsed) && elapsed >= 0 && elapsed < MESSAGE_BURST_MS
25
+ }
26
+
27
+ type ParticipantTranscriptEntry = {
28
+ kind: 'participant'
29
+ index: number
30
+ author: string
31
+ at: string
32
+ message: ChatMessage
33
+ }
34
+
35
+ type AgentTranscriptEntry = {
36
+ kind: 'agent'
37
+ index: number
38
+ author: 'agent'
39
+ at: string
40
+ post: AgentPost
41
+ }
42
+
43
+ export type ChatTranscriptEntry = (
44
+ ParticipantTranscriptEntry | AgentTranscriptEntry
45
+ ) & { showHeader: boolean }
46
+
47
+ export const buildChatTranscript = (
48
+ messages: ChatMessage[],
49
+ posts: AgentPost[],
50
+ ): ChatTranscriptEntry[] => {
51
+ const ordered = [
52
+ ...messages.map((message): ParticipantTranscriptEntry => ({
53
+ kind: 'participant',
54
+ index: message.index,
55
+ author: message.author,
56
+ at: message.at,
57
+ message,
58
+ })),
59
+ ...posts.map((post): AgentTranscriptEntry => ({
60
+ kind: 'agent',
61
+ index: post.index,
62
+ author: 'agent',
63
+ at: post.at,
64
+ post,
65
+ })),
66
+ ].toSorted((left, right) => left.index - right.index)
67
+
68
+ return ordered.map((entry, position) => {
69
+ const previous = ordered[position - 1]
70
+ return Object.assign(entry, {
71
+ showHeader:
72
+ previous === undefined || !shouldGroupMessage(previous, entry),
73
+ })
74
+ })
75
+ }
76
+
77
+ export type AgentMessageMetadata = Extract<
78
+ ChatMessageMetadata,
79
+ { kind: 'agent' }
80
+ >
81
+
82
+ export type AgentStreamSelection = {
83
+ message: ChatAgentMessage
84
+ parts: ChatAgentMessage['parts']
85
+ metadata: AgentMessageMetadata | null
86
+ }
87
+
88
+ export const selectAgentStream = (
89
+ messages: ChatAgentMessage[],
90
+ responseMessageId: string,
91
+ ): AgentStreamSelection | null => {
92
+ const message = messages.find(
93
+ (candidate) => candidate.id === responseMessageId,
94
+ )
95
+ if (message === undefined) return null
96
+ return {
97
+ message,
98
+ parts: message.parts,
99
+ metadata: message.metadata?.kind === 'agent' ? message.metadata : null,
100
+ }
101
+ }
@@ -0,0 +1,44 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { insertComposerText, isComposerTypingKey } from './composer'
3
+
4
+ const key = (
5
+ value: string,
6
+ overrides: Partial<Parameters<typeof isComposerTypingKey>[0]> = {},
7
+ ): Parameters<typeof isComposerTypingKey>[0] => ({
8
+ key: value,
9
+ altKey: false,
10
+ ctrlKey: false,
11
+ metaKey: false,
12
+ isComposing: false,
13
+ defaultPrevented: false,
14
+ ...overrides,
15
+ })
16
+
17
+ describe('composer keyboard routing', () => {
18
+ it('captures printable keys, including shifted characters and spaces', () => {
19
+ expect(isComposerTypingKey(key('A'))).toBe(true)
20
+ expect(isComposerTypingKey(key(' '))).toBe(true)
21
+ })
22
+
23
+ it('leaves shortcuts, composition, navigation, and handled keys alone', () => {
24
+ expect(isComposerTypingKey(key('k', { metaKey: true }))).toBe(false)
25
+ expect(isComposerTypingKey(key('a', { ctrlKey: true }))).toBe(false)
26
+ expect(isComposerTypingKey(key('Enter'))).toBe(false)
27
+ expect(isComposerTypingKey(key('ArrowDown'))).toBe(false)
28
+ expect(isComposerTypingKey(key('a', { isComposing: true }))).toBe(false)
29
+ expect(isComposerTypingKey(key('a', { defaultPrevented: true }))).toBe(
30
+ false,
31
+ )
32
+ })
33
+
34
+ it('inserts the first routed character at the current selection', () => {
35
+ expect(insertComposerText('hello room', '!', 5, 10)).toEqual({
36
+ value: 'hello!',
37
+ caret: 6,
38
+ })
39
+ expect(insertComposerText('hello', '?', null, null)).toEqual({
40
+ value: 'hello?',
41
+ caret: 6,
42
+ })
43
+ })
44
+ })
@@ -0,0 +1,30 @@
1
+ export type ComposerKey = {
2
+ key: string
3
+ altKey: boolean
4
+ ctrlKey: boolean
5
+ metaKey: boolean
6
+ isComposing: boolean
7
+ defaultPrevented: boolean
8
+ }
9
+
10
+ export const isComposerTypingKey = (event: ComposerKey): boolean =>
11
+ !event.defaultPrevented &&
12
+ !event.isComposing &&
13
+ !event.altKey &&
14
+ !event.ctrlKey &&
15
+ !event.metaKey &&
16
+ event.key.length === 1
17
+
18
+ export const insertComposerText = (
19
+ value: string,
20
+ text: string,
21
+ selectionStart: number | null,
22
+ selectionEnd: number | null,
23
+ ): { value: string; caret: number } => {
24
+ const start = selectionStart ?? value.length
25
+ const end = selectionEnd ?? start
26
+ return {
27
+ value: `${value.slice(0, start)}${text}${value.slice(end)}`,
28
+ caret: start + text.length,
29
+ }
30
+ }
@@ -0,0 +1,30 @@
1
+ import type { ReactNode } from 'react'
2
+ import { chatReducer } from '../model'
3
+ import { chatServer } from '../server'
4
+ import { SessionProvider } from './session'
5
+ import { ChatClient } from './chat-client'
6
+
7
+ export default async function ChatSessionPage({
8
+ params,
9
+ }: {
10
+ params: Promise<{ chatId: string }>
11
+ }): Promise<ReactNode> {
12
+ const { chatId } = await params
13
+ const session = chatServer.session(chatId)
14
+ const { state, index } = await session.state(chatReducer)
15
+ const events = (await session.history()).filter(
16
+ (event) => event.index <= index,
17
+ )
18
+
19
+ return (
20
+ <SessionProvider
21
+ sessionId={chatId}
22
+ api="/chat/events"
23
+ initialState={state}
24
+ initialIndex={index}
25
+ initialEvents={events}
26
+ >
27
+ <ChatClient chatId={chatId} />
28
+ </SessionProvider>
29
+ )
30
+ }
@@ -0,0 +1,7 @@
1
+ 'use client'
2
+ import { createReact } from 'experimental-a2/react'
3
+ import { chatReducer } from '../model'
4
+
5
+ export const { SessionProvider, useSession } = createReact({
6
+ reducer: chatReducer,
7
+ })
@@ -0,0 +1,7 @@
1
+ import { authorizeChatOperation, chatServer } from '../server'
2
+
3
+ export const GET = chatServer.fetch
4
+
5
+ export function POST(request: Request): Promise<Response> {
6
+ return chatServer.fetch(request, { authorize: authorizeChatOperation })
7
+ }
@@ -0,0 +1,155 @@
1
+ import type { ContractEvent } from 'experimental-a2'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { chat, chatReducer, type ChatState } from './model'
4
+
5
+ type ChatEvents = typeof chat.events
6
+ type ChatEvent<
7
+ K extends keyof ChatEvents & string = keyof ChatEvents & string,
8
+ > = ContractEvent<ChatEvents, K>
9
+
10
+ const event = <K extends keyof ChatEvents & string>(
11
+ index: number,
12
+ type: K,
13
+ payload: ChatEvent<K>['payload'],
14
+ ): ChatEvent<K> =>
15
+ ({
16
+ id: `event-${index}`,
17
+ type,
18
+ payload,
19
+ index,
20
+ sessionId: 'chat-1',
21
+ createdAt: new Date(index * 1_000),
22
+ }) as ChatEvent<K>
23
+
24
+ const generation = {
25
+ requestId: 'request-1',
26
+ messageId: 'participant-1',
27
+ generationId: 'generation-1',
28
+ responseMessageId: 'assistant-1',
29
+ attempt: 1,
30
+ model: 'test/model',
31
+ } satisfies ChatEvent<'ai.generation.started'>['payload']
32
+
33
+ const setup = (): ChatEvent[] => [event(1, 'ai.generation.started', generation)]
34
+
35
+ const toolCalled = (index: number, toolCallId: string, message: string) =>
36
+ event(index, 'ai.tool.called', {
37
+ requestId: generation.requestId,
38
+ messageId: generation.responseMessageId,
39
+ generationId: generation.generationId,
40
+ toolCallId,
41
+ toolName: 'post',
42
+ input: { message },
43
+ })
44
+
45
+ const messagePosted = (index: number, toolCallId: string, message: string) =>
46
+ event(index, 'agent.message.posted', {
47
+ generationId: generation.generationId,
48
+ responseMessageId: generation.responseMessageId,
49
+ toolCallId,
50
+ message,
51
+ })
52
+
53
+ const fold = (...events: ChatEvent[]): ChatState =>
54
+ events.reduce(
55
+ (state, nextEvent) => chatReducer.fold(state, nextEvent),
56
+ chatReducer.initialState,
57
+ )
58
+
59
+ describe('chat agent post projection', () => {
60
+ it('keeps tool calls invisible until the tool appends its post event', () => {
61
+ const called = toolCalled(2, 'call-1', 'Hello from the tool')
62
+ let state = fold(...setup(), called)
63
+
64
+ expect(state.posts).toEqual([])
65
+ expect(state.ai.tools).toEqual([
66
+ expect.objectContaining({
67
+ generationId: generation.generationId,
68
+ toolCallId: 'call-1',
69
+ status: 'running',
70
+ }),
71
+ ])
72
+
73
+ state = chatReducer.fold(
74
+ state,
75
+ messagePosted(3, 'call-1', 'Hello from the tool'),
76
+ )
77
+
78
+ expect(state.posts).toEqual([
79
+ {
80
+ generationId: generation.generationId,
81
+ responseMessageId: generation.responseMessageId,
82
+ toolCallId: 'call-1',
83
+ index: 3,
84
+ text: 'Hello from the tool',
85
+ at: new Date(3_000).toISOString(),
86
+ },
87
+ ])
88
+ })
89
+
90
+ it('projects every explicit post in durable event order', () => {
91
+ const firstCall = toolCalled(2, 'call-1', 'First post')
92
+ const secondCall = toolCalled(3, 'call-2', 'Second post')
93
+ const state = fold(
94
+ ...setup(),
95
+ firstCall,
96
+ secondCall,
97
+ messagePosted(4, 'call-1', 'First post'),
98
+ messagePosted(5, 'call-2', 'Second post'),
99
+ )
100
+
101
+ expect(
102
+ state.posts.map(({ toolCallId, index, text }) => ({
103
+ toolCallId,
104
+ index,
105
+ text,
106
+ })),
107
+ ).toEqual([
108
+ { toolCallId: 'call-1', index: 4, text: 'First post' },
109
+ { toolCallId: 'call-2', index: 5, text: 'Second post' },
110
+ ])
111
+ })
112
+
113
+ it('rewinds posts past an interruption cutpoint and ignores late appends', () => {
114
+ const firstCall = toolCalled(2, 'call-1', 'Visible before stop')
115
+ const secondCall = toolCalled(4, 'call-2', 'Not visible at stop')
116
+ let state = fold(
117
+ ...setup(),
118
+ firstCall,
119
+ messagePosted(3, 'call-1', 'Visible before stop'),
120
+ secondCall,
121
+ messagePosted(5, 'call-2', 'Not visible at stop'),
122
+ )
123
+
124
+ expect(state.posts.map((post) => post.toolCallId)).toEqual([
125
+ 'call-1',
126
+ 'call-2',
127
+ ])
128
+
129
+ state = chatReducer.fold(
130
+ state,
131
+ event(6, 'ai.message.interrupted', {
132
+ messageId: generation.responseMessageId,
133
+ generationId: generation.generationId,
134
+ lastSeenIndex: 3,
135
+ }),
136
+ )
137
+
138
+ expect(state.posts.map((post) => post.toolCallId)).toEqual(['call-1'])
139
+ expect(state.ai.activeGeneration).toBeNull()
140
+
141
+ state = chatReducer.fold(
142
+ state,
143
+ toolCalled(7, 'call-3', 'Called after stop'),
144
+ )
145
+
146
+ expect(state.ai.activeGeneration).toBeNull()
147
+
148
+ state = chatReducer.fold(
149
+ state,
150
+ messagePosted(8, 'call-3', 'Late after stop'),
151
+ )
152
+
153
+ expect(state.posts.map((post) => post.toolCallId)).toEqual(['call-1'])
154
+ })
155
+ })