@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/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/skills/ai-persistence/build-custom-adapter/SKILL.md +20 -5
- package/src/middleware.ts +58 -5
- package/src/reconstruct.ts +74 -2
- package/src/testkit/conformance.ts +76 -0
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,
|
|
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,
|
|
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.
|
|
2290
|
-
//
|
|
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.
|
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.
|