@volter/twin-standard 1.0.148 → 1.0.150

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.
Files changed (103) hide show
  1. package/README.md +12 -3
  2. package/client-sdks/bun.lock +0 -32
  3. package/client-sdks/package.json +0 -2
  4. package/dist/client-sdks/bun.lock +0 -32
  5. package/dist/client-sdks/package.json +0 -2
  6. package/dist/src/assessment.d.ts +6 -4
  7. package/dist/src/assessment.js +2 -1
  8. package/dist/src/cli.js +21 -6
  9. package/dist/src/clients/harness.d.ts +3 -1
  10. package/dist/src/clients/harness.js +4 -2
  11. package/dist/src/clients/jira.d.ts +2 -0
  12. package/dist/src/clients/jira.js +71 -0
  13. package/dist/src/clients/polar.d.ts +2 -0
  14. package/dist/src/clients/polar.js +73 -0
  15. package/dist/src/clients/resend.js +38 -0
  16. package/dist/src/clients/run.js +1 -1
  17. package/dist/src/clients/sentry.js +1 -26
  18. package/dist/src/clients/smtp.d.ts +2 -0
  19. package/dist/src/clients/smtp.js +54 -0
  20. package/dist/src/clients/stripe.js +1 -1
  21. package/dist/src/clients/volteridentity.js +4 -3
  22. package/dist/src/clients/x.d.ts +2 -0
  23. package/dist/src/clients/x.js +64 -0
  24. package/dist/src/conformance.d.ts +4 -3
  25. package/dist/src/conformance.js +293 -8
  26. package/dist/src/consumer.js +5 -1
  27. package/dist/src/create-pack.d.ts +5 -0
  28. package/dist/src/create-pack.js +38 -4
  29. package/dist/src/derive.js +21 -13
  30. package/dist/src/gate.d.ts +1 -0
  31. package/dist/src/gate.js +47 -4
  32. package/dist/src/grade.d.ts +1 -0
  33. package/dist/src/grade.js +1 -1
  34. package/dist/src/pack-facts.d.ts +9 -1
  35. package/dist/src/pack-facts.js +11 -2
  36. package/dist/src/pack-files.js +2 -1
  37. package/dist/src/protocol-3.js +40 -6
  38. package/dist/src/spec-ir-client.js +3 -2
  39. package/dist/src/spec-ir-commands.d.ts +73 -0
  40. package/dist/src/spec-ir-commands.js +63 -0
  41. package/dist/src/spec-ir.d.ts +2 -0
  42. package/dist/src/spec-ir.js +43 -0
  43. package/dist/src/tools/judge-brief.d.ts +1 -0
  44. package/dist/src/tools/judge-brief.js +67 -0
  45. package/dist/src/tools/life-coverage-walk.d.ts +2 -0
  46. package/dist/src/tools/life-coverage-walk.js +220 -0
  47. package/dist/src/tools/life-coverage.d.ts +2 -0
  48. package/dist/src/tools/life-coverage.js +271 -0
  49. package/dist/src/tools/packs-root.d.ts +9 -0
  50. package/dist/src/tools/packs-root.js +17 -0
  51. package/dist/src/tools/run-tool.d.ts +1 -0
  52. package/dist/src/tools/run-tool.js +18 -0
  53. package/dist/src/tools/score-pack.d.ts +2 -0
  54. package/dist/src/tools/score-pack.js +264 -0
  55. package/dist/src/tools/vendor-examples.d.ts +2 -0
  56. package/dist/src/tools/vendor-examples.js +1493 -0
  57. package/dist/src/types.d.ts +2 -0
  58. package/dist/src/verdict.js +1 -1
  59. package/dist/src/walk/behavior-journey.d.ts +6 -0
  60. package/dist/src/walk/behavior-journey.js +108 -8
  61. package/dist/src/walk/journey-kit.d.ts +23 -1
  62. package/dist/src/walk/journey-kit.js +7 -2
  63. package/dist/src/walk/rendered-scalar.d.ts +3 -0
  64. package/dist/src/walk/rendered-scalar.js +23 -0
  65. package/dist/src/walk/sse-example.js +45 -0
  66. package/package.json +2 -2
  67. package/src/assessment.ts +2 -1
  68. package/src/cli.ts +15 -2
  69. package/src/clients/harness.ts +4 -2
  70. package/src/clients/jira.ts +73 -0
  71. package/src/clients/polar.ts +73 -0
  72. package/src/clients/resend.ts +32 -0
  73. package/src/clients/run.ts +2 -2
  74. package/src/clients/sentry.ts +1 -25
  75. package/src/clients/smtp.ts +49 -0
  76. package/src/clients/stripe.ts +1 -1
  77. package/src/clients/volteridentity.ts +4 -3
  78. package/src/clients/x.ts +66 -0
  79. package/src/conformance.ts +226 -10
  80. package/src/consumer.ts +5 -1
  81. package/src/create-pack.ts +35 -5
  82. package/src/derive.ts +21 -14
  83. package/src/gate.ts +40 -6
  84. package/src/grade.ts +2 -2
  85. package/src/pack-facts.ts +10 -3
  86. package/src/pack-files.ts +2 -1
  87. package/src/protocol-3.ts +32 -6
  88. package/src/spec-ir-client.ts +3 -2
  89. package/src/spec-ir-commands.ts +81 -0
  90. package/src/spec-ir.ts +31 -0
  91. package/src/tools/judge-brief.ts +63 -0
  92. package/src/tools/life-coverage-walk.ts +173 -0
  93. package/src/tools/life-coverage.ts +237 -0
  94. package/src/tools/packs-root.ts +21 -0
  95. package/src/tools/run-tool.ts +10 -0
  96. package/src/tools/score-pack.ts +244 -0
  97. package/src/tools/vendor-examples.ts +1250 -0
  98. package/src/types.ts +2 -0
  99. package/src/verdict.ts +1 -1
  100. package/src/walk/behavior-journey.ts +65 -4
  101. package/src/walk/journey-kit.ts +21 -3
  102. package/src/walk/rendered-scalar.ts +17 -0
  103. package/src/walk/sse-example.ts +37 -0
package/src/types.ts CHANGED
@@ -29,6 +29,8 @@ export interface ScoreReport {
29
29
 
30
30
  export interface CriterionResult {
31
31
  pass: boolean;
32
+ /** An interrupted check is not a verdict about unchanged source. */
33
+ cacheable?: boolean;
32
34
  /** why it failed, or what it measured when it passed */
33
35
  reason: string;
34
36
  }
package/src/verdict.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  /** Committed attribution names people or author roles, never execution identities. */
8
8
  export function attributionName(name: string | undefined): string | undefined {
9
9
  if (name === undefined) return undefined;
10
- if (/\bcx-|\bt_[0-9a-f]{8}|wt\/|\/root(?:\/|$|[: )])|\b(?:pane|session)[ -:]|[0-9a-f]{8}-[0-9a-f-]{27}|\b[0-9a-f]{16,64}\b/i.test(name)) throw new Error('attribution must use an author name, not an execution identifier');
10
+ if (/\bcx-|\bt_[0-9a-f]{8}|wt\/|\/root(?:\/|$|[: )])|\b(?:pane|session)[ :-]|[0-9a-f]{8}-[0-9a-f-]{27}|\b[0-9a-f]{16,64}\b/i.test(name)) throw new Error('attribution must use an author name, not an execution identifier');
11
11
  return name;
12
12
  }
13
13
  export type Verdict = { judge?: { agent?: string; name?: string; session?: string; review?: string }; authors?: string[] };
@@ -9,7 +9,8 @@ import { attributionName } from '../verdict.ts';
9
9
  //
10
10
  // The caller names the pack repository first (usePacks).
11
11
 
12
- import { mysql, redis } from '@volter/world-core';
12
+ import { mongodb, mysql, redis } from '@volter/world-core';
13
+ import { createRequire } from 'node:module';
13
14
  import { gzipSync } from 'node:zlib';
14
15
  import { parse, visit } from 'graphql';
15
16
  import { existsSync, readdirSync, readFileSync } from 'node:fs';
@@ -17,7 +18,7 @@ import { join } from 'node:path';
17
18
  import { databaseSession, type DatabaseSession, getActivePackAssets, getActiveWorldStore, LocalPackAssets, MemoryBlobStore, MemoryWorldStore, setActiveBlobStore, setActivePackAssets, setActiveWorldStore, setDeliveryObserver, setInProcessTwins, type InProcessTwin, type TwinStream, type TwinStreamConnection } from '@volter/world-core/runtime';
18
19
  import { createHostTable, type HostFacts } from '@volter/world-core/host-routing';
19
20
  import packFactsDocument from '@volter/world-core/generated/pack-facts.json' with { type: 'json' };
20
- import { type Captured, type GitStep, type Journey, type JourneyStep, type LineStep, type SocketStep, type SqlStep, type RespStep, type MysqlStep, isMysql, isResp, at, captureAt, echoFailures, expandFills, expectFailures, fill, fillJson, formBody, isGit, isLine, isSql, isSocket, isWait, jwtAuthorization, oauth1Authorization, presignedPath, sigv4Headers, waitMs } from './journey-kit.ts';
21
+ import { type Captured, type GitStep, type Journey, type JourneyStep, type LineStep, type SocketStep, type SqlStep, type RespStep, type MysqlStep, type MongoStep, isMongo, isMysql, isResp, at, captureAt, echoFailures, expandFills, expectFailures, fill, fillJson, formBody, isGit, isLine, isSql, isSocket, isWait, jwtAuthorization, oauth1Authorization, presignedPath, sigv4Headers, waitMs } from './journey-kit.ts';
21
22
  import { lanesOf, lineShape, shapeChecker, type LineConnection, type ShapeChecker } from './shape.ts';
22
23
  import { walkPackDirectory } from './pack-directory.ts';
23
24
  import { xmlMembers } from './xml-view.ts';
@@ -141,7 +142,7 @@ export type Failure = { step: string; beat: string; detail: string };
141
142
  * reads (an event's delivery, a socket frame), the kernel's own `updatedAt`, which a raw row filtered on `_` alone
142
143
  * carries (Slack's message events once did). The kernel's `deleted` is not read there: vendors send their own
143
144
  * (Stripe's deleted customer's `deleted: true`). */
144
- function leaked(v: unknown, vendorFields: Set<string>, at = '', unshaped = false): string[] {
145
+ export function leaked(v: unknown, vendorFields: Set<string>, at = '', unshaped = false): string[] {
145
146
  if (Array.isArray(v)) return v.flatMap((x, i) => leaked(x, vendorFields, `${at}[${i}]`, unshaped)).slice(0, 5);
146
147
  if (!v || typeof v !== 'object') return [];
147
148
  return Object.entries(v).flatMap(([k, x]) => ((k.startsWith('_') || (unshaped && k === 'updatedAt')) && !vendorFields.has(k) ? [`${at}${at ? '.' : ''}${k}`] : leaked(x, vendorFields, `${at}${at ? '.' : ''}${k}`, unshaped))).slice(0, 5);
@@ -446,6 +447,61 @@ export async function walk(journey: Journey, root: string, collect: Failure[], o
446
447
  finally { opts.hooks?.after?.(step as never, link, answered, collect.slice(before)); }
447
448
  };
448
449
 
450
+ // A MongoDB step speaks the pack's MongoDB wire as an application's driver does (@volter/world-core/mongodb's client), on
451
+ // the same byte-stream adapter the owned TCP listener serves. The document codec is BSON, the library the pack
452
+ // installs: the walk loads it from the pack's own dependencies, so a step is encoded by the library the pack decodes with.
453
+ const mongoLinks = new Map<string, { client: ReturnType<typeof mongodb.mongoClient>; database: string; close: () => void }>();
454
+ type Bson = { serialize(document: Record<string, unknown>): Uint8Array; deserialize(bytes: Uint8Array, options: Record<string, unknown>): Record<string, unknown>; EJSON: { deserialize(value: unknown, options: { relaxed: boolean }): Record<string, unknown>; serialize(value: unknown, options: { relaxed: boolean }): Record<string, unknown> } };
455
+ let bson: Bson | undefined;
456
+ const bareNumber = (value: unknown, at = 'mongo'): string | undefined => {
457
+ if (typeof value === 'number') return at;
458
+ if (Array.isArray(value)) { for (const [i, item] of value.entries()) { const found = bareNumber(item, `${at}[${i}]`); if (found) return found; } return undefined; }
459
+ if (value && typeof value === 'object') for (const [key, item] of Object.entries(value)) { const found = bareNumber(item, `${at}.${key}`); if (found) return found; }
460
+ return undefined;
461
+ };
462
+ const mongoStep = async (step: MongoStep): Promise<void> => {
463
+ const before = collect.length; let answered: { status: number; body: unknown } = { status: 0, body: undefined };
464
+ const link = step.link ?? 'mongo';
465
+ opts.hooks?.before?.(step as never, link, step.mongo);
466
+ try {
467
+ if (!bson) {
468
+ const pack = walkPackDirectory(packsDir(), journey.vendor, dependenciesDir());
469
+ if (!pack) throw new Error(`no pack directory for ${journey.vendor} to load its BSON library from`);
470
+ bson = createRequire(join(pack, 'package.json'))('bson') as Bson;
471
+ }
472
+ const codec: mongodb.MongoCodec = { serialize: (document) => bson!.serialize(document), deserialize: (bytes) => bson!.deserialize(bytes, { promoteValues: false, promoteLongs: false, bsonRegExp: true }) };
473
+ let held = mongoLinks.get(link);
474
+ if (!held) {
475
+ if (!served?.stream) streams ??= await vendorStream(packsDir(), journey.vendor, dependenciesDir());
476
+ const factory = served?.stream ?? streams?.(root, {}); if (!factory) throw new Error(`no create<Name>TwinStream adapter enrolled for ${journey.vendor}`);
477
+ let out: number[] = [];
478
+ const conn = factory({ write: (b) => { out.push(...b); }, end: () => {} }, '127.0.0.1');
479
+ const exchange: mongodb.MongoExchange = async (bytes) => { conn.data(bytes); await conn.settled(); const got = Uint8Array.from(out); out = []; return got; };
480
+ held = { client: mongodb.mongoClient(exchange, codec), database: 'test', close: () => conn.close() };
481
+ mongoLinks.set(link, held);
482
+ }
483
+ if (step.database) held.database = fill(step.database, captured);
484
+ const written = fillJson(step.mongo, captured);
485
+ const bare = bareNumber(written);
486
+ if (bare) throw new Error(`${bare} is a bare JSON number: write its kind ({ "$numberInt": "…" }, $numberLong or $numberDouble)`);
487
+ const reply = await held.client.command(held.database, bson.EJSON.deserialize(written, { relaxed: false }));
488
+ const body = bson.EJSON.serialize(reply, { relaxed: false });
489
+ const ok = (body.ok as Record<string, unknown> | undefined), failed = !ok || Number(Object.values(ok)[0]) !== 1;
490
+ const code = failed ? Number(Object.values((body.code as Record<string, unknown> | undefined) ?? { none: Number.NaN })[0]) : undefined;
491
+ answered = { status: code ?? 0, body };
492
+ if (opts.verbose) console.log(` ${String(code ?? 'OK').padEnd(5)} ${link.padEnd(6)} ${String(Object.keys(step.mongo)[0] ?? '').padEnd(20)} ${step.name}`);
493
+ if (step.refused !== undefined ? code !== step.refused : failed) {
494
+ collect.push({ step: step.name, beat: 'STATUS', detail: failed ? `MongoDB refused it: ${code} ${String(body.codeName ?? '')} ${String(body.errmsg ?? '')}${step.refused ? ` (wanted ${step.refused})` : ''}` : `MongoDB completed it, wanted ${step.refused}` });
495
+ return;
496
+ }
497
+ for (const [name, dot] of Object.entries(step.capture ?? {})) { const value = captureAt(body, dot); if (value === undefined || value === null) collect.push({ step: step.name, beat: 'CAPTURE', detail: `nothing at "${dot}" in the answer to capture as {${name}}` }); else remember(name, value); }
498
+ try { for (const detail of expectFailures(step.expect ?? [], body, captured)) collect.push({ step: step.name, beat: 'EXPECT', detail }); }
499
+ catch (e) { collect.push({ step: step.name, beat: 'CAPTURE', detail: (e as Error).message }); }
500
+ if (step.close) { mongoLinks.get(link)?.close(); mongoLinks.delete(link); }
501
+ } catch (e) { collect.push({ step: step.name, beat: 'MONGO', detail: (e as Error).message }); }
502
+ finally { opts.hooks?.after?.(step as never, link, answered, collect.slice(before)); }
503
+ };
504
+
449
505
  // A git step is a git client's clone or push on the pack's smart-HTTP plane (packages/twin-standard/src/walk/git-client.ts); its answer is
450
506
  // checked as a request's is (status, captures, expectations).
451
507
  const gitStep = async (step: GitStep): Promise<void> => {
@@ -679,6 +735,7 @@ export async function walk(journey: Journey, root: string, collect: Failure[], o
679
735
  try {
680
736
  for (const step of journey.steps) {
681
737
  stepNow = step.name;
738
+ if (isMongo(step)) { await mongoStep(step); continue; }
682
739
  if (isMysql(step)) { await mysqlStep(step); continue; }
683
740
  if (isResp(step)) { await respStep(step); continue; }
684
741
  if (isLine(step)) { await speak(step); continue; }
@@ -766,7 +823,11 @@ export async function walk(journey: Journey, root: string, collect: Failure[], o
766
823
  let res: Answer;
767
824
  if (deferred) {
768
825
  pending.delete(step.await!);
769
- try { res = await deferred.answer; } catch (error) { collect.push({ step: step.name, beat: 'PENDING', detail: String(error) }); continue; }
826
+ const got = await deferred.answer.then((a) => ({ a }), (error: unknown) => ({ error }));
827
+ // a body the vendor broke off, as the collect expects (`breaks`): its words are the answer
828
+ if ('error' in got) { if (step.breaks === undefined || !String(got.error).includes(step.breaks)) collect.push({ step: step.name, beat: 'PENDING', detail: String(got.error) }); continue; }
829
+ // over a listener, which ends a broken stream as a short body, the ordinary checks hold the part that arrived
830
+ res = got.a;
770
831
  } else res = await send({ ...step, body, headers, ...(formed ? { formed } : {}) }, path);
771
832
  answered = { status: res.status, body: res.body, headers: res.headers };
772
833
  if (opts.verbose) console.log(` ${String(res.status).padEnd(4)} ${step.method.padEnd(6)} ${path.slice(0, 70).padEnd(70)} ${step.name}`);
@@ -52,6 +52,8 @@ export type JourneyStep = {
52
52
  defer?: string;
53
53
  /** Collect a previously deferred request once, with all ordinary response checks. */
54
54
  await?: string;
55
+ /** A collect whose answer's body breaks off with these words (a stream the vendor ends in an error), not arriving whole. */
56
+ breaks?: string;
55
57
  body?: unknown;
56
58
  /** A body of raw bytes (a gzip'd package, an image, an audio file), base64 in the journey and sent decoded: a
57
59
  * string body is sent as UTF-8, which no binary format survives. A step carries `body` or `bodyBase64`, never both. */
@@ -215,7 +217,8 @@ export function expectFailures(expect: NonNullable<JourneyStep['expect']>, body:
215
217
  if (e.contains !== undefined) {
216
218
  const want = fill(e.contains, captured);
217
219
  const text = typeof v === 'string' ? v : JSON.stringify(v ?? '');
218
- if (!text.includes(want)) out.push(`${e.path || 'the answer'} does not contain ${JSON.stringify(want)}`);
220
+ // what it holds instead, its first characters, so a failure says what the twin answered
221
+ if (!text.includes(want)) out.push(`${e.path || 'the answer'} does not contain ${JSON.stringify(want)} (it begins ${JSON.stringify(text.replace(/\s+/g, ' ').slice(0, 160))})`);
219
222
  }
220
223
  // `notContains`: the value does not hold the text (a list that omits what the caller may not see)
221
224
  if (e.notContains !== undefined) {
@@ -327,7 +330,7 @@ export type Journey = {
327
330
  steps: Step[];
328
331
  };
329
332
 
330
- export type Step = JourneyStep | WaitStep | LineStep | GitStep | SocketStep | SqlStep | RespStep | MysqlStep;
333
+ export type Step = JourneyStep | WaitStep | LineStep | GitStep | SocketStep | SqlStep | RespStep | MysqlStep | MongoStep;
331
334
 
332
335
  /** A step on a MySQL wire (the kernel's @volter/world-core/mysql, a pack's `create<Name>TwinStream`), spoken as an
333
336
  * application's driver speaks it. `signIn` opens a named connection (`link`, `mysql` by default) with a user and
@@ -342,6 +345,19 @@ export type MysqlStep = {
342
345
  };
343
346
  export const isMysql = (step: Step): step is MysqlStep => 'mysql' in step || 'signIn' in step;
344
347
 
348
+ /** A step on a pack's MongoDB wire, sent as an application's driver sends it (`@volter/world-core/mongodb`'s client)
349
+ * on a named `link`, opened by the first step that names it. `mongo` is ONE command document, written in
350
+ * canonical extended JSON so that what a document holds is written as it is: an ObjectId as `{ "$oid": … }`, a date
351
+ * as `{ "$date": … }`, and a number with its kind (`{ "$numberInt": "1" }`, `$numberLong`, `$numberDouble`); a bare
352
+ * JSON number is refused, because JSON does not say which of three kinds it is. `database` is the database it is
353
+ * sent to (the link's last one, else `test`). Its answer is the reply document in the same form. A reply whose
354
+ * `ok` is 0 fails the step unless `refused` names its `code`. */
355
+ export type MongoStep = {
356
+ name: string; mongo: Record<string, unknown>; database?: string; link?: string; close?: boolean;
357
+ refused?: number; capture?: JourneyStep['capture']; expect?: JourneyStep['expect']; example?: string; exampleDiffers?: Record<string, string>;
358
+ };
359
+ export const isMongo = (step: Step): step is MongoStep => 'mongo' in step;
360
+
345
361
  /** A typed request/reply stream step. `resp` is one command or a pipeline of commands, on a named connection.
346
362
  * Captures and expectations see `{ replies, last, error }`; binary arguments use `{ base64 }`.
347
363
  * `read` drains pushes or a blocking command's answer without sending bytes. */
@@ -530,7 +546,9 @@ export function fill(template: string, captured: Captured, graphqlDocument = fal
530
546
  }
531
547
  // a doubled brace is the vendor's own template syntax ({{sample.output_text}} in an OpenAI grader), not a capture; a
532
548
  // capture's name starts with a letter, so a pattern's count (`[A-Za-z0-9]{40}` in an expectation) is no capture
533
- return encoded.replace(/\{\{[^{}]*\}\}|\{([A-Za-z_][A-Za-z0-9_.]*)\}/g, (m, key: string | undefined, offset: number) => {
549
+ // `{literal:word}` is `{word}` as the vendor's page prints it (a Redis hash tag, `{queue}`), never a capture
550
+ return encoded.replace(/\{literal:([^{}]*)\}|\{\{[^{}]*\}\}|\{([A-Za-z_][A-Za-z0-9_.]*)\}/g, (m, literal: string | undefined, key: string | undefined, offset: number) => {
551
+ if (literal !== undefined) return `{${literal}}`;
534
552
  if (selections.has(offset)) return m;
535
553
  if (key === undefined) return m;
536
554
  const v = captured[key];
@@ -0,0 +1,17 @@
1
+ /** Unfilled scalar type words in schema-rendered examples. Real values and enum words remain literal. */
2
+ const BOOLEAN = Symbol('rendered boolean');
3
+ const INTEGER = Symbol('rendered integer');
4
+ const NUMBER = Symbol('rendered number');
5
+ export function renderedScalar(value: unknown, types: unknown[], enumerated: boolean): symbol | undefined {
6
+ if (enumerated || typeof value !== 'string') return undefined;
7
+ if (value === 'boolean' && types.includes('boolean')) return BOOLEAN;
8
+ if (value === 'integer' && types.includes('integer')) return INTEGER;
9
+ if (value === 'number' && types.includes('number')) return NUMBER;
10
+ return undefined;
11
+ }
12
+ export function isRenderedScalar(value: unknown): boolean { return value === BOOLEAN || value === INTEGER || value === NUMBER; }
13
+ export function matchesRenderedScalar(marker: unknown, value: unknown): boolean {
14
+ if (marker === BOOLEAN) return typeof value === 'boolean';
15
+ if (marker === INTEGER) return typeof value === 'bigint' || (typeof value === 'number' && Number.isInteger(value));
16
+ return marker === NUMBER && typeof value === 'number' && Number.isFinite(value);
17
+ }
@@ -1,6 +1,7 @@
1
1
  import { parseExactJson } from '@volter/world-core/exact-json';
2
2
  /** Match a published SDK-decoded SSE sample to a real HTTP frame; preserve non-SSE answers and mismatches. */
3
3
  export function sseExampleAnswer(example: unknown, answer: unknown, matches: (frame: unknown) => boolean): unknown {
4
+ if (typeof example === 'string' && typeof answer === 'string') return streamSample(example, answer);
4
5
  if (typeof answer !== 'string' || !example || typeof example !== 'object') return answer;
5
6
  const wrapped = 'data' in example;
6
7
  if (!wrapped && !('type' in example) && !('object' in example)) return answer;
@@ -23,3 +24,39 @@ export function sseExampleAnswer(example: unknown, answer: unknown, matches: (fr
23
24
  const candidates = relevant.map((frame) => wrapped ? frame : frame.data);
24
25
  return candidates.find(matches) ?? candidates[0] ?? answer;
25
26
  }
27
+
28
+ type Frame = { data: unknown; event?: string };
29
+ /** A stream's frames: each block's `event:` and joined `data:` lines, or a block that is JSON alone (a page printing the
30
+ * chunks without their framing); `[DONE]`, an elision (`...`, `....`) and anything else not JSON are no frame. */
31
+ function framesOf(text: string): Frame[] {
32
+ const frames: Frame[] = [];
33
+ for (const block of text.replace(/\r\n/g, '\n').split(/\n\s*\n/)) {
34
+ const lines = block.split('\n').map((l) => l.trim()).filter(Boolean);
35
+ const framed = lines.some((l) => l.startsWith('data:'));
36
+ const data = framed ? lines.filter((l) => l.startsWith('data:')).map((l) => l.slice(5).trim()).join('\n') : lines.join('\n');
37
+ if (!data || data === '[DONE]' || /^\.{3,}$/.test(data)) continue;
38
+ const event = lines.find((l) => l.startsWith('event:'))?.slice(6).trim();
39
+ try { frames.push({ data: parseExactJson(data), ...(event ? { event } : {}) }); } catch { /* not a JSON frame */ }
40
+ }
41
+ return frames;
42
+ }
43
+ const kindOf = (v: unknown): string => (v === null ? 'null' : Array.isArray(v) ? 'array' : typeof v);
44
+ /** Whether an answer's frame holds a published frame's keys with the same kinds of value (a null published value admits
45
+ * any), its type or object the same. */
46
+ function holds(published: unknown, got: unknown): boolean {
47
+ if (!published || typeof published !== 'object' || Array.isArray(published)) return kindOf(published) === kindOf(got) || published === null;
48
+ if (!got || typeof got !== 'object' || Array.isArray(got)) return false;
49
+ const p = published as Record<string, unknown>, g = got as Record<string, unknown>;
50
+ for (const key of ['type', 'object']) if (typeof p[key] === 'string' && p[key] !== g[key]) return false;
51
+ return Object.entries(p).every(([k, v]) => k in g && (v === null || kindOf(v) === kindOf(g[k]) || (kindOf(v) === 'object' && holds(v, g[k]))));
52
+ }
53
+ /** A published stream printed as text (a page's sample of a stream, its middle elided): each frame it shows has a frame
54
+ * of the answer holding it, the answer then standing for the sample; else the answer, unchanged, which the literal
55
+ * comparison reports. */
56
+ function streamSample(example: string, answer: string): unknown {
57
+ const published = framesOf(example);
58
+ if (published.length < 2) return answer;
59
+ const frames = framesOf(answer);
60
+ const held = published.every((p) => frames.some((f) => (!p.event || p.event === f.event) && holds(p.data, f.data)));
61
+ return held ? example : answer;
62
+ }