feat(chat): message.subject + fetch_subject adapter hook#131
Conversation
Port the core message.subject feature from upstream eb5f94a (PR #459, "feat(chat): message.subject + adapter client access"). Tracks #98. Core type system + chat binding only: - MessageSubject dataclass (snake_case, ~10 fields) + MessageSubjectParty for the assignee/author { id, name } sub-objects, mirroring the upstream TS MessageSubject interface in packages/chat/src/types.ts. - Optional fetch_subject(raw) -> MessageSubject | None hook on BaseAdapter (default returns None). Declared on BaseAdapter (not the Adapter Protocol) to match how every other optional adapter hook is declared here, so adapters that don't implement it still satisfy Adapter for type-checking. - Message.subject async accessor backed by an identity-keyed, weakly-scoped adapter registry + cached resolution future (mirrors upstream's adapterMap WeakMap and _subjectPromise). Resolved subject is cached so a second `await message.subject` does not re-call fetch_subject; raising hooks resolve to None (mirrors upstream .catch(() => null)). - Chat registers the owning adapter at the single dispatch bind site (_dispatch_to_handlers), through which every dispatched message flows. WeakMap hashability decision: Message is a plain @DataClass (eq=True) and therefore unhashable, so weakref.WeakKeyDictionary[Message, Adapter] raises TypeError. Rather than change Message's equality contract (eq=False/frozen), we key a plain dict by id(message) (object identity, matching WeakMap semantics) and register a weakref.finalize callback per message that pops the entry on GC. weakref.ref works on a plain dataclass even though hash() does not, and the finalizer closes the id() reuse hole. Out of scope (follow-ups): GitHub + Linear adapter implementations of fetch_subject, which depend on those adapters exposing their native client (.octokit / .linear_client) — not yet present. https://claude.ai/code/session_01FyMxQn2BEAzmwKS1GZczKj
|
Warning Review limit reached
More reviews will be available in 53 minutes and 55 seconds. Learn how PR review limits work. Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file). ⌛ How to resolve this issue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based credits. 🚦 How do rate limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan refill rate. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, the refill rate gradually slows as usage increases. The highest same-day bursts are limited more strictly. Please see our Fair Usage Limits Policy for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (5)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Code Review
This pull request introduces the MessageSubject and MessageSubjectParty dataclasses to represent external subjects associated with messages, along with a weakly-scoped registry mapping Message objects to their owning Adapter to lazily resolve and cache these subjects. Feedback on the changes suggests preventing duplicate finalizer registrations when registering a message adapter multiple times, and reordering the adapter checks to ensure adapter is verified as not None before attempting to retrieve its fetch_subject attribute.
| key = id(message) | ||
| _message_adapter_map[key] = adapter | ||
|
|
||
| # Drop the entry when the message is GC'd. A zero-arg closure (rather than | ||
| # ``weakref.finalize(message, dict.pop, key, None)``) captures ``key`` and | ||
| # keeps the finalizer callable's type unambiguous for the type-checker. | ||
| # ``pop(key, None)`` is a no-op if the entry was already removed. | ||
| def _cleanup() -> None: | ||
| _message_adapter_map.pop(key, None) | ||
|
|
||
| weakref.finalize(message, _cleanup) |
There was a problem hiding this comment.
If set_message_adapter is called multiple times for the same message object, a new finalizer is registered on each call. This can lead to an accumulation of duplicate finalizer objects and closures, causing unnecessary memory overhead. Checking if the message is already registered before creating a new finalizer prevents this duplicate registration.
| key = id(message) | |
| _message_adapter_map[key] = adapter | |
| # Drop the entry when the message is GC'd. A zero-arg closure (rather than | |
| # ``weakref.finalize(message, dict.pop, key, None)``) captures ``key`` and | |
| # keeps the finalizer callable's type unambiguous for the type-checker. | |
| # ``pop(key, None)`` is a no-op if the entry was already removed. | |
| def _cleanup() -> None: | |
| _message_adapter_map.pop(key, None) | |
| weakref.finalize(message, _cleanup) | |
| key = id(message) | |
| already_registered = key in _message_adapter_map | |
| _message_adapter_map[key] = adapter | |
| if not already_registered: | |
| # Drop the entry when the message is GC'd. A zero-arg closure (rather than | |
| # weakref.finalize(message, dict.pop, key, None)) captures key and | |
| # keeps the finalizer callable's type unambiguous for the type-checker. | |
| # pop(key, None) is a no-op if the entry was already removed. | |
| def _cleanup() -> None: | |
| _message_adapter_map.pop(key, None) | |
| weakref.finalize(message, _cleanup) |
| adapter = _get_message_adapter(self) | ||
| fetch_subject = getattr(adapter, "fetch_subject", None) | ||
| if adapter is None or fetch_subject is None: | ||
| return None |
There was a problem hiding this comment.
When adapter is None, calling getattr(adapter, "fetch_subject", None) is unnecessary and can be confusing, even though getattr(None, ...) technically returns the default value in Python. It is cleaner, safer, and more idiomatic to perform the adapter is None check first before attempting to retrieve any attributes from it. Additionally, when checking for optional values that can be falsy but valid, ensure you use explicit is not None (or is None) checks instead of truthiness checks.
adapter = _get_message_adapter(self)
if adapter is None:
return None
fetch_subject = getattr(adapter, "fetch_subject", None)
if fetch_subject is None:
return NoneReferences
- When checking for optional values that can be falsy but valid (e.g., 0, empty string, empty list), use
is not Noneinstead of a truthiness check to avoid silently ignoring them.
…emini review) Address two Gemini review findings on PR #131: - set_message_adapter: register the weakref.finalize cleanup only on first registration for a given message identity. Re-registering the same live message now overwrites only the adapter value instead of accumulating redundant finalizers (re-dispatch / rehydrate / multiple handler passes). The id()-reuse hole stays closed: a GC'd predecessor's finalizer has already popped the entry, so a fresh finalizer is registered for the new object. - Message._resolve_subject: check adapter is None and return early before calling getattr(adapter, "fetch_subject", None) for clarity/robustness (behavior unchanged). Extends TestSetMessageAdapterWeakref: no-duplicate-finalizer (load-bearing, counts live weakref.finalize callbacks), re-registration adapter overwrite, and clean single GC cleanup after re-registration. https://claude.ai/code/session_01FyMxQn2BEAzmwKS1GZczKj
CodeQL flagged test_types.py importing chat_sdk.types both as `import chat_sdk.types as types_module` and via `from chat_sdk.types import`. Its suggested "minimal fix" (delete the aliased import) would have broken the build — `types_module` is referenced in 8 places to reach the module internals `_message_adapter_map` and `_get_message_adapter`. Correct consolidation: import those two names in the existing `from` block and drop the alias, replacing the 8 `types_module.X` references with `X`. Pure import-style change, no behavior change; test_types.py still 40 passed. https://claude.ai/code/session_01FyMxQn2BEAzmwKS1GZczKj
|
@codex review Generated by Claude Code |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: fd9293122f
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| """ | ||
| if self._subject_future is None: | ||
| self._subject_future = asyncio.ensure_future(self._resolve_subject()) | ||
| return await self._subject_future |
There was a problem hiding this comment.
Shield cached subject lookup from caller cancellation
When a caller awaits message.subject through a timeout/cancelled handler, cancellation propagates into the cached _subject_future because it is awaited directly. For example, await asyncio.wait_for(message.subject, ...) on a slow fetch_subject cancels the shared task; every later await message.subject then raises CancelledError instead of retrying or resolving to None, so one timed-out handler permanently poisons that Message's subject accessor. Consider shielding the shared task or clearing the cache when it is cancelled.
Useful? React with 👍 / 👎.
…dex review) The cached `_subject_future` in `Message._subject` was awaited directly. A caller cancellation (e.g. `asyncio.wait_for(msg.subject, timeout=...)` firing) propagated into the shared future, marking it as cancelled and permanently poisoning the cache so every subsequent `await msg.subject` raised CancelledError. Wrap the cached-future await with `asyncio.shield(...)` so caller cancellation propagates to the caller while the inner task keeps running and the cache stays usable. Add a regression test that exercises the bug end-to-end (slow adapter + tight wait_for timeout + second awaiter).
|
@GHCodex review Generated by Claude Code |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 66405461a0
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| # Mirrors upstream's ``setMessageAdapter`` call at the dispatch bind | ||
| # site (packages/chat/src/chat.ts). Every dispatched message flows | ||
| # through here, so this is the single registration point. | ||
| set_message_adapter(message, adapter) |
There was a problem hiding this comment.
Bind skipped context messages before dispatch
When queue/burst drains collapse multiple messages, only the latest message passed to _dispatch_to_handlers is registered here; the earlier messages are delivered to handlers as context.skipped without ever calling set_message_adapter (they were only enqueued while the lock was busy). In that scenario, await context.skipped[0].subject always resolves to None even when the adapter implements fetch_subject, so handlers cannot use the new subject accessor on skipped messages preserved by the concurrency context. Register the skipped messages in the context with the same adapter before invoking handlers.
Useful? React with 👍 / 👎.
Codex P2 finding on PR #131: when concurrency strategies collapse multiple messages (burst window, queue drain), only the latest ``message`` was bound to its owning adapter via ``set_message_adapter``. The earlier arrivals surfaced to handlers through ``context.skipped`` had no adapter registered, so ``await context.skipped[i].subject`` always resolved to ``None`` — even when the adapter implemented ``fetch_subject``. ``_dispatch_to_handlers`` is the single registration point for adapter binding; extend it to bind every message in ``context.skipped`` before invoking the handler chain. This is the only call site that ever passes a ``MessageContext``, so no other dispatch path needs touching. Test: end-to-end burst test that drives msg1 → msg2 → msg3 through ``burst`` strategy, attaches a ``fetch_subject`` hook to the mock adapter, and asserts ``await message.subject`` *and* both ``await context.skipped[i].subject`` calls resolve to their adapter- fetched ``MessageSubject`` (not ``None``). Reverting the fix reproduces the bug exactly. Full suite green (4067 passed). https://claude.ai/code/session_01FyMxQn2BEAzmwKS1GZczKj
Fixes the Lint & Type Check CI failure on PR #131 — ruff's line-length heuristic kept the two ``handle_incoming_message`` calls on a single line each (they fit). My audit-fix commit unnecessarily wrapped them. https://claude.ai/code/session_01FyMxQn2BEAzmwKS1GZczKj
This reverts commit 583643a.
Fixes the Lint & Type Check CI failure on PR #131 — ruff's line-length heuristic kept the two ``handle_incoming_message`` calls on a single line each (they fit). My audit-fix commit unnecessarily wrapped them. Re-applied after revert 66ef156 (which had to undo the stray pickup of unrelated messenger adapter files that bled in from a checkout to ``origin/main`` during local mypy investigation). https://claude.ai/code/session_01FyMxQn2BEAzmwKS1GZczKj
…alignment Ports the core slice of vercel/chat#444 (processMessage returns the inner task as Promise<void>): process_message now returns the eagerly-started asyncio.Task so streaming callers can await full handler completion and observe handler exceptions; wait_until keeps swallowed-error semantics. Fidelity alignment for the chat@4.29.0 pin: - MAPPING follows upstream's ai.test.ts split into ai/messages.test.ts + ai/index.test.ts (vercel/chat#492) - tests/test_ai_tools.py: 14 tests renamed to converter-exact names so the strict checker exact-matches the upstream it() titles - new faithful ports in tests/test_chat_faithful.py: the #444 awaitable test, plus the #459/#495 queue/burst subject-rehydration tests, skipif- gated on BaseAdapter.fetch_subject so they activate when PR #131 lands https://claude.ai/code/session_013zwTcMek5rNqBTQvs2oF64
- MAPPING: four new chat@4.29.0 core test files mapped (callback-url, thread-history, transcripts, transcripts-wiring) — 12 of 19 files now in scope; stale 4.26-era references in the script/template updated - fidelity_baseline.json regenerated at chat@4.29.0 (731 TS tests in scope, 0 missing; strict mode green) - docs/UPSTREAM_SYNC.md: Known Non-Parity rows from the port wave — GitHub octokit / Linear linear_client getters, @chat-adapter/tests kit, Teams modal-submit webhook options slice, jsx-runtime callbackUrl props, Transcripts API Python adaptations, Slack legacy mrkdwn renderer scope note, Discord gateway-only interactions surface, and the Teams microsoft-teams-apps deferral to 0.4.30 - tests/test_chat_faithful.py: the [Slash Commands] duplicate-title openModal-unsupported port (matcher counts names as a multiset) and the #459 subject-wiring port (skipif-gated on BaseAdapter.fetch_subject, activates when PR #131 lands) https://claude.ai/code/session_013zwTcMek5rNqBTQvs2oF64
…er Protocol CI's Lint & Type Check job failed on pyrefly (43 errors) while every other step was green: - persist_thread_history / persist_message_history were declared on the structural Adapter Protocol, making them REQUIRED members and breaking conformance for every adapter that satisfies the Protocol without extending BaseAdapter (the exact trap documented in PR #131). Moved to BaseAdapter only, with a NOTE on the Protocol; the SDK reads both flags via getattr. - MessengerAdapter (recovered #124 — the original cause of the failure on the recovery push) now defines get_user, mirroring BaseAdapter's ChatNotImplementedError contract; the real Graph-backed lookup remains issue #132. Also: _map_attachment_type returns the Attachment.type Literal, and the user-profile cache write is typed. - transcripts: retention_ms coerced to int (append_to_list ttl_ms contract; fractional milliseconds are meaningless). uv run pyrefly check: 0 errors (matches the origin/main baseline). https://claude.ai/code/session_013zwTcMek5rNqBTQvs2oF64
….29 ports + cut (#134) * feat(messenger): adapter — webhook, Graph API, send/stream (PR 2 of 2) (#124) * feat(messenger): adapter — webhook, Graph API, send/stream (vercel/chat#461) Port of `packages/adapter-messenger/src/index.ts` (PR 2 of 2). Builds on the scaffolding (types/format converter/cards) added in PR #118. Includes: - `MessengerAdapter`: webhook routing (GET verification + POST events), X-Hub-Signature-256 HMAC-SHA256 verification, Graph API client backed by aiohttp, send paths (text / Generic template / Button template / text fallback), buffered streaming, postback / reaction / echo / delivery / read handling, attachment extraction with lazy download, message cache (Messenger has no history API), user-profile cache, thread / channel helpers, typed Graph-API error mapping. - `create_messenger_adapter` factory with FACEBOOK_* env fallbacks. - Exports wired through `chat_sdk.adapters.messenger`. Q1 (init-failure behavior, see #110): match `WhatsAppAdapter` — raise `ValidationError` from both the factory and the constructor when any required credential is missing, so config errors surface loudly at startup. Q3 (signature verification, see #110): pin upstream's X-Hub-Signature-256 + App Secret HMAC contract. A swappable verifier (Slack-style) would diverge from Meta's protocol with no offsetting benefit for a single-secret integration; flagged as a possible future divergence but not introduced here. Tests: `tests/test_messenger_webhook.py` (57) and `tests/test_messenger_api.py` (64) — mirrors the Telegram/WhatsApp file split. Covers signature valid/invalid/missing/wrong-algo/replay, webhook routing for all event types, postback decoding (raw + chat: prefix), card → template paths, stream buffering, truncation, error mapping, and the Q1 constructor failure path. 121 new tests, full suite: 4220 passed, 3 skipped. * fix(messenger): verify HMAC on raw bytes + URL/profile defenses (gemini review) Four follow-ups to PR #124 from Gemini's review of the Messenger adapter: - `_handle_verification` casts `request.url` to `str` before `urlparse`. Starlette/yarl `URL` objects are non-`str` and would raise. - `_get_request_body` now returns `bytes` instead of `str`. Decoding to UTF-8 and re-encoding for the HMAC step risks replacement characters (U+FFFD) for any non-UTF-8 byte sequence — that breaks signature parity with Meta's reference implementation and silently rejects legitimate webhooks. Body sources are also reordered: `body` attribute first (canonical raw bytes on Starlette/Django), `text` attribute fallback (aiohttp's str path). `is not None` everywhere so a legitimately empty `b""` body isn't skipped. - `_verify_signature(body: bytes, ...)` matches the new contract; the `body.encode("utf-8")` step is dropped — HMAC operates on the exact wire bytes Meta signed. JSON parsing at the call site still works (`json.loads` accepts both str and bytes). - `_fetch_user_profile` now `isinstance(profile, dict)` checks the Graph API response before caching. A non-dict response (None, list, unexpected shape) used to poison `_user_profile_cache` and raise `AttributeError` on the next `.get` call in `_profile_display_name`; we now fall back to the minimal `{"id": user_id}` profile and leave the cache untouched. Regression coverage (+7 tests): - `TestVerifySignature::test_verifies_raw_bytes_without_encoding_roundtrip` feeds a body with lone continuation bytes (0x80 0xff) that don't survive UTF-8 round-trip. Fails on the old decode+re-encode path. - `TestGetRequestBody` (new class, 5 cases) pins the bytes return type across all four framework-shaped inputs: bytes body attribute, str body attribute, async-callable body (Starlette/FastAPI), async-callable text (aiohttp), and missing-body → `b""`. - `test_non_dict_profile_response_falls_back` exercises both `None` and list responses, asserting the fallback display name and that the cache stays empty. Existing `TestVerifySignature` cases updated to pass `bytes` bodies, matching the new contract. `_sign` test helper accepts `bytes | str` for convenience. https://claude.ai/code/session_01FyMxQn2BEAzmwKS1GZczKj * fix(messenger): accept uppercase-hex signatures + clarify init-failure docstring (review) https://claude.ai/code/session_01FyMxQn2BEAzmwKS1GZczKj * fix(messenger): implement rehydrate_attachment for queue/debounce safety (codex) Codex P2: Messenger attachments processed under queue/debounce/burst concurrency lose their fetch_data closure during JSON serialization in the state backend. Without a rehydrate_attachment hook, dequeued handlers see attachment.fetch_data is None and cannot download the file. Mirrors WhatsAppAdapter.rehydrate_attachment (same Meta family, same queue-mode failure shape): - _extract_attachments now persists the download URL on attachment.fetch_metadata={"url": url} - New rehydrate_attachment reads that URL and rebuilds the lazy downloader via _make_attachment_downloader, reusing the shared aiohttp session. Returns the attachment unchanged when metadata is missing/incomplete (degraded mode, matches the documented "leave unchanged when no hook" behavior). No auth headers are attached by the rebuilt closure — Messenger payload URLs are signature-gated by Meta and the original _download_attachment already operated without Bearer tokens. Upstream parity: vercel/chat's TS adapter-messenger does not implement this hook because queue mode is Python-only. Tests (4 new, load-bearing): - fetch_metadata carries the URL after extraction - queue/serialize roundtrip + rehydrate restores a working downloader that hits the original URL (would fail without the hook — verified by temporary revert) - two degraded-mode pins (no metadata, metadata without url key) * fix(messenger): use is-not-None for limit/user_name/cache checks + regression tests (audit) Targeted audit follow-up — three real truthiness bugs in src/chat_sdk/adapters/messenger/adapter.py, same Port Rule #1 root pattern: 1. _paginate_messages: ``options.limit or 50`` silently swallowed an explicit ``limit=0`` and substituted 50. Switched to ``is not None``. Regression: tests/test_messenger_api.py::test_explicit_zero_limit_is_not_swallowed_to_default. 2. __init__: ``config.user_name or "bot"`` paired with ``bool(config.user_name)`` silently replaced an explicit ``user_name=""`` with the ``"bot"`` fallback AND left ``_has_explicit_user_name`` False, so ``initialize()`` would then overwrite it from ``chat.get_user_name()`` / ``/me``. Switched both sites to ``is not None`` to match upstream's ``hasExplicitUserName`` semantics. Regression: tests/test_messenger_webhook.py::test_explicit_empty_user_name_is_respected. 3. _fetch_user_profile: ``if cached:`` treated a cached ``{}`` (empty dict, falsy) as a miss and re-fetched every call. Switched to ``if cached is not None``. Theoretical (Graph API rarely returns ``{}``) but cheap to make robust. Regression: tests/test_messenger_api.py::test_empty_dict_cache_entry_is_a_hit_not_a_miss. All three regression tests are load-bearing: each asserts an outcome the old code would have produced differently. Other Meta-family adapters (Teams, Discord, Google Chat) already use the ``is not None`` pattern for the same code site. Validation: ruff check / format clean, audit_test_quality.py 0 hard failures, 4235 passed / 3 skipped (was 4232 + 3 new tests = 4235). * fix(messenger): thread echo events by recipient PSID in parse_message (codex review) For a Messenger echo event (a bot-sent message echoed back), sender.id is the Page ID and recipient.id is the user's PSID — the reverse of a normal inbound message. parse_message previously keyed the thread ID off sender.id unconditionally, so a replayed echo was threaded under messenger:<page id> instead of messenger:<user PSID>, causing fetch_messages('messenger:<PSID>') to miss the bot's own echoed message. Branch on message.is_echo so the thread ID keys off recipient.id for echoes (matching _handle_echo) and sender.id otherwise. Non-echo behavior unchanged. https://claude.ai/code/session_01FyMxQn2BEAzmwKS1GZczKj * build(messenger): add messenger extra declaring aiohttp (codex review) The Messenger adapter lazy-imports aiohttp on its runtime paths (initialize, send, profile lookup, attachment downloads), but the package ships `dependencies = []` and had no `messenger` optional-dependencies extra. A base install enabling only Messenger would raise `ModuleNotFoundError: aiohttp` the moment the adapter talks to Meta. Add `messenger = ["aiohttp>=3.9"]`, matching the sibling aiohttp adapters (telegram/whatsapp/teams/linear). The `all` extra already includes aiohttp>=3.9, so no change there. https://claude.ai/code/session_01FyMxQn2BEAzmwKS1GZczKj --------- Co-authored-by: Claude <noreply@anthropic.com> (cherry picked from commit 3ad373a) https://claude.ai/code/session_013zwTcMek5rNqBTQvs2oF64 * feat(ai): add create_chat_tools tool factory (PR 2 of 3) (#122) * feat(ai): add create_chat_tools tool factory (PR 2 of 3) (vercel/chat#492) Port of `packages/chat/src/ai/tools.ts` (plus the supporting `tools/{channels,messages,reactions,threads,users}.ts` and `types.ts`) introduced by vercel/chat#492. Builds on top of #116, which moved `chat_sdk.ai` from a single module to a package so `tools.py` has a home. `create_chat_tools(chat, preset=, require_approval=, overrides=)` returns a mapping of tool-name -> `ChatTool` dataclass holding a description, JSON-Schema-shaped `input_schema`, an async `execute` callable, and the `needs_approval` flag. Three presets (`reader` / `messenger` / `moderator`) and per-tool overrides match the upstream API surface verbatim. Individual factories (`post_message`, `add_reaction`, ...) are also exported so callers can cherry-pick. PR 3 will wire these tools into the existing handler paths; this PR adds the surface only. Validation: uv run ruff check src/ tests/ -> clean uv run ruff format --check src/ tests/ -> clean uv run python scripts/audit_test_quality.py -> 0 hard failures uv run pytest tests/ -q -> 4081 passed, 3 skipped Refs #98, #109. * fix(ai/tools): wrap ChatNotImplementedError + tighten preset check (gemini review) Four small follow-ups to PR 2 of the chat-ai port from Gemini's review: - Import `ChatNotImplementedError` alongside `ChatError`. - Wrap `fetch_channel_messages` invocation in `try/except ChatNotImplementedError` and re-raise as `ChatError` (preserves cause). The early `getattr(adapter, "fetch_channel_messages", None) is None` branch only catches missing attributes; `BaseAdapter` exposes the attribute as a stub that itself raises `ChatNotImplementedError`, so without this wrap the tool surfaces a different exception type than callers expect. Matches the `Chat.get_user` pattern. - Same wrap for `list_threads`. - `_resolve_preset_tools` now distinguishes a single `str` preset from any iterable of presets via `isinstance(preset, str)` rather than `isinstance(preset, list)`, so tuples/sets/Sequences work correctly. Two new regression tests (`test_fetch_channel_messages_wraps_not_implemented`, `test_list_threads_wraps_not_implemented`) drive an `AsyncMock` whose `side_effect` is `ChatNotImplementedError`, assert the surfaced exception is `ChatError` with the expected message, and pin the cause chain (`__cause__ is ChatNotImplementedError`). https://claude.ai/code/session_01FyMxQn2BEAzmwKS1GZczKj * fix(ai/tools): isolate per-tool input schemas to prevent mutation bleed (review) * fix(ai/tools): pass list_threads options as keyword (codex P2) `MockAdapter.list_threads` is declared as `list_threads(self, channel_id, **kwargs)`, so the tool's positional `list_method(channel_id, ListThreadsOptions(...))` raised `TypeError` for any consumer wiring `create_chat_tools` into MockAdapter (the SDK's own mock). Production adapters accept the kwarg form just as readily as the positional form, so the change is universally safe. The existing `test_list_threads_projects_summaries` used `AsyncMock(return_value=...)` which masked the TypeError by replacing the real `MockAdapter.list_threads` entirely. New `test_list_threads_uses_keyword_options` exercises the real MockAdapter and asserts the default empty result — fails on the old positional call with `TypeError`, passes after. https://claude.ai/code/session_01FyMxQn2BEAzmwKS1GZczKj * style(ai/tools): apply is-not-None idiom + hoist core import (audit nits) --------- Co-authored-by: Claude <noreply@anthropic.com> (cherry picked from commit bf66edb) https://claude.ai/code/session_013zwTcMek5rNqBTQvs2oF64 * feat(chat): process_message returns the handler task + 4.29 fidelity alignment Ports the core slice of vercel/chat#444 (processMessage returns the inner task as Promise<void>): process_message now returns the eagerly-started asyncio.Task so streaming callers can await full handler completion and observe handler exceptions; wait_until keeps swallowed-error semantics. Fidelity alignment for the chat@4.29.0 pin: - MAPPING follows upstream's ai.test.ts split into ai/messages.test.ts + ai/index.test.ts (vercel/chat#492) - tests/test_ai_tools.py: 14 tests renamed to converter-exact names so the strict checker exact-matches the upstream it() titles - new faithful ports in tests/test_chat_faithful.py: the #444 awaitable test, plus the #459/#495 queue/burst subject-rehydration tests, skipif- gated on BaseAdapter.fetch_subject so they activate when PR #131 lands https://claude.ai/code/session_013zwTcMek5rNqBTQvs2oF64 * release: 0.4.29 — version cut, CHANGELOG consolidation, fidelity re-pin, ai example - pyproject 0.4.29a2 -> 0.4.29; README status + 9-platform claims - CHANGELOG: consolidated 0.4.29 release entry (0.4.29a3 notes folded in) + backfilled 0.4.27.1 section - lint.yml upstream clone re-pinned chat@4.26.0 -> chat@4.29.0 - docs/UPSTREAM_SYNC.md: version-table rows for 0.4.27.1/0.4.29; Known Non-Parity rows for GitHub octokit / Linear linear_client getters (no Python SDK object to expose), @chat-adapter/tests kit, and the Teams microsoft-teams-apps migration deferral to 0.4.30 (issue #93) - CLAUDE.md: pin references moved to chat@4.29.0 - examples/ai_tools_example.py + README AI section (chat/ai PR 3 of 3, design #109) - 0.4.30 wave tracking issue: #135 https://claude.ai/code/session_013zwTcMek5rNqBTQvs2oF64 * feat(slack): native markdown_text for outgoing messages (vercel/chat#440) Port of upstream 3546b3f. Slack natively renders markdown via the markdown_text parameter on chat.postMessage / postEphemeral / update / scheduleMessage, so the adapter passes markdown through directly instead of converting to mrkdwn. - str / PostableRaw messages still go to text (preserves literal *). - PostableMarkdown / PostableAst go to markdown_text (12k char limit). - to_blocks_with_table, _mdast_table_to_slack_block, and the _render_with_table_blocks call sites are removed (tables now ride along in markdown_text). - SlackMarkdownConverter alias removed; use SlackFormatConverter. - render_formatted(ast) / from_ast now return standard markdown (was mrkdwn) via stringify_markdown. - response_url payloads reject markdown_text (no_text), so those render through the retained mrkdwn node renderer (to_response_url_text). - Incoming message events still arrive as mrkdwn and parse unchanged. https://claude.ai/code/session_013zwTcMek5rNqBTQvs2oF64 (cherry picked from commit f07815e327ef1f2ba8ff688b2eb593af818c4ab6) * feat(slack): external installation provider for bot token management (vercel/chat#467) Port of upstream c46fdb6. Adds SlackAdapterConfig.installation_provider (SlackInstallationProvider protocol) for multi-workspace apps using external token management (e.g. Vercel Connect). When set, the adapter bypasses internal StateAdapter storage for token lookups on incoming webhooks — the provider is authoritative (no state fallback) and read-only (set_installation / delete_installation / OAuth callback still write to internal state). Enterprise Grid support rides along: org-wide installs (is_enterprise_install) resolve by enterprise_id instead of team_id across event_callback, slash command, and interactive payload entry paths; RequestContext carries enterprise_id/is_enterprise_install; attachment fetch_metadata captures enterpriseId/isEnterpriseInstall (omitted when absent) and rehydrate_attachment routes through _resolve_token_for_team so the provider is honored after a JSON roundtrip. Composes with the existing bot_token resolver design (see docs/UPSTREAM_SYNC.md non-parity rows): a configured default bot_token (static or resolver) still selects single-workspace mode and bypasses per-installation resolution entirely; _get_token / client caches are untouched. https://claude.ai/code/session_013zwTcMek5rNqBTQvs2oF64 (cherry picked from commit ee1e30703514d8a871d7f64c0ccc17c0774a7d6d) * fix(discord): handle interactions in gateway-only mode (vercel/chat#490) Port of upstream b9b17cd. Discord sends interactions through either the Gateway or an Interactions Endpoint URL, not both — deployments without an endpoint URL receive interactions over the Gateway, and the adapter previously dropped them ("Forwarded Gateway event (no handler)"). The Python adapter is HTTP-interactions-only with a gateway-forwarder receiver, so the port lands on that surface: a forwarded GATEWAY_INTERACTION_CREATE event (raw wire-format INTERACTION_CREATE dispatch payload) is now acknowledged via the interaction callback REST endpoint — POST /interactions/{id}/{token}/callback with type 5 for slash commands / type 6 for components, the same wire calls upstream's resident discord.js handler makes via deferReply() / deferUpdate() — then routed through the existing slash-command and action handler paths. Deferred slash responses resolve exactly like HTTP ones (post_message PATCHes the @original webhook message). Callback path segments are URL-quoted (hazard #12) and defer failures are logged without dispatching the handler, matching upstream's listener-level catch. Also aligns slash-command boolean option flattening with TS String(true) — "status true", not Python str(True) "status True" — pinned by the ported upstream test. https://claude.ai/code/session_013zwTcMek5rNqBTQvs2oF64 (cherry picked from commit 458428afc383b57704e6fe7c6d8abde23235afda) * feat(chat): Transcripts API + rename message history cache to thread_history (vercel/chat#448) Port of upstream 46d183b (chat@4.29.0). Rename (with back-compat, mirroring upstream): - message_history.py -> thread_history.py; MessageHistoryCache -> ThreadHistoryCache. Old module path kept as a deprecated re-export shim. - ChatConfig.thread_history added; deprecated ChatConfig.message_history still read, thread_history wins when both are set. - Adapter.persist_thread_history added; deprecated persist_message_history still honored (either flag enables persistence). Telegram and WhatsApp adapters switch to the new flag, matching upstream. - State storage key prefix "msg-history:" is deliberately unchanged so existing persisted data is not orphaned. New Transcripts API: - transcripts.py: TranscriptsApiImpl (append/list/count/delete) keyed by a cross-platform user key, backed by StateAdapter.append_to_list. delete() writes a tombstone via append_to_list(max_length=1) because state.delete only addresses the k/v namespace on non-memory adapters. - ChatConfig.transcripts + ChatConfig.identity (IdentityResolver); the constructor raises when transcripts is set without identity. Inbound dispatch resolves message.user_key once per message via the resolver. - chat.transcripts accessor raises when not configured (fail loudly). - New types: TranscriptEntry, TranscriptsConfig, TranscriptRole, AppendInput, AppendOptions, ListQuery, CountQuery, DeleteTarget, DeleteResult, IdentityContext, IdentityResolver, DurationString. Tests: test_message_history.py renamed to test_thread_history.py (names aligned to thread-history.test.ts titles, deprecated-alias test added); chat.test.ts persistThreadHistory block ported into test_chat_faithful.py; new test_transcripts.py and test_transcripts_wiring.py port transcripts.test.ts and transcripts-wiring.test.ts 1:1. https://claude.ai/code/session_013zwTcMek5rNqBTQvs2oF64 (cherry picked from commit 765420dc4d21804b19f1d8eccddebc3a392b0e61) * feat(chat): add callback_url to buttons and modals (vercel/chat#454) https://claude.ai/code/session_013zwTcMek5rNqBTQvs2oF64 (cherry picked from commit 20a5fd10a2dd46094fdf812ff47628a12bb9d362) * sync(4.29): fidelity scope extension, divergence rows, final test ports - MAPPING: four new chat@4.29.0 core test files mapped (callback-url, thread-history, transcripts, transcripts-wiring) — 12 of 19 files now in scope; stale 4.26-era references in the script/template updated - fidelity_baseline.json regenerated at chat@4.29.0 (731 TS tests in scope, 0 missing; strict mode green) - docs/UPSTREAM_SYNC.md: Known Non-Parity rows from the port wave — GitHub octokit / Linear linear_client getters, @chat-adapter/tests kit, Teams modal-submit webhook options slice, jsx-runtime callbackUrl props, Transcripts API Python adaptations, Slack legacy mrkdwn renderer scope note, Discord gateway-only interactions surface, and the Teams microsoft-teams-apps deferral to 0.4.30 - tests/test_chat_faithful.py: the [Slash Commands] duplicate-title openModal-unsupported port (matcher counts names as a multiset) and the #459 subject-wiring port (skipif-gated on BaseAdapter.fetch_subject, activates when PR #131 lands) https://claude.ai/code/session_013zwTcMek5rNqBTQvs2oF64 * fix(types): restore pyrefly conformance — history flags off the Adapter Protocol CI's Lint & Type Check job failed on pyrefly (43 errors) while every other step was green: - persist_thread_history / persist_message_history were declared on the structural Adapter Protocol, making them REQUIRED members and breaking conformance for every adapter that satisfies the Protocol without extending BaseAdapter (the exact trap documented in PR #131). Moved to BaseAdapter only, with a NOTE on the Protocol; the SDK reads both flags via getattr. - MessengerAdapter (recovered #124 — the original cause of the failure on the recovery push) now defines get_user, mirroring BaseAdapter's ChatNotImplementedError contract; the real Graph-backed lookup remains issue #132. Also: _map_attachment_type returns the Attachment.type Literal, and the user-profile cache write is typed. - transcripts: retention_ms coerced to int (append_to_list ttl_ms contract; fractional milliseconds are meaningless). uv run pyrefly check: 0 errors (matches the origin/main baseline). https://claude.ai/code/session_013zwTcMek5rNqBTQvs2oF64 * fix(chat): await modal-context state write before opening modal `_store_modal_context` was synchronous and scheduled the state write as a fire-and-forget task before `open_modal` was awaited. With a remote (Redis/Postgres) state backend a fast modal submit could race ahead of the write and miss the stored callbackUrl/channel, silently breaking modal callback POSTs and context restoration. Make `_store_modal_context` `async` and `await` it at both modal-opening call sites (slash + action paths) before `adapter.open_modal`, matching upstream where `openModal` awaits `storeModalContext` (chat.ts :1280/:1342/:1554). Remove the now-unnecessary `asyncio.sleep(0.02)` workaround in the modal-callback test; the context is persisted deterministically by the time `open_modal` returns. https://claude.ai/code/session_013zwTcMek5rNqBTQvs2oF64 * fix(messenger): use `is not None` for config credential fallbacks Convert the `config.X or <default/env>` truthiness patterns to `config.X if config.X is not None else <default/env>` for api_version (adapter) and the app_secret / page_access_token / verify_token env fallbacks (config helpers), per the TS->Python truthiness port rule. Mirrors upstream's `??` null-coalescing (index.ts:92/931). Behaviour is unchanged for real configs; an explicit value now wins over the fallback even if it is falsy. https://claude.ai/code/session_013zwTcMek5rNqBTQvs2oF64 * fix(types): widen ChatInstance.process_message return annotation The `ChatInstance` Protocol annotated `process_message` as `-> None`, but the concrete `Chat.process_message` returns `asyncio.Task[None] | None` (it hands back the handler task so streaming callers can await it). Widen the Protocol annotation to match the implementation and upstream's interface-equals-impl typing. No runtime impact. https://claude.ai/code/session_013zwTcMek5rNqBTQvs2oF64 --------- Co-authored-by: Claude <noreply@anthropic.com>
…ubject # Conflicts: # src/chat_sdk/types.py # tests/test_chat_faithful.py
| await asyncio.sleep(0.005) | ||
| await chat.handle_incoming_message(adapter, "slack:C123:1234.5678", _mk("msg-burst-2", "Hey @slack-bot second")) | ||
| await chat.handle_incoming_message(adapter, "slack:C123:1234.5678", _mk("msg-burst-3", "Hey @slack-bot third")) | ||
| await task |
Ports the core
message.subjectfeature from upstreameb5f94a(vercel/chat PR #459,feat(chat): message.subject + adapter client access,chat@4.29.0).Worklist item P0-1. Tracks #98.
What's ported (CORE type system + chat binding only)
MessageSubjectdataclass insrc/chat_sdk/types.py— snake_case mirror of upstream's TSMessageSubjectinterface (id,type,raw,assignee,author,description,labels,status,title,url). Plus a smallMessageSubjectPartydataclass for the{ id, name }shape upstream uses inline forassignee/author. Typed dataclass, not a raw dict.fetch_subject(raw) -> MessageSubject | Noneadapter hook onBaseAdapter(default returnsNone). See the Protocol-placement note below.Message.subjectasync accessor backed by upstream's WeakMap +_subjectPromisepattern:fetch_subject;Nonewhen no adapter is registered, the adapter has no hook, the hook returnsNone, or the hook raises (failures swallowed, mirroring upstream.catch(() => null));await message.subjectdoes NOT re-callfetch_subject; concurrent awaits share one in-flight resolution.Chat._dispatch_to_handlersregisters the owning adapter for every dispatched message viaset_message_adapter. This is the single convergence point all dispatch paths (subscribed / mention / DM / pattern / queue / debounce / burst / rehydrated) flow through — upstream callssetMessageAdapterin 3 scattered spots (handleIncomingMessage+ tworehydrateMessagebranches), all of which reach this method here.WeakMap hashability decision (the load-bearing call)
Upstream uses
const adapterMap = new WeakMap<Message, Adapter>()— weak on the Message key, strong on the Adapter value, keyed by object identity.Messagein this repo is a plain@dataclass(eq=True), which makes instances unhashable (verified:hash(msg)raisesTypeError, andweakref.WeakKeyDictionary[Message, Adapter]raisesTypeError: unhashable type: 'Message').weakref.ref(msg)does work on a plain dataclass.Chosen approach: a plain
dict[int, Adapter]keyed byid(message)+ a per-messageweakref.finalizecallback that pops the entry on GC.id()), matchingWeakMap.id()-reuse hole — the entry is removed before CPython can recycle the id.Messagetoeq=False/frozen=True, which would have restored an identity hash but silently changedMessage's public equality contract (andWeakValueDictionaryis the wrong shape — it would weakly hold the long-lived Adapter, not the Message).The cache (
_subject_future) is a dataclass field withinit=False, compare=False, repr=False, so it stays out of__init__, equality, andrepr. The accessor returns a fresh coroutine each access (soawait msg.subjectworks repeatedly andasyncio.run(msg.subject)is valid) while sharing a singleensure_future-scheduled resolution underneath.Protocol placement note
fetch_subjectis declared onBaseAdapteronly, NOT on theAdapterProtocol. Adding it to the structuralProtocolmakes it a required attribute and breaks every adapter that doesn't define it (pyrefly flagged 36 such errors). This matches how every other optional hook here (stream,open_dm,rehydrate_attachment,get_channel_visibility, ...) is declared — onBaseAdapter, not the Protocol.Message.subjectreads the hook viagetattr(adapter, "fetch_subject", None), so presence is fully optional regardless of base class.Follow-ups (out of scope)
fetch_subject— depends on the adapter exposing its native.octokitclient (doesn't exist yet).fetch_subject— depends on the adapter exposing its native.linear_client(doesn't exist yet).Both are the "adapter client access" half of upstream #459 and are deferred until those adapters expose their native clients.
Tests
tests/test_types.py:TestMessageSubjectDataclass— minimal required fields, all fields incl. nestedMessageSubjectParty.TestMessageSubject(mirrors upstreammessage.test.tsdescribe("subject")): None when no adapter; None when adapter has nofetch_subject; returns adapter result; awaited twice -> called once; null result cached; concurrent access shares one call; raising hook -> None; raw payload passed through.TestSetMessageAdapterWeakref— registration doesn't crash on unhashable Message; entry removed on GC; distinct messages map to distinct adapters.tests/test_chat_faithful.py:TestSubjectBinding— handler resolvesmessage.subjectvia the adapter hook after real dispatch;Nonewhen the adapter has no hook.Validation
uv run ruff check src/ tests/— All checks passeduv run ruff format --check src/ tests/— 195 files already formatteduv run python scripts/audit_test_quality.py— Hard failures: 0uv run pytest tests/ -q— 4062 passed, 3 skipped (no regressions)uv run pyrefly check— 0 errors (matchesorigin/mainbaseline; no new errors)scripts/verify_test_fidelity.py --strictis not runnable in this environment (the upstreamchat@4.29.0checkout movedai.test.tsintoai/, so the script's 4.26.0-pinned path resolution fails identically on untouchedorigin/main);message.test.tsis not in its MAPPING scope. CHANGELOG /pyproject.tomlintentionally untouched (cut PR handles versioning).https://claude.ai/code/session_01FyMxQn2BEAzmwKS1GZczKj
Generated by Claude Code