diff --git a/backend/apps/agents/browser/browser_prestage.py b/backend/apps/agents/browser/browser_prestage.py index 08bbc21a..31f3a836 100644 --- a/backend/apps/agents/browser/browser_prestage.py +++ b/backend/apps/agents/browser/browser_prestage.py @@ -388,6 +388,7 @@ async def run_prestage( p_total_timeout = OPENER_TOTAL_TIMEOUT_S if opener_mode() else TOTAL_TIMEOUT_S p_system = P_SYSTEM_OPENER if opener_mode() else P_SYSTEM p_results_overruled = False + p_composer_overruled = False while (not staged_complete and steps < p_max_steps and (time.monotonic() - t0) < p_total_timeout): # Per-step cost, broken out. Prestage is the largest single phase of a LinkedIn write @@ -423,6 +424,21 @@ async def run_prestage( "one specific person or thing, CLICK through to its own page first; " "READY again only if the task really is about this list.]") continue + # A send task is not staged until there is somewhere to write. Measured live: on + # instagram the aux replies "I can see the Instagram home page is loaded" and calls + # READY from the feed, and on tiktok it says "CLICK [1] READY" in one breath, before + # the click it just asked for could open anything. Both then decline downstream with + # composer=0 textboxes=0, having spent the whole stage. Same overrule shape as the + # results-list rule above: nudge once, accept a second READY, because some surfaces + # really do hide their box until the fill tier clicks an opener. + if (task_is_send and not p_composer_overruled + and not browser_send_parse.composer_index_in_state(li_text)): + p_composer_overruled = True + task = task + ( + "\n\n[You replied READY but no compose text box is listed in the elements. " + "OPEN the specific item the task names, on its own page, or CLICK the " + "control that reveals the box. READY again only once a textbox is listed.]") + continue staged_complete = True logger.info(f"[browser-prestage] READY after {steps} step(s): {arg[:80]}") break diff --git a/backend/tests/test_browser_prestage_opener.py b/backend/tests/test_browser_prestage_opener.py index 2e1c8312..7b7c5d31 100644 --- a/backend/tests/test_browser_prestage_opener.py +++ b/backend/tests/test_browser_prestage_opener.py @@ -103,3 +103,21 @@ def test_half_an_emoji_cannot_kill_the_whole_prestage(): # A well-formed emoji is left alone; scrubbing real content would be its own bug. assert strip_lone_surrogates("done \U0001f9e0 ok") == "done \U0001f9e0 ok" assert strip_lone_surrogates("") == "" + + +def test_ready_on_a_page_with_nowhere_to_write_gets_one_nudge(): + """The instagram/tiktok shape, measured live 2026-08-02 at N=5: 0/4 each, every run declining + downstream with composer=0 textboxes=0. + + Prestage took the aux model's word for "READY". On instagram it replied "I can see the Instagram + home page is loaded" and called READY from the feed; on tiktok it said "CLICK [1] READY" in one + breath, declaring done before the click it had just asked for could open anything. Both burned + the whole stage and handed the send script a page with no box in it. + + A send task is not staged until something can be written into. The gate is the same shape as the + results-list overrule beside it: nudge once, accept a second READY, because some surfaces really + do hide their box behind an opener the fill tier clicks later. + """ + from backend.apps.agents.browser.browser_send_parse import composer_index_in_state + assert composer_index_in_state(NO_COMPOSER) is None, "the feed shape must read as no composer" + assert composer_index_in_state(COMPOSER_PRESENT) is not None, "a staged page must read as one"