From 43ba7d2f1c782f498300a7f45d939c4c4f27fc7a Mon Sep 17 00:00:00 2001 From: ciregenz Date: Tue, 2 Jun 2026 03:56:07 -0700 Subject: [PATCH] [eric] browser: unit and end-to-end loop tests for the new browser behaviors --- backend/tests/test_browser_agent_loop.py | 184 +++++++++++++++++++++++ backend/tests/test_browser_hints.py | 36 +++++ backend/tests/test_browser_stagnation.py | 101 +++++++++++++ backend/tests/test_browser_validator.py | 64 ++++++++ 4 files changed, 385 insertions(+) create mode 100644 backend/tests/test_browser_agent_loop.py create mode 100644 backend/tests/test_browser_hints.py create mode 100644 backend/tests/test_browser_stagnation.py create mode 100644 backend/tests/test_browser_validator.py diff --git a/backend/tests/test_browser_agent_loop.py b/backend/tests/test_browser_agent_loop.py new file mode 100644 index 00000000..0330a47e --- /dev/null +++ b/backend/tests/test_browser_agent_loop.py @@ -0,0 +1,184 @@ +"""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 FakeLLM: + def __init__(self, scripted): + self.scripted = scripted; self.turn = 0; self.calls = [] + self.messages = self + + async def create(self, **kw): + self.calls.append(kw) + i = min(self.turn, len(self.scripted) - 1) + self.turn += 1 + return self.scripted[i] + + +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 _tu(name, **inp): + return Blk("tool_use", id="t" + uuid.uuid4().hex[:8], name=name, input=inp) + + +def _rp(goal, mem="Share dialog is a cross-origin iframe; use the index list."): + return _tu("ReportProgress", evaluation_previous="prev", working_memory=mem, next_goal=goal) + + +DOC_URL = "https://docs.google.com/document/d/abc/edit" + + +def _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) + monkeypatch.setattr(reg_mod, "_find_builtin_model", lambda m: object(), raising=True) + monkeypatch.setattr(reg_mod, "resolve_model_id_for_sdk", lambda m, s: "primary-x", raising=True) + + async def _aux_resolve(s, preferred_tier="haiku"): + return ("aux-x", None) + monkeypatch.setattr(reg_mod, "resolve_aux_model", _aux_resolve, raising=True) + + def _client_for(s, model): + return aux if model == "aux-x" else primary + monkeypatch.setattr(cred_mod, "get_anthropic_client_for_model", _client_for, raising=True) + + monkeypatch.setattr(BA, "load_builtin_permissions", lambda: {}, raising=True) + monkeypatch.setattr(am_mod.agent_manager, "_sync_session_close", lambda *a, **k: None, raising=False) + + # fake WS: record browser commands, script results by action + sent = [] + + async def _send_browser_command(request_id, action, browser_id, params, tab_id=""): + sent.append({"action": action, "params": params}) + if action == "list_interactives": + return {"text": '1 interactive elements:\n[1]