assistant/tests/test_managed_target_d43.py

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}')