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:
@@ -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:
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user