From 69706e9415a484c66122b4b8d19c60f4340a06fe Mon Sep 17 00:00:00 2001 From: patrick-chinchill Date: Thu, 23 Apr 2026 17:42:41 -0700 Subject: [PATCH 1/2] feat(thread): port getParticipants + close 8 fidelity gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Thread.get_participants() mirroring upstream TS Thread.getParticipants() (vercel-chat/packages/chat/src/thread.ts:359). Returns unique non-bot, non-self authors who've posted in the thread — seeds from current_message.author when eligible, then iterates all_messages() oldest-first and dedupes on user_id. Added to the Thread Protocol so consumers typed against the interface can call it. Also ports the 4 [getParticipants] tests from thread.test.ts and the 4 [thread] factory tests from chat.test.ts (existing-behavior coverage for Chat.thread(id) that had no Python equivalent). Closes 8 fidelity gaps; verify_test_fidelity now shows 32 missing, down from 40. Changelog entry under Unreleased — will batch into 0.4.26.2 alongside other Tier 1/2 parity fixes in flight. Refs #54. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 12 ++++ src/chat_sdk/thread.py | 24 +++++++ src/chat_sdk/types.py | 4 ++ tests/test_chat_faithful.py | 39 +++++++++++ tests/test_thread_faithful.py | 118 ++++++++++++++++++++++++++++++++++ 5 files changed, 197 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a44f65..c6f415b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,14 @@ ## Unreleased +Parity catch-up with upstream `4.26.0`. No upstream version change. + ### New public APIs +- **`Thread.get_participants()`**: returns unique non-bot, non-self authors + who've posted in the thread. Seeds from `current_message.author` (if + eligible), then iterates `all_messages()` and dedupes by `user_id`. + Mirrors upstream TS `Thread.getParticipants()`. Issue #54. - **`IoRedisStateAdapter`**: `RedisStateAdapter` subclass defaulting to the `ioredis_` lock-token prefix used by upstream Vercel Chat's `ioredis`-backed state. Enables cross-runtime Redis sharing between TS and Python chat-sdk @@ -34,6 +40,12 @@ or federated (workload identity) authentication instead."`). Not a functional implementation; upstream does not implement cert auth either. +### Test fidelity + +- Ported the 4 `[getParticipants]` tests from `thread.test.ts` and the 4 + `[thread]` factory tests from `chat.test.ts` (existing-behavior coverage + for `Chat.thread(id)`). Closes 8 fidelity gaps. + ### Test hygiene - Sweep remaining `time.sleep` → `await asyncio.sleep` in async tests diff --git a/src/chat_sdk/thread.py b/src/chat_sdk/thread.py index 7702fa1..3494ca0 100644 --- a/src/chat_sdk/thread.py +++ b/src/chat_sdk/thread.py @@ -430,6 +430,30 @@ async def all_messages(self) -> AsyncIterator[Message]: for msg in cached: yield msg + async def get_participants(self) -> list[Author]: + """Return unique non-bot, non-self authors who've posted in the thread. + + Mirrors ``getParticipants`` from the upstream TS SDK: seeds the + result with ``_current_message.author`` (if present and eligible), + then scans ``all_messages()`` oldest-first, skipping ``is_me`` and + ``is_bot`` authors and deduping by ``user_id``. + """ + seen: dict[str, Author] = {} + + if ( + self._current_message is not None + and not self._current_message.author.is_me + and not self._current_message.author.is_bot + ): + seen[self._current_message.author.user_id] = self._current_message.author + + async for msg in self.all_messages(): + if msg.author.is_me or msg.author.is_bot or msg.author.user_id in seen: + continue + seen[msg.author.user_id] = msg.author + + return list(seen.values()) + # -- Subscriptions ------------------------------------------------------- async def is_subscribed(self) -> bool: diff --git a/src/chat_sdk/types.py b/src/chat_sdk/types.py index 439d7e2..0462b07 100644 --- a/src/chat_sdk/types.py +++ b/src/chat_sdk/types.py @@ -1519,6 +1519,10 @@ async def refresh(self) -> None: """Refresh ``recent_messages`` from the API.""" ... + async def get_participants(self) -> list[Author]: + """Return unique non-bot, non-self authors who've posted in the thread.""" + ... + def create_sent_message_from_message(self, message: Message) -> SentMessage: """Wrap a Message as a SentMessage with edit/delete capabilities.""" ... diff --git a/tests/test_chat_faithful.py b/tests/test_chat_faithful.py index 4bb8766..5d0f77f 100644 --- a/tests/test_chat_faithful.py +++ b/tests/test_chat_faithful.py @@ -1282,6 +1282,45 @@ async def test_should_allow_posting_to_dm_thread(self): assert any(tid == "slack:DU123456:" and content == "Hello via DM!" for tid, content in adapter._post_calls) +# ============================================================================ +# thread() factory +# ============================================================================ + + +class TestThreadFactory: + """describe("thread") — chat.thread(id) factory for building a Thread handle.""" + + # TS: "should return a Thread handle for a valid thread ID" + async def test_should_return_a_thread_handle_for_a_valid_thread_id(self): + chat, adapter, state = await _init_chat() + thread = chat.thread("slack:C123:1234.5678") + assert thread is not None + assert thread.id == "slack:C123:1234.5678" + + # TS: "should allow posting to a thread handle" + async def test_should_allow_posting_to_a_thread_handle(self): + chat, adapter, state = await _init_chat() + thread = chat.thread("slack:C123:1234.5678") + await thread.post("Hello from outside a webhook!") + + assert any( + tid == "slack:C123:1234.5678" and content == "Hello from outside a webhook!" + for tid, content in adapter._post_calls + ) + + # TS: "should throw for an invalid thread ID" + async def test_should_throw_for_an_invalid_thread_id(self): + chat, adapter, state = await _init_chat() + with pytest.raises(ChatError, match="Invalid thread ID"): + chat.thread("") + + # TS: "should throw for an unknown adapter prefix" + async def test_should_throw_for_an_unknown_adapter_prefix(self): + chat, adapter, state = await _init_chat() + with pytest.raises(ChatError, match=r'Adapter "unknown" not found'): + chat.thread("unknown:C123:1234.5678") + + # ============================================================================ # 14. isDM (tests 55-57) # ============================================================================ diff --git a/tests/test_thread_faithful.py b/tests/test_thread_faithful.py index 533daac..98477d2 100644 --- a/tests/test_thread_faithful.py +++ b/tests/test_thread_faithful.py @@ -2540,6 +2540,124 @@ def test_work_with_different_adapters(self): assert channel_id == "gchat:spaces/ABC123" +# =========================================================================== +# getParticipants +# =========================================================================== + + +class TestGetParticipants: + """describe("getParticipants")""" + + # it("should return unique non-bot authors from messages") + @pytest.mark.asyncio + async def test_should_return_unique_nonbot_authors_from_messages(self): + adapter = create_mock_adapter() + state = create_mock_state() + + msg1 = create_test_message( + "1", + "Hello", + author=Author(user_id="U1", user_name="alice", full_name="Alice", is_bot=False, is_me=False), + ) + msg2 = create_test_message( + "2", + "Hi", + author=Author(user_id="U2", user_name="bob", full_name="Bob", is_bot=False, is_me=False), + ) + msg3 = create_test_message( + "3", + "Hello again", + author=Author(user_id="U1", user_name="alice", full_name="Alice", is_bot=False, is_me=False), + ) + + adapter.fetch_messages = AsyncMock( # type: ignore[assignment] + return_value=FetchResult(messages=[msg1, msg2, msg3], next_cursor=None) + ) + + thread = _make_thread(adapter, state) + participants = await thread.get_participants() + + assert len(participants) == 2 + user_ids = [p.user_id for p in participants] + assert "U1" in user_ids + assert "U2" in user_ids + + # it("should exclude bot messages") + @pytest.mark.asyncio + async def test_should_exclude_bot_messages(self): + adapter = create_mock_adapter() + state = create_mock_state() + + human_msg = create_test_message( + "1", + "Hello", + author=Author(user_id="U1", user_name="alice", full_name="Alice", is_bot=False, is_me=False), + ) + self_bot_msg = create_test_message( + "2", + "Hi there!", + author=Author(user_id="B1", user_name="bot", full_name="Bot", is_bot=True, is_me=True), + ) + third_party_bot_msg = create_test_message( + "3", + "Notification", + author=Author(user_id="B2", user_name="jira-bot", full_name="Jira Bot", is_bot=True, is_me=False), + ) + + adapter.fetch_messages = AsyncMock( # type: ignore[assignment] + return_value=FetchResult(messages=[human_msg, self_bot_msg, third_party_bot_msg], next_cursor=None) + ) + + thread = _make_thread(adapter, state) + participants = await thread.get_participants() + + assert len(participants) == 1 + assert participants[0].user_id == "U1" + + # it("should return empty array for thread with only bot messages") + @pytest.mark.asyncio + async def test_should_return_empty_array_for_thread_with_only_bot_messages(self): + adapter = create_mock_adapter() + state = create_mock_state() + + bot_msg = create_test_message( + "1", + "Bot message", + author=Author(user_id="B1", user_name="bot", full_name="Bot", is_bot=True, is_me=True), + ) + + adapter.fetch_messages = AsyncMock( # type: ignore[assignment] + return_value=FetchResult(messages=[bot_msg], next_cursor=None) + ) + + thread = _make_thread(adapter, state) + participants = await thread.get_participants() + + assert len(participants) == 0 + + # it("should include currentMessage author") + @pytest.mark.asyncio + async def test_should_include_currentmessage_author(self): + adapter = create_mock_adapter() + state = create_mock_state() + + current_msg = create_test_message( + "1", + "Hey bot", + author=Author(user_id="U1", user_name="alice", full_name="Alice", is_bot=False, is_me=False), + ) + + adapter.fetch_messages = AsyncMock( # type: ignore[assignment] + return_value=FetchResult(messages=[], next_cursor=None) + ) + + thread = _make_thread(adapter, state, current_message=current_msg) + participants = await thread.get_participants() + + assert len(participants) == 1 + assert participants[0].user_id == "U1" + + class TestMissingAbsorbers: """Fidelity-check absorbers for TS test names that have no Python equivalent.""" From 1523d275a195581bc517d91e561919eaf0ee1e29 Mon Sep 17 00:00:00 2001 From: patrick-chinchill Date: Thu, 23 Apr 2026 18:46:39 -0700 Subject: [PATCH 2/2] test(thread): add explicit is_me exclusion test for get_participants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses bot review feedback on PR #64 — the existing bot-exclusion test doesn't isolate the is_me=True, is_bot=False path. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/test_thread_faithful.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/test_thread_faithful.py b/tests/test_thread_faithful.py index 98477d2..adb02f7 100644 --- a/tests/test_thread_faithful.py +++ b/tests/test_thread_faithful.py @@ -2614,6 +2614,33 @@ async def test_should_exclude_bot_messages(self): assert len(participants) == 1 assert participants[0].user_id == "U1" + # Python-only: isolates the is_me=True, is_bot=False exclusion path + # (addresses PR #64 bot-review nit — prior test mixes is_bot and is_me). + @pytest.mark.asyncio + async def test_should_exclude_self_messages_even_when_not_bot(self): + adapter = create_mock_adapter() + state = create_mock_state() + + self_msg = create_test_message( + "1", + "my msg", + author=Author(user_id="U_SELF", user_name="me", full_name="Me", is_bot=False, is_me=True), + ) + other_msg = create_test_message( + "2", + "hi", + author=Author(user_id="U1", user_name="alice", full_name="Alice", is_bot=False, is_me=False), + ) + + adapter.fetch_messages = AsyncMock( # type: ignore[assignment] + return_value=FetchResult(messages=[self_msg, other_msg], next_cursor=None) + ) + + thread = _make_thread(adapter, state) + participants = await thread.get_participants() + + assert [p.user_id for p in participants] == ["U1"] + # it("should return empty array for thread with only bot messages") @pytest.mark.asyncio async def test_should_return_empty_array_for_thread_with_only_bot_messages(self):