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
9 changes: 9 additions & 0 deletions .changeset/experimental-features-helper.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@adcp/client': minor
---

Add `experimental_features` support on capabilities (adcp-client#627).

`AdcpCapabilities` now carries an `experimentalFeatures?: string[]` field populated from the AdCP 3.0 GA `experimental_features` envelope on `get_adcp_capabilities` responses. New helper `supportsExperimentalFeature(caps, id)` lets consumers gate reliance on `x-status: experimental` surfaces (`brand.rights_lifecycle`, `governance.campaign`, `trusted_match.core`, etc.) on an explicit seller opt-in. `resolveFeature` handles the `experimental:<id>` namespace so `require()`/`supports()` flows work the same way they do for `ext:<name>` extensions.

The `custom` vendor-pricing variant and the `per_unit` catchup from AdCP 3.0 GA were already picked up in the previous types regeneration — no type-surface changes ship with this release.
9 changes: 9 additions & 0 deletions .changeset/idempotency-auto-inject.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@adcp/client': patch
---

Auto-inject `idempotency_key` on mutating storyboard requests and untyped `executeTask` calls (adcp-client#625).

The storyboard runner now mints a UUID v4 `idempotency_key` on any mutating step whose `sample_request` omits one — matching how a real buyer operates, so compliance storyboards exercise handler logic rather than short-circuiting on the server's required-field check. Auto-injection applies to `expect_error` steps too, so scenarios that expect specific failures (GOVERNANCE_DENIED, UNAUTHORIZED, brand_mismatch, etc.) reach the error path they named instead of hitting INVALID_REQUEST first. Storyboards that intentionally test the server's missing-key rejection opt out with the new `step.omit_idempotency_key: true` flag.

The underlying `normalizeRequestParams` helper now derives its mutating-task set from the Zod request schemas (`MUTATING_TASKS` in `utils/idempotency`) rather than a hand-maintained list. The Zod-derived set adds auto-injection for `acquire_rights`, `update_media_buy`, `si_initiate_session`, `si_send_message`, `build_creative`, and the property / collection / content-standards writes — all of which the spec declares as mutating but the hand-maintained list was missing. Any caller using `client.executeTask(<mutating-task>, params)` — typed or untyped — now receives the same auto-injected key the typed methods already minted via `executeAndHandle`.
1 change: 1 addition & 0 deletions src/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -728,6 +728,7 @@ export {
requiresOperatorAuth,
requiresAccountForProducts,
supportsSandbox,
supportsExperimentalFeature,
resolveFeature,
listDeclaredFeatures,
MEDIA_BUY_TOOLS,
Expand Down
51 changes: 41 additions & 10 deletions src/lib/testing/storyboard/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import {
import { runValidations, type ValidationContext } from './validations';
import { buildRequest, hasRequestBuilder } from './request-builder';
import { resolveAccount, resolveBrand } from '../client';
import { isMutatingTask } from '../../utils/idempotency';
import { isMutatingTask, generateIdempotencyKey } from '../../utils/idempotency';
import {
PROBE_TASKS,
probeProtectedResourceMetadata,
Expand Down Expand Up @@ -658,6 +658,14 @@ async function executeStep(
// when individual builders or sample_request YAML omit brand.
request = applyBrandInvariant(request, options);

// Mutating AdCP requests require idempotency_key per spec. Storyboard
// yamls generally omit it so authors don't have to remember it on every
// mutating step — mint one here on the runner's behalf, matching how a
// real buyer would operate. Suppressed when the step expects a missing-key
// error (see `testsMissingIdempotencyKey` below) so that compliance
// surfaces can still exercise the server's required-field check.
request = applyIdempotencyInvariant(request, effectiveStep.task, step);

// Detect unresolved $context placeholders — a prior step likely failed
// and didn't produce the expected output. Skip rather than sending garbage.
const unresolvedVars = findUnresolvedContextVars(request);
Expand Down Expand Up @@ -688,15 +696,12 @@ async function executeStep(
// + `WWW-Authenticate` header for http_* validations.
//
// Tests for envelope validation on mutating tasks (e.g., "missing
// idempotency_key returns INVALID_REQUEST") need to suppress the AdCP
// client's auto-inject — otherwise the client helpfully generates a
// UUID and the server never sees a missing-key request. Narrow trigger:
// the step expects an error, the task is mutating, and the request
// doesn't provide idempotency_key.
const testsMissingIdempotencyKey =
step.expect_error === true &&
isMutatingTask(effectiveStep.task) &&
(request as Record<string, unknown>).idempotency_key === undefined;
// idempotency_key returns INVALID_REQUEST") set `step.omit_idempotency_key`
// to suppress both the runner's `applyIdempotencyInvariant` (above) and the
// AdCP client's auto-inject — otherwise the SDK helpfully generates a UUID
// and the server never sees a missing-key request. Paired flags so the two
// layers agree; see `applyIdempotencyInvariant` for the runner-level skip.
const testsMissingIdempotencyKey = step.omit_idempotency_key === true && isMutatingTask(effectiveStep.task);

let taskResult: TaskResult | undefined;
let stepResult: { duration_ms: number; error?: string; passed: boolean };
Expand Down Expand Up @@ -1153,6 +1158,32 @@ export function applyBrandInvariant(
return result;
}

/**
* Mint an `idempotency_key` for mutating storyboard requests when one wasn't
* supplied. Storyboard `sample_request` blocks generally omit it; the runner
* fills it in so the server's required-field check doesn't short-circuit the
* handler under test, including on `expect_error` steps that name specific
* failure modes (GOVERNANCE_DENIED, UNAUTHORIZED, brand_mismatch, etc.).
*
* Skipped when:
* - `step.omit_idempotency_key === true` — the scenario is explicitly
* exercising the server's missing-key rejection path.
* - the task isn't mutating per {@link MUTATING_TASKS}.
* - the request already carries a key — typically a
* `$generate:uuid_v4#alias` the context injector has resolved to a
* concrete UUID for replay scenarios, or a BYOK key supplied inline.
*/
export function applyIdempotencyInvariant(
request: Record<string, unknown>,
taskName: string,
step: StoryboardStep
): Record<string, unknown> {
if (step.omit_idempotency_key === true) return request;
if (!isMutatingTask(taskName)) return request;
if (typeof request.idempotency_key === 'string' && request.idempotency_key.length > 0) return request;
return { ...request, idempotency_key: generateIdempotencyKey() };
}

// ────────────────────────────────────────────────────────────
// Helpers
// ────────────────────────────────────────────────────────────
Expand Down
12 changes: 12 additions & 0 deletions src/lib/testing/storyboard/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,18 @@ export interface StoryboardStep {
stateful?: boolean;
/** When true, the step passes if the task returns an error */
expect_error?: boolean;
/**
* When true, suppress the runner's `idempotency_key` auto-injection on a
* mutating step so the storyboard can exercise the server's missing-key
* rejection path. The runner also disables the SDK's client-side
* auto-inject for this step so the request reaches the wire without a key.
*
* Default (false) matches buyer-agent behavior: every mutating request
* carries a fresh UUID v4 so handlers under test run against the actual
* error path the storyboard names (GOVERNANCE_DENIED, UNAUTHORIZED, etc.)
* rather than short-circuiting on `INVALID_REQUEST: idempotency_key`.
*/
omit_idempotency_key?: boolean;
/** Tool name required for this step to run. Skipped if agent lacks it. */
requires_tool?: string;
/** Explicit context extraction rules (supplements convention-based extractors) */
Expand Down
46 changes: 46 additions & 0 deletions src/lib/utils/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,17 @@ export interface AdcpCapabilities {
/** Supported extension namespaces (e.g., 'scope3', 'garm') */
extensions: string[];

/**
* Experimental AdCP surfaces this agent implements. Dot-namespaced feature
* ids (e.g. `brand.rights_lifecycle`, `governance.campaign`, `trusted_match.core`)
* sellers declare when they opt into surfaces whose schemas carry
* `x-status: experimental`. Consumers should gate any reliance on
* experimental fields on presence of the matching id here.
*
* See https://adcontextprotocol.org/docs/reference/experimental-status
*/
experimentalFeatures?: string[];

/** Publisher domains covered by this agent */
publisherDomains?: string[];

Expand Down Expand Up @@ -393,6 +404,9 @@ export function parseCapabilitiesResponse(response: any): AdcpCapabilities {
creative,
idempotency,
extensions: response.extensions_supported ?? [],
experimentalFeatures: Array.isArray(response.experimental_features)
? response.experimental_features.filter((f: unknown): f is string => typeof f === 'string')
: undefined,
publisherDomains: response.media_buy?.portfolio?.publisher_domains,
channels: response.media_buy?.portfolio?.channels,
lastUpdated: response.last_updated,
Expand Down Expand Up @@ -450,6 +464,27 @@ export function supportsSandbox(capabilities: AdcpCapabilities): boolean {
return capabilities.account?.sandbox ?? false;
}

/**
* Check if the agent has opted into a specific experimental AdCP surface.
*
* Experimental surfaces carry `x-status: experimental` in the spec and their
* fields may change between minor releases. Consumers should gate any
* reliance on experimental fields on a positive check here.
*
* @param capabilities — normalized capabilities from `parseCapabilitiesResponse`
* @param featureId — dot-namespaced id (e.g. `brand.rights_lifecycle`)
*
* @example
* ```ts
* if (supportsExperimentalFeature(caps, 'brand.rights_lifecycle')) {
* // safe to call acquire_rights / release_rights and read their responses
* }
* ```
*/
export function supportsExperimentalFeature(capabilities: AdcpCapabilities, featureId: string): boolean {
return capabilities.experimentalFeatures?.includes(featureId) ?? false;
}

/**
* Feature name that can be checked via supports()/require().
*
Expand Down Expand Up @@ -567,6 +602,12 @@ export function resolveFeature(capabilities: AdcpCapabilities, feature: FeatureN
return capabilities.extensions.includes(extName);
}

// Experimental-surface check (e.g., 'experimental:brand.rights_lifecycle')
if (feature.startsWith('experimental:')) {
const featureId = feature.slice('experimental:'.length);
return supportsExperimentalFeature(capabilities, featureId);
}

// Targeting check (e.g., 'targeting.geo_countries')
if (feature.startsWith('targeting.')) {
const targetingKey = feature.slice(10);
Expand Down Expand Up @@ -614,6 +655,11 @@ export function listDeclaredFeatures(capabilities: AdcpCapabilities): string[] {
features.push(`ext:${ext}`);
}

// Experimental surfaces
for (const id of capabilities.experimentalFeatures ?? []) {
features.push(`experimental:${id}`);
}

// Targeting (from raw response)
const targeting = getRawNested(capabilities._raw, 'media_buy', 'execution', 'targeting');
if (targeting && typeof targeting === 'object') {
Expand Down
36 changes: 6 additions & 30 deletions src/lib/utils/request-normalizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,35 +6,9 @@
* deprecation warning via warnOnce().
*/

import { randomUUID } from 'crypto';

import { brandManifestToBrandReference, promotedProductsToCatalog } from '../types/compat';
import { warnOnce } from './deprecation';

/**
* Task types whose request schema requires `idempotency_key` per AdCP spec.
* When the caller omits one, the normalizer mints a fresh UUID — the spec
* requires a per-(seller, request) unique value and auto-generation gives
* that by construction for buyers that don't track their own keys.
*/
const TASKS_REQUIRING_IDEMPOTENCY_KEY: ReadonlySet<string> = new Set([
'activate_signal',
'calibrate_content',
'create_media_buy',
'delete_collection_list',
'delete_property_list',
'log_event',
'provide_performance_feedback',
'report_plan_outcome',
'report_usage',
'sync_accounts',
'sync_audiences',
'sync_catalogs',
'sync_creatives',
'sync_event_sources',
'sync_governance',
'sync_plans',
]);
import { MUTATING_TASKS, generateIdempotencyKey } from './idempotency';

/**
* Normalize a single package's params for backward compatibility.
Expand Down Expand Up @@ -95,16 +69,18 @@ export function normalizeRequestParams(

// ── idempotency_key auto-generation ──
// Tasks that mutate state require a caller-supplied idempotency_key per
// AdCP spec. When the caller omits it, mint a fresh UUID v4. Most buyer
// AdCP spec. When the caller omits one, mint a fresh UUID v4. Most buyer
// code never needs to track keys of its own — retries via a kept-around
// key are the less-common path, and those callers supply their own.
// `opts.skipIdempotencyAutoInject` disables this for compliance testing.
// MUTATING_TASKS is derived from the Zod request schemas at module load
// so this stays in sync with the upstream spec — no hand-maintained list.
if (
!opts.skipIdempotencyAutoInject &&
TASKS_REQUIRING_IDEMPOTENCY_KEY.has(taskType) &&
MUTATING_TASKS.has(taskType) &&
(typeof normalized.idempotency_key !== 'string' || normalized.idempotency_key.length === 0)
) {
normalized.idempotency_key = randomUUID();
normalized.idempotency_key = generateIdempotencyKey();
}

// ── Universal shims (all tools) ──
Expand Down
Loading
Loading