From 96b10327b663629ea7ee1bbd1b5c7d11079efb83 Mon Sep 17 00:00:00 2001 From: Rick Ratmansky Date: Sat, 20 Jun 2026 16:23:41 +0530 Subject: [PATCH 1/4] fix(signal): strip bot self-mention from group messages before agent dispatch --- gateway/platforms/signal.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/gateway/platforms/signal.py b/gateway/platforms/signal.py index 860f6468818..36a26649d37 100644 --- a/gateway/platforms/signal.py +++ b/gateway/platforms/signal.py @@ -620,6 +620,17 @@ class SignalAdapter(BasePlatformAdapter): "Signal: ignoring group message (require_mention=true, bot not mentioned)" ) return + # Strip the bot's own @mention from the message text 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 @, which looks like an + # addressee to the LLM rather than a self-reference. + if account_norm: + text = text.replace(f"@{account_norm}", "").strip() + # 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}", "").strip() # Extract quote (reply-to) context from Signal dataMessage. Signal's # quote.id is the timestamp of the quoted message; quote.author points From 40b6ac9ac73b68c9e8133df9bc31d70cc83e172e Mon Sep 17 00:00:00 2001 From: Kailigithub <12250313+Kailigithub@users.noreply.github.com> Date: Sat, 20 Jun 2026 16:23:41 +0530 Subject: [PATCH 2/4] fix(signal): send explicit stop-typing RPC when cancelling indicator --- gateway/platforms/signal.py | 25 +++++++- tests/gateway/test_signal.py | 110 +++++++++++++++++++++++++++++++++++ 2 files changed, 133 insertions(+), 2 deletions(-) diff --git a/gateway/platforms/signal.py b/gateway/platforms/signal.py index 36a26649d37..790c4ef8934 100644 --- a/gateway/platforms/signal.py +++ b/gateway/platforms/signal.py @@ -1471,8 +1471,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) diff --git a/tests/gateway/test_signal.py b/tests/gateway/test_signal.py index 5a3d8c6b738..5657f49156a 100644 --- a/tests/gateway/test_signal.py +++ b/tests/gateway/test_signal.py @@ -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 # --------------------------------------------------------------------------- From ef7e716930a2216eb971011443741c0dbd100aa5 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Sat, 20 Jun 2026 16:24:15 +0530 Subject: [PATCH 3/4] chore(release): map rratmansky contributor email to GitHub login --- scripts/release.py | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/release.py b/scripts/release.py index 767ee2c2416..2ae24e5b4b4 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -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", From 32a97a20af025a05621c4961c4bd7dbbe5af5299 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Sat, 20 Jun 2026 16:27:28 +0530 Subject: [PATCH 4/4] fix(signal): strip self-mention in all groups, not just require_mention Review follow-up on the salvaged self-mention strip (#31217): the original only stripped the bot's rendered @/@ self-mention inside the `require_mention=true` branch, so groups with require_mention=false still leaked it into the agent text. Hoist the strip to run for every group message (fixing the whole bug class), and collapse the doubled space a mid-sentence removal leaves while preserving intentional newlines. --- gateway/platforms/signal.py | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/gateway/platforms/signal.py b/gateway/platforms/signal.py index 790c4ef8934..1b41bc47444 100644 --- a/gateway/platforms/signal.py +++ b/gateway/platforms/signal.py @@ -620,17 +620,27 @@ class SignalAdapter(BasePlatformAdapter): "Signal: ignoring group message (require_mention=true, bot not mentioned)" ) return - # Strip the bot's own @mention from the message text 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 @, which looks like an - # addressee to the LLM rather than a self-reference. + + # 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 @, 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}", "").strip() + 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}", "").strip() + 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