{"repo":"NousResearch/hermes-agent","public_id":"8358","display_label":"Feishu approval card button visual state fix","summary":"These members address a bug where Feishu approval card buttons do not visually reflect the selected state after a user clicks them. The work involves implementing optimistic UI updates and resolving approval card state from the callback path.","subsystem":"gateway","counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":9871,"merged_at":"2026-04-14T21:22:12+00:00"},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"stale","last_verified_at":"2026-04-14T21:22:12+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":8246,"pr_number":9871,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-04-14T21:22:12+00:00"},{"issue_number":8358,"pr_number":9871,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-04-14T21:22:12+00:00"},{"issue_number":8764,"pr_number":9871,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-04-14T21:22:12+00:00"}],"members":[{"kind":"issue","number":8358,"title":"[Bug] Feishu: approval card button selection state not visually updated when user clicks","url":null,"state":"closed"},{"kind":"pr","number":8393,"title":"fix(gateway/feishu): #8358 implement optimistic UI updates for approv…","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#9871","number":9871}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":9871,"title":"fix(feishu): resolve approval cards inline from callback path","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/feishu.py","scripts/release.py","tests/gateway/test_feishu_approval_buttons.py"],"fix_hunks":[{"file":"gateway/platforms/feishu.py","hunks":["@@ -72,7 +72,10 @@\n         UpdateMessageRequestBody,\n     )\n     from lark_oapi.core.const import FEISHU_DOMAIN, LARK_DOMAIN\n-    from lark_oapi.event.callback.model.p2_card_action_trigger import P2CardActionTriggerResponse\n+    from lark_oapi.event.callback.model.p2_card_action_trigger import (\n+        CallBackCard,\n+        P2CardActionTriggerResponse,\n+    )\n     from lark_oapi.event.dispatcher_handler import EventDispatcherHandler\n     from lark_oapi.ws import Client as FeishuWSClient\n ","@@ -80,6 +83,7 @@\n except ImportError:\n     FEISHU_AVAILABLE = False\n     lark = None  # type: ignore[assignment]\n+    CallBackCard = None  # type: ignore[assignment]\n     P2CardActionTriggerResponse = None  # type: ignore[assignment]\n     EventDispatcherHandler = None  # type: ignore[assignment]\n     FeishuWSClient = None  # type: ignore[assignment]","@@ -169,6 +173,19 @@\n _FEISHU_WEBHOOK_ANOMALY_THRESHOLD = 25             # consecutive error responses before WARNING log\n _FEISHU_WEBHOOK_ANOMALY_TTL_SECONDS = 6 * 60 * 60  # anomaly tracker TTL (6 hours) — matches openclaw\n _FEISHU_CARD_ACTION_DEDUP_TTL_SECONDS = 15 * 60    # card action token dedup window (15 min)\n+\n+_APPROVAL_CHOICE_MAP: Dict[str, str] = {\n+    \"approve_once\": \"once\",\n+    \"approve_session\": \"session\",\n+    \"approve_always\": \"always\",\n+    \"deny\": \"deny\",\n+}\n+_APPROVAL_LABEL_MAP: Dict[str, str] = {\n+    \"once\": \"Approved once\",\n+    \"session\": \"Approved for session\",\n+    \"always\": \"Approved permanently\",\n+    \"deny\": \"Denied\",\n+}\n _FEISHU_BOT_MSG_TRACK_SIZE = 512                   # LRU size for tracking sent message IDs\n _FEISHU_REPLY_FALLBACK_CODES = frozenset({230011, 231003})  # reply target withdrawn/missing → create fallback\n _FEISHU_ACK_EMOJI = \"OK\"","@@ -1490,14 +1507,12 @@ def _btn(label: str, action_name: str, btn_type: str = \"default\") -> dict:\n             logger.warning(\"[Feishu] send_exec_approval failed: %s\", exc)\n             return SendResult(success=False, error=str(exc))\n \n-    async def _update_approval_card(\n-        self, message_id: str, label: str, user_name: str, choice: str,\n-    ) -> None:\n-        \"\"\"Replace the approval card with a resolved status card.\"\"\"\n-        if not self._client or not message_id:\n-            return\n+    @staticmethod\n+    def _build_resolved_approval_card(*, choice: str, user_name: str) -> Dict[str, Any]:\n+        \"\"\"Build raw card JSON for a resolved approval action.\"\"\"\n         icon = \"❌\" if choice == \"deny\" else \"✅\"\n-        card = {\n+        label = _APPROVAL_LABEL_MAP.get(choice, \"Resolved\")\n+        return {\n             \"config\": {\"wide_screen_mode\": True},\n             \"header\": {\n                 \"title\": {\"content\": f\"{icon} {label}\", \"tag\": \"plain_text\"},","@@ -1510,13 +1525,6 @@ async def _update_approval_card(\n                 },\n             ],\n         }\n-        try:\n-            payload = json.dumps(card, ensure_ascii=False)\n-            body = self._build_update_message_body(msg_type=\"interactive\", content=payload)\n-            request = self._build_update_message_request(message_id=message_id, request_body=body)\n-            await asyncio.to_thread(self._client.im.v1.message.update, request)\n-        except Exception as exc:\n-            logger.warning(\"[Feishu] Failed to update approval card %s: %s\", message_id, exc)\n \n     async def send_voice(\n         self,","@@ -1845,20 +1853,82 @@ def _on_reaction_event(self, event_type: str, data: Any) -> None:\n         future.add_done_callback(self._log_background_failure)\n \n     def _on_card_action_trigger(self, data: Any) -> Any:\n-        \"\"\"Schedule Feishu card actions on the adapter loop and acknowledge immediately.\"\"\"\n+        \"\"\"Handle card-action callback from the Feishu SDK (synchronous).\n+\n+        For approval actions: parses the event once, returns the resolved card\n+        inline (the only reliable way to sync all clients), and schedules a\n+        lightweight async method to actually unblock the agent.\n+\n+        For other card actions: delegates to ``_handle_card_action_event``.\n+        \"\"\"\n         loop = self._loop\n-        if loop is None or bool(getattr(loop, \"is_closed\", lambda: False)()):\n+        if not self._loop_accepts_callbacks(loop):\n             logger.warning(\"[Feishu] Dropping card action before adapter loop is ready\")\n-        else:\n-            future = asyncio.run_coroutine_threadsafe(\n-                self._handle_card_action_event(data),\n-                loop,\n-            )\n-            future.add_done_callback(self._log_background_failure)\n+            return P2CardActionTriggerResponse() if P2CardActionTriggerResponse else None\n+\n+        event = getattr(data, \"event\", None)\n+        action = getattr(event, \"action\", None)\n+        action_value = getattr(action, \"value\", {}) or {}\n+        hermes_action = action_value.get(\"hermes_action\") if isinstance(action_value, dict) else None\n+\n+        if hermes_action:\n+            return self._handle_approval_card_action(event=event, action_value=action_value, loop=loop)\n+\n+        self._submit_on_loop(loop, self._handle_card_action_event(data))\n         if P2CardActionTriggerResponse is None:\n             return None\n         return P2CardActionTriggerResponse()\n \n+    @staticmethod\n+    def _loop_accepts_callbacks(loop: Any) -> bool:\n+        \"\"\"Return True when the adapter loop can accept thread-safe submissions.\"\"\"\n+        return loop is not None and not bool(getattr(loop, \"is_closed\", lambda: False)())\n+\n+    def _submit_on_loop(self, loop: Any, coro: Any) -> None:\n+        \"\"\"Schedule background work on the adapter loop with shared failure logging.\"\"\"\n+        future = asyncio.run_coroutine_threadsafe(coro, loop)\n+        future.add_done_callback(self._log_background_failure)\n+\n+    def _handle_approval_card_action(self, *, event: Any, action_value: Dict[str, Any], loop: Any) -> Any:\n+        \"\"\"Schedule approval resolution and build the synchronous callback response.\"\"\"\n+        approval_id = action_value.get(\"approval_id\")\n+        if approval_id is None:\n+            logger.debug(\"[Feishu] Card action missing approval_id, ignoring\")\n+            return P2CardActionTriggerResponse() if P2CardActionTriggerResponse else None\n+        choice = _APPROVAL_CHOICE_MAP.get(action_value.get(\"hermes_action\"), \"deny\")\n+\n+        operator = getattr(event, \"operator\", None)\n+        open_id = str(getattr(operator, \"open_id\", \"\") or \"\")\n+        user_name = self._get_cached_sender_name(open_id) or open_id\n+\n+        self._submit_on_loop(loop, self._resolve_approval(approval_id, choice, user_name))\n+\n+        if P2CardActionTriggerResponse is None:\n+            return None\n+        response = P2CardActionTriggerResponse()\n+        if CallBackCard is not None:\n+            card = CallBackCard()\n+            card.type = \"raw\"\n+            card.data = self._build_resolved_approval_card(choice=choice, user_name=user_name)\n+            response.card = card\n+        return response\n+\n+    async def _resolve_approval(self, approval_id: Any, choice: str, user_name: str) -> None:\n+        \"\"\"Pop approval state and unblock the waiting agent thread.\"\"\"\n+        state = self._approval_state.pop(approval_id, None)\n+        if not state:\n+            logger.debug(\"[Feishu] Approval %s already resolved or unknown\", approval_id)\n+            return\n+        try:\n+            from tools.approval import resolve_gateway_approval\n+            count = resolve_gateway_approval(state[\"session_key\"], choice)\n+            logger.info(\n+                \"Feishu button resolved %d approval(s) for session %s (choice=%s, user=%s)\",\n+                count, state[\"session_key\"], choice, user_name,\n+            )\n+        except Exception as exc:\n+            logger.error(\"Failed to resolve gateway approval from Feishu button: %s\", exc)\n+\n     async def _handle_reaction_event(self, event_type: str, data: Any) -> None:\n         \"\"\"Fetch the reacted-to message; if it was sent by this bot, emit a synthetic text event.\"\"\"\n         if not self._client:","@@ -1950,51 +2020,6 @@ async def _handle_card_action_event(self, data: Any) -> None:\n         action_tag = str(getattr(action, \"tag\", \"\") or \"button\")\n         action_value = getattr(action, \"value\", {}) or {}\n \n-        # --- Exec approval button intercept ---\n-        hermes_action = action_value.get(\"hermes_action\") if isinstance(action_value, dict) else None\n-        if hermes_action:\n-            approval_id = action_value.get(\"approval_id\")\n-            state = self._approval_state.pop(approval_id, None)\n-            if not state:\n-                logger.debug(\"[Feishu] Approval %s already resolved or unknown\", approval_id)\n-                return\n-\n-            choice_map = {\n-                \"approve_once\": \"once\",\n-                \"approve_session\": \"session\",\n-                \"approve_always\": \"always\",\n-                \"deny\": \"deny\",\n-            }\n-            choice = choice_map.get(hermes_action, \"deny\")\n-\n-            label_map = {\n-                \"once\": \"Approved once\",\n-                \"session\": \"Approved for session\",\n-                \"always\": \"Approved permanently\",\n-                \"deny\": \"Denied\",\n-            }\n-            label = label_map.get(choice, \"Resolved\")\n-\n-            # Resolve sender name for the status card\n-            sender_id = SimpleNamespace(open_id=open_id, user_id=None, union_id=None)\n-            sender_profile = await self._resolve_sender_profile(sender_id)\n-            user_name = sender_profile.get(\"user_name\") or open_id\n-\n-            # Resolve the approval — unblocks the agent thread\n-            try:\n-                from tools.approval import resolve_gateway_approval\n-                count = resolve_gateway_approval(state[\"session_key\"], choice)\n-                logger.info(\n-                    \"Feishu button resolved %d approval(s) for session %s (choice=%s, user=%s)\",\n-                    count, state[\"session_key\"], choice, user_name,\n-                )\n-            except Exception as exc:\n-                logger.error(\"Failed to resolve gateway approval from Feishu button: %s\", exc)\n-\n-            # Update the card to show the decision\n-            await self._update_approval_card(state.get(\"message_id\", \"\"), label, user_name, choice)\n-            return\n-\n         synthetic_text = f\"/card {action_tag}\"\n         if action_value:\n             try:","@@ -2897,6 +2922,19 @@ async def _resolve_sender_profile(self, sender_id: Any) -> Dict[str, Optional[st\n             \"user_id_alt\": union_id,\n         }\n \n+    def _get_cached_sender_name(self, sender_id: Optional[str]) -> Optional[str]:\n+        \"\"\"Return a cached sender name only while its TTL is still valid.\"\"\"\n+        if not sender_id:\n+            return None\n+        cached = self._sender_name_cache.get(sender_id)\n+        if cached is None:\n+            return None\n+        name, expire_at = cached\n+        if time.time() < expire_at:\n+            return name\n+        self._sender_name_cache.pop(sender_id, None)\n+        return None\n+\n     async def _resolve_sender_name_from_api(self, sender_id: Optional[str]) -> Optional[str]:\n         \"\"\"Fetch the sender's display name from the Feishu contact API with a 10-minute cache.\n ","@@ -2909,11 +2947,9 @@ async def _resolve_sender_name_from_api(self, sender_id: Optional[str]) -> Optio\n         if not trimmed:\n             return None\n         now = time.time()\n-        cached = self._sender_name_cache.get(trimmed)\n-        if cached is not None:\n-            name, expire_at = cached\n-            if now < expire_at:\n-                return name\n+        cached_name = self._get_cached_sender_name(trimmed)\n+        if cached_name is not None:\n+            return cached_name\n         try:\n             from lark_oapi.api.contact.v3 import GetUserRequest  # lazy import\n             if trimmed.startswith(\"ou_\"):"]},{"file":"scripts/release.py","hunks":["@@ -62,6 +62,7 @@\n     \"258577966+voidborne-d@users.noreply.github.com\": \"voidborne-d\",\n     \"70424851+insecurejezza@users.noreply.github.com\": \"insecurejezza\",\n     \"259807879+Bartok9@users.noreply.github.com\": \"Bartok9\",\n+    \"268667990+Roy-oss1@users.noreply.github.com\": \"Roy-oss1\",\n     # contributors (manual mapping from git names)\n     \"[redacted:email]\": \"dmahan93\",\n     \"[redacted:email]\": \"samherring99\","]},{"file":"tests/gateway/test_feishu_approval_buttons.py","hunks":["@@ -1,12 +1,11 @@\n \"\"\"Tests for Feishu interactive card approval buttons.\"\"\"\n \n-import asyncio\n+import importlib.util\n import json\n-import os\n import sys\n from pathlib import Path\n from types import SimpleNamespace\n-from unittest.mock import AsyncMock, MagicMock, Mock, patch\n+from unittest.mock import AsyncMock, MagicMock, patch\n \n import pytest\n ","@@ -23,14 +22,14 @@\n # ---------------------------------------------------------------------------\n def _ensure_feishu_mocks():\n     \"\"\"Provide stubs for lark-oapi / aiohttp.web so the import succeeds.\"\"\"\n-    if \"lark_oapi\" not in sys.modules:\n+    if importlib.util.find_spec(\"lark_oapi\") is None and \"lark_oapi\" not in sys.modules:\n         mod = MagicMock()\n         for name in (\n             \"lark_oapi\", \"lark_oapi.api.im.v1\",\n             \"lark_oapi.event\", \"lark_oapi.event.callback_type\",\n         ):\n             sys.modules.setdefault(name, mod)\n-    if \"aiohttp\" not in sys.modules:\n+    if importlib.util.find_spec(\"aiohttp\") is None and \"aiohttp\" not in sys.modules:\n         aio = MagicMock()\n         sys.modules.setdefault(\"aiohttp\", aio)\n         sys.modules.setdefault(\"aiohttp.web\", aio.web)","@@ -39,6 +38,7 @@ def _ensure_feishu_mocks():\n _ensure_feishu_mocks()\n \n from gateway.config import PlatformConfig\n+import gateway.platforms.feishu as feishu_module\n from gateway.platforms.feishu import FeishuAdapter\n \n ","@@ -74,6 +74,12 @@ def _make_card_action_data(\n     )\n \n \n+def _close_submitted_coro(coro, _loop):\n+    \"\"\"Close scheduled coroutines in sync-handler tests to avoid unawaited warnings.\"\"\"\n+    coro.close()\n+    return SimpleNamespace(add_done_callback=lambda *_args, **_kwargs: None)\n+\n+\n # ===========================================================================\n # send_exec_approval — interactive card with buttons\n # ===========================================================================","@@ -203,14 +209,14 @@ async def test_multiple_approvals_get_unique_ids(self):\n \n \n # ===========================================================================\n-# _handle_card_action_event — approval button clicks\n+# _resolve_approval — approval state pop + gateway resolution\n # ===========================================================================\n \n-class TestFeishuApprovalCallback:\n-    \"\"\"Test the approval intercept in _handle_card_action_event.\"\"\"\n+class TestResolveApproval:\n+    \"\"\"Test _resolve_approval pops state and calls resolve_gateway_approval.\"\"\"\n \n     @pytest.mark.asyncio\n-    async def test_resolves_approval_on_click(self):\n+    async def test_resolves_once(self):\n         adapter = _make_adapter()\n         adapter._approval_state[1] = {\n             \"session_key\": \"agent:main:feishu:group:oc_12345\",","@@ -218,28 +224,14 @@ async def test_resolves_approval_on_click(self):\n             \"chat_id\": \"oc_12345\",\n         }\n \n-        data = _make_card_action_data(\n-            action_value={\"hermes_action\": \"approve_once\", \"approval_id\": 1},\n-        )\n-\n-        with (\n-            patch.object(\n-                adapter, \"_resolve_sender_profile\", new_callable=AsyncMock,\n-                return_value={\"user_id\": \"ou_user1\", \"user_name\": \"Norbert\", \"user_id_alt\": None},\n-            ),\n-            patch.object(adapter, \"_update_approval_card\", new_callable=AsyncMock) as mock_update,\n-            patch(\"tools.approval.resolve_gateway_approval\", return_value=1) as mock_resolve,\n-        ):\n-            await adapter._handle_card_action_event(data)\n+        with patch(\"tools.approval.resolve_gateway_approval\", return_value=1) as mock_resolve:\n+            await adapter._resolve_approval(1, \"once\", \"Norbert\")\n \n         mock_resolve.assert_called_once_with(\"agent:main:feishu:group:oc_12345\", \"once\")\n-        mock_update.assert_called_once_with(\"msg_001\", \"Approved once\", \"Norbert\", \"once\")\n-\n-        # State should be cleaned up\n         assert 1 not in adapter._approval_state\n \n     @pytest.mark.asyncio\n-    async def test_deny_button(self):\n+    async def test_resolves_deny(self):\n         adapter = _make_adapter()\n         adapter._approval_state[2] = {\n             \"session_key\": \"some-session\",","@@ -247,26 +239,13 @@ async def test_deny_button(self):\n             \"chat_id\": \"oc_12345\",\n         }\n \n-        data = _make_card_action_data(\n-            action_value={\"hermes_action\": \"deny\", \"approval_id\": 2},\n-            token=\"[redacted:secret-assignment]\",\n-        )\n-\n-        with (\n-            patch.object(\n-                adapter, \"_resolve_sender_profile\", new_callable=AsyncMock,\n-                return_value={\"user_id\": \"ou_alice\", \"user_name\": \"Alice\", \"user_id_alt\": None},\n-            ),\n-            patch.object(adapter, \"_update_approval_card\", new_callable=AsyncMock) as mock_update,\n-            patch(\"tools.approval.resolve_gateway_approval\", return_value=1) as mock_resolve,\n-        ):\n-            await adapter._handle_card_action_event(data)\n+        with patch(\"tools.approval.resolve_gateway_approval\", return_value=1) as mock_resolve:\n+            await adapter._resolve_approval(2, \"deny\", \"Alice\")\n \n         mock_resolve.assert_called_once_with(\"some-session\", \"deny\")\n-        mock_update.assert_called_once_with(\"msg_002\", \"Denied\", \"Alice\", \"deny\")\n \n     @pytest.mark.asyncio\n-    async def test_session_approval(self):\n+    async def test_resolves_session(self):\n         adapter = _make_adapter()\n         adapter._approval_state[3] = {\n             \"session_key\": \"sess-3\",","@@ -274,26 +253,13 @@ async def test_session_approval(self):\n             \"chat_id\": \"oc_99\",\n         }\n \n-        data = _make_card_action_data(\n-            action_value={\"hermes_action\": \"approve_session\", \"approval_id\": 3},\n-            token=\"[redacted:secret-assignment]\",\n-        )\n-\n-        with (\n-            patch.object(\n-                adapter, \"_resolve_sender_profile\", new_callable=AsyncMock,\n-                return_value={\"user_id\": \"ou_u\", \"user_name\": \"Bob\", \"user_id_alt\": None},\n-            ),\n-            patch.object(adapter, \"_update_approval_card\", new_callable=AsyncMock) as mock_update,\n-            patch(\"tools.approval.resolve_gateway_approval\", return_value=1) as mock_resolve,\n-        ):\n-            await adapter._handle_card_action_event(data)\n+        with patch(\"tools.approval.resolve_gateway_approval\", return_value=1) as mock_resolve:\n+            await adapter._resolve_approval(3, \"session\", \"Bob\")\n \n         mock_resolve.assert_called_once_with(\"sess-3\", \"session\")\n-        mock_update.assert_called_once_with(\"msg_003\", \"Approved for session\", \"Bob\", \"session\")\n \n     @pytest.mark.asyncio\n-    async def test_always_approval(self):\n+    async def test_resolves_always(self):\n         adapter = _make_adapter()\n         adapter._approval_state[4] = {\n             \"session_key\": \"sess-4\",","@@ -301,42 +267,29 @@ async def test_always_approval(self):\n             \"chat_id\": \"oc_55\",\n         }\n \n-        data = _make_card_action_data(\n-            action_value={\"hermes_action\": \"approve_always\", \"approval_id\": 4},\n-            token=\"[redacted:secret-assignment]\",\n-        )\n-\n-        with (\n-            patch.object(\n-                adapter, \"_resolve_sender_profile\", new_callable=AsyncMock,\n-                return_value={\"user_id\": \"ou_u\", \"user_name\": \"Carol\", \"user_id_alt\": None},\n-            ),\n-            patch.object(adapter, \"_update_approval_card\", new_callable=AsyncMock),\n-            patch(\"tools.approval.resolve_gateway_approval\", return_value=1) as mock_resolve,\n-        ):\n-            await adapter._handle_card_action_event(data)\n+        with patch(\"tools.approval.resolve_gateway_approval\", return_value=1) as mock_resolve:\n+            await adapter._resolve_approval(4, \"always\", \"Carol\")\n \n         mock_resolve.assert_called_once_with(\"sess-4\", \"always\")\n \n     @pytest.mark.asyncio\n     async def test_already_resolved_drops_silently(self):\n         adapter = _make_adapter()\n-        # No state for approval_id 99 — already resolved\n-\n-        data = _make_card_action_data(\n-            action_value={\"hermes_action\": \"approve_once\", \"approval_id\": 99},\n-            token=\"[redacted:secret-assignment]\",\n-        )\n \n         with patch(\"tools.approval.resolve_gateway_approval\") as mock_resolve:\n-            await adapter._handle_card_action_event(data)\n+            await adapter._resolve_approval(99, \"once\", \"Nobody\")\n \n-        # Should NOT resolve — already handled\n         mock_resolve.assert_not_called()\n \n+# ===========================================================================\n+# _handle_card_action_event — non-approval card actions\n+# ===========================================================================\n+\n+class TestNonApprovalCardAction:\n+    \"\"\"Non-approval card actions should still route as synthetic commands.\"\"\"\n+\n     @pytest.mark.asyncio\n-    async def test_non_approval_actions_route_normally(self):\n-        \"\"\"Non-approval card actions should still become synthetic commands.\"\"\"\n+    async def test_routes_as_synthetic_command(self):\n         adapter = _make_adapter()\n \n         data = _make_card_action_data(","@@ -351,82 +304,141 @@ async def test_non_approval_actions_route_normally(self):\n             ),\n             patch.object(adapter, \"get_chat_info\", new_callable=AsyncMock, return_value={\"name\": \"Test Chat\"}),\n             patch.object(adapter, \"_handle_message_with_guards\", new_callable=AsyncMock) as mock_handle,\n-            patch(\"tools.approval.resolve_gateway_approval\") as mock_resolve,\n         ):\n             await adapter._handle_card_action_event(data)\n \n-        # Should NOT resolve any approval\n-        mock_resolve.assert_not_called()\n-        # Should have routed as synthetic command\n         mock_handle.assert_called_once()\n         event = mock_handle.call_args[0][0]\n         assert \"/card button\" in event.text\n \n \n # ===========================================================================\n-# _update_approval_card — card replacement after resolution\n+# _on_card_action_trigger — inline card response for approval actions\n # ===========================================================================\n \n-class TestFeishuUpdateApprovalCard:\n-    \"\"\"Test the card update after approval resolution.\"\"\"\n+class _FakeCallBackCard:\n+    def __init__(self):\n+        self.type = None\n+        self.data = None\n \n-    @pytest.mark.asyncio\n-    async def test_updates_card_on_approve(self):\n+\n+class _FakeP2Response:\n+    def __init__(self):\n+        self.card = None\n+\n+\n[redacted:email](autouse=False)\n+def _patch_callback_card_types(monkeypatch):\n+    \"\"\"Provide real-ish P2CardActionTriggerResponse / CallBackCard for tests.\"\"\"\n+    monkeypatch.setattr(feishu_module, \"P2CardActionTriggerResponse\", _FakeP2Response)\n+    monkeypatch.setattr(feishu_module, \"CallBackCard\", _FakeCallBackCard)\n+\n+\n+class TestCardActionCallbackResponse:\n+    \"\"\"Test that _on_card_action_trigger returns updated card inline.\"\"\"\n+\n+    def test_drops_action_when_loop_not_ready(self, _patch_callback_card_types):\n         adapter = _make_adapter()\n+        adapter._loop = None\n+        data = _make_card_action_data({\"hermes_action\": \"approve_once\", \"approval_id\": 1})\n \n-        mock_update = AsyncMock()\n-        adapter._client.im.v1.message.update = MagicMock()\n+        with patch(\"asyncio.run_coroutine_threadsafe\") as mock_submit:\n+            response = adapter._on_card_action_trigger(data)\n \n-        with patch(\"asyncio.to_thread\", new_callable=AsyncMock) as mock_thread:\n-            await adapter._update_approval_card(\n-                \"msg_001\", \"Approved once\", \"Norbert\", \"once\"\n-            )\n+        assert response is not None\n+        assert response.card is None\n+        mock_submit.assert_not_called()\n \n-        mock_thread.assert_called_once()\n-        # Verify the update request was built\n-        call_args = mock_thread.call_args\n-        assert call_args[0][0] == adapter._client.im.v1.message.update\n+    def test_returns_card_for_approve_action(self, _patch_callback_card_types):\n+        adapter = _make_adapter()\n+        adapter._loop = MagicMock()\n+        adapter._loop.is_closed = MagicMock(return_value=False)\n+        data = _make_card_action_data(\n+            {\"hermes_action\": \"approve_once\", \"approval_id\": 1},\n+            open_id=\"ou_bob\",\n+        )\n+        adapter._sender_name_cache[\"ou_bob\"] = (\"Bob\", 9999999999)\n \n-    @pytest.mark.asyncio\n-    async def test_updates_card_on_deny(self):\n+        with patch(\"asyncio.run_coroutine_threadsafe\", side_effect=_close_submitted_coro):\n+            response = adapter._on_card_action_trigger(data)\n+\n+        assert response is not None\n+        assert response.card is not None\n+        assert response.card.type == \"raw\"\n+        card = response.card.data\n+        assert card[\"header\"][\"template\"] == \"green\"\n+        assert \"Approved once\" in card[\"header\"][\"title\"][\"content\"]\n+        assert \"Bob\" in card[\"elements\"][0][\"content\"]\n+\n+    def test_returns_card_for_deny_action(self, _patch_callback_card_types):\n         adapter = _make_adapter()\n+        adapter._loop = MagicMock()\n+        adapter._loop.is_closed = MagicMock(return_value=False)\n+        data = _make_card_action_data(\n+            {\"hermes_action\": \"deny\", \"approval_id\": 2},\n+        )\n \n-        with patch(\"asyncio.to_thread\", new_callable=AsyncMock) as mock_thread:\n-            await adapter._update_approval_card(\n-                \"msg_002\", \"Denied\", \"Alice\", \"deny\"\n-            )\n+        with patch(\"asyncio.run_coroutine_threadsafe\", side_effect=_close_submitted_coro):\n+            response = adapter._on_card_action_trigger(data)\n \n-        mock_thread.assert_called_once()\n+        assert response.card is not None\n+        card = response.card.data\n+        assert card[\"header\"][\"template\"] == \"red\"\n+        assert \"Denied\" in card[\"header\"][\"title\"][\"content\"]\n \n-    @pytest.mark.asyncio\n-    async def test_skips_update_when_not_connected(self):\n+    def test_ignores_missing_approval_id(self, _patch_callback_card_types):\n         adapter = _make_adapter()\n-        adapter._client = None\n+        adapter._loop = MagicMock()\n+        adapter._loop.is_closed = MagicMock(return_value=False)\n+        data = _make_card_action_data({\"hermes_action\": \"approve_once\"})\n \n-        with patch(\"asyncio.to_thread\", new_callable=AsyncMock) as mock_thread:\n-            await adapter._update_approval_card(\n-                \"msg_001\", \"Approved\", \"Bob\", \"once\"\n-            )\n+        with patch(\"asyncio.run_coroutine_threadsafe\") as mock_submit:\n+            response = adapter._on_card_action_trigger(data)\n \n-        mock_thread.assert_not_called()\n+        assert response is not None\n+        assert response.card is None\n+        mock_submit.assert_not_called()\n \n-    @pytest.mark.asyncio\n-    async def test_skips_update_when_no_message_id(self):\n+    def test_no_card_for_non_approval_action(self, _patch_callback_card_types):\n         adapter = _make_adapter()\n+        adapter._loop = MagicMock()\n+        adapter._loop.is_closed = MagicMock(return_value=False)\n+        data = _make_card_action_data({\"some_other\": \"value\"})\n \n-        with patch(\"asyncio.to_thread\", new_callable=AsyncMock) as mock_thread:\n-            await adapter._update_approval_card(\n-                \"\", \"Approved\", \"Bob\", \"once\"\n-            )\n+        with patch(\"asyncio.run_coroutine_threadsafe\", side_effect=_close_submitted_coro):\n+            response = adapter._on_card_action_trigger(data)\n \n-        mock_thread.assert_not_called()\n+        assert response is not None\n+        assert response.card is None\n \n-    @pytest.mark.asyncio\n-    async def test_swallows_update_errors(self):\n+    def test_falls_back_to_open_id_when_name_not_cached(self, _patch_callback_card_types):\n         adapter = _make_adapter()\n+        adapter._loop = MagicMock()\n+        adapter._loop.is_closed = MagicMock(return_value=False)\n+        data = _make_card_action_data(\n+            {\"hermes_action\": \"approve_session\", \"approval_id\": 3},\n+            open_id=\"ou_unknown\",\n+        )\n \n-        with patch(\"asyncio.to_thread\", new_callable=AsyncMock, side_effect=Exception(\"API error\")):\n-            # Should not raise\n-            await adapter._update_approval_card(\n-                \"msg_001\", \"Approved\", \"Bob\", \"once\"\n-            )\n+        with patch(\"asyncio.run_coroutine_threadsafe\", side_effect=_close_submitted_coro):\n+            response = adapter._on_card_action_trigger(data)\n+\n+        card = response.card.data\n+        assert \"ou_unknown\" in card[\"elements\"][0][\"content\"]\n+\n+    def test_ignores_expired_cached_name(self, _patch_callback_card_types):\n+        adapter = _make_adapter()\n+        adapter._loop = MagicMock()\n+        adapter._loop.is_closed = MagicMock(return_value=False)\n+        data = _make_card_action_data(\n+            {\"hermes_action\": \"approve_once\", \"approval_id\": 4},\n+            open_id=\"ou_expired\",\n+        )\n+        adapter._sender_name_cache[\"ou_expired\"] = (\"Old Name\", 1)\n+\n+        with patch(\"asyncio.run_coroutine_threadsafe\", side_effect=_close_submitted_coro):\n+            response = adapter._on_card_action_trigger(data)\n+\n+        card = response.card.data\n+        assert \"Old Name\" not in card[\"elements\"][0][\"content\"]\n+        assert \"ou_expired\" in card[\"elements\"][0][\"content\"]\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}