diff --git a/.changeset/proposal-gate-absence.md b/.changeset/proposal-gate-absence.md new file mode 100644 index 000000000..01c6cb27a --- /dev/null +++ b/.changeset/proposal-gate-absence.md @@ -0,0 +1,8 @@ +--- +"@adcp/sdk": patch +--- + +fix(conformance): skip proposal storyboards when supports_proposals is absent + +Treat omitted `media_buy.supports_proposals` as unsupported for proposal lifecycle +`requires_capability` gates, including profiles without raw capabilities. diff --git a/src/lib/testing/storyboard/runner.ts b/src/lib/testing/storyboard/runner.ts index c81386175..2cb910017 100644 --- a/src/lib/testing/storyboard/runner.ts +++ b/src/lib/testing/storyboard/runner.ts @@ -283,6 +283,12 @@ export function evaluateCapabilityPredicate( // a real spec-coverage gap (under-declared agent) rather than a behavior // the agent affirmatively refused. Skip ONLY when the agent declared a // value AND that value disagrees with the predicate. + // + // Exception: optional proposal lifecycle storyboards require an explicit + // seller opt-in. Absent support is equivalent to unsupported. + if (actual === undefined && isProposalLifecycleGate(predicate)) { + return `Capability predicate \`${predicate.path} === true\` not satisfied: ` + `agent did not declare support.`; + } if (actual !== undefined && actual !== predicate.equals) { return ( `Capability predicate \`${predicate.path} === ${JSON.stringify(predicate.equals)}\` not satisfied: ` + @@ -292,6 +298,13 @@ export function evaluateCapabilityPredicate( return null; } +function isProposalLifecycleGate(predicate: { + path: string; + equals?: boolean | string | number | null; +}): predicate is { path: 'media_buy.supports_proposals'; equals: true } { + return predicate.path === 'media_buy.supports_proposals' && predicate.equals === true; +} + function buildSkip(reason: RunnerSkipReason, detail?: string): { reason: RunnerSkipReason; detail: string } { return { reason, detail: detail ?? SKIP_DETAILS[reason] }; } @@ -1593,9 +1606,9 @@ async function executeStoryboardPass( // tests (e.g. `adcp.idempotency.supported: false`), skip the whole storyboard // rather than producing a cascade of misleading per-phase failures. if (storyboard.requires_capability) { + const cap = storyboard.requires_capability; const rawCaps = profile?.raw_capabilities; if (rawCaps !== undefined) { - const cap = storyboard.requires_capability; const actual = resolveCapabilityPath(rawCaps, cap.path); const unmetDetail = evaluateCapabilityPredicate(cap, actual); if (unmetDetail !== null) { @@ -1605,6 +1618,19 @@ async function executeStoryboardPass( notices: preflightNotices, }; } + } else if ( + isProposalLifecycleGate(cap) && + profile !== undefined && + !profile.tools.includes('get_adcp_capabilities') + ) { + const unmetDetail = evaluateCapabilityPredicate(cap, undefined); + if (unmetDetail !== null) { + if (!options._client) await closeConnections(options.protocol); + return { + ...buildCapabilityUnsupportedResult(agentUrls, storyboard, unmetDetail), + notices: preflightNotices, + }; + } } } diff --git a/test/lib/storyboard-capability-gate.test.js b/test/lib/storyboard-capability-gate.test.js index 20ae2b5cb..cf3251705 100644 --- a/test/lib/storyboard-capability-gate.test.js +++ b/test/lib/storyboard-capability-gate.test.js @@ -52,6 +52,32 @@ const disabledProfile = { raw_capabilities: { adcp: { idempotency: { supported: false } } }, }; +const proposalLifecycleGatedStoryboard = { + id: 'proposal_lifecycle_optional_feature_gate_test', + version: '1.0.0', + title: 'Proposal lifecycle (optional feature gated)', + category: 'test', + summary: 'Skipped when media-buy proposal lifecycle support is not advertised.', + narrative: '', + agent: { interaction_model: 'media_buy_seller', capabilities: [] }, + caller: { role: 'buyer_agent' }, + requires_capability: { path: 'media_buy.supports_proposals', equals: true }, + phases: [ + { + id: 'proposal_lifecycle', + title: 'Proposal lifecycle phase', + steps: [ + { + id: 'proposal_finalize', + title: 'Finalize a proposal', + task: 'proposal_finalize', + sample_request: { proposal_id: 'proposal_test' }, + }, + ], + }, + ], +}; + describe('requires_capability storyboard skip gate (#933)', () => { test('emits capability_unsupported skip when agent declares supported: false', async () => { // _profile bypasses discoverAgentProfile; no network calls made because @@ -139,6 +165,43 @@ describe('requires_capability storyboard skip gate (#933)', () => { assert.ok(typeof result === 'function' || result === undefined); }); + test('optional proposal lifecycle feature skips when omitted', async () => { + const result = await runStoryboard('http://fake-local-99990', proposalLifecycleGatedStoryboard, { + _profile: { + name: 'Test Agent (no proposal support declared)', + tools: ['get_adcp_capabilities', 'proposal_finalize'], + raw_capabilities: { media_buy: {} }, + }, + }); + + assert.equal(result.overall_passed, true); + assert.equal(result.skipped_count, 1); + const step = result.phases[0].steps[0]; + assert.equal(step.skipped, true); + assert.equal(step.skip_reason, 'capability_unsupported'); + assert.equal(step.skip.reason, 'unsatisfied_contract'); + assert.ok(step.skip.detail.includes('media_buy.supports_proposals')); + assert.ok(step.skip.detail.includes('did not declare')); + }); + + test('optional proposal lifecycle feature skips when raw capabilities are unavailable', async () => { + const result = await runStoryboard('http://fake-local-99989', proposalLifecycleGatedStoryboard, { + _profile: { + name: 'Test Agent (proposal capability unavailable)', + tools: ['proposal_finalize'], + }, + }); + + assert.equal(result.overall_passed, true); + assert.equal(result.skipped_count, 1); + const step = result.phases[0].steps[0]; + assert.equal(step.skipped, true); + assert.equal(step.skip_reason, 'capability_unsupported'); + assert.equal(step.skip.reason, 'unsatisfied_contract'); + assert.ok(step.skip.detail.includes('media_buy.supports_proposals')); + assert.ok(step.skip.detail.includes('did not declare')); + }); + test('DETAILED_SKIP_TO_CANONICAL maps capability_unsupported to unsatisfied_contract', () => { assert.equal( DETAILED_SKIP_TO_CANONICAL['capability_unsupported'], @@ -269,6 +332,10 @@ describe('requires_capability `present:` matcher (#1811)', () => { const presentTrue = { path: 'x.y', present: true }; const presentFalse = { path: 'x.y', present: false }; const equalsTrue = { path: 'x.y', equals: true }; + const proposalFeatureEqualsTrue = { + path: 'media_buy.supports_proposals', + equals: true, + }; // present: true assert.equal(evaluateCapabilityPredicate(presentTrue, undefined)?.includes('must be present'), true); @@ -291,6 +358,17 @@ describe('requires_capability `present:` matcher (#1811)', () => { true, 'declared mismatch skips with `not satisfied` detail' ); + assert.equal( + evaluateCapabilityPredicate(proposalFeatureEqualsTrue, undefined)?.includes('did not declare'), + true, + 'supports_proposals is an optional feature gate: absent skips' + ); + assert.equal(evaluateCapabilityPredicate(proposalFeatureEqualsTrue, true), null); + assert.equal( + evaluateCapabilityPredicate(proposalFeatureEqualsTrue, false)?.includes('not satisfied'), + true, + 'supports_proposals: false skips proposal lifecycle storyboards' + ); }); });