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
7 changes: 7 additions & 0 deletions .changeset/verify-deterministic-read-filters.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"adcontextprotocol": patch
---

Add behavioral conformance scenarios for media-buy status and ID filters,
half-open delivery date ranges, and sales-agent creative status and assignment
filters. Update the training agent to honor `list_creatives` media-buy filters.
22 changes: 18 additions & 4 deletions server/src/training-agent/comply-test-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1306,6 +1306,8 @@ export async function handleComplyTestController(args: ToolArgs, ctx: TrainingCo
const targetsControllerFixtureState = scenario === 'seed_product'
|| scenario === 'seed_pricing_option'
|| scenario === 'seed_measurement_catalog';
const targetsPublicTaskState = scenario === 'seed_media_buy'
|| scenario === 'seed_creative';
// The frozen 3.0 runner injects a synthetic natural account into controller
// and fixture calls, sometimes without copying its brand to the top level.
// Platform methods on that compatibility surface historically key by brand.
Expand All @@ -1320,7 +1322,8 @@ export async function handleComplyTestController(args: ToolArgs, ctx: TrainingCo
? args.account.account_id
: undefined;
let staticFixtureAccount: ToolArgs['account'] | undefined;
if (targetsControllerFixtureState && ctx.principal?.startsWith('static:') && args.account) {
let staticTaskAccount: ToolArgs['account'] | undefined;
if ((targetsControllerFixtureState || targetsPublicTaskState) && ctx.principal?.startsWith('static:') && args.account) {
try {
const canonical = canonicalizeAccountRef(args.account);
if (canonical.kind === 'natural' && canonical.sandbox) {
Expand All @@ -1329,11 +1332,20 @@ export async function handleComplyTestController(args: ToolArgs, ctx: TrainingCo
// task examples may name the buyer operator. Canonicalize only this
// fixture projection to the brand-owned sandbox partition; real
// principals keep the complete natural account identity.
staticFixtureAccount = {
const brandOwnedAccount = {
brand: canonical.brand,
operator: canonical.brand.domain,
sandbox: true,
};
if (targetsControllerFixtureState) {
staticFixtureAccount = { ...brandOwnedAccount, sandbox: true };
} else {
// The SDK strips the controller-only sandbox assertion before
// ordinary media-buy reads. Store public demo entity fixtures in
// that exact brand-owned task partition so seed_media_buy and
// seed_creative remain observable without projecting entity state
// across the isolated controller-fixture boundary.
staticTaskAccount = { ...brandOwnedAccount, sandbox: false };
}
}
} catch {
staticFixtureAccount = undefined;
Expand All @@ -1351,7 +1363,9 @@ export async function handleComplyTestController(args: ToolArgs, ctx: TrainingCo
? { ...args, account: { account_id: opaqueAccountId }, brand: undefined }
: staticFixtureAccount
? { ...args, account: staticFixtureAccount }
: args;
: staticTaskAccount
? { ...args, account: staticTaskAccount }
: args;
let sessionKey = targetsGetProductsState
? getProductsSessionKeyFromArgs(sessionArgs, ctx.mode, ctx.userId, ctx.moduleId)
: sessionKeyFromArgs(
Expand Down
24 changes: 20 additions & 4 deletions server/src/training-agent/task-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8753,8 +8753,10 @@ export async function handleGetMediaBuyDelivery(args: ToolArgs, ctx: TrainingCon

const { model: pricingModel, rate } = derivePricing(pkg, productMap);
const isRevenueShare = pricingModel === 'revenue_share';
const useScopedSimulation = simDelivery !== undefined
&& Boolean(req.start_date || req.end_date);
const budget = pkg.budget;
const spend = isRevenueShare
const spend = isRevenueShare || useScopedSimulation
? (simDelivery?.reportedSpend.amount ?? 0)
: Math.round(budget * elapsed * 100) / 100;

Expand All @@ -8770,12 +8772,14 @@ export async function handleGetMediaBuyDelivery(args: ToolArgs, ctx: TrainingCon
else if (channels?.some(c => ['print'].includes(c))) ctr = 0;
else ctr = 0.001;

const impressions = isRevenueShare
const impressions = isRevenueShare || useScopedSimulation
? (simDelivery?.impressions ?? 0)
: rate > 0 ? Math.round((spend / rate) * 1000) : 0;
const clicks = isRevenueShare ? (simDelivery?.clicks ?? 0) : Math.round(impressions * ctr);
const clicks = isRevenueShare || useScopedSimulation
? (simDelivery?.clicks ?? 0)
: Math.round(impressions * ctr);

if (!isRevenueShare) {
if (!isRevenueShare && !useScopedSimulation) {
totalImpressions += impressions;
totalSpend += spend;
totalClicks += clicks;
Expand Down Expand Up @@ -9299,6 +9303,7 @@ function accountRefsOverlap(stored: AccountRef | undefined, requested: AccountRe
type CreativeListFilters = {
creative_ids?: string[];
statuses?: string[];
media_buy_ids?: string[];
format_ids?: FormatID[];
asset_types?: string[];
};
Expand Down Expand Up @@ -9422,6 +9427,17 @@ export async function handleListCreatives(args: ToolArgs, ctx: TrainingContext)
const statuses = new Set(filters.statuses);
creatives = creatives.filter(c => statuses.has(c.status));
}
if (filters.media_buy_ids?.length) {
const requestedMediaBuyIds = new Set(filters.media_buy_ids);
const assignedCreativeIds = new Set<string>();
for (const mediaBuy of session.mediaBuys.values()) {
if (!requestedMediaBuyIds.has(mediaBuy.mediaBuyId)) continue;
for (const pkg of mediaBuy.packages) {
for (const creativeId of pkg.creativeAssignments) assignedCreativeIds.add(creativeId);
}
}
creatives = creatives.filter(c => assignedCreativeIds.has(c.creativeId));
}
const formatKinds = (req.filters as unknown as { format_kinds?: string[] } | undefined)?.format_kinds;
const filterProjectionAdapters = formatKinds?.length || filters.format_ids?.length
? creativeProjectionAdapters()
Expand Down
50 changes: 50 additions & 0 deletions server/tests/unit/comply-test-controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -701,6 +701,56 @@ describe('comply_test_controller', () => {
expect(found.some(b => b.media_buy_id === 'seeded_mb_1')).toBe(true);
});

it('keeps public controller entity seeds visible after the SDK strips sandbox', async () => {
const publicServer = createTrainingAgentServer({ mode: 'open', principal: 'static:public' });
const sandboxAccount = {
brand: { domain: 'public-seed.example' },
operator: 'public-seed.example',
sandbox: true,
};
const taskAccount = {
brand: sandboxAccount.brand,
operator: sandboxAccount.operator,
};

await simulateCallTool(publicServer, 'comply_test_controller', {
scenario: 'seed_creative',
account: sandboxAccount,
params: {
creative_id: 'public_seed_creative',
fixture: { status: 'approved', format_id: { id: 'display_300x250' } },
},
});
await simulateCallTool(publicServer, 'comply_test_controller', {
scenario: 'seed_media_buy',
account: sandboxAccount,
params: {
media_buy_id: 'public_seed_buy',
fixture: {
status: 'active',
packages: [{
package_id: 'public_seed_package',
creative_assignments: ['public_seed_creative'],
}],
},
},
});

const { result: buys } = await simulateCallTool(publicServer, 'get_media_buys', {
account: taskAccount,
media_buy_ids: ['public_seed_buy'],
});
expect((buys as any).media_buys.map((buy: any) => buy.media_buy_id)).toEqual(['public_seed_buy']);

const { result: creatives } = await simulateCallTool(publicServer, 'list_creatives', {
account: taskAccount,
filters: { media_buy_ids: ['public_seed_buy'] },
});
expect((creatives as any).creatives.map((creative: any) => creative.creative_id)).toEqual([
'public_seed_creative',
]);
});

it('seed_media_buy preserves available_actions and enforces non-self-serve mode mismatch', async () => {
const { result, isError } = await simulateCallTool(server, 'comply_test_controller', {
scenario: 'seed_media_buy',
Expand Down
59 changes: 59 additions & 0 deletions server/tests/unit/training-agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6909,6 +6909,65 @@ describe('list_creatives handler', () => {
expect(pg.total_count).toBe(1);
});

it('filters creatives by status and media buy assignment with AND semantics', async () => {
const account = {
brand: { domain: 'creative-read-filters.example' },
operator: 'creative-read-filters.example',
sandbox: true,
};
const server = createTrainingAgentServer(DEFAULT_CTX);

for (const [creativeId, status] of [
['cr_filter_match', 'rejected'],
['cr_filter_wrong_status', 'approved'],
['cr_filter_wrong_buy', 'rejected'],
] as const) {
await simulateCallTool(server, 'comply_test_controller', {
account,
scenario: 'seed_creative',
params: {
creative_id: creativeId,
fixture: { status, format_kind: 'image' },
},
});
}

for (const [mediaBuyId, creativeAssignments] of [
['mb_filter_target', ['cr_filter_match', 'cr_filter_wrong_status']],
['mb_filter_other', ['cr_filter_wrong_buy']],
] as const) {
await simulateCallTool(server, 'comply_test_controller', {
account,
scenario: 'seed_media_buy',
params: {
media_buy_id: mediaBuyId,
fixture: {
status: 'active',
packages: [{
package_id: `${mediaBuyId}_package`,
creative_assignments: creativeAssignments,
}],
},
},
});
}

const { result } = await simulateCallTool(server, 'list_creatives', {
account,
adcp_version: '3.1',
ext: { adcp: { creative_wire: 'legacy' } },
filters: {
statuses: ['rejected'],
media_buy_ids: ['mb_filter_target'],
},
});

expect((result.creatives as Array<{ creative_id: string; status: string }>)).toEqual([
expect.objectContaining({ creative_id: 'cr_filter_match', status: 'rejected' }),
]);
expect(result.query_summary).toEqual({ total_matching: 1, returned: 1 });
});

it('filters by top-level asset type and composes with format_ids', async () => {
const account = { brand: { domain: 'assetfilters.example' }, operator: 'assetfilters.example' };
const server = createTrainingAgentServer(DEFAULT_CTX);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
id: media_buy_seller/list_creatives_filter_behavior
version: "1.0.0"
introduced_in: "3.2"
title: "Sales agent applies deterministic creative filters"
category: media_buy_seller
summary: "Verifies a sales agent applies creative-status and media-buy-assignment filters conjunctively."
track: creative

required_tools:
- list_creatives
- comply_test_controller

narrative: |
A sales agent may expose its creative library through list_creatives, where
both approval status and media-buy assignment are deterministic predicates.
The controller seeds one matching creative and two independent negative
controls so ignoring either accepted filter produces an observable superset.

agent:
interaction_model: media_buy_seller
capabilities:
- sells_media
- has_creative_library
examples:
- "Publishers and SSPs that manage creative assignments for media buys"

caller:
role: buyer_agent
example: "Pinnacle Agency (buyer)"

prerequisites:
description: |
Three creatives are seeded across two media buys: a rejected target, an
approved status negative control on the target buy, and a rejected
assignment negative control on another buy.
test_kit: "test-kits/acme-outdoor.yaml"
controller_seeding: true

fixtures:
creatives:
- creative_id: "creative_filter_match"
name: "Rejected creative on requested buy"
status: "rejected"
format_id:
id: "display_300x250"
- creative_id: "creative_filter_wrong_status"
name: "Approved creative on requested buy"
status: "approved"
format_id:
id: "display_300x250"
- creative_id: "creative_filter_wrong_buy"
name: "Rejected creative on another buy"
status: "rejected"
format_id:
id: "display_300x250"
media_buys:
- media_buy_id: "creative_filter_target_buy"
account: &seed_account
brand:
domain: "acmeoutdoor.example"
operator: "acmeoutdoor.example"
sandbox: true
status: "active"
currency: "USD"
start_time: "2026-01-01T00:00:00Z"
end_time: "2099-12-31T00:00:00Z"
packages:
- package_id: "creative_filter_target_package"
product_id: "creative_filter_product"
pricing_option_id: "creative_filter_pricing"
budget: 1000
creative_assignments:
- "creative_filter_match"
- "creative_filter_wrong_status"
- media_buy_id: "creative_filter_other_buy"
account: *seed_account
status: "active"
currency: "USD"
start_time: "2026-01-01T00:00:00Z"
end_time: "2099-12-31T00:00:00Z"
packages:
- package_id: "creative_filter_other_package"
product_id: "creative_filter_product"
pricing_option_id: "creative_filter_pricing"
budget: 1000
creative_assignments: ["creative_filter_wrong_buy"]

phases:
- id: creative_membership
title: "Apply status and assignment filters conjunctively"
steps:
- id: list_filtered_creatives
title: "Exclude both independent negative controls"
task: list_creatives
schema_ref: "creative/list-creatives-request.json"
response_schema_ref: "creative/list-creatives-response.json"
doc_ref: "/creative/task-reference/list_creatives"
stateful: true
sample_request:
account:
brand:
domain: "acmeoutdoor.example"
operator: "acmeoutdoor.example"
filters:
statuses: ["rejected"]
media_buy_ids: ["creative_filter_target_buy"]
context:
correlation_id: "list_creatives_filter_behavior--combined"
expected: |
Return only the rejected creative assigned to the requested buy.
The approved creative catches a status no-op and the rejected
creative on the other buy catches an assignment-filter no-op.
validations:
- check: response_schema
description: "Response matches list-creatives-response.json"
- check: field_value
path: "creatives[0].creative_id"
value: "creative_filter_match"
description: "DR-0001 / list_creatives filter contract: the returned creative is assigned to the requested buy"
- check: field_value
path: "creatives[0].status"
value: "rejected"
description: "DR-0001 / list_creatives filter contract: the returned creative satisfies statuses"
- check: field_absent
path: "creatives[1]"
description: "Both independent negative-control creatives are excluded"
2 changes: 2 additions & 0 deletions static/compliance/source/protocols/media-buy/index.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ requires_scenarios:
- media_buy_seller/product_signal_targeting
- media_buy_seller/demographic_targeting
- media_buy_seller/product_filter_behavior
- media_buy_seller/read_filter_behavior
- media_buy_seller/list_creatives_filter_behavior
- media_buy_seller/targeting_aware_discovery
- media_buy_seller/geo_place_targeting
- media_buy_seller/available_actions
Expand Down
Loading
Loading