From 4a313e2118ab15da740d215233b6daedafa8ead9 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Wed, 17 Jun 2026 16:10:53 +0200 Subject: [PATCH] fix(conformance): treat absent proposal support as unsupported --- .changeset/proposal-gate-absence.md | 8 +++ src/lib/testing/storyboard/runner.ts | 22 +++--- src/lib/testing/storyboard/types.ts | 9 +-- test/lib/storyboard-capability-gate.test.js | 78 +++++++++++++++++++++ 4 files changed, 104 insertions(+), 13 deletions(-) create mode 100644 .changeset/proposal-gate-absence.md 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 cc61a1ba5..9b893cd5d 100644 --- a/src/lib/testing/storyboard/runner.ts +++ b/src/lib/testing/storyboard/runner.ts @@ -339,11 +339,10 @@ export function evaluateCapabilityPredicate(predicate: RequiresCapabilityPredica // the agent affirmatively refused. Skip ONLY when the agent declared a // value AND that value disagrees with the predicate. // - // Exception: media_buy.features.inline_creative_management is an optional - // feature flag whose rc.9 storyboard states that non-advertising sellers - // grade not_applicable. Treat absence as unsupported only for that feature. - if (actual === undefined && isInlineCreativeManagementGate(predicate)) { - return `Capability predicate \`${predicate.path} === true\` not satisfied: ` + `agent did not declare the feature.`; + // Exception: a small set of optional opt-in features treat absence as + // unsupported. These storyboards must skip when the seller stays silent. + if (actual === undefined && isAbsenceMeansUnsupportedGate(predicate)) { + return `Capability predicate \`${predicate.path} === true\` not satisfied: ` + `agent did not declare support.`; } if (actual !== undefined && actual !== predicate.equals) { return ( @@ -354,12 +353,17 @@ export function evaluateCapabilityPredicate(predicate: RequiresCapabilityPredica return null; } -function isInlineCreativeManagementGate( +const ABSENCE_MEANS_UNSUPPORTED_EQUALS_TRUE_PATHS = new Set([ + 'media_buy.features.inline_creative_management', + 'media_buy.supports_proposals', +]); + +function isAbsenceMeansUnsupportedGate( predicate: RequiresCapabilityPredicate -): predicate is { path: 'media_buy.features.inline_creative_management'; equals: true } { +): predicate is { path: string; equals: true } { return ( 'equals' in predicate && - predicate.path === 'media_buy.features.inline_creative_management' && + ABSENCE_MEANS_UNSUPPORTED_EQUALS_TRUE_PATHS.has(predicate.path) && predicate.equals === true ); } @@ -374,7 +378,7 @@ function evaluateRequiresCapabilityGate( return evaluateCapabilityPredicate(predicate, actual); } if ( - isInlineCreativeManagementGate(predicate) && + isAbsenceMeansUnsupportedGate(predicate) && profile !== undefined && !profile.tools.includes('get_adcp_capabilities') ) { diff --git a/src/lib/testing/storyboard/types.ts b/src/lib/testing/storyboard/types.ts index a43f556bd..2da08884e 100644 --- a/src/lib/testing/storyboard/types.ts +++ b/src/lib/testing/storyboard/types.ts @@ -147,10 +147,11 @@ export interface Storyboard { * * When `raw_capabilities` is not available (e.g. the agent doesn't expose * `get_adcp_capabilities`), the gate is a no-op and the storyboard runs. - * Exception: `media_buy.features.inline_creative_management === true` gates - * treat missing raw capabilities as unsupported when the discovered profile - * does not expose `get_adcp_capabilities`, because inline package creative - * upload is an optional rc.9 feature that sellers must advertise. + * Exception: optional opt-in feature gates such as + * `media_buy.features.inline_creative_management === true` and + * `media_buy.supports_proposals === true` treat missing raw capabilities as + * unsupported when the discovered profile does not expose + * `get_adcp_capabilities`, because sellers must advertise those features. */ requires_capability?: RequiresCapabilityPredicate; /** Scenario IDs that must pass alongside this storyboard (loaded from storyboards/scenarios/) */ diff --git a/test/lib/storyboard-capability-gate.test.js b/test/lib/storyboard-capability-gate.test.js index 68315072c..e9983565a 100644 --- a/test/lib/storyboard-capability-gate.test.js +++ b/test/lib/storyboard-capability-gate.test.js @@ -78,6 +78,32 @@ const inlineCreativeGatedStoryboard = { ], }; +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 @@ -202,6 +228,43 @@ describe('requires_capability storyboard skip gate (#933)', () => { assert.ok(step.skip.detail.includes('did not declare')); }); + 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'], @@ -336,6 +399,10 @@ describe('requires_capability `present:` matcher (#1811)', () => { path: 'media_buy.features.inline_creative_management', equals: true, }; + const proposalFeatureEqualsTrue = { + path: 'media_buy.supports_proposals', + equals: true, + }; // present: true assert.equal(evaluateCapabilityPredicate(presentTrue, undefined)?.includes('must be present'), true); @@ -364,6 +431,17 @@ describe('requires_capability `present:` matcher (#1811)', () => { 'inline_creative_management is an optional feature gate: absent skips' ); assert.equal(evaluateCapabilityPredicate(inlineFeatureEqualsTrue, true), null); + 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' + ); }); });