fix(gateway): prevent duplicate messages on no-message-id platforms
Platforms that don't return a message_id after the first send (Signal, GitHub webhooks) were causing GatewayStreamConsumer to re-enter the "first send" path on every tool boundary, posting one platform message per tool call (observed as 155 PR comments on a single response). Fix: treat _message_id == "__no_edit__" as a sentinel meaning "platform accepted the send but cannot be edited". When a tool boundary arrives in that state, skip the message_id/accumulated/last_sent_text reset so all continuation text is delivered once via _send_fallback_final rather than re-posted per segment. Also make prompt_toolkit imports in hermes_cli/commands.py optional so gateway and test environments that lack the package can still import resolve_command, gateway_help_lines, and COMMAND_REGISTRY.
This commit is contained in:
@@ -437,6 +437,45 @@ class TestSegmentBreakOnToolBoundary:
|
||||
# Only one send call (the initial message)
|
||||
assert adapter.send.call_count == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_message_id_segment_breaks_do_not_resend(self):
|
||||
"""On a platform that never returns a message_id (e.g. webhook with
|
||||
github_comment delivery), tool-call segment breaks must NOT trigger
|
||||
a new adapter.send() per boundary. The fix: _message_id == '__no_edit__'
|
||||
suppresses the reset so all text accumulates and is sent once."""
|
||||
adapter = MagicMock()
|
||||
# No message_id on first send, then one more for the fallback final
|
||||
adapter.send = AsyncMock(side_effect=[
|
||||
SimpleNamespace(success=True, message_id=None),
|
||||
SimpleNamespace(success=True, message_id=None),
|
||||
])
|
||||
adapter.edit_message = AsyncMock(return_value=SimpleNamespace(success=True))
|
||||
adapter.MAX_MESSAGE_LENGTH = 4096
|
||||
|
||||
config = StreamConsumerConfig(edit_interval=0.01, buffer_threshold=5)
|
||||
consumer = GatewayStreamConsumer(adapter, "chat_123", config)
|
||||
|
||||
# Simulate: text → tool boundary → text → tool boundary → text (3 segments)
|
||||
consumer.on_delta("Phase 1 text")
|
||||
consumer.on_delta(None) # tool call boundary
|
||||
consumer.on_delta("Phase 2 text")
|
||||
consumer.on_delta(None) # another tool call boundary
|
||||
consumer.on_delta("Phase 3 text")
|
||||
consumer.finish()
|
||||
|
||||
await consumer.run()
|
||||
|
||||
# Before the fix this would post 3 comments (one per segment).
|
||||
# After the fix: only the initial partial + one fallback-final continuation.
|
||||
assert adapter.send.call_count == 2, (
|
||||
f"Expected 2 sends (initial + fallback), got {adapter.send.call_count}"
|
||||
)
|
||||
assert consumer.already_sent
|
||||
# The continuation must contain the text from segments 2 and 3
|
||||
final_text = adapter.send.call_args_list[1][1]["content"]
|
||||
assert "Phase 2" in final_text
|
||||
assert "Phase 3" in final_text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fallback_final_splits_long_continuation_without_dropping_text(self):
|
||||
"""Long continuation tails should be chunked when fallback final-send runs."""
|
||||
|
||||
Reference in New Issue
Block a user