@rulvar/testing 1.31.0 → 1.32.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,19 @@ 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.
154
+ */
155
+ occurrence?: number;
143
156
  requestHash: string;
144
157
  /** Redacted canonical request, for humans and drift review. */
145
158
  request: unknown;
@@ -177,8 +190,13 @@ declare function requestHash(req: ChatRequest): string;
177
190
  * (a requested abort or a truncated read), throws, or violates the
178
191
  * adapter contract (a second terminal, data after the terminal)
179
192
  * 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.
193
+ * completed exchange (v1.28.0 review P2). Every call also claims a
194
+ * per `(adapterId, requestHash)` occurrence number synchronously in
195
+ * the `stream()` call itself and persists it on the completed row,
196
+ * so replay can restore the caller to response association even when
197
+ * concurrent identical calls completed out of order (v1.31.0 review
198
+ * P2). The wrapped adapters are drop-in: same ids, providers, caps,
199
+ * and event streams.
182
200
  */
183
201
  declare function record(options: {
184
202
  adapters: ProviderAdapter[];
@@ -189,8 +207,9 @@ declare function record(options: {
189
207
  * Typed hermetic-miss error; onMiss: 'throw' raises it on any request
190
208
  * without a servable row. `recordedOccurrences` above zero means the
191
209
  * 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).
210
+ * serves each recorded exchange once, in recorded order); absent or
211
+ * zero means the request was never recorded at all (v1.29.0 review
212
+ * P2).
194
213
  */
195
214
  declare class VcrMissError extends Error {
196
215
  readonly requestHash: string;
@@ -211,7 +230,8 @@ interface VcrCassette {
211
230
  * instead of being read as v1. Every documented header field (kind,
212
231
  * v, an integer hashVersion, a date string recordedAt) and row field
213
232
  * (adapterId, model, requestHash, request, caps, events, an optional
214
- * string provider, an optional nonempty usageSemantics) is checked
233
+ * string provider, an optional nonempty usageSemantics, an optional
234
+ * nonnegative integer occurrence) is checked
215
235
  * here, and the nested structures are validated in depth (v1.30.0
216
236
  * review P3): the request must be a plain object, every event must
217
237
  * be a member of the canonical ChatEvent vocabulary with its
@@ -232,11 +252,17 @@ declare function readCassette(path: string): VcrCassette;
232
252
  * hermetic CI mode; `'passthrough'` forwards unrecorded requests to the
233
253
  * matching live adapter in `adapters` (a development convenience only).
234
254
  *
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.
255
+ * Repeated hashes replay as ordered occurrences (v1.29.0 review P2):
256
+ * rows sharing a `(adapterId, requestHash)` key form an ordered
257
+ * occurrence list, and every `stream()` call consumes exactly one
258
+ * occurrence, allocated synchronously inside the call itself, so two
259
+ * concurrent identical requests can never be served the same
260
+ * recorded exchange. The list is sorted by the recorded `occurrence`
261
+ * numbers when every row of the group carries one, so concurrent
262
+ * identical calls whose live completions were appended out of order
263
+ * still replay to the callers that made them (v1.31.0 review P2); a
264
+ * group with any unnumbered row (recorded before v1.32.0) keeps file
265
+ * order.
240
266
  * A call after the last occurrence is a miss: under `onMiss: 'throw'`
241
267
  * it raises a VcrMissError whose `recordedOccurrences` says the hash
242
268
  * WAS recorded but is exhausted, and under `'passthrough'` it
@@ -257,7 +283,13 @@ declare function readCassette(path: string): VcrCassette;
257
283
  * both declarations; a conflict refuses with a typed ConfigError
258
284
  * before anything is served. A cassette recorded before v1.31.0
259
285
  * stores no usageSemantics, so its replays stamp nothing (documented
260
- * historical laxity).
286
+ * historical laxity). Under `onMiss: 'passthrough'` the recorded
287
+ * declarations must also match the live adapter's, absent versus
288
+ * present included, because a live served miss is journaled under
289
+ * the wrapper's declarations; a mismatch refuses at construction
290
+ * (v1.31.0 review P2). An adapter with no recorded rows keeps the
291
+ * live adapter's own declarations, so the wrapper stays a metadata
292
+ * preserving drop in.
261
293
  */
262
294
  declare function replay(options: {
263
295
  cassette: string;
package/dist/index.js CHANGED
@@ -336,63 +336,78 @@ function headerLine() {
336
336
  * (a requested abort or a truncated read), throws, or violates the
337
337
  * adapter contract (a second terminal, data after the terminal)
338
338
  * 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.
339
+ * completed exchange (v1.28.0 review P2). Every call also claims a
340
+ * per `(adapterId, requestHash)` occurrence number synchronously in
341
+ * the `stream()` call itself and persists it on the completed row,
342
+ * so replay can restore the caller to response association even when
343
+ * concurrent identical calls completed out of order (v1.31.0 review
344
+ * P2). The wrapped adapters are drop-in: same ids, providers, caps,
345
+ * and event streams.
341
346
  */
342
347
  function record(options) {
343
348
  const redact = options.redact ? (value) => defaultRedact(options.redact ? options.redact(value) : value) : defaultRedact;
344
349
  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
- }
350
+ return options.adapters.map((adapter) => {
351
+ const occurrences = /* @__PURE__ */ new Map();
352
+ return {
353
+ ...adapter,
354
+ id: adapter.id,
355
+ ...adapter.provider === void 0 ? {} : { provider: adapter.provider },
356
+ caps: (model) => adapter.caps(model),
357
+ stream(req, signal) {
358
+ const hash = requestHash(req);
359
+ const occurrence = occurrences.get(hash) ?? 0;
360
+ occurrences.set(hash, occurrence + 1);
361
+ return (async function* () {
362
+ const events = [];
363
+ let thrown = false;
364
+ let terminals = 0;
365
+ let postTerminal = false;
366
+ try {
367
+ for await (const event of adapter.stream(req, signal)) {
368
+ if (terminals > 0) postTerminal = true;
369
+ if (isTerminalEvent(event)) terminals += 1;
370
+ events.push(event);
371
+ yield event;
372
+ }
373
+ } catch (error) {
374
+ thrown = true;
375
+ throw error;
376
+ } finally {
377
+ if (!thrown && terminals === 1 && !postTerminal) {
378
+ const row = {
379
+ adapterId: adapter.id,
380
+ ...adapter.provider === void 0 ? {} : { provider: adapter.provider },
381
+ ...adapter.usageSemantics === void 0 ? {} : { usageSemantics: adapter.usageSemantics },
382
+ occurrence,
383
+ requestHash: hash,
384
+ request: walkStrings(JSON.parse(JSON.stringify(req)), redact),
385
+ events: walkStrings(JSON.parse(JSON.stringify(events)), redact),
386
+ caps: adapter.caps(req.model),
387
+ model: req.model
388
+ };
389
+ appendFileSync(options.cassette, `${JSON.stringify(row)}\n`, "utf8");
390
+ }
391
+ }
392
+ })();
379
393
  }
380
- }
381
- }));
394
+ };
395
+ });
382
396
  }
383
397
  /**
384
398
  * Typed hermetic-miss error; onMiss: 'throw' raises it on any request
385
399
  * without a servable row. `recordedOccurrences` above zero means the
386
400
  * 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).
401
+ * serves each recorded exchange once, in recorded order); absent or
402
+ * zero means the request was never recorded at all (v1.29.0 review
403
+ * P2).
389
404
  */
390
405
  var VcrMissError = class extends Error {
391
406
  requestHash;
392
407
  /** Rows recorded for this hash; absent or 0 = never recorded. */
393
408
  recordedOccurrences;
394
409
  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`);
410
+ 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
411
  this.name = "VcrMissError";
397
412
  this.requestHash = hash;
398
413
  if (recordedOccurrences !== void 0) this.recordedOccurrences = recordedOccurrences;
@@ -493,7 +508,9 @@ function eventShapeIssue(event, index) {
493
508
  case "tool-call-delta":
494
509
  if (typeof event.id !== "string" || event.id === "") return `${at}.id must be a nonempty string`;
495
510
  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`;
511
+ case "tool-call-end":
512
+ if (typeof event.id !== "string" || event.id === "") return `${at}.id must be a nonempty string`;
513
+ return Object.hasOwn(event, "args") ? void 0 : `${at}.args must be present (the arguments the call ended with)`;
497
514
  case "usage": {
498
515
  const usage = event.usage;
499
516
  if (!isPlainObject(usage)) return `${at}.usage must be an object`;
@@ -511,7 +528,20 @@ function eventShapeIssue(event, index) {
511
528
  if (reason === "refusal") {
512
529
  const refusal = finish.refusal;
513
530
  if (!isPlainObject(refusal) || typeof refusal.provider !== "string") return `${at}.finish.refusal must be an object naming the provider`;
531
+ const stopDetails = refusal.stopDetails;
532
+ if (stopDetails !== void 0) {
533
+ if (!isPlainObject(stopDetails)) return `${at}.finish.refusal.stopDetails must be an object when present`;
534
+ for (const field of [
535
+ "type",
536
+ "category",
537
+ "explanation"
538
+ ]) {
539
+ const value = stopDetails[field];
540
+ if (value !== void 0 && typeof value !== "string") return `${at}.finish.refusal.stopDetails.${field} must be a string when present`;
541
+ }
542
+ }
514
543
  }
544
+ if (event.providerMetadata !== void 0 && !isPlainObject(event.providerMetadata)) return `${at}.providerMetadata must be a plain object when present`;
515
545
  const usage = event.usage;
516
546
  if (!isPlainObject(usage)) return `${at}.usage must be an object (the full Usage of the exchange)`;
517
547
  const violations = usageViolations(usage);
@@ -536,7 +566,8 @@ function eventShapeIssue(event, index) {
536
566
  * instead of being read as v1. Every documented header field (kind,
537
567
  * v, an integer hashVersion, a date string recordedAt) and row field
538
568
  * (adapterId, model, requestHash, request, caps, events, an optional
539
- * string provider, an optional nonempty usageSemantics) is checked
569
+ * string provider, an optional nonempty usageSemantics, an optional
570
+ * nonnegative integer occurrence) is checked
540
571
  * here, and the nested structures are validated in depth (v1.30.0
541
572
  * review P3): the request must be a plain object, every event must
542
573
  * be a member of the canonical ChatEvent vocabulary with its
@@ -583,6 +614,7 @@ function readCassette(path) {
583
614
  if (typeof row.model !== "string" || row.model === "") reject("model must be a nonempty string");
584
615
  if (row.provider !== void 0 && typeof row.provider !== "string") reject("provider, when present, must be a string");
585
616
  if (row.usageSemantics !== void 0 && (typeof row.usageSemantics !== "string" || row.usageSemantics === "")) reject("usageSemantics, when present, must be a nonempty string");
617
+ 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
618
  if (typeof row.request !== "object" || row.request === null || Array.isArray(row.request)) reject("request must be an object (the redacted canonical request)");
587
619
  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
620
  const capsIssue = capsShapeIssue(row.caps);
@@ -602,11 +634,17 @@ function readCassette(path) {
602
634
  * hermetic CI mode; `'passthrough'` forwards unrecorded requests to the
603
635
  * matching live adapter in `adapters` (a development convenience only).
604
636
  *
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.
637
+ * Repeated hashes replay as ordered occurrences (v1.29.0 review P2):
638
+ * rows sharing a `(adapterId, requestHash)` key form an ordered
639
+ * occurrence list, and every `stream()` call consumes exactly one
640
+ * occurrence, allocated synchronously inside the call itself, so two
641
+ * concurrent identical requests can never be served the same
642
+ * recorded exchange. The list is sorted by the recorded `occurrence`
643
+ * numbers when every row of the group carries one, so concurrent
644
+ * identical calls whose live completions were appended out of order
645
+ * still replay to the callers that made them (v1.31.0 review P2); a
646
+ * group with any unnumbered row (recorded before v1.32.0) keeps file
647
+ * order.
610
648
  * A call after the last occurrence is a miss: under `onMiss: 'throw'`
611
649
  * it raises a VcrMissError whose `recordedOccurrences` says the hash
612
650
  * WAS recorded but is exhausted, and under `'passthrough'` it
@@ -627,7 +665,13 @@ function readCassette(path) {
627
665
  * both declarations; a conflict refuses with a typed ConfigError
628
666
  * before anything is served. A cassette recorded before v1.31.0
629
667
  * stores no usageSemantics, so its replays stamp nothing (documented
630
- * historical laxity).
668
+ * historical laxity). Under `onMiss: 'passthrough'` the recorded
669
+ * declarations must also match the live adapter's, absent versus
670
+ * present included, because a live served miss is journaled under
671
+ * the wrapper's declarations; a mismatch refuses at construction
672
+ * (v1.31.0 review P2). An adapter with no recorded rows keeps the
673
+ * live adapter's own declarations, so the wrapper stays a metadata
674
+ * preserving drop in.
631
675
  */
632
676
  function replay(options) {
633
677
  const { header, rows } = readCassette(options.cassette);
@@ -646,6 +690,7 @@ function replay(options) {
646
690
  forAdapter.set(row.requestHash, occurrences);
647
691
  byAdapter.set(row.adapterId, forAdapter);
648
692
  }
693
+ for (const forAdapter of byAdapter.values()) for (const occurrences of forAdapter.values()) if (occurrences.every((row) => row.occurrence !== void 0)) occurrences.sort((a, b) => (a.occurrence ?? 0) - (b.occurrence ?? 0));
649
694
  const live = new Map((options.adapters ?? []).map((adapter) => [adapter.id, adapter]));
650
695
  return [.../* @__PURE__ */ new Set([...byAdapter.keys(), ...live.keys()])].map((adapterId) => {
651
696
  const recorded = byAdapter.get(adapterId) ?? /* @__PURE__ */ new Map();
@@ -666,11 +711,18 @@ function replay(options) {
666
711
  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
712
  }
668
713
  const passthrough = live.get(adapterId);
714
+ if (someRow !== void 0 && passthrough !== void 0 && options.onMiss === "passthrough") {
715
+ for (const field of ["provider", "usageSemantics"]) if (someRow[field] !== passthrough[field]) {
716
+ const describe = (value) => value === void 0 ? "absent" : `'${value}'`;
717
+ 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`);
718
+ }
719
+ }
720
+ const declared = someRow ?? passthrough;
669
721
  const cursors = /* @__PURE__ */ new Map();
670
722
  return {
671
723
  id: adapterId,
672
- ...someRow?.provider === void 0 ? {} : { provider: someRow.provider },
673
- ...someRow?.usageSemantics === void 0 ? {} : { usageSemantics: someRow.usageSemantics },
724
+ ...declared?.provider === void 0 ? {} : { provider: declared.provider },
725
+ ...declared?.usageSemantics === void 0 ? {} : { usageSemantics: declared.usageSemantics },
674
726
  caps: (model) => {
675
727
  const snapshot = capsByModel.get(model)?.caps ?? passthrough?.caps(model);
676
728
  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.32.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.32.0"
30
30
  },
31
31
  "devDependencies": {
32
32
  "@types/node": "^22.20.0",