@tanstack/ai-client 0.12.0 → 0.13.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/dist/esm/chat-client.d.ts +15 -1
- package/dist/esm/chat-client.js +196 -30
- package/dist/esm/chat-client.js.map +1 -1
- package/dist/esm/devtools-noop.d.ts +60 -0
- package/dist/esm/devtools-noop.js +135 -0
- package/dist/esm/devtools-noop.js.map +1 -0
- package/dist/esm/devtools.d.ts +276 -0
- package/dist/esm/devtools.js +1178 -0
- package/dist/esm/devtools.js.map +1 -0
- package/dist/esm/events.d.ts +50 -7
- package/dist/esm/events.js +65 -20
- package/dist/esm/events.js.map +1 -1
- package/dist/esm/generation-client.d.ts +14 -0
- package/dist/esm/generation-client.js +135 -5
- package/dist/esm/generation-client.js.map +1 -1
- package/dist/esm/generation-types.d.ts +15 -1
- package/dist/esm/generation-types.js.map +1 -1
- package/dist/esm/index.d.ts +1 -0
- package/dist/esm/index.js +2 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/types.d.ts +21 -3
- package/dist/esm/types.js.map +1 -1
- package/dist/esm/video-generation-client.d.ts +15 -0
- package/dist/esm/video-generation-client.js +147 -8
- package/dist/esm/video-generation-client.js.map +1 -1
- package/package.json +7 -3
- package/src/chat-client.ts +248 -31
- package/src/devtools-noop.ts +205 -0
- package/src/devtools.ts +1866 -0
- package/src/events.ts +142 -11
- package/src/generation-client.ts +176 -9
- package/src/generation-types.ts +23 -4
- package/src/index.ts +9 -0
- package/src/types.ts +29 -2
- package/src/video-generation-client.ts +186 -11
package/src/devtools.ts
ADDED
|
@@ -0,0 +1,1866 @@
|
|
|
1
|
+
import {
|
|
2
|
+
aiEventClient,
|
|
3
|
+
createAIDevtoolsEventEnvelope,
|
|
4
|
+
emitAIDevtoolsEvent,
|
|
5
|
+
} from '@tanstack/ai-event-client'
|
|
6
|
+
import { convertSchemaToJsonSchema } from '@tanstack/ai'
|
|
7
|
+
import { DefaultChatClientEventEmitter } from './events'
|
|
8
|
+
import type { AnyClientTool, StreamChunk } from '@tanstack/ai'
|
|
9
|
+
import type { AIDevtoolsEventVisibility } from '@tanstack/ai-event-client'
|
|
10
|
+
import type {
|
|
11
|
+
ChatClientEventContext,
|
|
12
|
+
ChatClientEventEmitter,
|
|
13
|
+
ChatClientRunEventContext,
|
|
14
|
+
} from './events'
|
|
15
|
+
import type {
|
|
16
|
+
ChatClientState,
|
|
17
|
+
ConnectionStatus,
|
|
18
|
+
MessagePart,
|
|
19
|
+
ToolCallPart,
|
|
20
|
+
UIMessage,
|
|
21
|
+
} from './types'
|
|
22
|
+
|
|
23
|
+
export interface AIDevtoolsDisplayOptions {
|
|
24
|
+
name?: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface AIDevtoolsClientMetadata extends AIDevtoolsDisplayOptions {
|
|
28
|
+
framework?: string
|
|
29
|
+
hookName: string
|
|
30
|
+
outputKind?: 'chat' | 'text' | 'structured' | 'image' | 'video' | 'audio'
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface AIDevtoolsGenerationProgress {
|
|
34
|
+
value: number
|
|
35
|
+
message?: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface AIDevtoolsGenerationMediaItem {
|
|
39
|
+
src: string
|
|
40
|
+
sourceType: 'url' | 'base64'
|
|
41
|
+
mimeType?: string
|
|
42
|
+
format?: string
|
|
43
|
+
duration?: number
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface AIDevtoolsGenerationVideoJob {
|
|
47
|
+
jobId: string
|
|
48
|
+
status?: string
|
|
49
|
+
progress?: number
|
|
50
|
+
error?: string
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export type AIDevtoolsGenerationPreview =
|
|
54
|
+
| {
|
|
55
|
+
kind: 'image'
|
|
56
|
+
items: Array<AIDevtoolsGenerationMediaItem>
|
|
57
|
+
}
|
|
58
|
+
| {
|
|
59
|
+
kind: 'audio'
|
|
60
|
+
items: Array<AIDevtoolsGenerationMediaItem>
|
|
61
|
+
}
|
|
62
|
+
| {
|
|
63
|
+
kind: 'video'
|
|
64
|
+
items: Array<AIDevtoolsGenerationMediaItem>
|
|
65
|
+
job?: AIDevtoolsGenerationVideoJob
|
|
66
|
+
}
|
|
67
|
+
| {
|
|
68
|
+
kind: 'text'
|
|
69
|
+
text: string
|
|
70
|
+
}
|
|
71
|
+
| {
|
|
72
|
+
kind: 'structured'
|
|
73
|
+
value: unknown
|
|
74
|
+
}
|
|
75
|
+
| {
|
|
76
|
+
kind: 'empty'
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export type AIDevtoolsGenerationRunStatus =
|
|
80
|
+
| 'idle'
|
|
81
|
+
| 'generating'
|
|
82
|
+
| 'success'
|
|
83
|
+
| 'error'
|
|
84
|
+
| 'cancelled'
|
|
85
|
+
|
|
86
|
+
export interface AIDevtoolsGenerationRunSnapshot<TOutput = unknown> {
|
|
87
|
+
id: string
|
|
88
|
+
input: unknown
|
|
89
|
+
result: TOutput | null
|
|
90
|
+
preview: AIDevtoolsGenerationPreview
|
|
91
|
+
progress: AIDevtoolsGenerationProgress | null
|
|
92
|
+
status: AIDevtoolsGenerationRunStatus
|
|
93
|
+
isLoading: boolean
|
|
94
|
+
startedAt: number
|
|
95
|
+
updatedAt: number
|
|
96
|
+
completedAt?: number
|
|
97
|
+
error?: string
|
|
98
|
+
jobId?: string | null
|
|
99
|
+
videoStatus?: unknown
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface AIDevtoolsGenerationPreviewInput {
|
|
103
|
+
outputKind?: AIDevtoolsClientMetadata['outputKind']
|
|
104
|
+
result: unknown
|
|
105
|
+
videoStatus?: unknown
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface AIDevtoolsChatSnapshot {
|
|
109
|
+
[key: string]: unknown
|
|
110
|
+
messages: Array<UIMessage>
|
|
111
|
+
status: ChatClientState
|
|
112
|
+
isLoading: boolean
|
|
113
|
+
isSubscribed: boolean
|
|
114
|
+
connectionStatus: ConnectionStatus
|
|
115
|
+
sessionGenerating: boolean
|
|
116
|
+
activeRunIds: Array<string>
|
|
117
|
+
error?: string
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function createAIDevtoolsGenerationPreview(
|
|
121
|
+
input: AIDevtoolsGenerationPreviewInput,
|
|
122
|
+
): AIDevtoolsGenerationPreview {
|
|
123
|
+
if (input.outputKind === 'image') {
|
|
124
|
+
return imagePreviewFromResult(input.result)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (input.outputKind === 'audio') {
|
|
128
|
+
return audioPreviewFromResult(input.result)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (input.outputKind === 'video') {
|
|
132
|
+
return videoPreviewFromResult(input.result, input.videoStatus)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (input.outputKind === 'text') {
|
|
136
|
+
return textPreviewFromResult(input.result)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (input.result === null || input.result === undefined) {
|
|
140
|
+
return { kind: 'empty' }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
kind: 'structured',
|
|
145
|
+
value: input.result,
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
type UnknownRecord = { [key: string]: unknown }
|
|
150
|
+
|
|
151
|
+
function imagePreviewFromResult(result: unknown): AIDevtoolsGenerationPreview {
|
|
152
|
+
const record = asRecord(result)
|
|
153
|
+
const images = Array.isArray(record?.images) ? record.images : []
|
|
154
|
+
const items = images
|
|
155
|
+
.map((image) => mediaItemFromSource(image, 'image/png'))
|
|
156
|
+
.filter(isGenerationMediaItem)
|
|
157
|
+
|
|
158
|
+
if (items.length === 0 && result !== null && result !== undefined) {
|
|
159
|
+
const directItem = mediaItemFromSource(result, 'image/png')
|
|
160
|
+
if (directItem) {
|
|
161
|
+
items.push(directItem)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return { kind: 'image', items }
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function audioPreviewFromResult(result: unknown): AIDevtoolsGenerationPreview {
|
|
169
|
+
const record = asRecord(result)
|
|
170
|
+
const audio = record?.audio
|
|
171
|
+
const resultContentType = stringField(record, 'contentType')
|
|
172
|
+
const format = stringField(record, 'format')
|
|
173
|
+
const mimeType = resultContentType ?? mimeTypeFromAudioFormat(format)
|
|
174
|
+
|
|
175
|
+
const items: Array<AIDevtoolsGenerationMediaItem> = []
|
|
176
|
+
const directItem =
|
|
177
|
+
typeof audio === 'string'
|
|
178
|
+
? base64MediaItem(audio, mimeType, {
|
|
179
|
+
format,
|
|
180
|
+
duration: numberField(record, 'duration'),
|
|
181
|
+
})
|
|
182
|
+
: mediaItemFromSource(audio, mimeType, {
|
|
183
|
+
format,
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
if (directItem) {
|
|
187
|
+
items.push(directItem)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return { kind: 'audio', items }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function videoPreviewFromResult(
|
|
194
|
+
result: unknown,
|
|
195
|
+
videoStatus: unknown,
|
|
196
|
+
): AIDevtoolsGenerationPreview {
|
|
197
|
+
const resultRecord = asRecord(result)
|
|
198
|
+
const statusRecord = asRecord(videoStatus)
|
|
199
|
+
const item =
|
|
200
|
+
mediaItemFromSource(result, 'video/mp4') ??
|
|
201
|
+
mediaItemFromSource(videoStatus, 'video/mp4')
|
|
202
|
+
const items = item ? [item] : []
|
|
203
|
+
const job = videoJobFromStatus(statusRecord ?? resultRecord)
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
kind: 'video',
|
|
207
|
+
items,
|
|
208
|
+
...(job ? { job } : {}),
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function textPreviewFromResult(result: unknown): AIDevtoolsGenerationPreview {
|
|
213
|
+
const record = asRecord(result)
|
|
214
|
+
const text =
|
|
215
|
+
stringField(record, 'text') ??
|
|
216
|
+
stringField(record, 'summary') ??
|
|
217
|
+
stringField(record, 'content') ??
|
|
218
|
+
(typeof result === 'string' ? result : undefined)
|
|
219
|
+
|
|
220
|
+
if (text !== undefined) {
|
|
221
|
+
return { kind: 'text', text }
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (result === null || result === undefined) {
|
|
225
|
+
return { kind: 'empty' }
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return {
|
|
229
|
+
kind: 'structured',
|
|
230
|
+
value: result,
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function videoJobFromStatus(
|
|
235
|
+
record: UnknownRecord | undefined,
|
|
236
|
+
): AIDevtoolsGenerationVideoJob | undefined {
|
|
237
|
+
const jobId = stringField(record, 'jobId')
|
|
238
|
+
if (!jobId) return undefined
|
|
239
|
+
|
|
240
|
+
return {
|
|
241
|
+
jobId,
|
|
242
|
+
...(stringField(record, 'status')
|
|
243
|
+
? { status: stringField(record, 'status') }
|
|
244
|
+
: {}),
|
|
245
|
+
...(numberField(record, 'progress') !== undefined
|
|
246
|
+
? { progress: numberField(record, 'progress') }
|
|
247
|
+
: {}),
|
|
248
|
+
...(stringField(record, 'error')
|
|
249
|
+
? { error: stringField(record, 'error') }
|
|
250
|
+
: {}),
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function mediaItemFromSource(
|
|
255
|
+
value: unknown,
|
|
256
|
+
defaultMimeType: string,
|
|
257
|
+
extras: {
|
|
258
|
+
format?: string
|
|
259
|
+
duration?: number
|
|
260
|
+
} = {},
|
|
261
|
+
): AIDevtoolsGenerationMediaItem | undefined {
|
|
262
|
+
const record = asRecord(value)
|
|
263
|
+
if (!record) return undefined
|
|
264
|
+
|
|
265
|
+
const explicitContentType =
|
|
266
|
+
stringField(record, 'contentType') ?? stringField(record, 'mimeType')
|
|
267
|
+
const duration = numberField(record, 'duration') ?? extras.duration
|
|
268
|
+
const format = stringField(record, 'format') ?? extras.format
|
|
269
|
+
const url = stringField(record, 'url')
|
|
270
|
+
if (url) {
|
|
271
|
+
return {
|
|
272
|
+
src: url,
|
|
273
|
+
sourceType: 'url',
|
|
274
|
+
...(explicitContentType ? { mimeType: explicitContentType } : {}),
|
|
275
|
+
...(format ? { format } : {}),
|
|
276
|
+
...(duration !== undefined ? { duration } : {}),
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const b64Json = stringField(record, 'b64Json')
|
|
281
|
+
if (!b64Json) return undefined
|
|
282
|
+
|
|
283
|
+
return base64MediaItem(b64Json, explicitContentType ?? defaultMimeType, {
|
|
284
|
+
format,
|
|
285
|
+
duration,
|
|
286
|
+
})
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function base64MediaItem(
|
|
290
|
+
value: string,
|
|
291
|
+
mimeType: string | undefined,
|
|
292
|
+
extras: {
|
|
293
|
+
format?: string
|
|
294
|
+
duration?: number
|
|
295
|
+
} = {},
|
|
296
|
+
): AIDevtoolsGenerationMediaItem {
|
|
297
|
+
const src = value.startsWith('data:')
|
|
298
|
+
? value
|
|
299
|
+
: `data:${mimeType ?? 'application/octet-stream'};base64,${value}`
|
|
300
|
+
|
|
301
|
+
return {
|
|
302
|
+
src,
|
|
303
|
+
sourceType: 'base64',
|
|
304
|
+
...(mimeType ? { mimeType } : {}),
|
|
305
|
+
...(extras.format ? { format: extras.format } : {}),
|
|
306
|
+
...(extras.duration !== undefined ? { duration: extras.duration } : {}),
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function mimeTypeFromAudioFormat(format: string | undefined): string {
|
|
311
|
+
if (!format) return 'audio/mpeg'
|
|
312
|
+
if (format === 'mp3') return 'audio/mpeg'
|
|
313
|
+
return `audio/${format}`
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function asRecord(value: unknown): UnknownRecord | undefined {
|
|
317
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
318
|
+
return undefined
|
|
319
|
+
}
|
|
320
|
+
return value as UnknownRecord
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function stringField(
|
|
324
|
+
record: UnknownRecord | undefined,
|
|
325
|
+
field: string,
|
|
326
|
+
): string | undefined {
|
|
327
|
+
const value = record?.[field]
|
|
328
|
+
return typeof value === 'string' && value.length > 0 ? value : undefined
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function numberField(
|
|
332
|
+
record: UnknownRecord | undefined,
|
|
333
|
+
field: string,
|
|
334
|
+
): number | undefined {
|
|
335
|
+
const value = record?.[field]
|
|
336
|
+
return typeof value === 'number' ? value : undefined
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function isGenerationMediaItem(
|
|
340
|
+
value: AIDevtoolsGenerationMediaItem | undefined,
|
|
341
|
+
): value is AIDevtoolsGenerationMediaItem {
|
|
342
|
+
return Boolean(value)
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export interface AIDevtoolsToolFixture {
|
|
346
|
+
fixtureId?: string
|
|
347
|
+
hookId?: string
|
|
348
|
+
threadId?: string
|
|
349
|
+
runId?: string
|
|
350
|
+
toolName: string
|
|
351
|
+
input: unknown
|
|
352
|
+
output: unknown
|
|
353
|
+
execute?: boolean
|
|
354
|
+
message?: {
|
|
355
|
+
id: string
|
|
356
|
+
role: UIMessage['role']
|
|
357
|
+
parts: Array<unknown>
|
|
358
|
+
createdAt?: number | string
|
|
359
|
+
}
|
|
360
|
+
toolCallId?: string
|
|
361
|
+
messageId?: string
|
|
362
|
+
errorText?: string
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
type AIDevtoolsRunEventType =
|
|
366
|
+
| 'run:created'
|
|
367
|
+
| 'run:started'
|
|
368
|
+
| 'run:updated'
|
|
369
|
+
| 'run:completed'
|
|
370
|
+
| 'run:errored'
|
|
371
|
+
| 'run:cancelled'
|
|
372
|
+
|
|
373
|
+
type AIDevtoolsRunStatus =
|
|
374
|
+
| 'created'
|
|
375
|
+
| 'started'
|
|
376
|
+
| 'updated'
|
|
377
|
+
| 'completed'
|
|
378
|
+
| 'errored'
|
|
379
|
+
| 'cancelled'
|
|
380
|
+
|
|
381
|
+
export interface AIDevtoolsBridgeOptions<TSnapshot extends object> {
|
|
382
|
+
hookId: string
|
|
383
|
+
threadId?: string
|
|
384
|
+
clientId: string
|
|
385
|
+
metadata: AIDevtoolsClientMetadata
|
|
386
|
+
getSnapshot: () => TSnapshot
|
|
387
|
+
getTools?: () => Iterable<AnyClientTool>
|
|
388
|
+
applyToolFixture?: (fixture: AIDevtoolsToolFixture) => void | Promise<void>
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
type Unsubscribe = () => void
|
|
392
|
+
|
|
393
|
+
interface AIDevtoolsEvent<TPayload> {
|
|
394
|
+
payload: TPayload
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
interface ActiveDevtoolsBridge {
|
|
398
|
+
deactivate: () => void
|
|
399
|
+
dispose: () => void
|
|
400
|
+
supersede?: () => void
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const activeBridgeRegistryKey = Symbol.for(
|
|
404
|
+
'tanstack.ai.devtools.activeBridgeByHookId',
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
function getActiveBridgeRegistry(): Map<string, ActiveDevtoolsBridge> {
|
|
408
|
+
const global = globalThis as typeof globalThis & {
|
|
409
|
+
[activeBridgeRegistryKey]?: Map<string, ActiveDevtoolsBridge>
|
|
410
|
+
}
|
|
411
|
+
const existing = global[activeBridgeRegistryKey]
|
|
412
|
+
if (existing) return existing
|
|
413
|
+
|
|
414
|
+
const registry = new Map<string, ActiveDevtoolsBridge>()
|
|
415
|
+
global[activeBridgeRegistryKey] = registry
|
|
416
|
+
return registry
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
export class ClientDevtoolsBridge<TSnapshot extends object> {
|
|
420
|
+
protected readonly options: AIDevtoolsBridgeOptions<TSnapshot>
|
|
421
|
+
private readonly bridgeId: string
|
|
422
|
+
private readonly unsubscribers: Array<Unsubscribe> = []
|
|
423
|
+
private disposed = false
|
|
424
|
+
private superseded = false
|
|
425
|
+
private registered = false
|
|
426
|
+
|
|
427
|
+
constructor(options: AIDevtoolsBridgeOptions<TSnapshot>) {
|
|
428
|
+
this.options = options
|
|
429
|
+
this.bridgeId = createBridgeId(options.hookId)
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
emitRegistered(): void {
|
|
433
|
+
if (!this.prepareForMountEmit()) {
|
|
434
|
+
return
|
|
435
|
+
}
|
|
436
|
+
this.registered = true
|
|
437
|
+
emitAIDevtoolsEvent('hook:registered', {
|
|
438
|
+
...this.createEnvelope('hook:registered'),
|
|
439
|
+
...this.createMetadataPayload(),
|
|
440
|
+
lifecycle: 'mounted',
|
|
441
|
+
})
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
emitUpdated(): void {
|
|
445
|
+
if (!this.prepareForEmit()) {
|
|
446
|
+
return
|
|
447
|
+
}
|
|
448
|
+
emitAIDevtoolsEvent('hook:updated', {
|
|
449
|
+
...this.createEnvelope('hook:updated'),
|
|
450
|
+
...this.createMetadataPayload(),
|
|
451
|
+
lifecycle: 'active',
|
|
452
|
+
})
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
emitSnapshot(): void {
|
|
456
|
+
if (!this.prepareForEmit()) {
|
|
457
|
+
return
|
|
458
|
+
}
|
|
459
|
+
emitAIDevtoolsEvent('hook:state-snapshot', {
|
|
460
|
+
...this.createEnvelope('hook:state-snapshot'),
|
|
461
|
+
...this.createMetadataPayload(),
|
|
462
|
+
// Wire envelope uses Record<string, unknown>; widen the typed snapshot
|
|
463
|
+
// here so the typed-snapshot constraint above can stay narrow.
|
|
464
|
+
// eslint-disable-next-line no-restricted-syntax -- TSnapshot extends object is structurally compatible but TS can't see the missing index signature
|
|
465
|
+
state: this.options.getSnapshot() as unknown as Record<string, unknown>,
|
|
466
|
+
})
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
emitToolsRegistered(): void {
|
|
470
|
+
if (!this.prepareForEmit()) {
|
|
471
|
+
return
|
|
472
|
+
}
|
|
473
|
+
const tools = this.options.getTools
|
|
474
|
+
? Array.from(this.options.getTools()).map((tool) => ({
|
|
475
|
+
name: tool.name,
|
|
476
|
+
description: tool.description,
|
|
477
|
+
inputSchema: tool.inputSchema
|
|
478
|
+
? convertSchemaToJsonSchema(tool.inputSchema)
|
|
479
|
+
: { type: 'object' },
|
|
480
|
+
outputSchema: tool.outputSchema
|
|
481
|
+
? convertSchemaToJsonSchema(tool.outputSchema)
|
|
482
|
+
: undefined,
|
|
483
|
+
needsApproval: tool.needsApproval,
|
|
484
|
+
metadata: tool.metadata,
|
|
485
|
+
}))
|
|
486
|
+
: []
|
|
487
|
+
|
|
488
|
+
emitAIDevtoolsEvent('tools:registered', {
|
|
489
|
+
...this.createEnvelope('tools:registered'),
|
|
490
|
+
...this.createMetadataPayload(),
|
|
491
|
+
tools,
|
|
492
|
+
})
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
emitRunLifecycle(
|
|
496
|
+
eventType: AIDevtoolsRunEventType,
|
|
497
|
+
runId: string,
|
|
498
|
+
status: AIDevtoolsRunStatus,
|
|
499
|
+
options: { error?: string } = {},
|
|
500
|
+
): void {
|
|
501
|
+
if (!this.prepareForEmit()) {
|
|
502
|
+
return
|
|
503
|
+
}
|
|
504
|
+
emitAIDevtoolsEvent(eventType, {
|
|
505
|
+
...this.createEnvelope(eventType, 'client-state', { runId }),
|
|
506
|
+
runId,
|
|
507
|
+
status,
|
|
508
|
+
...(options.error ? { error: options.error } : {}),
|
|
509
|
+
})
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
deactivate(): void {
|
|
513
|
+
const activeBridgeByHookId = getActiveBridgeRegistry()
|
|
514
|
+
if (activeBridgeByHookId.get(this.options.hookId) === this) {
|
|
515
|
+
activeBridgeByHookId.delete(this.options.hookId)
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
for (const unsubscribe of this.unsubscribers.splice(0)) {
|
|
519
|
+
unsubscribe()
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
supersede(): void {
|
|
524
|
+
if (this.disposed) {
|
|
525
|
+
return
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
this.superseded = true
|
|
529
|
+
this.disposed = true
|
|
530
|
+
this.deactivate()
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
dispose(): void {
|
|
534
|
+
if (this.disposed) {
|
|
535
|
+
return
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
this.disposed = true
|
|
539
|
+
if (!this.registered) {
|
|
540
|
+
this.deactivate()
|
|
541
|
+
return
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const payload = {
|
|
545
|
+
...this.createEnvelope('hook:unregistered'),
|
|
546
|
+
...this.createMetadataPayload(),
|
|
547
|
+
reason: 'disposed',
|
|
548
|
+
} as const
|
|
549
|
+
|
|
550
|
+
emitAIDevtoolsEvent('hook:unregistered', payload)
|
|
551
|
+
|
|
552
|
+
this.deactivate()
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
private prepareForEmit(): boolean {
|
|
556
|
+
if (this.disposed || this.superseded) {
|
|
557
|
+
return false
|
|
558
|
+
}
|
|
559
|
+
this.activate()
|
|
560
|
+
return true
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
private prepareForMountEmit(): boolean {
|
|
564
|
+
if (this.superseded) {
|
|
565
|
+
return false
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
if (this.disposed) {
|
|
569
|
+
this.disposed = false
|
|
570
|
+
this.registered = false
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
this.activate()
|
|
574
|
+
return true
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
private activate(): void {
|
|
578
|
+
if (this.disposed) {
|
|
579
|
+
return
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const activeBridgeByHookId = getActiveBridgeRegistry()
|
|
583
|
+
const activeBridge = activeBridgeByHookId.get(this.options.hookId)
|
|
584
|
+
if (activeBridge && activeBridge !== this) {
|
|
585
|
+
if (typeof activeBridge.supersede === 'function') {
|
|
586
|
+
activeBridge.supersede()
|
|
587
|
+
} else {
|
|
588
|
+
activeBridge.deactivate()
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
activeBridgeByHookId.set(this.options.hookId, this)
|
|
592
|
+
|
|
593
|
+
if (this.unsubscribers.length > 0) {
|
|
594
|
+
return
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
this.unsubscribers.push(
|
|
598
|
+
aiEventClient.on('devtools:request-state', (event) => {
|
|
599
|
+
this.handleRequestState(event)
|
|
600
|
+
}),
|
|
601
|
+
)
|
|
602
|
+
|
|
603
|
+
if (this.options.applyToolFixture) {
|
|
604
|
+
this.unsubscribers.push(
|
|
605
|
+
aiEventClient.on('devtools:tool-fixture:apply', (event) => {
|
|
606
|
+
void this.handleToolFixtureApply(event)
|
|
607
|
+
}),
|
|
608
|
+
)
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
private handleRequestState(
|
|
613
|
+
event: AIDevtoolsEvent<{ targetHookId?: string }>,
|
|
614
|
+
): void {
|
|
615
|
+
if (this.disposed || this.superseded) {
|
|
616
|
+
return
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
const targetHookId = event.payload.targetHookId
|
|
620
|
+
if (targetHookId && targetHookId !== this.options.hookId) {
|
|
621
|
+
return
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
this.emitRegistered()
|
|
625
|
+
this.emitToolsRegistered()
|
|
626
|
+
this.emitSnapshot()
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
private async handleToolFixtureApply(
|
|
630
|
+
event: AIDevtoolsEvent<AIDevtoolsToolFixture>,
|
|
631
|
+
): Promise<void> {
|
|
632
|
+
const fixture = event.payload
|
|
633
|
+
if (!this.matchesFixtureTarget(fixture)) {
|
|
634
|
+
return
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
await this.options.applyToolFixture?.(fixture)
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
private matchesFixtureTarget(fixture: AIDevtoolsToolFixture): boolean {
|
|
641
|
+
if (!fixture.hookId && !fixture.threadId) {
|
|
642
|
+
return false
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
if (fixture.hookId) {
|
|
646
|
+
return fixture.hookId === this.options.hookId
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
if (
|
|
650
|
+
fixture.threadId &&
|
|
651
|
+
(!this.options.threadId || fixture.threadId !== this.options.threadId)
|
|
652
|
+
) {
|
|
653
|
+
return false
|
|
654
|
+
}
|
|
655
|
+
return true
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
private createEnvelope(
|
|
659
|
+
eventType:
|
|
660
|
+
| 'hook:registered'
|
|
661
|
+
| 'hook:updated'
|
|
662
|
+
| 'hook:unregistered'
|
|
663
|
+
| 'hook:state-snapshot'
|
|
664
|
+
| 'tools:registered'
|
|
665
|
+
| AIDevtoolsRunEventType,
|
|
666
|
+
visibility: AIDevtoolsEventVisibility = 'client-state',
|
|
667
|
+
context: { runId?: string } = {},
|
|
668
|
+
) {
|
|
669
|
+
return createAIDevtoolsEventEnvelope({
|
|
670
|
+
eventType,
|
|
671
|
+
source: 'client',
|
|
672
|
+
visibility,
|
|
673
|
+
clientId: this.options.clientId,
|
|
674
|
+
hookId: this.options.hookId,
|
|
675
|
+
correlationId: this.bridgeId,
|
|
676
|
+
...(this.options.threadId ? { threadId: this.options.threadId } : {}),
|
|
677
|
+
...(context.runId ? { runId: context.runId } : {}),
|
|
678
|
+
timestamp: Date.now(),
|
|
679
|
+
})
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
private createMetadataPayload() {
|
|
683
|
+
return {
|
|
684
|
+
hookId: this.options.hookId,
|
|
685
|
+
hookName: this.options.metadata.hookName,
|
|
686
|
+
...(this.options.metadata.name
|
|
687
|
+
? { displayName: this.options.metadata.name }
|
|
688
|
+
: {}),
|
|
689
|
+
...(this.options.metadata.outputKind
|
|
690
|
+
? { outputKind: this.options.metadata.outputKind }
|
|
691
|
+
: {}),
|
|
692
|
+
...(this.options.metadata.framework
|
|
693
|
+
? { framework: this.options.metadata.framework }
|
|
694
|
+
: {}),
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
let bridgeIdSequence = 0
|
|
700
|
+
|
|
701
|
+
function createBridgeId(hookId: string): string {
|
|
702
|
+
const cryptoLike = (
|
|
703
|
+
globalThis as {
|
|
704
|
+
crypto?: {
|
|
705
|
+
randomUUID?: () => string
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
).crypto
|
|
709
|
+
|
|
710
|
+
if (cryptoLike?.randomUUID) {
|
|
711
|
+
return `bridge:${hookId}:${cryptoLike.randomUUID()}`
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
bridgeIdSequence += 1
|
|
715
|
+
return `bridge:${hookId}:${bridgeIdSequence}`
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// Owns the chat-client devtools surface so the chat client itself stays a
|
|
719
|
+
// pure transport. Fixture replay, per-run / per-stream event context, and
|
|
720
|
+
// snapshot emission all live here; a no-op bridge can drop in for prod.
|
|
721
|
+
|
|
722
|
+
export interface ChatDevtoolsBridgeOptions extends AIDevtoolsBridgeOptions<AIDevtoolsChatSnapshot> {
|
|
723
|
+
getMessages: () => Array<UIMessage>
|
|
724
|
+
setMessages: (messages: Array<UIMessage>) => void
|
|
725
|
+
addToolResult: (
|
|
726
|
+
toolCallId: string,
|
|
727
|
+
output: unknown,
|
|
728
|
+
errorText?: string,
|
|
729
|
+
) => void
|
|
730
|
+
generateId: (prefix: string) => string
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
export class ChatDevtoolsBridge extends ClientDevtoolsBridge<AIDevtoolsChatSnapshot> {
|
|
734
|
+
readonly events: ChatClientEventEmitter
|
|
735
|
+
private readonly chatOptions: ChatDevtoolsBridgeOptions
|
|
736
|
+
private currentRunId: string | null = null
|
|
737
|
+
private currentRunThreadId: string | null = null
|
|
738
|
+
private currentStreamId: string | null = null
|
|
739
|
+
private lastStreamId: string | null = null
|
|
740
|
+
private lastRunEventContext: ChatClientRunEventContext | undefined
|
|
741
|
+
|
|
742
|
+
constructor(options: ChatDevtoolsBridgeOptions) {
|
|
743
|
+
super({
|
|
744
|
+
...options,
|
|
745
|
+
// Thunk defers `this.applyFixture` lookup until after `super` returns.
|
|
746
|
+
applyToolFixture: (fixture) => this.applyFixture(fixture),
|
|
747
|
+
})
|
|
748
|
+
this.chatOptions = options
|
|
749
|
+
// Auto-attaches run/thread context and auto-emits a snapshot after each
|
|
750
|
+
// event so callers can keep using `this.events.X(...)` with no context arg.
|
|
751
|
+
this.events = new ChatDevtoolsAwareEventEmitter(options.clientId, this)
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// --- Stream / run context API -------------------------------------------
|
|
755
|
+
|
|
756
|
+
setCurrentStreamId(streamId: string | null): void {
|
|
757
|
+
this.currentStreamId = streamId
|
|
758
|
+
if (streamId) {
|
|
759
|
+
this.lastStreamId = streamId
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/**
|
|
764
|
+
* Called by the auto-attaching emitter every time it sees a non-empty
|
|
765
|
+
* streamId pass through. Lets devtools track the latest stream id
|
|
766
|
+
* without the chat client wiring it up explicitly.
|
|
767
|
+
*/
|
|
768
|
+
recordStreamId(streamId: string): void {
|
|
769
|
+
if (streamId) this.lastStreamId = streamId
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
mountWithTools(initialMessageCount: number): void {
|
|
773
|
+
this.events.clientCreated(initialMessageCount)
|
|
774
|
+
this.emitRegistered()
|
|
775
|
+
this.emitToolsRegistered()
|
|
776
|
+
this.emitSnapshot()
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
notifyToolsChanged(): void {
|
|
780
|
+
this.emitToolsRegistered()
|
|
781
|
+
this.emitSnapshot()
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
getCurrentStreamId(): string | null {
|
|
785
|
+
return this.currentStreamId
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
getLastStreamId(): string | null {
|
|
789
|
+
return this.lastStreamId
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
resolveStreamId(): string {
|
|
793
|
+
return (
|
|
794
|
+
this.currentStreamId ??
|
|
795
|
+
this.lastStreamId ??
|
|
796
|
+
this.chatOptions.generateId('stream')
|
|
797
|
+
)
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
// Called when the chat client has just generated a runId for outbound emits;
|
|
801
|
+
// the matching RUN_STARTED chunk from the adapter lands later and
|
|
802
|
+
// observeChunk keeps the same context.
|
|
803
|
+
beginRun(runId: string, threadId: string): void {
|
|
804
|
+
this.currentRunId = runId
|
|
805
|
+
this.currentRunThreadId = threadId
|
|
806
|
+
this.lastRunEventContext = { runId, threadId }
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
observeChunk(chunk: StreamChunk): void {
|
|
810
|
+
if (chunk.type === 'RUN_STARTED') {
|
|
811
|
+
this.beginRun(chunk.runId, chunk.threadId)
|
|
812
|
+
return
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
if (chunk.type === 'RUN_FINISHED' || chunk.type === 'RUN_ERROR') {
|
|
816
|
+
const runId =
|
|
817
|
+
chunk.type === 'RUN_FINISHED'
|
|
818
|
+
? chunk.runId
|
|
819
|
+
: (chunk as { runId?: string }).runId
|
|
820
|
+
if (!runId || runId === this.currentRunId) {
|
|
821
|
+
const context = this.getCurrentRunEventContext()
|
|
822
|
+
if (context) {
|
|
823
|
+
this.lastRunEventContext = context
|
|
824
|
+
}
|
|
825
|
+
this.currentRunId = null
|
|
826
|
+
this.currentRunThreadId = null
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
getCurrentRunEventContext(): ChatClientRunEventContext | undefined {
|
|
832
|
+
if (!this.currentRunId) return undefined
|
|
833
|
+
return {
|
|
834
|
+
threadId: this.currentRunThreadId ?? this.chatOptions.threadId ?? '',
|
|
835
|
+
runId: this.currentRunId,
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
getCurrentOrLastRunEventContext(): ChatClientRunEventContext | undefined {
|
|
840
|
+
return this.getCurrentRunEventContext() ?? this.lastRunEventContext
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
findToolCallContext(toolCallId: string): ChatClientEventContext {
|
|
844
|
+
const base: ChatClientEventContext = { toolCallId }
|
|
845
|
+
const runContext = this.getCurrentRunEventContext()
|
|
846
|
+
if (runContext) {
|
|
847
|
+
return {
|
|
848
|
+
threadId: runContext.threadId,
|
|
849
|
+
runId: runContext.runId,
|
|
850
|
+
toolCallId,
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
if (this.chatOptions.threadId) {
|
|
854
|
+
return { threadId: this.chatOptions.threadId, toolCallId }
|
|
855
|
+
}
|
|
856
|
+
return base
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
// --- Fixture replay ------------------------------------------------------
|
|
860
|
+
|
|
861
|
+
/**
|
|
862
|
+
* Entry point invoked when the devtools panel emits
|
|
863
|
+
* `devtools:tool-fixture:apply`. The chat client never calls this
|
|
864
|
+
* directly; it is wired through the base bridge's fixture subscription.
|
|
865
|
+
*/
|
|
866
|
+
async applyFixture(fixture: AIDevtoolsToolFixture): Promise<void> {
|
|
867
|
+
const messages = this.chatOptions.getMessages()
|
|
868
|
+
const threadId = fixture.threadId ?? this.chatOptions.threadId ?? ''
|
|
869
|
+
if (fixture.execute) {
|
|
870
|
+
await this.executeFixture(fixture, messages, threadId)
|
|
871
|
+
return
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
const replay = this.createReplayMessageFromFixture(fixture, messages)
|
|
875
|
+
const { message, toolCallId } = replay
|
|
876
|
+
const messageId = message.id
|
|
877
|
+
|
|
878
|
+
this.events.messageAppended(message, undefined, {
|
|
879
|
+
threadId,
|
|
880
|
+
toolCallId,
|
|
881
|
+
...(fixture.runId ? { runId: fixture.runId } : {}),
|
|
882
|
+
})
|
|
883
|
+
this.chatOptions.setMessages([...messages, message])
|
|
884
|
+
this.events.toolFixtureApplied({
|
|
885
|
+
hookId: this.chatOptions.hookId,
|
|
886
|
+
threadId,
|
|
887
|
+
...(fixture.runId ? { runId: fixture.runId } : {}),
|
|
888
|
+
toolName: fixture.toolName,
|
|
889
|
+
input: fixture.input,
|
|
890
|
+
output: fixture.output,
|
|
891
|
+
messageId,
|
|
892
|
+
toolCallId,
|
|
893
|
+
...(fixture.execute !== undefined ? { execute: fixture.execute } : {}),
|
|
894
|
+
...(fixture.message ? { message: fixture.message } : {}),
|
|
895
|
+
...(fixture.errorText ? { errorText: fixture.errorText } : {}),
|
|
896
|
+
})
|
|
897
|
+
this.emitSnapshot()
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
private async executeFixture(
|
|
901
|
+
fixture: AIDevtoolsToolFixture,
|
|
902
|
+
messages: Array<UIMessage>,
|
|
903
|
+
threadId: string,
|
|
904
|
+
): Promise<void> {
|
|
905
|
+
const toolCallId = this.resolveFixtureToolCallId(
|
|
906
|
+
fixture.toolCallId,
|
|
907
|
+
messages,
|
|
908
|
+
)
|
|
909
|
+
const messageId = this.resolveFixtureMessageId(fixture.messageId, messages)
|
|
910
|
+
const message: UIMessage = {
|
|
911
|
+
id: messageId,
|
|
912
|
+
role: 'assistant',
|
|
913
|
+
parts: [
|
|
914
|
+
{
|
|
915
|
+
type: 'tool-call',
|
|
916
|
+
id: toolCallId,
|
|
917
|
+
name: fixture.toolName,
|
|
918
|
+
arguments: stringifyFixtureValue(fixture.input),
|
|
919
|
+
input: fixture.input,
|
|
920
|
+
state: 'input-complete',
|
|
921
|
+
},
|
|
922
|
+
],
|
|
923
|
+
createdAt: new Date(),
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
this.events.messageAppended(message, undefined, {
|
|
927
|
+
threadId,
|
|
928
|
+
toolCallId,
|
|
929
|
+
...(fixture.runId ? { runId: fixture.runId } : {}),
|
|
930
|
+
})
|
|
931
|
+
this.chatOptions.setMessages([...messages, message])
|
|
932
|
+
this.emitSnapshot()
|
|
933
|
+
|
|
934
|
+
const clientTool = this.findClientTool(fixture.toolName)
|
|
935
|
+
const executeFunc = clientTool?.execute
|
|
936
|
+
if (!executeFunc) {
|
|
937
|
+
console.warn(
|
|
938
|
+
`[ai-devtools] tool fixture "${fixture.toolName}" requested execute=true but no client tool implementation is registered; replaying saved output instead.`,
|
|
939
|
+
)
|
|
940
|
+
this.addToolResultForFixture({
|
|
941
|
+
fixture,
|
|
942
|
+
messageId,
|
|
943
|
+
toolCallId,
|
|
944
|
+
threadId,
|
|
945
|
+
output: fixture.output,
|
|
946
|
+
errorText: fixture.errorText,
|
|
947
|
+
})
|
|
948
|
+
return
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
let output: unknown
|
|
952
|
+
try {
|
|
953
|
+
output = await executeFunc(fixture.input)
|
|
954
|
+
} catch (error) {
|
|
955
|
+
console.error(
|
|
956
|
+
`[ai-devtools] tool fixture "${fixture.toolName}" execute threw`,
|
|
957
|
+
error,
|
|
958
|
+
)
|
|
959
|
+
this.addToolResultForFixture({
|
|
960
|
+
fixture,
|
|
961
|
+
messageId,
|
|
962
|
+
toolCallId,
|
|
963
|
+
threadId,
|
|
964
|
+
output: null,
|
|
965
|
+
errorText:
|
|
966
|
+
error instanceof Error
|
|
967
|
+
? `${error.name}: ${error.message}`
|
|
968
|
+
: `Tool execution failed: ${String(error)}`,
|
|
969
|
+
})
|
|
970
|
+
return
|
|
971
|
+
}
|
|
972
|
+
this.addToolResultForFixture({
|
|
973
|
+
fixture,
|
|
974
|
+
messageId,
|
|
975
|
+
toolCallId,
|
|
976
|
+
threadId,
|
|
977
|
+
output,
|
|
978
|
+
})
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
private addToolResultForFixture(input: {
|
|
982
|
+
fixture: AIDevtoolsToolFixture
|
|
983
|
+
messageId: string
|
|
984
|
+
toolCallId: string
|
|
985
|
+
threadId: string
|
|
986
|
+
output: unknown
|
|
987
|
+
errorText?: string
|
|
988
|
+
}): void {
|
|
989
|
+
const state = input.errorText ? 'output-error' : 'output-available'
|
|
990
|
+
this.events.toolResultAdded(
|
|
991
|
+
input.toolCallId,
|
|
992
|
+
input.fixture.toolName,
|
|
993
|
+
input.output,
|
|
994
|
+
state,
|
|
995
|
+
{
|
|
996
|
+
threadId: input.threadId,
|
|
997
|
+
...(input.fixture.runId ? { runId: input.fixture.runId } : {}),
|
|
998
|
+
toolCallId: input.toolCallId,
|
|
999
|
+
},
|
|
1000
|
+
)
|
|
1001
|
+
this.chatOptions.addToolResult(
|
|
1002
|
+
input.toolCallId,
|
|
1003
|
+
input.output,
|
|
1004
|
+
input.errorText,
|
|
1005
|
+
)
|
|
1006
|
+
this.events.toolFixtureApplied({
|
|
1007
|
+
hookId: this.chatOptions.hookId,
|
|
1008
|
+
threadId: input.threadId,
|
|
1009
|
+
...(input.fixture.runId ? { runId: input.fixture.runId } : {}),
|
|
1010
|
+
toolName: input.fixture.toolName,
|
|
1011
|
+
input: input.fixture.input,
|
|
1012
|
+
output: input.output,
|
|
1013
|
+
execute: true,
|
|
1014
|
+
messageId: input.messageId,
|
|
1015
|
+
toolCallId: input.toolCallId,
|
|
1016
|
+
...(input.errorText ? { errorText: input.errorText } : {}),
|
|
1017
|
+
})
|
|
1018
|
+
this.emitSnapshot()
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
private createReplayMessageFromFixture(
|
|
1022
|
+
fixture: AIDevtoolsToolFixture,
|
|
1023
|
+
messages: Array<UIMessage>,
|
|
1024
|
+
): { message: UIMessage; toolCallId: string } {
|
|
1025
|
+
const cloned = this.cloneFixtureSourceMessage(fixture, messages)
|
|
1026
|
+
if (cloned) return cloned
|
|
1027
|
+
|
|
1028
|
+
const toolCallId = this.resolveFixtureToolCallId(
|
|
1029
|
+
fixture.toolCallId,
|
|
1030
|
+
messages,
|
|
1031
|
+
)
|
|
1032
|
+
const messageId = this.resolveFixtureMessageId(fixture.messageId, messages)
|
|
1033
|
+
const state = fixture.errorText ? 'error' : 'complete'
|
|
1034
|
+
|
|
1035
|
+
return {
|
|
1036
|
+
toolCallId,
|
|
1037
|
+
message: {
|
|
1038
|
+
id: messageId,
|
|
1039
|
+
role: 'assistant',
|
|
1040
|
+
parts: [
|
|
1041
|
+
{
|
|
1042
|
+
type: 'tool-call',
|
|
1043
|
+
id: toolCallId,
|
|
1044
|
+
name: fixture.toolName,
|
|
1045
|
+
arguments: stringifyFixtureValue(fixture.input),
|
|
1046
|
+
input: fixture.input,
|
|
1047
|
+
state: 'input-complete',
|
|
1048
|
+
output: fixture.output,
|
|
1049
|
+
},
|
|
1050
|
+
{
|
|
1051
|
+
type: 'tool-result',
|
|
1052
|
+
toolCallId,
|
|
1053
|
+
content: stringifyFixtureValue(fixture.output),
|
|
1054
|
+
state,
|
|
1055
|
+
...(fixture.errorText ? { error: fixture.errorText } : {}),
|
|
1056
|
+
},
|
|
1057
|
+
],
|
|
1058
|
+
createdAt: new Date(),
|
|
1059
|
+
},
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
private cloneFixtureSourceMessage(
|
|
1064
|
+
fixture: AIDevtoolsToolFixture,
|
|
1065
|
+
messages: Array<UIMessage>,
|
|
1066
|
+
): { message: UIMessage; toolCallId: string } | undefined {
|
|
1067
|
+
const sourceMessage = fixture.message
|
|
1068
|
+
if (!sourceMessage || !Array.isArray(sourceMessage.parts)) {
|
|
1069
|
+
return undefined
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
const toolCallIds = this.createFixtureToolCallIdMap(
|
|
1073
|
+
sourceMessage.parts,
|
|
1074
|
+
messages,
|
|
1075
|
+
)
|
|
1076
|
+
const parts = sourceMessage.parts
|
|
1077
|
+
.map((part) => cloneFixtureMessagePart(part, toolCallIds))
|
|
1078
|
+
.filter((part): part is MessagePart => Boolean(part))
|
|
1079
|
+
const mappedFixtureToolCallId = fixture.toolCallId
|
|
1080
|
+
? toolCallIds.get(fixture.toolCallId)
|
|
1081
|
+
: undefined
|
|
1082
|
+
hydrateToolCallOutputs(parts, {
|
|
1083
|
+
...(mappedFixtureToolCallId
|
|
1084
|
+
? { mappedToolCallId: mappedFixtureToolCallId }
|
|
1085
|
+
: {}),
|
|
1086
|
+
output: fixture.output,
|
|
1087
|
+
})
|
|
1088
|
+
|
|
1089
|
+
if (parts.length === 0) return undefined
|
|
1090
|
+
|
|
1091
|
+
const toolCallId =
|
|
1092
|
+
(fixture.toolCallId ? toolCallIds.get(fixture.toolCallId) : undefined) ??
|
|
1093
|
+
firstToolCallId(parts)
|
|
1094
|
+
if (!toolCallId) return undefined
|
|
1095
|
+
|
|
1096
|
+
return {
|
|
1097
|
+
toolCallId,
|
|
1098
|
+
message: {
|
|
1099
|
+
id: this.resolveFixtureMessageId(sourceMessage.id, messages),
|
|
1100
|
+
role: sourceMessage.role,
|
|
1101
|
+
parts,
|
|
1102
|
+
createdAt: new Date(),
|
|
1103
|
+
},
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
private createFixtureToolCallIdMap(
|
|
1108
|
+
parts: Array<unknown>,
|
|
1109
|
+
messages: Array<UIMessage>,
|
|
1110
|
+
): Map<string, string> {
|
|
1111
|
+
const ids = new Map<string, string>()
|
|
1112
|
+
for (const part of parts) {
|
|
1113
|
+
if (!isRecord(part) || part.type !== 'tool-call') continue
|
|
1114
|
+
if (typeof part.id !== 'string') continue
|
|
1115
|
+
ids.set(part.id, this.resolveFixtureToolCallId(part.id, messages))
|
|
1116
|
+
}
|
|
1117
|
+
return ids
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
private resolveFixtureMessageId(
|
|
1121
|
+
messageId: string | undefined,
|
|
1122
|
+
messages: Array<UIMessage>,
|
|
1123
|
+
): string {
|
|
1124
|
+
if (messageId && !messages.some((message) => message.id === messageId)) {
|
|
1125
|
+
return messageId
|
|
1126
|
+
}
|
|
1127
|
+
return this.chatOptions.generateId('fixture-msg')
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
private resolveFixtureToolCallId(
|
|
1131
|
+
toolCallId: string | undefined,
|
|
1132
|
+
messages: Array<UIMessage>,
|
|
1133
|
+
): string {
|
|
1134
|
+
if (toolCallId && !hasToolCallId(messages, toolCallId)) {
|
|
1135
|
+
return toolCallId
|
|
1136
|
+
}
|
|
1137
|
+
return this.chatOptions.generateId('fixture-tool-call')
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
private findClientTool(name: string): AnyClientTool | undefined {
|
|
1141
|
+
const tools = this.chatOptions.getTools?.()
|
|
1142
|
+
if (!tools) return undefined
|
|
1143
|
+
for (const tool of tools) {
|
|
1144
|
+
if (tool.name === name) return tool
|
|
1145
|
+
}
|
|
1146
|
+
return undefined
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
// ---- Module-level fixture helpers (pure; share no state) -------------------
|
|
1151
|
+
|
|
1152
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
1153
|
+
return typeof value === 'object' && value !== null
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
function stringifyFixtureValue(value: unknown): string {
|
|
1157
|
+
if (typeof value === 'string') return value
|
|
1158
|
+
if (
|
|
1159
|
+
value === undefined ||
|
|
1160
|
+
typeof value === 'function' ||
|
|
1161
|
+
typeof value === 'symbol'
|
|
1162
|
+
) {
|
|
1163
|
+
return String(value)
|
|
1164
|
+
}
|
|
1165
|
+
try {
|
|
1166
|
+
return JSON.stringify(value)
|
|
1167
|
+
} catch (error) {
|
|
1168
|
+
console.error(
|
|
1169
|
+
'[ai-devtools] failed to JSON.stringify fixture value; falling back to String(). Tool call arguments may be malformed.',
|
|
1170
|
+
{ error, value },
|
|
1171
|
+
)
|
|
1172
|
+
return String(value)
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
function parseFixtureResultContent(content: string): unknown {
|
|
1177
|
+
try {
|
|
1178
|
+
return JSON.parse(content)
|
|
1179
|
+
} catch (error) {
|
|
1180
|
+
console.error(
|
|
1181
|
+
'[ai-devtools] failed to JSON.parse fixture result content; replaying as raw string. Fixture payload may be corrupted.',
|
|
1182
|
+
{ error, content },
|
|
1183
|
+
)
|
|
1184
|
+
return content
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
function cloneFixtureMessagePart(
|
|
1189
|
+
part: unknown,
|
|
1190
|
+
toolCallIds: Map<string, string>,
|
|
1191
|
+
): MessagePart | undefined {
|
|
1192
|
+
if (!isRecord(part) || typeof part.type !== 'string') return undefined
|
|
1193
|
+
const cloned: Record<string, unknown> = { ...part }
|
|
1194
|
+
|
|
1195
|
+
if (part.type === 'tool-call' && typeof part.id === 'string') {
|
|
1196
|
+
cloned.id = toolCallIds.get(part.id) ?? part.id
|
|
1197
|
+
}
|
|
1198
|
+
if (part.type === 'tool-result' && typeof part.toolCallId === 'string') {
|
|
1199
|
+
cloned.toolCallId = toolCallIds.get(part.toolCallId) ?? part.toolCallId
|
|
1200
|
+
}
|
|
1201
|
+
return cloned as MessagePart
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
function firstToolCallId(parts: Array<MessagePart>): string | undefined {
|
|
1205
|
+
const toolCall = parts.find((part) => part.type === 'tool-call')
|
|
1206
|
+
return toolCall?.type === 'tool-call' ? toolCall.id : undefined
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
function hydrateToolCallOutputs(
|
|
1210
|
+
parts: Array<MessagePart>,
|
|
1211
|
+
fixtureOutput: { mappedToolCallId?: string; output: unknown },
|
|
1212
|
+
): void {
|
|
1213
|
+
for (const part of parts) {
|
|
1214
|
+
if (part.type !== 'tool-result') continue
|
|
1215
|
+
const toolCall = parts.find(
|
|
1216
|
+
(candidate): candidate is ToolCallPart =>
|
|
1217
|
+
candidate.type === 'tool-call' &&
|
|
1218
|
+
candidate.id === part.toolCallId &&
|
|
1219
|
+
candidate.output === undefined,
|
|
1220
|
+
)
|
|
1221
|
+
if (toolCall) {
|
|
1222
|
+
toolCall.output = parseFixtureResultContent(part.content)
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
if (fixtureOutput.mappedToolCallId && fixtureOutput.output !== undefined) {
|
|
1227
|
+
const toolCall = parts.find(
|
|
1228
|
+
(candidate): candidate is ToolCallPart =>
|
|
1229
|
+
candidate.type === 'tool-call' &&
|
|
1230
|
+
candidate.id === fixtureOutput.mappedToolCallId &&
|
|
1231
|
+
candidate.output === undefined,
|
|
1232
|
+
)
|
|
1233
|
+
if (toolCall) {
|
|
1234
|
+
toolCall.output = fixtureOutput.output
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
function hasToolCallId(
|
|
1240
|
+
messages: Array<UIMessage>,
|
|
1241
|
+
toolCallId: string,
|
|
1242
|
+
): boolean {
|
|
1243
|
+
return messages.some((message) =>
|
|
1244
|
+
message.parts.some((part) => {
|
|
1245
|
+
if (part.type === 'tool-call') return part.id === toolCallId
|
|
1246
|
+
if (part.type === 'tool-result') return part.toolCallId === toolCallId
|
|
1247
|
+
return false
|
|
1248
|
+
}),
|
|
1249
|
+
)
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
// Devtools surface for GenerationClient / VideoGenerationClient. Owns per-run
|
|
1253
|
+
// history, active-run lifecycle, and snapshot emission; the generation client
|
|
1254
|
+
// pushes its core state in via the record* methods.
|
|
1255
|
+
|
|
1256
|
+
export interface AIDevtoolsGenerationSnapshotBase<TOutput> {
|
|
1257
|
+
input: unknown
|
|
1258
|
+
result: TOutput | null
|
|
1259
|
+
preview: AIDevtoolsGenerationPreview
|
|
1260
|
+
progress: AIDevtoolsGenerationProgress | null
|
|
1261
|
+
status: AIDevtoolsGenerationRunStatus
|
|
1262
|
+
isLoading: boolean
|
|
1263
|
+
activeRunId: string | null
|
|
1264
|
+
runs: Array<AIDevtoolsGenerationRunSnapshot<TOutput>>
|
|
1265
|
+
error?: string
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
export interface GenerationDevtoolsBridgeOptions<TOutput> extends Omit<
|
|
1269
|
+
AIDevtoolsBridgeOptions<AIDevtoolsGenerationSnapshotBase<TOutput>>,
|
|
1270
|
+
'getSnapshot'
|
|
1271
|
+
> {
|
|
1272
|
+
getCoreState: () => GenerationDevtoolsCoreState<TOutput>
|
|
1273
|
+
maxRuns?: number
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
export interface GenerationDevtoolsCoreState<TOutput> {
|
|
1277
|
+
input: unknown
|
|
1278
|
+
result: TOutput | null
|
|
1279
|
+
progress: AIDevtoolsGenerationProgress | null
|
|
1280
|
+
status: AIDevtoolsGenerationRunStatus
|
|
1281
|
+
isLoading: boolean
|
|
1282
|
+
error?: string
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
export interface GenerationRunPatch<TOutput> {
|
|
1286
|
+
input?: unknown
|
|
1287
|
+
result?: TOutput | null
|
|
1288
|
+
preview?: AIDevtoolsGenerationPreview
|
|
1289
|
+
progress?: AIDevtoolsGenerationProgress | null
|
|
1290
|
+
status?: AIDevtoolsGenerationRunStatus
|
|
1291
|
+
isLoading?: boolean
|
|
1292
|
+
completedAt?: number
|
|
1293
|
+
error?: string
|
|
1294
|
+
clearError?: boolean
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
export class GenerationDevtoolsBridge<TOutput> extends ClientDevtoolsBridge<
|
|
1298
|
+
AIDevtoolsGenerationSnapshotBase<TOutput>
|
|
1299
|
+
> {
|
|
1300
|
+
protected activeRunId: string | null = null
|
|
1301
|
+
protected activeRunStarted = false
|
|
1302
|
+
protected devtoolsRuns: Array<AIDevtoolsGenerationRunSnapshot<TOutput>> = []
|
|
1303
|
+
protected readonly maxRuns: number
|
|
1304
|
+
protected readonly getCoreState: () => GenerationDevtoolsCoreState<TOutput>
|
|
1305
|
+
|
|
1306
|
+
constructor(options: GenerationDevtoolsBridgeOptions<TOutput>) {
|
|
1307
|
+
super({
|
|
1308
|
+
...options,
|
|
1309
|
+
getSnapshot: () => this.buildSnapshot(),
|
|
1310
|
+
})
|
|
1311
|
+
this.maxRuns = options.maxRuns ?? 20
|
|
1312
|
+
this.getCoreState = options.getCoreState
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
// --- Run lifecycle (called by GenerationClient) -----------------------
|
|
1316
|
+
|
|
1317
|
+
beginRun(input: unknown): string {
|
|
1318
|
+
const runId = this.generateRunId()
|
|
1319
|
+
this.activeRunId = runId
|
|
1320
|
+
this.activeRunStarted = false
|
|
1321
|
+
this.upsertRun(runId, {
|
|
1322
|
+
input,
|
|
1323
|
+
result: null,
|
|
1324
|
+
preview: this.createPreview(null),
|
|
1325
|
+
progress: null,
|
|
1326
|
+
status: 'generating',
|
|
1327
|
+
isLoading: true,
|
|
1328
|
+
clearError: true,
|
|
1329
|
+
})
|
|
1330
|
+
return runId
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
ensureRunStarted(runId: string): void {
|
|
1334
|
+
if (this.activeRunStarted && this.activeRunId === runId) return
|
|
1335
|
+
|
|
1336
|
+
if (
|
|
1337
|
+
!this.activeRunStarted &&
|
|
1338
|
+
this.activeRunId &&
|
|
1339
|
+
this.activeRunId !== runId
|
|
1340
|
+
) {
|
|
1341
|
+
this.renameRun(this.activeRunId, runId)
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
this.activeRunId = runId
|
|
1345
|
+
this.activeRunStarted = true
|
|
1346
|
+
this.upsertRun(runId, {
|
|
1347
|
+
status: 'generating',
|
|
1348
|
+
isLoading: true,
|
|
1349
|
+
clearError: true,
|
|
1350
|
+
})
|
|
1351
|
+
this.emitRunLifecycle('run:started', runId, 'started')
|
|
1352
|
+
this.emitState()
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
finishRun(
|
|
1356
|
+
runId: string,
|
|
1357
|
+
eventType: 'run:completed' | 'run:errored' | 'run:cancelled',
|
|
1358
|
+
status: 'completed' | 'errored' | 'cancelled',
|
|
1359
|
+
error?: string,
|
|
1360
|
+
): void {
|
|
1361
|
+
this.ensureRunStarted(runId)
|
|
1362
|
+
const completedAt = Date.now()
|
|
1363
|
+
const completedProgress =
|
|
1364
|
+
status === 'completed' ? this.completeProgress() : this.getProgress()
|
|
1365
|
+
const runStatus =
|
|
1366
|
+
status === 'completed'
|
|
1367
|
+
? 'success'
|
|
1368
|
+
: status === 'errored'
|
|
1369
|
+
? 'error'
|
|
1370
|
+
: 'cancelled'
|
|
1371
|
+
|
|
1372
|
+
this.upsertRun(runId, {
|
|
1373
|
+
status: runStatus,
|
|
1374
|
+
isLoading: false,
|
|
1375
|
+
progress: completedProgress,
|
|
1376
|
+
completedAt,
|
|
1377
|
+
...(error ? { error } : { clearError: true }),
|
|
1378
|
+
})
|
|
1379
|
+
|
|
1380
|
+
if (this.activeRunId === runId) {
|
|
1381
|
+
this.activeRunId = null
|
|
1382
|
+
}
|
|
1383
|
+
this.activeRunStarted = false
|
|
1384
|
+
this.emitRunLifecycle(eventType, runId, status, {
|
|
1385
|
+
...(error ? { error } : {}),
|
|
1386
|
+
})
|
|
1387
|
+
this.emitState()
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
getActiveRunId(): string | null {
|
|
1391
|
+
return this.activeRunId
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
/** Clear all per-run history. Called when the client `reset()`s. */
|
|
1395
|
+
resetRuns(): void {
|
|
1396
|
+
this.activeRunId = null
|
|
1397
|
+
this.activeRunStarted = false
|
|
1398
|
+
this.devtoolsRuns = []
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
/** Record state changes from the client and emit the matching snapshot. */
|
|
1402
|
+
recordResultChange(): void {
|
|
1403
|
+
this.updateActiveRun({
|
|
1404
|
+
result: this.getCoreState().result,
|
|
1405
|
+
preview: this.createPreview(this.getCoreState().result),
|
|
1406
|
+
clearError: true,
|
|
1407
|
+
})
|
|
1408
|
+
this.emitState()
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
recordLoadingChange(): void {
|
|
1412
|
+
this.updateActiveRun({ isLoading: this.getCoreState().isLoading })
|
|
1413
|
+
this.emitState()
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
recordErrorChange(error: Error | undefined): void {
|
|
1417
|
+
this.updateActiveRun(
|
|
1418
|
+
error ? { error: error.message } : { clearError: true },
|
|
1419
|
+
)
|
|
1420
|
+
this.emitState()
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
recordStatusChange(status: AIDevtoolsGenerationRunStatus): void {
|
|
1424
|
+
this.updateActiveRun({ status })
|
|
1425
|
+
this.emitState()
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
recordProgressChange(): void {
|
|
1429
|
+
this.updateActiveRun({ progress: this.getCoreState().progress })
|
|
1430
|
+
this.emitState()
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
/** Emit the latest snapshot without touching run state. */
|
|
1434
|
+
emitState(): void {
|
|
1435
|
+
this.emitUpdated()
|
|
1436
|
+
this.emitSnapshot()
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
// --- Internal ---------------------------------------------------------
|
|
1440
|
+
|
|
1441
|
+
protected buildSnapshot(): AIDevtoolsGenerationSnapshotBase<TOutput> {
|
|
1442
|
+
const core = this.getCoreState()
|
|
1443
|
+
return {
|
|
1444
|
+
input: core.input,
|
|
1445
|
+
result: core.result,
|
|
1446
|
+
preview: this.createPreview(core.result),
|
|
1447
|
+
progress: core.progress,
|
|
1448
|
+
status: core.status,
|
|
1449
|
+
isLoading: core.isLoading,
|
|
1450
|
+
activeRunId: this.activeRunId,
|
|
1451
|
+
runs: this.devtoolsRuns,
|
|
1452
|
+
...(core.error ? { error: core.error } : {}),
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
protected updateActiveRun(patch: GenerationRunPatch<TOutput>): void {
|
|
1457
|
+
if (!this.activeRunId) return
|
|
1458
|
+
this.upsertRun(this.activeRunId, patch)
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
protected upsertRun(runId: string, patch: GenerationRunPatch<TOutput>): void {
|
|
1462
|
+
const now = Date.now()
|
|
1463
|
+
const index = this.devtoolsRuns.findIndex((run) => run.id === runId)
|
|
1464
|
+
const existing = index >= 0 ? this.devtoolsRuns[index] : undefined
|
|
1465
|
+
const next: AIDevtoolsGenerationRunSnapshot<TOutput> = existing
|
|
1466
|
+
? { ...existing }
|
|
1467
|
+
: {
|
|
1468
|
+
id: runId,
|
|
1469
|
+
input: this.getCoreState().input,
|
|
1470
|
+
result: null,
|
|
1471
|
+
preview: this.createPreview(null),
|
|
1472
|
+
progress: null,
|
|
1473
|
+
status: 'idle',
|
|
1474
|
+
isLoading: false,
|
|
1475
|
+
startedAt: now,
|
|
1476
|
+
updatedAt: now,
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
if ('input' in patch) next.input = patch.input ?? null
|
|
1480
|
+
if ('result' in patch) next.result = patch.result ?? null
|
|
1481
|
+
if (patch.preview) next.preview = patch.preview
|
|
1482
|
+
if ('progress' in patch) next.progress = patch.progress ?? null
|
|
1483
|
+
if (patch.status) next.status = patch.status
|
|
1484
|
+
if ('isLoading' in patch) next.isLoading = patch.isLoading === true
|
|
1485
|
+
if (patch.completedAt !== undefined) next.completedAt = patch.completedAt
|
|
1486
|
+
if (patch.clearError) delete next.error
|
|
1487
|
+
if (patch.error !== undefined) next.error = patch.error
|
|
1488
|
+
next.updatedAt = now
|
|
1489
|
+
|
|
1490
|
+
if (index >= 0) {
|
|
1491
|
+
this.devtoolsRuns = this.devtoolsRuns.map((run) =>
|
|
1492
|
+
run.id === runId ? next : run,
|
|
1493
|
+
)
|
|
1494
|
+
} else {
|
|
1495
|
+
this.devtoolsRuns = [...this.devtoolsRuns, next]
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
if (this.devtoolsRuns.length > this.maxRuns) {
|
|
1499
|
+
this.devtoolsRuns = this.devtoolsRuns.slice(-this.maxRuns)
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
protected renameRun(previousRunId: string, nextRunId: string): void {
|
|
1504
|
+
if (previousRunId === nextRunId) return
|
|
1505
|
+
|
|
1506
|
+
const existing = this.devtoolsRuns.find((run) => run.id === previousRunId)
|
|
1507
|
+
if (!existing) return
|
|
1508
|
+
|
|
1509
|
+
const renamed = { ...existing, id: nextRunId, updatedAt: Date.now() }
|
|
1510
|
+
this.devtoolsRuns = this.devtoolsRuns
|
|
1511
|
+
.filter((run) => run.id !== nextRunId)
|
|
1512
|
+
.map((run) => (run.id === previousRunId ? renamed : run))
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
protected getProgress(): AIDevtoolsGenerationProgress | null {
|
|
1516
|
+
return this.getCoreState().progress
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
protected completeProgress(): AIDevtoolsGenerationProgress | null {
|
|
1520
|
+
const progress = this.getCoreState().progress
|
|
1521
|
+
if (!progress) return null
|
|
1522
|
+
return {
|
|
1523
|
+
value: 100,
|
|
1524
|
+
...(progress.message ? { message: progress.message } : {}),
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
protected createPreview(result: TOutput | null): AIDevtoolsGenerationPreview {
|
|
1529
|
+
return createAIDevtoolsGenerationPreview({
|
|
1530
|
+
outputKind: this.options.metadata.outputKind,
|
|
1531
|
+
result,
|
|
1532
|
+
})
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
protected generateRunId(): string {
|
|
1536
|
+
return `run-${Date.now()}-${Math.random().toString(36).substring(7)}`
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
// Video-job specialization: snapshots also carry the job id and the latest
|
|
1541
|
+
// provider-reported video status so the panel can show streaming progress
|
|
1542
|
+
// before the final URL lands.
|
|
1543
|
+
|
|
1544
|
+
export interface AIDevtoolsVideoSnapshotBase<
|
|
1545
|
+
TOutput,
|
|
1546
|
+
> extends AIDevtoolsGenerationSnapshotBase<TOutput> {
|
|
1547
|
+
jobId: string | null
|
|
1548
|
+
videoStatus: unknown
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
export interface VideoDevtoolsCoreState<
|
|
1552
|
+
TOutput,
|
|
1553
|
+
> extends GenerationDevtoolsCoreState<TOutput> {
|
|
1554
|
+
jobId: string | null
|
|
1555
|
+
videoStatus: unknown
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
export interface VideoDevtoolsBridgeOptions<TOutput> extends Omit<
|
|
1559
|
+
GenerationDevtoolsBridgeOptions<TOutput>,
|
|
1560
|
+
'getCoreState'
|
|
1561
|
+
> {
|
|
1562
|
+
getCoreState: () => VideoDevtoolsCoreState<TOutput>
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
export interface VideoRunPatch<TOutput> extends GenerationRunPatch<TOutput> {
|
|
1566
|
+
jobId?: string | null
|
|
1567
|
+
videoStatus?: unknown
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
export class VideoDevtoolsBridge<
|
|
1571
|
+
TOutput,
|
|
1572
|
+
> extends GenerationDevtoolsBridge<TOutput> {
|
|
1573
|
+
constructor(options: VideoDevtoolsBridgeOptions<TOutput>) {
|
|
1574
|
+
super(options)
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
recordJobIdChange(): void {
|
|
1578
|
+
this.updateActiveRun({
|
|
1579
|
+
jobId: (this.getCoreState() as VideoDevtoolsCoreState<TOutput>).jobId,
|
|
1580
|
+
} as VideoRunPatch<TOutput>)
|
|
1581
|
+
this.emitState()
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
recordVideoStatusChange(): void {
|
|
1585
|
+
const core = this.getCoreState() as VideoDevtoolsCoreState<TOutput>
|
|
1586
|
+
this.updateActiveRun({
|
|
1587
|
+
videoStatus: core.videoStatus,
|
|
1588
|
+
preview: this.createVideoPreview(core.result, core.videoStatus),
|
|
1589
|
+
} as VideoRunPatch<TOutput>)
|
|
1590
|
+
this.emitState()
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
protected override buildSnapshot(): AIDevtoolsVideoSnapshotBase<TOutput> {
|
|
1594
|
+
const core = this.getCoreState() as VideoDevtoolsCoreState<TOutput>
|
|
1595
|
+
return {
|
|
1596
|
+
input: core.input,
|
|
1597
|
+
result: core.result,
|
|
1598
|
+
preview: this.createVideoPreview(core.result, core.videoStatus),
|
|
1599
|
+
progress: core.progress,
|
|
1600
|
+
status: core.status,
|
|
1601
|
+
isLoading: core.isLoading,
|
|
1602
|
+
activeRunId: this.activeRunId,
|
|
1603
|
+
runs: this.devtoolsRuns,
|
|
1604
|
+
jobId: core.jobId,
|
|
1605
|
+
videoStatus: core.videoStatus,
|
|
1606
|
+
...(core.error ? { error: core.error } : {}),
|
|
1607
|
+
}
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
protected override upsertRun(
|
|
1611
|
+
runId: string,
|
|
1612
|
+
patch: VideoRunPatch<TOutput>,
|
|
1613
|
+
): void {
|
|
1614
|
+
super.upsertRun(runId, patch)
|
|
1615
|
+
if (!('jobId' in patch || 'videoStatus' in patch)) return
|
|
1616
|
+
|
|
1617
|
+
const index = this.devtoolsRuns.findIndex((run) => run.id === runId)
|
|
1618
|
+
if (index < 0) return
|
|
1619
|
+
const target = this.devtoolsRuns[index]
|
|
1620
|
+
if (!target) return
|
|
1621
|
+
const merged: AIDevtoolsGenerationRunSnapshot<TOutput> = { ...target }
|
|
1622
|
+
if ('jobId' in patch) merged.jobId = patch.jobId ?? null
|
|
1623
|
+
if ('videoStatus' in patch) merged.videoStatus = patch.videoStatus ?? null
|
|
1624
|
+
this.devtoolsRuns = this.devtoolsRuns.map((run) =>
|
|
1625
|
+
run.id === runId ? merged : run,
|
|
1626
|
+
)
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
// Override so record* methods inherited from GenerationDevtoolsBridge
|
|
1630
|
+
// (e.g. recordResultChange) thread the latest videoStatus into the preview.
|
|
1631
|
+
protected override createPreview(
|
|
1632
|
+
result: TOutput | null,
|
|
1633
|
+
): AIDevtoolsGenerationPreview {
|
|
1634
|
+
const core = this.getCoreState() as VideoDevtoolsCoreState<TOutput>
|
|
1635
|
+
return this.createVideoPreview(result, core.videoStatus)
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
private createVideoPreview(
|
|
1639
|
+
result: TOutput | null,
|
|
1640
|
+
videoStatus: unknown,
|
|
1641
|
+
): AIDevtoolsGenerationPreview {
|
|
1642
|
+
return createAIDevtoolsGenerationPreview({
|
|
1643
|
+
outputKind: this.options.metadata.outputKind,
|
|
1644
|
+
result,
|
|
1645
|
+
videoStatus,
|
|
1646
|
+
})
|
|
1647
|
+
}
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
// Wraps the plain emitter so callers can do `this.events.X(...)` and get:
|
|
1651
|
+
// auto-attached run/thread context on every event that accepts one,
|
|
1652
|
+
// an auto-emitted snapshot after each event, and passive streamId tracking
|
|
1653
|
+
// so resolveStreamId() works without the chat client telling it.
|
|
1654
|
+
class ChatDevtoolsAwareEventEmitter extends DefaultChatClientEventEmitter {
|
|
1655
|
+
constructor(
|
|
1656
|
+
clientId: string,
|
|
1657
|
+
private readonly helper: ChatDevtoolsBridge,
|
|
1658
|
+
) {
|
|
1659
|
+
super(clientId)
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
private afterEmit(streamId?: string): void {
|
|
1663
|
+
if (streamId) this.helper.recordStreamId(streamId)
|
|
1664
|
+
this.helper.emitSnapshot()
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
// -- methods with run context --------------------------------------------
|
|
1668
|
+
|
|
1669
|
+
override textUpdated(
|
|
1670
|
+
streamId: string,
|
|
1671
|
+
messageId: string,
|
|
1672
|
+
content: string,
|
|
1673
|
+
context?: ChatClientRunEventContext,
|
|
1674
|
+
): void {
|
|
1675
|
+
super.textUpdated(
|
|
1676
|
+
streamId,
|
|
1677
|
+
messageId,
|
|
1678
|
+
content,
|
|
1679
|
+
context ?? this.helper.getCurrentRunEventContext(),
|
|
1680
|
+
)
|
|
1681
|
+
this.afterEmit(streamId)
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
override thinkingUpdated(
|
|
1685
|
+
streamId: string,
|
|
1686
|
+
messageId: string,
|
|
1687
|
+
content: string,
|
|
1688
|
+
delta?: string,
|
|
1689
|
+
context?: ChatClientRunEventContext,
|
|
1690
|
+
): void {
|
|
1691
|
+
super.thinkingUpdated(
|
|
1692
|
+
streamId,
|
|
1693
|
+
messageId,
|
|
1694
|
+
content,
|
|
1695
|
+
delta,
|
|
1696
|
+
context ?? this.helper.getCurrentRunEventContext(),
|
|
1697
|
+
)
|
|
1698
|
+
this.afterEmit(streamId)
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1701
|
+
override messageAppended(
|
|
1702
|
+
uiMessage: Parameters<DefaultChatClientEventEmitter['messageAppended']>[0],
|
|
1703
|
+
streamId?: string,
|
|
1704
|
+
context?: ChatClientEventContext,
|
|
1705
|
+
): void {
|
|
1706
|
+
super.messageAppended(
|
|
1707
|
+
uiMessage,
|
|
1708
|
+
streamId,
|
|
1709
|
+
context ?? this.helper.getCurrentRunEventContext(),
|
|
1710
|
+
)
|
|
1711
|
+
this.afterEmit(streamId)
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
override toolCallStateChanged(
|
|
1715
|
+
streamId: string,
|
|
1716
|
+
messageId: string,
|
|
1717
|
+
toolCallId: string,
|
|
1718
|
+
toolName: string,
|
|
1719
|
+
state: string,
|
|
1720
|
+
args: string,
|
|
1721
|
+
context?: ChatClientRunEventContext,
|
|
1722
|
+
): void {
|
|
1723
|
+
super.toolCallStateChanged(
|
|
1724
|
+
streamId,
|
|
1725
|
+
messageId,
|
|
1726
|
+
toolCallId,
|
|
1727
|
+
toolName,
|
|
1728
|
+
state,
|
|
1729
|
+
args,
|
|
1730
|
+
context ?? this.helper.getCurrentRunEventContext(),
|
|
1731
|
+
)
|
|
1732
|
+
this.afterEmit(streamId)
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
override structuredOutputChanged(
|
|
1736
|
+
eventName: Parameters<
|
|
1737
|
+
DefaultChatClientEventEmitter['structuredOutputChanged']
|
|
1738
|
+
>[0],
|
|
1739
|
+
streamId: string,
|
|
1740
|
+
messageId: string,
|
|
1741
|
+
output: Parameters<
|
|
1742
|
+
DefaultChatClientEventEmitter['structuredOutputChanged']
|
|
1743
|
+
>[3],
|
|
1744
|
+
context?: ChatClientRunEventContext,
|
|
1745
|
+
): void {
|
|
1746
|
+
super.structuredOutputChanged(
|
|
1747
|
+
eventName,
|
|
1748
|
+
streamId,
|
|
1749
|
+
messageId,
|
|
1750
|
+
output,
|
|
1751
|
+
context ?? this.helper.getCurrentOrLastRunEventContext(),
|
|
1752
|
+
)
|
|
1753
|
+
this.afterEmit(streamId)
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
override approvalRequested(
|
|
1757
|
+
streamId: string,
|
|
1758
|
+
messageId: string,
|
|
1759
|
+
toolCallId: string,
|
|
1760
|
+
toolName: string,
|
|
1761
|
+
input: unknown,
|
|
1762
|
+
approvalId: string,
|
|
1763
|
+
context?: ChatClientRunEventContext,
|
|
1764
|
+
): void {
|
|
1765
|
+
super.approvalRequested(
|
|
1766
|
+
streamId,
|
|
1767
|
+
messageId,
|
|
1768
|
+
toolCallId,
|
|
1769
|
+
toolName,
|
|
1770
|
+
input,
|
|
1771
|
+
approvalId,
|
|
1772
|
+
context ?? this.helper.getCurrentOrLastRunEventContext(),
|
|
1773
|
+
)
|
|
1774
|
+
this.afterEmit(streamId)
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
override toolResultAdded(
|
|
1778
|
+
toolCallId: string,
|
|
1779
|
+
toolName: string,
|
|
1780
|
+
output: unknown,
|
|
1781
|
+
state: string,
|
|
1782
|
+
context?: ChatClientEventContext,
|
|
1783
|
+
): void {
|
|
1784
|
+
super.toolResultAdded(
|
|
1785
|
+
toolCallId,
|
|
1786
|
+
toolName,
|
|
1787
|
+
output,
|
|
1788
|
+
state,
|
|
1789
|
+
context ?? this.helper.getCurrentRunEventContext(),
|
|
1790
|
+
)
|
|
1791
|
+
this.afterEmit()
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1794
|
+
override toolApprovalResponded(
|
|
1795
|
+
approvalId: string,
|
|
1796
|
+
toolCallId: string,
|
|
1797
|
+
approved: boolean,
|
|
1798
|
+
context?: ChatClientRunEventContext,
|
|
1799
|
+
): void {
|
|
1800
|
+
super.toolApprovalResponded(
|
|
1801
|
+
approvalId,
|
|
1802
|
+
toolCallId,
|
|
1803
|
+
approved,
|
|
1804
|
+
context ?? this.helper.getCurrentRunEventContext(),
|
|
1805
|
+
)
|
|
1806
|
+
this.afterEmit()
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
// -- methods without context (just auto-emit snapshot) -------------------
|
|
1810
|
+
|
|
1811
|
+
override clientCreated(initialMessageCount: number): void {
|
|
1812
|
+
super.clientCreated(initialMessageCount)
|
|
1813
|
+
this.afterEmit()
|
|
1814
|
+
}
|
|
1815
|
+
override loadingChanged(isLoading: boolean): void {
|
|
1816
|
+
super.loadingChanged(isLoading)
|
|
1817
|
+
this.afterEmit()
|
|
1818
|
+
}
|
|
1819
|
+
override errorChanged(error: string | null): void {
|
|
1820
|
+
super.errorChanged(error)
|
|
1821
|
+
this.afterEmit()
|
|
1822
|
+
}
|
|
1823
|
+
override reloaded(fromMessageIndex: number): void {
|
|
1824
|
+
super.reloaded(fromMessageIndex)
|
|
1825
|
+
this.afterEmit()
|
|
1826
|
+
}
|
|
1827
|
+
override stopped(): void {
|
|
1828
|
+
super.stopped()
|
|
1829
|
+
this.afterEmit()
|
|
1830
|
+
}
|
|
1831
|
+
override messagesCleared(): void {
|
|
1832
|
+
super.messagesCleared()
|
|
1833
|
+
this.afterEmit()
|
|
1834
|
+
}
|
|
1835
|
+
override messageSent(
|
|
1836
|
+
messageId: string,
|
|
1837
|
+
content: Parameters<DefaultChatClientEventEmitter['messageSent']>[1],
|
|
1838
|
+
): void {
|
|
1839
|
+
super.messageSent(messageId, content)
|
|
1840
|
+
this.afterEmit()
|
|
1841
|
+
}
|
|
1842
|
+
override toolFixtureApplied(
|
|
1843
|
+
fixture: Parameters<DefaultChatClientEventEmitter['toolFixtureApplied']>[0],
|
|
1844
|
+
): void {
|
|
1845
|
+
super.toolFixtureApplied(fixture)
|
|
1846
|
+
this.afterEmit()
|
|
1847
|
+
}
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
export function createChatDevtoolsBridge(
|
|
1851
|
+
options: ChatDevtoolsBridgeOptions,
|
|
1852
|
+
): ChatDevtoolsBridge {
|
|
1853
|
+
return new ChatDevtoolsBridge(options)
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1856
|
+
export function createGenerationDevtoolsBridge<TOutput>(
|
|
1857
|
+
options: GenerationDevtoolsBridgeOptions<TOutput>,
|
|
1858
|
+
): GenerationDevtoolsBridge<TOutput> {
|
|
1859
|
+
return new GenerationDevtoolsBridge<TOutput>(options)
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1862
|
+
export function createVideoDevtoolsBridge<TOutput>(
|
|
1863
|
+
options: VideoDevtoolsBridgeOptions<TOutput>,
|
|
1864
|
+
): VideoDevtoolsBridge<TOutput> {
|
|
1865
|
+
return new VideoDevtoolsBridge<TOutput>(options)
|
|
1866
|
+
}
|