Merge pull request #49583 from NousResearch/salvage/signal-mention-typing

fix(signal): salvage self-mention strip + explicit stop-typing RPC (batch of #31217, #40054)
This commit is contained in:
kshitij 2026-06-20 16:32:30 +05:30 committed by GitHub
commit b88d0007c9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 155 additions and 2 deletions

View file

@ -621,6 +621,27 @@ class SignalAdapter(BasePlatformAdapter):
)
return
# Strip the bot's own @mention from any group message so the agent
# doesn't misinterpret "@+155****4567 say hello" as a directive to
# contact that phone number. _render_mentions replaces the Signal
# placeholder with @<number-or-uuid>, which looks like an
# addressee to the LLM rather than a self-reference. Applies to every
# group (not just require_mention groups) so the self-mention is
# cleaned wherever it appears.
if is_group and text:
account_norm = self._account_normalized
if account_norm:
text = text.replace(f"@{account_norm}", "")
# Also strip if the mention was rendered using the bot's UUID
bot_uuid = self._recipient_uuid_by_number.get(account_norm)
if bot_uuid:
text = text.replace(f"@{bot_uuid}", "")
# Tidy the spacing the removed mention left behind: collapse the
# double-space at a mid-sentence removal and trim the ends.
# Only touches the doubled space the removal introduced, so
# intentional newlines in a multi-line message are preserved.
text = text.replace(" ", " ").strip()
# Extract quote (reply-to) context from Signal dataMessage. Signal's
# quote.id is the timestamp of the quoted message; quote.author points
# at the quoted sender when available. Preserve both so the gateway can
@ -1460,8 +1481,29 @@ class SignalAdapter(BasePlatformAdapter):
await task
except asyncio.CancelledError:
pass
# Reset per-chat typing backoff state so the next agent turn starts
# fresh rather than inheriting a cooldown from a prior conversation.
# Send an explicit stop-typing RPC so the recipient's device drops the
# indicator immediately instead of waiting for Signal's ~5s built-in
# timeout. Failures are best-effort — the backoff state must still be
# cleared so the next agent turn starts clean.
try:
params: Dict[str, Any] = {"account": self.account}
if chat_id.startswith("group:"):
params["groupId"] = chat_id[6:]
else:
params["recipient"] = [await self._resolve_recipient(chat_id)]
params["stop"] = True
await self._rpc(
"sendTyping",
params,
rpc_id="typing-stop",
log_failures=False,
)
except Exception:
# Best-effort: any RPC failure (or recipient-resolution failure)
# must not prevent backoff cleanup.
pass
self._typing_failures.pop(chat_id, None)
self._typing_skip_until.pop(chat_id, None)

View file

@ -45,6 +45,7 @@ ACP_REGISTRY_MANIFEST = REPO_ROOT / "acp_registry" / "agent.json"
# Auto-extracted from noreply emails + manual overrides
AUTHOR_MAP = {
"rratmansky@gmail.com": "rratmansky",
"lkz-de@users.noreply.github.com": "lkz-de",
"charles@salesondemand.io": "salesondemandio",
"victor@rocketfueldev.com": "victor-kyriazakos",

View file

@ -1353,6 +1353,116 @@ class TestSignalTypingBackoff:
assert "+155****4567" not in adapter._typing_skip_until
# ---------------------------------------------------------------------------
# _stop_typing_indicator sends explicit sendTyping(stop=True) RPC
# ---------------------------------------------------------------------------
class TestSignalStopTypingExplicitRPC:
"""Cancelling the typing indicator must issue an explicit
sendTyping(stop=True) RPC so the recipient's device drops the indicator
immediately, instead of waiting for Signal's built-in ~5s timeout.
The stop RPC is best-effort: any failure must not prevent the per-chat
backoff state from being cleared.
"""
@pytest.mark.asyncio
async def test_stop_typing_indicator_sends_stop_rpc_for_dm(self, monkeypatch):
adapter = _make_signal_adapter(monkeypatch)
adapter._resolve_recipient = AsyncMock(return_value="uuid-recipient")
captured = []
async def mock_rpc(method, params, rpc_id=None, **kwargs):
captured.append({"method": method, "params": dict(params), "rpc_id": rpc_id})
return {}
adapter._rpc = mock_rpc
await adapter._stop_typing_indicator("+15555550000")
assert len(captured) == 1
assert captured[0]["method"] == "sendTyping"
assert captured[0]["params"]["stop"] is True
assert captured[0]["params"]["recipient"] == ["uuid-recipient"]
assert captured[0]["rpc_id"] == "typing-stop"
adapter._resolve_recipient.assert_awaited_once_with("+15555550000")
@pytest.mark.asyncio
async def test_stop_typing_indicator_sends_stop_rpc_for_group(self, monkeypatch):
adapter = _make_signal_adapter(monkeypatch)
captured = []
async def mock_rpc(method, params, rpc_id=None, **kwargs):
captured.append({"method": method, "params": dict(params), "rpc_id": rpc_id})
return {}
adapter._rpc = mock_rpc
await adapter._stop_typing_indicator("group:group123")
assert len(captured) == 1
assert captured[0]["method"] == "sendTyping"
assert captured[0]["params"]["stop"] is True
assert captured[0]["params"]["groupId"] == "group123"
assert "recipient" not in captured[0]["params"]
@pytest.mark.asyncio
async def test_stop_typing_indicator_best_effort_on_rpc_failure(self, monkeypatch):
adapter = _make_signal_adapter(monkeypatch)
adapter._resolve_recipient = AsyncMock(return_value="uuid-recipient")
# Drive the chat into backoff so we can confirm cleanup still happens
# even when the stop RPC itself fails.
async def _noop(method, params, rpc_id=None, **kwargs):
return None
adapter._rpc = _noop
for _ in range(3):
await adapter.send_typing("+155****0000")
assert adapter._typing_failures.get("+155****0000") == 3
assert "+155****0000" in adapter._typing_skip_until
# Now make the stop RPC raise — backoff state must still be cleared.
async def failing_rpc(method, params, rpc_id=None, **kwargs):
raise RuntimeError("signal-cli unreachable")
adapter._rpc = failing_rpc
await adapter._stop_typing_indicator("+155****0000")
assert "+155****0000" not in adapter._typing_failures
assert "+155****0000" not in adapter._typing_skip_until
@pytest.mark.asyncio
async def test_stop_typing_indicator_best_effort_on_recipient_failure(self, monkeypatch):
# When _resolve_recipient() raises, the per-chat backoff state must
# still be cleared — otherwise a transient resolution failure would
# silently keep the chat in cooldown forever.
adapter = _make_signal_adapter(monkeypatch)
adapter._resolve_recipient = AsyncMock(
side_effect=RuntimeError("recipient resolution failed")
)
captured = []
async def mock_rpc(method, params, rpc_id=None, **kwargs):
captured.append({"method": method, "params": dict(params), "rpc_id": rpc_id})
return {}
adapter._rpc = mock_rpc
adapter._typing_failures["+155****0000"] = 2
adapter._typing_skip_until["+155****0000"] = 9999999999.0
await adapter._stop_typing_indicator("+155****0000")
# No RPC must be issued when recipient resolution itself fails.
assert captured == []
assert "+155****0000" not in adapter._typing_failures
assert "+155****0000" not in adapter._typing_skip_until
# ---------------------------------------------------------------------------
# Reply quote extraction
# ---------------------------------------------------------------------------