fix(signal): send explicit stop-typing RPC when cancelling indicator

This commit is contained in:
Kailigithub 2026-06-20 16:23:41 +05:30 committed by kshitijk4poor
parent 96b10327b6
commit 40b6ac9ac7
2 changed files with 133 additions and 2 deletions

View file

@ -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)

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
# ---------------------------------------------------------------------------