From 1828719f85065a04cda4bcfbe2e17259cf3714ef Mon Sep 17 00:00:00 2001 From: ciregenz Date: Tue, 28 Jul 2026 19:16:17 -0700 Subject: [PATCH] [eric] browser: a cleared composer plus a failure toast is a refusal, not a delivery --- .../agents/browser/browser_delivery_check.py | 64 ++++++++ .../agents/browser/browser_send_script.py | 22 ++- backend/tests/test_send_rejection.py | 140 ++++++++++++++++++ 3 files changed, 222 insertions(+), 4 deletions(-) create mode 100644 backend/tests/test_send_rejection.py diff --git a/backend/apps/agents/browser/browser_delivery_check.py b/backend/apps/agents/browser/browser_delivery_check.py index 57b3687f..f49d3700 100644 --- a/backend/apps/agents/browser/browser_delivery_check.py +++ b/backend/apps/agents/browser/browser_delivery_check.py @@ -10,6 +10,7 @@ Gmail) and this module is never consulted, so proven sends keep their exact spee import asyncio import json +import re from typing import Awaitable, Callable from urllib.parse import urlparse @@ -22,6 +23,15 @@ ToolRunner = Callable[[str, dict, str, str], Awaitable[dict]] # Hosts known to accept-then-silently-drop an automated post. A newly-found one is a one-line add. GHOST_DROP_HOSTS = ("youtube.com",) +# What a site says when it REFUSED the write. Only ever consulted inside a live announcement +# region, never against the whole page, so an unrelated "failed" in an article body can't match. +P_REJECTION_RE = re.compile( + r"something went wrong|went wrong|couldn'?t\s|could not\s|unable to|failed to|" + r"\bfailed\b|try again|too many|rate.?limit|limit exceeded|not allowed|" + r"blocked|error occurred|wasn'?t (?:sent|posted)|was not (?:sent|posted)", + re.I, +) + @typechecked def is_ghost_drop_host(url: str) -> bool: @@ -53,6 +63,60 @@ async def payload_visible(payload: str, browser_id: str, tab_id: str, execute_to return bool(isinstance(v, dict) and v.get("visible")) +@typechecked +def rejection_probe_expression() -> str: + """JS returning the text of the page's live ANNOUNCEMENT regions only. + + role="alert" and aria-live are how sites are required to announce a transient result to + assistive tech, so error toasts land here on every major site without us naming any of them. + Scoped deliberately: reading whole-page text for the word "failed" would match articles, + changelogs and half the internet.""" + return ("(()=>{try{var out=[];" + "var sel='[role=alert],[role=alertdialog],[aria-live=assertive],[aria-live=polite]';" + "document.querySelectorAll(sel).forEach(function(e){" + "var s=(e.innerText||'').trim(); if(s) out.push(s);});" + "return {text: out.join(' | ').slice(0,600)};}" + "catch(e){return {text:''};}})()") + + +@typechecked +async def send_rejected(browser_id: str, tab_id: str, execute_tool: ToolRunner) -> bool: + """Did the site announce that the write FAILED, right after the composer cleared? + + A cleared composer is the receipt this whole fast path rests on, and the code has long admitted + it "cannot tell submitted from dismissed". The realistic way that bites is not a mis-click: it + is the site accepting the click, clearing the box, and popping "Something went wrong" or a rate + limit. The receipt then reads as success and the agent tells the user it posted. + + This only ever DEMOTES a claim, and only on an explicit failure announcement, so a normal send + (no alert region, or a success toast) is untouched and keeps its measured speed. Any read + failure returns False, because refusing to claim delivery on the basis of a broken probe would + invent failures that did not happen.""" + try: + r = await asyncio.wait_for(execute_tool( + "BrowserEvaluate", {"expression": rejection_probe_expression()}, + browser_id, tab_id), timeout=4.0) + except Exception: + return False + v = browser_submit_click.parse_eval_value(r) + if not isinstance(v, dict): + return False + return bool(P_REJECTION_RE.search(str(v.get("text") or ""))) + + +@typechecked +def rejected_send_note(url: str, payload: str) -> str: + """Honest line for a send the SITE said no to. Distinct from the unverified case: here we are + not guessing, the page told us, so the user should be told plainly rather than asked to check.""" + host = urlparse(url or "").hostname or "the site" + if host.startswith("www."): + host = host[4:] + clip = payload if len(payload) <= 80 else payload[:77] + "..." + return (f'I typed "{clip}" and clicked send, but {host} rejected it: the composer cleared and ' + f'the page showed an error instead of posting. It did NOT go through. I did not retry, ' + f'since whatever the site refused is likely to be refused again.') + + @typechecked async def ghost_delivery_confirmed( payload: str, browser_id: str, tab_id: str, execute_tool: ToolRunner diff --git a/backend/apps/agents/browser/browser_send_script.py b/backend/apps/agents/browser/browser_send_script.py index 58759ade..4cd6997c 100644 --- a/backend/apps/agents/browser/browser_send_script.py +++ b/backend/apps/agents/browser/browser_send_script.py @@ -127,7 +127,14 @@ async def complete_send( # then silently eat the post; there we verify it persisted. delivered stays None (unchecked, # composer-clear trusted) for every other site, so proven sends keep their exact speed. delivered = None - if sent and browser_delivery_check.is_ghost_drop_host(current_url): + rejected = False + # The site gets the first word. A cleared composer plus "Something went wrong" is a REFUSAL, and + # trusting the clear there is how the agent ends up announcing a post that never existed. + if sent and await browser_delivery_check.send_rejected(browser_id, tab_id, execute_tool): + rejected, delivered, sent = True, False, False + logger.info("[browser-sendscript] composer cleared but the page announced a failure; " + "treating as REJECTED, not delivered") + elif sent and browser_delivery_check.is_ghost_drop_host(current_url): delivered = await browser_delivery_check.ghost_delivery_confirmed( payload, browser_id, tab_id, execute_tool) elif sent and via == "by-name": @@ -144,9 +151,16 @@ async def complete_send( if not delivered: logger.info("[browser-sendscript] by-name click cleared the composer but the payload " "never rendered; treating as NOT delivered") - note = ("" if sent else - "A Send-class click already RAN for this payload but the composer state is unverified: " - "verify on the page whether it delivered; do NOT send again unless verifiably absent.") + if rejected: + # We are not guessing here: the page said no. Saying "unverified" would send the user off to + # check something we already know the answer to. + note = browser_delivery_check.rejected_send_note(current_url, payload) + elif sent: + note = "" + else: + note = ("A Send-class click already RAN for this payload but the composer state is " + "unverified: verify on the page whether it delivered; do NOT send again unless " + "verifiably absent.") return {"clicked": True, "sent": sent, "delivered": delivered, "log": log, "note": note} diff --git a/backend/tests/test_send_rejection.py b/backend/tests/test_send_rejection.py new file mode 100644 index 00000000..1b13eed5 --- /dev/null +++ b/backend/tests/test_send_rejection.py @@ -0,0 +1,140 @@ +"""A cleared composer is not proof when the site just said no. + +The whole fast write path rests on one signal: the payload left the composer. browser_send_script +has always carried the admission that this "cannot tell submitted from dismissed", guarded only for +guessed clicks and ghost-drop hosts. The realistic way it bites is neither: the site accepts the +click, clears the box, and pops "Something went wrong" or a rate limit. The receipt then reads as a +clean success and the agent tells the user it posted. + +The guard reads only the page's live announcement regions (role=alert, aria-live), which is the +accessibility contract sites already follow, so it needs no per-site knowledge. It can only ever +DEMOTE a claim, never manufacture one, and it fails open: a broken probe returns "not rejected" +rather than inventing a failure that did not happen. +""" +import pytest + +from backend.apps.agents.browser import browser_delivery_check as dc + +REJECTIONS = [ + "Something went wrong. Try again.", + "Couldn't post your reply", + "Could not send message", + "Unable to post right now", + "Failed to send", + "You've reached your daily limit. Try again later.", + "Too many requests, please slow down", + "Rate limit exceeded", + "Your post wasn't sent", + "An error occurred", +] + +# A live region is also how sites announce SUCCESS and ordinary chatter. Matching these would turn +# every good send into a scary "it was rejected", which is a worse lie than the one being fixed. +NOT_REJECTIONS = [ + "Your post was sent.", + "Posted", + "Message sent", + "Draft saved", + "1 new notification", + "Copied to clipboard", + "", + " ", +] + + +def p_probe(text): + """A fake BrowserEvaluate returning whatever the announcement regions supposedly held. + + Shape matters: parse_eval_value reads {"value": ...} at the TOP level. An earlier version of + this fixture nested it one level deeper, which made every negative case pass for the wrong + reason (unreadable result, not correct matching) while every positive case failed loudly.""" + async def run(tool, args, browser_id, tab_id): + assert tool == "BrowserEvaluate" + return {"value": {"text": text}} + return run + + +@pytest.mark.asyncio +@pytest.mark.parametrize("text", REJECTIONS) +async def test_a_refusal_is_detected(text): + assert await dc.send_rejected("b", "t", p_probe(text)) is True + + +@pytest.mark.asyncio +@pytest.mark.parametrize("text", NOT_REJECTIONS) +async def test_success_and_chatter_are_left_alone(text): + assert await dc.send_rejected("b", "t", p_probe(text)) is False + + +@pytest.mark.asyncio +async def test_a_broken_probe_does_not_invent_a_failure(): + """Fail OPEN. Claiming rejection because we could not read the page would be the same class of + lie in the other direction.""" + async def boom(tool, args, browser_id, tab_id): + raise RuntimeError("evaluate died") + assert await dc.send_rejected("b", "t", boom) is False + + +@pytest.mark.asyncio +async def test_a_hung_probe_does_not_block_the_send_path(): + """The probe sits on the irreversible path; it must time out rather than wedge the turn.""" + import asyncio + + async def hang(tool, args, browser_id, tab_id): + await asyncio.sleep(30) + return {} + assert await dc.send_rejected("b", "t", hang) is False + + +@pytest.mark.asyncio +async def test_a_junk_shaped_result_is_not_a_rejection(): + async def junk(tool, args, browser_id, tab_id): + return {"value": "not a dict"} + assert await dc.send_rejected("b", "t", junk) is False + + +# --- the probe itself ------------------------------------------------------------------------ + +def test_the_probe_reads_only_announcement_regions(): + """Scope is the entire point. Whole-page text contains the word 'failed' on a huge fraction of + the internet, and matching that would demote correct sends at random.""" + js = dc.rejection_probe_expression() + assert "role=alert" in js + assert "aria-live" in js + assert "document.body" not in js, "the probe must not fall back to whole-page text" + + +def test_the_probe_bounds_what_it_returns(): + """An unbounded innerText from a chatty live region would bloat every send's payload.""" + assert "slice(0,600)" in dc.rejection_probe_expression() + + +# --- what the user is told ------------------------------------------------------------------- + +def test_the_rejection_note_states_plainly_that_it_did_not_send(): + note = dc.rejected_send_note("https://x.com/home", "hello there") + assert "did NOT go through" in note + assert "x.com" in note and "www." not in note + assert "hello there" in note + + +def test_the_rejection_note_says_it_will_not_retry(): + """A blind retry on a refusal is how you get rate-limited harder, or post twice if the refusal + was cosmetic.""" + assert "did not retry" in dc.rejected_send_note("https://x.com", "hi").lower() + + +def test_a_long_payload_is_clipped_in_the_note(): + note = dc.rejected_send_note("https://x.com", "y" * 500) + assert "..." in note + assert len(note) < 400 + + +def test_the_rejection_note_is_distinct_from_the_unverified_one(): + """Different evidence, different claim. Collapsing them would either overclaim or send the user + to go check something we already know.""" + rejected = dc.rejected_send_note("https://x.com", "hi") + unverified = dc.unverified_send_note("https://x.com", "hi") + assert rejected != unverified + assert "could NOT confirm" in unverified + assert "could NOT confirm" not in rejected