@tanstack/ai-devtools-core 0.3.38 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/dist/Shell/DKCFIT6H.js +12142 -0
  2. package/dist/Shell/SLOX2TPU.js +12119 -0
  3. package/dist/Shell/Z6HLJOVZ.js +10170 -0
  4. package/dist/dev.js +1 -1
  5. package/dist/index.js +1 -1
  6. package/dist/production/Shell/DKCFIT6H.js +12142 -0
  7. package/dist/production/Shell/SLOX2TPU.js +12119 -0
  8. package/dist/production/Shell/Z6HLJOVZ.js +10170 -0
  9. package/dist/production/dev.js +1 -1
  10. package/dist/production/index.js +1 -1
  11. package/dist/production/server.js +1 -1
  12. package/dist/server.js +1 -1
  13. package/package.json +4 -4
  14. package/src/components/Shell.tsx +74 -30
  15. package/src/components/conversation/IterationCard.tsx +261 -12
  16. package/src/components/conversation/IterationTimeline.tsx +66 -16
  17. package/src/components/conversation/index.ts +0 -9
  18. package/src/components/hooks/FixtureNamePopover.tsx +69 -0
  19. package/src/components/hooks/GenerationPanel.tsx +847 -0
  20. package/src/components/hooks/HookDashboard.tsx +292 -0
  21. package/src/components/hooks/HookDetails.tsx +1757 -0
  22. package/src/components/hooks/ToolFixtureForm.tsx +347 -0
  23. package/src/components/hooks/hook-dashboard-model.ts +134 -0
  24. package/src/components/hooks/index.ts +4 -0
  25. package/src/components/hooks/preview-messages.ts +117 -0
  26. package/src/components/hooks/preview-model.ts +195 -0
  27. package/src/components/utils/format.ts +0 -58
  28. package/src/store/ai-context.tsx +1165 -159
  29. package/src/store/ai-store.ts +0 -1
  30. package/src/store/hook-registry.ts +815 -0
  31. package/src/store/message-event-utils.ts +60 -0
  32. package/src/styles/use-styles.ts +1558 -99
  33. package/dist/Shell/MRWZEHAJ.js +0 -6225
  34. package/dist/Shell/RRRNZJ5E.js +0 -7560
  35. package/dist/production/Shell/MRWZEHAJ.js +0 -6225
  36. package/dist/production/Shell/RRRNZJ5E.js +0 -7560
  37. package/src/components/ConversationDetails.tsx +0 -216
  38. package/src/components/ConversationsList.tsx +0 -21
  39. package/src/components/conversation/ActivityEventsTab.tsx +0 -57
  40. package/src/components/conversation/ChunkBadges.tsx +0 -51
  41. package/src/components/conversation/ChunkItem.tsx +0 -273
  42. package/src/components/conversation/ChunksCollapsible.tsx +0 -56
  43. package/src/components/conversation/ChunksTab.tsx +0 -87
  44. package/src/components/conversation/ConversationHeader.tsx +0 -106
  45. package/src/components/conversation/ConversationTabs.tsx +0 -205
  46. package/src/components/conversation/MessageCard.tsx +0 -174
  47. package/src/components/conversation/MessageGroup.tsx +0 -101
  48. package/src/components/conversation/MessagesTab.tsx +0 -30
  49. package/src/components/conversation/SummariesTab.tsx +0 -91
  50. package/src/components/conversation/ToolCallDisplay.tsx +0 -99
  51. package/src/components/list/ConversationRow.tsx +0 -85
  52. package/src/components/list/index.ts +0 -1
@@ -0,0 +1,815 @@
1
+ import type {
2
+ AIDevtoolsEventEnvelope,
3
+ AIDevtoolsEventSource,
4
+ AIDevtoolsEventVisibility,
5
+ DevtoolsToolFixtureApplyEvent,
6
+ HookRegisteredEvent,
7
+ HookStateSnapshotEvent,
8
+ HookUnregisteredEvent,
9
+ HookUpdatedEvent,
10
+ RunLifecycleEvent,
11
+ ToolsRegisteredEvent,
12
+ } from '@tanstack/ai-event-client'
13
+
14
+ export type HookOutputKind =
15
+ | 'chat'
16
+ | 'text'
17
+ | 'structured'
18
+ | 'image'
19
+ | 'video'
20
+ | 'audio'
21
+
22
+ export type HookLifecycle =
23
+ | 'mounted'
24
+ | 'active'
25
+ | 'streaming'
26
+ | 'errored'
27
+ | 'stale'
28
+
29
+ export interface RegisteredTool {
30
+ name: string
31
+ description?: string
32
+ inputSchema?: unknown
33
+ outputSchema?: unknown
34
+ needsApproval?: boolean
35
+ metadata?: unknown
36
+ }
37
+
38
+ export interface ToolFixtureRecord {
39
+ id: string
40
+ createdAt: number
41
+ name?: string
42
+ hookId?: string
43
+ threadId?: string
44
+ runId?: string
45
+ toolName: string
46
+ input: unknown
47
+ output: unknown
48
+ execute?: boolean
49
+ message?: ToolFixtureMessage
50
+ toolCallId?: string
51
+ messageId?: string
52
+ errorText?: string
53
+ }
54
+
55
+ export interface ToolFixtureMessage {
56
+ id: string
57
+ role: 'system' | 'user' | 'assistant'
58
+ parts: Array<unknown>
59
+ createdAt?: number | string
60
+ }
61
+
62
+ export interface ToolFixtureRecordDraft {
63
+ id?: string
64
+ createdAt?: number
65
+ name?: string
66
+ hookId?: string
67
+ threadId?: string
68
+ runId?: string
69
+ toolName: string
70
+ input: unknown
71
+ output?: unknown
72
+ execute?: boolean
73
+ message?: ToolFixtureMessage
74
+ toolCallId?: string
75
+ messageId?: string
76
+ errorText?: string
77
+ }
78
+
79
+ export interface TimelineEvent {
80
+ id: string
81
+ eventType: string
82
+ timestamp: number
83
+ source?: AIDevtoolsEventSource
84
+ visibility?: AIDevtoolsEventVisibility
85
+ runtimeId?: string
86
+ hookId?: string
87
+ threadId?: string
88
+ runId?: string
89
+ messageId?: string
90
+ toolCallId?: string
91
+ payload: unknown
92
+ }
93
+
94
+ export interface RunRecord {
95
+ id: string
96
+ hookId?: string
97
+ threadId?: string
98
+ status: RunLifecycleEvent['status']
99
+ source?: AIDevtoolsEventSource
100
+ visibility?: AIDevtoolsEventVisibility
101
+ startedAt: number
102
+ updatedAt: number
103
+ completedAt?: number
104
+ error?: string
105
+ eventIds: Array<string>
106
+ }
107
+
108
+ export interface HookRecord {
109
+ id: string
110
+ hookName: string
111
+ displayName?: string
112
+ framework?: string
113
+ outputKind?: HookOutputKind
114
+ lifecycle: HookLifecycle
115
+ clientId?: string
116
+ threadId?: string
117
+ correlationId?: string
118
+ registeredAt: number
119
+ updatedAt: number
120
+ unregisteredAt?: number
121
+ state: Record<string, unknown>
122
+ tools: Array<RegisteredTool>
123
+ runIds: Array<string>
124
+ eventIds: Array<string>
125
+ activityRunIds: Array<string>
126
+ }
127
+
128
+ /**
129
+ * Cap on the number of recently-seen event IDs we retain for dedupe. Once
130
+ * exceeded, the oldest entries are evicted FIFO so the structure stays
131
+ * bounded for the life of the session. Events older than this window may
132
+ * dedupe-miss; consumers should not rely on perfect global dedupe.
133
+ */
134
+ export const SEEN_EVENT_IDS_CAP = 10_000
135
+
136
+ export interface HookRegistryState {
137
+ hooks: Record<string, HookRecord>
138
+ runs: Record<string, RunRecord>
139
+ events: Record<string, TimelineEvent>
140
+ fixtures: Array<ToolFixtureRecord>
141
+ activeHookId: string | null
142
+ seenEventIds: Record<string, true>
143
+ seenEventIdOrder: Array<string>
144
+ seenHookActivityCounts: Record<string, number>
145
+ unregisteredHookIds: Record<string, true>
146
+ }
147
+
148
+ export function createHookRegistryState(): HookRegistryState {
149
+ return {
150
+ hooks: {},
151
+ runs: {},
152
+ events: {},
153
+ fixtures: [],
154
+ activeHookId: null,
155
+ seenEventIds: {},
156
+ seenEventIdOrder: [],
157
+ seenHookActivityCounts: {},
158
+ unregisteredHookIds: {},
159
+ }
160
+ }
161
+
162
+ type KnownHookEvent =
163
+ | HookRegisteredEvent
164
+ | HookUpdatedEvent
165
+ | HookUnregisteredEvent
166
+ | HookStateSnapshotEvent
167
+ | RunLifecycleEvent
168
+ | ToolsRegisteredEvent
169
+ | DevtoolsToolFixtureApplyEvent
170
+
171
+ type RuntimeScopedHookEvent = KnownHookEvent & { runtimeId?: string }
172
+
173
+ interface HookUpsertEvent extends Partial<
174
+ Pick<
175
+ HookRegisteredEvent,
176
+ | 'clientId'
177
+ | 'displayName'
178
+ | 'framework'
179
+ | 'outputKind'
180
+ | 'source'
181
+ | 'threadId'
182
+ | 'visibility'
183
+ | 'correlationId'
184
+ >
185
+ > {
186
+ hookId: string
187
+ hookName: string
188
+ lifecycle: HookLifecycle
189
+ timestamp: number
190
+ }
191
+
192
+ export function markEventSeen(
193
+ state: HookRegistryState,
194
+ eventName: string,
195
+ event: Partial<AIDevtoolsEventEnvelope> & {
196
+ runtimeId?: string
197
+ timestamp?: number
198
+ },
199
+ ): boolean {
200
+ let key: string
201
+ if (event.eventId) {
202
+ key = event.eventId
203
+ } else {
204
+ key = [
205
+ eventName,
206
+ event.source ?? 'unknown',
207
+ event.visibility ?? 'unknown',
208
+ event.runtimeId ?? 'no-runtime',
209
+ event.hookId ?? event.clientId ?? 'no-hook',
210
+ event.threadId ?? 'no-thread',
211
+ event.runId ?? 'no-run',
212
+ event.messageId ?? 'no-message',
213
+ event.toolCallId ?? 'no-tool-call',
214
+ event.timestamp ?? 'no-time',
215
+ ].join(':')
216
+ // If every identifying field fell back to its literal sentinel the synthesised
217
+ // key is just `eventName:unknown:unknown:no-runtime:...` — useful for very
218
+ // little. Warn so it's obvious in the console why deduplication may be
219
+ // collapsing distinct events together.
220
+ if (
221
+ !event.source &&
222
+ !event.visibility &&
223
+ !event.runtimeId &&
224
+ !event.hookId &&
225
+ !event.clientId &&
226
+ !event.threadId &&
227
+ !event.runId &&
228
+ !event.messageId &&
229
+ !event.toolCallId &&
230
+ !event.timestamp
231
+ ) {
232
+ console.warn(
233
+ `[ai-devtools] dedupe key for "${eventName}" has no identifying fields; events may collide.`,
234
+ )
235
+ }
236
+ }
237
+
238
+ if (state.seenEventIds[key]) {
239
+ return false
240
+ }
241
+ state.seenEventIds[key] = true
242
+ state.seenEventIdOrder.push(key)
243
+ if (state.seenEventIdOrder.length > SEEN_EVENT_IDS_CAP) {
244
+ const evicted = state.seenEventIdOrder.shift()
245
+ if (evicted !== undefined) {
246
+ delete state.seenEventIds[evicted]
247
+ }
248
+ }
249
+ return true
250
+ }
251
+
252
+ export function applyHookEvent(
253
+ state: HookRegistryState,
254
+ eventName: string,
255
+ event: RuntimeScopedHookEvent,
256
+ ): void {
257
+ if (isForeignClientRuntimeEvent(event)) {
258
+ return
259
+ }
260
+
261
+ if (!markEventSeen(state, eventName, event)) {
262
+ return
263
+ }
264
+
265
+ const timelineEvent = createTimelineEvent(eventName, event)
266
+ state.events[timelineEvent.id] = timelineEvent
267
+
268
+ switch (eventName) {
269
+ case 'hook:registered': {
270
+ const registered = event as HookRegisteredEvent
271
+ delete state.unregisteredHookIds[registered.hookId]
272
+ upsertHook(state, registered)
273
+ attachEventToHook(state, registered.hookId, timelineEvent.id)
274
+ break
275
+ }
276
+ case 'hook:updated': {
277
+ const updated = event as HookUpdatedEvent
278
+ if (state.unregisteredHookIds[updated.hookId]) {
279
+ break
280
+ }
281
+ if (isStaleHookInstanceEvent(state, updated.hookId, updated)) {
282
+ break
283
+ }
284
+ upsertHook(state, updated)
285
+ attachEventToHook(state, updated.hookId, timelineEvent.id)
286
+ break
287
+ }
288
+ case 'hook:unregistered': {
289
+ const unregistered = event as HookUnregisteredEvent
290
+ const existing = state.hooks[unregistered.hookId]
291
+ if (
292
+ existing?.clientId &&
293
+ unregistered.clientId &&
294
+ existing.clientId !== unregistered.clientId
295
+ ) {
296
+ break
297
+ }
298
+ if (
299
+ existing?.correlationId &&
300
+ unregistered.correlationId &&
301
+ existing.correlationId !== unregistered.correlationId
302
+ ) {
303
+ break
304
+ }
305
+ if (existing && existing.registeredAt > unregistered.timestamp) {
306
+ break
307
+ }
308
+ state.unregisteredHookIds[unregistered.hookId] = true
309
+ removeHookRecord(state, unregistered.hookId)
310
+ break
311
+ }
312
+ case 'hook:state-snapshot': {
313
+ const snapshot = event as HookStateSnapshotEvent
314
+ if (state.unregisteredHookIds[snapshot.hookId]) {
315
+ break
316
+ }
317
+ if (isStaleHookInstanceEvent(state, snapshot.hookId, snapshot)) {
318
+ break
319
+ }
320
+ upsertHook(state, {
321
+ ...snapshot,
322
+ lifecycle: inferLifecycleFromSnapshot(snapshot.state),
323
+ })
324
+ const hook = state.hooks[snapshot.hookId]
325
+ if (hook) {
326
+ hook.state = snapshot.state
327
+ hook.updatedAt = snapshot.timestamp
328
+ }
329
+ attachEventToHook(state, snapshot.hookId, timelineEvent.id)
330
+ // Generation hooks ship their run history inside the snapshot. When
331
+ // devtools is opened after a run has already completed, the run
332
+ // lifecycle events fired before mount are lost, so backfill the
333
+ // global runs map and the hook's runIds from the snapshot itself.
334
+ syncRunsFromSnapshot(state, snapshot, timelineEvent.id)
335
+ break
336
+ }
337
+ case 'tools:registered': {
338
+ const toolsEvent = event as ToolsRegisteredEvent
339
+ if (state.unregisteredHookIds[toolsEvent.hookId]) {
340
+ break
341
+ }
342
+ if (isStaleHookInstanceEvent(state, toolsEvent.hookId, toolsEvent)) {
343
+ break
344
+ }
345
+ if (!toolsEvent.hookName) {
346
+ console.warn(
347
+ `[ai-devtools] tools:registered event for hook "${toolsEvent.hookId}" had no hookName; displaying raw hookId in the UI.`,
348
+ )
349
+ }
350
+ upsertHook(state, {
351
+ ...toolsEvent,
352
+ hookName: toolsEvent.hookName ?? toolsEvent.hookId,
353
+ lifecycle: 'active',
354
+ })
355
+ const hook = state.hooks[toolsEvent.hookId]
356
+ if (hook) {
357
+ hook.tools = toolsEvent.tools
358
+ hook.updatedAt = toolsEvent.timestamp
359
+ }
360
+ attachEventToHook(state, toolsEvent.hookId, timelineEvent.id)
361
+ break
362
+ }
363
+ case 'run:created':
364
+ case 'run:started':
365
+ case 'run:updated':
366
+ case 'run:completed':
367
+ case 'run:errored':
368
+ case 'run:cancelled': {
369
+ const runEvent = event as RunLifecycleEvent
370
+ if (runEvent.hookId && state.unregisteredHookIds[runEvent.hookId]) {
371
+ break
372
+ }
373
+ if (
374
+ runEvent.hookId &&
375
+ isStaleHookInstanceEvent(state, runEvent.hookId, runEvent)
376
+ ) {
377
+ break
378
+ }
379
+ upsertRun(state, runEvent, timelineEvent.id)
380
+ if (runEvent.hookId) {
381
+ upsertUnknownHook(state, runEvent.hookId, runEvent)
382
+ attachRunToHook(state, runEvent.hookId, runEvent.runId)
383
+ attachActivityRunToHook(state, runEvent.hookId, runEvent.runId)
384
+ attachEventToHook(state, runEvent.hookId, timelineEvent.id)
385
+ }
386
+ break
387
+ }
388
+ case 'devtools:tool-fixture:apply': {
389
+ const fixtureEvent = event as DevtoolsToolFixtureApplyEvent
390
+ if (fixtureEvent.hookId) {
391
+ attachEventToHook(state, fixtureEvent.hookId, timelineEvent.id)
392
+ }
393
+ break
394
+ }
395
+ }
396
+
397
+ if (state.activeHookId && !state.hooks[state.activeHookId]) {
398
+ state.activeHookId = null
399
+ }
400
+
401
+ if (state.activeHookId) {
402
+ markHookViewed(state, state.activeHookId)
403
+ }
404
+ }
405
+
406
+ export function setActiveHook(
407
+ state: HookRegistryState,
408
+ hookId: string | null,
409
+ ): void {
410
+ state.activeHookId = hookId
411
+ if (hookId) {
412
+ markHookViewed(state, hookId)
413
+ }
414
+ }
415
+
416
+ export function clearHookRegistry(state: HookRegistryState): void {
417
+ state.hooks = {}
418
+ state.runs = {}
419
+ state.events = {}
420
+ state.activeHookId = null
421
+ state.seenEventIds = {}
422
+ state.seenEventIdOrder = []
423
+ state.seenHookActivityCounts = {}
424
+ state.unregisteredHookIds = {}
425
+ }
426
+
427
+ export function markHookViewed(state: HookRegistryState, hookId: string): void {
428
+ const hook = state.hooks[hookId]
429
+ if (!hook) return
430
+ state.seenHookActivityCounts[hookId] = hook.activityRunIds.length
431
+ }
432
+
433
+ export function getHookUnseenEventCount(
434
+ state: HookRegistryState,
435
+ hookId: string,
436
+ ): number {
437
+ const hook = state.hooks[hookId]
438
+ if (!hook) return 0
439
+ const seenCount = state.seenHookActivityCounts[hookId] ?? 0
440
+ return Math.max(0, hook.activityRunIds.length - seenCount)
441
+ }
442
+
443
+ export function addSavedFixture(
444
+ state: HookRegistryState,
445
+ fixture: ToolFixtureRecord,
446
+ ): void {
447
+ state.fixtures = [
448
+ fixture,
449
+ ...state.fixtures.filter((item) => item.id !== fixture.id),
450
+ ].slice(0, 50)
451
+ }
452
+
453
+ export function removeSavedFixture(
454
+ state: HookRegistryState,
455
+ fixtureId: string,
456
+ ): void {
457
+ state.fixtures = state.fixtures.filter((fixture) => fixture.id !== fixtureId)
458
+ }
459
+
460
+ export function replaceSavedFixtures(
461
+ state: HookRegistryState,
462
+ fixtures: Array<ToolFixtureRecord>,
463
+ ): void {
464
+ state.fixtures = fixtures.slice(0, 50)
465
+ }
466
+
467
+ export function createToolFixtureRecord(
468
+ draft: ToolFixtureRecordDraft,
469
+ ): ToolFixtureRecord {
470
+ const createdAt = draft.createdAt ?? Date.now()
471
+ const fixtureScope = draft.hookId ?? draft.threadId ?? 'global'
472
+ const fixtureKey = draft.toolCallId ?? createdAt
473
+
474
+ return {
475
+ id: draft.id ?? `fixture:${fixtureScope}:${draft.toolName}:${fixtureKey}`,
476
+ createdAt,
477
+ ...(draft.name ? { name: draft.name } : {}),
478
+ ...(draft.hookId ? { hookId: draft.hookId } : {}),
479
+ ...(draft.threadId ? { threadId: draft.threadId } : {}),
480
+ ...(draft.runId ? { runId: draft.runId } : {}),
481
+ toolName: draft.toolName,
482
+ input: draft.input,
483
+ output: draft.output ?? null,
484
+ ...(draft.execute !== undefined ? { execute: draft.execute } : {}),
485
+ ...(draft.message ? { message: draft.message } : {}),
486
+ ...(draft.toolCallId ? { toolCallId: draft.toolCallId } : {}),
487
+ ...(draft.messageId ? { messageId: draft.messageId } : {}),
488
+ ...(draft.errorText ? { errorText: draft.errorText } : {}),
489
+ }
490
+ }
491
+
492
+ function createTimelineEvent(
493
+ eventType: string,
494
+ event: RuntimeScopedHookEvent,
495
+ ): TimelineEvent {
496
+ return {
497
+ id: event.eventId ?? `${eventType}:${event.timestamp}:${Math.random()}`,
498
+ eventType,
499
+ timestamp: event.timestamp,
500
+ ...(event.source ? { source: event.source } : {}),
501
+ ...(event.visibility ? { visibility: event.visibility } : {}),
502
+ ...(event.runtimeId ? { runtimeId: event.runtimeId } : {}),
503
+ ...(event.hookId ? { hookId: event.hookId } : {}),
504
+ ...(event.threadId ? { threadId: event.threadId } : {}),
505
+ ...(event.runId ? { runId: event.runId } : {}),
506
+ ...(event.messageId ? { messageId: event.messageId } : {}),
507
+ ...(event.toolCallId ? { toolCallId: event.toolCallId } : {}),
508
+ payload: event,
509
+ }
510
+ }
511
+
512
+ function isForeignClientRuntimeEvent(event: RuntimeScopedHookEvent): boolean {
513
+ return (
514
+ event.source === 'client' &&
515
+ typeof event.runtimeId === 'string' &&
516
+ event.runtimeId !== getLocalAIDevtoolsRuntimeId()
517
+ )
518
+ }
519
+
520
+ declare global {
521
+ var __TANSTACK_AI_DEVTOOLS_RUNTIME_ID__: string | undefined
522
+ }
523
+
524
+ function getLocalAIDevtoolsRuntimeId(): string {
525
+ if (!globalThis.__TANSTACK_AI_DEVTOOLS_RUNTIME_ID__) {
526
+ globalThis.__TANSTACK_AI_DEVTOOLS_RUNTIME_ID__ = createRuntimeId()
527
+ }
528
+ return globalThis.__TANSTACK_AI_DEVTOOLS_RUNTIME_ID__
529
+ }
530
+
531
+ function createRuntimeId(): string {
532
+ const cryptoLike = (
533
+ globalThis as {
534
+ crypto?: {
535
+ randomUUID?: () => string
536
+ }
537
+ }
538
+ ).crypto
539
+ if (cryptoLike?.randomUUID) {
540
+ return cryptoLike.randomUUID()
541
+ }
542
+ return Math.random().toString(36).slice(2)
543
+ }
544
+
545
+ function upsertHook(state: HookRegistryState, event: HookUpsertEvent): void {
546
+ const existing = state.hooks[event.hookId]
547
+ if (!existing) {
548
+ state.hooks[event.hookId] = {
549
+ id: event.hookId,
550
+ hookName: event.hookName,
551
+ ...(event.displayName ? { displayName: event.displayName } : {}),
552
+ ...(event.framework ? { framework: event.framework } : {}),
553
+ ...(event.outputKind ? { outputKind: event.outputKind } : {}),
554
+ lifecycle: event.lifecycle,
555
+ ...(event.clientId ? { clientId: event.clientId } : {}),
556
+ ...(event.threadId ? { threadId: event.threadId } : {}),
557
+ ...(event.correlationId ? { correlationId: event.correlationId } : {}),
558
+ registeredAt: event.timestamp,
559
+ updatedAt: event.timestamp,
560
+ state: {},
561
+ tools: [],
562
+ runIds: [],
563
+ eventIds: [],
564
+ activityRunIds: [],
565
+ }
566
+ return
567
+ }
568
+
569
+ existing.hookName = event.hookName
570
+ if (event.displayName) existing.displayName = event.displayName
571
+ if (event.framework) existing.framework = event.framework
572
+ if (event.outputKind) existing.outputKind = event.outputKind
573
+ if (event.clientId) existing.clientId = event.clientId
574
+ if (event.threadId) existing.threadId = event.threadId
575
+ if (event.correlationId) existing.correlationId = event.correlationId
576
+ existing.lifecycle = event.lifecycle
577
+ existing.updatedAt = event.timestamp
578
+ }
579
+
580
+ function isStaleHookInstanceEvent(
581
+ state: HookRegistryState,
582
+ hookId: string,
583
+ event: RuntimeScopedHookEvent,
584
+ ): boolean {
585
+ const existing = state.hooks[hookId]
586
+ return Boolean(
587
+ existing?.correlationId &&
588
+ event.correlationId &&
589
+ existing.correlationId !== event.correlationId,
590
+ )
591
+ }
592
+
593
+ function upsertUnknownHook(
594
+ state: HookRegistryState,
595
+ hookId: string,
596
+ event: RunLifecycleEvent,
597
+ ): void {
598
+ if (state.hooks[hookId]) return
599
+ state.hooks[hookId] = {
600
+ id: hookId,
601
+ hookName: hookId,
602
+ lifecycle: 'active',
603
+ ...(event.clientId ? { clientId: event.clientId } : {}),
604
+ ...(event.threadId ? { threadId: event.threadId } : {}),
605
+ registeredAt: event.timestamp,
606
+ updatedAt: event.timestamp,
607
+ state: {},
608
+ tools: [],
609
+ runIds: [],
610
+ eventIds: [],
611
+ activityRunIds: [],
612
+ }
613
+ }
614
+
615
+ function removeHookRecord(state: HookRegistryState, hookId: string): void {
616
+ const hook = state.hooks[hookId]
617
+ const hookRunIds = new Set(hook?.runIds ?? [])
618
+ const hookEventIds = new Set(hook?.eventIds ?? [])
619
+
620
+ for (const [runId, run] of Object.entries(state.runs)) {
621
+ if (run.hookId === hookId || hookRunIds.has(runId)) {
622
+ delete state.runs[runId]
623
+ }
624
+ }
625
+
626
+ // Only remove events that were attached to the hook via attachEventToHook;
627
+ // the unregister event itself is intentionally not attached so it survives
628
+ // and can still be inspected on the global timeline.
629
+ for (const eventId of hookEventIds) {
630
+ delete state.events[eventId]
631
+ }
632
+
633
+ delete state.hooks[hookId]
634
+ delete state.seenHookActivityCounts[hookId]
635
+
636
+ if (state.activeHookId === hookId) {
637
+ state.activeHookId = null
638
+ }
639
+ }
640
+
641
+ function upsertRun(
642
+ state: HookRegistryState,
643
+ event: RunLifecycleEvent,
644
+ eventId: string,
645
+ ): void {
646
+ const existing = state.runs[event.runId]
647
+ if (!existing) {
648
+ state.runs[event.runId] = {
649
+ id: event.runId,
650
+ ...(event.hookId ? { hookId: event.hookId } : {}),
651
+ ...(event.threadId ? { threadId: event.threadId } : {}),
652
+ status: event.status,
653
+ ...(event.source ? { source: event.source } : {}),
654
+ ...(event.visibility ? { visibility: event.visibility } : {}),
655
+ startedAt: event.timestamp,
656
+ updatedAt: event.timestamp,
657
+ ...(isTerminalRunStatus(event.status)
658
+ ? { completedAt: event.timestamp }
659
+ : {}),
660
+ ...(event.error ? { error: event.error } : {}),
661
+ eventIds: [eventId],
662
+ }
663
+ return
664
+ }
665
+
666
+ if (event.hookId) existing.hookId = event.hookId
667
+ if (event.threadId) existing.threadId = event.threadId
668
+ if (event.source) existing.source = event.source
669
+ if (event.visibility) existing.visibility = event.visibility
670
+ existing.status = event.status
671
+ existing.updatedAt = event.timestamp
672
+ if (isTerminalRunStatus(event.status)) {
673
+ existing.completedAt = event.timestamp
674
+ }
675
+ if (event.error) {
676
+ existing.error = event.error
677
+ }
678
+ if (!existing.eventIds.includes(eventId)) {
679
+ existing.eventIds.push(eventId)
680
+ }
681
+ }
682
+
683
+ function attachRunToHook(
684
+ state: HookRegistryState,
685
+ hookId: string,
686
+ runId: string,
687
+ ): void {
688
+ const hook = state.hooks[hookId]
689
+ if (!hook || hook.runIds.includes(runId)) return
690
+ hook.runIds.push(runId)
691
+ }
692
+
693
+ function syncRunsFromSnapshot(
694
+ state: HookRegistryState,
695
+ snapshot: HookStateSnapshotEvent,
696
+ eventId: string,
697
+ ): void {
698
+ const rawRuns = (snapshot.state as { runs?: unknown }).runs
699
+ if (!Array.isArray(rawRuns)) return
700
+ for (const candidate of rawRuns) {
701
+ if (!candidate || typeof candidate !== 'object') continue
702
+ const run = candidate as {
703
+ id?: unknown
704
+ status?: unknown
705
+ startedAt?: unknown
706
+ updatedAt?: unknown
707
+ completedAt?: unknown
708
+ error?: unknown
709
+ }
710
+ if (typeof run.id !== 'string') continue
711
+ const status = normalizeRunStatusFromSnapshot(run.status)
712
+ if (status === null) {
713
+ console.warn(
714
+ `[ai-devtools] unknown run.status in snapshot for hook "${snapshot.hookId}": ${String(run.status)}; skipping run "${run.id}"`,
715
+ )
716
+ continue
717
+ }
718
+ const startedAt =
719
+ typeof run.startedAt === 'number' ? run.startedAt : snapshot.timestamp
720
+ const updatedAt =
721
+ typeof run.updatedAt === 'number' ? run.updatedAt : startedAt
722
+ const existing = state.runs[run.id]
723
+ if (!existing) {
724
+ state.runs[run.id] = {
725
+ id: run.id,
726
+ hookId: snapshot.hookId,
727
+ status,
728
+ startedAt,
729
+ updatedAt,
730
+ ...(typeof run.completedAt === 'number'
731
+ ? { completedAt: run.completedAt }
732
+ : isTerminalRunStatus(status)
733
+ ? { completedAt: updatedAt }
734
+ : {}),
735
+ ...(typeof run.error === 'string' ? { error: run.error } : {}),
736
+ eventIds: [eventId],
737
+ }
738
+ } else {
739
+ existing.hookId = snapshot.hookId
740
+ existing.status = status
741
+ existing.updatedAt = updatedAt
742
+ if (typeof run.completedAt === 'number') {
743
+ existing.completedAt = run.completedAt
744
+ } else if (isTerminalRunStatus(status) && !existing.completedAt) {
745
+ existing.completedAt = updatedAt
746
+ }
747
+ if (typeof run.error === 'string') existing.error = run.error
748
+ if (!existing.eventIds.includes(eventId)) {
749
+ existing.eventIds.push(eventId)
750
+ }
751
+ }
752
+ attachRunToHook(state, snapshot.hookId, run.id)
753
+ }
754
+ }
755
+
756
+ function normalizeRunStatusFromSnapshot(
757
+ value: unknown,
758
+ ): RunLifecycleEvent['status'] | null {
759
+ switch (value) {
760
+ case 'created':
761
+ case 'started':
762
+ case 'updated':
763
+ case 'completed':
764
+ case 'errored':
765
+ case 'cancelled':
766
+ return value
767
+ case 'success':
768
+ return 'completed'
769
+ case 'error':
770
+ return 'errored'
771
+ case 'idle':
772
+ return 'created'
773
+ default:
774
+ return null
775
+ }
776
+ }
777
+
778
+ function attachActivityRunToHook(
779
+ state: HookRegistryState,
780
+ hookId: string,
781
+ runId: string,
782
+ ): void {
783
+ const hook = state.hooks[hookId]
784
+ if (!hook || hook.activityRunIds.includes(runId)) return
785
+ hook.activityRunIds.push(runId)
786
+ }
787
+
788
+ function attachEventToHook(
789
+ state: HookRegistryState,
790
+ hookId: string | undefined,
791
+ eventId: string,
792
+ ): void {
793
+ if (!hookId) return
794
+ const hook = state.hooks[hookId]
795
+ if (!hook || hook.eventIds.includes(eventId)) return
796
+ hook.eventIds.push(eventId)
797
+ }
798
+
799
+ function inferLifecycleFromSnapshot(
800
+ state: Record<string, unknown>,
801
+ ): HookLifecycle {
802
+ if (state.status === 'error' || state.error) {
803
+ return 'errored'
804
+ }
805
+ if (state.isLoading || state.status === 'generating') {
806
+ return 'streaming'
807
+ }
808
+ return 'active'
809
+ }
810
+
811
+ function isTerminalRunStatus(status: RunLifecycleEvent['status']): boolean {
812
+ return (
813
+ status === 'completed' || status === 'errored' || status === 'cancelled'
814
+ )
815
+ }