353 lines
17 KiB
Python
353 lines
17 KiB
Python
"""Managed-view identity, segment reuse and cancellation without UI/model I/O."""
|
|
import asyncio
|
|
from copy import deepcopy
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock
|
|
|
|
import pytest
|
|
|
|
from backend.browser import runner, tools
|
|
from backend.prospecting import orchestrator
|
|
from backend.prospecting.contracts import KNOWLEDGE_ID, ProspectingBrief
|
|
|
|
|
|
NOTE = 'https://www.xiaohongshu.com/explore/'+'a'*24+'?xsec_token=private-fixture'
|
|
|
|
|
|
class Browser:
|
|
def __init__(self, *, url=NOTE, target='managed', focus='ui-target'):
|
|
self.url, self.target = url, target
|
|
self.agent_focus_target_id = focus
|
|
self.focuses, self.events = [], []
|
|
self.detached = False
|
|
self.stop = AsyncMock()
|
|
self.cdp_client = SimpleNamespace(send=SimpleNamespace(Target=SimpleNamespace(getTargetInfo=self.info)))
|
|
self.event_bus = SimpleNamespace(dispatch=self.dispatch)
|
|
|
|
async def info(self, params):
|
|
assert params == {'targetId': self.target}
|
|
if self.detached:
|
|
raise RuntimeError('detached private URL '+self.url)
|
|
return {'targetInfo': {'targetId': self.target, 'type': 'page', 'url': self.url}}
|
|
|
|
async def dispatch(self, event):
|
|
self.events.append(event)
|
|
self.agent_focus_target_id = event.target_id
|
|
|
|
async def get_or_create_cdp_session(self, target_id, focus=True):
|
|
assert target_id == self.target and focus is True
|
|
self.focuses.append(target_id)
|
|
self.agent_focus_target_id = target_id
|
|
return SimpleNamespace(session_id='session', cdp_client=SimpleNamespace(send=SimpleNamespace(
|
|
Emulation=SimpleNamespace(clearDeviceMetricsOverride=AsyncMock()))))
|
|
|
|
|
|
class Agent:
|
|
def __init__(self):
|
|
self.state = SimpleNamespace(n_steps=1, stopped=False, last_result=[])
|
|
self.prompts = []
|
|
self.calls = 0
|
|
self.history = SimpleNamespace(history=[], number_of_steps=lambda: 1, total_duration_seconds=lambda: 0,
|
|
is_done=lambda: True, is_successful=lambda: True, urls=lambda: [], action_names=lambda: [],
|
|
errors=lambda: [], final_result=lambda: 'fixture complete')
|
|
|
|
def add_new_task(self, prompt):
|
|
self.prompts.append(prompt)
|
|
self.state.stopped = False
|
|
|
|
async def run(self, max_steps, on_step_start, on_step_end):
|
|
await on_step_start(self)
|
|
self.calls += 1
|
|
self.state.n_steps += 1
|
|
await on_step_end(self)
|
|
return self.history
|
|
|
|
|
|
@pytest.fixture
|
|
def managed(monkeypatch, tmp_path):
|
|
tid = 'managed-d43-fixture'
|
|
agent, browser = Agent(), Browser()
|
|
session = runner.LiveSession(agent, browser, embedded=True, target_id=browser.target,
|
|
tool_profile='prospecting', allowed_domains=('www.xiaohongshu.com',))
|
|
runner._live[tid] = session
|
|
tools.clear_task_context(tid)
|
|
tools.set_prospecting_context(tid)
|
|
for module in (runner, tools):
|
|
monkeypatch.setattr(module, 'task_doc_dir', lambda _: tmp_path)
|
|
monkeypatch.setattr(runner, 'append_progress', lambda *args, **kwargs: None)
|
|
monkeypatch.setattr(runner, 'get_llm_config', lambda: {'provider': 'fixture', 'model': 'fixture'})
|
|
control = AsyncMock(return_value={'views': [{'taskId': tid, 'targetId': browser.target}]})
|
|
monkeypatch.setattr(runner, '_ctrl', control)
|
|
yield tid, session, control
|
|
runner._live.pop(tid, None)
|
|
runner._revision_views.pop(tid, None)
|
|
tools.clear_task_context(tid)
|
|
|
|
|
|
async def test_focus_is_bound_to_host_task_target_and_does_not_navigate_selected_note(managed):
|
|
tid, session, control = managed
|
|
await runner._focus_managed_target(tid, session, verify_host=True)
|
|
assert session.browser.agent_focus_target_id == 'managed'
|
|
assert session.browser.url == NOTE
|
|
assert session.browser.focuses == ['managed']
|
|
assert session.browser.events[0].url == ''
|
|
assert control.await_args.args == ('GET', '/health')
|
|
|
|
|
|
@pytest.mark.parametrize('views', [[], [{'taskId': 'other', 'targetId': 'managed'}],
|
|
[{'taskId': 'managed-d43-fixture', 'targetId': 'other'}],
|
|
[{'taskId': 'managed-d43-fixture', 'targetId': 'managed'}]*2])
|
|
async def test_missing_ambiguous_or_changed_task_binding_never_falls_back_to_ui(managed, views):
|
|
tid, session, control = managed
|
|
control.return_value = {'views': views}
|
|
with pytest.raises(runner.ManagedTargetError, match='managed_target_binding_changed'):
|
|
await runner._focus_managed_target(tid, session, verify_host=True)
|
|
assert not session.browser.focuses
|
|
assert session.browser.url == NOTE
|
|
|
|
|
|
@pytest.mark.parametrize('url', ['app://ui/index.html', 'about:blank', 'chrome://newtab',
|
|
'data:text/html,private', 'https://external.invalid/secret'])
|
|
async def test_invalid_current_page_is_a_safe_zero_model_stop_and_keeps_view(managed, url):
|
|
tid, session, control = managed
|
|
session.browser.url = url
|
|
result = await runner._run_agent(tid, session, session.agent, None)
|
|
assert result['stop_reason'] == 'managed_target_unavailable'
|
|
assert result['history']['steps_this_run'] == 0
|
|
assert session.agent.calls == 0
|
|
assert runner._live[tid] is session
|
|
session.browser.stop.assert_not_awaited()
|
|
assert not any(call.args[0] == 'DELETE' for call in control.await_args_list)
|
|
assert url not in result['summary']
|
|
|
|
|
|
async def test_detached_target_does_not_surface_private_error_or_guess_replacement(managed):
|
|
tid, session, _ = managed
|
|
session.browser.detached = True
|
|
result = await runner._run_agent(tid, session, session.agent, None)
|
|
assert result['history']['managed_target_error'] == 'managed_target_unavailable'
|
|
assert 'private-fixture' not in result['summary']
|
|
assert session.agent.calls == 0 and not session.browser.focuses
|
|
|
|
|
|
async def test_explicit_entry_can_start_from_blank_but_observed_business_page_disables_blank(managed):
|
|
tid, session, _ = managed
|
|
session.browser.url = 'about:blank'
|
|
session.allow_initial_blank = True
|
|
await runner._focus_managed_target(tid, session)
|
|
session.browser.url = NOTE
|
|
await runner._focus_managed_target(tid, session)
|
|
assert session.allow_initial_blank is False
|
|
session.browser.url = 'about:blank'
|
|
with pytest.raises(runner.ManagedTargetError):
|
|
await runner._focus_managed_target(tid, session)
|
|
|
|
|
|
async def test_same_view_new_segment_uses_full_new_prompt_and_goal_version_without_rebuilding(managed, monkeypatch):
|
|
tid, session, control = managed
|
|
monkeypatch.setattr(runner, 'create_llm', lambda: (_ for _ in ()).throw(AssertionError('do not replace the preserved agent')))
|
|
saved = {'_knowledge_goal_version': 3, 'navigation_attempts': [{'source_id': 'child', 'status': 'not_moved'}]}
|
|
tools.set_prospecting_context(tid, artifacts=deepcopy(saved))
|
|
result = await runner.run_browser_step(task_id=tid, agent_task='complete revised task prompt', profile_path='fixture',
|
|
allowed_domains=['www.xiaohongshu.com'], tool_profile='prospecting', max_steps=None,
|
|
reuse_managed_view=True, require_existing_page=True, keep_session=True, prospecting_artifacts=saved)
|
|
assert session.agent.prompts == ['complete revised task prompt']
|
|
assert session.agent.calls == 1
|
|
assert result['verdict'] == 'success'
|
|
assert runner._live[tid] is session and session.browser.url == NOTE
|
|
assert tools._prospecting_run_context[tid]['goal_version'] == 3
|
|
assert tools.get_task_artifacts(tid)['navigation_attempts'] == saved['navigation_attempts']
|
|
assert all(call.args[:2] == ('GET', '/health') for call in control.await_args_list)
|
|
|
|
|
|
@pytest.mark.parametrize('through_entrypoint', [False, True])
|
|
async def test_disabled_preflight_cannot_activate_from_explicit_or_preserved_session_policy(managed, monkeypatch, through_entrypoint):
|
|
tid, session, _ = managed
|
|
monkeypatch.setattr(runner.settings, 'reply_relation_preflight_enabled', False)
|
|
policy = SimpleNamespace(active=True)
|
|
session.preflight_policy = policy
|
|
monkeypatch.setattr(runner, 'PreflightGate', lambda *args, **kwargs: pytest.fail('disabled preflight may not create a gate'))
|
|
if through_entrypoint:
|
|
result = await runner.run_browser_step(task_id=tid, agent_task='continue actual task', profile_path='fixture',
|
|
allowed_domains=['www.xiaohongshu.com'], tool_profile='prospecting', max_steps=None,
|
|
reuse_managed_view=True, require_existing_page=True, keep_session=True, preflight_policy=policy)
|
|
assert session.preflight_policy is None
|
|
else:
|
|
result = await runner._run_agent(tid, session, session.agent, None, keep_session=True)
|
|
assert result['verdict'] == 'success'
|
|
assert session.agent.calls == 1 and session.agent._preflight_scope_gate is None
|
|
assert not tools.get_task_artifacts(tid).get('preflight_run')
|
|
|
|
|
|
@pytest.mark.parametrize('require_existing,expected_calls', [(True, 0), (False, 1)])
|
|
async def test_new_blank_view_requires_an_actual_entry_for_current_page_recheck(managed, monkeypatch, require_existing, expected_calls):
|
|
import browser_use
|
|
tid, _old_session, control = managed
|
|
runner._live.pop(tid)
|
|
created = []
|
|
class NewBrowser(Browser):
|
|
def __init__(self, **kwargs):
|
|
super().__init__(url='about:blank')
|
|
self.options = kwargs
|
|
created.append(self)
|
|
async def start(self):
|
|
pass
|
|
class NewAgent(Agent):
|
|
def __init__(self, **kwargs):
|
|
super().__init__()
|
|
async def ctrl(method, path, *args):
|
|
if path == '/views':
|
|
return {'target_id': 'managed'}
|
|
assert (method, path) == ('GET', '/health')
|
|
return {'views': [{'taskId': tid, 'targetId': 'managed'}]}
|
|
control.side_effect = ctrl
|
|
monkeypatch.setattr(browser_use, 'Browser', NewBrowser)
|
|
monkeypatch.setattr(browser_use, 'Agent', NewAgent)
|
|
monkeypatch.setattr(runner, 'embedded_mode', lambda: True)
|
|
monkeypatch.setattr(runner, 'create_llm', lambda: object())
|
|
monkeypatch.setattr(runner, 'build_prospecting_tools', lambda *args, **kwargs: object())
|
|
monkeypatch.setattr(runner, 'use_vision_for_model', lambda *args: False)
|
|
result = await runner.run_browser_step(task_id=tid, agent_task='actual entry supplied' if not require_existing else 'recheck current page only',
|
|
profile_path='fixture', allowed_domains=['www.xiaohongshu.com'], tool_profile='prospecting', max_steps=None,
|
|
require_existing_page=require_existing, keep_session=True)
|
|
assert runner._live[tid].agent.calls == expected_calls
|
|
assert created[0].url == 'about:blank'
|
|
if require_existing:
|
|
assert result['stop_reason'] == 'managed_target_unavailable'
|
|
assert result['history']['steps_this_run'] == 0
|
|
assert not any(call.args[0] == 'DELETE' for call in control.await_args_list)
|
|
|
|
|
|
async def test_disabling_reply_tools_updates_preserved_agent_schema_without_replacing_view(managed, monkeypatch):
|
|
from browser_use import Agent as BrowserUseAgent
|
|
tid, session, _ = managed
|
|
monkeypatch.setattr(runner.settings, 'reply_relation_preflight_enabled', True)
|
|
session.agent.tools = tools.build_prospecting_tools(tid)
|
|
session.agent.settings = SimpleNamespace(flash_mode=False, use_thinking=True)
|
|
session.agent._setup_action_models = lambda: BrowserUseAgent._setup_action_models(session.agent)
|
|
session.agent._setup_action_models()
|
|
assert 'resolve_reply_target' in str(session.agent.AgentOutput.model_json_schema())
|
|
monkeypatch.setattr(runner.settings, 'reply_relation_preflight_enabled', False)
|
|
result = await runner.run_browser_step(task_id=tid, agent_task='continue actual task', profile_path='fixture',
|
|
allowed_domains=['www.xiaohongshu.com'], tool_profile='prospecting', max_steps=None,
|
|
reuse_managed_view=True, require_existing_page=True, keep_session=True)
|
|
schema = session.agent.AgentOutput.model_json_schema()
|
|
assert all(name not in str(schema) for name in ('inspect_reply_relations', 'resolve_reply_target'))
|
|
assert 'save_source' in str(schema) and 'expand_replies' in str(schema)
|
|
assert result['verdict'] == 'success' and runner._live[tid] is session and session.browser.url == NOTE
|
|
|
|
|
|
async def test_each_step_revalidates_detach_before_another_model_attempt(managed):
|
|
tid, session, _ = managed
|
|
async def run(max_steps, on_step_start, on_step_end):
|
|
await on_step_start(session.agent)
|
|
session.agent.calls += 1
|
|
session.agent.state.n_steps += 1
|
|
session.browser.detached = True
|
|
await on_step_start(session.agent)
|
|
raise AssertionError('second step must never execute')
|
|
session.agent.run = run
|
|
result = await runner._run_agent(tid, session, session.agent, None)
|
|
assert result['history']['steps_this_run'] == 1 and session.agent.calls == 1
|
|
assert result['stop_reason'] == 'managed_target_unavailable'
|
|
assert runner._live[tid] is session
|
|
|
|
|
|
@pytest.mark.parametrize('url,new_tab', [('app://ui/index.html', False), ('https://ui/index.html', False),
|
|
('http://ui/index.html', False), (NOTE, True)])
|
|
async def test_managed_action_guard_rejects_ui_navigation_and_separate_targets(managed, url, new_tab):
|
|
_, session, _ = managed
|
|
session.browser.agent_focus_target_id = session.target_id
|
|
class Base:
|
|
async def multi_act(self, actions):
|
|
raise AssertionError('unsafe action must not reach browser-use')
|
|
agent = runner._prospecting_agent_class(Base)()
|
|
agent.state = SimpleNamespace(stopped=False)
|
|
agent._managed_session = session
|
|
agent._managed_target_guard = AsyncMock()
|
|
action = SimpleNamespace(model_dump=lambda **kwargs: {'navigate': {'url': url, 'new_tab': new_tab}})
|
|
with pytest.raises(runner.ManagedTargetError, match='managed_navigation_not_allowed'):
|
|
await agent.multi_act([action])
|
|
assert agent.state.stopped is True
|
|
|
|
|
|
async def test_context_is_locked_before_capture_and_wrong_ui_summary_never_reaches_model(managed):
|
|
tid, session, _ = managed
|
|
order = []
|
|
class Base:
|
|
async def _prepare_context(self, step_info):
|
|
order.append('capture')
|
|
assert session.browser.agent_focus_target_id == session.target_id
|
|
await self._update_action_models_for_page('app://ui/index.html')
|
|
order.append('messages')
|
|
return SimpleNamespace(url='app://ui/index.html')
|
|
async def _update_action_models_for_page(self, url):
|
|
order.append('action_models')
|
|
agent = runner._prospecting_agent_class(Base)()
|
|
agent.state = SimpleNamespace(stopped=False)
|
|
agent._managed_session = session
|
|
async def guard():
|
|
order.append('lock')
|
|
await runner._focus_managed_target(tid, session)
|
|
agent._managed_target_guard = guard
|
|
with pytest.raises(runner.ManagedTargetError, match='managed_context_changed'):
|
|
await agent._prepare_context()
|
|
assert order == ['lock', 'capture']
|
|
assert agent.state.stopped is True
|
|
|
|
|
|
@pytest.mark.parametrize('change', ['focus', 'url'])
|
|
async def test_action_focus_or_url_change_rejects_old_indices_and_requires_fresh_observation(managed, change):
|
|
tid, session, _ = managed
|
|
class Base:
|
|
async def multi_act(self, actions):
|
|
raise AssertionError('old model indices cannot reach the business view')
|
|
agent = runner._prospecting_agent_class(Base)()
|
|
agent.state = SimpleNamespace(stopped=False)
|
|
agent._managed_session = session
|
|
agent._managed_observed_url = NOTE
|
|
session.browser.agent_focus_target_id = 'ui-target' if change == 'focus' else session.target_id
|
|
if change == 'url':
|
|
session.browser.url = NOTE.replace('a'*24, 'b'*24)
|
|
async def guard():
|
|
await runner._focus_managed_target(tid, session)
|
|
agent._managed_target_guard = guard
|
|
action = SimpleNamespace(model_dump=lambda **kwargs: {'click': {'index': 4}})
|
|
result = await agent.multi_act([action])
|
|
assert len(result) == 1 and '旧动作未执行' in result[0].error
|
|
assert session.browser.agent_focus_target_id == session.target_id
|
|
assert agent.state.stopped is False
|
|
|
|
|
|
async def test_revision_waits_for_old_cleanup_before_new_goal_and_preserves_only_explicit_recheck(managed, monkeypatch):
|
|
tid, session, control = managed
|
|
brief = ProspectingBrief(intent='inspect', user_notes='old goal')
|
|
row = {'id': tid, 'knowledge_id': KNOWLEDGE_ID, 'status': 'running', 'inputs': {**brief.model_dump(), 'goal': 'old goal', '_goal_version': 1},
|
|
'artifacts': {'sources': [{'source_id': 'old'}], 'messages': []}}
|
|
monkeypatch.setattr(orchestrator.db, 'get_task', lambda _: deepcopy(row))
|
|
def update(_tid, **fields):
|
|
row.update(deepcopy(fields))
|
|
return deepcopy(row)
|
|
monkeypatch.setattr(orchestrator.db, 'update_task', update)
|
|
engine = orchestrator.ProspectingOrchestrator()
|
|
cleaned = []
|
|
async def old_run():
|
|
try:
|
|
await asyncio.Event().wait()
|
|
finally:
|
|
await runner.close_live_session(tid)
|
|
assert row['inputs']['_goal_version'] == 1
|
|
cleaned.append(True)
|
|
job = asyncio.create_task(old_run())
|
|
engine.jobs[tid] = job
|
|
await asyncio.sleep(0)
|
|
result = await engine._interrupt_for_revision(tid, preserve_browser=True)
|
|
assert cleaned == [True] and job.done()
|
|
assert runner._live[tid] is session and result['artifacts']['sources'] == [{'source_id': 'old'}]
|
|
assert tid not in runner._revision_views
|
|
assert not any(call.args[0] == 'DELETE' for call in control.await_args_list)
|
|
await engine._interrupt_for_revision(tid)
|
|
assert tid not in runner._live
|
|
session.browser.stop.assert_awaited_once()
|
|
assert control.await_args.args == ('DELETE', f'/views/{tid}')
|