diff --git a/backend/apps/agents/browser/browser_send_script.py b/backend/apps/agents/browser/browser_send_script.py index 5622d7d6..9078093a 100644 --- a/backend/apps/agents/browser/browser_send_script.py +++ b/backend/apps/agents/browser/browser_send_script.py @@ -105,9 +105,50 @@ async def complete_send( # title field is filled, and every run blind-tapped a coordinate and claimed success. # Handing back untouched is what lets the model do the one thing that CAN fix this, # fill the rest of the form, and it costs a run that was never going to send anyway. - logger.info(f"[browser-sendscript] submit {str(p_v.get('name'))!r} is present but DISABLED; " - f"the form is incomplete, handing to the model without clicking anything") - return {"clicked": False, "sent": False, "log": log, + # The form is incomplete, and on a two-field form we can usually say WHICH field. The + # composer we filled is the compose-shaped one (reddit's "Post text"); the one still + # empty is what the submit is waiting on (reddit's "Title"). Fill that too and re-ask. + # Measured: reddit's create-post flow could never complete, because the send path is + # single-composer by construction and reddit needs a title before its submit enables. + # + # Deliberately narrow: EXACTLY one other empty textbox, so there is no guessing about + # which field gets the payload, and the submit must ENABLE on its own afterwards. If it + # stays disabled we fall through to the same honest hand-off as before, so the failure + # mode is unchanged and nothing is ever clicked on a form the site still refuses. + p_state = await fresh_list() + # Exclude the composer BY INDEX, not by looking for the payload in the row: the row + # regex captures the accessible NAME only, so a filled box and an empty one are + # indistinguishable by name and every field would read as "still empty". + p_empty = [(i, n) for i, n in browser_send_parse.P_COMPOSER_ROW_RE.findall(p_state) + if int(i) != composer_index + and not browser_send_parse.P_AUTH_FIELD_NAME_RE.search(n or "")] + if len(p_empty) == 1: + p_idx, p_name = int(p_empty[0][0]), p_empty[0][1] + logger.info(f"[browser-sendscript] submit disabled and one field is still empty " + f"({p_name!r}); filling it and re-checking the submit") + await execute_tool("BrowserClickIndex", {"index": p_idx, "text": payload}, + browser_id, tab_id) + log.append({"tool": "BrowserClickIndex", "input": {"index": p_idx, "text": payload}, + "ok": True, "result_summary": f"filled required field {p_name!r}"[:200], + "elapsed_ms": 0}) + r_ev2 = await execute_tool( + "BrowserEvaluate", + {"expression": browser_submit_click.container_submit_expression(payload)}, + browser_id, tab_id) + p_v2 = browser_submit_click.parse_eval_value(r_ev2) + if isinstance(p_v2, dict) and p_v2.get("ok") and p_v2.get("xPct") is not None: + logger.info("[browser-sendscript] the submit enabled once the required field was filled") + r_send = await execute_tool( + "BrowserClickPoint", + {"xPercent": float(p_v2["xPct"]), "yPercent": float(p_v2["yPct"])}, + browser_id, tab_id) + send_name = str(p_v2.get("name") or "submit") + via = "container-after-required-field" + p_v = p_v2 + if via == "index" and not (isinstance(p_v, dict) and p_v.get("ok")): + logger.info(f"[browser-sendscript] submit {str(p_v.get('name'))!r} is present but DISABLED; " + f"the form is incomplete, handing to the model without clicking anything") + return {"clicked": False, "sent": False, "log": log, "note": (f"The {str(p_v.get('name')) or 'submit'} button is visible but disabled, so this " f"form is not ready to send: something it requires is still empty (often a " f"title or subject), or the editor never registered the typed text. Fill the " diff --git a/backend/tests/test_multifield_submit.py b/backend/tests/test_multifield_submit.py new file mode 100644 index 00000000..07f0af67 --- /dev/null +++ b/backend/tests/test_multifield_submit.py @@ -0,0 +1,61 @@ +"""A form whose submit is gated on a second field must still be sendable. + +Measured live 2026-08-04: reddit's create-post flow could never complete. `/r/test/submit` exposes +`textbox "Title"` (required, and its submit stays DISABLED until filled) and `textbox "Post text"`. +Only the second matches composer vocabulary, so the send script filled the body, the title stayed +empty, the submit never enabled, and the run handed off every time. Every other supported surface is +a single box, so this was the one shape the single-composer design could not express. + +The rule is deliberately narrow, and the narrowness is the safety: + - EXACTLY one other empty non-auth textbox, so there is never a guess about which field to fill + - the submit must ENABLE ON ITS OWN afterwards; we re-ask the page rather than assume + - if it stays disabled, the old honest hand-off is unchanged and nothing is clicked + +Auth fields are excluded via the same P_AUTH_FIELD_NAME_RE the login-wall gate uses, so a password +or email box beside a composer can never be treated as "the field the submit is waiting on". +""" + +import inspect + +from backend.apps.agents.browser import browser_send_parse as sp +from backend.apps.agents.browser import browser_send_script as ss + +SRC = inspect.getsource(ss) + + +def p_missing(state, composer_index): + """The selection rule under test, exactly as the send script applies it.""" + return [(i, n) for i, n in sp.P_COMPOSER_ROW_RE.findall(state) + if int(i) != composer_index and not sp.P_AUTH_FIELD_NAME_RE.search(n or "")] + + +def test_the_reddit_shape_identifies_exactly_one_missing_field(): + """The perception verbatim. The composer is excluded BY INDEX: the row regex captures only the + accessible name, so a filled box and an empty one look identical by name, and matching on the + payload made every field read as still-empty (this test caught that).""" + state = '[3]\n[7]' + missing = p_missing(state, composer_index=7) + assert len(missing) == 1 and missing[0][1] == "Title", missing + + +def test_an_auth_field_is_never_mistaken_for_the_missing_field(): + """A password box beside a composer must not be filled with the user's post.""" + state = '[3]\n[7]' + assert p_missing(state, composer_index=7) == [], "an auth field must never be the target" + + +def test_two_empty_fields_stay_ambiguous_and_are_not_guessed(): + """Three-field forms are a different problem; guessing which one gates the submit is how you + type a post into someone's phone-number box.""" + state = '[3]\n[5]\n[7]' + assert len(p_missing(state, composer_index=7)) == 2, "two candidates must not collapse to a pick" + + +def test_the_submit_must_re_enable_on_its_own_before_any_click(): + """The safety property. Filling a field is reversible; clicking submit is not, so the page has + to confirm the form is now valid rather than us assuming it.""" + i = SRC.index("filling it and re-checking the submit") + block = SRC[i:i + 1500] + assert "container_submit_expression" in block, "must re-ask the page whether the submit enabled" + assert block.index("container_submit_expression") < block.index("BrowserClickPoint"), \ + "the re-check must happen BEFORE the click"