@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.
- package/dist/esm/middleware.js +39 -2
- package/dist/esm/middleware.js.map +1 -1
- package/dist/esm/reconstruct.d.ts +19 -1
- package/dist/esm/reconstruct.js +39 -3
- package/dist/esm/reconstruct.js.map +1 -1
- package/dist/esm/testkit/conformance.d.ts +17 -0
- package/dist/esm/testkit/conformance.js +41 -0
- package/dist/esm/testkit/conformance.js.map +1 -1
- package/dist/esm/types.js +2 -2
- package/package.json +4 -3
- package/src/middleware.ts +58 -5
- package/src/reconstruct.ts +74 -2
- package/src/testkit/conformance.ts +76 -0
package/src/reconstruct.ts
CHANGED
|
@@ -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.
|