@tanstack/ai-client 0.15.2 → 0.16.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.
@@ -0,0 +1,337 @@
1
+ import { getChunkRunId } from './connection-adapters'
2
+ import type { StreamChunk } from '@tanstack/ai/client'
3
+ import type { ChatClientPersistence, UIMessage } from './types'
4
+
5
+ // `StreamChunk` is a discriminated union; `toolCallId` / `messageId` /
6
+ // `parentMessageId` exist on only some members. Narrow with `in` (matching
7
+ // `getChunkRunId`) instead of asserting a shape, so the field's real type is
8
+ // preserved and a protocol rename can't be read past silently.
9
+ function getChunkToolCallId(chunk: StreamChunk): string | undefined {
10
+ return 'toolCallId' in chunk && typeof chunk.toolCallId === 'string'
11
+ ? chunk.toolCallId
12
+ : undefined
13
+ }
14
+
15
+ function getChunkMessageId(chunk: StreamChunk): string | undefined {
16
+ return 'messageId' in chunk && typeof chunk.messageId === 'string'
17
+ ? chunk.messageId
18
+ : undefined
19
+ }
20
+
21
+ function getChunkParentMessageId(chunk: StreamChunk): string | undefined {
22
+ return 'parentMessageId' in chunk && typeof chunk.parentMessageId === 'string'
23
+ ? chunk.parentMessageId
24
+ : undefined
25
+ }
26
+
27
+ /**
28
+ * Encapsulates everything persistence-related for `ChatClient` so the client
29
+ * itself stays focused on streaming and message state.
30
+ *
31
+ * Two responsibilities live here:
32
+ *
33
+ * 1. **Storage orchestration** — hydrate from `getItem(id)` on creation, save to
34
+ * `setItem(id, messages)` on every change through an ordered write queue, and
35
+ * `removeItem(id)` on clear. A generation counter discards stale writes when a
36
+ * removal or a newer conversation supersedes an in-flight async operation.
37
+ * 2. **Clear-during-stream suppression** — when a conversation is cleared while a
38
+ * stream is still producing, late chunks for the cleared run(s) must not
39
+ * repopulate the now-empty state. The persistor tracks the cleared ids and
40
+ * decides, per chunk, whether the client should ignore it.
41
+ *
42
+ * All adapter calls are best-effort: a throwing or rejecting adapter is swallowed
43
+ * so storage problems never break the chat.
44
+ */
45
+ export class ChatPersistor {
46
+ // --- storage queue state ---
47
+ private skipNextPersist = false
48
+ private generation = 0
49
+ private queue: Promise<void> = Promise.resolve()
50
+ private queuePending = false
51
+ // Bumped on every message change; lets an in-flight async hydration detect
52
+ // that the message list moved on and avoid clobbering it.
53
+ private messagesGeneration = 0
54
+
55
+ // --- clear-during-stream suppression state ---
56
+ private readonly clearedMessageIds = new Set<string>()
57
+ private readonly clearedRunIds = new Set<string>()
58
+ private readonly ignoredActiveRunIds = new Set<string>()
59
+ private readonly clearedToolCallIds = new Set<string>()
60
+ private currentRunlessRunId: string | null = null
61
+
62
+ constructor(
63
+ private readonly adapter: ChatClientPersistence,
64
+ private readonly id: string,
65
+ private readonly applyMessages: (messages: Array<UIMessage>) => void,
66
+ ) {}
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // Storage orchestration
70
+ // ---------------------------------------------------------------------------
71
+
72
+ /**
73
+ * Synchronously read the persisted messages for constructor-time hydration.
74
+ * Returns the raw `getItem` result (which may be a promise for async stores).
75
+ */
76
+ readInitial():
77
+ | Array<UIMessage>
78
+ | null
79
+ | undefined
80
+ | Promise<Array<UIMessage> | null | undefined> {
81
+ try {
82
+ return this.adapter.getItem(this.id)
83
+ } catch {
84
+ return undefined
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Apply messages from an async `getItem` once it resolves, unless the message
90
+ * list has already changed since hydration began.
91
+ */
92
+ hydrateAsync(
93
+ persistedMessages:
94
+ | Array<UIMessage>
95
+ | null
96
+ | undefined
97
+ | Promise<Array<UIMessage> | null | undefined>,
98
+ ): void {
99
+ if (!(persistedMessages instanceof Promise)) {
100
+ return
101
+ }
102
+
103
+ const hydrationGeneration = this.messagesGeneration
104
+ persistedMessages
105
+ .then((messages) => {
106
+ if (
107
+ Array.isArray(messages) &&
108
+ this.messagesGeneration === hydrationGeneration
109
+ ) {
110
+ this.applyMessages(messages)
111
+ }
112
+ })
113
+ .catch(() => {
114
+ // Persistence adapters are best-effort and must not break chat setup.
115
+ })
116
+ }
117
+
118
+ /**
119
+ * Record a message-list change and queue a `setItem` write for it. Skips a
120
+ * single write after {@link beginClear} so the clear's empty snapshot isn't
121
+ * persisted between `clearMessages()` and {@link remove}.
122
+ */
123
+ notifyMessagesChanged(messages: Array<UIMessage>): void {
124
+ this.messagesGeneration++
125
+ if (this.skipNextPersist) {
126
+ this.skipNextPersist = false
127
+ return
128
+ }
129
+ const generation = this.generation
130
+ const messagesSnapshot = [...messages]
131
+ this.runOperation(() => {
132
+ if (generation !== this.generation) {
133
+ return
134
+ }
135
+ return this.adapter.setItem(this.id, messagesSnapshot)
136
+ })
137
+ }
138
+
139
+ /** Remove the persisted conversation. Invalidates any queued writes. */
140
+ remove(): void {
141
+ const generation = ++this.generation
142
+ this.runOperation(() => {
143
+ if (generation !== this.generation) {
144
+ return
145
+ }
146
+ return this.adapter.removeItem(this.id)
147
+ })
148
+ }
149
+
150
+ private runOperation(operation: () => void | Promise<void>): void {
151
+ if (this.queuePending) {
152
+ const queued = this.queue.then(operation).catch(() => {
153
+ // Persistence adapters are best-effort and must not break chat updates.
154
+ })
155
+ this.queue = queued
156
+ void queued.finally(() => {
157
+ if (this.queue === queued) {
158
+ this.queuePending = false
159
+ }
160
+ })
161
+ return
162
+ }
163
+
164
+ try {
165
+ const result = operation()
166
+ if (result instanceof Promise) {
167
+ this.queuePending = true
168
+ const queued = result.catch(() => {
169
+ // Persistence adapters are best-effort and must not break chat updates.
170
+ })
171
+ this.queue = queued
172
+ void queued.finally(() => {
173
+ if (this.queue === queued) {
174
+ this.queuePending = false
175
+ }
176
+ })
177
+ }
178
+ } catch {
179
+ // Persistence adapters are best-effort and must not break chat updates.
180
+ }
181
+ }
182
+
183
+ // ---------------------------------------------------------------------------
184
+ // Clear-during-stream suppression
185
+ // ---------------------------------------------------------------------------
186
+
187
+ /**
188
+ * Capture the message/run ids that exist at the moment of a clear so chunks
189
+ * still arriving for them can be ignored.
190
+ */
191
+ snapshotClear(context: {
192
+ messages: Array<UIMessage>
193
+ activeRunIds: Set<string>
194
+ currentRunId: string | null
195
+ }): void {
196
+ for (const message of context.messages) {
197
+ this.clearedMessageIds.add(message.id)
198
+ }
199
+ for (const runId of context.activeRunIds) {
200
+ this.clearedRunIds.add(runId)
201
+ this.ignoredActiveRunIds.add(runId)
202
+ }
203
+ if (context.currentRunId) {
204
+ this.clearedRunIds.add(context.currentRunId)
205
+ this.ignoredActiveRunIds.add(context.currentRunId)
206
+ }
207
+ }
208
+
209
+ /** Mark that the next persisted message change (the clear itself) is skipped. */
210
+ beginClear(): void {
211
+ this.skipNextPersist = true
212
+ }
213
+
214
+ /** Whether a chunk belongs to cleared state and should not be processed. */
215
+ shouldIgnoreChunk(chunk: StreamChunk): boolean {
216
+ const runId = getChunkRunId(chunk)
217
+ if (runId && this.clearedRunIds.has(runId)) {
218
+ if (chunk.type === 'RUN_STARTED') {
219
+ this.ignoredActiveRunIds.add(runId)
220
+ this.currentRunlessRunId = runId
221
+ }
222
+ this.markIgnoredChunkIds(chunk)
223
+ return true
224
+ }
225
+
226
+ if (runId && this.ignoredActiveRunIds.has(runId)) {
227
+ this.markIgnoredChunkIds(chunk)
228
+ return true
229
+ }
230
+
231
+ if (this.isRunlessChunkFromIgnoredRun(chunk)) {
232
+ this.markIgnoredChunkIds(chunk)
233
+ return true
234
+ }
235
+
236
+ const toolCallId = getChunkToolCallId(chunk)
237
+ if (toolCallId && this.clearedToolCallIds.has(toolCallId)) {
238
+ return true
239
+ }
240
+
241
+ const parentMessageId = getChunkParentMessageId(chunk)
242
+ if (parentMessageId && this.clearedMessageIds.has(parentMessageId)) {
243
+ if (toolCallId) {
244
+ this.clearedToolCallIds.add(toolCallId)
245
+ }
246
+ return true
247
+ }
248
+
249
+ const messageId = getChunkMessageId(chunk)
250
+ if (!messageId) {
251
+ return false
252
+ }
253
+ if (this.clearedMessageIds.has(messageId)) {
254
+ return true
255
+ }
256
+
257
+ return false
258
+ }
259
+
260
+ /**
261
+ * The owning client calls this when a run starts so runless content chunks
262
+ * (adapters that omit `runId` on content events) can be attributed to it.
263
+ */
264
+ onRunStarted(runId: string): void {
265
+ this.currentRunlessRunId = runId
266
+ }
267
+
268
+ /** Forget a settled run, advancing the runless pointer to another ignored run. */
269
+ onRunSettled(runId: string): void {
270
+ this.ignoredActiveRunIds.delete(runId)
271
+ this.clearedRunIds.delete(runId)
272
+ if (this.currentRunlessRunId === runId) {
273
+ this.currentRunlessRunId =
274
+ this.ignoredActiveRunIds.values().next().value ?? null
275
+ }
276
+ }
277
+
278
+ /** A session-level (runId-less) RUN_ERROR clears all ignored-run tracking. */
279
+ onSessionRunError(): void {
280
+ this.ignoredActiveRunIds.clear()
281
+ this.currentRunlessRunId = null
282
+ }
283
+
284
+ /** Clear the ignored-active-run markers (mirrors a session-generating reset). */
285
+ resetIgnored(): void {
286
+ this.ignoredActiveRunIds.clear()
287
+ }
288
+
289
+ /**
290
+ * Consume the current runless run id (if any), forgetting it. Used when an
291
+ * ignored, runId-less RUN_ERROR drains the run the client is still tracking.
292
+ */
293
+ takeRunlessRunId(): string | null {
294
+ const runId = this.currentRunlessRunId
295
+ if (!runId) return null
296
+ this.ignoredActiveRunIds.delete(runId)
297
+ this.clearedRunIds.delete(runId)
298
+ // Advance to another still-ignored run (mirroring `onRunSettled`) so that
299
+ // when two cleared runs drain concurrently, draining one via a runId-less
300
+ // RUN_ERROR doesn't stop suppressing the other's runless content.
301
+ this.currentRunlessRunId =
302
+ this.ignoredActiveRunIds.values().next().value ?? null
303
+ return runId
304
+ }
305
+
306
+ private markIgnoredChunkIds(chunk: StreamChunk): void {
307
+ const messageId = getChunkMessageId(chunk)
308
+ if (messageId) {
309
+ this.clearedMessageIds.add(messageId)
310
+ }
311
+ const toolCallId = getChunkToolCallId(chunk)
312
+ if (toolCallId) {
313
+ this.clearedToolCallIds.add(toolCallId)
314
+ }
315
+ }
316
+
317
+ private isRunlessChunkFromIgnoredRun(chunk: StreamChunk): boolean {
318
+ const runId = getChunkRunId(chunk)
319
+ if (runId || !this.currentRunlessRunId) return false
320
+ if (
321
+ !this.ignoredActiveRunIds.has(this.currentRunlessRunId) &&
322
+ !this.clearedRunIds.has(this.currentRunlessRunId)
323
+ ) {
324
+ return false
325
+ }
326
+ return (
327
+ chunk.type === 'TEXT_MESSAGE_START' ||
328
+ chunk.type === 'TEXT_MESSAGE_CONTENT' ||
329
+ chunk.type === 'TOOL_CALL_START' ||
330
+ chunk.type === 'TOOL_CALL_ARGS' ||
331
+ chunk.type === 'TOOL_CALL_END' ||
332
+ chunk.type === 'TOOL_CALL_RESULT' ||
333
+ chunk.type === 'MESSAGES_SNAPSHOT' ||
334
+ chunk.type === 'RUN_ERROR'
335
+ )
336
+ }
337
+ }
@@ -13,6 +13,26 @@ import type {
13
13
  } from '@tanstack/ai/client'
14
14
  import type { ChatFetcher } from './types'
15
15
 
16
+ /**
17
+ * Associates connect-wrapped chunks with the run they were produced under.
18
+ * Content events (TEXT_MESSAGE_CONTENT, TOOL_CALL_*, …) carry no `runId` of
19
+ * their own, so the connect wrapper stamps the caller's run id here. Lets
20
+ * run-scoped consumers (e.g. clear-during-stream suppression) attribute those
21
+ * otherwise-runless chunks to their originating request.
22
+ */
23
+ const chunkRunIds = new WeakMap<StreamChunk, string>()
24
+
25
+ /**
26
+ * Resolve a chunk's run id, preferring the value on the chunk itself
27
+ * (RUN_STARTED / RUN_FINISHED / RUN_ERROR carry one) and falling back to the
28
+ * run the connect wrapper stamped it with.
29
+ */
30
+ export function getChunkRunId(chunk: StreamChunk): string | undefined {
31
+ return 'runId' in chunk && typeof chunk.runId === 'string'
32
+ ? chunk.runId
33
+ : chunkRunIds.get(chunk)
34
+ }
35
+
16
36
  /**
17
37
  * Thrown when an SSE/HTTP stream ends with a non-empty unterminated buffer.
18
38
  * Indicates the connection was cut mid-line (server crash, dropped TCP, proxy
@@ -265,7 +285,10 @@ export function normalizeConnectionAdapter(
265
285
  let activeBuffer: Array<StreamChunk> = []
266
286
  let activeWaiters: Array<(chunk: StreamChunk | null) => void> = []
267
287
 
268
- function push(chunk: StreamChunk): void {
288
+ function push(chunk: StreamChunk, runId?: string): void {
289
+ if (runId) {
290
+ chunkRunIds.set(chunk, runId)
291
+ }
269
292
  const waiter = activeWaiters.shift()
270
293
  if (waiter) {
271
294
  waiter(chunk)
@@ -324,7 +347,7 @@ export function normalizeConnectionAdapter(
324
347
  if (chunk.type === 'RUN_FINISHED' || chunk.type === 'RUN_ERROR') {
325
348
  hasTerminalEvent = true
326
349
  }
327
- push(chunk)
350
+ push(chunk, runContext?.runId)
328
351
  }
329
352
 
330
353
  // If the connect stream ended cleanly without a terminal event,
package/src/index.ts CHANGED
@@ -12,6 +12,7 @@ export type {
12
12
  ThinkingPart,
13
13
  StructuredOutputPart,
14
14
  // Client configuration types
15
+ ChatClientPersistence,
15
16
  ChatClientOptions,
16
17
  ClientContextOptionFromTools,
17
18
  ChatRequestBody,
package/src/types.ts CHANGED
@@ -266,6 +266,23 @@ export interface UIMessage<
266
266
  createdAt?: Date
267
267
  }
268
268
 
269
+ export interface ChatClientPersistence<
270
+ TTools extends ReadonlyArray<AnyClientTool> = any,
271
+ > {
272
+ getItem: (
273
+ id: string,
274
+ ) =>
275
+ | Array<UIMessage<TTools>>
276
+ | null
277
+ | undefined
278
+ | Promise<Array<UIMessage<TTools>> | null | undefined>
279
+ setItem: (
280
+ id: string,
281
+ messages: Array<UIMessage<TTools>>,
282
+ ) => void | Promise<void>
283
+ removeItem: (id: string) => void | Promise<void>
284
+ }
285
+
269
286
  type IsUnknown<T> = unknown extends T
270
287
  ? [T] extends [unknown]
271
288
  ? true
@@ -356,6 +373,11 @@ export interface ChatClientBaseOptions<
356
373
  */
357
374
  initialMessages?: Array<UIMessage<TTools>>
358
375
 
376
+ /**
377
+ * Optional persistence adapter for chat messages.
378
+ */
379
+ persistence?: ChatClientPersistence<TTools>
380
+
359
381
  /**
360
382
  * Unique identifier for this chat instance
361
383
  * Used for managing multiple chats