Skip to content

Commit a579436

Browse files
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>
1 parent 0ba59b6 commit a579436

11 files changed

Lines changed: 266 additions & 32 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 2 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.

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: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
Multi-platform async chat SDK for Python. Port of [Vercel Chat](https://github.com/vercel/chat).
44

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

docs/UPSTREAM_SYNC.md

Lines changed: 16 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

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: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -703,15 +703,15 @@ async def fetch_thread(self, thread_id: str) -> ThreadInfo:
703703

704704
return ThreadInfo(
705705
id=thread_id,
706-
channel_id=f"{decoded.owner}/{decoded.repo}",
706+
channel_id=f"github:{decoded.owner}/{decoded.repo}",
707707
channel_name=f"{decoded.repo} #{decoded.pr_number}",
708708
is_dm=False,
709709
metadata={
710710
"owner": decoded.owner,
711711
"repo": decoded.repo,
712-
"issueNumber": decoded.pr_number,
713-
"issueTitle": issue.get("title"),
714-
"issueState": issue.get("state"),
712+
"issue_number": decoded.pr_number,
713+
"issue_title": issue.get("title"),
714+
"issue_state": issue.get("state"),
715715
"type": "issue",
716716
},
717717
)

src/chat_sdk/adapters/teams/adapter.py

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -356,7 +356,14 @@ def _handle_message_action(
356356
action_value: dict[str, Any],
357357
options: WebhookOptions | None = None,
358358
) -> None:
359-
"""Handle Action.Submit button clicks sent as message activities."""
359+
"""Handle Action.Submit button clicks sent as message activities.
360+
361+
For plain buttons, ``action_value`` looks like ``{"actionId": "btn_id", "value": "clicked"}``.
362+
For ChoiceSet (Select/RadioSelect) submissions, it looks like
363+
``{"actionId": "__auto_submit", "my_select": "option_1"}``.
364+
In both cases, we pass the full dict (minus ``actionId``) as ``value``
365+
so handlers receive all submitted input values.
366+
"""
360367
if not self._chat:
361368
return
362369

@@ -370,11 +377,19 @@ def _handle_message_action(
370377
)
371378
)
372379

380+
# Extract action_id; pass remaining keys as value so ChoiceSet
381+
# input values are not dropped.
382+
action_id = action_value.get("actionId", "")
383+
submitted_values = {k: v for k, v in action_value.items() if k != "actionId"}
384+
# If there's a single "value" key, unwrap it for simple button clicks
385+
if list(submitted_values.keys()) == ["value"]:
386+
submitted_values = submitted_values["value"]
387+
373388
from_user = activity.get("from", {})
374389
self._chat.process_action(
375390
ActionEvent(
376-
action_id=action_value.get("actionId", ""),
377-
value=action_value.get("value"),
391+
action_id=action_id,
392+
value=submitted_values,
378393
user=Author(
379394
user_id=from_user.get("id", "unknown"),
380395
user_name=from_user.get("name", "unknown"),
@@ -411,11 +426,16 @@ async def _handle_adaptive_card_action(
411426
)
412427
)
413428

429+
action_id = action_data.get("actionId", "")
430+
submitted_values = {k: v for k, v in action_data.items() if k != "actionId"}
431+
if list(submitted_values.keys()) == ["value"]:
432+
submitted_values = submitted_values["value"]
433+
414434
from_user = activity.get("from", {})
415435
self._chat.process_action(
416436
ActionEvent(
417-
action_id=action_data.get("actionId", ""),
418-
value=action_data.get("value"),
437+
action_id=action_id,
438+
value=submitted_values,
419439
user=Author(
420440
user_id=from_user.get("id", "unknown"),
421441
user_name=from_user.get("name", "unknown"),

src/chat_sdk/plan.py

Lines changed: 8 additions & 5 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")
@@ -419,11 +419,14 @@ async def _do_edit() -> None:
419419

420420
# Chain edits: wait for previous edit to finish before starting new one
421421
if bound.update_chain is not None:
422-
with contextlib.suppress(Exception):
422+
try:
423423
await bound.update_chain
424+
except Exception as prev_exc:
425+
if bound.logger:
426+
bound.logger.warn("Previous plan edit failed", prev_exc)
424427

425428
try:
426-
bound.update_chain = asyncio.ensure_future(_do_edit())
429+
bound.update_chain = asyncio.get_running_loop().create_task(_do_edit())
427430
await bound.update_chain
428431
except Exception as exc:
429432
if bound.logger:

tests/test_github_extended.py

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
from chat_sdk.adapters.github.adapter import (
2828
GitHubAdapter,
2929
)
30+
from chat_sdk.adapters.github.types import GitHubThreadId
3031
from chat_sdk.logger import ConsoleLogger
3132
from chat_sdk.shared.errors import ValidationError
3233

@@ -586,15 +587,15 @@ async def test_issue_thread_fetches_issue_metadata(self):
586587
assert "/pulls/" not in call[0][1]
587588

588589
assert info.id == "github:acme/app:issue:10"
589-
assert info.channel_id == "acme/app"
590+
assert info.channel_id == "github:acme/app"
590591
assert info.channel_name == "app #10"
591592
assert info.is_dm is False
592593
assert info.metadata == {
593594
"owner": "acme",
594595
"repo": "app",
595-
"issueNumber": 10,
596-
"issueTitle": "Bug report",
597-
"issueState": "open",
596+
"issue_number": 10,
597+
"issue_title": "Bug report",
598+
"issue_state": "open",
598599
"type": "issue",
599600
}
600601

@@ -786,3 +787,22 @@ async def text_gen():
786787
# The post should have been called with the accumulated text
787788
call_args = adapter._github_api_request.call_args
788789
assert call_args[0][2]["body"] == "Hello world"
790+
791+
792+
class TestIssueThreadChannelRoundTrip:
793+
"""fetch_thread().channel_id should work with channel APIs."""
794+
795+
async def test_issue_thread_channel_id_round_trips(self):
796+
"""channel_id from fetch_thread feeds into channel_id_from_thread_id."""
797+
adapter = _make_adapter()
798+
adapter._github_api_request = AsyncMock(return_value={"title": "Bug report", "state": "open", "number": 10})
799+
800+
thread_id = adapter.encode_thread_id(GitHubThreadId(owner="acme", repo="app", pr_number=10, type="issue"))
801+
info = await adapter.fetch_thread(thread_id)
802+
803+
# channel_id should have github: prefix
804+
assert info.channel_id.startswith("github:")
805+
806+
# channel_id should work with channel_id_from_thread_id
807+
derived_channel = adapter.channel_id_from_thread_id(thread_id)
808+
assert info.channel_id == derived_channel

tests/test_plan.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -499,3 +499,77 @@ async def test_add_task_with_children(self) -> None:
499499
assert task is not None
500500
# Check internal model has details
501501
assert plan._model.tasks[-1].details == ["a", "b"]
502+
503+
504+
# ---------------------------------------------------------------------------
505+
# Error handling in _enqueue_edit
506+
# ---------------------------------------------------------------------------
507+
508+
509+
class _FailingEditAdapter(MockAdapter):
510+
"""MockAdapter whose edit_message raises on demand."""
511+
512+
def __init__(self) -> None:
513+
super().__init__("failing")
514+
self.fail_edit = False
515+
516+
async def edit_message(self, thread_id: str, message_id: str, message: Any) -> RawMessage:
517+
if self.fail_edit:
518+
raise RuntimeError("simulated edit failure")
519+
return await super().edit_message(thread_id, message_id, message)
520+
521+
522+
class _SpyLogger:
523+
"""Minimal logger that records warn calls."""
524+
525+
def __init__(self) -> None:
526+
self.warnings: list[tuple[str, Any]] = []
527+
528+
def child(self, prefix: str) -> _SpyLogger:
529+
return self
530+
531+
def debug(self, message: str, *args: Any) -> None:
532+
pass
533+
534+
def info(self, message: str, *args: Any) -> None:
535+
pass
536+
537+
def warn(self, message: str, *args: Any) -> None:
538+
self.warnings.append((message, args))
539+
540+
def error(self, message: str, *args: Any) -> None:
541+
pass
542+
543+
544+
class TestEditErrorPath:
545+
@pytest.mark.asyncio
546+
async def test_edit_failure_is_logged_and_plan_continues(self) -> None:
547+
"""When adapter.edit_message raises, the error is logged and
548+
the next mutation still fires successfully."""
549+
adapter = _FailingEditAdapter()
550+
logger = _SpyLogger()
551+
thread = _make_thread(adapter=adapter)
552+
plan = Plan(StartPlanOptions(initial_message="Step 1"))
553+
await thread.post(plan)
554+
555+
# Inject the spy logger into the bound state
556+
assert plan._bound is not None
557+
plan._bound.logger = logger
558+
559+
# First mutation: edit will fail
560+
adapter.fail_edit = True
561+
await plan.add_task(AddTaskOptions(title="Step 2"))
562+
563+
# The error should have been logged
564+
assert any("Failed to edit plan" in w[0] for w in logger.warnings)
565+
566+
# Second mutation: edit succeeds -- plan is still usable
567+
adapter.fail_edit = False
568+
logger.warnings.clear()
569+
task = await plan.add_task(AddTaskOptions(title="Step 3"))
570+
assert task is not None
571+
assert task.title == "Step 3"
572+
assert len(plan.tasks) == 3
573+
574+
# The previous-chain failure should also be logged
575+
assert any("Previous plan edit failed" in w[0] for w in logger.warnings)

0 commit comments

Comments
 (0)