Files
openswarm/backend/tests/test_send_rejection.py

141 lines
5.4 KiB
Python

"""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