@openwop/openwop-conformance 1.106.1 → 1.124.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.
Files changed (166) hide show
  1. package/README.md +3 -3
  2. package/coverage.md +6 -0
  3. package/dist/cli.js +89 -45
  4. package/dist/lib/certification-bundle-verify.js +274 -0
  5. package/dist/lib/profiles.js +102 -19
  6. package/dist/lib/requirement-ledger.js +182 -0
  7. package/dist/lib/requirement-registry.js +110 -0
  8. package/dist/lib/scenario-disposition.js +249 -0
  9. package/dist/lib/soft-skip.js +82 -0
  10. package/fixtures/conformance-a2a-task-roundtrip.json +1 -1
  11. package/fixtures.md +1 -1
  12. package/package.json +1 -1
  13. package/schemas/CORPUS-STAMP.json +2 -2
  14. package/schemas/README.md +1 -0
  15. package/schemas/capabilities.schema.json +2 -2
  16. package/schemas/compensation-policy.schema.json +64 -0
  17. package/schemas/run-snapshot.schema.json +5 -0
  18. package/schemas/workflow-chain-pack-manifest.schema.json +125 -0
  19. package/schemas/workflow-definition.schema.json +4 -0
  20. package/src/cli.ts +99 -45
  21. package/src/lib/a2a-fake-peer.ts +290 -110
  22. package/src/lib/certification-bundle-verify.ts +356 -0
  23. package/src/lib/error-envelope.ts +77 -0
  24. package/src/lib/llm-cache-key-recipe.ts +6 -5
  25. package/src/lib/mcp-fake-server.ts +235 -72
  26. package/src/lib/profiles.ts +132 -19
  27. package/src/lib/requirement-ledger.ts +98 -1
  28. package/src/lib/requirement-registry.ts +47 -4
  29. package/src/lib/scenario-disposition.ts +291 -0
  30. package/src/lib/soft-skip.ts +89 -0
  31. package/src/lib/workflow-chain-expansion.ts +189 -0
  32. package/src/scenarios/a2a-1-0-agent-card.test.ts +179 -0
  33. package/src/scenarios/a2a-1-0-task-roundtrip.test.ts +109 -0
  34. package/src/scenarios/a2a-card-runtime-consistency.test.ts +167 -0
  35. package/src/scenarios/a2a-peer-authority.test.ts +99 -0
  36. package/src/scenarios/a2a-task-roundtrip.test.ts +18 -6
  37. package/src/scenarios/agent-loop-iteration-monotonic.test.ts +3 -2
  38. package/src/scenarios/agent-loop-stateful-resume.test.ts +3 -2
  39. package/src/scenarios/agent-loop-version5-shape.test.ts +2 -1
  40. package/src/scenarios/agent-loop-workspace-snapshot.test.ts +4 -3
  41. package/src/scenarios/approval-gate-flow.test.ts +5 -4
  42. package/src/scenarios/artifact-type-store-without-render.test.ts +2 -1
  43. package/src/scenarios/auth-scim-profile.test.ts +5 -4
  44. package/src/scenarios/authorization-fail-closed.test.ts +4 -3
  45. package/src/scenarios/certification-bundle-non-vacuous.test.ts +249 -0
  46. package/src/scenarios/certification-bundle-redaction.test.ts +182 -0
  47. package/src/scenarios/certification-floor-enforcement.test.ts +30 -6
  48. package/src/scenarios/chain-compensation-expansion.test.ts +243 -0
  49. package/src/scenarios/commitment-fired.test.ts +3 -2
  50. package/src/scenarios/compensation-behavior.test.ts +63 -0
  51. package/src/scenarios/compensation-profile.test.ts +144 -1
  52. package/src/scenarios/conformance-advertised-seam-required.test.ts +118 -0
  53. package/src/scenarios/conformance-execution-witness.test.ts +104 -0
  54. package/src/scenarios/conversationCapabilityNegotiation.test.ts +3 -2
  55. package/src/scenarios/core-manifest-and-extension-registry.test.ts +26 -0
  56. package/src/scenarios/credential-payload-redaction.test.ts +4 -3
  57. package/src/scenarios/credentials-capability-shape.test.ts +4 -3
  58. package/src/scenarios/cross-engine-append-behavior.test.ts +5 -4
  59. package/src/scenarios/cross-engine-append-ordering.test.ts +3 -2
  60. package/src/scenarios/cross-host-ancestry-endpoint.test.ts +5 -4
  61. package/src/scenarios/cross-host-causation-shape.test.ts +3 -2
  62. package/src/scenarios/cross-workspace-isolation.test.ts +4 -3
  63. package/src/scenarios/data-residency-admission.test.ts +6 -6
  64. package/src/scenarios/deadletter-retry-exhaustion.test.ts +5 -4
  65. package/src/scenarios/distillation-index-roundtrip.test.ts +3 -2
  66. package/src/scenarios/distillation-secret-carryforward.test.ts +3 -2
  67. package/src/scenarios/distillation-shape.test.ts +2 -1
  68. package/src/scenarios/distillation-stable-archive.test.ts +4 -3
  69. package/src/scenarios/distillation-token-budget.test.ts +4 -3
  70. package/src/scenarios/envelope-recovery-applied.test.ts +3 -2
  71. package/src/scenarios/envelope-refusal-shape.test.ts +4 -3
  72. package/src/scenarios/error-envelope-canonical-shape.test.ts +64 -0
  73. package/src/scenarios/feedback-correction-redaction.test.ts +4 -3
  74. package/src/scenarios/feedback-cross-tenant-isolation.test.ts +4 -3
  75. package/src/scenarios/feedback-fork-not-copied.test.ts +6 -5
  76. package/src/scenarios/feedback-on-terminal-run.test.ts +4 -3
  77. package/src/scenarios/feedback-record-and-list.test.ts +4 -3
  78. package/src/scenarios/feedback-unsupported-501.test.ts +3 -2
  79. package/src/scenarios/fixtures-valid.test.ts +16 -18
  80. package/src/scenarios/fs-path-traversal.test.ts +3 -2
  81. package/src/scenarios/heartbeat-capability-shape.test.ts +2 -1
  82. package/src/scenarios/heartbeat-fires-once-per-tick.test.ts +4 -3
  83. package/src/scenarios/heartbeat-idempotent-no-spam.test.ts +6 -5
  84. package/src/scenarios/heartbeat-runtime-bound.test.ts +4 -3
  85. package/src/scenarios/mcp-2026-07-28-discover.test.ts +207 -0
  86. package/src/scenarios/mcp-cache-tenant-scope.test.ts +66 -0
  87. package/src/scenarios/mcp-current-auth-boundary.test.ts +55 -0
  88. package/src/scenarios/mcp-extension-opacity.test.ts +79 -0
  89. package/src/scenarios/mcp-mrtr-roundtrip.test.ts +140 -0
  90. package/src/scenarios/mcp-stateless-request.test.ts +77 -0
  91. package/src/scenarios/mcp-tool-roundtrip.test.ts +4 -3
  92. package/src/scenarios/mcp-toolcall-redaction.test.ts +2 -1
  93. package/src/scenarios/memory-attribution-emits-on-write.test.ts +6 -5
  94. package/src/scenarios/memory-attribution-no-content.test.ts +4 -3
  95. package/src/scenarios/memory-attribution-replay-stable.test.ts +6 -5
  96. package/src/scenarios/memory-attribution-tenant-scoped.test.ts +4 -3
  97. package/src/scenarios/memory-compaction-event-emitted.test.ts +3 -2
  98. package/src/scenarios/memory-compaction-provenance-tag.test.ts +6 -5
  99. package/src/scenarios/memory-compaction-sr1-carry-forward.test.ts +4 -3
  100. package/src/scenarios/memory-consolidation-idempotent.test.ts +4 -3
  101. package/src/scenarios/memory-injection-budget.test.ts +4 -3
  102. package/src/scenarios/metric-emission.test.ts +4 -3
  103. package/src/scenarios/multi-agent-confidence-escalation.test.ts +5 -4
  104. package/src/scenarios/multi-agent-handoff-state-machine.test.ts +4 -3
  105. package/src/scenarios/multi-agent-memory-lifecycle.test.ts +5 -4
  106. package/src/scenarios/multi-party-conversation-behavioral.test.ts +3 -2
  107. package/src/scenarios/multi-region-idempotency-behavior.test.ts +7 -6
  108. package/src/scenarios/multi-region-idempotency.test.ts +4 -3
  109. package/src/scenarios/normative-example-extraction.test.ts +242 -0
  110. package/src/scenarios/oauth-authorization-code-roundtrip.test.ts +4 -3
  111. package/src/scenarios/oauth-capability-shape.test.ts +4 -3
  112. package/src/scenarios/oauth-connector-redaction.test.ts +4 -3
  113. package/src/scenarios/openapi-asyncapi-sdk-parity.test.ts +309 -0
  114. package/src/scenarios/org-position-no-authority-escalation.test.ts +2 -1
  115. package/src/scenarios/otel-emission-grpc.test.ts +4 -3
  116. package/src/scenarios/otel-emission.test.ts +4 -3
  117. package/src/scenarios/otel-trace-propagation-subworkflow.test.ts +5 -4
  118. package/src/scenarios/otel-trace-propagation.test.ts +4 -3
  119. package/src/scenarios/pack-registry-isolation.test.ts +4 -3
  120. package/src/scenarios/pack-registry-publish.test.ts +65 -3
  121. package/src/scenarios/pack-registry.test.ts +22 -0
  122. package/src/scenarios/profile-discovery-core-alias.test.ts +137 -0
  123. package/src/scenarios/profileDerivation.test.ts +2 -1
  124. package/src/scenarios/provider-usage.test.ts +2 -2
  125. package/src/scenarios/rate-limit-envelope.test.ts +2 -1
  126. package/src/scenarios/registry-public.test.ts +6 -5
  127. package/src/scenarios/replay-divergence-at-refusal.test.ts +9 -8
  128. package/src/scenarios/replay-llm-cache-key-portable.test.ts +19 -13
  129. package/src/scenarios/replay-llm-cache-key.test.ts +120 -47
  130. package/src/scenarios/replay-observable-sequence-determinism.test.ts +5 -4
  131. package/src/scenarios/replay-side-effect-suppression.test.ts +20 -1
  132. package/src/scenarios/requirement-ledger.test.ts +29 -6
  133. package/src/scenarios/runner-ledger.test.ts +279 -0
  134. package/src/scenarios/runtime-requires-install-gate.test.ts +6 -5
  135. package/src/scenarios/sandbox-memory-cap.test.ts +3 -2
  136. package/src/scenarios/sandbox-mvp-behavior.test.ts +11 -10
  137. package/src/scenarios/sandbox-no-host-fs-escape.test.ts +2 -1
  138. package/src/scenarios/sandbox-timeout-cap.test.ts +3 -2
  139. package/src/scenarios/stream-text-fixture.test.ts +5 -4
  140. package/src/scenarios/subrun-approval-fail-closed.test.ts +3 -2
  141. package/src/scenarios/subrun-approval-gate.test.ts +4 -3
  142. package/src/scenarios/subrun-attestation-shape.test.ts +2 -1
  143. package/src/scenarios/subrun-checksum-stable.test.ts +4 -3
  144. package/src/scenarios/table-schema-enforcement.test.ts +2 -2
  145. package/src/scenarios/tool-hooks-authorization-fail-closed.test.ts +3 -2
  146. package/src/scenarios/tool-hooks-content-free.test.ts +3 -2
  147. package/src/scenarios/tool-hooks-rate-limit.test.ts +3 -2
  148. package/src/scenarios/tool-hooks-secret-redaction.test.ts +3 -2
  149. package/src/scenarios/tool-hooks-shape.test.ts +2 -1
  150. package/src/scenarios/trigger-bridge-delivery.test.ts +3 -2
  151. package/src/scenarios/version-fold.test.ts +5 -4
  152. package/src/scenarios/voice-streamref-tenant-bound.test.ts +2 -1
  153. package/src/scenarios/voice-transcription-streaming.test.ts +2 -1
  154. package/src/scenarios/voice-transcription-unadvertised.test.ts +2 -1
  155. package/src/scenarios/wasm-pack-abi-version-rejection.test.ts +5 -4
  156. package/src/scenarios/wasm-pack-invoke-completed.test.ts +3 -2
  157. package/src/scenarios/wasm-pack-invoke-suspended.test.ts +3 -2
  158. package/src/scenarios/wasm-pack-load.test.ts +4 -3
  159. package/src/scenarios/wasm-pack-memory-cap.test.ts +4 -3
  160. package/src/scenarios/wasm-pack-replay-determinism.test.ts +3 -2
  161. package/src/scenarios/webhook-signed-delivery.test.ts +4 -3
  162. package/src/scenarios/workflow-primary-output-annotation.test.ts +13 -23
  163. package/src/scenarios/workload-identity-behavior.test.ts +7 -4
  164. package/src/scenarios/workspace-cross-tenant-isolation-blackbox.test.ts +3 -2
  165. package/src/scenarios/workspace-cross-tenant-isolation.test.ts +3 -2
  166. package/src/setup.ts +95 -1
@@ -306,6 +306,195 @@ export function expandChain(chain: WorkflowChain, ctx: ExpansionContext): Expand
306
306
  // If it is gated on an advertised capability, it goes below.
307
307
  // ─────────────────────────────────────────────────────────────────────────────
308
308
 
309
+ // ---------------------------------------------------------------------------
310
+ // RFC 0157 (RFC 0013 revision × RFC 0151 §B) — chain fragments carry
311
+ // compensation.
312
+ //
313
+ // A workflow that is "a chain or a stack" could not own an inverse action:
314
+ // `FragmentNode` had no `compensation` and the chain had no policy, so RFC 0151
315
+ // §B was reachable only through a hand-authored `POST /v1/workflows`. This pass
316
+ // carries the node-level `compensation` declaration and the chain-level policy
317
+ // through expansion into the registered `WorkflowDefinition`.
318
+ //
319
+ // It sits BELOW the mirrored core deliberately: the core is byte-mirrored by
320
+ // the in-memory reference host and gated in CI, and this pass composes on the
321
+ // core's output rather than editing it — a host runs `expandChain` then
322
+ // `carryCompensation` (or `expandChainWithCompensation`). It is NOT capability
323
+ // gated on the host's `compensation` advert: a host that does not advertise
324
+ // `capabilities.compensation` still MUST carry the declaration verbatim (it is
325
+ // descriptive) and MUST refuse a chain-level POLICY with `capability_required`
326
+ // per `compensation.md` §"Workflow policy" — the same rule as for a hand-authored
327
+ // `settings.compensation`.
328
+ //
329
+ // Normative reference: `spec/v1/workflow-chain-packs.md` §"Compensation
330
+ // (RFC 0157)"; error code `chain_compensation_policy_conflict`.
331
+ // ---------------------------------------------------------------------------
332
+
333
+ /** Mirror of `WorkflowNode.compensation` (workflow-definition.schema.json). */
334
+ export interface FragmentNodeCompensation {
335
+ nodeTypeId: string;
336
+ inputMapping?: Record<string, unknown>;
337
+ retry?: { maxAttempts?: number; backoffMs?: number };
338
+ requiresApproval?: boolean;
339
+ }
340
+
341
+ /** Mirror of `compensation-policy.schema.json` (RFC 0151 §B). */
342
+ export interface ChainCompensationPolicy {
343
+ profileVersion?: string;
344
+ orderingModel?: 'reverse-completion' | 'dependency-graph';
345
+ triggers: ReadonlyArray<'node-failure' | 'run-cancel' | 'cap-breach' | 'operator-request'>;
346
+ retry?: { maxAttempts?: number; backoffMs?: number };
347
+ timeoutMs?: number;
348
+ exhaustedDisposition?: 'record-outcome' | 'manual-intervention';
349
+ approvalScope?: 'declared' | 'all';
350
+ onParentCancel?: 'continue' | 'pause' | 'manual';
351
+ }
352
+
353
+ /** A chain as RFC 0157 sees it: the RFC 0013 shape plus the two optional
354
+ * compensation surfaces. */
355
+ export type WorkflowChainWithCompensation = Omit<WorkflowChain, 'dag'> & {
356
+ dag: { nodes: ReadonlyArray<FragmentNode & { compensation?: FragmentNodeCompensation }>; edges?: ReadonlyArray<FragmentEdge> };
357
+ compensation?: ChainCompensationPolicy;
358
+ };
359
+
360
+ /** Thrown when the parent already carries a `settings.compensation` policy that
361
+ * is not deep-equal to the chain's. Wire code
362
+ * `chain_compensation_policy_conflict` (`workflow-chain-packs.md` §"Error
363
+ * codes"). Fail closed: a merged policy nobody wrote is exactly the
364
+ * guess-at-a-contract failure the policy exists to prevent. */
365
+ export class ChainCompensationPolicyConflictError extends Error {
366
+ readonly code = 'chain_compensation_policy_conflict' as const;
367
+ constructor(
368
+ public readonly chainId: string,
369
+ ) {
370
+ super(
371
+ `chain_compensation_policy_conflict: chain "${chainId}" declares a compensation policy that differs ` +
372
+ 'from the parent workflow\'s settings.compensation; expansion MUST NOT merge policies',
373
+ );
374
+ this.name = 'ChainCompensationPolicyConflictError';
375
+ }
376
+ }
377
+
378
+ /** Rewrite fragment node-id references inside an `inputMapping` value the same
379
+ * way edge refs are rewritten: any string of the form
380
+ * `${nodes.<fragmentNodeId>.<path>}` (or bare `nodes.<id>.<path>` / `<id>.<port>`)
381
+ * gets its node-id segment prefixed. Everything else passes through. Recurses
382
+ * through objects/arrays. Deliberately conservative: only ids that ARE
383
+ * fragment node ids are rewritten, so a reference to a parent-workflow node
384
+ * survives verbatim (same rule as `rewriteEdgeRef`). */
385
+ function rewriteInputMappingRefs(value: unknown, fragmentNodeIds: ReadonlySet<string>, prefix: string): unknown {
386
+ if (typeof value === 'string') {
387
+ // `${nodes.<id>.…}` template form (RFC 0151 §B example) and `nodes.<id>.…`
388
+ return value.replace(/(\$\{\s*nodes\.|\bnodes\.)([A-Za-z0-9_.-]+?)(\.)/g, (m, lead: string, id: string, dot: string) =>
389
+ fragmentNodeIds.has(id) ? `${lead}${prefix}${id}${dot}` : m,
390
+ );
391
+ }
392
+ if (Array.isArray(value)) return value.map((v) => rewriteInputMappingRefs(v, fragmentNodeIds, prefix));
393
+ if (value !== null && typeof value === 'object') {
394
+ const out: Record<string, unknown> = {};
395
+ for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
396
+ out[k] = rewriteInputMappingRefs(v, fragmentNodeIds, prefix);
397
+ }
398
+ return out;
399
+ }
400
+ return value;
401
+ }
402
+
403
+ /** Stable deep equality for policy comparison (key order insensitive). */
404
+ function canonicalJson(v: unknown): string {
405
+ if (Array.isArray(v)) return '[' + v.map(canonicalJson).join(',') + ']';
406
+ if (v !== null && typeof v === 'object') {
407
+ const o = v as Record<string, unknown>;
408
+ return '{' + Object.keys(o).sort().map((k) => JSON.stringify(k) + ':' + canonicalJson(o[k])).join(',') + '}';
409
+ }
410
+ return JSON.stringify(v);
411
+ }
412
+
413
+ export interface CarriedCompensation {
414
+ /** The expanded fragment with `compensation` carried onto each node that
415
+ * declared one (typeIds validated, params substituted, id refs rewritten). */
416
+ nodes: ReadonlyArray<ExpandedFragment['nodes'][number] & { compensation?: FragmentNodeCompensation }>;
417
+ /** The `settings.compensation` the registered definition MUST carry after
418
+ * this expansion: the parent's when the chain declares none; the chain's
419
+ * when the parent had none; the (equal) shared policy when both agree.
420
+ * `undefined` when neither side declares one. */
421
+ settingsCompensation: ChainCompensationPolicy | undefined;
422
+ }
423
+
424
+ /**
425
+ * RFC 0157 — carry the chain's compensation surfaces through expansion.
426
+ *
427
+ * Steps (numbered to slot into `workflow-chain-packs.md` §"Expansion
428
+ * semantics"):
429
+ * 3b. every `compensation.nodeTypeId` MUST resolve exactly as `typeId` does
430
+ * (`chain_unresolvable_typeid`) — an unwind must not fail on a typo first
431
+ * discovered during a failure;
432
+ * 5b. `{{params.*}}` inside `inputMapping` are substituted (author-time
433
+ * literals; the recorded-facts rule is unaffected);
434
+ * 6b. fragment node-id references inside `inputMapping` are rewritten with
435
+ * the expansion prefix, exactly as edge refs are;
436
+ * 9b. the chain-level policy becomes the definition's `settings.compensation`
437
+ * — copied when the parent has none, accepted when equal, otherwise
438
+ * `chain_compensation_policy_conflict`.
439
+ *
440
+ * @throws ChainUnresolvableTypeIdError, ChainCompensationPolicyConflictError
441
+ */
442
+ export function carryCompensation(
443
+ chain: WorkflowChainWithCompensation,
444
+ expanded: ExpandedFragment,
445
+ ctx: ExpansionContext,
446
+ parentSettingsCompensation?: ChainCompensationPolicy,
447
+ ): CarriedCompensation {
448
+ const prefix = computePrefix(chain.chainId, ctx.expansionId);
449
+ const srcNodes = chain.dag.nodes;
450
+ const fragmentNodeIds = new Set(srcNodes.map((n) => n.id));
451
+ const byOriginalId = new Map(srcNodes.map((n) => [n.id, n] as const));
452
+
453
+ // 3b
454
+ for (const n of srcNodes) {
455
+ if (n.compensation !== undefined && !ctx.isTypeIdResolvable(n.compensation.nodeTypeId)) {
456
+ throw new ChainUnresolvableTypeIdError(n.compensation.nodeTypeId, chain.chainId);
457
+ }
458
+ }
459
+
460
+ // 5b + 6b
461
+ const nodes = expanded.nodes.map((en) => {
462
+ const originalId = en.id.startsWith(prefix) ? en.id.slice(prefix.length) : en.id;
463
+ const src = byOriginalId.get(originalId);
464
+ if (src?.compensation === undefined) return en;
465
+ const c: FragmentNodeCompensation = { nodeTypeId: src.compensation.nodeTypeId };
466
+ if (src.compensation.inputMapping !== undefined) {
467
+ const substituted = substitute(src.compensation.inputMapping, ctx.params) as Record<string, unknown>;
468
+ c.inputMapping = rewriteInputMappingRefs(substituted, fragmentNodeIds, prefix) as Record<string, unknown>;
469
+ }
470
+ if (src.compensation.retry !== undefined) c.retry = { ...src.compensation.retry };
471
+ if (src.compensation.requiresApproval !== undefined) c.requiresApproval = src.compensation.requiresApproval;
472
+ return { ...en, compensation: c };
473
+ });
474
+
475
+ // 9b
476
+ let settingsCompensation: ChainCompensationPolicy | undefined = parentSettingsCompensation;
477
+ if (chain.compensation !== undefined) {
478
+ if (parentSettingsCompensation === undefined) {
479
+ settingsCompensation = { ...chain.compensation, triggers: [...chain.compensation.triggers] };
480
+ } else if (canonicalJson(parentSettingsCompensation) !== canonicalJson(chain.compensation)) {
481
+ throw new ChainCompensationPolicyConflictError(chain.chainId);
482
+ }
483
+ }
484
+ return { nodes, settingsCompensation };
485
+ }
486
+
487
+ /** Convenience: `expandChain` followed by `carryCompensation`. */
488
+ export function expandChainWithCompensation(
489
+ chain: WorkflowChainWithCompensation,
490
+ ctx: ExpansionContext,
491
+ parentSettingsCompensation?: ChainCompensationPolicy,
492
+ ): ExpandedFragment & CarriedCompensation {
493
+ const expanded = expandChain(chain, ctx);
494
+ const carried = carryCompensation(chain, expanded, ctx, parentSettingsCompensation);
495
+ return { ...expanded, nodes: carried.nodes, settingsCompensation: carried.settingsCompensation };
496
+ }
497
+
309
498
  // ---------------------------------------------------------------------------
310
499
  // RFC 0124 (WCP4) — Portable per-run parameter deferral.
311
500
  //
@@ -0,0 +1,179 @@
1
+ /**
2
+ * RFC 0152 §A/§B/§C/§D — the suite's own A2A peer at 1.0, pinned.
3
+ *
4
+ * `A2AFakePeer` became dual-era at suite 1.112.0 (1.0 + 0.3-legacy). This file
5
+ * boots a private instance and asserts, from the wire, that it actually speaks
6
+ * A2A 1.0 as `a2a-integration.md` §"A2A 1.0 versioned composition" defines it
7
+ * — because a fake peer that is 1.0-shaped only in its comments would let every
8
+ * §C/§D host leg pass against a 0.3 wire and prove nothing (the "detector that
9
+ * could never go green" failure, inverted).
10
+ *
11
+ * Pinned here (all server-free; the peer is in-process on 127.0.0.1):
12
+ * - the Agent Card is 1.0-shaped: `supportedInterfaces[]{url,protocolBinding,
13
+ * protocolVersion}` (one per spoken revision), no top-level `url` /
14
+ * `protocolVersion`, `capabilities.extendedAgentCard`;
15
+ * - a legacy-only peer serves the 0.3 card (so the shape follows the revision,
16
+ * not the calendar);
17
+ * - `A2A-Version` absent ⇒ 0.3 semantics; unsupported ⇒ `-32009`
18
+ * `VERSION_NOT_SUPPORTED` with `supportedVersions[]`; a 1.0-only peer
19
+ * rejects header-less requests;
20
+ * - `SendMessage` returns `{ task }` (the `SendMessageResponse` oneof) with
21
+ * `TASK_STATE_*`, `ROLE_*`, `Part` as a `oneof` (no `kind`), `status.timestamp`;
22
+ * - `GetTask` / `CancelTask` / `ListTasks` behave per §D.1, incl.
23
+ * `TASK_NOT_CANCELABLE` on a terminal task;
24
+ * - a 0.3 method name under a 1.0 header is a method-not-found, loudly.
25
+ *
26
+ * This is the peer half of the RFC-named `a2a-1.0-agent-card` and
27
+ * `a2a-1.0-task-roundtrip` scenarios; the host halves are
28
+ * `a2a-card-runtime-consistency.test.ts` and `a2a-1-0-task-roundtrip.test.ts`,
29
+ * gated on the host claiming `a2a-1.0`.
30
+ *
31
+ * @see spec/v1/a2a-integration.md §"A2A 1.0 versioned composition" §A–§D
32
+ * @see conformance/src/lib/a2a-fake-peer.ts
33
+ */
34
+
35
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
36
+ import { A2AFakePeer } from '../lib/a2a-fake-peer.js';
37
+
38
+ export const HOST_CALLBACK_NOT_REQUIRED = 'the suite drives both ends itself: it boots the dual-era A2AFakePeer in-process and reads its agent card directly; no host connection is originated';
39
+
40
+ async function rpc(endpoint: string, method: string, params: unknown, version?: string, id = 1) {
41
+ const headers: Record<string, string> = { 'content-type': 'application/json' };
42
+ if (version !== undefined) headers['A2A-Version'] = version;
43
+ const res = await fetch(`${endpoint}/a2a/jsonrpc`, { method: 'POST', headers, body: JSON.stringify({ jsonrpc: '2.0', id, method, params }) });
44
+ const body = (await res.json()) as { result?: Record<string, unknown>; error?: { code: number; message: string; data?: Record<string, unknown> } };
45
+ return { status: res.status, ...body };
46
+ }
47
+
48
+ describe('RFC 0152 — the suite peer speaks A2A 1.0 (dual-era A2AFakePeer)', () => {
49
+ // Explicit 1.0-first so a header-less GET returns the 1.0 card (the default
50
+ // is 0.3-first for today's 0.3 clients; both eras are spoken either way).
51
+ const peer = new A2AFakePeer({ protocolVersions: ['1.0', '0.3'] });
52
+ beforeAll(async () => peer.start(0));
53
+ afterAll(async () => peer.stop());
54
+
55
+ it('the Agent Card is 1.0-shaped: supportedInterfaces[], no top-level url/protocolVersion', async () => {
56
+ const res = await fetch(`${peer.endpoint()}/.well-known/agent-card.json`);
57
+ expect(res.status).toBe(200);
58
+ const card = (await res.json()) as Record<string, unknown>;
59
+ const ifaces = card['supportedInterfaces'] as Array<{ url: string; protocolBinding: string; protocolVersion: string }>;
60
+ expect(Array.isArray(ifaces) && ifaces.length > 0, '1.0 REQUIRES supportedInterfaces[]').toBe(true);
61
+ expect(ifaces.map((i) => i.protocolVersion)).toEqual(['1.0', '0.3']);
62
+ for (const i of ifaces) {
63
+ expect(i.protocolBinding).toBe('JSONRPC');
64
+ expect(i.url.startsWith(peer.endpoint())).toBe(true);
65
+ }
66
+ expect(card['url'], '1.0 removed top-level url').toBeUndefined();
67
+ expect(card['protocolVersion'], '1.0 removed top-level protocolVersion (it is per interface)').toBeUndefined();
68
+ const caps = card['capabilities'] as Record<string, unknown>;
69
+ expect(caps['extendedAgentCard']).toBe(false);
70
+ expect(Array.isArray(card['skills']) && (card['skills'] as unknown[]).length > 0, 'skills[] REQUIRED').toBe(true);
71
+ for (const k of ['name', 'description', 'version', 'defaultInputModes', 'defaultOutputModes']) expect(card[k], `${k} REQUIRED`).toBeDefined();
72
+ });
73
+
74
+ it('the card shape follows the era asked for: A2A-Version: 0.3 on the GET returns the 0.3 card from the same peer', async () => {
75
+ const res = await fetch(`${peer.endpoint()}/.well-known/agent-card.json`, { headers: { 'A2A-Version': '0.3' } });
76
+ const card = (await res.json()) as Record<string, unknown>;
77
+ expect(card['protocolVersion']).toBe('0.3.0');
78
+ expect(typeof card['url']).toBe('string');
79
+ expect(card['supportedInterfaces']).toBeUndefined();
80
+ // and the default-constructed peer (0.3-first) serves the 0.3 card to a header-less GET
81
+ const dflt = new A2AFakePeer();
82
+ await dflt.start(0);
83
+ try {
84
+ const c = (await (await fetch(`${dflt.endpoint()}/.well-known/agent-card.json`)).json()) as Record<string, unknown>;
85
+ expect(c['protocolVersion']).toBe('0.3.0');
86
+ const c10 = (await (await fetch(`${dflt.endpoint()}/.well-known/agent-card.json`, { headers: { 'A2A-Version': '1.0' } })).json()) as Record<string, unknown>;
87
+ expect(Array.isArray(c10['supportedInterfaces'])).toBe(true);
88
+ } finally {
89
+ await dflt.stop();
90
+ }
91
+ });
92
+
93
+ it('a legacy-only peer serves the 0.3 card — shape follows revision, not calendar', async () => {
94
+ const legacy = new A2AFakePeer({ protocolVersions: ['0.3'] });
95
+ await legacy.start(0);
96
+ try {
97
+ const card = (await (await fetch(`${legacy.endpoint()}/.well-known/agent-card.json`)).json()) as Record<string, unknown>;
98
+ expect(card['protocolVersion']).toBe('0.3.0');
99
+ expect(typeof card['url']).toBe('string');
100
+ expect(card['supportedInterfaces']).toBeUndefined();
101
+ } finally {
102
+ await legacy.stop();
103
+ }
104
+ });
105
+
106
+ it('A2A-Version absent ⇒ 0.3 semantics; message/send returns a 0.3 task', async () => {
107
+ const r = await rpc(peer.endpoint(), 'message/send', { message: { kind: 'message', messageId: 'm1', role: 'user', parts: [{ kind: 'text', text: 'hi' }] } });
108
+ expect(r.error).toBeUndefined();
109
+ expect(r.result?.['kind']).toBe('task');
110
+ expect((r.result?.['status'] as { state: string }).state).toBe('submitted');
111
+ });
112
+
113
+ it('an unsupported version fails -32009 VERSION_NOT_SUPPORTED with supportedVersions[] (HTTP 400)', async () => {
114
+ const r = await rpc(peer.endpoint(), 'SendMessage', { message: { messageId: 'm2', role: 'ROLE_USER', parts: [{ text: 'hi' }] } }, '99.0');
115
+ expect(r.status).toBe(400);
116
+ expect(r.error?.code).toBe(-32009);
117
+ expect(r.error?.data?.['reason']).toBe('VERSION_NOT_SUPPORTED');
118
+ expect(r.error?.data?.['supportedVersions']).toEqual(['1.0', '0.3']); // constructor order
119
+ });
120
+
121
+ it('a 1.0-only peer rejects a header-less request (which is 0.3 by rule)', async () => {
122
+ const only10 = new A2AFakePeer({ protocolVersions: ['1.0'] });
123
+ await only10.start(0);
124
+ try {
125
+ const r = await rpc(only10.endpoint(), 'message/send', { message: { parts: [] } });
126
+ expect(r.error?.code).toBe(-32009);
127
+ expect(r.error?.data?.['requested']).toBe('0.3');
128
+ } finally {
129
+ await only10.stop();
130
+ }
131
+ });
132
+
133
+ it('SendMessage @1.0 returns { task } with TASK_STATE_*, ROLE_*, Part oneof, no kind, status.timestamp', async () => {
134
+ peer.reset();
135
+ const r = await rpc(peer.endpoint(), 'SendMessage', { message: { messageId: 'm3', role: 'ROLE_USER', parts: [{ text: 'hello 1.0' }] } }, '1.0');
136
+ expect(r.error).toBeUndefined();
137
+ const task = r.result?.['task'] as Record<string, unknown>;
138
+ expect(task, 'SendMessageResponse is a oneof payload: { task } | { message }').toBeDefined();
139
+ expect(task['kind'], '1.0 removed the kind discriminator').toBeUndefined();
140
+ const status = task['status'] as { state: string; timestamp?: string };
141
+ expect(status.state).toBe('TASK_STATE_SUBMITTED');
142
+ expect(typeof status.timestamp).toBe('string');
143
+ const history = task['history'] as Array<Record<string, unknown>>;
144
+ expect(history[0]?.['role']).toBe('ROLE_USER');
145
+ const part = (history[0]?.['parts'] as Array<Record<string, unknown>>)[0]!;
146
+ expect(part['text']).toBe('hello 1.0');
147
+ expect(part['kind'], 'Part is a oneof — discriminate by member presence').toBeUndefined();
148
+ expect(Array.isArray(task['artifacts'])).toBe(true);
149
+ });
150
+
151
+ it('GetTask / CancelTask / ListTasks per §D.1, incl. TASK_NOT_CANCELABLE on a terminal task', async () => {
152
+ peer.reset();
153
+ const sent = await rpc(peer.endpoint(), 'SendMessage', { message: { messageId: 'm4', role: 'ROLE_USER', parts: [{ text: 'x' }] } }, '1.0');
154
+ const id = (sent.result?.['task'] as { id: string }).id;
155
+ const got = await rpc(peer.endpoint(), 'GetTask', { id }, '1.0');
156
+ expect((got.result as { status: { state: string } }).status.state).toBe('TASK_STATE_SUBMITTED');
157
+ const listed = await rpc(peer.endpoint(), 'ListTasks', { status: 'TASK_STATE_SUBMITTED' }, '1.0');
158
+ expect((listed.result as { totalSize: number }).totalSize).toBe(1);
159
+ const cancelled = await rpc(peer.endpoint(), 'CancelTask', { id }, '1.0');
160
+ expect((cancelled.result as { status: { state: string } }).status.state).toBe('TASK_STATE_CANCELED');
161
+ const again = await rpc(peer.endpoint(), 'CancelTask', { id }, '1.0');
162
+ expect(again.error?.code).toBe(-32002);
163
+ expect(again.error?.data?.['reason']).toBe('TASK_NOT_CANCELABLE');
164
+ const missing = await rpc(peer.endpoint(), 'GetTask', { id: 'nope' }, '1.0');
165
+ expect(missing.error?.code).toBe(-32001);
166
+ expect(missing.error?.data?.['reason']).toBe('TASK_NOT_FOUND');
167
+ });
168
+
169
+ it('a 0.3 method name under a 1.0 header is method-not-found — loudly', async () => {
170
+ const r = await rpc(peer.endpoint(), 'message/send', { message: { parts: [] } }, '1.0');
171
+ expect(r.status).toBe(404);
172
+ expect(r.error?.code).toBe(-32601);
173
+ });
174
+
175
+ it('streaming is honestly unsupported: SubscribeToTask ⇒ UNSUPPORTED_OPERATION, matching capabilities.streaming=false', async () => {
176
+ const r = await rpc(peer.endpoint(), 'SubscribeToTask', { id: 'task-1' }, '1.0');
177
+ expect(r.error?.code).toBe(-32004);
178
+ });
179
+ });
@@ -0,0 +1,109 @@
1
+ /**
2
+ * RFC 0152 §D — the host as an A2A 1.0 SERVER: `SendMessage` → Task → `GetTask`
3
+ * (the task/event translation leg, host half of the RFC-named
4
+ * `a2a-1.0-task-roundtrip`).
5
+ *
6
+ * `a2a-integration.md` §"A2A 1.0 versioned composition" §D.1/§D.4: `SendMessage`
7
+ * (JSON-RPC method name, `A2A-Version: 1.0`) with no `taskId` creates a run and
8
+ * returns `{ task }`; `Task.id` IS the backing `runId` (RFC 0100); `status.state`
9
+ * is the 1.0 spelling projected via the D.4 bijection; `Part` is a oneof (no
10
+ * `kind`); `history[].role` is `ROLE_*`; `GetTask` reads it back; an unknown id
11
+ * is `TASK_NOT_FOUND` (`-32001`) — the same answer a cross-tenant id gets (§E,
12
+ * no enumeration).
13
+ *
14
+ * The endpoint is discovered the way a peer would: from the host's own Agent
15
+ * Card, `supportedInterfaces[]` with `protocolBinding: "JSONRPC"` and
16
+ * `protocolVersion: "1.0"`. Auth: the host's normal API credential is sent as
17
+ * the bearer, so the peer principal resolves to the conformance caller.
18
+ *
19
+ * Gate: `capabilities.a2a.profiles` contains `a2a-1.0` — a 0.3 host soft-skips
20
+ * (`blocked`) and this becomes its witness when it flips. Hard-fails under
21
+ * `OPENWOP_REQUIRE_BEHAVIOR=true`.
22
+ *
23
+ * @see spec/v1/a2a-integration.md §"A2A 1.0 versioned composition" §D.1, §D.4, §E
24
+ */
25
+
26
+ import { describe, it, expect } from 'vitest';
27
+ import { driver } from '../lib/driver.js';
28
+ import { behaviorGate } from '../lib/behavior-gate.js';
29
+ import { capabilityFamily } from '../lib/discovery-capabilities.js';
30
+
31
+ const PROFILE = 'a2a-1.0';
32
+ const STATES_10 = [
33
+ 'TASK_STATE_SUBMITTED', 'TASK_STATE_WORKING', 'TASK_STATE_INPUT_REQUIRED', 'TASK_STATE_AUTH_REQUIRED',
34
+ 'TASK_STATE_COMPLETED', 'TASK_STATE_FAILED', 'TASK_STATE_CANCELED', 'TASK_STATE_REJECTED',
35
+ ];
36
+
37
+ interface A2ACaps {
38
+ readonly supported?: boolean;
39
+ readonly agentCardUrl?: string;
40
+ readonly profiles?: readonly string[];
41
+ }
42
+
43
+ async function a2a(): Promise<A2ACaps | undefined> {
44
+ const disco = await driver.get('/.well-known/openwop');
45
+ return capabilityFamily<A2ACaps>(disco.json, 'a2a');
46
+ }
47
+
48
+ async function claims10(): Promise<boolean> {
49
+ const caps = await a2a();
50
+ return caps?.supported === true && (caps.profiles ?? []).includes('a2a-1.0');
51
+ }
52
+
53
+ async function jsonrpc10Url(): Promise<string | null> {
54
+ const caps = await a2a();
55
+ if (typeof caps?.agentCardUrl !== 'string') return null;
56
+ const res = await fetch(caps.agentCardUrl, { headers: { accept: 'application/json' } });
57
+ if (res.status !== 200) return null;
58
+ const card = (await res.json()) as { supportedInterfaces?: Array<{ url?: string; protocolBinding?: string; protocolVersion?: string }> };
59
+ const iface = (card.supportedInterfaces ?? []).find((i) => i.protocolBinding === 'JSONRPC' && i.protocolVersion === '1.0');
60
+ return typeof iface?.url === 'string' ? iface.url : null;
61
+ }
62
+
63
+ async function rpc10(url: string, method: string, params: unknown, id: number) {
64
+ const headers: Record<string, string> = { 'content-type': 'application/json', accept: 'application/json', 'A2A-Version': '1.0' };
65
+ const key = process.env.OPENWOP_API_KEY;
66
+ if (key) headers['authorization'] = `Bearer ${key}`;
67
+ const res = await fetch(url, { method: 'POST', headers, body: JSON.stringify({ jsonrpc: '2.0', id, method, params }) });
68
+ const body = (await res.json().catch(() => ({}))) as { result?: Record<string, unknown>; error?: { code: number; message: string; data?: Record<string, unknown> } };
69
+ return { status: res.status, ...body };
70
+ }
71
+
72
+ describe('RFC 0152 §D — a2a-1.0-task-roundtrip (host as A2A 1.0 server, gated on a2a.profiles ∋ a2a-1.0)', () => {
73
+ it('SendMessage @1.0 creates a task whose id is the runId, in 1.0 spelling; GetTask reads it back; unknown id is TASK_NOT_FOUND', async () => {
74
+ if (!behaviorGate(PROFILE, await claims10())) return;
75
+ const url = await jsonrpc10Url();
76
+ expect(url, driver.describe('a2a-integration.md §C', 'a `a2a-1.0` host MUST publish a JSONRPC/1.0 interface in its card')).not.toBeNull();
77
+ const sent = await rpc10(url!, 'SendMessage', {
78
+ message: { messageId: `conf-${Date.now()}`, role: 'ROLE_USER', parts: [{ text: 'openwop conformance a2a-1.0 roundtrip' }] },
79
+ configuration: { returnImmediately: true },
80
+ }, 1);
81
+ expect(sent.status, driver.describe('a2a-integration.md §D.1', 'SendMessage MUST be accepted on the 1.0 interface')).toBe(200);
82
+ expect(sent.error, driver.describe('a2a-integration.md §D.1', `SendMessage MUST NOT error: ${JSON.stringify(sent.error)}`)).toBeUndefined();
83
+ const result = sent.result ?? {};
84
+ // SendMessageResponse is a oneof: { task } | { message }. A run-creating host answers with a task.
85
+ const task = (result['task'] ?? (result['id'] !== undefined ? result : undefined)) as { id?: string; contextId?: string; status?: { state?: string; timestamp?: string }; history?: Array<{ role?: string; parts?: Array<Record<string, unknown>> }>; kind?: unknown } | undefined;
86
+ expect(task, driver.describe('a2a-integration.md §D.1', 'SendMessage without taskId MUST create a run and return `{ task }`')).toBeDefined();
87
+ expect(task!.kind, driver.describe('a2a-integration.md §D.5', '1.0 has no `kind` discriminator')).toBeUndefined();
88
+ expect(typeof task!.id).toBe('string');
89
+ expect(STATES_10, driver.describe('a2a-integration.md §D.4', 'status.state MUST use the 1.0 TASK_STATE_* spelling')).toContain(task!.status?.state);
90
+ for (const m of task!.history ?? []) {
91
+ expect(['ROLE_USER', 'ROLE_AGENT'], driver.describe('a2a-integration.md §D.2', 'history roles are ROLE_*')).toContain(m.role);
92
+ for (const p of m.parts ?? []) expect(p['kind'], driver.describe('a2a-integration.md §D.3', 'Part is a oneof — no `kind`')).toBeUndefined();
93
+ }
94
+ // Task.id IS the runId (RFC 0100): the OpenWOP snapshot for it MUST exist for the same caller.
95
+ const snap = await driver.get(`/v1/runs/${encodeURIComponent(task!.id!)}`);
96
+ expect(snap.status, driver.describe('a2a-integration.md §D.4', '`Task.id` MUST be the backing runId — GET /v1/runs/{id} resolves it for the same principal')).toBe(200);
97
+ // GetTask reads it back with the same id and a valid 1.0 state.
98
+ const got = await rpc10(url!, 'GetTask', { id: task!.id }, 2);
99
+ expect(got.error).toBeUndefined();
100
+ const gotTask = (got.result ?? {}) as { id?: string; status?: { state?: string } };
101
+ expect(gotTask.id).toBe(task!.id);
102
+ expect(STATES_10).toContain(gotTask.status?.state);
103
+ // Unknown id — and, per §E, a cross-tenant id — MUST be TASK_NOT_FOUND, never forbidden.
104
+ const missing = await rpc10(url!, 'GetTask', { id: `run_does_not_exist_${Date.now()}` }, 3);
105
+ expect(missing.error?.code, driver.describe('a2a-integration.md §D.7/§E', 'an unknown or unreadable task MUST be TASK_NOT_FOUND (-32001) — no enumeration')).toBe(-32001);
106
+ // Tidy: cancel the run we created (best effort; terminal runs answer TASK_NOT_CANCELABLE which is fine).
107
+ await rpc10(url!, 'CancelTask', { id: task!.id }, 4);
108
+ });
109
+ });
@@ -0,0 +1,167 @@
1
+ /**
2
+ * RFC 0152 §C — Agent Card ↔ runtime consistency (invariant
3
+ * `a2a-card-runtime-consistent`, named by RFC 0152 §E).
4
+ *
5
+ * `a2a-integration.md` §"A2A 1.0 versioned composition" §C: the card and
6
+ * `capabilities.a2a` MUST be generated from the same source the runtime routes
7
+ * on — the SET of `supportedInterfaces[].protocolVersion` MUST equal
8
+ * `capabilities.a2a.protocolVersions`, `capabilities.streaming` /
9
+ * `pushNotifications` MUST equal the discovery flags, the interface carrying
10
+ * `preferredVersion` SHOULD be listed first, `skills[]` MUST be non-empty, and
11
+ * a `a2a-1.0` host MUST serve the JSON-RPC binding at 1.0 (the mandatory floor).
12
+ *
13
+ * This is a BLACK-BOX leg against the host's own two documents — no peer, no
14
+ * seam: `GET /.well-known/openwop` (`capabilities.a2a`) and
15
+ * `GET <agentCardUrl>`. Two documents that describe one fact and disagree are
16
+ * card/runtime drift, which is what the invariant forbids.
17
+ *
18
+ * Gate: `capabilities.a2a.profiles` contains `a2a-1.0`. Written against the
19
+ * 1.0 card shape ONLY (per the host maintainers' explicit ask: do NOT
20
+ * dual-shape it to pass on 0.3) — a 0.3-card host soft-skips here and records
21
+ * `blocked`, and this leg becomes its witness the moment it flips the card.
22
+ * Hard-fails under `OPENWOP_REQUIRE_BEHAVIOR=true`.
23
+ *
24
+ * @see spec/v1/a2a-integration.md §"A2A 1.0 versioned composition" §C
25
+ */
26
+
27
+ import { describe, it, expect } from 'vitest';
28
+ import { softSkip } from '../lib/soft-skip.js';
29
+ import { driver } from '../lib/driver.js';
30
+ import { behaviorGate } from '../lib/behavior-gate.js';
31
+ import { capabilityFamily } from '../lib/discovery-capabilities.js';
32
+
33
+ const PROFILE = 'a2a-1.0';
34
+
35
+ interface A2ACaps {
36
+ readonly supported?: boolean;
37
+ readonly agentCardUrl?: string;
38
+ readonly streaming?: boolean;
39
+ readonly pushNotifications?: boolean;
40
+ readonly protocolVersions?: readonly string[];
41
+ readonly preferredVersion?: string;
42
+ readonly profiles?: readonly string[];
43
+ }
44
+
45
+ interface Card10 {
46
+ name?: string;
47
+ description?: string;
48
+ version?: string;
49
+ supportedInterfaces?: Array<{ url?: string; protocolBinding?: string; protocolVersion?: string; tenant?: string }>;
50
+ capabilities?: { streaming?: boolean; pushNotifications?: boolean; extensions?: unknown[]; extendedAgentCard?: boolean };
51
+ skills?: Array<{ id?: string; name?: string }>;
52
+ url?: unknown;
53
+ protocolVersion?: unknown;
54
+ }
55
+
56
+ async function a2a(): Promise<A2ACaps | undefined> {
57
+ const disco = await driver.get('/.well-known/openwop');
58
+ return capabilityFamily<A2ACaps>(disco.json, 'a2a');
59
+ }
60
+
61
+ async function claims10(): Promise<boolean> {
62
+ const caps = await a2a();
63
+ return caps?.supported === true && (caps.profiles ?? []).includes('a2a-1.0');
64
+ }
65
+
66
+ async function fetchCard(caps: A2ACaps): Promise<Card10 | null> {
67
+ if (typeof caps.agentCardUrl !== 'string') return null;
68
+ // A 1.0 client MUST declare the version on the card GET too (a2a-integration.md
69
+ // §C, decided 2026-08-16 — S18 Q1): header-less means 0.3 by the receiver rule,
70
+ // so a host that still advertises `a2a-0.3-legacy` serves the 0.3-shaped card
71
+ // header-less and the 1.0 card only when asked for it. Fetching header-less
72
+ // and asserting the 1.0 shape (as this leg did until today) forced the host to
73
+ // break every 0.3 client reading `card.url` NOW instead of at the legacy sunset.
74
+ const res = await fetch(caps.agentCardUrl, { headers: { accept: 'application/json', 'A2A-Version': '1.0' } });
75
+ if (res.status !== 200) return null;
76
+ return (await res.json()) as Card10;
77
+ }
78
+
79
+ describe('RFC 0152 §C — a2a-card-runtime-consistent (gated on a2a.profiles ∋ a2a-1.0)', () => {
80
+ it('the card is 1.0-shaped and reachable at agentCardUrl', async () => {
81
+ if (!behaviorGate(PROFILE, await claims10())) return;
82
+ const caps = (await a2a())!;
83
+ const card = await fetchCard(caps);
84
+ expect(card, driver.describe('a2a-integration.md §C', '`agentCardUrl` MUST resolve to the Agent Card (200)')).not.toBeNull();
85
+ expect(
86
+ Array.isArray(card!.supportedInterfaces) && card!.supportedInterfaces!.length > 0,
87
+ driver.describe('a2a-integration.md §C', 'a host claiming `a2a-1.0` MUST publish a 1.0 card: `supportedInterfaces[]` REQUIRED (≥1)'),
88
+ ).toBe(true);
89
+ expect(card!.url, driver.describe('a2a-integration.md §C', '1.0 removed the top-level `url` — a card with both shapes is neither')).toBeUndefined();
90
+ expect(card!.protocolVersion, driver.describe('a2a-integration.md §C', '1.0 removed the top-level `protocolVersion` (per interface now)')).toBeUndefined();
91
+ expect(Array.isArray(card!.skills) && card!.skills!.length > 0, driver.describe('a2a-integration.md §C', '`skills[]` MUST be non-empty — one per invocable workflow')).toBe(true);
92
+ });
93
+
94
+ it('a header-less card GET returns the 0.3-shaped card while a2a-0.3-legacy is advertised (§B receiver rule applied to discovery — S18 Q1)', async () => {
95
+ if (!behaviorGate(PROFILE, await claims10())) return;
96
+ const caps = (await a2a())!;
97
+ if (typeof caps.agentCardUrl !== 'string') return softSkip('blocked', 'no agentCardUrl advertised');
98
+ const legacy = (caps.protocolVersions ?? []).includes('0.3');
99
+ const res = await fetch(caps.agentCardUrl, { headers: { accept: 'application/json' } });
100
+ expect(res.status, driver.describe('a2a-integration.md §C', 'a header-less card GET MUST succeed — discovery must not fail')).toBe(200);
101
+ const card = (await res.json()) as Card10 & { url?: string; protocolVersion?: string };
102
+ if (legacy) {
103
+ // While 0.3 is advertised the header-less card is the 0.3 shape: an
104
+ // external 0.3 client reading `card.url` keeps working through the
105
+ // legacy window — the point of advertising `a2a-0.3-legacy` at all.
106
+ expect(typeof card.url, driver.describe('a2a-integration.md §C', 'header-less = 0.3 (§B receiver rule): the card MUST carry the 0.3 top-level `url` while `protocolVersions ∋ 0.3`')).toBe('string');
107
+ expect(card.supportedInterfaces, driver.describe('a2a-integration.md §C', 'the header-less card MUST NOT be the 1.0 shape while 0.3 is advertised (a card with both shapes is neither)')).toBeUndefined();
108
+ } else {
109
+ // 0.3 dropped: the preferred-version (1.0) card is served header-less.
110
+ expect(Array.isArray(card.supportedInterfaces) && card.supportedInterfaces!.length > 0, driver.describe('a2a-integration.md §C', 'with 0.3 dropped, the header-less card is the preferredVersion (1.0) card')).toBe(true);
111
+ }
112
+ });
113
+
114
+ it('the set of supportedInterfaces[].protocolVersion equals capabilities.a2a.protocolVersions', async () => {
115
+ if (!behaviorGate(PROFILE, await claims10())) return;
116
+ const caps = (await a2a())!;
117
+ const card = await fetchCard(caps);
118
+ if (card === null) return; // asserted above
119
+ const fromCard = [...new Set((card.supportedInterfaces ?? []).map((i) => i.protocolVersion).filter((v): v is string => typeof v === 'string'))].sort();
120
+ const fromDiscovery = [...new Set(caps.protocolVersions ?? [])].sort();
121
+ expect(
122
+ fromCard,
123
+ driver.describe(
124
+ 'a2a-integration.md §C',
125
+ 'the SET of `supportedInterfaces[].protocolVersion` MUST equal `capabilities.a2a.protocolVersions` — two documents, one fact; ' +
126
+ 'a version advertised in one and not the other is card/runtime drift (`a2a-card-runtime-consistent`)',
127
+ ),
128
+ ).toEqual(fromDiscovery);
129
+ });
130
+
131
+ it('a2a-1.0 MUST serve the JSON-RPC binding at 1.0 (the mandatory floor), and SHOULD list the preferred interface first', async () => {
132
+ if (!behaviorGate(PROFILE, await claims10())) return;
133
+ const caps = (await a2a())!;
134
+ const card = await fetchCard(caps);
135
+ if (card === null) return;
136
+ const ifaces = card.supportedInterfaces ?? [];
137
+ expect(
138
+ ifaces.some((i) => i.protocolBinding === 'JSONRPC' && i.protocolVersion === '1.0' && typeof i.url === 'string'),
139
+ driver.describe('a2a-integration.md §C', 'the `a2a-1.0` profile floor is the JSON-RPC binding at 1.0; HTTP+JSON / gRPC are optional additions'),
140
+ ).toBe(true);
141
+ for (const i of ifaces) {
142
+ expect(['JSONRPC', 'GRPC', 'HTTP+JSON'], driver.describe('a2a-integration.md §C', 'protocolBinding uses the upstream binding names')).toContain(i.protocolBinding);
143
+ }
144
+ if (typeof caps.preferredVersion === 'string' && ifaces.length > 0) {
145
+ // SHOULD, so a soft expectation: report, do not fail.
146
+ if (ifaces[0]?.protocolVersion !== caps.preferredVersion) {
147
+ // eslint-disable-next-line no-console
148
+ console.warn(`[a2a-card-runtime-consistency] SHOULD: the interface carrying preferredVersion (${caps.preferredVersion}) is not listed first`);
149
+ }
150
+ }
151
+ });
152
+
153
+ it('card capabilities.streaming / pushNotifications equal the discovery flags', async () => {
154
+ if (!behaviorGate(PROFILE, await claims10())) return;
155
+ const caps = (await a2a())!;
156
+ const card = await fetchCard(caps);
157
+ if (card === null) return;
158
+ expect(
159
+ card.capabilities?.streaming === true,
160
+ driver.describe('a2a-integration.md §C', '`capabilities.streaming` on the card MUST equal `capabilities.a2a.streaming` on discovery'),
161
+ ).toBe(caps.streaming === true);
162
+ expect(
163
+ card.capabilities?.pushNotifications === true,
164
+ driver.describe('a2a-integration.md §C', '`capabilities.pushNotifications` on the card MUST equal `capabilities.a2a.pushNotifications`'),
165
+ ).toBe(caps.pushNotifications === true);
166
+ });
167
+ });