Skip to content

Commit 89f9491

Browse files
bokelleyclaude
andauthored
fix(webhooks): add canceled/rejected/auth-required to A2A status map; fail fast on unknowns (#606)
Closes #603. create_a2a_webhook_payload silently fell back to TASK_STATE_UNSPECIFIED (proto3 integer 0) for any AdCP status not in the lookup map — canceled, rejected, auth_required, and any genuinely unknown value. MessageToDict omits proto3 zero-value fields entirely, so the wire shape became ``{"status": {}}`` with no ``state`` field. A2A v0.3 receivers that validate against the schema reject this as a missing required field. Changes: - Add canceled, rejected, auth_required/auth-required to adcp_to_task_state (each has a valid pb.TaskState constant). - Raise ValueError for any unmapped status value with a directional message naming the eight valid states. ``unknown`` has no a2a-sdk 1.0 protobuf constant, so it is explicitly rejected; callers needing that wire state should build a Task manually and pass it through to_wire_dict. - Expand is_terminated to include canceled and rejected — both are terminal states per A2A v0.3, returning Task with artifacts rather than TaskStatusUpdateEvent. - Tighten status.value access (no string fallback) — the function signature is GeneratedTaskStatus and the docstring contract is enum members. - Update stale docstrings in create_a2a_webhook_payload and extract_webhook_result_data to list all four terminal states. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 37d2cda commit 89f9491

3 files changed

Lines changed: 141 additions & 33 deletions

File tree

src/adcp/webhooks.py

Lines changed: 47 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -368,7 +368,7 @@ def extract_webhook_result_data(webhook_payload: dict[str, Any]) -> AdcpAsyncRes
368368
client initialization.
369369
370370
Protocol Detection:
371-
- A2A Task: Has "artifacts" field (terminated statuses: completed, failed)
371+
- A2A Task: Has "artifacts" field (terminated statuses: completed, failed, canceled, rejected)
372372
- A2A TaskStatusUpdateEvent: Has nested "status.message" structure (intermediate statuses)
373373
- MCP: Has "result" field directly
374374
@@ -505,9 +505,10 @@ def create_a2a_webhook_payload(
505505
Create A2A webhook payload (Task or TaskStatusUpdateEvent).
506506
507507
Per A2A specification:
508-
- Terminated statuses (completed, failed): Returns Task with artifacts[].parts[]
509-
- Intermediate statuses (working, input-required, submitted): Returns TaskStatusUpdateEvent
510-
with status.message.parts[]
508+
- Terminated statuses (completed, failed, canceled, rejected): Returns Task
509+
with artifacts[].parts[]
510+
- Intermediate statuses (working, input-required, submitted, auth-required):
511+
Returns TaskStatusUpdateEvent with status.message.parts[]
511512
512513
This function helps agent implementations construct properly formatted A2A webhook
513514
payloads for sending to clients.
@@ -564,28 +565,47 @@ def create_a2a_webhook_payload(
564565
timestamp_proto = _isoformat_to_proto_timestamp(timestamp_str) if timestamp_str else None
565566

566567
# Map GeneratedTaskStatus to A2A TaskState enum value.
567-
status_value = status.value if hasattr(status, "value") else str(status)
568+
# GeneratedTaskStatus is always an Enum so .value is guaranteed.
569+
status_value = status.value
568570
adcp_to_task_state: dict[str, int] = {
569571
"completed": pb.TaskState.TASK_STATE_COMPLETED,
570572
"failed": pb.TaskState.TASK_STATE_FAILED,
573+
"canceled": pb.TaskState.TASK_STATE_CANCELED,
574+
"rejected": pb.TaskState.TASK_STATE_REJECTED,
571575
"working": pb.TaskState.TASK_STATE_WORKING,
572576
"submitted": pb.TaskState.TASK_STATE_SUBMITTED,
577+
# GeneratedTaskStatus enum values are hyphenated ("input-required",
578+
# "auth-required"). The underscore forms are accepted as a convenience
579+
# for callers passing raw strings rather than enum members.
573580
"input_required": pb.TaskState.TASK_STATE_INPUT_REQUIRED,
574-
# Tolerate the hyphenated form servers may echo back.
575581
"input-required": pb.TaskState.TASK_STATE_INPUT_REQUIRED,
582+
"auth_required": pb.TaskState.TASK_STATE_AUTH_REQUIRED,
583+
"auth-required": pb.TaskState.TASK_STATE_AUTH_REQUIRED,
576584
}
577-
if status_value not in adcp_to_task_state:
578-
# Falling back to TASK_STATE_UNSPECIFIED would normalize to the
579-
# string ``"unspecified"`` on the wire, which is not a valid A2A
580-
# v0.3 ``TaskState`` — buyer receivers validating against the
581-
# spec reject the webhook. Fail loud at the builder boundary
582-
# instead of producing a silently-broken envelope.
585+
task_state_enum = adcp_to_task_state.get(status_value)
586+
if task_state_enum is None:
587+
# Falling back to TASK_STATE_UNSPECIFIED (proto3 zero) would be
588+
# silently omitted by MessageToDict, producing an invalid wire
589+
# shape ``{"status": {}}`` that A2A v0.3 receivers reject as
590+
# missing the required ``state`` field. Fail loud at the builder
591+
# boundary so callers can't ship a broken envelope.
592+
known = [
593+
"submitted",
594+
"working",
595+
"input-required",
596+
"completed",
597+
"canceled",
598+
"failed",
599+
"rejected",
600+
"auth-required",
601+
]
583602
raise ValueError(
584-
f"Unknown AdCP task status {status_value!r}; expected one of "
585-
f"{sorted(set(adcp_to_task_state))}. AdCP→A2A status mapping is "
586-
"closed — an unknown value indicates a caller bug."
603+
f"create_a2a_webhook_payload: unknown status {status_value!r}. "
604+
f"Known AdCP→A2A states: {known}. "
605+
"Note: 'unknown' has no a2a-sdk 1.0 protobuf constant; build a "
606+
"Task manually and pass it through to_wire_dict if you need to "
607+
"emit that state."
587608
)
588-
task_state_enum = adcp_to_task_state[status_value]
589609

590610
# Build parts for the message/artifact.
591611
parts: list[pb.Part] = []
@@ -600,8 +620,14 @@ def create_a2a_webhook_payload(
600620
ParseDict(result_dict, value)
601621
parts.append(pb.Part(data=value))
602622

603-
# Determine if this is a terminated status (Task) or intermediate (TaskStatusUpdateEvent)
604-
is_terminated = status in [GeneratedTaskStatus.completed, GeneratedTaskStatus.failed]
623+
# Determine if this is a terminated status (Task) or intermediate (TaskStatusUpdateEvent).
624+
# canceled and rejected are terminal: the task will not continue.
625+
is_terminated = status in (
626+
GeneratedTaskStatus.completed,
627+
GeneratedTaskStatus.failed,
628+
GeneratedTaskStatus.canceled,
629+
GeneratedTaskStatus.rejected,
630+
)
605631

606632
if is_terminated:
607633
status_kwargs: dict[str, Any] = {"state": task_state_enum}
@@ -1111,7 +1137,9 @@ def _normalize_a2a_task_state_to_v03(payload: dict[str, Any]) -> None:
11111137
state = status.get("state")
11121138
if isinstance(state, str) and state.startswith("TASK_STATE_"):
11131139
remainder = state[len("TASK_STATE_") :].lower()
1114-
# Spec uses hyphens for multi-word states.
1140+
# Spec uses hyphens for multi-word states (e.g. "auth-required").
1141+
# Note: TASK_STATE_UNSPECIFIED (0) is the proto3 default and is
1142+
# silently omitted by MessageToDict, so it never reaches this branch.
11151143
status["state"] = remainder.replace("_", "-")
11161144
message = status.get("message")
11171145
if isinstance(message, dict):

tests/test_a2a_webhook_payload.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
"""Tests for create_a2a_webhook_payload status mapping correctness.
2+
3+
Guards against issue #603: previously, statuses not in the AdCP→A2A map
4+
(canceled, rejected, auth_required) silently fell back to TASK_STATE_UNSPECIFIED
5+
(proto3 zero value), which MessageToDict omits — producing an invalid
6+
``{"status": {}}`` wire shape with no ``state`` field. Unknown statuses now
7+
raise ValueError instead.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import types
13+
14+
import pytest
15+
from a2a.types import Task, TaskStatusUpdateEvent
16+
from google.protobuf.json_format import MessageToDict
17+
18+
from adcp.types import GeneratedTaskStatus
19+
from adcp.webhooks import create_a2a_webhook_payload
20+
21+
22+
def _wire_state(obj: Task | TaskStatusUpdateEvent) -> str:
23+
"""Serialize to wire dict and return the normalized status.state string."""
24+
d = MessageToDict(obj, preserving_proto_field_name=False)
25+
state = d.get("status", {}).get("state", "")
26+
if isinstance(state, str) and state.startswith("TASK_STATE_"):
27+
state = state[len("TASK_STATE_") :].lower().replace("_", "-")
28+
return state
29+
30+
31+
# --- terminal statuses return Task ---
32+
33+
34+
def test_canceled_returns_task_with_canceled_state() -> None:
35+
payload = create_a2a_webhook_payload(
36+
task_id="t1",
37+
status=GeneratedTaskStatus.canceled,
38+
context_id="ctx1",
39+
result={},
40+
)
41+
assert isinstance(payload, Task)
42+
assert _wire_state(payload) == "canceled"
43+
44+
45+
def test_rejected_returns_task_with_rejected_state() -> None:
46+
payload = create_a2a_webhook_payload(
47+
task_id="t2",
48+
status=GeneratedTaskStatus.rejected,
49+
context_id="ctx2",
50+
result={},
51+
)
52+
assert isinstance(payload, Task)
53+
assert _wire_state(payload) == "rejected"
54+
55+
56+
# --- intermediate statuses return TaskStatusUpdateEvent ---
57+
58+
59+
def test_auth_required_returns_event_with_auth_required_state() -> None:
60+
payload = create_a2a_webhook_payload(
61+
task_id="t3",
62+
status=GeneratedTaskStatus.auth_required,
63+
context_id="ctx3",
64+
result={},
65+
)
66+
assert isinstance(payload, TaskStatusUpdateEvent)
67+
assert _wire_state(payload) == "auth-required"
68+
69+
70+
# --- unknown status raises ValueError ---
71+
72+
73+
def test_unknown_status_value_raises_value_error() -> None:
74+
# Simulate a caller passing an enum-like object whose .value is not in
75+
# the AdCP→A2A map (e.g. a future enum member not yet supported).
76+
fake_status = types.SimpleNamespace(value="bogus_status")
77+
with pytest.raises(ValueError, match="unknown status"):
78+
create_a2a_webhook_payload(
79+
task_id="t4",
80+
status=fake_status, # type: ignore[arg-type]
81+
context_id="ctx4",
82+
result={},
83+
)
84+
85+
86+
def test_unknown_status_error_message_names_known_states() -> None:
87+
fake_status = types.SimpleNamespace(value="bogus_status")
88+
with pytest.raises(ValueError, match="canceled"):
89+
create_a2a_webhook_payload(
90+
task_id="t5",
91+
status=fake_status, # type: ignore[arg-type]
92+
context_id="ctx5",
93+
result={},
94+
)

tests/test_webhooks_to_wire_dict.py

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -127,17 +127,3 @@ def test_unsupported_type_raises_type_error() -> None:
127127
"""Silent fallthrough would mask integration bugs — fail loud."""
128128
with pytest.raises(TypeError, match="Unsupported webhook payload type"):
129129
to_wire_dict("not a payload") # type: ignore[arg-type]
130-
131-
132-
def test_a2a_unknown_status_raises_value_error() -> None:
133-
"""Unknown AdCP status must fail at the builder, not produce an
134-
invalid-on-the-wire ``"unspecified"`` TaskState that buyer receivers
135-
reject. (Issue #603.)
136-
"""
137-
with pytest.raises(ValueError, match="Unknown AdCP task status"):
138-
create_a2a_webhook_payload(
139-
task_id="task_123",
140-
status="not-a-real-status", # type: ignore[arg-type]
141-
context_id="ctx_456",
142-
result={"media_buy_id": "mb_1"},
143-
)

0 commit comments

Comments
 (0)