From 5b3520a49bf7896bbaaafe325f0ea4805473b274 Mon Sep 17 00:00:00 2001 From: patrick-chinchill Date: Sat, 20 Jun 2026 02:34:26 -0700 Subject: [PATCH 1/2] =?UTF-8?q?chore(release):=20cut=200.4.31=20=E2=80=94?= =?UTF-8?q?=20sync=20to=20chat@4.31.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps version 0.4.30 → 0.4.31 and UPSTREAM_PARITY → 4.31.0; re-pins the fidelity check + CI clone to chat@4.31.0 (string-only — the mapped-core test files are byte-identical 4.30→4.31, 732/732 still pass). Wave content (already merged): Linear agent-sessions L1–L5 (#151), Teams SDK-free primitive subpaths, Slack 4.31 (#155), Telegram rich messages, LinkButton stable id, and the opt-in ThinkingChunk stream type (#169). Documents the new chat/adapters static catalog as an intentional non-port (npm-addressed + vendor adapters not shipped here; no consumer need). --- .github/workflows/lint.yml | 2 +- CHANGELOG.md | 39 +++++++++++++++++++++++++++++++++ CLAUDE.md | 7 +++--- README.md | 2 +- docs/UPSTREAM_SYNC.md | 1 + pyproject.toml | 2 +- scripts/verify_test_fidelity.py | 6 ++--- src/chat_sdk/__init__.py | 2 +- 8 files changed, 51 insertions(+), 10 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index b4868eb..6caba3f 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -60,7 +60,7 @@ jobs: - name: Clone upstream vercel/chat at pinned parity tag id: clone_upstream run: | - git clone --depth 1 --branch chat@4.30.0 \ + git clone --depth 1 --branch chat@4.31.0 \ https://github.com/vercel/chat.git /tmp/vercel-chat - name: Test fidelity check (strict — zero missing in mapped core files) diff --git a/CHANGELOG.md b/CHANGELOG.md index affda3d..fcb6d8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,44 @@ # Changelog +## 0.4.31 + +Synced to upstream `vercel/chat@4.31.0`. The mapped-core **test** files (`packages/chat/src/*.test.ts`) are byte-identical between the `chat@4.30.0` and `chat@4.31.0` tags, so the fidelity re-pin to `chat@4.31.0` is string-only (732/732 mapped-core tests still pass, 0 missing); the core **source** delta is the `LinkButton` stable-id field (below). The headline is the **Linear agent-sessions** mode, plus the **Teams SDK-free primitive subpaths**, the **Slack 4.31** changes, **Telegram rich messages**, and a Python-only opt-in **`ThinkingChunk`** stream type. Sets `UPSTREAM_PARITY = "4.31.0"`. + +### Headline: Linear agent-sessions mode (issue #151) + +Full port of upstream's Linear agent-sessions interaction model, delivered across five PRs (L1–L5). Because no official Linear Python SDK exists, every `@linear/sdk` call is reproduced as **raw GraphQL** over the existing `_graphql_query` helper and **schema-hardened field-by-field against Linear's published GraphQL schema** (`linear/packages/sdk/src/schema.graphql`). + +- **L1 — types** (#168). `LinearAgentSessionThreadId`, the `mode: "agent-sessions" | "comments"` config (default `"comments"`), the `kind`-discriminated raw-message variants (`comment` / `agent_session_comment`), and the `AgentSessionEvent` webhook payload types. +- **L2 — thread-id** (#170). Anchored `linear:{issue}:c:{comment}:s:{session}` / `linear:{issue}:s:{session}` encode/decode with a strict decode order; existing thread-id forms stay byte-identical so cross-SDK state is preserved. +- **L3 — webhook parse + routing** (#171). The `AgentSessionEvent` branch with mutually-exclusive mode gating (agent-session events flow only in `agent-sessions` mode, comment events only in `comments` mode), `_parse_message_from_agent_session_event` (created/prompted actions + null-return/warn paths), app-ownership guard, and `get_user_name_from_profile_url`. +- **L4 — emit** (#172). `post_message` / `start_typing` / `stream` route through raw `agentActivityCreate` / `agentSessionUpdate` mutations (lowercase `AgentActivityType` enum, `content: JSONObject!`, `ephemeral` included only when set); streaming flushes markdown deltas as `response`/`thought` activities and maps `task_update`/`plan_update` chunks to `action`/`error` activities and session-plan updates. +- **L5 — fetch** (#173). `fetch_messages` dispatches agent-session threads to `_fetch_agent_session_messages` (raw `agentSession(id:)` + `comments(filter:{parent})` with forward/backward pagination); `edit_message`/`delete_message` raise append-only errors for session threads. + +The schema-hardening caught two live-tenant-breaking selection bugs before they shipped (`AgentActivity` exposes the `agentSession` relation, not a scalar `agentSessionId`; `AgentSession` likewise has no scalar `issueId`). The mutations/queries are confirmed against the published schema but **not yet exercised against a live Linear agent-session tenant** — documented in `docs/UPSTREAM_SYNC.md`. + +### Teams: SDK-free primitive subpaths (chat@4.31, commit `8c71411`) + +New runtime-free Teams subpaths mirroring upstream's `@chat-adapter/teams/*` exports: `teams/api` (Bot Connector — token grant, post/update/delete message, typing, create conversation), `teams/graph` (Microsoft Graph — channels, messages, pagination), `teams/format` (Teams text/mention/HTML↔Markdown), `teams/webhook` (read/parse, continuation/user/attachment extraction, mention detection), `teams/cards` + `cards_input` (card → Adaptive Card + input parsing), and `teams/modals` (modal → Adaptive Card + dialog-submit parsing). Each network-facing primitive carries an SSRF/token-leak host gate: `call_teams_connector_api` (serviceUrl Bot Framework allowlist) and `call_teams_graph_api` (host pinned to `graph.microsoft.com`, also guarding followed `@odata.nextLink` cursors). + +### Slack 4.31 (commit `f801985`, PR #155) + +- **`@mention`-inside-URL fix.** Bare `@handle`s inside `http(s)` URLs (paths, query strings, fragments) are no longer rewritten into `<@handle>` mentions (which corrupted the link), via a URL-span exclusion pass. +- **`web_client_options`** config — forwarded to both the default and per-token `slack_sdk` `WebClient`s to tune the underlying HTTP client (timeout, `retry_handlers`, headers), with per-client header isolation. (Maps to slack_sdk kwargs rather than `@slack/web-api`'s axios options — documented divergence.) +- **Stable link-button `action_id`** — `LinkButton(id=…)` now flows to the Slack block `action_id` instead of always deriving it from the URL. + +### Telegram: rich messages (commit `4662309`) + +Character-for-character port of the new `rich.ts` (Telegram rich-message wire types → Markdown + plain text) as `telegram/rich.py`, plus the rich-message/media type family (`TelegramRichText`/`RichBlock`/`RichMessage`, animation/audio/location/video/voice), native `sendRichMessage`/`sendRichMessageDraft` threading through post/edit/stream with a rich→regular fallback, and a `/slash`-command router. + +### Core: `LinkButton` stable id + opt-in `ThinkingChunk` + +- **`LinkButton(id=…)`** (chat@4.31, commit `171657a`). Optional action identifier for platforms that report link clicks (matches the `Button`/`Select` `id` convention). The JSX-runtime half of the same commit has no Python equivalent (no JSX runtime) and is documented as such. +- **`ThinkingChunk`** (Python-only, opt-in, default-off; supersedes PR #39, landed in #169). A **separate** `ThinkingChunk(type="thinking", content=str)` stream-input type surfaces AI-SDK `reasoning`/`reasoning-delta` parts. **`StreamChunk` is not widened** — it stays byte-identical to upstream's three variants, so consumers referencing it are unaffected; `ThinkingChunk` is accepted only at the stream boundaries via the `StreamInput = str | StreamChunk | ThinkingChunk` alias. Emitted only when a caller opts in (`emit_thinking=True`); the default stream and persisted `Message` are byte-identical to upstream, so cross-SDK state stays compatible. Gives chinchill a first-class path to stream agent thinking to Slack/Teams without intercepting the model stream out-of-band. + +### Not ported (documented) + +- **`chat/adapters` static catalog** (new `./adapters` subpath). Upstream's SDK-free adapter/env-var metadata registry is addressed by npm package names and includes ~13 vendor-official adapters this SDK doesn't ship, so it isn't meaningfully portable verbatim; a Python-native equivalent would be a new feature with no current consumer need. Documented in `docs/UPSTREAM_SYNC.md`; deferred demand-driven. + ## 0.4.30 Synced to upstream `vercel/chat@4.30.0`. The mapped core (`packages/chat/src`) is content-identical between the `chat@4.29.0` and `chat@4.30.0` upstream tags, so this wave is all adapter work: a **new Twilio adapter**, a **Telegram native-streaming** port, a **Slack primitives-subpath** wave, a batch of **WhatsApp / Slack / Google Chat** fixes, and the headline — the **Teams adapter migration to the official `microsoft-teams-apps` SDK** (issue #93, delivered across four PRs). Sets `UPSTREAM_PARITY = "4.30.0"`; CI fidelity re-pinned to `chat@4.30.0` (732/732 mapped-core tests still pass, 0 missing). diff --git a/CLAUDE.md b/CLAUDE.md index 6ac74e4..af0d00a 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.30.0). Multi-platform async chat framework. +Python port of [Vercel Chat SDK](https://github.com/vercel/chat) (synced to upstream v4.31.0). Multi-platform async chat framework. ## Key Commands ```bash @@ -28,6 +28,7 @@ Our version embeds the upstream Vercel Chat version: `0.{upstream_major}.{upstre - `0.4.27.1` = Python-only fix on top of `4.27.0` - `0.4.29` = synced to upstream `4.29.0` (upstream never tagged 4.27/4.28 as `chat@*`) - `0.4.30` = synced to upstream `4.30.0` +- `0.4.31` = synced to upstream `4.31.0` - `UPSTREAM_PARITY` constant in `__init__.py` = programmatic access ## Architecture @@ -110,7 +111,7 @@ will not pass CI. **Fidelity check** (`scripts/verify_test_fidelity.py`) verifies every TS `it("...")` in the mapped core files has a matching Python `def test_*()`, -pinned to `chat@4.30.0` (matches `UPSTREAM_PARITY`; upstream never tagged +pinned to `chat@4.31.0` (matches `UPSTREAM_PARITY`; upstream never tagged `chat@4.27.0`/`chat@4.28.0`). The `MAPPING` dict in that script is the authoritative scope list — extending it to the remaining unmapped `packages/chat/src/*.test.ts` files is tracked as issue #78. @@ -125,7 +126,7 @@ divergence in `docs/UPSTREAM_SYNC.md`. Before the fidelity check can run locally, clone the pinned upstream checkout (same command CI uses in `lint.yml`): ```bash -git clone --depth 1 --branch chat@4.30.0 \ +git clone --depth 1 --branch chat@4.31.0 \ https://github.com/vercel/chat.git /tmp/vercel-chat ``` Then `TS_ROOT=/tmp/vercel-chat uv run python scripts/verify_test_fidelity.py --strict`. diff --git a/README.md b/README.md index 4f8ff40..83f3fef 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). -> **Status: 0.4.30 — synced to [Vercel Chat 4.30.0](https://github.com/vercel/chat)** (`UPSTREAM_PARITY = "4.30.0"`). See [CHANGELOG.md](CHANGELOG.md). +> **Status: 0.4.31 — synced to [Vercel Chat 4.31.0](https://github.com/vercel/chat)** (`UPSTREAM_PARITY = "4.31.0"`). See [CHANGELOG.md](CHANGELOG.md). ## Why chat-sdk? diff --git a/docs/UPSTREAM_SYNC.md b/docs/UPSTREAM_SYNC.md index 269f754..8b35a61 100644 --- a/docs/UPSTREAM_SYNC.md +++ b/docs/UPSTREAM_SYNC.md @@ -687,6 +687,7 @@ stay explicit instead of being rediscovered in code review. | `ThinkingChunk` opt-in stream-input type (Python-only, default-off; supersedes PR #39) | A **separate, opt-in** dataclass — `ThinkingChunk(type="thinking", content=str)` — surfaces AI-SDK `reasoning`/`reasoning-delta` (and pydantic-ai `part_kind == "thinking"`) parts. **`StreamChunk` is NOT widened**: the canonical union stays `StreamChunk = MarkdownTextChunk \| TaskUpdateChunk \| PlanUpdateChunk` — byte-identical to upstream's three variants, so a consumer doing an exhaustive `match` over `StreamChunk` sees zero change on upgrade. `ThinkingChunk` is accepted only at the **stream-input/output boundaries** via the public alias `StreamInput = str \| StreamChunk \| ThinkingChunk` (the `Adapter.stream()` protocol signature, `from_full_stream`/`_from_full_stream` returns, `Thread._wrapped_stream`, and each receiving adapter's `stream()` signature). A producer can yield `ThinkingChunk` (opt-in) and the adapters that receive the stream type-check; code that only references `StreamChunk` never touches it. **OPT-IN, default-off**: emitted only when a caller passes `from_full_stream(stream, emit_thinking=True)` or sets the thread-level `emit_thinking=True` config; the internal `_from_full_stream` threads the same flag. With the default (`emit_thinking=False`) the normalized stream is **byte-for-byte identical** to upstream — reasoning parts are dropped and **no** `ThinkingChunk` is produced. Consumption is graceful: `Thread._handle_stream` never accumulates a `ThinkingChunk` into the posted-message text, and every adapter's stream handler skips it (Slack/Teams expose an optional `render_thinking` hook via `chat_sdk.shared.adapter_utils.maybe_render_thinking`; the text-accumulate adapters ignore it structurally). **Streaming-only — never persisted**: `Message` has no `thinking` field, `to_json()` is unchanged, and a round-tripped `Message` is byte-identical, so cross-SDK state (Redis/Postgres shared with the TS SDK) stays compatible. | Upstream `from-full-stream.ts` forwards only `text-delta` + `finish-step`; AI-SDK `reasoning`/`reasoning-delta` parts fall through and are discarded. `StreamChunk = MarkdownTextChunk \| TaskUpdateChunk \| PlanUpdateChunk` — no reasoning variant and no stream-input alias. Upstream leaves reasoning display to the AI-SDK web UI. | chinchill actively streams agent thinking to Slack/Teams but has to intercept the model stream out-of-band today because the chat-platform SDK has no path for it. This gives the SDK a first-class, opt-in one without changing any default behavior — and crucially **without widening the public `StreamChunk` union**, so consumers referencing it are unaffected. The whole design constraint is that default-off == upstream and `StreamChunk` == upstream: separate opt-in input type, opt-in emit, graceful/skip consume, zero state pollution. Regression coverage: `tests/test_thinking_chunk.py`, `tests/test_from_full_stream.py::TestThinkingOptIn`, `tests/test_types.py::TestThinkingChunk`, plus per-adapter no-crash tests in `tests/test_slack_api.py`, `tests/test_teams_native_streaming.py`, `tests/test_twilio_adapter.py`, `tests/test_messenger_api.py`. | | 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`. Regression coverage: `tests/test_linear_agent_session_emit.py`. | | 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`, `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 ?? thread.issueId`, and the same `issueId ?? issue?.id` fallback upstream itself uses at `index.ts:959`. 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 **each message's `thread_id` encodes the comment's OWN id** (`linear:{issueId}:c:{comment.id}:s:{agentSessionId}`, NOT a single fixed thread id) and `is_mention=True`. `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. Nullish hazards preserved: `agentSession.issue.id ?? thread.issue_id` and `endCursor ?? undefined` → `is not None` (NOT `or` — an empty issue id still short-circuits per `??`). 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`, +24 `test()` cases): types `EnvVar`/`EnvGroup`/`AdapterEnvSpec`/`CatalogAdapter`, an `ADAPTERS` registry of ~22 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. | ### Platform-specific gaps | Area | Python | TS | Rationale | diff --git a/pyproject.toml b/pyproject.toml index 9c57f98..05a91b6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "chat-sdk" -version = "0.4.30" +version = "0.4.31" description = "Multi-platform async chat SDK for Python — port of Vercel Chat" keywords = [ "chat", diff --git a/scripts/verify_test_fidelity.py b/scripts/verify_test_fidelity.py index a18bdf6..9cc739c 100644 --- a/scripts/verify_test_fidelity.py +++ b/scripts/verify_test_fidelity.py @@ -14,7 +14,7 @@ ``--strict`` is the current CI contract (see ``.github/workflows/lint.yml``): the baseline is ignored and any missing translation — or a missing upstream checkout — fails the build. This repo ships at strict fidelity for mapped -core files (0 missing) against ``chat@4.30.0``. The ``MAPPING`` dict below +core files (0 missing) against ``chat@4.31.0``. The ``MAPPING`` dict below is the authoritative scope list (extending it to the remaining unmapped ``packages/chat/src/*.test.ts`` files is tracked as issue #78). @@ -317,7 +317,7 @@ def write_baseline(path: Path, all_missing: dict[str, list], total_ts: int) -> N current_parity = _current_parity_tag() payload = { "_comment": existing_comment if existing_comment is not None else _DEFAULT_BASELINE_COMMENT, - "ts_parity": current_parity if current_parity is not None else "chat@4.30.0", + "ts_parity": current_parity if current_parity is not None else "chat@4.31.0", "total_ts_tests": total_ts, "total_missing": sum(len(v) for v in all_missing.values()), "missing": { @@ -446,7 +446,7 @@ def main() -> int: print(f" - {path}") print( "\nClone the upstream repo at the pinned parity tag, e.g.:\n" - " git clone --depth 1 --branch chat@4.30.0 " + " git clone --depth 1 --branch chat@4.31.0 " "https://github.com/vercel/chat.git /tmp/vercel-chat\n" "then re-run with TS_ROOT=/tmp/vercel-chat." ) diff --git a/src/chat_sdk/__init__.py b/src/chat_sdk/__init__.py index f274257..5a759dd 100644 --- a/src/chat_sdk/__init__.py +++ b/src/chat_sdk/__init__.py @@ -227,7 +227,7 @@ ) # The upstream Vercel Chat version this release is synced to. -UPSTREAM_PARITY = "4.30.0" +UPSTREAM_PARITY = "4.31.0" __all__ = [ "UPSTREAM_PARITY", From 230fc3722062b3e9be31c2fb5fd3c995edd8590b Mon Sep 17 00:00:00 2001 From: patrick-chinchill Date: Sat, 20 Jun 2026 02:40:07 -0700 Subject: [PATCH 2/2] docs(upstream-sync): correct adapters-catalog counts (19 tests, ~25 adapters) --- docs/UPSTREAM_SYNC.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/UPSTREAM_SYNC.md b/docs/UPSTREAM_SYNC.md index 8b35a61..d8f499e 100644 --- a/docs/UPSTREAM_SYNC.md +++ b/docs/UPSTREAM_SYNC.md @@ -687,7 +687,7 @@ stay explicit instead of being rediscovered in code review. | `ThinkingChunk` opt-in stream-input type (Python-only, default-off; supersedes PR #39) | A **separate, opt-in** dataclass — `ThinkingChunk(type="thinking", content=str)` — surfaces AI-SDK `reasoning`/`reasoning-delta` (and pydantic-ai `part_kind == "thinking"`) parts. **`StreamChunk` is NOT widened**: the canonical union stays `StreamChunk = MarkdownTextChunk \| TaskUpdateChunk \| PlanUpdateChunk` — byte-identical to upstream's three variants, so a consumer doing an exhaustive `match` over `StreamChunk` sees zero change on upgrade. `ThinkingChunk` is accepted only at the **stream-input/output boundaries** via the public alias `StreamInput = str \| StreamChunk \| ThinkingChunk` (the `Adapter.stream()` protocol signature, `from_full_stream`/`_from_full_stream` returns, `Thread._wrapped_stream`, and each receiving adapter's `stream()` signature). A producer can yield `ThinkingChunk` (opt-in) and the adapters that receive the stream type-check; code that only references `StreamChunk` never touches it. **OPT-IN, default-off**: emitted only when a caller passes `from_full_stream(stream, emit_thinking=True)` or sets the thread-level `emit_thinking=True` config; the internal `_from_full_stream` threads the same flag. With the default (`emit_thinking=False`) the normalized stream is **byte-for-byte identical** to upstream — reasoning parts are dropped and **no** `ThinkingChunk` is produced. Consumption is graceful: `Thread._handle_stream` never accumulates a `ThinkingChunk` into the posted-message text, and every adapter's stream handler skips it (Slack/Teams expose an optional `render_thinking` hook via `chat_sdk.shared.adapter_utils.maybe_render_thinking`; the text-accumulate adapters ignore it structurally). **Streaming-only — never persisted**: `Message` has no `thinking` field, `to_json()` is unchanged, and a round-tripped `Message` is byte-identical, so cross-SDK state (Redis/Postgres shared with the TS SDK) stays compatible. | Upstream `from-full-stream.ts` forwards only `text-delta` + `finish-step`; AI-SDK `reasoning`/`reasoning-delta` parts fall through and are discarded. `StreamChunk = MarkdownTextChunk \| TaskUpdateChunk \| PlanUpdateChunk` — no reasoning variant and no stream-input alias. Upstream leaves reasoning display to the AI-SDK web UI. | chinchill actively streams agent thinking to Slack/Teams but has to intercept the model stream out-of-band today because the chat-platform SDK has no path for it. This gives the SDK a first-class, opt-in one without changing any default behavior — and crucially **without widening the public `StreamChunk` union**, so consumers referencing it are unaffected. The whole design constraint is that default-off == upstream and `StreamChunk` == upstream: separate opt-in input type, opt-in emit, graceful/skip consume, zero state pollution. Regression coverage: `tests/test_thinking_chunk.py`, `tests/test_from_full_stream.py::TestThinkingOptIn`, `tests/test_types.py::TestThinkingChunk`, plus per-adapter no-crash tests in `tests/test_slack_api.py`, `tests/test_teams_native_streaming.py`, `tests/test_twilio_adapter.py`, `tests/test_messenger_api.py`. | | 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`. Regression coverage: `tests/test_linear_agent_session_emit.py`. | | 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`, `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 ?? thread.issueId`, and the same `issueId ?? issue?.id` fallback upstream itself uses at `index.ts:959`. 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 **each message's `thread_id` encodes the comment's OWN id** (`linear:{issueId}:c:{comment.id}:s:{agentSessionId}`, NOT a single fixed thread id) and `is_mention=True`. `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. Nullish hazards preserved: `agentSession.issue.id ?? thread.issue_id` and `endCursor ?? undefined` → `is not None` (NOT `or` — an empty issue id still short-circuits per `??`). 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`, +24 `test()` cases): types `EnvVar`/`EnvGroup`/`AdapterEnvSpec`/`CatalogAdapter`, an `ADAPTERS` registry of ~22 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) | 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. | ### Platform-specific gaps | Area | Python | TS | Rationale |