"""End-to-end integration test of the real browser agent loop. Drives run_browser_agent() with only the two external boundaries faked: the LLM client (scripted tool calls) and the browser executor (scripted results). Proves the four ported behaviors fire together in the actual loop, not just in isolation: - goal threading into BrowserListInteractives, - deterministic stagnation nudges, - exactly-once aux-LLM adjudication at exhaustion, - per-domain hints written, then seeded into the system prompt next run. """ import asyncio import json import uuid from backend.apps.agents.browser import browser_agent as BA from backend.apps.agents.browser import browser_history as BH # --- fake Anthropic-shaped objects ----------------------------------------- class Blk: def __init__(self, type, text=None, id=None, name=None, input=None): self.type = type; self.text = text; self.id = id; self.name = name; self.input = input class Resp: def __init__(self, content, stop_reason="tool_use"): self.content = content self.stop_reason = stop_reason self.usage = type("U", (), {"input_tokens": 1, "output_tokens": 1})() class FakeStream: # mirrors anthropic's messages.stream(): async CM whose get_final_message() returns the turn def __init__(self, resp): self.resp = resp async def __aenter__(self): return self async def __aexit__(self, *a): return False async def get_final_message(self): return self.resp class FakeLLM: def __init__(self, scripted): self.scripted = scripted; self.turn = 0; self.calls = [] self.messages = self def p_next(self, kw): self.calls.append(kw) i = min(self.turn, len(self.scripted) - 1) self.turn += 1 return self.scripted[i] async def create(self, **kw): return self.p_next(kw) def stream(self, **kw): # the loop now streams; return an async-CM yielding the scripted turn return FakeStream(self.p_next(kw)) class FakeAux: def __init__(self): self.calls = [] self.messages = self async def create(self, **kw): self.calls.append(kw) return Resp([Blk("text", "Try BrowserListInteractives then BrowserClickIndex.")], stop_reason="end_turn") def p_tu(name, **inp): return Blk("tool_use", id="t" + uuid.uuid4().hex[:8], name=name, input=inp) def p_rp(goal, mem="Share dialog is a cross-origin iframe; use the index list."): return p_tu("ReportProgress", evaluation_previous="prev", working_memory=mem, next_goal=goal) DOC_URL = "https://docs.google.com/document/d/abc/edit" def p_run_settled(**kw): """run_browser_agent then drain the backgrounded learning task; the distill no longer blocks the reply path, so tests asserting its effects must settle it.""" async def p_go(): r = await BA.run_browser_agent(**kw) if BA.learn_tasks: await asyncio.gather(*list(BA.learn_tasks), return_exceptions=True) return r return asyncio.run(p_go()) def p_install(monkeypatch, primary, aux): # local imports inside run_browser_agent resolve from these source modules import backend.apps.settings.settings as settings_mod import backend.apps.settings.credentials as cred_mod import backend.apps.agents.providers.registry as reg_mod import backend.apps.agents.agent_manager as am_mod monkeypatch.setattr(settings_mod, "load_settings", lambda: {"fake": True}, raising=True) # a dict (not object()) so get_api_type's (entry or {}).get("api") works like the real registry rows monkeypatch.setattr(reg_mod, "find_builtin_model", lambda m: {"api": "anthropic"}, raising=True) monkeypatch.setattr(reg_mod, "resolve_model_id_for_sdk", lambda m, s: "primary-x", raising=True) async def p_aux_resolve(s, preferred_tier="haiku", primary_api=None): return ("aux-x", None) monkeypatch.setattr(reg_mod, "resolve_aux_model", p_aux_resolve, raising=True) def p_client_for(s, model): return aux if model == "aux-x" else primary monkeypatch.setattr(cred_mod, "get_anthropic_client_for_model", p_client_for, raising=True) monkeypatch.setattr(BA, "load_builtin_permissions", lambda: {}, raising=True) # fake WS: record browser commands, script results by action sent = [] async def p_send_browser_command(request_id, action, browser_id, params, tab_id=""): sent.append({"action": action, "params": params}) # smart-wait probes via evaluate; report 'settled' so BrowserWait returns fast in tests instead of riding the full cap. if action == "evaluate" and "getEntriesByType('resource')" in str(params.get("expression", "")): expr = str(params.get("expression", "")) # a confirm/target probe embeds a non-empty `const spec="..."`; report it found found = "const spec=" in expr and 'const spec=""' not in expr return {"text": json.dumps({"ready": True, "quiet": 9999, "elems": 100, "found": found}), "url": DOC_URL} # generic evaluate echoes its expression so distinct reads yield distinct results (lets a test exercise new-data-each-turn gather vs spinning) if action == "evaluate": return {"text": f"eval:{str(params.get('expression',''))[:120]}", "url": DOC_URL} if action == "list_interactives": # a non-irreversible label on purpose: Send/Submit-named steps are refused by the replay send-gate, which has its own test below return {"text": '1 interactive elements:\n[1]