Skip to content

feat(teams): native streaming for DMs via emit (vercel/chat#416)#88

Merged
patrick-chinchill merged 22 commits into
mainfrom
claude/port-teams-native-dm-streaming-J7S7H
May 28, 2026
Merged

feat(teams): native streaming for DMs via emit (vercel/chat#416)#88
patrick-chinchill merged 22 commits into
mainfrom
claude/port-teams-native-dm-streaming-J7S7H

Conversation

@patrick-chinchill
Copy link
Copy Markdown
Collaborator

@patrick-chinchill patrick-chinchill commented May 9, 2026

Summary

Ports upstream vercel/chat#416 (commit ed46bae) — feat(adapter-teams): native streaming for DMs via emit — into the Python Teams adapter.

DM threads now dispatch chunks through the Bot Framework streaming protocol (typing activities with channelData.streamType=streaming + streamSequence, then a final message activity with streamType=final) instead of routing through Thread._fallback_stream. Group chats / channels accumulate the stream and post a single message — matching upstream's post-#416 behavior of avoiding the post+edit flicker where Teams doesn't support native streaming.

Wire format

  • First chunk: typing activity with channelData.streamType=streaming + channelData.streamSequence=1. streamId is omitted from both channelData and the streaminfo entity (server hasn't assigned one yet).
  • Subsequent chunks: streamId (captured from the first send's REST response) appears on BOTH channelData and the streaminfo entity, per the Bot Framework streaming contract.
  • Final activity: message (not typing), streamType=final, with streamId on both channelData and the streaminfo entity.

Throttling

Teams' streaming endpoint enforces ~1 request/second; Microsoft recommends 1.5-2s buffering. The emit loop gates per-chunk sends on a configurable throttle window:

  • New TeamsAdapterConfig.native_stream_min_emit_interval_ms (default 1500ms)
  • Honors StreamOptions.update_interval_ms per-call (e.g. from a StreamingPlan)
  • Chunks within the window are accumulated locally; the next eligible chunk ships the cumulative text snapshot
  • End-of-stream flush: when the iterator ends, any buffered text that wasn't shipped in an intermediate emit is force-flushed via one final typing activity. Without this, buffered text would only ship in the close-path message activity and a close-path failure would leave Thread.stream recording text Teams never accepted.

Send-failure contract (re-raise)

A _teams_send failure mid-stream (429, network error) cancels the session, logs at warn, and re-raises. Thread.stream's outer accumulator builds the SentMessage from each chunk before yielding to the adapter — re-raising short-circuits the post-stream history append so the SDK's record matches what the user actually saw via prior accepted typing activities. Same contract applies to the end-of-stream flush.

The close-path final message activity is allowed to fail-soft: by the time it runs, every byte of session.text was confirmed-accepted via the flush, so a failure here is a stale-streaming-UI cost (until client timeout) rather than a recording inconsistency.

Lifecycle (hazard #5: async task lifecycle)

  • _handle_message_activity registers a _TeamsStreamSession for DMs and awaits the chat handler so the session stays alive through stream(). The handler is fire-and-forget for non-DM threads.
  • _stream_via_emit emits cumulative text per chunk; streamId is captured from the first send and threaded through all subsequent activities.
  • _close_stream_session posts the final activity. Skipped on cancellation or zero-chunk streams (no orphan finalizers).
  • Iterator and send exceptions cancel the session and propagate to the caller.

Documentation

docs/UPSTREAM_SYNC.md:

  • Scoped existing "Fallback streaming" rows to non-Teams adapters (Teams no longer routes through _fallback_stream after feat(adapter-teams): native streaming for DMs via emit vercel/chat#416).
  • New row: hand-rolled Bot Framework streaming wire format is transitional; tracked migration to microsoft-teams-apps (Python SDK, GA 2026-05-01) where IStreamer.emit() handles the wire format internally.
  • New row: streamId placement on both channelData and the streaminfo entity (Bot Framework REST contract).
  • Group-chat accumulate-and-post divergence row updated.

Migration tracking issue: #93 — Migrate Teams adapter to microsoft-teams-apps. Headlines a 0.4.28 / point release; out of scope for 4.27.0 (SDK only 2 weeks GA, requires Python 3.12 floor bump).

Tests

tests/test_teams_native_streaming.py (33 tests):

  • Wire format: first-chunk omits streamId everywhere; subsequent chunks carry streamSequence increments and streamId on channelData; final close activity ships streamType=final.
  • streamInfo entity contract: subsequent chunks AND the final activity carry streamId on the streaminfo entity (not just channelData).
  • Throttle: in-window chunks coalesce; out-of-window chunks emit individually; StreamOptions.update_interval_ms overrides the adapter default.
  • End-of-stream flush: buffered text flushed before return; flush failure propagates; no redundant flush when each chunk already had an emit; cancellation skips the flush.
  • Dispatch: DM with active session uses native; DM with no session falls through to accumulate-and-post; channel threads always accumulate-and-post.
  • Cancellation: canceled session skips remaining chunks AND the final close activity; close on a never-emitted session is a no-op; close still ships final when first chunk's REST id was empty.
  • Errors: iterator exception cancels and re-raises; in-loop send failure cancels and re-raises; flush failure cancels and re-raises.
  • Lifecycle: end-to-end webhook → process_messagestream()_close_stream_session; handler exception with prior partial stream still drops session and ships close.
  • Pass-interaction: distinct DM threads each have isolated session state; same-thread overlap test exercises the per-handler isolation deferred-as-out-of-scope path (tracked as Python-only follow-up).

Existing TestStream cases in test_teams_coverage.py and test_teams_extended.py updated for accumulate-and-post on group chats.

Test plan

  • uv run ruff check src/ tests/ scripts/
  • uv run ruff format --check src/ tests/ scripts/
  • uv run python scripts/audit_test_quality.py
  • uv run pytest tests/ --tb=short -q — 3,701 pass, 1 pre-existing failure (unrelated to this PR), 2 skipped

Review history (resolved)

Codex P1: streamInfo.streamId placement (subsequent + final activities) → fixed in b29efe0
Codex P1: throttle native emits (Bot Framework 1 req/sec quota) → fixed in b29efe0
Codex P2: re-raise on send failure (was silently corrupting Thread.stream history) → fixed in 86c95db
Codex P2: end-of-stream flush for throttle-buffered text (close-path failure variant of the above) → fixed in 114edde
CodeRabbit nitpick: duplicate final-streaminfo test → consolidated in b9f018e

Deferred Python-only follow-ups (tracked, out of scope for parity sync):

Upstream ref: vercel/chat#416 (commit ed46bae)

https://claude.ai/code/session_01FyMxQn2BEAzmwKS1GZczKj


Generated by Claude Code

Summary by CodeRabbit

  • New Features

    • Teams direct message streaming now uses native Bot Framework support with configurable throttling interval
    • Teams group chats now accumulate and send streamed text in optimized batches
  • Documentation

    • Updated streaming behavior documentation clarifying Teams DM and group chat streaming differences and implementation details
  • Tests

    • Added comprehensive tests for Teams streaming behavior including throttling, edge cases, and session lifecycle

Review Change Stack

Port upstream `feat(adapter-teams): native streaming for DMs via emit`
(commit ed46bae). DM threads now dispatch chunks through the Bot
Framework streaming protocol (typing activities with
`channelData.streamType=streaming` + `streamSequence`, then a final
`message` activity with `streamType=final`) instead of routing through
`Thread._fallback_stream`. Group chats / channels accumulate the stream
and post a single message — matching upstream's post-#416 behavior of
avoiding the post+edit flicker where Teams doesn't support native
streaming.

Lifecycle:
- `_handle_message_activity` registers a `_TeamsStreamSession` for DMs
  and `await`s the chat handler so the session stays alive through
  `stream()`. The handler is fire-and-forget for non-DM threads.
- `_stream_via_emit` emits cumulative text per chunk; `streamId` is
  captured from the first send and threaded through subsequent chunks
  (Hazard #7 — the first chunk omits the key entirely).
- `_close_stream_session` posts the final activity. Skipped on
  cancellation or zero-chunk streams (no orphan finalizers).
- Iterator exceptions cancel the session and re-raise; transient send
  failures (e.g. 429 mid-stream) cancel the session and return the
  partial RawMessage instead of bubbling up.

Tests: `tests/test_teams_native_streaming.py` covers wire-format
invariants, dispatch decisions, cancellation paths, error mid-stream,
very-short streams, two concurrent DMs, and the end-to-end
webhook → process_message → stream → close lifecycle. Existing
`TestStream` cases in `test_teams_coverage.py` and `test_teams_extended.py`
updated to reflect the new accumulate-and-post semantics for group
chats.

Non-parity: updated existing "Fallback streaming" rows in
`docs/UPSTREAM_SYNC.md` to scope the divergence to non-Teams adapters,
and added two new rows documenting Teams-specific divergences (group
chats now accumulate-and-post; native streaming soft-cancels on send
failure).

https://claude.ai/code/session_01FyMxQn2BEAzmwKS1GZczKj
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 9, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds native Bot Framework streaming for Teams DMs with per-thread _TeamsStreamSession state; DM webhooks register and keep sessions alive during handler execution; stream() uses native typing emits when a DM session is active and falls back to accumulate-and-post otherwise. Includes finalization, cancellation/error semantics, tests, and docs updates.

Changes

Teams Native Streaming Implementation

Layer / File(s) Summary
Session State & Data Structure
src/chat_sdk/adapters/teams/adapter.py, src/chat_sdk/adapters/teams/types.py
Introduces _TeamsStreamSession, TeamsAdapter._active_streams, and TeamsAdapterConfig.native_stream_min_emit_interval_ms to track per-thread streaming metadata and configure emit throttling.
Stream Dispatch & Webhook Integration
src/chat_sdk/adapters/teams/adapter.py
_handle_message_activity registers DM sessions, wraps the caller wait_until with a local completion gate to keep sessions alive without deadlock, ensures cleanup, and stream() now selects native emit when an active uncanceled session exists (group chats stay accumulate-and-post).
Native Streaming Implementation
src/chat_sdk/adapters/teams/adapter.py
Adds _stream_via_emit to emit throttled Bot Framework typing activities per chunk with streamSequence and conditional streamId, soft-cancels on send failures, records committed accumulated text after success, and _close_stream_session sends the final message activity (channelData.streamType = "final").
Accumulate-and-Post Fallback
src/chat_sdk/adapters/teams/adapter.py
Concatenates non-empty chunk text for non-DM or no-session flows and sends a single markdown message activity (returns empty-text RawMessage for empty streams).
Test Suite: Wire Format, Throttling, Lifecycle & Errors
tests/test_teams_native_streaming.py
New tests pin typing-chunk wire format (first chunk omits streamId, subsequent include it), cumulative text behavior, throttling/coalescing, final-message semantics, session lifecycle via webhooks, cancellation and error propagation, and concurrency/registry semantics.
Test Updates: Existing Coverage
tests/test_teams_coverage.py, tests/test_teams_extended.py
Strengthened assertions that group-chat streaming accumulates into a single _teams_send (no _teams_update) with concatenated text, and that empty streams skip sending and return empty messages.
Documentation: Streaming Non-Parities
docs/UPSTREAM_SYNC.md
Clarified non-Teams whitespace-only fallback behavior (final edit_message(" ") placeholder clear), refined fallback exception/cleanup and final content generation (_remend-repaired final_content), and added Teams-specific rows for group-chat accumulate-and-post, DM native streaming wire-format/throttle/re-raise, streamId placement, and final-send when first chunk id is empty.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant Adapter as TeamsAdapter
  participant Bot as BotFramework
  participant Session as _TeamsStreamSession
  Client->>Adapter: stream() / emits chunks
  Adapter->>Bot: send typing activity (first chunk without streamId)
  Bot-->>Adapter: accepted (id)
  Adapter->>Session: record streamId & cumulative text
  Adapter->>Bot: send subsequent typing (include streamId, increment streamSequence)
  Bot-->>Adapter: accepted
  Adapter->>Adapter: _close_stream_session -> send final message (streamType="final")
  Bot-->>Adapter: final accepted
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related issues

Poem

🐰 I hopped through chunks with a tiny drum beat,

typing lights flickered until the text was complete,
DMs trickled in while groups posted as one,
canceled streams paused, final messages done,
sessions slept softly when the handler was done.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 42.86% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: adding native streaming for Teams DMs via emit mechanism, and includes the upstream reference (vercel/chat#416).
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


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.

Copy link
Copy Markdown
Contributor

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

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 implements native streaming for Microsoft Teams DMs using the Bot Framework protocol, while transitioning group chats and channels to an accumulate-and-post strategy to eliminate UI flicker. It introduces a session management system for tracking in-flight DM streams and includes comprehensive test coverage for the new streaming lifecycle. Feedback suggests simplifying the stream finalization logic by removing a redundant check for empty content, as the presence of a stream ID already implies that content was sent.

Comment thread src/chat_sdk/adapters/teams/adapter.py Outdated
"""
if session.canceled:
return
if session.stream_id is None or not session._text: # noqa: SLF001
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

The check not session._text is redundant here. session.stream_id is only set when a chunk with content is sent, which also guarantees session._text will be non-empty. Therefore, checking for session.stream_id is None is sufficient.

Suggested change
if session.stream_id is None or not session._text: # noqa: SLF001
if session.stream_id is None:

Address gemini-code-assist review on PR #88 (line 1218).
``session.stream_id`` is only assigned after a successful chunk send, and
empty chunks are skipped before the send call — so ``stream_id is not None``
implies ``_text`` is non-empty. Drop the redundant ``not session._text``
check; the single condition expresses the invariant more clearly.

Behavior unchanged. Tests still pass.

https://claude.ai/code/session_01FyMxQn2BEAzmwKS1GZczKj
Copy link
Copy Markdown
Collaborator Author

@patrick-chinchill patrick-chinchill left a comment

Choose a reason for hiding this comment

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

Code review — feat(teams): native streaming for DMs via emit (vercel/chat#416)

Compared HEAD (58b7fc7, including the follow-up _text-check refactor) to upstream ed46bae at f55378a (packages/adapter-teams/src/index.ts). Walked the porting hazards (especially #5/#7) and the SELF_REVIEW adversarial checks (pass-interaction, emit/parse symmetry, sentinels).

✅ Looks good

  • Hazard #7 (omit vs None) is honored: the first chunk's channelData is constructed without a streamId key (if session.stream_id is not None: channel_data["streamId"] = ...), and test_first_chunk_omits_stream_id pins the wire shape with assert "streamId" not in first_payload["channelData"].
  • streamSequence starts at 1 and increments per emitted chunk (session.sequence += 1 before building the payload, after the empty-skip), matching the Bot Framework protocol and pinned by test_subsequent_chunks_carry_stream_id_and_increment_sequence.
  • DM detection matches upstream byte-for-byte — both Python is_dm and TS isDM use not conversationId.startsWith("19:") (not conversationType == "personal" as the brief implied). Group-chat IDs (19:...@thread.skype) correctly fall through to accumulate-and-post.
  • Invariant in the follow-up refactor holds: stream_id is only set after a successful _teams_send AND only when result.get("id") is non-empty (line 1174–1178); empty chunks are skipped before accumulated += text. So stream_id is not None ⇒ _text non-empty. The not session._text check was indeed redundant.
  • Cleanup-path safety: the finally in _handle_message_activity uses if current is session before popping (so a concurrent re-registration on the same DM thread isn't clobbered), and _close_stream_session is wrapped in try/except so its failure can't leak the registry entry. Cancel/iterator-exception paths both call session.cancel() before re-raising, so _close_stream_session correctly skips the final activity.
  • Soft-cancel-on-send-failure divergence is documented in docs/UPSTREAM_SYNC.md non-parity table with rationale and tested by test_emit_send_failure_cancels_session (verifies no exception bubbles, result.raw["text"] carries pre-failure content).
  • Group-chat accumulate-and-post is a parity behavior with upstream post-#416, not a divergence — the doc table row marks it "no divergence at the adapter level" for clarity, which is the right framing.

🟡 Medium

  • Concurrent-DM test doesn't exercise _active_streams racetest_two_concurrent_dm_streams_have_independent_sessions constructs two sessions for two distinct thread IDs and calls _stream_via_emit directly with explicit session args, bypassing _active_streams entirely. The realistic race (two near-simultaneous webhooks for the same DM thread overwriting _active_streams[tid]) isn't covered. This race exists in upstream too (activeStreams.set(threadId, ctx.stream) overwrites identically), so it's parity behavior — but the test name oversells what's being verified. Suggest renaming or adding a same-thread test that drives two _handle_message_activity calls under asyncio.gather.
  • First-chunk send returns id="": if Teams accepts the typing activity but returns an empty id, session.stream_id stays None (line 1177 guard), _text becomes non-empty, and _close_stream_session skips because of the (now-tightened) stream_id is None check. Net effect: user sees streamed chunks but no final message activity — the streaming UI stays running until Teams times it out client-side. Upstream has the same shape (messageId = "") but would still attempt the final send. Worth a one-line comment at the close-skip site explaining this, and ideally a test pinning the empty-id behavior.

🔵 Nit

  • _chained_wait_until invokes upstream_wait_until(task) before _resolve_processing(task). If the caller-supplied wait_until raises, the exception propagates back through process_message, hits the adapter's outer try, and falls into finally (no hang — await processing_done isn't reached). That's fine, but reversing the order — resolve first, then call upstream — would make the deadlock-immunity argument trivially obvious to a future reader.
  • _TeamsStreamSession._text is read from outside the class via session._text with # noqa: SLF001 — a public text attribute (or method) would be cleaner since the leading underscore isn't really enforcing privacy.

Posted by an automated reviewer agent. https://claude.ai/code/session_01FyMxQn2BEAzmwKS1GZczKj


Generated by Claude Code

- Add a same-thread concurrent _handle_message_activity test that exercises
  the realistic _active_streams race (two near-simultaneous DM webhooks for
  the same thread). Pins upstream's plain-Map clobber semantics: the second
  registration overwrites the first, both in-flight handlers observe the
  later session, and the registry ends empty after both finish. The original
  distinct-threads test is renamed to make clear it covers session
  ISOLATION, not the registry race.

- Empty-id final-send fallback: when Teams accepts streaming chunks but
  returns id="" on the first activity, _close_stream_session now ships the
  final message anyway (omitting streamId from channelData) instead of
  skipping and leaving the streaming UI spinning until Teams times the
  session out client-side. Mirrors upstream's looser check (text non-empty
  → ship the final). Adds a regression test and a non-parity row in
  docs/UPSTREAM_SYNC.md.

- _chained_wait_until: resolve our internal processing_done gate BEFORE
  invoking the caller-supplied waitUntil, so the deadlock-immunity argument
  is trivially obvious (a misbehaving upstream callback can't starve the
  await on processing_done).

- _TeamsStreamSession: add a public read-only `text` property so external
  callers (now _close_stream_session) read through it instead of the
  underscore-prefixed _text attribute. _stream_via_emit retains the direct
  _text write as the canonical mutator.
Copy link
Copy Markdown
Collaborator Author

@patrick-chinchill patrick-chinchill left a comment

Choose a reason for hiding this comment

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

Re-review of fixes in 2e96fbb

Verified the four fixes against ed46bae (vercel/chat#416) and the broader adapter surface.

Verified — fixes land cleanly

  1. test_same_thread_concurrent_handlers_clobber_active_stream (tests/test_teams_native_streaming.py:722) — drives two _handle_message_activity tasks for the same thread under a barrier, snapshots the registry post-overlap, and pins [second_session, second_session]. Matches upstream's Map clobber semantics. The renamed test_distinct_dm_threads_each_have_isolated_session_state makes the isolation-vs-race split explicit.
  2. Empty-id final-send (adapter.py:1239–1271, test 317–362) — _close_stream_session now ships the final message when text is non-empty even if stream_id is None, omitting streamId from channelData (rather than serializing None — Hazard #7). Regression test asserts "streamId" not in final_payload["channelData"]. Non-parity row at UPSTREAM_SYNC.md:492 is accurate.
  3. _chained_wait_until order (adapter.py:462–471) — _resolve_processing(task) runs before upstream_wait_until(task), with a comment explaining the deadlock-immunity argument.
  4. Public text property (adapter.py:95–103, 1243, 1260) — read-only text accessor exposed; reads in _close_stream_session go through it; only the canonical mutator at adapter.py:1216 retains # noqa: SLF001. Tests still poke _text directly (lines 176, 300) — fine, they're inside the test module's own laxer scope and lint-clean.

Findings

Nit (parity gap, pre-existing across multiple adapters)
_stream_via_emit and the accumulate-and-post fallback both check isinstance(chunk, dict) and chunk.get("type") == "markdown_text" (adapter.py:1081, 1139). A real MarkdownTextChunk dataclass instance silently drops to "" because isinstance(MarkdownTextChunk(...), dict) is False. Other adapters (google_chat, whatsapp, slack, github) use hasattr(chunk, "type") for this. Same gap exists in discord/linear, so out of scope here, but worth a follow-up issue — the StreamChunk union in types.py:798 is dataclass-typed.

Nit (divergence not in UPSTREAM_SYNC.md)
_chained_wait_until resolves the internal gate before invoking the caller-supplied wait_until. Upstream TS does the opposite (baseOptions?.waitUntil?.(task) first, then task.then(...)). Functionally equivalent (both register callbacks; neither blocks), but it IS a deliberate ordering divergence and the commit message frames it as a hardening defense — consider a one-line entry in the non-parity table so future syncs don't "correct" it back.

Nit (forward-looking)
chained_options = WebhookOptions(wait_until=_chained_wait_until) (adapter.py:473) drops any other fields that may exist on the inbound options. Today WebhookOptions only has wait_until, so no observable bug — but upstream uses {...baseOptions, waitUntil: ...} for a reason. A replace(options, wait_until=...) (or explicit comment "WebhookOptions has only wait_until — re-spread if it grows") would future-proof.

Hunt results — no other issues

  • Wire format: streamSequence (1-indexed, ++ per emit), streamType ("streaming"/"final"), streamId (omitted on first chunk + when server returned empty id), parallel streaminfo entity — all match the Bot Framework streaming protocol that upstream's IStreamer emits under the hood.
  • Cleanup: try/finally in _handle_message_activity mirrors upstream's try { await processingDone } finally { activeStreams.delete }; asyncio.CancelledError cancels the session and re-raises (covered by test_cancelled_error_propagates_and_marks_session_canceled).
  • DM detection: is_dm checks only not conversationId.startswith("19:") — matches thread-id.ts:30 exactly.
  • Test fidelity: upstream PR #416 added zero new it() blocks in index.test.ts; only assertion swaps in replay-streaming.test.ts. Python's 23 native-streaming tests + 159 teams-streaming-related tests all pass; no missing TS coverage.
  • Hazard #5: no untracked tasks in the new code. process_message tracks via _active_tasks, _chained_wait_until only calls add_done_callback, no bare create_task / ensure_future.

Re-review verdict: PASS

The three nits are real but non-blocking (one is pre-existing, one is hardening with no observable behavior change, one is forward-looking). Fixes for the previous round all landed correctly.

Posted by an automated re-reviewer agent. https://claude.ai/code/session_01FyMxQn2BEAzmwKS1GZczKj


Generated by Claude Code

patrick-chinchill pushed a commit that referenced this pull request May 10, 2026
Final upstream-coverage audit before merging the 7 sync PRs (#84-#90)
identified one undocumented N/A item:

vercel/chat#415 (Teams SDK 2.0.8 + User-Agent) is a JS-only botbuilder
dependency bump. The Python Teams adapter uses raw aiohttp (no
botbuilder), so there is no equivalent dependency to bump. The optional
User-Agent: Vercel.ChatSDK header on the ~9 outbound aiohttp call sites
is a defense-in-depth nice-to-have; deferred as a follow-up rather than
landed in this sync.

Updates:
- CHANGELOG.md: tick all completed items and link them to their PRs
  (#84, #85, #86, #87, #88, #89, #90, plus already-merged PR #74).
  Document #415 inline as N/A.
- docs/UPSTREAM_SYNC.md non-parity table: add row for Teams User-Agent
  header divergence so future syncers don't try to "port" the JS bump.

Item #6 (concurrency.maxConcurrent) is already implementation-covered
in the Python port (existing divergence row at L492). The 4 new TS
concurrency tests in chat.test.ts have Python-specific equivalents at
test_chat_faithful.py L2969-3055 that don't name-match — leaving as
deferred fidelity-baseline polish since the behavior is verified.

Verdict from the coverage audit: all 18 substantive ports across PRs
#84-#90 are upstream-verified. No commits in chat@4.26.0..f55378a were
missed. Ready to start merging.

https://claude.ai/code/session_01FyMxQn2BEAzmwKS1GZczKj
@patrick-chinchill patrick-chinchill marked this pull request as ready for review May 10, 2026 03:47
Copy link
Copy Markdown

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

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: 2e96fbb242

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

Comment thread src/chat_sdk/adapters/teams/adapter.py
Comment thread src/chat_sdk/adapters/teams/adapter.py
Address Codex P2 finding on PR #88. The previous flow updated
``accumulated`` and ``session.sequence`` BEFORE awaiting ``_teams_send``,
so a 429/network failure mid-stream would leave the partial RawMessage
returned to the caller (and ``session._text`` feeding the final close-
activity) carrying text that Teams never displayed to the user.

Fix:
- Build ``candidate_accumulated = accumulated + text`` and
  ``next_sequence = session.sequence + 1`` as candidates.
- Send the activity with the candidate values.
- Commit ``accumulated`` and ``session.sequence`` only after the send
  succeeds.

The soft-cancel path (logged warn + session.cancel + break) now
genuinely returns the high-water mark of successfully-sent text. Matches
the documented "RawMessage carries pre-failure content" guarantee in
``docs/UPSTREAM_SYNC.md`` non-parity table.

Updated test_emit_send_failure_cancels_session: previously asserted
``result.raw["text"] == "helloworld"`` (which contained the rejected
"world" chunk); now asserts ``"hello"`` and ``session.sequence == 1``.

Skipped Codex P2 #2 (active_streams clobber timing) — matches upstream
TS behavior; deferring fix as a Python-only improvement would be a
divergence and is design-significant. File as follow-up if desired.

23 native-streaming tests pass.

https://claude.ai/code/session_01FyMxQn2BEAzmwKS1GZczKj
@patrick-chinchill patrick-chinchill marked this pull request as draft May 10, 2026 06:06
@patrick-chinchill patrick-chinchill marked this pull request as ready for review May 10, 2026 06:06
Copy link
Copy Markdown

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

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: 07df4b778b

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

Comment thread src/chat_sdk/adapters/teams/adapter.py Outdated
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/chat_sdk/adapters/teams/adapter.py`:
- Around line 437-493: _active_streams is keyed only by thread_id causing
concurrent handlers on the same thread to overwrite each other's
_TeamsStreamSession; instead of registering the session in the shared dict and
re-resolving by thread_id, keep the session in handler-local context (e.g. a
ContextVar) or pass the session explicitly into stream-related calls so each
webhook handler uses its own _TeamsStreamSession instance; update code paths
that access _active_streams (creation in this block, stream() consumer, and
cleanup via _close_stream_session) to use the handler-local session, ensure
_chained_wait_until / processing_done still wire to that session, and remove or
restrict shared lookups by thread_id to avoid stale/overwritten sessions.
- Around line 442-478: The webhook can deadlock if Chat.process_message never
creates a task (so _chained_wait_until is never called) because processing_done
remains unresolved; after the call to self._chat.process_message(self,
thread_id, message, chained_options) ensure processing_done is always unblocked
by adding a finally block that (1) catches asyncio.CancelledError around the
await processing_done to call session.cancel() and re-raise, and (2) after the
try/except/cleanup, checks if processing_done.done() is False and calls
processing_done.set_result(None) to guarantee resolution; reference
_resolve_processing, _chained_wait_until, WebhookOptions.wait_until,
processing_done, and self._chat.process_message when applying this change.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: c107c5b6-a50f-47f3-8e14-0ee87262958c

📥 Commits

Reviewing files that changed from the base of the PR and between 04c0658 and 07df4b7.

📒 Files selected for processing (5)
  • docs/UPSTREAM_SYNC.md
  • src/chat_sdk/adapters/teams/adapter.py
  • tests/test_teams_coverage.py
  • tests/test_teams_extended.py
  • tests/test_teams_native_streaming.py

Comment thread src/chat_sdk/adapters/teams/adapter.py
Comment thread src/chat_sdk/adapters/teams/adapter.py
claude added 4 commits May 10, 2026 06:13
Address CodeRabbit Critical on PR #88. ``Chat.process_message`` invokes
``wait_until`` synchronously only if a chat task is actually scheduled.
If the message is deduped, dropped by the concurrency strategy, or
otherwise short-circuited, no task is created and ``wait_until`` is
never called — leaving ``processing_done`` unresolved and ``await
processing_done`` hanging the webhook indefinitely.

After ``process_message`` returns, check whether ``processing_done`` was
resolved (which can only happen via ``_chained_wait_until``, which is
only invoked when a task is scheduled). If not, resolve immediately —
there's no in-flight handler to wait on, no streaming will happen, and
``await processing_done`` should fall through to the ``finally`` cleanup.

Also addresses Codex P2 (lines 471): caller-supplied
``WebhookOptions.wait_until`` raising synchronously now logs and
continues instead of escaping through ``Chat.process_message``,
preventing the same-class deadlock-via-finally issue.

Both fixes together close the two paths where the streaming session
could be torn down while the chat task was still scheduled (or never
scheduled): a synchronous wait_until exception, and a deduped/dropped
message.

Tests:
- New ``test_caller_wait_until_raise_does_not_kill_native_streaming``
  verifies the wait_until-raise path keeps the session alive long
  enough for the chat task to call ``thread.stream()`` natively.
- Existing 23 native-streaming tests still pass.

Skipped CodeRabbit's other comment (``_active_streams`` thread-id
keying) — same as Codex P2 #2, matches upstream TS behavior and would
be a heavy-lift Python-only divergence.

https://claude.ai/code/session_01FyMxQn2BEAzmwKS1GZczKj
…messages

The previous fix (17009a9) used ``processing_done.done()`` to detect
whether a chat task was scheduled, but ``_resolve_processing`` registers
a ``done_callback`` on the task that fires only on task COMPLETION —
not at the moment ``wait_until`` is invoked. So my check
prematurely resolved ``processing_done`` immediately after
``process_message`` returned (before the task had a chance to run),
which broke 2 tests:

- test_caller_wait_until_raise_does_not_kill_native_streaming
- test_same_thread_concurrent_handlers_clobber_active_stream

Fix: track a separate ``wait_until_invoked`` flag inside
``_chained_wait_until``. After ``process_message`` returns, only resolve
``processing_done`` if ``wait_until`` was NEVER called — that's the
true signal that no chat task was scheduled (deduped/dropped).

23 native-streaming tests pass; full suite passes (modulo the
pre-existing unrelated github_webhook failure).

https://claude.ai/code/session_01FyMxQn2BEAzmwKS1GZczKj
Three separate review bots flagged the thread-id keying of
``_active_streams`` as a potential cross-handler corruption risk on the
DM streaming path. Add an inline comment at the registration site
pointing readers at the upstream parallel and the concurrency
invariant that makes per-thread keying safe today, plus a note that a
per-handler ContextVar is a tracked Python-only follow-up rather than
in-scope for the parity sync.
@patrick-chinchill patrick-chinchill marked this pull request as draft May 11, 2026 21:30
@patrick-chinchill patrick-chinchill marked this pull request as ready for review May 11, 2026 21:30
Copy link
Copy Markdown

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

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: 6acc796ae3

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

Comment thread src/chat_sdk/adapters/teams/adapter.py Outdated
…y matches user-visible text

The previous soft-cancel behaviour in ``_stream_via_emit`` kept the
adapter's internal ``accumulated`` buffer in sync with what Teams had
actually accepted, but ``Thread.stream`` wraps the iterator in its own
generator that updates an *outer* ``accumulated`` BEFORE yielding each
chunk to the adapter. Because the soft-cancel path returned a partial
``RawMessage`` without raising, that outer buffer (which already
includes the rejected chunk) was used as the SentMessage body and
appended to ``_message_history`` — so the recorded message and the
text the user actually saw could disagree on a 429 / network failure
mid-stream.

Propagate the send exception instead. ``_wrapped_stream`` re-raises
through ``Thread.stream``, which short-circuits the post-stream
``_create_sent_message`` + history append so the SDK's record matches
the user's view of the conversation. The session is still cancelled
first so ``_close_stream_session`` doesn't post a final activity, and
the failure is still logged at ``warn``.

Re-aligns the Teams emit-failure path with how upstream chat-teams
surfaces send errors, removing the Python-only soft-cancel divergence
introduced in 07df4b7.

Test updated to match: ``test_emit_send_failure_propagates_and_cancels_session``
now uses ``pytest.raises`` and drops the partial-RawMessage assertion.
@patrick-chinchill patrick-chinchill marked this pull request as draft May 11, 2026 21:52
@patrick-chinchill patrick-chinchill marked this pull request as ready for review May 11, 2026 21:52
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tests/test_teams_native_streaming.py`:
- Around line 694-727: The test
test_handler_exception_still_drops_session_and_closes only asserts the session
is removed and never verifies that _close_stream_session (or the final "close"
activity sent via adapter._teams_send) is invoked; update the test to actually
stream at least one chunk before the handler raises and then assert that the
close behavior occurred (e.g., inspect adapter._teams_send calls for a final
close activity or assert a call to _close_stream_session) while still keeping
the check that tid is removed from adapter._active_streams after
adapter._handle_message_activity finishes.
- Around line 607-658: The DM lifecycle block beginning at the comment needs to
be split into its own async pytest test so it doesn't run inside
test_caller_wait_until_raise_does_not_kill_native_streaming; extract the whole
scenario (adapter setup, _dm_thread_id usage, chat/process_message mock,
activity creation, await adapter._handle_message_activity, and assertions) into
a new function decorated with `@pytest.mark.asyncio` and named e.g.
test_dm_session_register_and_finalize (or similar), ensuring you copy the
adapter/_chat setup and AsyncMocking of _teams_send exactly so the new test is
independent and verifies that the session is removed and final stream message is
sent.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 6f529b35-d50e-4f29-ac4e-834638e406ca

📥 Commits

Reviewing files that changed from the base of the PR and between 6acc796 and 86c95db.

📒 Files selected for processing (2)
  • src/chat_sdk/adapters/teams/adapter.py
  • tests/test_teams_native_streaming.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/chat_sdk/adapters/teams/adapter.py

Comment thread tests/test_teams_native_streaming.py
Comment thread tests/test_teams_native_streaming.py Outdated
Copy link
Copy Markdown

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

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: 86c95dbde8

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

Comment thread src/chat_sdk/adapters/teams/adapter.py Outdated
Comment on lines +1223 to +1224
try:
result = await self._teams_send(decoded, activity_payload)
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 Throttle native Teams emits to Teams' quota

For normal LLM/token streams that yield more than once per second, this loop sends one Bot Framework streaming request per chunk. Microsoft’s Teams REST streaming docs state the streaming throttle is 1 request/second and recommend buffering tokens for about 1.5–2 seconds, so native DM streams will quickly hit 429 and cancel mid-response unless chunks are coalesced/throttled before _teams_send (e.g. honoring StreamOptions.update_interval_ms). See https://learn.microsoft.com/microsoftteams/platform/bots/streaming-ux#switch-to-streaming-response.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in commit b29efe0. The emit loop now gates per-chunk sends on a configurable throttle window:

  • New TeamsAdapterConfig.native_stream_min_emit_interval_ms (default 1500ms per Microsoft's 1.5-2s recommendation)
  • Honors StreamOptions.update_interval_ms when the caller passes one (e.g. from a StreamingPlan)
  • Chunks arriving within the window are accumulated locally and shipped together on the next eligible emit
  • Text is never lost: session.text accumulates every chunk regardless of coalescing, and the final message activity ships the full cumulative text — so even if the iterator ends inside a throttle window, the user sees the complete response

Tests in TestNativeStreamingThrottle pin the behavior with a deterministic injectable clock (no real sleeps):

  • test_chunks_within_throttle_interval_are_coalesced — fast LLM streams collapse to one emit
  • test_chunks_beyond_throttle_interval_emit_individually — slow streams still emit per chunk
  • test_caller_update_interval_ms_overrides_defaultStreamOptions override works
  • test_coalesced_text_ships_in_final_close_activity — regression test that buffered chunks survive in the close path

Long-term: this hand-rolled throttle is documented as transitional in UPSTREAM_SYNC.md. microsoft-teams-apps (Python SDK, GA 2026-05-01) provides IStreamer.emit() which handles throttling internally; migration is planned for 0.4.28 / future Python-only.


Generated by Claude Code

Comment thread src/chat_sdk/adapters/teams/adapter.py Outdated
claude added 2 commits May 11, 2026 21:56
…ception coverage

CodeRabbit caught two real defects in the test additions from this PR:

1. ``test_caller_wait_until_raise_does_not_kill_native_streaming``
   contained an orphan docstring followed by a complete second DM
   lifecycle scenario (adapter setup → process_message → assertions on
   final activity). The missing function header meant pytest executed
   the second scenario as a tail-end of the first test, so the
   register/finalize path only ran when the wait-until-raise scenario
   passed, and any failure attributed to the wrong test name. Split
   the second body into its own
   ``test_dm_message_activity_registers_session_and_finalizes`` so each
   scenario fails independently.

2. ``test_handler_exception_still_drops_session_and_closes`` only ever
   asserted ``tid not in adapter._active_streams`` — the "and_closes"
   part of the name was unverified because the handler raised before
   any chunk was streamed, leaving ``session.text`` empty and
   ``_close_stream_session`` early-returning. Stream one chunk before
   the raise and assert the final ``message`` activity actually shipped
   with ``streamType: final`` and the user-visible text. Renamed to
   ``test_handler_exception_after_partial_stream_drops_session_and_closes``
   so the name matches what is now actually exercised.
… to 1 req/sec

Two P1 wire-format defects in the hand-rolled native Teams DM
streaming, both surfaced by Codex review on PR #88:

1. **streamId missing from streaminfo entity.** The Bot Framework REST
   streaming contract requires ``streamId`` on BOTH ``channelData`` and
   the ``streaminfo`` entity for subsequent and final activities. We
   only set it on ``channelData``, which Teams may treat as a malformed
   continuation and detach from the original stream. Add the field to
   the entity in ``_stream_via_emit`` (subsequent chunks) and
   ``_close_stream_session`` (final). First chunk still omits it
   everywhere — the server has not yet assigned an id.

2. **No emit throttle.** Teams' streaming endpoint enforces ~1
   request/second; Microsoft recommends 1.5-2s buffering. The previous
   loop shipped one ``typing`` activity per source chunk, so any
   real-world LLM token stream (10+ tokens/s) would 429 within the
   first second and cancel the response mid-flight. Add a throttle gate
   keyed on a monotonic ms-clock: chunks within
   ``native_stream_min_emit_interval_ms`` of the previous emit are
   accumulated locally and shipped together on the next eligible emit
   (or in the final close activity if the iterator ends inside the
   window). Default 1500ms; overridable per-stream via
   ``StreamOptions.update_interval_ms`` and per-adapter via the new
   ``TeamsAdapterConfig.native_stream_min_emit_interval_ms`` field.
   Text is never lost — ``session.text`` accumulates every chunk and
   feeds the final ``message`` activity regardless of coalescing.

Adds 6 tests across two new classes:

- ``TestNativeStreamingThrottle`` — within-window coalescing,
  across-window per-chunk emit, ``StreamOptions.update_interval_ms``
  override, and "coalesced chunks survive in the final close
  activity" regression.
- ``TestStreamInfoEntityContract`` — entity-level ``streamId`` on
  subsequent chunks and on the final ``message`` activity.

Strengthens ``test_first_chunk_omits_stream_id`` and
``test_close_session_sends_final_message`` to also assert entity-level
``streamId`` presence/absence.

Test clock: tests now use ``_advancing_clock(step_ms)`` to drive the
throttle deterministically without real sleeps. The shared
``_make_adapter`` helper defaults to ``step_ms=2000`` (> 1500ms
throttle) so existing per-chunk-emit assertions still hold; throttle
tests pass ``clock_step_ms=0`` to land all chunks in a single window.

``UPSTREAM_SYNC.md`` row 491 is rewritten: the prior
soft-cancel-on-send-failure entry was replaced by re-raise in 86c95db,
and the row now documents both the hand-rolled wire format and the
tracked migration to ``microsoft-teams-apps`` (Python SDK, GA
2026-05-01). A new row documents the entity-level ``streamId``
contract for clarity.
@patrick-chinchill patrick-chinchill marked this pull request as draft May 15, 2026 21:37
@patrick-chinchill patrick-chinchill marked this pull request as ready for review May 15, 2026 21:37
Copy link
Copy Markdown

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

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: c18154740c

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

Comment thread src/chat_sdk/adapters/teams/adapter.py Outdated
Codex P1 on commit c181547: the end-of-stream flush in
``_stream_via_emit`` shipped buffered text as a ``typing`` activity
immediately when the iterator finished, without honoring the throttle
window relative to the last successful emit. For a fast LLM stream that
finishes inside ``native_stream_min_emit_interval_ms`` (default 1500ms)
after an earlier emit, the flush would 429 the Bot Framework streaming
endpoint (~1 req/sec quota) and cancel the stream mid-flight — exactly
the failure the throttle was added to prevent.

Fix: compute ``elapsed_ms = clock_now - last_emit_at_ms`` and, if it's
less than ``emit_interval_ms``, await the remaining window before
emitting the flush. Cancellation during the wait is honored — caller may
``session.cancel()`` while we sleep, in which case we skip the flush
emit and return the partial RawMessage (the cumulative text is still
recorded for the caller's history, matching the in-loop cancellation
shape).

Injects a new ``_stream_sleep_ms`` callable on the adapter that defaults
to ``asyncio.sleep(ms / 1000.0)`` and is overridden to an AsyncMock in
``_make_adapter`` so existing tests don't actually wait the configured
interval.

3 new regression tests in ``TestFlushThrottle``:

- ``test_flush_waits_for_throttle_window_when_iterator_ends_in_window``:
  pins that with ``clock_step_ms=0`` (every chunk inside the window), the
  flush awaits ``_stream_sleep_ms`` exactly once for the full 1500ms.
- ``test_flush_does_not_wait_when_window_already_elapsed``: pins that
  when all chunks emitted in-loop (no buffered text), the flush block is
  skipped entirely, so no sleep call.
- ``test_flush_skips_emit_if_session_canceled_during_wait``: pins the
  cancellation-during-wait path — sleep side-effect cancels the session,
  flush bails without emitting, but RawMessage still carries the
  cumulative text.

Tests: 36 pass in ``test_teams_native_streaming.py`` (was 33, +3 new),
217 in the full Teams test sweep. Lint + format clean.
@patrick-chinchill patrick-chinchill marked this pull request as draft May 15, 2026 23:43
@patrick-chinchill patrick-chinchill marked this pull request as ready for review May 15, 2026 23:43
Copy link
Copy Markdown

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

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: cbf3461225

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

Comment thread src/chat_sdk/adapters/teams/adapter.py Outdated
Comment on lines +1309 to +1313
session._text = accumulated # noqa: SLF001
return RawMessage(
id=session.first_chunk_id,
thread_id=thread_id,
raw={"text": accumulated},
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 Return only emitted text when flush is canceled

When a cancellation arrives during the throttle wait after some chunks were coalesced, this branch skips the flush and the close path also skips the final message because session.canceled is true, so Teams has only seen last_committed_text. Returning accumulated here lets Thread.stream record buffered text that was deliberately not emitted; in this scenario return the last committed text or propagate cancellation instead of committing the unsent suffix.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Good catch — and the same defect existed in the in-loop cancellation path too (the if session.canceled: break at the top of the loop falls through to the same bottom return, so a coalesced chunk that buffered before cancellation would also be returned as RawMessage.raw["text"]).

Fixed in commit 52dc032 with a unified return-text selection at the bottom of _stream_via_emit:

final_text = last_committed_text if session.canceled else accumulated
session._text = final_text
return RawMessage(..., raw={"text": final_text})

Happy-path streams (no cancellation) collapse to accumulated because the flush above commits the final batch, setting last_committed_text == accumulated. Canceled paths surface only what Teams actually accepted, regardless of whether cancellation arrived in-loop or during the flush throttle wait.

Also removed the early-return I'd added in the during-wait branch — the flush just skips the emit on canceled, falls through to the unified return, and the bottom does the right thing.

Tests updated:

  • test_flush_skips_emit_if_session_canceled_during_wait now asserts result.raw["text"] == "Hello" (was "Hello world") to pin Codex's contract for the during-wait variant.
  • New test_in_loop_cancellation_returns_only_committed_text pins the in-loop variant: yields a chunk, coalesces a second, cancels, then breaks on the next iter's cancel-check; the return must carry only the first emit's text.

Tests: 37 streaming (was 36, +1 new), 218 in the full Teams sweep. Lint + format clean.


Generated by Claude Code

…ight

Codex P2 on commit cbf3461: when cancellation arrived during the
end-of-stream flush's throttle wait, the early-return surfaced
``accumulated`` (including the locally-buffered suffix the user
explicitly canceled out of) as ``RawMessage.raw["text"]`` and
``session._text``. ``Thread.stream`` builds the ``SentMessage`` from
that value, so the SDK's recorded history would carry text Teams never
shipped.

The same defect existed in the in-loop cancellation path: a coalesced
chunk could leave ``accumulated > last_committed_text``, then
``if session.canceled: break`` would exit the loop without flushing,
fall through to ``session._text = accumulated``, and return the
buffered suffix.

Unified fix at the bottom return block:

  final_text = last_committed_text if session.canceled else accumulated

Now both cancellation paths surface only what Teams actually accepted.
Happy-path streams (no cancellation) collapse to ``accumulated`` because
the flush above commits the final batch, setting ``last_committed_text
== accumulated``.

Also removed the early-return from the during-wait cancellation branch
— the flush block just skips the emit on canceled, falls through to
the unified return, and the bottom does the right thing.

Updated ``test_flush_skips_emit_if_session_canceled_during_wait`` to
assert ``result.raw["text"] == "Hello"`` instead of "Hello world"
(captures Codex's contract). Added new test
``test_in_loop_cancellation_returns_only_committed_text`` covering the
in-loop cancellation variant — yields a chunk, coalesces a second,
cancels, then breaks on the cancel-check; the return must carry only
the first chunk.

Tests: 37 streaming (was 36, +1 new), 218 in the full Teams sweep.
Lint + format clean.
@patrick-chinchill patrick-chinchill marked this pull request as draft May 16, 2026 00:25
@patrick-chinchill patrick-chinchill marked this pull request as ready for review May 16, 2026 00:25
Copy link
Copy Markdown

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

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: 52dc032cef

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

# ran to completion, ``last_committed_text`` and ``accumulated``
# are equal (the flush above committed the final batch), so this
# collapses to ``accumulated`` in the happy path.
final_text = last_committed_text if session.canceled else accumulated
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 Trim Thread.stream history on canceled native streams

When cancellation happens after a coalesced chunk was yielded, setting final_text to last_committed_text here does not keep normal Thread.stream callers consistent: Thread._handle_stream accumulates each chunk before yielding it to the adapter and then creates the SentMessage from its own accumulated buffer instead of raw_result.raw (src/chat_sdk/thread.py:635-650). Fresh evidence for re-raising this issue: those Thread lines are unchanged, so this new RawMessage text is ignored and a canceled buffered suffix can still be recorded even though Teams never received it.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Right — my prior fix only corrected the adapter-level state. The actual SentMessage body comes from Thread._handle_stream's local accumulated buffer in _wrapped_stream (src/chat_sdk/thread.py:633-654), not from the RawMessage returned by the adapter. Without plumbing the corrected text through, the cancellation defect persisted at the SDK boundary.

Fixed in commit 7809b7e with a two-layer change:

  1. RawMessage gains an optional text: str | None = None field — adapter-authoritative override for the recorded text. Adapters whose internal state (cancellation, throttling, partial commits) makes their local-accumulator view diverge from what the platform actually accepted set this; adapters that don't need it leave the default None and Thread.stream falls back to its local accumulator. Backward-compatible by construction.

  2. Thread._handle_stream (native-streaming branch) prefers raw_result.text when not None, else uses the local accumulator:

recorded_text = raw_result.text if raw_result.text is not None else accumulated
sent = self._create_sent_message(
    raw_result.id,
    PostableMarkdown(markdown=recorded_text),
    raw_result.thread_id,
)
  1. Teams _stream_via_emit sets text=final_text on every returned RawMessage (where final_text = last_committed_text if session.canceled else accumulated). Happy path: override == accumulated, no behavior change. Cancellation paths: override carries only what Teams shipped, Thread.stream records that, the SentMessage and message history match the user's view.

Tests added (4 net new):

  • Teams: test_canceled_stream_sets_raw_message_text_override — adapter populates the override on cancellation
  • Teams: test_happy_path_stream_also_sets_raw_message_text — symmetry, adapter always sets it
  • Thread: test_should_prefer_raw_message_text_override_over_local_accumulator — Thread.stream uses the override when set
  • Thread: test_should_fall_back_to_local_accumulator_when_text_override_is_none — backward compatibility for adapters that don't set it

Tests: 39 streaming, 199 in the streaming + thread file pair, 3,709 in the full suite. 1 pre-existing GitHub-adapter failure unrelated. Lint + format clean.


Generated by Claude Code

claude added 3 commits May 16, 2026 00:37
…orded SentMessage

Codex P2 follow-up on commit 52dc032: the prior fix corrected
``RawMessage.raw["text"]`` and ``session._text`` at the adapter
boundary, but ``Thread._handle_stream`` builds the recorded
``SentMessage`` body from its own local ``accumulated`` buffer (in
``_wrapped_stream``, ``src/chat_sdk/thread.py:633-654``), not from the
adapter's return value. So a canceled native stream with buffered text
would still record the buffered suffix in ``SentMessage.text`` and the
``_message_history.append``, even though Teams never shipped it.

Fix in two layers:

1. ``RawMessage`` gains an optional ``text: str | None = None`` field —
   an adapter-authoritative override for the recorded text. Adapters
   whose internal state (cancellation, throttling, partial commits) makes
   their local-accumulator view diverge from what the platform actually
   accepted set this; adapters that don't need it leave the default
   ``None`` and ``Thread.stream`` falls back to its local accumulator.

2. ``Thread._handle_stream`` (the native-streaming branch) prefers
   ``raw_result.text`` when not None, else uses the local accumulator.
   Backward-compatible: Slack, Discord, GitHub, Linear, Google Chat,
   Telegram, WhatsApp don't set the override, so their behavior is
   unchanged.

3. Teams ``_stream_via_emit`` sets ``text=final_text`` on the returned
   ``RawMessage``, where ``final_text`` is ``last_committed_text`` when
   canceled and ``accumulated`` otherwise. So:
   - Happy path: override == accumulated, no behavior change.
   - Cancellation (in-loop break or during-wait): override carries only
     what Teams shipped, ``Thread.stream`` records that, the SentMessage
     and message history match the user's view.

Tests added (4 net new):

- ``test_canceled_stream_sets_raw_message_text_override`` (Teams) — pins
  that the adapter populates ``RawMessage.text`` on cancellation.
- ``test_happy_path_stream_also_sets_raw_message_text`` (Teams) — pins
  symmetry: the adapter always sets the override, Thread.stream always
  honors it.
- ``test_should_prefer_raw_message_text_override_over_local_accumulator``
  (Thread) — pins that Thread.stream uses the override instead of its
  local accumulator when set.
- ``test_should_fall_back_to_local_accumulator_when_text_override_is_none``
  (Thread) — pins backward compatibility for adapters that don't set
  the override.

Tests: 39 streaming, 199 in the streaming + thread file pair, 3,709 in
the full suite (was 3,701 — net +8 across both files). 1 pre-existing
GitHub-adapter failure unrelated. Lint + format clean.
The flush-window throttle wait (``_stream_sleep_ms``) and clock read
sit outside the in-loop try/except in ``_stream_via_emit``. A
supervisor-initiated task cancellation that fires while the flush is
awaiting the throttle window propagates ``CancelledError`` out of the
method while ``session.canceled`` is still ``False``. The finally-block
close path would then proceed as if the stream ran to completion —
violating the invariant "any exception leaving ``_stream_via_emit``
implies ``session.canceled``" that the close path relies on when
deciding whether to ship a final ``message`` activity.

Wrap the flush block in the same ``except (CancelledError, ...) /
except Exception`` shape as the in-loop loop. ``_emit_streaming_activity``
already self-cancels on its own send failures (covered by
``test_flush_failure_propagates_and_cancels_session``); this commit
covers the throttle-wait path that previously had no guard. Two new
regression tests pin both the CancelledError and the generic exception
cases.

https://claude.ai/code/session_01FyMxQn2BEAzmwKS1GZczKj
…rumb

The new optional ``text`` field on ``RawMessage`` (added by 7809b7e to
plumb the adapter-authoritative text override through to ``Thread.stream``'s
recorded ``SentMessage``) is a Python-only divergence from upstream's
``RawMessage`` interface (``packages/chat/src/types.ts`` — fields are
``id``, ``raw``, ``threadId`` only). Per ``docs/SELF_REVIEW.md`` principle
5 ("Divergence budget"), every divergence needs a row in
``docs/UPSTREAM_SYNC.md`` AND a breadcrumb at the code site.

Adds both: a new row under the Teams native streaming cluster
documenting the why (cancellation-text reconciliation lives inside
``@microsoft/teams.apps``'s ``IStreamer.emit`` upstream; we hand-roll
the wire format until ``microsoft-teams-apps`` Python is in reach) and
referencing the existing regression tests, plus a "Divergence from
upstream — see docs/UPSTREAM_SYNC.md" comment on the field itself.

https://claude.ai/code/session_01FyMxQn2BEAzmwKS1GZczKj
Copy link
Copy Markdown
Collaborator Author

Replying to the latest Codex review-summary on 52dc032cef.

Ran a self-review pass over the cancellation-text patch chain (86c95db through 7809b7e) per the adversarial checks in docs/SELF_REVIEW.md. Self-review pass found 2 additional issues; addressed in commits 6964685 and 22a1b7e.

Findings

  1. P1 — flush-wait CancelledError leaks past the in-loop guard (src/chat_sdk/adapters/teams/adapter.py:1295-1323 pre-fix). The end-of-stream flush block (_stream_sleep_ms throttle wait + _emit_streaming_activity) sits outside the in-loop try/except (CancelledError, ...) shape that the rest of _stream_via_emit uses. A supervisor-initiated task cancellation that fires while _stream_sleep_ms is awaiting would propagate CancelledError out of the method while session.canceled remained False, violating the invariant the close path relies on ("any exception leaving _stream_via_emit implies session.canceled"). The finally-block close path could then ship a final message activity that the in-loop guard would otherwise have suppressed. Fixed in 6964685 by mirroring the in-loop except (CancelledError, KeyboardInterrupt, SystemExit, GeneratorExit) / except Exception shape around the flush block. Two new regression tests pin both the CancelledError and the generic-exception cases (test_flush_wait_cancelled_error_cancels_session, test_flush_wait_generic_exception_cancels_session).

  2. P1 — RawMessage.text divergence not in docs/UPSTREAM_SYNC.md (docs/SELF_REVIEW.md principle 5 — Divergence budget). The new optional text field added to RawMessage in 7809b7e is a Python-only addition (upstream's RawMessage interface at packages/chat/src/types.ts:1285 has only id, raw, threadId). Per the divergence-budget rule, every divergence needs a row in the non-parity table AND a breadcrumb at the code site. Fixed in 22a1b7e by adding the table row under the Teams native streaming cluster (references the existing regression tests at test_should_prefer_raw_message_text_override_over_local_accumulator and test_canceled_stream_sets_raw_message_text_override that would fail if someone "fixes" Thread.stream back to upstream's local-accumulator-only behavior) and a # Divergence from upstream — see docs/UPSTREAM_SYNC.md comment at the field definition.

No defect on the other adversarial checks

  • Input sweeps (empty stream / single chunk / iterator-raised CancelledError / update_interval_ms=0 / _stream_clock_ms raising): no leaks beyond the one fixed above.
  • Pass-interaction (concurrent streams sharing _stream_clock_ms/_stream_sleep_ms): clock and sleep callables are stateless reads of loop.time() / asyncio.sleep; no cross-stream state.
  • Rebind/state coherence: after a successful flush, last_committed_text == accumulated == session._text == RawMessage.text == RawMessage.raw["text"]. After a flush-emit failure _emit_streaming_activity cancels-and-reraises, leaving last_committed_text as the prior accepted text.
  • Emit/parse symmetry on RawMessage: dataclass with the new field is optional + kwargs-only at every call site (no 4-arg positional ambiguity); no JSON/pickle/asdict path serializes it.
  • Unforgeable sentinels: Thread._handle_stream's raw_result.text if raw_result.text is not None else accumulated correctly distinguishes "" (use empty override) from None (fall back to local accumulator); the all-empty-stream case returns RawMessage(text="") symmetrically with accumulated="".

Validation

uv run ruff format src/ tests/ scripts/    # 194 files unchanged
uv run ruff check src/ tests/ scripts/     # All checks passed
uv run pytest tests/ --tb=short -q         # 3711 passed, 2 skipped, 1 failed

The single failure is the pre-existing tests/test_github_webhook.py::TestGitHubAdapterConstructor::test_throws_when_no_auth that also fails on main (out of scope for this PR).

Pushed as 6964685 (fix) and 22a1b7e (docs) on claude/port-teams-native-dm-streaming-J7S7H.

Posted by an automated self-reviewer agent. https://claude.ai/code/session_01FyMxQn2BEAzmwKS1GZczKj


Generated by Claude Code

@patrick-chinchill patrick-chinchill marked this pull request as draft May 16, 2026 10:35
@patrick-chinchill patrick-chinchill marked this pull request as ready for review May 16, 2026 10:35
Copy link
Copy Markdown

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

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: 22a1b7e336

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

Comment on lines +1488 to +1490
try:
await self._teams_send(decoded, final_activity)
except Exception as exc:
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 Throttle the final stream activity

For short DM streams, the last successful typing emit can happen immediately before this final message send (for example, a one-chunk response emits in _stream_via_emit, then _handle_message_activity closes the session right away). The Teams streaming REST contract treats the final message as the next streaming API request and documents a 1 request/second quota, so this immediate final call can 429 under the default 1500 ms throttle; because the exception is swallowed below, Teams may never receive the final message while the SDK records the response as sent. Track the last emit time and wait the remaining interval before sending the final activity.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Good catch — same root cause as the in-stream flush throttle (1 req/sec quota), just shifted to the close path. For short DM streams (one chunk emitted at t=0, then immediate close), the final message activity would land within the throttle window of the last typing and 429.

Fixed in commit 0509b3d by plumbing the timestamp + interval through to the close path:

  1. _TeamsStreamSession gains two fields:

    • last_emit_at_ms: float = float("-inf") — mirror of the in-loop local, written after each successful emit (in-loop AND flush) so the close path can compute elapsed against it.
    • emit_interval_ms: int | None = None — cache of the per-stream interval set at the top of _stream_via_emit. Honors caller-supplied StreamOptions.update_interval_ms overrides without _close_stream_session needing access to options. None falls back to the adapter default for defensive paths (e.g. sessions constructed manually in tests).
  2. _stream_via_emit writes both fields: emit_interval_ms once at the top; last_emit_at_ms after each successful in-loop emit (using now_ms) and after the flush emit (re-reading the clock since the wait may have elapsed real time).

  3. _close_stream_session computes elapsed_ms = clock_now - session.last_emit_at_ms and, if less than the interval, awaits _stream_sleep_ms(remaining) before sending the final activity. Supervisor-initiated cancellation during the wait is honored (mark session.canceled + re-raise); session.cancel() arriving from another task is re-checked after the sleep returns and skips the final emit.

6 new regression tests in TestCloseStreamThrottle:

  • test_close_waits_for_throttle_window_when_emit_was_recent — pins the wait when last_emit_at_ms is fresh
  • test_close_does_not_throttle_when_window_already_elapsed — pins no-wait when window has passed
  • test_close_honors_session_interval_overrideupdate_interval_ms=0 propagates through
  • test_close_falls_back_to_adapter_default_when_session_interval_unset — defensive: manual sessions
  • test_close_does_not_throttle_when_no_emit_happened — defensive: last_emit_at_ms == -inf
  • test_close_cancellation_during_wait_skips_final_emit — cancellation invariant during wait

Tests: 47 streaming (was 39, +8 — 6 new close-throttle + 2 from the self-review's earlier flush-wait CancelledError pair), 3,715 in the full suite, 1 pre-existing GitHub-adapter failure unrelated. Lint + format clean.

This closes out the throttle story: in-loop emits, end-of-stream flush, AND the close-path final activity all honor the same 1 req/sec quota.


Generated by Claude Code

…ing quota

Codex P2 on commit 22a1b7e: Teams' streaming endpoint rate-limits ALL
activities sharing a ``streamId`` together — the streaming ``typing``
activities AND the final ``message`` activity. For short DM streams
(one chunk, emitted immediately), ``_handle_message_activity`` closes
the session right after ``_stream_via_emit`` returns, and
``_close_stream_session`` ships the final ``message`` within
milliseconds of the last ``typing`` activity. That lands two requests
in the same second and risks a 429, which the close-path swallows
fail-soft — leaving Teams' streaming UI stuck while the SDK records
the response as sent.

Fix: plumb the last-emit timestamp and the per-stream interval through
to the close path via the session, then throttle.

- ``_TeamsStreamSession`` gains two fields: ``last_emit_at_ms`` (mirror
  of the in-loop local for the close path's clock check) and
  ``emit_interval_ms`` (cache of the per-stream interval, honoring
  ``StreamOptions.update_interval_ms`` overrides without the close
  path needing access to ``options``).
- ``_stream_via_emit`` writes both fields: ``emit_interval_ms`` at the
  top, ``last_emit_at_ms`` after each successful in-loop emit and the
  flush emit.
- ``_close_stream_session`` computes ``elapsed_ms = clock_now -
  session.last_emit_at_ms`` and, if less than the interval, awaits
  ``_stream_sleep_ms`` before sending the final activity.
- Cancellation during the close-path wait is honored: supervisor-
  initiated ``CancelledError`` cancels the session and re-raises (same
  shape as the flush wait); ``session.cancel()`` arriving via another
  task is re-checked after the sleep returns and skips the final emit.

Six new regression tests in ``TestCloseStreamThrottle``:
- ``test_close_waits_for_throttle_window_when_emit_was_recent`` — pins
  the throttle wait when ``last_emit_at_ms`` is fresh.
- ``test_close_does_not_throttle_when_window_already_elapsed`` — pins
  no-wait when the window has passed.
- ``test_close_honors_session_interval_override`` — caller's
  ``update_interval_ms=0`` propagates through to close.
- ``test_close_falls_back_to_adapter_default_when_session_interval_unset``
  — defensive: sessions constructed without ``emit_interval_ms``
  (e.g. manually in tests) use the adapter default.
- ``test_close_does_not_throttle_when_no_emit_happened`` — defensive:
  ``last_emit_at_ms == -inf`` (no emit ever) skips the wait.
- ``test_close_cancellation_during_wait_skips_final_emit`` —
  cancellation invariant during the close-path wait.

Tests: 45 streaming (was 39, +6 new), 3,715 in the full suite, 1
pre-existing GitHub-adapter failure unrelated. Lint + format clean.
@patrick-chinchill patrick-chinchill marked this pull request as draft May 22, 2026 03:34
@patrick-chinchill patrick-chinchill marked this pull request as ready for review May 22, 2026 03:34
@patrick-chinchill patrick-chinchill marked this pull request as draft May 22, 2026 03:51
@patrick-chinchill patrick-chinchill marked this pull request as ready for review May 22, 2026 03:51
Copy link
Copy Markdown

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

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: 0509b3dd34

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

Comment on lines +1151 to +1153
session = self._active_streams.get(thread_id)
if session is not None and not session.canceled:
return await self._stream_via_emit(thread_id, text_stream, session, options)
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 Create a fresh native stream for each DM stream() call

stream() always reuses the thread-scoped _TeamsStreamSession when one is present, so if a single DM handler calls thread.stream() (or thread.post() with a stream) more than once, the second call is sent on the same Teams stream state instead of starting a new message. In this path _stream_via_emit overwrites session._text and returns the original session.first_chunk_id, so later calls can overwrite what the user saw from the first streamed reply while the SDK still records multiple sent messages; this is a behavior regression from the previous per-call post/edit flow.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

@codex review

5 commits since the last Codex review (52dc032cef):

  • 7809b7eRawMessage.text override + Thread.stream plumbing
  • 6964685 — flush-wait CancelledError/exception cancels session (caught by formal self-review)
  • 22a1b7eRawMessage.text divergence row + code-site breadcrumb
  • 0509b3d — close-path final activity throttled against the 1 req/sec quota

Worth fresh eyes on the cancellation-text chain end-to-end (in-loop break, flush throttle wait, close-path throttle wait) and the new RawMessage.text SDK-wide override field — particularly whether anything outside the Teams adapter could legitimately set text="" in a way that diverges from the local accumulator fallback.


Generated by Claude Code

…cumulator

Self-review on commit 7809b7e (RawMessage.text happy-path override): the
adapter's ``_stream_via_emit`` only extracted text from ``str`` and
``dict[type=markdown_text]`` chunks while ``Thread.stream``'s
``_wrapped_stream`` also handles ``MarkdownTextChunk`` dataclass
instances (any object with ``.type == "markdown_text"``).

Before 7809b7e this asymmetry was masked: ``Thread.stream`` recorded
the ``SentMessage`` body from its own local accumulator (which DID
include dataclass chunks), so even though Teams never shipped any
streaming activities for object-form chunks, the recorded text matched
the user's intent.

After 7809b7e the Teams adapter sets ``RawMessage.text=final_text`` on
the happy path too, and ``Thread.stream`` prefers that override over
its own accumulator. ``final_text`` from Teams is the adapter's view
(empty when only dataclass chunks were yielded), so the recorded
``SentMessage`` and ``_message_history`` entry would silently become an
empty string while the user sees nothing in Teams.

Fix: mirror ``Thread.stream``'s third extraction branch in the Teams
adapter's chunk loop — ``getattr(chunk, "type", None) == "markdown_text"``
pulls ``.text``, matching the dataclass form. Non-text ``StreamChunk``
variants (``TaskUpdateChunk``, ``PlanUpdateChunk``) still fall through
with ``text == ""`` and are skipped, identical to Thread's behavior.

Adds ``test_markdown_text_chunk_dataclass_extract_text`` pinning the
behavior with a mixed stream of ``MarkdownTextChunk``, ``TaskUpdateChunk``,
and ``PlanUpdateChunk`` — would fail without the new branch.

Tests: 208 in streaming + thread file pair (was 207, +1), 3,718 in the
full suite (was 3,717), 1 pre-existing GitHub-adapter failure unrelated.
Lint + format + pyrefly clean.

https://claude.ai/code/session_01FyMxQn2BEAzmwKS1GZczKj
@chatgpt-codex-connector
Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Chef's kiss.

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

@patrick-chinchill patrick-chinchill merged commit 1ddc8d3 into main May 28, 2026
10 checks passed
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.

2 participants