diff --git a/src/router.py b/src/router.py index 7986fd5..5886c36 100644 --- a/src/router.py +++ b/src/router.py @@ -616,6 +616,11 @@ def route_message( # Same pattern as the existing __AUDIO__: sentinel — no # _set_last_response, the adapter reacts instead of replying. return response, False + if not (response or "").strip(): + # Adapters can't send an empty message (Discord 400 → generic + # "something went wrong"); say so instead of failing opaquely. + log.warning("channel=%s: Claude returned an empty response", channel_id) + return "⚠️ Claude a terminat turul fără niciun răspuns text. Mai trimite o dată mesajul.", False _set_last_response(channel_id, response) return response, False except Exception as e: diff --git a/src/stream_json.py b/src/stream_json.py index 477be0a..9bacf40 100644 --- a/src/stream_json.py +++ b/src/stream_json.py @@ -102,6 +102,14 @@ def consume_stream( logger.exception("on_init callback error") elif msg_type == "result": + # On --resume, a leftover background-task notification is + # replayed as its own zero-turn "turn" with an empty result, + # BEFORE the real one. Taking it as the answer returned "" (Discord + # 400 "Cannot send an empty message") and orphaned the real turn. + if (event.get("num_turns") == 0 and not event.get("is_error") + and not event.get("result") and not text_blocks): + logger.info("skipping zero-turn result (task notification replay)") + continue result_obj = event break # never read past the turn boundary @@ -146,6 +154,16 @@ def demo() -> None: # Blank/garbage lines are skipped, not fatal. assert consume_stream(["", "not json", json.dumps({"type": "result", "result": "ok"})])["result"] == "ok" + # --resume replays a stopped background task as a zero-turn empty result + # before the real turn — skip it, return the real one. + phantom = [ + json.dumps({"type": "system", "subtype": "task_notification", "status": "stopped"}), + json.dumps({"type": "result", "result": "", "num_turns": 0, "is_error": False}), + json.dumps({"type": "assistant", "message": {"content": [{"type": "text", "text": "PONG"}]}}), + json.dumps({"type": "result", "result": "PONG", "num_turns": 1, "is_error": False}), + ] + assert consume_stream(phantom)["result"] == "PONG" + print("stream_json self-check OK") diff --git a/tests/test_claude_runner.py b/tests/test_claude_runner.py index ee5558c..6dfbd20 100644 --- a/tests/test_claude_runner.py +++ b/tests/test_claude_runner.py @@ -349,3 +349,8 @@ def test_max_live_non_positive_logs_and_always_degrades(caplog): registry = claude_runner.RunnerRegistry(max_live=0, idle_minutes=20) assert any("max_live" in rec.message for rec in caplog.records) assert registry.get("any-channel") is None + + +def test_stream_json_self_check_skips_phantom_resume_result(): + from src import stream_json + stream_json.demo() diff --git a/tests/test_router.py b/tests/test_router.py index 3718b86..bba7ac7 100644 --- a/tests/test_router.py +++ b/tests/test_router.py @@ -195,6 +195,13 @@ class TestRegularMessage: assert is_cmd is False mock_send.assert_called_once_with("ch-1", "hello", model="sonnet", on_text=None, voice_mode=False, adapter_name=None) + @patch("src.router._get_channel_config", return_value=None) + @patch("src.router.send_message", return_value=" ") + def test_empty_response_becomes_notice(self, mock_send, _chan): + response, is_cmd = route_message("ch-1", "user-1", "hello", model="sonnet") + assert response.strip() + assert is_cmd is False + @patch("src.router.send_message") def test_model_override(self, mock_send): mock_send.return_value = "Response"