@openwop/openwop-conformance 2.35.0 → 2.36.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/CHANGELOG.md +27 -0
- package/README.md +3 -3
- package/coverage.md +13 -1
- package/dist/lib/certification-bundle-v3.js +21 -4
- package/dist/spec-artifacts.lock.json +2 -2
- package/fixtures/a2ui-v09/negative-event-name-deleteall.json +139 -0
- package/fixtures/a2ui-v09/negative-extra-property.json +140 -0
- package/fixtures/a2ui-v09/negative-foreign-catalog.json +139 -0
- package/fixtures/a2ui-v09/negative-formatstring-label.json +145 -0
- package/fixtures/a2ui-v09/negative-functioncall-openurl.json +129 -0
- package/fixtures/a2ui-v09/negative-image-component.json +145 -0
- package/fixtures/a2ui-v09/negative-textfield-obscured.json +140 -0
- package/fixtures/a2ui-v09/negative-theme-iconurl.json +142 -0
- package/fixtures/a2ui-v09/positive-approve-brief.json +139 -0
- package/fixtures/conformance-artifact-emit.json +28 -0
- package/fixtures/conformance-clarification-nested.json +53 -0
- package/fixtures/conformance-clarification-sensitive.json +49 -0
- package/fixtures/conformance-credential.json +25 -0
- package/fixtures/conformance-mcp-client.json +28 -0
- package/fixtures/interrupt-payloads/interrupt-payload-approval.json +1 -0
- package/fixtures/interrupt-payloads/interrupt-payload-clarification.json +1 -0
- package/fixtures/interrupt-payloads/interrupt-payload-conversation-close.json +1 -0
- package/fixtures/interrupt-payloads/interrupt-payload-conversation-exchange.json +1 -0
- package/fixtures/interrupt-payloads/interrupt-payload-conversation-start.json +1 -0
- package/fixtures/interrupt-payloads/interrupt-payload-custom.json +1 -0
- package/fixtures/interrupt-payloads/interrupt-payload-external-event.json +1 -0
- package/fixtures/interrupt-payloads/interrupt-payload-low-confidence.json +1 -0
- package/fixtures/interrupt-payloads/negative-approval-with-clarification-data.json +1 -0
- package/fixtures/interrupt-payloads/negative-conversation-close-with-conversation-start-data.json +1 -0
- package/fixtures/interrupt-payloads/negative-conversation-start-with-conversation-exchange-data.json +1 -0
- package/fixtures/interrupt-payloads/negative-custom-with-conversation-start-data.json +1 -0
- package/fixtures/interrupt-payloads/negative-low-confidence-with-custom-data.json +1 -0
- package/fixtures/node-pack-runtime/negative-entry-mismatch.json +31 -0
- package/fixtures/node-pack-runtime/negative-headers.json +37 -0
- package/fixtures/node-pack-runtime/negative-http.json +31 -0
- package/fixtures/node-pack-runtime/negative-icons.json +37 -0
- package/fixtures/node-pack-runtime/negative-meta.json +36 -0
- package/fixtures/node-pack-runtime/negative-non-remote-language.json +31 -0
- package/fixtures/node-pack-runtime/negative-packages.json +41 -0
- package/fixtures/node-pack-runtime/negative-sse.json +31 -0
- package/fixtures/node-pack-runtime/negative-templated-url.json +31 -0
- package/fixtures/node-pack-runtime/negative-two-remotes.json +35 -0
- package/fixtures/node-pack-runtime/negative-variables.json +38 -0
- package/fixtures/node-pack-runtime/negative-version-range.json +31 -0
- package/fixtures/node-pack-runtime/positive-every-optional-member.json +40 -0
- package/fixtures/node-pack-runtime/positive-rfc-example.json +31 -0
- package/fixtures/node-pack-runtime/upstream/mcp-registry-server-2025-12-11.schema.json +574 -0
- package/fixtures/oauth-providers/synthetic.json +36 -4
- package/fixtures/upstream/a2a-v1.0.1/README.md +24 -0
- package/fixtures/upstream/a2a-v1.0.1/a2a.proto +811 -0
- package/fixtures/upstream/a2ui-v0.9/README.md +33 -0
- package/fixtures/upstream/a2ui-v0.9/catalog.json +1383 -0
- package/fixtures/upstream/a2ui-v0.9/common_types.json +305 -0
- package/fixtures/upstream/a2ui-v0.9/server_to_client.json +132 -0
- package/fixtures.md +139 -0
- package/package.json +2 -2
- package/requirements.json +4480 -97
- package/scenario-majors.json +93 -6
- package/schemas/CORPUS-STAMP.json +103 -89
- package/src/lib/behavior-gate.ts +7 -1
- package/src/lib/certification-bundle-v3.ts +18 -4
- package/src/lib/localized-content.ts +53 -0
- package/src/lib/mcp-fake-server.ts +105 -4
- package/src/lib/node-pack-runtime.ts +41 -0
- package/src/lib/oauth-as-double.ts +316 -0
- package/src/lib/protected-resource.ts +88 -0
- package/src/lib/standard-webhooks.ts +120 -0
- package/src/lib/toolCatalog.ts +54 -4
- package/src/lib/trace-context.ts +98 -0
- package/src/lib/v2.ts +6 -0
- package/src/lib/webhook-receiver.ts +201 -1
- package/src/scenarios/artifact-type-pack-manifest-validation.test.ts +10 -1
- package/src/scenarios/auth-challenge-no-oracle.test.ts +122 -0
- package/src/scenarios/auth-oauth2-client-credentials.test.ts +46 -5
- package/src/scenarios/cross-host-traceparent-propagation.test.ts +92 -58
- package/src/scenarios/debugBundle.test.ts +41 -0
- package/src/scenarios/fixtures-valid.test.ts +72 -0
- package/src/scenarios/inbound-credential-no-passthrough.test.ts +115 -0
- package/src/scenarios/interrupt-approver-routing.test.ts +3 -0
- package/src/scenarios/localized-content-delivery.test.ts +26 -15
- package/src/scenarios/mcp-current-auth-boundary.test.ts +38 -0
- package/src/scenarios/mcp-tool-roundtrip.test.ts +29 -0
- package/src/scenarios/otel-mcp-semconv-projection.test.ts +85 -0
- package/src/scenarios/tool-catalog-compact-projection.test.ts +28 -14
- package/src/scenarios/tool-catalog-projection.test.ts +48 -12
- package/src/scenarios/tool-descriptor-shape.test.ts +34 -13
- package/src/scenarios/v2-a2a-agent-cards.test.ts +305 -0
- package/src/scenarios/v2-a2a-operation-map.test.ts +310 -0
- package/src/scenarios/v2-a2ui-v09-surface.test.ts +283 -0
- package/src/scenarios/v2-advertised-fixtures-exist.test.ts +2 -2
- package/src/scenarios/v2-artifact-a2a-shape.test.ts +122 -0
- package/src/scenarios/v2-auth-challenge.test.ts +119 -0
- package/src/scenarios/v2-bound-id-kinds.test.ts +1 -1
- package/src/scenarios/v2-callback-url-guarded.test.ts +2 -2
- package/src/scenarios/v2-capability-maturity-bounded.test.ts +108 -0
- package/src/scenarios/v2-configurable-closed.test.ts +1 -1
- package/src/scenarios/v2-content-locale-keys.test.ts +92 -0
- package/src/scenarios/v2-conversation-turn-parts.test.ts +74 -0
- package/src/scenarios/v2-credential-interrupt.test.ts +253 -0
- package/src/scenarios/v2-effect-identity-business-key.test.ts +2 -2
- package/src/scenarios/v2-fork-a-v1-run.test.ts +1 -1
- package/src/scenarios/v2-implementation-informational.test.ts +33 -0
- package/src/scenarios/v2-interop-trace-context.test.ts +351 -0
- package/src/scenarios/v2-lane-exp-only-bound.test.ts +236 -0
- package/src/scenarios/v2-lane-issuer-advertised.test.ts +52 -3
- package/src/scenarios/v2-mcp-client-results.test.ts +202 -0
- package/src/scenarios/v2-mcp-mount-map.test.ts +308 -0
- package/src/scenarios/v2-mcp-tasks.test.ts +503 -0
- package/src/scenarios/v2-oauth-client-pkce-state-iss.test.ts +203 -0
- package/src/scenarios/v2-oauth-mcp-reach-discovery.test.ts +186 -0
- package/src/scenarios/v2-oidc-id-token-audience.test.ts +108 -0
- package/src/scenarios/v2-protected-resource-metadata.test.ts +145 -0
- package/src/scenarios/v2-run-cancel.test.ts +1 -1
- package/src/scenarios/v2-run-fork-prefix.test.ts +4 -4
- package/src/scenarios/v2-run-pause-resume.test.ts +1 -1
- package/src/scenarios/v2-tool-catalog-annotations.test.ts +100 -0
- package/src/scenarios/v2-unmapped-type-refused.test.ts +1 -1
- package/src/scenarios/v2-v1-events-translated.test.ts +1 -1
- package/src/scenarios/v2-webhook-egress-refusal.test.ts +5 -5
- package/src/scenarios/v2-webhook-endpoint-verification.test.ts +124 -0
- package/src/scenarios/v2-webhook-message-id-stable.test.ts +124 -0
- package/src/scenarios/v2-webhook-secret-rotation.test.ts +132 -0
- package/src/scenarios/v2-webhook-standard-webhooks-delivery.test.ts +195 -0
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0202 — each inventoried agent is published as an A2A 1.0 Agent Card,
|
|
3
|
+
* reached by the opaque routing value `a2aTenant` (`R`) through
|
|
4
|
+
* `GetExtendedAgentCard { tenant: R }`, without making the card an existence
|
|
5
|
+
* oracle across tenants (`spec/v2/core/interop.md` §"Per-agent cards").
|
|
6
|
+
* Target major 2.
|
|
7
|
+
*
|
|
8
|
+
* Gate: the v2 `a2a` record carries `agentCards` (else `inapplicable`), and the
|
|
9
|
+
* caller's `GET /agents` returns at least one entry with `a2aTenant` (else
|
|
10
|
+
* `inapplicable` — a tenant-scoped inventory MAY be empty). The suite speaks
|
|
11
|
+
* A2A 1.0 JSON-RPC to the first `JSONRPC` interface at `1.0` of the host's own
|
|
12
|
+
* public card (`a2a.agentCardUrl`); every request carries `A2A-Version: 1.0`.
|
|
13
|
+
*
|
|
14
|
+
* Cross-tenant legs (`identical-refusal`, `tenant-of-record`) need
|
|
15
|
+
* `OPENWOP_TEST_TENANT_B_API_KEY`, a credential bound to a SECOND tenant
|
|
16
|
+
* (not `OPENWOP_TEST_SECONDARY_API_KEY`, a same-principal rotation key), and an
|
|
17
|
+
* agent tenant B has that the caller's inventory lacks. The suite finds it as
|
|
18
|
+
* the set difference of the two inventories, or takes
|
|
19
|
+
* `OPENWOP_TEST_TENANT_B_AGENT_ID` when the operator names one. Missing ⇒
|
|
20
|
+
* `blocked`, never a pass.
|
|
21
|
+
*
|
|
22
|
+
* SR-1 leg: the canary strings are defined by `conformance/fixtures.md`
|
|
23
|
+
* §"Agent Pack Install" for the `core.conformance.agent-pack` pack
|
|
24
|
+
* (`conformance-agent-pack-install`'s `requiresInstalledPack`); the leg runs
|
|
25
|
+
* only when the caller's inventory carries an agent of that pack.
|
|
26
|
+
*
|
|
27
|
+
* What `routing-value` does NOT witness (RFC 0202 G4): that `R` encodes no
|
|
28
|
+
* identifier the host keeps private. It checks stability and that `R` contains
|
|
29
|
+
* no identifier the host itself disclosed on a run snapshot (tenant, workspace,
|
|
30
|
+
* subject); a host that encodes an undisclosed identifier is not caught.
|
|
31
|
+
* Response latency is not compared (G7).
|
|
32
|
+
*
|
|
33
|
+
* @see spec/v2/core/interop.md §"Per-agent cards"
|
|
34
|
+
* @see schemas/v2/agent-inventory-response.schema.json `a2aTenant`
|
|
35
|
+
* @see RFCS/0202-per-agent-a2a-agent-cards.md
|
|
36
|
+
*/
|
|
37
|
+
import { describe, it, expect } from 'vitest';
|
|
38
|
+
import { randomBytes } from 'node:crypto';
|
|
39
|
+
import { driver } from '../lib/driver.js';
|
|
40
|
+
import { v2Discovery, familyAdvertised, v2Validator } from '../lib/v2.js';
|
|
41
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
42
|
+
import { req } from '../lib/requirement-ids.js';
|
|
43
|
+
|
|
44
|
+
export const HOST_CALLBACK_NOT_REQUIRED = 'the suite is the A2A client: every leg POSTs JSON-RPC to the interface the host\'s own public card lists; nothing harness-hosted is handed to the host';
|
|
45
|
+
|
|
46
|
+
const DOC = 'spec/v2/core/interop.md §"Per-agent cards" (RFC 0202)';
|
|
47
|
+
const PROFILE = 'a2a-1.0';
|
|
48
|
+
const R = (slug: string): string => `openwop.requirement.0202.${slug}`;
|
|
49
|
+
/** conformance/fixtures.md §"Agent Pack Install" — the pack and the two canaries it carries. */
|
|
50
|
+
const CANARY_PACK = 'core.conformance.agent-pack';
|
|
51
|
+
const CANARIES = ['OPENWOP-CONFORMANCE-CANARY-0202-PROMPT', 'OPENWOP-CONFORMANCE-CANARY-0202-HANDOFF'];
|
|
52
|
+
|
|
53
|
+
interface RpcError { code: number; message?: string; data?: unknown }
|
|
54
|
+
interface Rpc { status: number; body: Record<string, unknown>; result?: Record<string, unknown> | undefined; error?: RpcError | undefined }
|
|
55
|
+
interface Iface { url?: string; protocolBinding?: string; protocolVersion?: string; tenant?: string }
|
|
56
|
+
interface Card { name?: string; description?: string; version?: string; supportedInterfaces?: Iface[]; capabilities?: Record<string, unknown>; securitySchemes?: unknown; securityRequirements?: unknown; skills?: Array<{ id?: string; name?: string; description?: string; tags?: unknown }> }
|
|
57
|
+
interface Entry { agentId: string; persona?: string; label?: string; description?: string; packName?: string; packVersion?: string; hasHandoffSchemas?: boolean; a2aTenant?: string; roster?: Array<{ workflows?: string[] }> }
|
|
58
|
+
|
|
59
|
+
async function rpc(url: string, method: string, params: unknown, bearer: string | null = process.env.OPENWOP_API_KEY ?? null): Promise<Rpc> {
|
|
60
|
+
const headers: Record<string, string> = { 'content-type': 'application/json', accept: 'application/json', 'A2A-Version': '1.0' };
|
|
61
|
+
if (bearer) headers['authorization'] = `Bearer ${bearer}`;
|
|
62
|
+
const res = await fetch(url, { method: 'POST', headers, body: JSON.stringify({ jsonrpc: '2.0', id: randomBytes(4).readUInt32BE(0), method, params }) });
|
|
63
|
+
const text = await res.text();
|
|
64
|
+
let body: Record<string, unknown> = {};
|
|
65
|
+
try { const parsed = JSON.parse(text) as unknown; if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) body = parsed as Record<string, unknown>; } catch { body = { nonJson: text }; }
|
|
66
|
+
return { status: res.status, body, result: body['result'] as Record<string, unknown> | undefined, error: body['error'] as RpcError | undefined };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** What two refusals must share: HTTP status and the body apart from the JSON-RPC `id`. */
|
|
70
|
+
const refusalOf = (r: Rpc): string => { const { id: _id, ...rest } = r.body; void _id; return JSON.stringify({ status: r.status, body: rest }); };
|
|
71
|
+
|
|
72
|
+
async function inventory(bearer?: string): Promise<{ status: number; body: unknown; agents: Entry[] }> {
|
|
73
|
+
const res = bearer === undefined
|
|
74
|
+
? await driver.get('/agents')
|
|
75
|
+
: await driver.get('/agents', { authenticated: false, headers: { Authorization: `Bearer ${bearer}` } });
|
|
76
|
+
const agents = res.status === 200 && Array.isArray((res.json as { agents?: unknown[] } | undefined)?.agents) ? ((res.json as { agents: Entry[] }).agents) : [];
|
|
77
|
+
return { status: res.status, body: res.json, agents };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function publicCard(url: string, withVersion: boolean): Promise<{ status: number; text: string; card: Card }> {
|
|
81
|
+
const res = await fetch(url, { headers: withVersion ? { accept: 'application/json', 'A2A-Version': '1.0' } : { accept: 'application/json' } });
|
|
82
|
+
const text = await res.text();
|
|
83
|
+
let card: Card = {};
|
|
84
|
+
try { card = JSON.parse(text) as Card; } catch { /* non-JSON card */ }
|
|
85
|
+
return { status: res.status, text, card };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
type Target =
|
|
89
|
+
| { ok: true; url: string; card: Card; cardUrl: string; entries: Entry[]; routed: Entry[] }
|
|
90
|
+
| { ok: false; kind: 'inapplicable' | 'blocked'; reason: string }
|
|
91
|
+
| { ok: false; kind: 'fail'; reason: string };
|
|
92
|
+
|
|
93
|
+
async function target(): Promise<Target> {
|
|
94
|
+
if (!(await v2Discovery())) return { ok: false, kind: 'blocked', reason: 'v2 discovery unreachable' };
|
|
95
|
+
const a2a = await familyAdvertised('a2a');
|
|
96
|
+
if (!a2a) return { ok: false, kind: 'inapplicable', reason: 'a2a not advertised at major 2' };
|
|
97
|
+
if (a2a['agentCards'] !== true) return { ok: false, kind: 'inapplicable', reason: 'a2a.agentCards not advertised — the host publishes no per-agent card' };
|
|
98
|
+
const cardUrl = a2a['agentCardUrl'];
|
|
99
|
+
if (typeof cardUrl !== 'string') return { ok: false, kind: 'fail', reason: 'a host advertising a2a.agentCards offers the a2a-1.0 profile and MUST advertise a2a.agentCardUrl' };
|
|
100
|
+
const pub = await publicCard(cardUrl, true);
|
|
101
|
+
if (pub.status !== 200) return { ok: false, kind: 'fail', reason: `the advertised agentCardUrl answered ${pub.status}` };
|
|
102
|
+
const iface = (pub.card.supportedInterfaces ?? []).find((i) => i.protocolBinding === 'JSONRPC' && i.protocolVersion === '1.0');
|
|
103
|
+
if (typeof iface?.url !== 'string') return { ok: false, kind: 'fail', reason: 'claiming a2a-1.0 requires a JSONRPC interface at 1.0 in the public card' };
|
|
104
|
+
const inv = await inventory();
|
|
105
|
+
if (inv.status !== 200) return { ok: false, kind: 'fail', reason: `a2a.agentCards requires agents.manifestRuntime, and GET /agents MUST be served (got ${inv.status})` };
|
|
106
|
+
const routed = inv.agents.filter((e) => typeof e.a2aTenant === 'string' && e.a2aTenant.length > 0);
|
|
107
|
+
if (routed.length === 0) return { ok: false, kind: 'inapplicable', reason: 'the caller\'s GET /agents carries no entry with a2aTenant (a tenant-scoped inventory MAY be empty, and an agent with no routed workflow has no card)' };
|
|
108
|
+
return { ok: true, url: iface.url, card: pub.card, cardUrl, entries: inv.agents, routed };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function skip(t: Exclude<Target, { ok: true }>, id: string): undefined {
|
|
112
|
+
if (t.kind === 'fail') { expect(t.reason, req(id, DOC, t.reason)).toBe(''); return undefined; }
|
|
113
|
+
return softSkip(t.kind, t.reason);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const neverMinted = (): string => `ag-never-${randomBytes(9).toString('hex')}`;
|
|
117
|
+
const message = (): Record<string, unknown> => ({ messageId: `conf-0202-${randomBytes(8).toString('hex')}`, role: 'ROLE_USER', parts: [{ text: 'openwop conformance RFC 0202' }] });
|
|
118
|
+
const taskIdOf = (r: Rpc): string | undefined => {
|
|
119
|
+
const t = (r.result?.['task'] ?? r.result) as { id?: unknown } | undefined;
|
|
120
|
+
return typeof t?.id === 'string' ? t.id : undefined;
|
|
121
|
+
};
|
|
122
|
+
const ifaceKey = (i: Iface): string => JSON.stringify([i.url, i.protocolBinding, i.protocolVersion]);
|
|
123
|
+
|
|
124
|
+
/** Tenant B's entry that the caller's inventory lacks, or why the leg cannot run. */
|
|
125
|
+
async function foreignAgent(mine: Entry[]): Promise<{ ok: true; bearer: string; entry: Entry; theirs: Entry[] } | { ok: false; reason: string }> {
|
|
126
|
+
const bearer = process.env.OPENWOP_TEST_TENANT_B_API_KEY;
|
|
127
|
+
if (!bearer) return { ok: false, reason: 'OPENWOP_TEST_TENANT_B_API_KEY (a credential bound to a second tenant) is not set — the cross-tenant leg cannot run' };
|
|
128
|
+
if (bearer === process.env.OPENWOP_API_KEY) return { ok: false, reason: 'OPENWOP_TEST_TENANT_B_API_KEY equals OPENWOP_API_KEY — the leg needs a second tenant' };
|
|
129
|
+
const inv = await inventory(bearer);
|
|
130
|
+
if (inv.status !== 200) return { ok: false, reason: `GET /agents as tenant B answered ${inv.status}` };
|
|
131
|
+
const mineIds = new Set(mine.map((e) => e.agentId));
|
|
132
|
+
const mineR = new Set(mine.map((e) => e.a2aTenant).filter((x): x is string => typeof x === 'string'));
|
|
133
|
+
const named = process.env.OPENWOP_TEST_TENANT_B_AGENT_ID;
|
|
134
|
+
const candidates = inv.agents.filter((e) => typeof e.a2aTenant === 'string' && !mineIds.has(e.agentId) && !mineR.has(e.a2aTenant) && (named === undefined || named === '' || e.agentId === named));
|
|
135
|
+
const entry = candidates[0];
|
|
136
|
+
if (entry === undefined) return { ok: false, reason: `tenant B's inventory has no entry with a2aTenant that the caller's lacks${named ? ` (OPENWOP_TEST_TENANT_B_AGENT_ID=${named})` : ''} — provision an agent in tenant B only` };
|
|
137
|
+
return { ok: true, bearer, entry, theirs: inv.agents };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
describe('RFC 0202 — v2-a2a-agent-cards (per-agent A2A cards, gated on a2a.agentCards)', () => {
|
|
141
|
+
it('a2a.agentCards implies the a2a-1.0 profile, agents.manifestRuntime and capabilities.extendedAgentCard on the public card', async () => {
|
|
142
|
+
const t = await target();
|
|
143
|
+
if (!t.ok) return skip(t, R('advertisement'));
|
|
144
|
+
const a2a = (await familyAdvertised('a2a'))!;
|
|
145
|
+
const profiles = Array.isArray(a2a['profiles']) ? (a2a['profiles'] as unknown[]) : [];
|
|
146
|
+
expect(profiles, req(R('advertisement'), DOC, 'a host advertising a2a.agentCards MUST offer the a2a-1.0 profile')).toContain(PROFILE);
|
|
147
|
+
const agents = await familyAdvertised('agents');
|
|
148
|
+
const mr = agents?.['manifestRuntime'];
|
|
149
|
+
expect(mr !== null && typeof mr === 'object' && !Array.isArray(mr), req(R('advertisement'), DOC, 'a host advertising a2a.agentCards MUST advertise agents.manifestRuntime')).toBe(true);
|
|
150
|
+
expect(t.card.capabilities?.['extendedAgentCard'], req(R('advertisement'), `${DOC}; A2A v1.0.1 §3.1.11`, 'the public card (A2A-Version: 1.0) MUST declare capabilities.extendedAgentCard: true — GetExtendedAgentCard is served only when it is')).toBe(true);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('a2aTenant is schema-valid, stable across reads, and contains no identifier the host disclosed', async () => {
|
|
154
|
+
const t = await target();
|
|
155
|
+
if (!t.ok) return skip(t, R('routing-value'));
|
|
156
|
+
const again = await inventory();
|
|
157
|
+
const validate = v2Validator('agent-inventory-response');
|
|
158
|
+
const v = validate(again.body);
|
|
159
|
+
expect(v.ok, req(R('routing-value'), 'schemas/v2/agent-inventory-response.schema.json', `GET /agents MUST validate (a2aTenant is path-segment-safe, 1-128 chars): ${v.errors}`)).toBe(true);
|
|
160
|
+
const first = new Map(t.entries.map((e) => [e.agentId, e.a2aTenant]));
|
|
161
|
+
const second = new Map(again.agents.map((e) => [e.agentId, e.a2aTenant]));
|
|
162
|
+
for (const [agentId, r] of first) {
|
|
163
|
+
expect(second.get(agentId), req(R('routing-value'), DOC, `a2aTenant MUST be stable for the agent and host version: ${agentId} read ${String(r)} then ${String(second.get(agentId))}`)).toBe(r);
|
|
164
|
+
}
|
|
165
|
+
const rs = t.routed.map((e) => e.a2aTenant!);
|
|
166
|
+
expect(new Set(rs).size, req(R('routing-value'), DOC, 'each agent has its own routing value: two entries MUST NOT share an a2aTenant')).toBe(rs.length);
|
|
167
|
+
// Identifiers the host itself discloses: a run's tenant segment and its owner triple.
|
|
168
|
+
const entry = t.routed[0]!;
|
|
169
|
+
const sent = await rpc(t.url, 'SendMessage', { tenant: entry.a2aTenant, message: message() });
|
|
170
|
+
const taskId = taskIdOf(sent);
|
|
171
|
+
expect(typeof taskId, req(R('routing-value'), DOC, `SendMessage through a minted R MUST start a task so the suite can read the identifiers the host discloses (got ${JSON.stringify(sent.error ?? sent.result)})`)).toBe('string');
|
|
172
|
+
const snap = await driver.get(`/runs/${encodeURIComponent(taskId!)}`);
|
|
173
|
+
const owner = (snap.json as { owner?: { tenant?: unknown; workspace?: unknown; subject?: { subjectId?: unknown; issuer?: unknown } } } | undefined)?.owner;
|
|
174
|
+
const disclosed = [taskId!.includes('/') ? taskId!.slice(0, taskId!.indexOf('/')) : undefined, owner?.tenant, owner?.workspace, owner?.subject?.subjectId]
|
|
175
|
+
.filter((x): x is string => typeof x === 'string' && x.length >= 3);
|
|
176
|
+
expect(disclosed.length, req(R('routing-value'), 'identity.md §5', 'precondition: the run snapshot discloses at least the tenant of record')).toBeGreaterThan(0);
|
|
177
|
+
for (const r of rs) {
|
|
178
|
+
for (const id of disclosed) {
|
|
179
|
+
expect(r.toLowerCase().includes(id.toLowerCase()), req(R('routing-value'), DOC, `a2aTenant ${r} MUST NOT encode a tenant, workspace or principal — it contains the disclosed identifier ${id}`)).toBe(false);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
await rpc(t.url, 'CancelTask', { tenant: entry.a2aTenant, id: taskId });
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('GetExtendedAgentCard with tenant R returns that agent\'s card, projected from the entry and the host card', async () => {
|
|
186
|
+
const t = await target();
|
|
187
|
+
if (!t.ok) return skip(t, R('card-projection'));
|
|
188
|
+
const hostIfaces = new Set((t.card.supportedInterfaces ?? []).map(ifaceKey));
|
|
189
|
+
for (const e of t.routed) {
|
|
190
|
+
const r = await rpc(t.url, 'GetExtendedAgentCard', { tenant: e.a2aTenant });
|
|
191
|
+
expect(r.error, req(R('card-projection'), DOC, `GetExtendedAgentCard {tenant: ${e.a2aTenant}} MUST return ${e.agentId}'s card (got ${JSON.stringify(r.error)})`)).toBeUndefined();
|
|
192
|
+
const card = (r.result ?? {}) as Card;
|
|
193
|
+
expect(card.name, req(R('card-projection'), DOC, `card.name MUST be the entry's persona (${e.agentId})`)).toBe(e.persona);
|
|
194
|
+
expect(card.version, req(R('card-projection'), DOC, `card.version MUST be the entry's packVersion (${e.agentId})`)).toBe(e.packVersion);
|
|
195
|
+
expect(card.description, req(R('card-projection'), DOC, `card.description MUST be the entry's description, else its label (${e.agentId})`)).toBe(e.description ?? e.label);
|
|
196
|
+
const ifaces = card.supportedInterfaces ?? [];
|
|
197
|
+
expect(new Set(ifaces.map(ifaceKey)), req(R('card-projection'), DOC, 'supportedInterfaces[] MUST be the host card\'s interfaces ({url, protocolBinding, protocolVersion})')).toEqual(hostIfaces);
|
|
198
|
+
for (const i of ifaces) expect(i.tenant, req(R('card-projection'), `${DOC}; A2A v1.0.1 AgentInterface.tenant`, `every interface of ${e.agentId}'s card MUST carry tenant: R`)).toBe(e.a2aTenant);
|
|
199
|
+
expect(card.capabilities, req(R('card-projection'), DOC, 'capabilities MUST equal the host card\'s')).toEqual(t.card.capabilities);
|
|
200
|
+
expect(card.securitySchemes, req(R('card-projection'), DOC, 'securitySchemes MUST equal the host card\'s')).toEqual(t.card.securitySchemes);
|
|
201
|
+
expect(card.securityRequirements, req(R('card-projection'), 'RFC 0202 §C.4', 'securityRequirements MUST equal the host card\'s')).toEqual(t.card.securityRequirements);
|
|
202
|
+
const skills = card.skills ?? [];
|
|
203
|
+
expect(skills.length, req(R('card-projection'), `${DOC}; A2A AgentCard.skills REQUIRED`, `an entry with a2aTenant has at least one routed workflow, so its card has ≥1 skill (${e.agentId})`)).toBeGreaterThanOrEqual(1);
|
|
204
|
+
const ids = skills.map((s) => s.id);
|
|
205
|
+
expect(new Set(ids).size, req(R('card-projection'), 'spec/v2/interop-map.json a2a.card skills[]', 'one skill per routed workflow: skills[].id is unique')).toBe(ids.length);
|
|
206
|
+
for (const s of skills) {
|
|
207
|
+
expect(typeof s.id === 'string' && typeof s.name === 'string' && typeof s.description === 'string' && Array.isArray(s.tags), req(R('card-projection'), 'A2A v1.0.1 AgentSkill (id, name, description, tags REQUIRED)', `skill ${JSON.stringify(s)} MUST carry id, name, description and tags`)).toBe(true);
|
|
208
|
+
}
|
|
209
|
+
const portfolio = (e.roster ?? []).flatMap((x) => x.workflows ?? []);
|
|
210
|
+
if (portfolio.length > 0) {
|
|
211
|
+
for (const id of ids) expect(portfolio, req(R('card-projection'), DOC, `the inventory names ${e.agentId}'s workflows (roster[].workflows); skill ${String(id)} MUST be one of them`)).toContain(id);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('the per-agent card carries nothing the inventory entry may not (SR-1)', async () => {
|
|
217
|
+
const t = await target();
|
|
218
|
+
if (!t.ok) return skip(t, R('card-sr1'));
|
|
219
|
+
const fixture = t.routed.find((e) => e.packName === CANARY_PACK);
|
|
220
|
+
if (fixture === undefined) return softSkip('inapplicable', `the caller's inventory carries no routed agent of ${CANARY_PACK} (conformance/fixtures.md §"Agent Pack Install"), so no canary is installed to look for`);
|
|
221
|
+
if (fixture.hasHandoffSchemas !== true) return softSkip('blocked', `${fixture.agentId} does not declare handoff schemas — the fixture pack's agent MUST, or the handoff canary is not installed`);
|
|
222
|
+
const r = await rpc(t.url, 'GetExtendedAgentCard', { tenant: fixture.a2aTenant });
|
|
223
|
+
expect(r.error, req(R('card-sr1'), DOC, `GetExtendedAgentCard MUST return ${fixture.agentId}'s card (got ${JSON.stringify(r.error)})`)).toBeUndefined();
|
|
224
|
+
const text = JSON.stringify(r.result ?? {});
|
|
225
|
+
for (const canary of CANARIES) {
|
|
226
|
+
expect(text.includes(canary), req(R('card-sr1'), `${DOC}; SR-1`, `the card MUST NOT carry the system-prompt body or a resolved handoff schema — it contains the fixture canary ${canary}`)).toBe(false);
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('an unauthenticated request carrying R is refused exactly as one carrying a never-minted value', async () => {
|
|
231
|
+
const t = await target();
|
|
232
|
+
if (!t.ok) return skip(t, R('auth-before-resolve'));
|
|
233
|
+
const real = t.routed[0]!.a2aTenant!;
|
|
234
|
+
const a = await rpc(t.url, 'GetExtendedAgentCard', { tenant: real }, null);
|
|
235
|
+
const b = await rpc(t.url, 'GetExtendedAgentCard', { tenant: neverMinted() }, null);
|
|
236
|
+
expect(a.result === undefined || (a.result as Card).name === undefined, req(R('auth-before-resolve'), `${DOC}; A2A v1.0.1 §13.1`, `an unauthenticated GetExtendedAgentCard MUST NOT return a card (got HTTP ${a.status} ${JSON.stringify(a.body).slice(0, 200)})`)).toBe(true);
|
|
237
|
+
expect(a.status >= 400 || a.error !== undefined, req(R('auth-before-resolve'), `${DOC}; A2A v1.0.1 §13.1`, `an unauthenticated request carrying R MUST be refused (got HTTP ${a.status})`)).toBe(true);
|
|
238
|
+
expect(refusalOf(a), req(R('auth-before-resolve'), `${DOC}; A2A v1.0.1 §13.1 ("Authorization checks MUST occur before any database queries…")`, 'authentication MUST precede resolving R: the refusal for a real R and for a never-minted value MUST be identical (status, code, body)')).toBe(refusalOf(b));
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('another tenant\'s R is answered exactly as a never-minted one on every A2A operation', async () => {
|
|
242
|
+
const t = await target();
|
|
243
|
+
if (!t.ok) return skip(t, R('identical-refusal'));
|
|
244
|
+
const f = await foreignAgent(t.entries);
|
|
245
|
+
if (!f.ok) return softSkip('blocked', f.reason);
|
|
246
|
+
const theirR = f.entry.a2aTenant!;
|
|
247
|
+
// Positive control: B's own card resolves for B, so the value is real, not a typo.
|
|
248
|
+
const own = await rpc(t.url, 'GetExtendedAgentCard', { tenant: theirR }, f.bearer);
|
|
249
|
+
expect((own.result as Card | undefined)?.name, req(R('identical-refusal'), DOC, `positive control: tenant B's R MUST resolve to B's card for B (got ${JSON.stringify(own.error ?? own.result)})`)).toBe(f.entry.persona);
|
|
250
|
+
const started: string[] = [];
|
|
251
|
+
for (const [method, params] of [
|
|
252
|
+
['GetExtendedAgentCard', (r: string) => ({ tenant: r })],
|
|
253
|
+
['SendMessage', (r: string) => ({ tenant: r, message: message() })],
|
|
254
|
+
['ListTasks', (r: string) => ({ tenant: r })],
|
|
255
|
+
] as const) {
|
|
256
|
+
const foreign = await rpc(t.url, method, params(theirR));
|
|
257
|
+
const unminted = await rpc(t.url, method, params(neverMinted()));
|
|
258
|
+
for (const x of [foreign, unminted]) { const id = taskIdOf(x); if (method === 'SendMessage' && id !== undefined) started.push(id); }
|
|
259
|
+
if (method === 'GetExtendedAgentCard') {
|
|
260
|
+
expect((foreign.result as Card | undefined)?.name === f.entry.persona, req(R('identical-refusal'), DOC, 'tenant A MUST NOT receive tenant B\'s agent card')).toBe(false);
|
|
261
|
+
}
|
|
262
|
+
expect(refusalOf(foreign), req(R('identical-refusal'), DOC, `${method} carrying another tenant's R MUST return what it returns for an R the host never minted — same HTTP status, error code and body apart from the JSON-RPC id`)).toBe(refusalOf(unminted));
|
|
263
|
+
}
|
|
264
|
+
for (const id of started) await rpc(t.url, 'CancelTask', { id });
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('the public card lists no per-agent routing value', async () => {
|
|
268
|
+
const t = await target();
|
|
269
|
+
if (!t.ok) return skip(t, R('public-card-no-r'));
|
|
270
|
+
const seen = new Set(t.routed.map((e) => e.a2aTenant!));
|
|
271
|
+
const b = process.env.OPENWOP_TEST_TENANT_B_API_KEY;
|
|
272
|
+
if (b) for (const e of (await inventory(b)).agents) if (typeof e.a2aTenant === 'string') seen.add(e.a2aTenant);
|
|
273
|
+
for (const withVersion of [false, true]) {
|
|
274
|
+
const pub = await publicCard(t.cardUrl, withVersion);
|
|
275
|
+
expect(pub.status, req(R('public-card-no-r'), DOC, `the public card MUST be served ${withVersion ? 'with' : 'without'} A2A-Version`)).toBe(200);
|
|
276
|
+
const tenants = (pub.card.supportedInterfaces ?? []).map((i) => i.tenant).filter((x): x is string => typeof x === 'string');
|
|
277
|
+
for (const r of seen) {
|
|
278
|
+
expect(tenants, req(R('public-card-no-r'), DOC, `the unauthenticated card at agentCardUrl (${withVersion ? 'A2A-Version: 1.0' : 'header-less'}) MUST NOT list any R as an interface tenant`)).not.toContain(r);
|
|
279
|
+
expect(pub.text.includes(r), req(R('public-card-no-r'), DOC, `the unauthenticated card MUST NOT carry the routing value ${r} anywhere`)).toBe(false);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it('a task started through R is a run in the caller\'s tenant of record', async () => {
|
|
285
|
+
const t = await target();
|
|
286
|
+
if (!t.ok) return skip(t, R('tenant-of-record'));
|
|
287
|
+
const b = process.env.OPENWOP_TEST_TENANT_B_API_KEY;
|
|
288
|
+
if (!b || b === process.env.OPENWOP_API_KEY) return softSkip('blocked', 'OPENWOP_TEST_TENANT_B_API_KEY (a credential bound to a second tenant) is not set — the leg cannot show the run is invisible to another tenant');
|
|
289
|
+
const e = t.routed[0]!;
|
|
290
|
+
const sent = await rpc(t.url, 'SendMessage', { tenant: e.a2aTenant, message: message() });
|
|
291
|
+
const id = taskIdOf(sent);
|
|
292
|
+
expect(typeof id, req(R('tenant-of-record'), DOC, `SendMessage {tenant: R} MUST start a task (got ${JSON.stringify(sent.error ?? sent.result)})`)).toBe('string');
|
|
293
|
+
const mine = await driver.get(`/runs/${encodeURIComponent(id!)}`);
|
|
294
|
+
expect(mine.status, req(R('tenant-of-record'), 'interop.md §"The operation mappings" Isolation', 'Task.id MUST be a run the caller can read through getRun')).toBe(200);
|
|
295
|
+
const control = await rpc(t.url, 'SendMessage', { message: message() });
|
|
296
|
+
const controlId = taskIdOf(control);
|
|
297
|
+
if (typeof controlId === 'string' && controlId.includes('/') && id!.includes('/')) {
|
|
298
|
+
expect(id!.slice(0, id!.indexOf('/')), req(R('tenant-of-record'), `${DOC}; identity.md §5`, 'the run MUST carry the caller\'s tenant segment — the same one a run started on the host interface carries')).toBe(controlId.slice(0, controlId.indexOf('/')));
|
|
299
|
+
}
|
|
300
|
+
const theirs = await driver.get(`/runs/${encodeURIComponent(id!)}`, { authenticated: false, headers: { Authorization: `Bearer ${b}` } });
|
|
301
|
+
expect(theirs.status >= 200 && theirs.status < 300, req(R('tenant-of-record'), `${DOC}; interop.md Isolation`, `R is resolved within the caller's tenant: another tenant MUST NOT read the run (got ${theirs.status})`)).toBe(false);
|
|
302
|
+
await rpc(t.url, 'CancelTask', { id });
|
|
303
|
+
if (typeof controlId === 'string') await rpc(t.url, 'CancelTask', { id: controlId });
|
|
304
|
+
});
|
|
305
|
+
});
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0208 §B–§E — the host as an A2A 1.0 server, held to the rows of
|
|
3
|
+
* `spec/v2/interop-map.json` (`spec/v2/core/interop.md` §"The operation
|
|
4
|
+
* mappings"). Target major 2.
|
|
5
|
+
*
|
|
6
|
+
* Gate: the v2 `a2a` record lists profile `a2a-1.0` (a host serving an A2A
|
|
7
|
+
* interface). A host that advertises `a2a` for its client path only (no
|
|
8
|
+
* `a2a-1.0` profile) is `inapplicable` here — it serves no interface to hold.
|
|
9
|
+
* A host that claims the profile MUST advertise `agentCardUrl`, and the card
|
|
10
|
+
* MUST list a JSONRPC interface at 1.0 (the `a2a.card` supportedInterfaces row);
|
|
11
|
+
* every request carries `A2A-Version: 1.0`.
|
|
12
|
+
*
|
|
13
|
+
* Skill routing (a suite requirement, `conformance/fixtures.md`, not a spec
|
|
14
|
+
* MUST): an A2A 1.0 `Message` carries no skill selector — the caller picks the
|
|
15
|
+
* agent and the agent picks the skill — so the legs that start a task need the
|
|
16
|
+
* host's interface to route to exactly one skill, `conformance-approval`
|
|
17
|
+
* (skill id = workflowId, the `a2a.card` skills[] row). A card listing another
|
|
18
|
+
* or several skills records `blocked`: the suite cannot choose one.
|
|
19
|
+
*
|
|
20
|
+
* Every leg starts its own task(s), so no leg depends on another's side effect.
|
|
21
|
+
* `a2a-list-scoped` needs `OPENWOP_TEST_TENANT_B_API_KEY`, a credential bound to
|
|
22
|
+
* a SECOND tenant; without it the leg is `blocked`, never passed.
|
|
23
|
+
*
|
|
24
|
+
* @see spec/v2/core/interop.md §"The operation mappings" (Isolation, A2A multi-turn)
|
|
25
|
+
* @see spec/v2/interop-map.json a2a.operations / a2a.taskState / a2a.errors
|
|
26
|
+
* @see RFCS/0208-v2-a2a-mcp-operation-mappings.md §C, §D, §E
|
|
27
|
+
* @see RFCS/0199-outbound-oauth-client-and-credential-interrupt.md §D.1 (the credential leg; interop-map.json a2a.taskState override row)
|
|
28
|
+
*/
|
|
29
|
+
import { describe, it, expect } from 'vitest';
|
|
30
|
+
import { readFileSync } from 'node:fs';
|
|
31
|
+
import { join } from 'node:path';
|
|
32
|
+
import { randomBytes } from 'node:crypto';
|
|
33
|
+
import { driver } from '../lib/driver.js';
|
|
34
|
+
import { v2Discovery, familyAdvertised } from '../lib/v2.js';
|
|
35
|
+
import { isFixtureAdvertised } from '../lib/fixtures.js';
|
|
36
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
37
|
+
import { SCHEMAS_DIR } from '../lib/paths.js';
|
|
38
|
+
import { req } from '../lib/requirement-ids.js';
|
|
39
|
+
import { SEAMS_PREFIX } from '../lib/seams.js';
|
|
40
|
+
|
|
41
|
+
export const HOST_CALLBACK_NOT_REQUIRED = 'the suite is the A2A client: every leg POSTs JSON-RPC to the interface the host\'s own card lists; nothing harness-hosted is handed to the host';
|
|
42
|
+
|
|
43
|
+
const DOC = 'spec/v2/core/interop.md §"The operation mappings" (RFC 0208)';
|
|
44
|
+
const PROFILE = 'a2a-1.0';
|
|
45
|
+
const SKILL = 'conformance-approval';
|
|
46
|
+
const R = (slug: string): string => `openwop.requirement.0208.${slug}`;
|
|
47
|
+
const ACCEPT = { action: 'accept' };
|
|
48
|
+
|
|
49
|
+
interface MapStateRow { runStatus: string; wire: string; interruptKind?: string }
|
|
50
|
+
const MAP = JSON.parse(readFileSync(join(SCHEMAS_DIR, '..', 'spec', 'v2', 'interop-map.json'), 'utf8')) as { a2a: { taskState: MapStateRow[] } };
|
|
51
|
+
/** The map's forward projection (default rows only; an `interruptKind` override needs its interrupt kind). */
|
|
52
|
+
const WIRE_OF = new Map(MAP.a2a.taskState.filter((r) => r.interruptKind === undefined).map((r) => [r.runStatus, r.wire]));
|
|
53
|
+
/** RFC 0199 §D.1 — the override row for a run suspended on a `credential` interrupt. */
|
|
54
|
+
const CREDENTIAL_WIRE = MAP.a2a.taskState.find((r) => r.runStatus === 'waiting-input' && r.interruptKind === 'credential')?.wire;
|
|
55
|
+
|
|
56
|
+
interface RpcError { code: number; message?: string; data?: unknown }
|
|
57
|
+
interface Rpc { status: number; result?: Record<string, unknown> | undefined; error?: RpcError | undefined }
|
|
58
|
+
interface Task { id?: string; contextId?: string; status?: { state?: string } }
|
|
59
|
+
|
|
60
|
+
async function rpc(url: string, method: string, params: unknown, opts: { bearer?: string; version?: string } = {}): Promise<Rpc> {
|
|
61
|
+
const headers: Record<string, string> = { 'content-type': 'application/json', accept: 'application/json', 'A2A-Version': opts.version ?? '1.0' };
|
|
62
|
+
const key = opts.bearer ?? process.env.OPENWOP_API_KEY;
|
|
63
|
+
if (key) headers['authorization'] = `Bearer ${key}`;
|
|
64
|
+
const res = await fetch(url, { method: 'POST', headers, body: JSON.stringify({ jsonrpc: '2.0', id: randomBytes(4).readUInt32BE(0), method, params }) });
|
|
65
|
+
const body = (await res.json().catch(() => ({}))) as { result?: Record<string, unknown>; error?: RpcError };
|
|
66
|
+
return { status: res.status, ...body };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** `SendMessageResponse` is a oneof `{ task } | { message }`; a run-backed answer is a task. */
|
|
70
|
+
function taskOf(r: Rpc): Task | undefined {
|
|
71
|
+
const res = r.result ?? {};
|
|
72
|
+
const t = (res['task'] ?? (res['id'] !== undefined ? res : undefined)) as Task | undefined;
|
|
73
|
+
return t;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
type Target = { ok: true; url: string } | { ok: false; kind: 'inapplicable' | 'blocked'; reason: string } | { ok: false; kind: 'fail'; reason: string };
|
|
77
|
+
|
|
78
|
+
/** The JSON-RPC 1.0 interface the host's own card lists, and whether it routes the one suite skill. */
|
|
79
|
+
async function target(needsSkill: boolean): Promise<Target> {
|
|
80
|
+
if (!(await v2Discovery())) return { ok: false, kind: 'blocked', reason: 'v2 discovery unreachable' };
|
|
81
|
+
const a2a = await familyAdvertised('a2a');
|
|
82
|
+
if (!a2a) return { ok: false, kind: 'inapplicable', reason: 'a2a not advertised at major 2' };
|
|
83
|
+
const profiles = Array.isArray(a2a['profiles']) ? (a2a['profiles'] as unknown[]) : [];
|
|
84
|
+
if (!profiles.includes(PROFILE)) return { ok: false, kind: 'inapplicable', reason: 'a2a is advertised without profile a2a-1.0 — the host serves no A2A interface (client path only)' };
|
|
85
|
+
const cardUrl = a2a['agentCardUrl'];
|
|
86
|
+
if (typeof cardUrl !== 'string') return { ok: false, kind: 'fail', reason: 'a host claiming profile a2a-1.0 MUST advertise a2a.agentCardUrl' };
|
|
87
|
+
const res = await fetch(cardUrl, { headers: { accept: 'application/json', 'A2A-Version': '1.0' } });
|
|
88
|
+
if (res.status !== 200) return { ok: false, kind: 'fail', reason: `the advertised agentCardUrl answered ${res.status}` };
|
|
89
|
+
const card = (await res.json().catch(() => ({}))) as { supportedInterfaces?: Array<{ url?: string; protocolBinding?: string; protocolVersion?: string }>; skills?: Array<{ id?: string }> };
|
|
90
|
+
const iface = (card.supportedInterfaces ?? []).find((i) => i.protocolBinding === 'JSONRPC' && i.protocolVersion === '1.0');
|
|
91
|
+
if (typeof iface?.url !== 'string') return { ok: false, kind: 'fail', reason: 'the a2a.card supportedInterfaces row: claiming a2a-1.0 requires a JSONRPC interface at 1.0 in the card' };
|
|
92
|
+
if (needsSkill) {
|
|
93
|
+
const skills = (card.skills ?? []).map((s) => s.id);
|
|
94
|
+
if (skills.length !== 1 || skills[0] !== SKILL) return { ok: false, kind: 'blocked', reason: `the card routes skills [${skills.join(', ')}]; A2A 1.0 carries no skill selector, so the suite needs the interface to route exactly one skill, ${SKILL} (conformance/fixtures.md)` };
|
|
95
|
+
if (!isFixtureAdvertised(SKILL)) return { ok: false, kind: 'blocked', reason: `fixture ${SKILL} is not in the advertised fixtures[]` };
|
|
96
|
+
}
|
|
97
|
+
return { ok: true, url: iface.url };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function skip(t: Exclude<Target, { ok: true }>, id: string): undefined {
|
|
101
|
+
if (t.kind === 'fail') { expect(t.reason, req(id, DOC, t.reason)).toBe(''); return undefined; }
|
|
102
|
+
return softSkip(t.kind, t.reason);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const message = (extra: Record<string, unknown> = {}): Record<string, unknown> => ({ messageId: `conf-0208-${randomBytes(8).toString('hex')}`, role: 'ROLE_USER', parts: [{ text: 'openwop conformance RFC 0208' }], ...extra });
|
|
106
|
+
const acceptMessage = (taskId: string, extra: Record<string, unknown> = {}): Record<string, unknown> => message({ taskId, parts: [{ data: ACCEPT }], metadata: { openwop: { interrupt: ACCEPT } }, ...extra });
|
|
107
|
+
|
|
108
|
+
async function startApprovalTask(url: string, bearer?: string): Promise<{ rpc: Rpc; task: Task | undefined }> {
|
|
109
|
+
const r = await rpc(url, 'SendMessage', { message: message() }, bearer === undefined ? {} : { bearer });
|
|
110
|
+
return { rpc: r, task: taskOf(r) };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function runStatus(taskId: string): Promise<string | undefined> {
|
|
114
|
+
const r = await driver.get(`/runs/${encodeURIComponent(taskId)}`);
|
|
115
|
+
return r.status === 200 ? ((r.json as { status?: string }).status) : undefined;
|
|
116
|
+
}
|
|
117
|
+
async function eventCount(taskId: string): Promise<number> {
|
|
118
|
+
const r = await driver.get(`/runs/${encodeURIComponent(taskId)}/events/poll?timeout=1`);
|
|
119
|
+
return Array.isArray((r.json as { events?: unknown[] } | undefined)?.events) ? ((r.json as { events: unknown[] }).events.length) : -1;
|
|
120
|
+
}
|
|
121
|
+
async function settle(taskId: string, want: (s: string | undefined) => boolean, ms = 8000): Promise<string | undefined> {
|
|
122
|
+
const end = Date.now() + ms;
|
|
123
|
+
let s = await runStatus(taskId);
|
|
124
|
+
while (!want(s) && Date.now() < end) { await new Promise((ok) => setTimeout(ok, 100)); s = await runStatus(taskId); }
|
|
125
|
+
return s;
|
|
126
|
+
}
|
|
127
|
+
const TERMINAL = new Set(['completed', 'failed', 'cancelled']);
|
|
128
|
+
|
|
129
|
+
describe('RFC 0208 — v2-a2a-operation-map (host as A2A 1.0 server, gated on a2a.profiles ∋ a2a-1.0)', () => {
|
|
130
|
+
it('a message carrying taskId without contextId is answered with the task\'s contextId', async () => {
|
|
131
|
+
const t = await target(true);
|
|
132
|
+
if (!t.ok) return skip(t, R('a2a-context-inferred'));
|
|
133
|
+
const first = await startApprovalTask(t.url);
|
|
134
|
+
expect(first.rpc.error, req(R('a2a-context-inferred'), DOC, `SendMessage (no taskId) MUST start a run of the routed skill: ${JSON.stringify(first.rpc.error)}`)).toBeUndefined();
|
|
135
|
+
expect(first.task?.status?.state, req(R('a2a-context-inferred'), DOC, 'a run at waiting-approval projects as TASK_STATE_INPUT_REQUIRED (a2a.taskState)')).toBe(WIRE_OF.get('waiting-approval'));
|
|
136
|
+
const ctx = first.task?.contextId;
|
|
137
|
+
expect(typeof ctx === 'string' && ctx.length > 0, req(R('a2a-context-inferred'), DOC, 'the Task MUST carry a contextId (Message.contextId row: persisted as A2ATaskState.contextId)')).toBe(true);
|
|
138
|
+
const reply = await rpc(t.url, 'SendMessage', { message: acceptMessage(first.task!.id!) });
|
|
139
|
+
expect(reply.error, req(R('a2a-context-inferred'), DOC, `a message resolving the open interrupt by taskId MUST be accepted: ${JSON.stringify(reply.error)}`)).toBeUndefined();
|
|
140
|
+
expect(taskOf(reply)?.contextId, req(R('a2a-context-inferred'), 'interop.md §"A2A multi-turn" (A2A §3.4.3)', 'a message carrying taskId without contextId MUST be answered with the task\'s contextId — not a fresh one, not omitted')).toBe(ctx);
|
|
141
|
+
await settle(first.task!.id!, (s) => s !== undefined && TERMINAL.has(s));
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('a message whose contextId is not its task\'s is refused invalid-parameters and changes nothing', async () => {
|
|
145
|
+
const t = await target(true);
|
|
146
|
+
if (!t.ok) return skip(t, R('a2a-context-mismatch-refused'));
|
|
147
|
+
const first = await startApprovalTask(t.url);
|
|
148
|
+
const id = first.task?.id;
|
|
149
|
+
expect(typeof id, req(R('a2a-context-mismatch-refused'), DOC, `SendMessage MUST start a task: ${JSON.stringify(first.rpc.error)}`)).toBe('string');
|
|
150
|
+
await settle(id!, (s) => s === 'waiting-approval');
|
|
151
|
+
const before = await eventCount(id!);
|
|
152
|
+
const bad = await rpc(t.url, 'SendMessage', { message: acceptMessage(id!, { contextId: `ctx-mismatch-${randomBytes(6).toString('hex')}` }) });
|
|
153
|
+
expect(bad.error?.code, req(R('a2a-context-mismatch-refused'), 'interop.md §"A2A multi-turn"; a2a.errors (invalid parameters)', `a message whose contextId is not its task's MUST be refused with the JSON-RPC invalid-parameters error -32602 (got ${JSON.stringify(bad.error ?? bad.result)})`)).toBe(-32602);
|
|
154
|
+
expect(await runStatus(id!), req(R('a2a-context-mismatch-refused'), DOC, 'the refused message MUST NOT change the run: it is still waiting-approval (the interrupt did not resolve)')).toBe('waiting-approval');
|
|
155
|
+
expect(await eventCount(id!), req(R('a2a-context-mismatch-refused'), DOC, 'the refused message MUST NOT append an event to the run')).toBe(before);
|
|
156
|
+
await rpc(t.url, 'CancelTask', { id });
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('a message to a retained terminal task is refused UnsupportedOperationError, and appends no event', async () => {
|
|
160
|
+
const t = await target(true);
|
|
161
|
+
if (!t.ok) return skip(t, R('a2a-terminal-unsupported'));
|
|
162
|
+
const first = await startApprovalTask(t.url);
|
|
163
|
+
const id = first.task?.id;
|
|
164
|
+
expect(typeof id, req(R('a2a-terminal-unsupported'), DOC, `SendMessage MUST start a task: ${JSON.stringify(first.rpc.error)}`)).toBe('string');
|
|
165
|
+
await rpc(t.url, 'SendMessage', { message: acceptMessage(id!) });
|
|
166
|
+
const status = await settle(id!, (s) => s !== undefined && TERMINAL.has(s));
|
|
167
|
+
expect(status, req(R('a2a-terminal-unsupported'), DOC, 'precondition: the accepted approval task reaches completed and stays readable through getRun (retained)')).toBe('completed');
|
|
168
|
+
const before = await eventCount(id!);
|
|
169
|
+
const late = await rpc(t.url, 'SendMessage', { message: message({ taskId: id }) });
|
|
170
|
+
expect(late.error?.code, req(R('a2a-terminal-unsupported'), 'interop.md §"A2A multi-turn"; a2a.operations SendMessage (taskId, run terminal and retained)', `a message to a retained terminal task MUST be refused UnsupportedOperationError -32004, not TaskNotFoundError -32001 (got ${JSON.stringify(late.error ?? late.result)})`)).toBe(-32004);
|
|
171
|
+
expect(await eventCount(id!), req(R('a2a-terminal-unsupported'), DOC, 'the refusal appends no event (a2a.operations: "No event is appended")')).toBe(before);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('an unknown task and another tenant\'s task are answered by the same TaskNotFoundError', async () => {
|
|
175
|
+
const t = await target(true);
|
|
176
|
+
if (!t.ok) return skip(t, R('a2a-unreadable-not-found'));
|
|
177
|
+
const first = await startApprovalTask(t.url);
|
|
178
|
+
const id = first.task?.id;
|
|
179
|
+
expect(typeof id === 'string' && id.includes('/'), req(R('a2a-unreadable-not-found'), 'identity.md §5; a2a.operations GetTask', `Task.id is the tenant-bound runId <tenant>/<opaque> (got ${JSON.stringify(id)})`)).toBe(true);
|
|
180
|
+
const [tenant, opaque] = [id!.slice(0, id!.indexOf('/')), id!.slice(id!.indexOf('/') + 1)];
|
|
181
|
+
const fabricated = `${tenant}/${randomBytes(16).toString('base64url').replace(/[^A-Za-z0-9]/g, 'x').slice(0, 22)}`;
|
|
182
|
+
const foreign = `zz-conformance-foreign/${opaque}`;
|
|
183
|
+
const a = await rpc(t.url, 'GetTask', { id: fabricated });
|
|
184
|
+
const b = await rpc(t.url, 'GetTask', { id: foreign });
|
|
185
|
+
expect(a.error?.code, req(R('a2a-unreadable-not-found'), 'a2a.errors TaskNotFoundError', `an unknown task MUST be TaskNotFoundError -32001 (got ${JSON.stringify(a.error ?? a.result)})`)).toBe(-32001);
|
|
186
|
+
expect(b.error?.code, req(R('a2a-unreadable-not-found'), 'interop.md §"The operation mappings" Isolation', `a task the caller cannot read — here a foreign tenant segment REST refuses 403 — MUST be answered exactly as a nonexistent one, -32001 (got ${JSON.stringify(b.error ?? b.result)}, HTTP ${b.status})`)).toBe(-32001);
|
|
187
|
+
const shape = (e: RpcError | undefined): string => JSON.stringify({ http: e === undefined ? null : 'rpc', dataKeys: e?.data && typeof e.data === 'object' ? Object.keys(e.data as object).sort() : typeof e?.data });
|
|
188
|
+
expect(shape(b.error) + String(b.status), req(R('a2a-unreadable-not-found'), 'interop.md §"The operation mappings" Isolation (A2A 1.0.1 §3.3.2)', 'the foreign-tenant answer MUST be indistinguishable from the unknown-id answer: same HTTP status and the same error.data shape')).toBe(shape(a.error) + String(a.status));
|
|
189
|
+
await rpc(t.url, 'CancelTask', { id });
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('Task.status.state is the map\'s projection of the run status at every observed state', async () => {
|
|
193
|
+
const t = await target(true);
|
|
194
|
+
if (!t.ok) return skip(t, R('a2a-state-projection'));
|
|
195
|
+
const observed: string[] = [];
|
|
196
|
+
const check = async (id: string, label: string): Promise<void> => {
|
|
197
|
+
const status = await runStatus(id);
|
|
198
|
+
const got = taskOf(await rpc(t.url, 'GetTask', { id }))?.status?.state;
|
|
199
|
+
observed.push(`${label}: run ${String(status)} → ${String(got)}`);
|
|
200
|
+
expect(got, req(R('a2a-state-projection'), 'interop-map.json a2a.taskState', `GetTask state MUST equal taskState[${String(status)}].wire (${String(WIRE_OF.get(String(status)))}); observed ${observed.join('; ')}`)).toBe(WIRE_OF.get(String(status)));
|
|
201
|
+
};
|
|
202
|
+
const one = (await startApprovalTask(t.url)).task?.id;
|
|
203
|
+
const two = (await startApprovalTask(t.url)).task?.id;
|
|
204
|
+
expect(typeof one === 'string' && typeof two === 'string', req(R('a2a-state-projection'), DOC, 'SendMessage MUST start tasks')).toBe(true);
|
|
205
|
+
await settle(one!, (s) => s === 'waiting-approval');
|
|
206
|
+
await check(one!, 'suspended');
|
|
207
|
+
await rpc(t.url, 'SendMessage', { message: acceptMessage(one!) });
|
|
208
|
+
await settle(one!, (s) => s !== undefined && TERMINAL.has(s));
|
|
209
|
+
await check(one!, 'accepted');
|
|
210
|
+
await settle(two!, (s) => s === 'waiting-approval');
|
|
211
|
+
const cancel = await rpc(t.url, 'CancelTask', { id: two });
|
|
212
|
+
expect([WIRE_OF.get('cancelling'), WIRE_OF.get('cancelled')], req(R('a2a-state-projection'), 'a2a.operations CancelTask', `CancelTask answers the Task at TASK_STATE_WORKING while cancelling, TASK_STATE_CANCELED once cancelled (got ${JSON.stringify(cancel.error ?? taskOf(cancel)?.status)})`)).toContain(taskOf(cancel)?.status?.state);
|
|
213
|
+
await settle(two!, (s) => s !== undefined && TERMINAL.has(s));
|
|
214
|
+
await check(two!, 'cancelled');
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('the JSON-RPC error codes the suite can cause are the A2A 1.0.1 codes', async () => {
|
|
218
|
+
const t = await target(true);
|
|
219
|
+
if (!t.ok) return skip(t, R('a2a-error-codes'));
|
|
220
|
+
const first = await startApprovalTask(t.url);
|
|
221
|
+
const id = first.task?.id;
|
|
222
|
+
expect(typeof id, req(R('a2a-error-codes'), DOC, 'SendMessage MUST start a task')).toBe('string');
|
|
223
|
+
await rpc(t.url, 'SendMessage', { message: acceptMessage(id!) });
|
|
224
|
+
await settle(id!, (s) => s !== undefined && TERMINAL.has(s));
|
|
225
|
+
const cancel = await rpc(t.url, 'CancelTask', { id });
|
|
226
|
+
expect(cancel.error?.code, req(R('a2a-error-codes'), 'a2a.errors TaskNotCancelableError', `CancelTask on a terminal run MUST be TaskNotCancelableError -32002 (got ${JSON.stringify(cancel.error ?? cancel.result)})`)).toBe(-32002);
|
|
227
|
+
const version = await rpc(t.url, 'GetTask', { id }, { version: '9.9' });
|
|
228
|
+
expect(version.error?.code, req(R('a2a-error-codes'), 'a2a.errors VersionNotSupportedError', `an A2A-Version the interface does not serve MUST be VersionNotSupportedError -32009 (got ${JSON.stringify(version.error ?? version.result)})`)).toBe(-32009);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('a row whose requires facet is not advertised is refused with the row\'s error', async () => {
|
|
232
|
+
const t = await target(true);
|
|
233
|
+
if (!t.ok) return skip(t, R('row-requires-refused'));
|
|
234
|
+
const a2a = (await familyAdvertised('a2a'))!;
|
|
235
|
+
if (a2a['streaming'] === true) return softSkip('inapplicable', 'a2a.streaming is advertised — SubscribeToTask is a served row here, not a refused one');
|
|
236
|
+
const first = await startApprovalTask(t.url);
|
|
237
|
+
const id = first.task?.id;
|
|
238
|
+
expect(typeof id, req(R('row-requires-refused'), DOC, 'SendMessage MUST start a task')).toBe('string');
|
|
239
|
+
const sub = await rpc(t.url, 'SubscribeToTask', { id });
|
|
240
|
+
expect(sub.error?.code, req(R('row-requires-refused'), 'interop.md §"The operation mappings"; a2a.operations SubscribeToTask requires a2a.streaming', `a host not advertising a2a.streaming MUST refuse SubscribeToTask with UnsupportedOperationError -32004 (got ${JSON.stringify(sub.error ?? sub.result)})`)).toBe(-32004);
|
|
241
|
+
await rpc(t.url, 'CancelTask', { id });
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('ListTasks returns only what listRuns returns to the same Subject; tenant and contextId never select', async () => {
|
|
245
|
+
const other = process.env.OPENWOP_TEST_TENANT_B_API_KEY;
|
|
246
|
+
if (!other) return softSkip('blocked', 'OPENWOP_TEST_TENANT_B_API_KEY (a credential bound to a second tenant) is not set — the cross-caller leg cannot run');
|
|
247
|
+
const t = await target(true);
|
|
248
|
+
if (!t.ok) return skip(t, R('a2a-list-scoped'));
|
|
249
|
+
const mine = await startApprovalTask(t.url);
|
|
250
|
+
const theirs = await startApprovalTask(t.url, other);
|
|
251
|
+
const id = mine.task?.id;
|
|
252
|
+
const ctx = mine.task?.contextId;
|
|
253
|
+
const theirId = theirs.task?.id;
|
|
254
|
+
if (typeof id !== 'string' || typeof theirId !== 'string' || !id.includes('/')) {
|
|
255
|
+
expect(typeof id === 'string' && typeof theirId === 'string', req(R('a2a-list-scoped'), DOC, `SendMessage MUST start a task under both credentials: ${JSON.stringify(mine.rpc.error ?? theirs.rpc.error)}`)).toBe(true);
|
|
256
|
+
}
|
|
257
|
+
const tenantA = id!.slice(0, id!.indexOf('/'));
|
|
258
|
+
if (theirId!.startsWith(`${tenantA}/`)) {
|
|
259
|
+
await rpc(t.url, 'CancelTask', { id });
|
|
260
|
+
await rpc(t.url, 'CancelTask', { id: theirId }, { bearer: other });
|
|
261
|
+
return softSkip('blocked', 'OPENWOP_TEST_TENANT_B_API_KEY binds the same tenant as OPENWOP_API_KEY — the leg needs a second tenant');
|
|
262
|
+
}
|
|
263
|
+
const ids = (r: Rpc): string[] => (Array.isArray(r.result?.['tasks']) ? (r.result!['tasks'] as Task[]).map((x) => String(x.id)) : []);
|
|
264
|
+
const own = await rpc(t.url, 'ListTasks', { contextId: ctx });
|
|
265
|
+
expect(ids(own), req(R('a2a-list-scoped'), 'a2a.operations ListTasks', `positive control: the owner's ListTasks filtered by the task's contextId MUST list it (got ${JSON.stringify(own.error ?? own.result)})`)).toContain(id);
|
|
266
|
+
const plain = await rpc(t.url, 'ListTasks', {}, { bearer: other });
|
|
267
|
+
expect(plain.error, req(R('a2a-list-scoped'), 'a2a.operations ListTasks', `ListTasks MUST be served to the second caller: ${JSON.stringify(plain.error)}`)).toBeUndefined();
|
|
268
|
+
expect(ids(plain).filter((x) => x.startsWith(`${tenantA}/`)), req(R('a2a-list-scoped'), 'interop.md §"The operation mappings" Isolation', 'ListTasks MUST return only runs listRuns would return to the same Subject — no task of another tenant')).toEqual([]);
|
|
269
|
+
const hinted = await rpc(t.url, 'ListTasks', { tenant: tenantA, contextId: ctx }, { bearer: other });
|
|
270
|
+
expect(ids(hinted).filter((x) => x.startsWith(`${tenantA}/`)), req(R('a2a-list-scoped'), 'interop.md §"The operation mappings" Isolation', '`tenant` and `contextId` never select a tenant: naming the owner\'s tenant and contextId MUST NOT reveal the owner\'s task')).toEqual([]);
|
|
271
|
+
const peek = await rpc(t.url, 'GetTask', { id }, { bearer: other });
|
|
272
|
+
expect(peek.error?.code, req(R('a2a-list-scoped'), 'interop.md §"The operation mappings" Isolation', `another tenant's real task MUST be TaskNotFoundError for this caller (got ${JSON.stringify(peek.error ?? peek.result)})`)).toBe(-32001);
|
|
273
|
+
await rpc(t.url, 'CancelTask', { id });
|
|
274
|
+
await rpc(t.url, 'CancelTask', { id: theirId }, { bearer: other });
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('a run suspended on a credential interrupt projects to TASK_STATE_AUTH_REQUIRED, naming the provider and carrying connectUrl', async () => {
|
|
278
|
+
const id = 'openwop.requirement.0199.a2a-auth-required';
|
|
279
|
+
const t = await target(false);
|
|
280
|
+
if (!t.ok) return skip(t, id);
|
|
281
|
+
const oauth = await familyAdvertised('oauth');
|
|
282
|
+
if (oauth?.['credentialInterrupt'] !== true) return softSkip('inapplicable', 'oauth.credentialInterrupt is not advertised — the host raises no credential interrupt to project (RFC 0199 §C.1)');
|
|
283
|
+
if (!isFixtureAdvertised('conformance-credential')) return softSkip('blocked', 'fixture conformance-credential is not in the advertised fixtures[]');
|
|
284
|
+
// A fresh Subject, so no credential another scenario acquired satisfies the node.
|
|
285
|
+
const minted = await driver.post(`${SEAMS_PREFIX}/sample/auth/credential/mint`, { lane: 'api-key' }).catch(() => null);
|
|
286
|
+
const bearer = (minted?.json as { credential?: unknown } | undefined)?.credential;
|
|
287
|
+
if (typeof bearer !== 'string') return softSkip('blocked', 'the credential mint seam did not mint a fresh Subject — a Subject that may already hold a credential makes the leg vacuous');
|
|
288
|
+
const created = await driver.post('/runs', { workflowId: 'conformance-credential', inputs: {} }, { authenticated: false, headers: { Authorization: `Bearer ${bearer}` } });
|
|
289
|
+
const runId = (created.json as { runId?: string } | undefined)?.runId;
|
|
290
|
+
expect(typeof runId, req(id, 'runs.md createRun', `createRun of conformance-credential MUST be accepted (got ${created.status})`)).toBe('string');
|
|
291
|
+
const end = Date.now() + 8000;
|
|
292
|
+
let snap = await driver.get(`/runs/${encodeURIComponent(runId!)}`, { authenticated: false, headers: { Authorization: `Bearer ${bearer}` } });
|
|
293
|
+
while ((snap.json as { status?: string } | undefined)?.status !== 'waiting-input' && Date.now() < end) {
|
|
294
|
+
await new Promise((ok) => setTimeout(ok, 100));
|
|
295
|
+
snap = await driver.get(`/runs/${encodeURIComponent(runId!)}`, { authenticated: false, headers: { Authorization: `Bearer ${bearer}` } });
|
|
296
|
+
}
|
|
297
|
+
expect((snap.json as { status?: string } | undefined)?.status, req(id, 'oauth.md §The credential interrupt', 'precondition: the run suspends on the credential interrupt (waiting-input)')).toBe('waiting-input');
|
|
298
|
+
const got = await rpc(t.url, 'GetTask', { id: runId }, { bearer });
|
|
299
|
+
const task = taskOf(got) as (Task & { status?: { state?: string; message?: { parts?: Array<{ text?: string }> } }; metadata?: { openwop?: { interrupt?: { kind?: string } } } }) | undefined;
|
|
300
|
+
expect(CREDENTIAL_WIRE, req(id, 'interop-map.json a2a.taskState', 'the map carries the (waiting-input, credential) override row')).toBe('TASK_STATE_AUTH_REQUIRED');
|
|
301
|
+
expect(task?.status?.state, req(id, 'interop.md §The durable-task projection; interop-map.json a2a.taskState (waiting-input, credential)', `a run suspended on a credential interrupt MUST project to TASK_STATE_AUTH_REQUIRED, not INPUT_REQUIRED (got ${JSON.stringify(got.error ?? task?.status)})`)).toBe(CREDENTIAL_WIRE);
|
|
302
|
+
expect(task?.metadata?.openwop?.interrupt?.kind, req(id, 'RFC 0199 §D.1', 'metadata.openwop.interrupt.kind (the interruptKind carrier) MUST be credential')).toBe('credential');
|
|
303
|
+
const text = (task?.status?.message?.parts ?? []).map((p) => p.text ?? '').join(' ');
|
|
304
|
+
const events = await driver.get(`/runs/${encodeURIComponent(runId!)}/events/poll?timeout=1`, { authenticated: false, headers: { Authorization: `Bearer ${bearer}` } });
|
|
305
|
+
const asked = ((events.json as { events?: Array<{ type?: string; payload?: { kind?: string; data?: { connectUrl?: string; provider?: string } } }> } | undefined)?.events ?? []).find((e) => e.type === 'interrupt.requested' && e.payload?.kind === 'credential');
|
|
306
|
+
const connectUrl = asked?.payload?.data?.connectUrl ?? '\u0000';
|
|
307
|
+
expect(text.includes(connectUrl) && text.includes(asked?.payload?.data?.provider ?? '\u0000'), req(id, 'RFC 0199 §D.1 (A2A v1.0.1 §7.6.1)', `the TaskStatus message MUST name the provider and carry connectUrl (got ${JSON.stringify(text)})`)).toBe(true);
|
|
308
|
+
await driver.post(`/runs/${encodeURIComponent(runId!)}:cancel`, {}, { authenticated: false, headers: { Authorization: `Bearer ${bearer}` } });
|
|
309
|
+
});
|
|
310
|
+
});
|