@tanstack/ai-persistence 0.6.5 → 0.7.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.
@@ -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.