diff --git a/src/lib/server/create-adcp-server.ts b/src/lib/server/create-adcp-server.ts index 1ca16104b..2420be21e 100644 --- a/src/lib/server/create-adcp-server.ts +++ b/src/lib/server/create-adcp-server.ts @@ -2729,6 +2729,7 @@ function toProtocolTaskStatus(task: TaskRecord): GetTaskStatusResponse | undefin ? { completed_at: task.updatedAt } : {}), ...(task.hasWebhook !== undefined ? { has_webhook: task.hasWebhook === true } : {}), + ...(task.ext !== undefined ? { ext: task.ext } : {}), ...(progress !== undefined ? { progress } : {}), ...(task.status === 'rejected' && task.statusMessage !== undefined ? { message: task.statusMessage } : {}), ...(task.error !== undefined @@ -2762,6 +2763,8 @@ function toProtocolTaskListItem(task: TaskRecord): ListTasksResponse['tasks'][nu ? { completed_at: task.updatedAt } : {}), ...(task.hasWebhook !== undefined ? { has_webhook: task.hasWebhook === true } : {}), + // The list item schema is open (passthrough); `ext` is the spec's vendor slot. + ...(task.ext !== undefined ? ({ ext: task.ext } as Record) : {}), }; } diff --git a/src/lib/server/decisioning/async-outcome.ts b/src/lib/server/decisioning/async-outcome.ts index aa17005b4..da5b4cce4 100644 --- a/src/lib/server/decisioning/async-outcome.ts +++ b/src/lib/server/decisioning/async-outcome.ts @@ -221,6 +221,14 @@ export interface TaskHandoffOptions { * The caller is responsible for uniqueness within the account. */ task_id?: string; + /** + * Vendor-namespaced extension object echoed on the submitted envelope + * (`{ status: 'submitted', task_id, ext }`). The spec's submitted branch + * declares `ext`; keys MUST be namespaced under a vendor key (`ext.acme`), + * never a spec field such as `media_buy_id` — those belong on the terminal + * artifact. Not persisted; a registry decorator may re-attach it on reads. + */ + ext?: Record; } /** diff --git a/src/lib/server/decisioning/runtime/from-platform.ts b/src/lib/server/decisioning/runtime/from-platform.ts index eba8be755..ee615bf92 100644 --- a/src/lib/server/decisioning/runtime/from-platform.ts +++ b/src/lib/server/decisioning/runtime/from-platform.ts @@ -4089,6 +4089,7 @@ function buildTasksGetTool

>( logger.warn?.('Omitting unsafe stored task progress during tasks_get poll'); } } + if (record.ext !== undefined) payload.ext = record.ext; if ( args.include_result === true && (record.status === 'completed' || record.status === 'failed' || record.status === 'rejected') && @@ -4441,6 +4442,8 @@ function buildDefaultTaskRegistry(): TaskRegistry { type SubmittedEnvelope = { status: 'submitted'; task_id: string; + /** Adopter-supplied, vendor-namespaced extension (`TaskHandoffOptions.ext`). */ + ext?: Record; }; /** @@ -4812,7 +4815,8 @@ async function routeIfHandoff( await externalTaskFn(buildExternalHandoffContext(taskRegistry, taskRef, opts.servedAdcpVersion)); }, options.task_id, - 'external' + 'external', + options.ext ); } let handoffTaskStarted = false; @@ -4840,7 +4844,9 @@ async function routeIfHandoff( await lifecycle?.onHandoffSuccess?.(inner); return await project(inner); }, - options?.task_id + options?.task_id, + 'framework', + options?.ext ); } catch (error) { // Allocation/registration failures happen before the background task @@ -4860,7 +4866,8 @@ async function dispatchHitl( opts: DispatchHitlOpts, taskFn: (taskRef: ScopedTaskRef) => Promise, overrideTaskId?: string, - settlement: 'framework' | 'external' = 'framework' + settlement: 'framework' | 'external' = 'framework', + ext?: Record ): Promise { // Fail before task creation, external producer callbacks, or any terminal // state can be persisted. A buyer gets Submitted only after a validated @@ -5088,7 +5095,7 @@ async function dispatchHitl( })(); taskRegistry._registerBackground(taskId, taskRef, completion); - return { status: 'submitted', task_id: taskId }; + return { status: 'submitted', task_id: taskId, ...(ext !== undefined && { ext }) }; } /** diff --git a/src/lib/server/decisioning/runtime/task-registry.ts b/src/lib/server/decisioning/runtime/task-registry.ts index 66da046d8..6e6683164 100644 --- a/src/lib/server/decisioning/runtime/task-registry.ts +++ b/src/lib/server/decisioning/runtime/task-registry.ts @@ -144,6 +144,13 @@ export interface TaskRecord; /** * Whether the buyer wired `push_notification_config.url` and the dispatch * had an emitter capable of delivering it. Surfaced to the buyer via diff --git a/test/server-create-adcp-server.test.js b/test/server-create-adcp-server.test.js index 3a69c3012..4bf85754f 100644 --- a/test/server-create-adcp-server.test.js +++ b/test/server-create-adcp-server.test.js @@ -2989,6 +2989,39 @@ describe('createAdcpServer', () => { assert.strictEqual(listed.structuredContent.adcp_error.code, 'VALIDATION_ERROR'); }); + it('projects a decorating registry ext onto get_task_status and list_tasks items', async () => { + const inner = createInMemoryTaskRegistry(); + const held = await inner.create({ tool: 'create_media_buy', accountId: 'acct_1', ownerScope: 'api_key:buyer-1' }); + const ext = { acme: { media_buy_id: 'mb_42', campaign_link: 'https://acme.example/c/42' } }; + // A vendor decorator attaches the extension at read time; the built-in registry never stores it. + const taskRegistry = { + ...inner, + getTask: async (taskId, scope) => { + const record = await inner.getTask(taskId, scope); + return record && record.taskId === held.taskId ? { ...record, ext } : record; + }, + list: async opts => { + const listed = await inner.list(opts); + return { ...listed, tasks: listed.tasks.map(t => (t.taskId === held.taskId ? { ...t, ext } : t)) }; + }, + }; + const server = createAdcpServer({ + name: 'Test', + version: '1.0.0', + taskRegistry, + resolveAccountFromAuth: async () => ({ id: 'acct_1' }), + }); + const buyerOne = { authInfo: { credential: { kind: 'api_key', key_id: 'buyer-1' } } }; + + const status = await callTool(server, 'get_task_status', { task_id: held.taskId }, buyerOne); + assert.strictEqual(status.status, 'submitted'); + assert.deepStrictEqual(status.ext, ext); + + const listed = await callTool(server, 'list_tasks', {}, buyerOne); + assert.strictEqual(listed.tasks.length, 1); + assert.deepStrictEqual(listed.tasks[0].ext, ext); + }); + it('answers get_task_status/list_tasks from the scoped AdCP task registry only', async () => { const taskRegistry = createInMemoryTaskRegistry(); const owned = await taskRegistry.create({ diff --git a/test/server-decisioning-mock-seller.test.js b/test/server-decisioning-mock-seller.test.js index b7ea550a7..1a207d5cb 100644 --- a/test/server-decisioning-mock-seller.test.js +++ b/test/server-decisioning-mock-seller.test.js @@ -119,27 +119,30 @@ function makeSyncMockSeller({ floorCpm = 1.0 } = {}) { return platform; } -function makeHitlMockSeller({ floorCpm = 1.0, approvalDurationMs = 30 } = {}) { +function makeHitlMockSeller({ floorCpm = 1.0, approvalDurationMs = 30, ext } = {}) { const platform = basePlatformShape({ createMediaBuy: (req, ctx) => - ctx.handoffToTask(async () => { - const errors = preflight(req, { floorCpm }); - if (errors.length > 0) { - throw new AdcpError('INVALID_REQUEST', { - recovery: 'correctable', - message: errors[0].message, - field: errors[0].field, - details: { errors }, - }); - } - // Trafficker review window - await new Promise(r => setTimeout(r, approvalDurationMs)); - const buyId = `mb_${Date.now()}_${Math.floor(Math.random() * 1000)}`; - const totalBudget = typeof req.total_budget === 'number' ? req.total_budget : (req.total_budget?.amount ?? 0); - const buy = { media_buy_id: buyId, status: 'active', total_budget: totalBudget }; - platform.mediaBuys.set(buyId, buy); - return buy; - }), + ctx.handoffToTask( + async () => { + const errors = preflight(req, { floorCpm }); + if (errors.length > 0) { + throw new AdcpError('INVALID_REQUEST', { + recovery: 'correctable', + message: errors[0].message, + field: errors[0].field, + details: { errors }, + }); + } + // Trafficker review window + await new Promise(r => setTimeout(r, approvalDurationMs)); + const buyId = `mb_${Date.now()}_${Math.floor(Math.random() * 1000)}`; + const totalBudget = typeof req.total_budget === 'number' ? req.total_budget : (req.total_budget?.amount ?? 0); + const buy = { media_buy_id: buyId, status: 'active', total_budget: totalBudget }; + platform.mediaBuys.set(buyId, buy); + return buy; + }, + ext !== undefined ? { ext } : undefined + ), }); return platform; } @@ -226,6 +229,30 @@ describe('MockSeller worked example — unified hybrid shape', () => { assert.strictEqual(final.result.status, 'active'); }); + it('echoes the adopter ext on the submitted envelope, vendor-namespaced', async () => { + const ext = { acme: { media_buy_id: 'mb_pending_1', campaign_link: 'https://acme.example/c/1' } }; + const platform = makeHitlMockSeller({ approvalDurationMs: 30, ext }); + const server = buildServer(platform); + const result = await dispatchCreate(server, { total_budget: 100_000 }); + + assert.strictEqual(result.structuredContent.status, 'submitted'); + assert.deepStrictEqual(result.structuredContent.ext, ext); + assert.strictEqual(result.structuredContent.media_buy_id, undefined); + + await server.awaitTaskUnsafe(result.structuredContent.task_id); + }); + + it('omits ext from the submitted envelope when the adopter passed none', async () => { + const platform = makeHitlMockSeller({ approvalDurationMs: 30 }); + const server = buildServer(platform); + const result = await dispatchCreate(server, { total_budget: 100_000 }); + + assert.strictEqual(result.structuredContent.status, 'submitted'); + assert.ok(!('ext' in result.structuredContent)); + + await server.awaitTaskUnsafe(result.structuredContent.task_id); + }); + it('background AdcpError records terminal failed with structured fields', async () => { const platform = makeHitlMockSeller(); const server = buildServer(platform);