diff --git a/backend/apps/agents/browser/browser_send_parse.py b/backend/apps/agents/browser/browser_send_parse.py index 2399dedf..c091e190 100644 --- a/backend/apps/agents/browser/browser_send_parse.py +++ b/backend/apps/agents/browser/browser_send_parse.py @@ -96,6 +96,29 @@ def looks_signed_out(state_text: str) -> bool: return bool(P_SIGNIN_AFFORDANCE_RE.search(state_text)) +# Creating a POST and commenting on someone else's are different actions on different content. +# LinkedIn's feed carries a comment box on EVERY post, and the capped interactives listing routinely +# starves the real post modal of its own composer, so the only compose-shaped textbox left in the +# list is a stranger's comment box. Filling that is not a slower path to the same place, it is the +# wrong action on the wrong person's content. Measured in a dry-run sweep: linkedin reached its +# composer 1/4, and two of the three misses targeted 'Text editor for creating comment'. +P_POST_INTENT_RE = re.compile(r"\b(post|tweet|publish|share)\b", re.I) +P_COMMENT_INTENT_RE = re.compile(r"\b(comment|reply|respond)\b", re.I) +P_COMMENT_SURFACE_RE = re.compile(r"\b(comment|reply)\b", re.I) + + +def surface_mismatch(task: str, composer_name: str) -> bool: + """True when the task asks to create a POST but the composer found is a comment/reply box. + + Deliberately one-directional: a task that mentions commenting is left alone, so this can only + ever reject a comment box for a post task, never the reverse. A rejection is cheap (the + structural finder, which does find LinkedIn's real composer, gets its turn instead).""" + t, name = task or "", composer_name or "" + if not P_POST_INTENT_RE.search(t) or P_COMMENT_INTENT_RE.search(t): + return False + return bool(P_COMMENT_SURFACE_RE.search(name)) + + def is_readonly(text: str) -> bool: """A read-only directive ('verify whether', 'do not send') that must decline the scripted send even with a quoted payload in hand. Keeps the regex private to this file.""" diff --git a/backend/apps/agents/browser/browser_send_script.py b/backend/apps/agents/browser/browser_send_script.py index 9b6d2af6..58759ade 100644 --- a/backend/apps/agents/browser/browser_send_script.py +++ b/backend/apps/agents/browser/browser_send_script.py @@ -211,6 +211,12 @@ async def run_send_script( log: list[dict] = [] composer = browser_send_parse.composer_index_in_state(state_text) + if composer and browser_send_parse.surface_mismatch(task_sans_brief, composer[1]): + # Asked to POST, found a COMMENT box: that is someone else's content, not a slower route to + # ours. Drop it and let the tiers below (opener, then the structural finder, which does find + # LinkedIn's real composer) look properly. + logger.info(f"[browser-sendscript] ignoring {composer[1]!r}: a comment box is not where a post goes") + composer = None if not composer: # The staged snapshot is prestage's, frozen the instant it clicked Message; the overlay composer lazy-renders a beat later (r263/r269 declined on exactly this, prestage's LAST step was the Message click). Poll a short window so the overlay has time to appear before we fall back to the opener. for wait_s in (0.6, 1.2, 1.4): diff --git a/backend/tests/test_browser_readonly_guard.py b/backend/tests/test_browser_readonly_guard.py index 86459bd3..66d9c908 100644 --- a/backend/tests/test_browser_readonly_guard.py +++ b/backend/tests/test_browser_readonly_guard.py @@ -67,3 +67,33 @@ def test_empty_and_junk_are_not_readonly(): on a malformed input rather than letting the normal gates decide.""" assert not sp.is_readonly("") assert not sp.is_readonly(" ") + + +# --- surface targeting: a post is not a comment --------------------------------------------- + +def test_a_post_task_rejects_a_comment_box(): + """Measured: on LinkedIn's feed the capped listing starved the post modal of its own composer, + so the only compose-shaped textbox left was a stranger's comment box. Filling it is the wrong + action on the wrong content, not a slower route to the right one.""" + assert sp.surface_mismatch('post this, exactly: "hi"', "Text editor for creating comment") + assert sp.surface_mismatch("start a post saying hi", "Add a comment") + assert sp.surface_mismatch("tweet hello", "Post your reply") + + +def test_a_comment_task_keeps_its_comment_box(): + """One-directional by design: asking to comment must still land in a comment box.""" + assert not sp.surface_mismatch("comment on the first post saying hi", "Text editor for creating comment") + assert not sp.surface_mismatch("reply to that thread with hi", "Add a comment") + assert not sp.surface_mismatch("respond to his post", "Post your reply") + + +def test_a_post_task_keeps_a_real_post_composer(): + assert not sp.surface_mismatch('post this, exactly: "hi"', "Post text") + assert not sp.surface_mismatch("start a post", "Share your thoughts") + assert not sp.surface_mismatch("tweet hello", "What is happening?") + + +def test_a_task_with_no_post_intent_is_left_alone(): + """Messaging a person is neither posting nor commenting; the guard must not touch it.""" + assert not sp.surface_mismatch("text tyler hello", "Write a message") + assert not sp.surface_mismatch("", "Add a comment")