@tanstack/ai-persistence 0.6.7 → 0.7.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/middleware.ts CHANGED
@@ -294,6 +294,11 @@ interface RunStateEntry {
294
294
  */
295
295
  streamingMessageId?: string
296
296
  streamingMessageCreatedAt?: Date
297
+ /**
298
+ * Length of `ctx.messages` when this run started. Messages from this index
299
+ * on are the ones this run added, so they get its run id on save (#1061).
300
+ */
301
+ firstRunMessage?: number
297
302
  completion?: {
298
303
  promise: Promise<void>
299
304
  resolve: () => void
@@ -302,6 +307,50 @@ interface RunStateEntry {
302
307
  }
303
308
 
304
309
  const runState = new WeakMap<object, RunStateEntry>()
310
+ /** `metadata.tanstack.run.id` of a stored message, when set. */
311
+ function runTagOf(message: ModelMessage): string | undefined {
312
+ const tanstack: unknown = message.metadata?.tanstack
313
+ if (typeof tanstack !== 'object' || tanstack === null) return
314
+ const run: unknown = (tanstack as { run?: unknown }).run
315
+ if (typeof run !== 'object' || run === null) return
316
+ const id: unknown = (run as { id?: unknown }).id
317
+ return typeof id === 'string' && id !== '' ? id : undefined
318
+ }
319
+
320
+ function withRunTag(message: ModelMessage, runId: string): ModelMessage {
321
+ const metadata = message.metadata ?? {}
322
+ const tanstack: unknown = metadata.tanstack
323
+ return {
324
+ ...message,
325
+ metadata: {
326
+ ...metadata,
327
+ tanstack: {
328
+ ...(typeof tanstack === 'object' && tanstack !== null ? tanstack : {}),
329
+ run: { id: runId },
330
+ },
331
+ },
332
+ }
333
+ }
334
+
335
+ /**
336
+ * The thread to save, with this run's id on the assistant messages it added
337
+ * (`metadata.tanstack.run.id`). `reconstructChat` uses it to match each
338
+ * message to its run. Messages from earlier runs are left as they are.
339
+ */
340
+ function runMessages(
341
+ ctx: ChatMiddlewareContext,
342
+ state: RunStateEntry | undefined,
343
+ ): Array<ModelMessage> {
344
+ const from = state?.firstRunMessage
345
+ if (from === undefined) return [...ctx.messages]
346
+ return ctx.messages.map((message, index) =>
347
+ index >= from &&
348
+ message.role === 'assistant' &&
349
+ runTagOf(message) === undefined
350
+ ? withRunTag(message, ctx.runId)
351
+ : message,
352
+ )
353
+ }
305
354
 
306
355
  const validResumeStatuses = new Set(['resolved', 'cancelled'])
307
356
 
@@ -333,6 +382,7 @@ function mergeResumeToolState(
333
382
  left.clientToolResults,
334
383
  right.clientToolResults,
335
384
  ),
385
+ clientToolErrors: mergeMaps(left.clientToolErrors, right.clientToolErrors),
336
386
  genericInterrupts: mergeMaps(
337
387
  left.genericInterrupts,
338
388
  right.genericInterrupts,
@@ -856,6 +906,7 @@ function resumeToolStateFromPending(
856
906
  for (const interrupt of pending) {
857
907
  const entry = resumeByInterruptId.get(interrupt.interruptId)
858
908
  if (!entry) continue
909
+ if (hasReservedInterruptBinding(interrupt.payload)) continue
859
910
 
860
911
  const kind = interruptKind(interrupt)
861
912
  const reason = stringField(interrupt.payload, 'reason')
@@ -2122,6 +2173,8 @@ export function withPersistence<TStores extends ChatTranscriptStores>(
2122
2173
  // prior history) as soon as the run starts, so a reload mid-run rehydrates
2123
2174
  // it before the assistant reply exists. Best-effort: a failed eager
2124
2175
  // snapshot must not abort the run — the authoritative save is `onFinish`.
2176
+ const state = runState.get(ctx)
2177
+ if (state) state.firstRunMessage = ctx.messages.length
2125
2178
  try {
2126
2179
  await messageStore.saveThread(ctx.threadId, [...ctx.messages])
2127
2180
  } catch {
@@ -2235,7 +2288,7 @@ export function withPersistence<TStores extends ChatTranscriptStores>(
2235
2288
  : (state.usage ?? chunkUsage)
2236
2289
  state.usage = usage
2237
2290
  await interruptRun(runs, ctx.runId, usage)
2238
- await messageStore.saveThread(ctx.threadId, [...ctx.messages])
2291
+ await messageStore.saveThread(ctx.threadId, runMessages(ctx, state))
2239
2292
  state.interrupted = true
2240
2293
  },
2241
2294
 
@@ -2253,7 +2306,7 @@ export function withPersistence<TStores extends ChatTranscriptStores>(
2253
2306
  // or consuming approvals before the durable history lands leaves a
2254
2307
  // "finished" run whose transcript is missing the terminal turn.
2255
2308
  try {
2256
- await messageStore.saveThread(ctx.threadId, [...ctx.messages])
2309
+ await messageStore.saveThread(ctx.threadId, runMessages(ctx, state))
2257
2310
  await commitPendingResumes(state, persistence.stores.interrupts)
2258
2311
  await completeRun(runs, ctx.runId, state?.usage ?? info.usage)
2259
2312
  state?.completion?.resolve()
@@ -2286,9 +2339,9 @@ export function withPersistence<TStores extends ChatTranscriptStores>(
2286
2339
  // and durable (`RunRecord.cancelRequested`, the only channel that reaches
2287
2340
  // a run being driven elsewhere).
2288
2341
  // A run paused at an interrupt boundary is waiting for a HUMAN, not for
2289
- // this socket. `chat()` skips its terminal hook at an actionable-wait
2290
- // boundary, so its `finally` routes the disconnect here — and
2291
- // terminalizing then produced a record claiming the run finished while
2342
+ // this socket. A disconnect can land after the interrupt boundary but
2343
+ // before the invocation ends, and `chat()` then routes it here instead
2344
+ // of to `onFinish` — and terminalizing then produced a record claiming the run finished while
2292
2345
  // the interrupt rows stayed `'pending'` and `validatePendingResumes`
2293
2346
  // still threw on the next request. An explicit cancel is different: the
2294
2347
  // user gave up on the approval, so the cancel band stays authoritative.
@@ -1,8 +1,9 @@
1
- import { modelMessagesToUIMessages } from '@tanstack/ai'
1
+ import { isTerminalRunStatus, modelMessagesToUIMessages } from '@tanstack/ai'
2
2
  import type {
3
3
  ModelMessage,
4
4
  RunRecord,
5
5
  SubagentPart,
6
+ TerminalRunStatus,
6
7
  UIMessage,
7
8
  } from '@tanstack/ai'
8
9
  import { storedSubagentInfo } from './subagent-runs'
@@ -41,11 +42,29 @@ export interface ReconstructedChat {
41
42
  pending: Array<Record<string, unknown>>
42
43
  } | null
43
44
  page?: { truncated: false } | { truncated: true; cursor: string }
45
+ /**
46
+ * The thread's finished runs, ascending by `startedAt`. Set only when
47
+ * {@link ReconstructChatOptions.includeRuns} is `true` and the `runs` store
48
+ * implements `listByThread`. Each assistant message of a listed run also
49
+ * gets the timings on `message.metadata.tanstack.run`.
50
+ */
51
+ runs?: Array<{
52
+ runId: string
53
+ status: TerminalRunStatus
54
+ startedAt: number
55
+ finishedAt?: number
56
+ }>
44
57
  }
45
58
 
46
59
  export interface ReconstructChatOptions {
47
60
  /** Query parameter carrying the thread id. Defaults to `threadId`. */
48
61
  param?: string
62
+ /**
63
+ * Add the thread's finished runs, with `startedAt` and `finishedAt`, to the
64
+ * response as `runs`. Needs a `runs` store that implements `listByThread`.
65
+ * Default: `false`.
66
+ */
67
+ includeRuns?: boolean
49
68
  /**
50
69
  * Authorize access to the requested thread before loading history.
51
70
  *
@@ -185,8 +204,26 @@ export async function reconstructChat(
185
204
  threadId,
186
205
  pending,
187
206
  )
207
+ const runStore = persistence.stores.runs
208
+ const runs =
209
+ options?.includeRuns && threadId && runStore?.listByThread
210
+ ? (await runStore.listByThread(threadId)).flatMap((run) =>
211
+ isTerminalRunStatus(run.status)
212
+ ? [
213
+ {
214
+ runId: run.runId,
215
+ status: run.status,
216
+ startedAt: run.startedAt,
217
+ ...(run.finishedAt !== undefined && {
218
+ finishedAt: run.finishedAt,
219
+ }),
220
+ },
221
+ ]
222
+ : [],
223
+ )
224
+ : undefined
188
225
  const body: ReconstructedChat = {
189
- messages,
226
+ messages: runs ? stampRunTimings(messages, runs) : messages,
190
227
  activeRun: active ? { runId: active.runId } : null,
191
228
  interrupts: firstPending
192
229
  ? {
@@ -195,6 +232,7 @@ export async function reconstructChat(
195
232
  }
196
233
  : null,
197
234
  ...('page' in transcript ? { page: transcript.page } : {}),
235
+ ...(runs ? { runs } : {}),
198
236
  }
199
237
  return new Response(JSON.stringify(body), {
200
238
  headers: {
@@ -213,6 +251,40 @@ function messageRunId(message: UIMessage) {
213
251
  return typeof runId === 'string' && runId !== '' ? runId : undefined
214
252
  }
215
253
 
254
+ /**
255
+ * Write each finished run's timings to `metadata.tanstack.run` on the
256
+ * assistant messages of that run, so a client reads them from the message.
257
+ */
258
+ function stampRunTimings(
259
+ messages: Array<UIMessage>,
260
+ runs: NonNullable<ReconstructedChat['runs']>,
261
+ ): Array<UIMessage> {
262
+ const byId = new Map(runs.map((run) => [run.runId, run]))
263
+ return messages.map((message) => {
264
+ const tanstack = message.metadata?.tanstack
265
+ const runId: unknown = tanstack?.run?.id
266
+ const run =
267
+ message.role === 'assistant' && typeof runId === 'string'
268
+ ? byId.get(runId)
269
+ : undefined
270
+ if (!run) return message
271
+ return {
272
+ ...message,
273
+ metadata: {
274
+ ...message.metadata,
275
+ tanstack: {
276
+ ...tanstack,
277
+ run: {
278
+ id: run.runId,
279
+ startedAt: run.startedAt,
280
+ ...(run.finishedAt !== undefined && { finishedAt: run.finishedAt }),
281
+ },
282
+ },
283
+ },
284
+ }
285
+ })
286
+ }
287
+
216
288
  type Runs = NonNullable<ChatTranscriptStores['runs']>
217
289
 
218
290
  /** Rebuild one child card from its run record and stored transcript. */
@@ -62,6 +62,20 @@ type OptionalRunStoreMethod =
62
62
  /** Dotted `store.method` key a backend passes to declare an omitted method. */
63
63
  export type PersistenceConformanceMethodKey = `runs.${OptionalRunStoreMethod}`
64
64
 
65
+ /**
66
+ * Checks added after the suite shipped. They are off by default, so a backend
67
+ * that passed before still passes. Turn them on with `options.checks`.
68
+ *
69
+ * - `'messages.metadata'`: `saveThread` / `loadThread` keep message `metadata`,
70
+ * including `metadata.tanstack.run.id` (run timings on reload need it).
71
+ * - `'runs.listByThread.state'`: `listByThread` returns each run's current
72
+ * `status` and `finishedAt` after `update` (`reconstructChat`'s
73
+ * `includeRuns` needs it).
74
+ */
75
+ export type PersistenceConformanceCheck =
76
+ | 'messages.metadata'
77
+ | 'runs.listByThread.state'
78
+
65
79
  /**
66
80
  * Unwrap a value the store contract says must be present. Fails the test with a
67
81
  * readable message instead of a non-null assertion (banned in this package) or
@@ -115,6 +129,12 @@ export interface PersistenceConformanceOptions {
115
129
  * has no effect.
116
130
  */
117
131
  skipMethods?: Array<PersistenceConformanceMethodKey>
132
+ /**
133
+ * Opt-in checks, off by default so existing backends keep passing. A check
134
+ * that is not listed is reported as a skipped case. See
135
+ * {@link PersistenceConformanceCheck}.
136
+ */
137
+ checks?: Array<PersistenceConformanceCheck>
118
138
  }
119
139
 
120
140
  /**
@@ -131,6 +151,7 @@ export function runPersistenceConformance(
131
151
  const skipMethods = new Set<PersistenceConformanceMethodKey>(
132
152
  options?.skipMethods ?? [],
133
153
  )
154
+ const checks = new Set<PersistenceConformanceCheck>(options?.checks ?? [])
134
155
 
135
156
  describe(`AIPersistence conformance: ${name}`, () => {
136
157
  let persistence: AIPersistence
@@ -274,6 +295,32 @@ export function runPersistenceConformance(
274
295
  await store.saveThread('thread-rich', rich)
275
296
  expect(await store.loadThread('thread-rich')).toEqual(rich)
276
297
  })
298
+
299
+ it('round-trips message metadata', async (ctx) => {
300
+ if (!checks.has('messages.metadata')) {
301
+ return ctx.skip(
302
+ "opt-in check: pass { checks: ['messages.metadata'] }",
303
+ )
304
+ }
305
+ const store = resolveStore('messages')
306
+ if (!store) return ctx.skip('store not provided')
307
+
308
+ const withMetadata: Array<ModelMessage> = [
309
+ {
310
+ role: 'user',
311
+ content: 'hi',
312
+ metadata: { author: { id: 'user-42' } },
313
+ },
314
+ {
315
+ role: 'assistant',
316
+ content: 'hello',
317
+ metadata: { tanstack: { run: { id: 'run-1' } }, custom: 1 },
318
+ },
319
+ ]
320
+
321
+ await store.saveThread('thread-metadata', withMetadata)
322
+ expect(await store.loadThread('thread-metadata')).toEqual(withMetadata)
323
+ })
277
324
  })
278
325
 
279
326
  describe('runs', () => {
@@ -520,6 +567,35 @@ export function runPersistenceConformance(
520
567
  expect(listed.map((r) => r.runId)).toEqual(['lt-a', 'lt-b'])
521
568
  })
522
569
 
570
+ it('lists runs by thread with their current status and finishedAt', async (ctx) => {
571
+ if (!checks.has('runs.listByThread.state')) {
572
+ return ctx.skip(
573
+ "opt-in check: pass { checks: ['runs.listByThread.state'] }",
574
+ )
575
+ }
576
+ const runs = resolveStore('runs')
577
+ if (!runs) return ctx.skip('store not provided')
578
+ if (!hasRunsMethod(runs, 'listByThread')) {
579
+ return ctx.skip('runs.listByThread not implemented')
580
+ }
581
+
582
+ await runs.createOrResume({
583
+ runId: 'lts-a',
584
+ threadId: 'lts',
585
+ startedAt: 1,
586
+ })
587
+ await runs.update('lts-a', { status: 'completed', finishedAt: 5 })
588
+
589
+ expect(await runs.listByThread('lts')).toEqual([
590
+ expect.objectContaining({
591
+ runId: 'lts-a',
592
+ status: 'completed',
593
+ startedAt: 1,
594
+ finishedAt: 5,
595
+ }),
596
+ ])
597
+ })
598
+
523
599
  // `listByParentRun` is optional and is skipped when absent. A store that
524
600
  // has it returns only that parent's children, oldest `startedAt` first,
525
601
  // and [] for an unknown parent.