# -*- coding: utf-8 -*- # pylint: disable=protected-access """Waiting for another session's turn at the desktop. The helper refuses immediately when another session holds the desktop rather than queueing the request, so the waiting happens on this side. That placement is the point: a thread parked inside the helper is not reading its connection, so a stop could not reach it, and it would wake later to act on a desktop the user had already withdrawn. """ from __future__ import annotations import asyncio from typing import Any import pytest from computer_use_tool import client as client_module from computer_use_tool.client import ComputerUseClient from computer_use_tool.protocol import ComputerUseProtocolError class _Transport: """A transport that reports the desktop busy a fixed number of times.""" def __init__(self, busy_replies: int) -> None: self.busy_replies = busy_replies self.attempts = 0 self.closed = False def set_reverse_request_handler(self, _handler: Any) -> None: return None async def connect(self) -> None: return None async def close(self) -> None: self.closed = True async def request(self, message: dict[str, Any]) -> dict[str, Any]: self.attempts += 1 if self.attempts <= self.busy_replies: raise ComputerUseProtocolError( "desktop_busy", "Another Computer Use session is using the desktop.", ) return { "protocol_version": message["protocol_version"], "request_id": message["request_id"], "ok": True, "result": {"done": True}, } @pytest.fixture(autouse=True) def _fast_retries(monkeypatch: pytest.MonkeyPatch): """Keep the backoff from making the suite wait for real seconds.""" monkeypatch.setattr(client_module, "_DESKTOP_BUSY_DELAY_SECONDS", 0.001) yield @pytest.mark.asyncio async def test_a_busy_desktop_is_retried_until_it_frees_up( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr( client_module, "get_current_computer_use_turn_id", lambda: "turn-1", ) transport = _Transport(busy_replies=2) client = ComputerUseClient( "session-1", transport_factory=lambda: transport, ) result = await client.execute("click", {"observation_id": "observation-1"}) assert result == {"done": True} assert transport.attempts == 3, "should have retried past both refusals" @pytest.mark.asyncio async def test_a_desktop_busy_for_too_long_is_reported( monkeypatch: pytest.MonkeyPatch, ) -> None: """The model is told, rather than left waiting indefinitely.""" monkeypatch.setattr( client_module, "get_current_computer_use_turn_id", lambda: "turn-1", ) transport = _Transport(busy_replies=99) client = ComputerUseClient( "session-1", transport_factory=lambda: transport, ) with pytest.raises(ComputerUseProtocolError) as refusal: await client.execute("click", {"observation_id": "observation-1"}) assert refusal.value.code == "desktop_busy" assert transport.attempts == client_module._DESKTOP_BUSY_ATTEMPTS @pytest.mark.asyncio async def test_a_stop_ends_the_retrying( monkeypatch: pytest.MonkeyPatch, ) -> None: """The reason the waiting belongs on this side. A stop arriving while the desktop is busy must end the attempts. Inside the helper the same wait could not be interrupted, and the queued input would land after the user had already stopped it. """ monkeypatch.setattr( client_module, "get_current_computer_use_turn_id", lambda: "turn-1", ) transport = _Transport(busy_replies=99) client = ComputerUseClient( "session-1", transport_factory=lambda: transport, ) async def _stop_once_contended() -> None: while transport.attempts < 1: await asyncio.sleep(0) client._stopped_turn = "turn-1" stopper = asyncio.create_task(_stop_once_contended()) with pytest.raises(ComputerUseProtocolError) as refusal: await client.execute("click", {"observation_id": "observation-1"}) await stopper assert refusal.value.code == "turn_stopped" assert ( transport.attempts < client_module._DESKTOP_BUSY_ATTEMPTS ), "the retrying should have stopped early rather than using every attempt" @pytest.mark.asyncio async def test_other_failures_are_not_retried( monkeypatch: pytest.MonkeyPatch, ) -> None: """Only this refusal is safe to repeat. It is raised before the helper touches anything. A timeout is not: the action may already have happened, and repeating it would click twice. """ monkeypatch.setattr( client_module, "get_current_computer_use_turn_id", lambda: "turn-1", ) class _Timeout(_Transport): async def request(self, message: dict[str, Any]) -> dict[str, Any]: self.attempts += 1 raise ComputerUseProtocolError("request_timeout", "too slow") transport = _Timeout(busy_replies=0) client = ComputerUseClient( "session-1", transport_factory=lambda: transport, ) with pytest.raises(ComputerUseProtocolError) as failure: await client.execute("click", {"observation_id": "observation-1"}) assert failure.value.code == "request_timeout" assert transport.attempts == 1, "a timeout must be reported, not repeated" @pytest.mark.asyncio async def test_user_intervention_is_a_retryable_soft_refusal( monkeypatch: pytest.MonkeyPatch, ) -> None: """The recency guard's refusal must not tear down the connection. Removing the post-approval exemption means an action right after an approval can be refused as ``user_intervention``. The design relies on that being recoverable: the caller observes again and reissues once the grace has passed. So it has to surface as a plain action-level error on a live connection -- not discard the transport the way a broken pipe would, or the retry would pay for a fresh connect every time. """ monkeypatch.setattr( client_module, "get_current_computer_use_turn_id", lambda: "turn-1", ) class _Intervene(_Transport): async def request(self, message: dict[str, Any]) -> dict[str, Any]: self.attempts += 1 if self.attempts == 1: raise ComputerUseProtocolError( "user_intervention", "Recent user input was detected; observe again.", ) return { "protocol_version": message["protocol_version"], "request_id": message["request_id"], "ok": True, "result": {"done": True}, } transport = _Intervene(busy_replies=0) client = ComputerUseClient( "session-1", transport_factory=lambda: transport, ) with pytest.raises(ComputerUseProtocolError) as refusal: await client.execute("click", {"observation_id": "observation-1"}) assert refusal.value.code == "user_intervention" # The connection is intact: not closed, and not retried behind the caller's # back -- a soft refusal, not a transport failure. assert transport.closed is False assert transport.attempts == 1 # The caller observes again and reissues on the same connection; it works. result = await client.execute("click", {"observation_id": "observation-1"}) assert result == {"done": True} assert transport.attempts == 2