|
15 | 15 |
|
16 | 16 | import json |
17 | 17 | import threading |
| 18 | +import time |
18 | 19 | import uuid |
19 | 20 |
|
20 | 21 | import pytest |
@@ -189,6 +190,48 @@ def process_query(self, query, max_steps=None): |
189 | 190 | raise ValueError("triage produced malformed JSON at row 4") |
190 | 191 |
|
191 | 192 |
|
| 193 | +class _RecoverableRetryFakeAgent: |
| 194 | + """Reproduces #2515: a per-tool error the agent loop is retrying (e.g. the |
| 195 | + live repro — ``archive_message_batch`` called with a spurious ``mailbox`` |
| 196 | + kwarg), NOT a fatal top-level failure. Pauses right after emitting the |
| 197 | + recoverable error so the test can inspect ``run.cancel_event`` / |
| 198 | + ``handler.cancelled`` BEFORE the retry step runs — proving the streaming |
| 199 | + layer didn't cut the response and cancel the still-retrying agent out |
| 200 | + from under it. |
| 201 | + """ |
| 202 | + |
| 203 | + def __init__(self): |
| 204 | + self.conversation_history = [] |
| 205 | + self.console = None |
| 206 | + self._cancel_event = None |
| 207 | + self.error_emitted = threading.Event() |
| 208 | + |
| 209 | + def process_query(self, query, max_steps=None): |
| 210 | + self.console.print_processing_start(query, 20, "fake-model") |
| 211 | + self.console.print_step_header(1, 20) |
| 212 | + self.console.print_tool_usage("archive_message_batch") |
| 213 | + self.console.print_error( |
| 214 | + "Unexpected argument(s) for archive_message_batch: mailbox. " |
| 215 | + "Accepted argument(s): message_ids.", |
| 216 | + recoverable=True, |
| 217 | + ) |
| 218 | + self.error_emitted.set() |
| 219 | + # Give the streaming layer a beat to process the queued event (and, |
| 220 | + # pre-fix, cut the stream + cancel this run) before the retry. |
| 221 | + if self._cancel_event is not None: |
| 222 | + self._cancel_event.wait(timeout=2) |
| 223 | + if self._cancel_event.is_set(): |
| 224 | + self.console.print_final_answer("Cancelled.", streaming=False) |
| 225 | + return {"answer": "Cancelled."} |
| 226 | + self.console.print_step_header(2, 20) |
| 227 | + self.console.print_tool_usage("archive_message_batch") |
| 228 | + self.console.pretty_print_json({"message_ids": ["m1"]}, title="Arguments") |
| 229 | + self.console.pretty_print_json({"archived": 1}) |
| 230 | + self.console.print_tool_complete() |
| 231 | + self.console.print_final_answer("Archived 1 message.", streaming=False) |
| 232 | + return {"answer": "Archived 1 message."} |
| 233 | + |
| 234 | + |
192 | 235 | class _InternalErrorFakeAgent: |
193 | 236 | """Mimics the base agent's Lemonade-down branch: it sets an actionable |
194 | 237 | ``final_answer`` and returns a failed result WITHOUT calling |
@@ -347,6 +390,51 @@ def test_cancel_unknown_run_id_is_404(app_client): |
347 | 390 | assert resp.status_code == 404 |
348 | 391 |
|
349 | 392 |
|
| 393 | +# --------------------------------------------------------------------------- |
| 394 | +# #2515 — a recoverable per-tool error must not end the stream or cancel the |
| 395 | +# still-retrying agent |
| 396 | +# --------------------------------------------------------------------------- |
| 397 | + |
| 398 | + |
| 399 | +def test_recoverable_tool_error_does_not_terminate_stream_or_cancel_run(monkeypatch): |
| 400 | + fake = _RecoverableRetryFakeAgent() |
| 401 | + monkeypatch.setattr(query_routes, "build_query_agent", lambda **k: fake) |
| 402 | + client = TestClient(export_openapi.build_app()) |
| 403 | + run_id = str(uuid.uuid4()) |
| 404 | + collected = {} |
| 405 | + |
| 406 | + def _stream(): |
| 407 | + resp = client.post( |
| 408 | + "/v1/email/query", |
| 409 | + json={"query": "archive stuff", "run_id": run_id, "context": []}, |
| 410 | + ) |
| 411 | + collected["text"] = resp.text |
| 412 | + |
| 413 | + t = threading.Thread(target=_stream, daemon=True) |
| 414 | + t.start() |
| 415 | + |
| 416 | + assert fake.error_emitted.wait(timeout=10), "recoverable error never emitted" |
| 417 | + # Give the async stream generator a moment to drain the queued |
| 418 | + # ``agent_error`` event through the translator before asserting nothing |
| 419 | + # tore the run down in response to it. |
| 420 | + time.sleep(0.3) |
| 421 | + run = query_routes.registry.get(run_id) |
| 422 | + assert run is not None, "run ended prematurely — was cancelled before the retry" |
| 423 | + assert not run.cancel_event.is_set(), "recoverable error set the cancel event" |
| 424 | + assert not run.handler.cancelled.is_set(), "recoverable error cancelled the handler" |
| 425 | + |
| 426 | + t.join(timeout=10) |
| 427 | + events = _parse_sse(collected["text"]) |
| 428 | + types = _types(events) |
| 429 | + # Both the failed attempt (step 1) AND the retried attempt (step 2) got |
| 430 | + # their tool_call streamed — proving the loop was not cut off after the |
| 431 | + # recoverable error and reached completion (#2515). |
| 432 | + assert types.count("tool_call") == 2 |
| 433 | + assert types.count("error") == 0 |
| 434 | + assert types[-1] == "final" |
| 435 | + assert events[-1]["answer"] == "Archived 1 message." |
| 436 | + |
| 437 | + |
350 | 438 | # --------------------------------------------------------------------------- |
351 | 439 | # Error path — a failed run ends with a terminal error event |
352 | 440 | # --------------------------------------------------------------------------- |
|
0 commit comments