"""Cancellation provenance must survive the real runtime-to-model path.""" import asyncio from copy import deepcopy import pytest from artemis.llm.final_review import FinalReviewBudget from artemis.llm.reliability import LLMExhaustedError from artemis.runtime.cancellation import CancellationReason from artemis.runtime import task_control from artemis.sdk.agent import Agent from artemis.services import llm class Engine: current_session_id = "cancellation-test" current_step_id = None def __init__(self): self.records = [] def record_trace(self, **record): self.records.append(deepcopy(record)) def _publish(self, *_): pass @property def attempt(self): return next(r["payload"] for r in reversed(self.records) if r["type"] == "llm_diagnostic") @pytest.mark.asyncio async def test_guidance_cancellation_reaches_request_without_retry(tmp_path, monkeypatch): monkeypatch.setattr(task_control, "TRACES_PATH", tmp_path) engine = Engine() started = asyncio.Event() calls = 0 async def model(): nonlocal calls calls += 1 started.set() await asyncio.Event().wait() control = task_control.TaskControl("task", object()) review = FinalReviewBudget( engine, attempt_id="final#1", request_timeout=10, max_retries=5, retry_delay=0 ) running = asyncio.create_task( control.run_interruptibly( review.run(lambda: llm._run_with_recovery(model, max_retries=5, engine=engine)) ) ) await asyncio.wait_for(started.wait(), 2) task_control.add_instruction("task", "Use the newly observed page") assert await asyncio.wait_for(running, 2) == (True, None) assert engine.attempt["phase"] == "cancelled" assert engine.attempt["cancel_reason"] == "guidance_replaced" assert review.data["status"] == "cancelled" assert review.data["cancel_reason"] == "guidance_replaced" assert calls == 1 @pytest.mark.asyncio async def test_sdk_stop_reaches_request_without_retry(): engine = Engine() started = asyncio.Event() async def model(): started.set() await asyncio.Event().wait() agent = Agent.__new__(Agent) agent._current_task = asyncio.create_task( llm._run_with_recovery(model, max_retries=5, engine=engine) ) await asyncio.wait_for(started.wait(), 2) agent.stop_current_task(reason=CancellationReason.USER_STOP) with pytest.raises(asyncio.CancelledError): await agent._current_task assert engine.attempt["phase"] == "cancelled" assert engine.attempt["cancel_reason"] == "user_stop" @pytest.mark.asyncio async def test_outer_timeout_is_not_a_request_timeout(): engine = Engine() async def model(): await asyncio.Event().wait() with pytest.raises(TimeoutError): await llm.invoke_llm_with_timeout_message( llm._run_with_recovery(model, max_retries=5, engine=engine), timeout_seconds=0, hard_timeout=0, ) assert engine.attempt["phase"] == "cancelled" assert engine.attempt["cancel_reason"] == "outer_timeout" assert engine.attempt["timeout_scope"] == "outer" @pytest.mark.asyncio async def test_sdk_stop_preserves_cause_through_nested_request_wrappers(tmp_path, monkeypatch): monkeypatch.setattr(task_control, "TRACES_PATH", tmp_path) engine = Engine() started = asyncio.Event() async def model(): started.set() await asyncio.Event().wait() control = task_control.TaskControl("task", object()) agent = Agent.__new__(Agent) agent._current_task = asyncio.create_task( control.run_interruptibly( llm.invoke_llm_with_timeout_message( llm._run_with_recovery(model, max_retries=5, engine=engine), timeout_seconds=60 ) ) ) await asyncio.wait_for(started.wait(), 2) agent.stop_current_task() with pytest.raises(asyncio.CancelledError): await asyncio.wait_for(agent._current_task, 2) assert engine.attempt["phase"] == "cancelled" assert engine.attempt["cancel_reason"] == "user_stop" @pytest.mark.asyncio async def test_request_timer_is_identified_without_triggering_an_extra_request(): engine = Engine() token = llm._FALLBACK_AVAILABLE.set(True) calls = 0 async def model(): nonlocal calls calls += 1 await asyncio.Event().wait() try: with pytest.raises(LLMExhaustedError): await llm._run_with_recovery(model, request_timeout=0.01, max_retries=0, engine=engine) finally: llm._FALLBACK_AVAILABLE.reset(token) assert engine.attempt["phase"] == "failed" assert engine.attempt["timeout_scope"] == "request" assert "cancel_reason" not in engine.attempt assert calls == 1 @pytest.mark.asyncio async def test_unattributed_cancellation_does_not_infer_a_user_action_or_store_its_text(): engine = Engine() started = asyncio.Event() async def model(): started.set() await asyncio.Event().wait() running = asyncio.create_task(llm._run_with_recovery(model, max_retries=5, engine=engine)) await asyncio.wait_for(started.wait(), 2) running.cancel("private user guidance") with pytest.raises(asyncio.CancelledError): await running assert engine.attempt["cancel_reason"] == "unknown" assert "private user guidance" not in repr(engine.records) @pytest.mark.asyncio async def test_http_499_preserves_server_id_but_does_not_infer_local_cancellation(): engine = Engine() token = llm._FALLBACK_AVAILABLE.set(True) class ServerError(Exception): status_code = 499 request_id = "server-request-499" async def model(): raise ServerError("Request closed") try: with pytest.raises(LLMExhaustedError): await llm._run_with_recovery(model, max_retries=0, engine=engine) finally: llm._FALLBACK_AVAILABLE.reset(token) assert engine.attempt["phase"] == "failed" assert engine.attempt["http_status"] == 499 assert engine.attempt["server_request_id"] == "server-request-499" assert "cancel_reason" not in engine.attempt @pytest.mark.asyncio async def test_pause_cancellation_uses_the_existing_checkpoint(tmp_path, monkeypatch): monkeypatch.setattr(task_control, "TRACES_PATH", tmp_path) engine = Engine() started = asyncio.Event() paused = asyncio.Event() events = [] class Lock: def release(self): events.append("released") async def model(): started.set() await asyncio.Event().wait() async def checkpoint(**_kwargs): events.append("checkpoint") paused.set() control = task_control.TaskControl("task", Lock()) monkeypatch.setattr(control, "checkpoint", checkpoint) running = asyncio.create_task( control.run_interruptibly(llm._run_with_recovery(model, max_retries=5, engine=engine)) ) await asyncio.wait_for(started.wait(), 2) task_control.request_pause("task") await asyncio.wait_for(paused.wait(), 2) assert await running == (True, None) assert engine.attempt["cancel_reason"] == "task_paused" assert events == ["checkpoint"]