|
7 | 7 | import pytest |
8 | 8 | import pytest_asyncio |
9 | 9 |
|
10 | | -from a2a.server.agent_execution.active_task import ActiveTask |
| 10 | +from a2a.server.agent_execution.active_task import ActiveTask, EventConsumer |
11 | 11 | from a2a.server.agent_execution.agent_executor import AgentExecutor |
12 | 12 | from a2a.server.agent_execution.context import RequestContext |
13 | 13 | from a2a.server.context import ServerCallContext |
@@ -234,6 +234,115 @@ async def execute_mock(req, q): |
234 | 234 | assert shared.status.state == TaskState.TASK_STATE_WORKING |
235 | 235 | stop_event.set() |
236 | 236 |
|
| 237 | + @pytest.mark.asyncio |
| 238 | + @pytest.mark.parametrize( |
| 239 | + ('final_state', 'cancel_error_message', 'expected_failed_count'), |
| 240 | + [ |
| 241 | + (TaskState.TASK_STATE_COMPLETED, 'cancel lost the race', 0), |
| 242 | + (TaskState.TASK_STATE_WORKING, 'agent cancel blew up', 1), |
| 243 | + ], |
| 244 | + ids=['terminal-task-not-overwritten', 'active-task-still-marks-failed'], |
| 245 | + ) |
| 246 | + async def test_active_task_cancel_exception_marks_failed_only_when_not_terminal( |
| 247 | + self, |
| 248 | + active_task: ActiveTask, |
| 249 | + agent_executor: Mock, |
| 250 | + request_context: Mock, |
| 251 | + task_manager: Mock, |
| 252 | + final_state: TaskState, |
| 253 | + cancel_error_message: str, |
| 254 | + expected_failed_count: int, |
| 255 | + ) -> None: |
| 256 | + """Regression test: FAILED is enqueued only if the task was not already terminal when cancel() failed.""" |
| 257 | + stop_event = asyncio.Event() |
| 258 | + |
| 259 | + async def execute_mock(req: RequestContext, q: EventQueue) -> None: |
| 260 | + await stop_event.wait() |
| 261 | + |
| 262 | + get_task_calls = 0 |
| 263 | + |
| 264 | + async def get_task_side_effect() -> Task | None: |
| 265 | + nonlocal get_task_calls |
| 266 | + get_task_calls += 1 |
| 267 | + # start()'s existence check needs a non-terminal task so it sets _task_created. |
| 268 | + if get_task_calls == 1: |
| 269 | + return Task( |
| 270 | + id='test-task-id', |
| 271 | + status=TaskStatus(state=TaskState.TASK_STATE_WORKING), |
| 272 | + ) |
| 273 | + return Task(id='test-task-id', status=TaskStatus(state=final_state)) |
| 274 | + |
| 275 | + agent_executor.execute = AsyncMock(side_effect=execute_mock) |
| 276 | + agent_executor.cancel = AsyncMock( |
| 277 | + side_effect=RuntimeError(cancel_error_message) |
| 278 | + ) |
| 279 | + task_manager.get_task = AsyncMock(side_effect=get_task_side_effect) |
| 280 | + |
| 281 | + await active_task.enqueue_request(request_context) |
| 282 | + await active_task.start( |
| 283 | + call_context=ServerCallContext(), create_task_if_missing=True |
| 284 | + ) |
| 285 | + await asyncio.sleep(0.1) |
| 286 | + # Precondition: the guard under test only applies once this is set. |
| 287 | + assert active_task._task_created.is_set() |
| 288 | + |
| 289 | + with patch.object( |
| 290 | + active_task._event_queue_agent, |
| 291 | + 'enqueue_event', |
| 292 | + wraps=active_task._event_queue_agent.enqueue_event, |
| 293 | + ) as enqueue_spy: |
| 294 | + with pytest.raises(RuntimeError, match=cancel_error_message): |
| 295 | + await active_task.cancel(request_context) |
| 296 | + |
| 297 | + failed_writes = [ |
| 298 | + call.args[0] |
| 299 | + for call in enqueue_spy.await_args_list |
| 300 | + if isinstance(call.args[0], TaskStatusUpdateEvent) |
| 301 | + and call.args[0].status.state == TaskState.TASK_STATE_FAILED |
| 302 | + ] |
| 303 | + assert len(failed_writes) == expected_failed_count |
| 304 | + assert all(write.task_id == 'test-task-id' for write in failed_writes) |
| 305 | + |
| 306 | + stop_event.set() |
| 307 | + await active_task.aclose() |
| 308 | + |
| 309 | + @pytest.mark.asyncio |
| 310 | + async def test_event_consumer_drops_status_update_for_terminal_task( |
| 311 | + self, |
| 312 | + active_task: ActiveTask, |
| 313 | + task_manager: Mock, |
| 314 | + ) -> None: |
| 315 | + """Regression test: FAILED update enqueued before the task turned terminal must be dropped at apply-time too.""" |
| 316 | + task_manager.get_task = AsyncMock( |
| 317 | + return_value=Task( |
| 318 | + id='test-task-id', |
| 319 | + status=TaskStatus(state=TaskState.TASK_STATE_COMPLETED), |
| 320 | + ) |
| 321 | + ) |
| 322 | + |
| 323 | + stale_failed_event = TaskStatusUpdateEvent( |
| 324 | + task_id='test-task-id', |
| 325 | + status=TaskStatus(state=TaskState.TASK_STATE_FAILED), |
| 326 | + ) |
| 327 | + await active_task._event_queue_agent.enqueue_event(stale_failed_event) |
| 328 | + # Round-trip through the queue so task_done() below has a matching get(). |
| 329 | + dequeued = await active_task._event_queue_agent.dequeue_event() |
| 330 | + |
| 331 | + # Not started: exercising the consumer's guard directly needs no |
| 332 | + # producer/consumer loop. |
| 333 | + consumer = EventConsumer(active_task) |
| 334 | + with patch.object( |
| 335 | + active_task._event_queue_subscribers, |
| 336 | + 'enqueue_event', |
| 337 | + wraps=active_task._event_queue_subscribers.enqueue_event, |
| 338 | + ) as subscriber_spy: |
| 339 | + await consumer._process_event(dequeued) |
| 340 | + |
| 341 | + # Dropped event must reach neither TaskManager nor subscribers. |
| 342 | + task_manager.ensure_task_id.assert_not_awaited() |
| 343 | + task_manager.process.assert_not_awaited() |
| 344 | + subscriber_spy.assert_not_awaited() |
| 345 | + |
237 | 346 | @pytest.mark.asyncio |
238 | 347 | async def test_active_task_interrupted_auth( |
239 | 348 | self, |
|
0 commit comments