From bfc6e1877ddd5c03d8fef719ebd6bcaabae9a25a Mon Sep 17 00:00:00 2001 From: Abiba Date: Mon, 6 Jul 2026 00:00:50 +0000 Subject: [PATCH 1/2] feat: add edit_message + streaming support to Zulip adapter Implements edit_message() using Zulip's PATCH /api/v1/messages/{id} API. Enables the Hermes GatewayStreamConsumer to progressively update Zulip messages during agent generation, giving users real-time visibility into agent thinking via progressive edits. Adds _api_patch() helper for PATCH HTTP method. --- hermes-zulip-plugin/src/adapter.py | 33 ++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/hermes-zulip-plugin/src/adapter.py b/hermes-zulip-plugin/src/adapter.py index f302aaa..7542b9c 100644 --- a/hermes-zulip-plugin/src/adapter.py +++ b/hermes-zulip-plugin/src/adapter.py @@ -203,6 +203,22 @@ class ZulipAdapter(BasePlatformAdapter): logger.error("Zulip POST %s network error: %s", path, exc) return {"result": "error", "msg": str(exc)} + async def _api_patch(self, path: str, payload: Dict[str, Any]) -> Dict[str, Any]: + import aiohttp + url = f"{self._site}/api/v1/{path.lstrip('/')}" + try: + async with self._session.patch( + url, data=payload, auth=self._auth(), + timeout=aiohttp.ClientTimeout(total=15), + ) as resp: + data = await resp.json() + if resp.status >= 400: + logger.debug("Zulip PATCH %s -> %s: %s", path, resp.status, str(data)[:200]) + return data + except (aiohttp.ClientError, asyncio.TimeoutError) as exc: + logger.debug("Zulip PATCH %s network error: %s", path, exc) + return {"result": "error", "msg": str(exc)} + async def _api_delete(self, path: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: import aiohttp url = f"{self._site}/api/v1/{path.lstrip('/')}" @@ -326,6 +342,23 @@ class ZulipAdapter(BasePlatformAdapter): return SendResult(success=True, message_id=str(last_id) if last_id else None) + async def edit_message( + self, + chat_id: str, + message_id: str, + content: str, + **kwargs, + ) -> SendResult: + """Edit a previously-sent message. Used by the gateway stream consumer + for progressive message updates during agent streaming.""" + formatted = self.format_message(content) + data = await self._api_patch(f"messages/{message_id}", {"content": formatted}) + if data.get("result") != "success": + msg = str(data.get("msg", "edit failed")) + logger.debug("Zulip edit_message(%s) -> %s", message_id, msg) + return SendResult(success=False, error=msg) + return SendResult(success=True, message_id=message_id) + async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: kind, to, topic = _parse_target(chat_id, self._default_topic) if kind == "direct": -- 2.54.0 From 5d9e36f657f109fb3e1759bc414c9849ec0ba1bb Mon Sep 17 00:00:00 2001 From: Jerome Tabiri Date: Wed, 8 Jul 2026 03:18:03 -0400 Subject: [PATCH 2/2] fix(zulip): add _strip_html for slash command matching Zulip delivers message content as HTML (

/approve

). The gateway slash command parser expects plain text, so HTML tags prevent command matching. This helper strips HTML tags and decodes common entities (&, <, etc.). Per contract: zulip-approval-fix.prose.md Applied to: Mumuni (CT114), Tanko (CT112), Koby (CT111) --- plugins/platforms/zulip/adapter.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/plugins/platforms/zulip/adapter.py b/plugins/platforms/zulip/adapter.py index a6be243..a9c6877 100644 --- a/plugins/platforms/zulip/adapter.py +++ b/plugins/platforms/zulip/adapter.py @@ -99,6 +99,23 @@ def _build_auth_header(email: str, api_key: str) -> str: return f"Basic {token}" +def _strip_html(text: str) -> str: + """Strip HTML tags and decode HTML entities from Zulip message content. + + Zulip delivers message content as rendered HTML (e.g.

/approve

). + The gateway slash command parser expects plain text, so HTML tags + prevent command matching. This helper strips tags and decodes entities. + """ + if not text: + return text + # Strip HTML tags + text = re.sub(r"<[^>]+>", "", text) + # Decode common HTML entities + text = text.replace("&", "&").replace("<", "<").replace(">", ">") + text = text.replace(""", '"').replace("'", "'").replace(" ", " ") + return text.strip() + + def _truncate(text: str, limit: int = MAX_ZULIP_MESSAGE) -> str: """Truncate to Zulip's message limit with notice.""" if len(text) <= limit: @@ -784,6 +801,8 @@ class ZulipAdapter(BasePlatformAdapter): sender_name = msg.get("sender_full_name", "Unknown") sender_id = msg.get("sender_id") content = msg.get("content", "") + # Strip Zulip HTML for slash command matching (zulip-approval-fix contract) + content = _strip_html(content) # Echo-loop prevention: skip own messages if sender_email == self._bot_email: -- 2.54.0