@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.
- package/README.md +3 -3
- package/coverage.md +6 -0
- package/dist/cli.js +89 -45
- package/dist/lib/certification-bundle-verify.js +274 -0
- package/dist/lib/profiles.js +102 -19
- package/dist/lib/requirement-ledger.js +182 -0
- package/dist/lib/requirement-registry.js +110 -0
- package/dist/lib/scenario-disposition.js +249 -0
- package/dist/lib/soft-skip.js +82 -0
- package/fixtures/conformance-a2a-task-roundtrip.json +1 -1
- package/fixtures.md +1 -1
- package/package.json +1 -1
- package/schemas/CORPUS-STAMP.json +2 -2
- package/schemas/README.md +1 -0
- package/schemas/capabilities.schema.json +2 -2
- package/schemas/compensation-policy.schema.json +64 -0
- package/schemas/run-snapshot.schema.json +5 -0
- package/schemas/workflow-chain-pack-manifest.schema.json +125 -0
- package/schemas/workflow-definition.schema.json +4 -0
- package/src/cli.ts +99 -45
- package/src/lib/a2a-fake-peer.ts +290 -110
- package/src/lib/certification-bundle-verify.ts +356 -0
- package/src/lib/error-envelope.ts +77 -0
- package/src/lib/llm-cache-key-recipe.ts +6 -5
- package/src/lib/mcp-fake-server.ts +235 -72
- package/src/lib/profiles.ts +132 -19
- package/src/lib/requirement-ledger.ts +98 -1
- package/src/lib/requirement-registry.ts +47 -4
- package/src/lib/scenario-disposition.ts +291 -0
- package/src/lib/soft-skip.ts +89 -0
- package/src/lib/workflow-chain-expansion.ts +189 -0
- package/src/scenarios/a2a-1-0-agent-card.test.ts +179 -0
- package/src/scenarios/a2a-1-0-task-roundtrip.test.ts +109 -0
- package/src/scenarios/a2a-card-runtime-consistency.test.ts +167 -0
- package/src/scenarios/a2a-peer-authority.test.ts +99 -0
- package/src/scenarios/a2a-task-roundtrip.test.ts +18 -6
- package/src/scenarios/agent-loop-iteration-monotonic.test.ts +3 -2
- package/src/scenarios/agent-loop-stateful-resume.test.ts +3 -2
- package/src/scenarios/agent-loop-version5-shape.test.ts +2 -1
- package/src/scenarios/agent-loop-workspace-snapshot.test.ts +4 -3
- package/src/scenarios/approval-gate-flow.test.ts +5 -4
- package/src/scenarios/artifact-type-store-without-render.test.ts +2 -1
- package/src/scenarios/auth-scim-profile.test.ts +5 -4
- package/src/scenarios/authorization-fail-closed.test.ts +4 -3
- package/src/scenarios/certification-bundle-non-vacuous.test.ts +249 -0
- package/src/scenarios/certification-bundle-redaction.test.ts +182 -0
- package/src/scenarios/certification-floor-enforcement.test.ts +30 -6
- package/src/scenarios/chain-compensation-expansion.test.ts +243 -0
- package/src/scenarios/commitment-fired.test.ts +3 -2
- package/src/scenarios/compensation-behavior.test.ts +63 -0
- package/src/scenarios/compensation-profile.test.ts +144 -1
- package/src/scenarios/conformance-advertised-seam-required.test.ts +118 -0
- package/src/scenarios/conformance-execution-witness.test.ts +104 -0
- package/src/scenarios/conversationCapabilityNegotiation.test.ts +3 -2
- package/src/scenarios/core-manifest-and-extension-registry.test.ts +26 -0
- package/src/scenarios/credential-payload-redaction.test.ts +4 -3
- package/src/scenarios/credentials-capability-shape.test.ts +4 -3
- package/src/scenarios/cross-engine-append-behavior.test.ts +5 -4
- package/src/scenarios/cross-engine-append-ordering.test.ts +3 -2
- package/src/scenarios/cross-host-ancestry-endpoint.test.ts +5 -4
- package/src/scenarios/cross-host-causation-shape.test.ts +3 -2
- package/src/scenarios/cross-workspace-isolation.test.ts +4 -3
- package/src/scenarios/data-residency-admission.test.ts +6 -6
- package/src/scenarios/deadletter-retry-exhaustion.test.ts +5 -4
- package/src/scenarios/distillation-index-roundtrip.test.ts +3 -2
- package/src/scenarios/distillation-secret-carryforward.test.ts +3 -2
- package/src/scenarios/distillation-shape.test.ts +2 -1
- package/src/scenarios/distillation-stable-archive.test.ts +4 -3
- package/src/scenarios/distillation-token-budget.test.ts +4 -3
- package/src/scenarios/envelope-recovery-applied.test.ts +3 -2
- package/src/scenarios/envelope-refusal-shape.test.ts +4 -3
- package/src/scenarios/error-envelope-canonical-shape.test.ts +64 -0
- package/src/scenarios/feedback-correction-redaction.test.ts +4 -3
- package/src/scenarios/feedback-cross-tenant-isolation.test.ts +4 -3
- package/src/scenarios/feedback-fork-not-copied.test.ts +6 -5
- package/src/scenarios/feedback-on-terminal-run.test.ts +4 -3
- package/src/scenarios/feedback-record-and-list.test.ts +4 -3
- package/src/scenarios/feedback-unsupported-501.test.ts +3 -2
- package/src/scenarios/fixtures-valid.test.ts +16 -18
- package/src/scenarios/fs-path-traversal.test.ts +3 -2
- package/src/scenarios/heartbeat-capability-shape.test.ts +2 -1
- package/src/scenarios/heartbeat-fires-once-per-tick.test.ts +4 -3
- package/src/scenarios/heartbeat-idempotent-no-spam.test.ts +6 -5
- package/src/scenarios/heartbeat-runtime-bound.test.ts +4 -3
- package/src/scenarios/mcp-2026-07-28-discover.test.ts +207 -0
- package/src/scenarios/mcp-cache-tenant-scope.test.ts +66 -0
- package/src/scenarios/mcp-current-auth-boundary.test.ts +55 -0
- package/src/scenarios/mcp-extension-opacity.test.ts +79 -0
- package/src/scenarios/mcp-mrtr-roundtrip.test.ts +140 -0
- package/src/scenarios/mcp-stateless-request.test.ts +77 -0
- package/src/scenarios/mcp-tool-roundtrip.test.ts +4 -3
- package/src/scenarios/mcp-toolcall-redaction.test.ts +2 -1
- package/src/scenarios/memory-attribution-emits-on-write.test.ts +6 -5
- package/src/scenarios/memory-attribution-no-content.test.ts +4 -3
- package/src/scenarios/memory-attribution-replay-stable.test.ts +6 -5
- package/src/scenarios/memory-attribution-tenant-scoped.test.ts +4 -3
- package/src/scenarios/memory-compaction-event-emitted.test.ts +3 -2
- package/src/scenarios/memory-compaction-provenance-tag.test.ts +6 -5
- package/src/scenarios/memory-compaction-sr1-carry-forward.test.ts +4 -3
- package/src/scenarios/memory-consolidation-idempotent.test.ts +4 -3
- package/src/scenarios/memory-injection-budget.test.ts +4 -3
- package/src/scenarios/metric-emission.test.ts +4 -3
- package/src/scenarios/multi-agent-confidence-escalation.test.ts +5 -4
- package/src/scenarios/multi-agent-handoff-state-machine.test.ts +4 -3
- package/src/scenarios/multi-agent-memory-lifecycle.test.ts +5 -4
- package/src/scenarios/multi-party-conversation-behavioral.test.ts +3 -2
- package/src/scenarios/multi-region-idempotency-behavior.test.ts +7 -6
- package/src/scenarios/multi-region-idempotency.test.ts +4 -3
- package/src/scenarios/normative-example-extraction.test.ts +242 -0
- package/src/scenarios/oauth-authorization-code-roundtrip.test.ts +4 -3
- package/src/scenarios/oauth-capability-shape.test.ts +4 -3
- package/src/scenarios/oauth-connector-redaction.test.ts +4 -3
- package/src/scenarios/openapi-asyncapi-sdk-parity.test.ts +309 -0
- package/src/scenarios/org-position-no-authority-escalation.test.ts +2 -1
- package/src/scenarios/otel-emission-grpc.test.ts +4 -3
- package/src/scenarios/otel-emission.test.ts +4 -3
- package/src/scenarios/otel-trace-propagation-subworkflow.test.ts +5 -4
- package/src/scenarios/otel-trace-propagation.test.ts +4 -3
- package/src/scenarios/pack-registry-isolation.test.ts +4 -3
- package/src/scenarios/pack-registry-publish.test.ts +65 -3
- package/src/scenarios/pack-registry.test.ts +22 -0
- package/src/scenarios/profile-discovery-core-alias.test.ts +137 -0
- package/src/scenarios/profileDerivation.test.ts +2 -1
- package/src/scenarios/provider-usage.test.ts +2 -2
- package/src/scenarios/rate-limit-envelope.test.ts +2 -1
- package/src/scenarios/registry-public.test.ts +6 -5
- package/src/scenarios/replay-divergence-at-refusal.test.ts +9 -8
- package/src/scenarios/replay-llm-cache-key-portable.test.ts +19 -13
- package/src/scenarios/replay-llm-cache-key.test.ts +120 -47
- package/src/scenarios/replay-observable-sequence-determinism.test.ts +5 -4
- package/src/scenarios/replay-side-effect-suppression.test.ts +20 -1
- package/src/scenarios/requirement-ledger.test.ts +29 -6
- package/src/scenarios/runner-ledger.test.ts +279 -0
- package/src/scenarios/runtime-requires-install-gate.test.ts +6 -5
- package/src/scenarios/sandbox-memory-cap.test.ts +3 -2
- package/src/scenarios/sandbox-mvp-behavior.test.ts +11 -10
- package/src/scenarios/sandbox-no-host-fs-escape.test.ts +2 -1
- package/src/scenarios/sandbox-timeout-cap.test.ts +3 -2
- package/src/scenarios/stream-text-fixture.test.ts +5 -4
- package/src/scenarios/subrun-approval-fail-closed.test.ts +3 -2
- package/src/scenarios/subrun-approval-gate.test.ts +4 -3
- package/src/scenarios/subrun-attestation-shape.test.ts +2 -1
- package/src/scenarios/subrun-checksum-stable.test.ts +4 -3
- package/src/scenarios/table-schema-enforcement.test.ts +2 -2
- package/src/scenarios/tool-hooks-authorization-fail-closed.test.ts +3 -2
- package/src/scenarios/tool-hooks-content-free.test.ts +3 -2
- package/src/scenarios/tool-hooks-rate-limit.test.ts +3 -2
- package/src/scenarios/tool-hooks-secret-redaction.test.ts +3 -2
- package/src/scenarios/tool-hooks-shape.test.ts +2 -1
- package/src/scenarios/trigger-bridge-delivery.test.ts +3 -2
- package/src/scenarios/version-fold.test.ts +5 -4
- package/src/scenarios/voice-streamref-tenant-bound.test.ts +2 -1
- package/src/scenarios/voice-transcription-streaming.test.ts +2 -1
- package/src/scenarios/voice-transcription-unadvertised.test.ts +2 -1
- package/src/scenarios/wasm-pack-abi-version-rejection.test.ts +5 -4
- package/src/scenarios/wasm-pack-invoke-completed.test.ts +3 -2
- package/src/scenarios/wasm-pack-invoke-suspended.test.ts +3 -2
- package/src/scenarios/wasm-pack-load.test.ts +4 -3
- package/src/scenarios/wasm-pack-memory-cap.test.ts +4 -3
- package/src/scenarios/wasm-pack-replay-determinism.test.ts +3 -2
- package/src/scenarios/webhook-signed-delivery.test.ts +4 -3
- package/src/scenarios/workflow-primary-output-annotation.test.ts +13 -23
- package/src/scenarios/workload-identity-behavior.test.ts +7 -4
- package/src/scenarios/workspace-cross-tenant-isolation-blackbox.test.ts +3 -2
- package/src/scenarios/workspace-cross-tenant-isolation.test.ts +3 -2
- 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
|
+
});
|