[eric] browser: a cleared composer plus a failure toast is a refusal, not a delivery

This commit is contained in:
ciregenz
2026-07-28 19:16:17 -07:00
parent 7f2f03c980
commit 1828719f85
3 changed files with 222 additions and 4 deletions
@@ -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
@@ -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}
+140
View File
@@ -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