scenescout 3.14.1 → 3.15.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.
@@ -26,6 +26,26 @@ export const FINDING_CATEGORIES = [
26
26
  "missing-testid",
27
27
  "other",
28
28
  ];
29
+ /** Most judged merges one finding keeps; the oldest go first. */
30
+ export const MAX_JUDGED_MERGES = 10;
31
+ /**
32
+ * A finding's judged merges that are well formed. The file is read back
33
+ * without a schema, and the report prints each of these, so a hand-edited
34
+ * entry that is not one is dropped, not trusted.
35
+ */
36
+ export function judgedMergesOf(f) {
37
+ if (!Array.isArray(f.judgedMerges))
38
+ return [];
39
+ return f.judgedMerges.filter((m) => !!m &&
40
+ typeof m === "object" &&
41
+ typeof m.title === "string" &&
42
+ typeof m.category === "string" &&
43
+ typeof m.at === "string" &&
44
+ ["high", "medium", "low"].includes(m.severity) &&
45
+ typeof m.pSame === "number" &&
46
+ Number.isFinite(m.pSame) &&
47
+ (m.evidence === undefined || typeof m.evidence === "string"));
48
+ }
29
49
  /**
30
50
  * Whether a finding is in the "worth a look" tier. Anything else a file holds
31
51
  * reads as a defect: a finding is never taken out of the defect count on a
@@ -217,6 +237,8 @@ export function mergeMemory(mine, theirs) {
217
237
  // Keep whichever action was actually recorded; ours wins a tie.
218
238
  ...((el.lastAction ?? prev?.lastAction) ? { lastAction: el.lastAction ?? prev?.lastAction } : {}),
219
239
  absentStreak: Math.min(prev?.absentStreak ?? 0, el.absentStreak ?? 0),
240
+ // Ours is the latest reading of whether a user can act on it.
241
+ ...(el.inert ? { inert: true } : {}),
220
242
  };
221
243
  }
222
244
  out.states[fp] = {
@@ -251,6 +273,12 @@ export function mergeMemory(mine, theirs) {
251
273
  Object.assign(withoutTier(merged), tier);
252
274
  if (!isWorthALook(merged) && isWorthALook(newer))
253
275
  merged.severity = older.severity;
276
+ // Each side's judged merges are a record of filings, not later knowledge: kept from both.
277
+ const trail = unionJudgedMerges(f, other);
278
+ if (trail)
279
+ merged.judgedMerges = trail;
280
+ else
281
+ delete merged.judgedMerges;
254
282
  byId.set(f.id, merged);
255
283
  }
256
284
  out.findings = [...byId.values()];
@@ -307,6 +335,16 @@ export function mergeMemory(mine, theirs) {
307
335
  delete out.laneRoutes;
308
336
  return out;
309
337
  }
338
+ /** Two findings' judged merges as one list, each filing once, oldest first, at most MAX_JUDGED_MERGES. Idempotent. */
339
+ function unionJudgedMerges(a, b) {
340
+ const all = [...judgedMergesOf(b), ...judgedMergesOf(a)];
341
+ if (all.length === 0)
342
+ return undefined;
343
+ const byKey = new Map();
344
+ for (const m of all)
345
+ byKey.set(`${m.at}\u0000${m.title}`, m);
346
+ return [...byKey.values()].sort((x, y) => x.at.localeCompare(y.at)).slice(-MAX_JUDGED_MERGES);
347
+ }
310
348
  /** Most routes kept per lane. A lane report carries at most a few hundred; this keeps a long-lived project's file small. */
311
349
  export const MAX_LANE_ROUTES = 255;
312
350
  /**
@@ -374,6 +412,10 @@ function findingJaccard(a, b) {
374
412
  const union = new Set([...a, ...b]).size;
375
413
  return union === 0 ? 0 : inter / union;
376
414
  }
415
+ /** How alike two titles are, 0 to 1, by the token overlap the rule's last tier uses. Orders the dedup judge's candidates. */
416
+ export function titleSimilarity(a, b) {
417
+ return findingJaccard(findingTokens(a), findingTokens(b));
418
+ }
377
419
  /**
378
420
  * Distinctive literals — quoted or parenthesized fragments like
379
421
  * `"(role not recorded)"` or `"Document not found"`. Two findings on the same
@@ -809,7 +851,20 @@ export class MemoryStore {
809
851
  this.auditsThisRun = 0;
810
852
  this.selectChoices.clear();
811
853
  this.emptySubmits.clear();
854
+ this.dedupJudge = null;
855
+ this.dedupChoice = undefined;
856
+ this.dedupOff = undefined;
812
857
  }
858
+ /**
859
+ * The dedup judge filings go through (fileFinding), or null for the rule
860
+ * alone. Per run, like the probes: the server sets it when a session
861
+ * attaches, and a run that never asks for it dedups by the rule only.
862
+ */
863
+ dedupJudge = null;
864
+ /** What an attach of this run asked for by name; unset means the server's environment decides. */
865
+ dedupChoice;
866
+ /** Why the judge this run asked for could not be set up (no key), for the report; unset when it was not asked for or is on. */
867
+ dedupOff;
813
868
  /**
814
869
  * Each dropdown's options and the ones chosen in THIS run, by any session,
815
870
  * keyed by route and element. A select counts as exercised after one choice,
@@ -953,6 +1008,10 @@ export class MemoryStore {
953
1008
  dupOf.runs += f.runs;
954
1009
  if (!dupOf.evidence && f.evidence)
955
1010
  dupOf.evidence = f.evidence;
1011
+ // Each copy's judged merges are filings: folding the copies keeps them all.
1012
+ const trail = unionJudgedMerges(dupOf, f);
1013
+ if (trail)
1014
+ dupOf.judgedMerges = trail;
956
1015
  if (f.status === "resolved")
957
1016
  dupOf.status = "resolved";
958
1017
  const promoted = isWorthALook(dupOf) && !isWorthALook(f);
@@ -1435,8 +1494,12 @@ export class MemoryStore {
1435
1494
  this.actionLog.push(full);
1436
1495
  fs.appendFileSync(this.sessionLogPath, JSON.stringify(full) + "\n");
1437
1496
  }
1438
- /** Record a visit to a state; returns whether it was new. */
1439
- visitState(fingerprint, url, route, elementKeys) {
1497
+ /**
1498
+ * Record a visit to a state; returns whether it was new. `inertKeys` are the
1499
+ * listed keys a user cannot act on (collector inertKeys): they stay known,
1500
+ * so a click on one still registers, but coverage does not count them.
1501
+ */
1502
+ visitState(fingerprint, url, route, elementKeys, inertKeys = []) {
1440
1503
  let rec = this.data.states[fingerprint];
1441
1504
  const isNew = !rec;
1442
1505
  if (!rec) {
@@ -1446,10 +1509,15 @@ export class MemoryStore {
1446
1509
  rec.visits += 1;
1447
1510
  rec.lastSeen = new Date().toISOString();
1448
1511
  const present = new Set(elementKeys);
1512
+ const inert = new Set(inertKeys);
1449
1513
  for (const key of elementKeys) {
1450
1514
  if (!rec.elements[key])
1451
1515
  rec.elements[key] = { exercised: false };
1452
1516
  rec.elements[key].absentStreak = 0;
1517
+ if (inert.has(key))
1518
+ rec.elements[key].inert = true;
1519
+ else
1520
+ delete rec.elements[key].inert;
1453
1521
  }
1454
1522
  // Prune ghosts: dynamic elements (list rows, ordinal-suffixed duplicates)
1455
1523
  // that vanish for 3 consecutive visits would otherwise make coverage
@@ -1492,9 +1560,65 @@ export class MemoryStore {
1492
1560
  */
1493
1561
  /** Returns the finding, whether it is new, and whether an existing worth-a-look was just promoted to a defect by it. */
1494
1562
  addFinding(input) {
1495
- // Redact BEFORE the id is derived, so a re-found finding whose quoted
1496
- // secret differs by a character still hashes to the same id.
1497
- const f = {
1563
+ const f = this.redacted(input);
1564
+ const id = findingId(f);
1565
+ const existing = this.data.findings.find((x) => isDuplicateFinding(x, f));
1566
+ if (existing)
1567
+ return [existing, false, this.mergeInto(existing, f, id)];
1568
+ return [this.append(f, id), true, false];
1569
+ }
1570
+ /**
1571
+ * File a finding, asking the dedup judge when one is set (dedupJudge) and
1572
+ * the rule keeps the filing apart from everything stored. The rule decides
1573
+ * first, exactly as addFinding: a merge the rule makes is never put to the
1574
+ * model, so the judge can only add merges, never undo one, and the rule's
1575
+ * merge at the next load (retroMerge) cannot split what the judge joined.
1576
+ * Without a judge this is addFinding.
1577
+ */
1578
+ async fileFinding(input) {
1579
+ const f = this.redacted(input);
1580
+ const id = findingId(f);
1581
+ const byRule = this.data.findings.find((x) => isDuplicateFinding(x, f));
1582
+ if (byRule)
1583
+ return { finding: byRule, isNew: false, promoted: this.mergeInto(byRule, f, id) };
1584
+ const judge = this.dedupJudge;
1585
+ if (!judge)
1586
+ return { finding: this.append(f, id), isNew: true, promoted: false };
1587
+ const before = new Set(this.data.findings.map((x) => x.id));
1588
+ let verdict = null;
1589
+ let judgeError;
1590
+ try {
1591
+ verdict = await judge.judge(f, [...this.data.findings]);
1592
+ }
1593
+ catch (err) {
1594
+ judgeError = err instanceof Error ? err.message : String(err);
1595
+ }
1596
+ const extra = judgeError ? { judgeError } : {};
1597
+ // The list may have changed while the judge was asked. A filing that
1598
+ // landed meanwhile is compared by the rule, as it would have been with no
1599
+ // judge; the finding the judge chose is looked up again by id, since a
1600
+ // merge with another process's memory may have replaced its object.
1601
+ const landed = this.data.findings.find((x) => !before.has(x.id) && isDuplicateFinding(x, f));
1602
+ if (landed)
1603
+ return { finding: landed, isNew: false, promoted: this.mergeInto(landed, f, id), ...extra };
1604
+ // Only what a judge may answer is taken: an open finding on the filing's page, called the same at better than even.
1605
+ const route = f.state.split("#")[0];
1606
+ const chosen = verdict && verdict.pSame >= 0.5
1607
+ ? this.data.findings.find((x) => x.id === verdict.sameAs && x.status !== "resolved" && x.state.split("#")[0] === route)
1608
+ : undefined;
1609
+ if (chosen && verdict) {
1610
+ const judged = { pSame: verdict.pSame };
1611
+ return { finding: chosen, isNew: false, promoted: this.mergeInto(chosen, f, id, judged), judged, ...extra };
1612
+ }
1613
+ return { finding: this.append(f, id), isNew: true, promoted: false, ...extra };
1614
+ }
1615
+ /**
1616
+ * The filing with secrets redacted. BEFORE the id is derived, so a re-found
1617
+ * finding whose quoted secret differs by a character still hashes to the
1618
+ * same id; and before the dedup judge is shown it.
1619
+ */
1620
+ redacted(input) {
1621
+ return {
1498
1622
  ...input,
1499
1623
  title: redactSecrets(input.title),
1500
1624
  detail: redactSecrets(input.detail),
@@ -1503,37 +1627,54 @@ export class MemoryStore {
1503
1627
  url: redactSecrets(input.url),
1504
1628
  evidence: input.evidence ? redactSecrets(input.evidence) : input.evidence,
1505
1629
  };
1506
- const id = findingId(f);
1507
- const existing = this.data.findings.find((x) => isDuplicateFinding(x, f));
1508
- if (existing) {
1509
- existing.runs += 1;
1510
- existing.foundAt = new Date().toISOString();
1511
- if (!existing.evidence && f.evidence)
1512
- existing.evidence = f.evidence;
1513
- // A worth-a-look filed again as a defect is promoted, at the severity the defect was filed at.
1514
- const promoted = isWorthALook(existing) && !isWorthALook(f);
1515
- const tier = mergeTier(existing, f);
1516
- Object.assign(withoutTier(existing), tier);
1517
- if (promoted)
1518
- existing.severity = f.severity;
1519
- // Re-finding a RESOLVED finding is a regression — reopen it loudly
1520
- // rather than letting it hide in the report's completed section. But a
1521
- // FUZZY match must never resurrect a fixed bug: telling someone a
1522
- // regression landed when it did not is far more costly than carrying a
1523
- // visible duplicate, and it corrupts the one signal that says whether a
1524
- // fix held. Demand an exact identity match (same category+title+route) or
1525
- // an identical evidence signature before reopening.
1526
- const exactMatch = existing.id === id ||
1527
- (!!existing.evidence &&
1528
- !!f.evidence &&
1529
- existing.evidence.toLowerCase().replace(/\s+/g, " ").trim() === f.evidence.toLowerCase().replace(/\s+/g, " ").trim());
1530
- if (existing.status === "resolved" && exactMatch) {
1531
- existing.status = "open";
1532
- existing.regressedAt = existing.foundAt;
1533
- }
1534
- this.flush();
1535
- return [existing, false, promoted];
1630
+ }
1631
+ /**
1632
+ * Fold a filing into the finding it duplicates. Returns whether a
1633
+ * worth-a-look was promoted to a defect by it. `judged`: the dedup judge
1634
+ * made this merge, so the filing is kept on the finding (judgedMerges).
1635
+ */
1636
+ mergeInto(existing, f, id, judged) {
1637
+ existing.runs += 1;
1638
+ existing.foundAt = new Date().toISOString();
1639
+ if (!existing.evidence && f.evidence)
1640
+ existing.evidence = f.evidence;
1641
+ // A worth-a-look filed again as a defect is promoted, at the severity the defect was filed at.
1642
+ const promoted = isWorthALook(existing) && !isWorthALook(f);
1643
+ const tier = mergeTier(existing, f);
1644
+ Object.assign(withoutTier(existing), tier);
1645
+ if (promoted)
1646
+ existing.severity = f.severity;
1647
+ // Re-finding a RESOLVED finding is a regression — reopen it loudly
1648
+ // rather than letting it hide in the report's completed section. But a
1649
+ // FUZZY match must never resurrect a fixed bug: telling someone a
1650
+ // regression landed when it did not is far more costly than carrying a
1651
+ // visible duplicate, and it corrupts the one signal that says whether a
1652
+ // fix held. Demand an exact identity match (same category+title+route) or
1653
+ // an identical evidence signature before reopening.
1654
+ const exactMatch = existing.id === id ||
1655
+ (!!existing.evidence &&
1656
+ !!f.evidence &&
1657
+ existing.evidence.toLowerCase().replace(/\s+/g, " ").trim() === f.evidence.toLowerCase().replace(/\s+/g, " ").trim());
1658
+ if (existing.status === "resolved" && exactMatch) {
1659
+ existing.status = "open";
1660
+ existing.regressedAt = existing.foundAt;
1536
1661
  }
1662
+ if (judged) {
1663
+ const entry = {
1664
+ title: f.title,
1665
+ category: f.category,
1666
+ severity: f.severity,
1667
+ ...(f.evidence ? { evidence: f.evidence } : {}),
1668
+ pSame: judged.pSame,
1669
+ at: existing.foundAt,
1670
+ };
1671
+ existing.judgedMerges = [...(existing.judgedMerges ?? []), entry].slice(-MAX_JUDGED_MERGES);
1672
+ }
1673
+ this.flush();
1674
+ return promoted;
1675
+ }
1676
+ /** Store a filing as a new finding, with its repro trace. */
1677
+ append(f, id) {
1537
1678
  // Repro trace scoped to the finding's route: everything since the action
1538
1679
  // that landed there, not 12 lines of unrelated cross-module noise.
1539
1680
  const routeOf = (url) => {
@@ -1569,7 +1710,7 @@ export class MemoryStore {
1569
1710
  };
1570
1711
  this.data.findings.push(finding);
1571
1712
  this.flush();
1572
- return [finding, true, false];
1713
+ return finding;
1573
1714
  }
1574
1715
  get findings() {
1575
1716
  return this.data.findings;
@@ -1684,6 +1825,9 @@ export class MemoryStore {
1684
1825
  byRoute.set(rec.route, route);
1685
1826
  }
1686
1827
  for (const [key, v] of Object.entries(rec.elements)) {
1828
+ // Not a control: nothing to exercise, so not counted as a gap or a total.
1829
+ if (v.inert)
1830
+ continue;
1687
1831
  route.set(key, (route.get(key) ?? false) || v.exercised);
1688
1832
  }
1689
1833
  }
@@ -106,6 +106,78 @@ export class EmbedRequestLog {
106
106
  return key === null ? null : (this.byKey.get(key) ?? null);
107
107
  }
108
108
  }
109
+ /** How long after a replay ends the browser's console echo of its failure is still taken for the replay's. */
110
+ export const REPLAY_ECHO_WINDOW_MS = 2000;
111
+ /**
112
+ * Addresses the tester is calling with scout_request, so the browser's console
113
+ * echo of a refused probe ("Failed to load resource: … 403") is known as the
114
+ * tester's and not charged to the page. The request itself is matched by
115
+ * identity in the monitor; the echo carries only an address, so it is matched
116
+ * by address while the replay is in flight and for a short window after, which
117
+ * is when the browser prints it. Bounded, dropping the oldest.
118
+ */
119
+ export class ReplayLog {
120
+ cap;
121
+ /** Address → until when an echo of it is the replay's (Infinity while in flight). */
122
+ until = new Map();
123
+ constructor(cap = 200) {
124
+ this.cap = cap;
125
+ }
126
+ begin(url) {
127
+ const key = requestKey(url);
128
+ this.until.delete(key);
129
+ this.until.set(key, Infinity);
130
+ if (this.until.size > this.cap)
131
+ this.until.delete(this.until.keys().next().value);
132
+ }
133
+ end(url, now) {
134
+ const key = requestKey(url);
135
+ if (this.until.has(key))
136
+ this.until.set(key, now + REPLAY_ECHO_WINDOW_MS);
137
+ }
138
+ /** Whether a console message is the browser's echo of a replay's failed load. */
139
+ echoes(text, locationUrl, pageUrl, now) {
140
+ const key = failedLoadEchoOf(text, locationUrl, pageUrl);
141
+ if (key === null)
142
+ return false;
143
+ const until = this.until.get(key);
144
+ if (until === undefined)
145
+ return false;
146
+ if (now > until) {
147
+ this.until.delete(key);
148
+ return false;
149
+ }
150
+ return true;
151
+ }
152
+ }
153
+ /**
154
+ * Wording of a router cancelling a route change on purpose. Some client-side
155
+ * routers can only stop a navigation (to keep a dirty form, say) by throwing
156
+ * from their route-change event, and the apps that use them filter this
157
+ * sentinel out of their own error monitoring. Narrow on purpose: it must name
158
+ * the route and the cancelling.
159
+ */
160
+ const ROUTE_CANCEL_RE = /\brout(?:e|ing)\b.{0,40}\b(?:abort|cancel)|\b(?:abort|cancel)\w*\b.{0,40}\brout(?:e|ing)\b/i;
161
+ /**
162
+ * Is this uncaught page error a router deliberately cancelling a route change,
163
+ * rather than a crash? Only for a click that left the URL where it was, and
164
+ * then only when the message says so, or when the click asked for a route
165
+ * change and the page put up a confirmation instead. Still reported, at
166
+ * medium with a note: the error is real and uncaught, but the user never sees
167
+ * it, and ranking it with crashes made every such page argue it away.
168
+ */
169
+ export function isRouteCancellation(message, c) {
170
+ if (!c.byClick || c.urlChanged)
171
+ return false;
172
+ return ROUTE_CANCEL_RE.test(message) || (c.viaLink && c.dialogOpened);
173
+ }
174
+ /** Appended to a page error read as a route-change cancellation, saying which evidence it was read on. */
175
+ export function routeCancelNote(message) {
176
+ const why = ROUTE_CANCEL_RE.test(message)
177
+ ? "the click left the URL unchanged and the error says the route change was cancelled"
178
+ : "the click on a link left the URL unchanged and the page opened a dialog instead";
179
+ return ` (likely a router cancelling the route change on purpose: ${why}; reported at medium, check what the page showed before filing)`;
180
+ }
109
181
  /**
110
182
  * Invariant oracles: passive listeners that record violations regardless of
111
183
  * what the agent is doing. The engine drains the buffer after every action and
@@ -124,6 +196,11 @@ export class OracleMonitor {
124
196
  // Benign noise: failed favicon / source map fetches show up as console errors.
125
197
  if (/favicon|source map/i.test(text))
126
198
  return;
199
+ // The browser's echo of the tester's own scout_request: its answer was in that tool's result.
200
+ if (this.replays.echoes(text, msg.location().url, page.url(), Date.now())) {
201
+ this.replayAttributed += 1;
202
+ return;
203
+ }
127
204
  this.record({
128
205
  kind: "console_error",
129
206
  severity: "high",
@@ -150,6 +227,10 @@ export class OracleMonitor {
150
227
  return;
151
228
  if (BENIGN_URL_RE.test(req.url()))
152
229
  return;
230
+ if (this.isReplay(req)) {
231
+ this.replayAttributed += 1;
232
+ return;
233
+ }
153
234
  if (this.refusedByPolicy(req)) {
154
235
  this.policyAttributed += 1;
155
236
  return;
@@ -170,6 +251,12 @@ export class OracleMonitor {
170
251
  // reported here on every page load teaches the driver to ignore http_error.
171
252
  if (BENIGN_URL_RE.test(res.url()))
172
253
  return;
254
+ // The tester's own scout_request: a probe of a boundary is meant to be
255
+ // refused, and its answer was in that tool's result, not the page's.
256
+ if (this.isReplay(res.request())) {
257
+ this.replayAttributed += 1;
258
+ return;
259
+ }
173
260
  // The write policy's own stand-in refusal, matched by request identity
174
261
  // like a dropped one: the server never said this.
175
262
  if (this.refusedByPolicy(res.request())) {
@@ -205,6 +292,44 @@ export class OracleMonitor {
205
292
  * errors look the same.
206
293
  */
207
294
  policyAttributed = 0;
295
+ isReplay = () => false;
296
+ replays = new ReplayLog();
297
+ /** Failures of the tester's own scout_request calls, kept out of the page's violations and counted. */
298
+ replayAttributed = 0;
299
+ /**
300
+ * The engine knows which requests are its own scout_request replays, by
301
+ * identity; their failures are the tester's probes, not the page's.
302
+ */
303
+ setReplayCheck(check) {
304
+ this.isReplay = check;
305
+ }
306
+ /** A scout_request call to this address is starting: the browser's echo of its failure is the tester's. */
307
+ replayStarted(url) {
308
+ this.replays.begin(url);
309
+ }
310
+ /** That call has returned; its echo is still expected for a short window. */
311
+ replayEnded(url) {
312
+ this.replays.end(url, Date.now());
313
+ }
314
+ /** Whether a page error was recorded since `since` (ms) and not yet drained. */
315
+ hasPageErrorSince(since) {
316
+ return this.buffer.some((v) => v.kind === "page_error" && Date.parse(v.at) >= since);
317
+ }
318
+ /**
319
+ * Re-rank the page errors this click raised that read as a router
320
+ * cancelling a route change (isRouteCancellation). Done before the drain, so
321
+ * the action's result, the session log and the report all see one verdict.
322
+ */
323
+ downgradeRouteCancellations(since, c) {
324
+ for (const v of this.buffer) {
325
+ if (v.kind !== "page_error" || v.severity !== "high" || Date.parse(v.at) < since)
326
+ continue;
327
+ if (!isRouteCancellation(v.detail, c))
328
+ continue;
329
+ v.severity = "medium";
330
+ v.detail += routeCancelNote(v.detail);
331
+ }
332
+ }
208
333
  embedOfRequest = () => null;
209
334
  embedRequests = new EmbedRequestLog();
210
335
  /**
@@ -241,7 +366,7 @@ export class OracleMonitor {
241
366
  * it is reported through here rather than by a page event.
242
367
  */
243
368
  noteContradiction(c, url) {
244
- this.record({ kind: c.kind, severity: "high", detail: c.detail, url });
369
+ this.record({ kind: c.kind, severity: c.severity ?? "high", detail: c.detail, url });
245
370
  }
246
371
  /**
247
372
  * The page posted a token-shaped value with targetOrigin "*" (postmessage.ts).
@@ -3,7 +3,8 @@ export class MalformedReply extends Error {
3
3
  }
4
4
  /** The most output one model call may produce. Large enough for a plan of steps; small enough that one reply cannot spend the budget. */
5
5
  export const MAX_OUTPUT_TOKENS = 16_000;
6
- const obj = (v) => (v && typeof v === "object" && !Array.isArray(v) ? v : null);
6
+ /** A plain object as a record, or null for anything else (an array, null, a string). */
7
+ export const obj = (v) => (v && typeof v === "object" && !Array.isArray(v) ? v : null);
7
8
  const num = (v) => (typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : 0);
8
9
  function parseArgs(raw) {
9
10
  if (raw === undefined || raw === null || raw === "")
@@ -31,7 +32,7 @@ export class AnthropicConversation {
31
32
  headers: { "content-type": "application/json", "x-api-key": key, "anthropic-version": "2023-06-01" },
32
33
  body: {
33
34
  model: this.o.model,
34
- max_tokens: MAX_OUTPUT_TOKENS,
35
+ max_tokens: this.o.maxOutputTokens ?? MAX_OUTPUT_TOKENS,
35
36
  // The method and the tools are the same on every call: cached once, read back at a tenth of the price.
36
37
  system: [{ type: "text", text: this.o.system, cache_control: { type: "ephemeral" } }],
37
38
  tools: this.o.tools.map((t) => ({ name: t.name, description: t.description, input_schema: t.parameters })),
@@ -102,7 +103,7 @@ export class OpenAIConversation {
102
103
  input: this.input,
103
104
  tools: this.o.tools.map((t) => ({ type: "function", name: t.name, description: t.description, parameters: t.parameters, strict: false })),
104
105
  reasoning: { effort: this.o.effort },
105
- max_output_tokens: MAX_OUTPUT_TOKENS,
106
+ max_output_tokens: this.o.maxOutputTokens ?? MAX_OUTPUT_TOKENS,
106
107
  // Stateless: nothing of the tested app is kept on the provider's side
107
108
  // between calls, and the reasoning comes back encrypted so it can be
108
109
  // handed back with the rest of the turn.