mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-09-13 03:32:21 +02:00
1765 lines
64 KiB
Python
1765 lines
64 KiB
Python
import asyncio
|
|
import pytest
|
|
import base64
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import src.agent_tools.web_tools as web_tools
|
|
from src.agent_tools.web_tools import (
|
|
PrivateBrowserTool,
|
|
YouTubeTool,
|
|
shutdown_private_browser_sessions,
|
|
)
|
|
from src.tool_schemas import FUNCTION_TOOL_SCHEMAS
|
|
|
|
|
|
def test_private_browser_plain_url_defaults_to_read() -> None:
|
|
args, err = PrivateBrowserTool()._parse_args("https://example.com")
|
|
|
|
assert err is None
|
|
assert args == {"action": "read", "url": "https://example.com"}
|
|
|
|
|
|
def test_private_browser_rejects_snapshot_path_as_stale_page_risk(monkeypatch) -> None:
|
|
"""A snapshot has no target path; local media needs inspect_media."""
|
|
|
|
called = False
|
|
|
|
async def _unexpected_subprocess(*args, **kwargs):
|
|
nonlocal called
|
|
called = True
|
|
raise AssertionError("snapshot path must be rejected before browser launch")
|
|
|
|
monkeypatch.setattr(asyncio, "create_subprocess_exec", _unexpected_subprocess)
|
|
|
|
result = asyncio.run(PrivateBrowserTool().execute(
|
|
json.dumps({"action": "snapshot", "path": "/workspace/fixture.png"}),
|
|
{"session_id": "snapshot-path"},
|
|
))
|
|
|
|
assert result["exit_code"] == 1
|
|
assert "inspect_media" in result["error"]
|
|
assert not called
|
|
|
|
|
|
def test_private_browser_maps_workspace_file_urls_and_screenshot_paths(monkeypatch, tmp_path) -> None:
|
|
monkeypatch.setattr("src.tool_execution.get_active_workspace", lambda: str(tmp_path))
|
|
|
|
source = tmp_path / "task browser.html"
|
|
source.write_text("<html></html>")
|
|
|
|
resolved_url = PrivateBrowserTool._resolve_local_file_url(
|
|
"file:///workspace/task%20browser.html"
|
|
)
|
|
resolved_path = PrivateBrowserTool._resolve_workspace_path(
|
|
"/workspace/output.png"
|
|
)
|
|
|
|
assert resolved_url == source.as_uri()
|
|
assert resolved_path == tmp_path / "output.png"
|
|
|
|
|
|
def test_private_browser_maps_bare_workspace_page_for_direct_and_batch_open(
|
|
monkeypatch, tmp_path
|
|
) -> None:
|
|
monkeypatch.setattr("src.tool_execution.get_active_workspace", lambda: str(tmp_path))
|
|
page = tmp_path / "output.html"
|
|
page.write_text("<title>local</title>")
|
|
|
|
assert PrivateBrowserTool._resolve_local_file_url(
|
|
"/workspace/output.html"
|
|
) == page.as_uri()
|
|
|
|
commands, paths = PrivateBrowserTool()._normalize_batch_screenshots([
|
|
["open", "/workspace/output.html"],
|
|
{"action": "read", "url": "file:///workspace/output.html"},
|
|
])
|
|
|
|
assert paths == []
|
|
assert commands == [
|
|
["open", page.as_uri()],
|
|
["open", page.as_uri()],
|
|
]
|
|
|
|
|
|
def test_generate_image_has_stable_native_schema() -> None:
|
|
names = {
|
|
schema.get("function", {}).get("name")
|
|
for schema in FUNCTION_TOOL_SCHEMAS
|
|
}
|
|
|
|
assert "generate_image" in names
|
|
|
|
|
|
def test_private_browser_batch_schema_declares_array_items() -> None:
|
|
schema = next(
|
|
schema["function"]
|
|
for schema in FUNCTION_TOOL_SCHEMAS
|
|
if schema.get("function", {}).get("name") == "private_browser"
|
|
)
|
|
commands = schema["parameters"]["properties"]["commands"]
|
|
|
|
# Providers such as Gemini reject an array property without `items` before
|
|
# generation starts. Keep both supported batch command representations
|
|
# explicit in the native JSON schema.
|
|
assert commands["items"]["oneOf"] == [
|
|
{"type": "array", "items": {"type": "string"}},
|
|
{"type": "object"},
|
|
]
|
|
|
|
|
|
def test_all_native_array_schemas_declare_items() -> None:
|
|
"""Provider APIs reject an array schema without an item schema."""
|
|
|
|
def walk(value, path="schema"):
|
|
if isinstance(value, dict):
|
|
if value.get("type") == "array":
|
|
assert "items" in value, f"missing items at {path}"
|
|
for key, child in value.items():
|
|
yield from walk(child, f"{path}.{key}")
|
|
elif isinstance(value, list):
|
|
for index, child in enumerate(value):
|
|
yield from walk(child, f"{path}[{index}]")
|
|
|
|
# Consume the generator so assertions execute for every schema node.
|
|
list(walk(FUNCTION_TOOL_SCHEMAS, "FUNCTION_TOOL_SCHEMAS"))
|
|
|
|
|
|
def test_private_browser_builds_fill_command_without_shell() -> None:
|
|
command, stdin_data, err = PrivateBrowserTool()._command_for_action(
|
|
["agent-browser"],
|
|
"fill",
|
|
{"selector": "@e1", "text": "hello"},
|
|
)
|
|
|
|
assert err is None
|
|
assert stdin_data is None
|
|
assert command == ["agent-browser", "fill", "@e1", "hello"]
|
|
|
|
|
|
def test_private_browser_builds_visible_text_find_command() -> None:
|
|
command, stdin_data, err = PrivateBrowserTool()._command_for_action(
|
|
["agent-browser"],
|
|
"find",
|
|
{"find": "Learn more"},
|
|
)
|
|
|
|
assert err is None
|
|
assert stdin_data is None
|
|
assert command == ["agent-browser", "find", "text", "Learn more", "text"]
|
|
|
|
|
|
def test_private_browser_click_accepts_role_and_accessible_name_fields() -> None:
|
|
command, stdin_data, err = PrivateBrowserTool()._command_for_action(
|
|
["agent-browser"],
|
|
"click",
|
|
{"target": "link", "text": "Learn more"},
|
|
)
|
|
|
|
assert err is None
|
|
assert stdin_data is None
|
|
assert command == [
|
|
"agent-browser", "find", "role", "link", "click", "--name", "Learn more",
|
|
]
|
|
|
|
|
|
def test_private_browser_click_accepts_quoted_role_target() -> None:
|
|
command, stdin_data, err = PrivateBrowserTool()._command_for_action(
|
|
["agent-browser"],
|
|
"click",
|
|
{"target": 'link "Learn more"'},
|
|
)
|
|
|
|
assert err is None
|
|
assert stdin_data is None
|
|
assert command == [
|
|
"agent-browser", "find", "role", "link", "click", "--name", "Learn more",
|
|
]
|
|
|
|
|
|
def test_private_browser_batch_normalizes_stable_inspection_action_names() -> None:
|
|
commands, paths = PrivateBrowserTool()._normalize_batch_screenshots([
|
|
["open", "https://example.com"],
|
|
["evaluate", "document.title"],
|
|
["find", "Learn more"],
|
|
])
|
|
|
|
assert paths == []
|
|
assert commands == [
|
|
["open", "https://example.com"],
|
|
["eval", "document.title"],
|
|
["find", "text", "Learn more", "text"],
|
|
]
|
|
|
|
|
|
def test_private_browser_batch_normalizes_object_commands_to_cli_arrays() -> None:
|
|
commands, paths = PrivateBrowserTool()._normalize_batch_screenshots([
|
|
{"action": "open", "url": "https://example.com"},
|
|
{"action": "snapshot"},
|
|
{"action": "find", "find": "Contact"},
|
|
])
|
|
|
|
assert paths == []
|
|
assert commands == [
|
|
["open", "https://example.com"],
|
|
["snapshot"],
|
|
["find", "text", "Contact", "text"],
|
|
]
|
|
|
|
|
|
def test_private_browser_batch_stops_guessed_interaction_after_open_at_snapshot() -> None:
|
|
commands, paths = PrivateBrowserTool()._normalize_batch_screenshots([
|
|
["open", "https://example.com"],
|
|
["fill", "search input", "chair"],
|
|
["press", "Enter"],
|
|
])
|
|
|
|
assert paths == []
|
|
assert commands == [["open", "https://example.com"], ["snapshot"]]
|
|
|
|
|
|
def test_private_browser_batch_preserves_explicit_css_after_open() -> None:
|
|
commands, paths = PrivateBrowserTool()._normalize_batch_screenshots([
|
|
["open", "https://example.com"],
|
|
["fill", "#search", "chair"],
|
|
["press", "Enter"],
|
|
])
|
|
|
|
assert paths == []
|
|
assert commands[1] == ["fill", "#search", "chair"]
|
|
|
|
|
|
def test_private_browser_exposes_global_store_landing_link_ref() -> None:
|
|
output = '''
|
|
- heading "Welcome to IKEA Global!"
|
|
- link "Go shopping at IKEA dot j p(English), or use the store selector to search for another store" [ref=e172]
|
|
'''
|
|
|
|
hint = PrivateBrowserTool._shopping_landing_hint(output)
|
|
assert "global store-selector landing page" in hint
|
|
assert "@e172" in hint
|
|
|
|
|
|
def test_private_browser_builds_evaluate_command() -> None:
|
|
command, stdin_data, err = PrivateBrowserTool()._command_for_action(
|
|
["agent-browser"],
|
|
"evaluate",
|
|
{"script": "document.location.hostname"},
|
|
)
|
|
|
|
assert err is None
|
|
assert stdin_data is None
|
|
assert command == ["agent-browser", "eval", "document.location.hostname"]
|
|
|
|
|
|
def test_private_browser_executes_scroll_with_native_browser_command(monkeypatch) -> None:
|
|
monkeypatch.setattr(
|
|
web_tools.shutil, "which", lambda name: "/usr/bin/agent-browser"
|
|
)
|
|
monkeypatch.setattr(PrivateBrowserTool, "_AUTO_SCREENSHOT_ACTIONS", set())
|
|
calls = []
|
|
|
|
class _FakeProc:
|
|
returncode = 0
|
|
|
|
async def communicate(self, stdin=None):
|
|
return b"scrolled", b""
|
|
|
|
async def _fake_create_subprocess_exec(*command, **kwargs):
|
|
calls.append(list(command))
|
|
return _FakeProc()
|
|
|
|
monkeypatch.setattr(
|
|
asyncio, "create_subprocess_exec", _fake_create_subprocess_exec
|
|
)
|
|
|
|
result = asyncio.run(PrivateBrowserTool().execute(
|
|
json.dumps({"action": "scroll", "direction": "down", "amount": 500}),
|
|
{"session_id": "scroll-session"},
|
|
))
|
|
|
|
assert result["exit_code"] == 0
|
|
assert calls[0][-3:] == ["scroll", "down", "500"]
|
|
|
|
|
|
def test_private_browser_expands_tiny_scroll_steps_to_pixels() -> None:
|
|
command, stdin_data, err = PrivateBrowserTool()._command_for_action(
|
|
["agent-browser"], "scroll", {"direction": "down", "amount": 5},
|
|
)
|
|
assert err is None
|
|
assert stdin_data is None
|
|
assert command[-3:] == ["scroll", "down", "1500"]
|
|
|
|
|
|
def test_private_browser_reads_element_from_current_page_without_url(monkeypatch) -> None:
|
|
monkeypatch.setattr(
|
|
web_tools.shutil, "which", lambda name: "/usr/bin/agent-browser"
|
|
)
|
|
monkeypatch.setattr(PrivateBrowserTool, "_AUTO_SCREENSHOT_ACTIONS", set())
|
|
calls = []
|
|
|
|
class _FakeProc:
|
|
returncode = 0
|
|
|
|
async def communicate(self, stdin=None):
|
|
return b"Play Animation", b""
|
|
|
|
async def _fake_create_subprocess_exec(*command, **kwargs):
|
|
calls.append(list(command))
|
|
return _FakeProc()
|
|
|
|
monkeypatch.setattr(
|
|
asyncio, "create_subprocess_exec", _fake_create_subprocess_exec
|
|
)
|
|
|
|
result = asyncio.run(PrivateBrowserTool().execute(
|
|
json.dumps({"action": "read", "selector": "#playBtn"}),
|
|
{"session_id": "read-session"},
|
|
))
|
|
|
|
assert result["exit_code"] == 0
|
|
assert calls[0][-3:] == ["get", "text", "#playBtn"]
|
|
|
|
|
|
@pytest.mark.parametrize('mode,observed', [('recent_model_choice', True), ('baseline', False)])
|
|
def test_keyboard_submit_returns_new_page_state_without_repeating_key(monkeypatch, mode, observed):
|
|
from types import SimpleNamespace
|
|
import src.turn_contract as turn_contract
|
|
monkeypatch.setattr(turn_contract, 'active_turn_contract',
|
|
lambda: SimpleNamespace(routing_experiment=mode))
|
|
monkeypatch.setattr(web_tools.shutil, 'which', lambda name: '/usr/bin/agent-browser')
|
|
commands = []
|
|
class Proc:
|
|
returncode = 0
|
|
def __init__(self, kwargs): self.kwargs = kwargs
|
|
async def communicate(self, stdin=None):
|
|
if stdin:
|
|
assert all(command[0] in {'wait', 'snapshot'} for command in json.loads(stdin))
|
|
return json.dumps([{'success': True, 'result': {
|
|
'origin': 'https://example.org/results',
|
|
'snapshot': '- heading "Search results" [ref=e4]'}}]).encode(), b''
|
|
self.kwargs['stdout'].write(b'Done')
|
|
return b'', b''
|
|
async def spawn(*command, **kwargs):
|
|
commands.append(command)
|
|
return Proc(kwargs)
|
|
monkeypatch.setattr(asyncio, 'create_subprocess_exec', spawn)
|
|
result = asyncio.run(PrivateBrowserTool().execute(
|
|
json.dumps({'action': 'press', 'key': 'Enter'}), {'session_id': 'keyboard-submit'}))
|
|
assert result['exit_code'] == 0
|
|
assert ('Search results' in result['output']) is observed
|
|
assert sum('press' in command for command in commands) == 1
|
|
assert commands[0][-2:] == ('press', 'Enter')
|
|
|
|
|
|
def test_private_browser_accepts_visible_text_button_selector(monkeypatch) -> None:
|
|
monkeypatch.setattr(
|
|
web_tools.shutil, "which", lambda name: "/usr/bin/agent-browser"
|
|
)
|
|
monkeypatch.setattr(PrivateBrowserTool, "_AUTO_SCREENSHOT_ACTIONS", set())
|
|
calls = []
|
|
|
|
class _FakeProc:
|
|
returncode = 0
|
|
|
|
async def communicate(self, stdin=None):
|
|
return b"clicked", b""
|
|
|
|
async def _fake_create_subprocess_exec(*command, **kwargs):
|
|
calls.append(list(command))
|
|
return _FakeProc()
|
|
|
|
monkeypatch.setattr(
|
|
asyncio, "create_subprocess_exec", _fake_create_subprocess_exec
|
|
)
|
|
|
|
result = asyncio.run(PrivateBrowserTool().execute(
|
|
json.dumps({
|
|
"action": "click",
|
|
"selector": 'button:has-text("Play Animation")',
|
|
}),
|
|
{"session_id": "click-session"},
|
|
))
|
|
|
|
assert result["exit_code"] == 0
|
|
assert calls[0][-6:] == [
|
|
"find", "role", "button", "click", "--name", "Play Animation",
|
|
]
|
|
|
|
|
|
def test_private_browser_successful_click_returns_settled_snapshot(monkeypatch) -> None:
|
|
monkeypatch.setattr(
|
|
web_tools.shutil, "which", lambda name: "/usr/bin/agent-browser"
|
|
)
|
|
monkeypatch.setattr(PrivateBrowserTool, "_AUTO_SCREENSHOT_ACTIONS", set())
|
|
calls = []
|
|
|
|
class _FakeProc:
|
|
returncode = 0
|
|
|
|
async def communicate(self, stdin=None):
|
|
if stdin:
|
|
return b'[{"command":["snapshot"],"result":{"snapshot":"heading Example"},"success":true}]', b""
|
|
return b"clicked", b""
|
|
|
|
async def _fake_create_subprocess_exec(*command, **kwargs):
|
|
calls.append(list(command))
|
|
return _FakeProc()
|
|
|
|
monkeypatch.setattr(asyncio, "create_subprocess_exec", _fake_create_subprocess_exec)
|
|
|
|
result = asyncio.run(PrivateBrowserTool().execute(
|
|
json.dumps({"action": "click", "target": "@e2"}),
|
|
{"session_id": "click-settled-session"},
|
|
))
|
|
|
|
assert result["exit_code"] == 0
|
|
assert "post-click page state" in result["output"]
|
|
assert "heading Example" in result["output"]
|
|
assert calls[1][-2:] == ["batch", "--json"]
|
|
|
|
|
|
@pytest.mark.parametrize('action', ['fill', 'click', 'read', 'wait'])
|
|
@pytest.mark.parametrize('ref', ['e2', '@e2'])
|
|
def test_browser_accepts_explicit_snapshot_ref_at_execution_boundary(monkeypatch, action, ref):
|
|
monkeypatch.setattr(web_tools.shutil, 'which', lambda name: '/usr/bin/agent-browser')
|
|
monkeypatch.setattr(PrivateBrowserTool, '_AUTO_SCREENSHOT_ACTIONS', set())
|
|
commands = []
|
|
class Proc:
|
|
returncode = 0
|
|
async def communicate(self, stdin=None):
|
|
return b'[]', b''
|
|
async def spawn(*command, **kwargs):
|
|
commands.append(command)
|
|
return Proc()
|
|
monkeypatch.setattr(asyncio, 'create_subprocess_exec', spawn)
|
|
result = asyncio.run(PrivateBrowserTool().execute(
|
|
json.dumps({'action': action, 'ref': ref, 'text': 'orange'}),
|
|
{'session_id': 'snapshot-ref-alias-test'}))
|
|
assert result['exit_code'] == 0, result
|
|
suffix = {'fill': ('fill', '@e2', 'orange'), 'click': ('click', '@e2'),
|
|
'read': ('get', 'text', '@e2'), 'wait': ('wait', '@e2')}[action]
|
|
assert commands[0][-len(suffix):] == suffix
|
|
|
|
|
|
@pytest.mark.parametrize('ref', ['button', '[ref=e2]', '--help', 'e2;click e3'])
|
|
def test_browser_rejects_malformed_ref_without_launching(monkeypatch, ref):
|
|
async def unexpected(*args, **kwargs):
|
|
raise AssertionError('invalid reference reached browser')
|
|
monkeypatch.setattr(asyncio, 'create_subprocess_exec', unexpected)
|
|
result = asyncio.run(PrivateBrowserTool().execute(
|
|
json.dumps({'action': 'fill', 'ref': ref, 'text': 'orange'}), {}))
|
|
assert result['exit_code'] == 1
|
|
assert 'ref' in result['error']
|
|
|
|
|
|
@pytest.mark.parametrize('field', ['selector', 'target'])
|
|
def test_browser_rejects_conflicting_ref_targets_without_launching(monkeypatch, field):
|
|
async def unexpected(*args, **kwargs):
|
|
raise AssertionError('conflicting targets reached browser')
|
|
monkeypatch.setattr(asyncio, 'create_subprocess_exec', unexpected)
|
|
result = asyncio.run(PrivateBrowserTool().execute(
|
|
json.dumps({'action': 'click', 'ref': 'e2', field: '@e3'}), {}))
|
|
assert result['exit_code'] == 1
|
|
assert 'conflicts' in result['error']
|
|
|
|
|
|
def test_model_choice_open_returns_page_refs_without_rewriting_requested_url(monkeypatch):
|
|
from types import SimpleNamespace
|
|
import src.turn_contract as turn_contract
|
|
monkeypatch.setattr(turn_contract, 'active_turn_contract',
|
|
lambda: SimpleNamespace(routing_experiment='recent_model_choice'))
|
|
monkeypatch.setattr(web_tools.shutil, 'which', lambda name: '/usr/bin/agent-browser')
|
|
monkeypatch.setattr(PrivateBrowserTool, '_AUTO_SCREENSHOT_ACTIONS', set())
|
|
calls = []
|
|
class Proc:
|
|
returncode = 0
|
|
async def communicate(self, stdin=None):
|
|
return (b'[{"result":{"snapshot":"textbox Search [ref=e1]"},"success":true}]', b'') if stdin else (b'opened', b'')
|
|
async def spawn(*command, **kwargs):
|
|
calls.append(command)
|
|
return Proc()
|
|
monkeypatch.setattr(asyncio, 'create_subprocess_exec', spawn)
|
|
result = asyncio.run(PrivateBrowserTool().execute(
|
|
json.dumps({'action': 'open', 'url': 'https://example.org/catalog'}),
|
|
{'session_id': 'open-observation-test'}))
|
|
assert result['exit_code'] == 0
|
|
assert 'textbox Search [ref=e1]' in result['output']
|
|
assert 'https://example.org/catalog' in calls[0]
|
|
assert len(calls) == 2
|
|
|
|
|
|
@pytest.mark.parametrize('mode,observed', [('recent_model_choice', True), ('baseline', False)])
|
|
def test_successful_fill_observes_script_driven_dialog_without_retry(monkeypatch, mode, observed):
|
|
from types import SimpleNamespace
|
|
import src.turn_contract as turn_contract
|
|
monkeypatch.setattr(turn_contract, 'active_turn_contract',
|
|
lambda: SimpleNamespace(routing_experiment=mode))
|
|
monkeypatch.setattr(web_tools.shutil, 'which', lambda name: '/usr/bin/agent-browser')
|
|
monkeypatch.setattr(PrivateBrowserTool, '_AUTO_SCREENSHOT_ACTIONS', set())
|
|
commands = []
|
|
class Proc:
|
|
returncode = 0
|
|
async def communicate(self, stdin=None):
|
|
if stdin:
|
|
return json.dumps([
|
|
{'command': ['get', 'value', '@e2'], 'success': True, 'result': {'value': 'orange'}},
|
|
{'result': {'snapshot': 'dialog Preferences\nbutton Close [ref=e2]'}, 'success': True},
|
|
]).encode(), b''
|
|
return b'', b''
|
|
async def spawn(*command, **kwargs):
|
|
commands.append(command)
|
|
return Proc()
|
|
monkeypatch.setattr(asyncio, 'create_subprocess_exec', spawn)
|
|
result = asyncio.run(PrivateBrowserTool().execute(
|
|
json.dumps({'action': 'fill', 'target': '@e2', 'text': 'orange'}),
|
|
{'session_id': 'post-fill-observation'}))
|
|
assert result['exit_code'] == 0
|
|
assert ('dialog Preferences' in result['output']) is observed
|
|
assert len(commands) == (2 if observed else 1)
|
|
assert sum('fill' in command for command in commands) == 1
|
|
|
|
|
|
@pytest.mark.parametrize('mode,outcome', [
|
|
('recent_model_choice', 'populated'), ('recent_model_choice', 'empty'),
|
|
('recent_model_choice', 'timeout'), ('recent_model_choice', 'invalid'),
|
|
('baseline', 'populated'),
|
|
])
|
|
def test_click_observes_empty_destination_with_bounded_read_only_retry(monkeypatch, mode, outcome):
|
|
from types import SimpleNamespace
|
|
import src.turn_contract as turn_contract
|
|
monkeypatch.setattr(turn_contract, 'active_turn_contract',
|
|
lambda: SimpleNamespace(routing_experiment=mode))
|
|
monkeypatch.setattr(web_tools.shutil, 'which', lambda name: '/usr/bin/agent-browser')
|
|
monkeypatch.setattr(PrivateBrowserTool, '_AUTO_SCREENSHOT_ACTIONS', set())
|
|
commands, batches, deadlines, killed = [], [], [], []
|
|
real_timeout = asyncio.timeout
|
|
def timed_observation(delay):
|
|
deadlines.append(delay)
|
|
return real_timeout(delay)
|
|
monkeypatch.setattr(asyncio, 'timeout', timed_observation)
|
|
class Proc:
|
|
returncode = 0
|
|
def __init__(self, kwargs):
|
|
self.kwargs = kwargs
|
|
def kill(self):
|
|
killed.append(True)
|
|
async def communicate(self, stdin=None):
|
|
if stdin:
|
|
batches.append(json.loads(stdin))
|
|
if len(batches) == 1:
|
|
await asyncio.sleep(0.01)
|
|
elif outcome == 'timeout':
|
|
raise asyncio.TimeoutError()
|
|
elif outcome == 'invalid':
|
|
return b'[{"success": false, "error": "snapshot unavailable"}]', b''
|
|
snapshot = '(empty page)' if len(batches) == 1 or outcome == 'empty' else '- heading "Destination" [ref=e7]'
|
|
return json.dumps([{'command': ['snapshot'], 'success': True,
|
|
'result': {'origin': 'https://example.org/destination', 'snapshot': snapshot}}]).encode(), b''
|
|
self.kwargs['stdout'].write('✓ Done'.encode())
|
|
return b'', b''
|
|
async def spawn(*command, **kwargs):
|
|
commands.append(command)
|
|
return Proc(kwargs)
|
|
monkeypatch.setattr(asyncio, 'create_subprocess_exec', spawn)
|
|
result = asyncio.run(PrivateBrowserTool().execute(
|
|
json.dumps({'action': 'click', 'target': '@e2'}), {'session_id': 'empty-destination'}))
|
|
assert result['exit_code'] == 0, result
|
|
if mode == 'recent_model_choice' and outcome == 'populated':
|
|
assert 'heading "Destination" [ref=e7]' in result['output']
|
|
assert '(empty page)' not in result['output']
|
|
else:
|
|
assert '(empty page)' in result['output']
|
|
assert '[ref=' not in result['output']
|
|
if outcome in {'timeout', 'invalid'}:
|
|
assert 'fresh page snapshot could not be obtained' in result['output']
|
|
assert bool(killed) is (outcome == 'timeout')
|
|
assert len(batches) == (2 if mode == 'recent_model_choice' else 1)
|
|
if mode == 'recent_model_choice':
|
|
assert 0 < deadlines[1] < deadlines[0] <= 20
|
|
assert sum('click' in command for command in commands) == 1
|
|
assert all(command[0] in {'wait', 'snapshot'} for batch in batches for command in batch)
|
|
|
|
|
|
@pytest.mark.parametrize('retained', [True, False])
|
|
def test_empty_snapshot_retry_preserves_fill_verification_without_reusing_old_refs(monkeypatch, retained):
|
|
from types import SimpleNamespace
|
|
import src.turn_contract as turn_contract
|
|
monkeypatch.setattr(turn_contract, 'active_turn_contract',
|
|
lambda: SimpleNamespace(routing_experiment='recent_model_choice'))
|
|
monkeypatch.setattr(web_tools.shutil, 'which', lambda name: '/usr/bin/agent-browser')
|
|
commands, batches = [], []
|
|
class Proc:
|
|
returncode = 0
|
|
def __init__(self, kwargs):
|
|
self.kwargs = kwargs
|
|
async def communicate(self, stdin=None):
|
|
if stdin:
|
|
batches.append(json.loads(stdin))
|
|
rows = [{'command': ['snapshot'], 'success': True, 'result': {
|
|
'snapshot': '(empty page)' if len(batches) == 1 else '- textbox Search [ref=e9]'}}]
|
|
if len(batches) == 1:
|
|
rows.insert(0, {'command': ['get', 'value', '@e2'], 'success': True,
|
|
'result': {'value': 'private-sentinel' if retained else ''}})
|
|
return json.dumps(rows).encode(), b''
|
|
self.kwargs['stdout'].write('✓ Done'.encode())
|
|
return b'', b''
|
|
async def spawn(*command, **kwargs):
|
|
commands.append(command)
|
|
return Proc(kwargs)
|
|
monkeypatch.setattr(asyncio, 'create_subprocess_exec', spawn)
|
|
result = asyncio.run(PrivateBrowserTool().execute(
|
|
json.dumps({'action': 'fill', 'target': '@e2', 'text': 'private-sentinel'}),
|
|
{'session_id': 'fill-empty-snapshot'}))
|
|
assert result['exit_code'] == (0 if retained else 1), result
|
|
assert 'textbox Search [ref=e9]' in result['output']
|
|
assert 'private-sentinel' not in json.dumps(result)
|
|
assert len(batches) == 2
|
|
assert batches[0].index(['get', 'value', '@e2']) < batches[0].index(['snapshot'])
|
|
assert all(command[0] in {'wait', 'snapshot'} for command in batches[1])
|
|
assert sum('fill' in command for command in commands) == 1
|
|
|
|
|
|
def test_snapshot_observation_preserves_dom_refs_without_duplicate_metadata():
|
|
snapshot = '- searchbox "Search catalog" [ref=e2]\n- button "Search" [ref=e3]'
|
|
raw = json.dumps([{'success': True, 'result': {
|
|
'lifecycle': {'noise': 'x' * 9000}, 'refs': {'e2': {'role': 'searchbox'}},
|
|
'origin': 'https://example.org', 'snapshot': snapshot,
|
|
}}])
|
|
assert PrivateBrowserTool._snapshot_observation(raw) == 'https://example.org\n' + snapshot
|
|
assert PrivateBrowserTool._snapshot_observation('unparsed failure') == 'unparsed failure'
|
|
errors = json.dumps([{'error': 'snapshot failed'}])
|
|
assert PrivateBrowserTool._snapshot_observation(errors) == errors
|
|
|
|
|
|
@pytest.mark.parametrize('action', ['open', 'snapshot'])
|
|
def test_large_page_dialog_controls_survive_both_browser_observation_budgets(monkeypatch, action):
|
|
from types import SimpleNamespace
|
|
import src.turn_contract as turn_contract
|
|
from src.clean_agent_preview import preview_tool_result_text
|
|
monkeypatch.setattr(turn_contract, 'active_turn_contract',
|
|
lambda: SimpleNamespace(routing_experiment='recent_model_choice'))
|
|
monkeypatch.setattr(web_tools.shutil, 'which', lambda name: '/usr/bin/agent-browser')
|
|
snapshot = '- main\n' + ' - paragraph "Catalog item description"\n' * 1000 + (
|
|
'- region "Preferences"\n'
|
|
' - dialog "Choose preferences"\n'
|
|
' - paragraph "Some choices are optional."\n'
|
|
' - button "Only necessary" [ref=e901]\n'
|
|
' - button "All options" [ref=e902]\n'
|
|
'- contentinfo\n'
|
|
)
|
|
commands = []
|
|
class Proc:
|
|
returncode = 0
|
|
def __init__(self, command, kwargs):
|
|
self.command, self.kwargs = command, kwargs
|
|
async def communicate(self, stdin=None):
|
|
if not stdin and self.command[-1] == 'snapshot':
|
|
self.kwargs['stdout'].write(snapshot.encode())
|
|
return (json.dumps([{'success': True, 'result': {
|
|
'origin': 'https://example.org/catalog', 'snapshot': snapshot,
|
|
}}]).encode(), b'') if stdin else (b'', b'')
|
|
async def spawn(*command, **kwargs):
|
|
commands.append(command)
|
|
return Proc(command, kwargs)
|
|
monkeypatch.setattr(asyncio, 'create_subprocess_exec', spawn)
|
|
args = {'action': action}
|
|
if action == 'open':
|
|
args['url'] = 'https://example.org/catalog'
|
|
result = asyncio.run(PrivateBrowserTool().execute(json.dumps(args), {'session_id': 'large-dialog'}))
|
|
observation = preview_tool_result_text(result, 'private_browser', args)
|
|
assert result['exit_code'] == 0
|
|
assert '- dialog "Choose preferences"' in observation
|
|
assert observation.count('button "Only necessary" [ref=e901]') == 1
|
|
assert observation.count('button "All options" [ref=e902]') == 1
|
|
if action == 'open':
|
|
assert 'https://example.org/catalog' in observation
|
|
assert len(observation) < 8100
|
|
assert not any('click' in command or 'fill' in command for command in commands)
|
|
|
|
|
|
def test_fill_reports_incomplete_when_browser_success_did_not_retain_text(monkeypatch):
|
|
from types import SimpleNamespace
|
|
import src.turn_contract as turn_contract
|
|
monkeypatch.setattr(turn_contract, 'active_turn_contract',
|
|
lambda: SimpleNamespace(routing_experiment='recent_model_choice'))
|
|
monkeypatch.setattr(web_tools.shutil, 'which', lambda name: '/usr/bin/agent-browser')
|
|
monkeypatch.setattr(PrivateBrowserTool, '_AUTO_SCREENSHOT_ACTIONS', set())
|
|
commands = []
|
|
batches = []
|
|
class Proc:
|
|
returncode = 0
|
|
def __init__(self, kwargs):
|
|
self.kwargs = kwargs
|
|
async def communicate(self, stdin=None):
|
|
if stdin:
|
|
batches.append(json.loads(stdin))
|
|
return json.dumps([
|
|
{'command': ['get', 'value', '@e2'], 'success': True, 'result': {'value': ''}},
|
|
{'command': ['snapshot'], 'success': True,
|
|
'result': {'snapshot': 'dialog Preferences\nbutton Close [ref=e2]'}},
|
|
]).encode(), b''
|
|
self.kwargs['stdout'].write('✓ Done'.encode())
|
|
return b'', b''
|
|
async def spawn(*command, **kwargs):
|
|
commands.append(command)
|
|
return Proc(kwargs)
|
|
monkeypatch.setattr(asyncio, 'create_subprocess_exec', spawn)
|
|
result = asyncio.run(PrivateBrowserTool().execute(
|
|
json.dumps({'action': 'fill', 'ref': 'e2', 'text': 'orange'}),
|
|
{'session_id': 'incomplete-fill'}))
|
|
assert result['exit_code'] == 1, result
|
|
assert 'did not retain' in result['error']
|
|
assert 'dialog Preferences' in result['output']
|
|
assert '✓ Done' not in result['output']
|
|
assert sum('fill' in command for command in commands) == 1
|
|
assert batches[0].index(['get', 'value', '@e2']) < batches[0].index(['snapshot'])
|
|
|
|
|
|
@pytest.mark.parametrize('raw,expected_exit', [
|
|
([{'command': ['get', 'value', '@e2'], 'success': True,
|
|
'result': {'value': 'sentinel-private-input'}}], 0),
|
|
([{'command': ['get', 'value', '@e2'], 'success': False,
|
|
'result': {'value': 'sentinel-private-input'}}], 1),
|
|
([], 1),
|
|
('malformed sentinel-private-input', 1),
|
|
])
|
|
def test_fill_verification_is_truthful_without_dumping_input_values(monkeypatch, raw, expected_exit):
|
|
from types import SimpleNamespace
|
|
import src.turn_contract as turn_contract
|
|
monkeypatch.setattr(turn_contract, 'active_turn_contract',
|
|
lambda: SimpleNamespace(routing_experiment='recent_model_choice'))
|
|
monkeypatch.setattr(web_tools.shutil, 'which', lambda name: '/usr/bin/agent-browser')
|
|
monkeypatch.setattr(PrivateBrowserTool, '_AUTO_SCREENSHOT_ACTIONS', set())
|
|
class Proc:
|
|
returncode = 0
|
|
async def communicate(self, stdin=None):
|
|
return (json.dumps(raw).encode(), b'') if stdin else (b'', b'')
|
|
async def spawn(*command, **kwargs):
|
|
return Proc()
|
|
monkeypatch.setattr(asyncio, 'create_subprocess_exec', spawn)
|
|
result = asyncio.run(PrivateBrowserTool().execute(
|
|
json.dumps({'action': 'fill', 'target': '@e2', 'text': 'sentinel-private-input'}),
|
|
{'session_id': 'private-fill-verification'}))
|
|
assert result['exit_code'] == expected_exit
|
|
assert 'sentinel-private-input' not in json.dumps(result)
|
|
if expected_exit:
|
|
assert 'could not be verified' in result['error']
|
|
|
|
|
|
@pytest.mark.parametrize('mode,observed', [('recent_model_choice', True), ('baseline', False)])
|
|
@pytest.mark.parametrize('action', ['click', 'fill'])
|
|
def test_failed_interaction_returns_current_refs_without_retrying_action(monkeypatch, mode, observed, action):
|
|
from types import SimpleNamespace
|
|
import src.turn_contract as turn_contract
|
|
monkeypatch.setattr(turn_contract, 'active_turn_contract',
|
|
lambda: SimpleNamespace(routing_experiment=mode))
|
|
monkeypatch.setattr(web_tools.shutil, 'which', lambda name: '/usr/bin/agent-browser')
|
|
monkeypatch.setattr(PrivateBrowserTool, '_AUTO_SCREENSHOT_ACTIONS', set())
|
|
commands = []
|
|
class Proc:
|
|
def __init__(self, kwargs, failed):
|
|
self.kwargs, self.failed = kwargs, failed
|
|
self.returncode = 1 if failed else 0
|
|
async def communicate(self, stdin=None):
|
|
if self.failed:
|
|
self.kwargs['stderr'].write(b'Element is covered by a dialog')
|
|
return b'', b''
|
|
return b'[{"result":{"snapshot":"dialog Cookie choices\\nbutton Reject optional [ref=e9]"},"success":true}]', b''
|
|
async def spawn(*command, **kwargs):
|
|
commands.append(command)
|
|
return Proc(kwargs, len(commands) == 1)
|
|
monkeypatch.setattr(asyncio, 'create_subprocess_exec', spawn)
|
|
result = asyncio.run(PrivateBrowserTool().execute(
|
|
json.dumps({'action': action, 'target': '@e2', 'text': 'orange'}), {'session_id': 'failed-interaction-observation'}))
|
|
assert result['exit_code'] == 1
|
|
assert 'Element is covered' in result['output']
|
|
assert ('Reject optional [ref=e9]' in result['output']) is observed
|
|
assert len(commands) == (2 if observed else 1)
|
|
assert sum(action in command for command in commands) == 1
|
|
if observed:
|
|
assert commands[1][-2:] == ('batch', '--json')
|
|
|
|
|
|
def test_private_browser_bare_wait_uses_timeout_as_duration_with_process_headroom() -> None:
|
|
tool = PrivateBrowserTool()
|
|
|
|
command, stdin_data, err = tool._command_for_action(
|
|
["agent-browser"],
|
|
"wait",
|
|
{"timeout_ms": 2000},
|
|
)
|
|
|
|
assert err is None
|
|
assert stdin_data is None
|
|
assert command == ["agent-browser", "wait", "2000"]
|
|
assert tool._timeout_seconds({"timeout_ms": 2000}, action="wait") >= 7
|
|
|
|
|
|
def test_private_browser_prefix_is_scoped_to_odysseus_session() -> None:
|
|
prefix = PrivateBrowserTool()._with_session_args(
|
|
["agent-browser"],
|
|
{"session_id": "f42b1fb9-3747-44f0-bf64-61e7b3b14faa"},
|
|
)
|
|
|
|
assert prefix == [
|
|
"agent-browser",
|
|
"--session",
|
|
"ody-" + __import__('hashlib').sha256(
|
|
b'odysseus-ui\0f42b1fb9-3747-44f0-bf64-61e7b3b14faa'
|
|
).hexdigest()[:20],
|
|
]
|
|
|
|
|
|
def test_private_browser_namespace_can_isolate_parallel_runtimes(monkeypatch) -> None:
|
|
monkeypatch.setenv("ODYSSEUS_BROWSER_NAMESPACE", "clawmm-run/abc")
|
|
|
|
prefix = PrivateBrowserTool()._with_session_args(
|
|
["agent-browser"],
|
|
{"session_id": "session-1"},
|
|
)
|
|
|
|
assert prefix == [
|
|
"agent-browser",
|
|
"--session",
|
|
"ody-" + __import__('hashlib').sha256(
|
|
b'clawmm-run/abc\0session-1'
|
|
).hexdigest()[:20],
|
|
]
|
|
|
|
|
|
def test_private_browser_namespace_uses_effective_task_environment(monkeypatch) -> None:
|
|
monkeypatch.delenv("ODYSSEUS_BROWSER_NAMESPACE", raising=False)
|
|
|
|
prefix = PrivateBrowserTool()._with_session_args(
|
|
["agent-browser"],
|
|
{
|
|
"session_id": "session-1",
|
|
"subproc_env": {"ODYSSEUS_BROWSER_NAMESPACE": "clawmm-task-abc"},
|
|
},
|
|
)
|
|
|
|
assert prefix == [
|
|
"agent-browser",
|
|
"--session",
|
|
"ody-" + __import__('hashlib').sha256(
|
|
b'clawmm-task-abc\0session-1'
|
|
).hexdigest()[:20],
|
|
]
|
|
|
|
|
|
def test_private_browser_long_session_and_namespace_are_bounded(monkeypatch):
|
|
monkeypatch.setenv('ODYSSEUS_BROWSER_NAMESPACE', 'runtime-' + 'n'*100)
|
|
prefix = PrivateBrowserTool()._with_session_args(['agent-browser'], {'session_id':'x'*200})
|
|
assert len(prefix[prefix.index('--session')+1]) <= 24
|
|
|
|
|
|
def test_private_browser_hashes_preserve_session_isolation():
|
|
tool=PrivateBrowserTool()
|
|
names=[tool._with_session_args(['agent-browser'], {'session_id':s})[-1]
|
|
for s in ['x'*100+'a', 'x'*100+'b', 'a/b', 'a?b']]
|
|
assert len(set(names)) == 4
|
|
assert tool._with_session_args(['agent-browser'], {'session_id':'x'*100+'a'})[-1] == names[0]
|
|
|
|
|
|
def test_private_browser_reuses_host_npx_cache_when_task_home_is_isolated(
|
|
monkeypatch, tmp_path
|
|
) -> None:
|
|
host_home = tmp_path / "host-home"
|
|
task_home = tmp_path / "task-home"
|
|
host_home.mkdir()
|
|
task_home.mkdir()
|
|
monkeypatch.setenv("HOME", str(host_home))
|
|
monkeypatch.setattr(web_tools, "_service_home", lambda: host_home)
|
|
monkeypatch.setattr(web_tools, "_host_npm_roots", lambda: [host_home / ".npm"])
|
|
monkeypatch.delenv("npm_config_cache", raising=False)
|
|
monkeypatch.delenv("NPM_CONFIG_CACHE", raising=False)
|
|
|
|
def _which(name: str):
|
|
return "/usr/bin/npx" if name == "npx" else None
|
|
|
|
monkeypatch.setattr(web_tools.shutil, "which", _which)
|
|
calls = {}
|
|
|
|
class _FakeProc:
|
|
returncode = 0
|
|
|
|
async def communicate(self, stdin=None):
|
|
return b"page text", b""
|
|
|
|
async def _fake_create_subprocess_exec(*command, **kwargs):
|
|
calls["env"] = kwargs["env"]
|
|
return _FakeProc()
|
|
|
|
monkeypatch.setattr(
|
|
asyncio, "create_subprocess_exec", _fake_create_subprocess_exec
|
|
)
|
|
|
|
result = asyncio.run(PrivateBrowserTool().execute(
|
|
json.dumps({"action": "read", "url": "https://example.com"}),
|
|
{"subproc_env": {"HOME": str(task_home)}},
|
|
))
|
|
|
|
assert result["exit_code"] == 0
|
|
assert calls["env"]["HOME"] == str(host_home)
|
|
assert calls["env"]["npm_config_cache"] == str(host_home / ".npm")
|
|
assert calls["env"]["NPM_CONFIG_CACHE"] == str(host_home / ".npm")
|
|
assert calls["env"]["AGENT_BROWSER_IDLE_TIMEOUT_MS"] == "300000"
|
|
|
|
|
|
def test_private_browser_prefers_installed_npx_binary(monkeypatch, tmp_path) -> None:
|
|
package_bin = (
|
|
tmp_path
|
|
/ ".npm"
|
|
/ "_npx"
|
|
/ "abc"
|
|
/ "node_modules"
|
|
/ "agent-browser"
|
|
/ "bin"
|
|
)
|
|
package_bin.mkdir(parents=True)
|
|
binary = package_bin / "agent-browser-linux-x64"
|
|
binary.write_text("#!/bin/sh\n")
|
|
binary.chmod(0o755)
|
|
monkeypatch.setattr(web_tools, "_service_home", lambda: tmp_path)
|
|
monkeypatch.setattr(web_tools, "_host_npm_roots", lambda: [tmp_path / ".npm"])
|
|
|
|
assert PrivateBrowserTool._local_agent_browser_binary() == str(binary)
|
|
|
|
|
|
def test_private_browser_skips_unreadable_host_cache(monkeypatch, tmp_path) -> None:
|
|
blocked = tmp_path / "blocked"
|
|
usable = tmp_path / "usable"
|
|
binary = (
|
|
usable
|
|
/ "_npx"
|
|
/ "abc"
|
|
/ "node_modules"
|
|
/ "agent-browser"
|
|
/ "bin"
|
|
/ "agent-browser-linux-x64"
|
|
)
|
|
binary.parent.mkdir(parents=True)
|
|
binary.write_text("#!/bin/sh\n")
|
|
binary.chmod(0o755)
|
|
real_glob = Path.glob
|
|
|
|
def _glob(path, pattern):
|
|
if blocked in path.parents or path == blocked:
|
|
raise PermissionError(path)
|
|
return real_glob(path, pattern)
|
|
|
|
monkeypatch.setattr(web_tools, "_host_npm_roots", lambda: [blocked, usable])
|
|
monkeypatch.setattr(Path, "glob", _glob)
|
|
|
|
assert PrivateBrowserTool._local_agent_browser_binary() == str(binary)
|
|
|
|
|
|
def test_browser_executable_discovery_supports_chromium_snapshot_cache(
|
|
monkeypatch, tmp_path
|
|
) -> None:
|
|
chrome = (
|
|
tmp_path
|
|
/ ".chromium-browser-snapshots"
|
|
/ "chromium"
|
|
/ "linux-123"
|
|
/ "chrome-linux"
|
|
/ "chrome"
|
|
)
|
|
chrome.parent.mkdir(parents=True)
|
|
chrome.write_text("#!/bin/sh\n")
|
|
chrome.chmod(0o755)
|
|
monkeypatch.setattr(web_tools, "_service_home", lambda: tmp_path)
|
|
real_glob = Path.glob
|
|
|
|
def _glob(path, pattern):
|
|
if path == Path("/home") or path == Path("/root"):
|
|
return iter(())
|
|
return real_glob(path, pattern)
|
|
|
|
monkeypatch.setattr(Path, "glob", _glob)
|
|
|
|
assert web_tools._browser_executable_candidates() == [chrome]
|
|
|
|
|
|
def test_private_browser_shutdown_is_namespace_scoped(monkeypatch) -> None:
|
|
calls = {}
|
|
|
|
class _FakeProc:
|
|
async def communicate(self):
|
|
return b"", b""
|
|
|
|
monkeypatch.setattr(web_tools.shutil, "which", lambda name: "/bin/agent-browser")
|
|
monkeypatch.setenv("ODYSSEUS_BROWSER_NAMESPACE", "clawmm-test")
|
|
web_tools._ACTIVE_BROWSER_SESSIONS.clear()
|
|
session = web_tools._scoped_browser_session("clawmm-test", "session-1")
|
|
web_tools._ACTIVE_BROWSER_SESSIONS.add(session)
|
|
|
|
async def _fake_create_subprocess_exec(*command, **kwargs):
|
|
calls["command"] = command
|
|
calls["env"] = kwargs["env"]
|
|
return _FakeProc()
|
|
|
|
monkeypatch.setattr(asyncio, "create_subprocess_exec", _fake_create_subprocess_exec)
|
|
asyncio.run(shutdown_private_browser_sessions())
|
|
|
|
assert calls["command"] == (
|
|
"/bin/agent-browser", "--session", session, "close"
|
|
)
|
|
assert not web_tools._ACTIVE_BROWSER_SESSIONS
|
|
|
|
|
|
def test_browser_pid_candidates_include_upstream_root_session() -> None:
|
|
runtime = Path("/run/user/1000")
|
|
namespace = "clawmm-test"
|
|
session = "session-1"
|
|
|
|
candidates = web_tools._browser_pid_file_candidates(runtime, namespace, session)
|
|
|
|
assert candidates[0] == runtime / "agent-browser" / (
|
|
web_tools._scoped_browser_session(namespace, session) + ".pid"
|
|
)
|
|
assert all("session-1" not in str(path) for path in candidates)
|
|
|
|
|
|
def test_browser_pid_candidates_do_not_sweep_shared_root_without_session(
|
|
tmp_path,
|
|
) -> None:
|
|
legacy = tmp_path / "agent-browser" / "namespaces" / "legacy" / "run"
|
|
legacy.mkdir(parents=True)
|
|
(legacy / "ody-old.pid").write_text("1")
|
|
|
|
candidates = web_tools._browser_pid_file_candidates(tmp_path, "legacy", None)
|
|
|
|
assert candidates == [legacy / "ody-old.pid"]
|
|
|
|
|
|
def test_private_browser_local_file_read_uses_supported_open_action(
|
|
monkeypatch, tmp_path
|
|
) -> None:
|
|
page = tmp_path / "output.html"
|
|
page.write_text("<title>local</title>")
|
|
monkeypatch.setattr(
|
|
"src.tool_execution.get_active_workspace", lambda: str(tmp_path)
|
|
)
|
|
monkeypatch.setattr(
|
|
web_tools.shutil, "which", lambda name: "/usr/bin/agent-browser"
|
|
)
|
|
monkeypatch.setattr(PrivateBrowserTool, "_AUTO_SCREENSHOT_ACTIONS", set())
|
|
monkeypatch.setattr(PrivateBrowserTool, "_owned_daemon_exists", staticmethod(lambda env, session: True))
|
|
calls = []
|
|
|
|
class _FakeProc:
|
|
returncode = 0
|
|
|
|
def __init__(self, command):
|
|
self.command = list(command)
|
|
|
|
async def communicate(self, stdin=None):
|
|
if self.command[-1] == "errors":
|
|
return b"No page errors found", b""
|
|
return b"opened local page", b""
|
|
|
|
async def _fake_create_subprocess_exec(*command, **kwargs):
|
|
calls.append(list(command))
|
|
return _FakeProc(command)
|
|
|
|
monkeypatch.setattr(
|
|
asyncio, "create_subprocess_exec", _fake_create_subprocess_exec
|
|
)
|
|
|
|
result = asyncio.run(PrivateBrowserTool().execute(
|
|
json.dumps({"action": "read", "url": "/workspace/output.html"}),
|
|
{"session_id": "local-read"},
|
|
))
|
|
|
|
assert result["exit_code"] == 0
|
|
assert calls[0][-1] == "close"
|
|
assert calls[1][-2:] == ["open", page.as_uri()]
|
|
assert calls[2][-1] == "errors"
|
|
|
|
|
|
def test_private_browser_new_local_session_skips_reset_close(
|
|
monkeypatch, tmp_path
|
|
) -> None:
|
|
page = tmp_path / "new.html"
|
|
page.write_text("<title>new</title>")
|
|
monkeypatch.setattr(
|
|
"src.tool_execution.get_active_workspace", lambda: str(tmp_path)
|
|
)
|
|
monkeypatch.setattr(
|
|
web_tools.shutil, "which", lambda name: "/usr/bin/agent-browser"
|
|
)
|
|
monkeypatch.setattr(PrivateBrowserTool, "_AUTO_SCREENSHOT_ACTIONS", set())
|
|
calls = []
|
|
|
|
class _FakeProc:
|
|
returncode = 0
|
|
|
|
def __init__(self, command):
|
|
self.command = list(command)
|
|
|
|
async def communicate(self, stdin=None):
|
|
if self.command[-1] == "errors":
|
|
return b"No page errors found", b""
|
|
return b"opened new local page", b""
|
|
|
|
async def _fake_create_subprocess_exec(*command, **kwargs):
|
|
calls.append(list(command))
|
|
return _FakeProc(command)
|
|
|
|
monkeypatch.setattr(asyncio, "create_subprocess_exec", _fake_create_subprocess_exec)
|
|
|
|
result = asyncio.run(PrivateBrowserTool().execute(
|
|
json.dumps({"action": "open", "url": "/workspace/new.html"}),
|
|
{"session_id": "never-started"},
|
|
))
|
|
|
|
assert result["exit_code"] == 0
|
|
session = web_tools._scoped_browser_session("odysseus-ui", "never-started")
|
|
assert calls == [
|
|
["/usr/bin/agent-browser", "--session", session, "open", page.as_uri()],
|
|
["/usr/bin/agent-browser", "--session", session, "errors"],
|
|
]
|
|
|
|
|
|
def test_private_browser_local_html_ignores_stale_page_errors(
|
|
monkeypatch, tmp_path
|
|
) -> None:
|
|
page = tmp_path / "clean.html"
|
|
page.write_text("<title>clean</title>")
|
|
monkeypatch.setattr(
|
|
"src.tool_execution.get_active_workspace", lambda: str(tmp_path)
|
|
)
|
|
monkeypatch.setattr(
|
|
web_tools.shutil, "which", lambda name: "/usr/bin/agent-browser"
|
|
)
|
|
monkeypatch.setattr(PrivateBrowserTool, "_AUTO_SCREENSHOT_ACTIONS", set())
|
|
monkeypatch.setattr(PrivateBrowserTool, "_owned_daemon_exists", staticmethod(lambda env, session: True))
|
|
calls = []
|
|
|
|
class _FakeProc:
|
|
returncode = 0
|
|
|
|
def __init__(self, command):
|
|
self.command = list(command)
|
|
|
|
async def communicate(self, stdin=None):
|
|
if self.command[-1] == "close":
|
|
return b"closed stale browser session", b""
|
|
if self.command[-1] == "errors":
|
|
return b"No page errors found", b""
|
|
return b"opened clean local page", b""
|
|
|
|
async def _fake_create_subprocess_exec(*command, **kwargs):
|
|
calls.append(list(command))
|
|
return _FakeProc(command)
|
|
|
|
monkeypatch.setattr(
|
|
asyncio, "create_subprocess_exec", _fake_create_subprocess_exec
|
|
)
|
|
|
|
result = asyncio.run(PrivateBrowserTool().execute(
|
|
json.dumps({"action": "open", "url": "/workspace/clean.html"}),
|
|
{"session_id": "reused-session"},
|
|
))
|
|
|
|
assert result["exit_code"] == 0
|
|
assert "stale error" not in result["output"]
|
|
assert calls[0][-1] == "close"
|
|
assert calls[2][-1] == "errors"
|
|
|
|
|
|
def test_private_browser_local_html_surfaces_page_errors(monkeypatch, tmp_path) -> None:
|
|
page = tmp_path / "broken.html"
|
|
page.write_text("<script>missingFunction()</script>")
|
|
monkeypatch.setattr(
|
|
"src.tool_execution.get_active_workspace", lambda: str(tmp_path)
|
|
)
|
|
monkeypatch.setattr(
|
|
web_tools.shutil, "which", lambda name: "/usr/bin/agent-browser"
|
|
)
|
|
monkeypatch.setattr(PrivateBrowserTool, "_AUTO_SCREENSHOT_ACTIONS", set())
|
|
|
|
class _FakeProc:
|
|
returncode = 0
|
|
|
|
def __init__(self, command):
|
|
self.command = list(command)
|
|
|
|
async def communicate(self, stdin=None):
|
|
if self.command[-1] == "errors":
|
|
return b"ReferenceError: missingFunction is not defined", b""
|
|
return b"opened local page", b""
|
|
|
|
async def _fake_create_subprocess_exec(*command, **kwargs):
|
|
return _FakeProc(command)
|
|
|
|
monkeypatch.setattr(
|
|
asyncio, "create_subprocess_exec", _fake_create_subprocess_exec
|
|
)
|
|
|
|
result = asyncio.run(PrivateBrowserTool().execute(
|
|
json.dumps({"action": "open", "url": "/workspace/broken.html"}),
|
|
{"session_id": "broken-local-page"},
|
|
))
|
|
|
|
assert result["exit_code"] == 1
|
|
assert "[page errors]" in result["output"]
|
|
assert "ReferenceError" in result["output"]
|
|
assert "Fix the artifact and reopen" in result["error"]
|
|
|
|
|
|
def test_private_browser_batch_uses_json_stdin() -> None:
|
|
command, stdin_data, err = PrivateBrowserTool()._command_for_action(
|
|
[
|
|
"agent-browser",
|
|
"--namespace",
|
|
"odysseus-ui",
|
|
"--session",
|
|
"ody-session-a",
|
|
],
|
|
"batch",
|
|
{"commands": [["open", "https://example.com"], ["snapshot"]]},
|
|
)
|
|
|
|
assert err is None
|
|
assert command == [
|
|
"agent-browser",
|
|
"--namespace",
|
|
"odysseus-ui",
|
|
"--session",
|
|
"ody-session-a",
|
|
"batch",
|
|
"--json",
|
|
]
|
|
assert stdin_data == '[["open", "https://example.com"], ["snapshot"]]'
|
|
|
|
|
|
def test_private_browser_batch_screenshot_gets_writable_path(monkeypatch, tmp_path) -> None:
|
|
monkeypatch.setattr(web_tools.tempfile, "gettempdir", lambda: str(tmp_path))
|
|
|
|
commands, paths = PrivateBrowserTool()._normalize_batch_screenshots([
|
|
["open", "https://example.com"],
|
|
["screenshot"],
|
|
])
|
|
|
|
assert len(paths) == 1
|
|
assert commands[0] == ["open", "https://example.com"]
|
|
assert commands[1][0] == "screenshot"
|
|
assert commands[1][1].endswith(".png")
|
|
assert Path(commands[1][1]).parent == tmp_path / "odysseus-private-browser"
|
|
|
|
|
|
def test_private_browser_batch_screenshot_ignores_model_chosen_path(monkeypatch, tmp_path) -> None:
|
|
monkeypatch.setattr(web_tools.tempfile, "gettempdir", lambda: str(tmp_path))
|
|
|
|
commands, paths = PrivateBrowserTool()._normalize_batch_screenshots([
|
|
["open", "https://example.com"],
|
|
["screenshot", "/tmp/example_com.png"],
|
|
["screenshot", {"path": "/tmp/also_bad.png"}],
|
|
])
|
|
|
|
assert len(paths) == 2
|
|
assert commands[1] == ["screenshot", str(paths[0])]
|
|
assert commands[2] == ["screenshot", str(paths[1])]
|
|
assert all(path.parent == tmp_path / "odysseus-private-browser" for path in paths)
|
|
|
|
|
|
def test_private_browser_empty_batch_recovers_as_snapshot() -> None:
|
|
command, stdin_data, err = PrivateBrowserTool()._command_for_action(
|
|
[
|
|
"agent-browser",
|
|
"--namespace",
|
|
"odysseus-ui",
|
|
"--session",
|
|
"ody-session-a",
|
|
],
|
|
"batch",
|
|
{"commands": []},
|
|
)
|
|
|
|
assert err is None
|
|
assert command == [
|
|
"agent-browser",
|
|
"--namespace",
|
|
"odysseus-ui",
|
|
"--session",
|
|
"ody-session-a",
|
|
"snapshot",
|
|
]
|
|
assert stdin_data is None
|
|
|
|
|
|
def test_private_browser_screenshot_without_path_returns_image_payload(monkeypatch, tmp_path) -> None:
|
|
png_bytes = b"\x89PNG\r\n\x1a\nbrowser"
|
|
|
|
monkeypatch.setattr(web_tools.shutil, "which", lambda name: "/usr/bin/agent-browser")
|
|
monkeypatch.setattr(web_tools.tempfile, "gettempdir", lambda: str(tmp_path))
|
|
|
|
class _FakeProc:
|
|
returncode = 0
|
|
|
|
async def communicate(self, stdin=None):
|
|
screenshot_path = Path(calls["command"][-1])
|
|
screenshot_path.write_bytes(png_bytes)
|
|
return b"saved screenshot", b""
|
|
|
|
calls = {}
|
|
|
|
async def _fake_create_subprocess_exec(*command, **kwargs):
|
|
calls["command"] = list(command)
|
|
return _FakeProc()
|
|
|
|
monkeypatch.setattr(asyncio, "create_subprocess_exec", _fake_create_subprocess_exec)
|
|
|
|
result = asyncio.run(PrivateBrowserTool().execute(
|
|
json.dumps({"action": "screenshot"}),
|
|
{"session_id": "abc"},
|
|
))
|
|
|
|
assert result["exit_code"] == 0
|
|
assert calls["command"][:4] == [
|
|
"/usr/bin/agent-browser",
|
|
"--session",
|
|
web_tools._scoped_browser_session("odysseus-ui", "abc"),
|
|
"screenshot",
|
|
]
|
|
assert calls["command"][-1].endswith(".png")
|
|
assert result["images"] == [{
|
|
"data": base64.b64encode(png_bytes).decode("ascii"),
|
|
"mimeType": "image/png",
|
|
}]
|
|
|
|
|
|
def test_private_browser_timeout_terminates_the_process_group(monkeypatch) -> None:
|
|
calls = []
|
|
|
|
class _Proc:
|
|
pid = 1234
|
|
|
|
def kill(self):
|
|
calls.append("fallback-kill")
|
|
|
|
monkeypatch.setattr(web_tools.os, "getpgid", lambda pid: pid)
|
|
monkeypatch.setattr(
|
|
web_tools.os,
|
|
"killpg",
|
|
lambda pgid, signum: calls.append((pgid, signum)),
|
|
)
|
|
|
|
PrivateBrowserTool._terminate_subprocess(_Proc())
|
|
|
|
assert calls == [(1234, web_tools.signal.SIGKILL), "fallback-kill"]
|
|
|
|
|
|
def test_private_browser_retries_one_timed_out_local_open(monkeypatch, tmp_path) -> None:
|
|
page = tmp_path / "output.html"
|
|
page.write_text("<html><title>retry</title></html>")
|
|
monkeypatch.setattr("src.tool_execution.get_active_workspace", lambda: str(tmp_path))
|
|
monkeypatch.setattr(web_tools.shutil, "which", lambda name: "/usr/bin/agent-browser")
|
|
monkeypatch.setattr(PrivateBrowserTool, "_AUTO_SCREENSHOT_ACTIONS", set())
|
|
|
|
monkeypatch.setattr(PrivateBrowserTool, "_capture_page_errors", lambda *args: _no_page_errors())
|
|
|
|
calls = []
|
|
|
|
class _Proc:
|
|
returncode = 0
|
|
|
|
def __init__(self, command):
|
|
self.command = list(command)
|
|
|
|
async def communicate(self, stdin=None):
|
|
if self.command[-1] == "open" or (
|
|
len(self.command) > 1 and self.command[-2] == "open"
|
|
):
|
|
if sum(1 for call in calls if call[-1] == page.as_uri()) == 1:
|
|
raise asyncio.TimeoutError()
|
|
return b"opened", b""
|
|
|
|
def kill(self):
|
|
return None
|
|
|
|
async def _no_page_errors():
|
|
return ""
|
|
|
|
async def _fake_create_subprocess_exec(*command, **kwargs):
|
|
calls.append(list(command))
|
|
return _Proc(command)
|
|
|
|
monkeypatch.setattr(asyncio, "create_subprocess_exec", _fake_create_subprocess_exec)
|
|
|
|
result = asyncio.run(PrivateBrowserTool().execute(
|
|
json.dumps({"action": "open", "url": "file:///workspace/output.html"}),
|
|
{"session_id": "retry-local-open"},
|
|
))
|
|
|
|
assert result["exit_code"] == 0
|
|
assert sum(1 for call in calls if call[-1] == page.as_uri()) == 2
|
|
|
|
|
|
def test_private_browser_retries_transient_local_browser_bootstrap_failure(
|
|
monkeypatch, tmp_path
|
|
) -> None:
|
|
page = tmp_path / "output.html"
|
|
page.write_text("<html><title>bootstrap retry</title></html>")
|
|
monkeypatch.setattr("src.tool_execution.get_active_workspace", lambda: str(tmp_path))
|
|
monkeypatch.setattr(web_tools.shutil, "which", lambda name: "/usr/bin/agent-browser")
|
|
monkeypatch.setattr(PrivateBrowserTool, "_AUTO_SCREENSHOT_ACTIONS", set())
|
|
|
|
async def _no_page_errors():
|
|
return ""
|
|
|
|
monkeypatch.setattr(PrivateBrowserTool, "_capture_page_errors", lambda *args: _no_page_errors())
|
|
monkeypatch.setattr(PrivateBrowserTool, "_terminate_owned_chrome", lambda *args: None)
|
|
monkeypatch.setattr(PrivateBrowserTool, "_terminate_owned_daemon", lambda *args: None)
|
|
|
|
calls = []
|
|
|
|
class _Proc:
|
|
def __init__(self, command, attempt, kwargs):
|
|
self.command = list(command)
|
|
self.returncode = 1 if attempt == 1 else 0
|
|
self._stdout = kwargs.get("stdout")
|
|
self._stderr = kwargs.get("stderr")
|
|
|
|
async def communicate(self, stdin=None):
|
|
if self.returncode:
|
|
self._stderr.write(
|
|
b"Could not configure browser: Failed to connect: "
|
|
b"No such file or directory (os error 2)"
|
|
)
|
|
else:
|
|
self._stdout.write(b"opened")
|
|
return b"", b""
|
|
|
|
async def _fake_create_subprocess_exec(*command, **kwargs):
|
|
calls.append(list(command))
|
|
return _Proc(
|
|
command,
|
|
sum(1 for call in calls if call[-1] == page.as_uri()),
|
|
kwargs,
|
|
)
|
|
|
|
monkeypatch.setattr(asyncio, "create_subprocess_exec", _fake_create_subprocess_exec)
|
|
|
|
result = asyncio.run(PrivateBrowserTool().execute(
|
|
json.dumps({"action": "open", "url": "file:///workspace/output.html"}),
|
|
{"session_id": "bootstrap-retry"},
|
|
))
|
|
|
|
assert result["exit_code"] == 0, result
|
|
assert sum(1 for call in calls if call[-1] == page.as_uri()) == 2
|
|
|
|
|
|
def test_private_browser_open_defers_screenshot_until_snapshot(monkeypatch, tmp_path) -> None:
|
|
png_bytes = b"\x89PNG\r\n\x1a\nauto-browser"
|
|
|
|
monkeypatch.setattr(web_tools.shutil, "which", lambda name: "/usr/bin/agent-browser")
|
|
monkeypatch.setattr(web_tools.tempfile, "gettempdir", lambda: str(tmp_path))
|
|
|
|
class _FakeProc:
|
|
returncode = 0
|
|
|
|
def __init__(self, command):
|
|
self.command = list(command)
|
|
|
|
async def communicate(self, stdin=None):
|
|
if "screenshot" in self.command:
|
|
Path(self.command[-1]).write_bytes(png_bytes)
|
|
return b"saved screenshot", b""
|
|
return b"opened", b""
|
|
|
|
calls = []
|
|
|
|
async def _fake_create_subprocess_exec(*command, **kwargs):
|
|
calls.append(list(command))
|
|
return _FakeProc(command)
|
|
|
|
monkeypatch.setattr(asyncio, "create_subprocess_exec", _fake_create_subprocess_exec)
|
|
|
|
result = asyncio.run(PrivateBrowserTool().execute(
|
|
json.dumps({"action": "open", "url": "https://example.com"}),
|
|
{"session_id": "abc"},
|
|
))
|
|
|
|
assert result["exit_code"] == 0
|
|
assert calls[0] == [
|
|
"/usr/bin/agent-browser",
|
|
"--session",
|
|
web_tools._scoped_browser_session("odysseus-ui", "abc"),
|
|
"open",
|
|
"https://example.com",
|
|
]
|
|
assert len(calls) == 1
|
|
assert "images" not in result
|
|
|
|
|
|
def test_youtube_tool_comments_falls_back_to_ytdlp(monkeypatch) -> None:
|
|
from services.youtube import youtube_handler
|
|
|
|
async def _fake_comments(video_id, max_comments=25, timeout=30):
|
|
return {
|
|
"success": True,
|
|
"title": "Example Video",
|
|
"channel": "Example Channel",
|
|
"comments": [
|
|
{"author": "Alice", "text": "Useful demo.", "likes": 12},
|
|
{"author": "Bob", "text": "Loved the walkthrough.", "likes": 3},
|
|
],
|
|
}
|
|
|
|
monkeypatch.delenv("YOUTUBE_API_KEY", raising=False)
|
|
monkeypatch.setattr(youtube_handler, "fetch_youtube_comments", _fake_comments)
|
|
|
|
result = asyncio.run(YouTubeTool().execute(
|
|
json.dumps({
|
|
"action": "comments",
|
|
"url": "https://www.youtube.com/watch?v=abc123DEF45",
|
|
"max_results": 2,
|
|
}),
|
|
{},
|
|
))
|
|
|
|
assert result["exit_code"] == 0
|
|
assert "Example Video" in result["output"]
|
|
assert "@Alice [12 likes]: Useful demo." in result["output"]
|
|
|
|
|
|
def test_youtube_tool_latest_channel_parses_playlist_json() -> None:
|
|
parsed = YouTubeTool._parse_ytdlp_json_output(json.dumps({
|
|
"entries": [
|
|
{"id": "vid123", "title": "Latest upload"},
|
|
{"id": "vid122", "title": "Previous upload"},
|
|
]
|
|
}))
|
|
|
|
assert parsed["entries"][0]["title"] == "Latest upload"
|
|
|
|
|
|
def test_youtube_tool_latest_channel_parses_json_lines() -> None:
|
|
parsed = YouTubeTool._parse_ytdlp_json_output(
|
|
'{"id":"vid123","title":"Latest upload"}\n'
|
|
'{"id":"vid122","title":"Previous upload"}\n'
|
|
)
|
|
|
|
assert [entry["id"] for entry in parsed["entries"]] == ["vid123", "vid122"]
|
|
|
|
|
|
def test_youtube_tool_latest_channel_formats_requested_upload_count(monkeypatch) -> None:
|
|
async def _fake_ytdlp_json(self, url, *, timeout, flat_playlist=False, playlist_end=5):
|
|
assert flat_playlist is True
|
|
assert playlist_end == 5
|
|
return {
|
|
"success": True,
|
|
"data": {
|
|
"entries": [
|
|
{"id": f"vid{i}", "title": f"Upload {i}", "duration": 60 + i}
|
|
for i in range(1, 8)
|
|
]
|
|
},
|
|
}
|
|
|
|
monkeypatch.setattr(YouTubeTool, "_ytdlp_json", _fake_ytdlp_json)
|
|
|
|
result = asyncio.run(YouTubeTool().execute(
|
|
json.dumps({
|
|
"action": "latest_channel_video",
|
|
"channel_url": "https://www.youtube.com/c/RAINBOLTGEO/videos",
|
|
"max_results": 5,
|
|
}),
|
|
{},
|
|
))
|
|
|
|
assert result["exit_code"] == 0
|
|
assert "Latest 5 channel videos" in result["output"]
|
|
assert "1. Upload 1" in result["output"]
|
|
assert "5. Upload 5" in result["output"]
|
|
assert "6. Upload 6" not in result["output"]
|
|
|
|
|
|
def test_youtube_tool_latest_channel_resolves_bad_handle(monkeypatch) -> None:
|
|
calls = []
|
|
|
|
async def _fake_ytdlp_json(self, url, *, timeout, flat_playlist=False, playlist_end=5):
|
|
calls.append(url)
|
|
if url == "https://www.youtube.com/@rainbolt/videos":
|
|
return {"success": False, "error": "HTTP Error 404: Not Found"}
|
|
if url.startswith("ytsearch10:"):
|
|
return {
|
|
"success": True,
|
|
"data": {
|
|
"entries": [
|
|
{
|
|
"title": "rainbolt clips",
|
|
"channel": "rainbolt clips",
|
|
"uploader_id": "@rainboltshorts",
|
|
"channel_url": "https://www.youtube.com/channel/clips",
|
|
"view_count": 1000000,
|
|
},
|
|
{
|
|
"title": "geoguessr pro reacts",
|
|
"channel": "RAINBOLT",
|
|
"uploader_id": "@georainbolt",
|
|
"channel_url": "https://www.youtube.com/channel/official",
|
|
"view_count": 100,
|
|
},
|
|
]
|
|
},
|
|
}
|
|
if url == "https://www.youtube.com/channel/official/videos":
|
|
return {
|
|
"success": True,
|
|
"data": {"entries": [{"id": "vid123", "title": "Official latest upload"}]},
|
|
}
|
|
return {"success": False, "error": f"unexpected url {url}"}
|
|
|
|
monkeypatch.setattr(YouTubeTool, "_ytdlp_json", _fake_ytdlp_json)
|
|
|
|
result = asyncio.run(YouTubeTool().execute(
|
|
json.dumps({
|
|
"action": "latest_channel_video",
|
|
"handle": "@rainbolt",
|
|
"max_results": 5,
|
|
}),
|
|
{},
|
|
))
|
|
|
|
assert result["exit_code"] == 0
|
|
assert "Official latest upload" in result["output"]
|
|
assert calls == [
|
|
"https://www.youtube.com/@rainbolt/videos",
|
|
"ytsearch10:rainbolt official YouTube channel",
|
|
"https://www.youtube.com/channel/official/videos",
|
|
]
|
|
|
|
|
|
def test_youtube_tool_channel_search_query_handles_urls_and_handles() -> None:
|
|
assert YouTubeTool._channel_search_query("@rainbolt") == "rainbolt"
|
|
assert YouTubeTool._channel_search_query("https://www.youtube.com/@rainbolt/videos") == "rainbolt"
|
|
assert YouTubeTool._channel_search_query("https://www.youtube.com/c/RainboltGeo") == "RainboltGeo"
|
|
|
|
|
|
def test_youtube_tool_metadata_with_channel_url_returns_channel_uploads(monkeypatch) -> None:
|
|
async def _fake_ytdlp_json(self, url, *, timeout, flat_playlist=False, playlist_end=5):
|
|
assert flat_playlist is True
|
|
return {
|
|
"success": True,
|
|
"data": {"entries": [{"id": "vid123", "title": "Latest upload"}]},
|
|
}
|
|
|
|
monkeypatch.setattr(YouTubeTool, "_ytdlp_json", _fake_ytdlp_json)
|
|
|
|
result = asyncio.run(YouTubeTool().execute(
|
|
json.dumps({
|
|
"action": "metadata",
|
|
"channel_url": "https://www.youtube.com/@example/videos",
|
|
"max_results": 1,
|
|
}),
|
|
{},
|
|
))
|
|
|
|
assert result["exit_code"] == 0
|
|
assert "Latest channel video" in result["output"]
|
|
assert "Latest upload" in result["output"]
|
|
|
|
|
|
def test_youtube_tool_comment_author_keeps_single_at_prefix() -> None:
|
|
output = YouTubeTool()._format_comments(
|
|
{
|
|
"comments": [
|
|
{"author": "@AlreadyPrefixed", "text": "Looks good.", "likes": 1}
|
|
]
|
|
},
|
|
"https://www.youtube.com/watch?v=abc123DEF45",
|
|
)
|
|
|
|
assert "@AlreadyPrefixed [1 likes]: Looks good." in output
|
|
assert "@@AlreadyPrefixed" not in output
|
|
|
|
|
|
class _FakeBrowserMcp:
|
|
def get_all_tools(self, disabled_map=None):
|
|
disabled = (disabled_map or {}).get("builtin_browser", set())
|
|
return [
|
|
{
|
|
"server_id": "builtin_browser",
|
|
"server_name": "Built-in: Browser",
|
|
"name": "browser_navigate",
|
|
"qualified_name": "mcp__builtin_browser__browser_navigate",
|
|
"description": "Navigate",
|
|
"input_schema": {"type": "object", "properties": {}},
|
|
"is_disabled": "browser_navigate" in disabled,
|
|
}
|
|
]
|
|
|
|
def get_all_openai_schemas(self, disabled_map=None):
|
|
return [
|
|
{
|
|
"type": "function",
|
|
"function": {
|
|
"name": tool["qualified_name"],
|
|
"description": tool["description"],
|
|
"parameters": tool["input_schema"],
|
|
},
|
|
}
|
|
for tool in self.get_all_tools(disabled_map)
|
|
if not tool["is_disabled"]
|
|
]
|
|
|
|
def get_tool_descriptions_for_prompt(self, disabled_map=None, allowed_names=None):
|
|
lines = []
|
|
for tool in self.get_all_tools(disabled_map):
|
|
if tool["is_disabled"]:
|
|
continue
|
|
if allowed_names is not None and tool["qualified_name"] not in allowed_names:
|
|
continue
|
|
lines.append(tool["qualified_name"])
|
|
return "\n".join(lines)
|
|
|
|
|
|
def test_raw_browser_mcp_is_hidden_when_private_browser_is_available() -> None:
|
|
from src.agent_loop import _build_system_prompt
|
|
|
|
messages, schemas = _build_system_prompt(
|
|
[{"role": "user", "content": "open https://example.com and take a screenshot"}],
|
|
"gpt-5.5",
|
|
None,
|
|
_FakeBrowserMcp(),
|
|
disabled_tools=set(),
|
|
relevant_tools={
|
|
"private_browser",
|
|
"builtin_browser",
|
|
"mcp__builtin_browser__browser_navigate",
|
|
},
|
|
compact=True,
|
|
)
|
|
|
|
prompt_text = "\n".join(str(message.get("content") or "") for message in messages)
|
|
schema_names = {
|
|
schema.get("function", {}).get("name")
|
|
for schema in schemas
|
|
}
|
|
|
|
assert "native tool schemas provided for this turn" in prompt_text
|
|
assert "mcp__builtin_browser__browser_navigate" not in prompt_text
|
|
assert "mcp__builtin_browser__browser_navigate" not in schema_names
|
|
|
|
|
|
def test_generic_go_to_phrase_is_browser_interaction() -> None:
|
|
from src.agent_loop import _looks_like_explicit_browser_interaction
|
|
|
|
assert _looks_like_explicit_browser_interaction(
|
|
"Go to Airbnb and find stays in Tokyo for next weekend under $150/night."
|
|
)
|
|
assert _looks_like_explicit_browser_interaction(
|
|
"Navigate to the vendor portal and check the pricing table."
|
|
)
|
|
assert _looks_like_explicit_browser_interaction(
|
|
"Open Spotify's web player and search for Bach cello suites."
|
|
)
|
|
assert not _looks_like_explicit_browser_interaction(
|
|
"Find rules for getting rid of garbage in Setagaya-ku."
|
|
)
|