fix(steering): skip phantom zero-turn result on --resume; guard empty replies

On --resume, a background task stopped by the previous restart is replayed
as its own zero-turn turn with an empty result, before the real turn.
consume_stream took it as the answer -> empty Discord message (400) ->
"Sorry, something went wrong", while the real turn ran orphaned.

- stream_json: skip result with num_turns=0, empty, non-error
- router: empty Claude response becomes a visible notice, not an adapter crash

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014y1sJNe6mkWwFMwkWmCg8J
This commit is contained in:
2026-10-01 15:51:34 +00:00
parent 9b10e8b9d1
commit 5bc52fe67f
4 changed files with 35 additions and 0 deletions

View File

@@ -616,6 +616,11 @@ def route_message(
# Same pattern as the existing __AUDIO__: sentinel — no # Same pattern as the existing __AUDIO__: sentinel — no
# _set_last_response, the adapter reacts instead of replying. # _set_last_response, the adapter reacts instead of replying.
return response, False 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) _set_last_response(channel_id, response)
return response, False return response, False
except Exception as e: except Exception as e:

View File

@@ -102,6 +102,14 @@ def consume_stream(
logger.exception("on_init callback error") logger.exception("on_init callback error")
elif msg_type == "result": 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 result_obj = event
break # never read past the turn boundary break # never read past the turn boundary
@@ -146,6 +154,16 @@ def demo() -> None:
# Blank/garbage lines are skipped, not fatal. # Blank/garbage lines are skipped, not fatal.
assert consume_stream(["", "not json", json.dumps({"type": "result", "result": "ok"})])["result"] == "ok" 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") print("stream_json self-check OK")

View File

@@ -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) registry = claude_runner.RunnerRegistry(max_live=0, idle_minutes=20)
assert any("max_live" in rec.message for rec in caplog.records) assert any("max_live" in rec.message for rec in caplog.records)
assert registry.get("any-channel") is None 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()

View File

@@ -195,6 +195,13 @@ class TestRegularMessage:
assert is_cmd is False 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) 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") @patch("src.router.send_message")
def test_model_override(self, mock_send): def test_model_override(self, mock_send):
mock_send.return_value = "Response" mock_send.return_value = "Response"