Skip to content

Commit 9888fc0

Browse files
docs(chat): clarify DM routing precedence in on_direct_message + Thread.subscribe (#121)
* docs(chat): clarify DM routing precedence in on_direct_message + Thread.subscribe Ports vercel/chat#491 (commit 67c1794) docstring-only update. Aligns Python docstrings with upstream's clarified contract: registered on_direct_message handlers take precedence for every DM message before on_subscribed_message, on_mention, and on_message pattern handlers; when no DM handler is registered, DMs continue through normal routing. The Python runtime already implemented this precedence (see chat.py::_handle_incoming_message:2154), so this is documentation alignment with zero behavior change. Added regression tests: - TestDMRoutingDocs pins the new docstring wording on both Chat.on_direct_message and ThreadImpl.subscribe (so a future doc rewrite cannot silently drift back from upstream). - test_dm_handler_runs_before_pattern_handler covers DM > on_message pattern precedence, which existing DM tests covered for subscribed and mention but not for pattern handlers. Refs vercel/chat#491, tracking #98. https://claude.ai/code/session_01FyMxQn2BEAzmwKS1GZczKj * docs(thread): fully qualify Sphinx Chat refs (gemini review) Rewrites `:py:meth:`Chat.on_subscribed_message`` and `:py:meth:`Chat.on_direct_message`` in `Thread.subscribe`'s docstring as `:py:meth:`~chat_sdk.chat.Chat.on_subscribed_message`` and `:py:meth:`~chat_sdk.chat.Chat.on_direct_message``. `Chat` is intentionally not imported in `thread.py` (TYPE_CHECKING-only) to avoid a circular import. The unqualified refs wouldn't resolve under Sphinx; the absolute paths do, and the `~` keeps the rendered link text short. The `test_thread_subscribe_docstring_documents_dm_carveout` pin still passes: its `"on_direct_message" in doc` assertion matches the substring inside the new qualified ref. https://claude.ai/code/session_01FyMxQn2BEAzmwKS1GZczKj --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e6e1d18 commit 9888fc0

4 files changed

Lines changed: 93 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
# Changelog
22

3+
## 0.4.29a3 (unreleased)
4+
5+
### Documentation alignment
6+
7+
- **DM routing precedence docstrings** (vercel/chat#491, commit `67c1794`). The Python runtime already routed direct messages to `on_direct_message` handlers before subscribed-message, mention, and pattern handlers (`Chat._handle_incoming_message`, `chat.py:2154`), but `on_direct_message` and `Thread.subscribe` lacked docstrings clarifying that contract. Refreshed both to match upstream's #491 docstring rewrite, plus a new `tests/integration/test_dm_flow.py::TestDMRoutingDocs` group and a `test_dm_handler_runs_before_pattern_handler` regression test that pin the documented precedence (DM > pattern) the previous suite did not explicitly cover. No runtime behavior change.
8+
9+
> Version bump from `0.4.29a2` to `0.4.29a3` will be cut by a coordinating PR once the parallel 4.29 ports land; this entry intentionally leaves `pyproject.toml` unchanged.
10+
311
## 0.4.29a2 (2026-05-28)
412

513
Python-only fix. No upstream version change.

src/chat_sdk/chat.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -570,7 +570,21 @@ async def handle(thread, message):
570570
return handler
571571

572572
def on_direct_message(self, handler: DirectMessageHandler) -> DirectMessageHandler:
573-
"""Register a handler for direct messages."""
573+
"""Register a handler for direct messages.
574+
575+
Called for every message received in a DM thread when at least one
576+
direct message handler is registered. Direct message handlers run
577+
before :py:meth:`on_subscribed_message`, :py:meth:`on_mention`, and
578+
pattern handlers.
579+
580+
If no ``on_direct_message`` handlers are registered, DMs continue
581+
through normal routing. Unsubscribed DMs fall through to
582+
:py:meth:`on_mention` for backward compatibility.
583+
584+
Args:
585+
handler: Handler called for DM messages. Receives
586+
``(thread, message, channel, context)``.
587+
"""
574588
self._direct_message_handlers.append(handler)
575589
self._logger.debug("Registered direct message handler")
576590
return handler

src/chat_sdk/thread.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -504,6 +504,14 @@ async def is_subscribed(self) -> bool:
504504
return await self._state_adapter.is_subscribed(self._id)
505505

506506
async def subscribe(self) -> None:
507+
"""Subscribe to future messages in this thread.
508+
509+
Once subscribed, messages in non-DM threads trigger
510+
:py:meth:`~chat_sdk.chat.Chat.on_subscribed_message` handlers. DM threads route to
511+
:py:meth:`~chat_sdk.chat.Chat.on_direct_message` first when a direct message handler
512+
is registered. The initial message that triggered subscription will
513+
NOT fire the handler.
514+
"""
507515
await self._state_adapter.subscribe(self._id)
508516
if hasattr(self.adapter, "on_thread_subscribe") and self.adapter.on_thread_subscribe: # type: ignore[union-attr]
509517
await self.adapter.on_thread_subscribe(self._id) # type: ignore[union-attr]

tests/integration/test_dm_flow.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
import pytest
1313

14+
from chat_sdk.chat import Chat
1415
from chat_sdk.testing import create_mock_adapter
1516
from chat_sdk.types import Message
1617

@@ -184,3 +185,64 @@ async def handler(thread, message, channel=None, context=None):
184185
await chat.handle_incoming_message(adapter, dm_thread_id, msg)
185186

186187
assert len(calls) == 0
188+
189+
@pytest.mark.asyncio
190+
async def test_dm_handler_runs_before_pattern_handler(self):
191+
"""DM handler takes precedence over ``on_message`` pattern handlers.
192+
193+
Mirrors the routing-precedence clarification in vercel/chat#491 — DM
194+
handlers run before subscribed/mention/pattern handlers when registered.
195+
Without this precedence a DM whose text matches a registered pattern
196+
would fire both handlers (or only the pattern handler), silently
197+
breaking DM-only flows.
198+
"""
199+
chat, adapters, state = await create_chat()
200+
adapter = adapters["slack"]
201+
dm_calls: list[Message] = []
202+
pattern_calls: list[Message] = []
203+
204+
@chat.on_direct_message
205+
async def dm_handler(thread, message, channel=None, context=None):
206+
dm_calls.append(message)
207+
208+
@chat.on_message(r"^!help")
209+
async def pattern_handler(thread, message, context=None):
210+
pattern_calls.append(message)
211+
212+
dm_thread_id = "slack:DDMCHAN:"
213+
msg = create_msg("!help me please", thread_id=dm_thread_id)
214+
await chat.handle_incoming_message(adapter, dm_thread_id, msg)
215+
216+
assert len(dm_calls) == 1
217+
assert dm_calls[0].text == "!help me please"
218+
assert pattern_calls == []
219+
220+
221+
class TestDMRoutingDocs:
222+
"""Lock in the precedence-clarification docstrings ported from vercel/chat#491.
223+
224+
The runtime routing was already correct in the Python port (see
225+
``Chat._handle_incoming_message``: DM handlers run before
226+
subscribed/mention/pattern). These checks fail if a future doc rewrite drops
227+
the precedence wording, which would let the docs drift out of sync with
228+
upstream's clarified contract again (the original issue closed by #491).
229+
"""
230+
231+
def test_on_direct_message_docstring_documents_precedence(self):
232+
"""``Chat.on_direct_message`` docstring states DM handlers run first."""
233+
doc = Chat.on_direct_message.__doc__ or ""
234+
assert "before" in doc.lower()
235+
assert "on_subscribed_message" in doc
236+
assert "on_mention" in doc
237+
# Falls-through-when-unregistered carve-out preserved.
238+
assert "backward compatibility" in doc
239+
240+
def test_thread_subscribe_docstring_documents_dm_carveout(self):
241+
"""``ThreadImpl.subscribe`` docstring covers the DM precedence carve-out."""
242+
from chat_sdk.thread import ThreadImpl
243+
244+
doc = ThreadImpl.subscribe.__doc__ or ""
245+
assert "non-DM" in doc
246+
assert "on_direct_message" in doc
247+
# Pre-existing invariant: initial subscribing message does NOT fire.
248+
assert "NOT fire" in doc

0 commit comments

Comments
 (0)