Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/proposal-gate-absence.md
Original file line number Diff line number Diff line change
@@ -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.
22 changes: 13 additions & 9 deletions src/lib/testing/storyboard/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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
);
}
Expand All @@ -374,7 +378,7 @@ function evaluateRequiresCapabilityGate(
return evaluateCapabilityPredicate(predicate, actual);
}
if (
isInlineCreativeManagementGate(predicate) &&
isAbsenceMeansUnsupportedGate(predicate) &&
profile !== undefined &&
!profile.tools.includes('get_adcp_capabilities')
) {
Expand Down
9 changes: 5 additions & 4 deletions src/lib/testing/storyboard/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/) */
Expand Down
78 changes: 78 additions & 0 deletions test/lib/storyboard-capability-gate.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'],
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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'
);
});
});

Expand Down
Loading