""" Tests for Slack platform adapter. Covers: app_mention handler, send_document, send_video, incoming document handling, message routing. Note: slack-bolt may not be installed in the test environment. We mock the slack modules at import time to avoid collection errors. """ import asyncio import os import sys from unittest.mock import AsyncMock, MagicMock, patch import pytest from gateway.config import Platform, PlatformConfig from gateway.platforms.base import ( MessageEvent, MessageType, SendResult, SUPPORTED_DOCUMENT_TYPES, ) # --------------------------------------------------------------------------- # Mock the slack-bolt package if it's not installed # --------------------------------------------------------------------------- def _ensure_slack_mock(): """Install mock slack modules so SlackAdapter can be imported.""" if "slack_bolt" in sys.modules and hasattr(sys.modules["slack_bolt"], "__file__"): return # Real library installed slack_bolt = MagicMock() slack_bolt.async_app.AsyncApp = MagicMock slack_bolt.adapter.socket_mode.async_handler.AsyncSocketModeHandler = MagicMock slack_sdk = MagicMock() slack_sdk.web.async_client.AsyncWebClient = MagicMock for name, mod in [ ("slack_bolt", slack_bolt), ("slack_bolt.async_app", slack_bolt.async_app), ("slack_bolt.adapter", slack_bolt.adapter), ("slack_bolt.adapter.socket_mode", slack_bolt.adapter.socket_mode), ("slack_bolt.adapter.socket_mode.async_handler", slack_bolt.adapter.socket_mode.async_handler), ("slack_sdk", slack_sdk), ("slack_sdk.web", slack_sdk.web), ("slack_sdk.web.async_client", slack_sdk.web.async_client), ]: sys.modules.setdefault(name, mod) _ensure_slack_mock() # Patch SLACK_AVAILABLE before importing the adapter import gateway.platforms.slack as _slack_mod _slack_mod.SLACK_AVAILABLE = True from gateway.platforms.slack import SlackAdapter # noqa: E402 # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @pytest.fixture() def adapter(): config = PlatformConfig(enabled=True, token="xoxb-fake-token") a = SlackAdapter(config) # Mock the Slack app client a._app = MagicMock() a._app.client = AsyncMock() a._bot_user_id = "U_BOT" a._running = True # Capture events instead of processing them a.handle_message = AsyncMock() return a @pytest.fixture(autouse=True) def _redirect_cache(tmp_path, monkeypatch): """Point document cache to tmp_path so tests don't touch ~/.hermes.""" monkeypatch.setattr( "gateway.platforms.base.DOCUMENT_CACHE_DIR", tmp_path / "doc_cache" ) # --------------------------------------------------------------------------- # TestAppMentionHandler # --------------------------------------------------------------------------- class TestAppMentionHandler: """Verify that the app_mention event handler is registered.""" def test_app_mention_registered_on_connect(self): """connect() should register message + assistant lifecycle handlers.""" config = PlatformConfig(enabled=True, token="xoxb-fake") adapter = SlackAdapter(config) # Track which events get registered registered_events = [] registered_commands = [] mock_app = MagicMock() def mock_event(event_type): def decorator(fn): registered_events.append(event_type) return fn return decorator def mock_command(cmd): def decorator(fn): registered_commands.append(cmd) return fn return decorator mock_app.event = mock_event mock_app.command = mock_command mock_app.client = AsyncMock() mock_app.client.auth_test = AsyncMock(return_value={ "user_id": "U_BOT", "user": "testbot", }) # Mock AsyncWebClient so multi-workspace auth_test is awaitable mock_web_client = AsyncMock() mock_web_client.auth_test = AsyncMock(return_value={ "user_id": "U_BOT", "user": "testbot", "team_id": "T_FAKE", "team": "FakeTeam", }) with patch.object(_slack_mod, "AsyncApp", return_value=mock_app), \ patch.object(_slack_mod, "AsyncWebClient", return_value=mock_web_client), \ patch.object(_slack_mod, "AsyncSocketModeHandler", return_value=MagicMock()), \ patch.dict(os.environ, {"SLACK_APP_TOKEN": "xapp-fake"}), \ patch("gateway.status.acquire_scoped_lock", return_value=(True, None)), \ patch("asyncio.create_task"): asyncio.run(adapter.connect()) assert "message" in registered_events assert "app_mention" in registered_events assert "assistant_thread_started" in registered_events assert "assistant_thread_context_changed" in registered_events assert "/hermes" in registered_commands # --------------------------------------------------------------------------- # TestSendDocument # --------------------------------------------------------------------------- class TestSendDocument: @pytest.mark.asyncio async def test_send_document_success(self, adapter, tmp_path): test_file = tmp_path / "report.pdf" test_file.write_bytes(b"%PDF-1.4 fake content") adapter._app.client.files_upload_v2 = AsyncMock(return_value={"ok": True}) result = await adapter.send_document( chat_id="C123", file_path=str(test_file), caption="Here's the report", ) assert result.success adapter._app.client.files_upload_v2.assert_called_once() call_kwargs = adapter._app.client.files_upload_v2.call_args[1] assert call_kwargs["channel"] == "C123" assert call_kwargs["file"] == str(test_file) assert call_kwargs["filename"] == "report.pdf" assert call_kwargs["initial_comment"] == "Here's the report" @pytest.mark.asyncio async def test_send_document_custom_name(self, adapter, tmp_path): test_file = tmp_path / "data.csv" test_file.write_bytes(b"a,b,c\n1,2,3") adapter._app.client.files_upload_v2 = AsyncMock(return_value={"ok": True}) result = await adapter.send_document( chat_id="C123", file_path=str(test_file), file_name="quarterly-report.csv", ) assert result.success call_kwargs = adapter._app.client.files_upload_v2.call_args[1] assert call_kwargs["filename"] == "quarterly-report.csv" @pytest.mark.asyncio async def test_send_document_missing_file(self, adapter): result = await adapter.send_document( chat_id="C123", file_path="/nonexistent/file.pdf", ) assert not result.success assert "not found" in result.error.lower() @pytest.mark.asyncio async def test_send_document_not_connected(self, adapter): adapter._app = None result = await adapter.send_document( chat_id="C123", file_path="/some/file.pdf", ) assert not result.success assert "Not connected" in result.error @pytest.mark.asyncio async def test_send_document_api_error_falls_back(self, adapter, tmp_path): test_file = tmp_path / "doc.pdf" test_file.write_bytes(b"content") adapter._app.client.files_upload_v2 = AsyncMock( side_effect=RuntimeError("Slack API error") ) # Should fall back to base class (text message) result = await adapter.send_document( chat_id="C123", file_path=str(test_file), ) # Base class send() is also mocked, so check it was attempted adapter._app.client.chat_postMessage.assert_called_once() @pytest.mark.asyncio async def test_send_document_with_thread(self, adapter, tmp_path): test_file = tmp_path / "notes.txt" test_file.write_bytes(b"some notes") adapter._app.client.files_upload_v2 = AsyncMock(return_value={"ok": True}) result = await adapter.send_document( chat_id="C123", file_path=str(test_file), reply_to="1234567890.123456", ) assert result.success call_kwargs = adapter._app.client.files_upload_v2.call_args[1] assert call_kwargs["thread_ts"] == "1234567890.123456" # --------------------------------------------------------------------------- # TestSendVideo # --------------------------------------------------------------------------- class TestSendVideo: @pytest.mark.asyncio async def test_send_video_success(self, adapter, tmp_path): video = tmp_path / "clip.mp4" video.write_bytes(b"fake video data") adapter._app.client.files_upload_v2 = AsyncMock(return_value={"ok": True}) result = await adapter.send_video( chat_id="C123", video_path=str(video), caption="Check this out", ) assert result.success call_kwargs = adapter._app.client.files_upload_v2.call_args[1] assert call_kwargs["filename"] == "clip.mp4" assert call_kwargs["initial_comment"] == "Check this out" @pytest.mark.asyncio async def test_send_video_missing_file(self, adapter): result = await adapter.send_video( chat_id="C123", video_path="/nonexistent/video.mp4", ) assert not result.success assert "not found" in result.error.lower() @pytest.mark.asyncio async def test_send_video_not_connected(self, adapter): adapter._app = None result = await adapter.send_video( chat_id="C123", video_path="/some/video.mp4", ) assert not result.success assert "Not connected" in result.error @pytest.mark.asyncio async def test_send_video_api_error_falls_back(self, adapter, tmp_path): video = tmp_path / "clip.mp4" video.write_bytes(b"fake video") adapter._app.client.files_upload_v2 = AsyncMock( side_effect=RuntimeError("Slack API error") ) # Should fall back to base class (text message) result = await adapter.send_video( chat_id="C123", video_path=str(video), ) adapter._app.client.chat_postMessage.assert_called_once() # --------------------------------------------------------------------------- # TestIncomingDocumentHandling # --------------------------------------------------------------------------- class TestIncomingDocumentHandling: def _make_event(self, files=None, text="hello", channel_type="im"): """Build a mock Slack message event with file attachments.""" return { "text": text, "user": "U_USER", "channel": "C123", "channel_type": channel_type, "ts": "1234567890.000001", "files": files or [], } @pytest.mark.asyncio async def test_pdf_document_cached(self, adapter): """A PDF attachment should be downloaded, cached, and set as DOCUMENT type.""" pdf_bytes = b"%PDF-1.4 fake content" with patch.object(adapter, "_download_slack_file_bytes", new_callable=AsyncMock) as dl: dl.return_value = pdf_bytes event = self._make_event(files=[{ "mimetype": "application/pdf", "name": "report.pdf", "url_private_download": "https://files.slack.com/report.pdf", "size": len(pdf_bytes), }]) await adapter._handle_slack_message(event) msg_event = adapter.handle_message.call_args[0][0] assert msg_event.message_type == MessageType.DOCUMENT assert len(msg_event.media_urls) == 1 assert os.path.exists(msg_event.media_urls[0]) assert msg_event.media_types == ["application/pdf"] @pytest.mark.asyncio async def test_txt_document_injects_content(self, adapter): """A .txt file under 100KB should have its content injected into event text.""" content = b"Hello from a text file" with patch.object(adapter, "_download_slack_file_bytes", new_callable=AsyncMock) as dl: dl.return_value = content event = self._make_event( text="summarize this", files=[{ "mimetype": "text/plain", "name": "notes.txt", "url_private_download": "https://files.slack.com/notes.txt", "size": len(content), }], ) await adapter._handle_slack_message(event) msg_event = adapter.handle_message.call_args[0][0] assert "Hello from a text file" in msg_event.text assert "[Content of notes.txt]" in msg_event.text assert "summarize this" in msg_event.text @pytest.mark.asyncio async def test_md_document_injects_content(self, adapter): """A .md file under 100KB should have its content injected.""" content = b"# Title\nSome markdown content" with patch.object(adapter, "_download_slack_file_bytes", new_callable=AsyncMock) as dl: dl.return_value = content event = self._make_event(files=[{ "mimetype": "text/markdown", "name": "readme.md", "url_private_download": "https://files.slack.com/readme.md", "size": len(content), }], text="") await adapter._handle_slack_message(event) msg_event = adapter.handle_message.call_args[0][0] assert "# Title" in msg_event.text @pytest.mark.asyncio async def test_large_txt_not_injected(self, adapter): """A .txt file over 100KB should be cached but NOT injected.""" content = b"x" * (200 * 1024) with patch.object(adapter, "_download_slack_file_bytes", new_callable=AsyncMock) as dl: dl.return_value = content event = self._make_event(files=[{ "mimetype": "text/plain", "name": "big.txt", "url_private_download": "https://files.slack.com/big.txt", "size": len(content), }], text="") await adapter._handle_slack_message(event) msg_event = adapter.handle_message.call_args[0][0] assert len(msg_event.media_urls) == 1 assert "[Content of" not in (msg_event.text or "") @pytest.mark.asyncio async def test_zip_file_cached(self, adapter): """A .zip file should be cached as a supported document.""" with patch.object(adapter, "_download_slack_file_bytes", new_callable=AsyncMock) as dl: dl.return_value = b"PK\x03\x04zip" event = self._make_event(files=[{ "mimetype": "application/zip", "name": "archive.zip", "url_private_download": "https://files.slack.com/archive.zip", "size": 1024, }]) await adapter._handle_slack_message(event) msg_event = adapter.handle_message.call_args[0][0] assert msg_event.message_type == MessageType.DOCUMENT assert len(msg_event.media_urls) == 1 assert msg_event.media_types == ["application/zip"] @pytest.mark.asyncio async def test_oversized_document_skipped(self, adapter): """A document over 20MB should be skipped.""" event = self._make_event(files=[{ "mimetype": "application/pdf", "name": "huge.pdf", "url_private_download": "https://files.slack.com/huge.pdf", "size": 25 * 1024 * 1024, }]) await adapter._handle_slack_message(event) msg_event = adapter.handle_message.call_args[0][0] assert len(msg_event.media_urls) == 0 @pytest.mark.asyncio async def test_document_download_error_handled(self, adapter): """If document download fails, handler should not crash.""" with patch.object(adapter, "_download_slack_file_bytes", new_callable=AsyncMock) as dl: dl.side_effect = RuntimeError("download failed") event = self._make_event(files=[{ "mimetype": "application/pdf", "name": "report.pdf", "url_private_download": "https://files.slack.com/report.pdf", "size": 1024, }]) await adapter._handle_slack_message(event) # Handler should still be called (the exception is caught) adapter.handle_message.assert_called_once() @pytest.mark.asyncio async def test_image_still_handled(self, adapter): """Image attachments should still go through the image path, not document.""" with patch.object(adapter, "_download_slack_file", new_callable=AsyncMock) as dl: dl.return_value = "/tmp/cached_image.jpg" event = self._make_event(files=[{ "mimetype": "image/jpeg", "name": "photo.jpg", "url_private_download": "https://files.slack.com/photo.jpg", "size": 1024, }]) await adapter._handle_slack_message(event) msg_event = adapter.handle_message.call_args[0][0] assert msg_event.message_type == MessageType.PHOTO # --------------------------------------------------------------------------- # TestMessageRouting # --------------------------------------------------------------------------- class TestMessageRouting: @pytest.mark.asyncio async def test_dm_processed_without_mention(self, adapter): """DM messages should be processed without requiring a bot mention.""" event = { "text": "hello", "user": "U_USER", "channel": "D123", "channel_type": "im", "ts": "1234567890.000001", } await adapter._handle_slack_message(event) adapter.handle_message.assert_called_once() @pytest.mark.asyncio async def test_channel_message_requires_mention(self, adapter): """Channel messages without a bot mention should be ignored.""" event = { "text": "just talking", "user": "U_USER", "channel": "C123", "channel_type": "channel", "ts": "1234567890.000001", } await adapter._handle_slack_message(event) adapter.handle_message.assert_not_called() @pytest.mark.asyncio async def test_channel_mention_strips_bot_id(self, adapter): """When mentioned in a channel, the bot mention should be stripped.""" event = { "text": "<@U_BOT> what's the weather?", "user": "U_USER", "channel": "C123", "channel_type": "channel", "ts": "1234567890.000001", } await adapter._handle_slack_message(event) msg_event = adapter.handle_message.call_args[0][0] assert msg_event.text == "what's the weather?" assert "<@U_BOT>" not in msg_event.text @pytest.mark.asyncio async def test_bot_messages_ignored(self, adapter): """Messages from bots should be ignored.""" event = { "text": "bot response", "bot_id": "B_OTHER", "channel": "C123", "channel_type": "im", "ts": "1234567890.000001", } await adapter._handle_slack_message(event) adapter.handle_message.assert_not_called() @pytest.mark.asyncio async def test_message_edits_ignored(self, adapter): """Message edits should be ignored.""" event = { "text": "edited message", "user": "U_USER", "channel": "C123", "channel_type": "im", "ts": "1234567890.000001", "subtype": "message_changed", } await adapter._handle_slack_message(event) adapter.handle_message.assert_not_called() # --------------------------------------------------------------------------- # TestSendTyping — assistant.threads.setStatus # --------------------------------------------------------------------------- class TestSendTyping: """Test typing indicator via assistant.threads.setStatus.""" @pytest.mark.asyncio async def test_sets_status_in_thread(self, adapter): adapter._app.client.assistant_threads_setStatus = AsyncMock() await adapter.send_typing("C123", metadata={"thread_id": "parent_ts"}) adapter._app.client.assistant_threads_setStatus.assert_called_once_with( channel_id="C123", thread_ts="parent_ts", status="is thinking...", ) @pytest.mark.asyncio async def test_noop_without_thread(self, adapter): adapter._app.client.assistant_threads_setStatus = AsyncMock() await adapter.send_typing("C123") adapter._app.client.assistant_threads_setStatus.assert_not_called() @pytest.mark.asyncio async def test_handles_missing_scope_gracefully(self, adapter): adapter._app.client.assistant_threads_setStatus = AsyncMock( side_effect=Exception("missing_scope") ) # Should not raise await adapter.send_typing("C123", metadata={"thread_id": "ts1"}) @pytest.mark.asyncio async def test_uses_thread_ts_fallback(self, adapter): adapter._app.client.assistant_threads_setStatus = AsyncMock() await adapter.send_typing("C123", metadata={"thread_ts": "fallback_ts"}) adapter._app.client.assistant_threads_setStatus.assert_called_once_with( channel_id="C123", thread_ts="fallback_ts", status="is thinking...", ) # --------------------------------------------------------------------------- # TestFormatMessage — Markdown → mrkdwn conversion # --------------------------------------------------------------------------- class TestFormatMessage: """Test markdown to Slack mrkdwn conversion.""" def test_bold_conversion(self, adapter): assert adapter.format_message("**hello**") == "*hello*" def test_italic_asterisk_conversion(self, adapter): assert adapter.format_message("*hello*") == "_hello_" def test_italic_underscore_preserved(self, adapter): assert adapter.format_message("_hello_") == "_hello_" def test_header_to_bold(self, adapter): assert adapter.format_message("## Section Title") == "*Section Title*" def test_header_with_bold_content(self, adapter): # **bold** inside a header should not double-wrap assert adapter.format_message("## **Title**") == "*Title*" def test_link_conversion(self, adapter): result = adapter.format_message("[click here](https://example.com)") assert result == "" def test_link_conversion_strips_markdown_angle_brackets(self, adapter): result = adapter.format_message("[click here]()") assert result == "" def test_escapes_control_characters(self, adapter): result = adapter.format_message("AT&T < 5 > 3") assert result == "AT&T < 5 > 3" def test_preserves_existing_slack_entities(self, adapter): text = "Hey <@U123>, see and " assert adapter.format_message(text) == text def test_strikethrough(self, adapter): assert adapter.format_message("~~deleted~~") == "~deleted~" def test_code_block_preserved(self, adapter): code = "```python\nx = **not bold**\n```" assert adapter.format_message(code) == code def test_inline_code_preserved(self, adapter): text = "Use `**raw**` syntax" assert adapter.format_message(text) == "Use `**raw**` syntax" def test_mixed_content(self, adapter): text = "**Bold** and *italic* with `code`" result = adapter.format_message(text) assert "*Bold*" in result assert "_italic_" in result assert "`code`" in result def test_empty_string(self, adapter): assert adapter.format_message("") == "" def test_none_passthrough(self, adapter): assert adapter.format_message(None) is None def test_blockquote_preserved(self, adapter): """Single-line blockquote > marker is preserved.""" assert adapter.format_message("> quoted text") == "> quoted text" def test_multiline_blockquote(self, adapter): """Multi-line blockquote preserves > on each line.""" text = "> line one\n> line two" assert adapter.format_message(text) == "> line one\n> line two" def test_blockquote_with_formatting(self, adapter): """Blockquote containing bold text.""" assert adapter.format_message("> **bold quote**") == "> *bold quote*" def test_nested_blockquote(self, adapter): """Multiple > characters for nested quotes.""" assert adapter.format_message(">> deeply quoted") == ">> deeply quoted" def test_blockquote_mixed_with_plain(self, adapter): """Blockquote lines interleaved with plain text.""" text = "normal\n> quoted\nnormal again" result = adapter.format_message(text) assert "> quoted" in result assert "normal" in result def test_non_prefix_gt_still_escaped(self, adapter): """Greater-than in mid-line is still escaped.""" assert adapter.format_message("5 > 3") == "5 > 3" def test_blockquote_with_code(self, adapter): """Blockquote containing inline code.""" result = adapter.format_message("> use `fmt.Println`") assert result.startswith(">") assert "`fmt.Println`" in result def test_bold_italic_combined(self, adapter): """Triple-star ***text*** converts to Slack bold+italic *_text_*.""" assert adapter.format_message("***hello***") == "*_hello_*" def test_bold_italic_with_surrounding_text(self, adapter): """Bold+italic in a sentence.""" result = adapter.format_message("This is ***important*** stuff") assert "*_important_*" in result def test_bold_italic_does_not_break_plain_bold(self, adapter): """**bold** still works after adding ***bold italic*** support.""" assert adapter.format_message("**bold**") == "*bold*" def test_bold_italic_does_not_break_plain_italic(self, adapter): """*italic* still works after adding ***bold italic*** support.""" assert adapter.format_message("*italic*") == "_italic_" def test_bold_italic_mixed_with_bold(self, adapter): """Both ***bold italic*** and **bold** in the same message.""" result = adapter.format_message("***important*** and **bold**") assert "*_important_*" in result assert "*bold*" in result def test_pre_escaped_ampersand_not_double_escaped(self, adapter): """Already-escaped & must not become &amp;.""" assert adapter.format_message("&") == "&" def test_pre_escaped_lt_not_double_escaped(self, adapter): """Already-escaped < must not become &lt;.""" assert adapter.format_message("<") == "<" def test_pre_escaped_gt_not_double_escaped(self, adapter): """Already-escaped > in plain text must not become &gt;.""" assert adapter.format_message("5 > 3") == "5 > 3" def test_mixed_raw_and_escaped_entities(self, adapter): """Raw & and pre-escaped & coexist correctly.""" result = adapter.format_message("AT&T and & entity") assert result == "AT&T and & entity" def test_link_with_parentheses_in_url(self, adapter): """Wikipedia-style URL with balanced parens is not truncated.""" result = adapter.format_message("[Foo](https://en.wikipedia.org/wiki/Foo_(bar))") assert result == "" def test_link_with_multiple_paren_pairs(self, adapter): """URL with multiple balanced paren pairs.""" result = adapter.format_message("[text](https://example.com/a_(b)_c_(d))") assert result == "" def test_link_without_parens_still_works(self, adapter): """Normal URL without parens is unaffected by regex change.""" result = adapter.format_message("[click](https://example.com/path?q=1)") assert result == "" def test_link_with_angle_brackets_and_parens(self, adapter): """Angle-bracket URL with parens (CommonMark syntax).""" result = adapter.format_message("[Foo]()") assert result == "" def test_escaping_is_idempotent(self, adapter): """Formatting already-formatted text produces the same result.""" original = "AT&T < 5 > 3" once = adapter.format_message(original) twice = adapter.format_message(once) assert once == twice # --- Entity preservation (spec-compliance) --- def test_channel_mention_preserved(self, adapter): """ special mention passes through unchanged.""" assert adapter.format_message("Attention ") == "Attention " def test_everyone_mention_preserved(self, adapter): """ special mention passes through unchanged.""" assert adapter.format_message("Hey ") == "Hey " def test_subteam_mention_preserved(self, adapter): """ user group mention passes through unchanged.""" assert adapter.format_message("Paging ") == "Paging " def test_date_formatting_preserved(self, adapter): """ formatting token passes through unchanged.""" text = "Posted " assert adapter.format_message(text) == text def test_channel_link_preserved(self, adapter): """<#CHANNEL_ID> channel link passes through unchanged.""" assert adapter.format_message("Join <#C12345>") == "Join <#C12345>" # --- Additional edge cases --- def test_message_only_code_block(self, adapter): """Entire message is a fenced code block — no conversion.""" code = "```python\nx = 1\n```" assert adapter.format_message(code) == code def test_multiline_mixed_formatting(self, adapter): """Multi-line message with headers, bold, links, code, and blockquotes.""" text = "## Title\n**bold** and [link](https://x.com)\n> quote\n`code`" result = adapter.format_message(text) assert result.startswith("*Title*") assert "*bold*" in result assert "" in result assert "> quote" in result assert "`code`" in result def test_markdown_unordered_list_with_asterisk(self, adapter): """Asterisk list items must not trigger italic conversion.""" text = "* item one\n* item two" result = adapter.format_message(text) assert "item one" in result assert "item two" in result def test_nested_bold_in_link(self, adapter): """Bold inside link label — label is stashed before bold pass.""" result = adapter.format_message("[**bold**](https://example.com)") assert "https://example.com" in result assert "bold" in result def test_url_with_query_string_and_ampersand(self, adapter): """Ampersand in URL query string must not be escaped.""" result = adapter.format_message("[link](https://x.com?a=1&b=2)") assert result == "" def test_emoji_shortcodes_passthrough(self, adapter): """Emoji shortcodes like :smile: pass through unchanged.""" assert adapter.format_message(":smile: hello :wave:") == ":smile: hello :wave:" # --------------------------------------------------------------------------- # TestEditMessage # --------------------------------------------------------------------------- class TestEditMessage: """Verify that edit_message() applies mrkdwn formatting before sending.""" @pytest.mark.asyncio async def test_edit_message_formats_bold(self, adapter): """edit_message converts **bold** to Slack *bold*.""" adapter._app.client.chat_update = AsyncMock(return_value={"ok": True}) await adapter.edit_message("C123", "1234.5678", "**hello world**") kwargs = adapter._app.client.chat_update.call_args.kwargs assert kwargs["text"] == "*hello world*" @pytest.mark.asyncio async def test_edit_message_formats_links(self, adapter): """edit_message converts markdown links to Slack format.""" adapter._app.client.chat_update = AsyncMock(return_value={"ok": True}) await adapter.edit_message("C123", "1234.5678", "[click](https://example.com)") kwargs = adapter._app.client.chat_update.call_args.kwargs assert kwargs["text"] == "" @pytest.mark.asyncio async def test_edit_message_preserves_blockquotes(self, adapter): """edit_message preserves blockquote > markers.""" adapter._app.client.chat_update = AsyncMock(return_value={"ok": True}) await adapter.edit_message("C123", "1234.5678", "> quoted text") kwargs = adapter._app.client.chat_update.call_args.kwargs assert kwargs["text"] == "> quoted text" @pytest.mark.asyncio async def test_edit_message_escapes_control_chars(self, adapter): """edit_message escapes & < > in plain text.""" adapter._app.client.chat_update = AsyncMock(return_value={"ok": True}) await adapter.edit_message("C123", "1234.5678", "AT&T < 5 > 3") kwargs = adapter._app.client.chat_update.call_args.kwargs assert kwargs["text"] == "AT&T < 5 > 3" # --------------------------------------------------------------------------- # TestEditMessageStreamingPipeline # --------------------------------------------------------------------------- class TestEditMessageStreamingPipeline: """E2E: verify that sequential streaming edits all go through format_message. Simulates the GatewayStreamConsumer pattern where edit_message is called repeatedly with progressively longer accumulated text. Every call must produce properly formatted mrkdwn in the chat_update payload. """ @pytest.mark.asyncio async def test_edit_message_formats_streaming_updates(self, adapter): """Simulates streaming: multiple edits, each should be formatted.""" adapter._app.client.chat_update = AsyncMock(return_value={"ok": True}) # First streaming update — bold result1 = await adapter.edit_message("C123", "ts1", "**Processing**...") assert result1.success is True kwargs1 = adapter._app.client.chat_update.call_args.kwargs assert kwargs1["text"] == "*Processing*..." # Second streaming update — bold + link result2 = await adapter.edit_message( "C123", "ts1", "**Done!** See [results](https://example.com)" ) assert result2.success is True kwargs2 = adapter._app.client.chat_update.call_args.kwargs assert kwargs2["text"] == "*Done!* See " @pytest.mark.asyncio async def test_edit_message_formats_code_and_bold(self, adapter): """Streaming update with code block and bold — code must be preserved.""" adapter._app.client.chat_update = AsyncMock(return_value={"ok": True}) content = "**Result:**\n```python\nprint('hello')\n```" result = await adapter.edit_message("C123", "ts1", content) assert result.success is True kwargs = adapter._app.client.chat_update.call_args.kwargs assert kwargs["text"].startswith("*Result:*") assert "```python\nprint('hello')\n```" in kwargs["text"] @pytest.mark.asyncio async def test_edit_message_formats_blockquote_in_stream(self, adapter): """Streaming update with blockquote — '>' marker must survive.""" adapter._app.client.chat_update = AsyncMock(return_value={"ok": True}) content = "> **Important:** do this\nnormal line" result = await adapter.edit_message("C123", "ts1", content) assert result.success is True kwargs = adapter._app.client.chat_update.call_args.kwargs assert kwargs["text"].startswith("> *Important:*") assert "normal line" in kwargs["text"] @pytest.mark.asyncio async def test_edit_message_formats_progressive_accumulation(self, adapter): """Simulate real streaming: text grows with each edit, all formatted.""" adapter._app.client.chat_update = AsyncMock(return_value={"ok": True}) updates = [ ("**Step 1**", "*Step 1*"), ("**Step 1**\n**Step 2**", "*Step 1*\n*Step 2*"), ( "**Step 1**\n**Step 2**\nSee [docs](https://docs.example.com)", "*Step 1*\n*Step 2*\nSee ", ), ] for raw, expected in updates: result = await adapter.edit_message("C123", "ts1", raw) assert result.success is True kwargs = adapter._app.client.chat_update.call_args.kwargs assert kwargs["text"] == expected, f"Failed for input: {raw!r}" # Total edit count should match number of updates assert adapter._app.client.chat_update.call_count == len(updates) @pytest.mark.asyncio async def test_edit_message_formats_bold_italic(self, adapter): """Bold+italic ***text*** is formatted as *_text_* in edited messages.""" adapter._app.client.chat_update = AsyncMock(return_value={"ok": True}) await adapter.edit_message("C123", "ts1", "***important*** update") kwargs = adapter._app.client.chat_update.call_args.kwargs assert "*_important_*" in kwargs["text"] @pytest.mark.asyncio async def test_edit_message_does_not_double_escape(self, adapter): """Pre-escaped entities in edited messages must not get double-escaped.""" adapter._app.client.chat_update = AsyncMock(return_value={"ok": True}) await adapter.edit_message("C123", "ts1", "5 > 3 and & entity") kwargs = adapter._app.client.chat_update.call_args.kwargs assert "&gt;" not in kwargs["text"] assert "&amp;" not in kwargs["text"] assert ">" in kwargs["text"] assert "&" in kwargs["text"] @pytest.mark.asyncio async def test_edit_message_formats_url_with_parens(self, adapter): """Wikipedia-style URL with parens survives edit pipeline.""" adapter._app.client.chat_update = AsyncMock(return_value={"ok": True}) await adapter.edit_message("C123", "ts1", "See [Foo](https://en.wikipedia.org/wiki/Foo_(bar))") kwargs = adapter._app.client.chat_update.call_args.kwargs assert "" in kwargs["text"] @pytest.mark.asyncio async def test_edit_message_not_connected(self, adapter): """edit_message returns failure when adapter is not connected.""" adapter._app = None result = await adapter.edit_message("C123", "ts1", "**hello**") assert result.success is False assert "Not connected" in result.error # --------------------------------------------------------------------------- # TestReactions # --------------------------------------------------------------------------- class TestReactions: """Test emoji reaction methods.""" @pytest.mark.asyncio async def test_add_reaction_calls_api(self, adapter): adapter._app.client.reactions_add = AsyncMock() result = await adapter._add_reaction("C123", "ts1", "eyes") assert result is True adapter._app.client.reactions_add.assert_called_once_with( channel="C123", timestamp="ts1", name="eyes" ) @pytest.mark.asyncio async def test_add_reaction_handles_error(self, adapter): adapter._app.client.reactions_add = AsyncMock(side_effect=Exception("already_reacted")) result = await adapter._add_reaction("C123", "ts1", "eyes") assert result is False @pytest.mark.asyncio async def test_remove_reaction_calls_api(self, adapter): adapter._app.client.reactions_remove = AsyncMock() result = await adapter._remove_reaction("C123", "ts1", "eyes") assert result is True @pytest.mark.asyncio async def test_reactions_in_message_flow(self, adapter): """Reactions should be added on receipt and swapped on completion.""" adapter._app.client.reactions_add = AsyncMock() adapter._app.client.reactions_remove = AsyncMock() adapter._app.client.users_info = AsyncMock(return_value={ "user": {"profile": {"display_name": "Tyler"}} }) event = { "text": "hello", "user": "U_USER", "channel": "C123", "channel_type": "im", "ts": "1234567890.000001", } await adapter._handle_slack_message(event) # Should have added 👀, then removed 👀, then added ✅ add_calls = adapter._app.client.reactions_add.call_args_list remove_calls = adapter._app.client.reactions_remove.call_args_list assert len(add_calls) == 2 assert add_calls[0].kwargs["name"] == "eyes" assert add_calls[1].kwargs["name"] == "white_check_mark" assert len(remove_calls) == 1 assert remove_calls[0].kwargs["name"] == "eyes" # --------------------------------------------------------------------------- # TestThreadReplyHandling # --------------------------------------------------------------------------- class TestThreadReplyHandling: """Test thread reply processing without explicit bot mentions.""" @pytest.fixture() def mock_session_store(self): """Create a mock session store with entries dict.""" store = MagicMock() store._entries = {} store._ensure_loaded = MagicMock() store.config = MagicMock() store.config.group_sessions_per_user = True return store @pytest.fixture() def adapter_with_session_store(self, mock_session_store): """Create an adapter with a mock session store attached.""" config = PlatformConfig(enabled=True, token="***") a = SlackAdapter(config) a._app = MagicMock() a._app.client = AsyncMock() a._bot_user_id = "U_BOT" a._team_bot_user_ids = {"T_TEAM": "U_BOT"} a._running = True a.handle_message = AsyncMock() a.set_session_store(mock_session_store) return a @pytest.mark.asyncio async def test_thread_reply_without_mention_no_session_ignored( self, adapter_with_session_store, mock_session_store ): """Thread replies without mention should be ignored if no active session.""" mock_session_store._entries = {} # No active sessions event = { "text": "Just replying in the thread", "user": "U_USER", "channel": "C123", "ts": "123.456", "thread_ts": "123.000", # Different from ts - this is a reply "channel_type": "channel", "team": "T_TEAM", } await adapter_with_session_store._handle_slack_message(event) adapter_with_session_store.handle_message.assert_not_called() @pytest.mark.asyncio async def test_thread_reply_without_mention_with_session_processed( self, adapter_with_session_store, mock_session_store ): """Thread replies without mention should be processed if there's an active session.""" # Simulate an active session for this thread session_key = "agent:main:slack:group:C123:123.000:U_USER" mock_session_store._entries = {session_key: MagicMock()} event = { "text": "Follow-up question", "user": "U_USER", "channel": "C123", "ts": "123.456", "thread_ts": "123.000", # Reply in thread 123.000 "channel_type": "channel", "team": "T_TEAM", } await adapter_with_session_store._handle_slack_message(event) adapter_with_session_store.handle_message.assert_called_once() # Verify the text is passed through unchanged (no mention stripping needed) msg_event = adapter_with_session_store.handle_message.call_args[0][0] assert msg_event.text == "Follow-up question" @pytest.mark.asyncio async def test_thread_reply_with_mention_strips_bot_id( self, adapter_with_session_store, mock_session_store ): """Thread replies with @mention should still strip the bot ID.""" # Even with a session, mentions should be stripped session_key = "agent:main:slack:group:C123:123.000:U_USER" mock_session_store._entries = {session_key: MagicMock()} event = { "text": "<@U_BOT> thanks for the help", "user": "U_USER", "channel": "C123", "ts": "123.456", "thread_ts": "123.000", "channel_type": "channel", "team": "T_TEAM", } await adapter_with_session_store._handle_slack_message(event) adapter_with_session_store.handle_message.assert_called_once() msg_event = adapter_with_session_store.handle_message.call_args[0][0] assert "<@U_BOT>" not in msg_event.text assert msg_event.text == "thanks for the help" @pytest.mark.asyncio async def test_top_level_message_requires_mention_even_with_session( self, adapter_with_session_store, mock_session_store ): """Top-level channel messages should require mention even if session exists.""" # Session exists but this is a top-level message (no thread_ts) session_key = "agent:main:slack:group:C123:123.000:U_USER" mock_session_store._entries = {session_key: MagicMock()} event = { "text": "New question without mention", "user": "U_USER", "channel": "C123", "ts": "456.789", # No thread_ts - this is a top-level message "channel_type": "channel", "team": "T_TEAM", } await adapter_with_session_store._handle_slack_message(event) adapter_with_session_store.handle_message.assert_not_called() @pytest.mark.asyncio async def test_no_session_store_ignores_thread_replies( self, adapter ): """If no session store is attached, thread replies without mention should be ignored.""" # adapter fixture has no session store attached event = { "text": "Thread reply without mention", "user": "U_USER", "channel": "C123", "ts": "123.456", "thread_ts": "123.000", "channel_type": "channel", "team": "T_TEAM", } await adapter._handle_slack_message(event) adapter.handle_message.assert_not_called() # --------------------------------------------------------------------------- # TestAssistantThreadLifecycle # --------------------------------------------------------------------------- class TestAssistantThreadLifecycle: """Slack Assistant lifecycle events should seed session/user context.""" @pytest.fixture() def mock_session_store(self): store = MagicMock() store._entries = {} store._ensure_loaded = MagicMock() store.config = MagicMock() store.config.group_sessions_per_user = True store.get_or_create_session = MagicMock() return store @pytest.fixture() def assistant_adapter(self, mock_session_store): config = PlatformConfig(enabled=True, token="***") a = SlackAdapter(config) a._app = MagicMock() a._app.client = AsyncMock() a._bot_user_id = "U_BOT" a._team_bot_user_ids = {"T_TEAM": "U_BOT"} a._running = True a.handle_message = AsyncMock() a.set_session_store(mock_session_store) return a @pytest.mark.asyncio async def test_lifecycle_event_seeds_session_store(self, assistant_adapter, mock_session_store): event = { "type": "assistant_thread_started", "team_id": "T_TEAM", "assistant_thread": { "channel_id": "D123", "thread_ts": "171.000", "user_id": "U_USER", "context": {"channel_id": "C_ORIGIN"}, }, } await assistant_adapter._handle_assistant_thread_lifecycle_event(event) assert assistant_adapter._assistant_threads[("D123", "171.000")]["user_id"] == "U_USER" mock_session_store.get_or_create_session.assert_called_once() source = mock_session_store.get_or_create_session.call_args[0][0] assert source.chat_id == "D123" assert source.chat_type == "dm" assert source.user_id == "U_USER" assert source.thread_id == "171.000" assert source.chat_topic == "C_ORIGIN" @pytest.mark.asyncio async def test_message_uses_cached_assistant_thread_identity(self, assistant_adapter): assistant_adapter._assistant_threads[("D123", "171.000")] = { "channel_id": "D123", "thread_ts": "171.000", "user_id": "U_USER", "team_id": "T_TEAM", } assistant_adapter._app.client.users_info = AsyncMock(return_value={ "user": {"profile": {"display_name": "Tyler"}} }) assistant_adapter._app.client.reactions_add = AsyncMock() assistant_adapter._app.client.reactions_remove = AsyncMock() event = { "text": "hello from assistant dm", "channel": "D123", "channel_type": "im", "thread_ts": "171.000", "ts": "171.111", "team": "T_TEAM", } await assistant_adapter._handle_slack_message(event) msg_event = assistant_adapter.handle_message.call_args[0][0] assert msg_event.source.user_id == "U_USER" assert msg_event.source.thread_id == "171.000" assert msg_event.source.user_name == "Tyler" def test_assistant_threads_cache_eviction(self, assistant_adapter): """Cache should evict oldest entries when exceeding the size limit.""" assistant_adapter._ASSISTANT_THREADS_MAX = 10 # Fill to the limit for i in range(10): assistant_adapter._cache_assistant_thread_metadata({ "channel_id": f"D{i}", "thread_ts": f"{i}.000", "user_id": f"U{i}", }) assert len(assistant_adapter._assistant_threads) == 10 # Adding one more should trigger eviction (down to max // 2 = 5) assistant_adapter._cache_assistant_thread_metadata({ "channel_id": "D999", "thread_ts": "999.000", "user_id": "U999", }) assert len(assistant_adapter._assistant_threads) <= 10 # The newest entry must survive eviction assert ("D999", "999.000") in assistant_adapter._assistant_threads # --------------------------------------------------------------------------- # TestUserNameResolution # --------------------------------------------------------------------------- class TestUserNameResolution: """Test user identity resolution.""" @pytest.mark.asyncio async def test_resolves_display_name(self, adapter): adapter._app.client.users_info = AsyncMock(return_value={ "user": {"profile": {"display_name": "Tyler", "real_name": "Tyler B"}} }) name = await adapter._resolve_user_name("U123") assert name == "Tyler" @pytest.mark.asyncio async def test_falls_back_to_real_name(self, adapter): adapter._app.client.users_info = AsyncMock(return_value={ "user": {"profile": {"display_name": "", "real_name": "Tyler B"}} }) name = await adapter._resolve_user_name("U123") assert name == "Tyler B" @pytest.mark.asyncio async def test_caches_result(self, adapter): adapter._app.client.users_info = AsyncMock(return_value={ "user": {"profile": {"display_name": "Tyler"}} }) await adapter._resolve_user_name("U123") await adapter._resolve_user_name("U123") # Only one API call despite two lookups assert adapter._app.client.users_info.call_count == 1 @pytest.mark.asyncio async def test_handles_api_error(self, adapter): adapter._app.client.users_info = AsyncMock(side_effect=Exception("rate limited")) name = await adapter._resolve_user_name("U123") assert name == "U123" # Falls back to user_id @pytest.mark.asyncio async def test_user_name_in_message_source(self, adapter): """Message source should include resolved user name.""" adapter._app.client.users_info = AsyncMock(return_value={ "user": {"profile": {"display_name": "Tyler"}} }) adapter._app.client.reactions_add = AsyncMock() adapter._app.client.reactions_remove = AsyncMock() event = { "text": "hello", "user": "U_USER", "channel": "C123", "channel_type": "im", "ts": "1234567890.000001", } await adapter._handle_slack_message(event) # Check the source in the MessageEvent passed to handle_message msg_event = adapter.handle_message.call_args[0][0] assert msg_event.source.user_name == "Tyler" # --------------------------------------------------------------------------- # TestSlashCommands — expanded command set # --------------------------------------------------------------------------- class TestSlashCommands: """Test slash command routing.""" @pytest.mark.asyncio async def test_compact_maps_to_compress(self, adapter): command = {"text": "compact", "user_id": "U1", "channel_id": "C1"} await adapter._handle_slash_command(command) msg = adapter.handle_message.call_args[0][0] assert msg.text == "/compress" @pytest.mark.asyncio async def test_resume_command(self, adapter): command = {"text": "resume my session", "user_id": "U1", "channel_id": "C1"} await adapter._handle_slash_command(command) msg = adapter.handle_message.call_args[0][0] assert msg.text == "/resume my session" @pytest.mark.asyncio async def test_background_command(self, adapter): command = {"text": "background run tests", "user_id": "U1", "channel_id": "C1"} await adapter._handle_slash_command(command) msg = adapter.handle_message.call_args[0][0] assert msg.text == "/background run tests" @pytest.mark.asyncio async def test_usage_command(self, adapter): command = {"text": "usage", "user_id": "U1", "channel_id": "C1"} await adapter._handle_slash_command(command) msg = adapter.handle_message.call_args[0][0] assert msg.text == "/usage" @pytest.mark.asyncio async def test_reasoning_command(self, adapter): command = {"text": "reasoning", "user_id": "U1", "channel_id": "C1"} await adapter._handle_slash_command(command) msg = adapter.handle_message.call_args[0][0] assert msg.text == "/reasoning" # --------------------------------------------------------------------------- # TestMessageSplitting # --------------------------------------------------------------------------- class TestMessageSplitting: """Test that long messages are split before sending.""" @pytest.mark.asyncio async def test_long_message_split_into_chunks(self, adapter): """Messages over MAX_MESSAGE_LENGTH should be split.""" long_text = "x" * 45000 # Over Slack's 40k API limit adapter._app.client.chat_postMessage = AsyncMock( return_value={"ts": "ts1"} ) await adapter.send("C123", long_text) # Should have been called multiple times assert adapter._app.client.chat_postMessage.call_count >= 2 @pytest.mark.asyncio async def test_short_message_single_send(self, adapter): """Short messages should be sent in one call.""" adapter._app.client.chat_postMessage = AsyncMock( return_value={"ts": "ts1"} ) await adapter.send("C123", "hello world") assert adapter._app.client.chat_postMessage.call_count == 1 @pytest.mark.asyncio async def test_send_preserves_blockquote_formatting(self, adapter): """Blockquote '>' markers must survive format → chunk → send pipeline.""" adapter._app.client.chat_postMessage = AsyncMock(return_value={"ts": "ts1"}) await adapter.send("C123", "> quoted text\nnormal text") kwargs = adapter._app.client.chat_postMessage.call_args.kwargs sent_text = kwargs["text"] assert sent_text.startswith("> quoted text") assert "normal text" in sent_text @pytest.mark.asyncio async def test_send_formats_bold_italic(self, adapter): """Bold+italic ***text*** is formatted as *_text_* in sent messages.""" adapter._app.client.chat_postMessage = AsyncMock(return_value={"ts": "ts1"}) await adapter.send("C123", "***important*** update") kwargs = adapter._app.client.chat_postMessage.call_args.kwargs assert "*_important_*" in kwargs["text"] @pytest.mark.asyncio async def test_send_explicitly_enables_mrkdwn(self, adapter): adapter._app.client.chat_postMessage = AsyncMock(return_value={"ts": "ts1"}) await adapter.send("C123", "**hello**") kwargs = adapter._app.client.chat_postMessage.call_args.kwargs assert kwargs.get("mrkdwn") is True @pytest.mark.asyncio async def test_send_does_not_double_escape_entities(self, adapter): """Pre-escaped & in sent messages must not become &amp;.""" adapter._app.client.chat_postMessage = AsyncMock(return_value={"ts": "ts1"}) await adapter.send("C123", "Use & for ampersand") kwargs = adapter._app.client.chat_postMessage.call_args.kwargs assert "&amp;" not in kwargs["text"] assert "&" in kwargs["text"] @pytest.mark.asyncio async def test_send_formats_url_with_parens(self, adapter): """Wikipedia-style URL with parens survives send pipeline.""" adapter._app.client.chat_postMessage = AsyncMock(return_value={"ts": "ts1"}) await adapter.send("C123", "See [Foo](https://en.wikipedia.org/wiki/Foo_(bar))") kwargs = adapter._app.client.chat_postMessage.call_args.kwargs assert "" in kwargs["text"] # --------------------------------------------------------------------------- # TestReplyBroadcast # --------------------------------------------------------------------------- class TestReplyBroadcast: """Test reply_broadcast config option.""" @pytest.mark.asyncio async def test_broadcast_disabled_by_default(self, adapter): adapter._app.client.chat_postMessage = AsyncMock( return_value={"ts": "ts1"} ) await adapter.send("C123", "hi", metadata={"thread_id": "parent_ts"}) kwargs = adapter._app.client.chat_postMessage.call_args.kwargs assert "reply_broadcast" not in kwargs @pytest.mark.asyncio async def test_broadcast_enabled_via_config(self, adapter): adapter.config.extra["reply_broadcast"] = True adapter._app.client.chat_postMessage = AsyncMock( return_value={"ts": "ts1"} ) await adapter.send("C123", "hi", metadata={"thread_id": "parent_ts"}) kwargs = adapter._app.client.chat_postMessage.call_args.kwargs assert kwargs.get("reply_broadcast") is True # --------------------------------------------------------------------------- # TestFallbackPreservesThreadContext # --------------------------------------------------------------------------- class TestFallbackPreservesThreadContext: """Bug fix: file upload fallbacks lost thread context (metadata) when calling super() without metadata, causing replies to appear outside the thread.""" @pytest.mark.asyncio async def test_send_image_file_fallback_preserves_thread(self, adapter, tmp_path): test_file = tmp_path / "photo.jpg" test_file.write_bytes(b"\xff\xd8\xff\xe0") adapter._app.client.files_upload_v2 = AsyncMock( side_effect=Exception("upload failed") ) adapter._app.client.chat_postMessage = AsyncMock( return_value={"ts": "msg_ts"} ) metadata = {"thread_id": "parent_ts_123"} await adapter.send_image_file( chat_id="C123", image_path=str(test_file), caption="test image", metadata=metadata, ) call_kwargs = adapter._app.client.chat_postMessage.call_args.kwargs assert call_kwargs.get("thread_ts") == "parent_ts_123" @pytest.mark.asyncio async def test_send_video_fallback_preserves_thread(self, adapter, tmp_path): test_file = tmp_path / "clip.mp4" test_file.write_bytes(b"\x00\x00\x00\x1c") adapter._app.client.files_upload_v2 = AsyncMock( side_effect=Exception("upload failed") ) adapter._app.client.chat_postMessage = AsyncMock( return_value={"ts": "msg_ts"} ) metadata = {"thread_id": "parent_ts_456"} await adapter.send_video( chat_id="C123", video_path=str(test_file), metadata=metadata, ) call_kwargs = adapter._app.client.chat_postMessage.call_args.kwargs assert call_kwargs.get("thread_ts") == "parent_ts_456" @pytest.mark.asyncio async def test_send_document_fallback_preserves_thread(self, adapter, tmp_path): test_file = tmp_path / "report.pdf" test_file.write_bytes(b"%PDF-1.4") adapter._app.client.files_upload_v2 = AsyncMock( side_effect=Exception("upload failed") ) adapter._app.client.chat_postMessage = AsyncMock( return_value={"ts": "msg_ts"} ) metadata = {"thread_id": "parent_ts_789"} await adapter.send_document( chat_id="C123", file_path=str(test_file), caption="report", metadata=metadata, ) call_kwargs = adapter._app.client.chat_postMessage.call_args.kwargs assert call_kwargs.get("thread_ts") == "parent_ts_789" @pytest.mark.asyncio async def test_send_image_file_fallback_includes_caption(self, adapter, tmp_path): test_file = tmp_path / "photo.jpg" test_file.write_bytes(b"\xff\xd8\xff\xe0") adapter._app.client.files_upload_v2 = AsyncMock( side_effect=Exception("upload failed") ) adapter._app.client.chat_postMessage = AsyncMock( return_value={"ts": "msg_ts"} ) await adapter.send_image_file( chat_id="C123", image_path=str(test_file), caption="important screenshot", ) call_kwargs = adapter._app.client.chat_postMessage.call_args.kwargs assert "important screenshot" in call_kwargs["text"] # --------------------------------------------------------------------------- # TestSendImageSSRFGuards # --------------------------------------------------------------------------- class TestSendImageSSRFGuards: """send_image should reject redirects that land on private/internal hosts.""" @pytest.mark.asyncio async def test_send_image_blocks_private_redirect_target(self, adapter): redirect_response = MagicMock() redirect_response.is_redirect = True redirect_response.next_request = MagicMock( url="http://169.254.169.254/latest/meta-data" ) client_kwargs = {} mock_client = AsyncMock() mock_client.__aenter__ = AsyncMock(return_value=mock_client) mock_client.__aexit__ = AsyncMock(return_value=False) async def fake_get(_url): for hook in client_kwargs["event_hooks"]["response"]: await hook(redirect_response) mock_client.get = AsyncMock(side_effect=fake_get) adapter._app.client.files_upload_v2 = AsyncMock(return_value={"ok": True}) adapter._app.client.chat_postMessage = AsyncMock(return_value={"ts": "reply_ts"}) def fake_async_client(*args, **kwargs): client_kwargs.update(kwargs) return mock_client def fake_is_safe_url(url): return url == "https://public.example/image.png" with ( patch("tools.url_safety.is_safe_url", side_effect=fake_is_safe_url), patch("httpx.AsyncClient", side_effect=fake_async_client), ): result = await adapter.send_image( chat_id="C123", image_url="https://public.example/image.png", caption="see this", ) assert result.success assert client_kwargs["follow_redirects"] is True assert client_kwargs["event_hooks"]["response"] adapter._app.client.files_upload_v2.assert_not_awaited() adapter._app.client.chat_postMessage.assert_awaited_once() call_kwargs = adapter._app.client.chat_postMessage.call_args.kwargs assert "see this" in call_kwargs["text"] assert "https://public.example/image.png" in call_kwargs["text"] # --------------------------------------------------------------------------- # TestProgressMessageThread # --------------------------------------------------------------------------- class TestProgressMessageThread: """Verify that progress messages go to the correct thread. Issue #2954: For Slack DM top-level messages, source.thread_id is None but the final reply is threaded under the user's message via reply_to. Progress messages must use the same thread anchor (the original message's ts) so they appear in the thread instead of the DM root. """ @pytest.mark.asyncio async def test_dm_toplevel_progress_uses_message_ts_as_thread(self, adapter): """Progress messages for a top-level DM should go into the reply thread.""" # Simulate a top-level DM: no thread_ts in the event event = { "channel": "D_DM", "channel_type": "im", "user": "U_USER", "text": "Hello bot", "ts": "1234567890.000001", # No thread_ts — this is a top-level DM } captured_events = [] adapter.handle_message = AsyncMock(side_effect=lambda e: captured_events.append(e)) # Patch _resolve_user_name to avoid async Slack API call with patch.object(adapter, "_resolve_user_name", new=AsyncMock(return_value="testuser")): await adapter._handle_slack_message(event) assert len(captured_events) == 1 msg_event = captured_events[0] source = msg_event.source # For a top-level DM: source.thread_id should remain None # (session keying must not be affected) assert source.thread_id is None, ( "source.thread_id must stay None for top-level DMs " "so they share one continuous session" ) # The message_id should be the event's ts — this is what the gateway # passes as event_message_id so progress messages can thread correctly assert msg_event.message_id == "1234567890.000001", ( "message_id must equal the event ts so _run_agent can use it as " "the fallback thread anchor for progress messages" ) # Verify that the Slack send() method correctly threads a message # when metadata contains thread_id equal to the original ts adapter._app.client.chat_postMessage = AsyncMock(return_value={"ts": "reply_ts"}) result = await adapter.send( chat_id="D_DM", content="⚙️ working...", metadata={"thread_id": msg_event.message_id}, ) assert result.success call_kwargs = adapter._app.client.chat_postMessage.call_args[1] assert call_kwargs.get("thread_ts") == "1234567890.000001", ( "send() must pass thread_ts when metadata has thread_id, " "ensuring progress messages land in the thread" ) @pytest.mark.asyncio async def test_channel_mention_progress_uses_thread_ts(self, adapter): """Progress messages for a channel @mention should go into the reply thread.""" # Simulate an @mention in a channel: the event ts becomes the thread anchor event = { "channel": "C_CHAN", "channel_type": "channel", "user": "U_USER", "text": f"<@U_BOT> help me", "ts": "2000000000.000001", # No thread_ts — top-level channel message } captured_events = [] adapter.handle_message = AsyncMock(side_effect=lambda e: captured_events.append(e)) with patch.object(adapter, "_resolve_user_name", new=AsyncMock(return_value="testuser")): await adapter._handle_slack_message(event) assert len(captured_events) == 1 msg_event = captured_events[0] source = msg_event.source # For channel @mention: thread_id should equal the event ts (fallback) assert source.thread_id == "2000000000.000001", ( "source.thread_id must equal the event ts for channel messages " "so each @mention starts its own thread" ) assert msg_event.message_id == "2000000000.000001"