assistant/tests/test_managed_batch_boundary...

172 lines
8.3 KiB
Python

"""Exercise the pinned multi_act loop, including its inter-action await."""
import asyncio
import json
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from browser_use import ActionResult, Agent
from backend.browser import runner
NOTE = 'https://www.xiaohongshu.com/explore/'+'a'*24
def action(name, **values):
return SimpleNamespace(model_dump=lambda **kwargs: {name: values})
def make_agent(*, first_effect=None, terminates=False, wait=.02):
browser = SimpleNamespace(id='local-fixture', agent_focus_target_id='managed', url=NOTE,
_cached_browser_state_summary=None, browser_profile=SimpleNamespace(wait_between_actions=wait))
async def info(params):
assert params == {'targetId': 'managed'}
return {'targetInfo': {'targetId': 'managed', 'type': 'page', 'url': browser.url}}
async def focus(target_id, focus):
assert (target_id, focus) == ('managed', True)
browser.agent_focus_target_id = target_id
return object()
async def dispatch(event):
browser.agent_focus_target_id = event.target_id
async def current_url():
return browser.url if browser.agent_focus_target_id == 'managed' else 'app://ui/index.html'
browser.cdp_client = SimpleNamespace(send=SimpleNamespace(Target=SimpleNamespace(getTargetInfo=info)))
browser.get_or_create_cdp_session = focus
browser.event_bus = SimpleNamespace(dispatch=dispatch)
browser.get_current_page_url = current_url
session = runner.LiveSession(None, browser, embedded=True, target_id='managed',
allowed_domains=('www.xiaohongshu.com',), last_verified_url=NOTE)
agent = object.__new__(runner._prospecting_agent_class(Agent))
agent.browser_session = browser
agent._managed_session = session
agent._managed_observed_url = NOTE
agent.state = SimpleNamespace(n_steps=1, stopped=False)
agent.file_system = None
agent.settings = SimpleNamespace(page_extraction_llm=None)
agent.sensitive_data = None
agent.available_file_paths = []
agent.extraction_schema = None
agent._check_stop_or_pause = AsyncMock()
agent._log_action = AsyncMock()
agent._demo_mode_log = AsyncMock()
calls = []
first_finished = asyncio.Event()
async def act(**kwargs):
calls.append((kwargs['action'].model_dump(), browser.agent_focus_target_id))
if len(calls) == 1:
if first_effect:
await first_effect(browser)
first_finished.set()
return ActionResult(extracted_content='local observation only')
original = SimpleNamespace(act=act, registry=SimpleNamespace(registry=SimpleNamespace(actions={
'inspect_source_candidates': SimpleNamespace(terminates_sequence=terminates),
'send_keys': SimpleNamespace(terminates_sequence=False), 'scroll': SimpleNamespace(terminates_sequence=False)})))
agent.tools = original
checks = []
async def guard():
checks.append(browser.agent_focus_target_id)
await runner._focus_managed_target('local-fixture', session)
agent._managed_target_guard = guard
return agent, browser, original, calls, first_finished, checks
@pytest.mark.parametrize('second', [action('send_keys', keys='End'), action('scroll', down=True, num_pages=1)])
async def test_focus_drift_during_upstream_inter_action_sleep_never_executes_index_free_second_action(second):
agent, browser, original, calls, finished, checks = make_agent()
async def drift():
await finished.wait()
await asyncio.sleep(.005)
browser.agent_focus_target_id = 'ui-target'
drifting = asyncio.create_task(drift())
results = await agent.multi_act([action('inspect_source_candidates', max_comments=1), second,
action('send_keys', keys='Home')])
await drifting
assert len(calls) == 1 and calls[0][1] == 'managed'
assert len(results) == 2 and results[0].error is None
assert '旧动作未执行' in results[1].error
assert 'ui-target' in checks # Rechecked after upstream's await, before act.
assert browser.agent_focus_target_id == 'managed'
assert agent.tools is original
async def test_url_drift_during_batch_sleep_also_discards_remaining_old_actions():
agent, browser, original, calls, finished, _ = make_agent()
async def drift():
await finished.wait()
await asyncio.sleep(.005)
browser.url = NOTE.replace('a'*24, 'b'*24)
drifting = asyncio.create_task(drift())
results = await agent.multi_act([action('inspect_source_candidates', max_comments=1), action('scroll', down=True)])
await drifting
assert len(calls) == 1 and len(results) == 2 and results[-1].error
assert agent.tools is original
async def test_unchanged_batch_retains_the_original_upstream_loop_and_all_results():
agent, _, original, calls, _, checks = make_agent(wait=0)
results = await agent.multi_act([action('inspect_source_candidates', max_comments=1), action('scroll', down=True),
action('send_keys', keys='Home')])
assert len(calls) == len(results) == 3
assert len(checks) == 4 # Batch entry plus every actual action boundary.
assert all(result.error is None for result in results)
assert agent.tools is original
async def test_upstream_terminates_sequence_metadata_is_still_obeyed():
agent, _, original, calls, _, checks = make_agent(terminates=True)
results = await agent.multi_act([action('inspect_source_candidates', max_comments=1), action('send_keys', keys='End')])
assert len(calls) == len(results) == 1 and len(checks) == 2
assert agent.tools is original
async def test_action_ledger_counts_only_invoked_tools_not_remaining_model_proposals(monkeypatch, tmp_path):
monkeypatch.setattr(runner.diagnostics, 'task_doc_dir', lambda _: tmp_path)
agent, _, _, calls, _, _ = make_agent(terminates=True)
with runner.diagnostics.run_scope('baseline-fixture', 'baseline-run'):
await agent.multi_act([action('inspect_source_candidates', max_comments=1),
action('send_keys', keys='private-parameter')])
rows = [json.loads(line) for line in (tmp_path/'browser-actions.jsonl').read_text().splitlines()]
assert len(calls) == 1
assert [row['status'] for row in rows] == ['invoked', 'returned']
assert {row['action_name'] for row in rows} == {'inspect_source_candidates'}
assert rows[0]['action_id'] == rows[1]['action_id']
assert 'private-parameter' not in (tmp_path/'browser-actions.jsonl').read_text()
async def test_action_ledger_distinguishes_rejected_before_invocation(monkeypatch, tmp_path):
monkeypatch.setattr(runner.diagnostics, 'task_doc_dir', lambda _: tmp_path)
delegate = SimpleNamespace(act=AsyncMock())
async def reject(_):
return ActionResult(error='fixture rejected')
boundary = runner._ManagedActionBoundary(delegate, reject)
with runner.diagnostics.run_scope('baseline-fixture', 'baseline-run'):
result = await boundary.act(action=action('scroll', secret='private-parameter'))
rows = [json.loads(line) for line in (tmp_path/'browser-actions.jsonl').read_text().splitlines()]
assert result.error and [row['status'] for row in rows] == ['blocked']
delegate.act.assert_not_awaited()
assert 'private-parameter' not in (tmp_path/'browser-actions.jsonl').read_text()
async def test_upstream_runtime_page_change_guard_is_still_obeyed():
async def change(browser):
browser.url = NOTE.replace('a'*24, 'b'*24)
agent, _, original, calls, _, checks = make_agent(first_effect=change)
results = await agent.multi_act([action('inspect_source_candidates', max_comments=1), action('send_keys', keys='End')])
assert len(calls) == len(results) == 1 and len(checks) == 2
assert agent.tools is original
async def test_cancellation_restores_tools_without_running_a_later_action():
entered = asyncio.Event()
async def blocked(_browser):
entered.set()
await asyncio.Event().wait()
agent, _, original, calls, _, _ = make_agent(first_effect=blocked)
job = asyncio.create_task(agent.multi_act([action('inspect_source_candidates', max_comments=1), action('send_keys', keys='End')]))
await entered.wait()
job.cancel()
with pytest.raises(asyncio.CancelledError):
await job
assert len(calls) == 1 and agent.tools is original