Skip to content

[4.41/C6] Thread.reply, Thread.mark_as_read, post_ephemeral options #200

Description

@patrick-chinchill

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 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; ChatError MESSAGE_REQUIRED/THREAD_MISMATCH, NotImplementedError "read-receipts". mock-adapter.ts gains markAsRead (enabled by default).
  • 160140e3 feat(teams): add targeted ephemeral messages (#737) — chat@4.35.0 — core change is doc-only (postEphemeral JSDoc lists Teams targeted messages); Teams implementation is [4.41/T4] Teams outbound: reactions, targeted ephemeral messages, placeholder-aware native streaming #219.
  • 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:1093 async def mark_as_read(self, message_id: str), whose one-argument signature collides with the new (thread_id, message_id, message=None) contract.
  • ThreadImpl has no reply/mark_as_read; _create_sent_message (thread.py:1180-1186) takes no reply target; Message/SentMessage lack reply_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-636 and channel.py:334-360 post_ephemeral(user, message, options) call self.adapter.post_ephemeral(self._id, user_id, message) without options.
  • types.py:1549-1556 BaseAdapter.post_ephemeral(thread_id, user_id, message) -> EphemeralMessage has no options parameter and never returns None.
  • In-repo implementations: adapters/slack/adapter.py:4072, adapters/google_chat/adapter.py:1567. PostEphemeralOptions (types.py:1231-1234) has fallback_to_dm: bool = True.
  • 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)).
  • thread.py: async def mark_as_read(self, message: str | Message | None = None) -> None; error order: capability → MESSAGE_REQUIRED → THREAD_MISMATCH; calls adapter.mark_as_read(self._id, id, message_or_None).
  • 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_read AsyncMock 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.

Out of scope

Porting notes

  • 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.
  • Until [4.41/W4] WhatsApp & Messenger: mark_as_read, native replies, code fences, guarded downloads #239 lands, thread.mark_as_read() on WhatsApp calls the old one-argument method and raises TypeError. Acceptable (new API), but note it in the CHANGELOG.

Tests

packages/chat/src/thread.test.ts → tests/test_thread_faithful.py (fidelity-mapped):

  • [markAsRead]
    • "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

Dependencies

Blocked by #199 (and transitively #192 for Message.reply_to). Blocks #219, #228, #239, #241, #203.

Metadata

  • Effort: M
  • Consumer impact: low. Additive APIs; Slack/Teams streaming unaffected; the post_ephemeral signature change is kept compatible for custom adapters by the probe.
  • Suggested branch: sync/4.41-c6

Part of #184.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions