@rulvar/testing 1.31.0 → 1.33.0

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/index.d.ts CHANGED
@@ -140,6 +140,23 @@ interface VcrRow {
140
140
  * unstamped entry reads as recorded before the stamp existed).
141
141
  */
142
142
  usageSemantics?: string;
143
+ /**
144
+ * Zero based per `(adapterId, requestHash)` call counter, claimed
145
+ * synchronously when the recorded `stream()` call was made
146
+ * (v1.31.0 review P2): rows are appended in COMPLETION order, so
147
+ * without this number two concurrent identical live calls that
148
+ * finish out of order would swap callers at replay, which hands
149
+ * occurrences out in caller order. Replay sorts same hash rows by
150
+ * it when every row of the group carries one; absent in cassettes
151
+ * recorded before v1.32.0, whose same hash rows keep file order.
152
+ * An aborted or failed call claims a number but appends no row, so
153
+ * gaps in the numbering are valid. An appending `record()` session
154
+ * seeds its counters past the numbers already on disk, so the
155
+ * numbering continues across sequential sessions; a duplicate
156
+ * number inside a fully numbered group refuses replay as ambiguous
157
+ * (v1.32.0 review P2).
158
+ */
159
+ occurrence?: number;
143
160
  requestHash: string;
144
161
  /** Redacted canonical request, for humans and drift review. */
145
162
  request: unknown;
@@ -177,8 +194,24 @@ declare function requestHash(req: ChatRequest): string;
177
194
  * (a requested abort or a truncated read), throws, or violates the
178
195
  * adapter contract (a second terminal, data after the terminal)
179
196
  * appends nothing, so a cassette row is always the record of one
180
- * completed exchange (v1.28.0 review P2). The wrapped adapters are
181
- * drop-in: same ids, providers, caps, and event streams.
197
+ * completed exchange (v1.28.0 review P2). Every call also claims a
198
+ * per `(adapterId, requestHash)` occurrence number synchronously in
199
+ * the `stream()` call itself and persists it on the completed row,
200
+ * so replay can restore the caller to response association even when
201
+ * concurrent identical calls completed out of order (v1.31.0 review
202
+ * P2). A later `record()` call on the same cassette file is an
203
+ * appending session: the existing file is read and validated first
204
+ * (a target that was never a cassette, a header whose hashVersion is
205
+ * not the one this build records under, and a file whose occurrence
206
+ * numbering is already ambiguous all refuse with a typed
207
+ * ConfigError), and every hash counter is seeded past the numbers
208
+ * already on disk, so the numbering continues where the file left
209
+ * off instead of restarting at zero (v1.32.0 review P2). One
210
+ * recorder session may be active on a cassette at a time: two
211
+ * concurrently constructed recorders seed identically and claim
212
+ * colliding numbers, which replay refuses as ambiguous instead of
213
+ * silently serving either order. The wrapped adapters are drop-in:
214
+ * same ids, providers, caps, and event streams.
182
215
  */
183
216
  declare function record(options: {
184
217
  adapters: ProviderAdapter[];
@@ -189,8 +222,9 @@ declare function record(options: {
189
222
  * Typed hermetic-miss error; onMiss: 'throw' raises it on any request
190
223
  * without a servable row. `recordedOccurrences` above zero means the
191
224
  * hash WAS recorded but every occurrence is already consumed (replay
192
- * serves each recorded exchange once, in file order); absent or zero
193
- * means the request was never recorded at all (v1.29.0 review P2).
225
+ * serves each recorded exchange once, in recorded order); absent or
226
+ * zero means the request was never recorded at all (v1.29.0 review
227
+ * P2).
194
228
  */
195
229
  declare class VcrMissError extends Error {
196
230
  readonly requestHash: string;
@@ -211,7 +245,8 @@ interface VcrCassette {
211
245
  * instead of being read as v1. Every documented header field (kind,
212
246
  * v, an integer hashVersion, a date string recordedAt) and row field
213
247
  * (adapterId, model, requestHash, request, caps, events, an optional
214
- * string provider, an optional nonempty usageSemantics) is checked
248
+ * string provider, an optional nonempty usageSemantics, an optional
249
+ * nonnegative integer occurrence) is checked
215
250
  * here, and the nested structures are validated in depth (v1.30.0
216
251
  * review P3): the request must be a plain object, every event must
217
252
  * be a member of the canonical ChatEvent vocabulary with its
@@ -232,11 +267,21 @@ declare function readCassette(path: string): VcrCassette;
232
267
  * hermetic CI mode; `'passthrough'` forwards unrecorded requests to the
233
268
  * matching live adapter in `adapters` (a development convenience only).
234
269
  *
235
- * Repeated hashes replay in file order (v1.29.0 review P2): rows
236
- * sharing a `(adapterId, requestHash)` key form an ordered occurrence
237
- * list, and every `stream()` call consumes exactly one occurrence,
238
- * allocated synchronously inside the call itself, so two concurrent
239
- * identical requests can never be served the same recorded exchange.
270
+ * Repeated hashes replay as ordered occurrences (v1.29.0 review P2):
271
+ * rows sharing a `(adapterId, requestHash)` key form an ordered
272
+ * occurrence list, and every `stream()` call consumes exactly one
273
+ * occurrence, allocated synchronously inside the call itself, so two
274
+ * concurrent identical requests can never be served the same
275
+ * recorded exchange. The list is sorted by the recorded `occurrence`
276
+ * numbers when every row of the group carries one, so concurrent
277
+ * identical calls whose live completions were appended out of order
278
+ * still replay to the callers that made them (v1.31.0 review P2); a
279
+ * group with any unnumbered row (recorded before v1.32.0) keeps file
280
+ * order. A duplicate occurrence inside a fully numbered group
281
+ * refuses the whole cassette with a typed ConfigError naming the
282
+ * adapter and hash: it means two recorder sessions wrote the file
283
+ * concurrently, and serving either order would hand a caller the
284
+ * wrong exchange (v1.32.0 review P2).
240
285
  * A call after the last occurrence is a miss: under `onMiss: 'throw'`
241
286
  * it raises a VcrMissError whose `recordedOccurrences` says the hash
242
287
  * WAS recorded but is exhausted, and under `'passthrough'` it
@@ -257,7 +302,13 @@ declare function readCassette(path: string): VcrCassette;
257
302
  * both declarations; a conflict refuses with a typed ConfigError
258
303
  * before anything is served. A cassette recorded before v1.31.0
259
304
  * stores no usageSemantics, so its replays stamp nothing (documented
260
- * historical laxity).
305
+ * historical laxity). Under `onMiss: 'passthrough'` the recorded
306
+ * declarations must also match the live adapter's, absent versus
307
+ * present included, because a live served miss is journaled under
308
+ * the wrapper's declarations; a mismatch refuses at construction
309
+ * (v1.31.0 review P2). An adapter with no recorded rows keeps the
310
+ * live adapter's own declarations, so the wrapper stays a metadata
311
+ * preserving drop in.
261
312
  */
262
313
  declare function replay(options: {
263
314
  cassette: string;
package/dist/index.js CHANGED
@@ -330,69 +330,144 @@ function headerLine() {
330
330
  });
331
331
  }
332
332
  /**
333
+ * Groups rows by `(adapterId, requestHash)` and orders every fully
334
+ * numbered group by its recorded occurrence numbers. Same hash rows
335
+ * sit in the file in COMPLETION order; when every row of a group
336
+ * carries the occurrence number claimed at stream call time, the
337
+ * group is served in that order instead, so concurrent identical
338
+ * calls that finished out of order still replay to the callers that
339
+ * made them (v1.31.0 review P2). A group with any unnumbered row
340
+ * (recorded before v1.32.0) keeps file order, and gaps in the
341
+ * numbering (an aborted or failed call claims a number but appends
342
+ * no row) are valid. A DUPLICATE number inside a fully numbered
343
+ * group refuses the whole cassette: it means two recorder sessions
344
+ * wrote the file concurrently (the documented contract is one active
345
+ * recorder per cassette), and serving either order would silently
346
+ * hand a caller the wrong exchange (v1.32.0 review P2). Both replay
347
+ * and an appending record session group through here, so the refusal
348
+ * fires before anything is served or appended.
349
+ */
350
+ function groupRows(rows, cassette) {
351
+ const byAdapter = /* @__PURE__ */ new Map();
352
+ for (const row of rows) {
353
+ const forAdapter = byAdapter.get(row.adapterId) ?? /* @__PURE__ */ new Map();
354
+ const occurrences = forAdapter.get(row.requestHash) ?? [];
355
+ occurrences.push(row);
356
+ forAdapter.set(row.requestHash, occurrences);
357
+ byAdapter.set(row.adapterId, forAdapter);
358
+ }
359
+ for (const [adapterId, forAdapter] of byAdapter) for (const [hash, occurrences] of forAdapter) {
360
+ if (!occurrences.every((row) => row.occurrence !== void 0)) continue;
361
+ occurrences.sort((a, b) => (a.occurrence ?? 0) - (b.occurrence ?? 0));
362
+ for (let index = 1; index < occurrences.length; index += 1) {
363
+ const number = occurrences[index]?.occurrence;
364
+ if (number !== void 0 && number === occurrences[index - 1]?.occurrence) throw new ConfigError(`${cassette} records occurrence ${String(number)} twice for adapter '${adapterId}' hash ${hash.slice(0, 12)}; two recorder sessions likely wrote this cassette concurrently, so the replay order would be ambiguous; record the cassette again`);
365
+ }
366
+ }
367
+ return byAdapter;
368
+ }
369
+ /**
333
370
  * Wraps live adapters for recording: every stream that completes with
334
371
  * exactly one terminal event (finish or error) appends one redacted
335
372
  * row to the cassette JSONL. A stream that ends without a terminal
336
373
  * (a requested abort or a truncated read), throws, or violates the
337
374
  * adapter contract (a second terminal, data after the terminal)
338
375
  * appends nothing, so a cassette row is always the record of one
339
- * completed exchange (v1.28.0 review P2). The wrapped adapters are
340
- * drop-in: same ids, providers, caps, and event streams.
376
+ * completed exchange (v1.28.0 review P2). Every call also claims a
377
+ * per `(adapterId, requestHash)` occurrence number synchronously in
378
+ * the `stream()` call itself and persists it on the completed row,
379
+ * so replay can restore the caller to response association even when
380
+ * concurrent identical calls completed out of order (v1.31.0 review
381
+ * P2). A later `record()` call on the same cassette file is an
382
+ * appending session: the existing file is read and validated first
383
+ * (a target that was never a cassette, a header whose hashVersion is
384
+ * not the one this build records under, and a file whose occurrence
385
+ * numbering is already ambiguous all refuse with a typed
386
+ * ConfigError), and every hash counter is seeded past the numbers
387
+ * already on disk, so the numbering continues where the file left
388
+ * off instead of restarting at zero (v1.32.0 review P2). One
389
+ * recorder session may be active on a cassette at a time: two
390
+ * concurrently constructed recorders seed identically and claim
391
+ * colliding numbers, which replay refuses as ambiguous instead of
392
+ * silently serving either order. The wrapped adapters are drop-in:
393
+ * same ids, providers, caps, and event streams.
341
394
  */
342
395
  function record(options) {
343
396
  const redact = options.redact ? (value) => defaultRedact(options.redact ? options.redact(value) : value) : defaultRedact;
344
- if (!existsSync(options.cassette)) writeFileSync(options.cassette, `${headerLine()}\n`, "utf8");
345
- return options.adapters.map((adapter) => ({
346
- ...adapter,
347
- id: adapter.id,
348
- ...adapter.provider === void 0 ? {} : { provider: adapter.provider },
349
- caps: (model) => adapter.caps(model),
350
- async *stream(req, signal) {
351
- const events = [];
352
- let thrown = false;
353
- let terminals = 0;
354
- let postTerminal = false;
355
- try {
356
- for await (const event of adapter.stream(req, signal)) {
357
- if (terminals > 0) postTerminal = true;
358
- if (isTerminalEvent(event)) terminals += 1;
359
- events.push(event);
360
- yield event;
361
- }
362
- } catch (error) {
363
- thrown = true;
364
- throw error;
365
- } finally {
366
- if (!thrown && terminals === 1 && !postTerminal) {
367
- const row = {
368
- adapterId: adapter.id,
369
- ...adapter.provider === void 0 ? {} : { provider: adapter.provider },
370
- ...adapter.usageSemantics === void 0 ? {} : { usageSemantics: adapter.usageSemantics },
371
- requestHash: requestHash(req),
372
- request: walkStrings(JSON.parse(JSON.stringify(req)), redact),
373
- events: walkStrings(JSON.parse(JSON.stringify(events)), redact),
374
- caps: adapter.caps(req.model),
375
- model: req.model
376
- };
377
- appendFileSync(options.cassette, `${JSON.stringify(row)}\n`, "utf8");
378
- }
397
+ const seeds = /* @__PURE__ */ new Map();
398
+ if (existsSync(options.cassette)) {
399
+ const existing = readCassette(options.cassette);
400
+ if (existing.header.hashVersion !== CURRENT_HASH_VERSION) throw new ConfigError(`${options.cassette} was recorded under hashVersion ${String(existing.header.hashVersion)} and this build records under ${String(CURRENT_HASH_VERSION)}; appending would mix two identity profiles under one header, so record the cassette again from scratch`);
401
+ for (const [adapterId, forAdapter] of groupRows(existing.rows, options.cassette)) {
402
+ const forSeeds = /* @__PURE__ */ new Map();
403
+ for (const [hash, rows] of forAdapter) {
404
+ const numbered = rows.map((row) => row.occurrence).filter((value) => value !== void 0);
405
+ forSeeds.set(hash, numbered.length === 0 ? 0 : Math.max(...numbered) + 1);
379
406
  }
407
+ seeds.set(adapterId, forSeeds);
380
408
  }
381
- }));
409
+ } else writeFileSync(options.cassette, `${headerLine()}\n`, "utf8");
410
+ return options.adapters.map((adapter) => {
411
+ const occurrences = new Map(seeds.get(adapter.id) ?? []);
412
+ return {
413
+ ...adapter,
414
+ id: adapter.id,
415
+ ...adapter.provider === void 0 ? {} : { provider: adapter.provider },
416
+ caps: (model) => adapter.caps(model),
417
+ stream(req, signal) {
418
+ const hash = requestHash(req);
419
+ const occurrence = occurrences.get(hash) ?? 0;
420
+ occurrences.set(hash, occurrence + 1);
421
+ return (async function* () {
422
+ const events = [];
423
+ let thrown = false;
424
+ let terminals = 0;
425
+ let postTerminal = false;
426
+ try {
427
+ for await (const event of adapter.stream(req, signal)) {
428
+ if (terminals > 0) postTerminal = true;
429
+ if (isTerminalEvent(event)) terminals += 1;
430
+ events.push(event);
431
+ yield event;
432
+ }
433
+ } catch (error) {
434
+ thrown = true;
435
+ throw error;
436
+ } finally {
437
+ if (!thrown && terminals === 1 && !postTerminal) {
438
+ const row = {
439
+ adapterId: adapter.id,
440
+ ...adapter.provider === void 0 ? {} : { provider: adapter.provider },
441
+ ...adapter.usageSemantics === void 0 ? {} : { usageSemantics: adapter.usageSemantics },
442
+ occurrence,
443
+ requestHash: hash,
444
+ request: walkStrings(JSON.parse(JSON.stringify(req)), redact),
445
+ events: walkStrings(JSON.parse(JSON.stringify(events)), redact),
446
+ caps: adapter.caps(req.model),
447
+ model: req.model
448
+ };
449
+ appendFileSync(options.cassette, `${JSON.stringify(row)}\n`, "utf8");
450
+ }
451
+ }
452
+ })();
453
+ }
454
+ };
455
+ });
382
456
  }
383
457
  /**
384
458
  * Typed hermetic-miss error; onMiss: 'throw' raises it on any request
385
459
  * without a servable row. `recordedOccurrences` above zero means the
386
460
  * hash WAS recorded but every occurrence is already consumed (replay
387
- * serves each recorded exchange once, in file order); absent or zero
388
- * means the request was never recorded at all (v1.29.0 review P2).
461
+ * serves each recorded exchange once, in recorded order); absent or
462
+ * zero means the request was never recorded at all (v1.29.0 review
463
+ * P2).
389
464
  */
390
465
  var VcrMissError = class extends Error {
391
466
  requestHash;
392
467
  /** Rows recorded for this hash; absent or 0 = never recorded. */
393
468
  recordedOccurrences;
394
469
  constructor(adapterId, hash, recordedOccurrences) {
395
- super(recordedOccurrences !== void 0 && recordedOccurrences > 0 ? `VCR miss: adapter '${adapterId}' exhausted the ${String(recordedOccurrences)} recorded occurrence${recordedOccurrences === 1 ? "" : "s"} of request hash ${hash.slice(0, 12)}; a replay serves each recorded exchange once, in file order` : `VCR miss: adapter '${adapterId}' received a request with no recorded row (hash ${hash.slice(0, 12)}); onMiss: 'throw' keeps cassette tests hermetic`);
470
+ super(recordedOccurrences !== void 0 && recordedOccurrences > 0 ? `VCR miss: adapter '${adapterId}' exhausted the ${String(recordedOccurrences)} recorded occurrence${recordedOccurrences === 1 ? "" : "s"} of request hash ${hash.slice(0, 12)}; a replay serves each recorded exchange once, in recorded order` : `VCR miss: adapter '${adapterId}' received a request with no recorded row (hash ${hash.slice(0, 12)}); onMiss: 'throw' keeps cassette tests hermetic`);
396
471
  this.name = "VcrMissError";
397
472
  this.requestHash = hash;
398
473
  if (recordedOccurrences !== void 0) this.recordedOccurrences = recordedOccurrences;
@@ -493,7 +568,9 @@ function eventShapeIssue(event, index) {
493
568
  case "tool-call-delta":
494
569
  if (typeof event.id !== "string" || event.id === "") return `${at}.id must be a nonempty string`;
495
570
  return typeof event.argsTextDelta === "string" ? void 0 : `${at}.argsTextDelta must be a string`;
496
- case "tool-call-end": return typeof event.id === "string" && event.id !== "" ? void 0 : `${at}.id must be a nonempty string`;
571
+ case "tool-call-end":
572
+ if (typeof event.id !== "string" || event.id === "") return `${at}.id must be a nonempty string`;
573
+ return Object.hasOwn(event, "args") ? void 0 : `${at}.args must be present (the arguments the call ended with)`;
497
574
  case "usage": {
498
575
  const usage = event.usage;
499
576
  if (!isPlainObject(usage)) return `${at}.usage must be an object`;
@@ -511,7 +588,20 @@ function eventShapeIssue(event, index) {
511
588
  if (reason === "refusal") {
512
589
  const refusal = finish.refusal;
513
590
  if (!isPlainObject(refusal) || typeof refusal.provider !== "string") return `${at}.finish.refusal must be an object naming the provider`;
591
+ const stopDetails = refusal.stopDetails;
592
+ if (stopDetails !== void 0) {
593
+ if (!isPlainObject(stopDetails)) return `${at}.finish.refusal.stopDetails must be an object when present`;
594
+ for (const field of [
595
+ "type",
596
+ "category",
597
+ "explanation"
598
+ ]) {
599
+ const value = stopDetails[field];
600
+ if (value !== void 0 && typeof value !== "string") return `${at}.finish.refusal.stopDetails.${field} must be a string when present`;
601
+ }
602
+ }
514
603
  }
604
+ if (event.providerMetadata !== void 0 && !isPlainObject(event.providerMetadata)) return `${at}.providerMetadata must be a plain object when present`;
515
605
  const usage = event.usage;
516
606
  if (!isPlainObject(usage)) return `${at}.usage must be an object (the full Usage of the exchange)`;
517
607
  const violations = usageViolations(usage);
@@ -536,7 +626,8 @@ function eventShapeIssue(event, index) {
536
626
  * instead of being read as v1. Every documented header field (kind,
537
627
  * v, an integer hashVersion, a date string recordedAt) and row field
538
628
  * (adapterId, model, requestHash, request, caps, events, an optional
539
- * string provider, an optional nonempty usageSemantics) is checked
629
+ * string provider, an optional nonempty usageSemantics, an optional
630
+ * nonnegative integer occurrence) is checked
540
631
  * here, and the nested structures are validated in depth (v1.30.0
541
632
  * review P3): the request must be a plain object, every event must
542
633
  * be a member of the canonical ChatEvent vocabulary with its
@@ -583,6 +674,7 @@ function readCassette(path) {
583
674
  if (typeof row.model !== "string" || row.model === "") reject("model must be a nonempty string");
584
675
  if (row.provider !== void 0 && typeof row.provider !== "string") reject("provider, when present, must be a string");
585
676
  if (row.usageSemantics !== void 0 && (typeof row.usageSemantics !== "string" || row.usageSemantics === "")) reject("usageSemantics, when present, must be a nonempty string");
677
+ if (row.occurrence !== void 0 && (typeof row.occurrence !== "number" || !Number.isSafeInteger(row.occurrence) || row.occurrence < 0)) reject("occurrence, when present, must be a nonnegative safe integer");
586
678
  if (typeof row.request !== "object" || row.request === null || Array.isArray(row.request)) reject("request must be an object (the redacted canonical request)");
587
679
  if (typeof row.caps !== "object" || row.caps === null || Array.isArray(row.caps)) reject("caps must be an object (the model caps snapshot at record time)");
588
680
  const capsIssue = capsShapeIssue(row.caps);
@@ -602,11 +694,21 @@ function readCassette(path) {
602
694
  * hermetic CI mode; `'passthrough'` forwards unrecorded requests to the
603
695
  * matching live adapter in `adapters` (a development convenience only).
604
696
  *
605
- * Repeated hashes replay in file order (v1.29.0 review P2): rows
606
- * sharing a `(adapterId, requestHash)` key form an ordered occurrence
607
- * list, and every `stream()` call consumes exactly one occurrence,
608
- * allocated synchronously inside the call itself, so two concurrent
609
- * identical requests can never be served the same recorded exchange.
697
+ * Repeated hashes replay as ordered occurrences (v1.29.0 review P2):
698
+ * rows sharing a `(adapterId, requestHash)` key form an ordered
699
+ * occurrence list, and every `stream()` call consumes exactly one
700
+ * occurrence, allocated synchronously inside the call itself, so two
701
+ * concurrent identical requests can never be served the same
702
+ * recorded exchange. The list is sorted by the recorded `occurrence`
703
+ * numbers when every row of the group carries one, so concurrent
704
+ * identical calls whose live completions were appended out of order
705
+ * still replay to the callers that made them (v1.31.0 review P2); a
706
+ * group with any unnumbered row (recorded before v1.32.0) keeps file
707
+ * order. A duplicate occurrence inside a fully numbered group
708
+ * refuses the whole cassette with a typed ConfigError naming the
709
+ * adapter and hash: it means two recorder sessions wrote the file
710
+ * concurrently, and serving either order would hand a caller the
711
+ * wrong exchange (v1.32.0 review P2).
610
712
  * A call after the last occurrence is a miss: under `onMiss: 'throw'`
611
713
  * it raises a VcrMissError whose `recordedOccurrences` says the hash
612
714
  * WAS recorded but is exhausted, and under `'passthrough'` it
@@ -627,7 +729,13 @@ function readCassette(path) {
627
729
  * both declarations; a conflict refuses with a typed ConfigError
628
730
  * before anything is served. A cassette recorded before v1.31.0
629
731
  * stores no usageSemantics, so its replays stamp nothing (documented
630
- * historical laxity).
732
+ * historical laxity). Under `onMiss: 'passthrough'` the recorded
733
+ * declarations must also match the live adapter's, absent versus
734
+ * present included, because a live served miss is journaled under
735
+ * the wrapper's declarations; a mismatch refuses at construction
736
+ * (v1.31.0 review P2). An adapter with no recorded rows keeps the
737
+ * live adapter's own declarations, so the wrapper stays a metadata
738
+ * preserving drop in.
631
739
  */
632
740
  function replay(options) {
633
741
  const { header, rows } = readCassette(options.cassette);
@@ -638,14 +746,7 @@ function replay(options) {
638
746
  const last = row.events[row.events.length - 1];
639
747
  if (terminals !== 1 || last === void 0 || !isTerminalEvent(last)) throw new ConfigError(`${options.cassette} row ${String(index + 1)} (adapter '${row.adapterId}', hash ${row.requestHash.slice(0, 12)}) does not record one completed exchange: expected exactly one trailing terminal event (finish or error), found ${String(terminals)}; record the cassette again on a current engine`);
640
748
  });
641
- const byAdapter = /* @__PURE__ */ new Map();
642
- for (const row of rows) {
643
- const forAdapter = byAdapter.get(row.adapterId) ?? /* @__PURE__ */ new Map();
644
- const occurrences = forAdapter.get(row.requestHash) ?? [];
645
- occurrences.push(row);
646
- forAdapter.set(row.requestHash, occurrences);
647
- byAdapter.set(row.adapterId, forAdapter);
648
- }
749
+ const byAdapter = groupRows(rows, options.cassette);
649
750
  const live = new Map((options.adapters ?? []).map((adapter) => [adapter.id, adapter]));
650
751
  return [.../* @__PURE__ */ new Set([...byAdapter.keys(), ...live.keys()])].map((adapterId) => {
651
752
  const recorded = byAdapter.get(adapterId) ?? /* @__PURE__ */ new Map();
@@ -666,11 +767,18 @@ function replay(options) {
666
767
  if (values.length > 1) throw new ConfigError(`${options.cassette} carries conflicting ${field} values for adapter '${adapterId}' (${values.map((value) => value === void 0 ? "absent" : `'${value}'`).join(", ")}); a replay adapter reports one declaration per adapter, so record the cassette again in one session`);
667
768
  }
668
769
  const passthrough = live.get(adapterId);
770
+ if (someRow !== void 0 && passthrough !== void 0 && options.onMiss === "passthrough") {
771
+ for (const field of ["provider", "usageSemantics"]) if (someRow[field] !== passthrough[field]) {
772
+ const describe = (value) => value === void 0 ? "absent" : `'${value}'`;
773
+ throw new ConfigError(`${options.cassette} records ${field} ${describe(someRow[field])} for adapter '${adapterId}' but the live passthrough adapter declares ${describe(passthrough[field])}; the engine journals live served misses under the replay adapter declaration, so replay with a matching adapter or record the cassette again`);
774
+ }
775
+ }
776
+ const declared = someRow ?? passthrough;
669
777
  const cursors = /* @__PURE__ */ new Map();
670
778
  return {
671
779
  id: adapterId,
672
- ...someRow?.provider === void 0 ? {} : { provider: someRow.provider },
673
- ...someRow?.usageSemantics === void 0 ? {} : { usageSemantics: someRow.usageSemantics },
780
+ ...declared?.provider === void 0 ? {} : { provider: declared.provider },
781
+ ...declared?.usageSemantics === void 0 ? {} : { usageSemantics: declared.usageSemantics },
674
782
  caps: (model) => {
675
783
  const snapshot = capsByModel.get(model)?.caps ?? passthrough?.caps(model);
676
784
  if (snapshot === void 0) throw new ConfigError(`VCR replay adapter '${adapterId}' has no caps snapshot for model '${model}'`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rulvar/testing",
3
- "version": "1.31.0",
3
+ "version": "1.33.0",
4
4
  "description": "Rulvar test harness: createTestEngine, FakeAdapter, VCR cassettes, replay-strict runs, matchers.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -26,7 +26,7 @@
26
26
  "access": "public"
27
27
  },
28
28
  "dependencies": {
29
- "@rulvar/core": "1.31.0"
29
+ "@rulvar/core": "1.33.0"
30
30
  },
31
31
  "devDependencies": {
32
32
  "@types/node": "^22.20.0",