Skip to content

feat(telegram): add native DM draft streaming with segmented stream results#340

Merged
dancer merged 3 commits into
vercel:mainfrom
lurenjia534:main
May 28, 2026
Merged

feat(telegram): add native DM draft streaming with segmented stream results#340
dancer merged 3 commits into
vercel:mainfrom
lurenjia534:main

Conversation

@lurenjia534

Copy link
Copy Markdown
Contributor

Summary

Add native Telegram DM streaming via sendMessageDraft while preserving Chat SDK's existing post+edit fallback for non-DM threads.

This PR:

  • adds native private-chat draft streaming to the Telegram adapter
  • splits long streamed markdown into Telegram-safe persisted segments
  • retries without parse_mode when Telegram rejects markdown entity parsing
  • exposes segmented native stream results in the chat core
  • updates docs and feature matrices to reflect Telegram DM draft streaming

Test plan

  • pnpm check
  • pnpm typecheck
  • pnpm test:workspace
  • pnpm build

@vercel

vercel Bot commented Apr 6, 2026

Copy link
Copy Markdown
Contributor

@lurenjia534 is attempting to deploy a commit to the Vercel Labs Team on Vercel.

A member of the Team first needs to authorize it.

@lurenjia534

Copy link
Copy Markdown
Contributor Author

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.

@bensabic bensabic requested a review from visyat May 9, 2026 12:21
@lurenjia534 lurenjia534 requested a review from a team as a code owner May 15, 2026 22:39
@lurenjia534

Copy link
Copy Markdown
Contributor Author

@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
length. I also added a regression test covering escaped MarkdownV2 expansion.

Verified locally:

  • pnpm --filter @chat-adapter/telegram test
  • pnpm check
  • pnpm typecheck
  • pnpm test:workspace
  • pnpm build

Could you take another look when you have a chance?

@slmnsh

slmnsh commented May 22, 2026

Copy link
Copy Markdown
Contributor

Hi @visyat, any updates on this?

@vercel

vercel Bot commented May 26, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
chat Canceled Canceled Open in v0 May 26, 2026 6:56am

@vercel vercel Bot temporarily deployed to Preview – chat May 26, 2026 06:56 Inactive
@lurenjia534

Copy link
Copy Markdown
Contributor Author

@visyat I synced the branch with the latest upstream main, including the CI Node version fix from #543.

I verified locally:

  • pnpm --filter @chat-adapter/telegram test
  • pnpm install --frozen-lockfile
  • pnpm validate

All passed.

The current GitHub Actions CI run is action_required with no jobs started, so it looks like it needs maintainer
approval to run for this fork PR. The remaining Vercel failures are deployment authorization checks, not test
failures.

@dancer dancer merged commit 5461ea9 into vercel:main May 28, 2026
10 of 12 checks passed
@dancer dancer mentioned this pull request Jun 1, 2026
1 task
patrick-chinchill pushed a commit to Chinchill-AI/chat-sdk-python that referenced this pull request Jun 13, 2026
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
patrick-chinchill added a commit to Chinchill-AI/chat-sdk-python that referenced this pull request Jun 18, 2026
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>
patrick-chinchill added a commit to Chinchill-AI/chat-sdk-python that referenced this pull request Jun 18, 2026
…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.
patrick-chinchill added a commit to Chinchill-AI/chat-sdk-python that referenced this pull request Jun 18, 2026
…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.
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.

4 participants