@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.
@@ -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
+ }