Skip to content

Commit e624b5c

Browse files
bokelleyclaude
andauthored
fix(webhooks): correct type annotations for extract_webhook_result_data and payload builders (#600)
* fix(webhooks): correct type annotations for webhook helpers extract_webhook_result_data declared AdcpAsyncResponseData | None but every code path returned dict[str, Any] | None, forcing adopters to cast before using .get(). Return type corrected to dict[str, Any] | None and internal cast calls updated accordingly. create_mcp_webhook_payload and create_a2a_webhook_payload typed result narrowly as AdcpAsyncResponseData but accepted any BaseModel via hasattr check at runtime. Parameter widened to PydanticBaseModel | dict[str, Any] so callers pass any Pydantic model without type: ignore. Also drops the direct generated_poc import (CLAUDE.md layering rule) since AdcpAsyncResponseData is no longer referenced in this module, and fixes a bogus message= kwarg in the create_a2a_webhook_payload docstring example. https://claude.ai/code/session_013oiysa4CAeiSFFdnvFTHqh * test(webhooks): add coverage for PydanticBaseModel branch in payload builders Adds TestWebhookPayloadBuilderPydanticModel covering the model_dump path in create_mcp_webhook_payload and create_a2a_webhook_payload (both completed and working status paths). Also fixes the result param docstring in create_mcp_webhook_payload to match the widened type. https://claude.ai/code/session_013oiysa4CAeiSFFdnvFTHqh * test(layering): drop webhooks.py from _KNOWN_VIOLATIONS The fix removed the generated_poc/ import from webhooks.py, so the entry in _KNOWN_VIOLATIONS is now stale and the layering test fails on the "stale entries" branch. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3d269f5 commit e624b5c

3 files changed

Lines changed: 99 additions & 18 deletions

File tree

src/adcp/webhooks.py

Lines changed: 17 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
)
4141
from google.protobuf.json_format import MessageToDict, ParseDict
4242
from google.protobuf.struct_pb2 import Value
43+
from pydantic import BaseModel as PydanticBaseModel
4344

4445
from adcp.server.idempotency.backends import MemoryBackend as MemoryBackend
4546
from adcp.server.idempotency.webhook_dedup import WebhookDedupStore as WebhookDedupStore
@@ -57,7 +58,6 @@
5758
)
5859
from adcp.types import GeneratedTaskStatus
5960
from adcp.types.base import AdCPBaseModel
60-
from adcp.types.generated_poc.core.async_response_data import AdcpAsyncResponseData
6161
from adcp.webhook_receiver import (
6262
LegacyHmacFallback,
6363
VerifiedSignerLike,
@@ -86,7 +86,7 @@ def generate_webhook_idempotency_key() -> str:
8686
def create_mcp_webhook_payload(
8787
task_id: str,
8888
status: GeneratedTaskStatus | str,
89-
result: AdcpAsyncResponseData | dict[str, Any] | None = None,
89+
result: PydanticBaseModel | dict[str, Any] | None = None,
9090
timestamp: datetime | None = None,
9191
task_type: str | None = None,
9292
operation_id: str | None = None,
@@ -107,7 +107,7 @@ def create_mcp_webhook_payload(
107107
status: Current task status
108108
task_type: Optionally type of AdCP operation (e.g., "get_products", "create_media_buy")
109109
timestamp: When the webhook was generated (defaults to current UTC time)
110-
result: Task-specific payload (AdCP response data)
110+
result: Task-specific payload — any Pydantic model or plain dict
111111
operation_id: Client-generated identifier the buyer embedded in the
112112
webhook URL when registering push-notification config. Publishers
113113
MUST echo this back in the payload so buyers correlate notifications
@@ -358,7 +358,7 @@ def sign_legacy_webhook(
358358
return signature_headers, body_bytes
359359

360360

361-
def extract_webhook_result_data(webhook_payload: dict[str, Any]) -> AdcpAsyncResponseData | None:
361+
def extract_webhook_result_data(webhook_payload: dict[str, Any]) -> dict[str, Any] | None:
362362
"""
363363
Extract result data from webhook payload (MCP or A2A format).
364364
@@ -376,8 +376,8 @@ def extract_webhook_result_data(webhook_payload: dict[str, Any]) -> AdcpAsyncRes
376376
webhook_payload: Raw webhook dictionary from HTTP request (JSON-deserialized)
377377
378378
Returns:
379-
AdcpAsyncResponseData union type containing the extracted AdCP response, or None
380-
if no result present. For A2A webhooks, unwraps data from artifacts/message parts
379+
dict[str, Any] containing the extracted AdCP response data, or None if no
380+
result is present. For A2A webhooks, unwraps data from artifacts/message parts
381381
structure. For MCP webhooks, returns the result field directly.
382382
383383
Examples:
@@ -464,8 +464,8 @@ def extract_webhook_result_data(webhook_payload: dict[str, Any]) -> AdcpAsyncRes
464464
data = part["data"]
465465
# Unwrap {"response": {...}} wrapper if present (A2A convention)
466466
if isinstance(data, dict) and "response" in data and len(data) == 1:
467-
return cast(AdcpAsyncResponseData, data["response"])
468-
return cast(AdcpAsyncResponseData, data)
467+
return cast(dict[str, Any], data["response"])
468+
return cast(dict[str, Any], data)
469469

470470
return None
471471

@@ -485,20 +485,20 @@ def extract_webhook_result_data(webhook_payload: dict[str, Any]) -> AdcpAsyncRes
485485
data = part["data"]
486486
# Unwrap {"response": {...}} wrapper if present
487487
if isinstance(data, dict) and "response" in data and len(data) == 1:
488-
return cast(AdcpAsyncResponseData, data["response"])
489-
return cast(AdcpAsyncResponseData, data)
488+
return cast(dict[str, Any], data["response"])
489+
return cast(dict[str, Any], data)
490490

491491
return None
492492

493493
# MCP format: result field directly
494-
return cast(AdcpAsyncResponseData | None, webhook_payload.get("result"))
494+
return cast(dict[str, Any] | None, webhook_payload.get("result"))
495495

496496

497497
def create_a2a_webhook_payload(
498498
task_id: str,
499499
status: GeneratedTaskStatus,
500500
context_id: str,
501-
result: AdcpAsyncResponseData | dict[str, Any],
501+
result: PydanticBaseModel | dict[str, Any],
502502
timestamp: datetime | None = None,
503503
) -> Task | TaskStatusUpdateEvent:
504504
"""
@@ -518,7 +518,7 @@ def create_a2a_webhook_payload(
518518
status: Current task status
519519
context_id: Session/conversation identifier (required by A2A protocol)
520520
timestamp: When the webhook was generated (defaults to current UTC time)
521-
result: Task-specific payload (AdCP response data)
521+
result: Task-specific payload — any Pydantic model or plain dict
522522
523523
Returns:
524524
Task object for terminated statuses, TaskStatusUpdateEvent for intermediate statuses
@@ -530,17 +530,18 @@ def create_a2a_webhook_payload(
530530
>>>
531531
>>> task = create_a2a_webhook_payload(
532532
... task_id="task_123",
533+
... context_id="ctx_123",
533534
... status=GeneratedTaskStatus.completed,
534535
... result={"products": [...]},
535-
... message="Found 5 products"
536536
... )
537537
>>> # task is a Task object with artifacts containing the result
538538
539539
Create a working status update:
540540
>>> event = create_a2a_webhook_payload(
541541
... task_id="task_456",
542+
... context_id="ctx_456",
542543
... status=GeneratedTaskStatus.working,
543-
... message="Processing 3 of 10 items"
544+
... result={"current_step": "processing", "percentage": 30},
544545
... )
545546
>>> # event is a TaskStatusUpdateEvent with status.message
546547
@@ -610,7 +611,7 @@ def create_a2a_webhook_payload(
610611
# Build parts for the message/artifact.
611612
parts: list[pb.Part] = []
612613

613-
# Convert AdcpAsyncResponseData to dict if it's a Pydantic model
614+
# Convert Pydantic model to dict if needed
614615
if hasattr(result, "model_dump"):
615616
result_dict: dict[str, Any] = result.model_dump(mode="json")
616617
else:

tests/test_import_layering.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,6 @@
5757
"src/adcp/utils/preview_cache.py",
5858
"src/adcp/webhook_receiver.py",
5959
"src/adcp/webhook_sender.py",
60-
"src/adcp/webhooks.py",
6160
}
6261
)
6362

tests/test_webhook_handling.py

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,18 @@
1212
from a2a.types import TaskState, TaskStatusUpdateEvent
1313
from google.protobuf.json_format import MessageToDict as _MessageToDict
1414

15+
from pydantic import BaseModel
16+
1517
from adcp.client import ADCPClient
1618
from adcp.exceptions import ADCPWebhookSignatureError
19+
from adcp.types import GeneratedTaskStatus
1720
from adcp.types.core import AgentConfig, Protocol, TaskStatus
18-
from adcp.webhooks import extract_webhook_result_data, get_adcp_signed_headers_for_webhook
21+
from adcp.webhooks import (
22+
create_a2a_webhook_payload,
23+
create_mcp_webhook_payload,
24+
extract_webhook_result_data,
25+
get_adcp_signed_headers_for_webhook,
26+
)
1927
from tests.a2a_compat_shim import (
2028
Artifact,
2129
DataPart,
@@ -1186,6 +1194,79 @@ def test_extract_from_mcp_with_error_response(self):
11861194
assert result["errors"][0]["code"] == "INTERNAL_ERROR"
11871195

11881196

1197+
class _DeliveryResponse(BaseModel):
1198+
"""Minimal Pydantic model for testing the BaseModel branch in payload builders."""
1199+
1200+
media_buy_id: str
1201+
buyer_ref: str
1202+
packages: list[str] = []
1203+
1204+
1205+
class TestWebhookPayloadBuilderPydanticModel:
1206+
"""Pydantic BaseModel inputs to create_mcp_webhook_payload / create_a2a_webhook_payload.
1207+
1208+
Regression guard for the PydanticBaseModel branch (model_dump path inside
1209+
both builders). Prior to the fix these functions were typed to accept only
1210+
AdcpAsyncResponseData, which is a narrow discriminated union — passing any
1211+
other BaseModel subclass required a type: ignore comment even though the
1212+
runtime hasattr(result, "model_dump") guard handled it correctly.
1213+
"""
1214+
1215+
def test_create_mcp_payload_accepts_pydantic_model(self):
1216+
model = _DeliveryResponse(media_buy_id="mb_1", buyer_ref="ref_1")
1217+
payload = create_mcp_webhook_payload(
1218+
task_id="task_1",
1219+
task_type="media_buy_delivery",
1220+
status=GeneratedTaskStatus.completed,
1221+
result=model,
1222+
)
1223+
assert payload["result"] == {"media_buy_id": "mb_1", "buyer_ref": "ref_1", "packages": []}
1224+
1225+
def test_create_mcp_payload_pydantic_model_serialized_as_json(self):
1226+
model = _DeliveryResponse(media_buy_id="mb_2", buyer_ref="ref_2", packages=["pkg_a"])
1227+
payload = create_mcp_webhook_payload(
1228+
task_id="task_2",
1229+
task_type="media_buy_delivery",
1230+
status=GeneratedTaskStatus.completed,
1231+
result=model,
1232+
)
1233+
result = payload["result"]
1234+
assert isinstance(result, dict)
1235+
assert result["packages"] == ["pkg_a"]
1236+
1237+
def test_create_a2a_payload_accepts_pydantic_model_completed(self):
1238+
from a2a.types import Task as A2ATask
1239+
1240+
model = _DeliveryResponse(media_buy_id="mb_3", buyer_ref="ref_3")
1241+
task = create_a2a_webhook_payload(
1242+
task_id="task_3",
1243+
context_id="ctx_3",
1244+
status=GeneratedTaskStatus.completed,
1245+
result=model,
1246+
)
1247+
assert isinstance(task, A2ATask)
1248+
task_dict = _MessageToDict(task, preserving_proto_field_name=False)
1249+
extracted = extract_webhook_result_data(task_dict)
1250+
assert extracted is not None
1251+
assert extracted["media_buy_id"] == "mb_3"
1252+
1253+
def test_create_a2a_payload_accepts_pydantic_model_working(self):
1254+
from a2a.types import TaskStatusUpdateEvent as A2AEvent
1255+
1256+
model = _DeliveryResponse(media_buy_id="mb_4", buyer_ref="ref_4")
1257+
event = create_a2a_webhook_payload(
1258+
task_id="task_4",
1259+
context_id="ctx_4",
1260+
status=GeneratedTaskStatus.working,
1261+
result=model,
1262+
)
1263+
assert isinstance(event, A2AEvent)
1264+
event_dict = _MessageToDict(event, preserving_proto_field_name=False)
1265+
extracted = extract_webhook_result_data(event_dict)
1266+
assert extracted is not None
1267+
assert extracted["media_buy_id"] == "mb_4"
1268+
1269+
11891270
# Load official AdCP HMAC test vectors from fixtures.
11901271
# Source: adcontextprotocol/adcp PR #2478 (merged 2026-04-20), which pins the
11911272
# canonical on-wire JSON form (compact separators) and adds rejection vectors

0 commit comments

Comments
 (0)