@tanstack/ai-persistence 0.6.3 → 0.6.5

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,67 @@
1
+ import type { ModelMessage } from '@tanstack/ai'
2
+
3
+ // Empty incoming keeps stored. Non-empty: the last incoming id that already
4
+ // exists in stored is a cutoff (reload drops the old assistant after that
5
+ // user). Same id is replaced in place. New ids and messages with no id are
6
+ // appended.
7
+ export function mergeStoredMessages(
8
+ stored: ReadonlyArray<ModelMessage>,
9
+ incoming: ReadonlyArray<ModelMessage>,
10
+ ) {
11
+ if (incoming.length === 0) {
12
+ return stored.slice()
13
+ }
14
+
15
+ let cutoff = stored.length
16
+ for (let index = incoming.length - 1; index >= 0; index--) {
17
+ const id = incoming[index]?.id
18
+ if (id === undefined) continue
19
+ const storedIndex = stored.findIndex((message) => message.id === id)
20
+ if (storedIndex >= 0) {
21
+ cutoff = storedIndex + 1
22
+ break
23
+ }
24
+ }
25
+ const prefix = stored.slice(0, cutoff)
26
+
27
+ const incomingById = new Map<string, ModelMessage>()
28
+ for (const message of incoming) {
29
+ const id = message.id
30
+ if (id) incomingById.set(id, message)
31
+ }
32
+
33
+ const storedIds = new Set<string>()
34
+ const merged: Array<ModelMessage> = []
35
+ for (const message of prefix) {
36
+ const id = message.id
37
+ if (id) {
38
+ storedIds.add(id)
39
+ merged.push(incomingById.get(id) ?? message)
40
+ continue
41
+ }
42
+ merged.push(message)
43
+ }
44
+
45
+ for (let index = 0; index < incoming.length; index++) {
46
+ const message = incoming[index]
47
+ if (!message) continue
48
+ const id = message.id
49
+ if (id && storedIds.has(id)) continue
50
+ // Attach/reload can post the stored transcript again with no ids. Keep the
51
+ // prefix row instead of appending a second copy of the same turn.
52
+ if (!id) {
53
+ const existing = merged[index]
54
+ if (
55
+ existing &&
56
+ existing.id === undefined &&
57
+ existing.role === message.role &&
58
+ existing.content === message.content
59
+ ) {
60
+ continue
61
+ }
62
+ }
63
+ merged.push(message)
64
+ }
65
+
66
+ return merged
67
+ }
package/src/middleware.ts CHANGED
@@ -29,6 +29,8 @@ import {
29
29
  providePersistence,
30
30
  providePersistenceCompletion,
31
31
  } from './capabilities'
32
+ import { mergeStoredMessages } from './merge-stored'
33
+ import { createSubagentRunRecorder } from './subagent-runs'
32
34
  import {
33
35
  validateChatPersistenceStores,
34
36
  validateGenerationPersistenceStores,
@@ -1915,72 +1917,6 @@ function threadMessages(
1915
1917
  return Array.isArray(loaded) ? loaded : loaded.messages
1916
1918
  }
1917
1919
 
1918
- // Empty incoming keeps stored. Non-empty: the last incoming id that already
1919
- // exists in stored is a cutoff (reload drops the old assistant after that
1920
- // user). Same id is replaced in place. New ids and messages with no id are
1921
- // appended.
1922
- function mergeStoredMessages(
1923
- stored: ReadonlyArray<ModelMessage>,
1924
- incoming: ReadonlyArray<ModelMessage>,
1925
- ) {
1926
- if (incoming.length === 0) {
1927
- return stored.slice()
1928
- }
1929
-
1930
- let cutoff = stored.length
1931
- for (let index = incoming.length - 1; index >= 0; index--) {
1932
- const id = incoming[index]?.id
1933
- if (id === undefined) continue
1934
- const storedIndex = stored.findIndex((message) => message.id === id)
1935
- if (storedIndex >= 0) {
1936
- cutoff = storedIndex + 1
1937
- break
1938
- }
1939
- }
1940
- const prefix = stored.slice(0, cutoff)
1941
-
1942
- const incomingById = new Map<string, ModelMessage>()
1943
- for (const message of incoming) {
1944
- const id = message.id
1945
- if (id) incomingById.set(id, message)
1946
- }
1947
-
1948
- const storedIds = new Set<string>()
1949
- const merged: Array<ModelMessage> = []
1950
- for (const message of prefix) {
1951
- const id = message.id
1952
- if (id) {
1953
- storedIds.add(id)
1954
- merged.push(incomingById.get(id) ?? message)
1955
- continue
1956
- }
1957
- merged.push(message)
1958
- }
1959
-
1960
- for (let index = 0; index < incoming.length; index++) {
1961
- const message = incoming[index]
1962
- if (!message) continue
1963
- const id = message.id
1964
- if (id && storedIds.has(id)) continue
1965
- // Attach/reload can post the stored transcript again with no ids. Keep the
1966
- // prefix row instead of appending a second copy of the same turn.
1967
- if (!id) {
1968
- const existing = merged[index]
1969
- if (
1970
- existing &&
1971
- existing.id === undefined &&
1972
- existing.role === message.role &&
1973
- existing.content === message.content
1974
- ) {
1975
- continue
1976
- }
1977
- }
1978
- merged.push(message)
1979
- }
1980
-
1981
- return merged
1982
- }
1983
-
1984
1920
  export interface WithPersistenceOptions {
1985
1921
  /**
1986
1922
  * Also persist a throttled snapshot of the in-progress assistant reply while
@@ -1993,7 +1929,8 @@ export interface WithPersistenceOptions {
1993
1929
  snapshotStreaming?: boolean
1994
1930
  /**
1995
1931
  * Minimum milliseconds between streaming snapshots when `snapshotStreaming`
1996
- * is on. Defaults to 1000.
1932
+ * is on. Defaults to 1000. A streaming subagent child always uses the same
1933
+ * interval for its transcript writes, with or without `snapshotStreaming`.
1997
1934
  */
1998
1935
  snapshotIntervalMs?: number
1999
1936
  }
@@ -2044,8 +1981,18 @@ export function withPersistence<TStores extends ChatTranscriptStores>(
2044
1981
  ...(wantsInterrupts ? [InterruptsCapability] : []),
2045
1982
  ]
2046
1983
 
1984
+ const subagentRuns = createSubagentRunRecorder({
1985
+ messages: messageStore,
1986
+ runs,
1987
+ intervalMs: snapshotIntervalMs,
1988
+ ...(wantsInterrupts && persistence.stores.interrupts
1989
+ ? { interrupts: persistence.stores.interrupts }
1990
+ : {}),
1991
+ })
1992
+
2047
1993
  return defineChatMiddleware({
2048
1994
  name: 'chat-persistence',
1995
+ routedSubagentPersistence: subagentRuns,
2049
1996
  provides,
2050
1997
  setup(ctx: ChatMiddlewareContext) {
2051
1998
  providePersistence(ctx, persistence)
@@ -2183,6 +2130,11 @@ export function withPersistence<TStores extends ChatTranscriptStores>(
2183
2130
  },
2184
2131
 
2185
2132
  async onChunk(ctx: ChatMiddlewareContext, chunk: StreamChunk) {
2133
+ await subagentRuns.chunk({
2134
+ threadId: ctx.threadId,
2135
+ runId: ctx.runId,
2136
+ chunk,
2137
+ })
2186
2138
  // Capture the current assistant turn's identity for optional in-progress
2187
2139
  // snapshots. Completed messages already live in `ctx.messages`.
2188
2140
  if (snapshotStreaming && ctx.phase === 'modelStream') {
@@ -1,9 +1,16 @@
1
1
  import { modelMessagesToUIMessages } from '@tanstack/ai'
2
- import type { ModelMessage, UIMessage } from '@tanstack/ai'
2
+ import type {
3
+ ModelMessage,
4
+ RunRecord,
5
+ SubagentPart,
6
+ UIMessage,
7
+ } from '@tanstack/ai'
8
+ import { storedSubagentInfo } from './subagent-runs'
3
9
  import { validateReconstructChatStores } from './types'
4
10
  import type {
5
11
  AIPersistence,
6
12
  ChatTranscriptStores,
13
+ InterruptRecord,
7
14
  MessagePage,
8
15
  MessageStore,
9
16
  } from './types'
@@ -171,8 +178,15 @@ export async function reconstructChat(
171
178
  before,
172
179
  })
173
180
  : windowFromMessagePage(stored, pageSize)
181
+ const messages = await attachSubagentCards(
182
+ transcript.messages,
183
+ persistence.stores.runs,
184
+ messageStore,
185
+ threadId,
186
+ pending,
187
+ )
174
188
  const body: ReconstructedChat = {
175
- messages: transcript.messages,
189
+ messages,
176
190
  activeRun: active ? { runId: active.runId } : null,
177
191
  interrupts: firstPending
178
192
  ? {
@@ -190,6 +204,145 @@ export async function reconstructChat(
190
204
  })
191
205
  }
192
206
 
207
+ function messageRunId(message: UIMessage) {
208
+ const metadata = message.metadata
209
+ if (!metadata || typeof metadata !== 'object') return
210
+ const tanstack = metadata.tanstack
211
+ if (!tanstack || typeof tanstack !== 'object') return
212
+ const runId = (tanstack as { runId?: unknown }).runId
213
+ return typeof runId === 'string' && runId !== '' ? runId : undefined
214
+ }
215
+
216
+ type Runs = NonNullable<ChatTranscriptStores['runs']>
217
+
218
+ /** Rebuild one child card from its run record and stored transcript. */
219
+ async function childCard(
220
+ child: RunRecord,
221
+ runs: Runs,
222
+ messageStore: MessageStore,
223
+ pending: ReadonlyArray<InterruptRecord>,
224
+ depth: number,
225
+ ): Promise<SubagentPart> {
226
+ const subagentRunId = child.subagentRunId ?? child.runId
227
+ const stored = await messageStore.loadThread(child.threadId)
228
+ const info = storedSubagentInfo(stored)
229
+ const messages = modelMessagesToUIMessages(
230
+ stored.filter(
231
+ (message) => storedSubagentInfo([message])?.placeholder !== true,
232
+ ),
233
+ )
234
+ const nested =
235
+ runs.listByParentRun && depth < 8
236
+ ? await runs.listByParentRun(subagentRunId)
237
+ : []
238
+ if (nested.length > 0) {
239
+ const cards = await Promise.all(
240
+ nested.map((run) =>
241
+ childCard(run, runs, messageStore, pending, depth + 1),
242
+ ),
243
+ )
244
+ const last = messages.findLastIndex((m) => m.role === 'assistant')
245
+ if (last === -1) {
246
+ messages.push({
247
+ id: `child-cards:${subagentRunId}`,
248
+ role: 'assistant',
249
+ parts: cards,
250
+ })
251
+ } else {
252
+ const host = messages[last]
253
+ if (host) messages[last] = { ...host, parts: [...host.parts, ...cards] }
254
+ }
255
+ }
256
+ const failed = child.status === 'failed' || child.status === 'aborted'
257
+ const interruptIds = pending
258
+ .filter((record) => record.payload.subagentRunId === subagentRunId)
259
+ .map((record) => record.interruptId)
260
+ return {
261
+ type: 'subagent',
262
+ subagent: {
263
+ id: subagentRunId,
264
+ name: child.name ?? info?.name ?? 'subagent',
265
+ status: failed
266
+ ? 'error'
267
+ : child.status === 'running'
268
+ ? 'running'
269
+ : child.status === 'interrupted'
270
+ ? 'suspended'
271
+ : 'finished',
272
+ ...(child.parentRunId !== undefined && {
273
+ parentRunId: child.parentRunId,
274
+ }),
275
+ ...(info?.parentToolCallId !== undefined && {
276
+ parentToolCallId: info.parentToolCallId,
277
+ }),
278
+ ...(interruptIds.length > 0 && { interruptIds }),
279
+ ...(info?.metadata !== undefined && { metadata: info.metadata }),
280
+ messages,
281
+ ...(failed && child.error ? { error: child.error } : {}),
282
+ },
283
+ }
284
+ }
285
+
286
+ /**
287
+ * Put stored subagent cards back on the transcript. A routed child sits on
288
+ * the parent assistant message of its run. A child that a tool call started
289
+ * sits on the message that holds that tool call.
290
+ */
291
+ async function attachSubagentCards(
292
+ messages: Array<UIMessage>,
293
+ runs: ChatTranscriptStores['runs'],
294
+ messageStore: MessageStore,
295
+ threadId: string,
296
+ pending: ReadonlyArray<InterruptRecord>,
297
+ ) {
298
+ if (!runs?.listByParentRun) return messages
299
+ const parentRunIds = new Set<string>()
300
+ for (const message of messages) {
301
+ const runId = messageRunId(message)
302
+ if (runId) parentRunIds.add(runId)
303
+ }
304
+ const hasToolCalls = messages.some((message) =>
305
+ message.parts.some((part) => part.type === 'tool-call'),
306
+ )
307
+ if (hasToolCalls && runs.listByThread && threadId !== '') {
308
+ for (const run of await runs.listByThread(threadId)) {
309
+ parentRunIds.add(run.runId)
310
+ }
311
+ }
312
+
313
+ const cardsByRun = new Map<string, Array<SubagentPart>>()
314
+ const cardsByToolCall = new Map<string, Array<SubagentPart>>()
315
+ for (const runId of parentRunIds) {
316
+ for (const child of await runs.listByParentRun(runId)) {
317
+ const card = await childCard(child, runs, messageStore, pending, 0)
318
+ const toolCallId = card.subagent.parentToolCallId
319
+ const target = toolCallId === undefined ? cardsByRun : cardsByToolCall
320
+ const key = toolCallId ?? runId
321
+ target.set(key, [...(target.get(key) ?? []), card])
322
+ }
323
+ }
324
+ if (cardsByRun.size === 0 && cardsByToolCall.size === 0) return messages
325
+
326
+ return messages.map((message) => {
327
+ if (message.role !== 'assistant') return message
328
+ const runId = messageRunId(message)
329
+ const routed = runId !== undefined ? cardsByRun.get(runId) : undefined
330
+ const started = message.parts.flatMap((part) =>
331
+ part.type === 'tool-call' ? (cardsByToolCall.get(part.id) ?? []) : [],
332
+ )
333
+ if (!routed && started.length === 0) return message
334
+ // A routed parent message holds only the children's text. The cards
335
+ // replace it.
336
+ const parts = routed
337
+ ? message.parts.filter((part) => part.type !== 'text')
338
+ : message.parts
339
+ return {
340
+ ...message,
341
+ parts: [...(routed ?? []), ...parts, ...started],
342
+ }
343
+ })
344
+ }
345
+
193
346
  function parsePageSize(raw: string | null) {
194
347
  if (raw == null) return
195
348
  const pageSize = Number(raw)