experimental-a2 0.15.0 → 0.16.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 (101) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/dist/actor-client.d.ts +1 -1
  3. package/dist/{actor-BfQSE0KC.d.ts → actor-ohPC-81x.d.ts} +5 -5
  4. package/dist/{actor-BfQSE0KC.d.ts.map → actor-ohPC-81x.d.ts.map} +1 -1
  5. package/dist/actor-react.d.ts +3 -3
  6. package/dist/actor.d.ts +1 -1
  7. package/dist/actor.js +1 -1
  8. package/dist/ai-CrLYwNEx.js +2431 -0
  9. package/dist/ai-CrLYwNEx.js.map +1 -0
  10. package/dist/{ai-Cai-lCbj.d.ts → ai-HJ9fHfYI.d.ts} +72 -13
  11. package/dist/ai-HJ9fHfYI.d.ts.map +1 -0
  12. package/dist/ai-server.d.ts +4 -5
  13. package/dist/ai-server.d.ts.map +1 -1
  14. package/dist/ai-server.js +755 -290
  15. package/dist/ai-server.js.map +1 -1
  16. package/dist/ai.d.ts +2 -2
  17. package/dist/ai.js +1 -1999
  18. package/dist/client-BYzHjkwU.js.map +1 -1
  19. package/dist/{client-BAEABRZB.d.ts → client-CzyacQpJ.d.ts} +9 -9
  20. package/dist/client-CzyacQpJ.d.ts.map +1 -0
  21. package/dist/client.d.ts +1 -1
  22. package/dist/idempotent-id-BRJVeylj.js +13 -0
  23. package/dist/idempotent-id-BRJVeylj.js.map +1 -0
  24. package/dist/index.d.ts +3 -3
  25. package/dist/index.js +1 -12
  26. package/dist/otel.d.ts +1 -1
  27. package/dist/react.d.ts +13 -13
  28. package/dist/react.d.ts.map +1 -1
  29. package/dist/react.js.map +1 -1
  30. package/dist/reducer-DEMjEY_O.js.map +1 -1
  31. package/dist/{reducer-BcS9VDKC.d.ts → reducer-otzHjuJj.d.ts} +3 -3
  32. package/dist/{reducer-BcS9VDKC.d.ts.map → reducer-otzHjuJj.d.ts.map} +1 -1
  33. package/dist/scheduler-qstash.d.ts +2 -2
  34. package/dist/scheduler-qstash.js +1 -1
  35. package/dist/scheduler-vercel.d.ts +2 -2
  36. package/dist/scheduler-vercel.js +1 -1
  37. package/dist/{server-CjJSGcF7.js → server-BD5ckxZb.js} +39 -26
  38. package/dist/server-BD5ckxZb.js.map +1 -0
  39. package/dist/{server-Bp5Nd1pF.d.ts → server-DbD7IJVK.d.ts} +5 -5
  40. package/dist/{server-Bp5Nd1pF.d.ts.map → server-DbD7IJVK.d.ts.map} +1 -1
  41. package/dist/server.d.ts +3 -3
  42. package/dist/server.js +1 -1
  43. package/dist/{store-D_yhNdPz.d.ts → store-DyZM6fS5.d.ts} +3 -2
  44. package/dist/{store-D_yhNdPz.d.ts.map → store-DyZM6fS5.d.ts.map} +1 -1
  45. package/dist/store-N8PXxDAS.js.map +1 -1
  46. package/dist/store-memory.d.ts +1 -1
  47. package/dist/store-postgres.d.ts +1 -1
  48. package/dist/store-redis-http.d.ts +1 -1
  49. package/dist/store-redis-http.js +1 -1
  50. package/dist/{store-redis-notify-D2EI6gwX.js → store-redis-notify-BVLUfI3j.js} +14 -13
  51. package/dist/store-redis-notify-BVLUfI3j.js.map +1 -0
  52. package/dist/store-redis.d.ts +1 -1
  53. package/dist/store-redis.js +1 -1
  54. package/dist/store-sqlite.d.ts +1 -1
  55. package/dist/{telemetry-CpeclqB2.d.ts → telemetry-B5jzpy6y.d.ts} +2 -2
  56. package/dist/telemetry-B5jzpy6y.d.ts.map +1 -0
  57. package/docs/concepts/04-state.mdx +9 -4
  58. package/docs/guides/06-ai-agents.mdx +134 -43
  59. package/docs/reference/01-api.mdx +104 -27
  60. package/examples/playground/app/agent/[agentId]/agent-client.tsx +20 -28
  61. package/examples/playground/app/agent/[agentId]/agent-queue.test.tsx +123 -0
  62. package/examples/playground/app/agent/[agentId]/agent-queue.tsx +37 -43
  63. package/examples/playground/app/agent/[agentId]/compaction-panel.tsx +44 -24
  64. package/examples/playground/app/agent/compaction-timeline.test.ts +47 -7
  65. package/examples/playground/app/agent/compaction-timeline.ts +13 -3
  66. package/examples/playground/app/chat/[chatId]/chat-client.tsx +1 -5
  67. package/examples/playground/app/chat/[chatId]/session.ts +17 -2
  68. package/examples/playground/app/chat/model.test.ts +4 -4
  69. package/examples/playground/app/chat/model.ts +18 -6
  70. package/examples/playground/app/chat/server.ts +6 -3
  71. package/examples/playground/package.json +3 -1
  72. package/package.json +1 -1
  73. package/src/ai-client-state.ts +0 -2
  74. package/src/ai-context-schema.ts +296 -0
  75. package/src/ai-context.ts +680 -0
  76. package/src/ai-control-server.ts +160 -131
  77. package/src/ai-control-state.ts +6 -0
  78. package/src/ai-control.ts +6 -7
  79. package/src/ai-coordinator.ts +92 -38
  80. package/src/ai-id.ts +6 -0
  81. package/src/ai-input-tokens.ts +75 -0
  82. package/src/ai-message-projection.ts +348 -0
  83. package/src/ai-server.ts +232 -334
  84. package/src/ai-stored-state.ts +484 -0
  85. package/src/ai.ts +252 -418
  86. package/src/client.ts +21 -15
  87. package/src/react.ts +25 -19
  88. package/src/reducer.ts +2 -1
  89. package/src/server.ts +73 -56
  90. package/src/store-redis-core.ts +15 -17
  91. package/src/store.ts +1 -0
  92. package/src/telemetry.ts +2 -1
  93. package/dist/ai-Cai-lCbj.d.ts.map +0 -1
  94. package/dist/ai-control-CcD4hh3y.js +0 -119
  95. package/dist/ai-control-CcD4hh3y.js.map +0 -1
  96. package/dist/ai.js.map +0 -1
  97. package/dist/client-BAEABRZB.d.ts.map +0 -1
  98. package/dist/index.js.map +0 -1
  99. package/dist/server-CjJSGcF7.js.map +0 -1
  100. package/dist/store-redis-notify-D2EI6gwX.js.map +0 -1
  101. package/dist/telemetry-CpeclqB2.d.ts.map +0 -1
@@ -6,7 +6,11 @@ import type {
6
6
  } from 'react'
7
7
  import { useEffect, useRef, useState } from 'react'
8
8
  import type { UIMessage } from 'ai'
9
- import { inputs, type ControlCommand } from 'experimental-a2/ai'
9
+ import {
10
+ inputs,
11
+ type ControlCommand,
12
+ type PendingApproval,
13
+ } from 'experimental-a2/ai'
10
14
  import { ActivityFeed } from '@/app/components/activity-feed'
11
15
  import { ConnectionPill } from '@/app/components/connection-pill'
12
16
  import { useSession } from '../session'
@@ -215,20 +219,22 @@ export function AgentClient({ agentId }: { agentId: string }): ReactNode {
215
219
  }
216
220
  }
217
221
 
218
- const answerApproval = async (
219
- messageId: string,
220
- generationId: string,
221
- approvalId: string,
222
- approved: boolean,
223
- ): Promise<void> => {
222
+ const answerApproval = async ({
223
+ approval,
224
+ approved,
225
+ }: {
226
+ approval: PendingApproval
227
+ approved: boolean
228
+ }): Promise<void> => {
224
229
  setResponding(true)
225
230
  setError(null)
226
231
  try {
227
232
  await push(
228
233
  ...inputs.approval({
229
- messageId,
230
- generationId,
231
- approvalId,
234
+ requestId: approval.requestId,
235
+ messageId: approval.messageId,
236
+ generationId: approval.generationId,
237
+ approvalId: approval.approvalId,
232
238
  approved,
233
239
  ...(!approved ? { reason: 'operator denied the restart' } : {}),
234
240
  }),
@@ -349,12 +355,7 @@ export function AgentClient({ agentId }: { agentId: string }): ReactNode {
349
355
  type="button"
350
356
  disabled={responding}
351
357
  onClick={() =>
352
- void answerApproval(
353
- approval.messageId,
354
- approval.generationId,
355
- approval.approvalId,
356
- true,
357
- )
358
+ void answerApproval({ approval, approved: true })
358
359
  }
359
360
  >
360
361
  Approve restart
@@ -364,12 +365,7 @@ export function AgentClient({ agentId }: { agentId: string }): ReactNode {
364
365
  className="secondary"
365
366
  disabled={responding}
366
367
  onClick={() =>
367
- void answerApproval(
368
- approval.messageId,
369
- approval.generationId,
370
- approval.approvalId,
371
- false,
372
- )
368
+ void answerApproval({ approval, approved: false })
373
369
  }
374
370
  >
375
371
  Deny
@@ -387,10 +383,6 @@ export function AgentClient({ agentId }: { agentId: string }): ReactNode {
387
383
  <AgentQueue
388
384
  items={inbox.items}
389
385
  paused={inbox.paused}
390
- pendingEdits={(state.pendingQueueCommands ?? []).flatMap(
391
- ({ command: pending }) =>
392
- pending.action === 'edit' ? [pending.message.id] : [],
393
- )}
394
386
  remove={async (inputId) => {
395
387
  await command(inputs.queue.remove({ inputId }))
396
388
  }}
@@ -402,8 +394,8 @@ export function AgentClient({ agentId }: { agentId: string }): ReactNode {
402
394
  }),
403
395
  )
404
396
  }}
405
- edit={async (message, expectedRevision) => {
406
- await push(...inputs.queue.edit({ message, expectedRevision }))
397
+ edit={async (message) => {
398
+ await push(...inputs.queue.edit({ message }))
407
399
  }}
408
400
  move={(inputId, beforeId) =>
409
401
  void command(inputs.queue.move({ inputId, beforeId }))
@@ -0,0 +1,123 @@
1
+ // @vitest-environment jsdom
2
+ import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
3
+ import { afterEach, expect, it, vi } from 'vitest'
4
+ import type { UIMessage } from 'ai'
5
+ import { AgentQueue } from './agent-queue'
6
+
7
+ afterEach(cleanup)
8
+
9
+ type Edit = (message: UIMessage) => Promise<void>
10
+
11
+ const queue = (edit: Edit) =>
12
+ render(
13
+ <AgentQueue
14
+ items={[
15
+ {
16
+ id: 'queued',
17
+ message: {
18
+ id: 'queued',
19
+ role: 'user',
20
+ parts: [{ type: 'text', text: 'Original' }],
21
+ },
22
+ generate: true,
23
+ },
24
+ ]}
25
+ paused
26
+ edit={edit}
27
+ move={vi.fn<() => void>()}
28
+ remove={vi.fn<() => Promise<void>>().mockResolvedValue(undefined)}
29
+ sendNow={vi.fn<() => Promise<void>>().mockResolvedValue(undefined)}
30
+ />,
31
+ )
32
+
33
+ const open = (): void => {
34
+ fireEvent.click(screen.getByRole('button', { name: 'Edit queued message 1' }))
35
+ }
36
+ const save = (text: string): void => {
37
+ fireEvent.change(screen.getByRole('textbox'), { target: { value: text } })
38
+ fireEvent.click(screen.getByRole('button', { name: 'Save' }))
39
+ }
40
+
41
+ it('closes immediately and permits another edit while saves remain in flight', async () => {
42
+ const first = Promise.withResolvers<void>()
43
+ const second = Promise.withResolvers<void>()
44
+ const edit = vi
45
+ .fn<Edit>()
46
+ .mockImplementationOnce(() => first.promise)
47
+ .mockImplementationOnce(() => second.promise)
48
+ queue(edit)
49
+ open()
50
+ save('First')
51
+ expect(screen.queryByRole('textbox')).toBeNull()
52
+ expect(
53
+ (
54
+ screen.getByRole('button', {
55
+ name: 'Edit queued message 1',
56
+ }) as HTMLButtonElement
57
+ ).disabled,
58
+ ).toBe(false)
59
+ expect(
60
+ (
61
+ screen.getByRole('button', {
62
+ name: 'Send queued message 1 after current step',
63
+ }) as HTMLButtonElement
64
+ ).disabled,
65
+ ).toBe(false)
66
+ open()
67
+ save('Second')
68
+ expect(edit.mock.calls.map(([message]) => message.parts)).toEqual([
69
+ [{ type: 'text', text: 'First' }],
70
+ [{ type: 'text', text: 'Second' }],
71
+ ])
72
+ await act(async () => {
73
+ first.reject(new Error('Earlier save failed'))
74
+ await first.promise.catch(() => {})
75
+ })
76
+ expect(screen.queryByRole('textbox')).toBeNull()
77
+ expect(screen.queryByRole('alert')).toBeNull()
78
+ await act(async () => {
79
+ second.resolve()
80
+ await second.promise
81
+ })
82
+ })
83
+
84
+ it('restores the latest submitted draft on transport failure', async () => {
85
+ const pending = Promise.withResolvers<void>()
86
+ queue(() => pending.promise)
87
+ open()
88
+ save('Keep this draft')
89
+ expect(screen.queryByRole('textbox')).toBeNull()
90
+ await act(async () => {
91
+ pending.reject(new Error('Save failed'))
92
+ await pending.promise.catch(() => {})
93
+ })
94
+ expect((screen.getByRole('textbox') as HTMLTextAreaElement).value).toBe(
95
+ 'Keep this draft',
96
+ )
97
+ expect(screen.getByRole('alert').textContent).toBe('Save failed')
98
+ })
99
+
100
+ it.each(['open', 'cancel'] as const)(
101
+ 'does not restore an old failed save after a newer draft is %s',
102
+ async (action) => {
103
+ const pending = Promise.withResolvers<void>()
104
+ queue(() => pending.promise)
105
+ open()
106
+ save('Old draft')
107
+ open()
108
+ fireEvent.change(screen.getByRole('textbox'), {
109
+ target: { value: 'New draft' },
110
+ })
111
+ if (action === 'cancel')
112
+ fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
113
+ await act(async () => {
114
+ pending.reject(new Error('Old save failed'))
115
+ await pending.promise.catch(() => {})
116
+ })
117
+ expect(
118
+ (screen.queryByRole('textbox') as HTMLTextAreaElement | null)?.value ??
119
+ null,
120
+ ).toBe(action === 'open' ? 'New draft' : null)
121
+ expect(screen.queryByRole('alert')).toBeNull()
122
+ },
123
+ )
@@ -7,8 +7,7 @@ import type { InboxItem } from 'experimental-a2/ai'
7
7
  type QueueProps = {
8
8
  items: InboxItem[]
9
9
  paused: boolean
10
- pendingEdits: readonly string[]
11
- edit(message: UIMessage, revision: number): Promise<void>
10
+ edit(message: UIMessage): Promise<void>
12
11
  move(inputId: string, beforeId: string | null): void
13
12
  remove(inputId: string): Promise<void>
14
13
  sendNow(inputId: string): Promise<void>
@@ -50,7 +49,6 @@ function QueuedInput({
50
49
  remove,
51
50
  sendNow,
52
51
  paused,
53
- pendingEdit,
54
52
  }: {
55
53
  item: InboxItem
56
54
  position: number
@@ -61,26 +59,25 @@ function QueuedInput({
61
59
  remove: QueueProps['remove']
62
60
  sendNow: QueueProps['sendNow']
63
61
  paused: boolean
64
- pendingEdit: boolean
65
62
  }): ReactNode {
66
- const [draft, setDraft] = useState<{ text: string; revision: number } | null>(
67
- null,
68
- )
63
+ const [draft, setDraft] = useState<string | null>(null)
64
+ const editAttempt = useRef(0)
69
65
  const [pending, setPending] = useState(false)
70
66
  const [error, setError] = useState<string | null>(null)
71
67
  const editButton = useRef<HTMLButtonElement>(null)
72
68
  const restoreFocus = useRef(false)
73
69
  useEffect(() => {
74
- if (draft === null && !pending && !pendingEdit && restoreFocus.current) {
70
+ if (draft === null && !pending && restoreFocus.current) {
75
71
  if (document.activeElement === document.body) editButton.current?.focus()
76
72
  restoreFocus.current = false
77
73
  }
78
- }, [draft, pending, pendingEdit])
74
+ }, [draft, pending])
79
75
  const text = item.message.parts
80
76
  .filter((part) => part.type === 'text')
81
77
  .map((part) => part.text)
82
78
  .join('\n')
83
79
  const cancel = (): void => {
80
+ editAttempt.current += 1
84
81
  restoreFocus.current = true
85
82
  setDraft(null)
86
83
  setError(null)
@@ -98,17 +95,23 @@ function QueuedInput({
98
95
  }
99
96
  }
100
97
  const save = async (): Promise<void> => {
101
- if (!draft || pendingEdit || !draft.text.trim()) return
102
- await perform(async () => {
103
- await edit(
104
- { ...item.message, parts: [{ type: 'text', text: draft.text }] },
105
- draft.revision,
106
- )
107
- cancel()
108
- })
98
+ if (draft === null || !draft.trim()) return
99
+ const submitted = draft
100
+ cancel()
101
+ const attempt = editAttempt.current
102
+ try {
103
+ await edit({
104
+ ...item.message,
105
+ parts: [{ type: 'text', text: submitted }],
106
+ })
107
+ } catch (failure) {
108
+ if (editAttempt.current !== attempt) return
109
+ setDraft(submitted)
110
+ setError(failure instanceof Error ? failure.message : String(failure))
111
+ }
109
112
  }
110
113
  return (
111
- <li className={`agent-queue-item${draft ? ' editing' : ''}`}>
114
+ <li className={`agent-queue-item${draft !== null ? ' editing' : ''}`}>
112
115
  <div className="agent-queue-row">
113
116
  <span
114
117
  className="agent-queue-number"
@@ -129,12 +132,7 @@ function QueuedInput({
129
132
  : 'Send after current step'
130
133
  }
131
134
  aria-label={`Send queued message ${position + 1} after current step`}
132
- disabled={
133
- pending ||
134
- pendingEdit ||
135
- draft !== null ||
136
- item.message.role !== 'user'
137
- }
135
+ disabled={pending || draft !== null || item.message.role !== 'user'}
138
136
  onClick={() => void perform(() => sendNow(item.id))}
139
137
  >
140
138
  <QueueIcon name="send" />
@@ -142,15 +140,18 @@ function QueuedInput({
142
140
  <button
143
141
  ref={editButton}
144
142
  type="button"
145
- title={
146
- pendingEdit ? 'Waiting for edit confirmation' : 'Edit message'
147
- }
143
+ title="Edit message"
148
144
  aria-label={`Edit queued message ${position + 1}`}
149
145
  aria-expanded={draft !== null}
150
- disabled={pending || pendingEdit}
151
- onClick={() =>
152
- draft ? cancel() : setDraft({ text, revision: item.revision })
153
- }
146
+ disabled={pending}
147
+ onClick={() => {
148
+ if (draft !== null) cancel()
149
+ else {
150
+ editAttempt.current += 1
151
+ setDraft(text)
152
+ setError(null)
153
+ }
154
+ }}
154
155
  >
155
156
  <QueueIcon name="edit" />
156
157
  </button>
@@ -189,7 +190,7 @@ function QueuedInput({
189
190
  {error}
190
191
  </p>
191
192
  ) : null}
192
- {draft ? (
193
+ {draft !== null ? (
193
194
  <form
194
195
  className="agent-queue-editor"
195
196
  onSubmit={(event) => {
@@ -201,11 +202,9 @@ function QueuedInput({
201
202
  autoFocus
202
203
  aria-label={`Edit text for queued message ${position + 1}`}
203
204
  rows={2}
204
- value={draft.text}
205
+ value={draft}
205
206
  disabled={pending}
206
- onChange={(event) =>
207
- setDraft({ ...draft, text: event.target.value })
208
- }
207
+ onChange={(event) => setDraft(event.target.value)}
209
208
  onKeyDown={(event) => {
210
209
  if (event.nativeEvent.isComposing) return
211
210
  if (event.key === 'Escape' && !pending) {
@@ -228,11 +227,8 @@ function QueuedInput({
228
227
  >
229
228
  Cancel
230
229
  </button>
231
- <button
232
- type="submit"
233
- disabled={pending || pendingEdit || !draft.text.trim()}
234
- >
235
- {pending ? 'Saving…' : 'Save'}
230
+ <button type="submit" disabled={pending || !draft.trim()}>
231
+ Save
236
232
  </button>
237
233
  </div>
238
234
  </form>
@@ -244,7 +240,6 @@ function QueuedInput({
244
240
  export function AgentQueue({
245
241
  items,
246
242
  paused,
247
- pendingEdits,
248
243
  edit,
249
244
  move,
250
245
  remove,
@@ -272,7 +267,6 @@ export function AgentQueue({
272
267
  remove={remove}
273
268
  sendNow={sendNow}
274
269
  paused={paused}
275
- pendingEdit={pendingEdits.includes(item.id)}
276
270
  moveUp={() => {
277
271
  const previous = items[position - 1]
278
272
  if (previous) move(item.id, previous.id)
@@ -183,6 +183,11 @@ export function CompactionPanel({
183
183
  const completed = events
184
184
  .filter((event) => event.type === 'ai.compaction.completed')
185
185
  .toReversed()
186
+ const requested = new Map(
187
+ events
188
+ .filter((event) => event.type === 'ai.compaction.requested')
189
+ .map((event) => [event.payload.generationId, event.payload]),
190
+ )
186
191
  const compacting = state.compaction?.status === 'running'
187
192
 
188
193
  return (
@@ -232,10 +237,15 @@ export function CompactionPanel({
232
237
  <dd>{tokens(lastStep?.payload.usage?.inputTokens)}</dd>
233
238
  </div>
234
239
  <div>
235
- <dt>Last step input · estimated</dt>
240
+ <dt>Last step input · raw estimate</dt>
236
241
  <dd>{tokens(lastStep?.payload.inputTokenEstimate)}</dd>
237
242
  </div>
238
243
  </dl>
244
+ <p className="hint">
245
+ The raw estimate excludes provider metadata. Compaction adjusts it
246
+ using the previous step’s reported usage when the model matches and
247
+ the prompt has not shrunk.
248
+ </p>
239
249
  {limits === undefined ? (
240
250
  <p className="hint">
241
251
  {metadata?.status === 'unavailable'
@@ -262,29 +272,39 @@ export function CompactionPanel({
262
272
  </p>
263
273
  ) : (
264
274
  <ol>
265
- {completed.map((event) => (
266
- <li key={event.id}>
267
- <div className="compaction-marker">
268
- <strong>Context compacted · #{event.index}</strong>
269
- <time dateTime={event.createdAt.toISOString()}>
270
- {formatActivityTime(event.createdAt)}
271
- </time>
272
- </div>
273
- <p className="hint">
274
- {event.payload.throughIndex === undefined
275
- ? `Through message ${event.payload.throughMessageId}`
276
- : `Summarized through event #${event.payload.throughIndex}`}{' '}
277
- · {tokens(event.payload.usage?.inputTokens)} in ·{' '}
278
- {tokens(event.payload.usage?.outputTokens)} out
279
- </p>
280
- {event.payload.summary ? (
281
- <details>
282
- <summary>View summary</summary>
283
- <pre>{event.payload.summary}</pre>
284
- </details>
285
- ) : null}
286
- </li>
287
- ))}
275
+ {completed.map((event) => {
276
+ const trigger = requested.get(event.payload.generationId)
277
+ return (
278
+ <li key={event.id}>
279
+ <div className="compaction-marker">
280
+ <strong>Context compacted · #{event.index}</strong>
281
+ <time dateTime={event.createdAt.toISOString()}>
282
+ {formatActivityTime(event.createdAt)}
283
+ </time>
284
+ </div>
285
+ <p className="hint">
286
+ {event.payload.throughIndex === undefined
287
+ ? `Through message ${event.payload.throughMessageId}`
288
+ : `Summarized through event #${event.payload.throughIndex}`}{' '}
289
+ · {tokens(event.payload.usage?.inputTokens)} in ·{' '}
290
+ {tokens(event.payload.usage?.outputTokens)} out
291
+ </p>
292
+ {trigger?.inputTokens === undefined ? null : (
293
+ <p className="hint">
294
+ Trigger: {tokens(trigger.inputTokens)} against a{' '}
295
+ {tokens(trigger.thresholdTokens)} threshold. Raw
296
+ estimate: {tokens(trigger.inputTokenEstimate)}.
297
+ </p>
298
+ )}
299
+ {event.payload.summary ? (
300
+ <details>
301
+ <summary>View summary</summary>
302
+ <pre>{event.payload.summary}</pre>
303
+ </details>
304
+ ) : null}
305
+ </li>
306
+ )
307
+ })}
288
308
  </ol>
289
309
  )}
290
310
  </details>
@@ -37,16 +37,18 @@ function transcript() {
37
37
  requestId = 'request',
38
38
  reason = 'message',
39
39
  attempt = 1,
40
+ sourceGenerationId,
40
41
  }: {
41
42
  requestId?: string
42
43
  reason?: 'message' | 'tool' | 'retry'
43
44
  attempt?: number
45
+ sourceGenerationId?: string
44
46
  } = {}): Generation => {
45
47
  const generation = {
46
48
  requestId,
47
49
  messageId: reason === 'tool' ? 'assistant' : 'user',
48
50
  responseMessageId: 'assistant',
49
- generationId: `${requestId}:generation:${attempt}`,
51
+ generationId: `generation-${events.length}-${attempt}`,
50
52
  attempt,
51
53
  model: 'test/model',
52
54
  }
@@ -58,6 +60,7 @@ function transcript() {
58
60
  messageId: generation.messageId,
59
61
  responseMessageId: 'assistant',
60
62
  reason,
63
+ ...(sourceGenerationId === undefined ? {} : { sourceGenerationId }),
61
64
  },
62
65
  })
63
66
  append({ type: 'ai.generation.started', payload: generation })
@@ -94,7 +97,10 @@ function transcript() {
94
97
  })
95
98
  }
96
99
  const state = () =>
97
- events.reduce(reduceAIState, demoAgent.reducer.initialState)
100
+ events.reduce(
101
+ reduceAIState,
102
+ demoAgent.reducer.view(demoAgent.reducer.initialState),
103
+ )
98
104
  const timeline = () => compactionTimeline({ state: state(), events })
99
105
  return {
100
106
  events,
@@ -168,7 +174,8 @@ it('places compaction between tool steps without moving a queued user into the r
168
174
  })
169
175
  log.user('queued')
170
176
  const second = log.start({
171
- requestId: `ai.generate:tools:${first.generationId}`,
177
+ requestId: 'continuation-request',
178
+ sourceGenerationId: first.generationId,
172
179
  reason: 'tool',
173
180
  })
174
181
  log.compact(second, false)
@@ -230,7 +237,7 @@ it('preserves compaction history but drops superseded attempt content', () => {
230
237
  log
231
238
  .timeline()
232
239
  .find((entry) => entry.id === `compaction:${first.generationId}`),
233
- ).toMatchObject({ outcome: 'superseded', running: false })
240
+ ).toMatchObject({ outcome: undefined, running: false })
234
241
  })
235
242
 
236
243
  it('keeps old checkpoints before an explicit retry without restoring discarded parts', () => {
@@ -256,7 +263,7 @@ it('keeps old checkpoints before an explicit retry without restoring discarded p
256
263
  log
257
264
  .timeline()
258
265
  .find((entry) => entry.id === `compaction:${first.generationId}`),
259
- ).toMatchObject({ outcome: 'failed' })
266
+ ).toMatchObject({ outcome: undefined })
260
267
  })
261
268
 
262
269
  it('does not leave failed compaction spinning', () => {
@@ -277,6 +284,38 @@ it('does not leave failed compaction spinning', () => {
277
284
  })
278
285
  })
279
286
 
287
+ it('keeps completed steps around committed compactions when retrying a later step', () => {
288
+ const log = transcript()
289
+ log.user('user')
290
+ const first = log.start()
291
+ log.compact(first)
292
+ log.text(first, 'Completed step')
293
+ log.append({
294
+ type: 'ai.generation.completed',
295
+ payload: { ...first, finishReason: 'tool-calls' },
296
+ })
297
+ const second = log.start({
298
+ requestId: 'second',
299
+ reason: 'tool',
300
+ sourceGenerationId: first.generationId,
301
+ })
302
+ log.compact(second)
303
+ log.text(second, 'Discarded partial step')
304
+ log.append({
305
+ type: 'ai.generation.failed',
306
+ payload: { ...second, error: 'Failed' },
307
+ })
308
+ const retry = log.start({ requestId: 'retry', reason: 'retry' })
309
+ log.text(retry, 'Retried step')
310
+ expect(labels(log.timeline())).toEqual([
311
+ 'user',
312
+ `compaction:${first.generationId}`,
313
+ 'Completed step',
314
+ `compaction:${second.generationId}`,
315
+ 'Retried step',
316
+ ])
317
+ })
318
+
280
319
  it.each([false, true])(
281
320
  'preserves complete messages without step boundaries, delta only=%s',
282
321
  (deltaOnly) => {
@@ -289,7 +328,8 @@ it.each([false, true])(
289
328
  payload: { ...first, finishReason: 'tool-calls' },
290
329
  })
291
330
  const second = log.start({
292
- requestId: `ai.generate:tools:${first.generationId}`,
331
+ requestId: 'continuation-request',
332
+ sourceGenerationId: first.generationId,
293
333
  reason: 'tool',
294
334
  })
295
335
  log.compact(second)
@@ -326,7 +366,7 @@ it('ignores a rejected later generation when mapping canonical parts', () => {
326
366
  })
327
367
  const rejected = log.start({ requestId: 'stale-retry', reason: 'retry' })
328
368
  log.text(rejected, 'Ignored')
329
- expect(log.state().responseGenerationIds['assistant']).toBe(
369
+ expect(log.state().responseOwners['assistant']?.generationId).toBe(
330
370
  first.generationId,
331
371
  )
332
372
  expect(labels(log.timeline())).toEqual([
@@ -31,6 +31,7 @@ export function compactionTimeline({
31
31
  const requests = new Map<string, string>()
32
32
  const stepCounts = new Map<string, number>()
33
33
  const contentGenerations = new Set<string>()
34
+ const completedGenerations = new Set<string>()
34
35
  const compactions = new Map<string, CompactionEvent>()
35
36
  const roots = new Map<string, string>()
36
37
  for (const event of events) {
@@ -72,13 +73,16 @@ export function compactionTimeline({
72
73
  case 'ai.compaction.completed':
73
74
  compactions.set(event.payload.generationId, event)
74
75
  break
76
+ case 'ai.generation.completed':
77
+ completedGenerations.add(event.payload.generationId)
78
+ break
75
79
  }
76
80
  }
77
81
 
78
82
  const contributions = new Map<string, Generation[]>()
79
83
  for (const generation of generations.values()) {
80
84
  const { generationId, responseMessageId, requestId } = generation.payload
81
- const ownerId = state.responseGenerationIds[responseMessageId]
85
+ const ownerId = state.responseOwners[responseMessageId]?.generationId
82
86
  const owner = ownerId === undefined ? undefined : generations.get(ownerId)
83
87
  if (
84
88
  owner === undefined ||
@@ -87,7 +91,10 @@ export function compactionTimeline({
87
91
  )
88
92
  continue
89
93
  let steps = contributions.get(responseMessageId) ?? []
90
- if (requests.get(requestId) === 'retry') steps = []
94
+ if (requests.get(requestId) === 'retry')
95
+ steps = steps.filter((step) =>
96
+ completedGenerations.has(step.payload.generationId),
97
+ )
91
98
  if (steps.at(-1)?.payload.requestId === requestId) steps.pop()
92
99
  steps.push(generation)
93
100
  contributions.set(responseMessageId, steps)
@@ -122,7 +129,10 @@ export function compactionTimeline({
122
129
  running:
123
130
  state.compaction?.generationId === generationId &&
124
131
  state.compaction.status === 'running',
125
- outcome: terminal === 'completed' ? undefined : terminal,
132
+ outcome:
133
+ event.type === 'ai.compaction.completed' || terminal === 'completed'
134
+ ? undefined
135
+ : terminal,
126
136
  }
127
137
  const responseId = generations.get(generationId)?.payload.responseMessageId
128
138
  const anchor =
@@ -376,11 +376,7 @@ export function ChatClient({ chatId }: { chatId: string }): ReactNode {
376
376
  )
377
377
  : undefined
378
378
  const failedRootMessageId = failedGeneration
379
- ? state.messages.find(
380
- (message) =>
381
- `${message.id}:assistant` ===
382
- failedGeneration.payload.responseMessageId,
383
- )?.id
379
+ ? state.ai.active?.input.id
384
380
  : undefined
385
381
 
386
382
  const sendTypingPing = useCallback((): void => {