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.
28 changes: 27 additions & 1 deletion src/lib/testing/storyboard/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: ` +
Expand All @@ -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] };
}
Expand Down Expand Up @@ -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) {
Expand All @@ -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,
};
}
}
}

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 @@ -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
Expand Down Expand Up @@ -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'],
Expand Down Expand Up @@ -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);
Expand All @@ -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'
);
});
});

Expand Down
Loading