From 1c07046332ef3f724df0898daca026fc1e8f761a Mon Sep 17 00:00:00 2001 From: Backend Engineer Date: Wed, 15 Apr 2026 17:58:10 +0000 Subject: [PATCH] fix(a2a): cancel() event, stateTransitionHistory capability, wire push store (#173 #174 #175) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #173 — implement cancel() in LangGraphA2AExecutor: emits TaskStatusUpdateEvent(state=canceled, final=True) so clients see the state transition rather than silence. Removes pragma: no cover. Test: test_cancel_emits_canceled_event. #174 — add stateTransitionHistory=True to AgentCapabilities in main.py so microsoft/agent-framework clients know they can request full task history via the A2A protocol. #175 — wire InMemoryPushNotificationConfigStore and PushNotificationSender into DefaultRequestHandler so the advertised pushNotifications capability is backed by a real store. Both classes live in a2a.server.tasks (a2a-sdk 0.3.25); import confirmed by probe. Co-Authored-By: Claude Sonnet 4.6 --- workspace-template/a2a_executor.py | 12 ++++-- workspace-template/main.py | 5 ++- workspace-template/tests/test_a2a_executor.py | 43 +++++++++++++++++++ 3 files changed, 56 insertions(+), 4 deletions(-) diff --git a/workspace-template/a2a_executor.py b/workspace-template/a2a_executor.py index 59a3a79c..ebe40087 100644 --- a/workspace-template/a2a_executor.py +++ b/workspace-template/a2a_executor.py @@ -408,6 +408,12 @@ class LangGraphA2AExecutor(AgentExecutor): return _result - async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None: # pragma: no cover - """Cancel a running task (cancellation via asyncio task cancellation).""" - pass + async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None: + """Cancel a running task — emits canceled state to comply with A2A protocol.""" + from a2a.types import TaskStatus, TaskState, TaskStatusUpdateEvent + await event_queue.enqueue_event( + TaskStatusUpdateEvent( + status=TaskStatus(state=TaskState.canceled), + final=True, + ) + ) diff --git a/workspace-template/main.py b/workspace-template/main.py index 9f8c4fca..d54e7bb3 100644 --- a/workspace-template/main.py +++ b/workspace-template/main.py @@ -12,7 +12,7 @@ import httpx import uvicorn from a2a.server.apps import A2AStarletteApplication from a2a.server.request_handlers import DefaultRequestHandler -from a2a.server.tasks import InMemoryTaskStore +from a2a.server.tasks import InMemoryTaskStore, InMemoryPushNotificationConfigStore, PushNotificationSender from a2a.types import AgentCard, AgentCapabilities, AgentSkill from adapters import get_adapter, AdapterConfig @@ -136,6 +136,7 @@ async def main(): # pragma: no cover capabilities=AgentCapabilities( streaming=config.a2a.streaming, pushNotifications=config.a2a.push_notifications, + stateTransitionHistory=True, ), skills=[ AgentSkill( @@ -155,6 +156,8 @@ async def main(): # pragma: no cover handler = DefaultRequestHandler( agent_executor=executor, task_store=InMemoryTaskStore(), + push_config_store=InMemoryPushNotificationConfigStore(), + push_sender=PushNotificationSender(), ) app = A2AStarletteApplication( diff --git a/workspace-template/tests/test_a2a_executor.py b/workspace-template/tests/test_a2a_executor.py index 3c0eba11..9194cd96 100644 --- a/workspace-template/tests/test_a2a_executor.py +++ b/workspace-template/tests/test_a2a_executor.py @@ -998,3 +998,46 @@ def test_default_recursion_limit_value(): """Regression guard: DeepAgents fan-outs need 100+; 500 is today's ceiling.""" from a2a_executor import DEFAULT_RECURSION_LIMIT assert DEFAULT_RECURSION_LIMIT == 500 + + +# --------------------------------------------------------------------------- +# Issue #173 — cancel() emits TaskStatusUpdateEvent(state=canceled, final=True) +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_cancel_emits_canceled_event(monkeypatch): + """cancel() must enqueue a TaskStatusUpdateEvent with state=canceled and final=True. + + The a2a.types module is pre-mocked by conftest; inject the three extra + type stubs needed by cancel() so the local import inside the method resolves. + """ + import sys + types_mod = sys.modules["a2a.types"] + + class _TaskState: + canceled = "canceled" + + class _TaskStatus: + def __init__(self, state=None): + self.state = state + + class _TaskStatusUpdateEvent: + def __init__(self, status=None, final=False): + self.status = status + self.final = final + + monkeypatch.setattr(types_mod, "TaskState", _TaskState, raising=False) + monkeypatch.setattr(types_mod, "TaskStatus", _TaskStatus, raising=False) + monkeypatch.setattr(types_mod, "TaskStatusUpdateEvent", _TaskStatusUpdateEvent, raising=False) + + executor = LangGraphA2AExecutor(agent=MagicMock(), heartbeat=None) + context = _make_context([]) + eq = _make_event_queue() + + await executor.cancel(context, eq) + + eq.enqueue_event.assert_called_once() + event = eq.enqueue_event.call_args[0][0] + assert isinstance(event, _TaskStatusUpdateEvent), "expected a TaskStatusUpdateEvent" + assert event.final is True, "cancel event must be marked final=True" + assert event.status.state == _TaskState.canceled, "cancel event must have state=canceled"