@tanstack/ai-client 0.29.0 → 0.29.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/devtools.ts CHANGED
@@ -8,6 +8,7 @@ import { DefaultChatClientEventEmitter } from './events'
8
8
  import type { AnyClientTool, StreamChunk } from '@tanstack/ai/client'
9
9
  import type {
10
10
  AIDevtoolsEventVisibility,
11
+ CompactionMessagePreview,
11
12
  MemoryScopeLite,
12
13
  } from '@tanstack/ai-event-client'
13
14
  import type {
@@ -33,6 +34,11 @@ export interface AIDevtoolsDisplayOptions {
33
34
  * payload of the `memory:state` CUSTOM chunk. Kept local so `ai-client` doesn't
34
35
  * depend on `ai-memory`; the memory middleware is the producer.
35
36
  */
37
+ interface SkillsStateEventValue {
38
+ catalog?: Array<{ name: string; description: string }>
39
+ activated?: Array<string>
40
+ }
41
+
36
42
  interface MemoryStateEventValue {
37
43
  scope: MemoryScopeLite
38
44
  adapter: string
@@ -55,6 +61,127 @@ interface MemoryStateEventValue {
55
61
  }
56
62
  }
57
63
 
64
+ function readCompactionBoundaryValue(rawValue: unknown): {
65
+ before?: number
66
+ after?: number
67
+ messagesBefore?: number
68
+ messagesAfter?: number
69
+ reusedCheckpoint?: boolean
70
+ maxTokens?: number
71
+ strategyKey?: string
72
+ durationMs?: number
73
+ } {
74
+ if (!rawValue || typeof rawValue !== 'object') return {}
75
+ return {
76
+ ...('before' in rawValue && typeof rawValue.before === 'number'
77
+ ? { before: rawValue.before }
78
+ : {}),
79
+ ...('after' in rawValue && typeof rawValue.after === 'number'
80
+ ? { after: rawValue.after }
81
+ : {}),
82
+ ...('messagesBefore' in rawValue &&
83
+ typeof rawValue.messagesBefore === 'number'
84
+ ? { messagesBefore: rawValue.messagesBefore }
85
+ : {}),
86
+ ...('messagesAfter' in rawValue &&
87
+ typeof rawValue.messagesAfter === 'number'
88
+ ? { messagesAfter: rawValue.messagesAfter }
89
+ : {}),
90
+ ...('reusedCheckpoint' in rawValue &&
91
+ typeof rawValue.reusedCheckpoint === 'boolean'
92
+ ? { reusedCheckpoint: rawValue.reusedCheckpoint }
93
+ : {}),
94
+ ...('maxTokens' in rawValue && typeof rawValue.maxTokens === 'number'
95
+ ? { maxTokens: rawValue.maxTokens }
96
+ : {}),
97
+ ...('strategyKey' in rawValue && typeof rawValue.strategyKey === 'string'
98
+ ? { strategyKey: rawValue.strategyKey }
99
+ : {}),
100
+ ...('durationMs' in rawValue && typeof rawValue.durationMs === 'number'
101
+ ? { durationMs: rawValue.durationMs }
102
+ : {}),
103
+ }
104
+ }
105
+
106
+ function readPreviewList(
107
+ value: unknown,
108
+ ): Array<CompactionMessagePreview> | undefined {
109
+ if (!Array.isArray(value)) return undefined
110
+ const previews: Array<CompactionMessagePreview> = []
111
+ for (const item of value) {
112
+ if (!item || typeof item !== 'object') continue
113
+ if (!('role' in item) || !('tokens' in item) || !('text' in item)) continue
114
+ if (
115
+ typeof item.role !== 'string' ||
116
+ typeof item.tokens !== 'number' ||
117
+ typeof item.text !== 'string'
118
+ ) {
119
+ continue
120
+ }
121
+ previews.push({
122
+ role: item.role,
123
+ tokens: item.tokens,
124
+ text: item.text,
125
+ })
126
+ }
127
+ return previews
128
+ }
129
+
130
+ function readCompactionStateValue(rawValue: unknown): {
131
+ before: number
132
+ after: number
133
+ messagesBefore: number
134
+ messagesAfter: number
135
+ reusedCheckpoint: boolean
136
+ maxTokens?: number
137
+ strategyKey?: string
138
+ dropped?: Array<CompactionMessagePreview>
139
+ result?: Array<CompactionMessagePreview>
140
+ } | null {
141
+ if (!rawValue || typeof rawValue !== 'object') return null
142
+ if (
143
+ !('before' in rawValue) ||
144
+ !('after' in rawValue) ||
145
+ !('messagesBefore' in rawValue) ||
146
+ !('messagesAfter' in rawValue)
147
+ ) {
148
+ return null
149
+ }
150
+ if (
151
+ typeof rawValue.before !== 'number' ||
152
+ typeof rawValue.after !== 'number' ||
153
+ typeof rawValue.messagesBefore !== 'number' ||
154
+ typeof rawValue.messagesAfter !== 'number'
155
+ ) {
156
+ return null
157
+ }
158
+ const reusedCheckpoint =
159
+ 'reusedCheckpoint' in rawValue && rawValue.reusedCheckpoint === true
160
+ const maxTokens =
161
+ 'maxTokens' in rawValue && typeof rawValue.maxTokens === 'number'
162
+ ? rawValue.maxTokens
163
+ : undefined
164
+ const strategyKey =
165
+ 'strategyKey' in rawValue && typeof rawValue.strategyKey === 'string'
166
+ ? rawValue.strategyKey
167
+ : undefined
168
+ const dropped =
169
+ 'dropped' in rawValue ? readPreviewList(rawValue.dropped) : undefined
170
+ const result =
171
+ 'result' in rawValue ? readPreviewList(rawValue.result) : undefined
172
+ return {
173
+ before: rawValue.before,
174
+ after: rawValue.after,
175
+ messagesBefore: rawValue.messagesBefore,
176
+ messagesAfter: rawValue.messagesAfter,
177
+ reusedCheckpoint,
178
+ ...(maxTokens !== undefined ? { maxTokens } : {}),
179
+ ...(strategyKey ? { strategyKey } : {}),
180
+ ...(dropped ? { dropped } : {}),
181
+ ...(result ? { result } : {}),
182
+ }
183
+ }
184
+
58
185
  export interface AIDevtoolsClientMetadata extends AIDevtoolsDisplayOptions {
59
186
  framework?: string
60
187
  hookName: string
@@ -448,6 +575,49 @@ function getActiveBridgeRegistry(): Map<string, ActiveDevtoolsBridge> {
448
575
  return registry
449
576
  }
450
577
 
578
+ /**
579
+ * `{...options}` turns `get hookId()` into a data property. Chat/generation
580
+ * clients mint `threadId` after construct (`ensureThreadId` on mount), so a
581
+ * spread would freeze `hookId: ''` and DevTools could never select the hook.
582
+ */
583
+ function withLiveClientIdentity<TSnapshot extends object>(
584
+ identity: Pick<
585
+ AIDevtoolsBridgeOptions<TSnapshot>,
586
+ | 'hookId'
587
+ | 'clientId'
588
+ | 'threadId'
589
+ | 'metadata'
590
+ | 'getTools'
591
+ | 'applyToolFixture'
592
+ >,
593
+ rest: Pick<AIDevtoolsBridgeOptions<TSnapshot>, 'getSnapshot'> &
594
+ Partial<
595
+ Pick<AIDevtoolsBridgeOptions<TSnapshot>, 'getTools' | 'applyToolFixture'>
596
+ >,
597
+ ): AIDevtoolsBridgeOptions<TSnapshot> {
598
+ return {
599
+ get hookId() {
600
+ return identity.hookId
601
+ },
602
+ get clientId() {
603
+ return identity.clientId
604
+ },
605
+ get threadId() {
606
+ return identity.threadId
607
+ },
608
+ metadata: identity.metadata,
609
+ getSnapshot: rest.getSnapshot,
610
+ ...(rest.getTools || identity.getTools
611
+ ? { getTools: rest.getTools ?? identity.getTools }
612
+ : {}),
613
+ ...(rest.applyToolFixture || identity.applyToolFixture
614
+ ? {
615
+ applyToolFixture: rest.applyToolFixture ?? identity.applyToolFixture,
616
+ }
617
+ : {}),
618
+ }
619
+ }
620
+
451
621
  export class ClientDevtoolsBridge<TSnapshot extends object> {
452
622
  protected readonly options: AIDevtoolsBridgeOptions<TSnapshot>
453
623
  private readonly unsubscribers: Array<Unsubscribe> = []
@@ -712,6 +882,10 @@ export class ClientDevtoolsBridge<TSnapshot extends object> {
712
882
  | 'memory:retrieve:started'
713
883
  | 'memory:retrieve:completed'
714
884
  | 'memory:snapshot'
885
+ | 'compaction:started'
886
+ | 'compaction:state'
887
+ | 'compaction:ended'
888
+ | 'skills:snapshot'
715
889
  | AIDevtoolsRunEventType,
716
890
  visibility: AIDevtoolsEventVisibility = 'client-state',
717
891
  context: { runId?: string } = {},
@@ -770,17 +944,29 @@ export class ChatDevtoolsBridge extends ClientDevtoolsBridge<AIDevtoolsChatSnaps
770
944
  private lastRunEventContext: ChatClientRunEventContext | undefined
771
945
  /** Last transported `memory:state` value, replayed when a panel opens. */
772
946
  private lastMemoryStateValue: unknown = null
947
+ /** Transported compaction CUSTOM events, replayed when a panel opens. */
948
+ private readonly lastCompactionEvents: Array<{
949
+ eventType: string
950
+ value: unknown
951
+ }> = []
952
+ /** Last transported `skills:state` value, replayed when a panel opens. */
953
+ private lastSkillsStateValue: unknown = null
773
954
 
774
955
  constructor(options: ChatDevtoolsBridgeOptions) {
775
- super({
776
- ...options,
777
- // Thunk defers `this.applyFixture` lookup until after `super` returns.
778
- applyToolFixture: (fixture) => this.applyFixture(fixture),
779
- })
956
+ super(
957
+ withLiveClientIdentity(options, {
958
+ getSnapshot: options.getSnapshot,
959
+ // Thunk defers `this.applyFixture` lookup until after `super` returns.
960
+ applyToolFixture: (fixture) => this.applyFixture(fixture),
961
+ }),
962
+ )
780
963
  this.chatOptions = options
781
964
  // Auto-attaches run/thread context and auto-emits a snapshot after each
782
965
  // event so callers can keep using `this.events.X(...)` with no context arg.
783
- this.events = new ChatDevtoolsAwareEventEmitter(options.clientId, this)
966
+ this.events = new ChatDevtoolsAwareEventEmitter(
967
+ () => options.clientId,
968
+ this,
969
+ )
784
970
  }
785
971
 
786
972
  // --- Stream / run context API -------------------------------------------
@@ -874,6 +1060,11 @@ export class ChatDevtoolsBridge extends ClientDevtoolsBridge<AIDevtoolsChatSnaps
874
1060
  this.emitMemoryState(rawValue)
875
1061
  }
876
1062
 
1063
+ recordSkillsState(rawValue: unknown): void {
1064
+ this.lastSkillsStateValue = rawValue
1065
+ this.emitSkillsState(rawValue)
1066
+ }
1067
+
877
1068
  /**
878
1069
  * Re-emit the browser-side `memory:*` events from a transported
879
1070
  * `memory:state` value. The devtools store consumes these to render the
@@ -926,10 +1117,78 @@ export class ChatDevtoolsBridge extends ClientDevtoolsBridge<AIDevtoolsChatSnaps
926
1117
  }
927
1118
  }
928
1119
 
1120
+ /**
1121
+ * Record a transported compaction CUSTOM event. Called from the chat
1122
+ * client's `onCustomEvent` handler so server-side compaction reaches the
1123
+ * browser DevTools panel.
1124
+ */
1125
+ recordCompactionEvent(eventType: string, rawValue: unknown): void {
1126
+ this.lastCompactionEvents.push({ eventType, value: rawValue })
1127
+ if (this.lastCompactionEvents.length > 60) {
1128
+ this.lastCompactionEvents.splice(0, this.lastCompactionEvents.length - 60)
1129
+ }
1130
+ this.emitCompactionEvent(eventType, rawValue)
1131
+ }
1132
+
1133
+ recordCompactionState(rawValue: unknown): void {
1134
+ this.recordCompactionEvent('compaction:state', rawValue)
1135
+ }
1136
+
1137
+ private emitCompactionEvent(eventType: string, rawValue: unknown): void {
1138
+ const runContext = this.currentRunId ? { runId: this.currentRunId } : {}
1139
+ if (eventType === 'compaction:started') {
1140
+ const value = readCompactionBoundaryValue(rawValue)
1141
+ emitAIDevtoolsEvent('compaction:started', {
1142
+ ...this.createEnvelope(
1143
+ 'compaction:started',
1144
+ 'client-state',
1145
+ runContext,
1146
+ ),
1147
+ ...value,
1148
+ })
1149
+ return
1150
+ }
1151
+ if (eventType === 'compaction:ended') {
1152
+ const value = readCompactionBoundaryValue(rawValue)
1153
+ emitAIDevtoolsEvent('compaction:ended', {
1154
+ ...this.createEnvelope('compaction:ended', 'client-state', runContext),
1155
+ ...value,
1156
+ })
1157
+ return
1158
+ }
1159
+ if (eventType === 'compaction:state') {
1160
+ const value = readCompactionStateValue(rawValue)
1161
+ if (!value) return
1162
+ emitAIDevtoolsEvent('compaction:state', {
1163
+ ...this.createEnvelope('compaction:state', 'client-state', runContext),
1164
+ ...value,
1165
+ })
1166
+ }
1167
+ }
1168
+
1169
+ private emitSkillsState(rawValue: unknown): void {
1170
+ if (!rawValue || typeof rawValue !== 'object') return
1171
+ const value = rawValue as SkillsStateEventValue
1172
+ const catalog = Array.isArray(value.catalog) ? value.catalog : []
1173
+ const activated = Array.isArray(value.activated) ? value.activated : []
1174
+ const runContext = this.currentRunId ? { runId: this.currentRunId } : {}
1175
+ emitAIDevtoolsEvent('skills:snapshot', {
1176
+ ...this.createEnvelope('skills:snapshot', 'client-state', runContext),
1177
+ catalog,
1178
+ activated,
1179
+ })
1180
+ }
1181
+
929
1182
  protected override onReplayState(): void {
930
1183
  if (this.lastMemoryStateValue != null) {
931
1184
  this.emitMemoryState(this.lastMemoryStateValue)
932
1185
  }
1186
+ for (const event of this.lastCompactionEvents) {
1187
+ this.emitCompactionEvent(event.eventType, event.value)
1188
+ }
1189
+ if (this.lastSkillsStateValue != null) {
1190
+ this.emitSkillsState(this.lastSkillsStateValue)
1191
+ }
933
1192
  }
934
1193
 
935
1194
  getCurrentRunEventContext(): ChatClientRunEventContext | undefined {
@@ -1410,10 +1669,11 @@ export class GenerationDevtoolsBridge<TOutput> extends ClientDevtoolsBridge<
1410
1669
  protected readonly getCoreState: () => GenerationDevtoolsCoreState<TOutput>
1411
1670
 
1412
1671
  constructor(options: GenerationDevtoolsBridgeOptions<TOutput>) {
1413
- super({
1414
- ...options,
1415
- getSnapshot: () => this.buildSnapshot(),
1416
- })
1672
+ super(
1673
+ withLiveClientIdentity(options, {
1674
+ getSnapshot: () => this.buildSnapshot(),
1675
+ }),
1676
+ )
1417
1677
  this.maxRuns = options.maxRuns ?? 20
1418
1678
  this.getCoreState = options.getCoreState
1419
1679
  }
@@ -1759,10 +2019,18 @@ export class VideoDevtoolsBridge<
1759
2019
  // so resolveStreamId() works without the chat client telling it.
1760
2020
  class ChatDevtoolsAwareEventEmitter extends DefaultChatClientEventEmitter {
1761
2021
  constructor(
1762
- clientId: string,
2022
+ private readonly getClientId: () => string,
1763
2023
  private readonly helper: ChatDevtoolsBridge,
1764
2024
  ) {
1765
- super(clientId)
2025
+ super(getClientId())
2026
+ }
2027
+
2028
+ protected override emitEvent(
2029
+ eventName: string,
2030
+ data?: Record<string, any>,
2031
+ ): void {
2032
+ this.clientId = this.getClientId()
2033
+ super.emitEvent(eventName, data)
1766
2034
  }
1767
2035
 
1768
2036
  private afterEmit(streamId?: string): void {