diff --git a/backend/apps/agents/manager/run/handle_run_error.py b/backend/apps/agents/manager/run/handle_run_error.py index f99d8280..e63d57e0 100644 --- a/backend/apps/agents/manager/run/handle_run_error.py +++ b/backend/apps/agents/manager/run/handle_run_error.py @@ -194,12 +194,23 @@ async def handle_run_error(e: Exception, session: AgentSession, session_id: str, p_report_model_error("cert_failure", session_id, session, turn, e, p_stderr_tail) elif is_cli_binary_missing(e, extra_text=p_stderr_tail): # The bundled CLI vanished from an installed app (Windows AV quarantine class; 22 of 25 field installs never recovered). The raw "not found at: C:\..." card is unactionable; name the likely cause and the two real fixes. + # "Restore it from your antivirus quarantine" is technically right and empirically useless: + # 22 of 25 affected field installs never recovered, and a real user replied "don't know how + # to take a file out of quarantine" (2026-08-29). A card that names a fix the reader cannot + # perform is a dead end, so spell the clicks out and put the fix that always works first. friendly_msg = ( - "A core OpenSwarm component (the bundled agent runtime) is missing from " - "this install, which usually means antivirus software quarantined it. " - "Restore it from your antivirus quarantine and add an exclusion for " - "OpenSwarm, or reinstall from openswarm.com. Your chats and settings " - "are kept either way." + "A core OpenSwarm component (the bundled agent runtime) is missing from this " + "install. Antivirus software almost always caused this by quarantining it. " + "Your chats and settings are safe either way.\n\n" + "Easiest fix: reinstall from openswarm.com over the top of this install. " + "Nothing is lost.\n\n" + "Or restore it in Windows Security:\n" + "1. Open Windows Security, then Virus & threat protection.\n" + "2. Under Current threats choose Protection history.\n" + "3. Find the quarantined OpenSwarm or claude item and choose Actions, then Restore.\n" + "4. Back in Virus & threat protection, open Manage settings, scroll to Exclusions, " + "choose Add or remove exclusions, then Add an exclusion, then Folder, and pick your " + "OpenSwarm folder. Without this step it will be quarantined again." ) error_msg = Message(role="system", content=friendly_msg, branch_id=session.active_branch_id) absorb_repeat_card(session, error_msg) diff --git a/backend/tests/test_quarantine_card_is_actionable.py b/backend/tests/test_quarantine_card_is_actionable.py new file mode 100644 index 00000000..f5fceccc --- /dev/null +++ b/backend/tests/test_quarantine_card_is_actionable.py @@ -0,0 +1,50 @@ +"""The AV-quarantine card has to be performable by the person who receives it. + +Field evidence, 2026-08-29: a first-time user hit this card and replied "don't know how to take a +file out of quarantine". She is not an outlier -- 22 of 25 affected installs never produced another +agent reply. The old card named the right fix ("restore it from your antivirus quarantine and add an +exclusion") and gave no way to do it, which is a dead end dressed as guidance. + +Row 4 on the ladder: the app is unusable, and the stated recovery empirically does not happen.""" + +import re + +SRC = open("backend/apps/agents/manager/run/handle_run_error.py", encoding="utf-8").read() +# Anchor on THIS card's own words: the file holds several `friendly_msg = (` blocks and slicing the +# first one silently tested the long-context card instead. +_start = SRC.index("bundled agent runtime") +CARD = SRC[_start:SRC.index("error_msg = Message(", _start)] + + +def test_it_leads_with_the_fix_that_always_works(): + """Reinstall works for everyone; the quarantine dance works for the few who can do it. A card + that opens with the harder path loses the readers who needed it most.""" + lower = CARD.lower() + assert "reinstall" in lower + assert lower.index("reinstall") < lower.index("windows security"), \ + "the always-works fix must come before the fiddly one" + + +def test_the_restore_path_is_actual_clicks(): + """Naming a destination is not instructions. These are the four screens a user has to touch.""" + lower = CARD.lower() + for step in ("windows security", "protection history", "restore", "exclusion"): + assert step in lower, f"the card never mentions {step!r}" + + +def test_it_says_why_the_exclusion_matters(): + """Restoring without an exclusion just gets it quarantined again, which is how someone 'fixes' + it twice and gives up.""" + assert "quarantined again" in CARD.lower() + + +def test_it_still_reassures_about_data(): + """The single most common panic on this card is 'have I lost my chats'.""" + lower = CARD.lower() + assert "chats and settings are safe" in lower or "your chats" in lower + + +def test_it_does_not_dump_the_dead_path(): + """The raw CLINotFoundError path was the original unactionable card; it must not come back.""" + assert "not found at" not in CARD.lower() + assert not re.search(r"[A-Za-z]:\\\\", CARD), "no raw Windows path in the user-facing text"