Skip to content

feat(teams): migrate native streaming to the SDK IStreamer + unwind transitional divergences (#93 PR 3/4)#145

Merged
patrick-chinchill merged 2 commits into
mainfrom
feat/teams-sdk-pr3-streaming
Jun 18, 2026
Merged

feat(teams): migrate native streaming to the SDK IStreamer + unwind transitional divergences (#93 PR 3/4)#145
patrick-chinchill merged 2 commits into
mainfrom
feat/teams-sdk-pr3-streaming

Conversation

@patrick-chinchill

@patrick-chinchill patrick-chinchill commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator

What

PR 3/4 of the Teams adapter migration to microsoft-teams-apps (#93) — the highest-risk PR. Replaces the hand-rolled Bot Framework streaming wire format with the SDK's native streamer (ctx.stream / StreamerProtocol.emit()), mirroring upstream adapter-teams@chat@4.30.0 index.ts (streamViaEmit DM path), and atomically unwinds the two transitional public-type divergences this enables.

PRs 1 (inbound+auth) and 2 (outbound) are already merged to main; this branch is off that main.

How streaming now flows through ctx.stream

DMs (native):

  1. _handle_message_activity detects a DM, calls _create_streamer(activity, thread_id) which builds a ConversationReference from the inbound activity and calls app.activity_sender.create_stream(ref) — the same call the SDK's own ActivityContext makes to expose ctx.stream. The result is a real SDK HttpStream.
  2. The streamer is registered in _active_streams[thread_id], and the handler awaits a processing_done gate (a wrapped wait_until shim, the Python translation of upstream's processingDone promise + wrapped waitUntil) so the streamer stays alive while the chat handler streams.
  3. Thread.streamadapter.stream_stream_via_emit: each non-empty chunk is handed to stream.emit(text). The SDK owns the wire format, throttle, and 429 retry. We never call stream.close().
  4. The handler's finally calls stream.close() once — this is the lifecycle-owner role the SDK App's process_activity plays upstream (app_process.py:216). Our bridge overrides server.on_request, so we own dispatch and reproduce the close. The SDK HttpStream.close no-ops when canceled or contentless, so closing in both success and cancel paths is safe.
  5. The first chunk's server-assigned id is captured via stream.on_chunk(...) (the Python SDK exposes on_chunk/on_close, not events.once) and awaited only when text was emitted and the stream was not canceled.
  6. Cancellation is detected two ways, matching upstream: the stream.canceled property (checked before each emit) and catching StreamCancelledError.

Group / channel / proactive (fallback): no active streamer → accumulate and post a single post_message (SDK-backed from PR 2), matching upstream's postMessage(threadId, { markdown: accumulated }).

The two divergences, exactly how + where unwound

These had to land together — touching one without the other corrupts recorded message history.

(a) RawMessage.text override — removed in src/chat_sdk/types.py. RawMessage is now {id, thread_id, raw}, matching upstream packages/chat/src/types.ts. The consumer in src/chat_sdk/thread.py _handle_stream (raw_result.text if … else accumulated) is gone; the recorded SentMessage is now always built from the local accumulator (upstream thread.ts parity). This is correct because the adapter now emits each chunk through the SDK IStreamer as it is yielded, so the accumulator and what the platform shipped stay in lockstep — there is no buffered-but-unsent suffix to reconcile.

(b) update_interval_ms non-seeding — restored in src/chat_sdk/thread.py _handle_stream. Now seeds StreamOptions(update_interval_ms=self._streaming_update_interval_ms) (500ms thread default) before merging caller options, exactly like upstream thread.ts (vercel/chat#340). Harmless for Teams now because the Teams adapter no longer owns a quota throttle — the SDK IStreamer does.

Also removed: native_stream_min_emit_interval_ms config field (adapters/teams/types.py) and _teams_send (now dead).

History-fidelity tests (the #1 risk)

  • tests/test_thread_faithful.py::test_should_record_local_accumulator_for_native_streaming — recorded text comes from the accumulator, not the adapter return value.
  • tests/test_thread_faithful.py::test_should_fall_back_when_adapterstream_returns_null — now asserts update_interval_ms == 500 (was is None).
  • tests/test_teams_native_streaming.py::TestHistoryFidelity — three end-to-end tests driving a real Thread.post(stream) against the real Teams adapter.stream() with a FakeStreamer + capturing thread-history: full-text recording, the precise cancellation semantics (history records exactly what the wrapping iterator pulled — emitted text plus the single chunk pulled in the iteration that detected cancellation, byte-for-byte upstream parity), and update_interval_ms seeding.

THROTTLE PARITY finding (from the SDK source)

Verified against the installed SDK (microsoft-teams-apps==2.0.13), the SDK HttpStream throttles and is 429-safe, so we do not regress to rate-limit errors:

  • Inter-flush delay: http_stream.py:266 — after a flush, if more is queued, the next flush is scheduled via call_later(0.5, …) (500ms). The module docstring (http_stream.py:39-41) states this is "to ensure we dont hit API rate limits with Microsoft Teams".
  • streamSequence: http_stream.py:283,290add_stream_update(self._index) stamps the Bot Framework streamSequence; self._index increments per stream activity.
  • 429 retry: http_stream.py:285-288 — each chunk send goes through retry(..., RetryOptions(max_delay=4.0, max_attempts=8)).
  • Final/close rate-limit: http_stream.py:180-201close() waits for the queue to drain (_wait_for_id_and_queue) and the final add_stream_final() send also goes through retry().

A LIVE Teams check (streaming a real long response without a 429) is out of scope for this build and is flagged for the reviewers/maintainer.

Test rewrite

tests/test_teams_native_streaming.py (was ~82KB of wire-format/throttle-internal assertions) rewritten to mock ctx.stream with a FakeStreamer StreamerProtocol double (exposing emit / canceled / closed / on_chunk). Asserts: emit per chunk (string / markdown_text dict / dataclass forms); close() NOT called by stream(); first-chunk-id capture; empty-stream does-not-hang; DM-only processing_done blocking + registration/teardown; group/proactive buffered fallback; both cancellation paths; streamer construction from the inbound activity; pass-interaction isolation. Dropped the wire-format/throttle-internal assertions. All other Teams tests updated to the SDK-backed paths and kept passing.

SDK-forced divergence / residual risk

  • Close call site: upstream lets the SDK App auto-close ctx.stream; our bridge owns dispatch (server.on_request override from PR 1), so we reproduce the close in the handler finally. Behaviorally identical (no double-close — the SDK no-ops a second close).
  • Event API: the Python SDK exposes on_chunk/on_close handler registration, not events.once("chunk", …). Mapped 1:1 via a Future resolved by the first on_chunk callback.
  • Live 429 verification is unperformed (flagged above).

Gauntlet

  • ruff check — clean
  • ruff format --check — 257 files formatted
  • audit_test_quality.py0 hard failures (pre-existing cross-adapter duplicate warnings only)
  • pyrefly check — 0 errors
  • pytest4773 passed, 3 skipped
  • verify_test_fidelity.py --strict732/732 (100%), exit 0

Summary by CodeRabbit

  • Bug Fixes

    • Improved Teams direct message streaming reliability and behavior consistency.
  • Documentation

    • Updated documentation clarifying streaming behavior differences across platforms.
  • Chores

    • Removed Teams adapter streaming interval configuration option, simplifying setup.

…ransitional divergences (#93 PR 3/4)

Replace the hand-rolled Bot Framework streaming wire format with the
Teams SDK's native streamer (microsoft-teams-apps IStreamer / HttpStream),
mirroring upstream adapter-teams@chat@4.30.0 index.ts (streamViaEmit DM
path). Atomically unwind the two transitional public-type divergences
this enables.

Streaming flow (DMs):
- _handle_message_activity captures an IStreamer for DMs via
  app.activity_sender.create_stream(ref), registers it in _active_streams,
  and awaits a processing_done gate (wrapped wait_until shim) so the
  streamer stays alive while the handler streams.
- stream() -> _stream_via_emit calls stream.emit(text) per chunk; it NEVER
  calls close(). The handler's finally calls stream.close() once (the
  lifecycle-owner role the SDK App's process_activity plays upstream;
  our bridge owns dispatch via server.on_request).
- Cancellation is detected two ways: stream.canceled (checked before each
  emit) and catching StreamCancelledError (other exceptions re-raise).
- The first chunk's server-assigned id is captured via on_chunk and
  awaited only when text was emitted and the stream was not canceled.
- Group / channel / proactive threads fall back to a single buffered
  post_message (SDK-backed from PR 2).

Removed: _TeamsStreamSession, _stream_via_emit's hand-rolled wire format,
_emit_streaming_activity, _close_stream_session, _teams_send, the 1500ms
emit throttle, the clock/sleep injectables, and the
native_stream_min_emit_interval_ms config field.

Divergence unwinds (must land together — touching one without the other
corrupts recorded message history):
- types.py: removed the RawMessage.text override field (RawMessage is now
  {id, thread_id, raw}, matching upstream packages/chat/src/types.ts).
- thread.py: _handle_stream now seeds StreamOptions.update_interval_ms
  with the thread default (upstream parity, vercel/chat#340) and records
  the local accumulator (no adapter-side text override).

Throttle parity: the SDK HttpStream owns the Bot Framework streaming wire
format (streamType/streamSequence/streamId), a 500ms inter-flush throttle,
and 429 retry/backoff — verified against microsoft-teams-apps==2.0.13
http_stream.py (see docs/UPSTREAM_SYNC.md). A live Teams check is flagged
for reviewers/maintainer.

Tests: rewrote tests/test_teams_native_streaming.py to mock ctx.stream as
a StreamerProtocol double; added history-fidelity tests proving the
SentMessage->Message recording path and update_interval_ms seeding after
the unwind.
@coderabbitai

coderabbitai Bot commented Jun 18, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: d89bd363-5004-48bb-bd16-6e5633e7a6b6

📥 Commits

Reviewing files that changed from the base of the PR and between a9f0c80 and 19b8b88.

📒 Files selected for processing (11)
  • docs/UPSTREAM_SYNC.md
  • src/chat_sdk/adapters/teams/adapter.py
  • src/chat_sdk/adapters/teams/streamer.py
  • src/chat_sdk/adapters/teams/types.py
  • src/chat_sdk/thread.py
  • src/chat_sdk/types.py
  • tests/test_teams_coverage.py
  • tests/test_teams_extended.py
  • tests/test_teams_native_streaming.py
  • tests/test_telegram_streaming.py
  • tests/test_thread_faithful.py

📝 Walkthrough

Walkthrough

Replaces the custom _TeamsStreamSession/_teams_send Bot Framework streaming implementation with the Microsoft Teams SDK's native IStreamer/StreamerProtocol for DM channels. A new streamer.py module adds build_conversation_reference. RawMessage.text and native_stream_min_emit_interval_ms are removed. Thread-level streaming now seeds update_interval_ms and always records locally accumulated text.

Changes

Teams SDK IStreamer Migration

Layer / File(s) Summary
Removed/updated contracts: RawMessage, TeamsAdapterConfig, registry
src/chat_sdk/types.py, src/chat_sdk/adapters/teams/types.py, src/chat_sdk/adapters/teams/adapter.py
Removes RawMessage.text adapter-override field, deletes native_stream_min_emit_interval_ms from TeamsAdapterConfig, removes _TeamsStreamSession, changes _active_streams to store StreamerProtocol, and updates imports.
New streamer module: build_conversation_reference
src/chat_sdk/adapters/teams/streamer.py
Adds build_conversation_reference to construct a ConversationReference from an inbound Bot Framework activity dict, with bot_app_id fallback for the bot account.
DM message handling: _create_streamer and finally-close lifecycle
src/chat_sdk/adapters/teams/adapter.py
Rewrites _handle_message_activity DM path to call _create_streamer, register the SDK streamer, fall back to buffered posting on failure, and always close the streamer in a finally block. Adds _create_streamer which validates serviceUrl, builds the conversation reference, and calls activity_sender.create_stream.
stream() dispatch and _stream_via_emit rewrite
src/chat_sdk/adapters/teams/adapter.py
Updates stream() to delegate to _stream_via_emit when an active SDK streamer exists; otherwise accumulates and posts via post_message. Rewrites _stream_via_emit to emit chunks via stream.emit, capture the first activity id via stream.on_chunk, and handle cancellation. Deletes _teams_send.
Thread streaming: StreamOptions seeding and SentMessage text
src/chat_sdk/thread.py
Seeds StreamOptions.update_interval_ms from the thread's default before merging caller overrides. Removes the raw_result.text override path; SentMessage.text always uses locally accumulated text.
Tests and upstream sync docs
tests/test_teams_coverage.py, tests/test_teams_extended.py, tests/test_thread_faithful.py, tests/test_telegram_streaming.py, docs/UPSTREAM_SYNC.md
Updates streaming tests to mock _mock_app_send instead of _teams_send, removes TestTeamsHTTPErrorPaths, pins accumulated-text and update_interval_ms=500 assertions in thread tests, and updates the non-parity table for Teams DM SDK streaming rows.

Sequence Diagram(s)

sequenceDiagram
    participant BotFramework as Bot Framework
    participant Handler as _handle_message_activity
    participant CreateStreamer as _create_streamer
    participant SDKSend as activity_sender.create_stream
    participant Registry as _active_streams
    participant StreamViaEmit as _stream_via_emit
    participant Thread as Thread._handle_stream

    BotFramework->>Handler: DM activity received
    Handler->>CreateStreamer: activity, thread_id
    CreateStreamer->>SDKSend: build_conversation_reference + create_stream
    SDKSend-->>CreateStreamer: StreamerProtocol
    CreateStreamer-->>Handler: StreamerProtocol
    Handler->>Registry: register[thread_id] = StreamerProtocol
    Handler->>Thread: process_message (streamer alive in registry)
    Thread->>StreamViaEmit: stream(), finds streamer in _active_streams
    loop each text chunk
        StreamViaEmit->>SDKSend: stream.emit(text)
    end
    StreamViaEmit-->>Thread: RawMessage(first_id)
    Thread-->>Thread: SentMessage.text = accumulated (not raw_result.text)
    Handler->>Registry: remove[thread_id]
    Handler->>SDKSend: await stream.close()
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related issues

Possibly related PRs

  • Chinchill-AI/chat-sdk-python#43: Both PRs modify src/chat_sdk/thread.py and src/chat_sdk/types.py to remove RawMessage.text as an adapter-override and record locally accumulated text in SentMessage, plus seed update_interval_ms.
  • Chinchill-AI/chat-sdk-python#74: Both PRs modify the StreamOptions.update_interval_ms propagation path in src/chat_sdk/thread.py for native vs. fallback streaming.
  • Chinchill-AI/chat-sdk-python#88: This PR removes _TeamsStreamSession, _teams_send, and the buffered typing/final-message emit logic introduced in PR #88, replacing it entirely with the SDK StreamerProtocol lifecycle.

Poem

🐰 Hoppity-hop, the old code must go,
No more hand-rolled typing envelopes in the flow!
IStreamer arrives with a close() and emit,
The conversation reference is neatly built.
_teams_send has vanished, the registry sings—
SDK-native streaming does all of the things! 🎉

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

# exits cleanly without sending more chunks.
session.cancel()
raise
await processing_done

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request migrates Microsoft Teams native streaming from a hand-rolled wire format implementation to the official Teams SDK IStreamer (StreamerProtocol). This simplifies the codebase by removing the custom _TeamsStreamSession class, the hand-rolled _teams_send REST helper, and custom throttling logic, aligning the Python SDK closer to upstream parity. Feedback was provided to address a potential issue in _stream_via_emit where id_captured could hang indefinitely if the stream is closed or cancelled before any chunk is delivered, suggesting an on_close callback to safely resolve the future.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +1685 to +1689
async def _on_chunk(activity: Any) -> None:
if not id_captured.done():
id_captured.set_result(getattr(activity, "id", "") or "")

stream.on_chunk(_on_chunk)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If the stream is closed or cancelled before any chunk is delivered, id_captured will never be resolved, which can cause await id_captured to hang indefinitely. Registering an on_close callback to resolve the future with an empty string ensures that the future always unblocks when the stream terminates.

        async def _on_chunk(activity: Any) -> None:
            if not id_captured.done():
                id_captured.set_result(getattr(activity, "id", "") or "")

        async def _on_close(*args: Any, **kwargs: Any) -> None:
            if not id_captured.done():
                id_captured.set_result("")

        stream.on_chunk(_on_chunk)
        stream.on_close(_on_close)

…throttle note

Review fast-follow for PR 3 (native streaming): the finally-block comment
now distinguishes SDK-detected cancel (HttpStream.close no-ops, _canceled
set) from a raw asyncio.CancelledError (close flushes a final activity for
the accumulated text — intentional, and the except-Exception wrapper does
not catch CancelledError so cancellation still propagates). Also pins the
throttle-parity note to the installed microsoft-teams-apps==2.0.13.4 so a
future SDK bump that changes the 0.5s cadence is caught against the cited
baseline. No logic change.
@patrick-chinchill patrick-chinchill marked this pull request as ready for review June 18, 2026 22:25
@patrick-chinchill patrick-chinchill merged commit 17aa41a into main Jun 18, 2026
8 of 9 checks passed

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 19b8b88038

ℹ️ 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".

# text. The decision below only governs whether THIS
# chunk triggers an intermediate ``typing`` send.
stream.emit(text)
accumulated += text

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Record only delivered Teams chunks

With the real Teams SDK HttpStream, emit() only queues the activity and the actual send/cancel happens later in the background _flush() task. In a fast stream, this line adds text to accumulated before Teams has accepted it; if the user presses Stop or the SDK gets a 403 before queued chunks flush, close() no-ops for the canceled stream while Thread still records the queued suffix as sent. Please base the recorded text on confirmed delivery, or wait for/observe SDK flush or close state before returning.

Useful? React with 👍 / 👎.

# emitted (empty stream, or stream canceled before first send).
if not session.text:
return
message_id = await id_captured

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Bound the first-chunk ID wait

When the first SDK flush fails or is canceled before any chunk event fires, id_captured is never resolved. Because HttpStream.emit() returns after queuing and the failure occurs later in _flush(), this await can hang forever for a first-chunk 403/network error or immediate Stop, preventing the DM handler from reaching cleanup. Please also resolve/cancel this future from a close/error/cancel signal or add a timeout.

Useful? React with 👍 / 👎.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant