@tanstack/ai-client 0.28.0 → 0.29.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/esm/chat-client.d.ts +9 -0
- package/dist/esm/chat-client.js +60 -15
- package/dist/esm/chat-client.js.map +1 -1
- package/dist/esm/connection-adapters.js +17 -1
- package/dist/esm/connection-adapters.js.map +1 -1
- package/dist/esm/devtools-noop.d.ts +1 -0
- package/dist/esm/devtools-noop.js +1 -0
- package/dist/esm/devtools-noop.js.map +1 -1
- package/dist/esm/devtools.d.ts +5 -1
- package/dist/esm/devtools.js +54 -10
- package/dist/esm/devtools.js.map +1 -1
- package/dist/esm/message-date-normalizer.d.ts +3 -0
- package/dist/esm/message-date-normalizer.js +35 -0
- package/dist/esm/message-date-normalizer.js.map +1 -0
- package/dist/esm/storage-adapters.js +13 -1
- package/dist/esm/storage-adapters.js.map +1 -1
- package/dist/esm/types.d.ts +5 -0
- package/dist/esm/types.js.map +1 -1
- package/package.json +3 -3
- package/src/chat-client.ts +113 -15
- package/src/connection-adapters.ts +28 -2
- package/src/devtools-noop.ts +1 -0
- package/src/devtools.ts +98 -12
- package/src/message-date-normalizer.ts +29 -0
- package/src/storage-adapters.ts +13 -2
- package/src/types.ts +5 -0
package/src/chat-client.ts
CHANGED
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
} from './connection-adapters'
|
|
27
27
|
import { ChatPersistor } from './client-persistor'
|
|
28
28
|
import { ClearedStreamTracker } from './cleared-stream-tracker'
|
|
29
|
+
import { normalizeMessagesDates } from './message-date-normalizer'
|
|
29
30
|
import { InterruptManager } from './interrupt-manager'
|
|
30
31
|
import type {
|
|
31
32
|
AnyClientTool,
|
|
@@ -163,6 +164,20 @@ function resolveTransport(transport: {
|
|
|
163
164
|
throw new Error('ChatClient: either `connection` or `fetcher` is required.')
|
|
164
165
|
}
|
|
165
166
|
|
|
167
|
+
function connectionDrainsOnSend(connection: ConnectionAdapter): boolean {
|
|
168
|
+
return 'connect' in connection
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function isIntermediateToolTurn(chunk: StreamChunk): boolean {
|
|
172
|
+
if (chunk.type !== 'RUN_FINISHED') return false
|
|
173
|
+
if (chunk.outcome?.type === 'interrupt') return false
|
|
174
|
+
const extra = chunk as StreamChunk & { finishReason?: unknown }
|
|
175
|
+
if (extra.finishReason !== undefined) {
|
|
176
|
+
return extra.finishReason === 'tool_calls'
|
|
177
|
+
}
|
|
178
|
+
return tanstackMetadata(chunk)?.finishReason === 'tool_calls'
|
|
179
|
+
}
|
|
180
|
+
|
|
166
181
|
export interface NormalizedQueueConfig {
|
|
167
182
|
whenBusy: WhenBusy
|
|
168
183
|
drain: 'fifo' | 'batch'
|
|
@@ -415,8 +430,19 @@ export class ChatClient<
|
|
|
415
430
|
private continuationPending = false
|
|
416
431
|
private subscriptionAbortController: AbortController | null = null
|
|
417
432
|
private processingResolve: (() => void) | null = null
|
|
433
|
+
/**
|
|
434
|
+
* `connect()` adapters push the full HTTP body into the subscribe queue, then
|
|
435
|
+
* wait until that queue is idle. After `send()` returns, every chunk from this
|
|
436
|
+
* request has been processed. Subscribe/send sockets do not drain that way.
|
|
437
|
+
*/
|
|
438
|
+
private connectionDrainsOnSend = false
|
|
418
439
|
private errorReportedGeneration: number | null = null
|
|
419
440
|
private streamGeneration = 0
|
|
441
|
+
private continuationGeneration = 0
|
|
442
|
+
// Generation of the run that opened the current stream. Public
|
|
443
|
+
// `addToolResult` must use this, not the live counter: `stop()` increments
|
|
444
|
+
// the live counter, so a post-stop call would otherwise look current.
|
|
445
|
+
private streamContinuationGeneration = 0
|
|
420
446
|
// Tracks whether a queued checkForContinuation was skipped because
|
|
421
447
|
// continuationPending was true (chained approval scenario)
|
|
422
448
|
private continuationSkipped = false
|
|
@@ -512,7 +538,9 @@ export class ChatClient<
|
|
|
512
538
|
this.byokProvider = options.byokProvider
|
|
513
539
|
this.context = options.context
|
|
514
540
|
this.queueConfig = normalizeQueueOption(options.queue)
|
|
515
|
-
|
|
541
|
+
const transport = resolveTransport(options)
|
|
542
|
+
this.connectionDrainsOnSend = connectionDrainsOnSend(transport)
|
|
543
|
+
this.connection = normalizeConnectionAdapter(transport)
|
|
516
544
|
|
|
517
545
|
// Build client tools map
|
|
518
546
|
this.clientToolsRef = { current: new Map() }
|
|
@@ -737,6 +765,7 @@ export class ChatClient<
|
|
|
737
765
|
const clientTool = clientTools.get(args.toolName)
|
|
738
766
|
const executeFunc = clientTool?.execute
|
|
739
767
|
if (executeFunc) {
|
|
768
|
+
const continuationGeneration = this.continuationGeneration
|
|
740
769
|
// Capture the run context at execution-start so a tool whose
|
|
741
770
|
// result lands AFTER the originating run finishes still reports
|
|
742
771
|
// back against the originating run, not whatever run is
|
|
@@ -763,6 +792,7 @@ export class ChatClient<
|
|
|
763
792
|
state: 'output-available',
|
|
764
793
|
},
|
|
765
794
|
clientTool,
|
|
795
|
+
continuationGeneration,
|
|
766
796
|
runEventContext,
|
|
767
797
|
)
|
|
768
798
|
} catch (error: any) {
|
|
@@ -775,6 +805,7 @@ export class ChatClient<
|
|
|
775
805
|
errorText: error.message,
|
|
776
806
|
},
|
|
777
807
|
clientTool,
|
|
808
|
+
continuationGeneration,
|
|
778
809
|
runEventContext,
|
|
779
810
|
)
|
|
780
811
|
} finally {
|
|
@@ -820,6 +851,9 @@ export class ChatClient<
|
|
|
820
851
|
if (eventType === 'memory:state') {
|
|
821
852
|
this.devtoolsBridge.recordMemoryState(data)
|
|
822
853
|
}
|
|
854
|
+
if (eventType === 'skills:state') {
|
|
855
|
+
this.devtoolsBridge.recordSkillsState(data)
|
|
856
|
+
}
|
|
823
857
|
this.callbacksRef.current.onCustomEvent(eventType, data, context)
|
|
824
858
|
},
|
|
825
859
|
},
|
|
@@ -1017,7 +1051,7 @@ export class ChatClient<
|
|
|
1017
1051
|
// A send may have started while the fetch was in flight — don't stomp it.
|
|
1018
1052
|
if (this.isLoading || this.abortController) return
|
|
1019
1053
|
if (result.messages.length > 0) {
|
|
1020
|
-
this.processor.setMessages(result.messages)
|
|
1054
|
+
this.processor.setMessages(normalizeMessagesDates(result.messages))
|
|
1021
1055
|
}
|
|
1022
1056
|
if (result.interrupts && result.interrupts.pending.length > 0) {
|
|
1023
1057
|
// Pending interrupt = the thread is paused awaiting a human decision, so
|
|
@@ -1131,7 +1165,9 @@ export class ChatClient<
|
|
|
1131
1165
|
this.clearedStreamTracker.onSessionRunError()
|
|
1132
1166
|
}
|
|
1133
1167
|
this.setSessionGenerating(this.activeRunIds.size > 0)
|
|
1134
|
-
|
|
1168
|
+
const skipProcessingResolve =
|
|
1169
|
+
chunk.type === 'RUN_FINISHED' && isIntermediateToolTurn(chunk)
|
|
1170
|
+
if (options?.resolveProcessing !== false && !skipProcessingResolve) {
|
|
1135
1171
|
this.resolveProcessing()
|
|
1136
1172
|
}
|
|
1137
1173
|
}
|
|
@@ -1304,6 +1340,21 @@ export class ChatClient<
|
|
|
1304
1340
|
): Promise<boolean> {
|
|
1305
1341
|
const target = state ?? this.lastResume
|
|
1306
1342
|
if (!target) return Promise.resolve(false)
|
|
1343
|
+
return this.resumeInterruptsUnsafeForGeneration(
|
|
1344
|
+
resume,
|
|
1345
|
+
target,
|
|
1346
|
+
this.continuationGeneration,
|
|
1347
|
+
)
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
private resumeInterruptsUnsafeForGeneration(
|
|
1351
|
+
resume: Array<RunAgentResumeItem>,
|
|
1352
|
+
target: ChatResumeState,
|
|
1353
|
+
continuationGeneration: number,
|
|
1354
|
+
): Promise<boolean> {
|
|
1355
|
+
if (continuationGeneration !== this.continuationGeneration) {
|
|
1356
|
+
return Promise.resolve(false)
|
|
1357
|
+
}
|
|
1307
1358
|
// Auto-executed client tools resolve during the parent stream's
|
|
1308
1359
|
// `pendingToolExecutions` wait — while `isLoading` is still true.
|
|
1309
1360
|
// Defer the child continuation until that stream settles so we do not
|
|
@@ -1312,7 +1363,13 @@ export class ChatClient<
|
|
|
1312
1363
|
return new Promise<boolean>((resolve, reject) => {
|
|
1313
1364
|
this.queuePostStreamAction(async () => {
|
|
1314
1365
|
try {
|
|
1315
|
-
resolve(
|
|
1366
|
+
resolve(
|
|
1367
|
+
await this.resumeInterruptsUnsafeForGeneration(
|
|
1368
|
+
resume,
|
|
1369
|
+
target,
|
|
1370
|
+
continuationGeneration,
|
|
1371
|
+
),
|
|
1372
|
+
)
|
|
1316
1373
|
} catch (error) {
|
|
1317
1374
|
reject(error)
|
|
1318
1375
|
}
|
|
@@ -1336,6 +1393,7 @@ export class ChatClient<
|
|
|
1336
1393
|
private async submitInterruptBatch(
|
|
1337
1394
|
submission: InterruptManagerSubmission,
|
|
1338
1395
|
): Promise<void> {
|
|
1396
|
+
const continuationGeneration = this.continuationGeneration
|
|
1339
1397
|
this.activeInterruptSubmission = submission
|
|
1340
1398
|
this.interruptSubmissionFailure = undefined
|
|
1341
1399
|
// Reflect approval decisions in the local message tree immediately so a
|
|
@@ -1347,15 +1405,21 @@ export class ChatClient<
|
|
|
1347
1405
|
const approvalId = resolution.interruptId
|
|
1348
1406
|
this.processor.addToolApprovalResponse(approvalId, approved)
|
|
1349
1407
|
}
|
|
1350
|
-
const resumed = await this.
|
|
1408
|
+
const resumed = await this.resumeInterruptsUnsafeForGeneration(
|
|
1351
1409
|
[...submission.resolutions],
|
|
1352
1410
|
{
|
|
1353
1411
|
threadId: submission.threadId,
|
|
1354
1412
|
runId: submission.interruptedRunId,
|
|
1355
1413
|
},
|
|
1414
|
+
continuationGeneration,
|
|
1356
1415
|
).finally(() => {
|
|
1357
|
-
this
|
|
1416
|
+
// Only clear if this resume still owns the client: `stop()` may have
|
|
1417
|
+
// invalidated it while the submission was settling.
|
|
1418
|
+
if (this.activeInterruptSubmission === submission) {
|
|
1419
|
+
this.activeInterruptSubmission = undefined
|
|
1420
|
+
}
|
|
1358
1421
|
})
|
|
1422
|
+
if (continuationGeneration !== this.continuationGeneration) return
|
|
1359
1423
|
const failure = this.takeInterruptSubmissionFailure()
|
|
1360
1424
|
if (failure !== undefined) {
|
|
1361
1425
|
throw { errors: failure.errors }
|
|
@@ -1678,6 +1742,7 @@ export class ChatClient<
|
|
|
1678
1742
|
// persisted pointer with the provider id — so a SECOND reload would
|
|
1679
1743
|
// `joinRun` an id the log isn't keyed by and never re-attach.
|
|
1680
1744
|
this.lastResume = { threadId: this.threadId, runId }
|
|
1745
|
+
this.streamContinuationGeneration = this.continuationGeneration
|
|
1681
1746
|
this.setIsLoading(true)
|
|
1682
1747
|
this.setStatus('streaming')
|
|
1683
1748
|
void (async () => {
|
|
@@ -2163,6 +2228,7 @@ export class ChatClient<
|
|
|
2163
2228
|
|
|
2164
2229
|
// Track generation so a superseded stream's cleanup doesn't clobber the new one
|
|
2165
2230
|
const generation = ++this.streamGeneration
|
|
2231
|
+
this.streamContinuationGeneration = this.continuationGeneration
|
|
2166
2232
|
// Native interrupt continuation is a fresh child run. The interrupted run
|
|
2167
2233
|
// is carried as parentRunId and the complete resolution batch as resume.
|
|
2168
2234
|
const resumeThreadId = this.pendingResumeThreadId
|
|
@@ -2305,6 +2371,14 @@ export class ChatClient<
|
|
|
2305
2371
|
return false
|
|
2306
2372
|
}
|
|
2307
2373
|
|
|
2374
|
+
// connect() send() already waited until the subscribe queue was idle.
|
|
2375
|
+
// Kick the processing wait so a stream that ends on tool_calls (no
|
|
2376
|
+
// interrupt / stop) cannot hang. Subscribe/send sockets still wait for
|
|
2377
|
+
// a request-ending terminal below.
|
|
2378
|
+
if (this.connectionDrainsOnSend) {
|
|
2379
|
+
this.resolveProcessing()
|
|
2380
|
+
}
|
|
2381
|
+
|
|
2308
2382
|
// Wait for subscription loop to finish processing all chunks
|
|
2309
2383
|
await processingComplete
|
|
2310
2384
|
|
|
@@ -2506,9 +2580,14 @@ export class ChatClient<
|
|
|
2506
2580
|
* Stop the current stream
|
|
2507
2581
|
*/
|
|
2508
2582
|
stop(): void {
|
|
2583
|
+
// Invalidate deferred work from the stopped continuation.
|
|
2584
|
+
this.continuationGeneration++
|
|
2509
2585
|
const hadLocalStream = this.abortController !== null
|
|
2510
2586
|
this.cancelInFlightStream({ setReadyStatus: true })
|
|
2511
2587
|
this.discardPendingSends()
|
|
2588
|
+
this.lastResume = null
|
|
2589
|
+
this.activeInterruptSubmission = undefined
|
|
2590
|
+
this.interruptManager.reset()
|
|
2512
2591
|
if (hadLocalStream) {
|
|
2513
2592
|
this.resetSessionGenerating()
|
|
2514
2593
|
}
|
|
@@ -2552,12 +2631,17 @@ export class ChatClient<
|
|
|
2552
2631
|
*/
|
|
2553
2632
|
async addToolResult(result: ClientToolResult): Promise<void> {
|
|
2554
2633
|
const clientTool = this.clientToolsRef.current.get(result.tool)
|
|
2555
|
-
await this.addToolResultForClientTool(
|
|
2634
|
+
await this.addToolResultForClientTool(
|
|
2635
|
+
result,
|
|
2636
|
+
clientTool,
|
|
2637
|
+
this.streamContinuationGeneration,
|
|
2638
|
+
)
|
|
2556
2639
|
}
|
|
2557
2640
|
|
|
2558
2641
|
private async addToolResultForClientTool(
|
|
2559
2642
|
result: ClientToolResult,
|
|
2560
2643
|
clientTool: AnyClientTool | undefined,
|
|
2644
|
+
continuationGeneration: number,
|
|
2561
2645
|
context?: ChatClientRunEventContext,
|
|
2562
2646
|
): Promise<void> {
|
|
2563
2647
|
if (clientTool && result.state !== 'output-error') {
|
|
@@ -2584,6 +2668,8 @@ export class ChatClient<
|
|
|
2584
2668
|
context,
|
|
2585
2669
|
)
|
|
2586
2670
|
|
|
2671
|
+
if (continuationGeneration !== this.continuationGeneration) return
|
|
2672
|
+
|
|
2587
2673
|
// Always update local message state so the tool-call part is terminal in
|
|
2588
2674
|
// the UI even when the AG-UI interrupt path owns server continuation.
|
|
2589
2675
|
this.processor.addToolResult(
|
|
@@ -2609,7 +2695,11 @@ export class ChatClient<
|
|
|
2609
2695
|
|
|
2610
2696
|
// If stream is in progress, queue continuation check for after it ends
|
|
2611
2697
|
if (this.isLoading) {
|
|
2612
|
-
this.queuePostStreamAction(() =>
|
|
2698
|
+
this.queuePostStreamAction(() =>
|
|
2699
|
+
continuationGeneration === this.continuationGeneration
|
|
2700
|
+
? this.checkForContinuation()
|
|
2701
|
+
: Promise.resolve(),
|
|
2702
|
+
)
|
|
2613
2703
|
return
|
|
2614
2704
|
}
|
|
2615
2705
|
|
|
@@ -2689,7 +2779,11 @@ export class ChatClient<
|
|
|
2689
2779
|
* Queue an action to be executed after the current stream ends
|
|
2690
2780
|
*/
|
|
2691
2781
|
private queuePostStreamAction(action: () => Promise<void>): void {
|
|
2692
|
-
this.
|
|
2782
|
+
const continuationGeneration = this.continuationGeneration
|
|
2783
|
+
this.postStreamActions.push(async () => {
|
|
2784
|
+
if (continuationGeneration !== this.continuationGeneration) return
|
|
2785
|
+
await action()
|
|
2786
|
+
})
|
|
2693
2787
|
}
|
|
2694
2788
|
|
|
2695
2789
|
/**
|
|
@@ -2712,6 +2806,10 @@ export class ChatClient<
|
|
|
2712
2806
|
* Check if we should continue the flow and do so if needed
|
|
2713
2807
|
*/
|
|
2714
2808
|
private async checkForContinuation(): Promise<void> {
|
|
2809
|
+
// stop() bumps continuationGeneration without opening a new stream.
|
|
2810
|
+
if (this.streamContinuationGeneration !== this.continuationGeneration) {
|
|
2811
|
+
return
|
|
2812
|
+
}
|
|
2715
2813
|
if (this.hasPendingInterrupts()) return
|
|
2716
2814
|
|
|
2717
2815
|
// Prevent duplicate continuation attempts
|
|
@@ -2982,12 +3080,12 @@ export class ChatClient<
|
|
|
2982
3080
|
this.resetSessionGenerating()
|
|
2983
3081
|
this.setIsSubscribed(false)
|
|
2984
3082
|
this.setConnectionStatus('disconnected')
|
|
2985
|
-
|
|
2986
|
-
|
|
2987
|
-
|
|
2988
|
-
|
|
2989
|
-
|
|
2990
|
-
)
|
|
3083
|
+
const transport = resolveTransport({
|
|
3084
|
+
connection: options.connection,
|
|
3085
|
+
fetcher: options.fetcher,
|
|
3086
|
+
})
|
|
3087
|
+
this.connectionDrainsOnSend = connectionDrainsOnSend(transport)
|
|
3088
|
+
this.connection = normalizeConnectionAdapter(transport)
|
|
2991
3089
|
|
|
2992
3090
|
if (wasSubscribed) {
|
|
2993
3091
|
this.subscribe()
|
|
@@ -20,6 +20,7 @@ import type {
|
|
|
20
20
|
UIMessage,
|
|
21
21
|
} from '@tanstack/ai/client'
|
|
22
22
|
import type { ChatFetcher, ChatPendingInterrupt } from './types'
|
|
23
|
+
import { normalizeMessagesDates } from './message-date-normalizer'
|
|
23
24
|
|
|
24
25
|
/**
|
|
25
26
|
* Associates connect-wrapped chunks with the run they were produced under.
|
|
@@ -573,7 +574,9 @@ async function fetchThreadHydration(
|
|
|
573
574
|
}
|
|
574
575
|
: null
|
|
575
576
|
return {
|
|
576
|
-
messages: Array.isArray(data.messages)
|
|
577
|
+
messages: Array.isArray(data.messages)
|
|
578
|
+
? normalizeMessagesDates(data.messages)
|
|
579
|
+
: [],
|
|
577
580
|
activeRun,
|
|
578
581
|
interrupts,
|
|
579
582
|
}
|
|
@@ -1052,6 +1055,28 @@ export function normalizeConnectionAdapter(
|
|
|
1052
1055
|
}
|
|
1053
1056
|
}
|
|
1054
1057
|
|
|
1058
|
+
async function waitUntilSubscriberIdle(
|
|
1059
|
+
abortSignal?: AbortSignal,
|
|
1060
|
+
): Promise<void> {
|
|
1061
|
+
// Idle means the subscriber is waiting for the next chunk, so the
|
|
1062
|
+
// previous chunk has left processIncomingChunk. Empty waiters with an
|
|
1063
|
+
// empty buffer is in-flight delivery, not idle.
|
|
1064
|
+
const idle = () =>
|
|
1065
|
+
activeBuffer.length === 0 &&
|
|
1066
|
+
(activeWaiters.length > 0 || abortSignal?.aborted)
|
|
1067
|
+
for (let i = 0; i < 16 && !abortSignal?.aborted; i++) {
|
|
1068
|
+
if (idle()) return
|
|
1069
|
+
await Promise.resolve()
|
|
1070
|
+
}
|
|
1071
|
+
let macrotaskWaits = 0
|
|
1072
|
+
while (!abortSignal?.aborted) {
|
|
1073
|
+
if (idle()) return
|
|
1074
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 0))
|
|
1075
|
+
macrotaskWaits++
|
|
1076
|
+
if (activeWaiters.length === 0 && macrotaskWaits >= 32) return
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1055
1080
|
return {
|
|
1056
1081
|
subscribe(abortSignal?: AbortSignal): AsyncIterable<StreamChunk> {
|
|
1057
1082
|
// Transfer ownership to the latest subscriber so only one active
|
|
@@ -1159,6 +1184,7 @@ export function normalizeConnectionAdapter(
|
|
|
1159
1184
|
}
|
|
1160
1185
|
throw err
|
|
1161
1186
|
}
|
|
1187
|
+
await waitUntilSubscriberIdle(abortSignal)
|
|
1162
1188
|
},
|
|
1163
1189
|
// Expose joinRun only when the underlying connection is resumable. Require
|
|
1164
1190
|
// a real function — `'joinRun' in connection` is true for
|
|
@@ -1224,7 +1250,7 @@ function buildRunAgentInputBody(
|
|
|
1224
1250
|
): Record<string, unknown> {
|
|
1225
1251
|
// Precedence (later spreads win): static adapter `body` is the base,
|
|
1226
1252
|
// overridden by `runContext.forwardedProps`, overridden by per-message `data`.
|
|
1227
|
-
const wireMessages = uiMessagesToWire(messages
|
|
1253
|
+
const wireMessages = uiMessagesToWire(messages)
|
|
1228
1254
|
const forwardedProps = {
|
|
1229
1255
|
...options.body,
|
|
1230
1256
|
...(runContext?.forwardedProps ?? {}),
|
package/src/devtools-noop.ts
CHANGED
|
@@ -87,6 +87,7 @@ export class NoOpChatDevtoolsBridge {
|
|
|
87
87
|
}
|
|
88
88
|
observeChunk(_chunk: StreamChunk): void {}
|
|
89
89
|
recordMemoryState(_value: unknown): void {}
|
|
90
|
+
recordSkillsState(_value: unknown): void {}
|
|
90
91
|
beginRun(_runId: string, _threadId: string): void {}
|
|
91
92
|
getCurrentRunEventContext(): ChatClientRunEventContext | undefined {
|
|
92
93
|
return undefined
|
package/src/devtools.ts
CHANGED
|
@@ -33,6 +33,11 @@ export interface AIDevtoolsDisplayOptions {
|
|
|
33
33
|
* payload of the `memory:state` CUSTOM chunk. Kept local so `ai-client` doesn't
|
|
34
34
|
* depend on `ai-memory`; the memory middleware is the producer.
|
|
35
35
|
*/
|
|
36
|
+
interface SkillsStateEventValue {
|
|
37
|
+
catalog?: Array<{ name: string; description: string }>
|
|
38
|
+
activated?: Array<string>
|
|
39
|
+
}
|
|
40
|
+
|
|
36
41
|
interface MemoryStateEventValue {
|
|
37
42
|
scope: MemoryScopeLite
|
|
38
43
|
adapter: string
|
|
@@ -448,6 +453,49 @@ function getActiveBridgeRegistry(): Map<string, ActiveDevtoolsBridge> {
|
|
|
448
453
|
return registry
|
|
449
454
|
}
|
|
450
455
|
|
|
456
|
+
/**
|
|
457
|
+
* `{...options}` turns `get hookId()` into a data property. Chat/generation
|
|
458
|
+
* clients mint `threadId` after construct (`ensureThreadId` on mount), so a
|
|
459
|
+
* spread would freeze `hookId: ''` and DevTools could never select the hook.
|
|
460
|
+
*/
|
|
461
|
+
function withLiveClientIdentity<TSnapshot extends object>(
|
|
462
|
+
identity: Pick<
|
|
463
|
+
AIDevtoolsBridgeOptions<TSnapshot>,
|
|
464
|
+
| 'hookId'
|
|
465
|
+
| 'clientId'
|
|
466
|
+
| 'threadId'
|
|
467
|
+
| 'metadata'
|
|
468
|
+
| 'getTools'
|
|
469
|
+
| 'applyToolFixture'
|
|
470
|
+
>,
|
|
471
|
+
rest: Pick<AIDevtoolsBridgeOptions<TSnapshot>, 'getSnapshot'> &
|
|
472
|
+
Partial<
|
|
473
|
+
Pick<AIDevtoolsBridgeOptions<TSnapshot>, 'getTools' | 'applyToolFixture'>
|
|
474
|
+
>,
|
|
475
|
+
): AIDevtoolsBridgeOptions<TSnapshot> {
|
|
476
|
+
return {
|
|
477
|
+
get hookId() {
|
|
478
|
+
return identity.hookId
|
|
479
|
+
},
|
|
480
|
+
get clientId() {
|
|
481
|
+
return identity.clientId
|
|
482
|
+
},
|
|
483
|
+
get threadId() {
|
|
484
|
+
return identity.threadId
|
|
485
|
+
},
|
|
486
|
+
metadata: identity.metadata,
|
|
487
|
+
getSnapshot: rest.getSnapshot,
|
|
488
|
+
...(rest.getTools || identity.getTools
|
|
489
|
+
? { getTools: rest.getTools ?? identity.getTools }
|
|
490
|
+
: {}),
|
|
491
|
+
...(rest.applyToolFixture || identity.applyToolFixture
|
|
492
|
+
? {
|
|
493
|
+
applyToolFixture: rest.applyToolFixture ?? identity.applyToolFixture,
|
|
494
|
+
}
|
|
495
|
+
: {}),
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
451
499
|
export class ClientDevtoolsBridge<TSnapshot extends object> {
|
|
452
500
|
protected readonly options: AIDevtoolsBridgeOptions<TSnapshot>
|
|
453
501
|
private readonly unsubscribers: Array<Unsubscribe> = []
|
|
@@ -712,6 +760,7 @@ export class ClientDevtoolsBridge<TSnapshot extends object> {
|
|
|
712
760
|
| 'memory:retrieve:started'
|
|
713
761
|
| 'memory:retrieve:completed'
|
|
714
762
|
| 'memory:snapshot'
|
|
763
|
+
| 'skills:snapshot'
|
|
715
764
|
| AIDevtoolsRunEventType,
|
|
716
765
|
visibility: AIDevtoolsEventVisibility = 'client-state',
|
|
717
766
|
context: { runId?: string } = {},
|
|
@@ -770,17 +819,24 @@ export class ChatDevtoolsBridge extends ClientDevtoolsBridge<AIDevtoolsChatSnaps
|
|
|
770
819
|
private lastRunEventContext: ChatClientRunEventContext | undefined
|
|
771
820
|
/** Last transported `memory:state` value, replayed when a panel opens. */
|
|
772
821
|
private lastMemoryStateValue: unknown = null
|
|
822
|
+
/** Last transported `skills:state` value, replayed when a panel opens. */
|
|
823
|
+
private lastSkillsStateValue: unknown = null
|
|
773
824
|
|
|
774
825
|
constructor(options: ChatDevtoolsBridgeOptions) {
|
|
775
|
-
super(
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
826
|
+
super(
|
|
827
|
+
withLiveClientIdentity(options, {
|
|
828
|
+
getSnapshot: options.getSnapshot,
|
|
829
|
+
// Thunk defers `this.applyFixture` lookup until after `super` returns.
|
|
830
|
+
applyToolFixture: (fixture) => this.applyFixture(fixture),
|
|
831
|
+
}),
|
|
832
|
+
)
|
|
780
833
|
this.chatOptions = options
|
|
781
834
|
// Auto-attaches run/thread context and auto-emits a snapshot after each
|
|
782
835
|
// event so callers can keep using `this.events.X(...)` with no context arg.
|
|
783
|
-
this.events = new ChatDevtoolsAwareEventEmitter(
|
|
836
|
+
this.events = new ChatDevtoolsAwareEventEmitter(
|
|
837
|
+
() => options.clientId,
|
|
838
|
+
this,
|
|
839
|
+
)
|
|
784
840
|
}
|
|
785
841
|
|
|
786
842
|
// --- Stream / run context API -------------------------------------------
|
|
@@ -874,6 +930,11 @@ export class ChatDevtoolsBridge extends ClientDevtoolsBridge<AIDevtoolsChatSnaps
|
|
|
874
930
|
this.emitMemoryState(rawValue)
|
|
875
931
|
}
|
|
876
932
|
|
|
933
|
+
recordSkillsState(rawValue: unknown): void {
|
|
934
|
+
this.lastSkillsStateValue = rawValue
|
|
935
|
+
this.emitSkillsState(rawValue)
|
|
936
|
+
}
|
|
937
|
+
|
|
877
938
|
/**
|
|
878
939
|
* Re-emit the browser-side `memory:*` events from a transported
|
|
879
940
|
* `memory:state` value. The devtools store consumes these to render the
|
|
@@ -926,10 +987,26 @@ export class ChatDevtoolsBridge extends ClientDevtoolsBridge<AIDevtoolsChatSnaps
|
|
|
926
987
|
}
|
|
927
988
|
}
|
|
928
989
|
|
|
990
|
+
private emitSkillsState(rawValue: unknown): void {
|
|
991
|
+
if (!rawValue || typeof rawValue !== 'object') return
|
|
992
|
+
const value = rawValue as SkillsStateEventValue
|
|
993
|
+
const catalog = Array.isArray(value.catalog) ? value.catalog : []
|
|
994
|
+
const activated = Array.isArray(value.activated) ? value.activated : []
|
|
995
|
+
const runContext = this.currentRunId ? { runId: this.currentRunId } : {}
|
|
996
|
+
emitAIDevtoolsEvent('skills:snapshot', {
|
|
997
|
+
...this.createEnvelope('skills:snapshot', 'client-state', runContext),
|
|
998
|
+
catalog,
|
|
999
|
+
activated,
|
|
1000
|
+
})
|
|
1001
|
+
}
|
|
1002
|
+
|
|
929
1003
|
protected override onReplayState(): void {
|
|
930
1004
|
if (this.lastMemoryStateValue != null) {
|
|
931
1005
|
this.emitMemoryState(this.lastMemoryStateValue)
|
|
932
1006
|
}
|
|
1007
|
+
if (this.lastSkillsStateValue != null) {
|
|
1008
|
+
this.emitSkillsState(this.lastSkillsStateValue)
|
|
1009
|
+
}
|
|
933
1010
|
}
|
|
934
1011
|
|
|
935
1012
|
getCurrentRunEventContext(): ChatClientRunEventContext | undefined {
|
|
@@ -1410,10 +1487,11 @@ export class GenerationDevtoolsBridge<TOutput> extends ClientDevtoolsBridge<
|
|
|
1410
1487
|
protected readonly getCoreState: () => GenerationDevtoolsCoreState<TOutput>
|
|
1411
1488
|
|
|
1412
1489
|
constructor(options: GenerationDevtoolsBridgeOptions<TOutput>) {
|
|
1413
|
-
super(
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1490
|
+
super(
|
|
1491
|
+
withLiveClientIdentity(options, {
|
|
1492
|
+
getSnapshot: () => this.buildSnapshot(),
|
|
1493
|
+
}),
|
|
1494
|
+
)
|
|
1417
1495
|
this.maxRuns = options.maxRuns ?? 20
|
|
1418
1496
|
this.getCoreState = options.getCoreState
|
|
1419
1497
|
}
|
|
@@ -1759,10 +1837,18 @@ export class VideoDevtoolsBridge<
|
|
|
1759
1837
|
// so resolveStreamId() works without the chat client telling it.
|
|
1760
1838
|
class ChatDevtoolsAwareEventEmitter extends DefaultChatClientEventEmitter {
|
|
1761
1839
|
constructor(
|
|
1762
|
-
|
|
1840
|
+
private readonly getClientId: () => string,
|
|
1763
1841
|
private readonly helper: ChatDevtoolsBridge,
|
|
1764
1842
|
) {
|
|
1765
|
-
super(
|
|
1843
|
+
super(getClientId())
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1846
|
+
protected override emitEvent(
|
|
1847
|
+
eventName: string,
|
|
1848
|
+
data?: Record<string, any>,
|
|
1849
|
+
): void {
|
|
1850
|
+
this.clientId = this.getClientId()
|
|
1851
|
+
super.emitEvent(eventName, data)
|
|
1766
1852
|
}
|
|
1767
1853
|
|
|
1768
1854
|
private afterEmit(streamId?: string): void {
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { UIMessage } from './types'
|
|
2
|
+
|
|
3
|
+
function validDate(value: unknown): Date | undefined {
|
|
4
|
+
if (value instanceof Date)
|
|
5
|
+
return Number.isNaN(value.getTime()) ? undefined : value
|
|
6
|
+
if (typeof value !== 'string') return undefined
|
|
7
|
+
const date = new Date(value)
|
|
8
|
+
return Number.isNaN(date.getTime()) ? undefined : date
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function normalizeMessageDates(message: UIMessage): UIMessage {
|
|
12
|
+
const messageDate = validDate(message.createdAt)
|
|
13
|
+
const parts = message.parts.map((part) => {
|
|
14
|
+
if (part.type !== 'tool-result') return part
|
|
15
|
+
const date = validDate(part.createdAt)
|
|
16
|
+
const { createdAt: _ignored, ...rest } = part
|
|
17
|
+
return date ? { ...rest, createdAt: date } : rest
|
|
18
|
+
})
|
|
19
|
+
const { createdAt: _ignored, ...rest } = message
|
|
20
|
+
return messageDate
|
|
21
|
+
? { ...rest, parts, createdAt: messageDate }
|
|
22
|
+
: { ...rest, parts }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function normalizeMessagesDates(
|
|
26
|
+
messages: Array<UIMessage>,
|
|
27
|
+
): Array<UIMessage> {
|
|
28
|
+
return messages.map(normalizeMessageDates)
|
|
29
|
+
}
|
package/src/storage-adapters.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type { ChatPersistedState, ChatStorageAdapter } from './types'
|
|
1
|
+
import type { ChatPersistedState, ChatStorageAdapter, UIMessage } from './types'
|
|
2
|
+
import { normalizeMessagesDates } from './message-date-normalizer'
|
|
2
3
|
|
|
3
4
|
export interface WebStoragePersistenceOptions {
|
|
4
5
|
keyPrefix?: string
|
|
@@ -45,6 +46,16 @@ function stringifyJson(value: ChatPersistedState): string {
|
|
|
45
46
|
return serialized
|
|
46
47
|
}
|
|
47
48
|
|
|
49
|
+
function reviveMessageCreatedAt(message: UIMessage): UIMessage {
|
|
50
|
+
return normalizeMessagesDates([message]).at(0) ?? message
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function revivePersistedState(state: ChatPersistedState): ChatPersistedState {
|
|
54
|
+
if (state == null || typeof state !== 'object') return state
|
|
55
|
+
if (!Array.isArray(state.messages)) return state
|
|
56
|
+
return { ...state, messages: state.messages.map(reviveMessageCreatedAt) }
|
|
57
|
+
}
|
|
58
|
+
|
|
48
59
|
function createWebStoragePersistence(
|
|
49
60
|
storageName: 'localStorage' | 'sessionStorage',
|
|
50
61
|
options: WebStoragePersistenceOptions,
|
|
@@ -69,7 +80,7 @@ function createWebStoragePersistence(
|
|
|
69
80
|
return {
|
|
70
81
|
getItem(id) {
|
|
71
82
|
const item = getStorage().getItem(key(id))
|
|
72
|
-
return item === null ? null : deserialize(item)
|
|
83
|
+
return item === null ? null : revivePersistedState(deserialize(item))
|
|
73
84
|
},
|
|
74
85
|
setItem(id, value) {
|
|
75
86
|
getStorage().setItem(key(id), serialize(value))
|
package/src/types.ts
CHANGED
|
@@ -595,10 +595,14 @@ export type ToolCallPart<TTools extends ReadonlyArray<AnyClientTool> = any> =
|
|
|
595
595
|
|
|
596
596
|
export interface ToolResultPart {
|
|
597
597
|
type: 'tool-result'
|
|
598
|
+
id?: string
|
|
599
|
+
name?: string
|
|
598
600
|
toolCallId: string
|
|
599
601
|
content: string | Array<ContentPart>
|
|
600
602
|
state: ToolResultState
|
|
601
603
|
error?: string // Error message if state is "error"
|
|
604
|
+
metadata?: Record<string, unknown>
|
|
605
|
+
createdAt?: Date
|
|
602
606
|
}
|
|
603
607
|
|
|
604
608
|
export interface ThinkingPart {
|
|
@@ -639,6 +643,7 @@ export interface UIMessage<
|
|
|
639
643
|
> {
|
|
640
644
|
id: string
|
|
641
645
|
role: 'system' | 'user' | 'assistant'
|
|
646
|
+
name?: string
|
|
642
647
|
parts: Array<MessagePart<TTools, TData>>
|
|
643
648
|
createdAt?: Date
|
|
644
649
|
/**
|