Skip to content
Draft
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
154 commits
Select commit Hold shift + click to select a range
5657f80
fix: A2A top-level failures return failed Task with AdCP envelope, no…
numarasSigmaSoftware Jul 6, 2026
8d78cb7
fix: Cover A2A failed task routing
numarasSigmaSoftware Jul 9, 2026
8fca519
Merge branch 'prebid:main' into pr/a2a-error-routing
numarasSigmaSoftware Jul 9, 2026
e6ef756
test: address non-blocking review follow-ups on A2A error routing
numarasSigmaSoftware Jul 9, 2026
7d540d0
refactor: share the failed-Task envelope reader across test altitudes
numarasSigmaSoftware Jul 10, 2026
a5248fe
test: harden the shared failed-Task reader pins
numarasSigmaSoftware Jul 10, 2026
c51e76c
test: address round-3 review — BDD merge durability, helper DRY, read…
numarasSigmaSoftware Jul 13, 2026
51d7b33
Merge remote-tracking branch 'upstream/main' into pr/a2a-error-routing
numarasSigmaSoftware Jul 13, 2026
4a5ee33
test: fold in round-4 review polish (naming, guard hardening, spec co…
numarasSigmaSoftware Jul 13, 2026
1bc4185
fix: route A2A skill failures to failed Tasks; drop redundant failure…
numarasSigmaSoftware Jul 13, 2026
acfe455
fix: no webhook for immediate terminal A2A tasks; preserve mixed-batc…
numarasSigmaSoftware Jul 13, 2026
8ae774c
fix: preserve duplicate-named artifacts in A2A webhook payload
numarasSigmaSoftware Jul 13, 2026
a5132c9
fix: reject multi-skill batches; sanitize untyped crashes; harden A2A…
numarasSigmaSoftware Jul 13, 2026
fe4c23d
merge: upstream/main into pr/a2a-error-routing
numarasSigmaSoftware Jul 14, 2026
5a23a65
fix: close explicit-skill secret leak + orphan Task; de-advertise stu…
numarasSigmaSoftware Jul 14, 2026
176a5d4
fix: sanitize typed internal-error leaks + correlate A2A task id to w…
numarasSigmaSoftware Jul 14, 2026
0c10422
fix: durable tasks/cancel + one webhook per workflow status change
numarasSigmaSoftware Jul 14, 2026
d037cc0
merge: upstream/main into pr/a2a-error-routing (adcp 6.6.0, #1417/#1430)
numarasSigmaSoftware Jul 14, 2026
ee13a6d
fix: close round-12 blockers — JSON-RPC message scrub, principal-scop…
numarasSigmaSoftware Jul 15, 2026
ea44fe7
fix: close round-13 blockers — in-memory principal isolation + two-si…
numarasSigmaSoftware Jul 15, 2026
e9c2514
fix: round-14 B1+B2 — atomic update_workflow_step + callers honor ref…
numarasSigmaSoftware Jul 15, 2026
2428449
fix: round-15 — admin writers atomic, cancellable-state policy (B3), …
numarasSigmaSoftware Jul 15, 2026
daacade
docs: correct _durable_cancel_step docstring to cancel_if_cancellable…
numarasSigmaSoftware Jul 15, 2026
c806db2
fix: exclude in_progress from cancellable workflow-step statuses
numarasSigmaSoftware Jul 15, 2026
87b5141
fix: source-state-guarded compare-and-set for workflow approve/reject
numarasSigmaSoftware Jul 15, 2026
a764510
fix: media-buy detail route selects approvable steps via the canonica…
numarasSigmaSoftware Jul 16, 2026
f300986
test: render-grade the media-buy detail approval UI for legacy approv…
numarasSigmaSoftware Jul 16, 2026
4400d1b
fix: harden task auth and approval selection
numarasSigmaSoftware Jul 16, 2026
b466601
fix: scrub internal-error messages on the webhook path via one shared…
numarasSigmaSoftware Jul 16, 2026
0f75f1f
refactor: drop dead cancel_if_nonterminal; make legacy approval cance…
numarasSigmaSoftware Jul 16, 2026
34fe71f
test: break auth-boundary tautology + add merge-durability self-test
numarasSigmaSoftware Jul 16, 2026
cd7c087
refactor: fold in review minors (DRY durable preamble, type hints, as…
numarasSigmaSoftware Jul 16, 2026
75fdf24
fix: sanitized suggestion must match recovery — terminal errors must …
numarasSigmaSoftware Jul 16, 2026
6c07d8b
fix: normalize sanitized internal-error recovery to canonical + 3-way…
numarasSigmaSoftware Jul 16, 2026
5b5d799
fix: scrub untyped ValueError/PermissionError on the webhook audit path
numarasSigmaSoftware Jul 16, 2026
dc59b0f
fix: separate exception provenance from semantic code in the shared s…
numarasSigmaSoftware Jul 16, 2026
8e3f84c
fix: category-specific sanitized message + suggestion so text matches…
numarasSigmaSoftware Jul 16, 2026
75fc3d0
fix: enforce sanitized-category registry completeness vs built-in nor…
numarasSigmaSoftware Jul 17, 2026
1b75743
Merge remote-tracking branch 'upstream/main' into pr/a2a-error-routing
numarasSigmaSoftware Jul 17, 2026
17d774c
fix: unify every raw-exception normalizer into one introspectable reg…
numarasSigmaSoftware Jul 17, 2026
6f287d3
fix: derive normalizer target from the factory, not a separate declar…
numarasSigmaSoftware Jul 17, 2026
2c37087
fix: pin exception normalizer semantics
numarasSigmaSoftware Jul 17, 2026
e3bf24e
fix: correlate admin webhooks to buyer task IDs
numarasSigmaSoftware Jul 18, 2026
e29a490
chore: retrigger CI
numarasSigmaSoftware Jul 19, 2026
5e99d68
fix: review remediation — pollable failed A2A Task + scrub-family con…
numarasSigmaSoftware Jul 21, 2026
5bf1821
Merge remote-tracking branch 'upstream/main' into pr/a2a-error-routing
numarasSigmaSoftware Jul 21, 2026
832aaba
test: drive on_get_task in the failed-Task pollability regression; st…
numarasSigmaSoftware Jul 21, 2026
41cdfae
refactor: route the last stragglers through their shared handles (A2A…
numarasSigmaSoftware Jul 22, 2026
bd0545f
Merge remote-tracking branch 'upstream/main' into pr/a2a-error-routing
numarasSigmaSoftware Jul 22, 2026
b1b2076
fix: sanitize untyped failures at the tasks/get and tasks/cancel boun…
numarasSigmaSoftware Jul 22, 2026
c8ca35a
test: one strict failed-Task reader; recovery required on the shared …
numarasSigmaSoftware Jul 22, 2026
78ba8c4
test: route every hand-rolled secret check through the shared leak or…
numarasSigmaSoftware Jul 22, 2026
5d266a8
fix: ground sanitized suggestions in the pinned spec enum
numarasSigmaSoftware Jul 22, 2026
6e08548
refactor: one sanitize composition, no dead machinery in on_message_send
numarasSigmaSoftware Jul 22, 2026
34d3c3a
refactor: one name for the terminal-guarded workflow transition
numarasSigmaSoftware Jul 22, 2026
c55c904
fix: policy-route tenant gate + step-type filter; unknown-action not …
numarasSigmaSoftware Jul 22, 2026
8284a90
test: enforce the A2A external_task_id forwarding (guard + behavioral)
numarasSigmaSoftware Jul 22, 2026
c005712
test: merge-durability guard catches empty/unmodeled refs; tighten co…
numarasSigmaSoftware Jul 22, 2026
269ea52
refactor: strip cross-PR back-references; harden two A2A wire nits
numarasSigmaSoftware Jul 22, 2026
4395a92
chore: formatting, lint, and durable-test docstring accuracy
numarasSigmaSoftware Jul 22, 2026
32e7aa9
test: read the C3 forwarding assertion via the repository, not a raw …
numarasSigmaSoftware Jul 22, 2026
806e52f
test: close the last oracle bypasses and pin the readers that grade them
numarasSigmaSoftware Jul 23, 2026
cfdeb1e
fix: one error framing for a failed Task, polled or synchronous
numarasSigmaSoftware Jul 23, 2026
99f4ff8
refactor: one reading of the acting user; canonical approvable set on…
numarasSigmaSoftware Jul 23, 2026
c0daf64
docs: correct claims that outran the code
numarasSigmaSoftware Jul 23, 2026
98ace2d
refactor: route the admin policy read and the refused-claim redirect …
numarasSigmaSoftware Jul 23, 2026
090bbe6
test: one home for the failed-Task client-facing scrub surface
numarasSigmaSoftware Jul 23, 2026
b13146e
docs: restore whitespace in prose comments; drop unresolvable local t…
numarasSigmaSoftware Jul 23, 2026
48989f7
fix: every A2A auth failure carries the AUTH_REQUIRED envelope, from …
numarasSigmaSoftware Jul 23, 2026
86edccc
test: the secret-leak oracle now defends itself
numarasSigmaSoftware Jul 23, 2026
d7abf17
refactor: shared failed-artifact locator, hoisted import, positive co…
numarasSigmaSoftware Jul 23, 2026
19bb1a8
fix: one enveloped source for every A2A rejection, graded arm by arm
numarasSigmaSoftware Jul 23, 2026
0a6c467
test: the secret-oracle self-test no longer grades itself
numarasSigmaSoftware Jul 23, 2026
e0e40f4
test: dedupe the approval setup; widen and anchor the repository guard
numarasSigmaSoftware Jul 23, 2026
916688e
test: pin the auth envelope's message/suggestion; dedupe the last app…
numarasSigmaSoftware Jul 24, 2026
8350ba3
test: pin the AUTH_REQUIRED envelope on the real JSON-RPC wire, not j…
numarasSigmaSoftware Jul 24, 2026
33bb1f2
fix: dedupe the A2A boundary-error arm; tighten CAS guard typing; fix…
numarasSigmaSoftware Jul 24, 2026
47d6c76
Merge upstream/main into pr/a2a-error-routing
numarasSigmaSoftware Jul 24, 2026
c945a39
test: update isolation tests for the merged TaskNotFoundError behavior
numarasSigmaSoftware Jul 24, 2026
b031a17
test: make webhook dedup oracle deterministic
numarasSigmaSoftware Jul 24, 2026
ee8743b
fix: record outer A2A task failures
numarasSigmaSoftware Jul 25, 2026
63cbf33
test: close PR 1547 review gaps
numarasSigmaSoftware Jul 25, 2026
22c1c72
fix: distinguish A2A authentication failures
numarasSigmaSoftware Jul 25, 2026
95d6065
fix: classify malformed A2A credentials
numarasSigmaSoftware Jul 25, 2026
33b8fea
fix: close A2A auth review gaps
numarasSigmaSoftware Jul 25, 2026
e771533
fix: harden cross-transport error boundaries
numarasSigmaSoftware Jul 25, 2026
8f11cdc
fix: close validation and approval consistency gaps
numarasSigmaSoftware Jul 25, 2026
5b41082
fix: close validation and approval consistency gaps
numarasSigmaSoftware Jul 27, 2026
61093b1
fix: repair PR 1547 CI contracts
numarasSigmaSoftware Jul 27, 2026
dc1dc97
fix: close remaining PR 1547 review gaps
numarasSigmaSoftware Jul 28, 2026
a2bdd62
fix: align approval webhook fixture with catalog
numarasSigmaSoftware Jul 28, 2026
3e52582
test: align creative format mocks with protocol types
numarasSigmaSoftware Jul 28, 2026
27803bf
fix: preserve sanitized date validation contract
numarasSigmaSoftware Jul 28, 2026
a01544f
refactor: single-source the sanitized scrub text selection
numarasSigmaSoftware Jul 28, 2026
5af3f4f
fix: one adapter-agnostic message for the waiting-for-creatives outcome
numarasSigmaSoftware Jul 28, 2026
540dd0a
refactor: type the two new signatures this branch left bare
numarasSigmaSoftware Jul 28, 2026
7d7e8ca
fix: log the original exception at the REST boundary, not its sanitiz…
numarasSigmaSoftware Jul 28, 2026
335fe38
test: grade the untyped-crash arm the top-level-failure locks were na…
numarasSigmaSoftware Jul 28, 2026
f047bd6
test: expose the NL A2A wire envelope as a TransportResult
numarasSigmaSoftware Jul 28, 2026
685ed0c
fix: frame NL client-correctable failures as failed Tasks, not Intern…
numarasSigmaSoftware Jul 28, 2026
d91fc38
refactor: route the push-config handlers through the shared boundary arm
numarasSigmaSoftware Jul 28, 2026
a0a6562
fix: make the pinned-schema provenance a checkable claim, not a label
numarasSigmaSoftware Jul 28, 2026
ce45f9a
docs: name the internal-bucket suggestion deviation and why it stands
numarasSigmaSoftware Jul 28, 2026
e7ec1cb
test: stop the wire-message containment step from also checking absence
numarasSigmaSoftware Jul 29, 2026
bf48d5c
test: prove rejected credentials never reach natural-key resolution
numarasSigmaSoftware Jul 29, 2026
c108982
docs: ground the buyer-value-echo opt-in in the AdCP spec, not just p…
numarasSigmaSoftware Jul 29, 2026
c6c92b8
fix: break the circular oracle in assert_sanitized_wire_error
numarasSigmaSoftware Jul 29, 2026
2985cf6
test: exercise the buyer-value-echo opt-in through the real wire, clo…
numarasSigmaSoftware Jul 29, 2026
fe19e95
test: assert no system secret rides alongside the echoed buyer value
numarasSigmaSoftware Jul 29, 2026
d209eb0
fix: skip the natural-key lookup spy on e2e transports, not just repo…
numarasSigmaSoftware Jul 29, 2026
5e53f0c
fix: single-source the PENDING_RECONCILIATION operator message
numarasSigmaSoftware Jul 29, 2026
841ced9
refactor: type four helpers to the concrete type already used a few l…
numarasSigmaSoftware Jul 29, 2026
21001c5
docs: ground list_accounts auth-required in the spec, not a rule this…
numarasSigmaSoftware Jul 29, 2026
c4e2506
merge: pull in datamodel-code-generator CVE fix from upstream/main
numarasSigmaSoftware Jul 29, 2026
1dd5280
merge: resolve #1697 SSRF collision, keeping this branch's webhook-sc…
numarasSigmaSoftware Jul 30, 2026
9deda75
fix: honor the dev callback seam at webhook registration, not only de…
numarasSigmaSoftware Jul 30, 2026
4889c1a
test: stop patching the SSRF gate the comment says not to patch; drop…
numarasSigmaSoftware Jul 30, 2026
13483b2
fix: let audited static validation messages reach the buyer
numarasSigmaSoftware Jul 30, 2026
399bb3c
fix: register and deliver webhooks under one HTTPS policy
numarasSigmaSoftware Jul 30, 2026
97a6976
fix: tell the operator to assign creatives instead of "waiting for 0"
numarasSigmaSoftware Jul 30, 2026
6756c3e
test: give _atomic_transition's tenant predicate a failing oracle
numarasSigmaSoftware Jul 30, 2026
5abd6af
test: dispatch the NL A2A step through the shared harness seam
numarasSigmaSoftware Jul 30, 2026
a436d04
docs: correct the AUTH_OPTIONAL_SKILLS grounding to what the spec says
numarasSigmaSoftware Jul 30, 2026
ee70d23
fix: put the callback HTTPS rule where a testing flag cannot reach it
numarasSigmaSoftware Jul 30, 2026
edb898b
fix: keep the buyer's suggestion when a raise site opts its message in
numarasSigmaSoftware Jul 30, 2026
2dfe3d7
fix: say where the two-layer envelope actually reaches the buyer
numarasSigmaSoftware Jul 30, 2026
562364e
test: grade two behaviours that were prose-only
numarasSigmaSoftware Jul 30, 2026
2b59b0d
test: make the auth non-disclosure check see the leak it names
numarasSigmaSoftware Jul 30, 2026
cf05bb4
test: stop the SSRF host tests short-circuiting on scheme
numarasSigmaSoftware Jul 30, 2026
3ab463a
fix: keep the non-disclosure step — it was tested, not dead
numarasSigmaSoftware Jul 30, 2026
c3b6a10
fix: require HTTPS for callbacks unless development is declared expli…
numarasSigmaSoftware Jul 31, 2026
e08193d
fix: one scheme policy across all three webhook delivery sinks
numarasSigmaSoftware Jul 31, 2026
97ec54f
test: restore the discriminator the suggestion-parity suite grades
numarasSigmaSoftware Jul 31, 2026
649ed61
test: make the opt-in guard check the direction that can leak
numarasSigmaSoftware Jul 31, 2026
8cceed3
test: match the re-inline guard on the clause that survives a drift
numarasSigmaSoftware Jul 31, 2026
7e3c9e1
test: make the cancel/approval TOCTOU ordering actually race
numarasSigmaSoftware Jul 31, 2026
c68dc41
Merge upstream/main: adopt the SDK's own schema tree as the single pin
numarasSigmaSoftware Aug 12, 2026
c0d7d2a
fix(test): forward the REST auth headers the capabilities harness dro…
numarasSigmaSoftware Aug 12, 2026
9eaa7a1
fix: stop four failures that were invisible to the buyer or the operator
numarasSigmaSoftware Aug 12, 2026
17a0c29
fix: update_media_buy was the third async skill with no persisted tas…
numarasSigmaSoftware Aug 12, 2026
c456e75
fix: one home for approval-execution eligibility; revert the webhook …
numarasSigmaSoftware Aug 12, 2026
dea3df8
fix: grade the creative gate on real creative statuses only [order R1-4]
numarasSigmaSoftware Aug 13, 2026
80cde69
fix: validate update_media_buy push-notification callbacks [order R1-5]
numarasSigmaSoftware Aug 13, 2026
5b9f3a2
test: add known-bad self-tests for the two unguarded scrub oracles [o…
numarasSigmaSoftware Aug 13, 2026
e4293de
fix: derive complete_task pre-check from the canonical terminal set […
numarasSigmaSoftware Aug 13, 2026
1bf9718
fix: forward idempotency_key, reporting_webhook and ext from the A2A …
numarasSigmaSoftware Aug 13, 2026
00ca307
docs: correct two stale BDD conftest comments [order R1-13]
numarasSigmaSoftware Aug 13, 2026
475f87b
fix: log every creative-approval identifier through log_safe [order R…
numarasSigmaSoftware Aug 13, 2026
6b1a47e
refactor: apply the mechanical review nits [order R1-NITS]
numarasSigmaSoftware Aug 13, 2026
0067454
fix: forward the flight and economics parameters the A2A update skill…
numarasSigmaSoftware Aug 13, 2026
a52f1ac
fix: stop the approval service logging a refused transition as a comp…
numarasSigmaSoftware Aug 13, 2026
fec0a3b
fix: principal-scope the MCP task tools [order R3-1]
numarasSigmaSoftware Aug 13, 2026
d82b3a3
fix: update_media_buy rejected every callback, not just unsafe ones
numarasSigmaSoftware Aug 13, 2026
e36919d
fix: stop threading the outer task id into sync_creatives [order R4-1]
numarasSigmaSoftware Aug 14, 2026
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
2 changes: 1 addition & 1 deletion .duplication-baseline
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"src": 36,
"tests": 89,
"tests": 88,
"scripts": 0
}
6 changes: 6 additions & 0 deletions docs/test-obligations/bdd-traceability.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -858,6 +858,12 @@ mappings:
upstream_refs: ["BR-UC-002-ext-d"]
business_rules: []
status: new
- adcp_scenario_id: "T-UC-002-ext-nl-unsupported"
adcp_feature: "BR-UC-002-create-media-buy.feature"
obligation_id: null
upstream_refs: ["transport-errors.mdx#Layer-Separation"]
business_rules: []
status: new
- adcp_scenario_id: "T-UC-002-ext-e"
adcp_feature: "BR-UC-002-create-media-buy.feature"
obligation_id: null
Expand Down
71 changes: 44 additions & 27 deletions src/a2a_server/adcp_a2a_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,23 +147,25 @@ def _dict_to_struct(d: dict) -> struct_pb2.Struct:


def _internal_error_for(operation: str, exc: Exception) -> InternalError:
"""Canonical InternalError shape for non-skill A2A boundary failures.
"""Canonical InternalError shape for non-Task A2A boundary failures.

Skill handlers raise typed ``AdCPError`` (or untyped exceptions that the
dispatcher normalizes), and ``_handle_explicit_skill`` → ``on_message_send``
surface those as a two-layer envelope on a failed Task's DataPart. Non-skill
paths (``on_message_send`` fallthrough, NL handlers) historically picked their
own prefixes (``"Message processing failed: "``, ``"Error in ..."``)
for semantically identical untyped failures — divergence on the buyer-
facing wire message for the same condition.
surface those as a two-layer envelope on a failed Task's DataPart —
including the top-level ``on_message_send`` fallthrough, which per AdCP
3.1.x transport rules (building/operating/transport-errors.mdx "Layer
Separation") RETURNS a failed Task carrying the envelope artifact rather
than raising this helper. JSON-RPC errors are reserved for genuine
transport faults (``A2AError``) and methods with no Task to carry the
envelope.

Use this helper at every non-skill ``InternalError(...)`` raise site that
is NOT a deliberate protocol-level convention (see push-notif handlers
below). The canonical prefix is ``"{operation} failed: {exc}"`` so
storyboard runners can parse the failure uniformly.
Use this helper only at ``InternalError(...)`` raise sites that have no
async Task to attach an envelope artifact to. The canonical prefix is
``"{operation} failed: {exc}"`` so storyboard runners can parse the
failure uniformly.

The four ``on_*_task_push_notification_config`` JSON-RPC protocol methods use
this helper too — they have no async Task to carry a DataPart, so the two-layer
this helper — they have no async Task to carry a DataPart, so the two-layer
envelope rides in the error's ``data`` field (``error.data["errors"][0]["code"]``
/ ``error.data["adcp_error"]``). ``InternalError`` stays an ``A2AError`` so the
SDK's ``JsonRpcDispatcher`` serializes it as a structured JSON-RPC error; raising
Expand Down Expand Up @@ -220,6 +222,11 @@ def _build_failed_skill_result(skill_name: str, exc: Exception) -> dict[str, Any
"success": False,
}

async def _fail_task_with_webhook(self, task: Task, error: str) -> None:
"""Mark a task failed and notify protocol webhooks with a required reason."""
task.status.CopyFrom(TaskStatus(state=TaskState.TASK_STATE_FAILED))
await self._send_protocol_webhook(task, status="failed", error=error)

def _get_auth_token(self, context: ServerCallContext | None = None) -> str | None:
"""Extract Bearer token from ServerCallContext.

Expand Down Expand Up @@ -582,6 +589,11 @@ async def on_message_send(
self._task_push_configs[task_id] = push_notification_config
self.tasks[task_id] = task

# Initialized before the try so the outer error handler can always read
# it — a failure during auth-token extraction (before resolution) must
# not turn into a NameError inside the except block.
identity: ResolvedIdentity | None = None

try:
# Get authentication token
auth_token = self._get_auth_token(context)
Expand All @@ -605,7 +617,6 @@ async def on_message_send(
# ── Transport boundary: resolve identity ONCE ──
# Like REST's _resolve_auth(), identity is resolved here and passed
# to all downstream handlers. No handler should call resolve_identity().
identity: ResolvedIdentity | None = None
if auth_token:
identity = self._resolve_a2a_identity(auth_token, require_valid_token=requires_auth, context=context)
elif not requires_auth:
Expand Down Expand Up @@ -730,14 +741,10 @@ async def on_message_send(
successful_skills = [res["skill"] for res in results if res["success"]]

if failed_skills and not successful_skills:
# All skills failed - mark task as failed
task.status.CopyFrom(TaskStatus(state=TaskState.TASK_STATE_FAILED))

# Send protocol-level webhook notification for failure
error_messages = [
res["error_envelope"]["errors"][0]["message"] for res in results if not res["success"]
]
await self._send_protocol_webhook(task, status="failed", error="; ".join(error_messages))
await self._fail_task_with_webhook(task, "; ".join(error_messages))

return task
elif successful_skills:
Expand Down Expand Up @@ -864,9 +871,10 @@ async def on_message_send(
# ``_create_media_buy`` is an NL stub that always raises
# ``AdCPCapabilityNotSupportedError`` — the explicit-skill
# path is the spec contract for media buy creation. The
# outer error handler at on_message_send catches the raise
# and attaches a spec-compliant two-layer envelope to the
# failed Task artifact.
# outer error handler at on_message_send catches the raise,
# attaches a spec-compliant two-layer envelope to the failed
# Task artifact, and returns that failed Task (never a
# JSON-RPC error).
await self._create_media_buy(combined_text, identity)
else:
# General help response
Expand Down Expand Up @@ -957,12 +965,13 @@ async def on_message_send(
principal_id=err_principal_id,
)

# Send protocol-level webhook notification for failure if configured
task.status.CopyFrom(TaskStatus(state=TaskState.TASK_STATE_FAILED))
# Attach error to task artifacts as a spec-compliant two-layer
# envelope (same shape as failed-skill DataParts) so storyboard
# runners can ``JSON.parse`` the artifact uniformly regardless of
# which failure path produced it.
# which failure path produced it. ``_build_error_envelope``
# normalizes untyped exceptions to base AdCPError (wire code
# SERVICE_UNAVAILABLE via ERROR_CODE_MAPPING) and passes
# through a typed AdCPError's own wire code.
del task.artifacts[:]
task.artifacts.append(
Artifact(
Expand All @@ -972,10 +981,18 @@ async def on_message_send(
)
)

await self._send_protocol_webhook(task, status="failed")

# Raise A2A error instead of creating failed task
raise _internal_error_for("message processing", e)
await self._fail_task_with_webhook(task, str(e))

# Per AdCP 3.1.x transport rules (spec prose:
# building/operating/transport-errors.mdx "Layer Separation" and the
# two-layer error-handling model), application/task-execution
# failures MUST be returned in the task response body as a failed
# Task carrying the envelope artifact. JSON-RPC errors are reserved
# for genuine transport faults (A2AError, re-raised above). So we
# fall through to the shared store-and-return below — never raise
# InternalError here. Cross-transport execution of the same BDD
# scenario is tracked in #1574 once the transport-aware harness
# from #1430 is available.

self.tasks[task_id] = task
return task
Expand Down
45 changes: 44 additions & 1 deletion tests/a2a_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,15 @@

Provides make_a2a_context() to build a ServerCallContext the same way
AdCPCallContextBuilder.build() does in production, but without needing
a Starlette request object.
a Starlette request object, and extract_processing_error_envelope() to
read the two-layer AdCP error envelope off a failed Task returned by
on_message_send's outer error handler.
"""

import json

from a2a.server.context import ServerCallContext
from google.protobuf import json_format

from src.core.auth_context import AUTH_CONTEXT_STATE_KEY, AuthContext

Expand All @@ -28,3 +33,41 @@ def make_a2a_context(
"""
auth_ctx = AuthContext(auth_token=auth_token, headers=headers or {})
return ServerCallContext(state={AUTH_CONTEXT_STATE_KEY: auth_ctx})


def extract_processing_error_envelope(task) -> dict:
"""Read the two-layer AdCP envelope from a failed Task's processing_error artifact.

``on_message_send``'s outer error handler attaches the envelope built by
``AdCPRequestHandler._build_error_envelope`` to the failed Task as a
single ``processing_error`` artifact with one DataPart (AdCP 3.1.x
transport-errors.mdx "Layer Separation": application failures ride in
the task body, not JSON-RPC errors).
"""
assert task.artifacts, "failed Task must carry the error envelope artifact"
artifact = task.artifacts[0]
assert artifact.name == "processing_error", f"expected processing_error artifact, got {artifact.name!r}"
part = artifact.parts[0]
assert part.HasField("data"), "envelope artifact part must be a DataPart"
return json.loads(json_format.MessageToJson(part.data))
Comment thread
numarasSigmaSoftware marked this conversation as resolved.
Outdated


def make_mock_a2a_identity():
"""Standard mock ResolvedIdentity for A2A handler unit tests."""
from tests.factories import PrincipalFactory

return PrincipalFactory.make_identity(
principal_id="test-principal",
tenant_id="test-tenant",
tenant={"tenant_id": "test-tenant"},
protocol="a2a",
)


def make_nl_send_message_request(text: str):
"""Build a minimal A2A SendMessageRequest carrying NL text (no skills)."""
from a2a.types import SendMessageRequest

from tests.utils.a2a_helpers import create_a2a_text_message

return SendMessageRequest(message=create_a2a_text_message(text))
14 changes: 7 additions & 7 deletions tests/bdd/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -2584,6 +2584,9 @@ def pytest_collection_modifyitems(items: list[pytest.Item]) -> None:
"T-UC-002-v31-idempotency-replay",
"T-UC-002-v31-idempotency-missing",
}
_UC002_MEDIA_BUY_CREATE_WIRED: set[str] = _UC002_IDEMPOTENCY_WIRED | {
"transport-layer-separation",
}

# Admin scenarios have their own transport (Flask test_client / requests.Session).
# They must NOT be parametrized across MCP/A2A/REST/IMPL API transports.
Expand Down Expand Up @@ -2843,13 +2846,10 @@ def _harness_env(request: pytest.FixtureRequest, ctx: dict) -> Generator[None, N
with MediaBuyAccountEnv(e2e_config=ctx.get("e2e_config")) as env:
ctx["env"] = env
yield
elif marker_names & _UC002_IDEMPOTENCY_WIRED:
# v3.1 idempotency replay/missing scenarios — MediaBuyCreateEnv runs a
# real create_media_buy through every transport (the replay scenario
# creates once, then sends the same key again to exercise the
# production replay path). Only the two wired tags go live here; the
# remaining @idempotency-key scenarios (in-flight, expired, conflict,
# pattern, canonical) stay blanket-xfailed below until their
elif marker_names & _UC002_MEDIA_BUY_CREATE_WIRED:
# Wired create-media-buy scenarios — MediaBuyCreateEnv seeds the
# tenant/principal/product chain and drives the real transport path.
# Most UC-002 scenarios stay blanket-xfailed below until their
# production gaps + steps are wired.
request.getfixturevalue("integration_db")
from tests.harness.media_buy_create import MediaBuyCreateEnv
Expand Down
11 changes: 11 additions & 0 deletions tests/bdd/features/BR-UC-002-create-media-buy.feature
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,17 @@ Feature: BR-UC-002 Create Media Buy
# POST-F1: System state is unchanged on failure
# POST-F2: Buyer knows what failed
# POST-F3: Buyer knows how to fix the issue

@T-UC-002-ext-nl-unsupported @extension @error @transport-layer-separation @a2a
Scenario: NL media-buy request is unsupported -- returned as a failed task, not a transport error
Given an authenticated buyer
When the buyer sends a natural-language "create a media buy" request
Then the error code should be "UNSUPPORTED_FEATURE"
And the error recovery should be "correctable"
And the wire error envelope should carry code "UNSUPPORTED_FEATURE" with recovery "correctable"
# grounded: AdCP 3.1.0-beta.3 building/operating/transport-errors.mdx "Layer Separation"
# storyboard: ungraded, pending upstream obligation (#1574)
# drives the top-level on_message_send except -> failed Task envelope, not JSON-RPC
# --- ext-e: Duplicate Products ---

@T-UC-002-ext-e @extension @ext-e @error @post-f1 @post-f2 @post-f3
Expand Down
50 changes: 50 additions & 0 deletions tests/bdd/steps/domain/uc002_create_media_buy.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@

from __future__ import annotations

import asyncio
from unittest.mock import MagicMock

from pytest_bdd import given, parsers, then, when

from tests.bdd.steps._harness_db import db_session as _db_session
Expand Down Expand Up @@ -61,6 +64,12 @@ def given_valid_request(ctx: dict) -> None:
ctx.setdefault("account_ref", None)


@given("an authenticated buyer")
def given_authenticated_buyer(ctx: dict) -> None:
"""Record that this scenario uses the harness-created buyer identity."""
ctx["has_auth"] = True


@given(parsers.parse('a valid create_media_buy request with account "{account_id}"'))
def given_request_with_account(ctx: dict, account_id: str) -> None:
"""Set up a create_media_buy request with account (short form)."""
Expand Down Expand Up @@ -187,6 +196,47 @@ def given_account_active(ctx: dict) -> None:
AgentAccountAccessFactory(tenant_id=tenant.tenant_id, principal=principal, account=account)


@when(parsers.parse('the buyer sends a natural-language "{request_text}" request'))
def when_buyer_sends_nl_a2a_request(ctx: dict, request_text: str) -> None:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This when-step hand-builds AdCPRequestHandler(), MagicMocks _get_auth_token/_resolve_a2a_identity, and hand-stashes ctx["response"]/ctx["wire_error_envelope"]/ctx["error"] (lines 335/344/345) rather than dispatching through dispatch_request, the sole writer of those keys. Combined with the @a2a tag, the transport-blind envelope contract is graded on one transport. Build the harness NL dispatch entry point (#1574), route this step through dispatch_request, and drop the @a2a fork so the scenario runs a2a/mcp/rest. #1574 is OPEN but unassigned — file/self-assign it naming this step.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[SHOULD-FIX] Two separable points here.

The read half is honest — _read_failed_a2a_task(expect_processing_error=True) reads the real protobuf DataPart with strict asserts, there is no model_dump fallback anywhere in then_error.py or tests/utils/a2a_helpers.py, and the scenario genuinely reddens on the production fix. But nothing pins TASK_STATE_FAILED, so the Gherkin title's "returned as a failed task, not a transport error" holds only because this hand-rolled step skips the harness's _unwrap_a2a_server_error. An eight-line Then reading ctx["env"].last_a2a_task.status.state closes that, and is worth adding regardless of the dispatch question.

On the dispatch bypass: the stated cost — that NL dispatch is "a genuinely new dispatch primitive, not a signature change" — does not hold up. A throwaway prototype at this head is three files, +27/-51, a net 24 lines deleted: an nl_text kwarg popped in _run_a2a_handler plus two branch points (create_a2a_text_message instead of the skill DataPart, and expect_processing_error=nl_text is not None), after which this 55-line step becomes ctx["transport"] = Transport.A2A; dispatch_request(ctx, nl_text=request_text). Regression parity is identical: 348 passed / 3 skipped / 1263 xfailed both ways. Worth reconsidering on that basis rather than on the ticket's status.

Also worth knowing, since the hand-built TransportResult diverges from the real one: envelope={"transport":"a2a"} is set on the error path (the real dispatcher sets it only on success), raw_response=result is set where A2A never sets it, and on a non-failed Task ctx["response"] = result stores a raw protobuf where dispatch_request stores result.payload — so wire_dict(ctx) would model_dump() a protobuf. No guard currently forbids a When step writing these keys; test_architecture_bdd_wire_discipline.py Check A only catches constructed errors.

"""Drive real A2A ``on_message_send`` with a natural-language text part."""
from a2a.server.routes.common import ServerCallContext
from a2a.types import Task, TaskState

from src.a2a_server.adcp_a2a_server import AdCPRequestHandler
from src.core.config_loader import set_current_tenant
from src.core.exceptions import AdCPError
from tests.a2a_helpers import make_nl_send_message_request
from tests.harness._base import _envelope_to_adcp_error
from tests.harness.transport import Transport
from tests.utils.a2a_helpers import extract_data_from_artifact

env = ctx["env"]
identity = env.identity_for(Transport.A2A)
set_current_tenant(identity.tenant)

handler = AdCPRequestHandler()
handler._get_auth_token = MagicMock(return_value=identity.auth_token)
handler._resolve_a2a_identity = MagicMock(return_value=identity)

async def _call() -> Task:
return await handler.on_message_send(make_nl_send_message_request(request_text), ServerCallContext())

try:
result = asyncio.run(_call())
except Exception as exc:
ctx["error"] = exc
return

ctx["response"] = result
if result.status.state == TaskState.TASK_STATE_FAILED:
if not result.artifacts:
ctx["error"] = AdCPError(f"A2A task failed without artifacts: {result.status}")
return
envelope = extract_data_from_artifact(result.artifacts[0])
Comment thread
numarasSigmaSoftware marked this conversation as resolved.
Outdated
ctx["wire_error_envelope"] = envelope
ctx["error"] = _envelope_to_adcp_error(envelope, fallback_message="A2A natural-language request failed")


@given(parsers.parse("a create_media_buy request with account configuration {partition}"))
def given_request_with_partition(ctx: dict, partition: str) -> None:
"""Set up request based on partition name (for Scenario Outline tables)."""
Expand Down
19 changes: 19 additions & 0 deletions tests/bdd/steps/generic/then_error.py
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,25 @@ def then_error_recovery(ctx: dict, recovery: str) -> None:
raise AssertionError(f"Cannot check recovery on non-AdCPError: {type(error).__name__}")


@then(parsers.parse('the wire error envelope should carry code "{code}" with recovery "{recovery}"'))
def then_wire_error_envelope_shape(ctx: dict, code: str, recovery: str) -> None:
"""Assert the two-layer AdCP envelope shape on the captured wire envelope.

Wire-envelope variant of the error-code/recovery steps above (tests/CLAUDE.md
§ Error Verification Policy): asserts on ``ctx["wire_error_envelope"]``, the
envelope captured off the wire by the dispatching When step, rather than the
lossy reconstructed exception in ``ctx["error"]``.
"""
from tests.helpers import assert_envelope_shape

envelope = ctx.get("wire_error_envelope")
assert envelope is not None, (
f"No wire error envelope captured in ctx — the operation succeeded or the "
f"When step does not populate ctx['wire_error_envelope']. response={ctx.get('response')!r}"
)
assert_envelope_shape(envelope, code, recovery=recovery)


@then('the error should include a "suggestion" field')
@then('the error should include "suggestion" field')
def then_error_has_suggestion(ctx: dict) -> None:
Expand Down
Loading
Loading