Skip to content

Commit 3ed4a9a

Browse files
fix(slack): route empty-DM thread_ts fetch to conversations.history (#138) (#175)
A top-level Slack DM root encodes an empty thread_ts (slack:Dxxx:). The fetch paths previously called conversations.replies(ts="") unconditionally, which returns no replies and loses the DM root context for every DM. - fetch_messages: when thread_ts is empty (falsy), route to the existing channel-history helpers (_fetch_channel_messages_forward / _fetch_channel_messages_backward, both conversations.history) instead of the thread-reply helpers. Direction, limit, and cursor semantics are preserved; for a DM the channel IS the conversation, so this is correct. - fetch_message: when thread_ts is empty, fetch the single message via conversations.history(latest=message_id, inclusive=True, limit=1), mirroring the inner link-preview fetch_message. - Non-empty thread_ts stays byte-identical (still conversations.replies). This is a divergence ahead of upstream — upstream's fetchMessages / fetchMessage call conversations.replies(ts: threadTs) with no empty-thread_ts guard either. Documented in docs/UPSTREAM_SYNC.md as a candidate to file upstream; it also covers the #137 DM block-action consumer uniformly. Tests: empty-DM fetch_messages (forward + backward) and fetch_message assert conversations.history is used and conversations.replies(ts="") is never called; non-empty thread_ts regression guards assert conversations.replies is still used (so the routing cannot over-trigger).
1 parent 3d753c0 commit 3ed4a9a

3 files changed

Lines changed: 198 additions & 10 deletions

File tree

docs/UPSTREAM_SYNC.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -667,7 +667,8 @@ stay explicit instead of being rediscovered in code review.
667667
| `_rehydrate_message` with `Message` input | Falls through to the `rehydrate_attachment` pass even when the dequeued entry is already a `Message` instance | Early-returns on `raw instanceof Message` before rehydration | The Python port's Redis + Postgres `dequeue()` upgrade raw JSON to `Message.from_json(...)` before returning (upstream's dequeue returns the raw JSON.parse'd dict). Upstream's `instanceof Message` shortcut therefore only fires for in-memory state, but ours would fire for persistent backends too, leaving `fetch_data` stripped forever. The rehydrate pass still skips any attachment that already has `fetch_data`, so in-memory callers pay no cost. |
668668
| Slack Socket Mode reconnect loop | Outer reconnect loop on top of `slack_sdk.socket_mode.aiohttp.SocketModeClient` (which itself has `auto_reconnect_enabled=True`). Exponential backoff (1s → 30s) with explicit shutdown signaling and a tracked `asyncio.Task` so `disconnect()` can cancel cleanly | Single `SocketModeClient` instance from `@slack/socket-mode`; relies entirely on the package's internal reconnect | Hazard #5 (async task lifecycle): a long-lived WebSocket needs an explicit shutdown path so `disconnect()` doesn't leak the loop, and a guarded outer reconnect path so the adapter survives `connect()` itself raising (which the inner client doesn't retry). Inner auto-reconnect still runs; the outer loop is belt-and-suspenders, not a divergence in observable behavior. |
669669
| Slack Socket Mode listener serverless variant | Not ported | `startSocketModeListener()` / `runSocketModeListener()` open a transient socket for `durationMs` and forward events via HTTP POST | Vercel-specific pattern (cron-triggered ephemeral listener with `waitUntil`). The forwarded-event receiver (`x-slack-socket-token` handling in `handle_webhook`) is ported so a separate Python process can run the long-lived listener; the deployment glue itself isn't part of the SDK. |
670-
| Slack DM block-action threading (#133/#137) | `_handle_block_actions` sets `thread_ts=""` for a top-level DM button click (never falls back to the clicked message's own `ts`), so a handler's `event.thread.post(...)` does not spawn a phantom "1 reply" thread in the DM. Mirrors `_handle_message_event`'s DM handling (`thread_ts=""` for top-level DMs). | `handleBlockActions` (`adapter-slack/src/index.ts:1455-1456,1470`) computes `thread_ts \|\| container.thread_ts \|\| messageTs` and encodes `threadTs \|\| messageTs \|\| ""` — it falls back to the clicked message's `ts` even for DMs, so a DM button click spawns a phantom reply thread. Upstream's `handleMessageEvent` *does* empty-case DMs (`:2158`), but `handleBlockActions` does **not** — an upstream internal inconsistency. | Hard UX failure with no workaround (phantom "1 reply" threads on DM button clicks). We extend upstream's own DM-message convention to the block-action path. The resulting empty DM `thread_ts` is consumed unguarded by `fetch_messages` → `conversations.replies(ts="")` — identical to upstream (`fetchMessages` `:4178` has no empty-`thread_ts` guard) and to the faithful DM-message path; a `conversations.history` fallback for empty DM `thread_ts` is a separate, codebase-wide follow-up. The block-action fix should be contributed upstream (cf. PR #107's stream() divergence) to restore parity. |
670+
| Slack DM block-action threading (#133/#137) | `_handle_block_actions` sets `thread_ts=""` for a top-level DM button click (never falls back to the clicked message's own `ts`), so a handler's `event.thread.post(...)` does not spawn a phantom "1 reply" thread in the DM. Mirrors `_handle_message_event`'s DM handling (`thread_ts=""` for top-level DMs). | `handleBlockActions` (`adapter-slack/src/index.ts:1455-1456,1470`) computes `thread_ts \|\| container.thread_ts \|\| messageTs` and encodes `threadTs \|\| messageTs \|\| ""` — it falls back to the clicked message's `ts` even for DMs, so a DM button click spawns a phantom reply thread. Upstream's `handleMessageEvent` *does* empty-case DMs (`:2158`), but `handleBlockActions` does **not** — an upstream internal inconsistency. | Hard UX failure with no workaround (phantom "1 reply" threads on DM button clicks). We extend upstream's own DM-message convention to the block-action path. The resulting empty DM `thread_ts` is consumed by `fetch_messages` → now routed to `conversations.history` for empty `thread_ts` (see the #138 row below); the block-action fix should be contributed upstream (cf. PR #107's stream() divergence) to restore parity. |
671+
| Slack empty-DM `thread_ts` fetch routing (#138) | `fetch_messages` routes an empty (falsy) `thread_ts` — every top-level DM root, encoded `slack:Dxxx:` — to the channel-history path (`_fetch_channel_messages_forward` / `_fetch_channel_messages_backward`, both `conversations.history`) instead of `conversations.replies(ts="")`, preserving direction/limit/cursor. `fetch_message` likewise reads a single empty-`thread_ts` message via `conversations.history(channel, latest=message_id, inclusive=True, limit=1)` (mirroring the inner link-preview `fetch_message` at `slack/adapter.py:3293`). Non-empty `thread_ts` stays byte-identical on `conversations.replies`. | `fetchMessages` (`adapter-slack/src/index.ts:4135` → `fetchMessagesForward`/`Backward` `:4187`/`:4250`) and `fetchMessage` (`:4350`) call `conversations.replies({ ts: threadTs })` with **no** empty-`thread_ts` guard. With `ts=""` Slack returns no replies and the DM root context is lost. | Hard UX failure on **every** DM root: history/single-message fetches over a DM silently return nothing (or lose the root) because the DM root legitimately encodes `threadTs=""` (faithful to `_handle_message_event` / `handleMessageEvent`, "matches openDM subscriptions"). For a DM the channel **is** the conversation, so `conversations.history` is the correct source. This supersedes the "separate follow-up" noted in the #133/#137 row and covers DM message fetches **and** the #137 DM block-action consumer uniformly. Candidate to file upstream against vercel/chat (an empty-`thread_ts` guard in `fetchMessages`/`fetchMessage`); remove this divergence once upstream adds it. |
671672
| `GitHubAdapter.octokit` native client getter (vercel/chat#459, #478) | Not exposed | `get octokit(): Octokit` (plus deprecated `client` alias) returns the underlying Octokit — fixed instance in PAT/single-tenant App mode, per-installation client resolved from `AsyncLocalStorage` inside a webhook handler in multi-tenant mode | The Python adapter is hand-rolled over raw `aiohttp` (`_github_api_request`) with PyJWT for App JWTs and an installation-token cache; the `github` extra is `pyjwt[crypto]` only — there is no Octokit-equivalent object to return, and exposing the raw session or an invented facade under the name `octokit` would misrepresent the surface. Revisit if the adapter adopts an octokit-style SDK (e.g. `githubkit`) as an optional dependency per hazard #10's "prefer official SDKs" sub-rule; the getter (and the GitHub `fetch_subject` half of #459) ports cleanly then. |
672673
| `LinearAdapter.linear_client` native client getter (vercel/chat#459, #478) | Not exposed | `get linearClient(): LinearClient` (plus deprecated `client` alias) returns the `@linear/sdk` `LinearClient`, per-org from `AsyncLocalStorage` in multi-tenant OAuth mode | `@linear/sdk` is TypeScript-only and no official Linear Python SDK exists; the adapter issues GraphQL directly over `aiohttp` (`_graphql_query`) and already documents that stance. Nothing honest to put behind the name. Revisit only if Linear ships an official Python SDK (the Linear `fetch_subject` half of #459 is blocked on the same). |
673674
| `@chat-adapter/tests` adapter test kit (vercel/chat#470) | Not ported | New TS package with test utilities for adapter authors | Python already ships `chat_sdk.testing` (`MockAdapter`, `MockStateAdapter`, `create_test_message()`) covering the same surface for this repo's adapter tests; mirroring the TS kit verbatim would duplicate it. Revisit if upstream's kit grows capabilities ours lacks (e.g. recorded replay fixtures for third-party adapter authors). |

src/chat_sdk/adapters/slack/adapter.py

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4317,15 +4317,21 @@ async def fetch_messages(self, thread_id: str, options: FetchOptions | None = No
43174317
thread_ts = decoded.thread_ts
43184318
direction = getattr(opts, "direction", "backward") or "backward"
43194319
limit = getattr(opts, "limit", 100) if getattr(opts, "limit", 100) is not None else 100
4320+
cursor = getattr(opts, "cursor", None)
43204321

4322+
# Divergence (chat-sdk-python#138): a top-level DM root encodes an empty
4323+
# thread_ts (slack:Dxxx:). conversations.replies(ts="") returns no replies
4324+
# and loses the DM root context, so route empty thread_ts to the channel
4325+
# history path (conversations.history), where the channel *is* the
4326+
# conversation. Upstream's fetchMessages has no empty-thread_ts guard.
43214327
try:
4328+
if not thread_ts:
4329+
if direction == "forward":
4330+
return await self._fetch_channel_messages_forward(channel, limit, cursor)
4331+
return await self._fetch_channel_messages_backward(channel, limit, cursor)
43224332
if direction == "forward":
4323-
return await self._fetch_messages_forward(
4324-
channel, thread_ts, thread_id, limit, getattr(opts, "cursor", None)
4325-
)
4326-
return await self._fetch_messages_backward(
4327-
channel, thread_ts, thread_id, limit, getattr(opts, "cursor", None)
4328-
)
4333+
return await self._fetch_messages_forward(channel, thread_ts, thread_id, limit, cursor)
4334+
return await self._fetch_messages_backward(channel, thread_ts, thread_id, limit, cursor)
43294335
except Exception as error:
43304336
self._handle_slack_error(error)
43314337

@@ -4383,9 +4389,16 @@ async def fetch_message(self, thread_id: str, message_id: str) -> Message | None
43834389

43844390
try:
43854391
client = self._get_client()
4386-
result = await client.conversations_replies(
4387-
channel=channel, ts=thread_ts, oldest=message_id, inclusive=True, limit=1
4388-
)
4392+
# Divergence (chat-sdk-python#138): a DM root encodes an empty
4393+
# thread_ts, so conversations.replies(ts="") cannot locate the
4394+
# message. Fetch the single message from conversations.history
4395+
# instead (mirrors the link-preview fetch_message at ~3293).
4396+
if not thread_ts:
4397+
result = await client.conversations_history(channel=channel, latest=message_id, inclusive=True, limit=1)
4398+
else:
4399+
result = await client.conversations_replies(
4400+
channel=channel, ts=thread_ts, oldest=message_id, inclusive=True, limit=1
4401+
)
43894402
messages = result.get("messages", [])
43904403
target = next((m for m in messages if m.get("ts") == message_id), None)
43914404
if not target:

tests/test_slack_api.py

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -612,6 +612,180 @@ async def test_fetch_with_limit(self):
612612
# Should return at most limit messages
613613
assert len(result.messages) <= 10
614614

615+
@pytest.mark.asyncio
616+
async def test_empty_dm_thread_ts_backward_uses_history_not_replies(self):
617+
"""A DM root (slack:Dxxx:) encodes thread_ts="" — backward fetch must
618+
route to conversations.history (the channel IS the conversation), not
619+
conversations.replies(ts="") which returns nothing for a DM (#138)."""
620+
adapter, client, _ = await _init_adapter()
621+
client.set_response(
622+
"conversations_history",
623+
{
624+
"ok": True,
625+
"messages": [
626+
{"ts": "1234567890.000002", "text": "DM 2", "user": "U2"},
627+
{"ts": "1234567890.000001", "text": "DM 1", "user": "U1"},
628+
],
629+
"has_more": False,
630+
},
631+
)
632+
633+
result = await adapter.fetch_messages("slack:D999:")
634+
635+
# The DM root messages come back via conversations.history.
636+
assert len(result.messages) == 2
637+
history_calls = client.get_calls("conversations_history")
638+
assert len(history_calls) == 1
639+
assert history_calls[0]["kwargs"]["channel"] == "D999"
640+
# conversations.replies must NOT be hit at all (esp. not with ts="").
641+
replies_calls = client.get_calls("conversations_replies")
642+
assert replies_calls == []
643+
644+
@pytest.mark.asyncio
645+
async def test_empty_dm_thread_ts_forward_uses_history_not_replies(self):
646+
"""Forward direction over a DM root also routes to conversations.history,
647+
preserving cursor/limit semantics, never conversations.replies(ts="")."""
648+
adapter, client, _ = await _init_adapter()
649+
client.set_response(
650+
"conversations_history",
651+
{
652+
"ok": True,
653+
"messages": [
654+
{"ts": "1234567890.000002", "text": "Newer", "user": "U2"},
655+
{"ts": "1234567890.000001", "text": "Older", "user": "U1"},
656+
],
657+
"has_more": True,
658+
},
659+
)
660+
661+
result = await adapter.fetch_messages(
662+
"slack:D999:",
663+
FetchOptions(direction="forward", cursor="1234567890.000000", limit=50),
664+
)
665+
666+
assert len(result.messages) == 2
667+
history_calls = client.get_calls("conversations_history")
668+
assert len(history_calls) == 1
669+
assert history_calls[0]["kwargs"]["channel"] == "D999"
670+
# Forward cursor maps to oldest= on conversations.history (channel-history path).
671+
assert history_calls[0]["kwargs"]["oldest"] == "1234567890.000000"
672+
assert history_calls[0]["kwargs"]["limit"] == 50
673+
# has_more + slack messages → next_cursor from the newest ts.
674+
assert result.next_cursor == "1234567890.000002"
675+
assert client.get_calls("conversations_replies") == []
676+
677+
@pytest.mark.asyncio
678+
async def test_non_empty_thread_ts_still_uses_replies_backward(self):
679+
"""Regression guard: a real thread root (non-empty thread_ts) MUST keep
680+
using conversations.replies — the empty-DM routing must not over-trigger."""
681+
adapter, client, _ = await _init_adapter()
682+
client.set_response(
683+
"conversations_replies",
684+
{
685+
"ok": True,
686+
"messages": [
687+
{"ts": "1234567890.000001", "text": "Reply 1", "user": "U1"},
688+
],
689+
"has_more": False,
690+
},
691+
)
692+
693+
result = await adapter.fetch_messages("slack:C123:1234567890.000000")
694+
695+
assert len(result.messages) == 1
696+
replies_calls = client.get_calls("conversations_replies")
697+
assert len(replies_calls) == 1
698+
assert replies_calls[0]["kwargs"]["ts"] == "1234567890.000000"
699+
# Channel-history path must NOT be used for a real thread.
700+
assert client.get_calls("conversations_history") == []
701+
702+
@pytest.mark.asyncio
703+
async def test_non_empty_thread_ts_still_uses_replies_forward(self):
704+
"""Regression guard (forward): non-empty thread_ts keeps conversations.replies."""
705+
adapter, client, _ = await _init_adapter()
706+
client.set_response(
707+
"conversations_replies",
708+
{
709+
"ok": True,
710+
"messages": [
711+
{"ts": "1234567890.000001", "text": "First", "user": "U1"},
712+
],
713+
"response_metadata": {"next_cursor": "cur"},
714+
},
715+
)
716+
717+
result = await adapter.fetch_messages(
718+
"slack:C123:1234567890.000000",
719+
FetchOptions(direction="forward"),
720+
)
721+
722+
assert len(result.messages) == 1
723+
replies_calls = client.get_calls("conversations_replies")
724+
assert len(replies_calls) == 1
725+
assert replies_calls[0]["kwargs"]["ts"] == "1234567890.000000"
726+
assert client.get_calls("conversations_history") == []
727+
728+
729+
# =============================================================================
730+
# fetchMessage (single) Tests
731+
# =============================================================================
732+
733+
734+
class TestFetchSingleMessage:
735+
@pytest.mark.asyncio
736+
async def test_non_empty_thread_ts_uses_replies(self):
737+
"""A single-message fetch on a real thread uses conversations.replies
738+
(oldest=message_id) — byte-identical to the pre-#138 path."""
739+
adapter, client, _ = await _init_adapter()
740+
client.set_response(
741+
"conversations_replies",
742+
{
743+
"ok": True,
744+
"messages": [
745+
{"ts": "1234567890.000050", "text": "Target", "user": "U1"},
746+
],
747+
},
748+
)
749+
750+
msg = await adapter.fetch_message("slack:C123:1234567890.000000", "1234567890.000050")
751+
752+
assert msg is not None
753+
assert msg.id == "1234567890.000050"
754+
replies_calls = client.get_calls("conversations_replies")
755+
assert len(replies_calls) == 1
756+
assert replies_calls[0]["kwargs"]["ts"] == "1234567890.000000"
757+
assert replies_calls[0]["kwargs"]["oldest"] == "1234567890.000050"
758+
assert client.get_calls("conversations_history") == []
759+
760+
@pytest.mark.asyncio
761+
async def test_empty_dm_thread_ts_uses_history(self):
762+
"""A single-message fetch on a DM root (empty thread_ts) reads from
763+
conversations.history (latest=message_id), NOT conversations.replies(ts="")
764+
which cannot locate the message (#138)."""
765+
adapter, client, _ = await _init_adapter()
766+
client.set_response(
767+
"conversations_history",
768+
{
769+
"ok": True,
770+
"messages": [
771+
{"ts": "1234567890.000050", "text": "DM message", "user": "U1"},
772+
],
773+
},
774+
)
775+
776+
msg = await adapter.fetch_message("slack:D999:", "1234567890.000050")
777+
778+
assert msg is not None
779+
assert msg.id == "1234567890.000050"
780+
history_calls = client.get_calls("conversations_history")
781+
assert len(history_calls) == 1
782+
assert history_calls[0]["kwargs"]["channel"] == "D999"
783+
assert history_calls[0]["kwargs"]["latest"] == "1234567890.000050"
784+
assert history_calls[0]["kwargs"]["inclusive"] is True
785+
assert history_calls[0]["kwargs"]["limit"] == 1
786+
# conversations.replies must NOT be called with ts="".
787+
assert client.get_calls("conversations_replies") == []
788+
615789

616790
# =============================================================================
617791
# fetchThread Tests

0 commit comments

Comments
 (0)