diff --git a/backend/apps/agents/browser/browser_send_parse.py b/backend/apps/agents/browser/browser_send_parse.py index 36626d85..6d4f9fd1 100644 --- a/backend/apps/agents/browser/browser_send_parse.py +++ b/backend/apps/agents/browser/browser_send_parse.py @@ -191,6 +191,46 @@ def surface_mismatch(task: str, composer_name: str) -> bool: return bool(P_COMMENT_SURFACE_RE.search(name)) +# What a URL path declares the page to BE. Sites disagree about almost everything, but these path +# segments are near-universal, and each one is the site telling us the content type in its own words. +P_URL_CONTENT_TYPES = ( + ("story", re.compile(r"/stories?/", re.I)), + ("message", re.compile(r"/(direct|messages?|dm)/", re.I)), + ("video", re.compile(r"/(watch|shorts)\b|/video/", re.I)), + ("post", re.compile(r"/(p|posts?|status|submit)/|/submit\b", re.I)), +) +# The same types as the TASK names them. Deliberately narrow: a word has to be unambiguous here or +# the guard starts refusing pages that were right all along. +P_TASK_CONTENT_TYPES = ( + ("story", re.compile(r"\bstor(?:y|ies)\b", re.I)), + ("message", re.compile(r"\b(dm|direct message)\b", re.I)), + ("video", re.compile(r"\bvideos?\b", re.I)), + ("post", re.compile(r"\bposts?\b", re.I)), +) + + +def content_type_mismatch(task: str, url: str) -> str: + """"asked for a , landed on a ", or "" when they agree or either is unclear. + + surface_mismatch below asks whether the COMPOSER contradicts the task. This asks the same thing + one level up, about the page, because a page can hand you a perfectly good composer that belongs + to the wrong thing entirely. Measured on instagram 2026-08-04: "write a comment on the first + post" opened a STORY (`/stories//`), whose reply box is a real composer, so every gate + downstream was satisfied and the comment would have gone to a story nobody asked about. + + Both sides must be unambiguous. A task that names no type, or a URL that declares none, returns + "" and changes nothing, so the cost of being wrong here is a page we decline to fill fast and + hand to the model instead. + """ + if not task or not url: + return "" + want = [name for name, rx in P_TASK_CONTENT_TYPES if rx.search(task)] + got = [name for name, rx in P_URL_CONTENT_TYPES if rx.search(url)] + if len(want) != 1 or len(got) != 1 or want[0] == got[0]: + return "" + return f"asked for a {want[0]}, landed on a {got[0]}" + + 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 0639c29a..e0921d53 100644 --- a/backend/apps/agents/browser/browser_send_script.py +++ b/backend/apps/agents/browser/browser_send_script.py @@ -263,6 +263,15 @@ async def run_send_script( if not payload: logger.info("[browser-sendscript] decline: no unambiguous quoted payload") return None + # The page itself says what it is, and a composer on the wrong thing is still the wrong thing. + # Live on instagram: a "comment on the first post" task opened a STORY, whose reply box is a + # perfectly real composer, so every gate below passed and the comment would have gone somewhere + # nobody asked for. Declining costs the fast path and hands the page to the model, which can + # navigate; filling would have been silent and wrong. + p_ctype = browser_send_parse.content_type_mismatch(task_sans_brief, current_url or "") + if p_ctype: + logger.info(f"[browser-sendscript] decline: {p_ctype} ({(current_url or '')[:60]})") + return None log: list[dict] = [] composer = browser_send_parse.composer_index_in_state(state_text) diff --git a/backend/tests/test_content_type_mismatch.py b/backend/tests/test_content_type_mismatch.py new file mode 100644 index 00000000..b2dbe924 --- /dev/null +++ b/backend/tests/test_content_type_mismatch.py @@ -0,0 +1,56 @@ +"""A composer on the wrong THING is still the wrong thing. + +`surface_mismatch` already asks whether the composer contradicts the task (a post ask must not fill +a DM box). This is the same question one level up, about the page, because a page can hand you a +perfectly real composer that belongs to something nobody asked about. + +Measured live on instagram 2026-08-04 at N=5: "write a comment on the first post" opened a STORY +(`/stories//`). A story's reply box IS a composer, so the DM guard, the structural finder and +the fill receipt were all satisfied, and the comment would have gone to a story silently. The URL is +the page's own declaration of what it is, and it was the only signal that disagreed. + +Both sides must be unambiguous before this fires. The cost of a false positive is one declined fast +path (the model still gets the page); the cost of a false negative is a write to the wrong surface. +""" + +from backend.apps.agents.browser import browser_send_parse as sp + + +def test_the_instagram_story_bug_verbatim(): + why = sp.content_type_mismatch("write a comment on the first post", + "https://www.instagram.com/stories/miy.jpg/") + assert why == "asked for a post, landed on a story", why + + +def test_the_same_task_on_a_real_post_is_fine(): + """The guard must not cost us the case that works: instagram reached /p/ on other runs.""" + assert sp.content_type_mismatch("write a comment on the first post", + "https://www.instagram.com/p/DbRdbkgCZgk/") == "" + + +def test_asking_for_a_story_and_getting_one_is_fine(): + """The user is allowed to want a story. The guard is about disagreement, not about stories.""" + assert sp.content_type_mismatch("reply to the first story", + "https://www.instagram.com/stories/miy.jpg/") == "" + + +def test_the_working_sites_are_untouched(): + """Every site at 5/5 or 4/4 in the N=5 sweep must keep scoring exactly as it did.""" + for task, url in ( + ("comment on the first video", "https://www.youtube.com/watch?v=SAjrSUNCQbc"), + ("post this tweet", "https://x.com/compose/post"), + ("create a text post", "https://www.reddit.com/r/test/submit/?type=TEXT"), + ("create a post", "https://www.linkedin.com/feed/?shareActive=true"), + ("write in chat", "https://www.twitch.tv/jynxzi"), + ): + assert sp.content_type_mismatch(task, url) == "", (task, url) + + +def test_it_stays_silent_when_either_side_is_unclear(): + """An ambiguous task names two types and a plain URL declares none. Guessing between them would + refuse pages that were right all along, which costs more than the bug it would catch.""" + assert sp.content_type_mismatch("post a comment on the story", + "https://www.instagram.com/stories/x/") == "" + assert sp.content_type_mismatch("say hi to tyler", "https://example.com/anything") == "" + assert sp.content_type_mismatch("", "https://www.instagram.com/stories/x/") == "" + assert sp.content_type_mismatch("comment on the first post", "") == ""