From 2b3ac1302be1bede61d589ea190699314f717347 Mon Sep 17 00:00:00 2001 From: dorukardahan <35905596+dorukardahan@users.noreply.github.com> Date: Fri, 29 May 2026 13:42:04 +0300 Subject: [PATCH 1/7] feat(signal): support timestamp-based message edits --- gateway/platforms/signal.py | 104 ++++++++++++++++++---- tests/gateway/test_signal.py | 12 +-- tests/gateway/test_signal_format.py | 132 +++++++++++++++++++++++----- 3 files changed, 201 insertions(+), 47 deletions(-) diff --git a/gateway/platforms/signal.py b/gateway/platforms/signal.py index 45eef2a07426b..303ec8749c587 100644 --- a/gateway/platforms/signal.py +++ b/gateway/platforms/signal.py @@ -175,10 +175,9 @@ class SignalAdapter(BasePlatformAdapter): """Signal messenger adapter using signal-cli HTTP daemon.""" platform = Platform.SIGNAL - # Signal has no real edit API for already-sent messages. Mark it explicitly - # so streaming suppresses the visible cursor instead of leaving a stale tofu - # square behind in chat clients when edit attempts fail. - SUPPORTS_MESSAGE_EDITING = False + # signal-cli exposes Signal edit messages by reusing the ``send`` command + # with ``editTimestamp`` set to the original message timestamp. + SUPPORTS_MESSAGE_EDITING = True def __init__(self, config: PlatformConfig): super().__init__(config, Platform.SIGNAL) @@ -962,26 +961,22 @@ def format_message(self, content: str) -> str: # Our send() override bypasses this entirely. return content - # ------------------------------------------------------------------ - # Sending - # ------------------------------------------------------------------ - - async def send( + async def _build_send_params( self, chat_id: str, content: str, - reply_to: Optional[str] = None, - metadata: Optional[Dict[str, Any]] = None, - ) -> SendResult: - """Send a text message with native Signal formatting.""" - await self._stop_typing_indicator(chat_id) - + *, + edit_timestamp: Optional[int] = None, + ) -> Dict[str, Any]: + """Build signal-cli JSON-RPC ``send`` params for text sends/edits.""" plain_text, text_styles = self._markdown_to_signal(content) params: Dict[str, Any] = { "account": self.account, "message": plain_text, } + if edit_timestamp is not None: + params["editTimestamp"] = edit_timestamp if text_styles: if len(text_styles) == 1: @@ -994,16 +989,87 @@ async def send( else: params["recipient"] = [await self._resolve_recipient(chat_id)] + return params + + @staticmethod + def _extract_send_timestamp(rpc_result: Any) -> Optional[str]: + """Return signal-cli's send timestamp as an editable message id.""" + if isinstance(rpc_result, dict): + timestamp = rpc_result.get("timestamp") + else: + timestamp = None + if timestamp is None or timestamp == "": + return None + return str(timestamp) + + # ------------------------------------------------------------------ + # Sending + # ------------------------------------------------------------------ + + async def send( + self, + chat_id: str, + content: str, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + """Send a text message with native Signal formatting.""" + await self._stop_typing_indicator(chat_id) + + params = await self._build_send_params(chat_id, content) result = await self._rpc("send", params) if result is not None: self._track_sent_timestamp(result) - # Signal has no editable message identifier. Returning None keeps the - # stream consumer on the non-edit fallback path instead of pretending - # future edits can remove an in-progress cursor from the chat thread. - return SendResult(success=True, message_id=None) + return SendResult( + success=True, + message_id=self._extract_send_timestamp(result), + ) return SendResult(success=False, error="RPC send failed") + async def edit_message( + self, + chat_id: str, + message_id: str, + content: str, + *, + finalize: bool = False, + ) -> SendResult: + """Edit a previously sent Signal message via signal-cli editTimestamp. + + signal-cli's JSON-RPC API mirrors the CLI's ``send --edit-timestamp`` + option: edits are sent through the ``send`` method with + ``editTimestamp`` set to the original message timestamp. Hermes stores + that timestamp as ``message_id`` when ``send()`` succeeds. + """ + del finalize # Signal edits have no explicit streaming finalization. + + if not message_id: + return SendResult(success=False, error="Signal edit requires message_id timestamp") + + try: + edit_timestamp = int(str(message_id)) + except (TypeError, ValueError): + return SendResult( + success=False, + error="Signal edit requires numeric message_id timestamp", + ) + + await self._stop_typing_indicator(chat_id) + params = await self._build_send_params( + chat_id, + content, + edit_timestamp=edit_timestamp, + ) + result = await self._rpc("send", params) + + if result is not None: + self._track_sent_timestamp(result) + # Keep returning the original timestamp so subsequent edits target + # the same Signal message rather than the edit event's timestamp. + return SendResult(success=True, message_id=str(message_id)) + return SendResult(success=False, error="RPC edit failed") + def _track_sent_timestamp(self, rpc_result) -> None: """Record outbound message timestamp for echo-back filtering.""" ts = rpc_result.get("timestamp") if isinstance(rpc_result, dict) else None diff --git a/tests/gateway/test_signal.py b/tests/gateway/test_signal.py index a5e225b759b05..fdc9c369eaf37 100644 --- a/tests/gateway/test_signal.py +++ b/tests/gateway/test_signal.py @@ -814,19 +814,19 @@ async def test_send_document_error_includes_path(self, monkeypatch): # --------------------------------------------------------------------------- class TestSignalStreamingCapabilities: - """Signal must opt out of edit-based streaming behavior.""" + """Signal uses timestamp-based edit messages for streaming/tool progress.""" - def test_signal_declares_no_message_editing(self, monkeypatch): + def test_signal_declares_message_editing(self, monkeypatch): adapter = _make_signal_adapter(monkeypatch) - assert adapter.SUPPORTS_MESSAGE_EDITING is False + assert adapter.SUPPORTS_MESSAGE_EDITING is True class TestSignalSendReturnsMessageId: - """Signal send() should not pretend sent messages are editable.""" + """Signal send() returns the signal-cli timestamp as message_id when present.""" @pytest.mark.asyncio - async def test_send_returns_none_message_id_even_with_timestamp(self, monkeypatch): + async def test_send_returns_timestamp_message_id(self, monkeypatch): adapter = _make_signal_adapter(monkeypatch) mock_rpc, _ = _stub_rpc({"timestamp": 1712345678000}) adapter._rpc = mock_rpc @@ -835,7 +835,7 @@ async def test_send_returns_none_message_id_even_with_timestamp(self, monkeypatc result = await adapter.send(chat_id="+155****4567", content="hello") assert result.success is True - assert result.message_id is None + assert result.message_id == "1712345678000" @pytest.mark.asyncio async def test_send_returns_none_message_id_when_no_timestamp(self, monkeypatch): diff --git a/tests/gateway/test_signal_format.py b/tests/gateway/test_signal_format.py index 0050a980f59a8..de971da672578 100644 --- a/tests/gateway/test_signal_format.py +++ b/tests/gateway/test_signal_format.py @@ -413,39 +413,127 @@ def test_empty_bold_not_crash(self): # =========================================================================== class TestSignalStreamingPatch: - """Tests for signal-streaming-patch: cursor suppression and edit support. - - These verify the adapter-level properties that prevent the streaming - cursor from leaking into Signal messages. - """ + """Tests for Signal send/edit behavior used by streaming/tool progress.""" - def test_signal_does_not_support_editing(self, monkeypatch): - """SignalAdapter.SUPPORTS_MESSAGE_EDITING must be False.""" - monkeypatch.setenv("SIGNAL_GROUP_ALLOWED_USERS", "") - from gateway.platforms.signal import SignalAdapter - assert SignalAdapter.SUPPORTS_MESSAGE_EDITING is False - - @pytest.mark.asyncio - async def test_send_returns_no_message_id(self, monkeypatch): - """send() returns message_id=None so stream consumer uses no-edit path.""" + def _adapter(self, monkeypatch): monkeypatch.setenv("SIGNAL_GROUP_ALLOWED_USERS", "") - from gateway.platforms.signal import SignalAdapter - config = PlatformConfig(enabled=True) config.extra = { "http_url": "http://localhost:8080", - "account": "+15551234567", + "account": "+155****4567", } - adapter = SignalAdapter(config) + return SignalAdapter(config) + + def test_signal_supports_message_editing(self, monkeypatch): + """SignalAdapter advertises edit support once edit_message is implemented.""" + monkeypatch.setenv("SIGNAL_GROUP_ALLOWED_USERS", "") + assert SignalAdapter.SUPPORTS_MESSAGE_EDITING is True + + @pytest.mark.asyncio + async def test_send_returns_signal_timestamp_as_message_id(self, monkeypatch): + """send() returns signal-cli's send timestamp so later edits can target it.""" + adapter = self._adapter(monkeypatch) + calls = [] - # Mock the RPC call - async def mock_rpc(method, params, rpc_id=None): + async def mock_rpc(method, params, rpc_id=None, **kwargs): + calls.append((method, params)) return {"timestamp": 1234567890} adapter._rpc = mock_rpc result = await adapter.send( - chat_id="+15559876543", + chat_id="recipient-service-id", content="Hello", ) - assert result.message_id is None + + assert result.success is True + assert result.message_id == "1234567890" + assert calls == [( + "send", + { + "account": "+155****4567", + "message": "Hello", + "recipient": ["recipient-service-id"], + }, + )] + + @pytest.mark.asyncio + async def test_edit_message_sends_dm_edit_timestamp_with_signal_formatting(self, monkeypatch): + """DM edits use JSON-RPC send + editTimestamp and preserve bodyRanges.""" + adapter = self._adapter(monkeypatch) + calls = [] + + async def mock_rpc(method, params, rpc_id=None, **kwargs): + calls.append((method, params)) + return {"timestamp": 1234567999} + + adapter._rpc = mock_rpc + + result = await adapter.edit_message( + chat_id="recipient-service-id", + message_id="1234567890", + content="Hello **world**", + finalize=True, + ) + + assert result.success is True + assert result.message_id == "1234567890" + method, params = calls[0] + assert method == "send" + assert params["account"] == "+155****4567" + assert params["message"] == "Hello world" + assert params["recipient"] == ["recipient-service-id"] + assert params["editTimestamp"] == 1234567890 + assert params["textStyle"].endswith(":BOLD") + + @pytest.mark.asyncio + async def test_edit_message_sends_group_edit_timestamp(self, monkeypatch): + """Group edits route by groupId and never include a DM recipient.""" + adapter = self._adapter(monkeypatch) + calls = [] + + async def mock_rpc(method, params, rpc_id=None, **kwargs): + calls.append((method, params)) + return {"timestamp": 1234567999} + + adapter._rpc = mock_rpc + + result = await adapter.edit_message( + chat_id="group:BASE64_GROUP_ID", + message_id="1234567890", + content="group update", + ) + + assert result.success is True + assert result.message_id == "1234567890" + assert calls == [( + "send", + { + "account": "+155****4567", + "message": "group update", + "editTimestamp": 1234567890, + "groupId": "BASE64_GROUP_ID", + }, + )] + + @pytest.mark.asyncio + async def test_edit_message_requires_message_id(self, monkeypatch): + """Signal edits need the original send timestamp as message_id.""" + adapter = self._adapter(monkeypatch) + calls = [] + + async def mock_rpc(method, params, rpc_id=None, **kwargs): + calls.append((method, params)) + return {"timestamp": 1234567999} + + adapter._rpc = mock_rpc + + result = await adapter.edit_message( + chat_id="recipient-service-id", + message_id="", + content="cannot edit", + ) + + assert result.success is False + assert "message_id" in result.error + assert calls == [] From a2ef10273453ba92d369974d056d5dc2403491dc Mon Sep 17 00:00:00 2001 From: dorukardahan <35905596+dorukardahan@users.noreply.github.com> Date: Mon, 6 Jul 2026 02:14:37 +0300 Subject: [PATCH 2/7] fix(signal): gate streaming edits and propagate edit timestamps --- gateway/platforms/base.py | 12 +++++ gateway/platforms/signal.py | 64 ++++++++++++++++++++------- gateway/run.py | 18 +++++++- gateway/stream_consumer.py | 15 +++++-- tests/gateway/test_signal.py | 64 +++++++++++++++++++++++++-- tests/gateway/test_signal_format.py | 27 +++++------ tests/gateway/test_stream_consumer.py | 28 ++++++++++++ 7 files changed, 191 insertions(+), 37 deletions(-) diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index 1025964dc43bc..03a373f650911 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -2287,6 +2287,18 @@ class BasePlatformAdapter(ABC): # set this to False to stay correct-by-default. supports_async_delivery: bool = True + # Whether this adapter supports explicit ``edit_message`` calls for + # previously sent messages. Most chat platforms support this; non-editable + # adapters override to False so callers can avoid pretending a message can + # be mutated later. + SUPPORTS_MESSAGE_EDITING: bool = True + + # Whether edit-based high-frequency response streaming/tool-progress is + # safe. Defaults to SUPPORTS_MESSAGE_EDITING via gateway.run's helper; set + # explicitly when a platform can handle operator/user-requested edits but + # should not be driven by the stream consumer cadence. + SUPPORTS_STREAMING_EDITS: Optional[bool] = None + # Whether this adapter's ``send()`` splits long content into multiple # messages via ``truncate_message()``. When True, the delivery router # (gateway/delivery.py) skips gateway-level truncation and lets the diff --git a/gateway/platforms/signal.py b/gateway/platforms/signal.py index 8ca7ab1494dd8..3b1629499067c 100644 --- a/gateway/platforms/signal.py +++ b/gateway/platforms/signal.py @@ -248,9 +248,12 @@ class SignalAdapter(BasePlatformAdapter): """Signal messenger adapter using signal-cli HTTP daemon.""" platform = Platform.SIGNAL - # signal-cli exposes Signal edit messages by reusing the ``send`` command - # with ``editTimestamp`` set to the original message timestamp. + # signal-cli exposes explicit message edits via send(editTimestamp=...). + # Keep high-frequency gateway streaming/tool-progress edits disabled: real + # clients surface each Signal edit as a visible edit event, so explicit edits + # are safe while stream-consumer cadence remains too noisy. SUPPORTS_MESSAGE_EDITING = True + SUPPORTS_STREAMING_EDITS = False def __init__(self, config: PlatformConfig): super().__init__(config, Platform.SIGNAL) @@ -1043,22 +1046,22 @@ def _validate_send_result(self, result: Any) -> tuple[bool, Optional[str]]: # Sending # ------------------------------------------------------------------ - async def send( + async def _build_send_params( self, chat_id: str, content: str, - reply_to: Optional[str] = None, - metadata: Optional[Dict[str, Any]] = None, - ) -> SendResult: - """Send a text message with native Signal formatting.""" - await self._stop_typing_indicator(chat_id) - + *, + edit_timestamp: Optional[int] = None, + ) -> Dict[str, Any]: + """Build signal-cli JSON-RPC ``send`` params for text sends/edits.""" plain_text, text_styles = self._markdown_to_signal(content) params: Dict[str, Any] = { "account": self.account, "message": plain_text, } + if edit_timestamp is not None: + params["editTimestamp"] = edit_timestamp if text_styles: if len(text_styles) == 1: @@ -1071,7 +1074,31 @@ async def send( else: params["recipient"] = [await self._resolve_recipient(chat_id)] - logger.info("[Signal] Sending response (%d chars) to %s", len(plain_text), chat_id) + return params + + @staticmethod + def _extract_send_timestamp(rpc_result: Any) -> Optional[str]: + """Return signal-cli's send timestamp as an editable message id.""" + if isinstance(rpc_result, dict): + timestamp = rpc_result.get("timestamp") + else: + timestamp = None + if timestamp is None or timestamp == "": + return None + return str(timestamp) + + async def send( + self, + chat_id: str, + content: str, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + """Send a text message with native Signal formatting.""" + await self._stop_typing_indicator(chat_id) + + params = await self._build_send_params(chat_id, content) + logger.info("[Signal] Sending response (%d chars) to %s", len(params["message"]), chat_id) result = await self._rpc("send", params) if result is not None: @@ -1096,9 +1123,10 @@ async def edit_message( """Edit a previously sent Signal message via signal-cli editTimestamp. signal-cli's JSON-RPC API mirrors the CLI's ``send --edit-timestamp`` - option: edits are sent through the ``send`` method with - ``editTimestamp`` set to the original message timestamp. Hermes stores - that timestamp as ``message_id`` when ``send()`` succeeds. + option: edits are sent through ``send`` with ``editTimestamp`` set to + the current Signal timestamp stored as Hermes' ``message_id``. + signal-cli returns a new timestamp for the edit event; propagate that + fresh id so a chain of later edits targets the newest edit handle. """ del finalize # Signal edits have no explicit streaming finalization. @@ -1122,10 +1150,14 @@ async def edit_message( result = await self._rpc("send", params) if result is not None: + success, err_msg = self._validate_send_result(result) + if not success: + return SendResult(success=False, error=err_msg, raw_response=result) self._track_sent_timestamp(result) - # Keep returning the original timestamp so subsequent edits target - # the same Signal message rather than the edit event's timestamp. - return SendResult(success=True, message_id=str(message_id)) + return SendResult( + success=True, + message_id=self._extract_send_timestamp(result) or str(message_id), + ) return SendResult(success=False, error="RPC edit failed") def _track_sent_timestamp(self, rpc_result) -> None: diff --git a/gateway/run.py b/gateway/run.py index b81e87dba641f..d63899b21a80d 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -490,6 +490,20 @@ def _resolve_progress_thread_id(platform: Any, source_thread_id: Any, event_mess return None +def _adapter_supports_streaming_edits(adapter: Any) -> bool: + """Return whether adapter edits are safe for high-frequency streaming. + + SUPPORTS_MESSAGE_EDITING means explicit user/operator edits are possible. + Streaming is a narrower capability: some platforms expose an edit API but + make every edit a visible high-frequency event, so they should opt out of + response-stream/progress editing while keeping explicit edit_message(). + """ + streaming_capability = getattr(adapter, "SUPPORTS_STREAMING_EDITS", None) + if streaming_capability is not None: + return bool(streaming_capability) + return bool(getattr(adapter, "SUPPORTS_MESSAGE_EDITING", True)) + + def _has_platform_display_override(user_config: dict, platform_key: str, setting: str) -> bool: """Return True when display.platforms. explicitly sets setting.""" display = user_config.get("display") if isinstance(user_config, dict) else None @@ -16216,7 +16230,7 @@ def _pause_typing_before_finalize( _chat_id=source.chat_id, ) -> None: _adapter.pause_typing_for_chat(_chat_id) - _adapter_supports_edit = getattr(_adapter, "SUPPORTS_MESSAGE_EDITING", True) + _adapter_supports_edit = _adapter_supports_streaming_edits(_adapter) _effective_cursor = _scfg.cursor if _adapter_supports_edit else "" _buffer_only = False if source.platform == Platform.MATRIX: @@ -17544,7 +17558,7 @@ def _pause_typing_before_finalize( # without edit support, the consumer sends a partial # first message that can never be updated, resulting in # duplicate messages (partial + final). - _adapter_supports_edit = getattr(_adapter, "SUPPORTS_MESSAGE_EDITING", True) + _adapter_supports_edit = _adapter_supports_streaming_edits(_adapter) if not _adapter_supports_edit: raise RuntimeError("skip streaming for non-editable platform") _effective_cursor = _scfg.cursor diff --git a/gateway/stream_consumer.py b/gateway/stream_consumer.py index a08e169f2f994..743c82f43c5b5 100644 --- a/gateway/stream_consumer.py +++ b/gateway/stream_consumer.py @@ -1634,9 +1634,11 @@ async def _send_or_edit( ) if result.success: self._already_sent = True + _current_message_id = self._message_id # Record any continuation fragments an oversized edit # split off, so fresh-final can clean them all up. self._track_preview_ids_from_result(result) + _new_message_id = getattr(result, "message_id", None) # Adapter may have split-and-delivered an oversized # edit across the original message + N continuations. # When that happens, ``message_id`` is the LAST visible @@ -1651,15 +1653,22 @@ async def _send_or_edit( _continuation_ids = getattr(result, "continuation_message_ids", ()) or () if ( _continuation_ids - and result.message_id - and result.message_id != self._message_id + and _new_message_id + and str(_new_message_id) != str(_current_message_id) ): self._last_edit_overflowed = True - self._message_id = str(result.message_id) + self._message_id = str(_new_message_id) self._message_created_ts = time.monotonic() self._last_sent_text = "" self._notify_new_message() else: + # Some edit APIs return a fresh identifier/timestamp + # for the same visible message after every edit. + # Adopt it so a later explicit edit/finalize targets + # the newest platform handle instead of a stale one. + if _new_message_id and str(_new_message_id) != str(_current_message_id): + self._message_id = str(_new_message_id) + self._message_created_ts = time.monotonic() self._last_sent_text = text # Successful edit — reset flood strike counter self._flood_strikes = 0 diff --git a/tests/gateway/test_signal.py b/tests/gateway/test_signal.py index 13d4ab05601fd..af5a6dac9372f 100644 --- a/tests/gateway/test_signal.py +++ b/tests/gateway/test_signal.py @@ -1059,16 +1059,36 @@ async def test_send_document_error_includes_path(self, monkeypatch): # --------------------------------------------------------------------------- class TestSignalStreamingCapabilities: - """Signal uses timestamp-based edit messages for streaming/tool progress.""" + """Signal supports explicit edits but opts out of streaming edit cadence.""" - def test_signal_declares_message_editing(self, monkeypatch): + def test_signal_declares_explicit_message_editing_only(self, monkeypatch): adapter = _make_signal_adapter(monkeypatch) assert adapter.SUPPORTS_MESSAGE_EDITING is True + assert adapter.SUPPORTS_STREAMING_EDITS is False + + def test_gateway_streaming_capability_uses_narrow_flag(self, monkeypatch): + from gateway.run import _adapter_supports_streaming_edits + + adapter = _make_signal_adapter(monkeypatch) + + assert _adapter_supports_streaming_edits(adapter) is False + + def test_streaming_capability_falls_back_to_message_editing(self): + from gateway.run import _adapter_supports_streaming_edits + + class EditableAdapter: + SUPPORTS_MESSAGE_EDITING = True + + class NonEditableAdapter: + SUPPORTS_MESSAGE_EDITING = False + + assert _adapter_supports_streaming_edits(EditableAdapter()) is True + assert _adapter_supports_streaming_edits(NonEditableAdapter()) is False class TestSignalSendReturnsMessageId: - """Signal send() returns the signal-cli timestamp as message_id when present.""" + """Signal send() returns signal-cli timestamps when available.""" @pytest.mark.asyncio async def test_send_returns_timestamp_message_id(self, monkeypatch): @@ -1106,6 +1126,44 @@ async def test_send_returns_none_message_id_for_non_dict(self, monkeypatch): assert result.success is True assert result.message_id is None + @pytest.mark.asyncio + async def test_edit_message_returns_fresh_signal_timestamp(self, monkeypatch): + adapter = _make_signal_adapter(monkeypatch) + mock_rpc, captured = _stub_rpc({"timestamp": 1712345679000}) + adapter._rpc = mock_rpc + adapter._stop_typing_indicator = AsyncMock() + + result = await adapter.edit_message( + chat_id="+155****4567", + message_id="1712345678000", + content="edited **hello**", + ) + + assert result.success is True + assert result.message_id == "1712345679000" + assert captured[0]["method"] == "send" + params = captured[0]["params"] + assert params["editTimestamp"] == 1712345678000 + assert params["message"] == "edited hello" + assert params["recipient"] == ["+155****4567"] + + @pytest.mark.asyncio + async def test_edit_message_requires_numeric_message_id(self, monkeypatch): + adapter = _make_signal_adapter(monkeypatch) + mock_rpc, captured = _stub_rpc({"timestamp": 1712345679000}) + adapter._rpc = mock_rpc + adapter._stop_typing_indicator = AsyncMock() + + result = await adapter.edit_message( + chat_id="+155****4567", + message_id="not-a-timestamp", + content="edited hello", + ) + + assert result.success is False + assert "numeric" in result.error + assert captured == [] + class TestSignalSendResultValidation: """Verify that send() validates recipient-level delivery results.""" diff --git a/tests/gateway/test_signal_format.py b/tests/gateway/test_signal_format.py index 247d4c34bb314..d006de682c185 100644 --- a/tests/gateway/test_signal_format.py +++ b/tests/gateway/test_signal_format.py @@ -436,11 +436,11 @@ def test_empty_bold_not_crash(self): # =========================================================================== -# signal-streaming-patch: SUPPORTS_MESSAGE_EDITING and send() behavior +# Signal explicit edit support / streaming capability split # =========================================================================== class TestSignalStreamingPatch: - """Tests for Signal send/edit behavior used by streaming/tool progress.""" + """Tests for Signal send/edit behavior and streaming guardrails.""" def _adapter(self, monkeypatch): monkeypatch.setenv("SIGNAL_GROUP_ALLOWED_USERS", "") @@ -451,10 +451,11 @@ def _adapter(self, monkeypatch): } return SignalAdapter(config) - def test_signal_supports_message_editing(self, monkeypatch): - """SignalAdapter advertises edit support once edit_message is implemented.""" + def test_signal_supports_explicit_edit_but_not_streaming_edits(self, monkeypatch): + """Explicit edit_message is separate from noisy stream-progress edits.""" monkeypatch.setenv("SIGNAL_GROUP_ALLOWED_USERS", "") assert SignalAdapter.SUPPORTS_MESSAGE_EDITING is True + assert SignalAdapter.SUPPORTS_STREAMING_EDITS is False @pytest.mark.asyncio async def test_send_returns_signal_timestamp_as_message_id(self, monkeypatch): @@ -462,7 +463,7 @@ async def test_send_returns_signal_timestamp_as_message_id(self, monkeypatch): adapter = self._adapter(monkeypatch) calls = [] - async def mock_rpc(method, params, rpc_id=None, **kwargs): + async def mock_rpc(method, params, rpc_id=None): calls.append((method, params)) return {"timestamp": 1234567890} @@ -485,12 +486,12 @@ async def mock_rpc(method, params, rpc_id=None, **kwargs): )] @pytest.mark.asyncio - async def test_edit_message_sends_dm_edit_timestamp_with_signal_formatting(self, monkeypatch): - """DM edits use JSON-RPC send + editTimestamp and preserve bodyRanges.""" + async def test_edit_message_sends_dm_edit_timestamp_and_returns_new_ts(self, monkeypatch): + """DM edits use JSON-RPC send + editTimestamp and propagate the fresh ts.""" adapter = self._adapter(monkeypatch) calls = [] - async def mock_rpc(method, params, rpc_id=None, **kwargs): + async def mock_rpc(method, params, rpc_id=None): calls.append((method, params)) return {"timestamp": 1234567999} @@ -504,7 +505,7 @@ async def mock_rpc(method, params, rpc_id=None, **kwargs): ) assert result.success is True - assert result.message_id == "1234567890" + assert result.message_id == "1234567999" method, params = calls[0] assert method == "send" assert params["account"] == "+155****4567" @@ -519,7 +520,7 @@ async def test_edit_message_sends_group_edit_timestamp(self, monkeypatch): adapter = self._adapter(monkeypatch) calls = [] - async def mock_rpc(method, params, rpc_id=None, **kwargs): + async def mock_rpc(method, params, rpc_id=None): calls.append((method, params)) return {"timestamp": 1234567999} @@ -532,7 +533,7 @@ async def mock_rpc(method, params, rpc_id=None, **kwargs): ) assert result.success is True - assert result.message_id == "1234567890" + assert result.message_id == "1234567999" assert calls == [( "send", { @@ -545,11 +546,11 @@ async def mock_rpc(method, params, rpc_id=None, **kwargs): @pytest.mark.asyncio async def test_edit_message_requires_message_id(self, monkeypatch): - """Signal edits need the original send timestamp as message_id.""" + """Signal edits need the current send/edit timestamp as message_id.""" adapter = self._adapter(monkeypatch) calls = [] - async def mock_rpc(method, params, rpc_id=None, **kwargs): + async def mock_rpc(method, params, rpc_id=None): calls.append((method, params)) return {"timestamp": 1234567999} diff --git a/tests/gateway/test_stream_consumer.py b/tests/gateway/test_stream_consumer.py index cd49d3d74782f..403a6e2896c2e 100644 --- a/tests/gateway/test_stream_consumer.py +++ b/tests/gateway/test_stream_consumer.py @@ -2363,3 +2363,31 @@ def test_flush_think_buffer_strips_orphan_close(self): assert tag not in consumer._accumulated assert "trailing prose" in consumer._accumulated assert "more" in consumer._accumulated + + +class TestEditMessageIdPropagation: + @pytest.mark.asyncio + async def test_successful_edit_adopts_fresh_message_id(self): + adapter = MagicMock() + adapter.MAX_MESSAGE_LENGTH = 4096 + adapter.REQUIRES_EDIT_FINALIZE = False + adapter.send = AsyncMock(return_value=SimpleNamespace( + success=True, + message_id="ts-1", + )) + adapter.edit_message = AsyncMock(side_effect=[ + SimpleNamespace(success=True, message_id="ts-2"), + SimpleNamespace(success=True, message_id="ts-3"), + ]) + + consumer = GatewayStreamConsumer(adapter, "chat_123") + + assert await consumer._send_or_edit("first") is True + assert consumer.message_id == "ts-1" + + assert await consumer._send_or_edit("second") is True + assert consumer.message_id == "ts-2" + + assert await consumer._send_or_edit("third") is True + assert consumer.message_id == "ts-3" + assert adapter.edit_message.call_args_list[1].kwargs["message_id"] == "ts-2" From bfbd727f8bf2ddf25528ebbd8a18c26c79d881e4 Mon Sep 17 00:00:00 2001 From: dorukardahan <35905596+dorukardahan@users.noreply.github.com> Date: Tue, 7 Jul 2026 05:14:09 +0300 Subject: [PATCH 3/7] fix(signal): gate progress edits separately --- gateway/platforms/base.py | 14 +++-- gateway/platforms/signal.py | 1 + gateway/run.py | 28 +++++++-- tests/gateway/test_run_progress_topics.py | 72 +++++++++++++++++++++++ tests/gateway/test_signal.py | 12 +++- 5 files changed, 117 insertions(+), 10 deletions(-) diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index 03a373f650911..b2fafe46700f8 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -2293,12 +2293,18 @@ class BasePlatformAdapter(ABC): # be mutated later. SUPPORTS_MESSAGE_EDITING: bool = True - # Whether edit-based high-frequency response streaming/tool-progress is - # safe. Defaults to SUPPORTS_MESSAGE_EDITING via gateway.run's helper; set - # explicitly when a platform can handle operator/user-requested edits but - # should not be driven by the stream consumer cadence. + # Whether edit-based high-frequency response streaming is safe. Defaults + # to SUPPORTS_MESSAGE_EDITING via gateway.run's helper; set explicitly when + # a platform can handle operator/user-requested edits but should not be + # driven by the stream consumer cadence. SUPPORTS_STREAMING_EDITS: Optional[bool] = None + # Whether edit-based tool/thinking progress bubbles are safe. Defaults to + # the streaming-edit capability via gateway.run's helper; adapters can set + # this separately if token streaming and progress bubbles have different + # platform costs. + SUPPORTS_PROGRESS_EDITS: Optional[bool] = None + # Whether this adapter's ``send()`` splits long content into multiple # messages via ``truncate_message()``. When True, the delivery router # (gateway/delivery.py) skips gateway-level truncation and lets the diff --git a/gateway/platforms/signal.py b/gateway/platforms/signal.py index 3b1629499067c..4ab7b898af095 100644 --- a/gateway/platforms/signal.py +++ b/gateway/platforms/signal.py @@ -254,6 +254,7 @@ class SignalAdapter(BasePlatformAdapter): # are safe while stream-consumer cadence remains too noisy. SUPPORTS_MESSAGE_EDITING = True SUPPORTS_STREAMING_EDITS = False + SUPPORTS_PROGRESS_EDITS = False def __init__(self, config: PlatformConfig): super().__init__(config, Platform.SIGNAL) diff --git a/gateway/run.py b/gateway/run.py index d63899b21a80d..dc1ab2df3a07e 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -496,7 +496,7 @@ def _adapter_supports_streaming_edits(adapter: Any) -> bool: SUPPORTS_MESSAGE_EDITING means explicit user/operator edits are possible. Streaming is a narrower capability: some platforms expose an edit API but make every edit a visible high-frequency event, so they should opt out of - response-stream/progress editing while keeping explicit edit_message(). + response-stream editing while keeping explicit edit_message(). """ streaming_capability = getattr(adapter, "SUPPORTS_STREAMING_EDITS", None) if streaming_capability is not None: @@ -504,6 +504,19 @@ def _adapter_supports_streaming_edits(adapter: Any) -> bool: return bool(getattr(adapter, "SUPPORTS_MESSAGE_EDITING", True)) +def _adapter_supports_progress_edits(adapter: Any) -> bool: + """Return whether adapter edits are safe for tool/thinking progress. + + Progress bubbles use the same high-frequency edit cadence as token + streaming. Platforms may expose explicit edit_message() for deliberate + user/operator edits while still opting out of automatic progress edits. + """ + progress_capability = getattr(adapter, "SUPPORTS_PROGRESS_EDITS", None) + if progress_capability is not None: + return bool(progress_capability) + return _adapter_supports_streaming_edits(adapter) + + def _has_platform_display_override(user_config: dict, platform_key: str, setting: str) -> bool: """Return True when display.platforms. explicitly sets setting.""" display = user_config.get("display") if isinstance(user_config, dict) else None @@ -17027,10 +17040,15 @@ async def send_progress_messages(): if not adapter: return - # Skip tool progress for platforms that don't support message - # editing (e.g. iMessage/BlueBubbles) — each progress update - # would become a separate message bubble, which is noisy. - if type(adapter).edit_message is BasePlatformAdapter.edit_message: + # Skip tool/thinking progress for platforms that cannot safely edit + # progress bubbles. Some adapters (Signal) support explicit + # edit_message() calls but opt out of high-frequency automatic + # progress edits because clients surface every edit or require + # timestamp-chained edit handles. + if ( + not _adapter_supports_progress_edits(adapter) + or type(adapter).edit_message is BasePlatformAdapter.edit_message + ): while not progress_queue.empty(): try: progress_queue.get_nowait() diff --git a/tests/gateway/test_run_progress_topics.py b/tests/gateway/test_run_progress_topics.py index 5c151c56ea9a1..3b693f1c5df41 100644 --- a/tests/gateway/test_run_progress_topics.py +++ b/tests/gateway/test_run_progress_topics.py @@ -122,6 +122,37 @@ async def edit_message(self, chat_id, message_id, content) -> SendResult: raise AssertionError("non-editable adapters should not receive edit_message calls") +class NoProgressEditCaptureAdapter(ProgressCaptureAdapter): + """Adapter that supports explicit edits but opts out of progress edits.""" + + SUPPORTS_MESSAGE_EDITING = True + SUPPORTS_STREAMING_EDITS = False + + def __init__(self): + super().__init__(platform=Platform.SIGNAL) + + async def send(self, chat_id, content, reply_to=None, metadata=None) -> SendResult: + self.sent.append( + { + "chat_id": chat_id, + "content": content, + "reply_to": reply_to, + "metadata": metadata, + } + ) + return SendResult(success=True, message_id="progress-1") + + async def edit_message(self, chat_id, message_id, content) -> SendResult: + self.edits.append( + { + "chat_id": chat_id, + "message_id": message_id, + "content": content, + } + ) + return SendResult(success=True, message_id="progress-2") + + class FakeAgent: def __init__(self, **kwargs): # Capture anything passed via kwargs (older code path) but don't @@ -270,6 +301,47 @@ def _make_runner(adapter): return runner +@pytest.mark.asyncio +async def test_run_agent_progress_respects_streaming_edit_capability(monkeypatch, tmp_path): + """Adapters that opt out of streaming/progress edits must stay quiet. + + Signal exposes explicit timestamp-based ``edit_message()`` for user/operator + edits, but high-frequency progress edits are noisy and timestamp-chained. + The progress sender should therefore honor the same narrow capability gate + as token streaming instead of checking only whether ``edit_message`` exists. + """ + monkeypatch.setenv("HERMES_TOOL_PROGRESS_MODE", "all") + + fake_dotenv = types.ModuleType("dotenv") + fake_dotenv.load_dotenv = lambda *args, **kwargs: None + monkeypatch.setitem(sys.modules, "dotenv", fake_dotenv) + + fake_run_agent = types.ModuleType("run_agent") + fake_run_agent.AIAgent = FakeAgent + monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent) + import tools.terminal_tool # noqa: F401 - register terminal emoji for this fake-agent test + + adapter = NoProgressEditCaptureAdapter() + runner = _make_runner(adapter) + gateway_run = importlib.import_module("gateway.run") + monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path) + monkeypatch.setattr(gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "fake"}) + source = SessionSource(platform=Platform.SIGNAL, chat_id="+15551234567", chat_type="dm") + + result = await runner._run_agent( + message="hello", + context_prompt="", + history=[], + source=source, + session_id="sess-signal-no-progress-edits", + session_key="agent:main:signal:dm:+15551234567", + ) + + assert result["final_response"] == "done" + assert adapter.sent == [] + assert adapter.edits == [] + + @pytest.mark.asyncio async def test_run_agent_progress_stays_in_originating_topic(monkeypatch, tmp_path): monkeypatch.setenv("HERMES_TOOL_PROGRESS_MODE", "all") diff --git a/tests/gateway/test_signal.py b/tests/gateway/test_signal.py index af5a6dac9372f..8f6573e3f44ce 100644 --- a/tests/gateway/test_signal.py +++ b/tests/gateway/test_signal.py @@ -1066,6 +1066,7 @@ def test_signal_declares_explicit_message_editing_only(self, monkeypatch): assert adapter.SUPPORTS_MESSAGE_EDITING is True assert adapter.SUPPORTS_STREAMING_EDITS is False + assert adapter.SUPPORTS_PROGRESS_EDITS is False def test_gateway_streaming_capability_uses_narrow_flag(self, monkeypatch): from gateway.run import _adapter_supports_streaming_edits @@ -1074,8 +1075,15 @@ def test_gateway_streaming_capability_uses_narrow_flag(self, monkeypatch): assert _adapter_supports_streaming_edits(adapter) is False + def test_gateway_progress_capability_uses_narrow_flag(self, monkeypatch): + from gateway.run import _adapter_supports_progress_edits + + adapter = _make_signal_adapter(monkeypatch) + + assert _adapter_supports_progress_edits(adapter) is False + def test_streaming_capability_falls_back_to_message_editing(self): - from gateway.run import _adapter_supports_streaming_edits + from gateway.run import _adapter_supports_progress_edits, _adapter_supports_streaming_edits class EditableAdapter: SUPPORTS_MESSAGE_EDITING = True @@ -1085,6 +1093,8 @@ class NonEditableAdapter: assert _adapter_supports_streaming_edits(EditableAdapter()) is True assert _adapter_supports_streaming_edits(NonEditableAdapter()) is False + assert _adapter_supports_progress_edits(EditableAdapter()) is True + assert _adapter_supports_progress_edits(NonEditableAdapter()) is False class TestSignalSendReturnsMessageId: From e07d052517b826a3f01a061343b06ff05517ad6e Mon Sep 17 00:00:00 2001 From: dorukardahan <35905596+dorukardahan@users.noreply.github.com> Date: Tue, 14 Jul 2026 08:58:55 +0300 Subject: [PATCH 4/7] fix(signal): scope chained edit targets --- gateway/platforms/base.py | 29 ++++++++ gateway/platforms/signal.py | 1 + gateway/run.py | 9 ++- gateway/stream_consumer.py | 17 +++-- tests/gateway/test_run_progress_topics.py | 91 +++++++++++++++++++++++ tests/gateway/test_signal.py | 35 +++++++++ tests/gateway/test_stream_consumer.py | 38 +++++++++- 7 files changed, 212 insertions(+), 8 deletions(-) diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index b2fafe46700f8..0c4862b257a82 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -1885,6 +1885,28 @@ class SendResult: error_kind: Optional[str] = None +def next_edit_target_message_id( + adapter: Any, + current_message_id: Optional[str], + result: Any, +) -> Optional[str]: + """Return the platform handle that a subsequent edit must target. + + Most adapters keep editing the original message id even when an edit API + returns a fresh replacement/event id (Matrix is the important example). + Timestamp-chained platforms opt in explicitly so shared callers never + infer this contract from ``SendResult.message_id`` alone. + """ + if getattr(adapter, "EDIT_RESULT_ID_IS_NEXT_TARGET", False) is not True: + return current_message_id + if not (result and getattr(result, "success", False)): + return current_message_id + next_message_id = getattr(result, "message_id", None) + if next_message_id is None or next_message_id == "": + return current_message_id + return str(next_message_id) + + # Machine-readable send-failure categories. Kept platform-neutral so every # adapter can populate ``SendResult.error_kind`` from the same vocabulary and # the gateway can decide — once, in one place — whether a failure is worth @@ -2305,6 +2327,13 @@ class BasePlatformAdapter(ABC): # platform costs. SUPPORTS_PROGRESS_EDITS: Optional[bool] = None + # Whether a successful edit's SendResult.message_id becomes the required + # target for the NEXT edit. False by default: replacement-event APIs such + # as Matrix return a fresh event id while subsequent m.replace operations + # must continue targeting the original event. Signal timestamp chains set + # this True because each edit mints the next editTimestamp anchor. + EDIT_RESULT_ID_IS_NEXT_TARGET: bool = False + # Whether this adapter's ``send()`` splits long content into multiple # messages via ``truncate_message()``. When True, the delivery router # (gateway/delivery.py) skips gateway-level truncation and lets the diff --git a/gateway/platforms/signal.py b/gateway/platforms/signal.py index 4ab7b898af095..6955614352713 100644 --- a/gateway/platforms/signal.py +++ b/gateway/platforms/signal.py @@ -255,6 +255,7 @@ class SignalAdapter(BasePlatformAdapter): SUPPORTS_MESSAGE_EDITING = True SUPPORTS_STREAMING_EDITS = False SUPPORTS_PROGRESS_EDITS = False + EDIT_RESULT_ID_IS_NEXT_TARGET = True def __init__(self, config: PlatformConfig): super().__init__(config, Platform.SIGNAL) diff --git a/gateway/run.py b/gateway/run.py index dc1ab2df3a07e..2c660f0ce5d55 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -1750,6 +1750,7 @@ def _profile_runtime_scope(profile_home: "Path"): MessageType, _reply_anchor_for_event, merge_pending_message_event, + next_edit_target_message_id, ) from gateway.restart import ( DEFAULT_GATEWAY_RESTART_DRAIN_TIMEOUT, @@ -18897,7 +18898,13 @@ async def _notify_long_running(): except Exception as _ee: logger.debug("Heartbeat edit failed: %s", _ee) _notify_res = None - if not (_notify_res and getattr(_notify_res, "success", False)): + if _notify_res and getattr(_notify_res, "success", False): + _heartbeat_msg_id = next_edit_target_message_id( + _notify_adapter, + _heartbeat_msg_id, + _notify_res, + ) + else: _notify_res = await _notify_adapter.send( source.chat_id, _heartbeat_text, diff --git a/gateway/stream_consumer.py b/gateway/stream_consumer.py index 743c82f43c5b5..22e51affff6cd 100644 --- a/gateway/stream_consumer.py +++ b/gateway/stream_consumer.py @@ -27,6 +27,7 @@ from gateway.platforms.base import BasePlatformAdapter as _BasePlatformAdapter from gateway.platforms.base import _custom_unit_to_cp from gateway.platforms.base import MEDIA_TAG_CLEANUP_RE +from gateway.platforms.base import next_edit_target_message_id from gateway.config import ( DEFAULT_STREAMING_EDIT_INTERVAL as _DEFAULT_STREAMING_EDIT_INTERVAL, DEFAULT_STREAMING_BUFFER_THRESHOLD as _DEFAULT_STREAMING_BUFFER_THRESHOLD, @@ -1662,12 +1663,16 @@ async def _send_or_edit( self._last_sent_text = "" self._notify_new_message() else: - # Some edit APIs return a fresh identifier/timestamp - # for the same visible message after every edit. - # Adopt it so a later explicit edit/finalize targets - # the newest platform handle instead of a stale one. - if _new_message_id and str(_new_message_id) != str(_current_message_id): - self._message_id = str(_new_message_id) + # Only timestamp-chained adapters may replace the + # next edit target. Replacement-event APIs (Matrix) + # can return a fresh id while still requiring future + # edits to target the original event. + self._message_id = next_edit_target_message_id( + self.adapter, + _current_message_id, + result, + ) + if self._message_id != _current_message_id: self._message_created_ts = time.monotonic() self._last_sent_text = text # Successful edit — reset flood strike counter diff --git a/tests/gateway/test_run_progress_topics.py b/tests/gateway/test_run_progress_topics.py index 3b693f1c5df41..210abd3a504e7 100644 --- a/tests/gateway/test_run_progress_topics.py +++ b/tests/gateway/test_run_progress_topics.py @@ -153,6 +153,41 @@ async def edit_message(self, chat_id, message_id, content) -> SendResult: return SendResult(success=True, message_id="progress-2") +class ChainedHeartbeatAdapter(ProgressCaptureAdapter): + """Timestamp-style adapter whose edit result becomes the next target.""" + + EDIT_RESULT_ID_IS_NEXT_TARGET = True + + def __init__(self, platform=Platform.SIGNAL): + super().__init__(platform=platform) + self._next_id = 0 + + def _mint_id(self): + self._next_id += 1 + return f"heartbeat-{self._next_id}" + + async def send(self, chat_id, content, reply_to=None, metadata=None) -> SendResult: + self.sent.append( + { + "chat_id": chat_id, + "content": content, + "reply_to": reply_to, + "metadata": metadata, + } + ) + return SendResult(success=True, message_id=self._mint_id()) + + async def edit_message(self, chat_id, message_id, content) -> SendResult: + self.edits.append( + { + "chat_id": chat_id, + "message_id": message_id, + "content": content, + } + ) + return SendResult(success=True, message_id=self._mint_id()) + + class FakeAgent: def __init__(self, **kwargs): # Capture anything passed via kwargs (older code path) but don't @@ -176,6 +211,21 @@ def run_conversation(self, message, conversation_history=None, task_id=None): } +class SlowHeartbeatAgent: + """Keep a turn alive long enough for three heartbeat intervals.""" + + def __init__(self, **kwargs): + self.tools = [] + + def run_conversation(self, message, conversation_history=None, task_id=None): + time.sleep(0.35) + return { + "final_response": "done", + "messages": [], + "api_calls": 1, + } + + class ThinkingAgent: """Agent that emits _thinking scratch text (no tool calls). @@ -818,6 +868,7 @@ async def _run_with_agent( chat_type="group", thread_id="17585", adapter_cls=ProgressCaptureAdapter, + configure_runner=None, ): if config_data: import yaml @@ -834,6 +885,8 @@ async def _run_with_agent( adapter = adapter_cls(platform=platform) runner = _make_runner(adapter) + if configure_runner is not None: + configure_runner(runner) gateway_run = importlib.import_module("gateway.run") if config_data and "streaming" in config_data: runner.config.streaming = StreamingConfig.from_dict(config_data["streaming"]) @@ -867,6 +920,44 @@ async def _run_with_agent( return adapter, result +@pytest.mark.asyncio +async def test_long_running_heartbeat_adopts_timestamp_chain_targets(monkeypatch, tmp_path): + monkeypatch.setenv("HERMES_AGENT_NOTIFY_INTERVAL", "0.05") + + adapter, result = await _run_with_agent( + monkeypatch, + tmp_path, + SlowHeartbeatAgent, + session_id="sess-signal-heartbeat-chain", + config_data={ + "display": { + "tool_progress": "off", + "long_running_notifications": True, + } + }, + platform=Platform.SIGNAL, + chat_id="+155****4567", + chat_type="dm", + thread_id=None, + adapter_cls=ChainedHeartbeatAdapter, + configure_runner=lambda runner: setattr( + runner, + "_should_emit_long_running_notification", + lambda _session_key, agent, executor: ( + agent is not None and (executor is None or not executor.done()) + ), + ), + ) + + assert result["final_response"] == "done" + assert adapter.sent + assert len(adapter.edits) >= 2 + assert [call["message_id"] for call in adapter.edits[:2]] == [ + "heartbeat-1", + "heartbeat-2", + ] + + @pytest.mark.asyncio async def test_run_agent_rolls_progress_bubble_before_platform_limit(monkeypatch, tmp_path): """Tool progress should start a second editable bubble before Telegram's limit. diff --git a/tests/gateway/test_signal.py b/tests/gateway/test_signal.py index 8f6573e3f44ce..452571ec0132a 100644 --- a/tests/gateway/test_signal.py +++ b/tests/gateway/test_signal.py @@ -1067,6 +1067,7 @@ def test_signal_declares_explicit_message_editing_only(self, monkeypatch): assert adapter.SUPPORTS_MESSAGE_EDITING is True assert adapter.SUPPORTS_STREAMING_EDITS is False assert adapter.SUPPORTS_PROGRESS_EDITS is False + assert adapter.EDIT_RESULT_ID_IS_NEXT_TARGET is True def test_gateway_streaming_capability_uses_narrow_flag(self, monkeypatch): from gateway.run import _adapter_supports_streaming_edits @@ -1157,6 +1158,40 @@ async def test_edit_message_returns_fresh_signal_timestamp(self, monkeypatch): assert params["message"] == "edited hello" assert params["recipient"] == ["+155****4567"] + @pytest.mark.asyncio + async def test_successive_edits_chain_through_fresh_timestamps(self, monkeypatch): + adapter = _make_signal_adapter(monkeypatch) + responses = iter([ + {"timestamp": 1712345679000}, + {"timestamp": 1712345680000}, + ]) + captured = [] + + async def mock_rpc(method, params, rpc_id=None): + captured.append({"method": method, "params": dict(params)}) + return next(responses) + + adapter._rpc = mock_rpc + adapter._stop_typing_indicator = AsyncMock() + + first = await adapter.edit_message( + chat_id="+155****4567", + message_id="1712345678000", + content="first edit", + ) + second = await adapter.edit_message( + chat_id="+155****4567", + message_id=first.message_id, + content="second edit", + ) + + assert first.message_id == "1712345679000" + assert second.message_id == "1712345680000" + assert [call["params"]["editTimestamp"] for call in captured] == [ + 1712345678000, + 1712345679000, + ] + @pytest.mark.asyncio async def test_edit_message_requires_numeric_message_id(self, monkeypatch): adapter = _make_signal_adapter(monkeypatch) diff --git a/tests/gateway/test_stream_consumer.py b/tests/gateway/test_stream_consumer.py index 403a6e2896c2e..98479cdaf8f4e 100644 --- a/tests/gateway/test_stream_consumer.py +++ b/tests/gateway/test_stream_consumer.py @@ -133,6 +133,41 @@ async def test_identical_text_skip_respects_adapter_flag(self): assert picky.edit_message.call_args[1]["finalize"] is True +class TestEditResultTargetCapability: + """A fresh edit result id is not globally the next edit target.""" + + @staticmethod + def _adapter(*, chains_result_ids: bool): + adapter = MagicMock() + adapter.EDIT_RESULT_ID_IS_NEXT_TARGET = chains_result_ids + adapter.REQUIRES_EDIT_FINALIZE = False + adapter.MAX_MESSAGE_LENGTH = 4096 + adapter.send = AsyncMock( + return_value=SimpleNamespace(success=True, message_id="original") + ) + adapter.edit_message = AsyncMock( + side_effect=[ + SimpleNamespace(success=True, message_id="replacement-1"), + SimpleNamespace(success=True, message_id="replacement-2"), + ] + ) + return adapter + + @pytest.mark.asyncio + async def test_replacement_event_adapter_keeps_original_edit_target(self): + adapter = self._adapter(chains_result_ids=False) + consumer = GatewayStreamConsumer(adapter, "chat_1") + + await consumer._send_or_edit("first") + await consumer._send_or_edit("second") + await consumer._send_or_edit("third") + + assert [ + call.kwargs["message_id"] for call in adapter.edit_message.await_args_list + ] == ["original", "original"] + assert consumer._message_id == "original" + + class TestEditMessageFinalizeSignature: """Every concrete platform adapter must accept the ``finalize`` kwarg. @@ -2367,8 +2402,9 @@ def test_flush_think_buffer_strips_orphan_close(self): class TestEditMessageIdPropagation: @pytest.mark.asyncio - async def test_successful_edit_adopts_fresh_message_id(self): + async def test_timestamp_chain_capability_adopts_fresh_message_id(self): adapter = MagicMock() + adapter.EDIT_RESULT_ID_IS_NEXT_TARGET = True adapter.MAX_MESSAGE_LENGTH = 4096 adapter.REQUIRES_EDIT_FINALIZE = False adapter.send = AsyncMock(return_value=SimpleNamespace( From 3971cb365459bc8e0789ef67d518bc2618009922 Mon Sep 17 00:00:00 2001 From: dorukardahan <35905596+dorukardahan@users.noreply.github.com> Date: Sun, 19 Jul 2026 06:15:17 +0300 Subject: [PATCH 5/7] feat(signal): enable opt-in progress message edits --- gateway/display_config.py | 3 +- gateway/platforms/signal.py | 23 ++- gateway/run.py | 20 ++- tests/gateway/test_run_progress_topics.py | 181 +++++++++++++++++++- tests/gateway/test_signal.py | 44 ++++- tests/gateway/test_signal_format.py | 5 +- tests/gateway/test_stream_consumer.py | 1 + website/docs/user-guide/configuration.md | 6 +- website/docs/user-guide/messaging/signal.md | 14 +- 9 files changed, 263 insertions(+), 34 deletions(-) diff --git a/gateway/display_config.py b/gateway/display_config.py index b7d957a8f6cd6..4fd258b3db37d 100644 --- a/gateway/display_config.py +++ b/gateway/display_config.py @@ -145,7 +145,8 @@ "matrix": _TIER_MEDIUM, "feishu": _TIER_MEDIUM, - # Tier 3 — no edit support, progress messages are permanent + # Tier 3 — low-noise defaults. Signal can edit timestamp-addressed + # messages, but keeps token streaming and tool progress off until opt-in. "signal": _TIER_LOW, "whatsapp": _TIER_MEDIUM, # Baileys bridge supports /edit # WhatsApp Cloud API: Meta added message editing in 2023 but the diff --git a/gateway/platforms/signal.py b/gateway/platforms/signal.py index 996fd0314d194..89959fd28363c 100644 --- a/gateway/platforms/signal.py +++ b/gateway/platforms/signal.py @@ -256,13 +256,14 @@ class SignalAdapter(BasePlatformAdapter): """Signal messenger adapter using signal-cli HTTP daemon.""" platform = Platform.SIGNAL - # signal-cli exposes explicit message edits via send(editTimestamp=...). - # Keep high-frequency gateway streaming/tool-progress edits disabled: real - # clients surface each Signal edit as a visible edit event, so explicit edits - # are safe while stream-consumer cadence remains too noisy. + # signal-cli exposes message edits via send(editTimestamp=...). Keep + # token-by-token response streaming disabled, but allow the lower-frequency + # accumulated tool-progress bubble when the user explicitly enables it. + # Signal stays on the tier-low display default, so progress is off unless + # /verbose or a config override opts in. SUPPORTS_MESSAGE_EDITING = True SUPPORTS_STREAMING_EDITS = False - SUPPORTS_PROGRESS_EDITS = False + SUPPORTS_PROGRESS_EDITS = True EDIT_RESULT_ID_IS_NEXT_TARGET = True def __init__(self, config: PlatformConfig): @@ -1163,11 +1164,15 @@ async def edit_message( success, err_msg = self._validate_send_result(result) if not success: return SendResult(success=False, error=err_msg, raw_response=result) + fresh_timestamp = self._extract_send_timestamp(result) + if fresh_timestamp is None: + return SendResult( + success=False, + error="Signal edit response missing fresh timestamp", + raw_response=result, + ) self._track_sent_timestamp(result) - return SendResult( - success=True, - message_id=self._extract_send_timestamp(result) or str(message_id), - ) + return SendResult(success=True, message_id=fresh_timestamp) return SendResult(success=False, error="RPC edit failed") def _track_sent_timestamp(self, rpc_result) -> None: diff --git a/gateway/run.py b/gateway/run.py index 27bbe1a3a2548..b2a83e972fcfb 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -18980,10 +18980,8 @@ async def send_progress_messages(): return # Skip tool/thinking progress for platforms that cannot safely edit - # progress bubbles. Some adapters (Signal) support explicit - # edit_message() calls but opt out of high-frequency automatic - # progress edits because clients surface every edit or require - # timestamp-chained edit handles. + # progress bubbles. An adapter may support explicit edits while + # independently opting out of automatic progress cadence. if ( not _adapter_supports_progress_edits(adapter) or type(adapter).edit_message is BasePlatformAdapter.edit_message @@ -19034,6 +19032,7 @@ async def send_progress_messages(): _edit_accepts_metadata = False async def _edit_progress_message(message_id: str, content: str): + nonlocal progress_msg_id kwargs = { "chat_id": source.chat_id, "message_id": message_id, @@ -19043,7 +19042,13 @@ async def _edit_progress_message(message_id: str, content: str): kwargs["finalize"] = True if _edit_accepts_metadata: kwargs["metadata"] = _progress_metadata - return await adapter.edit_message(**kwargs) + result = await adapter.edit_message(**kwargs) + progress_msg_id = next_edit_target_message_id( + adapter, + message_id, + result, + ) + return result def _progress_text(lines: list) -> str: return "\n".join(str(line) for line in lines) @@ -19253,6 +19258,11 @@ async def _roll_progress_overflow_if_needed() -> bool: progress_msg_id = result.message_id if _cleanup_progress: _cleanup_msg_ids.append(str(result.message_id)) + elif result.success and can_edit: + # The message was delivered but cannot be addressed + # for a later edit. Degrade to one new line per update + # instead of replaying the accumulated transcript. + can_edit = False _last_edit_ts = time.monotonic() diff --git a/tests/gateway/test_run_progress_topics.py b/tests/gateway/test_run_progress_topics.py index 210abd3a504e7..e23db5d906a8c 100644 --- a/tests/gateway/test_run_progress_topics.py +++ b/tests/gateway/test_run_progress_topics.py @@ -127,6 +127,7 @@ class NoProgressEditCaptureAdapter(ProgressCaptureAdapter): SUPPORTS_MESSAGE_EDITING = True SUPPORTS_STREAMING_EDITS = False + SUPPORTS_PROGRESS_EDITS = False def __init__(self): super().__init__(platform=Platform.SIGNAL) @@ -153,6 +154,69 @@ async def edit_message(self, chat_id, message_id, content) -> SendResult: return SendResult(success=True, message_id="progress-2") +class TimestampChainProgressAdapter(ProgressCaptureAdapter): + """Signal-style adapter whose progress edits mint the next target.""" + + SUPPORTS_MESSAGE_EDITING = True + SUPPORTS_STREAMING_EDITS = False + SUPPORTS_PROGRESS_EDITS = True + EDIT_RESULT_ID_IS_NEXT_TARGET = True + + def __init__(self): + super().__init__(platform=Platform.SIGNAL) + self._next_id = 0 + + def _mint_id(self): + self._next_id += 1 + return f"progress-{self._next_id}" + + async def send(self, chat_id, content, reply_to=None, metadata=None) -> SendResult: + self.sent.append( + { + "chat_id": chat_id, + "content": content, + "reply_to": reply_to, + "metadata": metadata, + } + ) + return SendResult(success=True, message_id=self._mint_id()) + + async def edit_message(self, chat_id, message_id, content) -> SendResult: + self.edits.append( + { + "chat_id": chat_id, + "message_id": message_id, + "content": content, + } + ) + return SendResult(success=True, message_id=self._mint_id()) + + +class MissingIdProgressAdapter(ProgressCaptureAdapter): + """Editable adapter whose first send cannot provide an edit handle.""" + + SUPPORTS_MESSAGE_EDITING = True + SUPPORTS_STREAMING_EDITS = False + SUPPORTS_PROGRESS_EDITS = True + + def __init__(self): + super().__init__(platform=Platform.SIGNAL) + + async def send(self, chat_id, content, reply_to=None, metadata=None) -> SendResult: + self.sent.append( + { + "chat_id": chat_id, + "content": content, + "reply_to": reply_to, + "metadata": metadata, + } + ) + return SendResult(success=True, message_id=None) + + async def edit_message(self, chat_id, message_id, content) -> SendResult: + raise AssertionError("progress without an edit handle must not attempt edits") + + class ChainedHeartbeatAdapter(ProgressCaptureAdapter): """Timestamp-style adapter whose edit result becomes the next target.""" @@ -211,6 +275,29 @@ def run_conversation(self, message, conversation_history=None, task_id=None): } +class TimestampChainProgressAgent: + """Emit three spaced tool events so two progress edits are observable.""" + + def __init__(self, **kwargs): + self.tool_progress_callback = kwargs.get("tool_progress_callback") + self.tools = [] + + def run_conversation(self, message, conversation_history=None, task_id=None): + cb = self.tool_progress_callback + assert cb is not None + cb("tool.started", "terminal", "first", {}) + time.sleep(1.7) + cb("tool.started", "browser_navigate", "https://example.com/second", {}) + time.sleep(1.7) + cb("tool.started", "terminal", "third", {}) + time.sleep(0.4) + return { + "final_response": "done", + "messages": [], + "api_calls": 1, + } + + class SlowHeartbeatAgent: """Keep a turn alive long enough for three heartbeat intervals.""" @@ -352,14 +439,8 @@ def _make_runner(adapter): @pytest.mark.asyncio -async def test_run_agent_progress_respects_streaming_edit_capability(monkeypatch, tmp_path): - """Adapters that opt out of streaming/progress edits must stay quiet. - - Signal exposes explicit timestamp-based ``edit_message()`` for user/operator - edits, but high-frequency progress edits are noisy and timestamp-chained. - The progress sender should therefore honor the same narrow capability gate - as token streaming instead of checking only whether ``edit_message`` exists. - """ +async def test_run_agent_progress_respects_explicit_progress_opt_out(monkeypatch, tmp_path): + """An adapter can allow explicit edits while keeping progress disabled.""" monkeypatch.setenv("HERMES_TOOL_PROGRESS_MODE", "all") fake_dotenv = types.ModuleType("dotenv") @@ -392,6 +473,90 @@ async def test_run_agent_progress_respects_streaming_edit_capability(monkeypatch assert adapter.edits == [] +@pytest.mark.asyncio +async def test_run_agent_progress_adopts_timestamp_chain_targets(monkeypatch, tmp_path): + """Opted-in Signal progress edits must target each freshly minted timestamp.""" + fake_dotenv = types.ModuleType("dotenv") + setattr(fake_dotenv, "load_dotenv", lambda *args, **kwargs: None) + monkeypatch.setitem(sys.modules, "dotenv", fake_dotenv) + + fake_run_agent = types.ModuleType("run_agent") + setattr(fake_run_agent, "AIAgent", TimestampChainProgressAgent) + monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent) + import tools.terminal_tool # noqa: F401 - register terminal emoji for this fake-agent test + + adapter = TimestampChainProgressAdapter() + runner = _make_runner(adapter) + gateway_run = importlib.import_module("gateway.run") + monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path) + monkeypatch.setattr(gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "fake"}) + (tmp_path / "config.yaml").write_text( + "display:\n" + " platforms:\n" + " signal:\n" + " tool_progress: all\n", + encoding="utf-8", + ) + source = SessionSource(platform=Platform.SIGNAL, chat_id="+155****4567", chat_type="dm") + + result = await runner._run_agent( + message="hello", + context_prompt="", + history=[], + source=source, + session_id="sess-signal-progress-chain", + session_key="agent:main:signal:dm:+155****4567", + ) + + assert result["final_response"] == "done" + assert len(adapter.sent) == 1 + assert len(adapter.edits) >= 2 + assert [call["message_id"] for call in adapter.edits[:2]] == [ + "progress-1", + "progress-2", + ] + assert [len(call["content"].splitlines()) for call in adapter.edits[:2]] == [2, 3] + + +@pytest.mark.asyncio +async def test_run_agent_progress_degrades_to_separate_lines_without_message_id( + monkeypatch, + tmp_path, +): + """Missing edit handles must not replay the accumulated progress transcript.""" + monkeypatch.setenv("HERMES_TOOL_PROGRESS_MODE", "all") + + fake_dotenv = types.ModuleType("dotenv") + setattr(fake_dotenv, "load_dotenv", lambda *args, **kwargs: None) + monkeypatch.setitem(sys.modules, "dotenv", fake_dotenv) + + fake_run_agent = types.ModuleType("run_agent") + setattr(fake_run_agent, "AIAgent", TimestampChainProgressAgent) + monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent) + import tools.terminal_tool # noqa: F401 - register terminal emoji for this fake-agent test + + adapter = MissingIdProgressAdapter() + runner = _make_runner(adapter) + gateway_run = importlib.import_module("gateway.run") + monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path) + monkeypatch.setattr(gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "fake"}) + source = SessionSource(platform=Platform.SIGNAL, chat_id="+155****4567", chat_type="dm") + + result = await runner._run_agent( + message="hello", + context_prompt="", + history=[], + source=source, + session_id="sess-signal-progress-no-id", + session_key="agent:main:signal:dm:+155****4567", + ) + + assert result["final_response"] == "done" + assert len(adapter.sent) == 3 + assert [len(call["content"].splitlines()) for call in adapter.sent] == [1, 1, 1] + assert adapter.edits == [] + + @pytest.mark.asyncio async def test_run_agent_progress_stays_in_originating_topic(monkeypatch, tmp_path): monkeypatch.setenv("HERMES_TOOL_PROGRESS_MODE", "all") diff --git a/tests/gateway/test_signal.py b/tests/gateway/test_signal.py index 3de63df106aee..6e15d062a71f7 100644 --- a/tests/gateway/test_signal.py +++ b/tests/gateway/test_signal.py @@ -1081,14 +1081,14 @@ async def test_send_document_error_includes_path(self, monkeypatch): # --------------------------------------------------------------------------- class TestSignalStreamingCapabilities: - """Signal supports explicit edits but opts out of streaming edit cadence.""" + """Signal supports explicit and opt-in progress edits, not token streaming.""" - def test_signal_declares_explicit_message_editing_only(self, monkeypatch): + def test_signal_declares_explicit_and_progress_edit_capabilities(self, monkeypatch): adapter = _make_signal_adapter(monkeypatch) assert adapter.SUPPORTS_MESSAGE_EDITING is True assert adapter.SUPPORTS_STREAMING_EDITS is False - assert adapter.SUPPORTS_PROGRESS_EDITS is False + assert adapter.SUPPORTS_PROGRESS_EDITS is True assert adapter.EDIT_RESULT_ID_IS_NEXT_TARGET is True def test_gateway_streaming_capability_uses_narrow_flag(self, monkeypatch): @@ -1103,7 +1103,25 @@ def test_gateway_progress_capability_uses_narrow_flag(self, monkeypatch): adapter = _make_signal_adapter(monkeypatch) - assert _adapter_supports_progress_edits(adapter) is False + assert _adapter_supports_progress_edits(adapter) is True + + def test_signal_tool_progress_remains_off_by_default(self): + from gateway.display_config import resolve_display_setting + + assert resolve_display_setting({}, "signal", "tool_progress") == "off" + + def test_signal_tool_progress_can_be_enabled_per_platform(self): + from gateway.display_config import resolve_display_setting + + config = { + "display": { + "platforms": { + "signal": {"tool_progress": "all"}, + } + } + } + + assert resolve_display_setting(config, "signal", "tool_progress") == "all" def test_streaming_capability_falls_back_to_message_editing(self): from gateway.run import _adapter_supports_progress_edits, _adapter_supports_streaming_edits @@ -1180,6 +1198,24 @@ async def test_edit_message_returns_fresh_signal_timestamp(self, monkeypatch): assert params["message"] == "edited hello" assert params["recipient"] == ["+155****4567"] + @pytest.mark.asyncio + async def test_edit_message_fails_without_fresh_timestamp(self, monkeypatch): + """A successful RPC without the next edit handle must fail closed.""" + adapter = _make_signal_adapter(monkeypatch) + mock_rpc, _ = _stub_rpc({}) + adapter._rpc = mock_rpc + adapter._stop_typing_indicator = AsyncMock() + + result = await adapter.edit_message( + chat_id="+155****4567", + message_id="1712345678000", + content="edited hello", + ) + + assert result.success is False + assert result.message_id is None + assert "fresh timestamp" in result.error + @pytest.mark.asyncio async def test_successive_edits_chain_through_fresh_timestamps(self, monkeypatch): adapter = _make_signal_adapter(monkeypatch) diff --git a/tests/gateway/test_signal_format.py b/tests/gateway/test_signal_format.py index d006de682c185..c54d949611fca 100644 --- a/tests/gateway/test_signal_format.py +++ b/tests/gateway/test_signal_format.py @@ -451,11 +451,12 @@ def _adapter(self, monkeypatch): } return SignalAdapter(config) - def test_signal_supports_explicit_edit_but_not_streaming_edits(self, monkeypatch): - """Explicit edit_message is separate from noisy stream-progress edits.""" + def test_signal_supports_explicit_and_opt_in_progress_edits(self, monkeypatch): + """Signal edits tool progress only when display settings opt in.""" monkeypatch.setenv("SIGNAL_GROUP_ALLOWED_USERS", "") assert SignalAdapter.SUPPORTS_MESSAGE_EDITING is True assert SignalAdapter.SUPPORTS_STREAMING_EDITS is False + assert SignalAdapter.SUPPORTS_PROGRESS_EDITS is True @pytest.mark.asyncio async def test_send_returns_signal_timestamp_as_message_id(self, monkeypatch): diff --git a/tests/gateway/test_stream_consumer.py b/tests/gateway/test_stream_consumer.py index 4d0db70bee6d2..9ee1911fdbbc6 100644 --- a/tests/gateway/test_stream_consumer.py +++ b/tests/gateway/test_stream_consumer.py @@ -207,6 +207,7 @@ class TestEditMessageFinalizeSignature: ("plugins.platforms.feishu.adapter", "FeishuAdapter"), ("plugins.platforms.whatsapp.adapter", "WhatsAppAdapter"), ("plugins.platforms.dingtalk.adapter", "DingTalkAdapter"), + ("gateway.platforms.signal", "SignalAdapter"), ], ) def test_edit_message_accepts_finalize(self, module_path, class_name): diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index 5297a39f11d3d..fbb2a9ac93bf5 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -1546,7 +1546,7 @@ display: In the CLI, cycle through these modes with `/verbose`. To use `/verbose` in messaging platforms (Telegram, Discord, Slack, etc.), set `tool_progress_command: true` in the `display` section above. The command will then cycle the mode and save to config. -Tool progress requires a gateway adapter that can display progress updates safely. Platforms without message editing support, including Signal, suppress tool-progress bubbles even if `/verbose` saves a non-`off` mode. +Tool progress requires a gateway adapter that can display updates safely. Adapters without a usable message-edit path suppress accumulated tool-progress bubbles even if `/verbose` saves a non-`off` mode. Signal supports timestamp-based progress edits, but its built-in platform default remains `off`; enable it explicitly with `/verbose` or a per-platform override. ### Runtime-metadata footer (gateway only) @@ -1578,7 +1578,7 @@ display: tool_progress: all # global default platforms: signal: - tool_progress: 'off' # Signal cannot currently display tool-progress bubbles + tool_progress: all # opt in to one timestamp-edited Signal progress bubble telegram: tool_progress: verbose # detailed progress on Telegram slack: @@ -1587,7 +1587,7 @@ display: Platforms without an override fall back to the global `tool_progress` value. Valid platform keys: `telegram`, `discord`, `slack`, `signal`, `whatsapp`, `matrix`, `mattermost`, `email`, `sms`, `homeassistant`, `dingtalk`, `feishu`, `wecom`, `weixin`, `bluebubbles`, `qqbot`. The legacy `display.tool_progress_overrides` key still loads for backward compatibility but is deprecated and migrated into `display.platforms` on first load. -Signal is listed as a valid platform key because the setting can be saved per platform, but the current Signal adapter cannot edit sent messages and does not render tool-progress bubbles. Keep Signal `tool_progress` set to `off`; use the CLI or an editing-capable messaging platform if you need to watch each tool call live. +Signal's default remains `off` to avoid unsolicited edit traffic. When explicitly set to `new`, `all`, or `verbose`, Hermes accumulates tool activity in one Signal message and follows the fresh timestamp returned by each edit. Token-by-token assistant-response streaming remains disabled independently. `interim_assistant_messages` is gateway-only. When enabled, Hermes sends completed mid-turn assistant updates as separate chat messages. This is independent from `tool_progress` and does not require gateway streaming. diff --git a/website/docs/user-guide/messaging/signal.md b/website/docs/user-guide/messaging/signal.md index 597a7fa30be28..7a670dafc135c 100644 --- a/website/docs/user-guide/messaging/signal.md +++ b/website/docs/user-guide/messaging/signal.md @@ -187,9 +187,19 @@ The bot sends typing indicators while processing messages, refreshing every 8 se ### Tool Progress Display -Signal does not support editing already-sent messages. Hermes therefore suppresses gateway tool-progress bubbles on Signal, even when `/verbose` is enabled and saves a non-`off` mode for the platform. +Signal and signal-cli support editing already-sent messages through timestamp-based edit handles. Hermes uses that API to keep enabled tool progress in one accumulated message instead of posting a new message for every tool call. -You can still see tool activity in the CLI, and final Signal replies can include normal assistant output. If you need live per-tool progress in chat, use a messaging platform with message editing support. +Tool progress remains **off by default** on Signal, and token-by-token response streaming remains disabled. This avoids noisy edit traffic unless you explicitly opt in. To enable the single-message progress view for Signal: + +```yaml +display: + tool_progress_command: true + platforms: + signal: + tool_progress: all +``` + +You can then use `/verbose` in Signal to cycle between `off`, `new`, `all`, and `verbose`. Each successful Signal edit returns a fresh timestamp; Hermes automatically uses that timestamp for the next progress edit. ### Phone Number Redaction From f89fa67f32954ddaf4d3f5816700bdce94134b12 Mon Sep 17 00:00:00 2001 From: dorukardahan <35905596+dorukardahan@users.noreply.github.com> Date: Sun, 19 Jul 2026 06:38:26 +0300 Subject: [PATCH 6/7] fix(gateway): honor streaming opt-out in proxy mode --- gateway/run.py | 4 +- tests/gateway/test_proxy_mode.py | 73 +++++++++++++++++++++++++++++++- 2 files changed, 75 insertions(+), 2 deletions(-) diff --git a/gateway/run.py b/gateway/run.py index b2a83e972fcfb..038a59df2b0e8 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -18053,7 +18053,9 @@ def _pause_typing_before_finalize( ) -> None: _adapter.pause_typing_for_chat(_chat_id) _adapter_supports_edit = _adapter_supports_streaming_edits(_adapter) - _effective_cursor = _scfg.cursor if _adapter_supports_edit else "" + if not _adapter_supports_edit: + raise RuntimeError("skip streaming for non-editable platform") + _effective_cursor = _scfg.cursor _buffer_only = False if source.platform == Platform.MATRIX: _effective_cursor = "" diff --git a/tests/gateway/test_proxy_mode.py b/tests/gateway/test_proxy_mode.py index be98f7eb9acbd..16d9d457a2a9b 100644 --- a/tests/gateway/test_proxy_mode.py +++ b/tests/gateway/test_proxy_mode.py @@ -1,11 +1,12 @@ """Tests for gateway proxy mode — forwarding messages to a remote API server.""" +from typing import cast from unittest.mock import AsyncMock, MagicMock, patch import pytest from gateway.config import Platform, StreamingConfig -from gateway.platforms.base import resolve_proxy_url +from gateway.platforms.base import BasePlatformAdapter, SendResult, resolve_proxy_url from gateway.run import GatewayRunner from gateway.session import SessionSource @@ -227,6 +228,76 @@ async def test_run_agent_skips_proxy_when_not_configured(self, monkeypatch): class TestRunAgentViaProxy: """Test the actual proxy HTTP forwarding logic.""" + @pytest.mark.asyncio + async def test_signal_streaming_opt_out_skips_proxy_preview(self, monkeypatch): + """Proxy SSE must not bypass a platform's streaming-edit opt-out.""" + monkeypatch.setenv("GATEWAY_PROXY_URL", "http://host:8642") + monkeypatch.delenv("GATEWAY_PROXY_KEY", raising=False) + + class _SignalLikeAdapter: + SUPPORTS_MESSAGE_EDITING = True + SUPPORTS_STREAMING_EDITS = False + MAX_MESSAGE_LENGTH = 4096 + + def __init__(self): + self.sent = [] + self.edits = [] + + async def send_typing(self, chat_id, metadata=None): + return None + + async def send(self, chat_id, content, metadata=None, reply_to=None): + self.sent.append(content) + return SendResult(success=True, message_id="ts-1") + + async def edit_message(self, chat_id, message_id, content, metadata=None): + self.edits.append((message_id, content)) + return SendResult(success=True, message_id="ts-2") + + runner = _make_runner() + runner.config.streaming = StreamingConfig( + enabled=True, + transport="edit", + edit_interval=0, + buffer_threshold=1, + ) + adapter = _SignalLikeAdapter() + runner.adapters[Platform.SIGNAL] = cast(BasePlatformAdapter, adapter) + source = _make_source(Platform.SIGNAL) + + resp = _FakeSSEResponse( + status=200, + sse_chunks=[ + 'data: {"choices":[{"delta":{"content":"Hello"}}]}\n\n', + 'data: {"choices":[{"delta":{"content":" world"}}]}\n\n', + "data: [DONE]\n\n", + ], + ) + session = _FakeSession(resp) + config = { + "display": { + "platforms": { + "signal": {"streaming": True}, + } + } + } + + with patch("gateway.run._load_gateway_config", return_value=config): + with _patch_aiohttp(session): + with patch("aiohttp.ClientTimeout"): + result = await runner._run_agent_via_proxy( + message="hi", + context_prompt="", + history=[], + source=source, + session_id="signal-proxy", + ) + + assert result["final_response"] == "Hello world" + assert result["response_previewed"] is False + assert adapter.sent == [] + assert adapter.edits == [] + @pytest.mark.asyncio async def test_builds_correct_request(self, monkeypatch): monkeypatch.setenv("GATEWAY_PROXY_URL", "http://host:8642") From 506186f00e740e41849e6e3e949c8e1ae0356f0e Mon Sep 17 00:00:00 2001 From: dorukardahan <35905596+dorukardahan@users.noreply.github.com> Date: Sun, 19 Jul 2026 06:51:16 +0300 Subject: [PATCH 7/7] docs(gateway): clarify progress edit cadence --- gateway/run.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/gateway/run.py b/gateway/run.py index 038a59df2b0e8..b42677ac49825 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -541,9 +541,10 @@ def _adapter_supports_streaming_edits(adapter: Any) -> bool: def _adapter_supports_progress_edits(adapter: Any) -> bool: """Return whether adapter edits are safe for tool/thinking progress. - Progress bubbles use the same high-frequency edit cadence as token - streaming. Platforms may expose explicit edit_message() for deliberate - user/operator edits while still opting out of automatic progress edits. + Progress bubbles are throttled and lower-frequency than token streaming, + but they are still automatic edits. Platforms may expose explicit + edit_message() for deliberate user/operator edits while choosing a separate + policy for automatic progress edits. """ progress_capability = getattr(adapter, "SUPPORTS_PROGRESS_EDITS", None) if progress_capability is not None: