Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 18 additions & 8 deletions src/agent/browser/page.py
Original file line number Diff line number Diff line change
Expand Up @@ -226,18 +226,28 @@ async def set_file_input_at(self, x: int, y: int, files: list[str]) -> None:
'files': files, 'backendNodeId': backend_node_id,
}, session_id=sid)

async def select_option_at(self, x: int, y: int, labels: list[str]) -> None:
async def select_option_at(self, x: int, y: int, labels: list[str]) -> dict:
labels_json = json.dumps(labels)
await self.execute_script(
return await self.execute_script(
f'(function(){{'
f' var el = document.elementFromPoint({x}, {y});'
f' var start = document.elementFromPoint({x}, {y});'
f' if (!start) return {{error: "not_found"}};'
f' var el = start;'
f' while (el && el.tagName !== "SELECT") el = el.parentElement;'
f' if (!el) return false;'
f' if (!el) return {{error: "not_select", tag: start.tagName.toLowerCase()}};'
f' var labels = {labels_json};'
f' for (var i = 0; i < el.options.length; i++) {{'
f' if (labels.includes(el.options[i].text.trim())) el.options[i].selected = true;'
f' var texts = Array.from(el.options).map(function(o){{ return o.text.trim(); }});'
f' var selected = labels.filter(function(l){{ return texts.indexOf(l) >= 0; }});'
f' var notFound = labels.filter(function(l){{ return texts.indexOf(l) < 0; }});'
f' if (selected.length) {{'
f' if (el.multiple) {{'
f' for (var i = 0; i < el.options.length; i++) el.options[i].selected = labels.indexOf(texts[i]) >= 0;'
f' }} else {{'
f' el.selectedIndex = texts.indexOf(selected[0]);'
f' }}'
f' el.dispatchEvent(new Event("input", {{bubbles: true}}));'
f' el.dispatchEvent(new Event("change", {{bubbles: true}}));'
f' }}'
f' el.dispatchEvent(new Event("change", {{bubbles: true}}));'
f' return true;'
f' return {{selected: selected, notFound: notFound, available: texts.slice(0, 30)}};'
f'}})()'
)
27 changes: 18 additions & 9 deletions src/agent/browser/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -917,8 +917,8 @@ async def scroll_at(self, x: int, y: int, direction: str, amount: int = 500):
async def set_file_input_at(self, x: int, y: int, files: list[str]):
await self.current_page().set_file_input_at(x, y, files)

async def select_option_at(self, x: int, y: int, labels: list[str]):
await self.current_page().select_option_at(x, y, labels)
async def select_option_at(self, x: int, y: int, labels: list[str]) -> dict:
return await self.current_page().select_option_at(x, y, labels)

async def set_file_input(self, xpath: str, files: list[str]):
sid = self._get_current_session_id()
Expand All @@ -934,19 +934,28 @@ async def set_file_input(self, xpath: str, files: list[str]):
backend_node_id = node['node']['backendNodeId']
await self.send('DOM.setFileInputFiles', {'files': files, 'backendNodeId': backend_node_id}, session_id=sid)

async def select_option(self, xpath: str, labels: list[str]):
async def select_option(self, xpath: str, labels: list[str]) -> dict:
escaped = xpath.replace('"', '\\"')
labels_json = json.dumps(labels)
await self.execute_script(
return await self.execute_script(
f'(function(){{'
f' var el = document.evaluate("{escaped}", document, null, 8, null).singleNodeValue;'
f' if (!el) return false;'
f' if (!el) return {{error: "not_found"}};'
f' if (el.tagName !== "SELECT") return {{error: "not_select", tag: el.tagName.toLowerCase()}};'
f' var labels = {labels_json};'
f' for (var i = 0; i < el.options.length; i++) {{'
f' if (labels.includes(el.options[i].text.trim())) el.options[i].selected = true;'
f' var texts = Array.from(el.options).map(function(o){{ return o.text.trim(); }});'
f' var selected = labels.filter(function(l){{ return texts.indexOf(l) >= 0; }});'
f' var notFound = labels.filter(function(l){{ return texts.indexOf(l) < 0; }});'
f' if (selected.length) {{'
f' if (el.multiple) {{'
f' for (var i = 0; i < el.options.length; i++) el.options[i].selected = labels.indexOf(texts[i]) >= 0;'
f' }} else {{'
f' el.selectedIndex = texts.indexOf(selected[0]);'
f' }}'
f' el.dispatchEvent(new Event("input", {{bubbles: true}}));'
f' el.dispatchEvent(new Event("change", {{bubbles: true}}));'
f' }}'
f' el.dispatchEvent(new Event("change", {{bubbles: true}}));'
f' return true;'
f' return {{selected: selected, notFound: notFound, available: texts.slice(0, 30)}};'
f'}})()'
)

Expand Down
2 changes: 1 addition & 1 deletion src/agent/tools/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
click_tool, goto_tool, key_tool, scrape_tool,
type_tool, scroll_tool, wait_tool, back_tool,
tab_tool, done_tool, forward_tool, download_tool,
script_tool,
script_tool, menu_tool, upload_tool,
]

__all__ = ['BUILTIN_TOOLS']
12 changes: 10 additions & 2 deletions src/agent/tools/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -257,8 +257,16 @@ async def menu_tool(index: int, labels: list[str], session: Browser = None):
'''Selects one or more options in a <select> dropdown by their visible label text.'''
element = await session.get_element_by_index(index=index)
xpath = element.xpath.get('element', '')
await session.select_option(xpath, labels)
return f'Selected {", ".join(labels)} in element at label {index}'
result = await session.select_option(xpath, labels)
if not isinstance(result, dict) or result.get('error') == 'not_found':
raise Exception(f'Could not resolve the dropdown element at label {index}')
if result.get('error') == 'not_select':
tag = result.get('tag', 'unknown')
raise Exception(f'Element at label {index} is a <{tag}>, not a <select> dropdown — use click_tool to open custom dropdowns')
not_found = result.get('notFound', [])
if not_found:
raise Exception(f"Could not find option(s) {not_found} in dropdown at label {index}. Available options: {result.get('available', [])}")
return f"Selected {', '.join(result.get('selected', []))} in element at label {index}"


@Tool('script_tool', model=Script)
Expand Down
97 changes: 97 additions & 0 deletions tests/test_menu_tool.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import asyncio
import json
from unittest.mock import AsyncMock, MagicMock

import pytest

from src.agent.tools import BUILTIN_TOOLS
from src.agent.tools.service import menu_tool, upload_tool


def test_menu_tool_registered():
assert menu_tool in BUILTIN_TOOLS


def test_upload_tool_registered():
assert upload_tool in BUILTIN_TOOLS


def _stub_browser(script_result):
from src.agent.browser.service import Browser
browser = Browser.__new__(Browser)
browser.execute_script = AsyncMock(return_value=script_result)
return browser


def test_select_option_embeds_labels_via_json_dumps():
browser = _stub_browser({'selected': [], 'notFound': ["Mechanic's Lien"], 'available': ['Deed of Trust']})
asyncio.run(browser.select_option("//select[1]", ["Mechanic's Lien"]))
script = browser.execute_script.call_args.args[0]
assert json.dumps(["Mechanic's Lien"]) in script


def test_select_option_returns_structured_result():
expected = {'selected': ['BMW'], 'notFound': [], 'available': ['BMW', 'Audi']}
browser = _stub_browser(expected)
result = asyncio.run(browser.select_option("//select[1]", ['BMW']))
assert result == expected


def test_select_option_script_handles_error_shapes():
browser = _stub_browser({'error': 'not_found'})
asyncio.run(browser.select_option("//select[1]", ['BMW']))
script = browser.execute_script.call_args.args[0]
assert 'not_found' in script
assert 'not_select' in script
assert 'available' in script


def _stub_session(select_result):
element = MagicMock()
element.xpath = {'element': '//select[1]'}
session = MagicMock()
session.get_element_by_index = AsyncMock(return_value=element)
session.select_option = AsyncMock(return_value=select_result)
return session


def test_menu_tool_success_message():
session = _stub_session({'selected': ["Mechanic's Lien"], 'notFound': [], 'available': ["Mechanic's Lien", 'Deed of Trust']})
message = asyncio.run(menu_tool.ainvoke(index=3, labels=["Mechanic's Lien"], session=session))
assert "Mechanic's Lien" in message
assert 'label 3' in message


def test_menu_tool_raises_with_available_options_on_mismatch():
session = _stub_session({'selected': [], 'notFound': ["Mechanic's Lien"], 'available': ['Mechanic’s Lien', 'Deed of Trust']})
with pytest.raises(Exception) as exc_info:
asyncio.run(menu_tool.ainvoke(index=3, labels=["Mechanic's Lien"], session=session))
assert 'Mechanic’s Lien' in str(exc_info.value)
assert 'Available options' in str(exc_info.value)


def test_menu_tool_raises_on_non_select_element():
session = _stub_session({'error': 'not_select', 'tag': 'div'})
with pytest.raises(Exception) as exc_info:
asyncio.run(menu_tool.ainvoke(index=5, labels=['BMW'], session=session))
assert 'not a <select>' in str(exc_info.value)
assert 'click_tool' in str(exc_info.value)


def test_menu_tool_raises_on_element_not_found():
session = _stub_session({'error': 'not_found'})
with pytest.raises(Exception) as exc_info:
asyncio.run(menu_tool.ainvoke(index=7, labels=['BMW'], session=session))
assert 'label 7' in str(exc_info.value)


def test_select_option_at_returns_structured_result():
from src.agent.browser.page import Page
page = Page.__new__(Page)
page.execute_script = AsyncMock(return_value={'error': 'not_found'})
result = asyncio.run(page.select_option_at(10, 20, ['BMW']))
assert result == {'error': 'not_found'}
script = page.execute_script.call_args.args[0]
assert 'elementFromPoint(10, 20)' in script
assert json.dumps(['BMW']) in script
assert 'not_select' in script