You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Add three core thread APIs from 4.35–4.41: Thread.reply(target, message) (native reply to a specific message), Thread.mark_as_read(message=None) (read receipt), and a post_ephemeral adapter hook that receives the caller's PostEphemeralOptions and may return None. All adapter hooks are optional and declared on BaseAdapter, never on the Adapter Protocol. This PR adds only the core surface; adapter implementations come in #228, #239 and #219.
Upstream changes
83ede7ea feat(chat): add message reply support (#819) — chat@4.38.0 — optional Adapter.reply(threadId, messageId, message) + Thread.reply(target, message). A string target resolves only against currentMessage/recentMessages (never fetched); a cross-thread Message is rejected (plain Error); streams are buffered into one markdown post (" " if empty); callback URLs are processed; SentMessage.replyTo carries the target; the reply is appended to history.
18d4a230 feat(chat): add mark as read support (#820) — chat@4.38.0 — optional Adapter.markAsRead(threadId, messageId, message?) + Thread.markAsRead(message?), defaulting to the current message; ChatErrorMESSAGE_REQUIRED/THREAD_MISMATCH, NotImplementedError "read-receipts". mock-adapter.ts gains markAsRead (enabled by default).
bfee00af feat(gmail): add email adapter and standalone primitives (#939) — chat@4.41.0 — core slice only (not in this issue's original refs; it is where the options/Optional change lives): Adapter.postEphemeral(threadId, userId, message, options?) returns EphemeralMessage | null; Thread/Channel.postEphemeral forward options. The Gmail adapter is [4.41/GMAIL] Gmail adapter (demand-gated): primitives + Chat adapter #241.
Current Python behavior
grep -rn 'def reply\b\|mark_as_read' src/chat_sdk → only adapters/whatsapp/adapter.py:1093async def mark_as_read(self, message_id: str), whose one-argument signature collides with the new (thread_id, message_id, message=None) contract.
shared/mock_adapter.py has start_typing (:132) but no mark_as_read/reply.
Against chat@4.41.1 the fidelity script reports 14 of the 16 new markAsRead/reply() tests missing; the other 2 are only fuzzy-matched to unrelated tests, so port all 16 under exact names.
Scope
thread.py: async def reply(self, target: str | Message, message) -> SentMessage, in upstream order: capability check → resolve via private _find_known_message(id) (_current_message, then _recent_messages) → cross-thread check → buffer async iterables through from_full_stream into PostableMarkdown (" " if strip() is empty) → _process_callback_urls → adapter.reply(self._id, target_id, postable) → _create_sent_message(..., reply_to=target_message) → history append.
_create_sent_message: add reply_to: Message | None = None; SentMessage.edit() (_edit, thread.py:1193-1196) returns _create_sent_message(message_id, new_content, thread_id, reply_to), carrying both the resolved thread_id (today dropped, so an edited message posted with a thread_id_override reverts to self._id; handed off from [4.41/C3] Conversation context + AI tool scoping (read & write guards, strict_scope) #195) and reply_to (upstream createSentMessage(messageId, postable, threadId, replyTo)).
types.py: document optional reply/mark_as_read on BaseAdapter; add reply/mark_as_read to the Thread Protocol (types.py:1896).
post_ephemeral options passthrough: BaseAdapter.post_ephemeral(self, thread_id, user_id, message, *, options: PostEphemeralOptions | None = None) -> EphemeralMessage | None; ThreadImpl/ChannelImpl.post_ephemeral forward options (see compatibility note); Slack and Google Chat accept and ignore options.
shared/mock_adapter.py: mirror upstream mock-adapter.ts — a recording mark_as_readAsyncMock present by default (the "does not support" test removes it, as upstream sets adapter.markAsRead = undefined); no reply by default (reply tests attach one).
Export any new public names from chat_sdk/__init__.py.
Optional hook detection. In-repo adapters do not subclass BaseAdapter. Detect hooks with getattr(self.adapter, "reply", None) (the capability-check style of schedule at thread.py:669, which uses hasattr). Absent hook → raise ChatNotImplementedError(adapter.name, "replies") / (…, "read-receipts"). Do not add the hooks to the Adapter Protocol (see the note at types.py:1440-1445): Protocol members become required.
Custom-adapter compatibility for post_ephemeral(options=...): 3-argument third-party adapters would raise TypeError. Recommended default: a cached helper (e.g. chat_sdk._compat.accepts_kwarg(fn, "options") via inspect.signature, treating **kwargs as accepting); pass options= only when accepted. Shared with [4.41/C7] Turn cancellation: abort_turn, thread.signal, typing options, agent-session events #201 (start_typing(options=)); whichever PR lands first adds it.
Adapter post_ephemeral returning None: return it unchanged (no DM fallback), as upstream does.
Errors. Python ChatError has no code; raise ChatError with upstream's exact messages ("A message is required outside a message handler", "Cannot mark a message from another thread as read"). Upstream's reply cross-thread check throws a plain Error ("Cannot reply to a message from another thread"); recommended default: ChatError with that message for consistency (tests match on message). Do not add a code attribute.
Stream buffering: reuse post()'s async-iterable check; keep text and markdown_text chunks, drop task_update/plan_update.
Truthiness: mark_as_read(message=None) means "current message"; "" is an explicit (bad) id. Use is None, not or.
reply() with a string id unknown to the thread still sends with reply_to=None; it must not fetch.
"marks the current message when no target is provided"; "marks an explicit message id"; "marks an explicit message"; "rejects a message from another thread"; "requires a target outside a message handler"; "throws when the adapter does not support read receipts"; "preserves the current message through serialization"
[reply()]
"throws when the adapter does not support replies"; "delegates a message id and content to the adapter"; "accepts a Message and preserves it on the result"; "rejects a Message from another thread"
"converts JSX cards before delegating" (port as a CardElement-dict passthrough test; do not add an assert True absorber)
"buffers streams into one markdown reply"; "falls back to a space when a stream produces no text"; "resolves a message id against messages the thread knows"; "leaves replyTo undefined for an unknown message id"
bfee00af changed existing postEphemeral tests in thread.test.ts/channel.test.ts to assert the adapter receives { fallbackToDM: … }; update the matching Python assertions.
Python-specific (AsyncMock for every hook): a 3-argument custom post_ephemeral still works via the probe; an adapter returning None from post_ephemeral yields None; SentMessage.edit() after reply() keeps reply_to; editing a message created with a thread_id_override keeps that thread_id on the returned SentMessage.
Acceptance criteria
Full validation command from CLAUDE.md passes.
All 16 [markAsRead]/[reply()] tests exist under their exact names. Non-strict fidelity against chat@4.41.1 reports them matched; report the count.
docs/UPSTREAM_SYNC.md documents the signature probe (Python-only compatibility layer) and the ChatError-without-code choice.
Consumer impact: low. Additive APIs; Slack/Teams streaming unaffected; the post_ephemeral signature change is kept compatible for custom adapters by the probe.
Summary
Add three core thread APIs from 4.35–4.41:
Thread.reply(target, message)(native reply to a specific message),Thread.mark_as_read(message=None)(read receipt), and apost_ephemeraladapter hook that receives the caller'sPostEphemeralOptionsand may returnNone. All adapter hooks are optional and declared onBaseAdapter, never on theAdapterProtocol. This PR adds only the core surface; adapter implementations come in #228, #239 and #219.Upstream changes
83ede7eafeat(chat): add message reply support (#819) — chat@4.38.0 — optionalAdapter.reply(threadId, messageId, message)+Thread.reply(target, message). A string target resolves only againstcurrentMessage/recentMessages(never fetched); a cross-threadMessageis rejected (plainError); streams are buffered into one markdown post (" "if empty); callback URLs are processed;SentMessage.replyTocarries the target; the reply is appended to history.18d4a230feat(chat): add mark as read support (#820) — chat@4.38.0 — optionalAdapter.markAsRead(threadId, messageId, message?)+Thread.markAsRead(message?), defaulting to the current message;ChatErrorMESSAGE_REQUIRED/THREAD_MISMATCH,NotImplementedError"read-receipts".mock-adapter.tsgainsmarkAsRead(enabled by default).160140e3feat(teams): add targeted ephemeral messages (#737) — chat@4.35.0 — core change is doc-only (postEphemeralJSDoc lists Teams targeted messages); Teams implementation is [4.41/T4] Teams outbound: reactions, targeted ephemeral messages, placeholder-aware native streaming #219.bfee00affeat(gmail): add email adapter and standalone primitives (#939) — chat@4.41.0 — core slice only (not in this issue's original refs; it is where the options/Optional change lives):Adapter.postEphemeral(threadId, userId, message, options?)returnsEphemeralMessage | null;Thread/Channel.postEphemeralforwardoptions. The Gmail adapter is [4.41/GMAIL] Gmail adapter (demand-gated): primitives + Chat adapter #241.Current Python behavior
grep -rn 'def reply\b\|mark_as_read' src/chat_sdk→ onlyadapters/whatsapp/adapter.py:1093async def mark_as_read(self, message_id: str), whose one-argument signature collides with the new(thread_id, message_id, message=None)contract.ThreadImplhas noreply/mark_as_read;_create_sent_message(thread.py:1180-1186) takes no reply target;Message/SentMessagelackreply_to(added by [4.41/C2a] Core mentions & message model: tri-state is_mention, mention regex, Author.email/is_system, Message.reply_to #192).thread.py:603-636andchannel.py:334-360post_ephemeral(user, message, options)callself.adapter.post_ephemeral(self._id, user_id, message)withoutoptions.types.py:1549-1556BaseAdapter.post_ephemeral(thread_id, user_id, message) -> EphemeralMessagehas nooptionsparameter and never returnsNone.adapters/slack/adapter.py:4072,adapters/google_chat/adapter.py:1567.PostEphemeralOptions(types.py:1231-1234) hasfallback_to_dm: bool = True.shared/mock_adapter.pyhasstart_typing(:132) but nomark_as_read/reply.markAsRead/reply()tests missing; the other 2 are only fuzzy-matched to unrelated tests, so port all 16 under exact names.Scope
thread.py:async def reply(self, target: str | Message, message) -> SentMessage, in upstream order: capability check → resolve via private_find_known_message(id)(_current_message, then_recent_messages) → cross-thread check → buffer async iterables throughfrom_full_streamintoPostableMarkdown(" "ifstrip()is empty) →_process_callback_urls→adapter.reply(self._id, target_id, postable)→_create_sent_message(..., reply_to=target_message)→ history append._create_sent_message: addreply_to: Message | None = None;SentMessage.edit()(_edit,thread.py:1193-1196) returns_create_sent_message(message_id, new_content, thread_id, reply_to), carrying both the resolvedthread_id(today dropped, so an edited message posted with athread_id_overridereverts toself._id; handed off from [4.41/C3] Conversation context + AI tool scoping (read & write guards, strict_scope) #195) andreply_to(upstreamcreateSentMessage(messageId, postable, threadId, replyTo)).thread.py:async def mark_as_read(self, message: str | Message | None = None) -> None; error order: capability →MESSAGE_REQUIRED→THREAD_MISMATCH; callsadapter.mark_as_read(self._id, id, message_or_None).types.py: document optionalreply/mark_as_readonBaseAdapter; addreply/mark_as_readto theThreadProtocol (types.py:1896).post_ephemeraloptions passthrough:BaseAdapter.post_ephemeral(self, thread_id, user_id, message, *, options: PostEphemeralOptions | None = None) -> EphemeralMessage | None;ThreadImpl/ChannelImpl.post_ephemeralforwardoptions(see compatibility note); Slack and Google Chat accept and ignoreoptions.shared/mock_adapter.py: mirror upstreammock-adapter.ts— a recordingmark_as_readAsyncMockpresent by default (the "does not support" test removes it, as upstream setsadapter.markAsRead = undefined); noreplyby default (reply tests attach one).chat_sdk/__init__.py.Out of scope
mark_as_readshim and WhatsApp/Messenger reply/read: [4.41/W4] WhatsApp & Messenger: mark_as_read, native replies, code fences, guarded downloads #239. Telegram replies: [4.41/TG4] Telegram replies: replied-to context, reply-to-bot as mention, native replies, portable file data #228. Teams targeted ephemeral +used_fallback: [4.41/T4] Teams outbound: reactions, targeted ephemeral messages, placeholder-aware native streaming #219. Gmail: [4.41/GMAIL] Gmail adapter (demand-gated): primitives + Chat adapter #241.Message.reply_tomodel/serialization: [4.41/C2a] Core mentions & message model: tri-state is_mention, mention regex, Author.email/is_system, Message.reply_to #192.processCallbackUrlsordering inpost_ephemeral): [4.41/CB] Callback tokens: consume once, bind to conversation, shorter TTL, preserve button fields #194. If CB has merged, pass the thread scope fromreply().Porting notes
BaseAdapter. Detect hooks withgetattr(self.adapter, "reply", None)(the capability-check style ofscheduleatthread.py:669, which useshasattr). Absent hook → raiseChatNotImplementedError(adapter.name, "replies")/(…, "read-receipts"). Do not add the hooks to theAdapterProtocol (see the note attypes.py:1440-1445): Protocol members become required.post_ephemeral(options=...): 3-argument third-party adapters would raiseTypeError. Recommended default: a cached helper (e.g.chat_sdk._compat.accepts_kwarg(fn, "options")viainspect.signature, treating**kwargsas accepting); passoptions=only when accepted. Shared with [4.41/C7] Turn cancellation: abort_turn, thread.signal, typing options, agent-session events #201 (start_typing(options=)); whichever PR lands first adds it.post_ephemeralreturningNone: return it unchanged (no DM fallback), as upstream does.ChatErrorhas nocode; raiseChatErrorwith upstream's exact messages ("A message is required outside a message handler", "Cannot mark a message from another thread as read"). Upstream's reply cross-thread check throws a plainError("Cannot reply to a message from another thread"); recommended default:ChatErrorwith that message for consistency (tests match on message). Do not add acodeattribute.post()'s async-iterable check; keep text andmarkdown_textchunks, droptask_update/plan_update.mark_as_read(message=None)means "current message";""is an explicit (bad) id. Useis None, notor.reply()with a string id unknown to the thread still sends withreply_to=None; it must not fetch.thread.mark_as_read()on WhatsApp calls the old one-argument method and raisesTypeError. Acceptable (new API), but note it in the CHANGELOG.Tests
packages/chat/src/thread.test.ts→tests/test_thread_faithful.py(fidelity-mapped):assert Trueabsorber)postEphemeraltests inthread.test.ts/channel.test.tsto assert the adapter receives{ fallbackToDM: … }; update the matching Python assertions.AsyncMockfor every hook): a 3-argument custompost_ephemeralstill works via the probe; an adapter returningNonefrompost_ephemeralyieldsNone;SentMessage.edit()afterreply()keepsreply_to; editing a message created with athread_id_overridekeeps thatthread_idon the returnedSentMessage.Acceptance criteria
[markAsRead]/[reply()]tests exist under their exact names. Non-strict fidelity against chat@4.41.1 reports them matched; report the count.docs/UPSTREAM_SYNC.mddocuments the signature probe (Python-only compatibility layer) and theChatError-without-code choice.post_ephemeraladapters now receiveoptions=;Thread.reply/mark_as_readraiseChatNotImplementedErroron adapters without support (none in-repo until [4.41/W4] WhatsApp & Messenger: mark_as_read, native replies, code fences, guarded downloads #239/[4.41/TG4] Telegram replies: replied-to context, reply-to-bot as mention, native replies, portable file data #228).Dependencies
Blocked by #199 (and transitively #192 for
Message.reply_to). Blocks #219, #228, #239, #241, #203.Metadata
post_ephemeralsignature change is kept compatible for custom adapters by the probe.Part of #184.