@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
@@ -1,7 +1,25 @@
1
1
  import { batch, createContext, onCleanup, onMount, useContext } from 'solid-js'
2
2
  import { createStore, produce } from 'solid-js/store'
3
3
  import { aiEventClient } from '@tanstack/ai-event-client'
4
+ import {
5
+ addSavedFixture,
6
+ applyHookEvent,
7
+ clearHookRegistry,
8
+ createHookRegistryState,
9
+ removeSavedFixture,
10
+ replaceSavedFixtures,
11
+ setActiveHook,
12
+ } from './hook-registry'
13
+ import {
14
+ createClientToolCallMessage,
15
+ shouldSkipClientAssistantPlaceholder,
16
+ } from './message-event-utils'
4
17
  import type { ContentPartSource } from '@tanstack/ai'
18
+ import type {
19
+ DevtoolsToolFixtureApplyEvent,
20
+ RunLifecycleEvent,
21
+ } from '@tanstack/ai-event-client'
22
+ import type { HookRegistryState, ToolFixtureRecord } from './hook-registry'
5
23
  import type { ParentComponent } from 'solid-js'
6
24
 
7
25
  interface MessagePart {
@@ -14,13 +32,25 @@ interface MessagePart {
14
32
  | 'audio'
15
33
  | 'video'
16
34
  | 'document'
35
+ | 'structured-output'
17
36
  content?: string
37
+ status?: 'streaming' | 'complete' | 'error'
38
+ raw?: string
39
+ partial?: unknown
40
+ data?: unknown
41
+ reasoning?: string
42
+ errorMessage?: string
18
43
  toolCallId?: string
19
44
  toolName?: string
20
45
  arguments?: string
21
46
  state?: string
22
47
  output?: unknown
23
48
  error?: string
49
+ approval?: {
50
+ id?: string
51
+ needsApproval?: boolean
52
+ approved?: boolean
53
+ }
24
54
  // Multimodal content fields
25
55
  source?: ContentPartSource
26
56
  metadata?: unknown
@@ -34,6 +64,7 @@ export interface ToolCall {
34
64
  result?: unknown
35
65
  approvalRequired?: boolean
36
66
  approvalId?: string
67
+ approvalApproved?: boolean
37
68
  /** Duration of tool execution in milliseconds */
38
69
  duration?: number
39
70
  }
@@ -78,6 +109,7 @@ export interface Chunk {
78
109
  | 'error'
79
110
  | 'approval'
80
111
  | 'thinking'
112
+ | 'structured_output'
81
113
  timestamp: number
82
114
  messageId?: string
83
115
  /** Accumulated content from all merged chunks */
@@ -89,6 +121,7 @@ export interface Chunk {
89
121
  finishReason?: string
90
122
  error?: string
91
123
  approvalId?: string
124
+ approved?: boolean
92
125
  input?: unknown
93
126
  /** Tool arguments for tool_call chunks */
94
127
  arguments?: string
@@ -100,6 +133,12 @@ export interface Chunk {
100
133
  chunkCount: number
101
134
  /** Whether this is a client-side tool execution */
102
135
  isClientTool?: boolean
136
+ structuredStatus?: 'streaming' | 'complete' | 'error'
137
+ raw?: string
138
+ partial?: unknown
139
+ data?: unknown
140
+ reasoning?: string
141
+ errorMessage?: string
103
142
  }
104
143
 
105
144
  export interface MiddlewareEvent {
@@ -162,6 +201,7 @@ export interface Conversation {
162
201
  status: 'active' | 'completed' | 'error'
163
202
  startedAt: number
164
203
  completedAt?: number
204
+ runIds?: Array<string>
165
205
  usage?: TokenUsage
166
206
  iterations: Array<Iteration>
167
207
  iterationCount?: number
@@ -173,12 +213,14 @@ export interface Conversation {
173
213
  hasChat?: boolean
174
214
  hasSummarize?: boolean
175
215
  hasImage?: boolean
216
+ hasAudio?: boolean
176
217
  hasSpeech?: boolean
177
218
  hasTranscription?: boolean
178
219
  hasVideo?: boolean
179
220
  /** Summarize operations in this conversation */
180
221
  summaries?: Array<SummarizeOperation>
181
222
  imageEvents?: Array<ActivityEvent>
223
+ audioEvents?: Array<ActivityEvent>
182
224
  speechEvents?: Array<ActivityEvent>
183
225
  transcriptionEvents?: Array<ActivityEvent>
184
226
  videoEvents?: Array<ActivityEvent>
@@ -187,12 +229,18 @@ export interface Conversation {
187
229
  interface AIStoreState {
188
230
  conversations: Record<string, Conversation>
189
231
  activeConversationId: string | null
232
+ hooks: HookRegistryState
190
233
  }
191
234
 
192
235
  interface AIContextValue {
193
236
  state: AIStoreState
194
237
  clearAllConversations: () => void
195
238
  selectConversation: (id: string) => void
239
+ clearHooks: () => void
240
+ selectHook: (id: string | null) => void
241
+ saveToolFixture: (fixture: ToolFixtureRecord) => void
242
+ deleteToolFixture: (fixtureId: string) => void
243
+ applyToolFixture: (fixture: ToolFixtureRecord) => void
196
244
  }
197
245
 
198
246
  const AIContext = createContext<AIContextValue>()
@@ -209,20 +257,19 @@ export const AIProvider: ParentComponent = (props) => {
209
257
  const [state, setState] = createStore<AIStoreState>({
210
258
  conversations: {},
211
259
  activeConversationId: null,
260
+ hooks: createHookRegistryState(),
212
261
  })
213
262
 
214
263
  const streamToConversation = new Map<string, string>()
215
264
  const requestToConversation = new Map<string, string>()
216
265
  /** Track max cumulative usage per requestId per conversation for correct totals */
217
266
  const requestUsageByConversation = new Map<string, Map<string, TokenUsage>>()
267
+ const fixturesStorageKey = 'tanstack-ai-devtools:tool-fixtures'
218
268
 
219
- // Batching system for high-frequency chunk updates with consolidated chunk merging
220
- // Stores: conversationId -> { chunks to merge, totalNewChunks count }
221
269
  const pendingConversationChunks = new Map<
222
270
  string,
223
271
  { chunks: Array<Chunk>; newChunkCount: number }
224
272
  >()
225
- // Stores: conversationId -> messageIndex -> { chunks to merge, totalNewChunks count }
226
273
  const pendingMessageChunks = new Map<
227
274
  string,
228
275
  Map<number, { chunks: Array<Chunk>; newChunkCount: number }>
@@ -235,29 +282,24 @@ export const AIProvider: ParentComponent = (props) => {
235
282
  queueMicrotask(flushPendingChunks)
236
283
  }
237
284
 
238
- /** Check if a chunk type can be merged with consecutive same-type chunks */
239
285
  function isMergeableChunkType(type: Chunk['type']): boolean {
240
286
  return type === 'content' || type === 'thinking'
241
287
  }
242
288
 
243
- /** Merge pending chunks into existing chunks array, consolidating consecutive same-type chunks */
244
289
  function mergeChunks(existing: Array<Chunk>, pending: Array<Chunk>): void {
245
290
  for (const chunk of pending) {
246
291
  const lastChunk = existing[existing.length - 1]
247
292
 
248
- // If last chunk exists, is the same type, and both are mergeable types, merge them
249
293
  if (
250
294
  lastChunk &&
251
295
  lastChunk.type === chunk.type &&
252
296
  isMergeableChunkType(chunk.type) &&
253
297
  lastChunk.messageId === chunk.messageId
254
298
  ) {
255
- // Merge: use the new chunk's content (it's already accumulated), update delta, increment count
256
299
  lastChunk.content = chunk.content || lastChunk.content
257
300
  lastChunk.delta = chunk.delta
258
301
  lastChunk.chunkCount += chunk.chunkCount
259
302
  } else {
260
- // Different type or not mergeable - add as new entry
261
303
  existing.push(chunk)
262
304
  }
263
305
  }
@@ -267,7 +309,6 @@ export const AIProvider: ParentComponent = (props) => {
267
309
  batchScheduled = false
268
310
 
269
311
  batch(() => {
270
- // Flush conversation-level chunks
271
312
  for (const [
272
313
  conversationId,
273
314
  { chunks, newChunkCount },
@@ -286,7 +327,6 @@ export const AIProvider: ParentComponent = (props) => {
286
327
  }
287
328
  pendingConversationChunks.clear()
288
329
 
289
- // Flush message-level chunks
290
330
  for (const [conversationId, messageMap] of pendingMessageChunks) {
291
331
  const conv = state.conversations[conversationId]
292
332
  if (!conv) continue
@@ -294,7 +334,6 @@ export const AIProvider: ParentComponent = (props) => {
294
334
  for (const [messageIndex, { chunks, newChunkCount }] of messageMap) {
295
335
  const message = conv.messages[messageIndex]
296
336
  if (message) {
297
- // Update chunks array with merging
298
337
  setState(
299
338
  'conversations',
300
339
  conversationId,
@@ -302,15 +341,11 @@ export const AIProvider: ParentComponent = (props) => {
302
341
  messageIndex,
303
342
  'chunks',
304
343
  produce((arr: Array<Chunk> | undefined) => {
305
- if (!arr) {
306
- // First time - just set the chunks (they're already consolidated in pending)
307
- return chunks
308
- }
344
+ if (!arr) return chunks
309
345
  mergeChunks(arr, chunks)
310
346
  return arr
311
347
  }),
312
348
  )
313
- // Update total chunk count
314
349
  const currentTotal = message.totalChunkCount || 0
315
350
  setState(
316
351
  'conversations',
@@ -334,7 +369,6 @@ export const AIProvider: ParentComponent = (props) => {
334
369
  pendingConversationChunks.set(conversationId, pending)
335
370
  }
336
371
 
337
- // Pre-merge in pending buffer to reduce array operations during flush
338
372
  const lastPending = pending.chunks[pending.chunks.length - 1]
339
373
  if (
340
374
  lastPending &&
@@ -342,8 +376,6 @@ export const AIProvider: ParentComponent = (props) => {
342
376
  isMergeableChunkType(chunk.type) &&
343
377
  lastPending.messageId === chunk.messageId
344
378
  ) {
345
- // Merge into pending buffer - only append delta (incremental content)
346
- // Use the new chunk's content as the accumulated content (it's already accumulated)
347
379
  lastPending.content = chunk.content || lastPending.content
348
380
  lastPending.delta = chunk.delta
349
381
  lastPending.chunkCount += chunk.chunkCount
@@ -370,7 +402,6 @@ export const AIProvider: ParentComponent = (props) => {
370
402
  messageMap.set(messageIndex, pending)
371
403
  }
372
404
 
373
- // Pre-merge in pending buffer
374
405
  const lastPending = pending.chunks[pending.chunks.length - 1]
375
406
  if (
376
407
  lastPending &&
@@ -378,8 +409,6 @@ export const AIProvider: ParentComponent = (props) => {
378
409
  isMergeableChunkType(chunk.type) &&
379
410
  lastPending.messageId === chunk.messageId
380
411
  ) {
381
- // Merge into pending buffer - only append delta (incremental content)
382
- // Use the new chunk's content as the accumulated content (it's already accumulated)
383
412
  lastPending.content = chunk.content || lastPending.content
384
413
  lastPending.delta = chunk.delta
385
414
  lastPending.chunkCount += chunk.chunkCount
@@ -390,7 +419,6 @@ export const AIProvider: ParentComponent = (props) => {
390
419
  scheduleBatchFlush()
391
420
  }
392
421
 
393
- // Optimized helper functions using path-based updates
394
422
  function getOrCreateConversation(
395
423
  id: string,
396
424
  type: 'client' | 'server',
@@ -405,6 +433,7 @@ export const AIProvider: ParentComponent = (props) => {
405
433
  chunks: [],
406
434
  iterations: [],
407
435
  imageEvents: [],
436
+ audioEvents: [],
408
437
  speechEvents: [],
409
438
  transcriptionEvents: [],
410
439
  videoEvents: [],
@@ -417,6 +446,18 @@ export const AIProvider: ParentComponent = (props) => {
417
446
  }
418
447
  }
419
448
 
449
+ function attachRunToConversation(
450
+ conversationId: string,
451
+ runId: string | undefined,
452
+ ): void {
453
+ if (!runId) return
454
+ const conv = state.conversations[conversationId]
455
+ if (!conv || conv.runIds?.includes(runId)) return
456
+ updateConversation(conversationId, {
457
+ runIds: [...(conv.runIds ?? []), runId],
458
+ })
459
+ }
460
+
420
461
  function addMessage(conversationId: string, message: Message): void {
421
462
  const conv = state.conversations[conversationId]
422
463
  if (!conv) return
@@ -594,6 +635,181 @@ export const AIProvider: ParentComponent = (props) => {
594
635
  setState('activeConversationId', id)
595
636
  }
596
637
 
638
+ function clearHooks() {
639
+ setState(
640
+ 'hooks',
641
+ produce((hooks: HookRegistryState) => {
642
+ clearHookRegistry(hooks)
643
+ }),
644
+ )
645
+ }
646
+
647
+ function selectHook(id: string | null) {
648
+ setState(
649
+ 'hooks',
650
+ produce((hooks: HookRegistryState) => {
651
+ setActiveHook(hooks, id)
652
+ }),
653
+ )
654
+ }
655
+
656
+ function saveToolFixture(fixture: ToolFixtureRecord) {
657
+ const fixtures = mergeFixtures(fixture, state.hooks.fixtures)
658
+ setState(
659
+ 'hooks',
660
+ produce((hooks: HookRegistryState) => {
661
+ addSavedFixture(hooks, fixture)
662
+ }),
663
+ )
664
+ persistFixtures(fixtures)
665
+ }
666
+
667
+ function deleteToolFixture(fixtureId: string) {
668
+ const fixtures = state.hooks.fixtures.filter(
669
+ (fixture) => fixture.id !== fixtureId,
670
+ )
671
+ setState(
672
+ 'hooks',
673
+ produce((hooks: HookRegistryState) => {
674
+ removeSavedFixture(hooks, fixtureId)
675
+ }),
676
+ )
677
+ persistFixtures(fixtures)
678
+ }
679
+
680
+ function applyToolFixture(fixture: ToolFixtureRecord) {
681
+ const timestamp = Date.now()
682
+ const payload: DevtoolsToolFixtureApplyEvent = {
683
+ eventId: `devtools:tool-fixture:apply:${fixture.id}:${timestamp}:${Math.random()
684
+ .toString(36)
685
+ .slice(2)}`,
686
+ fixtureId: fixture.id,
687
+ timestamp,
688
+ source: 'devtools',
689
+ visibility: 'devtools-action',
690
+ ...(fixture.hookId ? { hookId: fixture.hookId } : {}),
691
+ ...(fixture.threadId ? { threadId: fixture.threadId } : {}),
692
+ ...(fixture.runId ? { runId: fixture.runId } : {}),
693
+ toolName: fixture.toolName,
694
+ input: fixture.input,
695
+ output: fixture.output,
696
+ ...(fixture.execute !== undefined ? { execute: fixture.execute } : {}),
697
+ ...(fixture.message ? { message: fixture.message } : {}),
698
+ ...(fixture.errorText ? { errorText: fixture.errorText } : {}),
699
+ }
700
+
701
+ aiEventClient.emit('devtools:tool-fixture:apply', payload)
702
+ }
703
+
704
+ function mergeFixtures(
705
+ fixture: ToolFixtureRecord,
706
+ fixtures: Array<ToolFixtureRecord>,
707
+ ): Array<ToolFixtureRecord> {
708
+ return [
709
+ fixture,
710
+ ...fixtures.filter((existing) => existing.id !== fixture.id),
711
+ ].slice(0, 50)
712
+ }
713
+
714
+ function persistFixtures(fixtures: Array<ToolFixtureRecord>) {
715
+ if (typeof localStorage === 'undefined') return
716
+ localStorage.setItem(fixturesStorageKey, JSON.stringify(fixtures))
717
+ }
718
+
719
+ function loadFixtures() {
720
+ if (typeof localStorage === 'undefined') return
721
+ const raw = localStorage.getItem(fixturesStorageKey)
722
+ if (!raw) return
723
+
724
+ let parsed: unknown
725
+ try {
726
+ parsed = JSON.parse(raw)
727
+ } catch (err) {
728
+ // Surface the bad payload instead of silently nuking it — keep the raw
729
+ // value in storage so the user can recover it manually if needed.
730
+ console.error(
731
+ '[ai-devtools] failed to parse saved tool fixtures; keeping raw payload for inspection',
732
+ err,
733
+ )
734
+ return
735
+ }
736
+ if (!Array.isArray(parsed)) {
737
+ console.error(
738
+ '[ai-devtools] saved tool fixtures payload is not an array; ignoring',
739
+ parsed,
740
+ )
741
+ return
742
+ }
743
+ const fixtures = parsed.filter(isToolFixtureRecord)
744
+ setState(
745
+ 'hooks',
746
+ produce((hooks: HookRegistryState) => {
747
+ replaceSavedFixtures(hooks, fixtures)
748
+ }),
749
+ )
750
+ }
751
+
752
+ function isToolFixtureRecord(value: unknown): value is ToolFixtureRecord {
753
+ if (typeof value !== 'object' || value === null) return false
754
+ const candidate = value as Partial<ToolFixtureRecord>
755
+ return (
756
+ typeof candidate.id === 'string' &&
757
+ typeof candidate.createdAt === 'number' &&
758
+ (candidate.name === undefined || typeof candidate.name === 'string') &&
759
+ typeof candidate.toolName === 'string' &&
760
+ (candidate.execute === undefined ||
761
+ typeof candidate.execute === 'boolean') &&
762
+ 'input' in candidate &&
763
+ 'output' in candidate &&
764
+ (candidate.message === undefined ||
765
+ isToolFixtureMessage(candidate.message))
766
+ )
767
+ }
768
+
769
+ function isToolFixtureMessage(
770
+ value: unknown,
771
+ ): value is NonNullable<ToolFixtureRecord['message']> {
772
+ if (typeof value !== 'object' || value === null) return false
773
+ const candidate = value as {
774
+ id?: unknown
775
+ role?: unknown
776
+ parts?: unknown
777
+ createdAt?: unknown
778
+ }
779
+ return (
780
+ typeof candidate.id === 'string' &&
781
+ (candidate.role === 'system' ||
782
+ candidate.role === 'user' ||
783
+ candidate.role === 'assistant') &&
784
+ Array.isArray(candidate.parts) &&
785
+ (candidate.createdAt === undefined ||
786
+ typeof candidate.createdAt === 'number' ||
787
+ typeof candidate.createdAt === 'string')
788
+ )
789
+ }
790
+
791
+ function normalizeMessageSource(
792
+ source: unknown,
793
+ fallback: 'client' | 'server',
794
+ ): 'client' | 'server' {
795
+ return source === 'client' || source === 'server' ? source : fallback
796
+ }
797
+
798
+ function stringifyToolArguments(value: unknown): string {
799
+ if (typeof value === 'string') return value
800
+ try {
801
+ return JSON.stringify(value ?? {})
802
+ } catch (error) {
803
+ console.error(
804
+ '[ai-devtools] failed to JSON.stringify tool call arguments; saved fixture replay will be malformed.',
805
+ { error, value },
806
+ )
807
+ return `[ai-devtools] unserializable tool arguments: ${
808
+ error instanceof Error ? error.message : String(error)
809
+ }`
810
+ }
811
+ }
812
+
597
813
  // Additional optimized helper functions
598
814
  function updateConversation(
599
815
  conversationId: string,
@@ -648,6 +864,202 @@ export const AIProvider: ParentComponent = (props) => {
648
864
  )
649
865
  }
650
866
 
867
+ function findToolCallLocation(
868
+ conversationId: string,
869
+ input: { toolCallId?: string; approvalId?: string },
870
+ ): { messageIndex: number; toolCallIndex: number } | undefined {
871
+ const conv = state.conversations[conversationId]
872
+ if (!conv) return undefined
873
+
874
+ for (
875
+ let messageIndex = conv.messages.length - 1;
876
+ messageIndex >= 0;
877
+ messageIndex--
878
+ ) {
879
+ const message = conv.messages[messageIndex]
880
+ if (!message?.toolCalls) continue
881
+
882
+ const toolCallIndex = message.toolCalls.findIndex((toolCall) => {
883
+ if (input.toolCallId && toolCall.id === input.toolCallId) return true
884
+ if (input.approvalId && toolCall.approvalId === input.approvalId) {
885
+ return true
886
+ }
887
+ return false
888
+ })
889
+ if (toolCallIndex >= 0) {
890
+ return { messageIndex, toolCallIndex }
891
+ }
892
+ }
893
+
894
+ return undefined
895
+ }
896
+
897
+ function getToolEventConversationIds(input: {
898
+ clientId?: string
899
+ threadId?: string
900
+ streamId?: string
901
+ toolCallId?: string
902
+ approvalId?: string
903
+ }): Array<string> {
904
+ const ids: Array<string> = []
905
+ const add = (id: string | undefined) => {
906
+ if (id && !ids.includes(id)) ids.push(id)
907
+ }
908
+
909
+ add(input.clientId)
910
+ add(input.streamId ? streamToConversation.get(input.streamId) : undefined)
911
+ add(input.threadId)
912
+
913
+ for (const conversationId of Object.keys(state.conversations)) {
914
+ if (
915
+ findToolCallLocation(conversationId, {
916
+ toolCallId: input.toolCallId,
917
+ approvalId: input.approvalId,
918
+ })
919
+ ) {
920
+ add(conversationId)
921
+ }
922
+ }
923
+
924
+ return ids
925
+ }
926
+
927
+ function getStructuredOutputConversationIds(input: {
928
+ clientId?: string
929
+ threadId?: string
930
+ streamId?: string
931
+ messageId?: string
932
+ }): Array<string> {
933
+ const ids: Array<string> = []
934
+ const add = (id: string | undefined) => {
935
+ if (id && !ids.includes(id)) ids.push(id)
936
+ }
937
+
938
+ add(input.clientId)
939
+ add(input.streamId ? streamToConversation.get(input.streamId) : undefined)
940
+ add(input.threadId)
941
+
942
+ if (input.messageId) {
943
+ for (const conversation of Object.values(state.conversations)) {
944
+ if (
945
+ conversation.messages.some(
946
+ (message) => message.id === input.messageId,
947
+ )
948
+ ) {
949
+ add(conversation.id)
950
+ }
951
+ }
952
+ }
953
+
954
+ return ids
955
+ }
956
+
957
+ function upsertStructuredOutputPart(
958
+ conversationId: string,
959
+ input: {
960
+ messageId: string
961
+ timestamp: number
962
+ source: 'client' | 'server'
963
+ requestId?: string
964
+ status: 'streaming' | 'complete' | 'error'
965
+ raw: string
966
+ partial?: unknown
967
+ data?: unknown
968
+ reasoning?: string
969
+ errorMessage?: string
970
+ },
971
+ ): number | undefined {
972
+ const conv = state.conversations[conversationId]
973
+ if (!conv) return undefined
974
+
975
+ const part: MessagePart = {
976
+ type: 'structured-output',
977
+ status: input.status,
978
+ raw: input.raw,
979
+ ...(input.partial !== undefined ? { partial: input.partial } : {}),
980
+ ...(input.data !== undefined ? { data: input.data } : {}),
981
+ ...(input.reasoning !== undefined ? { reasoning: input.reasoning } : {}),
982
+ ...(input.errorMessage !== undefined
983
+ ? { errorMessage: input.errorMessage }
984
+ : {}),
985
+ }
986
+
987
+ let messageIndex = conv.messages.findIndex(
988
+ (message) => message.id === input.messageId,
989
+ )
990
+
991
+ if (messageIndex === -1) {
992
+ messageIndex = conv.messages.length
993
+ addMessage(conversationId, {
994
+ id: input.messageId,
995
+ role: 'assistant',
996
+ content: '',
997
+ timestamp: input.timestamp,
998
+ parts: [part],
999
+ source: input.source,
1000
+ ...(input.requestId ? { requestId: input.requestId } : {}),
1001
+ })
1002
+ } else {
1003
+ setState(
1004
+ 'conversations',
1005
+ conversationId,
1006
+ 'messages',
1007
+ messageIndex,
1008
+ 'parts',
1009
+ produce((parts: Array<MessagePart> | undefined) => {
1010
+ const next = parts ? [...parts] : []
1011
+ const existingIndex = next.findIndex(
1012
+ (candidate) => candidate.type === 'structured-output',
1013
+ )
1014
+ if (existingIndex >= 0) {
1015
+ next[existingIndex] = part
1016
+ } else {
1017
+ next.push(part)
1018
+ }
1019
+ return next
1020
+ }),
1021
+ )
1022
+ }
1023
+
1024
+ return messageIndex
1025
+ }
1026
+
1027
+ function attachMessageToLatestIteration(
1028
+ conversationId: string,
1029
+ messageId: string,
1030
+ requestId: string | undefined,
1031
+ ): void {
1032
+ const conv = state.conversations[conversationId]
1033
+ if (!conv || conv.iterations.length === 0) return
1034
+
1035
+ let iterIndex = -1
1036
+ if (requestId) {
1037
+ for (let i = conv.iterations.length - 1; i >= 0; i--) {
1038
+ if (conv.iterations[i]?.requestId === requestId) {
1039
+ iterIndex = i
1040
+ break
1041
+ }
1042
+ }
1043
+ }
1044
+ if (iterIndex === -1) {
1045
+ iterIndex = conv.iterations.length - 1
1046
+ }
1047
+
1048
+ const iteration = conv.iterations[iterIndex]
1049
+ if (!iteration || iteration.messageIds.includes(messageId)) return
1050
+
1051
+ setState(
1052
+ 'conversations',
1053
+ conversationId,
1054
+ 'iterations',
1055
+ iterIndex,
1056
+ 'messageIds',
1057
+ produce((arr: Array<string>) => {
1058
+ arr.push(messageId)
1059
+ }),
1060
+ )
1061
+ }
1062
+
651
1063
  function setToolCalls(
652
1064
  conversationId: string,
653
1065
  messageIndex: number,
@@ -673,6 +1085,7 @@ export const AIProvider: ParentComponent = (props) => {
673
1085
  conversationId: string,
674
1086
  activity:
675
1087
  | 'imageEvents'
1088
+ | 'audioEvents'
676
1089
  | 'speechEvents'
677
1090
  | 'transcriptionEvents'
678
1091
  | 'videoEvents',
@@ -693,6 +1106,7 @@ export const AIProvider: ParentComponent = (props) => {
693
1106
 
694
1107
  const activityFlagMap: Record<typeof activity, keyof Conversation> = {
695
1108
  imageEvents: 'hasImage',
1109
+ audioEvents: 'hasAudio',
696
1110
  speechEvents: 'hasSpeech',
697
1111
  transcriptionEvents: 'hasTranscription',
698
1112
  videoEvents: 'hasVideo',
@@ -731,8 +1145,112 @@ export const AIProvider: ParentComponent = (props) => {
731
1145
  // Register all event listeners on mount
732
1146
  onMount(() => {
733
1147
  const cleanupFns: Array<() => void> = []
1148
+ loadFixtures()
1149
+
1150
+ cleanupFns.push(
1151
+ aiEventClient.on('hook:registered', (e) => {
1152
+ setState(
1153
+ 'hooks',
1154
+ produce((hooks: HookRegistryState) => {
1155
+ applyHookEvent(hooks, 'hook:registered', e.payload)
1156
+ }),
1157
+ )
1158
+ }),
1159
+ )
1160
+
1161
+ cleanupFns.push(
1162
+ aiEventClient.on('hook:updated', (e) => {
1163
+ setState(
1164
+ 'hooks',
1165
+ produce((hooks: HookRegistryState) => {
1166
+ applyHookEvent(hooks, 'hook:updated', e.payload)
1167
+ }),
1168
+ )
1169
+ }),
1170
+ )
1171
+
1172
+ cleanupFns.push(
1173
+ aiEventClient.on('hook:unregistered', (e) => {
1174
+ setState(
1175
+ 'hooks',
1176
+ produce((hooks: HookRegistryState) => {
1177
+ applyHookEvent(hooks, 'hook:unregistered', e.payload)
1178
+ }),
1179
+ )
1180
+ }),
1181
+ )
1182
+
1183
+ cleanupFns.push(
1184
+ aiEventClient.on('hook:state-snapshot', (e) => {
1185
+ setState(
1186
+ 'hooks',
1187
+ produce((hooks: HookRegistryState) => {
1188
+ applyHookEvent(hooks, 'hook:state-snapshot', e.payload)
1189
+ }),
1190
+ )
1191
+ }),
1192
+ )
734
1193
 
735
- // ============= Client Events =============
1194
+ const recordRunEvent = (
1195
+ eventName:
1196
+ | 'run:created'
1197
+ | 'run:started'
1198
+ | 'run:updated'
1199
+ | 'run:completed'
1200
+ | 'run:errored'
1201
+ | 'run:cancelled',
1202
+ payload: RunLifecycleEvent,
1203
+ ) => {
1204
+ setState(
1205
+ 'hooks',
1206
+ produce((hooks: HookRegistryState) => {
1207
+ applyHookEvent(hooks, eventName, payload)
1208
+ }),
1209
+ )
1210
+ }
1211
+
1212
+ cleanupFns.push(
1213
+ aiEventClient.on('run:created', (e) => {
1214
+ recordRunEvent('run:created', e.payload)
1215
+ }),
1216
+ aiEventClient.on('run:started', (e) => {
1217
+ recordRunEvent('run:started', e.payload)
1218
+ }),
1219
+ aiEventClient.on('run:updated', (e) => {
1220
+ recordRunEvent('run:updated', e.payload)
1221
+ }),
1222
+ aiEventClient.on('run:completed', (e) => {
1223
+ recordRunEvent('run:completed', e.payload)
1224
+ }),
1225
+ aiEventClient.on('run:errored', (e) => {
1226
+ recordRunEvent('run:errored', e.payload)
1227
+ }),
1228
+ aiEventClient.on('run:cancelled', (e) => {
1229
+ recordRunEvent('run:cancelled', e.payload)
1230
+ }),
1231
+ )
1232
+
1233
+ cleanupFns.push(
1234
+ aiEventClient.on('tools:registered', (e) => {
1235
+ setState(
1236
+ 'hooks',
1237
+ produce((hooks: HookRegistryState) => {
1238
+ applyHookEvent(hooks, 'tools:registered', e.payload)
1239
+ }),
1240
+ )
1241
+ }),
1242
+ )
1243
+
1244
+ cleanupFns.push(
1245
+ aiEventClient.on('devtools:tool-fixture:apply', (e) => {
1246
+ setState(
1247
+ 'hooks',
1248
+ produce((hooks: HookRegistryState) => {
1249
+ applyHookEvent(hooks, 'devtools:tool-fixture:apply', e.payload)
1250
+ }),
1251
+ )
1252
+ }),
1253
+ )
736
1254
 
737
1255
  cleanupFns.push(
738
1256
  aiEventClient.on('client:created', (e) => {
@@ -746,8 +1264,6 @@ export const AIProvider: ParentComponent = (props) => {
746
1264
  }),
747
1265
  )
748
1266
 
749
- // ============= Message Events =============
750
-
751
1267
  cleanupFns.push(
752
1268
  aiEventClient.on('text:message:created', (e) => {
753
1269
  const {
@@ -764,13 +1280,23 @@ export const AIProvider: ParentComponent = (props) => {
764
1280
  (streamId ? streamToConversation.get(streamId) : undefined)
765
1281
 
766
1282
  if (!conversationId) return
1283
+ if (clientId && streamId) {
1284
+ streamToConversation.set(streamId, clientId)
1285
+ }
767
1286
  if (role === 'tool' || role === 'system') return
768
1287
 
1288
+ const source = normalizeMessageSource(
1289
+ e.payload.source,
1290
+ clientId ? 'client' : 'server',
1291
+ )
1292
+ const conversationType =
1293
+ clientId && source !== 'server' ? 'client' : 'server'
1294
+
769
1295
  if (!state.conversations[conversationId]) {
770
1296
  getOrCreateConversation(
771
1297
  conversationId,
772
- clientId ? 'client' : 'server',
773
- clientId
1298
+ conversationType,
1299
+ conversationType === 'client'
774
1300
  ? `Client Chat (${conversationId.substring(0, 8)})`
775
1301
  : `Server Chat (${conversationId.substring(0, 8)})`,
776
1302
  )
@@ -783,76 +1309,111 @@ export const AIProvider: ParentComponent = (props) => {
783
1309
  (message) => message.id === messageId,
784
1310
  )
785
1311
 
786
- const parts = e.payload.parts
787
- ?.map((part): MessagePart | null => {
788
- if (part.type === 'text') {
789
- return { type: 'text', content: part.content }
790
- }
791
- if (part.type === 'tool-call') {
792
- return {
793
- type: 'tool-call',
794
- toolCallId: part.id,
795
- toolName: part.name,
796
- arguments: part.arguments,
797
- state: part.state,
798
- output: part.output,
799
- content: part.approval
800
- ? JSON.stringify(part.approval)
801
- : undefined,
1312
+ const parts =
1313
+ e.payload.parts
1314
+ ?.map((part): MessagePart | null => {
1315
+ if (part.type === 'text') {
1316
+ return { type: 'text', content: part.content }
802
1317
  }
803
- }
804
- if (part.type === 'tool-result') {
805
- return {
806
- type: 'tool-result',
807
- toolCallId: part.toolCallId,
808
- content: part.content,
809
- state: part.state,
810
- error: part.error,
1318
+ if (part.type === 'tool-call') {
1319
+ return {
1320
+ type: 'tool-call',
1321
+ toolCallId: part.id,
1322
+ toolName: part.name,
1323
+ arguments: part.arguments,
1324
+ state: part.state,
1325
+ output: part.output,
1326
+ approval: part.approval,
1327
+ content: part.approval
1328
+ ? JSON.stringify(part.approval)
1329
+ : undefined,
1330
+ }
811
1331
  }
812
- }
813
- if (part.type === 'thinking') {
814
- return {
815
- type: 'thinking',
816
- content: part.content,
1332
+ if (part.type === 'tool-result') {
1333
+ return {
1334
+ type: 'tool-result',
1335
+ toolCallId: part.toolCallId,
1336
+ content: part.content,
1337
+ state: part.state,
1338
+ error: part.error,
1339
+ }
817
1340
  }
818
- }
819
- // Handle multimodal parts (image, audio, video)
820
- // These have a source property instead of content
821
- if (
822
- part.type === 'image' ||
823
- part.type === 'audio' ||
824
- part.type === 'video'
825
- ) {
826
- return {
827
- type: part.type,
828
- source: part.source,
829
- metadata: part.metadata,
1341
+ if (part.type === 'thinking') {
1342
+ return {
1343
+ type: 'thinking',
1344
+ content: part.content,
1345
+ }
830
1346
  }
831
- }
832
- // Fallback for any unknown part types - skip them
833
- return null
834
- })
835
- .filter((part): part is MessagePart => part !== null)
1347
+ if (part.type === 'structured-output') {
1348
+ return {
1349
+ type: 'structured-output',
1350
+ status: part.status,
1351
+ raw: part.raw,
1352
+ partial: part.partial,
1353
+ data: part.data,
1354
+ reasoning: part.reasoning,
1355
+ errorMessage: part.errorMessage,
1356
+ }
1357
+ }
1358
+ // Handle multimodal parts (image, audio, video)
1359
+ // These have a source property instead of content
1360
+ if (
1361
+ part.type === 'image' ||
1362
+ part.type === 'audio' ||
1363
+ part.type === 'video'
1364
+ ) {
1365
+ return {
1366
+ type: part.type,
1367
+ source: part.source,
1368
+ metadata: part.metadata,
1369
+ }
1370
+ }
1371
+ // Fallback for any unknown part types - skip them
1372
+ return null
1373
+ })
1374
+ .filter((part): part is MessagePart => part !== null) ?? []
836
1375
 
837
- const toolCalls = e.payload.toolCalls?.map((toolCall) => ({
1376
+ const toolCallsFromPayload = e.payload.toolCalls?.map((toolCall) => ({
838
1377
  id: toolCall.id,
839
1378
  name: toolCall.function.name,
840
1379
  arguments: toolCall.function.arguments,
841
1380
  state: 'input-complete',
842
1381
  }))
843
-
844
- const source = e.payload.source ?? (clientId ? 'client' : 'server')
1382
+ const toolCallsFromParts = parts
1383
+ .filter((part) => part.type === 'tool-call')
1384
+ .map((part) => ({
1385
+ id: part.toolCallId ?? `${messageId}:${part.toolName ?? 'tool'}`,
1386
+ name: part.toolName ?? 'tool',
1387
+ arguments: part.arguments ?? stringifyToolArguments(part.output),
1388
+ state: part.state ?? 'input-complete',
1389
+ ...(part.output !== undefined ? { result: part.output } : {}),
1390
+ ...(part.approval?.needsApproval !== undefined
1391
+ ? { approvalRequired: part.approval.needsApproval }
1392
+ : {}),
1393
+ ...(part.approval?.id ? { approvalId: part.approval.id } : {}),
1394
+ ...(part.approval?.approved !== undefined
1395
+ ? { approvalApproved: part.approval.approved }
1396
+ : {}),
1397
+ }))
1398
+ const toolCalls =
1399
+ toolCallsFromPayload && toolCallsFromPayload.length > 0
1400
+ ? toolCallsFromPayload
1401
+ : toolCallsFromParts.length > 0
1402
+ ? toolCallsFromParts
1403
+ : undefined
845
1404
 
846
1405
  if (role === 'user' && conv.type === 'client' && source === 'server') {
847
1406
  return
848
1407
  }
849
1408
 
850
- // Skip empty assistant messages from client - the real content comes from server
851
1409
  if (
852
- role === 'assistant' &&
853
- source === 'client' &&
854
- !content &&
855
- (!toolCalls || toolCalls.length === 0)
1410
+ shouldSkipClientAssistantPlaceholder({
1411
+ role,
1412
+ source,
1413
+ content,
1414
+ toolCalls,
1415
+ parts,
1416
+ })
856
1417
  ) {
857
1418
  return
858
1419
  }
@@ -928,7 +1489,10 @@ export const AIProvider: ParentComponent = (props) => {
928
1489
 
929
1490
  if (existingIndex >= 0) return
930
1491
 
931
- const source = e.payload.source ?? (clientId ? 'client' : 'server')
1492
+ const source = normalizeMessageSource(
1493
+ e.payload.source,
1494
+ clientId ? 'client' : 'server',
1495
+ )
932
1496
 
933
1497
  if (conv.type === 'client' && source === 'server') {
934
1498
  return
@@ -1003,8 +1567,6 @@ export const AIProvider: ParentComponent = (props) => {
1003
1567
  }),
1004
1568
  )
1005
1569
 
1006
- // ============= Tool Events =============
1007
-
1008
1570
  cleanupFns.push(
1009
1571
  aiEventClient.on('tools:result:added', (e) => {
1010
1572
  const {
@@ -1068,31 +1630,59 @@ export const AIProvider: ParentComponent = (props) => {
1068
1630
 
1069
1631
  cleanupFns.push(
1070
1632
  aiEventClient.on('tools:approval:responded', (e) => {
1071
- const { clientId, toolCallId, approved } = e.payload
1633
+ const {
1634
+ clientId,
1635
+ threadId,
1636
+ streamId,
1637
+ toolCallId,
1638
+ approvalId,
1639
+ approved,
1640
+ timestamp,
1641
+ } = e.payload
1072
1642
 
1073
- if (!clientId) return
1074
- if (!state.conversations[clientId]) return
1643
+ const conversationIds = getToolEventConversationIds({
1644
+ clientId,
1645
+ threadId,
1646
+ streamId,
1647
+ toolCallId,
1648
+ approvalId,
1649
+ })
1075
1650
 
1076
- const conv = state.conversations[clientId]
1651
+ for (const conversationId of conversationIds) {
1652
+ const conv = state.conversations[conversationId]
1653
+ if (!conv) continue
1077
1654
 
1078
- for (
1079
- let messageIndex = conv.messages.length - 1;
1080
- messageIndex >= 0;
1081
- messageIndex--
1082
- ) {
1083
- const message = conv.messages[messageIndex]
1084
- if (!message?.toolCalls) continue
1655
+ const location = findToolCallLocation(conversationId, {
1656
+ toolCallId,
1657
+ approvalId,
1658
+ })
1659
+ if (!location) continue
1085
1660
 
1086
- const toolCallIndex = message.toolCalls.findIndex(
1087
- (t: ToolCall) => t.id === toolCallId,
1088
- )
1089
- if (toolCallIndex >= 0) {
1090
- updateToolCall(clientId, messageIndex, toolCallIndex, {
1661
+ updateToolCall(
1662
+ conversationId,
1663
+ location.messageIndex,
1664
+ location.toolCallIndex,
1665
+ {
1091
1666
  state: approved ? 'approved' : 'denied',
1092
1667
  approvalRequired: false,
1093
- })
1094
- return
1668
+ approvalId,
1669
+ approvalApproved: approved,
1670
+ },
1671
+ )
1672
+
1673
+ const message = conv.messages[location.messageIndex]
1674
+ const chunk: Chunk = {
1675
+ id: `chunk-${Date.now()}-${Math.random()}`,
1676
+ type: 'approval',
1677
+ ...(message?.id ? { messageId: message.id } : {}),
1678
+ toolCallId,
1679
+ approvalId,
1680
+ approved,
1681
+ timestamp,
1682
+ chunkCount: 1,
1095
1683
  }
1684
+
1685
+ addChunkToMessage(conversationId, chunk)
1096
1686
  }
1097
1687
  }),
1098
1688
  )
@@ -1113,12 +1703,40 @@ export const AIProvider: ParentComponent = (props) => {
1113
1703
  clientId ||
1114
1704
  (streamId ? streamToConversation.get(streamId) : undefined)
1115
1705
  if (!conversationId || !state.conversations[conversationId]) return
1706
+ if (clientId && streamId) {
1707
+ streamToConversation.set(streamId, clientId)
1708
+ }
1116
1709
 
1117
1710
  const conv = state.conversations[conversationId]
1118
1711
  const messageIndex = conv.messages.findIndex(
1119
1712
  (m: Message) => m.id === messageId,
1120
1713
  )
1121
- if (messageIndex === -1) return
1714
+ if (messageIndex === -1) {
1715
+ const source = normalizeMessageSource(
1716
+ e.payload.source,
1717
+ clientId ? 'client' : 'server',
1718
+ )
1719
+ addMessage(
1720
+ conversationId,
1721
+ createClientToolCallMessage({
1722
+ messageId,
1723
+ toolCallId,
1724
+ toolName,
1725
+ arguments: args,
1726
+ state: toolCallState,
1727
+ timestamp: e.payload.timestamp,
1728
+ source,
1729
+ ...(e.payload.requestId
1730
+ ? { requestId: e.payload.requestId }
1731
+ : {}),
1732
+ }),
1733
+ )
1734
+ updateConversation(conversationId, {
1735
+ status: 'active',
1736
+ hasChat: true,
1737
+ })
1738
+ return
1739
+ }
1122
1740
 
1123
1741
  const message = conv.messages[messageIndex]
1124
1742
  if (!message) return
@@ -1253,13 +1871,146 @@ export const AIProvider: ParentComponent = (props) => {
1253
1871
  }),
1254
1872
  )
1255
1873
 
1256
- // ============= Stream Events =============
1874
+ const recordStructuredOutput = (payload: {
1875
+ clientId?: string
1876
+ threadId?: string
1877
+ runId?: string
1878
+ streamId: string
1879
+ messageId: string
1880
+ requestId?: string
1881
+ timestamp: number
1882
+ source?: string
1883
+ status: 'streaming' | 'complete' | 'error'
1884
+ raw?: string
1885
+ partial?: unknown
1886
+ data?: unknown
1887
+ reasoning?: string
1888
+ errorMessage?: string
1889
+ delta?: string
1890
+ }) => {
1891
+ const {
1892
+ clientId,
1893
+ threadId,
1894
+ runId,
1895
+ streamId,
1896
+ messageId,
1897
+ requestId,
1898
+ timestamp,
1899
+ } = payload
1900
+
1901
+ if (clientId && streamId) {
1902
+ streamToConversation.set(streamId, clientId)
1903
+ }
1904
+
1905
+ const source = normalizeMessageSource(
1906
+ payload.source,
1907
+ clientId ? 'client' : 'server',
1908
+ )
1909
+ const conversationIds = getStructuredOutputConversationIds({
1910
+ clientId,
1911
+ threadId,
1912
+ streamId,
1913
+ messageId,
1914
+ })
1915
+
1916
+ for (const conversationId of conversationIds) {
1917
+ if (!state.conversations[conversationId]) {
1918
+ getOrCreateConversation(
1919
+ conversationId,
1920
+ conversationId === clientId && source === 'client'
1921
+ ? 'client'
1922
+ : 'server',
1923
+ conversationId === clientId && source === 'client'
1924
+ ? `Client Chat (${conversationId.substring(0, 8)})`
1925
+ : `Server Chat (${conversationId.substring(0, 8)})`,
1926
+ )
1927
+ }
1928
+
1929
+ attachRunToConversation(conversationId, runId)
1930
+ const messageIndex = upsertStructuredOutputPart(conversationId, {
1931
+ messageId,
1932
+ timestamp,
1933
+ source:
1934
+ conversationId === clientId && source === 'client'
1935
+ ? 'client'
1936
+ : 'server',
1937
+ ...(requestId ? { requestId } : {}),
1938
+ status: payload.status,
1939
+ raw: payload.raw ?? '',
1940
+ ...(payload.partial !== undefined
1941
+ ? { partial: payload.partial }
1942
+ : {}),
1943
+ ...(payload.data !== undefined ? { data: payload.data } : {}),
1944
+ ...(payload.reasoning !== undefined
1945
+ ? { reasoning: payload.reasoning }
1946
+ : {}),
1947
+ ...(payload.errorMessage !== undefined
1948
+ ? { errorMessage: payload.errorMessage }
1949
+ : {}),
1950
+ })
1951
+
1952
+ const chunk: Chunk = {
1953
+ id: `chunk-structured-${messageId}-${Date.now()}-${Math.random()}`,
1954
+ type: 'structured_output',
1955
+ messageId,
1956
+ timestamp,
1957
+ chunkCount: 1,
1958
+ structuredStatus: payload.status,
1959
+ raw: payload.raw ?? '',
1960
+ ...(payload.partial !== undefined
1961
+ ? { partial: payload.partial }
1962
+ : {}),
1963
+ ...(payload.data !== undefined ? { data: payload.data } : {}),
1964
+ ...(payload.reasoning !== undefined
1965
+ ? { reasoning: payload.reasoning }
1966
+ : {}),
1967
+ ...(payload.errorMessage !== undefined
1968
+ ? { errorMessage: payload.errorMessage }
1969
+ : {}),
1970
+ ...(payload.delta !== undefined ? { delta: payload.delta } : {}),
1971
+ }
1972
+
1973
+ const conv = state.conversations[conversationId]
1974
+ if (conv?.type === 'client' && messageIndex !== undefined) {
1975
+ queueMessageChunk(conversationId, messageIndex, chunk)
1976
+ } else if (conv?.type === 'client') {
1977
+ addChunkToMessage(conversationId, chunk)
1978
+ } else {
1979
+ addChunk(conversationId, chunk)
1980
+ }
1981
+
1982
+ attachMessageToLatestIteration(conversationId, messageId, requestId)
1983
+ updateConversation(conversationId, {
1984
+ status: payload.status === 'error' ? 'error' : 'active',
1985
+ hasChat: true,
1986
+ })
1987
+ }
1988
+ }
1989
+
1990
+ cleanupFns.push(
1991
+ aiEventClient.on('structured-output:started', (e) => {
1992
+ recordStructuredOutput(e.payload)
1993
+ }),
1994
+ aiEventClient.on('structured-output:updated', (e) => {
1995
+ recordStructuredOutput(e.payload)
1996
+ }),
1997
+ aiEventClient.on('structured-output:completed', (e) => {
1998
+ recordStructuredOutput(e.payload)
1999
+ }),
2000
+ aiEventClient.on('structured-output:errored', (e) => {
2001
+ recordStructuredOutput(e.payload)
2002
+ }),
2003
+ )
1257
2004
 
1258
2005
  cleanupFns.push(
1259
2006
  aiEventClient.on('text:chunk:content', (e) => {
1260
2007
  const streamId = e.payload.streamId
1261
- const conversationId = streamToConversation.get(streamId)
2008
+ const clientId = e.payload.clientId
2009
+ const conversationId = clientId || streamToConversation.get(streamId)
1262
2010
  if (!conversationId) return
2011
+ if (clientId) {
2012
+ streamToConversation.set(streamId, clientId)
2013
+ }
1263
2014
 
1264
2015
  const chunk: Chunk = {
1265
2016
  id: `chunk-${Date.now()}-${Math.random()}`,
@@ -1299,8 +2050,12 @@ export const AIProvider: ParentComponent = (props) => {
1299
2050
  cleanupFns.push(
1300
2051
  aiEventClient.on('text:chunk:tool-call', (e) => {
1301
2052
  const streamId = e.payload.streamId
1302
- const conversationId = streamToConversation.get(streamId)
2053
+ const clientId = e.payload.clientId
2054
+ const conversationId = clientId || streamToConversation.get(streamId)
1303
2055
  if (!conversationId) return
2056
+ if (clientId) {
2057
+ streamToConversation.set(streamId, clientId)
2058
+ }
1304
2059
 
1305
2060
  const chunk: Chunk = {
1306
2061
  id: `chunk-${Date.now()}-${Math.random()}`,
@@ -1366,8 +2121,12 @@ export const AIProvider: ParentComponent = (props) => {
1366
2121
  cleanupFns.push(
1367
2122
  aiEventClient.on('text:chunk:tool-result', (e) => {
1368
2123
  const streamId = e.payload.streamId
1369
- const conversationId = streamToConversation.get(streamId)
2124
+ const clientId = e.payload.clientId
2125
+ const conversationId = clientId || streamToConversation.get(streamId)
1370
2126
  if (!conversationId) return
2127
+ if (clientId) {
2128
+ streamToConversation.set(streamId, clientId)
2129
+ }
1371
2130
 
1372
2131
  const chunk: Chunk = {
1373
2132
  id: `chunk-${Date.now()}-${Math.random()}`,
@@ -1415,8 +2174,12 @@ export const AIProvider: ParentComponent = (props) => {
1415
2174
  cleanupFns.push(
1416
2175
  aiEventClient.on('text:chunk:thinking', (e) => {
1417
2176
  const streamId = e.payload.streamId
1418
- const conversationId = streamToConversation.get(streamId)
2177
+ const clientId = e.payload.clientId
2178
+ const conversationId = clientId || streamToConversation.get(streamId)
1419
2179
  if (!conversationId) return
2180
+ if (clientId) {
2181
+ streamToConversation.set(streamId, clientId)
2182
+ }
1420
2183
 
1421
2184
  const chunk: Chunk = {
1422
2185
  id: `chunk-${Date.now()}-${Math.random()}`,
@@ -1457,8 +2220,12 @@ export const AIProvider: ParentComponent = (props) => {
1457
2220
  cleanupFns.push(
1458
2221
  aiEventClient.on('text:chunk:done', (e) => {
1459
2222
  const streamId = e.payload.streamId
1460
- const conversationId = streamToConversation.get(streamId)
2223
+ const clientId = e.payload.clientId
2224
+ const conversationId = clientId || streamToConversation.get(streamId)
1461
2225
  if (!conversationId) return
2226
+ if (clientId) {
2227
+ streamToConversation.set(streamId, clientId)
2228
+ }
1462
2229
 
1463
2230
  const chunk: Chunk = {
1464
2231
  id: `chunk-${Date.now()}-${Math.random()}`,
@@ -1540,8 +2307,12 @@ export const AIProvider: ParentComponent = (props) => {
1540
2307
  cleanupFns.push(
1541
2308
  aiEventClient.on('text:chunk:error', (e) => {
1542
2309
  const streamId = e.payload.streamId
1543
- const conversationId = streamToConversation.get(streamId)
2310
+ const clientId = e.payload.clientId
2311
+ const conversationId = clientId || streamToConversation.get(streamId)
1544
2312
  if (!conversationId) return
2313
+ if (clientId) {
2314
+ streamToConversation.set(streamId, clientId)
2315
+ }
1545
2316
 
1546
2317
  const chunk: Chunk = {
1547
2318
  id: `chunk-${Date.now()}-${Math.random()}`,
@@ -1613,62 +2384,164 @@ export const AIProvider: ParentComponent = (props) => {
1613
2384
  approvalId,
1614
2385
  timestamp,
1615
2386
  clientId,
2387
+ threadId,
1616
2388
  } = e.payload
1617
2389
 
1618
- const conversationId =
1619
- clientId || streamToConversation.get(streamId) || ''
1620
- if (!conversationId) return
1621
-
1622
- const conv = state.conversations[conversationId]
1623
- if (!conv) return
2390
+ const source = normalizeMessageSource(
2391
+ e.payload.source,
2392
+ clientId ? 'client' : 'server',
2393
+ )
2394
+ if (clientId && streamId) {
2395
+ streamToConversation.set(streamId, clientId)
2396
+ }
1624
2397
 
1625
- const chunk: Chunk = {
1626
- id: `chunk-${Date.now()}-${Math.random()}`,
1627
- type: 'approval',
1628
- messageId: messageId,
2398
+ const conversationIds = getToolEventConversationIds({
2399
+ clientId,
2400
+ threadId,
2401
+ streamId,
1629
2402
  toolCallId,
1630
- toolName,
1631
2403
  approvalId,
1632
- input,
1633
- timestamp,
1634
- chunkCount: 1,
1635
- }
1636
-
1637
- if (conv.type === 'client') {
1638
- addChunkToMessage(conversationId, chunk)
1639
- } else {
1640
- addChunk(conversationId, chunk)
1641
- }
1642
-
1643
- for (let i = conv.messages.length - 1; i >= 0; i--) {
1644
- const message = conv.messages[i]
1645
- if (!message) continue
2404
+ })
1646
2405
 
1647
- if (message.role === 'assistant' && message.toolCalls) {
1648
- const toolCallIndex = message.toolCalls.findIndex(
1649
- (t: ToolCall) => t.id === toolCallId,
2406
+ for (const conversationId of conversationIds) {
2407
+ if (!state.conversations[conversationId]) {
2408
+ getOrCreateConversation(
2409
+ conversationId,
2410
+ conversationId === clientId && source === 'client'
2411
+ ? 'client'
2412
+ : 'server',
2413
+ conversationId === clientId && source === 'client'
2414
+ ? `Client Chat (${conversationId.substring(0, 8)})`
2415
+ : `Server Chat (${conversationId.substring(0, 8)})`,
1650
2416
  )
1651
- if (toolCallIndex >= 0) {
1652
- updateToolCall(conversationId, i, toolCallIndex, {
2417
+ }
2418
+
2419
+ let resolvedMessageId = messageId
2420
+ const location = findToolCallLocation(conversationId, { toolCallId })
2421
+ if (location) {
2422
+ updateToolCall(
2423
+ conversationId,
2424
+ location.messageIndex,
2425
+ location.toolCallIndex,
2426
+ {
1653
2427
  approvalRequired: true,
1654
2428
  approvalId,
1655
2429
  state: 'approval-requested',
1656
- })
1657
- return
1658
- }
2430
+ },
2431
+ )
2432
+ resolvedMessageId =
2433
+ state.conversations[conversationId]?.messages[
2434
+ location.messageIndex
2435
+ ]?.id ?? messageId
2436
+ } else {
2437
+ resolvedMessageId = messageId || `approval-message-${toolCallId}`
2438
+ addMessage(
2439
+ conversationId,
2440
+ createClientToolCallMessage({
2441
+ messageId: resolvedMessageId,
2442
+ toolCallId,
2443
+ toolName,
2444
+ arguments: stringifyToolArguments(input),
2445
+ state: 'approval-requested',
2446
+ timestamp,
2447
+ source:
2448
+ conversationId === clientId && source === 'client'
2449
+ ? 'client'
2450
+ : 'server',
2451
+ approvalRequired: true,
2452
+ approvalId,
2453
+ }),
2454
+ )
2455
+ }
2456
+
2457
+ const chunk: Chunk = {
2458
+ id: `chunk-${Date.now()}-${Math.random()}`,
2459
+ type: 'approval',
2460
+ ...(resolvedMessageId ? { messageId: resolvedMessageId } : {}),
2461
+ toolCallId,
2462
+ toolName,
2463
+ approvalId,
2464
+ input,
2465
+ timestamp,
2466
+ chunkCount: 1,
1659
2467
  }
2468
+
2469
+ if (state.conversations[conversationId]?.type === 'client') {
2470
+ addChunkToMessage(conversationId, chunk)
2471
+ } else {
2472
+ addChunk(conversationId, chunk)
2473
+ }
2474
+ }
2475
+
2476
+ if (conversationIds.length === 0) {
2477
+ const fallbackConversationId = clientId || threadId
2478
+ if (!fallbackConversationId) return
2479
+
2480
+ getOrCreateConversation(
2481
+ fallbackConversationId,
2482
+ clientId ? 'client' : 'server',
2483
+ clientId
2484
+ ? `Client Chat (${fallbackConversationId.substring(0, 8)})`
2485
+ : `Server Chat (${fallbackConversationId.substring(0, 8)})`,
2486
+ )
2487
+ const resolvedMessageId =
2488
+ messageId || `approval-message-${toolCallId}`
2489
+ addMessage(
2490
+ fallbackConversationId,
2491
+ createClientToolCallMessage({
2492
+ messageId: resolvedMessageId,
2493
+ toolCallId,
2494
+ toolName,
2495
+ arguments: stringifyToolArguments(input),
2496
+ state: 'approval-requested',
2497
+ timestamp,
2498
+ source: clientId ? 'client' : 'server',
2499
+ approvalRequired: true,
2500
+ approvalId,
2501
+ }),
2502
+ )
2503
+ addChunkToMessage(fallbackConversationId, {
2504
+ id: `chunk-${Date.now()}-${Math.random()}`,
2505
+ type: 'approval',
2506
+ messageId: resolvedMessageId,
2507
+ toolCallId,
2508
+ toolName,
2509
+ approvalId,
2510
+ input,
2511
+ timestamp,
2512
+ chunkCount: 1,
2513
+ })
1660
2514
  }
1661
2515
  }),
1662
2516
  )
1663
2517
 
1664
- // ============= Chat Events (for usage tracking) =============
1665
-
1666
2518
  cleanupFns.push(
1667
2519
  aiEventClient.on('text:request:started', (e) => {
1668
2520
  const streamId = e.payload.streamId
1669
2521
  const model = e.payload.model
1670
2522
  const provider = e.payload.provider
1671
2523
  const clientId = e.payload.clientId
2524
+ const source = normalizeMessageSource(e.payload.source, 'server')
2525
+ const threadConversationId =
2526
+ source === 'server' ? (e.payload.threadId ?? clientId) : undefined
2527
+
2528
+ if (threadConversationId) {
2529
+ getOrCreateConversation(
2530
+ threadConversationId,
2531
+ 'server',
2532
+ `Server Chat (${threadConversationId.substring(0, 8)})`,
2533
+ )
2534
+ streamToConversation.set(streamId, threadConversationId)
2535
+ requestToConversation.set(e.payload.requestId, threadConversationId)
2536
+ updateConversation(threadConversationId, {
2537
+ status: 'active',
2538
+ ...e.payload,
2539
+ systemPrompts: e.payload.systemPrompts,
2540
+ hasChat: true,
2541
+ })
2542
+ attachRunToConversation(threadConversationId, e.payload.runId)
2543
+ return
2544
+ }
1672
2545
 
1673
2546
  if (clientId && state.conversations[clientId]) {
1674
2547
  streamToConversation.set(streamId, clientId)
@@ -1679,6 +2552,7 @@ export const AIProvider: ParentComponent = (props) => {
1679
2552
  systemPrompts: e.payload.systemPrompts,
1680
2553
  hasChat: true,
1681
2554
  })
2555
+ attachRunToConversation(clientId, e.payload.runId)
1682
2556
  return
1683
2557
  }
1684
2558
 
@@ -1694,6 +2568,7 @@ export const AIProvider: ParentComponent = (props) => {
1694
2568
  systemPrompts: e.payload.systemPrompts,
1695
2569
  hasChat: true,
1696
2570
  })
2571
+ attachRunToConversation(activeClient.id, e.payload.runId)
1697
2572
  } else {
1698
2573
  const existingServerConv = Object.values(state.conversations).find(
1699
2574
  (c) => c.type === 'server' && c.model === model,
@@ -1711,6 +2586,7 @@ export const AIProvider: ParentComponent = (props) => {
1711
2586
  systemPrompts: e.payload.systemPrompts,
1712
2587
  hasChat: true,
1713
2588
  })
2589
+ attachRunToConversation(existingServerConv.id, e.payload.runId)
1714
2590
  } else {
1715
2591
  const serverId = `server-${model}`
1716
2592
  getOrCreateConversation(serverId, 'server', `${model} Server`)
@@ -1721,6 +2597,7 @@ export const AIProvider: ParentComponent = (props) => {
1721
2597
  systemPrompts: e.payload.systemPrompts,
1722
2598
  hasChat: true,
1723
2599
  })
2600
+ attachRunToConversation(serverId, e.payload.runId)
1724
2601
  }
1725
2602
  }
1726
2603
  }),
@@ -1790,8 +2667,6 @@ export const AIProvider: ParentComponent = (props) => {
1790
2667
  }),
1791
2668
  )
1792
2669
 
1793
- // ============= Iteration Events =============
1794
-
1795
2670
  cleanupFns.push(
1796
2671
  aiEventClient.on('text:iteration:started', (e) => {
1797
2672
  const { requestId, streamId, clientId, iteration, messageId } =
@@ -1901,8 +2776,6 @@ export const AIProvider: ParentComponent = (props) => {
1901
2776
  }),
1902
2777
  )
1903
2778
 
1904
- // ============= Middleware Events =============
1905
-
1906
2779
  /** Find the latest iteration for a given requestId, or the very latest iteration as fallback */
1907
2780
  function findLatestIterationIndex(
1908
2781
  conv: Conversation,
@@ -2026,8 +2899,6 @@ export const AIProvider: ParentComponent = (props) => {
2026
2899
  }),
2027
2900
  )
2028
2901
 
2029
- // ============= Summarize Events =============
2030
-
2031
2902
  cleanupFns.push(
2032
2903
  aiEventClient.on('summarize:request:started', (e) => {
2033
2904
  const { requestId, model, inputLength, timestamp, clientId } = e.payload
@@ -2105,8 +2976,6 @@ export const AIProvider: ParentComponent = (props) => {
2105
2976
  }),
2106
2977
  )
2107
2978
 
2108
- // ============= Image Events =============
2109
-
2110
2979
  cleanupFns.push(
2111
2980
  aiEventClient.on('image:request:started', (e) => {
2112
2981
  const { requestId, clientId, timestamp } = e.payload
@@ -2176,7 +3045,97 @@ export const AIProvider: ParentComponent = (props) => {
2176
3045
  }),
2177
3046
  )
2178
3047
 
2179
- // ============= Speech Events =============
3048
+ cleanupFns.push(
3049
+ aiEventClient.on('audio:request:started', (e) => {
3050
+ const { requestId, clientId, timestamp } = e.payload
3051
+
3052
+ let conversationId = clientId
3053
+ if (!conversationId || !state.conversations[conversationId]) {
3054
+ conversationId = `audio-${requestId}`
3055
+ getOrCreateConversation(
3056
+ conversationId,
3057
+ 'server',
3058
+ `Audio (${requestId.substring(0, 8)})`,
3059
+ )
3060
+ }
3061
+
3062
+ addActivityEvent(conversationId, 'audioEvents', {
3063
+ id: requestId,
3064
+ name: 'audio:request:started',
3065
+ timestamp,
3066
+ payload: e.payload,
3067
+ })
3068
+ }),
3069
+ )
3070
+
3071
+ cleanupFns.push(
3072
+ aiEventClient.on('audio:request:completed', (e) => {
3073
+ const { requestId, clientId, timestamp } = e.payload
3074
+
3075
+ let conversationId = clientId
3076
+ if (!conversationId || !state.conversations[conversationId]) {
3077
+ conversationId = `audio-${requestId}`
3078
+ getOrCreateConversation(
3079
+ conversationId,
3080
+ 'server',
3081
+ `Audio (${requestId.substring(0, 8)})`,
3082
+ )
3083
+ }
3084
+
3085
+ addActivityEvent(conversationId, 'audioEvents', {
3086
+ id: requestId,
3087
+ name: 'audio:request:completed',
3088
+ timestamp,
3089
+ payload: e.payload,
3090
+ })
3091
+ }),
3092
+ )
3093
+
3094
+ cleanupFns.push(
3095
+ aiEventClient.on('audio:request:error', (e) => {
3096
+ const { requestId, clientId, timestamp } = e.payload
3097
+
3098
+ let conversationId = clientId
3099
+ if (!conversationId || !state.conversations[conversationId]) {
3100
+ conversationId = `audio-${requestId}`
3101
+ getOrCreateConversation(
3102
+ conversationId,
3103
+ 'server',
3104
+ `Audio (${requestId.substring(0, 8)})`,
3105
+ )
3106
+ }
3107
+
3108
+ addActivityEvent(conversationId, 'audioEvents', {
3109
+ id: requestId,
3110
+ name: 'audio:request:error',
3111
+ timestamp,
3112
+ payload: e.payload,
3113
+ })
3114
+ }),
3115
+ )
3116
+
3117
+ cleanupFns.push(
3118
+ aiEventClient.on('audio:usage', (e) => {
3119
+ const { requestId, clientId, timestamp } = e.payload
3120
+
3121
+ let conversationId = clientId
3122
+ if (!conversationId || !state.conversations[conversationId]) {
3123
+ conversationId = `audio-${requestId}`
3124
+ getOrCreateConversation(
3125
+ conversationId,
3126
+ 'server',
3127
+ `Audio (${requestId.substring(0, 8)})`,
3128
+ )
3129
+ }
3130
+
3131
+ addActivityEvent(conversationId, 'audioEvents', {
3132
+ id: requestId,
3133
+ name: 'audio:usage',
3134
+ timestamp,
3135
+ payload: e.payload,
3136
+ })
3137
+ }),
3138
+ )
2180
3139
 
2181
3140
  cleanupFns.push(
2182
3141
  aiEventClient.on('speech:request:started', (e) => {
@@ -2224,6 +3183,29 @@ export const AIProvider: ParentComponent = (props) => {
2224
3183
  }),
2225
3184
  )
2226
3185
 
3186
+ cleanupFns.push(
3187
+ aiEventClient.on('speech:request:error', (e) => {
3188
+ const { requestId, clientId, timestamp } = e.payload
3189
+
3190
+ let conversationId = clientId
3191
+ if (!conversationId || !state.conversations[conversationId]) {
3192
+ conversationId = `speech-${requestId}`
3193
+ getOrCreateConversation(
3194
+ conversationId,
3195
+ 'server',
3196
+ `Speech (${requestId.substring(0, 8)})`,
3197
+ )
3198
+ }
3199
+
3200
+ addActivityEvent(conversationId, 'speechEvents', {
3201
+ id: requestId,
3202
+ name: 'speech:request:error',
3203
+ timestamp,
3204
+ payload: e.payload,
3205
+ })
3206
+ }),
3207
+ )
3208
+
2227
3209
  cleanupFns.push(
2228
3210
  aiEventClient.on('speech:usage', (e) => {
2229
3211
  const { requestId, clientId, timestamp } = e.payload
@@ -2247,8 +3229,6 @@ export const AIProvider: ParentComponent = (props) => {
2247
3229
  }),
2248
3230
  )
2249
3231
 
2250
- // ============= Transcription Events =============
2251
-
2252
3232
  cleanupFns.push(
2253
3233
  aiEventClient.on('transcription:request:started', (e) => {
2254
3234
  const { requestId, clientId, timestamp } = e.payload
@@ -2295,6 +3275,29 @@ export const AIProvider: ParentComponent = (props) => {
2295
3275
  }),
2296
3276
  )
2297
3277
 
3278
+ cleanupFns.push(
3279
+ aiEventClient.on('transcription:request:error', (e) => {
3280
+ const { requestId, clientId, timestamp } = e.payload
3281
+
3282
+ let conversationId = clientId
3283
+ if (!conversationId || !state.conversations[conversationId]) {
3284
+ conversationId = `transcription-${requestId}`
3285
+ getOrCreateConversation(
3286
+ conversationId,
3287
+ 'server',
3288
+ `Transcription (${requestId.substring(0, 8)})`,
3289
+ )
3290
+ }
3291
+
3292
+ addActivityEvent(conversationId, 'transcriptionEvents', {
3293
+ id: requestId,
3294
+ name: 'transcription:request:error',
3295
+ timestamp,
3296
+ payload: e.payload,
3297
+ })
3298
+ }),
3299
+ )
3300
+
2298
3301
  cleanupFns.push(
2299
3302
  aiEventClient.on('transcription:usage', (e) => {
2300
3303
  const { requestId, clientId, timestamp } = e.payload
@@ -2318,8 +3321,6 @@ export const AIProvider: ParentComponent = (props) => {
2318
3321
  }),
2319
3322
  )
2320
3323
 
2321
- // ============= Video Events =============
2322
-
2323
3324
  cleanupFns.push(
2324
3325
  aiEventClient.on('video:request:started', (e) => {
2325
3326
  const { requestId, clientId, timestamp } = e.payload
@@ -2403,6 +3404,11 @@ export const AIProvider: ParentComponent = (props) => {
2403
3404
  state,
2404
3405
  clearAllConversations,
2405
3406
  selectConversation,
3407
+ clearHooks,
3408
+ selectHook,
3409
+ saveToolFixture,
3410
+ deleteToolFixture,
3411
+ applyToolFixture,
2406
3412
  }
2407
3413
 
2408
3414
  return (