mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
fix(gateway): strip cursor from frozen message on empty fallback continuation (#7183)
When _send_fallback_final() is called with nothing new to deliver (the visible partial already matches final_text), the last edit may still show the cursor character because fallback mode was entered after a failed edit. Before this fix the early-return path left _already_sent = True without attempting to strip the cursor, so the message stayed frozen with a visible ▉ permanently. Adds a best-effort edit inside the empty-continuation branch to clean the cursor off the last-sent text. Harmless when fallback mode wasn't actually armed or when the cursor isn't present. If the strip edit itself fails (flood still active), we return without crashing and without corrupting _last_sent_text. Adapted from PR #7429 onto current main — the surrounding fallback block grew the #10807 stale-prefix handling since #7429 was written, so the cursor strip lives in the new else-branch where we still return early. 3 unit tests covering: cursor stripped on empty continuation, no edit attempted when cursor is not configured, cursor-strip edit failure handled without crash. Originally proposed as PR #7429.
This commit is contained in:
parent
62ce6a38ae
commit
b668c09ab2
2 changed files with 108 additions and 0 deletions
|
|
@ -571,6 +571,30 @@ class GatewayStreamConsumer:
|
||||||
if final_text.strip() and final_text != self._visible_prefix():
|
if final_text.strip() and final_text != self._visible_prefix():
|
||||||
continuation = final_text
|
continuation = final_text
|
||||||
else:
|
else:
|
||||||
|
# Defence-in-depth for #7183: the last edit may still show the
|
||||||
|
# cursor character because fallback mode was entered after an
|
||||||
|
# edit failure left it stuck. Try one final edit to strip it
|
||||||
|
# so the message doesn't freeze with a visible ▉. Best-effort
|
||||||
|
# — if this edit also fails (flood control still active),
|
||||||
|
# _try_strip_cursor has already been called on fallback entry
|
||||||
|
# and the adaptive-backoff retries will have had their shot.
|
||||||
|
if (
|
||||||
|
self._message_id
|
||||||
|
and self._last_sent_text
|
||||||
|
and self.cfg.cursor
|
||||||
|
and self._last_sent_text.endswith(self.cfg.cursor)
|
||||||
|
):
|
||||||
|
clean_text = self._last_sent_text[:-len(self.cfg.cursor)]
|
||||||
|
try:
|
||||||
|
result = await self.adapter.edit_message(
|
||||||
|
chat_id=self.chat_id,
|
||||||
|
message_id=self._message_id,
|
||||||
|
content=clean_text,
|
||||||
|
)
|
||||||
|
if result.success:
|
||||||
|
self._last_sent_text = clean_text
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
self._already_sent = True
|
self._already_sent = True
|
||||||
self._final_response_sent = True
|
self._final_response_sent = True
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -1216,3 +1216,87 @@ class TestBufferOnlyMode:
|
||||||
# text, the consumer may send then edit, or just send once at got_done.
|
# text, the consumer may send then edit, or just send once at got_done.
|
||||||
# The key assertion: this doesn't break.
|
# The key assertion: this doesn't break.
|
||||||
assert adapter.send.call_count >= 1
|
assert adapter.send.call_count >= 1
|
||||||
|
|
||||||
|
|
||||||
|
# ── Cursor stripping on fallback (#7183) ────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestCursorStrippingOnFallback:
|
||||||
|
"""Regression: cursor must be stripped when fallback continuation is empty (#7183).
|
||||||
|
|
||||||
|
When _send_fallback_final is called with nothing new to deliver (the visible
|
||||||
|
partial already matches final_text), the last edit may still show the cursor
|
||||||
|
character because fallback mode was entered after a failed edit. Before the
|
||||||
|
fix this would leave the message permanently frozen with a visible ▉.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cursor_stripped_when_continuation_empty(self):
|
||||||
|
"""_send_fallback_final must attempt a final edit to strip the cursor."""
|
||||||
|
adapter = MagicMock()
|
||||||
|
adapter.MAX_MESSAGE_LENGTH = 4096
|
||||||
|
adapter.edit_message = AsyncMock(
|
||||||
|
return_value=SimpleNamespace(success=True, message_id="msg-1")
|
||||||
|
)
|
||||||
|
|
||||||
|
consumer = GatewayStreamConsumer(
|
||||||
|
adapter, "chat-1",
|
||||||
|
config=StreamConsumerConfig(cursor=" ▉"),
|
||||||
|
)
|
||||||
|
consumer._message_id = "msg-1"
|
||||||
|
consumer._last_sent_text = "Hello world ▉"
|
||||||
|
consumer._fallback_final_send = False
|
||||||
|
|
||||||
|
await consumer._send_fallback_final("Hello world")
|
||||||
|
|
||||||
|
adapter.edit_message.assert_called_once()
|
||||||
|
call_args = adapter.edit_message.call_args
|
||||||
|
assert call_args.kwargs["content"] == "Hello world"
|
||||||
|
assert consumer._already_sent is True
|
||||||
|
# _last_sent_text should reflect the cleaned text after a successful strip
|
||||||
|
assert consumer._last_sent_text == "Hello world"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cursor_not_stripped_when_no_cursor_configured(self):
|
||||||
|
"""No edit attempted when cursor is not configured."""
|
||||||
|
adapter = MagicMock()
|
||||||
|
adapter.MAX_MESSAGE_LENGTH = 4096
|
||||||
|
adapter.edit_message = AsyncMock()
|
||||||
|
|
||||||
|
consumer = GatewayStreamConsumer(
|
||||||
|
adapter, "chat-1",
|
||||||
|
config=StreamConsumerConfig(cursor=""),
|
||||||
|
)
|
||||||
|
consumer._message_id = "msg-1"
|
||||||
|
consumer._last_sent_text = "Hello world"
|
||||||
|
consumer._fallback_final_send = False
|
||||||
|
|
||||||
|
await consumer._send_fallback_final("Hello world")
|
||||||
|
|
||||||
|
adapter.edit_message.assert_not_called()
|
||||||
|
assert consumer._already_sent is True
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cursor_strip_edit_failure_handled(self):
|
||||||
|
"""If the cursor-stripping edit itself fails, it must not crash and
|
||||||
|
must not corrupt _last_sent_text."""
|
||||||
|
adapter = MagicMock()
|
||||||
|
adapter.MAX_MESSAGE_LENGTH = 4096
|
||||||
|
adapter.edit_message = AsyncMock(
|
||||||
|
return_value=SimpleNamespace(success=False, error="flood_control")
|
||||||
|
)
|
||||||
|
|
||||||
|
consumer = GatewayStreamConsumer(
|
||||||
|
adapter, "chat-1",
|
||||||
|
config=StreamConsumerConfig(cursor=" ▉"),
|
||||||
|
)
|
||||||
|
consumer._message_id = "msg-1"
|
||||||
|
consumer._last_sent_text = "Hello ▉"
|
||||||
|
consumer._fallback_final_send = False
|
||||||
|
|
||||||
|
await consumer._send_fallback_final("Hello")
|
||||||
|
|
||||||
|
# Should still set already_sent despite the cursor-strip edit failure
|
||||||
|
assert consumer._already_sent is True
|
||||||
|
# _last_sent_text must NOT be updated when the edit failed
|
||||||
|
assert consumer._last_sent_text == "Hello ▉"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue