diff --git a/CHANGELOG.md b/CHANGELOG.md index c1984a8..ead5f66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,75 +1,61 @@ # Changelog -## 0.4.27a1 (2026-05-07) +## 0.4.27 (2026-05-28) -Alpha sync starter for upstream `4.27.0` (`vercel/chat` release commit -`f55378a`, Apr 30 2026). **No feature ports in this release** — this is a -parity-bookkeeping bump that establishes the sync branch, sets -`UPSTREAM_PARITY = "4.27.0"`, and lays out the porting plan below. Each -substantive commit lands as its own PR (matching the cadence used during -the `4.26.0` sync: #64, #66, #67, #74, etc.). +Synced to upstream `vercel/chat@4.27.0` (release commit `f55378a`, Apr 30 2026). Highlights: Slack Socket Mode + dynamic bot-token resolver, Teams native DM streaming, `chat.get_user()` across all 8 adapters, Telegram MarkdownV2 rendering, and a sweep of adapter bug fixes. Sets `UPSTREAM_PARITY = "4.27.0"`. -### Upstream tagging note +### Upstream parity ports + +#### Core (`packages/chat`) -Upstream cut versions for the entire monorepo on Apr 30 2026 (commit -`f55378a`), bumping `packages/chat/package.json` from `4.26.0` to -`4.27.0`. As of this writing only `@chat-adapter/shared@4.27.0` got a -git tag — no `chat@4.27.0` tag was published. The fidelity workflow -(`scripts/verify_test_fidelity.py`, `.github/workflows/lint.yml`) -therefore stays pinned to `chat@4.26.0` until either the tag is -published upstream or the first feature port lands and we move the pin -to commit `f55378a` directly. Local devs running fidelity in baseline -mode will see a `chat@4.26.0` vs `chat@4.27.0` mismatch — that's the -intended in-flight signal. +- **`Chat.get_user(adapter, user_id)`** for cross-platform user lookups (#90, vercel/chat#391). Returns `User | None` with `email`, `display_name`, `avatar_url`, `is_bot` populated from each platform's user-lookup API. Every adapter exposes `async def get_user(user_id)`; Telegram is best-effort (`getChat` only), WhatsApp returns minimal user info (Cloud API has no separate lookup). +- **`ExternalSelect.initial_option` + `option_groups`** (#84, vercel/chat#410, #397). Type extensions on `ExternalSelect`; Slack adapter serializes `option_groups` to Block Kit. +- **`concurrency.max_concurrent` honored in `concurrent` strategy** (vercel/chat#419) — already enforced in the Python port via `asyncio.Semaphore`; upstream has caught up. Divergence row in `docs/UPSTREAM_SYNC.md` downgrades from "silent correctness bug upstream" to "behavior parity restored". -### Sync scope (22 substantive upstream commits between `chat@4.26.0..f55378a`) +#### Slack (`packages/adapter-slack`) -#### Core (`packages/chat/` → `src/chat_sdk/`) +- **Socket Mode transport** (#86, vercel/chat#162). New `SlackAdapterConfig(mode="socket", app_token="xapp-...")` opens a persistent WebSocket via `slack_sdk.socket_mode.aiohttp.SocketModeClient`. Outer reconnect loop (1s → 30s exp backoff, 250ms shutdown poll) layered on top of the SDK's auto-reconnect. Forwarded-events receiver in `handle_webhook` for the serverless variant (`x-slack-socket-token`, `hmac.compare_digest`). `ModalResponse(action="clear")` lands too. New optional extra: `chat-sdk-python[slack-socket]`. Closes #68. +- **Dynamic `bot_token` resolver + custom `webhook_verifier`** (#87, vercel/chat#421). `bot_token` now accepts `str | Callable[[], str | Awaitable[str]]`; resolver is invoked per request and cached in a per-instance ContextVar so concurrent webhooks don't share tokens. `webhook_verifier` replaces built-in HMAC + timestamp verification (returning a `str` substitutes the canonical body). `signing_secret` precedence over `webhook_verifier` preserved. `schedule_message().cancel()` and `Attachment.fetch_data` are rotation-safe. New `SlackAdapter.current_token_async()` for cron-style callers outside `handle_webhook`. +- **Slack streaming team_id fix for interactive payloads** (#85, vercel/chat#330). `recipient_team_id` extraction now walks `team_id` → `team` (string) → `team.id` (object) → `user.team_id` in order, returning `None` only when no string ID is found. Previously the entire `team` dict was forwarded for `block_actions`, breaking streaming routing. +- **Link-preview unfurl enrichment** (#89, vercel/chat#395). `message_changed` events are routed through a new `_handle_message_changed` handler with a 2s poll window and per-event link cache (1h TTL), so the message handler sees enriched links. +- **`@mention` regex preserves email addresses** (#91, vercel/chat#394). The `@user` matcher now skips `@` characters inside email localparts. +- **Empty `thread_ts` guard** (#89, vercel/chat#292). `stream()` now degrades to a single `post_message` for empty `thread_ts` instead of raising — top-level Slack DMs encode thread IDs with an empty `thread_ts` by design, and the old `ValidationError` silently dropped the reply. -- [x] (PR #90) **`chat.getUser(adapter, userId)`** for cross-platform user lookups (vercel/chat#391, upstream commit `a520797`). Adapter-side: each adapter exposes `getUser`. Touches `chat.py`, `types.py` (`User` type extension), and every adapter (`slack`, `teams`, `gchat`, `telegram`, `discord`, `whatsapp`, `github`, `linear`). -- [x] (PR #84) **`ExternalSelect.initial_option` + `option_groups`** (vercel/chat#410, `70281dc`). Type extension in `types.py`; Slack adapter must serialize `option_groups` to Block Kit. -- [x] (already merged via PR #74) **`thread.post()` streaming options** (vercel/chat#388, `9093292`). New params plumb through `Thread.post` → `chat.py` orchestrator. -- [x] (PR #85) **Slack streaming team ID fix for interactive payloads** (vercel/chat#330, `8a0c7b3`). Bug fix in the Slack streaming path; check `adapters/slack/adapter.py` request-context plumbing. -- [⏭️] (out of scope) **Bundled guide markdown + templates manifest** (vercel/chat#423, `b0ab804`). Decision: skip or copy `packages/chat/resources/guides/*.md` and `templates.json` verbatim. Probably skip — these are TS-monorepo authoring resources, not runtime behavior. -- [x] **`concurrency.maxConcurrent` honored in `concurrent` strategy** (vercel/chat#419, `d630e6c`). Already addressed in the Python port — see the existing `ConcurrencyConfig.max_concurrent` row in `docs/UPSTREAM_SYNC.md` (we enforce via `asyncio.Semaphore` and reject misconfiguration). Upstream has now caught up; on this sync the divergence row downgrades from "silent correctness bug upstream" to "behavior parity restored". +#### Teams (`packages/adapter-teams`) -#### Slack (`packages/adapter-slack/` → `src/chat_sdk/adapters/slack/`) +- **Native streaming for DMs via emit** (#88, vercel/chat#416). DM threads use the Bot Framework streaming protocol (`channelData.streamType=streaming` + `streamSequence`, then a final `streamType=final` message); group chats accumulate and post once (matches upstream's flicker-free behavior). New `TeamsAdapterConfig.native_stream_min_emit_interval_ms` (default 1500ms) honors Teams' ~1 req/sec quota; `StreamOptions.update_interval_ms` overrides. Send-failure mid-stream cancels the session and re-raises so `Thread.stream` history matches user-visible text. Migration to `microsoft-teams-apps` (Python SDK, GA 2026-05-01) tracked as #93 for 0.4.28. +- **DM conversation ID resolution for Graph API** (#85, vercel/chat#403). Bot Framework opaque DM IDs are rejected by Graph's `/chats/{chat-id}/messages` endpoint; the adapter now caches the user's `aadObjectId` from inbound activities into a `TeamsDmContext` keyed by base conversation ID and resolves to the canonical `19:{userAadId}_{botId}@unq.gbl.spaces` form on Graph calls. -- [x] (PR #86) **Slack Socket Mode support** (vercel/chat#162, `7e9d0fc`). Big — adds a persistent WebSocket transport alongside HTTP webhooks. Decision: in scope or follow-up? Mirrors the Discord Gateway gap already documented in non-parity ("HTTP interactions only"). -- [x] (PR #87) **Dynamic `bot_token` resolver + custom `webhookVerifier`** (vercel/chat#421, `2531e9c`). Multi-workspace pattern; touches `SlackAdapter.__init__` and request handling. -- [x] (PR #84) **External-select Block Kit support** (vercel/chat#397, `a179b29`). Pairs with the core `option_groups` change above. -- [ ] **Native `markdown_text` for outgoing messages** (vercel/chat#440, post-release — Apr 17). NOTE: this commit is post-`f55378a` so technically out of `4.27.0` scope, but listed here because the team often picks up post-release fixes. -- [x] (PR #89) **Link-preview unfurl metadata enrichment** (vercel/chat#395, `ded6f78`). -- [x] (PR #89) **`@mention` regex preserves email addresses** (vercel/chat#394, `c26ee6c`). -- [x] (PR #89) **Guard against empty `threadTs` (`invalid_thread_ts` fix)** (vercel/chat#292, `53c6b68`). +#### Telegram (`packages/adapter-telegram`) -#### Teams (`packages/adapter-teams/` → `src/chat_sdk/adapters/teams/`) +- **MarkdownV2 rendering** (#89, vercel/chat#407). Replaces the legacy `Markdown` parse_mode with `MarkdownV2`. Three escape contexts (normal text, code blocks, inline-link URLs) handle the spec's 18-char escape set per region. -- [x] (PR #88) **Native streaming for DMs via `emit`** (vercel/chat#416, `ed46bae`). Currently the Python port falls back to `_fallback_stream` for Teams; native streaming would lift that. -- [x] (PR #85) **DM conversation ID resolution for Graph API** (vercel/chat#403, `4c24c94`). Bug fix. -- [x] **Teams SDK 2.0.8 + `User-Agent` header** (vercel/chat#415, `885a471`). **N/A — JS-only.** Upstream's change bumps the `botbuilder` dependency and flips the bot client header from `X-User-Agent` to `User-Agent: Vercel.ChatSDK`. The Python Teams adapter does not depend on `botbuilder` (uses raw `aiohttp`), so there is no equivalent dependency to bump. The optional `User-Agent` header propagation is a defense-in-depth nice-to-have; documented as a deferred enhancement in `docs/UPSTREAM_SYNC.md` rather than landed in this sync. +#### Discord (`packages/adapter-discord`) -#### Telegram +- **Card text deduplication** (#89, vercel/chat#256). Card posts omit `content` on create (Discord renders both `content` and the embed otherwise); edits explicitly send `content: ""` so leftover text from a previous edit is cleared. -- [x] (PR #89) **MarkdownV2 rendering fixes** (vercel/chat#407, `b9a1961`). Pairs with the streaming-chunk safety trim in vercel/chat#446 (post-`f55378a`). +### Python-only improvements -#### Discord +- **Markdown parser completeness** (#101). GFM task lists (`- [ ]` / `- [x]` → `checked: bool`), backslash-escaped delimiters (lookbehind `(? **Status: Alpha (0.4.27a1 — porting [Vercel Chat 4.27.0](https://github.com/vercel/chat))** — API may change. Last fully-synced release: `0.4.26.3` (parity with upstream `chat@4.26.0`). See [CHANGELOG.md](CHANGELOG.md) for the in-flight sync plan. +> **Status: 0.4.27** — synced to upstream [Vercel Chat 4.27.0](https://github.com/vercel/chat) (release commit `f55378a`). See [CHANGELOG.md](CHANGELOG.md) for release notes and [docs/UPSTREAM_SYNC.md](docs/UPSTREAM_SYNC.md) for the known non-parity list. ## Why chat-sdk? diff --git a/docs/SELF_REVIEW.md b/docs/SELF_REVIEW.md index d479c53..979cc08 100644 --- a/docs/SELF_REVIEW.md +++ b/docs/SELF_REVIEW.md @@ -4,10 +4,28 @@ Automated reviewers (Codex, CodeRabbit, CI linters) catch bugs not because they're smarter than humans or agents, but because they apply adversarial checks consistently on every diff. Self-review tends to verify happy paths and ship. This doc is the set of adversarial checks to run against your own -code *before* declaring a change ready. +code *before opening the PR* — so that what bots would flag in rounds 2–5 +is caught in commit 1 instead. ## When to apply +### Timing + +**Run self-review BEFORE opening the PR, not after bots converge.** + +Self-review is cheap (~5 minutes per PR). Bot review is expensive +(~1–2 hours per round, often 3–5 rounds before convergence). Running +self-review first catches what the bots would eventually flag, in the +original commit — eliminating most of the review-loop cost. + +If the temptation is "let bots find issues so I don't have to," re-read +the [Review-Loop Discipline](UPSTREAM_SYNC.md#review-loop-discipline) +section. The economics never favor that approach: one round of bot wait + +triage + fix + push costs more than the self-review that would have +pre-empted it. + +### Triggers + Any change that introduces novel logic — especially: - New regex, substitution, or tokenization pass diff --git a/docs/UPSTREAM_SYNC.md b/docs/UPSTREAM_SYNC.md index 0bf5fe6..9dd621d 100644 --- a/docs/UPSTREAM_SYNC.md +++ b/docs/UPSTREAM_SYNC.md @@ -12,6 +12,8 @@ Our version embeds the upstream Vercel Chat version: `0.{upstream_major}.{upstre | `0.4.25.1` | `4.25.0` | Python-only fix on top of 4.25.0 | | `0.4.25a1` | `4.25.0` | Alpha while porting 4.25.0 | | `0.4.26` | `4.26.0` | Synced to upstream 4.26.0 | +| `0.4.26.3` | `4.26.0` | Python-only fixes on top of 4.26.0 | +| `0.4.27` | `4.27.0` | Synced to upstream 4.27.0 | The `UPSTREAM_PARITY` constant in `chat_sdk/__init__.py` provides programmatic access to the upstream version this release is synced to. @@ -408,6 +410,16 @@ def _get_client(self): Rule: no optional adapter dependency imports at module top level. +**Sub-rule: prefer official SDKs over hand-rolling.** When an official +maintained SDK exists for a platform, use it as an optional dependency +(per the lazy-import rule above) rather than hand-rolling the wire +format. Hand-rolled wire formats become wide bot-finding surfaces — +every protocol quirk the SDK abstracts becomes a defect waiting to be +flagged in review. See [Review-Loop Discipline](#review-loop-discipline) +item 2 for the cost-accounting from the 4.27 Teams streaming PR. +Justify any hand-roll in the PR description (SDK missing the feature, +unmaintained, Python-version incompatible, etc.). + ### 11. Session and Connection Lifecycle Matter More in Python Ported code often starts with per-request HTTP clients. In Python async code, shared sessions plus explicit cleanup are usually the right design. @@ -446,7 +458,11 @@ Rule: behavior changes need regression tests, not just matching types. ## Review Checklist for Upstream Ports -Before merging an upstream-derived change, check: +Before **opening** an upstream-derived PR (not before merging — see +[Review-Loop Discipline](#review-loop-discipline) for why timing +matters), check: + +### Correctness - [ ] Are any `or default` patterns incorrectly changing valid falsy values? - [ ] Did any camelCase keys leak into internal Python event/state objects? @@ -461,6 +477,120 @@ Before merging an upstream-derived change, check: - [ ] Did markdown or streaming behavior change? - [ ] Does this need replay coverage rather than only unit coverage? +### Review-loop economics + +- [ ] **Did I run [`docs/SELF_REVIEW.md`](SELF_REVIEW.md) before opening?** + (Catches what bots would flag in rounds 2–5; pays back ~10×.) +- [ ] **Am I hand-rolling a wire format an official SDK provides?** + If yes, justify in the PR description or use the SDK instead + (Microsoft `microsoft-teams-apps`, Slack `slack_sdk`, etc.). +- [ ] **Did I trace fix cascades end-to-end?** If a fix in module X + changes the contract module Y depends on, walk the chain before + pushing — don't ship and wait for the bot to find Y. +- [ ] **Is this opening as ready-for-review, not draft?** Bots skip + drafts in this repo's config. +- [ ] **How many in-flight drafts will this make on the sync branch?** + Cap at 3–4. + +## Review-Loop Discipline + +Each round of automated review (Codex, CodeRabbit, github-code-quality) +costs ~1–2 hours of wall time per PR: push → bot runs → triage → fix → +push. The 4.27 sync wave averaged 5+ rounds per PR before convergence; +most of that cost was avoidable. Apply the rules below on every sync +PR, not as an after-the-fact patch once a third or fourth review round +hits. + +### Before opening the PR + +1. **Run [`docs/SELF_REVIEW.md`](SELF_REVIEW.md) first, not after bots converge.** + Five minutes of honest adversarial review catches what the bots will + eventually find, in the original commit — eliminating 3–5 sequential + review rounds. PR #88's formal self-review pass caught two real + defects Codex had missed across 5 rounds; running it first would + have caught them in commit 1. + +2. **Prefer official SDKs over hand-rolled wire formats.** When an + official maintained SDK exists for a platform (Slack `slack_sdk`, + Microsoft `microsoft-teams-apps`, Discord `discord.py`, etc.), use + it. Each hand-rolled wire format is a wide bot-finding surface: + every protocol quirk the SDK abstracts becomes a defect waiting to + be flagged. PR #88's Teams native streaming consumed ~6 of 9 review + rounds on Bot Framework REST details that `microsoft-teams-apps` + handles internally. Cost of using an SDK: optional dependency + surface, possibly a Python-version floor bump. Saved: most of the + adapter-PR bot-iteration cost. + +3. **Trace fix cascades end-to-end before pushing.** When a fix in + module X might affect downstream consumers in module Y, walk the + chain before committing. Pushing fix A, waiting for the bot to flag + B, fixing B, waiting for the bot to flag C is the most expensive + pattern. PR #88's cancellation-text chain was 5 sequential commits + because the adapter-level fix kept revealing downstream integration + gaps in `Thread.stream`. One end-to-end trace before the first + commit would have collapsed it. + +### Opening the PR + +4. **Open as ready-for-review, not draft.** Draft mode delays serious + bot review in this repo's config (CodeRabbit's "Review skipped" + message appears on every draft PR). Either the PR is ready and you + want feedback now, or it isn't ready and shouldn't be open. The + "open as draft and let it bake" pattern bought nothing in the 4.27 + wave — bots didn't engage until PRs flipped to ready anyway, so the + incubation time was pure lag. + +5. **Cap in-flight drafts at 3–4 per sync wave.** Each open PR is its + own review queue and context switch. Smaller batches ship faster + than bigger batches. The 4.27 wave had 7 drafts open simultaneously + for over a week; reducing to 3–4 concurrent and merging in series + would have shipped sooner. + +### After bot findings land + +6. **Triage every finding with a clear rubric**: + + | Severity | Action | + |---|---| + | **P1** (real defect, exploitable) | Fix today | + | **P2** (correctness gap, narrow scope) | Fix if small + scope-preserving | + | **Nit / style** | Batch into a single cleanup commit, OR skip if not a concrete defect | + | **False positive** | Reply once with rationale; add an in-code comment if the pattern will keep recurring; then stop engaging on re-flags | + | **Stale** (references prior PR state) | Reply with a brief commit-history pointer; no code change | + +7. **Bundle fixes when iterating.** Multiple back-to-back commits + trigger multiple bot reviews of overlapping content. Squash before + push when fixing related findings — same outcome, one round-trip + cost instead of N. + +8. **Don't engage every bot re-flag.** `github-code-quality` re-flags + the same site on every push regardless of prior threads. Reply once + with the rationale, drop an in-code comment explaining the + load-bearing semantics (e.g. `await task` inside + `contextlib.suppress` for deterministic drain), then ignore repeats. + PR #86 burned context responding to 4+ re-flags of the same false + positive before this rule was applied. + +### Author / agent practices + +9. **Detect echoes; stay silent.** Don't reply to your own webhook + echoes (the system sometimes re-broadcasts a comment you just + posted). A silent acknowledgment is sufficient. + +10. **Parallelize multi-PR triage on day one.** When several PRs are + in the same review state, dispatch parallel agents (one per PR, + each with its own `git worktree add`) immediately. The 4.27 wave + converged 6 PRs in ~1 hour of wall time once parallelized; running + them sequentially would have taken days. + +### Compounding effect + +Items (1) + (2) + (6) alone would have shaved most of the lag on +PR #88: from ~9 review rounds spanning days to ~2–3 rounds spanning +hours. The economics never favor "let bots find issues so I don't +have to" — a single sequential push-wait-fix loop costs more than the +self-review that would have pre-empted it. + ## Known Non-Parity with TypeScript SDK Intentional differences from the Vercel Chat TS SDK, collected here so they diff --git a/pyproject.toml b/pyproject.toml index 02115f0..1092edd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "chat-sdk" -version = "0.4.27a1" +version = "0.4.27" description = "Multi-platform async chat SDK for Python — port of Vercel Chat" keywords = [ "chat", diff --git a/tests/test_github_webhook.py b/tests/test_github_webhook.py index 942b4a6..5b9758e 100644 --- a/tests/test_github_webhook.py +++ b/tests/test_github_webhook.py @@ -188,14 +188,27 @@ def test_create_single_tenant_app(self): assert a.is_multi_tenant is False def test_throws_when_no_auth(self): - with pytest.raises(ValidationError, match="Authentication"): - GitHubAdapter( - { - "webhook_secret": "secret", - "user_name": "bot", - "logger": ConsoleLogger("error"), - } - ) + # Clear env vars so the constructor's env-fallback path can't + # silently satisfy auth from a GITHUB_TOKEN injected by CI / dev shell. + old_token = os.environ.pop("GITHUB_TOKEN", None) + old_app = os.environ.pop("GITHUB_APP_ID", None) + old_key = os.environ.pop("GITHUB_PRIVATE_KEY", None) + try: + with pytest.raises(ValidationError, match="Authentication"): + GitHubAdapter( + { + "webhook_secret": "secret", + "user_name": "bot", + "logger": ConsoleLogger("error"), + } + ) + finally: + if old_token is not None: + os.environ["GITHUB_TOKEN"] = old_token + if old_app is not None: + os.environ["GITHUB_APP_ID"] = old_app + if old_key is not None: + os.environ["GITHUB_PRIVATE_KEY"] = old_key def test_bot_user_id_when_set(self): a = _make_adapter(bot_user_id=42)