Skip to content

Commit 0a12e57

Browse files
committed
fix: adapter hygiene — slack stream await, teams divider, public worker APIs
Bundles four open adapter issues into one PR. - #44 SlackAdapter.stream(): await chat_stream(...). Without the await the code called .append on an unawaited coroutine and native Slack streaming failed on the first chunk. Existing tests were updated to use AsyncMock for chat_stream so they mirror the real AsyncWebClient. - #45 Teams divider now renders a visible separator. Dividers between siblings hoist separator: True onto the next element; a trailing divider emits a minimal non-empty Container. An empty Container with separator: True renders at zero height in Microsoft Teams. Documented as a divergence from upstream (same bug in TS). - #46 New Chat.thread(thread_id, *, current_message=None) public factory. Mirrors TS chat.thread(threadId). Lets worker processes rebuild a Thread without reaching into ThreadImpl/_ThreadImplConfig internals. current_message is preserved for Slack native streaming (it populates recipient_user_id / recipient_team_id). - #47 New SlackAdapter.current_token / current_client properties. Public accessors for the request-context-bound token and a preconfigured AsyncWebClient. Replaces underscore-prefixed _get_token / _get_client access from consumer code that makes direct Slack Web API calls from inside a handler. Tests and CHANGELOG updated. UPSTREAM_SYNC.md divergence table updated for #45 and #47. https://claude.ai/code/session_01MXobPieFH93txkgm38oMpC
1 parent b1a9562 commit 0a12e57

8 files changed

Lines changed: 349 additions & 8 deletions

File tree

CHANGELOG.md

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

3+
## Unreleased
4+
5+
### Fixes
6+
- **Slack native streaming no longer crashes on first chunk** (issue #44): `SlackAdapter.stream()` now awaits `AsyncWebClient.chat_stream(...)`; the previous code called `.append()` on an unawaited coroutine, raising `AttributeError` and forcing callers onto the post+edit fallback. Existing tests were updated to use `AsyncMock` for `chat_stream` so they mirror the real client.
7+
- **Teams divider now renders a visible separator line** (issue #45): `card_to_adaptive_card` previously emitted an empty `Container` with `separator: True`, which Microsoft Teams renders at zero height. The new behavior hoists `separator: True` onto the following sibling (or emits a minimal non-empty Container for a trailing divider). Upstream TS ships the same bug; documented as a divergence in [UPSTREAM_SYNC.md](docs/UPSTREAM_SYNC.md).
8+
9+
### Python-only additions
10+
- **`SlackAdapter.current_token` / `current_client`** (issue #47): public `@property` accessors that return the request-context-bound bot token and a preconfigured `AsyncWebClient`. Replaces reaching into `_get_token()` / `_get_client()` from consumer code that needs to call the Slack Web API directly from inside a handler (email resolution, user profile fetches, etc.). TS keeps `getToken()` private; documented as a Python-only extension in [UPSTREAM_SYNC.md](docs/UPSTREAM_SYNC.md).
11+
- **`Chat.thread(thread_id, *, current_message=None)`** (issue #46): public worker-reconstruction factory mirroring TS `chat.thread(threadId)`. Adapter is inferred from the thread ID prefix; state and message history come from the Chat instance. Pass `current_message` when the worker needs Slack native streaming (it populates `recipient_user_id` / `recipient_team_id`).
12+
313
## 0.4.26 (2026-04-16)
414

515
Synced to [Vercel Chat 4.26.0](https://github.com/vercel/chat).

docs/UPSTREAM_SYNC.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -455,6 +455,8 @@ stay explicit instead of being rediscovered in code review.
455455
| Google Chat image rendering | Images emit as `{alt} ({url})` or bare `url` | No image branch — falls through to default which concatenates children only, dropping the URL | Upstream silently drops image URLs when rendering to Google Chat text. We preserve the URL so the message content isn't lost. |
456456
| Fallback streaming stream-exception capture | `_fallback_stream` captures exceptions from the stream iterator, flushes whatever content was already rendered, awaits `pending_edit`, and re-raises after cleanup | `try/finally` only — exception propagates immediately, `pendingEdit` is un-awaited, and the placeholder is stranded as `"..."` | Upstream leaves a hard UX failure when streams crash mid-flight (common: LLM connection drops): placeholder visible forever, orphan background task. We flush + clean up before re-raising so the caller still sees the original error and users see the partial content instead of a spinner. |
457457
| Fallback streaming final SentMessage content | SentMessage + final edit carry `final_content` (remend'd — inline markers auto-closed) | SentMessage + final edit carry raw `accumulated` | Narrow UX refinement. If a stream ends with an unclosed `*`/`~~`/etc., upstream ships the unclosed marker; we run `_remend` so the user sees a clean final message. Not observable in the common case where streams close their own markers. |
458+
| Teams divider rendering | `card_to_adaptive_card` hoists `separator: True` onto the next sibling (or emits a non-empty Container for a trailing divider) | `convertDividerToElement` emits an empty `Container` with `separator: True` | Upstream shares the same bug: Microsoft Teams renders an empty Container at zero height, so the separator line is effectively invisible. Python port fixes locally (issue #45) rather than blocking on upstream. |
459+
| `SlackAdapter.current_token` / `current_client` | Public `@property` accessors that return the request-context-bound token and a preconfigured `AsyncWebClient` | Not exposed (`getToken()` is private on the TS `SlackAdapter`) | Python-only addition (issue #47). Downstream code that calls Slack Web APIs from inside a handler — email resolution, user profile fetches, reaction bookkeeping — otherwise depends on underscore-prefixed helpers. |
458460

459461
### Platform-specific gaps
460462

src/chat_sdk/adapters/slack/adapter.py

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,37 @@ def lock_scope(self) -> str:
252252
def persist_message_history(self) -> bool:
253253
return self._persist_message_history
254254

255+
# ------------------------------------------------------------------
256+
# Public request-context accessors
257+
#
258+
# These are Python-only extensions to the Adapter surface. They let
259+
# code running inside a handler call the Slack Web API directly —
260+
# e.g. ``users.info`` for caller-email resolution — without
261+
# reaching into the underscore-prefixed ``_get_token`` /
262+
# ``_get_client`` helpers. See docs/UPSTREAM_SYNC.md.
263+
# ------------------------------------------------------------------
264+
265+
@property
266+
def current_token(self) -> str:
267+
"""Return the bot token bound to the current request context.
268+
269+
In multi-workspace mode this is the token resolved by the
270+
``InstallationStore`` for the current request; in single-workspace
271+
mode it is the default bot token. Raises
272+
:class:`AuthenticationError` when called outside a request context
273+
with no default token configured.
274+
"""
275+
return self._get_token()
276+
277+
@property
278+
def current_client(self) -> Any:
279+
"""Return an ``AsyncWebClient`` preconfigured with :attr:`current_token`.
280+
281+
The returned client is LRU-cached by token. Raises
282+
:class:`AuthenticationError` when no token is available.
283+
"""
284+
return self._get_client()
285+
255286
# ------------------------------------------------------------------
256287
# Token management
257288
# ------------------------------------------------------------------
@@ -2030,7 +2061,7 @@ async def stream(
20302061
if options.task_display_mode:
20312062
stream_kwargs["task_display_mode"] = options.task_display_mode
20322063

2033-
streamer = client.chat_stream(**stream_kwargs)
2064+
streamer = await client.chat_stream(**stream_kwargs)
20342065

20352066
first = True
20362067
last_appended = ""

src/chat_sdk/adapters/teams/cards.py

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@
3131
# Used when a card has select/radio_select inputs but no submit button.
3232
AUTO_SUBMIT_ACTION_ID = "__auto_submit"
3333

34+
# Internal marker key for divider placeholders. Stripped by
35+
# ``_hoist_dividers`` before the card is serialized — never reaches Teams.
36+
_DIVIDER_MARKER = "__chatSdkDivider"
37+
3438

3539
def _convert_emoji(text: str) -> str:
3640
"""Convert emoji placeholders to Teams format."""
@@ -96,6 +100,8 @@ def card_to_adaptive_card(card: CardElement) -> dict[str, Any]:
96100
body.extend(result["elements"])
97101
actions.extend(result["actions"])
98102

103+
body = _hoist_dividers(body)
104+
99105
adaptive_card: dict[str, Any] = {
100106
"type": "AdaptiveCard",
101107
"$schema": ADAPTIVE_CARD_SCHEMA,
@@ -121,7 +127,14 @@ def _convert_child_to_adaptive(child: CardChild) -> dict[str, Any]:
121127
if child_type == "image":
122128
return {"elements": [_convert_image_to_element(child)], "actions": []} # type: ignore[arg-type]
123129
if child_type == "divider":
124-
return {"elements": [{"type": "Container", "separator": True, "items": []}], "actions": []}
130+
# Emit an internal marker instead of the final Container. The
131+
# post-processing pass (_hoist_dividers) either moves ``separator``
132+
# onto the next sibling (preferred — renders as a full-width line)
133+
# or, for a trailing divider with no next sibling, replaces it with
134+
# a minimal non-empty Container so the separator is visible. An
135+
# empty ``Container`` with ``separator: True`` renders at zero
136+
# height in Microsoft Teams.
137+
return {"elements": [{_DIVIDER_MARKER: True}], "actions": []}
125138
if child_type == "actions":
126139
return _convert_actions_to_elements(child) # type: ignore[arg-type]
127140
if child_type == "section":
@@ -150,6 +163,37 @@ def _convert_child_to_adaptive(child: CardChild) -> dict[str, Any]:
150163
return {"elements": [], "actions": []}
151164

152165

166+
def _hoist_dividers(elements: list[dict[str, Any]]) -> list[dict[str, Any]]:
167+
"""Replace internal divider markers with ``separator: True`` on the next sibling.
168+
169+
An Adaptive Card ``Container`` with ``separator: True`` and no ``items``
170+
renders at zero height in Microsoft Teams — the separator line is
171+
effectively invisible. Instead, hoist the separator onto the following
172+
sibling so it renders as a full-width line above that element. For a
173+
trailing divider with no next sibling, emit a non-empty Container so
174+
the separator is still visible.
175+
"""
176+
result: list[dict[str, Any]] = []
177+
pending = False
178+
for el in elements:
179+
if el.get(_DIVIDER_MARKER):
180+
pending = True
181+
continue
182+
if pending:
183+
el = {**el, "separator": True}
184+
pending = False
185+
result.append(el)
186+
if pending:
187+
result.append(
188+
{
189+
"type": "Container",
190+
"separator": True,
191+
"items": [{"type": "TextBlock", "text": " ", "wrap": False}],
192+
}
193+
)
194+
return result
195+
196+
153197
def _convert_text_to_element(element: TextElement) -> dict[str, Any]:
154198
"""Convert a text element to an Adaptive Card TextBlock."""
155199
content = element.get("content", "")
@@ -319,6 +363,7 @@ def _convert_section_to_elements(element: SectionElement) -> dict[str, Any]:
319363
container_items.extend(result["elements"])
320364
actions.extend(result["actions"])
321365

366+
container_items = _hoist_dividers(container_items)
322367
if container_items:
323368
elements.append({"type": "Container", "items": container_items})
324369

src/chat_sdk/chat.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1378,6 +1378,54 @@ def channel(self, channel_id: str) -> ChannelImpl:
13781378
)
13791379
)
13801380

1381+
def thread(
1382+
self,
1383+
thread_id: str,
1384+
*,
1385+
current_message: Message | None = None,
1386+
) -> ThreadImpl:
1387+
"""Get a Thread by its thread ID (e.g. 'slack:C123ABC:1234567890.123456').
1388+
1389+
The adapter is resolved from the thread ID prefix; state and message
1390+
history come from this Chat instance. This is the public
1391+
construction path for worker processes — use it instead of
1392+
reaching into ``ThreadImpl`` / ``_ThreadImplConfig`` directly.
1393+
1394+
Parameters
1395+
----------
1396+
thread_id:
1397+
Fully-qualified thread ID. Must start with a registered
1398+
adapter name followed by ``:``.
1399+
current_message:
1400+
Optional reference to the message the worker is responding to.
1401+
Required for Slack native streaming, which populates
1402+
``recipient_user_id`` / ``recipient_team_id`` from the author
1403+
of this message. Omit for post-only worker flows.
1404+
1405+
Mirrors ``chat.thread(threadId)`` from the upstream TS SDK.
1406+
"""
1407+
adapter_name = thread_id.split(":")[0] if ":" in thread_id else ""
1408+
if not adapter_name:
1409+
raise ChatError(f"Invalid thread ID: {thread_id}")
1410+
adapter = self._adapters.get(adapter_name)
1411+
if adapter is None:
1412+
raise ChatError(f'Adapter "{adapter_name}" not found for thread ID "{thread_id}"')
1413+
1414+
stub_message = (
1415+
current_message
1416+
if current_message is not None
1417+
else Message(
1418+
id="",
1419+
thread_id=thread_id,
1420+
text="",
1421+
formatted={"type": "root", "children": []},
1422+
raw=None,
1423+
author=Author(user_id="", user_name="", full_name="", is_bot=False, is_me=False),
1424+
metadata=MessageMetadata(date_sent=datetime.now(tz=timezone.utc), edited=False),
1425+
)
1426+
)
1427+
return self._create_thread(adapter, thread_id, stub_message, False)
1428+
13811429
# ========================================================================
13821430
# Adapter inference
13831431
# ========================================================================

tests/test_chat_resolver.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -426,3 +426,68 @@ async def task(chat: Chat, name: str) -> None:
426426
await asyncio.gather(task(chat_a, "ca"), task(chat_b, "cb"))
427427
assert results["ca"] == "ca"
428428
assert results["cb"] == "cb"
429+
430+
431+
class TestChatThreadFactory:
432+
"""``Chat.thread(thread_id)`` — public worker-reconstruction path (issue #46).
433+
434+
Mirrors ``chat.thread(threadId)`` from the upstream TS SDK. Lets worker
435+
processes rebuild a Thread bound to this Chat's state and the adapter
436+
inferred from the thread ID prefix — no need to reach into
437+
``ThreadImpl`` / ``_ThreadImplConfig`` directly.
438+
"""
439+
440+
def setup_method(self):
441+
clear_chat_singleton()
442+
443+
def teardown_method(self):
444+
clear_chat_singleton()
445+
446+
def test_infers_adapter_from_thread_id_prefix(self):
447+
chat = _make_chat("slack")
448+
thread = chat.thread("slack:C123:1234567890.123456")
449+
assert thread.adapter.name == "slack"
450+
assert thread.id == "slack:C123:1234567890.123456"
451+
452+
def test_propagates_explicit_current_message(self):
453+
"""Slack native streaming reads ``current_message`` to populate
454+
recipient IDs; the public factory must let workers supply it.
455+
"""
456+
from datetime import datetime, timezone
457+
458+
from chat_sdk import Author, Message, MessageMetadata
459+
460+
chat = _make_chat("slack")
461+
thread_id = "slack:C123:1234567890.123456"
462+
msg = Message(
463+
id="M1",
464+
thread_id=thread_id,
465+
text="hi",
466+
formatted={"type": "root", "children": []},
467+
raw=None,
468+
author=Author(user_id="U1", user_name="alice", full_name="Alice", is_bot=False, is_me=False),
469+
metadata=MessageMetadata(date_sent=datetime.now(tz=timezone.utc), edited=False),
470+
)
471+
thread = chat.thread(thread_id, current_message=msg)
472+
assert thread._current_message is msg
473+
474+
def test_omitting_current_message_stubs_a_placeholder(self):
475+
"""Workers that only post (no streaming) can omit ``current_message``."""
476+
chat = _make_chat("slack")
477+
thread = chat.thread("slack:C123:1234567890.123456")
478+
assert thread._current_message is not None
479+
assert thread._current_message.id == ""
480+
481+
def test_invalid_thread_id_raises(self):
482+
from chat_sdk.errors import ChatError
483+
484+
chat = _make_chat("slack")
485+
with pytest.raises(ChatError, match="Invalid thread ID"):
486+
chat.thread("no-colon-here")
487+
488+
def test_unregistered_adapter_raises(self):
489+
from chat_sdk.errors import ChatError
490+
491+
chat = _make_chat("slack")
492+
with pytest.raises(ChatError, match='Adapter "teams" not found'):
493+
chat.thread("teams:foo:bar")

tests/test_slack_api.py

Lines changed: 76 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -897,7 +897,7 @@ async def test_stream_with_markdown_text_chunks(self):
897897
mock_streamer = MagicMock()
898898
mock_streamer.append = AsyncMock()
899899
mock_streamer.stop = AsyncMock(return_value={"message": {"ts": "999.999"}})
900-
client.chat_stream = MagicMock(return_value=mock_streamer)
900+
client.chat_stream = AsyncMock(return_value=mock_streamer)
901901

902902
from chat_sdk.types import MarkdownTextChunk
903903

@@ -922,7 +922,7 @@ async def test_stream_with_task_update_chunks(self):
922922
mock_streamer = MagicMock()
923923
mock_streamer.append = AsyncMock()
924924
mock_streamer.stop = AsyncMock(return_value={"message": {"ts": "888.888"}})
925-
client.chat_stream = MagicMock(return_value=mock_streamer)
925+
client.chat_stream = AsyncMock(return_value=mock_streamer)
926926

927927
from chat_sdk.types import TaskUpdateChunk
928928

@@ -947,7 +947,7 @@ async def test_stream_with_plan_update_chunks(self):
947947
mock_streamer = MagicMock()
948948
mock_streamer.append = AsyncMock()
949949
mock_streamer.stop = AsyncMock(return_value={"message": {"ts": "777.777"}})
950-
client.chat_stream = MagicMock(return_value=mock_streamer)
950+
client.chat_stream = AsyncMock(return_value=mock_streamer)
951951

952952
from chat_sdk.types import PlanUpdateChunk
953953

@@ -964,6 +964,79 @@ async def chunk_gen() -> AsyncIterator[StreamChunk | str]:
964964

965965
assert result.id == "777.777"
966966

967+
@pytest.mark.asyncio
968+
async def test_stream_awaits_chat_stream_coroutine(self):
969+
"""Regression test for issue #44: ``AsyncWebClient.chat_stream`` is a
970+
coroutine function. Without ``await``, ``streamer`` is a coroutine
971+
and calling ``.append`` raises ``AttributeError``. This test uses
972+
an ``AsyncMock`` (mirroring the real client) so the fix is required
973+
to pass.
974+
"""
975+
adapter, client, _ = await _init_adapter()
976+
977+
mock_streamer = MagicMock()
978+
mock_streamer.append = AsyncMock()
979+
mock_streamer.stop = AsyncMock(return_value={"message": {"ts": "444.444"}})
980+
client.chat_stream = AsyncMock(return_value=mock_streamer)
981+
982+
async def text_gen() -> AsyncIterator[str]:
983+
yield "hello"
984+
985+
result = await adapter.stream(
986+
"slack:C123:1234567890.000000",
987+
text_gen(),
988+
StreamOptions(recipient_user_id="U1", recipient_team_id="T1"),
989+
)
990+
991+
assert result.id == "444.444"
992+
client.chat_stream.assert_awaited_once()
993+
assert mock_streamer.append.await_count >= 1
994+
995+
996+
# =============================================================================
997+
# Public request-context accessors (issue #47)
998+
# =============================================================================
999+
1000+
1001+
class TestPublicContextAccessors:
1002+
"""``current_token`` / ``current_client`` expose the same values as the
1003+
underscore-prefixed helpers without forcing callers into private API.
1004+
"""
1005+
1006+
@pytest.mark.asyncio
1007+
async def test_current_token_returns_default_bot_token_in_single_workspace(self):
1008+
adapter, _, _ = await _init_adapter()
1009+
assert adapter.current_token == "xoxb-test-token"
1010+
1011+
@pytest.mark.asyncio
1012+
async def test_current_client_returns_preconfigured_client(self):
1013+
adapter, mock_client, _ = await _init_adapter()
1014+
# ``_patch_client`` redirects ``_get_client`` to the MockSlackClient,
1015+
# so the public accessor must route through the same path.
1016+
assert adapter.current_client is mock_client
1017+
1018+
@pytest.mark.asyncio
1019+
async def test_current_token_honors_per_request_context(self):
1020+
adapter, _, _ = await _init_adapter()
1021+
1022+
async def within_ctx() -> str:
1023+
return adapter.current_token
1024+
1025+
from chat_sdk.adapters.slack.types import RequestContext
1026+
1027+
token = adapter._request_context.set(RequestContext(token="xoxb-per-request", bot_user_id="U_PER"))
1028+
try:
1029+
assert adapter.current_token == "xoxb-per-request"
1030+
finally:
1031+
adapter._request_context.reset(token)
1032+
1033+
def test_current_token_raises_without_any_token(self):
1034+
from chat_sdk.shared.errors import AuthenticationError
1035+
1036+
adapter = _make_adapter(bot_token=None)
1037+
with pytest.raises(AuthenticationError):
1038+
_ = adapter.current_token
1039+
9671040

9681041
# =============================================================================
9691042
# parseMessage -- complex edge cases

0 commit comments

Comments
 (0)