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
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.
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").
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.
The SDK ReactionClient interpolates reaction_type straight into the URL path (api/clients/reaction/client.py:52,71). Validate pass-through names against ^[A-Za-z0-9_]+$ and raise ValidationError otherwise.
The state record value is the string "1" and the TTL is 24h (24 * 60 * 60 * 1000 ms). Keep the key format identical for cross-SDK state.
stream.update() is synchronous on the SDK streamer (apps/plugins/streamer.py:75). Don't await it.
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:
add_reaction / remove_reaction now hit Teams and can raise.
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.
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.
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_textshows 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
5eb8b846feat(teams): support outbound reactions (#734), chat@4.35.0:addReaction/removeReactiongo through the conversations reactions API.handleTeamsError.160140e3feat(teams): add targeted ephemeral messages (#737), chat@4.35.0:postEphemeralsends a text or card activity withwithRecipient({id, name: id, role: "user"}, true)and returnsusedFallback: false.postMessagewithusedFallback: true.85089037fix(teams): mutate targeted messages through the targeted endpoint (#951), chat@4.41.0:teams:targetedActivity:{conversationId}:{messageId}="1"with a 24h TTL.editMessage/deleteMessageread the record and callupdateTargeted/deleteTargeted; delete clears it.93a58af5fix(teams): preserve native streaming with placeholders (#709), chat@4.35.0:stream()returnsnullwhen there is no active streamer andoptions.fallbackStreamingPlaceholderTextis a string, so core falls back to post+edit.streamViaEmitcallsstream.update(placeholder)before the first chunk.Current Python behavior
src/chat_sdk/adapters/teams/adapter.py:1655-1671:add_reaction/remove_reactiononly log"...not supported...".grep -n "post_ephemeral\|targeted" src/chat_sdk/adapters/teams/adapter.pyfinds nothing.TeamsAdapter(:270) does not subclassBaseAdapter, soThread.post_ephemeral(thread.py:603-636) falls back toopen_dm+post_messagewhenfallback_to_dmis set, and returnsNoneotherwise.edit_message(:1560) anddelete_message(:1628) always use the plain activities endpoint.stream()(:1698-1746):_stream_via_emitwhen an active streamer exists.post_message. It never returnsNoneand ignoresoptions.StreamOptions(types.py:1056-1063) has no placeholder field.ChatConfig.fallback_streaming_placeholder_textdefaults 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 anEmojiValuename or astr.add_reaction/remove_reactionthroughapi.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:is_dm), returnEphemeralMessage(used_fallback=True)viapost_message.MessageActivityInput(text withtext_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).ActivitySender.send, which routes targeted sends tocreate_targeted.used_fallback=False._targeted_activity_key,_remember_targeted_activity,_is_targeted_activityand_forget_targeted_activity, each wrapped in try/except so a state failure degrades to the plain endpoint.edit_message: chooseactivities(conv).update_targetedvs.update.delete_message: choosedelete_targetedvsdelete, then forget the record.stream():options.fallback_streaming_placeholder_textto_stream_via_emit, which callsstreamer.update(text)first when the value is astr.strplaceholder, returnNone.RawMessage | None.Out of scope
StreamOptionspassthrough: [4.41/C5] Streaming & thread API: lightweight threads, placeholder sentinel, restore streaming settings, Plan.auto_complete_previous, AG-UI streams #199.post_ephemeraloptions /EphemeralMessage | Nonesurface: [4.41/C6] Thread.reply, Thread.mark_as_read, post_ephemeral options #200._api_for/_send_tohelper; rebase onto it if it has merged.docs/UPSTREAM_SYNC.md:643).Porting notes
str(including"") triggers the new paths. An explicitNonemeans "no placeholder" (upstreamnull), which keeps native DM streaming without a status and buffers group chats. Read the value through [4.41/C5] Streaming & thread API: lightweight threads, placeholder sentinel, restore streaming settings, Plan.auto_complete_previous, AG-UI streams #199's helper and never compare to"...".ReactionClientinterpolatesreaction_typestraight into the URL path (api/clients/reaction/client.py:52,71). Validate pass-through names against^[A-Za-z0-9_]+$and raiseValidationErrorotherwise.ActivitySender.sendraisesValueErrorfor a targeted activity whenref.conversation.conversation_type == "personal". The DM check must run before building the activity; with [4.41/T1] Teams routing & outbound text: stop <at> rewriting, conversationType in thread ids, per-service-URL clients #216,is_dmhonours the explicit type."1"and the TTL is 24h (24 * 60 * 60 * 1000ms). Keep the key format identical for cross-SDK state.stream.update()is synchronous on the SDK streamer (apps/plugins/streamer.py:75). Don'tawaitit.Tests
Adapter tests are not fidelity-mapped. Port from
packages/adapter-teams/src/index.test.ts: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"it.each"preserves buffered group-chat streaming for placeholder %s", "sends an explicit placeholder as native status before the first chunk"Python-specific tests:
tests/test_teams_native_streaming.pyrather than duplicating it.Use
AsyncMockfor the SDK clients and state.Acceptance criteria
docs/UPSTREAM_SYNC.mdupdated::654and:655streaming rows for the placeholder behaviour.add_reaction/remove_reactionnow hit Teams and can raise.post_ephemeralin channels and group chats becomes a native targeted message instead of a DM fallback. This requires the app to be installed in the conversation.fallback_streaming_placeholder_textsee a DM status line, and group chats switch from one buffered post to post+edit.Dependencies
Blocked by #199, #200. Soft ordering: land after #216 to reuse its service-URL helper.
Verify first
microsoft-teams-apps2.0.13.4 (theuv.lockpin), reactions areApiClient.reactions.add/delete(conversation_id, activity_id, reaction_type). Upstream TS 2.0.14 usesconversations.addReaction. Targeted calls areactivities(conv).update_targeted/delete_targeted, andActivity.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
Part of #184.