feat(telegram): add native DM draft streaming with segmented stream results#340
Conversation
|
@lurenjia534 is attempting to deploy a commit to the Vercel Labs Team on Vercel. A member of the Team first needs to authorize it. |
|
Ah, I forgot to upload a video showing this PR screen-20260407-075015-1775519367603-10mb.mp4's effect it currently only works in private chats, since Telegram's official streaming API only allows us to use it in private conversations. |
|
@visyat I resolved the merge conflict and pushed a follow-up fix for Telegram streaming segmentation. The latest commit prevents streamed MarkdownV2 content from being truncated when the rendered Telegram payload grows beyond the original markdown source Verified locally:
Could you take another look when you have a chance? |
|
Hi @visyat, any updates on this? |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
@visyat I synced the branch with the latest upstream I verified locally:
All passed. The current GitHub Actions CI run is |
vercel/chat#340) Port of upstream chat@4.30.0's Telegram private-chat draft streaming (changeset 5461ea9, PR vercel/chat#340). Adapter (src/chat_sdk/adapters/telegram/adapter.py): - Add `stream()`: DMs get native draft streaming via the `sendMessageDraft` Bot API method. The draft bubble updates in place as chunks arrive, throttled to `options.update_interval_ms` (default TELEGRAM_DEFAULT_STREAM_UPDATE_INTERVAL_MS = 250ms), then a regular `sendMessage` persists the final text. Returns `None` for non-DM threads BEFORE consuming any chunks so the SDK's built-in post+edit fallback handles groups, supergroups, and channels. - Add `with_telegram_markdown_fallback()`: shared retry-without-`parse_mode` path now wraps `post_message`, `edit_message`, `send_document`, and `send_attachment`. When Telegram rejects MarkdownV2 with a "can't parse entities" 400, the call retries once with the plain-text rendering (or the original text when stripping markup left nothing). - Add `render_plain_text_message`, `resolve_telegram_fallback_text`, `create_draft_id` (int32-wrapping, wall-clock-seeded), and `_create_telegram_document_form_data` (form bodies are single-use, so the retry rebuilds them). Core (src/chat_sdk/thread.py, src/chat_sdk/types.py): - `Adapter.stream` / `BaseAdapter.stream` may now return `RawMessage | None`. `None` (returned before any chunk is consumed) delegates to the post+edit fallback for the current thread. - `Thread._handle_stream` checks `if raw_result is not None`; on `None` it closes the unused wrapper generator and falls through to the fallback. The existing `RawMessage.text` override (Teams cancellation reconciliation) is preserved inside the non-None branch. Documented divergence (docs/UPSTREAM_SYNC.md): unlike upstream's `thread.ts`, we do NOT seed `update_interval_ms` with the thread default. Our hand-rolled Teams native streaming path treats any non-`None` value as a caller override of its 1500ms quota-protecting throttle; seeding 500ms would silently drop Teams DM throttling below the platform's ~1 req/sec quota. Adapters apply their own default when the field is `None`, and `_fallback_stream` already substitutes `self._streaming_update_interval_ms`, so the fallback path stays observably identical to upstream. Tests: all 12 upstream `it()` blocks ported to tests/test_telegram_streaming.py plus the `stream returns null` fallback test to tests/test_thread_faithful.py, with Python-only coverage for the default-interval, whitespace-rejection, and empty-fallback-text branches. Parity note: this is a partial 4.30.0 slice; UPSTREAM_PARITY stays at 4.29.0 (the rest of the 4.30 wave is tracked separately). https://claude.ai/code/session_013zwTcMek5rNqBTQvs2oF64
vercel/chat#340) (#140) Port of upstream chat@4.30.0's Telegram private-chat draft streaming (changeset 5461ea9, PR vercel/chat#340). Adapter (src/chat_sdk/adapters/telegram/adapter.py): - Add `stream()`: DMs get native draft streaming via the `sendMessageDraft` Bot API method. The draft bubble updates in place as chunks arrive, throttled to `options.update_interval_ms` (default TELEGRAM_DEFAULT_STREAM_UPDATE_INTERVAL_MS = 250ms), then a regular `sendMessage` persists the final text. Returns `None` for non-DM threads BEFORE consuming any chunks so the SDK's built-in post+edit fallback handles groups, supergroups, and channels. - Add `with_telegram_markdown_fallback()`: shared retry-without-`parse_mode` path now wraps `post_message`, `edit_message`, `send_document`, and `send_attachment`. When Telegram rejects MarkdownV2 with a "can't parse entities" 400, the call retries once with the plain-text rendering (or the original text when stripping markup left nothing). - Add `render_plain_text_message`, `resolve_telegram_fallback_text`, `create_draft_id` (int32-wrapping, wall-clock-seeded), and `_create_telegram_document_form_data` (form bodies are single-use, so the retry rebuilds them). Core (src/chat_sdk/thread.py, src/chat_sdk/types.py): - `Adapter.stream` / `BaseAdapter.stream` may now return `RawMessage | None`. `None` (returned before any chunk is consumed) delegates to the post+edit fallback for the current thread. - `Thread._handle_stream` checks `if raw_result is not None`; on `None` it closes the unused wrapper generator and falls through to the fallback. The existing `RawMessage.text` override (Teams cancellation reconciliation) is preserved inside the non-None branch. Documented divergence (docs/UPSTREAM_SYNC.md): unlike upstream's `thread.ts`, we do NOT seed `update_interval_ms` with the thread default. Our hand-rolled Teams native streaming path treats any non-`None` value as a caller override of its 1500ms quota-protecting throttle; seeding 500ms would silently drop Teams DM throttling below the platform's ~1 req/sec quota. Adapters apply their own default when the field is `None`, and `_fallback_stream` already substitutes `self._streaming_update_interval_ms`, so the fallback path stays observably identical to upstream. Tests: all 12 upstream `it()` blocks ported to tests/test_telegram_streaming.py plus the `stream returns null` fallback test to tests/test_thread_faithful.py, with Python-only coverage for the default-interval, whitespace-rejection, and empty-fallback-text branches. Parity note: this is a partial 4.30.0 slice; UPSTREAM_PARITY stays at 4.29.0 (the rest of the 4.30 wave is tracked separately). https://claude.ai/code/session_013zwTcMek5rNqBTQvs2oF64 Co-authored-by: Claude <noreply@anthropic.com>
…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.
…ransitional divergences (#93 PR 3/4) (#145) * feat(teams): migrate native streaming to the SDK IStreamer + unwind transitional 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. * docs(teams): clarify close-on-raw-cancel intent + pin SDK version in 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.
Summary
Add native Telegram DM streaming via
sendMessageDraftwhile preserving Chat SDK's existing post+edit fallback for non-DM threads.This PR:
parse_modewhen Telegram rejects markdown entity parsingTest plan
pnpm checkpnpm typecheckpnpm test:workspacepnpm build