chore: import upstream snapshot with attribution
CI: cua-driver distro-compat matrix / debian:12 (glibc 2.36) (push) Has been cancelled
CI: SPDX Headers / Check SPDX headers (warn-only) (push) Has been cancelled
CD: Docs MCP Server / build (linux/amd64) (push) Has been cancelled
CD: Docs MCP Server / build (linux/arm64) (push) Has been cancelled
CD: Docs MCP Server / merge (push) Has been cancelled
CI: cua-driver distro-compat matrix / Resolve release version (push) Has been cancelled
CI: cua-driver distro-compat matrix / fedora:41 (glibc 2.40) (push) Has been cancelled
CI: cua-driver distro-compat matrix / rockylinux:9 (glibc 2.34) (push) Has been cancelled
CI: cua-driver distro-compat matrix / ubuntu:22.04 (glibc 2.35) (push) Has been cancelled
CI: cua-driver distro-compat matrix / ubuntu:24.04 (glibc 2.39) (push) Has been cancelled
CI: cua-driver distro-compat matrix / Distro compat summary (push) Has been cancelled
CI: Rust Linux unit / Rust Linux unit and compile (push) Has been cancelled
CI: Rust Windows unit / Rust Windows unit and compile (push) Has been cancelled
CI: Nix Linux Rust source / Nix / compositor build (push) Has been cancelled
CI: Nix Linux Rust source / Nix / driver package (push) Has been cancelled
CI: Nix Linux Rust source / Nix / Rust unit tests (push) Has been cancelled
CI: cua-driver distro-compat matrix / debian:12 (glibc 2.36) (push) Has been cancelled
CI: SPDX Headers / Check SPDX headers (warn-only) (push) Has been cancelled
CD: Docs MCP Server / build (linux/amd64) (push) Has been cancelled
CD: Docs MCP Server / build (linux/arm64) (push) Has been cancelled
CD: Docs MCP Server / merge (push) Has been cancelled
CI: cua-driver distro-compat matrix / Resolve release version (push) Has been cancelled
CI: cua-driver distro-compat matrix / fedora:41 (glibc 2.40) (push) Has been cancelled
CI: cua-driver distro-compat matrix / rockylinux:9 (glibc 2.34) (push) Has been cancelled
CI: cua-driver distro-compat matrix / ubuntu:22.04 (glibc 2.35) (push) Has been cancelled
CI: cua-driver distro-compat matrix / ubuntu:24.04 (glibc 2.39) (push) Has been cancelled
CI: cua-driver distro-compat matrix / Distro compat summary (push) Has been cancelled
CI: Rust Linux unit / Rust Linux unit and compile (push) Has been cancelled
CI: Rust Windows unit / Rust Windows unit and compile (push) Has been cancelled
CI: Nix Linux Rust source / Nix / compositor build (push) Has been cancelled
CI: Nix Linux Rust source / Nix / driver package (push) Has been cancelled
CI: Nix Linux Rust source / Nix / Rust unit tests (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
"""Pytest configuration and shared fixtures for agent package tests.
|
||||
|
||||
This file contains shared fixtures and configuration for all agent tests.
|
||||
Following SRP: This file ONLY handles test setup/teardown.
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_litellm():
|
||||
"""Mock liteLLM completion calls.
|
||||
|
||||
Use this fixture to avoid making real LLM API calls during tests.
|
||||
Returns a mock that simulates LLM responses.
|
||||
"""
|
||||
with patch("litellm.acompletion") as mock_completion:
|
||||
|
||||
async def mock_response(*args, **kwargs):
|
||||
"""Simulate a typical LLM response."""
|
||||
return {
|
||||
"id": "chatcmpl-test123",
|
||||
"object": "chat.completion",
|
||||
"created": 1234567890,
|
||||
"model": kwargs.get("model", "anthropic/claude-sonnet-4-5-20250929"),
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": "This is a mocked response for testing.",
|
||||
},
|
||||
"finish_reason": "stop",
|
||||
}
|
||||
],
|
||||
"usage": {
|
||||
"prompt_tokens": 10,
|
||||
"completion_tokens": 20,
|
||||
"total_tokens": 30,
|
||||
},
|
||||
}
|
||||
|
||||
mock_completion.side_effect = mock_response
|
||||
yield mock_completion
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_computer():
|
||||
"""Mock Computer interface for agent tests.
|
||||
|
||||
Use this fixture to test agent logic without requiring a real Computer instance.
|
||||
"""
|
||||
computer = AsyncMock()
|
||||
computer.interface = AsyncMock()
|
||||
computer.interface.screenshot = AsyncMock(return_value=b"fake_screenshot_data")
|
||||
computer.interface.left_click = AsyncMock()
|
||||
computer.interface.type = AsyncMock()
|
||||
computer.interface.key = AsyncMock()
|
||||
|
||||
# Mock context manager
|
||||
computer.__aenter__ = AsyncMock(return_value=computer)
|
||||
computer.__aexit__ = AsyncMock()
|
||||
|
||||
return computer
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def disable_telemetry(monkeypatch):
|
||||
"""Disable telemetry for tests.
|
||||
|
||||
Use this fixture to ensure no telemetry is sent during tests.
|
||||
"""
|
||||
monkeypatch.setenv("CUA_TELEMETRY_ENABLED", "false")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_messages():
|
||||
"""Provide sample messages for testing.
|
||||
|
||||
Returns a list of messages in the expected format.
|
||||
"""
|
||||
return [{"role": "user", "content": "Take a screenshot and tell me what you see"}]
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Unit tests for ComputerAgent class.
|
||||
|
||||
This file tests ONLY the ComputerAgent initialization and basic functionality.
|
||||
Following SRP: This file tests ONE class (ComputerAgent).
|
||||
All external dependencies (liteLLM, Computer) are mocked.
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestComputerAgentInitialization:
|
||||
"""Test ComputerAgent initialization (SRP: Only tests initialization)."""
|
||||
|
||||
@patch("cua_agent.agent.litellm")
|
||||
def test_agent_initialization_with_model(self, mock_litellm, disable_telemetry):
|
||||
"""Test that agent can be initialized with a model string."""
|
||||
from cua_agent import ComputerAgent
|
||||
|
||||
agent = ComputerAgent(model="anthropic/claude-sonnet-4-5-20250929")
|
||||
|
||||
assert agent is not None
|
||||
assert hasattr(agent, "model")
|
||||
assert agent.model == "anthropic/claude-sonnet-4-5-20250929"
|
||||
|
||||
@patch("cua_agent.agent.litellm")
|
||||
def test_agent_initialization_with_tools(self, mock_litellm, disable_telemetry, mock_computer):
|
||||
"""Test that agent can be initialized with tools."""
|
||||
from cua_agent import ComputerAgent
|
||||
|
||||
agent = ComputerAgent(model="anthropic/claude-sonnet-4-5-20250929", tools=[mock_computer])
|
||||
|
||||
assert agent is not None
|
||||
assert hasattr(agent, "tools")
|
||||
|
||||
@patch("cua_agent.agent.litellm")
|
||||
def test_agent_initialization_with_max_budget(self, mock_litellm, disable_telemetry):
|
||||
"""Test that agent can be initialized with max trajectory budget."""
|
||||
from cua_agent import ComputerAgent
|
||||
|
||||
budget = 5.0
|
||||
agent = ComputerAgent(
|
||||
model="anthropic/claude-sonnet-4-5-20250929", max_trajectory_budget=budget
|
||||
)
|
||||
|
||||
assert agent is not None
|
||||
|
||||
@patch("cua_agent.agent.litellm")
|
||||
def test_agent_requires_model(self, mock_litellm, disable_telemetry):
|
||||
"""Test that agent requires a model parameter."""
|
||||
from cua_agent import ComputerAgent
|
||||
|
||||
with pytest.raises(TypeError):
|
||||
# Should fail without model parameter - intentionally missing required argument
|
||||
ComputerAgent() # type: ignore[call-arg]
|
||||
|
||||
|
||||
class TestComputerAgentRun:
|
||||
"""Test ComputerAgent.run() method (SRP: Only tests run logic)."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("cua_agent.agent.litellm")
|
||||
async def test_agent_run_with_messages(self, mock_litellm, disable_telemetry, sample_messages):
|
||||
"""Test that agent.run() works with valid messages."""
|
||||
from cua_agent import ComputerAgent
|
||||
|
||||
# Mock liteLLM response
|
||||
mock_response = {
|
||||
"id": "chatcmpl-test",
|
||||
"choices": [
|
||||
{
|
||||
"message": {"role": "assistant", "content": "Test response"},
|
||||
"finish_reason": "stop",
|
||||
}
|
||||
],
|
||||
"usage": {"prompt_tokens": 10, "completion_tokens": 20, "total_tokens": 30},
|
||||
}
|
||||
|
||||
mock_litellm.acompletion = AsyncMock(return_value=mock_response)
|
||||
|
||||
agent = ComputerAgent(model="anthropic/claude-sonnet-4-5-20250929")
|
||||
|
||||
# Run should return an async generator
|
||||
result_generator = agent.run(sample_messages)
|
||||
|
||||
assert result_generator is not None
|
||||
# Check it's an async generator
|
||||
assert hasattr(result_generator, "__anext__")
|
||||
|
||||
def test_agent_has_run_method(self, disable_telemetry):
|
||||
"""Test that agent has run method available."""
|
||||
from cua_agent import ComputerAgent
|
||||
|
||||
agent = ComputerAgent(model="anthropic/claude-sonnet-4-5-20250929")
|
||||
|
||||
# Verify run method exists
|
||||
assert hasattr(agent, "run")
|
||||
assert callable(agent.run)
|
||||
|
||||
def test_agent_has_agent_loop(self, disable_telemetry):
|
||||
"""Test that agent has agent_loop initialized."""
|
||||
from cua_agent import ComputerAgent
|
||||
|
||||
agent = ComputerAgent(model="anthropic/claude-sonnet-4-5-20250929")
|
||||
|
||||
# Verify agent_loop is initialized
|
||||
assert hasattr(agent, "agent_loop")
|
||||
assert agent.agent_loop is not None
|
||||
|
||||
|
||||
class TestComputerAgentTypes:
|
||||
"""Test AgentResponse and Messages types (SRP: Only tests type definitions)."""
|
||||
|
||||
def test_messages_type_exists(self):
|
||||
"""Test that Messages type is exported."""
|
||||
from cua_agent import Messages
|
||||
|
||||
assert Messages is not None
|
||||
|
||||
def test_agent_response_type_exists(self):
|
||||
"""Test that AgentResponse type is exported."""
|
||||
from cua_agent import AgentResponse
|
||||
|
||||
assert AgentResponse is not None
|
||||
|
||||
|
||||
class TestComputerAgentIntegration:
|
||||
"""Test ComputerAgent integration with Computer tool (SRP: Integration within package)."""
|
||||
|
||||
def test_agent_accepts_computer_tool(self, disable_telemetry, mock_computer):
|
||||
"""Test that agent can be initialized with Computer tool."""
|
||||
from cua_agent import ComputerAgent
|
||||
|
||||
agent = ComputerAgent(model="anthropic/claude-sonnet-4-5-20250929", tools=[mock_computer])
|
||||
|
||||
# Verify agent accepted the tool
|
||||
assert agent is not None
|
||||
assert hasattr(agent, "tools")
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Tests for predict_click zero-coordinate fix (issue #1400).
|
||||
|
||||
These tests verify the coordinate-extraction logic in
|
||||
AnthropicHostedToolsConfig.predict_click directly, without requiring the full
|
||||
cua_agent import chain (which needs cua-computer, cua-core, etc.).
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _extract_click_coords_old(responses_items):
|
||||
"""Buggy implementation: falsy check silently drops x=0 or y=0."""
|
||||
for item in responses_items:
|
||||
if (
|
||||
isinstance(item, dict)
|
||||
and item.get("type") == "computer_call"
|
||||
and isinstance(item.get("action"), dict)
|
||||
):
|
||||
action = item["action"]
|
||||
if action.get("x") and action.get("y"): # BUG: 0 is falsy
|
||||
return (int(action.get("x")), int(action.get("y")))
|
||||
return None
|
||||
|
||||
|
||||
def _extract_click_coords_fixed(responses_items):
|
||||
"""Fixed implementation: explicit None check preserves zero coordinates."""
|
||||
for item in responses_items:
|
||||
if (
|
||||
isinstance(item, dict)
|
||||
and item.get("type") == "computer_call"
|
||||
and isinstance(item.get("action"), dict)
|
||||
):
|
||||
action = item["action"]
|
||||
if action.get("x") is not None and action.get("y") is not None:
|
||||
return (int(action.get("x")), int(action.get("y")))
|
||||
return None
|
||||
|
||||
|
||||
def _make_items(x, y):
|
||||
return [{"type": "computer_call", "action": {"type": "click", "x": x, "y": y}}]
|
||||
|
||||
|
||||
# --- Regression tests: these all FAIL with the old code, PASS with the fix ---
|
||||
|
||||
|
||||
def test_zero_x_was_broken_before_fix():
|
||||
"""Old code returns None for x=0; new code returns (0, y)."""
|
||||
items = _make_items(0, 100)
|
||||
assert _extract_click_coords_old(items) is None, "confirm old bug"
|
||||
assert _extract_click_coords_fixed(items) == (0, 100)
|
||||
|
||||
|
||||
def test_zero_y_was_broken_before_fix():
|
||||
"""Old code returns None for y=0; new code returns (x, 0)."""
|
||||
items = _make_items(200, 0)
|
||||
assert _extract_click_coords_old(items) is None, "confirm old bug"
|
||||
assert _extract_click_coords_fixed(items) == (200, 0)
|
||||
|
||||
|
||||
def test_zero_zero_was_broken_before_fix():
|
||||
"""Old code returns None for (0, 0); new code returns (0, 0)."""
|
||||
items = _make_items(0, 0)
|
||||
assert _extract_click_coords_old(items) is None, "confirm old bug"
|
||||
assert _extract_click_coords_fixed(items) == (0, 0)
|
||||
|
||||
|
||||
# --- Positive tests: non-zero coordinates work in both old and new code ---
|
||||
|
||||
|
||||
def test_nonzero_coordinates_still_work():
|
||||
items = _make_items(512, 384)
|
||||
assert _extract_click_coords_fixed(items) == (512, 384)
|
||||
|
||||
|
||||
def test_returns_none_when_no_computer_call():
|
||||
items = [{"type": "text", "text": "no click"}]
|
||||
assert _extract_click_coords_fixed(items) is None
|
||||
|
||||
|
||||
# --- Verify the actual fix is present in the source file ---
|
||||
|
||||
|
||||
def test_source_uses_is_not_none_check():
|
||||
"""Confirm the fix is applied in the real anthropic.py source."""
|
||||
import pathlib
|
||||
|
||||
src = (
|
||||
pathlib.Path(__file__).parent.parent / "cua_agent" / "loops" / "anthropic.py"
|
||||
).read_text()
|
||||
assert (
|
||||
'action.get("x") is not None and action.get("y") is not None' in src
|
||||
), "Fix not found in anthropic.py — the 'is not None' check is missing"
|
||||
# Ensure the old buggy pattern is gone
|
||||
assert (
|
||||
'if action.get("x") and action.get("y"):' not in src
|
||||
), "Old buggy truthiness check still present in anthropic.py"
|
||||
@@ -0,0 +1,145 @@
|
||||
"""
|
||||
Test script to verify telemetry events are emitted correctly.
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestAgentTelemetryEvents:
|
||||
"""Test telemetry events emitted by ComputerAgent."""
|
||||
|
||||
@patch("cua_agent.agent.record_event")
|
||||
@patch("cua_agent.agent.is_telemetry_enabled", return_value=True)
|
||||
def test_agent_init_event(self, mock_telemetry_enabled, mock_record_event):
|
||||
"""Test that agent_init event is emitted with correct args_provided."""
|
||||
from cua_agent.agent import ComputerAgent
|
||||
|
||||
# Create agent with various args
|
||||
agent = ComputerAgent(
|
||||
model="anthropic/claude-sonnet-4-5-20250929",
|
||||
instructions="Test instructions",
|
||||
max_retries=5, # non-default
|
||||
trajectory_dir="/tmp/test",
|
||||
)
|
||||
|
||||
# Find the agent_init call
|
||||
agent_init_calls = [
|
||||
call for call in mock_record_event.call_args_list if call[0][0] == "agent_init"
|
||||
]
|
||||
|
||||
assert len(agent_init_calls) == 1, "agent_init should be called once"
|
||||
|
||||
event_name, event_data = agent_init_calls[0][0]
|
||||
assert event_name == "agent_init"
|
||||
assert event_data["model"] == "anthropic/claude-sonnet-4-5-20250929"
|
||||
assert "instructions" in event_data["args_provided"]
|
||||
assert "max_retries" in event_data["args_provided"]
|
||||
assert "trajectory_dir" in event_data["args_provided"]
|
||||
|
||||
@patch("cua_agent.agent.record_event")
|
||||
@patch("cua_agent.agent.is_telemetry_enabled", return_value=True)
|
||||
def test_agent_init_minimal_args(self, mock_telemetry_enabled, mock_record_event):
|
||||
"""Test agent_init with minimal args (defaults)."""
|
||||
from cua_agent.agent import ComputerAgent
|
||||
|
||||
agent = ComputerAgent(model="anthropic/claude-sonnet-4-5-20250929")
|
||||
|
||||
agent_init_calls = [
|
||||
call for call in mock_record_event.call_args_list if call[0][0] == "agent_init"
|
||||
]
|
||||
|
||||
assert len(agent_init_calls) == 1
|
||||
event_name, event_data = agent_init_calls[0][0]
|
||||
|
||||
# With defaults, only model-related things should be tracked
|
||||
# instructions, trajectory_dir, etc. should NOT be in args_provided
|
||||
assert "instructions" not in event_data["args_provided"]
|
||||
assert "trajectory_dir" not in event_data["args_provided"]
|
||||
assert "max_retries" not in event_data["args_provided"] # default is 3
|
||||
|
||||
@patch("cua_agent.agent.record_event")
|
||||
@patch("cua_agent.agent.is_telemetry_enabled", return_value=False)
|
||||
def test_no_events_when_telemetry_disabled(self, mock_telemetry_enabled, mock_record_event):
|
||||
"""Test that no events are emitted when telemetry is disabled."""
|
||||
from cua_agent.agent import ComputerAgent
|
||||
|
||||
agent = ComputerAgent(
|
||||
model="anthropic/claude-sonnet-4-5-20250929",
|
||||
telemetry_enabled=False,
|
||||
)
|
||||
|
||||
# No agent_init should be called (telemetry disabled)
|
||||
agent_init_calls = [
|
||||
call for call in mock_record_event.call_args_list if call[0][0] == "agent_init"
|
||||
]
|
||||
|
||||
assert len(agent_init_calls) == 0
|
||||
|
||||
|
||||
class TestActionTelemetryEvents:
|
||||
"""Test telemetry events for computer actions."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("cua_agent.agent.record_event")
|
||||
@patch("cua_agent.agent.is_telemetry_enabled", return_value=True)
|
||||
async def test_computer_action_executed_event(self, mock_telemetry_enabled, mock_record_event):
|
||||
"""Test that computer_action_executed is emitted for computer calls."""
|
||||
from cua_agent.agent import ComputerAgent
|
||||
|
||||
agent = ComputerAgent(model="anthropic/claude-sonnet-4-5-20250929")
|
||||
agent.telemetry_enabled = True
|
||||
|
||||
# Mock computer handler
|
||||
mock_computer = MagicMock()
|
||||
mock_computer.click = AsyncMock(return_value=None)
|
||||
mock_computer.screenshot = AsyncMock(return_value="base64screenshot")
|
||||
|
||||
# Create a mock computer_call item
|
||||
item = {
|
||||
"type": "computer_call",
|
||||
"call_id": "test-call-id",
|
||||
"action": {
|
||||
"type": "click",
|
||||
"x": 100,
|
||||
"y": 200,
|
||||
},
|
||||
}
|
||||
|
||||
# Process the item (this would normally happen in the agent loop)
|
||||
# Note: We can't easily test this without running the full agent loop
|
||||
# This is more of an integration test
|
||||
|
||||
# For unit testing, we verify the event structure
|
||||
expected_event = {
|
||||
"action_type": "click",
|
||||
}
|
||||
|
||||
# Verify event structure is correct
|
||||
assert "action_type" in expected_event
|
||||
|
||||
|
||||
class TestToolExecutedEvents:
|
||||
"""Test telemetry events for tool execution."""
|
||||
|
||||
def test_event_structure(self):
|
||||
"""Test that agent_tool_executed event has correct structure."""
|
||||
expected_computer_tool_event = {
|
||||
"tool_type": "computer",
|
||||
"tool_name": "click",
|
||||
}
|
||||
|
||||
expected_function_tool_event = {
|
||||
"tool_type": "function",
|
||||
"tool_name": "my_custom_function",
|
||||
}
|
||||
|
||||
# Verify expected structure
|
||||
assert "tool_type" in expected_computer_tool_event
|
||||
assert "tool_name" in expected_computer_tool_event
|
||||
assert expected_computer_tool_event["tool_type"] in ["computer", "function"]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -0,0 +1,262 @@
|
||||
"""
|
||||
Tests for centralized tool resolution in ComputerAgent.
|
||||
|
||||
Tests that:
|
||||
1. FARA (specialized model) auto-wraps Computer to BrowserTool with warning
|
||||
2. FARA accepts explicit BrowserTool without warning
|
||||
3. Claude (general model) accepts any tool without wrapping
|
||||
4. Custom function tools pass through unchanged
|
||||
"""
|
||||
|
||||
import warnings
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
from cua_agent.computers import is_agent_computer
|
||||
from cua_agent.tools.browser_tool import BrowserTool
|
||||
from cua_agent.types import AgentConfigInfo
|
||||
|
||||
|
||||
# Mock agent config class for testing
|
||||
class MockAgentConfig:
|
||||
"""Mock agent config class for testing"""
|
||||
|
||||
async def predict_step(self, **kwargs):
|
||||
return {"output": [], "usage": {}}
|
||||
|
||||
async def predict_click(self, **kwargs):
|
||||
return None
|
||||
|
||||
def get_capabilities(self):
|
||||
return ["step"]
|
||||
|
||||
|
||||
class TestToolResolution:
|
||||
"""Tests for ComputerAgent._resolve_tools()"""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_computer(self):
|
||||
"""Create a mock Computer object"""
|
||||
computer = Mock()
|
||||
computer.interface = Mock()
|
||||
computer.interface.interface = Mock() # For hotkey, etc.
|
||||
computer.interface.playwright_exec = Mock()
|
||||
return computer
|
||||
|
||||
@pytest.fixture
|
||||
def mock_browser_tool(self):
|
||||
"""Create a mock BrowserTool"""
|
||||
tool = Mock(spec=BrowserTool)
|
||||
return tool
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fara_auto_wraps_computer_to_browser_tool(self, mock_computer):
|
||||
"""FARA auto-wraps Computer to BrowserTool with warning."""
|
||||
from cua_agent.agent import ComputerAgent
|
||||
|
||||
# Patch find_agent_config to return FARA config
|
||||
fara_config = AgentConfigInfo(
|
||||
agent_class=MockAgentConfig,
|
||||
models_regex=r"(?i).*fara.*",
|
||||
priority=0,
|
||||
tool_type="browser",
|
||||
)
|
||||
|
||||
# Patch is_agent_computer to recognize our mock as a computer
|
||||
def mock_is_agent_computer(tool):
|
||||
return tool is mock_computer
|
||||
|
||||
with patch("cua_agent.agent.find_agent_config", return_value=fara_config):
|
||||
with patch("cua_agent.agent.is_agent_computer", side_effect=mock_is_agent_computer):
|
||||
agent = ComputerAgent(model="cua/microsoft/fara-7b", tools=[mock_computer])
|
||||
# Tool resolution happens in _resolve_tools, which is async
|
||||
with pytest.warns(UserWarning, match="Auto-wrapping Computer to BrowserTool"):
|
||||
resolved = await agent._resolve_tools(agent.tools, "browser")
|
||||
|
||||
# Should have wrapped to BrowserTool
|
||||
assert len(resolved) == 1
|
||||
assert isinstance(resolved[0], BrowserTool)
|
||||
|
||||
def test_fara_accepts_explicit_browser_tool_no_warning(self, mock_browser_tool):
|
||||
"""FARA accepts explicit BrowserTool without warning."""
|
||||
from cua_agent.agent import ComputerAgent
|
||||
|
||||
# Patch find_agent_config to return FARA config
|
||||
fara_config = AgentConfigInfo(
|
||||
agent_class=MockAgentConfig,
|
||||
models_regex=r"(?i).*fara.*",
|
||||
priority=0,
|
||||
tool_type="browser",
|
||||
)
|
||||
|
||||
with patch("cua_agent.agent.find_agent_config", return_value=fara_config):
|
||||
# Should not raise any warnings
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("error")
|
||||
agent = ComputerAgent(model="cua/microsoft/fara-7b", tools=[mock_browser_tool])
|
||||
|
||||
# Should keep the original BrowserTool
|
||||
assert len(agent.tools) == 1
|
||||
assert agent.tools[0] is mock_browser_tool
|
||||
|
||||
def test_claude_accepts_any_tool_no_wrapping(self, mock_computer):
|
||||
"""Claude (general model) accepts any tool without wrapping."""
|
||||
from cua_agent.agent import ComputerAgent
|
||||
|
||||
# Patch find_agent_config to return Claude config (no tool_type)
|
||||
claude_config = AgentConfigInfo(
|
||||
agent_class=MockAgentConfig,
|
||||
models_regex=r".*claude.*",
|
||||
priority=0,
|
||||
tool_type=None, # General model, no tool type requirement
|
||||
)
|
||||
|
||||
with patch("cua_agent.agent.find_agent_config", return_value=claude_config):
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("error") # Fail if any warning
|
||||
agent = ComputerAgent(model="claude-sonnet-4", tools=[mock_computer])
|
||||
|
||||
# Should keep original Computer, not wrapped
|
||||
assert len(agent.tools) == 1
|
||||
assert agent.tools[0] is mock_computer
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_custom_tools_pass_through(self, mock_computer):
|
||||
"""Custom function tools pass through unchanged."""
|
||||
from cua_agent.agent import ComputerAgent
|
||||
|
||||
def custom_tool():
|
||||
"""A custom tool function."""
|
||||
pass
|
||||
|
||||
# Patch find_agent_config to return FARA config
|
||||
fara_config = AgentConfigInfo(
|
||||
agent_class=MockAgentConfig,
|
||||
models_regex=r"(?i).*fara.*",
|
||||
priority=0,
|
||||
tool_type="browser",
|
||||
)
|
||||
|
||||
# Patch is_agent_computer to recognize our mock as a computer
|
||||
def mock_is_agent_computer(tool):
|
||||
return tool is mock_computer
|
||||
|
||||
with patch("cua_agent.agent.find_agent_config", return_value=fara_config):
|
||||
with patch("cua_agent.agent.is_agent_computer", side_effect=mock_is_agent_computer):
|
||||
agent = ComputerAgent(
|
||||
model="cua/microsoft/fara-7b", tools=[mock_computer, custom_tool]
|
||||
)
|
||||
# Tool resolution happens in _resolve_tools, which is async
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore")
|
||||
resolved = await agent._resolve_tools(agent.tools, "browser")
|
||||
|
||||
# Should have wrapped computer but kept custom tool unchanged
|
||||
assert len(resolved) == 2
|
||||
assert isinstance(resolved[0], BrowserTool)
|
||||
assert resolved[1] is custom_tool
|
||||
|
||||
|
||||
class TestAgentConfigInfo:
|
||||
"""Tests for AgentConfigInfo with tool_type field"""
|
||||
|
||||
def test_agent_config_info_with_tool_type(self):
|
||||
"""AgentConfigInfo accepts tool_type parameter"""
|
||||
config = AgentConfigInfo(
|
||||
agent_class=MockAgentConfig,
|
||||
models_regex=r".*fara.*",
|
||||
priority=0,
|
||||
tool_type="browser",
|
||||
)
|
||||
assert config.tool_type == "browser"
|
||||
|
||||
def test_agent_config_info_without_tool_type(self):
|
||||
"""AgentConfigInfo defaults tool_type to None"""
|
||||
config = AgentConfigInfo(
|
||||
agent_class=MockAgentConfig,
|
||||
models_regex=r".*claude.*",
|
||||
priority=0,
|
||||
)
|
||||
assert config.tool_type is None
|
||||
|
||||
def test_agent_config_info_matches_model(self):
|
||||
"""AgentConfigInfo.matches_model works correctly"""
|
||||
config = AgentConfigInfo(
|
||||
agent_class=MockAgentConfig,
|
||||
models_regex=r"(?i).*fara-7b.*",
|
||||
priority=0,
|
||||
tool_type="browser",
|
||||
)
|
||||
assert config.matches_model("cua/microsoft/fara-7b")
|
||||
assert config.matches_model("FARA-7B")
|
||||
assert not config.matches_model("claude-sonnet-4")
|
||||
|
||||
|
||||
class TestRegisterAgentDecorator:
|
||||
"""Tests for @register_agent decorator with tool_type"""
|
||||
|
||||
def test_register_agent_with_tool_type(self):
|
||||
"""@register_agent accepts tool_type parameter"""
|
||||
from cua_agent.decorators import _agent_configs, register_agent
|
||||
|
||||
# Clear registry for test
|
||||
original_configs = _agent_configs.copy()
|
||||
_agent_configs.clear()
|
||||
|
||||
try:
|
||||
|
||||
@register_agent(models=r"test-model.*", tool_type="browser")
|
||||
class TestAgentConfig:
|
||||
async def predict_step(self, **kwargs):
|
||||
pass
|
||||
|
||||
async def predict_click(self, **kwargs):
|
||||
pass
|
||||
|
||||
def get_capabilities(self):
|
||||
return ["step"]
|
||||
|
||||
# Find the registered config
|
||||
from cua_agent.decorators import find_agent_config
|
||||
|
||||
config = find_agent_config("test-model-123")
|
||||
assert config is not None
|
||||
assert config.tool_type == "browser"
|
||||
|
||||
finally:
|
||||
# Restore original registry
|
||||
_agent_configs.clear()
|
||||
_agent_configs.extend(original_configs)
|
||||
|
||||
def test_register_agent_without_tool_type(self):
|
||||
"""@register_agent without tool_type defaults to None"""
|
||||
from cua_agent.decorators import _agent_configs, register_agent
|
||||
|
||||
# Clear registry for test
|
||||
original_configs = _agent_configs.copy()
|
||||
_agent_configs.clear()
|
||||
|
||||
try:
|
||||
|
||||
@register_agent(models=r"general-model.*")
|
||||
class GeneralAgentConfig:
|
||||
async def predict_step(self, **kwargs):
|
||||
pass
|
||||
|
||||
async def predict_click(self, **kwargs):
|
||||
pass
|
||||
|
||||
def get_capabilities(self):
|
||||
return ["step"]
|
||||
|
||||
# Find the registered config
|
||||
from cua_agent.decorators import find_agent_config
|
||||
|
||||
config = find_agent_config("general-model-123")
|
||||
assert config is not None
|
||||
assert config.tool_type is None
|
||||
|
||||
finally:
|
||||
# Restore original registry
|
||||
_agent_configs.clear()
|
||||
_agent_configs.extend(original_configs)
|
||||
Reference in New Issue
Block a user