From 21245fefac287bbb4eedef02f3492d1f45feffbf Mon Sep 17 00:00:00 2001 From: Simon van Laak Date: Thu, 13 Aug 2026 10:21:19 -0700 Subject: [PATCH] =?UTF-8?q?test(slack):=20cover=20native=20task-card=20pro?= =?UTF-8?q?gress=20=E2=80=94=20ID=20correlation,=20workspace=20scoping,=20?= =?UTF-8?q?fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports the test coverage from PR #29496 onto the salvaged implementation: adapter-level serialization/workspace isolation/disconnect sealing, and gateway-level ID-correlated concurrent duplicate tools plus the editable text fallback when the native stream fails. --- tests/gateway/test_run_progress_topics.py | 157 ++++++++++++++++++++++ tests/gateway/test_slack.py | 122 +++++++++++++++++ 2 files changed, 279 insertions(+) diff --git a/tests/gateway/test_run_progress_topics.py b/tests/gateway/test_run_progress_topics.py index da3856fb35..da3dd2efc7 100644 --- a/tests/gateway/test_run_progress_topics.py +++ b/tests/gateway/test_run_progress_topics.py @@ -229,6 +229,85 @@ class FakeAgent: } +class NativeTaskCardAdapter(ProgressCaptureAdapter): + def __init__(self, platform=Platform.SLACK): + super().__init__(platform=platform) + self.native_updates = [] + self.native_stops = 0 + + def native_task_cards_enabled(self): + return True + + async def send_native_task_card_progress( + self, + chat_id, + tasks, + *, + title, + reply_to=None, + metadata=None, + fallback_text=None, + ) -> SendResult: + self.native_updates.append( + { + "chat_id": chat_id, + "tasks": [dict(task) for task in tasks], + "metadata": dict(metadata or {}), + "fallback_text": fallback_text, + } + ) + return SendResult(success=True, message_id="native-stream-1") + + async def stop_native_task_card_progress( + self, chat_id, *, reply_to=None, metadata=None + ): + self.native_stops += 1 + + async def edit_message( + self, chat_id, message_id, content, *, finalize=False, metadata=None + ) -> SendResult: + self.edits.append( + { + "chat_id": chat_id, + "message_id": message_id, + "content": content, + "metadata": metadata, + } + ) + return SendResult(success=True, message_id=message_id) + + +class FailingNativeTaskCardAdapter(NativeTaskCardAdapter): + async def send_native_task_card_progress(self, *args, **kwargs) -> SendResult: + await super().send_native_task_card_progress(*args, **kwargs) + return SendResult(success=False, error="native stream unavailable", retryable=True) + + +class DuplicateNativeToolsAgent: + def __init__(self, **kwargs): + self.tool_progress_callback = kwargs.get("tool_progress_callback") + self.tool_start_callback = kwargs.get("tool_start_callback") + self.tool_complete_callback = kwargs.get("tool_complete_callback") + self.tools = [] + + def run_conversation(self, message, conversation_history=None, task_id=None): + self.tool_start_callback("call-a", "web_search", {"query": "alpha"}) + time.sleep(0.15) + self.tool_start_callback("call-b", "web_search", {"query": "beta"}) + time.sleep(0.15) + # Complete the second same-name call first. Correlation by tool name + # would incorrectly mark call-a as failed here. + self.tool_complete_callback( + "call-b", "web_search", {"query": "beta"}, '{"error": "boom"}' + ) + time.sleep(0.15) + self.tool_complete_callback( + "call-a", "web_search", {"query": "alpha"}, '{"success": true}' + ) + time.sleep(0.15) + return {"final_response": "done", "messages": [], "api_calls": 1} + + class ThinkingAgent: """Agent that emits _thinking scratch text (no tool calls). @@ -925,6 +1004,8 @@ async def _run_with_agent( chat_type="group", thread_id="17585", adapter_cls=ProgressCaptureAdapter, + user_id=None, + scope_id=None, ): if config_data: import yaml @@ -951,6 +1032,8 @@ async def _run_with_agent( chat_id=chat_id, chat_type=chat_type, thread_id=thread_id, + user_id=user_id, + scope_id=scope_id, ) session_key = f"agent:main:{platform.value}:{chat_type}:{chat_id}" if thread_id: @@ -974,6 +1057,80 @@ async def _run_with_agent( return adapter, result +@pytest.mark.asyncio +async def test_slack_native_progress_correlates_concurrent_duplicate_tools_by_id( + monkeypatch, tmp_path +): + adapter, result = await _run_with_agent( + monkeypatch, + tmp_path, + DuplicateNativeToolsAgent, + session_id="sess-native-ids", + config_data={ + "display": {"platforms": {"slack": {"tool_progress": "off"}}} + }, + platform=Platform.SLACK, + chat_id="C1", + thread_id="thread-1", + adapter_cls=NativeTaskCardAdapter, + user_id="U1", + scope_id="T1", + ) + + assert result["final_response"] == "done" + assert adapter.native_updates + second_completed = next( + update + for update in adapter.native_updates + if {task["id"]: task["status"] for task in update["tasks"]} + == {"call-a": "in_progress", "call-b": "error"} + ) + assert second_completed["metadata"]["recipient_team_id"] == "T1" + assert second_completed["metadata"]["recipient_user_id"] == "U1" + assert adapter.native_updates[-1]["tasks"] == [ + { + "id": "call-a", + "title": "web_search - alpha", + "status": "complete", + }, + { + "id": "call-b", + "title": "web_search - beta", + "status": "error", + }, + ] + assert adapter.sent == [] + assert adapter.native_stops == 1 + + +@pytest.mark.asyncio +async def test_slack_native_failure_keeps_editing_one_live_text_fallback( + monkeypatch, tmp_path +): + adapter, result = await _run_with_agent( + monkeypatch, + tmp_path, + DuplicateNativeToolsAgent, + session_id="sess-native-fallback", + platform=Platform.SLACK, + chat_id="C1", + thread_id="thread-1", + adapter_cls=FailingNativeTaskCardAdapter, + user_id="U1", + scope_id="T1", + ) + + assert result["final_response"] == "done" + assert len(adapter.native_updates) == 1 + assert len(adapter.sent) == 1 + assert adapter.sent[0]["content"].endswith("web_search - alpha - running") + assert len(adapter.edits) >= 2 + assert {edit["message_id"] for edit in adapter.edits} == {"progress-1"} + assert adapter.edits[-1]["content"].endswith("web_search - beta - error") + assert "web_search - alpha - complete" in adapter.edits[-1]["content"] + assert adapter.native_stops == 1 + + @pytest.mark.asyncio async def test_retryable_overflow_edit_keeps_editable_bubble_identity(monkeypatch, tmp_path): """A transient split edit must retain can_edit and the current message ID.""" diff --git a/tests/gateway/test_slack.py b/tests/gateway/test_slack.py index d9acaefaf6..2d770c236b 100644 --- a/tests/gateway/test_slack.py +++ b/tests/gateway/test_slack.py @@ -4559,3 +4559,125 @@ class TestSlackUserAgent: elsewhere in the codebase for platform-partner attribution.""" assert _slack_mod._HERMES_SLACK_USER_AGENT_PREFIX.startswith("HermesAgent/") + +class TestNativeTaskCardProgress: + def test_native_flag_is_an_explicit_opt_in(self): + config = PlatformConfig( + enabled=True, + token="xoxb-fake-token", + extra={"native_task_cards": "true"}, + ) + + assert SlackAdapter(config).native_task_cards_enabled() is True + assert SlackAdapter( + PlatformConfig(enabled=True, token="xoxb-fake-token") + ).native_task_cards_enabled() is False + + @pytest.mark.asyncio + async def test_native_updates_are_serialized_and_workspace_scoped(self, adapter): + team_client = AsyncMock() + start_count = 0 + + async def api_call(method, *, json): + nonlocal start_count + if method == "chat.startStream": + start_count += 1 + await asyncio.sleep(0) + return {"ts": "stream-1"} + return {"ok": True} + + team_client.api_call.side_effect = api_call + adapter._team_clients["T1"] = team_client + metadata = { + "thread_id": "thread-1", + "slack_team_id": "T1", + "recipient_team_id": "T1", + "recipient_user_id": "U1", + } + first = [{"id": "call-1", "title": "terminal", "status": "in_progress"}] + second = [{"id": "call-1", "title": "terminal", "status": "complete"}] + + results = await asyncio.gather( + adapter.send_native_task_card_progress("C1", first, metadata=metadata), + adapter.send_native_task_card_progress("C1", second, metadata=metadata), + ) + + assert all(result.success for result in results) + assert start_count == 1 + calls = team_client.api_call.await_args_list + assert [call.args[0] for call in calls] == [ + "chat.startStream", + "chat.appendStream", + "chat.appendStream", + ] + assert calls[0].kwargs["json"] == { + "channel": "C1", + "thread_ts": "thread-1", + "task_display_mode": "plan", + "recipient_team_id": "T1", + "recipient_user_id": "U1", + } + adapter._app.client.api_call.assert_not_awaited() + + await adapter.stop_native_task_card_progress("C1", metadata=metadata) + + assert team_client.api_call.await_args.args[0] == "chat.stopStream" + assert adapter._native_task_card_streams == {} + + @pytest.mark.asyncio + async def test_same_channel_thread_isolated_between_workspaces(self, adapter): + clients = {"T1": AsyncMock(), "T2": AsyncMock()} + + def api_call_for(team_id): + async def api_call(method, *, json): + if method == "chat.startStream": + return {"ts": f"stream-{team_id}"} + return {"ok": True} + + return api_call + + for team_id, client in clients.items(): + client.api_call.side_effect = api_call_for(team_id) + adapter._team_clients.update(clients) + tasks = [{"id": "call-1", "title": "search", "status": "in_progress"}] + + await asyncio.gather( + *( + adapter.send_native_task_card_progress( + "C-shared", + tasks, + metadata={"thread_id": "thread-shared", "slack_team_id": team_id}, + ) + for team_id in clients + ) + ) + + assert set(adapter._native_task_card_streams) == { + ("T1", "C-shared", "thread-shared"), + ("T2", "C-shared", "thread-shared"), + } + for client in clients.values(): + assert client.api_call.await_args_list[0].args[0] == "chat.startStream" + + @pytest.mark.asyncio + async def test_disconnect_stops_active_native_streams(self, adapter): + client = adapter._app.client + client.api_call.side_effect = [ + {"ts": "stream-1"}, + {"ok": True}, + {"ok": True}, + ] + await adapter.send_native_task_card_progress( + "C1", + [{"id": "call-1", "title": "terminal", "status": "in_progress"}], + metadata={"thread_id": "thread-1"}, + ) + + await adapter.disconnect() + + assert [call.args[0] for call in client.api_call.await_args_list] == [ + "chat.startStream", + "chat.appendStream", + "chat.stopStream", + ] + assert adapter._native_task_card_streams == {}