Skip to content

Commit 88cf241

Browse files
fix(chat): enforce ConcurrencyConfig.max_concurrent + release 0.4.26.1 (#60)
fix(chat): enforce ConcurrencyConfig.max_concurrent + release 0.4.26.1
2 parents 5a6cf44 + 7dc7ea9 commit 88cf241

6 files changed

Lines changed: 302 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 67 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,75 @@
11
# Changelog
22

3-
## Unreleased
3+
## 0.4.26.1 (2026-04-23)
4+
5+
Python-only follow-up on `0.4.26`. Still alpha — APIs may change.
46

57
### 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).
88

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`).
9+
- **Slack native streaming**: `SlackAdapter.stream()` no longer calls
10+
`AsyncWebClient.chat_stream(...)` without `await`. The unawaited coroutine
11+
returned a truthy object, and the first `streamer.append(...)` raised
12+
`AttributeError`, breaking native Slack streaming for any consumer using
13+
the default adapter. Issue #44.
14+
- **Teams divider renders at non-zero height**: empty `Container` with
15+
`separator: True` rendered as zero-height in the Teams UI. Dividers
16+
between siblings now hoist `separator: True` onto the following element;
17+
a trailing divider emits a minimal non-empty Container. Issue #45.
18+
- **`ConcurrencyConfig.max_concurrent` is now enforced**: consumers setting
19+
`concurrency=ConcurrencyConfig(strategy="concurrent", max_concurrent=N)`
20+
now actually get an `asyncio.Semaphore(N)` cap on in-flight handlers.
21+
Previously the field was accepted and ignored (upstream TS has the same
22+
gap). `None` / unset keeps the unbounded default. Issue #51.
23+
24+
### Python-specific (divergence from upstream 4.26)
25+
26+
- **Fallback streaming runtime robustness** (cluster of fixes): framework-
27+
agnostic `request.text()` handling now tolerates sync Flask-style
28+
requests (was raising `TypeError: object is not awaitable`). Handlers
29+
typed `Callable[..., Awaitable[None] | None]` may return sync (`None`) —
30+
the dispatcher now `await`s only when `inspect.isawaitable()` confirms,
31+
preventing runtime crashes on sync handlers.
32+
- **`max_concurrent` enforcement** (see above) — upstream accepts the
33+
config field but never enforces it; we do.
34+
35+
### New public APIs
36+
37+
- **`Chat.thread(thread_id, *, current_message=None)`**: new worker-
38+
reconstruction factory mirroring TS `chat.thread(threadId)`. Adapter is
39+
inferred from the thread-ID prefix; state and message history come from
40+
the Chat instance. `current_message` is preserved so Slack native
41+
streaming still works post-reconstruction. Issue #46.
42+
- **`SlackAdapter.current_token` / `current_client`**: public `@property`
43+
accessors for the request-context-bound bot token and a preconfigured
44+
`AsyncWebClient`. Replaces underscore access from consumer code making
45+
direct Slack Web API calls inside a handler (email resolution, user
46+
profile fetches, etc.). Issue #47.
47+
48+
### Internals
49+
50+
- **Pyrefly: 213 → 0 type errors**; baseline file removed. CI now enforces
51+
zero errors. Root causes fixed: 8-adapter `lock_scope: LockScope | None`
52+
protocol conformance; `_ChatSingleton` as `Protocol`; submodule-aware
53+
`replace-imports-with-any`; `NoReturn` on error re-raisers;
54+
`inspect.isawaitable` guards for duck-typed request handling and
55+
sync-or-async handler dispatch. No `Any` widening, no new `# type:
56+
ignore` lines beyond 10 at adapter event-construction sites where
57+
`thread=None`/`channel=None` get re-wrapped by `Chat` before handler
58+
dispatch (matches upstream TS's `Omit<>` partial-event pattern).
59+
- Test count: **3545 passed**, 2 skipped.
60+
61+
### Known gaps (not fixed in this release)
62+
63+
- `onOptionsLoad` handler for dynamic select dropdowns — issue #50
64+
- `Thread.getParticipants()` method — issue #54
65+
- `rehydrate_attachment` adapter hook for queue/debounce + attachments —
66+
issue #52
67+
- 40 upstream tests without Python equivalents (Options Load, Plan variants,
68+
StreamingPlan options, getParticipants) — issue #53
69+
- Discord native Gateway WebSocket (HTTP-only today) — issue #57
70+
- Teams certificate-based mTLS auth — issue #58
71+
- Google Chat file uploads (TODO upstream too) — issue #59
72+
- Global handler-dispatch bound across reactions/actions/slash/modals — issue #61
1273

1374
## 0.4.26 (2026-04-16)
1475

CONTRIBUTING.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,13 +58,20 @@ All PRs must pass `ruff check` with zero errors.
5858
Vercel Chat version. See [UPSTREAM_SYNC.md](docs/UPSTREAM_SYNC.md#version-mapping).
5959

6060
- `0.4.25` = synced to upstream `4.25.0`
61-
- `0.4.25.1` = Python-only fix on top of `4.25.0`
61+
- `0.4.25.1` = Python-only changes (fixes, and additive features during
62+
alpha) between upstream sync points
6263
- `0.4.26a1` = alpha while porting upstream `4.26.0`
6364

65+
> **Additive changes in `.patch` bumps are OK during alpha**. The package
66+
> is marked `Development Status :: 3 - Alpha` and the `0.x.y` prefix signals
67+
> pre-1.0 per semver convention, so new public APIs can land in `.patch`
68+
> bumps without a version-scheme violation. Once we hit `1.0`, `.patch`
69+
> should be fixes-only.
70+
>
6471
> **Upstream patch releases**: Vercel Chat has historically gone straight to
6572
> minor bumps, but if upstream ships a patch (e.g. `4.25.1`) we sync it by
6673
> bumping to the next minor (`0.4.26`). We don't reuse the `.patch` slot for
67-
> upstream patches — it's reserved for Python-only fixes so the two can't
74+
> upstream patches — it's reserved for Python-only changes so the two can't
6875
> collide.
6976
7077
### Steps

docs/UPSTREAM_SYNC.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -457,6 +457,7 @@ stay explicit instead of being rediscovered in code review.
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. |
458458
| 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. |
459459
| `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. |
460+
| `ConcurrencyConfig.max_concurrent` | Enforced via `asyncio.Semaphore` in the `"concurrent"` strategy path; rejects non-integer or `<= 0` values, and rejects any non-`None` `max_concurrent` paired with a non-`"concurrent"` strategy | Accepted into the config type with docstring "Default: Infinity" but never read (3 writes, 0 reads) | Silent correctness bug upstream — consumers setting `max_concurrent=N` with `strategy="concurrent"` reasonably expect an N-way bound on in-flight handlers. We honor the documented contract via a semaphore and fail-fast on misconfiguration so it's never silent. `max_concurrent=None` stays compatible with every strategy (unbounded default). |
460461

461462
### Platform-specific gaps
462463

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "chat-sdk"
3-
version = "0.4.26"
3+
version = "0.4.26.1"
44
description = "Multi-platform async chat SDK for Python — port of Vercel Chat"
55
readme = "README.md"
66
license = {text = "MIT"}

src/chat_sdk/chat.py

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
Channel,
4040
ChannelVisibility,
4141
ChatConfig,
42+
ConcurrencyConfig,
4243
ConcurrencyStrategy,
4344
EmojiValue,
4445
Lock,
@@ -297,6 +298,48 @@ def __init__(self, config: ChatConfig | None = None, **kwargs: Any) -> None:
297298
self._concurrency_on_queue_full = concurrency.on_queue_full
298299
self._concurrency_queue_entry_ttl_ms = concurrency.queue_entry_ttl_ms
299300

301+
# -- Concurrent-strategy semaphore ------------------------------------
302+
# Divergence from upstream — see docs/UPSTREAM_SYNC.md.
303+
# `max_concurrent` bounds in-flight handler dispatches when using the
304+
# `"concurrent"` strategy. `None` means unbounded (matches the upstream
305+
# TS default of `Infinity`). A positive integer caps parallel handler
306+
# runs. Upstream accepts the config field but never enforces it
307+
# (3 writes, 0 reads); we enforce it via `asyncio.Semaphore`.
308+
#
309+
# Only construct the semaphore when the strategy actually uses it —
310+
# if a user sets `max_concurrent=5` with `strategy="queue"`, they
311+
# have a misconfiguration that we surface as a `ValueError` rather
312+
# than silently allocating an unused primitive.
313+
#
314+
# Reject `<= 0` explicitly rather than silently ignoring — a user
315+
# passing `max_concurrent=0` likely means "pause all processing"
316+
# (not supported) or has a typo. Either way, silently falling back
317+
# to unbounded concurrency would surprise them.
318+
raw_max = concurrency.max_concurrent if isinstance(concurrency, ConcurrencyConfig) else None
319+
if raw_max is not None and (
320+
# Reject non-int (including bool, which is an int subclass but
321+
# semantically meaningless here) before any arithmetic —
322+
# `asyncio.Semaphore(1.5)` silently goes negative, `Semaphore(True)`
323+
# allocates a 1-way bound from a boolean, and `Semaphore("2")`
324+
# raises `TypeError` instead of our ValueError.
325+
isinstance(raw_max, bool) or not isinstance(raw_max, int) or raw_max <= 0
326+
):
327+
raise ValueError(
328+
f"ConcurrencyConfig.max_concurrent must be a positive integer or None; "
329+
f"got {raw_max!r}. Pass None for unbounded concurrency."
330+
)
331+
if self._concurrency_max_concurrent is not None and self._concurrency_strategy != "concurrent":
332+
raise ValueError(
333+
f"ConcurrencyConfig.max_concurrent is only honored when strategy='concurrent'; "
334+
f"got strategy={self._concurrency_strategy!r}. Either switch to strategy='concurrent' "
335+
"or drop max_concurrent."
336+
)
337+
self._concurrent_semaphore: asyncio.Semaphore | None = (
338+
asyncio.Semaphore(self._concurrency_max_concurrent)
339+
if self._concurrency_max_concurrent is not None
340+
else None
341+
)
342+
300343
# -- Message history (placeholder -- real impl would use MessageHistoryCache)
301344
self._message_history = _MessageHistoryCache(self._state_adapter, config.message_history)
302345

@@ -1840,7 +1883,15 @@ async def _handle_concurrent(
18401883
thread_id: str,
18411884
message: Message,
18421885
) -> None:
1843-
await self._dispatch_to_handlers(adapter, thread_id, message)
1886+
# Enforce `max_concurrent` bound when configured. Upstream TS
1887+
# accepts the config field but never enforces it; we do, so that
1888+
# consumers setting `ConcurrencyConfig(strategy="concurrent",
1889+
# max_concurrent=N)` actually get a bound of N in-flight handlers.
1890+
if self._concurrent_semaphore is None:
1891+
await self._dispatch_to_handlers(adapter, thread_id, message)
1892+
return
1893+
async with self._concurrent_semaphore:
1894+
await self._dispatch_to_handlers(adapter, thread_id, message)
18441895

18451896
# ========================================================================
18461897
# Dispatch to handlers

tests/test_chat_faithful.py

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2263,6 +2263,178 @@ async def handler(thread, message, context=None):
22632263
assert len(calls) == 1
22642264
assert calls[0] == "Hey @slack-bot concurrent"
22652265

2266+
# Python-specific: upstream accepts max_concurrent but doesn't enforce
2267+
# it. We do. Bound should cap in-flight handlers at N; the (N+1)th
2268+
# message has to wait until one of the first N releases.
2269+
async def test_max_concurrent_bounds_in_flight_handlers(self):
2270+
state = create_mock_state()
2271+
adapter = create_mock_adapter("slack")
2272+
2273+
chat, _, _ = await _init_chat(
2274+
adapter=adapter,
2275+
state=state,
2276+
concurrency=ConcurrencyConfig(strategy="concurrent", max_concurrent=2),
2277+
)
2278+
2279+
in_flight = 0
2280+
max_observed = 0
2281+
gate = asyncio.Event()
2282+
finished = 0
2283+
2284+
@chat.on_mention
2285+
async def handler(thread, message, context=None):
2286+
nonlocal in_flight, max_observed, finished
2287+
in_flight += 1
2288+
max_observed = max(max_observed, in_flight)
2289+
await gate.wait()
2290+
in_flight -= 1
2291+
finished += 1
2292+
2293+
# Dispatch 5 messages concurrently — at most 2 should be in flight
2294+
# at any time while the gate is closed.
2295+
tasks = [
2296+
asyncio.create_task(
2297+
chat.handle_incoming_message(
2298+
adapter,
2299+
f"slack:C123:{i}",
2300+
create_test_message(f"msg-{i}", "Hey @slack-bot"),
2301+
)
2302+
)
2303+
for i in range(5)
2304+
]
2305+
2306+
# Wait until the first 2 handlers reach the gate. asyncio uses a
2307+
# single-threaded cooperative scheduler, so between `_reach_cap`
2308+
# returning and the next assertion, no other task can interleave
2309+
# — tasks 3-5 are parked on `semaphore.acquire()`. The
2310+
# `in_flight == 2` check IS stable here.
2311+
async def _reach_cap() -> None:
2312+
while in_flight < 2:
2313+
await asyncio.sleep(0.001)
2314+
2315+
await asyncio.wait_for(_reach_cap(), timeout=1.0)
2316+
# Snapshot while the gate is still closed: exactly the bound
2317+
# should be in flight, and no more.
2318+
assert in_flight == 2
2319+
2320+
# Release the gate; all 5 should drain. If the semaphore leaked,
2321+
# `max_observed` inside the handlers captured the peak before
2322+
# any could unblock, so the final assertion below would fail.
2323+
gate.set()
2324+
await asyncio.gather(*tasks)
2325+
2326+
assert finished == 5
2327+
# The critical assertion: peak in-flight never exceeded 2.
2328+
assert max_observed == 2
2329+
2330+
# Python-specific: reject invalid `max_concurrent` values at construction
2331+
# time rather than silently falling back to unbounded (which would
2332+
# surprise users who set `max_concurrent=0` expecting strict throttling).
2333+
async def test_max_concurrent_zero_or_negative_raises(self):
2334+
state = create_mock_state()
2335+
adapter = create_mock_adapter("slack")
2336+
2337+
for bad_value in (0, -1, -100):
2338+
import pytest
2339+
2340+
with pytest.raises(ValueError, match="max_concurrent must be a positive integer or None"):
2341+
await _init_chat(
2342+
adapter=adapter,
2343+
state=state,
2344+
concurrency=ConcurrencyConfig(strategy="concurrent", max_concurrent=bad_value),
2345+
)
2346+
2347+
# Python-specific: reject non-integer `max_concurrent` at construction
2348+
# instead of letting `asyncio.Semaphore` misbehave (`1.5` silently drives
2349+
# the counter negative, `True` allocates a 1-way bound from a bool,
2350+
# `"2"` raises `TypeError` from inside the primitive instead of our
2351+
# `ValueError`).
2352+
async def test_max_concurrent_non_integer_raises(self):
2353+
import pytest
2354+
2355+
state = create_mock_state()
2356+
adapter = create_mock_adapter("slack")
2357+
2358+
for bad_value in (1.5, True, False, "2", 0.0, [1]):
2359+
with pytest.raises(ValueError, match="max_concurrent must be a positive integer or None"):
2360+
await _init_chat(
2361+
adapter=adapter,
2362+
state=state,
2363+
concurrency=ConcurrencyConfig(strategy="concurrent", max_concurrent=bad_value), # type: ignore[arg-type]
2364+
)
2365+
2366+
# Python-specific: setting `max_concurrent` with a non-concurrent strategy
2367+
# is a misconfiguration — the field is only honored under `"concurrent"`.
2368+
# Fail loudly instead of silently allocating an unused semaphore.
2369+
async def test_max_concurrent_with_non_concurrent_strategy_raises(self):
2370+
import pytest
2371+
2372+
state = create_mock_state()
2373+
adapter = create_mock_adapter("slack")
2374+
2375+
for bad_strategy in ("queue", "debounce", "drop"):
2376+
with pytest.raises(ValueError, match="only honored when strategy='concurrent'"):
2377+
await _init_chat(
2378+
adapter=adapter,
2379+
state=state,
2380+
concurrency=ConcurrencyConfig(strategy=bad_strategy, max_concurrent=5),
2381+
)
2382+
2383+
# Python-specific: None / missing max_concurrent must keep the
2384+
# unbounded behavior (matches upstream TS default of Infinity).
2385+
# Parameterized to cover both the string form (max_concurrent implicit)
2386+
# and the explicit ConcurrencyConfig(max_concurrent=None) form — the
2387+
# two take separate code paths in Chat.__init__ (string → defaults,
2388+
# ConcurrencyConfig → field read), so both must be verified.
2389+
@pytest.mark.parametrize(
2390+
"concurrency_value",
2391+
[
2392+
"concurrent",
2393+
ConcurrencyConfig(strategy="concurrent", max_concurrent=None),
2394+
],
2395+
ids=["string", "config_none"],
2396+
)
2397+
async def test_max_concurrent_none_allows_unbounded(self, concurrency_value):
2398+
state = create_mock_state()
2399+
adapter = create_mock_adapter("slack")
2400+
2401+
chat, _, _ = await _init_chat(adapter=adapter, state=state, concurrency=concurrency_value)
2402+
2403+
in_flight = 0
2404+
max_observed = 0
2405+
gate = asyncio.Event()
2406+
2407+
@chat.on_mention
2408+
async def handler(thread, message, context=None):
2409+
nonlocal in_flight, max_observed
2410+
in_flight += 1
2411+
max_observed = max(max_observed, in_flight)
2412+
await gate.wait()
2413+
in_flight -= 1
2414+
2415+
tasks = [
2416+
asyncio.create_task(
2417+
chat.handle_incoming_message(
2418+
adapter,
2419+
f"slack:C123:{i}",
2420+
create_test_message(f"msg-{i}", "Hey @slack-bot"),
2421+
)
2422+
)
2423+
for i in range(5)
2424+
]
2425+
2426+
# Poll until all 5 are in flight; with no semaphore they should
2427+
# all reach the gate.
2428+
async def _reach_five() -> None:
2429+
while in_flight < 5:
2430+
await asyncio.sleep(0.001)
2431+
2432+
await asyncio.wait_for(_reach_five(), timeout=1.0)
2433+
assert in_flight == 5
2434+
gate.set()
2435+
await asyncio.gather(*tasks)
2436+
assert max_observed == 5
2437+
22662438

22672439
# ============================================================================
22682440
# 22. lockScope (tests 87-91)

0 commit comments

Comments
 (0)