Files
Artemis-Eapil/tests/unit/test_cancellation_diagnostics.py
T
chenmingxuan fac4ccb760 feat: prepare 2.2.0 with UI and execution fixes
Add dark theme, richer Markdown and readable plugin results.
Improve Android input evidence, response integrity and diagnostics.
Unify lifecycle, guidance receipts and evidence sources across clients.
Keep direct action dispatch without an added AI quota approval layer.
Align version metadata at 2.2.0 and bundle Helper 1.2.2.

Validation: 3063 backend tests before the version bump; 44 after.
Frontend: 317 tests and production build passed.
2026-10-26 00:44:13 +08:00

226 lines
7.1 KiB
Python

"""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"]