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