# Copyright 2026 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. from unittest.mock import AsyncMock from unittest.mock import MagicMock from google.adk.models.llm_request import LlmRequest # Use the actual ComputerEnvironment enum from the code from google.adk.tools.computer_use.base_computer import BaseComputer from google.adk.tools.computer_use.base_computer import ComputerEnvironment from google.adk.tools.computer_use.base_computer import ComputerState from google.adk.tools.computer_use.computer_use_tool import ComputerUseTool from google.adk.tools.computer_use.computer_use_toolset import ComputerUseToolset from google.genai import types import pytest class MockComputer(BaseComputer): """Mock Computer implementation for testing.""" def __init__(self): self.initialize_called = False self.close_called = False self._screen_size = (1920, 1080) self._environment = ComputerEnvironment.ENVIRONMENT_BROWSER async def initialize(self): self.initialize_called = True async def close(self): self.close_called = True async def screen_size(self) -> tuple[int, int]: return self._screen_size async def environment(self) -> ComputerEnvironment: return self._environment # Implement all abstract methods to make this a concrete class async def open_web_browser(self) -> ComputerState: return ComputerState(screenshot=b"test", url="https://example.com") async def click_at(self, x: int, y: int) -> ComputerState: return ComputerState(screenshot=b"test", url="https://example.com") async def hover_at(self, x: int, y: int) -> ComputerState: return ComputerState(screenshot=b"test", url="https://example.com") async def type_text_at( self, x: int, y: int, text: str, press_enter: bool = True, clear_before_typing: bool = True, ) -> ComputerState: return ComputerState(screenshot=b"test", url="https://example.com") async def scroll_document(self, direction: str) -> ComputerState: return ComputerState(screenshot=b"test", url="https://example.com") async def scroll_at( self, x: int, y: int, direction: str, magnitude: int ) -> ComputerState: return ComputerState(screenshot=b"test", url="https://example.com") async def wait(self, seconds: int) -> ComputerState: return ComputerState(screenshot=b"test", url="https://example.com") async def go_back(self) -> ComputerState: return ComputerState(screenshot=b"test", url="https://example.com") async def go_forward(self) -> ComputerState: return ComputerState(screenshot=b"test", url="https://example.com") async def search(self) -> ComputerState: return ComputerState(screenshot=b"test", url="https://example.com") async def navigate(self, url: str) -> ComputerState: return ComputerState(screenshot=b"test", url=url) async def key_combination(self, keys: list[str]) -> ComputerState: return ComputerState(screenshot=b"test", url="https://example.com") async def drag_and_drop( self, x: int, y: int, destination_x: int, destination_y: int ) -> ComputerState: return ComputerState(screenshot=b"test", url="https://example.com") async def current_state(self) -> ComputerState: return ComputerState(screenshot=b"test", url="https://example.com") class TestComputerUseToolset: """Test cases for ComputerUseToolset class.""" @pytest.fixture def mock_computer(self): """Fixture providing a mock computer.""" return MockComputer() @pytest.fixture def toolset(self, mock_computer): """Fixture providing a ComputerUseToolset instance.""" return ComputerUseToolset(computer=mock_computer) def test_init(self, mock_computer): """Test ComputerUseToolset initialization.""" toolset = ComputerUseToolset(computer=mock_computer) assert toolset._computer == mock_computer assert toolset._initialized is False @pytest.mark.asyncio async def test_ensure_initialized(self, toolset, mock_computer): """Test that _ensure_initialized calls computer.initialize().""" assert not mock_computer.initialize_called assert not toolset._initialized await toolset._ensure_initialized() assert mock_computer.initialize_called assert toolset._initialized @pytest.mark.asyncio async def test_ensure_initialized_only_once(self, toolset, mock_computer): """Test that _ensure_initialized only calls initialize once.""" await toolset._ensure_initialized() # Reset the flag to test it's not called again mock_computer.initialize_called = False await toolset._ensure_initialized() # Should not be called again assert not mock_computer.initialize_called assert toolset._initialized @pytest.mark.asyncio async def test_get_tools(self, toolset, mock_computer): """Test that get_tools returns ComputerUseTool instances.""" tools = await toolset.get_tools() # Should initialize the computer assert mock_computer.initialize_called # Should return a list of ComputerUseTool instances assert isinstance(tools, list) assert len(tools) > 0 assert all(isinstance(tool, ComputerUseTool) for tool in tools) # Each tool should have the correct configuration for tool in tools: assert tool._screen_size == (1920, 1080) # Should use default virtual screen size assert tool._coordinate_space == (1000, 1000) @pytest.mark.asyncio async def test_get_tools_excludes_utility_methods(self, toolset): """Test that get_tools excludes utility methods like screen_size, environment, close.""" tools = await toolset.get_tools() # Get tool function names tool_names = [tool.func.__name__ for tool in tools] # Should exclude utility methods excluded_methods = {"screen_size", "environment", "close"} for method in excluded_methods: assert method not in tool_names # initialize might be included since it's a concrete method, not just abstract # This is acceptable behavior # Should include action methods expected_methods = { "open_web_browser", "click_at", "hover_at", "type_text_at", "scroll_document", "scroll_at", "wait", "go_back", "go_forward", "search", "navigate", "key_combination", "drag_and_drop", "current_state", } for method in expected_methods: assert method in tool_names @pytest.mark.asyncio async def test_get_tools_filters_excluded_functions(self, mock_computer): """Test that get_tools filters out excluded functions.""" excluded_funcs = ["drag_and_drop", "key_combination"] toolset = ComputerUseToolset( computer=mock_computer, excluded_predefined_functions=excluded_funcs, ) tools = await toolset.get_tools() tool_names = [tool.func.__name__ for tool in tools] for func in excluded_funcs: assert func not in tool_names assert "click_at" in tool_names @pytest.mark.asyncio async def test_get_tools_with_readonly_context(self, toolset): """Test get_tools with readonly_context parameter.""" from google.adk.agents.readonly_context import ReadonlyContext readonly_context = MagicMock(spec=ReadonlyContext) tools = await toolset.get_tools(readonly_context=readonly_context) # Should still return tools (readonly_context doesn't affect behavior currently) assert isinstance(tools, list) assert len(tools) > 0 @pytest.mark.asyncio async def test_close(self, toolset, mock_computer): """Test that close calls computer.close().""" await toolset.close() assert mock_computer.close_called @pytest.mark.asyncio async def test_get_tools_creates_tools_with_correct_methods( self, toolset, mock_computer ): """Test that get_tools creates tools with the correct underlying methods.""" tools = await toolset.get_tools() # Find the click_at tool click_tool = None for tool in tools: if tool.func.__name__ == "click_at": click_tool = tool break assert click_tool is not None # The tool's function should have the correct name (wrapped method) assert click_tool.func.__name__ == "click_at" @pytest.mark.asyncio async def test_get_tools_handles_custom_screen_size(self, mock_computer): """Test get_tools with custom screen size.""" mock_computer._screen_size = (2560, 1440) toolset = ComputerUseToolset(computer=mock_computer) tools = await toolset.get_tools() # All tools should have the custom screen size for tool in tools: assert tool._screen_size == (2560, 1440) @pytest.mark.asyncio async def test_get_tools_handles_custom_environment(self, mock_computer): """Test get_tools with custom environment.""" mock_computer._environment = ComputerEnvironment.ENVIRONMENT_UNSPECIFIED toolset = ComputerUseToolset(computer=mock_computer) tools = await toolset.get_tools() # Should still return tools regardless of environment assert isinstance(tools, list) assert len(tools) > 0 @pytest.mark.asyncio async def test_multiple_get_tools_calls_return_cached_instances( self, toolset ): """Test that multiple get_tools calls return the same cached instances.""" tools1 = await toolset.get_tools() tools2 = await toolset.get_tools() # Should return the same list instance assert tools1 is tools2 def test_inheritance(self, toolset): """Test that ComputerUseToolset inherits from BaseToolset.""" from google.adk.tools.base_toolset import BaseToolset assert isinstance(toolset, BaseToolset) @pytest.mark.asyncio async def test_get_tools_method_filtering(self, toolset): """Test that get_tools properly filters methods from BaseComputer.""" tools = await toolset.get_tools() # Get all method names from the tools tool_method_names = [tool.func.__name__ for tool in tools] # Should not include private methods (starting with _) for name in tool_method_names: assert not name.startswith("_") # Should not include excluded methods excluded_methods = {"screen_size", "environment", "close"} for excluded in excluded_methods: assert excluded not in tool_method_names @pytest.mark.asyncio async def test_computer_method_binding(self, toolset, mock_computer): """Test that tools are properly bound to the computer instance.""" tools = await toolset.get_tools() # All tools should have wrapped functions with correct names for tool in tools: # Wrapped functions preserve the original method name via functools.wraps assert callable(tool.func) assert not tool.func.__name__.startswith("_") @pytest.mark.asyncio async def test_toolset_handles_computer_initialization_failure( self, mock_computer ): """Test that toolset handles computer initialization failure gracefully.""" # Make initialize raise an exception async def failing_initialize(): raise Exception("Initialization failed") mock_computer.initialize = failing_initialize toolset = ComputerUseToolset(computer=mock_computer) # Should raise the exception when trying to get tools with pytest.raises(Exception, match="Initialization failed"): await toolset.get_tools() @pytest.mark.asyncio async def test_process_llm_request(self, toolset, mock_computer): """Test that process_llm_request adds tools and computer use configuration.""" llm_request = LlmRequest( model="gemini-2.5-flash", config=types.GenerateContentConfig(), ) await toolset.process_llm_request( tool_context=MagicMock(), llm_request=llm_request ) # Should add tools to the request assert len(llm_request.tools_dict) > 0 # Should add computer use configuration assert llm_request.config.tools is not None assert len(llm_request.config.tools) > 0 # Should have computer use tool computer_use_tools = [ tool for tool in llm_request.config.tools if hasattr(tool, "computer_use") and tool.computer_use ] assert len(computer_use_tools) == 1 # Should have correct environment computer_use_tool = computer_use_tools[0] assert ( computer_use_tool.computer_use.environment == types.Environment.ENVIRONMENT_BROWSER ) @pytest.mark.asyncio async def test_process_llm_request_with_excluded_functions( self, mock_computer ): """Test that process_llm_request passes excluded_predefined_functions.""" excluded_funcs = ["drag_and_drop", "key_combination"] toolset = ComputerUseToolset( computer=mock_computer, excluded_predefined_functions=excluded_funcs, ) llm_request = LlmRequest( model="gemini-2.5-flash", config=types.GenerateContentConfig(), ) await toolset.process_llm_request( tool_context=MagicMock(), llm_request=llm_request ) # Should have computer use tool computer_use_tools = [ tool for tool in llm_request.config.tools if hasattr(tool, "computer_use") and tool.computer_use ] assert len(computer_use_tools) == 1 # Should have correct excluded functions computer_use_tool = computer_use_tools[0] assert ( computer_use_tool.computer_use.excluded_predefined_functions == excluded_funcs ) @pytest.mark.asyncio async def test_process_llm_request_with_existing_computer_use( self, toolset, mock_computer ): """Test that process_llm_request doesn't add duplicate computer use configuration.""" llm_request = LlmRequest( model="gemini-2.5-flash", config=types.GenerateContentConfig( tools=[ types.Tool( computer_use=types.ComputerUse( environment=types.Environment.ENVIRONMENT_BROWSER ) ) ] ), ) original_tools_count = len(llm_request.config.tools) await toolset.process_llm_request( tool_context=MagicMock(), llm_request=llm_request ) # Should not add duplicate computer use configuration assert len(llm_request.config.tools) == original_tools_count # Should still add the actual tools assert len(llm_request.tools_dict) > 0 @pytest.mark.asyncio async def test_process_llm_request_error_handling(self, mock_computer): """Test that process_llm_request handles errors gracefully.""" # Make environment raise an exception async def failing_environment(): raise Exception("Environment failed") mock_computer.environment = failing_environment toolset = ComputerUseToolset(computer=mock_computer) llm_request = LlmRequest( model="gemini-2.5-flash", config=types.GenerateContentConfig(), ) # Should raise the exception with pytest.raises(Exception, match="Environment failed"): await toolset.process_llm_request( tool_context=MagicMock(), llm_request=llm_request ) @pytest.mark.asyncio async def test_adapt_computer_use_tool_sync_adapter(self): """Test adapt_computer_use_tool with sync adapter function.""" # Create a mock tool mock_func = AsyncMock() original_tool = ComputerUseTool( func=mock_func, screen_size=(1920, 1080), virtual_screen_size=(1000, 1000), ) llm_request = LlmRequest( model="gemini-2.5-flash", config=types.GenerateContentConfig(), ) llm_request.tools_dict["wait"] = original_tool # Create a sync adapter function def sync_adapter(original_func): async def adapted_func(): return await original_func(5) return adapted_func # Call the adaptation method await ComputerUseToolset.adapt_computer_use_tool( "wait", sync_adapter, llm_request ) # Verify the original tool was replaced assert "wait" not in llm_request.tools_dict assert "adapted_func" in llm_request.tools_dict # Verify the new tool has correct properties adapted_tool = llm_request.tools_dict["adapted_func"] assert isinstance(adapted_tool, ComputerUseTool) assert adapted_tool._screen_size == (1920, 1080) assert adapted_tool._coordinate_space == (1000, 1000) @pytest.mark.asyncio async def test_adapt_computer_use_tool_async_adapter(self): """Test adapt_computer_use_tool with async adapter function.""" # Create a mock tool mock_func = AsyncMock() original_tool = ComputerUseTool( func=mock_func, screen_size=(1920, 1080), virtual_screen_size=(1000, 1000), ) llm_request = LlmRequest( model="gemini-2.5-flash", config=types.GenerateContentConfig(), ) llm_request.tools_dict["wait"] = original_tool # Create an async adapter function async def async_adapter(original_func): async def adapted_func(): return await original_func(5) return adapted_func # Call the adaptation method await ComputerUseToolset.adapt_computer_use_tool( "wait", async_adapter, llm_request ) # Verify the original tool was replaced assert "wait" not in llm_request.tools_dict assert "adapted_func" in llm_request.tools_dict # Verify the new tool has correct properties adapted_tool = llm_request.tools_dict["adapted_func"] assert isinstance(adapted_tool, ComputerUseTool) assert adapted_tool._screen_size == (1920, 1080) assert adapted_tool._coordinate_space == (1000, 1000) @pytest.mark.asyncio async def test_adapt_computer_use_tool_invalid_method(self): """Test adapt_computer_use_tool with invalid method name.""" llm_request = LlmRequest( model="gemini-2.5-flash", config=types.GenerateContentConfig(), ) def adapter(original_func): async def adapted_func(): return await original_func() return adapted_func # Should not raise an exception, just log a warning await ComputerUseToolset.adapt_computer_use_tool( "invalid_method", adapter, llm_request ) # Should not add any tools assert len(llm_request.tools_dict) == 0 @pytest.mark.asyncio async def test_adapt_computer_use_tool_excluded_method(self): """Test adapt_computer_use_tool with excluded method name.""" llm_request = LlmRequest( model="gemini-2.5-flash", config=types.GenerateContentConfig(), ) def adapter(original_func): async def adapted_func(): return await original_func() return adapted_func # Should not raise an exception, just log a warning await ComputerUseToolset.adapt_computer_use_tool( "screen_size", adapter, llm_request ) # Should not add any tools assert len(llm_request.tools_dict) == 0 @pytest.mark.asyncio async def test_adapt_computer_use_tool_method_not_in_tools_dict(self): """Test adapt_computer_use_tool when method is not in tools_dict.""" llm_request = LlmRequest( model="gemini-2.5-flash", config=types.GenerateContentConfig(), ) def adapter(original_func): async def adapted_func(): return await original_func() return adapted_func # Should not raise an exception, just log a warning await ComputerUseToolset.adapt_computer_use_tool( "wait", adapter, llm_request ) # Should not add any tools assert len(llm_request.tools_dict) == 0