Skip to content

Commit 4427c97

Browse files
fix(chat): enforce ConcurrencyConfig.max_concurrent + release 0.4.26.1
Fixes #51. Upstream TS accepts the config field but never enforces it (3 writes, 0 reads). Python now uses an asyncio.Semaphore so a consumer setting `concurrency=ConcurrencyConfig(strategy="concurrent", max_concurrent=N)` actually gets an N-way cap on in-flight handlers. Divergence from upstream — Python leads here. The upstream behavior is documented ("Default: Infinity") but not implemented; our enforcement matches the documented contract. Changes: - `Chat.__init__` constructs `asyncio.Semaphore(max_concurrent)` when the value is set and positive, leaves it `None` otherwise (unbounded). - `_handle_concurrent` acquires the semaphore around `_dispatch_to_handlers` when present; falls through to direct dispatch when `None`. - 2 new tests in `TestConcurrencyConcurrent`: - `test_max_concurrent_bounds_in_flight_handlers`: 5 messages with `max_concurrent=2`; verifies `max_observed == 2` at any time. - `test_max_concurrent_none_allows_unbounded`: 5 concurrent handlers without a bound all run in parallel (regression guard for the default). Release prep for 0.4.26.1: - Version bump `0.4.26` → `0.4.26.1` in pyproject.toml - `UPSTREAM_PARITY` stays `4.26.0` (still synced to 4.26.0 upstream; no new upstream sync in this release) - CHANGELOG entry with Fixes / Python-specific / New public APIs / Internals / Known gaps sections - CONTRIBUTING.md version-scheme wording: `.patch` bumps during alpha can be additive (fixes + features). Tightens to "fixes-only" once we hit 1.0. Test plan: - `uv run ruff check src/ tests/ scripts/` — clean - `uv run ruff format --check src/ tests/ scripts/` — clean - `uv run python scripts/audit_test_quality.py` — 0 hard failures - `uv run pytest tests/ --tb=short -q` — 3471 passed, 2 skipped
1 parent 6292436 commit 4427c97

5 files changed

Lines changed: 198 additions & 4 deletions

File tree

CHANGELOG.md

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

3+
## 0.4.26.1 (2026-04-23)
4+
5+
Python-only follow-up on `0.4.26`. Still alpha — APIs may change.
6+
7+
### Fixes
8+
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: **3471 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+
373
## 0.4.26 (2026-04-16)
474

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

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

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: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,19 @@ def __init__(self, config: ChatConfig | None = None, **kwargs: Any) -> None:
296296
self._concurrency_on_queue_full = concurrency.on_queue_full
297297
self._concurrency_queue_entry_ttl_ms = concurrency.queue_entry_ttl_ms
298298

299+
# -- Concurrent-strategy semaphore ------------------------------------
300+
# `max_concurrent` bounds in-flight handler dispatches when using the
301+
# `"concurrent"` strategy. `None` means unbounded (matches the upstream
302+
# TS default of `Infinity`). A non-None value caps parallel handler
303+
# runs at that number — useful for rate-limit hygiene and bounded
304+
# resource use. Divergence from upstream: upstream accepts the config
305+
# field but doesn't enforce it; we do.
306+
self._concurrent_semaphore: asyncio.Semaphore | None = (
307+
asyncio.Semaphore(self._concurrency_max_concurrent)
308+
if self._concurrency_max_concurrent is not None and self._concurrency_max_concurrent > 0
309+
else None
310+
)
311+
299312
# -- Message history (placeholder -- real impl would use MessageHistoryCache)
300313
self._message_history = _MessageHistoryCache(self._state_adapter, config.message_history)
301314

@@ -1756,7 +1769,15 @@ async def _handle_concurrent(
17561769
thread_id: str,
17571770
message: Message,
17581771
) -> None:
1759-
await self._dispatch_to_handlers(adapter, thread_id, message)
1772+
# Enforce `max_concurrent` bound when configured. Upstream TS
1773+
# accepts the config field but never enforces it; we do, so that
1774+
# consumers setting `ConcurrencyConfig(strategy="concurrent",
1775+
# max_concurrent=N)` actually get a bound of N in-flight handlers.
1776+
if self._concurrent_semaphore is None:
1777+
await self._dispatch_to_handlers(adapter, thread_id, message)
1778+
return
1779+
async with self._concurrent_semaphore:
1780+
await self._dispatch_to_handlers(adapter, thread_id, message)
17601781

17611782
# ========================================================================
17621783
# Dispatch to handlers

tests/test_chat_faithful.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2263,6 +2263,102 @@ 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+
import asyncio
2271+
2272+
state = create_mock_state()
2273+
adapter = create_mock_adapter("slack")
2274+
2275+
chat, _, _ = await _init_chat(
2276+
adapter=adapter,
2277+
state=state,
2278+
concurrency=ConcurrencyConfig(strategy="concurrent", max_concurrent=2),
2279+
)
2280+
2281+
in_flight = 0
2282+
max_observed = 0
2283+
gate = asyncio.Event()
2284+
finished = 0
2285+
2286+
@chat.on_mention
2287+
async def handler(thread, message, context=None):
2288+
nonlocal in_flight, max_observed, finished
2289+
in_flight += 1
2290+
max_observed = max(max_observed, in_flight)
2291+
await gate.wait()
2292+
in_flight -= 1
2293+
finished += 1
2294+
2295+
# Dispatch 5 messages concurrently — at most 2 should be in flight
2296+
# at any time while the gate is closed.
2297+
tasks = [
2298+
asyncio.create_task(
2299+
chat.handle_incoming_message(
2300+
adapter,
2301+
f"slack:C123:{i}",
2302+
create_test_message(f"msg-{i}", "Hey @slack-bot"),
2303+
)
2304+
)
2305+
for i in range(5)
2306+
]
2307+
2308+
# Let the first two handlers enter and block on the gate.
2309+
for _ in range(20):
2310+
await asyncio.sleep(0)
2311+
assert in_flight == 2
2312+
assert max_observed == 2
2313+
2314+
# Release the gate; all 5 should drain.
2315+
gate.set()
2316+
await asyncio.gather(*tasks)
2317+
2318+
assert finished == 5
2319+
# The bound was never exceeded.
2320+
assert max_observed == 2
2321+
2322+
# Python-specific: None / missing max_concurrent must keep the
2323+
# unbounded behavior (matches upstream TS default of Infinity).
2324+
async def test_max_concurrent_none_allows_unbounded(self):
2325+
import asyncio
2326+
2327+
state = create_mock_state()
2328+
adapter = create_mock_adapter("slack")
2329+
2330+
chat, _, _ = await _init_chat(adapter=adapter, state=state, concurrency="concurrent")
2331+
2332+
in_flight = 0
2333+
max_observed = 0
2334+
gate = asyncio.Event()
2335+
2336+
@chat.on_mention
2337+
async def handler(thread, message, context=None):
2338+
nonlocal in_flight, max_observed
2339+
in_flight += 1
2340+
max_observed = max(max_observed, in_flight)
2341+
await gate.wait()
2342+
in_flight -= 1
2343+
2344+
tasks = [
2345+
asyncio.create_task(
2346+
chat.handle_incoming_message(
2347+
adapter,
2348+
f"slack:C123:{i}",
2349+
create_test_message(f"msg-{i}", "Hey @slack-bot"),
2350+
)
2351+
)
2352+
for i in range(5)
2353+
]
2354+
for _ in range(20):
2355+
await asyncio.sleep(0)
2356+
# All 5 should be in flight simultaneously (no bound).
2357+
assert in_flight == 5
2358+
gate.set()
2359+
await asyncio.gather(*tasks)
2360+
assert max_observed == 5
2361+
22662362

22672363
# ============================================================================
22682364
# 22. lockScope (tests 87-91)

0 commit comments

Comments
 (0)