@tanstack/ai-persistence 0.6.3 → 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.
@@ -19,7 +19,9 @@
19
19
  * a SKIPPED case, never as a pass. Silent gaps are not allowed: a case that did
20
20
  * not run must never be indistinguishable from one that did. A chat-only
21
21
  * adapter therefore passes `skip: ['generationRuns', 'artifacts', 'blobs']`,
22
- * and a generation-only one skips the four state stores.
22
+ * and a generation-only one skips the four state stores. `listByParentRun` is
23
+ * the exception. Subagent support is opt-in, its cases skip on their own, and
24
+ * a `'runs.listByParentRun'` entry is accepted but has no effect.
23
25
  *
24
26
  * NOT COVERED HERE: the four durable-run fields on `RunRecord` (`sandboxKey`,
25
27
  * `detachedSince`, `cancelRequested`, `driverEpoch`). They exist for durable
@@ -52,7 +54,10 @@ type MakePersistence = () => Promise<AIPersistence> | AIPersistence
52
54
  * policy in `../types.ts`. It was optional for one release cycle and silently
53
55
  * disabled reconnect on every backend that had not caught up.
54
56
  */
55
- type OptionalRunStoreMethod = 'listByThread' | 'listReclaimable'
57
+ type OptionalRunStoreMethod =
58
+ | 'listByThread'
59
+ | 'listByParentRun'
60
+ | 'listReclaimable'
56
61
 
57
62
  /** Dotted `store.method` key a backend passes to declare an omitted method. */
58
63
  export type PersistenceConformanceMethodKey = `runs.${OptionalRunStoreMethod}`
@@ -105,6 +110,9 @@ export interface PersistenceConformanceOptions {
105
110
  * OPTIONAL store methods this backend intentionally does not implement, as
106
111
  * `'runs.listByThread'` and friends. A method that is absent and NOT listed
107
112
  * here fails the suite; a listed one is reported as a skipped case.
113
+ * `listByParentRun` is the exception. Subagent support is opt-in, its cases
114
+ * skip on their own, and a `'runs.listByParentRun'` entry is accepted but
115
+ * has no effect.
108
116
  */
109
117
  skipMethods?: Array<PersistenceConformanceMethodKey>
110
118
  }
@@ -170,6 +178,15 @@ export function runPersistenceConformance(
170
178
  )
171
179
  }
172
180
 
181
+ // Subagent support is opt-in. A store without `listByParentRun` skips the
182
+ // subagent checks (vitest reports them as skipped) and needs no
183
+ // `skipMethods` entry.
184
+ function supportsSubagents(
185
+ runs: RunStore,
186
+ ): runs is RunStore & Required<Pick<RunStore, 'listByParentRun'>> {
187
+ return typeof runs.listByParentRun === 'function'
188
+ }
189
+
173
190
  describe('messages', () => {
174
191
  // One-argument loadThread is the full-thread contract. Paging
175
192
  // (`limit` / `before`) is an optional hint; this suite does not require it.
@@ -368,6 +385,66 @@ export function runPersistenceConformance(
368
385
  })
369
386
  })
370
387
 
388
+ // `parentRunId`, `subagentRunId`, and `name` travel with createOrResume.
389
+ // A parent row omits them. A child row keeps the values from the first
390
+ // insert. A later createOrResume for that runId must not overwrite them.
391
+ // Only a store with subagent support (`listByParentRun`) must keep them.
392
+ it('round-trips subagent link fields and ignores them on resume', async (ctx) => {
393
+ const store = resolveStore('runs')
394
+ if (!store) return ctx.skip('store not provided')
395
+ if (!supportsSubagents(store)) {
396
+ return ctx.skip('runs.listByParentRun not implemented')
397
+ }
398
+
399
+ const parent = await store.createOrResume({
400
+ runId: 'lp-parent',
401
+ threadId: 'lp-thread',
402
+ startedAt: 1,
403
+ })
404
+ expect(parent.parentRunId).toBeUndefined()
405
+ expect(parent.subagentRunId).toBeUndefined()
406
+ expect(parent.name).toBeUndefined()
407
+
408
+ const child = await store.createOrResume({
409
+ runId: 'lp-child',
410
+ threadId: 'subagent:lp-child',
411
+ startedAt: 2,
412
+ parentRunId: 'lp-parent',
413
+ subagentRunId: 'lp-sub',
414
+ name: 'researcher',
415
+ })
416
+ expect(child).toMatchObject({
417
+ runId: 'lp-child',
418
+ threadId: 'subagent:lp-child',
419
+ startedAt: 2,
420
+ parentRunId: 'lp-parent',
421
+ subagentRunId: 'lp-sub',
422
+ name: 'researcher',
423
+ })
424
+
425
+ const resumed = await store.createOrResume({
426
+ runId: 'lp-child',
427
+ threadId: 'lp-other-thread',
428
+ startedAt: 99,
429
+ parentRunId: 'lp-other-parent',
430
+ subagentRunId: 'lp-other-sub',
431
+ name: 'writer',
432
+ })
433
+ expect(resumed).toMatchObject({
434
+ runId: 'lp-child',
435
+ threadId: 'subagent:lp-child',
436
+ startedAt: 2,
437
+ parentRunId: 'lp-parent',
438
+ subagentRunId: 'lp-sub',
439
+ name: 'researcher',
440
+ })
441
+ expect(await store.get('lp-child')).toMatchObject({
442
+ parentRunId: 'lp-parent',
443
+ subagentRunId: 'lp-sub',
444
+ name: 'researcher',
445
+ })
446
+ })
447
+
371
448
  it('findActiveRun returns the most recent running run for a thread', async (ctx) => {
372
449
  const store = resolveStore('runs')
373
450
  if (!store) return ctx.skip('store not provided')
@@ -443,6 +520,52 @@ export function runPersistenceConformance(
443
520
  expect(listed.map((r) => r.runId)).toEqual(['lt-a', 'lt-b'])
444
521
  })
445
522
 
523
+ // `listByParentRun` is optional and is skipped when absent. A store that
524
+ // has it returns only that parent's children, oldest `startedAt` first,
525
+ // and [] for an unknown parent.
526
+ it('lists child runs by parent when supported', async (ctx) => {
527
+ const runs = resolveStore('runs')
528
+ if (!runs) return ctx.skip('store not provided')
529
+ if (!supportsSubagents(runs)) {
530
+ return ctx.skip('runs.listByParentRun not implemented')
531
+ }
532
+
533
+ await runs.createOrResume({
534
+ runId: 'lbp-parent',
535
+ threadId: 'lbp-thread',
536
+ startedAt: 1,
537
+ })
538
+ await runs.createOrResume({
539
+ runId: 'lbp-b',
540
+ threadId: 'subagent:lbp-b',
541
+ startedAt: 20,
542
+ parentRunId: 'lbp-parent',
543
+ subagentRunId: 'lbp-sub-b',
544
+ name: 'seo',
545
+ })
546
+ await runs.createOrResume({
547
+ runId: 'lbp-a',
548
+ threadId: 'subagent:lbp-a',
549
+ startedAt: 10,
550
+ parentRunId: 'lbp-parent',
551
+ subagentRunId: 'lbp-sub-a',
552
+ name: 'researcher',
553
+ })
554
+ await runs.createOrResume({
555
+ runId: 'lbp-other',
556
+ threadId: 'subagent:lbp-other',
557
+ startedAt: 5,
558
+ parentRunId: 'lbp-elsewhere',
559
+ subagentRunId: 'lbp-sub-other',
560
+ name: 'writer',
561
+ })
562
+
563
+ const listed = await runs.listByParentRun('lbp-parent')
564
+ expect(listed.map((run) => run.runId)).toEqual(['lbp-a', 'lbp-b'])
565
+ expect(listed.map((run) => run.name)).toEqual(['researcher', 'seo'])
566
+ expect(await runs.listByParentRun('lbp-missing')).toEqual([])
567
+ })
568
+
446
569
  // `listReclaimable` is optional on the RunStore contract; a declared
447
570
  // omission is reported as skipped and an undeclared one fails. Any
448
571
  // backend that has it must