From 16fd654839c45979f20e7aabbc9868674b5b5194 Mon Sep 17 00:00:00 2001 From: patrick-chinchill Date: Thu, 1 Oct 2026 05:02:13 -0700 Subject: [PATCH 1/3] chore(release): pin fidelity and UPSTREAM_PARITY to chat@4.41.1, prepare 0.4.41 (#203) Move scripts/upstream_pin.json pin to chat@4.41.1 (f690af3c), set UPSTREAM_PARITY = "4.41.1" and the package version to 0.4.41, and promote the eight 4.34-4.41 test files at 0 missing into the strict MAPPING (922/922 at the pin; cards/modals/emoji/message stay report-only, #78). Record skipped upstream changes and demand-gated platforms (#189) as Known Non-Parity rows, re-verify the adapter catalog row at 4.41.1, and promote the CHANGELOG wave section to 0.4.41 with a consumer digest and the pending live checks. No runtime change. Closes #203 Part of #184 --- CHANGELOG.md | 36 +++++++++++- CLAUDE.md | 9 ++- README.md | 2 +- docs/UPSTREAM_SYNC.md | 93 ++++++++++++++++++++++++------ pyproject.toml | 2 +- scripts/fidelity_baseline.json | 4 +- scripts/fidelity_target.json | 16 ++--- scripts/upstream_pin.json | 4 +- scripts/verify_test_fidelity.py | 32 +++++----- src/chat_sdk/__init__.py | 6 +- tests/test_callback_url.py | 13 ++--- tests/test_history_user.py | 7 ++- tests/test_verify_test_fidelity.py | 13 ++++- 13 files changed, 171 insertions(+), 66 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c70a047..11b786c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,40 @@ # Changelog -## Unreleased (4.41 wave) +## 0.4.41 -Sync wave from `chat@4.31.0` to `chat@4.41.1` (tracking #184). `UPSTREAM_PARITY` stays `4.31.0` until #203. +Synced to upstream `vercel/chat@4.41.1` (the 4.41 wave, tracking #184): twelve upstream releases (`chat@4.32.0` → `chat@4.41.1`) in one jump from `chat@4.31.0`. Sets `UPSTREAM_PARITY = "4.41.1"` (the exact strict fidelity pin tag, not `4.41.0`; the package version encodes only major.minor). The strict fidelity pin moves to `chat@4.41.1` (`f690af3c`): **922/922** mapped tests at the pin, up from 733/733 at `chat@4.31.0`, after the eight test files upstream added in 4.34–4.41 (`history/*`, `agent-session`, `app-context`, `installation-events`, `adapter-shared/download`) joined the strict tier. `cards`, `modals`, `emoji` and `message` stay report-only (100 of 142 upstream titles untranslated; issue #78). Upstream changes the wave skips (TanStack AI, AI SDK v7 typing, Workflow `requestApproval`, the serialization entrypoint, Vercel Connect, `create-chat-sdk`, catalog/docs-only commits) and the demand-gated platforms (Instagram, Notion, X, XChat, Telegram Business, Twilio RCS, Gmail #241, Discord Components V2, per-adapter Vercel Connect; #189) are recorded in `docs/UPSTREAM_SYNC.md` § Known Non-Parity. + +### Consumer-visible changes (migration checklist) + +Each item names the wave issues; their entries under "Wave PRs" below have the details and migration notes. + +- **Concurrency (#190, #191).** Thread/channel locks now heartbeat while a handler runs (`max_lock_lifetime_ms`, default 10 min). With the default `drop` strategy, a second message arriving 30 s or more into a long handler (e.g. a long Slack/Teams stream) now raises `LockError` instead of running concurrently; pick `queue`/`debounce`/`burst` or set `on_lock_conflict` to handle it. Dedupe TTL is 10 minutes. `wait_until` no longer surfaces handler errors unless `WebhookOptions(propagate_handler_errors=True)`. A failed adapter `initialize()` is cached until `shutdown()`. +- **Mentions and the message model (#192, #193, #209, #283).** `Message.is_mention` is tri-state: custom adapters must report `None` (not `False`) for "not detected". Emails, URL userinfo and `@name-suffix` no longer mention the bot. `ast_to_plain_text` keeps structural whitespace. On Slack, `message.text` is now the plain text of `message.formatted` (no mrkdwn markup), the bot's own mention reads `@BotName`, special mentions read `@here`/`@eng`, code blocks keep their first line, and bot authors use the `U…` user id. New `Author.email` / `Author.is_system` and `Message.reply_to`. +- **Streaming (#199, #207, #208, #219, #226, #206).** Slack: `native_streaming`, a post+edit fallback (top-level DMs now stream by editing a placeholder), `stream()` returns `None` without recipient context instead of raising, long native streams continue in a new message before Slack's ~5 min expiry (`stream_segment_max_age_ms`), and streamed `@name`s become real pings. Teams (#219): only with an explicit `fallback_streaming_placeholder_text`, DMs show it as the native informative status line and group chats/channels stream by post+edit; otherwise unchanged. Telegram: post-and-edit by default, native drafts opt-in. Core: `fallback_streaming_placeholder_text` defaults to `UNSET`; handle/DM threads have no stub `current_message`. +- **Callback-URL tokens (#194, security).** Single-use, bound to their button and conversation, 7-day TTL. Tokens minted before the upgrade stop resolving. +- **AI tools (#195, #198, security).** Agent tools are confined to the conversation being handled (`scope=False` for workspace-wide access); `getUser` needs approval by default; link metadata is fenced as untrusted. `to_ai_messages` keeps attachment/link-only messages; `ChatTool.name`. +- **Download caps (#204 and adopters #213, #218, #225, #229, #234, #235, #239).** Attachment downloads go through the shared guarded downloader: HTTPS only, internal addresses refused (including after redirects), **25 MB** decoded cap, **30 s** deadline, failures raise `NetworkError`. Slack file downloads no longer honor `HTTPS_PROXY`/`HTTP_PROXY` (set `file_transport`). Messenger, WhatsApp and Twilio narrow their media host/origin allowlists. +- **Webhook authentication (security).** Telegram requires a webhook secret by default (#224; `allow_unverified_webhooks` escape hatch). Google Chat JWT verification is bound to configured identities; Pub/Sub and Workspace Add-on deployments must set the new service-account emails, and `bot_user_id` should be set (#222, #223). Slack caches are installation-scoped and Enterprise Grid org-wide installs work (#205, #268). Teams gains a custom token factory and `webhook_verifier` (#221). +- **Thread and channel ids.** Linear agent sessions use one stable thread `linear:{issue}:s:{session}` (#232; re-subscribe old ids). Twilio `channel_id` equals the thread id (#235). Teams group chats carry a conversation-type segment and are no longer DMs; `TeamsAdapter.bot_user_id` is `28:{app_id}` (#216, #217). Slack `channel.post()` returns a replyable `slack:C…:` (#283). Discord validates a thread's parent channel (#229). +- **New lifecycle and API surface (additive).** `on_message_updated` / `on_message_deleted`, `on_installed` / `on_uninstalled`, app-context and agent-session events (#196, #201, #211, #215, #217); `chat.history` (#197); `Thread.reply` / `Thread.mark_as_read` (#200); turn cancellation (`chat.abort_turn`, `thread.signal`, #201); Slack `agent_view` and Agent Sessions (#214, #215); cards `Chart`, table options, tooltips, `DateInput` / `NumberInput` (#202, #212, #220); Telegram albums and media groups (#227, #278); WhatsApp templates, media and read receipts (#237, #238, #239). +- **Dependencies.** The `[teams]` extra allows `microsoft-teams-{apps,api,cards,common}` `>=2.0.13,<2.2` (#250). + +### Pending live checks before publishing + +The maintainer runs these against a live workspace before tagging and publishing 0.4.41 (release gate in #184). This PR does not tag, release or publish. + +- [ ] **Teams on SDK 2.1** (#262 / #250): native DM streaming, `edit_message` and `delete_message` on `microsoft-teams-apps` 2.1.x. +- [ ] **Slack streamed mentions** (#206): a streamed `@name` for a cached user becomes a real `<@U…>` ping on the native stream. +- [ ] **Slack stream rotation past 5 minutes** (#208): a native stream longer than `stream_segment_max_age_ms` continues in a new message without losing text. +- [ ] **Slack `agent_view` Stop button** (#215): clicking Stop aborts the running turn and sets the session `active`. +- [ ] Pre-publish git-ref smoke test of downstream consumers (chinchill) against the release commit, as for 0.4.30/0.4.31. + +### Wave PRs + +- **Release: fidelity pin, `UPSTREAM_PARITY` and version to 0.4.41 / chat@4.41.1** (#203). Tooling and docs only; no runtime change. + - `scripts/upstream_pin.json` `pin` → `chat@4.41.1` @ `f690af3c26a3bd7649387cccad12d215463ed22c` (same as `target`). CI's strict fidelity step clones that tag and checks the SHA. `UPSTREAM_PARITY = "4.41.1"`, `pyproject.toml` version `0.4.41`, README status line, and both version-mapping tables (CLAUDE.md, `docs/UPSTREAM_SYNC.md`). + - Strict tier: the eight test files upstream added in 4.34–4.41, all at 0 missing, move from `TARGET_MAPPING` to `MAPPING` (922/922 at the pin). `fidelity_baseline.json` is regenerated for `ts_parity: chat@4.41.1` (still empty). `cards` / `modals` / `emoji` / `message` stay report-only in `TARGET_MAPPING` (#78). + - `docs/UPSTREAM_SYNC.md`: new "Skipped upstream changes (4.41 wave)" and "Deferred platforms and adapter modes (#189)" tables; the `chat/adapters` catalog row is re-verified at `chat@4.41.1` (34 entries; Dial, Cloudflare Agents, Photon, Linq, Novu, Gmail, Instagram, Notion, X and XChat added since 4.31.0). - **Slack inbound mrkdwn normalization, `channel.post()` thread ids, Socket Mode retries** (#283, part (b) of #209; ports vercel/chat `0b63791b` #667 (Slack half), `92530dd3` #720, `c3118279` #756, `e71bfead` #843 (Slack half) and `44423bdc` #960, chat@4.33.0–4.41.1). A live Slack-loop check is pending. - **Breaking/consumer-visible (`message.text` is the plain text of `message.formatted`):** both parse paths now set `text = ast_to_plain_text(formatted)` (upstream `toPlainText(formatted)`), replacing the regex pass over the mrkdwn. Markup no longer leaks into `text`: inline-code backticks, list markers (`- a` → `a`, `1. one` → `one`), heading `#` and quote `>` prefixes are dropped, a trailing newline or a whitespace-only body is trimmed, and blocks are separated by a blank line. `SlackFormatConverter.extract_plain_text` drops its Python-only regex override for the base `to_ast` + `ast_to_plain_text` (upstream has none). *Migration:* match structure on `message.formatted`, or on `message.raw["text"]` for the original mrkdwn. diff --git a/CLAUDE.md b/CLAUDE.md index e7a1be33..b3373bc4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,7 +1,7 @@ # Claude Code Quick Reference -- chat-sdk-python ## What is this? -Python port of [Vercel Chat SDK](https://github.com/vercel/chat) (synced to upstream v4.31.0). Multi-platform async chat framework. +Python port of [Vercel Chat SDK](https://github.com/vercel/chat) (synced to upstream v4.41.1). Multi-platform async chat framework. ## Key Commands ```bash @@ -32,6 +32,7 @@ Our version embeds the upstream Vercel Chat version: `0.{upstream_major}.{upstre - `0.4.31.1` = Python-only fixes on top of `4.31.0` (Slack #138/#95) - `0.4.31.2` = tagged, never published (publish action rejected Metadata-Version 2.5; tag immutable) — superseded by - `0.4.31.3` = Python-only fixes on top of `4.31.0` (Teams Graph SSRF-guard hardening #178, NOTICE); same content as 0.4.31.2 +- `0.4.41` = synced to upstream `4.41.1` (the 4.41 wave, #184; `UPSTREAM_PARITY = "4.41.1"`, the exact strict pin tag, not `4.41.0`) - `UPSTREAM_PARITY` constant in `__init__.py` = programmatic access ## Architecture @@ -115,13 +116,15 @@ will not pass CI. **Fidelity check** (`scripts/verify_test_fidelity.py`) verifies every TS `it("...")` / `test("...")` in the mapped core files — plus one logical test per `it.each` template — has a matching Python `def test_*()`, -pinned to `chat@4.31.0` (major.minor must match `UPSTREAM_PARITY`). +pinned to `chat@4.41.1` (major.minor must match `UPSTREAM_PARITY`). The pin lives in **`scripts/upstream_pin.json`** (`pin` = strict CI tag + commit SHA, `target` = the sync wave's tag); nothing else hard-codes it. Scope is two-tier: `MAPPING` is the strict set; `TARGET_MAPPING` rows are only checked by `--report-target`; `UNMAPPED` lists deliberate skips with reasons. Every `packages/chat/src/**/*.test.ts(x)` must be in one of the -three (remaining unmapped work: issue #78). +three. At the `chat@4.41.1` pin the strict tier is 922/922; the four +report-only `TARGET_MAPPING` rows (`cards`, `modals`, `emoji`, `message`) +and the `#78 pending` `UNMAPPED` rows are the remaining work (issue #78). **CI runs `--strict`** (see `.github/workflows/lint.yml`): any missing translation in a mapped file fails the build, a missing upstream checkout fails, and a checkout whose HEAD differs from the pinned diff --git a/README.md b/README.md index 39998d90..21c0de0b 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Multi-platform async chat SDK for Python. Port of [Vercel Chat](https://github.com/vercel/chat) (MIT, © Vercel, Inc. — see [NOTICE](NOTICE)). -> **Status: 0.4.31.3 — synced to [Vercel Chat 4.31.0](https://github.com/vercel/chat)** (`UPSTREAM_PARITY = "4.31.0"`). See [CHANGELOG.md](CHANGELOG.md). +> **Status: 0.4.41 — synced to [Vercel Chat 4.41.1](https://github.com/vercel/chat)** (`UPSTREAM_PARITY = "4.41.1"`). See [CHANGELOG.md](CHANGELOG.md). ## Why chat-sdk? diff --git a/docs/UPSTREAM_SYNC.md b/docs/UPSTREAM_SYNC.md index c5deccfe..ae5e6d48 100644 --- a/docs/UPSTREAM_SYNC.md +++ b/docs/UPSTREAM_SYNC.md @@ -17,6 +17,9 @@ Our version embeds the upstream Vercel Chat version: `0.{upstream_major}.{upstre | `0.4.27.1` | `4.27.0` | Python-only fix on top of 4.27.0 (Slack upload confirmation backport) | | `0.4.29` | `4.29.0` | Synced to upstream 4.29.0 (upstream never tagged `chat@4.27.0`/`chat@4.28.0`) | | `0.4.30` | `4.30.0` | Synced to upstream 4.30.0 (Teams adapter migrated to the `microsoft-teams-apps` SDK, issue #93) | +| `0.4.31` | `4.31.0` | Synced to upstream 4.31.0 | +| `0.4.31.3` | `4.31.0` | Python-only fixes on top of 4.31.0 (`0.4.31.1` Slack #138/#95; `0.4.31.2` tagged but never published) | +| `0.4.41` | `4.41.1` | Synced to upstream 4.41.1 (the 4.41 wave, #184). `UPSTREAM_PARITY = "4.41.1"`, not `4.41.0`: it names the exact strict fidelity pin tag, which carries the 4.41.1 fixes ported in the wave (#199, #231) | The `UPSTREAM_PARITY` constant in `chat_sdk/__init__.py` provides programmatic access to the upstream version this release is synced to. @@ -88,19 +91,23 @@ tests. If upstream tests lock in inconsistent behavior, choose one of: ### Test fidelity (strict mode) `scripts/verify_test_fidelity.py` runs in CI (`.github/workflows/lint.yml`) -pinned to `vercel/chat@4.31.0`. **CI runs `--strict`** — the repo ships at 0 -missing *for mapped core files* (733/733 at the pin, counting each `it.each` -template as one test). +pinned to `vercel/chat@4.41.1`. **CI runs `--strict`** — the repo ships at 0 +missing *for mapped core files* (922/922 at the pin, counting each `it.each` +template as one test; it was 733/733 at the previous `chat@4.31.0` pin, and +#203 moved the pin and promoted the eight test files upstream added in 4.34–4.41 into the +strict tier). **Single pin source: `scripts/upstream_pin.json`.** It holds `pin: {tag, sha}` (the strict CI tag and its full commit SHA) and `target: {tag, sha}` (the tag an in-flight sync wave is porting towards; -`chat@4.41.1` for the 4.41 wave, #184). The script and `lint.yml` (via +`chat@4.41.1` for the 4.41 wave, #184; after the pin bump in #203 both +entries name `chat@4.41.1` until the next wave). The script and `lint.yml` (via `jq`) read it; no other file hard-codes the tag. Resolve SHAs with `git rev-parse ^{commit}`. The major.minor of `pin.tag` must equal the major.minor of `UPSTREAM_PARITY` in `src/chat_sdk/__init__.py` -(exact equality is not required: `UPSTREAM_PARITY = "4.41.0"` may pin -`chat@4.41.1`). Only the pin-bump PR changes `pin` (#203 for this wave). +(exact equality is not required, but by convention `UPSTREAM_PARITY` +names the pin tag exactly: `"4.41.1"` ↔ `chat@4.41.1`). Only the +pin-bump PR changes `pin` (#203 for the 4.41 wave). Clone the pinned checkout locally with: @@ -113,18 +120,25 @@ git clone --depth 1 --branch "$(jq -r .pin.tag scripts/upstream_pin.json)" \ - `MAPPING` — the strict set. CI fails on any missing test here. - `TARGET_MAPPING` — rows checked only by `--report-target` (together with - `MAPPING`): files that do not exist at the pin (`history/*`, - `agent-session`, `app-context`, `installation-events`), plus files whose - Python counterpart still has gaps at the pin (`cards`, `modals`, `emoji`, - `message`; issue #78). A row moves to `MAPPING` once it is at 0 missing - at the pin. A Python file that does not exist yet reports every TS test - as missing. + `MAPPING`): files whose Python counterpart still has gaps at the pin, + plus, during a sync wave, files that do not exist at the pin yet. A row + moves to `MAPPING` once it is at 0 missing at the pin; #203 promoted + `history/{user,thread,channel,to-prompt}`, `agent-session`, + `app-context`, `installation-events` and `adapter-shared/download` that + way. A Python file that does not exist yet reports every TS test as + missing. +- **Report-only at the `chat@4.41.1` pin (issue #78):** `cards`, `modals`, + `emoji` and `message` stay in `TARGET_MAPPING`. Their Python suites are + organized differently from upstream's and miss 100 of 142 upstream + titles (cards 20, modals 28, emoji 41, message 11; listed in + `scripts/fidelity_target.json`). They are not in the strict CI gate; + translating them is issue #78, not a release blocker. - `UNMAPPED` — core test files deliberately not checked, with the reason: | TS file(s) | Reason | |---|---| -| `ai/tanstack/messages.test.ts`, `ai/tanstack/tools.test.ts` | JS-only TanStack AI adapter (chat@4.41.0); no Python equivalent. Non-parity row lands in #203. | -| `workflow/approval.test.ts` | Vercel Workflow SDK integration (chat@4.35.0); no Python equivalent. | +| `ai/tanstack/messages.test.ts`, `ai/tanstack/tools.test.ts` | JS-only TanStack AI exporter (chat@4.41.0); covered by the "TanStack AI" row under [Skipped upstream changes](#skipped-upstream-changes-441-wave). | +| `workflow/approval.test.ts` | Vercel Workflow SDK integration (chat@4.35.0); covered by the "Workflow `requestApproval`" row under [Skipped upstream changes](#skipped-upstream-changes-441-wave). | | `jsx-react.test.tsx`, `jsx-runtime.test.ts`, `jsx-runtime.test.tsx` | Covered by the "JSX Card/Modal elements" non-parity row. | | `adapters/index.test.ts` | Covered by the "`chat/adapters` static adapter catalog" non-parity row. | | `errors.test.ts`, `logger.test.ts`, `chat-singleton.test.ts` | #78 pending: no Python counterpart identified yet (0 exact name matches). | @@ -2183,11 +2197,11 @@ Parity with upstream `169788b6` (vercel/chat#592, chat@4.39.0) and fetching channel messages`, `history.channel.listThreads: adapter "x" does not implement listThreads`). `ChatNotImplementedError` is still turned into `ChatError` (now inside `history.channel`). -- **Fidelity.** At the pin, `transcripts.test.ts` still holds the whole +- **Fidelity.** Up to chat@4.38, `transcripts.test.ts` held the whole user-history suite; from chat@4.39.0 it moved to `history/user.test.ts` and `transcripts.test.ts` keeps only the alias test. Both rows map to - `tests/test_history_user.py`, so the strict pin check and the target - report both pass without duplicate tests. + `tests/test_history_user.py` (both strict at the chat@4.41.1 pin, #203) + without duplicate tests. ### Slack agent_view and declarative agent config (chat@4.34–4.41, #214) @@ -3340,7 +3354,7 @@ stay explicit instead of being rediscovered in code review. | Linear agent-activity emit: raw GraphQL (chat@4.31 / #151 — L4) | The agent-session EMIT path (`post_message` session branch, `start_typing` session branch, `stream` → `_stream_in_agent_session` with its flush/`task_update`/`plan_update` logic, and `_parse_message_from_agent_activity`) is ported as **raw GraphQL mutations** over the existing `_graphql_query` helper, **schema-hardened against Linear's published GraphQL schema**. Mutation names: `agentActivityCreate(input: AgentActivityCreateInput!)` and `agentSessionUpdate(id: String!, input: AgentSessionUpdateInput!)`. `content` is sent inside the `AgentActivityCreateInput.content` **`JSONObject!`** scalar — so `type`/`body`/`action`/`parameter`/`result` are inline JSON fields, with the **lowercase** `AgentActivityType` enum values `"response"` / `"thought"` / `"error"` / `"action"` (confirmed lowercase, NOT PascalCase). `ephemeral: Boolean` is a sibling of `content` and is **included only when set** (absent — not `false` — on response/thought/error). The `agentActivityCreate` return selection requests only schema-valid fields (`success`, `agentActivity { id agentSession { id } sourceComment { id body parentId createdAt updatedAt url user{…} botActor{…} } }`) — `agentSessionId` is **not** a scalar field on Linear's `AgentActivity` type (the schema exposes only the relation `agentSession: AgentSession!`), so the session id is read off the nested `agentSession { id }` relation; requesting the non-existent scalar would server-reject the whole mutation under GraphQL strict selection validation. `plan` items are `{content, status:"completed"}`. `initialize` additionally captures the viewer's `organization.id` into `_default_organization_id` (mirroring upstream's `defaultOrganizationId`) for the emitted raw message's `organizationId`; on the emit path `organizationId` falls back to `""` when `_default_organization_id` is unset (no per-request installation context is plumbed — a pre-existing adapter-wide divergence from upstream, which throws `AuthenticationError` when no organization is resolvable). | Upstream `adapter-linear/src/index.ts` calls `@linear/sdk`'s `createAgentActivity({agentSessionId, content, ephemeral?})` / `updateAgentSession(id, {plan})`; the SDK owns the GraphQL document, the `AgentActivityType` enum, and the `AgentActivityPayload`/`Comment`/`sourceComment` resolution | `@linear/sdk` is TypeScript-only (no official Linear Python SDK — cf. the `linear_client` getter row), so the SDK calls are reproduced as raw GraphQL. Mutation names, the `content: JSONObject!` shape, the lowercase enum casing, and the `plan` item shape are **schema-hardened against the published schema** (`https://linear.app/developers/agent-interaction`, `https://linear.app/developers/graphql`, and the SDK's generated GraphQL documents). **Live-tenant verification pending**: the exact mutation/field names and enum casing are confirmed against the published schema/docs but have **not** been exercised against a live Linear agent-session tenant; if a future live run surfaces a casing/field mismatch (e.g. an enum the schema renders differently at runtime), update the mutation strings here. Faithful-port hazards preserved: `status ?? "Thinking..."` → `is not None` (an empty status stays `""`); `[title, output].filter(Boolean).join("\n")` → `"\n".join(x for x in [title, output] if x)` (drops `None` and `""`); `markdown.slice(...).trim()` uses the JS-`.trim()` whitespace set (`_JS_WHITESPACE`, mirroring `adapters/telegram/rich.py`), not Python's broader `str.strip()`; `if delta or force`; `ephemeral: status != "complete"`; the missing-final-flush bare `throw new Error(...)` → `RuntimeError`. **Thread ids (chat@4.40.0 / vercel/chat#885, #232):** the emitted raw message carries the stable session thread `linear:{issueId}:s:{agentSessionId}`, as upstream's `parseMessage` encodes it; the source comment's id is the message id only. A stored pre-#885 `linear:{i}:c:{c}:s:{s}` thread id still decodes to the same session and posts there. Regression coverage: `tests/test_linear_agent_session_emit.py`. | | Linear comment-thread fetch: root-first ownership check, `children` connection (chat@4.41.1 / vercel/chat#965 — #231) | `_fetch_comment_thread` issues **two** raw GraphQL queries in order: `CommentThreadRoot` (`comment(id:)` plus the nullable scalar `issueId` — the same field the SDK's `comment` document selects; unlike `AgentSession`, `Comment` does expose it in the published schema), then, only once the root's issue id is present and equal to the thread's, `CommentThreadChildren` = `comment(id:) { children(first:, last:) { nodes pageInfo } }` (`forward` → `first`, otherwise `last`, default 50). A missing or foreign issue raises `ValidationError("linear", "Comment does not belong to this issue")` with no children query and no parse; messages are built with the validated root issue id. A `null` root (raw-GraphQL not-found) still returns an empty result without a children query. Fetched messages keep the requested (fixed) thread id, as before. | `linear.comment({ id })` then `linear.comments({ filter: { parent: { id: { eq: commentId } } }, first|last })` (the root `comments` connection); the SDK throws on a missing root; `commentsToMessages` encodes each comment's own id in its thread id. | Query shape only: `Comment.children` and the parent-filtered root `comments` connection return the same replies with the same `first`/`last` pagination, and `children` was the port's existing, already-exercised selection, so keeping it avoids a second query document for the same data. Regression: `tests/test_linear_extended.py::TestFetchMessages::test_fetches_same_issue_comment_thread_in_order` pins the `children` variables (`commentId` + `first`/`last`, no `filter`) for `forward`, `backward` and the default (no direction → `last`). The fixed thread id and empty-on-null-root are pre-existing port behavior, not introduced here. | | Linear agent-session fetch: raw GraphQL (chat@4.31 / #151 — L5) | The agent-session FETCH/read path (`fetch_messages` session dispatch → `_fetch_agent_session_messages`, plus the append-only guards on `edit_message`/`delete_message` and the `agentSessionId` key in `fetch_thread` metadata) is ported as **raw GraphQL queries** over the existing `_graphql_query` helper, **schema-hardened against Linear's published GraphQL schema** (`linear/packages/sdk/src/schema.graphql` @ master). Two queries: (1) `agentSession(id: String!): AgentSession!` selecting `id`, `url`, `issue { id }`, and the nullable `comment { id body parentId createdAt updatedAt url user{…} botActor{…} }` root relation; (2) `comments(filter: CommentFilter, first: Int, last: Int): CommentConnection!` filtered by `{parent: {id: {eq: rootComment.id}}}` for the children, selecting the same `Comment` sub-fields + `pageInfo { hasNextPage endCursor }`. Upstream passes ONLY `first`/`last` here — it never reads `options.cursor` — and the sibling `_fetch_issue_comments`/`_fetch_comment_thread` paths forward no cursor either, so no inbound `after` is plumbed (only `next_cursor` is RETURNED, off `pageInfo.endCursor`). **CRITICAL schema-hardening: `AgentSession` has NO scalar `issueId` field in the published schema** (it exposes only the `issue: Issue` relation alongside `comment`/`sourceComment`/`id`); upstream's `agentSession.issueId` works because `@linear/sdk`'s model derives it from the serialized object, but in raw GraphQL requesting a non-existent `issueId` field would server-reject the whole query (the L4 blocking-bug class). So the issue id is read off the `issue { id }` relation — equivalent to upstream's `agentSession.issueId`. **Ownership check (chat@4.41.1, vercel/chat#974, #231):** when that id is missing (`None`/`""`) or differs from the thread's issue id, `ValidationError("linear", "Agent session does not belong to this issue")` is raised before the root `comment` is read and before the children query — there is **no** `thread.issue_id` fallback (the pre-4.41 `agentSession.issueId ?? thread.issueId` let a thread id naming issue A read a session on issue B). The session query still selects the root `comment` in the same request (upstream lazy-loads it); the content is discarded unread on a failed check. Pagination is direction-driven (`forward` → `first`, otherwise `last`, default limit 50); `next_cursor = endCursor if hasNextPage else None`. Each of `[rootComment, *children.nodes]` is parsed via the upstream `parseMessageFromComment(comment, issueId, agentSession.id)` semantics — reusing L4's `_raw_message_from_source_comment` (user-vs-`botActor` author resolution) + `_parse_agent_session_message` (the `parseMessage` agent-session branch), so every message carries the **stable session thread id** `linear:{issueId}:s:{agentSessionId}` (chat@4.40.0 / vercel/chat#885, #232; it used to encode each comment's own id as `…:c:{comment.id}:s:…`) and `is_mention=True`. **Rootless sessions (#232):** when the session has no root `comment`, history comes from a third query, `AgentSessionActivities` = `agentSession(id:) { activities(first:, after:, last:, before:) { nodes pageInfo } }` (`_fetch_agent_session_activities`, upstream `fetchAgentSessionActivities`), instead of raising. `forward` sends `first` + `after` (the inbound cursor), otherwise `last` + `before`, default 50; an unset or empty cursor is omitted, and only the variables upstream passes are sent. Unlike the children path, the inbound cursor IS forwarded here, as upstream does. Nodes are sorted by parsed `createdAt` and rendered by `_render_activity` (upstream `renderActivity`): `body`, or `"{action}: {parameter}"` plus `"\n{result}"`, trimmed with the JS `.trim()` set (`_JS_WHITESPACE`), an action that is empty after trimming rendering as `"Action"`. Prompt activities are authored as the user, all others as the bot (`user.id ?? (bot ? botUserId : "unknown")`, name fallback `userName`/`"unknown"`). Message id is `sourceComment.id ?? activity.id`. `next_cursor` is `endCursor` (forward, `hasNextPage`) or `startCursor` (backward, `hasPreviousPage`). Schema check for the activities query: `AgentActivity` has **no** scalar `sourceCommentId` or `userId` (the SDK derives both from the `sourceComment: Comment` and `user: User!` relations), so the query selects `sourceComment { id }` and `user { id displayName name email avatarUrl }` inline, one query with no per-activity follow-ups. `content` is the `AgentActivityContent` union, selected with inline fragments on `AgentActivityPromptContent`, `…ResponseContent`, `…ThoughtContent`, `…ErrorContent`, `…ElicitationContent` (`type body`) and `AgentActivityActionContent` (`type action parameter result`); `PageInfo` has all four of `hasNextPage hasPreviousPage endCursor startCursor`. The live-tenant caveat in this row applies to it too. `edit_message`/`delete_message` raise `AdapterError` with the exact upstream strings ("…append-only and cannot be edited" / "…cannot be deleted") for session threads, before any network call. | Upstream `adapter-linear/src/index.ts` calls `@linear/sdk`'s `linear.agentSession(id)` (lazy-resolving `issueId` + the `comment` relation off the SDK model) and `linear.comments({filter, first/last})`; the SDK owns the GraphQL documents and the `Comment`/author resolution. | `@linear/sdk` is TypeScript-only (no official Linear Python SDK — cf. the `linear_client` getter row), so the SDK calls are reproduced as raw GraphQL. Query names, the `agentSession(id)` shape, the root `comments(filter: CommentFilter, first/last)` connection, the `CommentFilter.parent → NullableCommentFilter.id → IDComparator.eq` chain, and every selected `AgentSession`/`Comment`/`User`/`ActorBot`/`PageInfo` field were each **verified field-by-field against the published `schema.graphql`** (this is how the absence of a scalar `AgentSession.issueId` was caught). **Live-tenant verification pending**: the query/field names are confirmed against the published schema but have **not** been exercised against a live Linear agent-session tenant; if a future live run surfaces a field mismatch, update the query strings here. Hazards: the ownership check is deliberately truthy (`not issue_id or issue_id != thread.issue_id`, mirroring upstream's `!issueId`, so `""` never matches `""`); `endCursor ?? undefined` → `is not None`. Regression coverage: `tests/test_linear_agent_session_fetch.py`. | -| `chat/adapters` static adapter catalog (chat@4.31.0, new `./adapters` package subpath) | Not ported | A new SDK-free metadata module (`packages/chat/src/adapters/index.ts`, 19 `test()` cases): types `EnvVar`/`EnvGroup`/`AdapterEnvSpec`/`CatalogAdapter`, an `ADAPTERS` registry of ~25 official + vendor-official adapters, and exports `ADAPTER_NAMES`/`AdapterSlug`/`getAdapter`/`isAdapterSlug`/`listPlatformAdapters`/`listStateAdapters`/`listEnvVars`/`getSecretEnvVars`. Imports no provider SDK, so it's safe for build scripts, onboarding/setup screens, and config-discovery UIs that need package + env-var metadata (incl. secret-masking flags) without loading an adapter. | **Not meaningfully portable verbatim, and not yet needed.** The catalog's spine is TypeScript-ecosystem-specific: every entry is addressed by an **npm `packageName`** (`@chat-adapter/slack`, `@kapso/chat-adapter`, …) and the registry includes ~13 **vendor-official adapters this Python SDK does not ship** (AgentPhone, Kapso, Lark/Feishu, Liveblocks, Beeper Matrix, Resend, Sendblue, ioredis, …). A 1:1 port would ship actively-misleading data to a `pip`-installed consumer (`@chat-adapter/slack` instead of `chat-sdk[slack]`; `get_adapter("kapso")` returning metadata for something uninstallable here). The only Python-applicable slice — per-platform env-var requirements + secret flags — is the same across the 9 platform + 3 state adapters we do ship, but exposing it would be a **new Python-native feature** (a divergent rewrite around `pip` extras, not a port), and no current consumer (chinchill-api configures adapters in code, not via env-var discovery) needs it. Nothing in chat-sdk's core depends on the catalog. Deferred demand-driven: a Python-native `adapters` catalog (our adapters only, `pip`-extra install names, `get_secret_env_vars` masking) can be designed against real requirements if/when a consumer needs config discovery. The unmapped test file is additionally covered by the issue #78 fidelity-scope note. | +| `chat/adapters` static adapter catalog (chat@4.31.0, new `./adapters` package subpath; **re-verified at chat@4.41.1**, #203) | Not ported | A new SDK-free metadata module (`packages/chat/src/adapters/index.ts`, 19 `test()` cases at both tags): types `EnvVar`/`EnvGroup`/`AdapterEnvSpec`/`CatalogAdapter`, an `ADAPTERS` registry of official + vendor-official adapters (24 entries at chat@4.31.0; 34 at chat@4.41.1, 20 official and 14 vendor-official, after vendor-official Linq `d034b8b5` and Novu `06af3e12` (chat@4.32.0), Dial `0c761f1b`, Cloudflare Agents `3abdc691` and Photon `24a04d56` (chat@4.33.0), and official Instagram, Notion, X, XChat and Gmail `f034cd27` (chat@4.37.0–4.41.0); no entry removed), and exports `ADAPTER_NAMES`/`AdapterSlug`/`getAdapter`/`isAdapterSlug`/`listPlatformAdapters`/`listStateAdapters`/`listEnvVars`/`getSecretEnvVars`. Imports no provider SDK, so it's safe for build scripts, onboarding/setup screens, and config-discovery UIs that need package + env-var metadata (incl. secret-masking flags) without loading an adapter. | **Not meaningfully portable verbatim, and not yet needed.** The catalog's spine is TypeScript-ecosystem-specific: every entry is addressed by an **npm `packageName`** (`@chat-adapter/slack`, `@kapso/chat-adapter`, …) and the registry includes adapters **this Python SDK does not ship**: all 14 vendor-official ones (AgentPhone, Cloudflare Agents, Dial, Kapso, Lark/Feishu, Linq, Liveblocks, Beeper Matrix, Novu, Photon, Resend, Sendblue, Velt, Zernio) plus the official ioredis, web, Instagram, Notion, X, XChat and Gmail entries (the last five are deferred, see #189 / #241). A 1:1 port would ship actively-misleading data to a `pip`-installed consumer (`@chat-adapter/slack` instead of `chat-sdk[slack]`; `get_adapter("kapso")` returning metadata for something uninstallable here). The only Python-applicable slice — per-platform env-var requirements + secret flags — is the same across the 10 platform + 3 state adapters we do ship, but exposing it would be a **new Python-native feature** (a divergent rewrite around `pip` extras, not a port), and no current consumer (chinchill-api configures adapters in code, not via env-var discovery) needs it. Nothing in chat-sdk's core depends on the catalog. Deferred demand-driven: a Python-native `adapters` catalog (our adapters only, `pip`-extra install names, `get_secret_env_vars` masking) can be designed against real requirements if/when a consumer needs config discovery. The unmapped test file is additionally covered by the issue #78 fidelity-scope note. | | `GoogleChatAdapterConfig` identity fields (#222; upstream `7a192235` / `c3b5a08e`) | `workspace_add_on_service_account_email` and `pubsub_service_account_email` are declared `field(default=None, kw_only=True)` after `disable_signature_verification`, which stays the last *positional* field. Passing an 11th positional argument raises `TypeError` | Optional properties on an options object (`workspaceAddOnServiceAccountEmail`, `pubsubServiceAccountEmail`), which have no positional order | `GoogleChatAdapterConfig` is a positional dataclass. Adding the fields positionally would shift existing callers' arguments, and a shifted argument could land in an identity field the verifier trusts. Pinned by `TestDisableSignatureVerificationFieldOrder` in `tests/test_gchat_verification.py`. #223 adds `bot_user_id` the same way | | Google Chat webhook JWT verification mechanics (#222; upstream `270b1c25` / `7a192235` / `c3b5a08e`) | OIDC tokens (endpoint-URL and Pub/Sub) are decoded with PyJWT against Google's JWKS (`oauth2/v3/certs`): RS256, strict `aud` equality, `exp`/`iat`/`iss` required, 300 s leeway. `iss` is then checked by hand against {`accounts.google.com`, `https://accounts.google.com`}. Project-number tokens are decoded against the Chat issuer's X.509 certs with the same PyJWT options, and `iss` is then compared by hand with `chat@system.gserviceaccount.com` (not PyJWT's `issuer=`, which PyJWT 2.10.0 matched as a substring, CVE-2024-53861). On both paths, a token whose `exp` is 24 h or more ahead of the wall clock is rejected by hand, because PyJWT has no maximum-lifetime check. Both key sets are fetched with the shared aiohttp session and cached as `(fetched_at, {kid: key})` for a fixed 1 hour on an injectable monotonic clock. A failed or empty fetch is never cached, and an unknown `kid` is rejected without a refetch | `OAuth2Client.verifyIdToken` (federated certs, cached per `Cache-Control`) and `verifySignedJwtWithCertsAsync` with the Chat certs, cached for 1 hour | No maintained async google-auth equivalent exists, and the old `PyJWKClient` did blocking I/O on the event loop. The claim checks are ported to match google-auth-library, including its `exp >= now + 86400` rejection (`DEFAULT_MAX_TOKEN_LIFETIME_SECS_`), and `email_verified` checked by identity (`is True`) and add-on emails compared with `==` and shape-tested with `re.fullmatch`. Google's keys are published hours before use (`max-age` is about 6 h), so a 1-hour TTL does not miss rotations. Pinned by `TestEndpointUrlVerification`, `TestProjectNumberVerification`, `TestDirectTokenTimeClaims` and `TestVerificationKeyCache` | | Google Chat media `resourceName` validation (#223; upstream `32687038`) | `fetch_data` (fresh and `rehydrate_attachment`) runs `_validate_media_resource_name` before minting a token: a non-string or empty value, `?`, `#`, `%`, backslash, anything outside printable ASCII (whitespace, control and non-ASCII characters), or a `.` / `..` path segment raise `ValidationError("gchat", "Invalid Google Chat attachment resource name: ...")`. Anything else is accepted, since `resourceName` is opaque. The value is then interpolated into `https://chat.googleapis.com/v1/media/{resourceName}?alt=media` | Passes `resourceName` to the googleapis client's `media.download`, which encodes it; no shape check | The Python port builds the URL itself, and `resourceName` can come from serialized `fetch_metadata`. Without the check a tampered value could add query parameters, or `..` segments (which the URL layer normalizes) could point the service-account token at another `/v1/` endpoint. Media errors go through `_handle_google_chat_error(error, "fetchAttachmentData")` as upstream (429 → `AdapterRateLimitError`, otherwise the `_GoogleApiError` carrying the HTTP status is re-raised). Pinned by `tests/test_google_chat_adapter.py::TestRehydrateAttachment::test_rehydrated_fetch_data_rejects_unsafe_resource_names` | @@ -3375,6 +3389,47 @@ stay explicit instead of being rediscovered in code review. | Linear `parse_message` for ordinary comments (#285) | `parse_message` with `kind: "comment"` returns `thread_id=""` and a hardcoded `"unknown"` `user_name`/`full_name`, reading the flat `comment.userId`. It leaves `is_mention` unset, as upstream. The `agent_session_comment` kind is at parity (stable `linear:{issue}:s:{session}` thread, structured author, `is_mention=True`) | `parseMessage` encodes `linear:{issueId}:c:{comment.id}` and reads the structured `comment.user` (`displayName`, `fullName`, `type === "bot"`) | Pre-existing gap, not on a routed path: core never calls `parse_message`; the comment webhook (`_build_message`) and comment fetch (`_comment_node_to_message`) build their messages directly with the right thread id and author. Only consumers calling `adapter.parse_message(raw)` directly see it. Fix tracked in **#285**. | | `adapter-web` (`@chat-adapter/web`) | Neither half ported. (a) The server-side `WebAdapter` is **deferred** — not yet ported; (b) the client subpaths are out of scope (see Rationale). | Two distinct things: (a) a server-side `WebAdapter` — an `Adapter` implementation serving a browser chat UI over the AI SDK UI stream protocol (`3490a8c`, vercel/chat#444); (b) React/Vue/Svelte client subpaths (`716e934`) | The `WebAdapter` (a) **is portable** to a Python server SDK (it's a standard `Adapter`) and is deferred, not excluded — a future wave can port it. The client subpaths (b) are genuinely browser-only (front-end framework bindings) and are out of scope for a Python server SDK. (Corrects the earlier over-broad "browser-only; no Python runtime" note in CHANGELOG.) **A future `WebAdapter` port must include the web half of upstream `500b7e6d` (vercel/chat#857, chat@4.39.0):** consume only the latest user message from the client-supplied `messages` array and strip tool parts from it, so a client cannot forge tool-approval results and bypass `needs_approval`. The core half (guarded write tools) is ported by #195. | +### Skipped upstream changes (4.41 wave) + +Upstream changes in `chat@4.32.0`–`chat@4.41.1` that the 4.41 wave (#184) +deliberately does not port, recorded by #203. Every other 4.32–4.41.1 change +was ported by a wave PR (its own section above) or is deferred in the next +table. + +| Area (upstream ref) | Python behavior | TS behavior | Rationale | +|------|----------------|-------------|-----------| +| TanStack AI (`2d0d3bf3`, vercel/chat#936, chat@4.41.0) | Not ported. `chat_sdk.ai` has one message converter (`to_ai_messages`) and framework-agnostic `ChatTool`s (`name`, description, input schema, `execute`, `needs_approval`; #198). `ai/tanstack/{messages,tools}.test.ts` are `UNMAPPED` in the fidelity script. | New `chat/ai/tanstack` subpath: `toTanStackMessages` / `createTanStackTools` exporters for the TanStack AI runtime (a JS-only framework). | TanStack AI has no Python runtime. A Python agent framework can consume `ChatTool` directly; the shared message-content helpers the exporter reuses are already ported (`chat_sdk.ai.message_content`, #198). | +| AI SDK v7 (`6714efc3`, vercel/chat#691, chat@4.34.0) | Not applicable. `ChatTool` is not tied to an AI SDK version, so there is no peer range to widen and no return type to change. The `ai/index.test.ts` changes from this commit are in the strict tier and pass. | `ai@^6 \|\| ^7` peer range; the `ai/tools/*` factories return types that satisfy both majors' `tool()` shape. | The change is TypeScript typing and npm peer-dependency metadata. Python's tools are plain dataclasses (#198). | +| Workflow `requestApproval` (`4cb7e5d5`, vercel/chat#728, chat@4.35.0) | Not ported. Approvals stay synchronous: `needs_approval` / `require_approval` on the AI tools (#195). `workflow/approval.test.ts` is `UNMAPPED`. | New `chat/workflow` subpath: `requestApproval()` posts an approval card whose buttons call a Workflow `createWebhook()` URL, and the workflow run waits on that webhook (optionally raced against `sleep` for a timeout) until a click resumes it. | Built on Vercel Workflow primitives (`"use workflow"` / `"use step"`, `createWebhook`, durable `sleep`), which have no Python runtime. A Python durable-approval feature would be a new design on a Python workflow engine, not a port. | +| Workflow serialization entrypoint (`bdeb2bf1`, vercel/chat#806, chat@4.38.0) | Not applicable. `Message`, `ThreadImpl` and `ChannelImpl` serialize through `to_json()` / `from_json()` / `chat.reviver()` and import no Node-only module. | New `chat/serialization` package entry and a tsup chunk split, so a Vercel Workflow bundle can register the `@workflow/serde` classes without pulling `node:async_hooks`. | A JavaScript bundler and package-export fix. Python has no bundle and no `@workflow/serde` registration step. | +| Vercel Connect (`ab0e1806` #647, `ba687cb1` #648, chat@4.33.0; `1136c967` #932, chat@4.41.0) | Not ported as a feature. The Slack adapter already accepts the same building blocks: a callable `bot_token` resolver and a `webhook_verifier` (rows above). The Teams token factory and `webhook_verifier` (#221) are the Teams equivalent. | Docs and wiring for the external `@vercel/connect` package, which supplies Vercel-hosted credentials and webhook verification to adapters. | `@vercel/connect` is a JavaScript package tied to Vercel's hosting platform. The per-adapter Connect hooks (Linear, Discord, Telegram, Notion, GitHub) are deferred in the next table. | +| `create-chat-sdk` scaffolder (`ba375ce1` #655, chat@4.33.0; `927d0dbd` #804, chat@4.38.0) | Not ported. | `npx create-chat-sdk` project generator (now with a Vercel Connect mode) and its docs page. | An npm project generator for TypeScript bots. A Python template would be a separate tool, and no consumer has asked for one. | +| Adapter catalog entries (`0c761f1b` #676, `3abdc691` #669, `24a04d56` #668, chat@4.33.0; `d034b8b5` #625, `06af3e12` #622, chat@4.32.0; `f034cd27` #958, chat@4.41.0) | Not ported (no catalog). | New `ADAPTERS` entries: vendor-official Dial, Cloudflare Agents (state), Photon, Linq and Novu, and the official Gmail listing. | Covered by the "`chat/adapters` static adapter catalog" row above, re-verified at `chat@4.41.1`. | +| Docs, community adapters and repo chore (`86b2fa4b` #970, chat@4.41.1; `1136c967` #932, chat@4.41.0; `f691ad58` #873, chat@4.40.0; `0642ce33` #772, chat@4.37.0; `a8867a0a` #753, chat@4.36.0; `1dff4515` #686, `b3123815` #687, chat@4.34.0; `eb466e52` #677, chat@4.33.0; `ef3f0f63` #638, `99c59850` #646, `efa96108` #635, `64b66864` #626, chat@4.32.0) | No change. | Docs-site migration and copy edits, community-adapter listings (LINE, QQ Bot, WeCom, zaileys, Weixin), the WhatsApp typing-indicator doc, the `xai/grok-4.5` model rename in examples, the KB sync script and a changesets rule for `example-*` packages. | Upstream docs site, examples and monorepo tooling only; nothing in a published package's runtime. Community adapters are third-party npm packages. | +| Discord import sort (`21962572`, vercel/chat#657, chat@4.32.0) | No change. | Reorders imports in `adapter-discord`. | Formatting only (Biome). Python import order is enforced by ruff. | +| Telegram rich-truncate test timeout (`57fa85ca`, vercel/chat#703, chat@4.34.0) | No change. | Raises one vitest timeout to 15 s. | Test-infra only. The Python suite sets no per-test timeout. | + +### Deferred platforms and adapter modes (demand-gated, #189) + +Upstream 4.32–4.41 features that no current consumer uses. Each is demand-gated; +see #189 for effort, prerequisites and porting notes. An item becomes its own +issue only when a consumer asks for it. + +| Area (upstream ref) | Python | TS | Status | +|------|--------|-----|-----------| +| Instagram DM adapter (`2a2b2c55`, vercel/chat#770, chat@4.37.0) | No `instagram` adapter | `@chat-adapter/instagram` (Messenger-like Graph adapter) | Deferred. Demand-gated; see #189. | +| Notion comments adapter (`0ec6a736` #689, chat@4.37.0; Connect support `06b04ac4` #812, chat@4.38.0) | No `notion` adapter | `@chat-adapter/notion` (comments over REST + HMAC webhooks) | Deferred. Demand-gated; see #189. | +| X (Twitter) adapter (`ef2542c5` #682, chat@4.33.0; `4bca64f0` #700, chat@4.34.0; `b6749238` #775, chat@4.37.0) | No `x` adapter | `@chat-adapter/x` (posts, mentions, DMs, media upload, CRC challenge) | Deferred. Demand-gated; see #189. A port must include `b6749238`'s CRC token validation. | +| XChat encrypted messaging (`caa63253` #745, chat@4.36.0; docs `fe4ed11e` #777, chat@4.37.0) | Not ported | `@chat-adapter/x/chat` on WASM `chat-xdk` + `juicebox-sdk` | **Skipped (no Python bindings).** On 2026-09-29 PyPI had no `chat-xdk`, `juicebox-sdk` or `juicebox` package. Revisit only if X ships Python bindings; see #189. | +| Telegram Business mode (`043386b5`, vercel/chat#888, chat@4.40.0) | No `business_connection_id` handling; `telegram:biz:…` thread ids are not decoded | Opt-in business connections, `telegram:biz:{conn}:{chat}` thread ids | Deferred. Demand-gated; see #189. | +| Twilio RCS (`75cadbf9`, vercel/chat#590, chat@4.39.0) | SMS/MMS only; an actions-only card renders to empty text (upstream sends `TWILIO_EMPTY_CARD_FALLBACK`, "Message from bot") | Content API templates, button actions, locations, channel inference | Deferred. Demand-gated; see #189 (the empty-card fallback is the small independent piece listed there). | +| Gmail adapter (`f034cd27` #958 and the `@chat-adapter/gmail` package, chat@4.41.0) | No `gmail` adapter | `@chat-adapter/gmail` (primitives + Chat adapter) | Deferred. Demand-gated; tracked in #241 (not landed for 0.4.41). | +| Discord Components V2 (`0fdb9029`, vercel/chat#678, chat@4.34.0) | Cards render as embeds + action rows | `DiscordContentFormat.ComponentsV2` | Deferred. Demand-gated; see #189 and the "Discord Components V2" row above. | +| Vercel Connect: Linear (`4115c943` #649, chat@4.33.0; `961cf454` #931, chat@4.41.0) | `webhook_secret` required; no access-token callable or `webhook_verifier` | Access-token function, `webhookVerifier`, Connect-mode installs | Deferred. Demand-gated; see #189. | +| Vercel Connect: Discord (`a0cba028`, vercel/chat#808, chat@4.38.0) | `public_key` required (Ed25519); string `bot_token` | `botToken` / `applicationId` functions, custom `webhookVerifier` | Deferred. Demand-gated; see #189. | +| Vercel Connect: Telegram (`7a1150ce`, vercel/chat#813, chat@4.38.0) | String `bot_token` | `botToken` function resolved on every Bot API call | Deferred. Demand-gated; see #189. (#224 ported only the bot-identity scope helper from this commit.) | +| Vercel Connect: GitHub (`6750d59e`, vercel/chat#650, chat@4.33.0) | App / PAT credentials only | Installation-token callable + `webhookVerifier` | Deferred. Demand-gated; see #189. (#233 ported only the bot-user-id config from this commit.) | + ### Serialization differences | Area | Python | TS | @@ -3389,7 +3444,7 @@ stay explicit instead of being rediscovered in code review. | Module | Confidence | Gap | |--------|-----------|-----| -| Core (chat/thread/channel) | High | 519 TS tests matched | +| Core (chat/thread/channel) | High | 922/922 strict-tier TS tests matched at chat@4.41.1 (cards/modals/emoji/message report-only, #78) | | Slack adapter | High | Extensive replay + unit tests | | Discord adapter | Medium-High | Good replay coverage | | Teams adapter | Medium | Replay tests; JWT auth hand-rolled | diff --git a/pyproject.toml b/pyproject.toml index 376c263e..3846eba9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "chat-sdk" -version = "0.4.31.3" +version = "0.4.41" description = "Multi-platform async chat SDK for Python — port of Vercel Chat" keywords = [ "chat", diff --git a/scripts/fidelity_baseline.json b/scripts/fidelity_baseline.json index 6ecab58a..41703a83 100644 --- a/scripts/fidelity_baseline.json +++ b/scripts/fidelity_baseline.json @@ -1,7 +1,7 @@ { "_comment": "Ratchet-down baseline for scripts/verify_test_fidelity.py. This repo ships at strict fidelity for mapped core files (0 missing) against the pin in scripts/upstream_pin.json, so the baseline is empty. 'ts_parity' is derived from UPSTREAM_PARITY and checked against it on load. Scope: the MAPPING dict in scripts/verify_test_fidelity.py is the authoritative strict list of TS files checked (TARGET_MAPPING rows are only checked by --report-target). Default CI mode runs --strict via .github/workflows/lint.yml; this file is retained for local workflows that want to opt back into baseline mode (e.g. during an upstream sync where several ports land in flight). To baseline genuinely-divergent tests, run scripts/verify_test_fidelity.py --update-baseline after documenting the divergence in docs/UPSTREAM_SYNC.md.", - "ts_parity": "chat@4.31.0", - "total_ts_tests": 733, + "ts_parity": "chat@4.41.1", + "total_ts_tests": 922, "total_missing": 0, "missing": {} } diff --git a/scripts/fidelity_target.json b/scripts/fidelity_target.json index e43ea60a..28aacbe9 100644 --- a/scripts/fidelity_target.json +++ b/scripts/fidelity_target.json @@ -248,7 +248,7 @@ }, "packages/chat/src/history/user.test.ts": { "python": "tests/test_history_user.py", - "tier": "target", + "tier": "strict", "python_exists": true, "ts_tests": 26, "each_templates": 1, @@ -261,7 +261,7 @@ }, "packages/chat/src/history/thread.test.ts": { "python": "tests/test_history_thread.py", - "tier": "target", + "tier": "strict", "python_exists": true, "ts_tests": 15, "each_templates": 0, @@ -274,7 +274,7 @@ }, "packages/chat/src/history/channel.test.ts": { "python": "tests/test_history_channel.py", - "tier": "target", + "tier": "strict", "python_exists": true, "ts_tests": 8, "each_templates": 0, @@ -287,7 +287,7 @@ }, "packages/chat/src/history/to-prompt.test.ts": { "python": "tests/test_history_to_prompt.py", - "tier": "target", + "tier": "strict", "python_exists": true, "ts_tests": 2, "each_templates": 0, @@ -300,7 +300,7 @@ }, "packages/chat/src/agent-session.test.ts": { "python": "tests/test_agent_session.py", - "tier": "target", + "tier": "strict", "python_exists": true, "ts_tests": 2, "each_templates": 0, @@ -313,7 +313,7 @@ }, "packages/chat/src/app-context.test.ts": { "python": "tests/test_app_context.py", - "tier": "target", + "tier": "strict", "python_exists": true, "ts_tests": 1, "each_templates": 0, @@ -326,7 +326,7 @@ }, "packages/chat/src/installation-events.test.ts": { "python": "tests/test_installation_events.py", - "tier": "target", + "tier": "strict", "python_exists": true, "ts_tests": 4, "each_templates": 0, @@ -339,7 +339,7 @@ }, "packages/adapter-shared/src/download.test.ts": { "python": "tests/test_shared_download.py", - "tier": "target", + "tier": "strict", "python_exists": true, "ts_tests": 28, "each_templates": 5, diff --git a/scripts/upstream_pin.json b/scripts/upstream_pin.json index 81c82c48..dfe4cadd 100644 --- a/scripts/upstream_pin.json +++ b/scripts/upstream_pin.json @@ -1,8 +1,8 @@ { "_comment": "Single source of truth for the upstream vercel/chat checkout used by scripts/verify_test_fidelity.py and .github/workflows/lint.yml. 'pin' is the strict CI tag (must share major.minor with UPSTREAM_PARITY in src/chat_sdk/__init__.py); 'target' is the sync-wave tag that --report-target measures against. Record full commit SHAs from `git rev-parse ^{commit}`. Only the pin-bump PR changes 'pin'.", "pin": { - "tag": "chat@4.31.0", - "sha": "2a553aa948e0605d5a93f4c30994ccca72a495f0" + "tag": "chat@4.41.1", + "sha": "f690af3c26a3bd7649387cccad12d215463ed22c" }, "target": { "tag": "chat@4.41.1", diff --git a/scripts/verify_test_fidelity.py b/scripts/verify_test_fidelity.py index 327ccf20..e538bcc2 100644 --- a/scripts/verify_test_fidelity.py +++ b/scripts/verify_test_fidelity.py @@ -83,23 +83,16 @@ # New core test files in chat@4.29.0 "packages/chat/src/callback-url.test.ts": "tests/test_callback_url.py", "packages/chat/src/thread-history.test.ts": "tests/test_thread_history.py", - # The pin's transcripts.test.ts is the whole user-history suite; at + # Up to chat@4.38 transcripts.test.ts was the whole user-history suite; at # chat@4.39.0+ it moved to history/user.test.ts and transcripts.test.ts # keeps only the deprecated-alias test. Both live in test_history_user.py # so either layout matches without duplicating tests. "packages/chat/src/transcripts.test.ts": "tests/test_history_user.py", "packages/chat/src/transcripts-wiring.test.ts": "tests/test_transcripts_wiring.py", "packages/chat/src/from-full-stream.test.ts": "tests/test_from_full_stream.py", -} - -# Target tier: only checked by --report-target (together with MAPPING). -# Rows are files that do not exist at the pin, plus files whose Python -# counterpart is not yet at 0 missing. A row moves into MAPPING once it is -# at 0 missing at the pin. A Python file that does not exist yet reports -# every TS test as missing. -TARGET_MAPPING = { # History API (chat@4.39.0, #197). history/user is the renamed - # transcripts suite. + # transcripts suite. Promoted from TARGET_MAPPING at the chat@4.41.1 pin + # (#203), like every row below. "packages/chat/src/history/user.test.ts": "tests/test_history_user.py", "packages/chat/src/history/thread.test.ts": "tests/test_history_thread.py", "packages/chat/src/history/channel.test.ts": "tests/test_history_channel.py", @@ -111,25 +104,34 @@ # Shared guarded downloader (chat@4.39.0, #204). Outside packages/chat, # so it is mapped explicitly rather than discovered as a core test file. "packages/adapter-shared/src/download.test.ts": "tests/test_shared_download.py", +} + +# Target tier: only checked by --report-target (together with MAPPING). +# Rows are files whose Python counterpart is not yet at 0 missing at the pin +# (plus, during a sync wave, files that do not exist at the pin yet). A row +# moves into MAPPING once it is at 0 missing at the pin. A Python file that +# does not exist yet reports every TS test as missing. The four rows below +# stay report-only after the chat@4.41.1 pin bump; closing them is issue #78. +TARGET_MAPPING = { # Present at the pin but with gaps (issue #78) "packages/chat/src/cards.test.ts": "tests/test_cards.py", "packages/chat/src/modals.test.ts": "tests/test_modals.py", "packages/chat/src/emoji.test.ts": "tests/test_emoji.py", - # Counterpart not yet settled: no Python file has more than 3 exact name - # matches; test_types.py is the closest (8 of 19 at the pin incl. fuzzy). + # Counterpart not yet settled: test_types.py is the closest + # (6 exact + 5 fuzzy of 22 at the chat@4.41.1 pin). "packages/chat/src/message.test.ts": "tests/test_types.py", } # Core test files deliberately not checked, with the reason. UNMAPPED = { "packages/chat/src/ai/tanstack/messages.test.ts": ( - "JS-only TanStack AI adapter (chat@4.41.0, 2d0d3bf3); no Python equivalent — non-parity row lands in #203" + "JS-only TanStack AI exporter (chat@4.41.0, 2d0d3bf3) — Known Non-Parity row 'TanStack AI'" ), "packages/chat/src/ai/tanstack/tools.test.ts": ( - "JS-only TanStack AI adapter (chat@4.41.0, 2d0d3bf3); no Python equivalent — non-parity row lands in #203" + "JS-only TanStack AI exporter (chat@4.41.0, 2d0d3bf3) — Known Non-Parity row 'TanStack AI'" ), "packages/chat/src/workflow/approval.test.ts": ( - "Vercel Workflow SDK integration (chat@4.35.0, 4cb7e5d5); no Python equivalent" + "Vercel Workflow SDK integration (chat@4.35.0, 4cb7e5d5) — Known Non-Parity row 'Workflow `requestApproval`'" ), "packages/chat/src/jsx-react.test.tsx": "JSX runtime — Known Non-Parity row 'JSX Card/Modal elements'", "packages/chat/src/jsx-runtime.test.ts": "JSX runtime — Known Non-Parity row 'JSX Card/Modal elements'", diff --git a/src/chat_sdk/__init__.py b/src/chat_sdk/__init__.py index f94fbbd7..6e4f8bc6 100644 --- a/src/chat_sdk/__init__.py +++ b/src/chat_sdk/__init__.py @@ -288,8 +288,10 @@ WellKnownEmoji, ) -# The upstream Vercel Chat version this release is synced to. -UPSTREAM_PARITY = "4.31.0" +# The upstream Vercel Chat version this release is synced to. It names the +# exact strict-fidelity pin tag (scripts/upstream_pin.json); the package +# version encodes only major.minor (0.4.41 -> chat@4.41.1). +UPSTREAM_PARITY = "4.41.1" __all__ = [ "UPSTREAM_PARITY", diff --git a/tests/test_callback_url.py b/tests/test_callback_url.py index 69faada0..e854a7be 100644 --- a/tests/test_callback_url.py +++ b/tests/test_callback_url.py @@ -388,13 +388,12 @@ async def test_rejects_legacy_unbound_callback_records(self): result = await resolve_callback_url("legacy-token", state, CallbackContext(action_id="approve")) assert result is None - # it("handles legacy string format") — chat@4.31.0 title, behavior now - # reversed. Kept so strict fidelity at the 4.31.0 pin stays green until - # the pin moves to 4.41.1 (#203), where upstream replaced it with the - # test above. Asserts what the rejection above does not: even a context - # that would match any record resolves nothing, and the legacy record is - # left for its TTL rather than deleted. - async def test_handles_legacy_string_format(self): + # Python-only follow-up to the rejection above (upstream's chat@4.31.0 + # "handles legacy string format" test asserted the opposite and was + # replaced at chat@4.40.0). Asserts what that rejection does not: even a + # context that would match any record resolves nothing, and the legacy + # record is left for its TTL rather than deleted. + async def test_legacy_string_record_never_resolves_and_is_not_deleted(self): state = create_mock_state() await state.set("chat:callback:legacy-token", "https://example.com/hook") state.delete = AsyncMock(wraps=state.delete) # type: ignore[method-assign] diff --git a/tests/test_history_user.py b/tests/test_history_user.py index 89d14564..17880837 100644 --- a/tests/test_history_user.py +++ b/tests/test_history_user.py @@ -5,9 +5,10 @@ ``TranscriptsApiImpl`` alias. TS files: packages/chat/src/history/user.test.ts (the renamed transcripts -suite) and packages/chat/src/transcripts.test.ts. The upstream pin -(chat@4.31.0) still has the whole suite in transcripts.test.ts, so both TS -files map here; see scripts/verify_test_fidelity.py. +suite) and packages/chat/src/transcripts.test.ts. Up to chat@4.38 the +whole suite lived in transcripts.test.ts; at the chat@4.41.1 pin that file +keeps only the deprecated-alias test. Both TS files map here; see +scripts/verify_test_fidelity.py. """ from __future__ import annotations diff --git a/tests/test_verify_test_fidelity.py b/tests/test_verify_test_fidelity.py index 7f597b11..cc15a20d 100644 --- a/tests/test_verify_test_fidelity.py +++ b/tests/test_verify_test_fidelity.py @@ -497,7 +497,7 @@ def test_missing_python_file_reports_every_test_missing(vtf, tmp_path): def test_committed_pin_file_and_parity_agree(vtf): pin = vtf.load_pin() - assert pin["pin"] == {"tag": "chat@4.31.0", "sha": _PIN_SHA} + assert pin["pin"] == {"tag": "chat@4.41.1", "sha": _TARGET_SHA} assert pin["target"] == {"tag": "chat@4.41.1", "sha": _TARGET_SHA} assert vtf.check_pin_parity(pin["pin"]["tag"], vtf._read_upstream_parity()) is None @@ -671,9 +671,19 @@ def test_mapping_tiers_are_disjoint(vtf): # --------------------------------------------------------------------------- +def _use_wave_pin(vtf, tmp_path, monkeypatch): + """Point the script at a mid-wave pin file (pin != target), independent of + the committed ``upstream_pin.json``, with a matching ``UPSTREAM_PARITY``.""" + pin = {"pin": {"tag": "chat@4.31.0", "sha": _PIN_SHA}, "target": {"tag": "chat@4.41.1", "sha": _TARGET_SHA}} + path = _write(tmp_path / "upstream_pin.json", json.dumps(pin)) + monkeypatch.setattr(vtf, "PIN_PATH", path) + monkeypatch.setattr(vtf, "_read_upstream_parity", lambda: "4.31.0") + + @pytest.fixture def fake_upstream(vtf, tmp_path, monkeypatch): """A tiny TS tree + Python tree wired into the script's module globals.""" + _use_wave_pin(vtf, tmp_path, monkeypatch) ts_root = tmp_path / "ts" py_root = tmp_path / "py" _write( @@ -866,6 +876,7 @@ def test_strict_fails_when_pin_and_upstream_parity_disagree(vtf, fake_upstream, ids=["pin", "stale", "target"], ) def test_check_docs_cli_compares_docs_against_the_pin(vtf, tmp_path, monkeypatch, claude_md, code): + _use_wave_pin(vtf, tmp_path, monkeypatch) _write(tmp_path / "CLAUDE.md", claude_md) _write(tmp_path / "docs" / "UPSTREAM_SYNC.md", "pinned to `chat@4.31.0`\n") monkeypatch.setattr(vtf, "REPO_ROOT", tmp_path) From d70385c23bf8cfb392486ac4972c986b62eca7eb Mon Sep 17 00:00:00 2001 From: patrick-chinchill Date: Thu, 1 Oct 2026 05:15:31 -0700 Subject: [PATCH 2/3] docs(release): exact upstream refs for catalog rows, 0.4.31.x version rows, fuller migration checklist (#203) Address review: Gmail catalog listing is chat@4.41.1 and X/XChat entries are 4.33/4.36; split 0.4.31.1 and 0.4.31.3 version rows; record the #972/#964 test-prune and shared test-contract commits; soften the completeness claim; cite #230 for the Discord Components V2 deferral; add WhatsApp BSUID, Discord thread-id, Postgres reclaim and Teams lookup latency to the migration checklist. New test keeps both version-mapping tables in step. --- CHANGELOG.md | 4 +- docs/UPSTREAM_SYNC.md | 25 +++++++----- tests/test_version_mapping_docs.py | 65 ++++++++++++++++++++++++++++++ 3 files changed, 83 insertions(+), 11 deletions(-) create mode 100644 tests/test_version_mapping_docs.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 11b786c0..c3336a83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,8 @@ Each item names the wave issues; their entries under "Wave PRs" below have the d - **AI tools (#195, #198, security).** Agent tools are confined to the conversation being handled (`scope=False` for workspace-wide access); `getUser` needs approval by default; link metadata is fenced as untrusted. `to_ai_messages` keeps attachment/link-only messages; `ChatTool.name`. - **Download caps (#204 and adopters #213, #218, #225, #229, #234, #235, #239).** Attachment downloads go through the shared guarded downloader: HTTPS only, internal addresses refused (including after redirects), **25 MB** decoded cap, **30 s** deadline, failures raise `NetworkError`. Slack file downloads no longer honor `HTTPS_PROXY`/`HTTP_PROXY` (set `file_transport`). Messenger, WhatsApp and Twilio narrow their media host/origin allowlists. - **Webhook authentication (security).** Telegram requires a webhook secret by default (#224; `allow_unverified_webhooks` escape hatch). Google Chat JWT verification is bound to configured identities; Pub/Sub and Workspace Add-on deployments must set the new service-account emails, and `bot_user_id` should be set (#222, #223). Slack caches are installation-scoped and Enterprise Grid org-wide installs work (#205, #268). Teams gains a custom token factory and `webhook_verifier` (#221). -- **Thread and channel ids.** Linear agent sessions use one stable thread `linear:{issue}:s:{session}` (#232; re-subscribe old ids). Twilio `channel_id` equals the thread id (#235). Teams group chats carry a conversation-type segment and are no longer DMs; `TeamsAdapter.bot_user_id` is `28:{app_id}` (#216, #217). Slack `channel.post()` returns a replyable `slack:C…:` (#283). Discord validates a thread's parent channel (#229). +- **Thread and channel ids.** Linear agent sessions use one stable thread `linear:{issue}:s:{session}` (#232; re-subscribe old ids). Twilio `channel_id` equals the thread id (#235). Teams group chats carry a conversation-type segment and are no longer DMs; `TeamsAdapter.bot_user_id` is `28:{app_id}` (#216, #217). Slack `channel.post()` returns a replyable `slack:C…:` (#283). Discord validates a thread's parent channel, and a thread interaction without `parent_id` encodes as `discord:{guild}:{thread}` (was `discord:{guild}:{thread}:{thread}`) (#229). WhatsApp `Author.user_id` and the thread's user segment may be a BSUID (e.g. `US.1349…`) for users without a shared phone number, and WhatsApp writes `whatsapp:identity:alias:*` / `whatsapp:identity:route:*` state keys (#236). +- **State and dispatch latency.** Postgres `set_if_not_exists` now reclaims an expired row, so Postgres-backed dedupe keys and leases recover without cleanup (#240). Teams looks up the sender's email before dispatch: on a cache miss that is one awaited network call, bounded at 5 s, cached for 1 h (#218). - **New lifecycle and API surface (additive).** `on_message_updated` / `on_message_deleted`, `on_installed` / `on_uninstalled`, app-context and agent-session events (#196, #201, #211, #215, #217); `chat.history` (#197); `Thread.reply` / `Thread.mark_as_read` (#200); turn cancellation (`chat.abort_turn`, `thread.signal`, #201); Slack `agent_view` and Agent Sessions (#214, #215); cards `Chart`, table options, tooltips, `DateInput` / `NumberInput` (#202, #212, #220); Telegram albums and media groups (#227, #278); WhatsApp templates, media and read receipts (#237, #238, #239). - **Dependencies.** The `[teams]` extra allows `microsoft-teams-{apps,api,cards,common}` `>=2.0.13,<2.2` (#250). @@ -35,6 +36,7 @@ The maintainer runs these against a live workspace before tagging and publishing - `scripts/upstream_pin.json` `pin` → `chat@4.41.1` @ `f690af3c26a3bd7649387cccad12d215463ed22c` (same as `target`). CI's strict fidelity step clones that tag and checks the SHA. `UPSTREAM_PARITY = "4.41.1"`, `pyproject.toml` version `0.4.41`, README status line, and both version-mapping tables (CLAUDE.md, `docs/UPSTREAM_SYNC.md`). - Strict tier: the eight test files upstream added in 4.34–4.41, all at 0 missing, move from `TARGET_MAPPING` to `MAPPING` (922/922 at the pin). `fidelity_baseline.json` is regenerated for `ts_parity: chat@4.41.1` (still empty). `cards` / `modals` / `emoji` / `message` stay report-only in `TARGET_MAPPING` (#78). - `docs/UPSTREAM_SYNC.md`: new "Skipped upstream changes (4.41 wave)" and "Deferred platforms and adapter modes (#189)" tables; the `chat/adapters` catalog row is re-verified at `chat@4.41.1` (34 entries; Dial, Cloudflare Agents, Photon, Linq, Novu, Gmail, Instagram, Notion, X and XChat added since 4.31.0). + - `tests/test_version_mapping_docs.py` keeps the CLAUDE.md and `docs/UPSTREAM_SYNC.md` version-mapping tables in step (same releases, same upstream versions) and ties the `pyproject.toml` version to `UPSTREAM_PARITY`. - **Slack inbound mrkdwn normalization, `channel.post()` thread ids, Socket Mode retries** (#283, part (b) of #209; ports vercel/chat `0b63791b` #667 (Slack half), `92530dd3` #720, `c3118279` #756, `e71bfead` #843 (Slack half) and `44423bdc` #960, chat@4.33.0–4.41.1). A live Slack-loop check is pending. - **Breaking/consumer-visible (`message.text` is the plain text of `message.formatted`):** both parse paths now set `text = ast_to_plain_text(formatted)` (upstream `toPlainText(formatted)`), replacing the regex pass over the mrkdwn. Markup no longer leaks into `text`: inline-code backticks, list markers (`- a` → `a`, `1. one` → `one`), heading `#` and quote `>` prefixes are dropped, a trailing newline or a whitespace-only body is trimmed, and blocks are separated by a blank line. `SlackFormatConverter.extract_plain_text` drops its Python-only regex override for the base `to_ast` + `ast_to_plain_text` (upstream has none). *Migration:* match structure on `message.formatted`, or on `message.raw["text"]` for the original mrkdwn. diff --git a/docs/UPSTREAM_SYNC.md b/docs/UPSTREAM_SYNC.md index ae5e6d48..1a70304c 100644 --- a/docs/UPSTREAM_SYNC.md +++ b/docs/UPSTREAM_SYNC.md @@ -18,7 +18,8 @@ Our version embeds the upstream Vercel Chat version: `0.{upstream_major}.{upstre | `0.4.29` | `4.29.0` | Synced to upstream 4.29.0 (upstream never tagged `chat@4.27.0`/`chat@4.28.0`) | | `0.4.30` | `4.30.0` | Synced to upstream 4.30.0 (Teams adapter migrated to the `microsoft-teams-apps` SDK, issue #93) | | `0.4.31` | `4.31.0` | Synced to upstream 4.31.0 | -| `0.4.31.3` | `4.31.0` | Python-only fixes on top of 4.31.0 (`0.4.31.1` Slack #138/#95; `0.4.31.2` tagged but never published) | +| `0.4.31.1` | `4.31.0` | Python-only fixes on top of 4.31.0 (Slack DM-fetch #138, Enterprise Grid `startStream` #95) | +| `0.4.31.3` | `4.31.0` | Python-only fixes on top of 4.31.0 (Teams Graph SSRF-guard hardening #178, NOTICE #179); same content as the never-published `0.4.31.2` tag | | `0.4.41` | `4.41.1` | Synced to upstream 4.41.1 (the 4.41 wave, #184). `UPSTREAM_PARITY = "4.41.1"`, not `4.41.0`: it names the exact strict fidelity pin tag, which carries the 4.41.1 fixes ported in the wave (#199, #231) | The `UPSTREAM_PARITY` constant in `chat_sdk/__init__.py` provides programmatic access @@ -1988,7 +1989,7 @@ part of `0fdb9029` (#678), `26c05225` (#715), the slash-flags part of `setThreadTitle`) does nothing without a thread segment; otherwise it validates the parent (`_resolve_thread_channel_id`, #229) and sends `PATCH /channels/{thread}` `{"name": title}`. -- **Deferred to #189:** the Components V2 card renderer from `0fdb9029` +- **Deferred (demand-gated, decided here):** the Components V2 card renderer from `0fdb9029` (`content_format` / `DiscordContentFormat`, V2 select rendering, and the 40-component / 4000-character validation). Cards still render as embeds. Not applicable (#57): the discord.js listener halves of `5341f909` and @@ -3354,7 +3355,7 @@ stay explicit instead of being rediscovered in code review. | Linear agent-activity emit: raw GraphQL (chat@4.31 / #151 — L4) | The agent-session EMIT path (`post_message` session branch, `start_typing` session branch, `stream` → `_stream_in_agent_session` with its flush/`task_update`/`plan_update` logic, and `_parse_message_from_agent_activity`) is ported as **raw GraphQL mutations** over the existing `_graphql_query` helper, **schema-hardened against Linear's published GraphQL schema**. Mutation names: `agentActivityCreate(input: AgentActivityCreateInput!)` and `agentSessionUpdate(id: String!, input: AgentSessionUpdateInput!)`. `content` is sent inside the `AgentActivityCreateInput.content` **`JSONObject!`** scalar — so `type`/`body`/`action`/`parameter`/`result` are inline JSON fields, with the **lowercase** `AgentActivityType` enum values `"response"` / `"thought"` / `"error"` / `"action"` (confirmed lowercase, NOT PascalCase). `ephemeral: Boolean` is a sibling of `content` and is **included only when set** (absent — not `false` — on response/thought/error). The `agentActivityCreate` return selection requests only schema-valid fields (`success`, `agentActivity { id agentSession { id } sourceComment { id body parentId createdAt updatedAt url user{…} botActor{…} } }`) — `agentSessionId` is **not** a scalar field on Linear's `AgentActivity` type (the schema exposes only the relation `agentSession: AgentSession!`), so the session id is read off the nested `agentSession { id }` relation; requesting the non-existent scalar would server-reject the whole mutation under GraphQL strict selection validation. `plan` items are `{content, status:"completed"}`. `initialize` additionally captures the viewer's `organization.id` into `_default_organization_id` (mirroring upstream's `defaultOrganizationId`) for the emitted raw message's `organizationId`; on the emit path `organizationId` falls back to `""` when `_default_organization_id` is unset (no per-request installation context is plumbed — a pre-existing adapter-wide divergence from upstream, which throws `AuthenticationError` when no organization is resolvable). | Upstream `adapter-linear/src/index.ts` calls `@linear/sdk`'s `createAgentActivity({agentSessionId, content, ephemeral?})` / `updateAgentSession(id, {plan})`; the SDK owns the GraphQL document, the `AgentActivityType` enum, and the `AgentActivityPayload`/`Comment`/`sourceComment` resolution | `@linear/sdk` is TypeScript-only (no official Linear Python SDK — cf. the `linear_client` getter row), so the SDK calls are reproduced as raw GraphQL. Mutation names, the `content: JSONObject!` shape, the lowercase enum casing, and the `plan` item shape are **schema-hardened against the published schema** (`https://linear.app/developers/agent-interaction`, `https://linear.app/developers/graphql`, and the SDK's generated GraphQL documents). **Live-tenant verification pending**: the exact mutation/field names and enum casing are confirmed against the published schema/docs but have **not** been exercised against a live Linear agent-session tenant; if a future live run surfaces a casing/field mismatch (e.g. an enum the schema renders differently at runtime), update the mutation strings here. Faithful-port hazards preserved: `status ?? "Thinking..."` → `is not None` (an empty status stays `""`); `[title, output].filter(Boolean).join("\n")` → `"\n".join(x for x in [title, output] if x)` (drops `None` and `""`); `markdown.slice(...).trim()` uses the JS-`.trim()` whitespace set (`_JS_WHITESPACE`, mirroring `adapters/telegram/rich.py`), not Python's broader `str.strip()`; `if delta or force`; `ephemeral: status != "complete"`; the missing-final-flush bare `throw new Error(...)` → `RuntimeError`. **Thread ids (chat@4.40.0 / vercel/chat#885, #232):** the emitted raw message carries the stable session thread `linear:{issueId}:s:{agentSessionId}`, as upstream's `parseMessage` encodes it; the source comment's id is the message id only. A stored pre-#885 `linear:{i}:c:{c}:s:{s}` thread id still decodes to the same session and posts there. Regression coverage: `tests/test_linear_agent_session_emit.py`. | | Linear comment-thread fetch: root-first ownership check, `children` connection (chat@4.41.1 / vercel/chat#965 — #231) | `_fetch_comment_thread` issues **two** raw GraphQL queries in order: `CommentThreadRoot` (`comment(id:)` plus the nullable scalar `issueId` — the same field the SDK's `comment` document selects; unlike `AgentSession`, `Comment` does expose it in the published schema), then, only once the root's issue id is present and equal to the thread's, `CommentThreadChildren` = `comment(id:) { children(first:, last:) { nodes pageInfo } }` (`forward` → `first`, otherwise `last`, default 50). A missing or foreign issue raises `ValidationError("linear", "Comment does not belong to this issue")` with no children query and no parse; messages are built with the validated root issue id. A `null` root (raw-GraphQL not-found) still returns an empty result without a children query. Fetched messages keep the requested (fixed) thread id, as before. | `linear.comment({ id })` then `linear.comments({ filter: { parent: { id: { eq: commentId } } }, first|last })` (the root `comments` connection); the SDK throws on a missing root; `commentsToMessages` encodes each comment's own id in its thread id. | Query shape only: `Comment.children` and the parent-filtered root `comments` connection return the same replies with the same `first`/`last` pagination, and `children` was the port's existing, already-exercised selection, so keeping it avoids a second query document for the same data. Regression: `tests/test_linear_extended.py::TestFetchMessages::test_fetches_same_issue_comment_thread_in_order` pins the `children` variables (`commentId` + `first`/`last`, no `filter`) for `forward`, `backward` and the default (no direction → `last`). The fixed thread id and empty-on-null-root are pre-existing port behavior, not introduced here. | | Linear agent-session fetch: raw GraphQL (chat@4.31 / #151 — L5) | The agent-session FETCH/read path (`fetch_messages` session dispatch → `_fetch_agent_session_messages`, plus the append-only guards on `edit_message`/`delete_message` and the `agentSessionId` key in `fetch_thread` metadata) is ported as **raw GraphQL queries** over the existing `_graphql_query` helper, **schema-hardened against Linear's published GraphQL schema** (`linear/packages/sdk/src/schema.graphql` @ master). Two queries: (1) `agentSession(id: String!): AgentSession!` selecting `id`, `url`, `issue { id }`, and the nullable `comment { id body parentId createdAt updatedAt url user{…} botActor{…} }` root relation; (2) `comments(filter: CommentFilter, first: Int, last: Int): CommentConnection!` filtered by `{parent: {id: {eq: rootComment.id}}}` for the children, selecting the same `Comment` sub-fields + `pageInfo { hasNextPage endCursor }`. Upstream passes ONLY `first`/`last` here — it never reads `options.cursor` — and the sibling `_fetch_issue_comments`/`_fetch_comment_thread` paths forward no cursor either, so no inbound `after` is plumbed (only `next_cursor` is RETURNED, off `pageInfo.endCursor`). **CRITICAL schema-hardening: `AgentSession` has NO scalar `issueId` field in the published schema** (it exposes only the `issue: Issue` relation alongside `comment`/`sourceComment`/`id`); upstream's `agentSession.issueId` works because `@linear/sdk`'s model derives it from the serialized object, but in raw GraphQL requesting a non-existent `issueId` field would server-reject the whole query (the L4 blocking-bug class). So the issue id is read off the `issue { id }` relation — equivalent to upstream's `agentSession.issueId`. **Ownership check (chat@4.41.1, vercel/chat#974, #231):** when that id is missing (`None`/`""`) or differs from the thread's issue id, `ValidationError("linear", "Agent session does not belong to this issue")` is raised before the root `comment` is read and before the children query — there is **no** `thread.issue_id` fallback (the pre-4.41 `agentSession.issueId ?? thread.issueId` let a thread id naming issue A read a session on issue B). The session query still selects the root `comment` in the same request (upstream lazy-loads it); the content is discarded unread on a failed check. Pagination is direction-driven (`forward` → `first`, otherwise `last`, default limit 50); `next_cursor = endCursor if hasNextPage else None`. Each of `[rootComment, *children.nodes]` is parsed via the upstream `parseMessageFromComment(comment, issueId, agentSession.id)` semantics — reusing L4's `_raw_message_from_source_comment` (user-vs-`botActor` author resolution) + `_parse_agent_session_message` (the `parseMessage` agent-session branch), so every message carries the **stable session thread id** `linear:{issueId}:s:{agentSessionId}` (chat@4.40.0 / vercel/chat#885, #232; it used to encode each comment's own id as `…:c:{comment.id}:s:…`) and `is_mention=True`. **Rootless sessions (#232):** when the session has no root `comment`, history comes from a third query, `AgentSessionActivities` = `agentSession(id:) { activities(first:, after:, last:, before:) { nodes pageInfo } }` (`_fetch_agent_session_activities`, upstream `fetchAgentSessionActivities`), instead of raising. `forward` sends `first` + `after` (the inbound cursor), otherwise `last` + `before`, default 50; an unset or empty cursor is omitted, and only the variables upstream passes are sent. Unlike the children path, the inbound cursor IS forwarded here, as upstream does. Nodes are sorted by parsed `createdAt` and rendered by `_render_activity` (upstream `renderActivity`): `body`, or `"{action}: {parameter}"` plus `"\n{result}"`, trimmed with the JS `.trim()` set (`_JS_WHITESPACE`), an action that is empty after trimming rendering as `"Action"`. Prompt activities are authored as the user, all others as the bot (`user.id ?? (bot ? botUserId : "unknown")`, name fallback `userName`/`"unknown"`). Message id is `sourceComment.id ?? activity.id`. `next_cursor` is `endCursor` (forward, `hasNextPage`) or `startCursor` (backward, `hasPreviousPage`). Schema check for the activities query: `AgentActivity` has **no** scalar `sourceCommentId` or `userId` (the SDK derives both from the `sourceComment: Comment` and `user: User!` relations), so the query selects `sourceComment { id }` and `user { id displayName name email avatarUrl }` inline, one query with no per-activity follow-ups. `content` is the `AgentActivityContent` union, selected with inline fragments on `AgentActivityPromptContent`, `…ResponseContent`, `…ThoughtContent`, `…ErrorContent`, `…ElicitationContent` (`type body`) and `AgentActivityActionContent` (`type action parameter result`); `PageInfo` has all four of `hasNextPage hasPreviousPage endCursor startCursor`. The live-tenant caveat in this row applies to it too. `edit_message`/`delete_message` raise `AdapterError` with the exact upstream strings ("…append-only and cannot be edited" / "…cannot be deleted") for session threads, before any network call. | Upstream `adapter-linear/src/index.ts` calls `@linear/sdk`'s `linear.agentSession(id)` (lazy-resolving `issueId` + the `comment` relation off the SDK model) and `linear.comments({filter, first/last})`; the SDK owns the GraphQL documents and the `Comment`/author resolution. | `@linear/sdk` is TypeScript-only (no official Linear Python SDK — cf. the `linear_client` getter row), so the SDK calls are reproduced as raw GraphQL. Query names, the `agentSession(id)` shape, the root `comments(filter: CommentFilter, first/last)` connection, the `CommentFilter.parent → NullableCommentFilter.id → IDComparator.eq` chain, and every selected `AgentSession`/`Comment`/`User`/`ActorBot`/`PageInfo` field were each **verified field-by-field against the published `schema.graphql`** (this is how the absence of a scalar `AgentSession.issueId` was caught). **Live-tenant verification pending**: the query/field names are confirmed against the published schema but have **not** been exercised against a live Linear agent-session tenant; if a future live run surfaces a field mismatch, update the query strings here. Hazards: the ownership check is deliberately truthy (`not issue_id or issue_id != thread.issue_id`, mirroring upstream's `!issueId`, so `""` never matches `""`); `endCursor ?? undefined` → `is not None`. Regression coverage: `tests/test_linear_agent_session_fetch.py`. | -| `chat/adapters` static adapter catalog (chat@4.31.0, new `./adapters` package subpath; **re-verified at chat@4.41.1**, #203) | Not ported | A new SDK-free metadata module (`packages/chat/src/adapters/index.ts`, 19 `test()` cases at both tags): types `EnvVar`/`EnvGroup`/`AdapterEnvSpec`/`CatalogAdapter`, an `ADAPTERS` registry of official + vendor-official adapters (24 entries at chat@4.31.0; 34 at chat@4.41.1, 20 official and 14 vendor-official, after vendor-official Linq `d034b8b5` and Novu `06af3e12` (chat@4.32.0), Dial `0c761f1b`, Cloudflare Agents `3abdc691` and Photon `24a04d56` (chat@4.33.0), and official Instagram, Notion, X, XChat and Gmail `f034cd27` (chat@4.37.0–4.41.0); no entry removed), and exports `ADAPTER_NAMES`/`AdapterSlug`/`getAdapter`/`isAdapterSlug`/`listPlatformAdapters`/`listStateAdapters`/`listEnvVars`/`getSecretEnvVars`. Imports no provider SDK, so it's safe for build scripts, onboarding/setup screens, and config-discovery UIs that need package + env-var metadata (incl. secret-masking flags) without loading an adapter. | **Not meaningfully portable verbatim, and not yet needed.** The catalog's spine is TypeScript-ecosystem-specific: every entry is addressed by an **npm `packageName`** (`@chat-adapter/slack`, `@kapso/chat-adapter`, …) and the registry includes adapters **this Python SDK does not ship**: all 14 vendor-official ones (AgentPhone, Cloudflare Agents, Dial, Kapso, Lark/Feishu, Linq, Liveblocks, Beeper Matrix, Novu, Photon, Resend, Sendblue, Velt, Zernio) plus the official ioredis, web, Instagram, Notion, X, XChat and Gmail entries (the last five are deferred, see #189 / #241). A 1:1 port would ship actively-misleading data to a `pip`-installed consumer (`@chat-adapter/slack` instead of `chat-sdk[slack]`; `get_adapter("kapso")` returning metadata for something uninstallable here). The only Python-applicable slice — per-platform env-var requirements + secret flags — is the same across the 10 platform + 3 state adapters we do ship, but exposing it would be a **new Python-native feature** (a divergent rewrite around `pip` extras, not a port), and no current consumer (chinchill-api configures adapters in code, not via env-var discovery) needs it. Nothing in chat-sdk's core depends on the catalog. Deferred demand-driven: a Python-native `adapters` catalog (our adapters only, `pip`-extra install names, `get_secret_env_vars` masking) can be designed against real requirements if/when a consumer needs config discovery. The unmapped test file is additionally covered by the issue #78 fidelity-scope note. | +| `chat/adapters` static adapter catalog (chat@4.31.0, new `./adapters` package subpath; **re-verified at chat@4.41.1**, #203) | Not ported | A new SDK-free metadata module (`packages/chat/src/adapters/index.ts`, 19 `test()` cases at both tags): types `EnvVar`/`EnvGroup`/`AdapterEnvSpec`/`CatalogAdapter`, an `ADAPTERS` registry of official + vendor-official adapters (24 entries at chat@4.31.0; 34 at chat@4.41.1, 20 official and 14 vendor-official, after vendor-official Linq `d034b8b5` and Novu `06af3e12` (chat@4.32.0), Dial `0c761f1b`, Cloudflare Agents `3abdc691` and Photon `24a04d56` (chat@4.33.0), and official X `ef2542c5` (chat@4.33.0) and XChat `caa63253` (chat@4.36.0), Instagram `2a2b2c55` and Notion `0ec6a736` (chat@4.37.0), and Gmail `f034cd27` (chat@4.41.1); no entry removed), and exports `ADAPTER_NAMES`/`AdapterSlug`/`getAdapter`/`isAdapterSlug`/`listPlatformAdapters`/`listStateAdapters`/`listEnvVars`/`getSecretEnvVars`. Imports no provider SDK, so it's safe for build scripts, onboarding/setup screens, and config-discovery UIs that need package + env-var metadata (incl. secret-masking flags) without loading an adapter. | **Not meaningfully portable verbatim, and not yet needed.** The catalog's spine is TypeScript-ecosystem-specific: every entry is addressed by an **npm `packageName`** (`@chat-adapter/slack`, `@kapso/chat-adapter`, …) and the registry includes adapters **this Python SDK does not ship**: all 14 vendor-official ones (AgentPhone, Cloudflare Agents, Dial, Kapso, Lark/Feishu, Linq, Liveblocks, Beeper Matrix, Novu, Photon, Resend, Sendblue, Velt, Zernio) plus the official ioredis, web, Instagram, Notion, X, XChat and Gmail entries (the last five are deferred, see #189 / #241). A 1:1 port would ship actively-misleading data to a `pip`-installed consumer (`@chat-adapter/slack` instead of `chat-sdk[slack]`; `get_adapter("kapso")` returning metadata for something uninstallable here). The only Python-applicable slice — per-platform env-var requirements + secret flags — is the same across the 10 platform + 3 state adapters we do ship, but exposing it would be a **new Python-native feature** (a divergent rewrite around `pip` extras, not a port), and no current consumer (chinchill-api configures adapters in code, not via env-var discovery) needs it. Nothing in chat-sdk's core depends on the catalog. Deferred demand-driven: a Python-native `adapters` catalog (our adapters only, `pip`-extra install names, `get_secret_env_vars` masking) can be designed against real requirements if/when a consumer needs config discovery. The unmapped test file is additionally covered by the issue #78 fidelity-scope note. | | `GoogleChatAdapterConfig` identity fields (#222; upstream `7a192235` / `c3b5a08e`) | `workspace_add_on_service_account_email` and `pubsub_service_account_email` are declared `field(default=None, kw_only=True)` after `disable_signature_verification`, which stays the last *positional* field. Passing an 11th positional argument raises `TypeError` | Optional properties on an options object (`workspaceAddOnServiceAccountEmail`, `pubsubServiceAccountEmail`), which have no positional order | `GoogleChatAdapterConfig` is a positional dataclass. Adding the fields positionally would shift existing callers' arguments, and a shifted argument could land in an identity field the verifier trusts. Pinned by `TestDisableSignatureVerificationFieldOrder` in `tests/test_gchat_verification.py`. #223 adds `bot_user_id` the same way | | Google Chat webhook JWT verification mechanics (#222; upstream `270b1c25` / `7a192235` / `c3b5a08e`) | OIDC tokens (endpoint-URL and Pub/Sub) are decoded with PyJWT against Google's JWKS (`oauth2/v3/certs`): RS256, strict `aud` equality, `exp`/`iat`/`iss` required, 300 s leeway. `iss` is then checked by hand against {`accounts.google.com`, `https://accounts.google.com`}. Project-number tokens are decoded against the Chat issuer's X.509 certs with the same PyJWT options, and `iss` is then compared by hand with `chat@system.gserviceaccount.com` (not PyJWT's `issuer=`, which PyJWT 2.10.0 matched as a substring, CVE-2024-53861). On both paths, a token whose `exp` is 24 h or more ahead of the wall clock is rejected by hand, because PyJWT has no maximum-lifetime check. Both key sets are fetched with the shared aiohttp session and cached as `(fetched_at, {kid: key})` for a fixed 1 hour on an injectable monotonic clock. A failed or empty fetch is never cached, and an unknown `kid` is rejected without a refetch | `OAuth2Client.verifyIdToken` (federated certs, cached per `Cache-Control`) and `verifySignedJwtWithCertsAsync` with the Chat certs, cached for 1 hour | No maintained async google-auth equivalent exists, and the old `PyJWKClient` did blocking I/O on the event loop. The claim checks are ported to match google-auth-library, including its `exp >= now + 86400` rejection (`DEFAULT_MAX_TOKEN_LIFETIME_SECS_`), and `email_verified` checked by identity (`is True`) and add-on emails compared with `==` and shape-tested with `re.fullmatch`. Google's keys are published hours before use (`max-age` is about 6 h), so a 1-hour TTL does not miss rotations. Pinned by `TestEndpointUrlVerification`, `TestProjectNumberVerification`, `TestDirectTokenTimeClaims` and `TestVerificationKeyCache` | | Google Chat media `resourceName` validation (#223; upstream `32687038`) | `fetch_data` (fresh and `rehydrate_attachment`) runs `_validate_media_resource_name` before minting a token: a non-string or empty value, `?`, `#`, `%`, backslash, anything outside printable ASCII (whitespace, control and non-ASCII characters), or a `.` / `..` path segment raise `ValidationError("gchat", "Invalid Google Chat attachment resource name: ...")`. Anything else is accepted, since `resourceName` is opaque. The value is then interpolated into `https://chat.googleapis.com/v1/media/{resourceName}?alt=media` | Passes `resourceName` to the googleapis client's `media.download`, which encodes it; no shape check | The Python port builds the URL itself, and `resourceName` can come from serialized `fetch_metadata`. Without the check a tampered value could add query parameters, or `..` segments (which the URL layer normalizes) could point the service-account token at another `/v1/` endpoint. Media errors go through `_handle_google_chat_error(error, "fetchAttachmentData")` as upstream (429 → `AdapterRateLimitError`, otherwise the `_GoogleApiError` carrying the HTTP status is re-raised). Pinned by `tests/test_google_chat_adapter.py::TestRehydrateAttachment::test_rehydrated_fetch_data_rejects_unsafe_resource_names` | @@ -3375,7 +3376,7 @@ stay explicit instead of being rediscovered in code review. | Discord Gateway WebSocket | HTTP interactions only | Both HTTP and Gateway | Gateway requires persistent connection | | Discord preview-suppressed links (4.41 wave, #229) | `DiscordFormatConverter` recognizes `[text]()` from the parsed destination: `<…>` delimiters are stripped from `url` and `data["discordLinkStyle"] = "suppressed-masked-link"` is set when the bracketed URL is http(s) and the link has no title. `` stays a text node (the Python parser has no autolinks), so its brackets render verbatim and `Message.text` keeps them | `markDiscordLinkStyles` reads each link node's source span (mdast `position`) and marks both `` (an autolink node, plain text `https://…`) and `[text]()` | Rendered output is identical for both forms (`tests/test_discord_format.py::TestFromAst::test_should_preserve_angle_brackets_on_an_autolink_to_suppress_its_embed`, `::test_should_preserve_angle_brackets_in_a_masked_link_destination`). The Python parser keeps no source positions; autolink parsing is a core-parser limitation (CLAUDE.md Known Limitations), not a Discord change. `from_ast` still honors `suppressed-autolink` on an AST that carries it. Regression test: `::test_suppressed_masked_link_url_has_no_angle_brackets_in_the_ast`. | | Discord `interaction_flags` callback errors (4.41 wave, #230) | A callback that raises, or returns something other than an `int` or `None` (an `async def` callback's coroutine, which is closed rather than left un-awaited, or a `bool`), is logged (`"Discord interaction_flags callback failed; deferring without flags"`, `{error, command}`) and the command is deferred and dispatched without flags | `getInteractionFlags` lets the exception propagate, so the HTTP interaction gets no ACK (Discord shows "The application did not respond") and the gateway path skips `deferReply` | A config callback bug should not make every slash command fail. Breadcrumb at `DiscordAdapter._get_interaction_flags`; regression tests `tests/test_discord_extended.py::TestInteractionFlags::test_raising_flags_callback_still_acks_without_flags` and `::test_async_or_non_int_flags_callback_still_acks_without_flags`. | -| Discord Components V2 (`contentFormat`, upstream `0fdb9029`, chat@4.34; #230) | Not ported; cards render as embeds + action rows only, and no 40-component / 4000-character V2 validation runs | `DiscordContentFormat.ComponentsV2` renders cards as V2 components, including string selects | Deferred to #189. Select-menu **inbound** values (`data.values[0]`) are ported, so V2 selects sent by other code still report their choice. | +| Discord Components V2 (`contentFormat`, upstream `0fdb9029`, chat@4.34; #230) | Not ported; cards render as embeds + action rows only, and no 40-component / 4000-character V2 validation runs | `DiscordContentFormat.ComponentsV2` renders cards as V2 components, including string selects | Deferred by #230 (demand-gated; not yet listed in the #189 backlog body). Select-menu **inbound** values (`data.values[0]`) are ported, so V2 selects sent by other code still report their choice. | | Discord gateway-only interactions (vercel/chat#490) | Handled on the forwarded-event surface: a `GATEWAY_INTERACTION_CREATE` envelope (raw INTERACTION_CREATE dispatch payload in `data`) is deferred via `POST /interactions/{id}/{token}/callback` (type 5 slash / type 6 component) and routed through the existing HTTP interaction handlers; a malformed forward missing `id`/`token` is logged and skipped | Upstream handles `Events.InteractionCreate` directly on the resident discord.js client via `deferReply()`/`deferUpdate()`; the upstream forwarder never forwards interactions | Python has no resident Gateway client (row above), so gateway-only deployments run an external listener shim that forwards raw dispatch payloads (`x-discord-gateway-token`). Observable wire behavior is identical — same callback REST calls, same handler routing, same `@original` deferred-response resolution. | | Teams `User-Agent: Vercel.ChatSDK` outbound header | Not set on `aiohttp` calls | Propagated by `botbuilder` 2.0.8 | Python Teams adapter doesn't use `botbuilder` (raw `aiohttp`). Upstream's vercel/chat#415 was a JS-only `botbuilder` SDK bump that flipped `X-User-Agent` → `User-Agent`. No equivalent dependency to bump on the Python side. Setting a `User-Agent` on the ~9 outbound `aiohttp` call sites would be a defense-in-depth nice-to-have; deferred to a follow-up. | | Teams adapter on `microsoft-teams-apps` (official MS Python SDK) | Inbound webhook + JWT auth, outbound send/edit/delete/typing, and native DM streaming all delegate to the official `microsoft-teams-apps` SDK `App`; Graph reads stay hand-rolled over `aiohttp` | `@microsoft/teams.apps` owns the wire format, throttling, and activity routing | **Delivered in 0.4.30** (issue #93, PRs 1–4). The migration shipped as four PRs: inbound + auth (#143), outbound (#144), native streaming via the SDK `IStreamer` (#145), and this release cut. The 3.12 floor bump (#111) — the migration's prerequisite — landed in 0.4.29. The residual adapter-level divergences (we keep the SDK as auth + transport but route the authenticated activity ourselves; close the streamer in our own `finally` because our bridge owns dispatch) are documented in the Teams divergence rows above. Graph stays hand-rolled (no `msgraph-sdk` / `[graph]` extra). | @@ -3392,9 +3393,10 @@ stay explicit instead of being rediscovered in code review. ### Skipped upstream changes (4.41 wave) Upstream changes in `chat@4.32.0`–`chat@4.41.1` that the 4.41 wave (#184) -deliberately does not port, recorded by #203. Every other 4.32–4.41.1 change -was ported by a wave PR (its own section above) or is deferred in the next -table. +deliberately does not port, recorded by #203. Every other 4.32–4.41.1 runtime +change was ported by a wave PR (its own section above) or is deferred in the +next table. Release (`version packages`), dependency-bump, CI, docs-site, +example-app and repo-governance commits are not listed individually. | Area (upstream ref) | Python behavior | TS behavior | Rationale | |------|----------------|-------------|-----------| @@ -3404,10 +3406,13 @@ table. | Workflow serialization entrypoint (`bdeb2bf1`, vercel/chat#806, chat@4.38.0) | Not applicable. `Message`, `ThreadImpl` and `ChannelImpl` serialize through `to_json()` / `from_json()` / `chat.reviver()` and import no Node-only module. | New `chat/serialization` package entry and a tsup chunk split, so a Vercel Workflow bundle can register the `@workflow/serde` classes without pulling `node:async_hooks`. | A JavaScript bundler and package-export fix. Python has no bundle and no `@workflow/serde` registration step. | | Vercel Connect (`ab0e1806` #647, `ba687cb1` #648, chat@4.33.0; `1136c967` #932, chat@4.41.0) | Not ported as a feature. The Slack adapter already accepts the same building blocks: a callable `bot_token` resolver and a `webhook_verifier` (rows above). The Teams token factory and `webhook_verifier` (#221) are the Teams equivalent. | Docs and wiring for the external `@vercel/connect` package, which supplies Vercel-hosted credentials and webhook verification to adapters. | `@vercel/connect` is a JavaScript package tied to Vercel's hosting platform. The per-adapter Connect hooks (Linear, Discord, Telegram, Notion, GitHub) are deferred in the next table. | | `create-chat-sdk` scaffolder (`ba375ce1` #655, chat@4.33.0; `927d0dbd` #804, chat@4.38.0) | Not ported. | `npx create-chat-sdk` project generator (now with a Vercel Connect mode) and its docs page. | An npm project generator for TypeScript bots. A Python template would be a separate tool, and no consumer has asked for one. | -| Adapter catalog entries (`0c761f1b` #676, `3abdc691` #669, `24a04d56` #668, chat@4.33.0; `d034b8b5` #625, `06af3e12` #622, chat@4.32.0; `f034cd27` #958, chat@4.41.0) | Not ported (no catalog). | New `ADAPTERS` entries: vendor-official Dial, Cloudflare Agents (state), Photon, Linq and Novu, and the official Gmail listing. | Covered by the "`chat/adapters` static adapter catalog" row above, re-verified at `chat@4.41.1`. | +| Adapter catalog entries (`0c761f1b` #676, `3abdc691` #669, `24a04d56` #668, chat@4.33.0; `d034b8b5` #625, `06af3e12` #622, chat@4.32.0; `f034cd27` #958, chat@4.41.1) | Not ported (no catalog). | New `ADAPTERS` entries: vendor-official Dial, Cloudflare Agents (state), Photon, Linq and Novu, and the official Gmail listing. (The official X, XChat, Instagram and Notion entries land with their adapter commits, deferred in the next table.) | Covered by the "`chat/adapters` static adapter catalog" row above, re-verified at `chat@4.41.1`. | | Docs, community adapters and repo chore (`86b2fa4b` #970, chat@4.41.1; `1136c967` #932, chat@4.41.0; `f691ad58` #873, chat@4.40.0; `0642ce33` #772, chat@4.37.0; `a8867a0a` #753, chat@4.36.0; `1dff4515` #686, `b3123815` #687, chat@4.34.0; `eb466e52` #677, chat@4.33.0; `ef3f0f63` #638, `99c59850` #646, `efa96108` #635, `64b66864` #626, chat@4.32.0) | No change. | Docs-site migration and copy edits, community-adapter listings (LINE, QQ Bot, WeCom, zaileys, Weixin), the WhatsApp typing-indicator doc, the `xai/grok-4.5` model rename in examples, the KB sync script and a changesets rule for `example-*` packages. | Upstream docs site, examples and monorepo tooling only; nothing in a published package's runtime. Community adapters are third-party npm packages. | | Discord import sort (`21962572`, vercel/chat#657, chat@4.32.0) | No change. | Reorders imports in `adapter-discord`. | Formatting only (Biome). Python import order is enforced by ruff. | | Telegram rich-truncate test timeout (`57fa85ca`, vercel/chat#703, chat@4.34.0) | No change. | Raises one vitest timeout to 15 s. | Test-infra only. The Python suite sets no per-test timeout. | +| Upstream test prune and security docs (`636da193` #972, `208a5ec2` #964, chat@4.41.1) | No change. The eight Python tests in `tests/test_thread_faithful.py` translated from `thread.test.ts` titles that #972 removed or merged (the `schedule()` return-value, raw-response, cancel and JSX-card cases) stay as Python-only extras; the strict fidelity check ignores extras. | #972 consolidates and removes upstream tests and un-exports two test-only helpers (Teams `normalizeServiceUrl`, Twilio content cache-key/friendly-name). #964 edits `SECURITY.md`. | Test-suite and docs changes. The un-exported helpers were not part of a package entrypoint, and the Twilio content helpers belong to the deferred RCS port (next table). The #972 `state-memory` TTL-refresh test is an optional ride-along in #188. | +| Shared adapter test contracts (`a7fb1bcf` #654, `840c0d16` #673, `ac5a54ee` #674, `e7a396ae` #675, chat@4.33.0) | Not adopted in this wave. Python adapters keep per-adapter thread-id and self-message tests. | `@chat-adapter/tests` gains `connectWebhookContract`, `threadIdContract`, `selfMessageContract` and shared mock factories/matchers, and adapter suites move onto them. | Upstream test infrastructure, no runtime change. Tracked as optional in #188. | +| ai@6/ai@7 CI guard and X example CRC route (`776a849a` #704, `8021c3ea` #702, chat@4.34.0) | No change. | A CI job that builds against both AI SDK majors; the Next.js example forwards X's CRC challenge. | CI and example-app only. The AI SDK typing change itself is the "AI SDK v7" row above; X is deferred (next table). | ### Deferred platforms and adapter modes (demand-gated, #189) @@ -3423,8 +3428,8 @@ issue only when a consumer asks for it. | XChat encrypted messaging (`caa63253` #745, chat@4.36.0; docs `fe4ed11e` #777, chat@4.37.0) | Not ported | `@chat-adapter/x/chat` on WASM `chat-xdk` + `juicebox-sdk` | **Skipped (no Python bindings).** On 2026-09-29 PyPI had no `chat-xdk`, `juicebox-sdk` or `juicebox` package. Revisit only if X ships Python bindings; see #189. | | Telegram Business mode (`043386b5`, vercel/chat#888, chat@4.40.0) | No `business_connection_id` handling; `telegram:biz:…` thread ids are not decoded | Opt-in business connections, `telegram:biz:{conn}:{chat}` thread ids | Deferred. Demand-gated; see #189. | | Twilio RCS (`75cadbf9`, vercel/chat#590, chat@4.39.0) | SMS/MMS only; an actions-only card renders to empty text (upstream sends `TWILIO_EMPTY_CARD_FALLBACK`, "Message from bot") | Content API templates, button actions, locations, channel inference | Deferred. Demand-gated; see #189 (the empty-card fallback is the small independent piece listed there). | -| Gmail adapter (`f034cd27` #958 and the `@chat-adapter/gmail` package, chat@4.41.0) | No `gmail` adapter | `@chat-adapter/gmail` (primitives + Chat adapter) | Deferred. Demand-gated; tracked in #241 (not landed for 0.4.41). | -| Discord Components V2 (`0fdb9029`, vercel/chat#678, chat@4.34.0) | Cards render as embeds + action rows | `DiscordContentFormat.ComponentsV2` | Deferred. Demand-gated; see #189 and the "Discord Components V2" row above. | +| Gmail adapter (`bfee00af` #939, the `@chat-adapter/gmail` package, chat@4.41.0; catalog listing `f034cd27` #958, chat@4.41.1) | No `gmail` adapter | `@chat-adapter/gmail` (primitives + Chat adapter) | Deferred. Demand-gated; tracked in #241 (not landed for 0.4.41). | +| Discord Components V2 (`0fdb9029`, vercel/chat#678, chat@4.34.0) | Cards render as embeds + action rows | `DiscordContentFormat.ComponentsV2` | Deferred. Demand-gated; deferred by #230 (see the "Discord Components V2" row above). The #189 backlog body does not list it yet. | | Vercel Connect: Linear (`4115c943` #649, chat@4.33.0; `961cf454` #931, chat@4.41.0) | `webhook_secret` required; no access-token callable or `webhook_verifier` | Access-token function, `webhookVerifier`, Connect-mode installs | Deferred. Demand-gated; see #189. | | Vercel Connect: Discord (`a0cba028`, vercel/chat#808, chat@4.38.0) | `public_key` required (Ed25519); string `bot_token` | `botToken` / `applicationId` functions, custom `webhookVerifier` | Deferred. Demand-gated; see #189. | | Vercel Connect: Telegram (`7a1150ce`, vercel/chat#813, chat@4.38.0) | String `bot_token` | `botToken` function resolved on every Bot API call | Deferred. Demand-gated; see #189. (#224 ported only the bot-identity scope helper from this commit.) | diff --git a/tests/test_version_mapping_docs.py b/tests/test_version_mapping_docs.py new file mode 100644 index 00000000..2068f2e8 --- /dev/null +++ b/tests/test_version_mapping_docs.py @@ -0,0 +1,65 @@ +"""The two version-mapping tables (CLAUDE.md and docs/UPSTREAM_SYNC.md) agree. + +Each release adds a row to both by hand, and they drifted once (#203: the +UPSTREAM_SYNC table folded ``0.4.31.1`` into a ``0.4.31.3`` row describing the +wrong fixes). These tests keep the version sets and upstream versions in step, +and tie the current package version to ``UPSTREAM_PARITY``. +""" + +from __future__ import annotations + +import re +import tomllib +from pathlib import Path + +import chat_sdk + +ROOT = Path(__file__).resolve().parent.parent + +_CLAUDE_ROW = re.compile(r"^- `(0\.\d+\.\d+(?:\.\d+)?)` = (.*)$") +_SYNC_ROW = re.compile(r"^\| `(0\.\d+\.\d+[^`]*)` \| `(\d+\.\d+\.\d+)` \| (.*) \|$") +_UPSTREAM = re.compile(r"`(\d+\.\d+\.\d+)`") + + +def _section(text: str, heading: str) -> list[str]: + lines = text.splitlines() + start = lines.index(heading) + 1 + end = next((i for i in range(start, len(lines)) if lines[i].startswith("## ")), len(lines)) + return lines[start:end] + + +def _claude_rows() -> dict[str, str]: + """Published versions in CLAUDE.md -> upstream version they name.""" + rows: dict[str, str] = {} + for line in _section((ROOT / "CLAUDE.md").read_text(), "## Version Mapping"): + m = _CLAUDE_ROW.match(line) + if not m or "never published" in m.group(2): + continue + upstream = _UPSTREAM.search(m.group(2)) + assert upstream, f"CLAUDE.md row names no upstream version: {line}" + rows[m.group(1)] = upstream.group(1) + return rows + + +def _sync_rows() -> dict[str, str]: + """Final (non-pre-release) versions in UPSTREAM_SYNC.md -> upstream version.""" + rows: dict[str, str] = {} + for line in _section((ROOT / "docs" / "UPSTREAM_SYNC.md").read_text(), "## Version Mapping"): + m = _SYNC_ROW.match(line) + if m and re.fullmatch(r"[\d.]+", m.group(1)): + rows[m.group(1)] = m.group(2) + return rows + + +def test_version_mapping_tables_list_the_same_releases_and_upstreams(): + claude, sync = _claude_rows(), _sync_rows() + assert claude, "parsed no CLAUDE.md version rows" + assert sync, "parsed no UPSTREAM_SYNC.md version rows" + assert sorted(claude) == sorted(sync) + assert claude == sync + + +def test_current_version_maps_to_upstream_parity(): + version = tomllib.loads((ROOT / "pyproject.toml").read_text())["project"]["version"] + assert _claude_rows()[version] == chat_sdk.UPSTREAM_PARITY + assert _sync_rows()[version] == chat_sdk.UPSTREAM_PARITY From ba33b37b7ac4e601b8e845b91be6bc472aad3af1 Mon Sep 17 00:00:00 2001 From: patrick-chinchill Date: Thu, 1 Oct 2026 05:20:00 -0700 Subject: [PATCH 3/3] address gpt-6-astra review: skip repo-only version-mapping doc tests in the sdist --- tests/test_version_mapping_docs.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/test_version_mapping_docs.py b/tests/test_version_mapping_docs.py index 2068f2e8..1f05ba8c 100644 --- a/tests/test_version_mapping_docs.py +++ b/tests/test_version_mapping_docs.py @@ -12,10 +12,18 @@ import tomllib from pathlib import Path +import pytest + import chat_sdk ROOT = Path(__file__).resolve().parent.parent +# CLAUDE.md is repository-only: the sdist allowlist (pyproject.toml) ships +# /tests and /docs but not CLAUDE.md, so skip there instead of failing. +pytestmark = pytest.mark.skipif( + not (ROOT / "CLAUDE.md").is_file(), reason="CLAUDE.md is not shipped in the sdist (repository checkout only)" +) + _CLAUDE_ROW = re.compile(r"^- `(0\.\d+\.\d+(?:\.\d+)?)` = (.*)$") _SYNC_ROW = re.compile(r"^\| `(0\.\d+\.\d+[^`]*)` \| `(\d+\.\d+\.\d+)` \| (.*) \|$") _UPSTREAM = re.compile(r"`(\d+\.\d+\.\d+)`")