fix: handle AdCP advisory errors by task#2020
Conversation
There was a problem hiding this comment.
LGTM. The advisory-vs-terminal split is the right shape: status: failed | rejected and success: false are the authoritative terminal signals, with the per-tool field-group table acting as the positive Success-arm witness for the errors[]-on-Success case AdCP 3.1 introduced. Pure classifier, no fabrication, no shape rewriting — the witness-not-translator invariant holds.
code-reviewer and ad-tech-protocol-expert both come back sound-with-caveats; the caveats are follow-ups, not blocks.
Things I checked
- Field-group table at
src/lib/utils/response-unwrapper.ts:97-158againstTOOL_RESPONSE_SCHEMAS(response-schemas.ts) — 58 of 59 tools covered; spot-checks againsttools.generated.tsconfirmcreate_media_buy [media_buy_id, packages],report_usage [accepted],update_content_standards [success, standards_id],delete_property_list [deleted, list_id],get_products [products | unchanged]all line up with the spec's Success arms. - Short-circuit ordering at
isTerminalAdcpError(L190-198) —status: failed | rejectedwins over field-group evidence, thenadcp_error.code, thenerrors[]defers tohasAdvisorySuccessPayload. Correct precedence for the four call sites (TaskExecutor L735, L869, L1023, L1509 + unwrapper L256). submitted + task_idbranch at L179 is toolName-agnostic — safe, mirrors the universal AdCP envelope contract.isAdcpErrorkept as the structural helper for back-compat; JSDoc now points callers toisTerminalAdcpErrorfor AdCP 3.1.- A2A synthetic-failure envelope at L628 already emits
status: failed, so MCP and A2A converge on the same terminal classifier. - Tests cover advisory-success polled completion, terminal payload (
status: failed), envelope-onlyerrors[](no Success evidence → terminal), submitted + advisoryerrors[], vendor_metricoptimization_goalsround-trip, andcreate_media_buymissingmedia_buy_idfallback.
Follow-ups (non-blocking — file as issues)
get_content_standardsis missing fromSUCCESS_PAYLOAD_FIELD_GROUPS_BY_TOOL. Registered inTOOL_RESPONSE_SCHEMAS(response-schemas.ts:83) withstandards_idrequired on Success. Addget_content_standards: [['standards_id']]. Not a regression (previous behavior also misclassified), but the only spec-registered tool the table misses.taskName === 'unknown'defeats advisory-success detection on the poll path.mapTasksGetResponseToTaskInfo(TaskExecutor L104, L127) defaultstaskTypeto'unknown'when the seller'stasks/getenvelope omitstask_type. ThepollTaskCompletioncall at L1509 hands that toisOperationSuccess, which threads'unknown'intohasAdvisorySuccessPayload— no entry → returns false → advisoryerrors[]becomes terminal.executeTaskalready knows the originating tool name; thread it through topollTaskCompletionso polled completions inherit the same classification as inline completions.report_usage: [['accepted']]is field-presence, not value-predicate. A 3.0.x seller emitting{ accepted: 0, errors: [...all rows rejected...] }without envelopestatus(3.0.x didn't require it) hits advisory-success → SDK reports completed. Previous behavior correctly reported failure for this shape. 3.1.0-beta.2+ sellers carrystatus: failedso the short-circuit at L176 catches them; this is a 3.0.x back-compat edge case. Either requireaccepted > 0(needs a value predicate, not just field presence) or document the limitation.- No changeset. CLAUDE.md is unambiguous that any
src/lib/change requires one, and this PR adds two new public exports (isTerminalAdcpError,hasAdvisorySuccessPayload) and changes the behavior ofisAdcpSuccess. CI'sCheck for changesetpassed because pre-mode counts the existing.changeset/pre.jsonentries — the gate isn't actually per-PR in pre mode. Runnpm run changeset(minor — new exports + bug-fix behavior change) before merge so the next beta release notes name the fix.
Minor nits (non-blocking)
submittedbranch accepts bothtask_idandtaskId.src/lib/utils/response-unwrapper.ts:179. AdCP wire is snake_case only; the camelCase fallback silently tolerates non-conformant sellers. Either drop it or add a comment explaining which adopter shape forced it.isAdcpErrorcould be@deprecated.response-unwrapper.ts:698. JSDoc points callers toisTerminalAdcpErrorbut TS won't warn. The remaining direct caller at TaskExecutor L1028 is fine (it's checking iterability for message extraction), but new code should not reach for the structural helper.- Table has no section comments.
response-unwrapper.ts:97.TOOL_RESPONSE_SCHEMASgroups by domain (// Product discovery,// Creative, etc.); mirroring that here makes the next addition easier to audit against the schema map and would have caught theget_content_standardsgap.
Notable that the [codex] PR shipped the test plan and root-cause section meticulously but skipped step 1 of the publishing flow — worth a changeset before merge.
Approving on the strength of the classifier shape plus the regression coverage. Land the changeset and the get_content_standards entry in the same follow-up.
There was a problem hiding this comment.
Right shape on the architectural question — AdCP 3.1 advisory errors[] on Success/Submitted arms needed task-aware terminal detection, and routing through isTerminalAdcpError(toolName) while keeping isAdcpError structural is the right witness-not-translator move. Two named coverage gaps before merge, plus follow-ups.
Things I checked
isOperationSuccessthread-through at all three call sites (TaskExecutor.ts:735,:869,:1519). Polling path passesstatus.taskType.extractOperationError(TaskExecutor.ts:1028) correctly keeps the structuralisAdcpErrorfor message extraction — it only runs after the terminal classification already failed.unwrapProtocolResponseearly-exit atresponse-unwrapper.ts:256now lets advisory-success payloads flow through schema validation instead of short-circuiting. The Zod schemas inresponse-schemas.tsmodelerrors[]as co-optional with success fields (e.g.GetProductsResponseStrictSchema), so the validation still passes — confirmed by the newresponse-unwrapper.test.jsadvisory-success case.'submitted' + task_iddiscriminator matches the wire —test/fixtures/create_media_buy_async_submitted.yaml:149-151pins snake_casetask_idas the AdCP-payload-layer field.- Witness-not-translator preserved:
errors[]flows through untouched, only the terminal-vs-advisory classification changed. No fabrication.
MUST FIX (one-line touches; safe to land in this PR)
get_content_standardsis missing fromSUCCESS_PAYLOAD_FIELD_GROUPS_BY_TOOL.response-unwrapper.ts:97-158lists every other tool fromTOOL_RESPONSE_SCHEMASbut skipsget_content_standards, which is present atresponse-schemas.ts:83and whose Success arm requiresstandards_id(schemas.generated.ts:6691-6709). Without an entry, aget_content_standardsresponse with(status: 'completed', standards_id: '...', errors: [advisory])falls throughhasAdvisorySuccessPayload, theerrors[]branch trips, and the SDK treats advisory as terminal — the exact bug this PR exists to fix, leaking through for one tool. Addget_content_standards: [['standards_id']].status: 'canceled'is not in the terminal-status check.response-unwrapper.ts:176and:192checkfailedandrejectedbut notcanceled.TaskStatusSchema(schemas.generated.ts:1274) listscanceledas a terminal envelope state alongside the other two. The envelope-level path inTaskExecutor.ts:822handlescanceledcorrectly so this doesn't bite in the common A2A flow, but a structured-content payload that surfacesstatus: 'canceled'with a matching field-group will currently be misclassified as advisory success. Add'canceled'to both checks for symmetry with the rest of the SDK's terminal handling.
Follow-ups (non-blocking — file as issues)
- The hand-maintained field-groups table will drift.
idempotency.ts:26derivesMUTATING_TASKSfrom the generated schemas at module-load — same problem, same solution. Most response schemas inschemas.generated.tseither compose.and(z.union([Success, Error]))(Success-arm required keys = field-group) or flatten everything optional with tool-specific required fields. Worth a follow-up to walkTOOL_RESPONSE_SCHEMASand extract Success-arm required keys with a small override map for the flattened-shape tools. At minimum: a unit test that assertsObject.keys(SUCCESS_PAYLOAD_FIELD_GROUPS_BY_TOOL) ⊇ Object.keys(TOOL_RESPONSE_SCHEMAS)would have caught theget_content_standardsmiss automatically. - Polling path defeats the fix when
taskTypeis'unknown'.TaskExecutor.ts:104/:127normalize a missingtask_type/taskTypeontasks/getto the literal string'unknown'.isOperationSuccess(status.result, 'unknown')then hits no field-group entry and falls back to terminal-on-any-errors[]. Pre-PR baseline behavior, so not a regression — but the fix silently doesn't apply on the polling path for any seller that omitstask_type. Either document the requirement or thread the originaltaskNamethrough the polling loop. cancel_media_buyis a real tool (server/create-adcp-server.ts:2758) but isn't inTOOL_RESPONSE_SCHEMASor the field-groups table. If/when it lands in schemas, add the entry too.
Minor nits (non-blocking)
- Changeset prose oversells the vendor_metric line.
.changeset/advisory-errors-vendor-metrics.mdreads "Accept AdCP 3.1vendor_metricoptimization goals increate_media_buyvalidation" as if a fix shipped — but the source diff has zerovendor_metriccontent. The only addition is the regression test attest/lib/request-validation.test.js:279-365, which verifies pre-existing schema behavior. Rewrite the prose to make clear this is regression coverage of existing acceptance, or drop the half-sentence entirely. Adopters scanning the changelog for the vendor-metric fix will land here and assume a code change shipped. taskId(camelCase) on the AdCP payload atresponse-unwrapper.ts:179is speculative. The wire is snake_casetask_id; the camelCase form is internal-only afterTaskExecutor.ts:125normalization, which runs after this helper. Either drop thetaskIdcheck or comment that it's a defensive belt-and-braces guard for legacy fixtures.validate_inputin field-groups but not inTOOL_RESPONSE_SCHEMAS. One-line comment that the entry is forward-looking would save the next reader from "cleaning it up."- Changeset type is
patchbut the PR adds two new public exports (isTerminalAdcpError,hasAdvisorySuccessPayload). New exports are conventionallyminor. Non-blocking — repo precedent on bug-fix-adjacent helpers may differ — but worth a glance before the Release PR lands.
LGTM after the get_content_standards and canceled additions land. Both are one-line touches; safe to push into this PR rather than a follow-up.
23e0a53 to
281a6e0
Compare
Pull request was closed
Summary
Root Cause
The SDK used structural errors[] detection as terminal failure detection. In AdCP 3.1, success/submitted payloads can carry advisory errors[], so callers need task-aware success evidence before treating errors[] as fatal. The fix keeps isAdcpError() structural for compatibility and routes operation success through isTerminalAdcpError(toolName).
Validation
Expert Review
Fixes #2013
Fixes #2014