[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

This commit is contained in:
ciregenz
2026-07-08 23:39:19 -07:00
parent 8dcf4b4ae0
commit 7de724c930
4 changed files with 55 additions and 5 deletions
@@ -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})"
@@ -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 <person>", 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
@@ -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
+21
View File
@@ -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