@tanstack/ai-persistence 0.6.4 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tanstack/ai-persistence",
3
- "version": "0.6.4",
3
+ "version": "0.6.5",
4
4
  "description": "Composable state persistence for TanStack AI messages, runs, interrupts, metadata, and locks.",
5
5
  "author": "",
6
6
  "license": "MIT",
@@ -43,7 +43,7 @@
43
43
  },
44
44
  "peerDependencies": {
45
45
  "vitest": "^4.1.10",
46
- "@tanstack/ai": "^0.58.0"
46
+ "@tanstack/ai": "^0.59.0"
47
47
  },
48
48
  "peerDependenciesMeta": {
49
49
  "vitest": {
@@ -53,7 +53,7 @@
53
53
  "devDependencies": {
54
54
  "@vitest/coverage-v8": "4.1.10",
55
55
  "vitest": "^4.1.10",
56
- "@tanstack/ai": "0.58.0"
56
+ "@tanstack/ai": "0.59.0"
57
57
  },
58
58
  "scripts": {
59
59
  "build": "vite build",
@@ -97,21 +97,27 @@ The invariants are the whole game, whichever route you take:
97
97
  | `runs` | `update` on an unknown id is a silent no-op — never throws, never inserts |
98
98
  | `runs` | `findActiveRun` (required) returns the latest `'running'` run for the thread, else null |
99
99
  | `runs` | `listByThread` (optional) returns every run for the thread `ORDER BY started_at ASC` |
100
+ | `runs` | `listByParentRun` (optional) returns child runs for one `parent_run_id`, `ORDER BY started_at ASC`. `reconstructChat` uses it to put subagent cards back |
100
101
  | `runs` | `listReclaimable` (optional) returns runs where `status = 'running' AND detached_since IS NOT NULL AND detached_since <= now - ttlMs` (inclusive cutoff); it is a query, not automatic reclamation |
101
102
  | `interrupts` | `create` is insert-if-absent; never clobber a resolved interrupt back to pending |
102
103
  | `interrupts` | every `list*` ends `ORDER BY requested_at ASC` |
103
104
  | `metadata` | reject nullish `set` with a clear `TypeError`; tell callers to use `delete` |
104
105
 
105
- On `runs`, `findActiveRun` is required; `listByThread` and `listReclaimable` are
106
- optional, so implement those two only if the app needs them. `withPersistence`
107
- calls **none** of the three — the consumers are `reconstruct.ts`
108
- (`findActiveRun`, for rejoin-by-thread) and `@tanstack/ai-sandbox`'s `reapDetachedRuns`
109
- (`listReclaimable`, without which the store cannot be reaped); nothing in the
110
- framework calls `listByThread`. Consumers of the two OPTIONAL methods
111
- feature-detect with `store.method?.(...)` and degrade to "not supported" when one
112
- is absent. The conformance testkit does not: either of those you leave out must
113
- be listed in `skipMethods` or the suite fails, so declare them and it reports the
114
- omission as a skip.
106
+ On `runs`, `findActiveRun` is required. `listByThread`, `listByParentRun`, and
107
+ `listReclaimable` are optional, so implement those only if the app needs them.
108
+ `withPersistence` calls `createOrResume`, `update`, and `get`. `reconstruct.ts` calls
109
+ `findActiveRun` for rejoin-by-thread and `listByParentRun` to put subagent
110
+ cards back. `@tanstack/ai-sandbox`'s `reapDetachedRuns` calls `listReclaimable`.
111
+ `reconstruct.ts` also calls `listByThread` to find the parent runs of children
112
+ that a tool call started. Consumers of the optional methods
113
+ feature-detect with `store.method?.(...)` and degrade when one is absent. The
114
+ conformance testkit does not: each optional method you leave out must be listed
115
+ in `skipMethods` or the suite fails. `listByParentRun` is the exception. When it
116
+ is absent, the subagent checks skip on their own.
117
+
118
+ `createOrResume` copies `parentRunId`, `subagentRunId`, and `name` on the first
119
+ insert. A later call for the same `runId` leaves them unchanged. If the caller
120
+ omits a field, omit it on the mapped record.
115
121
 
116
122
  `RunRecord.error` is a structured `RunError` (`{ message: string, code?: string }`),
117
123
  so the table gets two columns rather than one JSON blob: `error` for the
@@ -159,10 +165,14 @@ CREATE TABLE IF NOT EXISTS chat_runs (
159
165
  sandbox_key text,
160
166
  detached_since integer,
161
167
  cancel_requested integer,
162
- driver_epoch integer
168
+ driver_epoch integer,
169
+ parent_run_id text,
170
+ subagent_run_id text,
171
+ name text
163
172
  );
164
173
  CREATE INDEX IF NOT EXISTS chat_runs_thread_status ON chat_runs (thread_id, status);
165
174
  CREATE INDEX IF NOT EXISTS chat_runs_thread_started ON chat_runs (thread_id, started_at);
175
+ CREATE INDEX IF NOT EXISTS chat_runs_parent_started ON chat_runs (parent_run_id, started_at);
166
176
  -- Powers listReclaimable: status = 'running' AND detached_since <= cutoff.
167
177
  CREATE INDEX IF NOT EXISTS chat_runs_status_detached ON chat_runs (status, detached_since);
168
178
  CREATE TABLE IF NOT EXISTS chat_interrupts (
@@ -189,6 +199,20 @@ Apply with `wrangler d1 migrations apply <database-name>` (`--local` first, then
189
199
  `drizzle-kit generate` instead of hand-writing it — the SQL and the Drizzle
190
200
  table definitions must agree, so let one of them own the other.
191
201
 
202
+ An existing `chat_runs` table does not get the three subagent columns from
203
+ `CREATE TABLE IF NOT EXISTS`, and the `chat_runs_parent_started` index then
204
+ fails. To add subagent support to an existing database, apply a separate
205
+ migration first:
206
+
207
+ ```sql
208
+ ALTER TABLE chat_runs ADD COLUMN parent_run_id text;
209
+ ALTER TABLE chat_runs ADD COLUMN subagent_run_id text;
210
+ ALTER TABLE chat_runs ADD COLUMN name text;
211
+ CREATE INDEX IF NOT EXISTS chat_runs_parent_started ON chat_runs (parent_run_id, started_at);
212
+ ```
213
+
214
+ A store without subagent support can skip the columns and the index.
215
+
192
216
  ## 6. Wire it into the chat route
193
217
 
194
218
  ```ts ignore
@@ -290,11 +314,12 @@ once you add the R2-backed set from
290
314
  **ai-persistence/build-cloudflare-artifact-store**). `skip` never accepts
291
315
  `'locks'`, which is not a store.
292
316
 
293
- If your recipe leaves an optional `runs` method
294
- (`listByThread`/`listReclaimable`) unimplemented, declare it
295
- with `skipMethods`, e.g. `{ skipMethods: ['runs.listByThread'] }`. An
296
- omitted method that is not declared fails the suite instead of silently
297
- passing.
317
+ If your recipe leaves `listByThread` or `listReclaimable` unimplemented,
318
+ declare it with `skipMethods`, for example
319
+ `{ skipMethods: ['runs.listByThread'] }`. An omitted method that is not declared
320
+ fails the suite instead of silently passing. Subagent support is optional: when
321
+ `listByParentRun` is absent, the subagent checks skip on their own and need no
322
+ entry.
298
323
 
299
324
  The lock store needs its **own** tests, because nothing in the conformance suite
300
325
  touches it. Cover at minimum: two concurrent `withLock` calls on the same key
@@ -43,12 +43,12 @@ complete worked `node:sqlite` walkthrough is
43
43
  Four logical records. Whatever the engine, keep these keys — the store methods
44
44
  look records up by exactly these:
45
45
 
46
- | Record | Key | Fields |
47
- | --------- | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------- |
48
- | thread | `threadId` | `messages` (array, full transcript) |
49
- | run | `runId` | `threadId`, `status`, `startedAt`, `finishedAt?`, `error?`, `usage?`, `sandboxKey?`, `detachedSince?`, `cancelRequested?`, `driverEpoch?` |
50
- | interrupt | `interruptId` | `runId`, `threadId`, `status`, `requestedAt`, `resolvedAt?`, `payload`, `response?` |
51
- | metadata | `(namespace, key)` | `value` |
46
+ | Record | Key | Fields |
47
+ | --------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
48
+ | thread | `threadId` | `messages` (array, full transcript) |
49
+ | run | `runId` | `threadId`, `status`, `startedAt`, `finishedAt?`, `error?`, `usage?`, `parentRunId?`, `subagentRunId?`, `name?`, `sandboxKey?`, `detachedSince?`, `cancelRequested?`, `driverEpoch?` |
50
+ | interrupt | `interruptId` | `runId`, `threadId`, `status`, `requestedAt`, `resolvedAt?`, `payload`, `response?` |
51
+ | metadata | `(namespace, key)` | `value` |
52
52
 
53
53
  - Timestamps are **epoch milliseconds** (`number`) in records. Store them
54
54
  however the engine prefers and convert in the mapper.
@@ -57,8 +57,8 @@ look records up by exactly these:
57
57
  conformance suite checks it.
58
58
  - Index `runs(threadId, status)`, `runs(threadId, startedAt)`, and
59
59
  `interrupts(threadId, requestedAt)` for the listing paths. If the backend
60
- implements `listReclaimable`, also index `runs(status, detachedSince)`; that
61
- is the query it runs.
60
+ implements `listReclaimable`, also index `runs(status, detachedSince)`. If it
61
+ implements `listByParentRun`, also index `runs(parentRunId, startedAt)`.
62
62
  - `run.error` is a structured `RunError` (`{ message: string, code?: string }`),
63
63
  not a bare string. `message` is the provider's prose; `code` is the stable,
64
64
  machine-branchable classification an operator filters and groups by. In a
@@ -105,8 +105,12 @@ history. They are engine-independent:
105
105
  automatic reclamation: `reapDetachedRuns` from `@tanstack/ai-sandbox` is the
106
106
  sweep that consumes it, and the application schedules that sweep. A store
107
107
  without this method cannot be reaped. `runs.findActiveRun` is required;
108
- `runs.listByThread` / `runs.listReclaimable` are optional: implement only
109
- what the app needs and leave the rest off the object.
108
+ `runs.listByThread`, `runs.listByParentRun`, and `runs.listReclaimable` are
109
+ optional: implement only what the app needs and leave the rest off the object.
110
+ `listByParentRun` returns the child runs for one `parentRunId`, oldest
111
+ `startedAt` first. `reconstructChat` uses that list to put subagent cards
112
+ back. `createOrResume` copies `parentRunId`, `subagentRunId`, and `name` on
113
+ the first insert and leaves them unchanged on resume.
110
114
 
111
115
  Row mappers omit absent optionals
112
116
  (`...(row.sandbox_key != null ? { sandboxKey: row.sandbox_key } : {})`) so
@@ -165,21 +169,44 @@ function createRunStore(db: Pool): RunStore {
165
169
  return {
166
170
  get,
167
171
  // Idempotent: an existing runId is returned untouched.
168
- async createOrResume({ runId, threadId, startedAt, status }) {
172
+ async createOrResume(input) {
173
+ const { runId, threadId, startedAt, status } = input
169
174
  const existing = await get(runId)
170
175
  if (existing) return existing
171
176
 
172
177
  await db.query(
173
- `INSERT INTO chat_runs (run_id, thread_id, status, started_at)
174
- VALUES ($1, $2, $3, $4)
178
+ `INSERT INTO chat_runs (
179
+ run_id, thread_id, status, started_at,
180
+ parent_run_id, subagent_run_id, name
181
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7)
175
182
  ON CONFLICT (run_id) DO NOTHING`,
176
- [runId, threadId, status ?? 'running', startedAt],
183
+ [
184
+ runId,
185
+ threadId,
186
+ status ?? 'running',
187
+ startedAt,
188
+ input.parentRunId ?? null,
189
+ input.subagentRunId ?? null,
190
+ input.name ?? null,
191
+ ],
177
192
  )
178
193
  // Re-read: a concurrent createOrResume may have won the race, and that
179
194
  // row is the authoritative one.
180
195
  const stored = await get(runId)
181
196
  return (
182
- stored ?? { runId, threadId, status: status ?? 'running', startedAt }
197
+ stored ?? {
198
+ runId,
199
+ threadId,
200
+ status: status ?? 'running',
201
+ startedAt,
202
+ ...(input.parentRunId !== undefined
203
+ ? { parentRunId: input.parentRunId }
204
+ : {}),
205
+ ...(input.subagentRunId !== undefined
206
+ ? { subagentRunId: input.subagentRunId }
207
+ : {}),
208
+ ...(input.name !== undefined ? { name: input.name } : {}),
209
+ }
183
210
  )
184
211
  },
185
212
  // ... update (no-op on unknown id; sandboxKey/detachedSince/
@@ -191,8 +218,9 @@ function createRunStore(db: Pool): RunStore {
191
218
  // error = patch.error.message and error_code = patch.error.code ?? null,
192
219
  // together in the same call),
193
220
  // findActiveRun (latest 'running', required), listByThread (ascending
194
- // by startedAt, optional), listReclaimable (status = 'running' AND
195
- // detachedSince <= now - ttlMs, inclusive cutoff, optional)
221
+ // by startedAt, optional), listByParentRun (children of parentRunId,
222
+ // ascending by startedAt, optional), listReclaimable (status = 'running'
223
+ // AND detachedSince <= now - ttlMs, inclusive cutoff, optional)
196
224
  }
197
225
  }
198
226
 
@@ -321,8 +349,9 @@ seven stores, so declare every intentional omission — a chat adapter skips the
321
349
  generation half above, and adds e.g. `'metadata'` if it drops that too. `skip`
322
350
  never accepts `'locks'`, which is not a store.
323
351
 
324
- If your recipe leaves an optional `runs` method (`listByThread`/
325
- `listReclaimable`) unimplemented, declare it separately with `skipMethods`, e.g.
352
+ If your recipe leaves `listByThread` or `listReclaimable` unimplemented,
353
+ declare it separately with `skipMethods`, for example
326
354
  `{ skipMethods: ['runs.listByThread'] }`. An omitted method that is not declared
327
- fails the suite instead of silently passing. `findActiveRun` is **not** in that
355
+ fails the suite instead of silently passing. Subagent support is optional: when
356
+ `listByParentRun` is absent, the subagent checks skip on their own. `findActiveRun` is **not** in that
328
357
  set — it is required, so there is nothing to declare.
@@ -80,12 +80,17 @@ export const chatRuns = sqliteTable(
80
80
  detachedSince: integer('detached_since'),
81
81
  cancelRequested: integer('cancel_requested', { mode: 'boolean' }),
82
82
  driverEpoch: integer('driver_epoch'),
83
+ parentRunId: text('parent_run_id'),
84
+ subagentRunId: text('subagent_run_id'),
85
+ name: text('name'),
83
86
  },
84
87
  (table) => [
85
88
  // Powers listReclaimable: status = 'running' AND detachedSince <= cutoff.
86
89
  index('chat_runs_status_detached').on(table.status, table.detachedSince),
87
90
  // Powers listByThread and findActiveRun.
88
91
  index('chat_runs_thread_started').on(table.threadId, table.startedAt),
92
+ // Powers listByParentRun: children of one parent, oldest startedAt first.
93
+ index('chat_runs_parent_started').on(table.parentRunId, table.startedAt),
89
94
  ],
90
95
  )
91
96
 
@@ -135,7 +140,8 @@ behind.
135
140
  `boolean()` for `cancelRequested`, and `varchar(..., { length: 255 })` for the
136
141
  primary-key columns. The store bodies below are identical across all three,
137
142
  only `onConflictDoUpdate` becomes `onDuplicateKeyUpdate` on MySQL, and the
138
- `(status, detachedSince)` / `(threadId, startedAt)` indexes carry over as is.
143
+ `(status, detachedSince)`, `(threadId, startedAt)`, and
144
+ `(parentRunId, startedAt)` indexes carry over as is.
139
145
 
140
146
  ## 3. Write `src/lib/chat-persistence.ts`
141
147
 
@@ -190,6 +196,9 @@ function mapRun(row: typeof chatRuns.$inferSelect): RunRecord {
190
196
  ? { cancelRequested: row.cancelRequested }
191
197
  : {}),
192
198
  ...(row.driverEpoch != null ? { driverEpoch: row.driverEpoch } : {}),
199
+ ...(row.parentRunId != null ? { parentRunId: row.parentRunId } : {}),
200
+ ...(row.subagentRunId != null ? { subagentRunId: row.subagentRunId } : {}),
201
+ ...(row.name != null ? { name: row.name } : {}),
193
202
  }
194
203
  }
195
204
 
@@ -247,20 +256,45 @@ function createRunStore(db: Db): RunStore {
247
256
  get,
248
257
  // Idempotent: an existing runId is returned untouched so resume and
249
258
  // double-submit are safe.
250
- async createOrResume({ runId, threadId, startedAt, status }) {
259
+ async createOrResume(input) {
260
+ const { runId, threadId, startedAt, status } = input
251
261
  const existing = await get(runId)
252
262
  if (existing) return existing
253
263
 
254
264
  await db
255
265
  .insert(chatRuns)
256
- .values({ runId, threadId, status: status ?? 'running', startedAt })
266
+ .values({
267
+ runId,
268
+ threadId,
269
+ status: status ?? 'running',
270
+ startedAt,
271
+ ...(input.parentRunId !== undefined
272
+ ? { parentRunId: input.parentRunId }
273
+ : {}),
274
+ ...(input.subagentRunId !== undefined
275
+ ? { subagentRunId: input.subagentRunId }
276
+ : {}),
277
+ ...(input.name !== undefined ? { name: input.name } : {}),
278
+ })
257
279
  .onConflictDoNothing({ target: chatRuns.runId })
258
280
 
259
281
  // Re-read rather than trusting the insert: a concurrent createOrResume
260
282
  // may have won the race, and that row is the authoritative one.
261
283
  const stored = await get(runId)
262
284
  return (
263
- stored ?? { runId, threadId, status: status ?? 'running', startedAt }
285
+ stored ?? {
286
+ runId,
287
+ threadId,
288
+ status: status ?? 'running',
289
+ startedAt,
290
+ ...(input.parentRunId !== undefined
291
+ ? { parentRunId: input.parentRunId }
292
+ : {}),
293
+ ...(input.subagentRunId !== undefined
294
+ ? { subagentRunId: input.subagentRunId }
295
+ : {}),
296
+ ...(input.name !== undefined ? { name: input.name } : {}),
297
+ }
264
298
  )
265
299
  },
266
300
  // Patching an unknown runId is a no-op: never throws, never inserts.
@@ -314,6 +348,16 @@ function createRunStore(db: Db): RunStore {
314
348
  .orderBy(asc(chatRuns.startedAt))
315
349
  return rows.map(mapRun)
316
350
  },
351
+ // Optional. Child runs for one parent, oldest startedAt first.
352
+ // reconstructChat uses this list to put subagent cards back.
353
+ async listByParentRun(parentRunId) {
354
+ const rows = await db
355
+ .select()
356
+ .from(chatRuns)
357
+ .where(eq(chatRuns.parentRunId, parentRunId))
358
+ .orderBy(asc(chatRuns.startedAt))
359
+ return rows.map(mapRun)
360
+ },
317
361
  // Optional; still-running runs detached at or before the cutoff. Uses the
318
362
  // (status, detachedSince) index. The cutoff is inclusive.
319
363
  async listReclaimable({ now, ttlMs }) {
@@ -531,11 +575,12 @@ seven stores, so a chat adapter declares the generation half it omits; drop the
531
575
  `skip` once you add those tables. `skip` never accepts `'locks'`, which is not a
532
576
  store.
533
577
 
534
- If your recipe leaves an optional `runs` method
535
- (`listByThread`/`listReclaimable`) unimplemented, declare it
536
- with `skipMethods`, e.g. `{ skipMethods: ['runs.listByThread'] }`. An
537
- omitted method that is not declared fails the suite instead of silently
538
- passing.
578
+ If your recipe leaves `listByThread` or `listReclaimable` unimplemented,
579
+ declare it with `skipMethods`, for example
580
+ `{ skipMethods: ['runs.listByThread'] }`. An omitted method that is not declared
581
+ fails the suite instead of silently passing. Subagent support is optional: when
582
+ `listByParentRun` is absent, the subagent checks skip on their own and need no
583
+ entry.
539
584
 
540
585
  ## Only if you are publishing this as a package
541
586
 
@@ -67,9 +67,14 @@ model ChatRun {
67
67
  detachedSince BigInt? @map("detached_since")
68
68
  cancelRequested Boolean? @map("cancel_requested")
69
69
  driverEpoch Int? @map("driver_epoch")
70
+ parentRunId String? @map("parent_run_id")
71
+ subagentRunId String? @map("subagent_run_id")
72
+ name String?
70
73
 
71
74
  @@index([threadId, status])
72
75
  @@index([threadId, startedAt])
76
+ // Powers listByParentRun: children of one parent, oldest startedAt first.
77
+ @@index([parentRunId, startedAt])
73
78
  // Powers listReclaimable: status = 'running' AND detachedSince <= cutoff.
74
79
  @@index([status, detachedSince])
75
80
  @@map("chat_runs")
@@ -204,6 +209,9 @@ function mapRun(row: ChatRun): RunRecord {
204
209
  ? { cancelRequested: row.cancelRequested }
205
210
  : {}),
206
211
  ...(row.driverEpoch != null ? { driverEpoch: row.driverEpoch } : {}),
212
+ ...(row.parentRunId != null ? { parentRunId: row.parentRunId } : {}),
213
+ ...(row.subagentRunId != null ? { subagentRunId: row.subagentRunId } : {}),
214
+ ...(row.name != null ? { name: row.name } : {}),
207
215
  }
208
216
  }
209
217
 
@@ -250,7 +258,8 @@ function createRunStore(db: PrismaClient): RunStore {
250
258
  },
251
259
  // An empty `update` is Prisma's ON CONFLICT DO NOTHING: an existing runId
252
260
  // comes back untouched, so resume and double-submit are safe.
253
- async createOrResume({ runId, threadId, startedAt, status }) {
261
+ async createOrResume(input) {
262
+ const { runId, threadId, startedAt, status } = input
254
263
  const row = await db.chatRun.upsert({
255
264
  where: { runId },
256
265
  create: {
@@ -258,6 +267,13 @@ function createRunStore(db: PrismaClient): RunStore {
258
267
  threadId,
259
268
  status: status ?? 'running',
260
269
  startedAt: BigInt(startedAt),
270
+ ...(input.parentRunId !== undefined
271
+ ? { parentRunId: input.parentRunId }
272
+ : {}),
273
+ ...(input.subagentRunId !== undefined
274
+ ? { subagentRunId: input.subagentRunId }
275
+ : {}),
276
+ ...(input.name !== undefined ? { name: input.name } : {}),
261
277
  },
262
278
  update: {},
263
279
  })
@@ -314,6 +330,15 @@ function createRunStore(db: PrismaClient): RunStore {
314
330
  })
315
331
  return rows.map(mapRun)
316
332
  },
333
+ // Optional. Child runs for one parent, oldest startedAt first.
334
+ // reconstructChat uses this list to put subagent cards back.
335
+ async listByParentRun(parentRunId) {
336
+ const rows = await db.chatRun.findMany({
337
+ where: { parentRunId },
338
+ orderBy: { startedAt: 'asc' },
339
+ })
340
+ return rows.map(mapRun)
341
+ },
317
342
  // Optional; still-running runs detached at or before the cutoff. Uses
318
343
  // the (status, detachedSince) index. The cutoff is inclusive.
319
344
  async listReclaimable({ now, ttlMs }) {
@@ -489,11 +514,12 @@ provided; the suite also covers the three generation stores, so declare those as
489
514
  skipped until you add them. `skip` never accepts `'locks'`, which is not a
490
515
  store.
491
516
 
492
- If your recipe leaves an optional `runs` method
493
- (`listByThread`/`listReclaimable`) unimplemented, declare it
494
- with `skipMethods`, e.g. `{ skipMethods: ['runs.listByThread'] }`. An
495
- omitted method that is not declared fails the suite instead of silently
496
- passing.
517
+ If your recipe leaves `listByThread` or `listReclaimable` unimplemented,
518
+ declare it with `skipMethods`, for example
519
+ `{ skipMethods: ['runs.listByThread'] }`. An omitted method that is not declared
520
+ fails the suite instead of silently passing. Subagent support is optional: when
521
+ `listByParentRun` is absent, the subagent checks skip on their own and need no
522
+ entry.
497
523
 
498
524
  ## Only if you are publishing this as a package
499
525
 
@@ -113,28 +113,30 @@ package name.
113
113
  resolve.
114
114
 
115
115
  Four methods are required (`createOrResume` / `update` / `get` /
116
- `findActiveRun`). Two are optional: implement only the ones your backend needs,
116
+ `findActiveRun`). Three are optional: implement only the ones your backend needs,
117
117
  and leave the rest off the object entirely (not `undefined`, just absent). A
118
118
  four-method `RunStore` is a fully valid backend.
119
119
 
120
- `withPersistence` itself calls **none** of the three non-`createOrResume`/`update`
121
- query methods, so leaving both optional ones off costs nothing in the middleware.
122
- Their consumers are elsewhere, and each absence disables exactly one feature:
120
+ `withPersistence` calls `createOrResume` and `update`. The query methods have
121
+ other consumers. Each missing optional method disables one feature:
123
122
 
124
- | method | consumer | absent ⇒ |
125
- | ----------------- | --------------------------------------------------------- | ------------------------------------------------------------------------ |
126
- | `findActiveRun` | `reconstruct.ts` (`stores.runs?.findActiveRun(threadId)`) | required — cannot be absent; stubbing it to `null` silently kills rejoin |
127
- | `listReclaimable` | `reapDetachedRuns` in `@tanstack/ai-sandbox` | the store cannot be reaped at all |
128
- | `listByThread` | application code — nothing in the framework calls it | nothing framework-side breaks |
123
+ | method | consumer | absent means |
124
+ | ----------------- | --------------------------------------------------------- | ---------------------------------------------------------- |
125
+ | `findActiveRun` | `reconstruct.ts` (`stores.runs?.findActiveRun(threadId)`) | required. A `null` stub hides a live run |
126
+ | `listReclaimable` | `reapDetachedRuns` in `@tanstack/ai-sandbox` | the store cannot be reaped at all |
127
+ | `listByThread` | `reconstructChat`, when the transcript has tool calls | the cards of children that a tool call started stay absent |
128
+ | `listByParentRun` | `reconstructChat` | a reload shows the saved text, and the cards stay absent |
129
129
 
130
- Consumers of the two OPTIONAL methods feature-detect with `store.method?.(...)`
130
+ Consumers of the optional methods feature-detect with `store.method?.(...)`
131
131
  and degrade rather than throwing. `findActiveRun` is required, so nothing
132
132
  feature-detects it.
133
133
 
134
134
  The conformance testkit does not feature-detect. An optional method that is
135
135
  missing and not declared in `skipMethods` fails the suite, so an omission is
136
136
  always a choice you made on purpose rather than a check that quietly did not
137
- run. Declare yours and the suite reports them as skipped with a reason:
137
+ run. The one exception is `listByParentRun`: subagent support is optional, so
138
+ those checks skip on their own. Declare yours and the suite reports them as
139
+ skipped with a reason:
138
140
 
139
141
  ```ts
140
142
  import { runPersistenceConformance } from '@tanstack/ai-persistence/testkit'
@@ -155,6 +157,9 @@ interface RunStore {
155
157
  createOrResume: (
156
158
  input: Pick<RunRecord, 'runId' | 'threadId' | 'startedAt'> & {
157
159
  status?: RunStatus
160
+ parentRunId?: string
161
+ subagentRunId?: string
162
+ name?: string
158
163
  },
159
164
  ) => Promise<RunRecord>
160
165
  update: (
@@ -178,6 +183,7 @@ interface RunStore {
178
183
 
179
184
  // Optional
180
185
  listByThread?: (threadId: string) => Promise<Array<RunRecord>>
186
+ listByParentRun?: (parentRunId: string) => Promise<Array<RunRecord>>
181
187
  listReclaimable?: (opts: {
182
188
  now: number
183
189
  ttlMs: number
@@ -280,15 +286,20 @@ store through `update`/`get` — but `cancelRequested` must round-trip
280
286
  faithfully (previous section) for the durable path to work at all.
281
287
 
282
288
  - **`createOrResume`** (required): if `runId` exists, return it **unchanged**,
283
- including its stored `usage`, and ignore the passed `threadId` / `startedAt` /
284
- `status`. Resuming a run does not reset `startedAt` or overwrite its current
285
- status. Idempotent retries and double-submit depend on this. `status` defaults
286
- to `'running'` on first creation.
289
+ including its stored `usage`, and ignore the passed `threadId`, `startedAt`,
290
+ `status`, `parentRunId`, `subagentRunId`, and `name`. Resuming a run does not
291
+ reset `startedAt` or overwrite its current status. Idempotent retries and
292
+ double-submit depend on this. `status` defaults to `'running'` on first
293
+ creation. The three link fields are copied only on the first insert.
287
294
  - **`update`** (required): missing `runId` is a **no-op** (do not throw, do not
288
295
  insert).
289
296
  - **`get`** (required): current record, or `null` when unknown.
290
297
  - **`listByThread`** (optional): every run for `threadId`, ascending by
291
- `startedAt`. Only needed to render a thread's past agent activity.
298
+ `startedAt`. `reconstructChat` calls it to find the parent runs of children
299
+ that a tool call started.
300
+ - **`listByParentRun`** (optional): child runs for `parentRunId`, ascending by
301
+ `startedAt`. `reconstructChat` uses this list to put subagent cards back.
302
+ Omit the method and a reload shows the saved text. The cards stay absent.
292
303
  - **`listReclaimable`** (optional): runs where `status === 'running'` AND
293
304
  `detachedSince` is set AND `detachedSince <= now - ttlMs`. The cutoff is
294
305
  inclusive: a run detached exactly at the cutoff qualifies. This is a query, not
@@ -308,9 +319,15 @@ faithfully (previous section) for the durable path to work at all.
308
319
  one release cycle and cost precisely that, which is why it is required now.
309
320
 
310
321
  Capability tiers belong at the STORE level (omit `runs` entirely and declare
311
- `ChatTranscriptStores`), not the method level — never ship a `RunStore` with a
312
- stubbed method. The two list queries above are the only method-level options,
313
- and each must be declared via `skipMethods` when absent.
322
+ `ChatTranscriptStores`), not the method level. Never ship a `RunStore` with a
323
+ stubbed method. The list queries above are the only method-level options,
324
+ and each must be declared via `skipMethods` when absent, except
325
+ `listByParentRun`: without it the subagent checks skip on their own.
326
+
327
+ A subagent child run also stores `parentRunId`, `subagentRunId`, and `name`.
328
+ `createOrResume` writes them on the first insert. A later call for the same
329
+ `runId` leaves them unchanged. If the caller omits a field, omit it on the
330
+ record. Do not store `''` for a missing field.
314
331
 
315
332
  ### `InterruptStore`
316
333
 
@@ -468,17 +485,17 @@ in `skip` fails loudly.
468
485
  pass `'locks'`** — it is not a state store and the suite does not cover it.
469
486
 
470
487
  **`skipMethods` (declare-or-fail for optional `RunStore` methods).** A backend
471
- that omits an OPTIONAL `RunStore` method (`listByThread`, `listReclaimable` —
472
- `findActiveRun` is required and cannot be declared away) must declare it in
473
- `skipMethods` as `'runs.<method>'`, e.g.
474
- `skipMethods: ['runs.listByThread', 'runs.listReclaimable']`. An omitted
475
- method that is NOT declared throws with an actionable message instead of
476
- silently reporting a pass; a declared one is reported as a SKIPPED vitest
477
- case, never as a pass. A case that did not run must never be
488
+ that omits `listByThread` or `listReclaimable` must declare it. `findActiveRun`
489
+ is required. Declare the omission as `'runs.<method>'`, for example
490
+ `skipMethods: ['runs.listByThread', 'runs.listReclaimable']`. Subagent support is
491
+ optional: when `listByParentRun` is absent, the subagent checks (the link fields
492
+ and the child listing) skip on their own and need no entry.
493
+ An omitted method that is NOT declared throws with an actionable message
494
+ instead of silently reporting a pass. A declared one is reported as a SKIPPED
495
+ vitest case, never as a pass. A case that did not run must never be
478
496
  indistinguishable from one that did. See
479
- `examples/ts-react-chat/src/lib/sqlite-persistence.test.ts` for a worked
480
- example: it declares `skipMethods: ['runs.listByThread']` only, keeping both
481
- `findActiveRun` and `listReclaimable` under test.
497
+ `examples/ts-react-chat/src/lib/sqlite-persistence.test.ts`. It implements every
498
+ run method, so it declares no `skipMethods`.
482
499
 
483
500
  Reference implementation: `memoryPersistence()` in `@tanstack/ai-persistence`.
484
501
 
package/src/memory.ts CHANGED
@@ -60,6 +60,9 @@ class MemoryRunStore implements RunStore {
60
60
  threadId: string
61
61
  status?: RunRecord['status']
62
62
  startedAt: number
63
+ parentRunId?: string
64
+ subagentRunId?: string
65
+ name?: string
63
66
  }): Promise<RunRecord> {
64
67
  const existing = this.runs.get(input.runId)
65
68
  if (existing) return Promise.resolve(existing)
@@ -68,6 +71,13 @@ class MemoryRunStore implements RunStore {
68
71
  threadId: input.threadId,
69
72
  status: input.status ?? 'running',
70
73
  startedAt: input.startedAt,
74
+ ...(input.parentRunId !== undefined
75
+ ? { parentRunId: input.parentRunId }
76
+ : {}),
77
+ ...(input.subagentRunId !== undefined
78
+ ? { subagentRunId: input.subagentRunId }
79
+ : {}),
80
+ ...(input.name !== undefined ? { name: input.name } : {}),
71
81
  }
72
82
  this.runs.set(record.runId, record)
73
83
  return Promise.resolve(record)
@@ -107,6 +117,12 @@ class MemoryRunStore implements RunStore {
107
117
  .sort((a, b) => a.startedAt - b.startedAt)
108
118
  return Promise.resolve(matching)
109
119
  }
120
+ listByParentRun(parentRunId: string): Promise<Array<RunRecord>> {
121
+ const matching = [...this.runs.values()]
122
+ .filter((run) => run.parentRunId === parentRunId)
123
+ .sort((a, b) => a.startedAt - b.startedAt)
124
+ return Promise.resolve(matching)
125
+ }
110
126
  listReclaimable(opts: {
111
127
  now: number
112
128
  ttlMs: number