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:
2026-08-23 14:52:44 +00:00
parent a1d39637c8
commit b97edd5184
7 changed files with 147 additions and 17 deletions

View File

@@ -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()