diff --git a/backend/apps/agents/browser/browser_agent.py b/backend/apps/agents/browser/browser_agent.py index 087e8e3a..6674f4a6 100644 --- a/backend/apps/agents/browser/browser_agent.py +++ b/backend/apps/agents/browser/browser_agent.py @@ -1222,7 +1222,7 @@ async def run_browser_agent( p_script = await asyncio.wait_for(browser_send_script.run_send_script( task, browser_id, tab_id, preloaded_perception, execute_browser_tool, send_index_in_state, payload_in_textbox, - payload_source=user_prompt, + payload_source=user_prompt, current_url=current_url, ), timeout=30.0) except Exception as p_se: logger.info(f"[browser-sendscript] outer skip ({p_se})") diff --git a/backend/apps/agents/browser/browser_send_script.py b/backend/apps/agents/browser/browser_send_script.py index 1ccf52a3..c58a3185 100644 --- a/backend/apps/agents/browser/browser_send_script.py +++ b/backend/apps/agents/browser/browser_send_script.py @@ -71,6 +71,22 @@ def composer_index_in_state(state_text: str): return hits[0] if len(hits) == 1 else None +# Surfaces where a filled composer reliably exposes a detectable Send button. The +# live A/B (r246-r251) proved the profile docked-overlay composer is the opposite: +# the script fills it, LinkedIn's overlay Send never lands in the interactives list, +# the script aborts, and the half-staged handoff + recovery path ran 4x SLOWER and +# less reliable than a clean model run. So the script fires ONLY on a full-page +# composer surface; anywhere else it declines UNTOUCHED (no fill) and the model owns it. +P_COMPOSER_PAGE_RE = re.compile(r"/messaging/(thread|compose)/", re.I) + + +def surface_supports_script(current_url: str) -> bool: + """A full-page messaging composer (Send renders as a real listed button), not + the profile /in/ docked overlay (Send is a lazy, unlisted control). Empty URL + is unknown -> decline, since firing on the wrong surface is net-negative.""" + return bool(current_url and P_COMPOSER_PAGE_RE.search(current_url)) + + async def run_send_script( task: str, browser_id: str, @@ -80,6 +96,7 @@ async def run_send_script( send_index_in_state, payload_in_textbox, payload_source: str = "", + current_url: str = "", ) -> dict | None: """None = stage not script-ready or aborted pre-click (model path, stage untouched except a possibly committed fill, which the model sees). A dict @@ -88,6 +105,9 @@ async def run_send_script( prompt; the composed task carries the routing brief whose own quoted strings made every real payload look ambiguous (r242/r243).""" t0 = time.monotonic() + if not surface_supports_script(current_url): + logger.info(f"[browser-sendscript] decline: surface {current_url[:60]!r} not a full-page composer (overlay firing is net-negative)") + return None if P_READONLY_RE.search(task) or P_READONLY_RE.search(payload_source or ""): logger.info("[browser-sendscript] decline: read-only directive in task") return None diff --git a/backend/tests/test_browser_send_script.py b/backend/tests/test_browser_send_script.py index 81243504..7af4e9a7 100644 --- a/backend/tests/test_browser_send_script.py +++ b/backend/tests/test_browser_send_script.py @@ -32,16 +32,37 @@ def make_exec(list_script): return execute, calls -async def run(task, state0, list_script): +# A full-page messaging composer: the only surface the script fires on (the live +# A/B proved firing on the profile /in/ overlay is net-negative). Tests that exercise +# the fill/send mechanism pass this; the surface-gate test passes the overlay URL. +THREAD_URL = "https://www.linkedin.com/messaging/thread/2-abc/" +PROFILE_URL = "https://www.linkedin.com/in/tylerchen1200/" + + +async def run(task, state0, list_script, url=THREAD_URL): ex, calls = make_exec(list_script) - r = await ss.run_send_script(task, "b1", "", state0, ex, send_index_in_state, payload_in_textbox) + r = await ss.run_send_script(task, "b1", "", state0, ex, send_index_in_state, + payload_in_textbox, current_url=url) return r, calls +@pytest.mark.asyncio +async def test_surface_gate_declines_profile_overlay(): + """The live A/B (r246-r251) proved firing on the profile /in/ docked-overlay is + net-negative: fill commits, overlay Send never lists, abort, and the half-staged + handoff ran 4x slower than a clean model run. So a profile URL declines UNTOUCHED.""" + ex, calls = make_exec([COMPOSER_FILLED, COMPOSER_FILLED, COMPOSER_SENT]) + r = await ss.run_send_script(TASK, "b1", "", COMPOSER_EMPTY, ex, send_index_in_state, + payload_in_textbox, payload_source=TASK, current_url=PROFILE_URL) + assert r is None + assert not calls["clicks"] # nothing touched, model gets a clean composer + + @pytest.mark.asyncio async def test_opener_hop_full_success(): - """Prestage stopped on the profile: script opens the composer, fills, - sees it commit, finds the late Send, clicks, sees it clear -> receipt passes.""" + """On a messaging THREAD-LIST page (full-page surface): script opens the + conversation composer, fills, sees it commit, finds the late Send, clicks, + sees it clear -> receipt passes.""" r, calls = await run(TASK, PROFILE, [COMPOSER_EMPTY, COMPOSER_FILLED, COMPOSER_FILLED, COMPOSER_SENT]) assert r is not None and r["sent"] is True assert r["payload"] == "[test] hello world r9-os" @@ -113,7 +134,7 @@ async def test_composed_task_brief_quotes_fire_via_payload_source(): ex, calls = make_exec([COMPOSER_FILLED, COMPOSER_FILLED, COMPOSER_SENT]) r = await ss.run_send_script(COMPOSED, "b1", "", COMPOSER_EMPTY, ex, send_index_in_state, payload_in_textbox, - payload_source=TASK) + payload_source=TASK, current_url=THREAD_URL) assert r is not None and r["sent"] is True assert r["payload"] == "[test] hello world r9-os" @@ -130,6 +151,6 @@ async def test_readonly_probe_never_fires(): ex, calls = make_exec([COMPOSER_FILLED, COMPOSER_FILLED, COMPOSER_SENT]) r = await ss.run_send_script(probe, "b1", "", COMPOSER_EMPTY, ex, send_index_in_state, payload_in_textbox, - payload_source=TASK) + payload_source=TASK, current_url=THREAD_URL) assert r is None assert not calls["clicks"]