Skip to content

[4.41/T4] Teams outbound: reactions, targeted ephemeral messages, placeholder-aware native streaming #219

Description

@patrick-chinchill

Summary

Ports three outbound Teams features from 4.35–4.41: reactions become real API calls (today warn-only no-ops); ephemeral messages become native in-channel targeted messages whose ids are recorded so later edits/deletes use the targeted endpoint; and an explicitly set fallback_streaming_placeholder_text shows as native DM status while group chats with an explicit placeholder use core post+edit. The placeholder change needs #199's unset/None/str sentinel; the ephemeral surface needs #200.

Upstream changes

  • 5eb8b846 feat(teams): support outbound reactions (#734), chat@4.35.0:
    • addReaction / removeReaction go through the conversations reactions API.
    • A small alias map is applied; any other name passes through as a native Teams reaction id.
    • Errors go through handleTeamsError.
  • 160140e3 feat(teams): add targeted ephemeral messages (#737), chat@4.35.0:
    • postEphemeral sends a text or card activity with withRecipient({id, name: id, role: "user"}, true) and returns usedFallback: false.
    • In a DM it falls back to postMessage with usedFallback: true.
  • 85089037 fix(teams): mutate targeted messages through the targeted endpoint (#951), chat@4.41.0:
    • Records teams:targetedActivity:{conversationId}:{messageId} = "1" with a 24h TTL.
    • editMessage / deleteMessage read the record and call updateTargeted / deleteTargeted; delete clears it.
    • All state access is best-effort.
  • 93a58af5 fix(teams): preserve native streaming with placeholders (#709), chat@4.35.0:
    • stream() returns null when there is no active streamer and options.fallbackStreamingPlaceholderText is a string, so core falls back to post+edit.
    • streamViaEmit calls stream.update(placeholder) before the first chunk.
    • When the option is unset, behaviour is unchanged.

Current Python behavior

  • src/chat_sdk/adapters/teams/adapter.py:1655-1671: add_reaction / remove_reaction only log "...not supported...".
  • grep -n "post_ephemeral\|targeted" src/chat_sdk/adapters/teams/adapter.py finds nothing. TeamsAdapter (:270) does not subclass BaseAdapter, so Thread.post_ephemeral (thread.py:603-636) falls back to open_dm + post_message when fallback_to_dm is set, and returns None otherwise.
  • edit_message (:1560) and delete_message (:1628) always use the plain activities endpoint.
  • stream() (:1698-1746):
    • Delegates to _stream_via_emit when an active streamer exists.
    • Otherwise accumulates and calls post_message. It never returns None and ignores options.
  • StreamOptions (types.py:1056-1063) has no placeholder field. ChatConfig.fallback_streaming_placeholder_text defaults to "..." (types.py:1707), so "explicitly set" is not distinguishable today.

Scope

  • adapter.py: add _TEAMS_REACTION_ALIASES = {"check": "2705_whiteheavycheckmark", "eyes": "1f440_eyes", "pin": "1f4cc_pushpin", "rocket": "launch", "thinking": "think", "thumbs_up": "like", "x": "274c_crossmark"} and _resolve_teams_reaction_type(emoji), which takes an EmojiValue name or a str.
  • Implement add_reaction / remove_reaction through api.reactions.add/delete(conversation_id, message_id, reaction_type) on the thread's service-URL client. Errors go through _handle_teams_error(error, "addReaction" / "removeReaction").
  • post_ephemeral(thread_id, user_id, message, ...), with the signature per [4.41/C6] Thread.reply, Thread.mark_as_read, post_ephemeral options #200:
    • In a DM (is_dm), return EphemeralMessage(used_fallback=True) via post_message.
    • Otherwise build a MessageActivityInput (text with text_format="markdown", or an adaptive-card attachment, plus file attachments) and call .with_recipient(Account(id=user_id, name=user_id, role="user"), is_targeted=True).
    • Send via ActivitySender.send, which routes targeted sends to create_targeted.
    • Record the targeted id, and return used_fallback=False.
  • Add _targeted_activity_key, _remember_targeted_activity, _is_targeted_activity and _forget_targeted_activity, each wrapped in try/except so a state failure degrades to the plain endpoint.
  • edit_message: choose activities(conv).update_targeted vs .update. delete_message: choose delete_targeted vs delete, then forget the record.
  • stream():
    • With an active, non-canceled streamer, pass options.fallback_streaming_placeholder_text to _stream_via_emit, which calls streamer.update(text) first when the value is a str.
    • With no active streamer and an explicit str placeholder, return None.
    • When unset (sentinel), keep the current path byte-identical.
    • Widen the return type to RawMessage | None.

Out of scope

Porting notes

Tests

Adapter tests are not fidelity-mapped. Port from packages/adapter-teams/src/index.test.ts:

  • › "reactions": "should add a raw Teams reaction ID", it.each "should map %s to the Teams reaction ID %s", "should remove a reaction with the Teams conversation API", "should translate Teams API failures"
  • › "postEphemeral": "should send a targeted text message to the requested user", "should send targeted adaptive cards", "should handle targeted send failure by calling handleTeamsError", "falls back to a normal post in a 1:1 chat instead of a targeted send"
  • › "targeted message mutation": "deletes a message it sent targeted through the targeted endpoint", "edits a message it sent targeted through the targeted endpoint", "leaves a message it did not send targeted on the plain endpoint", "stops treating a targeted message as targeted once it is deleted", "falls back to the plain endpoint when the state adapter cannot be read"
  • › "streaming": "uses core fallback for an explicit group-chat placeholder", it.each "preserves buffered group-chat streaming for placeholder %s", "sends an explicit placeholder as native status before the first chunk"

Python-specific tests:

  • An invalid reaction name is rejected before any HTTP call.
  • The unset-sentinel path stays byte-identical to today; extend tests/test_teams_native_streaming.py rather than duplicating it.

Use AsyncMock for the SDK clients and state.

Acceptance criteria

  • Full validation command from CLAUDE.md passes.
  • docs/UPSTREAM_SYNC.md updated:
    • Add a row for the reaction-name validation.
    • Update the :654 and :655 streaming rows for the placeholder behaviour.
  • CHANGELOG entry under "Unreleased (4.41 wave)".
  • Consumer-visible changes called out:
    1. add_reaction / remove_reaction now hit Teams and can raise.
    2. post_ephemeral in channels and group chats becomes a native targeted message instead of a DM fallback. This requires the app to be installed in the conversation.
    3. Apps that explicitly set fallback_streaming_placeholder_text see a DM status line, and group chats switch from one buffered post to post+edit.
  • A Teams live loop covers a DM stream with and without a placeholder, and reactions plus an ephemeral message in a channel.

Dependencies

Blocked by #199, #200. Soft ordering: land after #216 to reuse its service-URL helper.

Verify first

  • In microsoft-teams-apps 2.0.13.4 (the uv.lock pin), reactions are ApiClient.reactions.add/delete(conversation_id, activity_id, reaction_type). Upstream TS 2.0.14 uses conversations.addReaction. Targeted calls are activities(conv).update_targeted/delete_targeted, and Activity.with_recipient(value, is_targeted). Re-check all of these names on 2.0.16, which CI has resolved (test(teams): make the skip-auth fixture survive the SDK's flag rename #180).

Metadata

  • Effort: M
  • Consumer impact: high for Teams streaming users. The unset path must be unchanged. Downstream consumers (e.g. chinchill) that set the placeholder explicitly will see different group-chat and DM output, and reactions and ephemerals change from no-ops or DMs to live calls.
  • Suggested branch: sync/4.41-t4

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