diff --git a/static/messages.js b/static/messages.js index e55464426..a94ecd48d 100644 --- a/static/messages.js +++ b/static/messages.js @@ -1825,8 +1825,8 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){ const normalized=String(text||'').replace(/\s+/g,' ').trim(); if(!normalized) return false; const systemRecovery=/^\[System:/i.test(normalized) - && /previous response was cut off by a network error/i.test(normalized) - && /continue exactly where you left off/i.test(normalized); + && (/continue exactly where you left off/i.test(normalized) + || /do not retry the same tool call/i.test(normalized)); const backendRecovery=/^the live worker stopped before this run finished\.?$/i.test(normalized); return !!(systemRecovery || backendRecovery); } diff --git a/static/ui.js b/static/ui.js index 31227c086..b7e01bc4d 100644 --- a/static/ui.js +++ b/static/ui.js @@ -7981,8 +7981,8 @@ function _isRecoveryControlMessageText(text){ const normalized=String(text||'').replace(/\s+/g,' ').trim(); if(!normalized) return false; const systemRecovery=/^\[System:/i.test(normalized) - && /previous response was cut off by a network error/i.test(normalized) - && /continue exactly where you left off/i.test(normalized); + && (/continue exactly where you left off/i.test(normalized) + || /do not retry the same tool call/i.test(normalized)); const backendRecovery=/^the live worker stopped before this run finished\.?$/i.test(normalized); return !!(systemRecovery || backendRecovery); } diff --git a/tests/test_issue4875_recovery_prompt_filter.py b/tests/test_issue4875_recovery_prompt_filter.py new file mode 100644 index 000000000..0256c4e3c --- /dev/null +++ b/tests/test_issue4875_recovery_prompt_filter.py @@ -0,0 +1,77 @@ +from __future__ import annotations +import json +import shutil +import subprocess +from pathlib import Path +import pytest + +ROOT = Path(__file__).resolve().parents[1] +UI_JS = (ROOT / "static" / "ui.js").read_text(encoding="utf-8") +MESSAGES_JS = (ROOT / "static" / "messages.js").read_text(encoding="utf-8") +NODE = shutil.which("node") + +pytestmark = pytest.mark.skipif(NODE is None, reason="node not available") + + +def _extract_function(src: str, name: str) -> str: + start = src.index(f"function {name}(") + brace = src.index("{", start) + depth = 0 + for i in range(brace, len(src)): + if src[i] == "{": + depth += 1 + elif src[i] == "}": + depth -= 1 + if depth == 0: + return src[start:i + 1] + raise AssertionError(f"{name} body not closed") + + +def _eval_filter(src: str, name: str, cases: list[str]) -> list[bool]: + script = ( + _extract_function(src, name) + + "\nconst cases = JSON.parse(process.argv[1]);\n" + + f"process.stdout.write(JSON.stringify(cases.map({name})));\n" + ) + result = subprocess.run( + [NODE, "-e", script, json.dumps(cases)], + check=True, capture_output=True, text=True, timeout=15, + ) + return json.loads(result.stdout) + + +RECOVERY_VARIANTS = [ + "[System: Your previous response was cut off by a network error. Continue exactly where you left off.]", + "[System: Your previous response was truncated by the output length limit. Continue exactly where you left off.]", + "[System: Your previous tool call (shell_command) was too large. Do not retry the same tool call.]", +] + +FALSE_POSITIVES = [ + "", + "Continue exactly where you left off.", + "Do not retry the same tool call.", + "[System: Some other note.]", + "[System: previous response was cut off by a network error.]", + "User said: [System: continue exactly where you left off.]", + "**Response interrupted:** the model stopped early", +] + +_PARAMS = [ + pytest.param(UI_JS, "_isRecoveryControlMessageText", id="ui_js"), + pytest.param(MESSAGES_JS, "_streamRecoveryControlMessageText", id="messages_js"), +] + + +@pytest.mark.parametrize(("src", "name"), _PARAMS) +def test_all_continuation_variants_filtered(src, name): + assert _eval_filter(src, name, RECOVERY_VARIANTS) == [True, True, True] + + +@pytest.mark.parametrize(("src", "name"), _PARAMS) +def test_false_positives_not_filtered(src, name): + assert _eval_filter(src, name, FALSE_POSITIVES) == [False] * len(FALSE_POSITIVES) + + +@pytest.mark.parametrize(("src", "name"), _PARAMS) +def test_backend_recovery_string_filtered(src, name): + assert _eval_filter(src, name, ["The live worker stopped before this run finished."]) == [True]