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.
- package/CHANGELOG.md +73 -0
- package/dist/actor-client.d.ts +1 -1
- package/dist/{actor-BfQSE0KC.d.ts → actor-ohPC-81x.d.ts} +5 -5
- package/dist/{actor-BfQSE0KC.d.ts.map → actor-ohPC-81x.d.ts.map} +1 -1
- package/dist/actor-react.d.ts +3 -3
- package/dist/actor.d.ts +1 -1
- package/dist/actor.js +1 -1
- package/dist/ai-CrLYwNEx.js +2431 -0
- package/dist/ai-CrLYwNEx.js.map +1 -0
- package/dist/{ai-Cai-lCbj.d.ts → ai-HJ9fHfYI.d.ts} +72 -13
- package/dist/ai-HJ9fHfYI.d.ts.map +1 -0
- package/dist/ai-server.d.ts +4 -5
- package/dist/ai-server.d.ts.map +1 -1
- package/dist/ai-server.js +755 -290
- package/dist/ai-server.js.map +1 -1
- package/dist/ai.d.ts +2 -2
- package/dist/ai.js +1 -1999
- package/dist/client-BYzHjkwU.js.map +1 -1
- package/dist/{client-BAEABRZB.d.ts → client-CzyacQpJ.d.ts} +9 -9
- package/dist/client-CzyacQpJ.d.ts.map +1 -0
- package/dist/client.d.ts +1 -1
- package/dist/idempotent-id-BRJVeylj.js +13 -0
- package/dist/idempotent-id-BRJVeylj.js.map +1 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.js +1 -12
- package/dist/otel.d.ts +1 -1
- package/dist/react.d.ts +13 -13
- package/dist/react.d.ts.map +1 -1
- package/dist/react.js.map +1 -1
- package/dist/reducer-DEMjEY_O.js.map +1 -1
- package/dist/{reducer-BcS9VDKC.d.ts → reducer-otzHjuJj.d.ts} +3 -3
- package/dist/{reducer-BcS9VDKC.d.ts.map → reducer-otzHjuJj.d.ts.map} +1 -1
- package/dist/scheduler-qstash.d.ts +2 -2
- package/dist/scheduler-qstash.js +1 -1
- package/dist/scheduler-vercel.d.ts +2 -2
- package/dist/scheduler-vercel.js +1 -1
- package/dist/{server-CjJSGcF7.js → server-BD5ckxZb.js} +39 -26
- package/dist/server-BD5ckxZb.js.map +1 -0
- package/dist/{server-Bp5Nd1pF.d.ts → server-DbD7IJVK.d.ts} +5 -5
- package/dist/{server-Bp5Nd1pF.d.ts.map → server-DbD7IJVK.d.ts.map} +1 -1
- package/dist/server.d.ts +3 -3
- package/dist/server.js +1 -1
- package/dist/{store-D_yhNdPz.d.ts → store-DyZM6fS5.d.ts} +3 -2
- package/dist/{store-D_yhNdPz.d.ts.map → store-DyZM6fS5.d.ts.map} +1 -1
- package/dist/store-N8PXxDAS.js.map +1 -1
- package/dist/store-memory.d.ts +1 -1
- package/dist/store-postgres.d.ts +1 -1
- package/dist/store-redis-http.d.ts +1 -1
- package/dist/store-redis-http.js +1 -1
- package/dist/{store-redis-notify-D2EI6gwX.js → store-redis-notify-BVLUfI3j.js} +14 -13
- package/dist/store-redis-notify-BVLUfI3j.js.map +1 -0
- package/dist/store-redis.d.ts +1 -1
- package/dist/store-redis.js +1 -1
- package/dist/store-sqlite.d.ts +1 -1
- package/dist/{telemetry-CpeclqB2.d.ts → telemetry-B5jzpy6y.d.ts} +2 -2
- package/dist/telemetry-B5jzpy6y.d.ts.map +1 -0
- package/docs/concepts/04-state.mdx +9 -4
- package/docs/guides/06-ai-agents.mdx +134 -43
- package/docs/reference/01-api.mdx +104 -27
- package/examples/playground/app/agent/[agentId]/agent-client.tsx +20 -28
- package/examples/playground/app/agent/[agentId]/agent-queue.test.tsx +123 -0
- package/examples/playground/app/agent/[agentId]/agent-queue.tsx +37 -43
- package/examples/playground/app/agent/[agentId]/compaction-panel.tsx +44 -24
- package/examples/playground/app/agent/compaction-timeline.test.ts +47 -7
- package/examples/playground/app/agent/compaction-timeline.ts +13 -3
- package/examples/playground/app/chat/[chatId]/chat-client.tsx +1 -5
- package/examples/playground/app/chat/[chatId]/session.ts +17 -2
- package/examples/playground/app/chat/model.test.ts +4 -4
- package/examples/playground/app/chat/model.ts +18 -6
- package/examples/playground/app/chat/server.ts +6 -3
- package/examples/playground/package.json +3 -1
- package/package.json +1 -1
- package/src/ai-client-state.ts +0 -2
- package/src/ai-context-schema.ts +296 -0
- package/src/ai-context.ts +680 -0
- package/src/ai-control-server.ts +160 -131
- package/src/ai-control-state.ts +6 -0
- package/src/ai-control.ts +6 -7
- package/src/ai-coordinator.ts +92 -38
- package/src/ai-id.ts +6 -0
- package/src/ai-input-tokens.ts +75 -0
- package/src/ai-message-projection.ts +348 -0
- package/src/ai-server.ts +232 -334
- package/src/ai-stored-state.ts +484 -0
- package/src/ai.ts +252 -418
- package/src/client.ts +21 -15
- package/src/react.ts +25 -19
- package/src/reducer.ts +2 -1
- package/src/server.ts +73 -56
- package/src/store-redis-core.ts +15 -17
- package/src/store.ts +1 -0
- package/src/telemetry.ts +2 -1
- package/dist/ai-Cai-lCbj.d.ts.map +0 -1
- package/dist/ai-control-CcD4hh3y.js +0 -119
- package/dist/ai-control-CcD4hh3y.js.map +0 -1
- package/dist/ai.js.map +0 -1
- package/dist/client-BAEABRZB.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/server-CjJSGcF7.js.map +0 -1
- package/dist/store-redis-notify-D2EI6gwX.js.map +0 -1
- 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 {
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
|
406
|
-
await push(...inputs.queue.edit({ message
|
|
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
|
-
|
|
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<
|
|
67
|
-
|
|
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 &&
|
|
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
|
|
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 (
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
|
151
|
-
onClick={() =>
|
|
152
|
-
draft
|
|
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
|
|
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
|
-
|
|
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 ·
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
<
|
|
270
|
-
{
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
<
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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:
|
|
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(
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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().
|
|
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.
|
|
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')
|
|
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:
|
|
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.
|
|
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 => {
|