Skip to content

Commit 68520d6

Browse files
release: v0.4.25 — synced to Vercel Chat 4.25.0
* fix: review findings + version scheme + integration tests Must-fix: - Teams Action.Submit now passes all submitted input values (was dropping ChoiceSet selections, making Select/RadioSelect unusable). For plain buttons with a single "value" key, the value is unwrapped for backward compat. - Added end-to-end Teams card input tests: render Select -> submit Action.Submit -> verify process_action receives selected values. Review fixes from previous round: - P1: GitHub fetch_thread issue threads missing github: prefix - P1: _content_to_plain_text could return non-string - P2: asyncio.ensure_future -> create_task in Plan - P2: Redundant isinstance(value, object) -> value is not None - P2: GitHub issue metadata camelCase -> snake_case - P2: Silent exception suppression -> logged in _enqueue_edit - P3: Added test for Plan edit error path Version scheme: 0.4.25 (embeds upstream version 4.25) Process updates in UPSTREAM_SYNC.md: - Upstream behavior is one input, not source of truth - Every new adapter feature needs integration-style tests - Every fetch_thread() needs round-trip test with channel APIs - Intentional divergences must be recorded before publishing Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address PR #41 review comments - Fix versioning scheme description inconsistency in CHANGELOG.md - Extract _extract_action_values() helper in Teams adapter (was duplicated between _handle_message_action and _handle_adaptive_card_action) - GitHub raw dict duplication noted but not extracted (only 2 call sites with slight differences in the type/review_comment branching) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * docs: add PyPI, Python version, CI, and license badges to README Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: GitHub raw dict helper + PostableObject history cache - Extract _build_raw_message() in GitHub adapter (was duplicated between post_message and edit_message) - PostableObject posts now cached in message history for both Thread and Channel, so Plan objects appear in messages()/all_messages() like regular posts. Uses fallback text as the cached message content. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: strip msteams transport keys from action values (P1) + revert PostableObject history (P2) P1: _extract_action_values now strips both actionId and msteams keys. The msteams key is injected by our own card renderer (task/fetch for modal buttons) and is transport metadata, not user input. Without this, modal button clicks would deliver {"value": "x", "msteams": {...}} instead of just "x". P2: Reverted PostableObject history caching — upstream TS does not cache PostableObjects in message history either. The previous fix used a synthetic "postable-obj" ID which would have caused ambiguous history entries. Matches upstream behavior. Added regression test for msteams key stripping. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: cache PostableObjects in history with real message ID PostableObject posts (e.g., Plan) now cached in thread/channel message history using the real adapter-returned message ID and fallback text. This is an intentional divergence from upstream TS, which skips history for PostableObjects (a gap, not a design choice — regular posts and streaming posts both cache, only PostableObjects were missed). Changes: - post_postable_object() now returns the RawMessage - Thread/Channel _handle_postable_object returns RawMessage - History append uses raw.id (not synthetic "postable-obj" constant) - Documented as intentional divergence in UPSTREAM_SYNC.md non-parity Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 0ba59b6 commit 68520d6

13 files changed

Lines changed: 362 additions & 70 deletions

File tree

CHANGELOG.md

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

3-
## 0.25.0 (2026-04-10)
3+
## 0.4.25 (2026-04-10)
44

5-
Synced to [Vercel Chat 4.25.0](https://github.com/vercel/chat). New versioning: our minor version matches the upstream minor version.
5+
Synced to [Vercel Chat 4.25.0](https://github.com/vercel/chat). New versioning: `0.{upstream_major}.{upstream_minor}` embeds the upstream version directly.
66

77
### New features (from upstream 4.25.0)
88
- **Plan blocks**: `Plan` PostableObject for structured task lists with live updates. Post a plan to a thread, then `add_task()`, `update_task()`, and `complete()` with automatic card rendering.
@@ -12,7 +12,7 @@ Synced to [Vercel Chat 4.25.0](https://github.com/vercel/chat). New versioning:
1212
- **Slack OAuth redirect fix**: `handle_oauth_callback` correctly forwards `redirect_uri` option.
1313

1414
### Versioning
15-
- Version scheme changed from `0.0.1aX` to `0.{upstream_minor}.{patch}`
15+
- Version scheme changed from `0.0.1aX` to `0.{upstream_major}.{upstream_minor}[.patch]`
1616
- `UPSTREAM_PARITY` constant in `chat_sdk.__init__` for programmatic access
1717
- Sync procedure documented in [UPSTREAM_SYNC.md](docs/UPSTREAM_SYNC.md)
1818

CLAUDE.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,11 @@ uv run pytest tests/ --tb=short -q
1919
```
2020

2121
## Version Mapping
22-
Our version tracks the upstream Vercel Chat minor version:
23-
- `0.25.0` = synced to upstream `4.25.0`
24-
- `0.25.1` = Python-only fixes on top of `4.25.0`
25-
- `0.26.0` = synced to upstream `4.26.0`
22+
Our version embeds the upstream Vercel Chat version: `0.{upstream_major}.{upstream_minor}[.patch]`
23+
- `0.4.25` = synced to upstream `4.25.0`
24+
- `0.4.25.1` = Python-only fix on top of `4.25.0`
25+
- `0.4.26` = synced to upstream `4.26.0`
26+
- `0.4.26a1` = alpha while porting upstream `4.26.0`
2627
- `UPSTREAM_PARITY` constant in `__init__.py` = programmatic access
2728

2829
## Architecture

README.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
# chat-sdk-python
22

3+
[![PyPI](https://img.shields.io/pypi/v/chat-sdk)](https://pypi.org/project/chat-sdk/)
4+
[![Python](https://img.shields.io/pypi/pyversions/chat-sdk)](https://pypi.org/project/chat-sdk/)
5+
[![Tests](https://github.com/Chinchill-AI/chat-sdk-python/actions/workflows/test.yml/badge.svg)](https://github.com/Chinchill-AI/chat-sdk-python/actions/workflows/test.yml)
6+
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
7+
38
Multi-platform async chat SDK for Python. Port of [Vercel Chat](https://github.com/vercel/chat).
49

5-
> **Status: Alpha (0.25.0 — synced to [Vercel Chat 4.25.0](https://github.com/vercel/chat))** — API may change.
10+
> **Status: Alpha (0.4.25 — synced to [Vercel Chat 4.25.0](https://github.com/vercel/chat))** — API may change.
611
712
## Why chat-sdk?
813

docs/UPSTREAM_SYNC.md

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,14 @@ How to keep `chat-sdk-python` in sync with the [Vercel Chat TS SDK](https://gith
44

55
## Version Mapping
66

7-
Our version number tracks the upstream Vercel Chat minor version:
7+
Our version embeds the upstream Vercel Chat version: `0.{upstream_major}.{upstream_minor}[.patch]`
88

99
| Python version | Upstream version | Meaning |
1010
|---|---|---|
11-
| `0.25.0` | `4.25.0` | Synced to upstream 4.25.0 |
12-
| `0.25.1` | `4.25.0` | Python-only fix on top of 4.25.0 |
13-
| `0.26.0` | `4.26.0` | Synced to upstream 4.26.0 |
11+
| `0.4.25` | `4.25.0` | Synced to upstream 4.25.0 |
12+
| `0.4.25.1` | `4.25.0` | Python-only fix on top of 4.25.0 |
13+
| `0.4.25a1` | `4.25.0` | Alpha while porting 4.25.0 |
14+
| `0.4.26` | `4.26.0` | Synced to upstream 4.26.0 |
1415

1516
The `UPSTREAM_PARITY` constant in `chat_sdk/__init__.py` provides programmatic access
1617
to the upstream version this release is synced to.
@@ -43,7 +44,7 @@ uv run python scripts/verify_test_fidelity.py
4344
uv run pytest tests/ --tb=short -q
4445

4546
# 6. Update version
46-
# - pyproject.toml: version = "0.26.0"
47+
# - pyproject.toml: version = "0.4.26"
4748
# - README.md: status line
4849
# - __init__.py: UPSTREAM_PARITY = "4.26.0"
4950
# - CLAUDE.md: version reference
@@ -57,10 +58,19 @@ gh pr create --title "sync: upstream v4.26.0"
5758

5859
- [ ] New types or fields → add to `types.py`
5960
- [ ] New methods on Thread/Channel → add to `thread.py`/`channel.py`
60-
- [ ] New adapter features → update the adapter + tests
61+
- [ ] New adapter features → update the adapter + integration-style tests
6162
- [ ] New TS tests → run fidelity script, port missing tests
6263
- [ ] Changed behavior → verify Python matches with regression tests
6364
- [ ] Review the porting hazards below for each change
65+
- [ ] Every new `fetch_thread()` behavior → round-trip test with channel APIs
66+
- [ ] Every new adapter feature → at least one end-to-end test (not just unit/conversion)
67+
68+
### Upstream behavior is one input, not source of truth
69+
70+
If upstream tests are missing coverage for a feature, add Python-only regression
71+
tests. If upstream tests lock in inconsistent behavior, choose one of:
72+
- **Preserve parity** and document the inconsistency in the non-parity section below
73+
- **Intentionally diverge** and document the divergence in the non-parity section
6474

6575
## How to Diff Upstream Changes
6676

@@ -382,6 +392,8 @@ stay explicit instead of being rediscovered in code review.
382392
| `ast_to_plain_text` | Joins blocks with `\n` | Concatenates directly | More readable output |
383393
| `renderPostable` on unknown input | Returns `str(message)` | Throws `Error` | More resilient |
384394
| Chat resolver | 3-level: explicit → ContextVar → global | Process-global singleton | See [DECISIONS.md](DECISIONS.md#why-3-level-chat-resolver) |
395+
| PostableObject history | Cached in message history with real message ID | Not cached (skips history) | Upstream gap — posted messages should appear in thread/channel history |
396+
| Teams `msteams` transport key | Stripped from action values | Not stripped | Upstream gap — SDK-injected metadata should not leak to handlers |
385397

386398
### Platform-specific gaps
387399

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.25.0"
3+
version = "0.4.25"
44
description = "Multi-platform async chat SDK for Python — port of Vercel Chat"
55
readme = "README.md"
66
license = {text = "MIT"}

src/chat_sdk/adapters/github/adapter.py

Lines changed: 24 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -476,24 +476,10 @@ async def post_message(self, thread_id: str, message: AdapterPostableMessage) ->
476476
{"body": body},
477477
)
478478

479-
raw: dict[str, Any] = {
480-
"type": "review_comment" if decoded.review_comment_id else "issue_comment",
481-
"comment": comment,
482-
"repository": {
483-
"id": 0,
484-
"name": decoded.repo,
485-
"full_name": f"{decoded.owner}/{decoded.repo}",
486-
"owner": {"id": 0, "login": decoded.owner, "type": "User"},
487-
},
488-
"pr_number": decoded.pr_number,
489-
}
490-
if not decoded.review_comment_id:
491-
raw["thread_type"] = decoded.type or "pr"
492-
493479
return RawMessage(
494480
id=str(comment["id"]),
495481
thread_id=thread_id,
496-
raw=raw,
482+
raw=self._build_raw_message(decoded, comment),
497483
)
498484

499485
async def edit_message(self, thread_id: str, message_id: str, message: AdapterPostableMessage) -> RawMessage:
@@ -518,24 +504,10 @@ async def edit_message(self, thread_id: str, message_id: str, message: AdapterPo
518504
{"body": body},
519505
)
520506

521-
raw: dict[str, Any] = {
522-
"type": "review_comment" if decoded.review_comment_id else "issue_comment",
523-
"comment": comment,
524-
"repository": {
525-
"id": 0,
526-
"name": decoded.repo,
527-
"full_name": f"{decoded.owner}/{decoded.repo}",
528-
"owner": {"id": 0, "login": decoded.owner, "type": "User"},
529-
},
530-
"pr_number": decoded.pr_number,
531-
}
532-
if not decoded.review_comment_id:
533-
raw["thread_type"] = decoded.type or "pr"
534-
535507
return RawMessage(
536508
id=str(comment["id"]),
537509
thread_id=thread_id,
538-
raw=raw,
510+
raw=self._build_raw_message(decoded, comment),
539511
)
540512

541513
async def stream(
@@ -553,6 +525,24 @@ async def stream(
553525
text += chunk.text
554526
return await self.post_message(thread_id, {"markdown": text})
555527

528+
@staticmethod
529+
def _build_raw_message(decoded: Any, comment: dict[str, Any]) -> dict[str, Any]:
530+
"""Build the raw message dict for post/edit responses."""
531+
raw: dict[str, Any] = {
532+
"type": "review_comment" if decoded.review_comment_id else "issue_comment",
533+
"comment": comment,
534+
"repository": {
535+
"id": 0,
536+
"name": decoded.repo,
537+
"full_name": f"{decoded.owner}/{decoded.repo}",
538+
"owner": {"id": 0, "login": decoded.owner, "type": "User"},
539+
},
540+
"pr_number": decoded.pr_number,
541+
}
542+
if not decoded.review_comment_id:
543+
raw["thread_type"] = decoded.type or "pr"
544+
return raw
545+
556546
async def delete_message(self, thread_id: str, message_id: str) -> None:
557547
"""Delete a message."""
558548
decoded = self.decode_thread_id(thread_id)
@@ -703,15 +693,15 @@ async def fetch_thread(self, thread_id: str) -> ThreadInfo:
703693

704694
return ThreadInfo(
705695
id=thread_id,
706-
channel_id=f"{decoded.owner}/{decoded.repo}",
696+
channel_id=f"github:{decoded.owner}/{decoded.repo}",
707697
channel_name=f"{decoded.repo} #{decoded.pr_number}",
708698
is_dm=False,
709699
metadata={
710700
"owner": decoded.owner,
711701
"repo": decoded.repo,
712-
"issueNumber": decoded.pr_number,
713-
"issueTitle": issue.get("title"),
714-
"issueState": issue.get("state"),
702+
"issue_number": decoded.pr_number,
703+
"issue_title": issue.get("title"),
704+
"issue_state": issue.get("state"),
715705
"type": "issue",
716706
},
717707
)

src/chat_sdk/adapters/teams/adapter.py

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -350,13 +350,40 @@ async def _handle_message_activity(
350350

351351
self._chat.process_message(self, thread_id, message, options)
352352

353+
# Keys injected by the SDK's card renderer or Teams transport — not user input.
354+
_ACTION_TRANSPORT_KEYS = frozenset({"actionId", "msteams"})
355+
356+
@staticmethod
357+
def _extract_action_values(action_data: dict[str, Any]) -> tuple[str, Any]:
358+
"""Extract action ID and submitted values from a Teams action payload.
359+
360+
Strips transport keys (``actionId``, ``msteams``) that are injected by
361+
the SDK's card renderer or Teams infrastructure and are not user input.
362+
363+
For plain buttons: ``{"actionId": "btn", "value": "x"}`` → ``("btn", "x")``
364+
For ChoiceSet: ``{"actionId": "__auto_submit", "sel": "opt"}`` → ``("__auto_submit", {"sel": "opt"})``
365+
"""
366+
action_id = action_data.get("actionId", "")
367+
submitted_values: Any = {k: v for k, v in action_data.items() if k not in TeamsAdapter._ACTION_TRANSPORT_KEYS}
368+
# Unwrap single "value" key for plain button backward compat
369+
if list(submitted_values.keys()) == ["value"]:
370+
submitted_values = submitted_values["value"]
371+
return action_id, submitted_values
372+
353373
def _handle_message_action(
354374
self,
355375
activity: dict[str, Any],
356376
action_value: dict[str, Any],
357377
options: WebhookOptions | None = None,
358378
) -> None:
359-
"""Handle Action.Submit button clicks sent as message activities."""
379+
"""Handle Action.Submit button clicks sent as message activities.
380+
381+
For plain buttons, ``action_value`` looks like ``{"actionId": "btn_id", "value": "clicked"}``.
382+
For ChoiceSet (Select/RadioSelect) submissions, it looks like
383+
``{"actionId": "__auto_submit", "my_select": "option_1"}``.
384+
In both cases, we pass the full dict (minus ``actionId``) as ``value``
385+
so handlers receive all submitted input values.
386+
"""
360387
if not self._chat:
361388
return
362389

@@ -370,11 +397,13 @@ def _handle_message_action(
370397
)
371398
)
372399

400+
action_id, submitted_values = self._extract_action_values(action_value)
401+
373402
from_user = activity.get("from", {})
374403
self._chat.process_action(
375404
ActionEvent(
376-
action_id=action_value.get("actionId", ""),
377-
value=action_value.get("value"),
405+
action_id=action_id,
406+
value=submitted_values,
378407
user=Author(
379408
user_id=from_user.get("id", "unknown"),
380409
user_name=from_user.get("name", "unknown"),
@@ -411,11 +440,13 @@ async def _handle_adaptive_card_action(
411440
)
412441
)
413442

443+
action_id, submitted_values = self._extract_action_values(action_data)
444+
414445
from_user = activity.get("from", {})
415446
self._chat.process_action(
416447
ActionEvent(
417-
action_id=action_data.get("actionId", ""),
418-
value=action_data.get("value"),
448+
action_id=action_id,
449+
value=submitted_values,
419450
user=Author(
420451
user_id=from_user.get("id", "unknown"),
421452
user_name=from_user.get("name", "unknown"),

src/chat_sdk/channel.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -282,8 +282,13 @@ async def post(
282282
If the message is a PostableObject (e.g. Plan), it is posted via
283283
native adapter support or fallback text.
284284
"""
285+
# Handle PostableObject (e.g. Plan)
285286
if is_postable_object(message):
286-
await self._handle_postable_object(message)
287+
raw = await self._handle_postable_object(message)
288+
if self._message_history is not None and raw is not None:
289+
fallback = message.get_fallback_text() if hasattr(message, "get_fallback_text") else ""
290+
sent = self._create_sent_message(raw.id, PostableMarkdown(markdown=fallback), raw.thread_id)
291+
await self._message_history.append(self._id, _to_message(sent))
287292
return message
288293

289294
if _is_async_iterable(message):
@@ -312,7 +317,7 @@ async def _post_single_message(
312317

313318
return sent
314319

315-
async def _handle_postable_object(self, obj: Any) -> None:
320+
async def _handle_postable_object(self, obj: Any) -> Any:
316321
"""Post a PostableObject using native adapter support or fallback."""
317322
adapter = self.adapter
318323

@@ -321,7 +326,7 @@ async def _post_fn(thread_id: str, message: str) -> Any:
321326
return await adapter.post_channel_message(thread_id, message)
322327
return await adapter.post_message(thread_id, message)
323328

324-
await post_postable_object(obj, adapter, self._id, _post_fn)
329+
return await post_postable_object(obj, adapter, self._id, _post_fn)
325330

326331
async def post_ephemeral(
327332
self,

src/chat_sdk/plan.py

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
from __future__ import annotations
1010

1111
import asyncio
12-
import contextlib
1312
import uuid
1413
from dataclasses import dataclass, field
1514
from typing import Any, Literal
@@ -115,7 +114,8 @@ def _content_to_plain_text(content: PlanContent | None) -> str:
115114
if isinstance(content, str):
116115
return content
117116
if isinstance(content, dict) and "markdown" in content:
118-
return content["markdown"]
117+
md = content["markdown"]
118+
return str(md) if md is not None else ""
119119
if isinstance(content, dict):
120120
# For ast dicts, return empty -- full rendering not needed for titles
121121
pass
@@ -125,7 +125,7 @@ def _content_to_plain_text(content: PlanContent | None) -> str:
125125
def is_postable_object(value: Any) -> bool:
126126
"""Check if a value is a PostableObject (has the required protocol methods)."""
127127
return (
128-
isinstance(value, object)
128+
value is not None
129129
and hasattr(value, "kind")
130130
and hasattr(value, "get_fallback_text")
131131
and hasattr(value, "get_post_data")
@@ -145,9 +145,12 @@ async def post_postable_object(
145145
thread_id: str,
146146
post_fn: Any,
147147
logger: Logger | None = None,
148-
) -> None:
148+
) -> Any:
149149
"""Post a PostableObject using the adapter's native support or fallback text.
150150
151+
Returns the ``RawMessage`` from the adapter so callers can use the
152+
real message ID for history caching.
153+
151154
Parameters
152155
----------
153156
obj:
@@ -176,6 +179,7 @@ def _make_context(raw: Any) -> PostableObjectContext:
176179
else:
177180
raw = await post_fn(thread_id, obj.get_fallback_text())
178181
obj.on_posted(_make_context(raw))
182+
return raw
179183

180184

181185
# =============================================================================
@@ -419,11 +423,14 @@ async def _do_edit() -> None:
419423

420424
# Chain edits: wait for previous edit to finish before starting new one
421425
if bound.update_chain is not None:
422-
with contextlib.suppress(Exception):
426+
try:
423427
await bound.update_chain
428+
except Exception as prev_exc:
429+
if bound.logger:
430+
bound.logger.warn("Previous plan edit failed", prev_exc)
424431

425432
try:
426-
bound.update_chain = asyncio.ensure_future(_do_edit())
433+
bound.update_chain = asyncio.get_running_loop().create_task(_do_edit())
427434
await bound.update_chain
428435
except Exception as exc:
429436
if bound.logger:

0 commit comments

Comments
 (0)