diff --git a/backend/apps/agents/browser/browser_send_script.py b/backend/apps/agents/browser/browser_send_script.py index 53062256..8420f03d 100644 --- a/backend/apps/agents/browser/browser_send_script.py +++ b/backend/apps/agents/browser/browser_send_script.py @@ -129,7 +129,8 @@ async def complete_send( 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], + "ok": True, "clicked_role": "textbox", "clicked_name": p_name, + "result_summary": f"filled required field {p_name!r}"[:200], "elapsed_ms": 0}) r_ev2 = await execute_tool( "BrowserEvaluate", @@ -374,7 +375,12 @@ async def run_send_script( r_open = await execute_tool("BrowserClickIndex", {"index": opener[0]}, browser_id, tab_id) if not (isinstance(r_open, dict) and "error" not in r_open): return None + # clicked_name/clicked_role are the fields the skill distiller reads. Putting the + # element's name only into result_summary prose is why that layer recorded nothing in 95 + # consecutive gate passes: distill_steps hits a click it cannot name, truncates there, + # and the navigation-only remainder is then correctly refused by its own guard. log.append({"tool": "BrowserClickIndex", "input": {"index": opener[0]}, "ok": True, + "clicked_role": "button", "clicked_name": opener[1], "result_summary": f"script opened composer via {opener[1]!r}"[:200], "elapsed_ms": 0}) # Wait for the surface to STOP MOVING, not for a number of seconds. Fixed budgets kept # being wrong in both directions: 1.8s missed gmail and linkedin entirely, 5.3s still @@ -464,8 +470,13 @@ async def run_send_script( # 1. fill (focused by node, the composer overlay path coordinate clicks miss) r_fill = await execute_tool("BrowserClickIndex", {"index": composer[0], "text": payload}, browser_id, tab_id) fill_ok = isinstance(r_fill, dict) and "error" not in r_fill + # Named for the distiller, same as the opener. The payload does NOT ride along: a + # BrowserClickIndex distills to a BrowserClickByName carrying role and name only, so a + # replay re-focuses this box and the send script fills it with the CURRENT text. Baking the + # payload in here is how a replay would re-post last week's message. log.append({"tool": "BrowserClickIndex", "input": {"index": composer[0], "text": payload}, - "ok": fill_ok, "result_summary": f"script fill into {composer[1]!r}"[:200], "elapsed_ms": 0}) + "ok": fill_ok, "clicked_role": "textbox", "clicked_name": composer[1], + "result_summary": f"script fill into {composer[1]!r}"[:200], "elapsed_ms": 0}) if not fill_ok and browser_submit_click.is_stale_index_error(r_fill): # The opener click opens a modal that keeps re-rendering after we listed it, so the # composer node we resolved is already detached by the time the fill lands. Measured @@ -499,6 +510,7 @@ async def run_send_script( fill_ok = isinstance(r_fill, dict) and "error" not in r_fill log.append({"tool": "BrowserClickIndex", "input": {"index": composer[0], "text": payload}, "ok": fill_ok, + "clicked_role": "textbox", "clicked_name": composer[1], "result_summary": f"script fill retry into {composer[1]!r}"[:200], "elapsed_ms": 0}) if not fill_ok: diff --git a/backend/tests/test_send_script_log_is_distillable.py b/backend/tests/test_send_script_log_is_distillable.py new file mode 100644 index 00000000..490198db --- /dev/null +++ b/backend/tests/test_send_script_log_is_distillable.py @@ -0,0 +1,66 @@ +"""The send script's own action_log must be able to become a skill. + +Measured across 2026-08 sweeps: 95 runs passed the skill record gate (`honest=True +informational=False removal=False unconfirmed_send=False`) and all 95 came back +`NOT recorded (host empty or no robust steps)`. Nothing was wrong with the gate. The send script +appended its opener and composer clicks with the element name in `result_summary` prose only, and +`distill_steps` reads `clicked_name`. An unnameable click truncates the distillation, the truncation +branch then drops the typing steps, and what is left is navigation-only, which `productive_count` +correctly refuses. Zero recordings, forever, from four missing dict keys. + +The class of bug is "one side writes prose, the other side reads a field", so the test is written +against the SHAPE the send script appends rather than against any one call site. +""" + +from backend.apps.agents.browser import browser_skills as bs + + +def p_sendscript_log() -> list: + """The action_log a fast-path write leaves behind: navigate, open the composer, fill it. + + Mirrors browser_send_script.py's own appends. `send click` is deliberately included and + deliberately not distillable: its tool name matches no branch, which is what keeps a replay from + ever re-firing a send. + """ + return [ + {"tool": "BrowserNavigate", "input": {"url": "https://www.linkedin.com/feed/"}, "ok": True}, + {"tool": "BrowserClickIndex", "input": {"index": 12}, "ok": True, + "clicked_role": "button", "clicked_name": "Start a post", + "result_summary": "script opened composer via 'Start a post'"}, + {"tool": "BrowserClickIndex", "input": {"index": 31, "text": "hello there"}, "ok": True, + "clicked_role": "textbox", "clicked_name": "Text editor for creating content", + "result_summary": "script fill into 'Text editor for creating content'"}, + {"tool": "send click", "input": {"via": "index"}, "ok": True, + "clicked_role": "button", "clicked_name": "Post"}, + ] + + +def test_a_fast_path_write_distills_into_a_replayable_prefix(): + """The regression: this returned [] on every single fast-path write.""" + steps = bs.distill_steps(p_sendscript_log()) + assert steps, "a completed fast-path write must be recordable" + assert [s["tool"] for s in steps] == [ + "BrowserNavigate", "BrowserClickByName", "BrowserClickByName"] + + +def test_the_payload_never_rides_into_the_recorded_skill(): + """A skill that carries last week's text re-posts last week's text. The composer click is + recorded as role+name, and the send script fills it fresh on every replay.""" + blob = repr(bs.distill_steps(p_sendscript_log())) + assert "hello there" not in blob + + +def test_the_send_click_is_never_recorded(): + """The one step a replay must never perform mechanically.""" + steps = bs.distill_steps(p_sendscript_log()) + names = [(s.get("params") or {}).get("name") for s in steps] + assert "Post" not in names + + +def test_an_unnameable_click_still_truncates(): + """The guard that made the old behaviour correct-but-useless has to stay correct: a click we + cannot name is a step we cannot replay, and keeping it would replay the wrong element.""" + log = p_sendscript_log() + del log[1]["clicked_name"] + steps = bs.distill_steps(log) + assert all(s["tool"] == "BrowserNavigate" for s in steps) or steps == []