fix(fallback): turnul nu se mai pierde cand Claude e la limita
Detectia de rate limit functiona, dar `_local_fallback_reply` putea intoarce None din patru locuri fara nicio linie de log — userul primea `Claude CLI error (exit 1): You've hit your session limit` pe Discord in loc de raspuns, si nu se putea afla ulterior din log care branch a picat. - daca runda de unelte iese goala, turnul se reface fara unelte in loc sa fie abandonat - fiecare return None ramas e logat - daca nici modelul local nu raspunde, mesajul e „Claude e la limita…" plus ora de reset, nu eroarea bruta a CLI-ului - cron: job-urile heartbeat* tac la limita (last_status: rate_limited); celelalte trimit o singura linie scurta, ca sa se vada rularea sarita - is_rate_limit_error / rate_limit_detail mutate in claude_session.py, folosite si de scheduler (care nu poate importa router-ul) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DzZAMzbyQbzFdsrVoXijBg
This commit is contained in:
@@ -596,3 +596,30 @@ class TestOtpCommand:
|
||||
def test_otp_is_not_a_fallback_tool(self):
|
||||
"""The fallback registry is read-only; auth is state-changing."""
|
||||
assert "otp" not in lft.TOOLS
|
||||
|
||||
|
||||
# --- The tool round must never swallow the turn ---
|
||||
|
||||
class TestFallbackNeverDropsTurn:
|
||||
"""A rate-limit rescue that returns None puts the raw Claude error on
|
||||
Discord — exactly the failure seen on 2026-08-23. If the tool round comes
|
||||
back empty, retry tool-free instead of giving up."""
|
||||
|
||||
@patch("src.router._get_config")
|
||||
def test_empty_tool_round_retries_tool_free(self, mock_get_config):
|
||||
from src.router import _local_fallback_reply
|
||||
|
||||
mock_cfg = MagicMock()
|
||||
mock_cfg.get.return_value = {"enabled": True, "url": "http://x/v1/chat/completions"}
|
||||
mock_get_config.return_value = mock_cfg
|
||||
|
||||
with patch("src.router._call_local_llm",
|
||||
return_value={"content": "", "tool_calls": [_call("cauta_web")]}), \
|
||||
patch("src.router._run_fallback_tools", return_value=None), \
|
||||
patch("src.router._conversational_reply", return_value="răspuns direct") as conv:
|
||||
reply = _local_fallback_reply("ceva")
|
||||
|
||||
assert reply is not None
|
||||
assert "răspuns direct" in reply
|
||||
conv.assert_called_once()
|
||||
|
||||
|
||||
@@ -242,7 +242,7 @@ class TestRegularMessage:
|
||||
@patch("src.router._get_channel_config")
|
||||
@patch("src.router._get_config")
|
||||
@patch("src.router.send_message")
|
||||
def test_rate_limit_fallback_unavailable_surfaces_original_error(
|
||||
def test_rate_limit_fallback_unavailable_surfaces_limit_notice(
|
||||
self, mock_send, mock_get_config, mock_chan_cfg, mock_fallback,
|
||||
):
|
||||
mock_send.side_effect = RuntimeError(
|
||||
@@ -255,8 +255,11 @@ class TestRegularMessage:
|
||||
mock_fallback.return_value = None # local fallback itself unreachable
|
||||
|
||||
response, is_cmd = route_message("ch-1", "user-1", "hello")
|
||||
assert "Error:" in response
|
||||
assert "hit your session limit" in response
|
||||
# The reset time still has to reach the user; the `Claude CLI error
|
||||
# (exit 1):` wrapper is noise and must not.
|
||||
assert "resets 10:50am (UTC)" in response
|
||||
assert "Claude e la limită" in response
|
||||
assert "Claude CLI error" not in response
|
||||
assert is_cmd is False
|
||||
|
||||
@patch("src.router._get_channel_config")
|
||||
|
||||
@@ -362,6 +362,46 @@ class TestRunJob:
|
||||
assert "Error" in result
|
||||
assert sched._jobs[0]["last_status"] == "error"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rate_limited_heartbeat_stays_silent(self, sched, tmp_jobs, callback):
|
||||
"""A limit isn't actionable and recurs every run until it resets —
|
||||
heartbeats must not narrate it into the channel."""
|
||||
sched.add_job("heartbeat-4h", "0 * * * *", "ch", "prompt")
|
||||
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.returncode = 1
|
||||
mock_proc.stdout = ""
|
||||
mock_proc.stderr = "You've hit your session limit \u00b7 resets 10:50am (UTC)"
|
||||
|
||||
with patch("src.scheduler.build_system_prompt", return_value="sys"), \
|
||||
patch("subprocess.run", return_value=mock_proc):
|
||||
result = await sched.run_job("heartbeat-4h")
|
||||
|
||||
assert result == ""
|
||||
assert sched._jobs[0]["last_status"] == "rate_limited"
|
||||
callback.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rate_limited_other_job_reports_one_short_line(
|
||||
self, sched, tmp_jobs, callback
|
||||
):
|
||||
sched.add_job("evening-report", "0 * * * *", "ch", "prompt")
|
||||
|
||||
mock_proc = MagicMock()
|
||||
mock_proc.returncode = 1
|
||||
mock_proc.stdout = ""
|
||||
mock_proc.stderr = "You've hit your session limit \u00b7 resets 10:50am (UTC)"
|
||||
|
||||
with patch("src.scheduler.build_system_prompt", return_value="sys"), \
|
||||
patch("subprocess.run", return_value=mock_proc):
|
||||
result = await sched.run_job("evening-report")
|
||||
|
||||
assert "Claude e la limită" in result
|
||||
assert "resets 10:50am (UTC)" in result
|
||||
assert "Claude CLI error" not in result
|
||||
assert sched._jobs[0]["last_status"] == "rate_limited"
|
||||
callback.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_job_invalid_json(self, sched, tmp_jobs, callback):
|
||||
sched.add_job("json-err", "0 * * * *", "ch", "prompt")
|
||||
|
||||
Reference in New Issue
Block a user