From 7de724c93070769a8626c6ca4d40fa28d12b04ef Mon Sep 17 00:00:00 2001 From: ciregenz Date: Wed, 8 Jul 2026 23:39:19 -0700 Subject: [PATCH] [eric] browser: fix the 2 warm-marriage replay bugs = (1) 'Send a message to X' opener no longer trips the replay send-boundary (it opens the composer, reversible), (2) marriage replays NAV+opener only and hands composer->send to the send-script (which polls the lazy overlay), instead of replaying a composer-textbox click that races the render; 4 tests --- backend/apps/agents/browser/browser_agent.py | 5 +++++ .../agents/browser/browser_batch_replay.py | 17 ++++++++++----- backend/apps/agents/browser/browser_skills.py | 17 +++++++++++++++ backend/tests/test_browser_skills.py | 21 +++++++++++++++++++ 4 files changed, 55 insertions(+), 5 deletions(-) diff --git a/backend/apps/agents/browser/browser_agent.py b/backend/apps/agents/browser/browser_agent.py index d7123c29..3a870197 100644 --- a/backend/apps/agents/browser/browser_agent.py +++ b/backend/apps/agents/browser/browser_agent.py @@ -1059,6 +1059,11 @@ async def run_browser_agent( logger.info(f"[browser-skills] skill on {host} not replayed: {why}; running the full agent so the send is confirmed") return None prefix = steps[:unsafe_i] + # Marriage mode: the send-script owns the composer (it polls for the lazy overlay + fills + sends). Replaying a recorded composer-textbox click races that render and misses (v903/v906), so truncate the prefix to NAV + opener only and let the script take it from the navigated page. Keep >=1 step or there's nothing to replay. + if os.environ.get("OSW_REPLAY_SENDTAIL", "0") == "1": + p_nav_prefix = [s for s in prefix if not browser_skills.step_touches_composer(s)] + if p_nav_prefix: + prefix = p_nav_prefix logger.info( f"[browser-skills] PREFIX replay: {len(prefix)}/{len(steps)} steps on {host}, " f"live agent confirms the tail ({why})" diff --git a/backend/apps/agents/browser/browser_batch_replay.py b/backend/apps/agents/browser/browser_batch_replay.py index e9f74abf..28b3e62d 100644 --- a/backend/apps/agents/browser/browser_batch_replay.py +++ b/backend/apps/agents/browser/browser_batch_replay.py @@ -111,17 +111,24 @@ P_LIVE_IRREVERSIBLE_RE = re.compile( r"confirm|apply|accept|decline|delete|remove|unsend|withdraw|endorse)\b", re.I, ) +# Composer OPENERS phrased with a send-word: LinkedIn's profile button is literally +# named "Send a message to ", which opens the compose box (reversible), not +# a real Send. A true Send control is short and exact ("Send", "Send now"); these +# describe opening a conversation, so they must NOT trip the irreversible boundary. +P_SEND_OPENER_RE = re.compile(r"send (a |an |the )?(message|note|inmail|dm) to\b", re.I) def is_replay_boundary(step: dict) -> bool: """The genuinely irreversible step where a learned skill's mechanical replay must STOP and hand to the live agent. Same as is_send_step EXCEPT a composer - OPENER ('Message'/'DM' click) is reversible and NOT a boundary: the prefix can - mechanically open the composer, and only the real Send (and composer typing) - crosses to the live model. Uses the same opener-excluded wordlist the live - send-guard already trusts, so a recorded Send still stops the prefix.""" + OPENER ('Message'/'DM' click, incl. 'Send a message to X') is reversible and NOT + a boundary: the prefix can mechanically open the composer, and only the real Send + (and composer typing) crosses to the live model.""" action = step.get("action") - if action == "click" and P_LIVE_IRREVERSIBLE_RE.search(str(step.get("name") or "")): + name = str(step.get("name") or "") + if action == "click" and P_SEND_OPENER_RE.search(name): + return False # opener phrasing, not a real send + if action == "click" and P_LIVE_IRREVERSIBLE_RE.search(name): return True if action == "type" and P_COMPOSE_SEL_RE.search(str(step.get("selector") or "")): return True diff --git a/backend/apps/agents/browser/browser_skills.py b/backend/apps/agents/browser/browser_skills.py index 1b23fcce..af336e29 100644 --- a/backend/apps/agents/browser/browser_skills.py +++ b/backend/apps/agents/browser/browser_skills.py @@ -330,6 +330,23 @@ def replay_settle_target(step: dict) -> str | None: return name if 0 < len(name) <= 60 else None +P_COMPOSER_STEP_RE = re.compile(r"write a message|compose|message body|comment|reply|tweet|post text|type here|editor", re.I) + + +def step_touches_composer(step: dict) -> bool: + """True if this step interacts with the compose box itself (focusing/typing), + as opposed to navigation or the opener click. The send-script owns the composer + (it polls for the lazy overlay), so the marriage replays only the nav+opener and + hands the composer->send tail to the script.""" + tool = step.get("tool", "") + p = step.get("params", {}) or {} + if tool == "BrowserType": + return True + if tool in ("BrowserClickByName", "BrowserClick"): + return bool(P_COMPOSER_STEP_RE.search(str(p.get("name") or p.get("selector") or ""))) + return False + + def first_unsafe_step(steps: list[dict]) -> tuple[int, str]: """Index of the first GENUINELY irreversible step (click Send/Submit/Pay, type into a composer), -1 if none. This is the prefix-replay/batch boundary, so a diff --git a/backend/tests/test_browser_skills.py b/backend/tests/test_browser_skills.py index 0efea231..b18b8945 100644 --- a/backend/tests/test_browser_skills.py +++ b/backend/tests/test_browser_skills.py @@ -717,3 +717,24 @@ def test_replay_settle_target_for_click_by_name(): {"tool": "BrowserClickByName", "params": {"name": "x" * 80}}) is None assert sk.replay_settle_target( {"tool": "BrowserClickByName", "params": {"name": ""}}) is None + + +def test_send_opener_is_not_a_replay_boundary(): + # The LinkedIn profile opener is literally "Send a message to Tyler Chen": + # it OPENS the composer (reversible), so it must NOT trip the send boundary + # (v902 bug: it did, killing the prefix replay). + from backend.apps.agents.browser import browser_batch_replay as BR + assert BR.is_replay_boundary({"action": "click", "name": "Send a message to Tyler Chen"}) is False + assert BR.is_replay_boundary({"action": "click", "name": "Send a note to Ada"}) is False + # a REAL send still stops the prefix + assert BR.is_replay_boundary({"action": "click", "name": "Send"}) is True + assert BR.is_replay_boundary({"action": "click", "name": "Send now"}) is True + + +def test_step_touches_composer(): + from backend.apps.agents.browser import browser_skills as SK + assert SK.step_touches_composer({"tool": "BrowserType", "params": {"text": "hi"}}) is True + assert SK.step_touches_composer({"tool": "BrowserClickByName", "params": {"name": "Write a message…"}}) is True + # nav + opener are NOT composer steps (they stay in the marriage prefix) + assert SK.step_touches_composer({"tool": "BrowserNavigate", "params": {"url": "https://x"}}) is False + assert SK.step_touches_composer({"tool": "BrowserClickByName", "params": {"name": "Send a message to Tyler Chen"}}) is False