Skip to content

Commit 50c985e

Browse files
bokelleyclaude
andauthored
fix(decisioning): wire-path dispatch + F12 silent no-op (Emma sales-direct P0s) (#338)
* fix(decisioning): wire-path dispatch + F12 silent no-op (Emma sales-direct P0s) PR #337 merged 31 handler shims that closed the AudioStack 404 issue but Emma's sales-direct backend test (verdict 2/10) surfaced three P0s downstream of the shim layer: every MCP ``tools/call`` 500'd with ``'dict' object has no attribute 'account'``, and buyer-registered push_notification_config.urls were silently dropped when the adopter didn't wire ``webhook_sender``. Unit tests passed because they bypass ``create_tool_caller`` and call shim methods directly. handler.py — moved every ``adcp.types`` Request/Response import out of ``if TYPE_CHECKING:`` so ``typing.get_type_hints()`` resolves the forward refs at runtime. Without this, the dispatcher's typed-params resolver raises ``NameError``, falls back to dict dispatch, and the shim's ``params.account`` access crashes. webhook_emit.py — warn when ``sender=None`` but the buyer registered ``push_notification_config.url``. Adopters who skip ``webhook_sender`` in ``serve()`` now see a WARNING citing the buyer's URL on first call instead of having buyers complain that their notifications never arrive. mcp_tools.py — bumped ``_resolve_params_pydantic_model`` fallback log from DEBUG to WARNING with explicit remediation guidance (import the model at module scope vs. declare ``params: dict``). Silent dispatch fallback is what hid the wire-dispatch break for two PRs. tests/test_decisioning_wire_dispatch.py (new, 29 tests) — pins: - ``typing.get_type_hints`` resolves on every shim - ``_resolve_params_pydantic_model`` returns the typed Pydantic class - ``create_tool_caller`` round-trips a wire-shape dict through the full dispatch path without the params.account crash Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(decisioning): expert-review P1s on PR #338 wire-dispatch fix Three reviewers (code-reviewer, python-expert, adtech-product-expert) converged on five P1/P2 fixes before merge. Wire-dispatch test: - Parametrize over ``PlatformHandler.advertised_tools`` so any new shim auto-extends regression coverage. Was 14 hardcoded tools (28 cases); now 40 (80 cases). - Pin the typed-dispatch contract end-to-end: assert the platform method actually receives a Pydantic ``GetProductsRequest`` / ``BuildCreativeRequest``, not a raw dict. - Drop hardcoded class-name assertions in favor of ``issubclass(resolved, BaseModel)``. Resolver warning (mcp_tools.py): - Surface the failing class name from ``NameError.name`` directly so adopters don't have to parse the bound-method repr. - Trim historical reference per CLAUDE.md. - Forward-compat note for PEP 749 (3.14 lazy annotations). F12 webhook silent-drop (webhook_emit.py): - Move the ``sender=None`` warning ABOVE the full URL/token extraction. A weird ``params`` shape that makes ``_extract_push_notification_url_and_token`` raise mid-traversal would otherwise re-introduce the silent-drop the previous fix was supposed to eliminate. Cheap pre-check on ``getattr(params, "push_notification_config", None)`` decides whether to warn; URL extraction is best-effort for log context. Test count: 2832 passed (was 2777 — +55 from wider parametrize coverage). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a51c5a4 commit 50c985e

5 files changed

Lines changed: 418 additions & 104 deletions

File tree

src/adcp/decisioning/handler.py

Lines changed: 97 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,103 @@
4242
from adcp.decisioning.webhook_emit import maybe_emit_sync_completion
4343
from adcp.server.base import ADCPHandler, ToolContext
4444

45+
# Pydantic Request/Response types are imported at module scope (NOT
46+
# under TYPE_CHECKING) so that ``typing.get_type_hints(method)`` can
47+
# resolve every shim's ``params`` annotation at runtime. The dispatcher
48+
# at ``adcp.server.mcp_tools._resolve_params_pydantic_model`` walks
49+
# these hints to deserialise wire-shape dicts into the typed Pydantic
50+
# models the shims expect; without runtime visibility, ``get_type_hints``
51+
# raises ``NameError`` on the forward refs (the file uses
52+
# ``from __future__ import annotations``), the resolver swallows the
53+
# exception, and the dispatcher falls back to the dict path — which
54+
# crashes inside the shim with ``'dict' object has no attribute
55+
# 'account'`` (Emma sales-direct backend test, verdict 2/10).
56+
from adcp.types import (
57+
AccountReference,
58+
AcquireRightsRequest,
59+
AcquireRightsResponse,
60+
ActivateSignalRequest,
61+
ActivateSignalSuccessResponse,
62+
BuildCreativeRequest,
63+
BuildCreativeResponse,
64+
CalibrateContentRequest,
65+
CalibrateContentResponse,
66+
CheckGovernanceRequest,
67+
CheckGovernanceResponse,
68+
CreateCollectionListRequest,
69+
CreateCollectionListResponse,
70+
CreateContentStandardsRequest,
71+
CreateContentStandardsResponse,
72+
CreateMediaBuyRequest,
73+
CreateMediaBuySuccessResponse,
74+
CreatePropertyListRequest,
75+
CreatePropertyListResponse,
76+
DeleteCollectionListRequest,
77+
DeleteCollectionListResponse,
78+
DeletePropertyListRequest,
79+
DeletePropertyListResponse,
80+
GetBrandIdentityRequest,
81+
GetBrandIdentitySuccessResponse,
82+
GetCollectionListRequest,
83+
GetCollectionListResponse,
84+
GetContentStandardsRequest,
85+
GetContentStandardsResponse,
86+
GetCreativeDeliveryRequest,
87+
GetCreativeDeliveryResponse,
88+
GetCreativeFeaturesRequest,
89+
GetCreativeFeaturesResponse,
90+
GetMediaBuyArtifactsRequest,
91+
GetMediaBuyArtifactsResponse,
92+
GetMediaBuyDeliveryRequest,
93+
GetMediaBuyDeliveryResponse,
94+
GetMediaBuysRequest,
95+
GetMediaBuysResponse,
96+
GetPlanAuditLogsRequest,
97+
GetPlanAuditLogsResponse,
98+
GetProductsRequest,
99+
GetProductsResponse,
100+
GetPropertyListRequest,
101+
GetPropertyListResponse,
102+
GetRightsRequest,
103+
GetRightsSuccessResponse,
104+
GetSignalsRequest,
105+
GetSignalsResponse,
106+
ListCollectionListsRequest,
107+
ListCollectionListsResponse,
108+
ListContentStandardsRequest,
109+
ListContentStandardsResponse,
110+
ListCreativeFormatsRequest,
111+
ListCreativeFormatsResponse,
112+
ListCreativesRequest,
113+
ListCreativesResponse,
114+
ListPropertyListsRequest,
115+
ListPropertyListsResponse,
116+
PreviewCreativeRequest,
117+
PreviewCreativeResponse,
118+
ProvidePerformanceFeedbackRequest,
119+
ProvidePerformanceFeedbackResponse,
120+
ReportPlanOutcomeRequest,
121+
ReportPlanOutcomeResponse,
122+
SyncAudiencesRequest,
123+
SyncAudiencesSuccessResponse,
124+
SyncCreativesRequest,
125+
SyncCreativesSuccessResponse,
126+
SyncPlansRequest,
127+
SyncPlansResponse,
128+
UpdateCollectionListRequest,
129+
UpdateCollectionListResponse,
130+
UpdateContentStandardsRequest,
131+
UpdateContentStandardsResponse,
132+
UpdateMediaBuyRequest,
133+
UpdateMediaBuySuccessResponse,
134+
UpdatePropertyListRequest,
135+
UpdatePropertyListResponse,
136+
UpdateRightsRequest,
137+
UpdateRightsResponse,
138+
ValidateContentDeliveryRequest,
139+
ValidateContentDeliveryResponse,
140+
)
141+
45142
if TYPE_CHECKING:
46143
from concurrent.futures import ThreadPoolExecutor
47144

@@ -50,91 +147,6 @@
50147
from adcp.decisioning.state import StateReader
51148
from adcp.decisioning.task_registry import TaskRegistry
52149
from adcp.decisioning.types import Account
53-
from adcp.types import (
54-
AccountReference,
55-
AcquireRightsRequest,
56-
AcquireRightsResponse,
57-
ActivateSignalRequest,
58-
ActivateSignalSuccessResponse,
59-
BuildCreativeRequest,
60-
BuildCreativeResponse,
61-
CalibrateContentRequest,
62-
CalibrateContentResponse,
63-
CheckGovernanceRequest,
64-
CheckGovernanceResponse,
65-
CreateCollectionListRequest,
66-
CreateCollectionListResponse,
67-
CreateContentStandardsRequest,
68-
CreateContentStandardsResponse,
69-
CreateMediaBuyRequest,
70-
CreateMediaBuySuccessResponse,
71-
CreatePropertyListRequest,
72-
CreatePropertyListResponse,
73-
DeleteCollectionListRequest,
74-
DeleteCollectionListResponse,
75-
DeletePropertyListRequest,
76-
DeletePropertyListResponse,
77-
GetBrandIdentityRequest,
78-
GetBrandIdentitySuccessResponse,
79-
GetCollectionListRequest,
80-
GetCollectionListResponse,
81-
GetContentStandardsRequest,
82-
GetContentStandardsResponse,
83-
GetCreativeDeliveryRequest,
84-
GetCreativeDeliveryResponse,
85-
GetCreativeFeaturesRequest,
86-
GetCreativeFeaturesResponse,
87-
GetMediaBuyArtifactsRequest,
88-
GetMediaBuyArtifactsResponse,
89-
GetMediaBuyDeliveryRequest,
90-
GetMediaBuyDeliveryResponse,
91-
GetMediaBuysRequest,
92-
GetMediaBuysResponse,
93-
GetPlanAuditLogsRequest,
94-
GetPlanAuditLogsResponse,
95-
GetProductsRequest,
96-
GetProductsResponse,
97-
GetPropertyListRequest,
98-
GetPropertyListResponse,
99-
GetRightsRequest,
100-
GetRightsSuccessResponse,
101-
GetSignalsRequest,
102-
GetSignalsResponse,
103-
ListCollectionListsRequest,
104-
ListCollectionListsResponse,
105-
ListContentStandardsRequest,
106-
ListContentStandardsResponse,
107-
ListCreativeFormatsRequest,
108-
ListCreativeFormatsResponse,
109-
ListCreativesRequest,
110-
ListCreativesResponse,
111-
ListPropertyListsRequest,
112-
ListPropertyListsResponse,
113-
PreviewCreativeRequest,
114-
PreviewCreativeResponse,
115-
ProvidePerformanceFeedbackRequest,
116-
ProvidePerformanceFeedbackResponse,
117-
ReportPlanOutcomeRequest,
118-
ReportPlanOutcomeResponse,
119-
SyncAudiencesRequest,
120-
SyncAudiencesSuccessResponse,
121-
SyncCreativesRequest,
122-
SyncCreativesSuccessResponse,
123-
SyncPlansRequest,
124-
SyncPlansResponse,
125-
UpdateCollectionListRequest,
126-
UpdateCollectionListResponse,
127-
UpdateContentStandardsRequest,
128-
UpdateContentStandardsResponse,
129-
UpdateMediaBuyRequest,
130-
UpdateMediaBuySuccessResponse,
131-
UpdatePropertyListRequest,
132-
UpdatePropertyListResponse,
133-
UpdateRightsRequest,
134-
UpdateRightsResponse,
135-
ValidateContentDeliveryRequest,
136-
ValidateContentDeliveryResponse,
137-
)
138150
from adcp.webhook_sender import WebhookSender
139151

140152

src/adcp/decisioning/webhook_emit.py

Lines changed: 57 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -176,11 +176,18 @@ def maybe_emit_sync_completion(
176176
Skips silently when:
177177
178178
* ``enabled`` is False (operator opted out).
179-
* ``sender`` is None (no emitter wired).
180179
* The request didn't carry ``push_notification_config.url``.
181-
* ``method_name`` isn't in :data:`SPEC_WEBHOOK_TASK_TYPES` (logged
182-
as a warning so adopters notice they extended the tool surface
183-
beyond the spec enum).
180+
181+
Logs a WARNING when:
182+
183+
* ``sender`` is None but the buyer DID register
184+
``push_notification_config.url`` — the buyer's notification
185+
registration is being silently dropped, which the adopter
186+
almost certainly didn't intend. Wire ``webhook_sender`` into
187+
:func:`adcp.decisioning.serve` or pass
188+
``auto_emit_completion_webhooks=False`` to silence this.
189+
* ``method_name`` isn't in :data:`SPEC_WEBHOOK_TASK_TYPES` (the
190+
adopter extended the tool surface beyond the spec enum).
184191
185192
Schedules the actual delivery via the running event loop's
186193
``create_task`` so the sync response path is non-blocking.
@@ -193,8 +200,53 @@ def maybe_emit_sync_completion(
193200
logged-and-swallowed.
194201
"""
195202
try:
196-
if not enabled or sender is None:
203+
if not enabled:
197204
return
205+
206+
# Cheap pre-check: did the buyer register ANY
207+
# ``push_notification_config``? Done BEFORE the full
208+
# extraction so the sender=None warning fires even on weird
209+
# ``params`` shapes that would have made
210+
# ``_extract_push_notification_url_and_token`` raise. The
211+
# outer ``try/except Exception`` would otherwise swallow such
212+
# extraction errors and we'd reproduce the very silent-drop
213+
# behavior this gate is supposed to eliminate.
214+
config = getattr(params, "push_notification_config", None)
215+
if config is None and isinstance(params, dict):
216+
config = params.get("push_notification_config")
217+
if config is None:
218+
return # buyer didn't register — nothing to do
219+
220+
if sender is None:
221+
# Buyer registered a webhook config but the adopter didn't
222+
# wire a sender. Without this branch, the buyer's
223+
# notification quietly disappears — they think they
224+
# registered for completion webhooks and just never
225+
# receive any. Surfacing a warning on first call gives
226+
# the adopter a fast path to the misconfig.
227+
#
228+
# Try to surface the URL for actionable error context;
229+
# fall back to the config repr when extraction raises
230+
# mid-traversal (still better than silent skip).
231+
try:
232+
url_for_log = getattr(config, "url", None)
233+
if url_for_log is None and isinstance(config, dict):
234+
url_for_log = config.get("url")
235+
except Exception:
236+
url_for_log = None
237+
logger.warning(
238+
"[adcp.decisioning] buyer registered "
239+
"push_notification_config (url=%s) for %s but auto-emit "
240+
"webhook_sender is None — webhook silently dropped. "
241+
"Pass webhook_sender to "
242+
"adcp.decisioning.serve.create_adcp_server_from_platform, "
243+
"or set auto_emit_completion_webhooks=False to silence "
244+
"this warning.",
245+
url_for_log if url_for_log else "<unextractable>",
246+
method_name,
247+
)
248+
return
249+
198250
extracted = _extract_push_notification_url_and_token(params)
199251
if extracted is None:
200252
return

src/adcp/server/mcp_tools.py

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1552,8 +1552,12 @@ class when the annotation is:
15521552
references that fail to resolve — the dispatcher then falls back
15531553
to the legacy dict path.
15541554
1555-
Cached per method object via the returned value being computed once
1556-
at ``create_tool_caller`` setup time.
1555+
The result is computed once at ``create_tool_caller`` setup time
1556+
(not per request) and captured in the closure returned to the
1557+
transport layer; warnings fire at server boot, not per call.
1558+
Forward-compat with PEP 749 (3.14 lazy annotations): ``get_type_hints``
1559+
is the supported migration target for runtime annotation
1560+
resolution, so this code keeps working as the language evolves.
15571561
"""
15581562
import typing
15591563
from types import UnionType
@@ -1563,15 +1567,27 @@ class when the annotation is:
15631567
try:
15641568
hints = typing.get_type_hints(method)
15651569
except Exception as exc: # forward-ref failure, missing import, etc.
1566-
# Log at debug so an author whose typed annotation silently
1567-
# failed to resolve (typo in the class name, import not at
1568-
# module top-level, PEP 563 name bound in a local scope) can
1569-
# find out why their handler is dispatching via the dict path.
1570-
logger.debug(
1571-
"typed params annotation failed to resolve for %r: %s; "
1572-
"falling back to dict dispatch",
1570+
# WARNING (not debug): silent dict-path fallback hides shim
1571+
# crashes on ``params.<field>`` access when the typed annotation
1572+
# didn't resolve. Author's choice: declare ``params: dict`` for
1573+
# the dict path, or ensure the typed annotation's class is
1574+
# importable at the method's module scope (not under
1575+
# ``TYPE_CHECKING``).
1576+
#
1577+
# Surface the failing name explicitly so adopters don't have to
1578+
# parse the method repr — ``NameError`` exposes it on
1579+
# ``.name`` on 3.10+. Falls back to ``str(exc)`` for other
1580+
# exception classes (rare).
1581+
failing_name = getattr(exc, "name", None) or str(exc)
1582+
logger.warning(
1583+
"typed params annotation failed to resolve for %r "
1584+
"(unresolved name: %s); falling back to dict dispatch. "
1585+
"If this method declares ``params: <PydanticModel>``, "
1586+
"import that model at the method's module scope (not "
1587+
"under ``TYPE_CHECKING``); otherwise declare "
1588+
"``params: dict[str, Any]`` to silence this warning.",
15731589
method,
1574-
exc,
1590+
failing_name,
15751591
)
15761592
return None
15771593
annotation = hints.get("params")

tests/test_decisioning_webhook_emit.py

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -148,8 +148,16 @@ class _Params:
148148

149149

150150
@pytest.mark.asyncio
151-
async def test_maybe_emit_skips_when_sender_none() -> None:
152-
"""``sender=None`` → silent skip (no emitter wired)."""
151+
async def test_maybe_emit_warns_when_sender_none_but_buyer_registered_url(
152+
caplog: pytest.LogCaptureFixture,
153+
) -> None:
154+
"""``sender=None`` while buyer DID register
155+
``push_notification_config.url`` → log a WARNING. Adopters often
156+
ship without wiring ``webhook_sender`` and only discover the
157+
misconfig when buyers complain about missing notifications. The
158+
warning surfaces this on first call. Regression for Emma
159+
sales-direct backend test (verdict 2/10) — the prior silent-skip
160+
branch hid the gap entirely."""
153161

154162
class _Config:
155163
url = "https://buyer.example.com/wh"
@@ -158,12 +166,35 @@ class _Config:
158166
class _Params:
159167
push_notification_config = _Config()
160168

161-
# Smoke — must not raise.
169+
with caplog.at_level("WARNING", logger="adcp.decisioning.webhook_emit"):
170+
maybe_emit_sync_completion(
171+
sender=None,
172+
enabled=True,
173+
method_name="create_media_buy",
174+
params=_Params(),
175+
result={"media_buy_id": "mb_1"},
176+
)
177+
messages = [r.message for r in caplog.records]
178+
assert any(
179+
"webhook_sender is None" in m and "buyer.example.com/wh" in m for m in messages
180+
), f"expected sender-None warning citing the buyer URL; got {messages}"
181+
182+
183+
@pytest.mark.asyncio
184+
async def test_maybe_emit_skips_silently_when_sender_none_and_no_url() -> None:
185+
"""``sender=None`` AND no ``push_notification_config.url`` → silent
186+
skip. Buyers who don't register webhooks aren't a misconfig — no
187+
point warning."""
188+
189+
class _Bare:
190+
pass
191+
192+
# Smoke — must not raise, must not warn (no caplog capture).
162193
maybe_emit_sync_completion(
163194
sender=None,
164195
enabled=True,
165196
method_name="create_media_buy",
166-
params=_Params(),
197+
params=_Bare(),
167198
result={"media_buy_id": "mb_1"},
168199
)
169200

0 commit comments

Comments
 (0)