feat(ai): add create_chat_tools tool factory (PR 2 of 3)#122
Conversation
…#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.
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
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 ports the TypeScript Chat SDK tool factory to Python, introducing the ChatTool class and the create_chat_tools orchestrator to expose chat operations to AI agents, supported by a comprehensive test suite. The review feedback suggests improving error handling by catching ChatNotImplementedError when invoking optional adapter methods (such as fetch_channel_messages and list_threads) and raising a clean ChatError. Additionally, it recommends a more robust type check for the preset parameter by checking if it is a string rather than a list.
| from typing import Any, Literal | ||
|
|
||
| from chat_sdk.chat import Chat | ||
| from chat_sdk.errors import ChatError |
| cursor = args.get("cursor") | ||
| direction = args.get("direction", "backward") | ||
| result = await fetch_method( | ||
| channel_id, |
There was a problem hiding this comment.
Since fetch_channel_messages is an optional adapter method defined on BaseAdapter (which raises ChatNotImplementedError by default), calling it directly will raise ChatNotImplementedError instead of the expected ChatError if the adapter does not implement it. Wrap the call in a try...except ChatNotImplementedError block to raise ChatError with a clean message, matching the pattern used in Chat.get_user. Ensure that the await statement is placed inside the try block, and always accompany the fix with a regression test that verifies the exception is correctly logged and propagated.
try:
result = await fetch_method(
channel_id,
FetchOptions(limit=limit, cursor=cursor, direction=direction),
)
except ChatNotImplementedError as exc:
raise ChatError(f'Adapter "{adapter_name}" does not support fetching channel messages') from excReferences
- When handling exceptions for asynchronous operations, ensure that the
awaitstatement is placed inside thetryblock so that exceptions are caught during execution, and always accompany the fix with a regression test that verifies the exception is correctly logged and propagated.
|
|
||
| limit = args.get("limit", 20) | ||
| cursor = args.get("cursor") | ||
| result = await list_method(channel_id, ListThreadsOptions(limit=limit, cursor=cursor)) |
There was a problem hiding this comment.
Since list_threads is an optional adapter method defined on BaseAdapter (which raises ChatNotImplementedError by default), calling it directly will raise ChatNotImplementedError instead of the expected ChatError if the adapter does not implement it. Wrap the call in a try...except ChatNotImplementedError block to raise ChatError with a clean message, matching the pattern used in Chat.get_user. Ensure that the await statement is placed inside the try block, and always accompany the fix with a regression test that verifies the exception is correctly logged and propagated.
try:
result = await list_method(channel_id, ListThreadsOptions(limit=limit, cursor=cursor))
except ChatNotImplementedError as exc:
raise ChatError(f'Adapter "{adapter_name}" does not support listing threads') from excReferences
- When handling exceptions for asynchronous operations, ensure that the
awaitstatement is placed inside thetryblock so that exceptions are caught during execution, and always accompany the fix with a regression test that verifies the exception is correctly logged and propagated.
|
|
||
|
|
||
| def _resolve_preset_tools(preset: ChatToolPreset | list[ChatToolPreset]) -> set[str]: | ||
| presets: list[str] = list(preset) if isinstance(preset, list) else [preset] |
There was a problem hiding this comment.
Using isinstance(preset, list) to check if preset is a collection of presets is fragile. If a user passes a tuple or set of presets, it will fail or behave unexpectedly because it will treat the entire collection as a single preset name. Checking isinstance(preset, str) to distinguish a single string from any other iterable collection is much more robust and idiomatic in Python.
presets: list[str] = [preset] if isinstance(preset, str) else list(preset)…emini 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
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 2a20605432
ℹ️ 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".
| limit = args.get("limit", 20) | ||
| cursor = args.get("cursor") | ||
| try: | ||
| result = await list_method(channel_id, ListThreadsOptions(limit=limit, cursor=cursor)) |
There was a problem hiding this comment.
Pass list thread options as a keyword
When the tool is used with the built-in MockAdapter (a common downstream test adapter), listThreads raises TypeError before returning anything because MockAdapter.list_threads is defined as list_threads(self, channel_id, **kwargs), so this second positional argument is rejected. Passing the options as options=... (or otherwise accommodating keyword-style adapters) keeps the production adapters working while making the new tool usable with the SDK’s own mock adapter.
Useful? React with 👍 / 👎.
`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
|
@codex review Generated by Claude Code |
|
Codex Review: Didn't find any major issues. Swish! ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
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". |
bf66edb
into
claude/port-chat-ai-module-move
….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>
PR 2 of 3 —
chat/aisubpath port (vercel/chat#492)Stacked on top of #116 (
claude/port-chat-ai-module-move), which did the structural module-to-package move only. This PR adds thecreate_chat_toolstool factory in the newchat_sdk.aipackage. PR 3 will wire these tools into the existing handler / consumer paths.GitHub will automatically retarget this PR to
mainonce #116 merges.Refs tracking issue #98 (overall 4.29 sync) and design issue #109 (chat-ai port design).
What's ported from vercel/chat#492
packages/chat/src/ai/tools.ts(createChatTools orchestrator)packages/chat/src/ai/types.ts(ChatBinding,ToolOptions,ToolOverrides)packages/chat/src/ai/tools/channels.ts—get_channel_infopackages/chat/src/ai/tools/messages.ts—post_message,post_channel_message,send_direct_message,edit_message,delete_messagepackages/chat/src/ai/tools/reactions.ts—add_reaction,remove_reactionpackages/chat/src/ai/tools/threads.ts—fetch_messages,fetch_channel_messages,fetch_thread,list_threads,get_thread_participants,subscribe_thread,unsubscribe_thread,start_typingpackages/chat/src/ai/tools/users.ts—get_userpackages/chat/src/ai/index.test.tsPublic API
Each tool is a
ChatTooldataclass:Individual factories (
post_message,add_reaction, ...) are also exported so callers can cherry-pick — matching upstream's per-symbol exports.Python idiom notes / divergences
ai)tool()helper andzodschemas. Python has no canonical agent runtime; rather than add a third-party schema validator as a hard dependency, each tool factory returns a plainChatTooldataclass holding a JSON-Schema-shapedinput_schemadict. Consumers that bind these into an actual agent runtime translate that dict to their schema layer. No new runtime deps added.postMessage,fetchChannelMessages, ...) so consumers that mix this with the JS SDK across language boundaries see the same tool ids.input_schema+inputSchema, etc.) so callers porting upstreamoverridesdicts get the same protection.ToolOverridesis a plaindict[str, Any]rather than aTypedDict; the upstream type is aPartial<Pick<Tool, ...>>over fields from the Vercel AI SDK package that don't have direct Python equivalents.Out of scope (PR 3)
create_chat_toolsinto existing handlers / consumer code paths.apps/docs/content/docs/ai/*).Files
src/chat_sdk/ai/tools.py(new)src/chat_sdk/ai/__init__.py(re-exports added)tests/test_ai_tools.py(new, 38 tests)Validation
Baseline (from #116) was 4043 passed, 3 skipped — this PR adds exactly the 38 new tests.
Load-bearing verification: each test was confirmed to fail when its corresponding production code is reverted (spot-checked by breaking
_resolve_approvaland watchingTestRequireApproval::test_per_tool_approval_overridesfail).🤖 Generated with Claude Code via claude-code-sdk-python
Generated by Claude Code