diff --git a/.changeset/tidy-spoons-stream.md b/.changeset/tidy-spoons-stream.md
new file mode 100644
index 000000000..5b7b00fca
--- /dev/null
+++ b/.changeset/tidy-spoons-stream.md
@@ -0,0 +1,6 @@
+---
+"chat": minor
+"@chat-adapter/telegram": minor
+---
+
+Add native Telegram private chat draft streaming with fallback streaming elsewhere.
diff --git a/README.md b/README.md
index 1a45546eb..ae6fea571 100644
--- a/README.md
+++ b/README.md
@@ -52,7 +52,7 @@ Browse official, vendor-official, and community adapters on [chat-sdk.dev/adapte
## Features
- [**Event handlers**](https://chat-sdk.dev/docs/usage) — mentions, messages, reactions, button clicks, slash commands, modals
-- [**AI streaming**](https://chat-sdk.dev/docs/streaming) — stream LLM responses with native Slack streaming and post+edit fallback
+- [**AI streaming**](https://chat-sdk.dev/docs/streaming) — stream LLM responses with native Slack streaming, Telegram private chat draft previews, and post+edit fallback
- [**Cards**](https://chat-sdk.dev/docs/cards) — JSX-based interactive cards (Block Kit, Adaptive Cards, Google Chat Cards)
- [**Actions**](https://chat-sdk.dev/docs/actions) — handle button clicks and dropdown selections
- [**Modals**](https://chat-sdk.dev/docs/modals) — form dialogs with text inputs, dropdowns, and validation
diff --git a/apps/docs/content/adapters/official/telegram.mdx b/apps/docs/content/adapters/official/telegram.mdx
index 48942a8f0..bec790f9d 100644
--- a/apps/docs/content/adapters/official/telegram.mdx
+++ b/apps/docs/content/adapters/official/telegram.mdx
@@ -16,7 +16,7 @@ features:
label: Single file
streaming:
status: partial
- label: Post+Edit
+ label: Private chat drafts / Post+Edit
scheduledMessages: no
cardFormat:
status: partial
diff --git a/apps/docs/content/docs/adapters.mdx b/apps/docs/content/docs/adapters.mdx
index ca31f6e9f..a8cdd96a6 100644
--- a/apps/docs/content/docs/adapters.mdx
+++ b/apps/docs/content/docs/adapters.mdx
@@ -23,7 +23,7 @@ Ready to build your own? Follow the [building](/docs/contributing/building) guid
| Edit message | | | | | | | Partial | | |
| Delete message | | | | | | | Partial | | |
| File uploads | | | | | Single file/media | | | Images, audio, docs | |
-| Streaming | Native | Native (DMs) / Buffered | Post+Edit | Post+Edit | Post+Edit | Buffered | Agent sessions / Post+Edit | Buffered | Buffered |
+| Streaming | Native | Native (DMs) / Buffered | Post+Edit | Post+Edit | Private chat drafts / Post+Edit | Buffered | Agent sessions / Post+Edit | Buffered | Buffered |
| Scheduled messages | Native | | | | | | | | |
### Rich content
diff --git a/apps/docs/content/docs/index.mdx b/apps/docs/content/docs/index.mdx
index 6961ca0b6..b011f1bb4 100644
--- a/apps/docs/content/docs/index.mdx
+++ b/apps/docs/content/docs/index.mdx
@@ -55,7 +55,7 @@ Each adapter factory auto-detects credentials from environment variables (`SLACK
| Microsoft Teams | `@chat-adapter/teams` | Yes | Read-only | Yes | Yes | Native (DMs) / Buffered | Yes |
| Google Chat | `@chat-adapter/gchat` | Yes | Yes | Yes | No | Post+Edit | Yes |
| Discord | `@chat-adapter/discord` | Yes | Yes | Yes | No | Post+Edit | Yes |
-| Telegram | `@chat-adapter/telegram` | Yes | Yes | Partial | No | Post+Edit | Yes |
+| Telegram | `@chat-adapter/telegram` | Yes | Yes | Partial | No | Private chat drafts / Post+Edit | Yes |
| GitHub | `@chat-adapter/github` | Yes | Yes | No | No | Buffered | No |
| Linear | `@chat-adapter/linear` | Yes | Yes | No | No | Agent sessions / Post+Edit | No |
| WhatsApp | `@chat-adapter/whatsapp` | N/A | Yes | Partial | No | Buffered | Yes |
diff --git a/apps/docs/content/docs/streaming.mdx b/apps/docs/content/docs/streaming.mdx
index 1e2074702..1740fb11e 100644
--- a/apps/docs/content/docs/streaming.mdx
+++ b/apps/docs/content/docs/streaming.mdx
@@ -59,10 +59,10 @@ await thread.post(stream);
| Platform | Method | Description |
|----------|--------|-------------|
| Slack | Native streaming API | Uses Slack's `chatStream` for smooth, real-time updates |
+| Telegram | Private chat draft previews | Uses Telegram's `sendMessageDraft` in private chats and falls back to post + edit elsewhere |
| Teams | Native (DMs) / Buffered (group chats) | Uses the Teams SDK's native `stream.emit()` for direct messages; accumulates chunks and posts one final message when no native streamer is active |
| Google Chat | Post + Edit | Posts a message then edits it as chunks arrive |
| Discord | Post + Edit | Posts a message then edits it as chunks arrive |
-| Telegram | Post + Edit | Posts a message then edits it as chunks arrive |
| GitHub | Buffered | Accumulates chunks and posts one final comment |
| Linear | Agent sessions / Post + Edit | Uses agent session activities in agent-session threads; falls back to post+edit comments in issue threads |
| WhatsApp | Buffered | Accumulates chunks and sends one final message |
diff --git a/packages/adapter-telegram/README.md b/packages/adapter-telegram/README.md
index 923c8c0f4..0a2a9ec3c 100644
--- a/packages/adapter-telegram/README.md
+++ b/packages/adapter-telegram/README.md
@@ -144,7 +144,7 @@ TELEGRAM_API_BASE_URL=https://api.telegram.org
| Delete message | Yes |
| File uploads | Single file (`sendDocument`) |
| Attachment uploads | Single image/audio/video/file (`sendPhoto`, `sendAudio`, `sendVideo`, `sendDocument`) |
-| Streaming | Post+Edit fallback |
+| Streaming | Private chat draft previews + post/edit fallback |
### Rich content
diff --git a/packages/adapter-telegram/src/index.test.ts b/packages/adapter-telegram/src/index.test.ts
index 76223c9fb..f96dda3a4 100644
--- a/packages/adapter-telegram/src/index.test.ts
+++ b/packages/adapter-telegram/src/index.test.ts
@@ -911,6 +911,360 @@ describe("TelegramAdapter", () => {
expect(sendMessageBody.text).toBe("hello");
});
+ it("streams draft updates for private chats and sends a final message", async () => {
+ mockFetch
+ .mockResolvedValueOnce(
+ telegramOk({
+ id: 999,
+ is_bot: true,
+ first_name: "Bot",
+ username: "mybot",
+ })
+ )
+ .mockResolvedValueOnce(telegramOk(true))
+ .mockResolvedValueOnce(telegramOk(true))
+ .mockResolvedValueOnce(telegramOk(true))
+ .mockResolvedValueOnce(
+ telegramOk(
+ sampleMessage({
+ text: "hello world",
+ })
+ )
+ );
+
+ const adapter = createTelegramAdapter({
+ botToken: "token",
+ mode: "webhook",
+ logger: mockLogger,
+ userName: "mybot",
+ });
+
+ await adapter.initialize(createMockChat());
+
+ async function* textStream(): AsyncIterable {
+ yield "hello";
+ yield " world";
+ }
+
+ const result = await adapter.stream("telegram:123", textStream(), {
+ updateIntervalMs: 0,
+ });
+
+ expect(result).not.toBeNull();
+ expect(result?.id).toBe("123:11");
+ expect(result?.threadId).toBe("telegram:123");
+
+ const initialDraftUrl = String(mockFetch.mock.calls[1]?.[0]);
+ const firstDraftUrl = String(mockFetch.mock.calls[2]?.[0]);
+ const secondDraftUrl = String(mockFetch.mock.calls[3]?.[0]);
+ const finalSendUrl = String(mockFetch.mock.calls[4]?.[0]);
+
+ expect(initialDraftUrl).toContain("/sendMessageDraft");
+ expect(firstDraftUrl).toContain("/sendMessageDraft");
+ expect(secondDraftUrl).toContain("/sendMessageDraft");
+ expect(finalSendUrl).toContain("/sendMessage");
+
+ const initialDraftBody = JSON.parse(
+ String((mockFetch.mock.calls[1]?.[1] as RequestInit).body)
+ ) as {
+ chat_id: string;
+ draft_id: number;
+ parse_mode?: string;
+ text: string;
+ };
+
+ const firstDraftBody = JSON.parse(
+ String((mockFetch.mock.calls[2]?.[1] as RequestInit).body)
+ ) as {
+ chat_id: string;
+ draft_id: number;
+ parse_mode?: string;
+ text: string;
+ };
+
+ const secondDraftBody = JSON.parse(
+ String((mockFetch.mock.calls[3]?.[1] as RequestInit).body)
+ ) as {
+ chat_id: string;
+ draft_id: number;
+ parse_mode?: string;
+ text: string;
+ };
+
+ const finalSendBody = JSON.parse(
+ String((mockFetch.mock.calls[4]?.[1] as RequestInit).body)
+ ) as { chat_id: string; parse_mode?: string; text: string };
+
+ expect(initialDraftBody.chat_id).toBe("123");
+ expect(initialDraftBody.text).toBe("");
+ expect(initialDraftBody.parse_mode).toBeUndefined();
+ expect(firstDraftBody.chat_id).toBe("123");
+ expect(firstDraftBody.draft_id).toBe(initialDraftBody.draft_id);
+ expect(firstDraftBody.text).toBe("hello");
+ expect(firstDraftBody.parse_mode).toBe("MarkdownV2");
+ expect(secondDraftBody.draft_id).toBe(firstDraftBody.draft_id);
+ expect(secondDraftBody.text).toBe("hello world");
+ expect(finalSendBody.chat_id).toBe("123");
+ expect(finalSendBody.text).toBe("hello world");
+ expect(finalSendBody.parse_mode).toBe("MarkdownV2");
+ });
+
+ it("keeps markdown parse mode for an exact-limit draft and final message", async () => {
+ const longMarkdown = `${"a".repeat(3494)}**ok**`;
+ const renderedMarkdown = `${"a".repeat(3494)}*ok*`;
+ const requestBodies: Array<{
+ method: string;
+ body: { parse_mode?: string; text?: string };
+ }> = [];
+ let nextMessageId = 41;
+
+ mockFetch.mockImplementation(async (input, init) => {
+ const url = String(input);
+ const method = url.split("/").at(-1) ?? url;
+ const rawBody = (init as RequestInit | undefined)?.body;
+ const body =
+ typeof rawBody === "string"
+ ? (JSON.parse(rawBody) as { parse_mode?: string; text?: string })
+ : {};
+
+ requestBodies.push({ method, body });
+
+ if (method === "getMe") {
+ return telegramOk({
+ id: 999,
+ is_bot: true,
+ first_name: "Bot",
+ username: "mybot",
+ });
+ }
+
+ if (method === "sendMessageDraft") {
+ return telegramOk(true);
+ }
+
+ if (method === "sendMessage") {
+ return telegramOk(
+ sampleMessage({
+ message_id: nextMessageId++,
+ text: `${"a".repeat(3494)}ok`,
+ })
+ );
+ }
+
+ throw new Error(`Unexpected Telegram method in test: ${method}`);
+ });
+
+ const adapter = createTelegramAdapter({
+ botToken: "token",
+ mode: "webhook",
+ logger: mockLogger,
+ userName: "mybot",
+ });
+
+ await adapter.initialize(createMockChat());
+
+ async function* textStream(): AsyncIterable {
+ yield longMarkdown;
+ }
+
+ const result = await adapter.stream("telegram:123", textStream(), {
+ updateIntervalMs: Number.MAX_SAFE_INTEGER,
+ });
+
+ expect(result).not.toBeNull();
+ expect(
+ requestBodies.map((request) => ({
+ method: request.method,
+ len: request.body.text?.length,
+ tail: request.body.text?.slice(-10),
+ parse_mode: request.body.parse_mode,
+ }))
+ ).toEqual([
+ {
+ method: "getMe",
+ len: undefined,
+ tail: undefined,
+ parse_mode: undefined,
+ },
+ {
+ method: "sendMessageDraft",
+ len: 0,
+ tail: "",
+ parse_mode: undefined,
+ },
+ {
+ method: "sendMessageDraft",
+ len: renderedMarkdown.length,
+ tail: renderedMarkdown.slice(-10),
+ parse_mode: "MarkdownV2",
+ },
+ {
+ method: "sendMessage",
+ len: renderedMarkdown.length,
+ tail: renderedMarkdown.slice(-10),
+ parse_mode: "MarkdownV2",
+ },
+ ]);
+
+ const draftBody = requestBodies[2]?.body as {
+ parse_mode?: string;
+ text: string;
+ };
+ const finalSendBody = requestBodies[3]?.body as {
+ parse_mode?: string;
+ text: string;
+ };
+
+ expect(draftBody.parse_mode).toBe("MarkdownV2");
+ expect(draftBody.text).toBe(renderedMarkdown);
+ expect(finalSendBody.parse_mode).toBe("MarkdownV2");
+ expect(finalSendBody.text).toBe(renderedMarkdown);
+ });
+
+ it("returns null for non-DM streaming so Chat SDK can use fallback streaming", async () => {
+ mockFetch.mockResolvedValueOnce(
+ telegramOk({
+ id: 999,
+ is_bot: true,
+ first_name: "Bot",
+ username: "mybot",
+ })
+ );
+
+ const adapter = createTelegramAdapter({
+ botToken: "token",
+ mode: "webhook",
+ logger: mockLogger,
+ userName: "mybot",
+ });
+
+ await adapter.initialize(createMockChat());
+
+ async function* textStream(): AsyncIterable {
+ yield "hello";
+ }
+
+ const result = await adapter.stream("telegram:-100123", textStream(), {
+ updateIntervalMs: 0,
+ });
+
+ expect(result).toBeNull();
+ expect(mockFetch).toHaveBeenCalledTimes(1);
+ });
+
+ it("falls back to a final message when draft streaming updates fail", async () => {
+ mockFetch
+ .mockResolvedValueOnce(
+ telegramOk({
+ id: 999,
+ is_bot: true,
+ first_name: "Bot",
+ username: "mybot",
+ })
+ )
+ .mockResolvedValueOnce(
+ telegramError(400, 400, "Bad Request: chat not found")
+ )
+ .mockResolvedValueOnce(
+ telegramOk(
+ sampleMessage({
+ text: "hello world",
+ })
+ )
+ );
+
+ const adapter = createTelegramAdapter({
+ botToken: "token",
+ mode: "webhook",
+ logger: mockLogger,
+ userName: "mybot",
+ });
+
+ await adapter.initialize(createMockChat());
+
+ async function* textStream(): AsyncIterable {
+ yield "hello";
+ yield " world";
+ }
+
+ const result = await adapter.stream("telegram:123", textStream(), {
+ updateIntervalMs: 0,
+ });
+
+ expect(result?.id).toBe("123:11");
+ expect(mockLogger.warn).toHaveBeenCalledWith(
+ "Telegram draft streaming update failed",
+ expect.objectContaining({
+ threadId: "telegram:123",
+ })
+ );
+
+ const finalSendUrl = String(mockFetch.mock.calls[2]?.[0]);
+ expect(finalSendUrl).toContain("/sendMessage");
+ });
+
+ it("continues to the final message when markdown draft retry also fails", async () => {
+ mockFetch
+ .mockResolvedValueOnce(
+ telegramOk({
+ id: 999,
+ is_bot: true,
+ first_name: "Bot",
+ username: "mybot",
+ })
+ )
+ .mockResolvedValueOnce(telegramOk(true))
+ .mockResolvedValueOnce(
+ telegramError(
+ 400,
+ 400,
+ "Bad Request: can't parse entities: Can't find end of the entity"
+ )
+ )
+ .mockResolvedValueOnce(
+ telegramError(429, 429, "Too Many Requests: retry later")
+ )
+ .mockResolvedValueOnce(
+ telegramOk(
+ sampleMessage({
+ text: "**broken",
+ })
+ )
+ );
+
+ const adapter = createTelegramAdapter({
+ botToken: "token",
+ mode: "webhook",
+ logger: mockLogger,
+ userName: "mybot",
+ });
+
+ await adapter.initialize(createMockChat());
+
+ async function* textStream(): AsyncIterable {
+ yield "**broken";
+ }
+
+ const result = await adapter.stream("telegram:123", textStream(), {
+ updateIntervalMs: 0,
+ });
+
+ expect(result).not.toBeNull();
+ expect(mockLogger.warn).toHaveBeenCalledWith(
+ "Telegram draft streaming update failed",
+ expect.objectContaining({
+ threadId: "telegram:123",
+ })
+ );
+
+ const finalSendBody = JSON.parse(
+ String((mockFetch.mock.calls[4]?.[1] as RequestInit).body)
+ ) as { parse_mode?: string; text: string };
+
+ expect(finalSendBody.parse_mode).toBeUndefined();
+ expect(finalSendBody.text).toBe("**broken");
+ });
+
it("postChannelMessage does not double-prefix channel ID", async () => {
mockFetch
.mockResolvedValueOnce(
@@ -1375,6 +1729,373 @@ describe("TelegramAdapter", () => {
expect(sendMessageBody.text).toBe("raw.unparsed!(text)");
});
+ it("retries markdown messages without parse_mode when Telegram can't parse entities", async () => {
+ mockFetch
+ .mockResolvedValueOnce(
+ telegramOk({
+ id: 999,
+ is_bot: true,
+ first_name: "Bot",
+ username: "mybot",
+ })
+ )
+ .mockResolvedValueOnce(
+ telegramError(
+ 400,
+ 400,
+ "Bad Request: can't parse entities: Can't find end of the entity"
+ )
+ )
+ .mockResolvedValueOnce(
+ telegramOk(
+ sampleMessage({
+ text: "**broken",
+ })
+ )
+ );
+
+ const adapter = createTelegramAdapter({
+ botToken: "token",
+ mode: "webhook",
+ logger: mockLogger,
+ userName: "mybot",
+ });
+
+ await adapter.initialize(createMockChat());
+
+ const result = await adapter.postMessage("telegram:123", {
+ markdown: "**broken",
+ });
+
+ expect(result.id).toBe("123:11");
+
+ const firstSendBody = JSON.parse(
+ String((mockFetch.mock.calls[1]?.[1] as RequestInit).body)
+ ) as { parse_mode?: string; text: string };
+ const secondSendBody = JSON.parse(
+ String((mockFetch.mock.calls[2]?.[1] as RequestInit).body)
+ ) as { parse_mode?: string; text: string };
+
+ expect(firstSendBody.parse_mode).toBe("MarkdownV2");
+ expect(secondSendBody.parse_mode).toBeUndefined();
+ expect(secondSendBody.text).toBe("**broken");
+ expect(mockLogger.warn).toHaveBeenCalledWith(
+ "Telegram markdown parse failed; retrying without parse mode",
+ expect.objectContaining({
+ method: "sendMessage",
+ threadId: "telegram:123",
+ })
+ );
+ });
+
+ it("retries markdown messages with original text when plain-text fallback would be empty", async () => {
+ mockFetch
+ .mockResolvedValueOnce(
+ telegramOk({
+ id: 999,
+ is_bot: true,
+ first_name: "Bot",
+ username: "mybot",
+ })
+ )
+ .mockResolvedValueOnce(
+ telegramError(
+ 400,
+ 400,
+ "Bad Request: can't parse entities: Can't find end of the entity"
+ )
+ )
+ .mockResolvedValueOnce(
+ telegramOk(
+ sampleMessage({
+ text: "**",
+ })
+ )
+ );
+
+ const adapter = createTelegramAdapter({
+ botToken: "token",
+ mode: "webhook",
+ logger: mockLogger,
+ userName: "mybot",
+ });
+
+ await adapter.initialize(createMockChat());
+
+ const result = await adapter.postMessage("telegram:123", {
+ markdown: "**",
+ });
+
+ expect(result.id).toBe("123:11");
+
+ const secondSendBody = JSON.parse(
+ String((mockFetch.mock.calls[2]?.[1] as RequestInit).body)
+ ) as { parse_mode?: string; text: string };
+
+ expect(secondSendBody.parse_mode).toBeUndefined();
+ expect(secondSendBody.text).toBe("**");
+ });
+
+ it("does not swallow non-parse validation errors during markdown send", async () => {
+ mockFetch
+ .mockResolvedValueOnce(
+ telegramOk({
+ id: 999,
+ is_bot: true,
+ first_name: "Bot",
+ username: "mybot",
+ })
+ )
+ .mockResolvedValueOnce(
+ telegramError(400, 400, "Bad Request: chat not found")
+ );
+
+ const adapter = createTelegramAdapter({
+ botToken: "token",
+ mode: "webhook",
+ logger: mockLogger,
+ userName: "mybot",
+ });
+
+ await adapter.initialize(createMockChat());
+
+ await expect(
+ adapter.postMessage("telegram:123", {
+ markdown: "**broken**",
+ })
+ ).rejects.toThrow("Bad Request: chat not found");
+ });
+
+ it("retries markdown edits without parse_mode when Telegram can't parse entities", async () => {
+ mockFetch
+ .mockResolvedValueOnce(
+ telegramOk({
+ id: 999,
+ is_bot: true,
+ first_name: "Bot",
+ username: "mybot",
+ })
+ )
+ .mockResolvedValueOnce(telegramOk(sampleMessage()))
+ .mockResolvedValueOnce(
+ telegramError(
+ 400,
+ 400,
+ "Bad Request: can't parse entities: Can't find end of the entity"
+ )
+ )
+ .mockResolvedValueOnce(
+ telegramOk(
+ sampleMessage({
+ text: "**broken",
+ })
+ )
+ );
+
+ const adapter = createTelegramAdapter({
+ botToken: "token",
+ mode: "webhook",
+ logger: mockLogger,
+ userName: "mybot",
+ });
+
+ await adapter.initialize(createMockChat());
+
+ const posted = await adapter.postMessage("telegram:123", "hello");
+ const result = await adapter.editMessage("telegram:123", posted.id, {
+ markdown: "**broken",
+ });
+
+ expect(result.id).toBe("123:11");
+
+ const firstEditBody = JSON.parse(
+ String((mockFetch.mock.calls[2]?.[1] as RequestInit).body)
+ ) as { parse_mode?: string; text: string };
+ const secondEditBody = JSON.parse(
+ String((mockFetch.mock.calls[3]?.[1] as RequestInit).body)
+ ) as { parse_mode?: string; text: string };
+
+ expect(firstEditBody.parse_mode).toBe("MarkdownV2");
+ expect(secondEditBody.parse_mode).toBeUndefined();
+ expect(secondEditBody.text).toBe("**broken");
+ expect(mockLogger.warn).toHaveBeenCalledWith(
+ "Telegram markdown parse failed; retrying without parse mode",
+ expect.objectContaining({
+ messageId: posted.id,
+ method: "editMessageText",
+ threadId: "telegram:123",
+ })
+ );
+ });
+
+ it("retries markdown edits with original text when plain-text fallback would be empty", async () => {
+ mockFetch
+ .mockResolvedValueOnce(
+ telegramOk({
+ id: 999,
+ is_bot: true,
+ first_name: "Bot",
+ username: "mybot",
+ })
+ )
+ .mockResolvedValueOnce(telegramOk(sampleMessage()))
+ .mockResolvedValueOnce(
+ telegramError(
+ 400,
+ 400,
+ "Bad Request: can't parse entities: Can't find end of the entity"
+ )
+ )
+ .mockResolvedValueOnce(
+ telegramOk(
+ sampleMessage({
+ text: "**",
+ })
+ )
+ );
+
+ const adapter = createTelegramAdapter({
+ botToken: "token",
+ mode: "webhook",
+ logger: mockLogger,
+ userName: "mybot",
+ });
+
+ await adapter.initialize(createMockChat());
+
+ const posted = await adapter.postMessage("telegram:123", "hello");
+ const result = await adapter.editMessage("telegram:123", posted.id, {
+ markdown: "**",
+ });
+
+ expect(result.id).toBe("123:11");
+
+ const secondEditBody = JSON.parse(
+ String((mockFetch.mock.calls[3]?.[1] as RequestInit).body)
+ ) as { parse_mode?: string; text: string };
+
+ expect(secondEditBody.parse_mode).toBeUndefined();
+ expect(secondEditBody.text).toBe("**");
+ });
+
+ it("falls back to plain-text draft and final send when Telegram can't parse streamed markdown", async () => {
+ mockFetch
+ .mockResolvedValueOnce(
+ telegramOk({
+ id: 999,
+ is_bot: true,
+ first_name: "Bot",
+ username: "mybot",
+ })
+ )
+ .mockResolvedValueOnce(telegramOk(true))
+ .mockResolvedValueOnce(
+ telegramError(
+ 400,
+ 400,
+ "Bad Request: can't parse entities: Can't find end of the entity"
+ )
+ )
+ .mockResolvedValueOnce(telegramOk(true))
+ .mockResolvedValueOnce(
+ telegramOk(
+ sampleMessage({
+ text: "**broken",
+ })
+ )
+ );
+
+ const adapter = createTelegramAdapter({
+ botToken: "token",
+ mode: "webhook",
+ logger: mockLogger,
+ userName: "mybot",
+ });
+
+ await adapter.initialize(createMockChat());
+
+ async function* textStream(): AsyncIterable {
+ yield "**broken";
+ }
+
+ const result = await adapter.stream("telegram:123", textStream(), {
+ updateIntervalMs: 0,
+ });
+
+ expect(result?.id).toBe("123:11");
+
+ const retryDraftBody = JSON.parse(
+ String((mockFetch.mock.calls[3]?.[1] as RequestInit).body)
+ ) as { parse_mode?: string; text: string };
+ const finalSendBody = JSON.parse(
+ String((mockFetch.mock.calls[4]?.[1] as RequestInit).body)
+ ) as { parse_mode?: string; text: string };
+
+ expect(retryDraftBody.parse_mode).toBeUndefined();
+ expect(retryDraftBody.text).toBe("**broken");
+ expect(finalSendBody.parse_mode).toBeUndefined();
+ expect(finalSendBody.text).toBe("**broken");
+ });
+
+ it("reuses original text when streamed plain-text fallback would be empty", async () => {
+ mockFetch
+ .mockResolvedValueOnce(
+ telegramOk({
+ id: 999,
+ is_bot: true,
+ first_name: "Bot",
+ username: "mybot",
+ })
+ )
+ .mockResolvedValueOnce(telegramOk(true))
+ .mockResolvedValueOnce(
+ telegramError(
+ 400,
+ 400,
+ "Bad Request: can't parse entities: Can't find end of the entity"
+ )
+ )
+ .mockResolvedValueOnce(telegramOk(true))
+ .mockResolvedValueOnce(
+ telegramOk(
+ sampleMessage({
+ text: "**",
+ })
+ )
+ );
+
+ const adapter = createTelegramAdapter({
+ botToken: "token",
+ mode: "webhook",
+ logger: mockLogger,
+ userName: "mybot",
+ });
+
+ await adapter.initialize(createMockChat());
+
+ async function* textStream(): AsyncIterable {
+ yield "**";
+ }
+
+ const result = await adapter.stream("telegram:123", textStream(), {
+ updateIntervalMs: 0,
+ });
+
+ expect(result?.id).toBe("123:11");
+
+ const retryDraftBody = JSON.parse(
+ String((mockFetch.mock.calls[3]?.[1] as RequestInit).body)
+ ) as { parse_mode?: string; text: string };
+ const finalSendBody = JSON.parse(
+ String((mockFetch.mock.calls[4]?.[1] as RequestInit).body)
+ ) as { parse_mode?: string; text: string };
+
+ expect(retryDraftBody.parse_mode).toBeUndefined();
+ expect(retryDraftBody.text).toBe("**");
+ expect(finalSendBody.parse_mode).toBeUndefined();
+ expect(finalSendBody.text).toBe("**");
+ });
+
it("posts cards with inline keyboard buttons", async () => {
mockFetch
.mockResolvedValueOnce(
diff --git a/packages/adapter-telegram/src/index.ts b/packages/adapter-telegram/src/index.ts
index effac2c4d..22bdbf079 100644
--- a/packages/adapter-telegram/src/index.ts
+++ b/packages/adapter-telegram/src/index.ts
@@ -23,6 +23,8 @@ import type {
FormattedContent,
Logger,
RawMessage,
+ StreamChunk,
+ StreamOptions,
ThreadInfo,
UserInfo,
WebhookOptions,
@@ -33,7 +35,10 @@ import {
defaultEmojiResolver,
getEmoji,
Message,
+ markdownToPlainText,
NotImplementedError,
+ StreamingMarkdownRenderer,
+ toPlainText,
} from "chat";
import {
cardToTelegramInlineKeyboard,
@@ -94,6 +99,9 @@ const EMOJI_NAME_PATTERN = /^[a-z0-9_+-]+$/i;
const TELEGRAM_DEFAULT_POLLING_TIMEOUT_SECONDS = 30;
const TELEGRAM_DEFAULT_POLLING_LIMIT = 100;
const TELEGRAM_DEFAULT_POLLING_RETRY_DELAY_MS = 1000;
+const TELEGRAM_DEFAULT_STREAM_UPDATE_INTERVAL_MS = 250;
+const TELEGRAM_MARKDOWN_PARSE_ERROR_PATTERN =
+ /can't parse (?:caption )?entities/i;
const TELEGRAM_MAX_POLLING_LIMIT = 100;
const TELEGRAM_MIN_POLLING_LIMIT = 1;
const TELEGRAM_MIN_POLLING_TIMEOUT_SECONDS = 0;
@@ -233,6 +241,7 @@ export class TelegramAdapter
private pollingAbortController: AbortController | null = null;
private pollingTask: Promise | null = null;
private pollingActive = false;
+ private nextDraftId = Math.max(1, Date.now() % 2_147_483_647);
get botUserId(): string | undefined {
return this._botUserId;
@@ -706,6 +715,14 @@ export class TelegramAdapter
const card = extractCard(message);
const replyMarkup = card ? cardToTelegramInlineKeyboard(card) : undefined;
const parseMode = this.resolveParseMode(message, card);
+ const plainText = truncateForTelegram(
+ convertEmojiPlaceholders(
+ this.renderPlainTextMessage(message, card),
+ "gchat"
+ ),
+ TELEGRAM_MESSAGE_LIMIT,
+ "plain"
+ );
const text = truncateForTelegram(
convertEmojiPlaceholders(
card
@@ -753,6 +770,7 @@ export class TelegramAdapter
parsedThread,
file,
text,
+ plainText,
replyMarkup,
parseMode
);
@@ -768,6 +786,7 @@ export class TelegramAdapter
parsedThread,
attachment,
text,
+ plainText,
replyMarkup,
parseMode
);
@@ -776,13 +795,23 @@ export class TelegramAdapter
throw new ValidationError("telegram", "Message text cannot be empty");
}
- rawMessage = await this.telegramFetch("sendMessage", {
- chat_id: parsedThread.chatId,
- message_thread_id: parsedThread.messageThreadId,
- text,
- reply_markup: replyMarkup,
- parse_mode: toBotApiParseMode(parseMode),
- });
+ rawMessage = await this.withTelegramMarkdownFallback(
+ parseMode,
+ (resolvedParseMode, resolvedText) =>
+ this.telegramFetch("sendMessage", {
+ chat_id: parsedThread.chatId,
+ message_thread_id: parsedThread.messageThreadId,
+ text: resolvedText,
+ reply_markup: replyMarkup,
+ parse_mode: toBotApiParseMode(resolvedParseMode),
+ }),
+ {
+ initialText: text,
+ fallbackText: plainText,
+ method: "sendMessage",
+ threadId,
+ }
+ );
}
const resultingThreadId = this.encodeThreadId({
@@ -826,6 +855,14 @@ export class TelegramAdapter
const card = extractCard(message);
const replyMarkup = card ? cardToTelegramInlineKeyboard(card) : undefined;
const parseMode = this.resolveParseMode(message, card);
+ const plainText = truncateForTelegram(
+ convertEmojiPlaceholders(
+ this.renderPlainTextMessage(message, card),
+ "gchat"
+ ),
+ TELEGRAM_MESSAGE_LIMIT,
+ "plain"
+ );
const text = truncateForTelegram(
convertEmojiPlaceholders(
card
@@ -843,14 +880,22 @@ export class TelegramAdapter
throw new ValidationError("telegram", "Message text cannot be empty");
}
- const result = await this.telegramFetch(
- "editMessageText",
+ const result = await this.withTelegramMarkdownFallback(
+ parseMode,
+ (resolvedParseMode, resolvedText) =>
+ this.telegramFetch("editMessageText", {
+ chat_id: chatId,
+ message_id: telegramMessageId,
+ text: resolvedText,
+ reply_markup: replyMarkup ?? emptyTelegramInlineKeyboard(),
+ parse_mode: toBotApiParseMode(resolvedParseMode),
+ }),
{
- chat_id: chatId,
- message_id: telegramMessageId,
- text,
- reply_markup: replyMarkup ?? emptyTelegramInlineKeyboard(),
- parse_mode: toBotApiParseMode(parseMode),
+ initialText: text,
+ fallbackText: plainText,
+ messageId,
+ method: "editMessageText",
+ threadId,
}
);
@@ -955,6 +1000,163 @@ export class TelegramAdapter
});
}
+ async stream(
+ threadId: string,
+ textStream: AsyncIterable,
+ options?: StreamOptions
+ ): Promise | null> {
+ if (!this.isDM(threadId)) {
+ return null;
+ }
+
+ const parsedThread = this.resolveThreadId(threadId);
+ const updateIntervalMs = this.clampInteger(
+ options?.updateIntervalMs,
+ TELEGRAM_DEFAULT_STREAM_UPDATE_INTERVAL_MS,
+ 0,
+ Number.MAX_SAFE_INTEGER
+ );
+
+ const renderer = new StreamingMarkdownRenderer();
+ const draftId = this.createDraftId();
+ let accumulated = "";
+ let lastDraftText: string | null = null;
+ let lastFlushAt = 0;
+ let draftStreamingEnabled = true;
+ let streamUsesMarkdown = true;
+
+ const renderMarkdownForTelegram = (text: string): string =>
+ convertEmojiPlaceholders(
+ this.formatConverter.fromMarkdown(text),
+ "gchat"
+ );
+
+ const renderMarkdownText = (text: string): string =>
+ truncateForTelegram(
+ renderMarkdownForTelegram(text),
+ TELEGRAM_MESSAGE_LIMIT,
+ "MarkdownV2"
+ );
+
+ const renderPlainText = (text: string): string =>
+ truncateForTelegram(
+ this.resolveTelegramFallbackText(text, markdownToPlainText(text)),
+ TELEGRAM_MESSAGE_LIMIT,
+ "plain"
+ );
+
+ const sendDraft = async (
+ text: string,
+ useMarkdown: boolean
+ ): Promise => {
+ if (!draftStreamingEnabled || text === lastDraftText) {
+ return;
+ }
+
+ try {
+ if (useMarkdown) {
+ await this.telegramFetch("sendMessageDraft", {
+ chat_id: parsedThread.chatId,
+ message_thread_id: parsedThread.messageThreadId,
+ draft_id: draftId,
+ text,
+ parse_mode: toBotApiParseMode("MarkdownV2"),
+ });
+ } else {
+ await this.telegramFetch("sendMessageDraft", {
+ chat_id: parsedThread.chatId,
+ message_thread_id: parsedThread.messageThreadId,
+ draft_id: draftId,
+ text,
+ });
+ }
+ lastDraftText = text;
+ lastFlushAt = Date.now();
+ } catch (error) {
+ if (useMarkdown && this.isTelegramMarkdownParseError(error)) {
+ streamUsesMarkdown = false;
+
+ const plainDraftText = renderPlainText(accumulated);
+
+ try {
+ await this.telegramFetch("sendMessageDraft", {
+ chat_id: parsedThread.chatId,
+ message_thread_id: parsedThread.messageThreadId,
+ draft_id: draftId,
+ text: plainDraftText,
+ });
+ lastDraftText = plainDraftText;
+ lastFlushAt = Date.now();
+ } catch (retryError) {
+ draftStreamingEnabled = false;
+ this.logger.warn("Telegram draft streaming update failed", {
+ error: String(retryError),
+ threadId,
+ });
+ }
+ return;
+ }
+
+ draftStreamingEnabled = false;
+ this.logger.warn("Telegram draft streaming update failed", {
+ error: String(error),
+ threadId,
+ });
+ }
+ };
+
+ const flushDraft = async (): Promise => {
+ if (!draftStreamingEnabled) {
+ return;
+ }
+
+ const draftText = streamUsesMarkdown
+ ? renderMarkdownText(renderer.render())
+ : renderPlainText(accumulated);
+ await sendDraft(draftText, streamUsesMarkdown);
+ };
+
+ await sendDraft("", false);
+
+ for await (const chunk of textStream) {
+ let text: string | null = null;
+ if (typeof chunk === "string") {
+ text = chunk;
+ } else if (chunk.type === "markdown_text") {
+ text = chunk.text;
+ }
+
+ if (text === null) {
+ continue;
+ }
+
+ accumulated += text;
+ renderer.push(text);
+
+ if (Date.now() - lastFlushAt >= updateIntervalMs) {
+ await flushDraft();
+ }
+ }
+
+ await flushDraft();
+
+ if (!accumulated.trim()) {
+ throw new ValidationError(
+ "telegram",
+ "Telegram streaming requires text content"
+ );
+ }
+
+ const finalPostable: AdapterPostableMessage = streamUsesMarkdown
+ ? { markdown: accumulated }
+ : this.resolveTelegramFallbackText(
+ accumulated,
+ markdownToPlainText(accumulated)
+ );
+
+ return this.postMessage(threadId, finalPostable);
+ }
+
async fetchMessages(
threadId: string,
options: FetchOptions = {}
@@ -1308,11 +1510,46 @@ export class TelegramAdapter
mimeType?: string;
},
text: string,
+ plainText: string,
replyMarkup?: TelegramInlineKeyboardMarkup,
parseMode: TelegramParseMode = "plain"
): Promise {
const buffer = await this.toTelegramBuffer(file.data);
+ return this.withTelegramMarkdownFallback(
+ parseMode,
+ (resolvedParseMode, resolvedText) =>
+ this.telegramFetch(
+ "sendDocument",
+ this.createTelegramDocumentFormData(
+ thread,
+ file,
+ buffer,
+ resolvedText,
+ replyMarkup,
+ resolvedParseMode
+ )
+ ),
+ {
+ initialText: text,
+ fallbackText: plainText,
+ method: "sendDocument",
+ threadId: this.encodeThreadId(thread),
+ }
+ );
+ }
+
+ private createTelegramDocumentFormData(
+ thread: TelegramThreadId,
+ file: {
+ filename: string;
+ mimeType?: string;
+ },
+ buffer: Buffer,
+ text: string,
+ replyMarkup?: TelegramInlineKeyboardMarkup,
+ parseMode: TelegramParseMode = "plain"
+ ): FormData {
const formData = new FormData();
formData.append("chat_id", thread.chatId);
if (typeof thread.messageThreadId === "number") {
@@ -1338,13 +1575,14 @@ export class TelegramAdapter
formData.append("reply_markup", JSON.stringify(replyMarkup));
}
- return this.telegramFetch("sendDocument", formData);
+ return formData;
}
protected async sendAttachment(
thread: TelegramThreadId,
attachment: Attachment,
text: string,
+ plainText: string,
replyMarkup?: TelegramInlineKeyboardMarkup,
parseMode: TelegramParseMode = "plain"
): Promise {
@@ -1360,81 +1598,97 @@ export class TelegramAdapter
);
}
- if (!data) {
- const payload: Record = {
- chat_id: thread.chatId,
- [upload.field]: attachment.url,
- };
+ const buffer = data ? await this.toTelegramBuffer(data) : undefined;
- if (typeof thread.messageThreadId === "number") {
- payload.message_thread_id = thread.messageThreadId;
- }
+ return this.withTelegramMarkdownFallback(
+ parseMode,
+ (resolvedParseMode, resolvedText) => {
+ if (!buffer) {
+ const payload: Record = {
+ chat_id: thread.chatId,
+ [upload.field]: attachment.url,
+ };
- if (text.trim()) {
- payload.caption = truncateForTelegram(
- text,
- TELEGRAM_CAPTION_LIMIT,
- parseMode
- );
- const botApiParseMode = toBotApiParseMode(parseMode);
- if (botApiParseMode) {
- payload.parse_mode = botApiParseMode;
- }
- }
+ if (typeof thread.messageThreadId === "number") {
+ payload.message_thread_id = thread.messageThreadId;
+ }
- if (attachment.type === "video") {
- if (Number.isInteger(attachment.width)) {
- payload.width = attachment.width;
- }
- if (Number.isInteger(attachment.height)) {
- payload.height = attachment.height;
- }
- }
+ if (resolvedText.trim()) {
+ payload.caption = truncateForTelegram(
+ resolvedText,
+ TELEGRAM_CAPTION_LIMIT,
+ resolvedParseMode
+ );
+ const botApiParseMode = toBotApiParseMode(resolvedParseMode);
+ if (botApiParseMode) {
+ payload.parse_mode = botApiParseMode;
+ }
+ }
- if (replyMarkup) {
- payload.reply_markup = replyMarkup;
- }
+ if (attachment.type === "video") {
+ if (Number.isInteger(attachment.width)) {
+ payload.width = attachment.width;
+ }
+ if (Number.isInteger(attachment.height)) {
+ payload.height = attachment.height;
+ }
+ }
- return this.telegramFetch(upload.method, payload);
- }
+ if (replyMarkup) {
+ payload.reply_markup = replyMarkup;
+ }
- const buffer = await this.toTelegramBuffer(data);
- const formData = new FormData();
+ return this.telegramFetch(upload.method, payload);
+ }
- formData.append("chat_id", thread.chatId);
- if (typeof thread.messageThreadId === "number") {
- formData.append("message_thread_id", String(thread.messageThreadId));
- }
+ const formData = new FormData();
- if (text.trim()) {
- formData.append(
- "caption",
- truncateForTelegram(text, TELEGRAM_CAPTION_LIMIT, parseMode)
- );
- const botApiParseMode = toBotApiParseMode(parseMode);
- if (botApiParseMode) {
- formData.append("parse_mode", botApiParseMode);
- }
- }
+ formData.append("chat_id", thread.chatId);
+ if (typeof thread.messageThreadId === "number") {
+ formData.append("message_thread_id", String(thread.messageThreadId));
+ }
- if (attachment.type === "video") {
- if (Number.isInteger(attachment.width)) {
- formData.append("width", String(attachment.width));
- }
- if (Number.isInteger(attachment.height)) {
- formData.append("height", String(attachment.height));
- }
- }
+ if (resolvedText.trim()) {
+ formData.append(
+ "caption",
+ truncateForTelegram(
+ resolvedText,
+ TELEGRAM_CAPTION_LIMIT,
+ resolvedParseMode
+ )
+ );
+ const botApiParseMode = toBotApiParseMode(resolvedParseMode);
+ if (botApiParseMode) {
+ formData.append("parse_mode", botApiParseMode);
+ }
+ }
- const blob = new Blob([new Uint8Array(buffer)], {
- type: attachment.mimeType ?? "application/octet-stream",
- });
- formData.append(upload.field, blob, attachment.name ?? "attachment");
- if (replyMarkup) {
- formData.append("reply_markup", JSON.stringify(replyMarkup));
- }
+ if (attachment.type === "video") {
+ if (Number.isInteger(attachment.width)) {
+ formData.append("width", String(attachment.width));
+ }
+ if (Number.isInteger(attachment.height)) {
+ formData.append("height", String(attachment.height));
+ }
+ }
- return this.telegramFetch(upload.method, formData);
+ const blob = new Blob([new Uint8Array(buffer)], {
+ type: attachment.mimeType ?? "application/octet-stream",
+ });
+ formData.append(upload.field, blob, attachment.name ?? "attachment");
+ if (replyMarkup) {
+ formData.append("reply_markup", JSON.stringify(replyMarkup));
+ }
+
+ return this.telegramFetch(upload.method, formData);
+ },
+ {
+ initialText: text,
+ fallbackText: plainText,
+ method: upload.method,
+ threadId: this.encodeThreadId(thread),
+ }
+ );
}
protected async toTelegramBuffer(
@@ -1550,6 +1804,12 @@ export class TelegramAdapter
return match ? Number.parseInt(match[1], 10) : 0;
}
+ protected createDraftId(): number {
+ this.nextDraftId =
+ this.nextDraftId >= 2_147_483_647 ? 1 : this.nextDraftId + 1;
+ return this.nextDraftId;
+ }
+
protected resolveThreadId(value: string): TelegramThreadId {
if (value.startsWith("telegram:")) {
return this.decodeThreadId(value);
@@ -1724,6 +1984,38 @@ export class TelegramAdapter
return "MarkdownV2";
}
+ protected renderPlainTextMessage(
+ message: AdapterPostableMessage,
+ card: ReturnType
+ ): string {
+ if (card) {
+ return cardToFallbackText(card);
+ }
+ if (typeof message === "string") {
+ return message;
+ }
+ if ("raw" in message) {
+ return message.raw;
+ }
+ if ("markdown" in message) {
+ return this.resolveTelegramFallbackText(
+ message.markdown,
+ markdownToPlainText(message.markdown)
+ );
+ }
+ if ("ast" in message) {
+ return toPlainText(message.ast);
+ }
+ return this.formatConverter.renderPostable(message);
+ }
+
+ protected resolveTelegramFallbackText(
+ originalText: string,
+ fallbackText: string
+ ): string {
+ return fallbackText.trim() ? fallbackText : originalText;
+ }
+
protected toTelegramReaction(
emoji: EmojiValue | string
): TelegramReactionType {
@@ -1999,6 +2291,53 @@ export class TelegramAdapter
`${description} (status ${status}, error ${errorCode})`
);
}
+
+ protected async withTelegramMarkdownFallback(
+ parseMode: TelegramParseMode,
+ operation: (parseMode: TelegramParseMode, text: string) => Promise,
+ context: {
+ initialText: string;
+ fallbackText: string;
+ method: string;
+ messageId?: string;
+ threadId?: string;
+ }
+ ): Promise {
+ try {
+ return await operation(parseMode, context.initialText);
+ } catch (error) {
+ if (
+ parseMode !== "MarkdownV2" ||
+ !this.isTelegramMarkdownParseError(error)
+ ) {
+ throw error;
+ }
+
+ this.logger.warn(
+ "Telegram markdown parse failed; retrying without parse mode",
+ {
+ error: String(error),
+ ...context,
+ }
+ );
+
+ return operation(
+ "plain",
+ this.resolveTelegramFallbackText(
+ context.initialText,
+ context.fallbackText
+ )
+ );
+ }
+ }
+
+ protected isTelegramMarkdownParseError(error: unknown): boolean {
+ return (
+ error instanceof ValidationError &&
+ error.adapter === "telegram" &&
+ TELEGRAM_MARKDOWN_PARSE_ERROR_PATTERN.test(error.message)
+ );
+ }
}
export function createTelegramAdapter(
diff --git a/packages/adapter-telegram/vitest.config.ts b/packages/adapter-telegram/vitest.config.ts
index edc2d946b..f9fe2900d 100644
--- a/packages/adapter-telegram/vitest.config.ts
+++ b/packages/adapter-telegram/vitest.config.ts
@@ -1,6 +1,12 @@
+import { resolve } from "node:path";
import { defineProject } from "vitest/config";
export default defineProject({
+ resolve: {
+ alias: {
+ chat: resolve(import.meta.dirname, "../chat/src/index.ts"),
+ },
+ },
test: {
globals: true,
environment: "node",
diff --git a/packages/chat/src/thread.test.ts b/packages/chat/src/thread.test.ts
index 455fbc983..3a551e815 100644
--- a/packages/chat/src/thread.test.ts
+++ b/packages/chat/src/thread.test.ts
@@ -217,6 +217,28 @@ describe("ThreadImpl", () => {
expect(mockAdapter.postMessage).not.toHaveBeenCalled();
});
+ it("should fall back when adapter.stream returns null", async () => {
+ mockAdapter.stream = vi.fn().mockResolvedValue(null);
+
+ const textStream = createTextStream(["Hello", " ", "World"]);
+ await thread.post(textStream);
+
+ expect(mockAdapter.stream).toHaveBeenCalledWith(
+ "slack:C123:1234.5678",
+ expect.any(Object),
+ expect.objectContaining({ updateIntervalMs: 500 })
+ );
+ expect(mockAdapter.postMessage).toHaveBeenCalledWith(
+ "slack:C123:1234.5678",
+ "..."
+ );
+ expect(mockAdapter.editMessage).toHaveBeenLastCalledWith(
+ "slack:C123:1234.5678",
+ "msg-1",
+ { markdown: "Hello World" }
+ );
+ });
+
it("should fall back to post+edit when adapter has no native streaming", async () => {
// Ensure no stream method
mockAdapter.stream = undefined;
diff --git a/packages/chat/src/thread.ts b/packages/chat/src/thread.ts
index b0cad54a2..8dd2772b8 100644
--- a/packages/chat/src/thread.ts
+++ b/packages/chat/src/thread.ts
@@ -604,7 +604,10 @@ export class ThreadImpl>
// Normalize: handles plain strings, AI SDK fullStream events, and StreamChunk objects
const textStream = fromFullStream(rawStream);
// Build streaming options from current message context + caller options
- const options: StreamOptions = { ...callerOptions };
+ const options: StreamOptions = {
+ updateIntervalMs: this._streamingUpdateIntervalMs,
+ ...callerOptions,
+ };
if (this._currentMessage) {
options.recipientUserId = this._currentMessage.author.userId;
// recipientTeamId is only consumed by the Slack adapter; other adapters
@@ -642,17 +645,19 @@ export class ThreadImpl>
};
const raw = await this.adapter.stream(this.id, wrappedStream, options);
- const sent = this.createSentMessage(
- raw.id,
- { markdown: accumulated },
- raw.threadId
- );
+ if (raw) {
+ const sent = this.createSentMessage(
+ raw.id,
+ { markdown: accumulated },
+ raw.threadId
+ );
+
+ if (this._threadHistory) {
+ await this._threadHistory.append(this.id, new Message(sent));
+ }
- if (this._threadHistory) {
- await this._threadHistory.append(this.id, new Message(sent));
+ return sent;
}
-
- return sent;
}
// Fallback: post + edit with throttling.
diff --git a/packages/chat/src/types.ts b/packages/chat/src/types.ts
index 31e2a3804..87ff780d2 100644
--- a/packages/chat/src/types.ts
+++ b/packages/chat/src/types.ts
@@ -528,7 +528,9 @@ export interface Adapter {
* Stream a message using platform-native streaming APIs.
*
* The adapter consumes the async iterable and handles the entire streaming lifecycle.
- * Only available on platforms with native streaming support (e.g., Slack).
+ * Available on platforms with native streaming or preview APIs.
+ * Adapters may return `null` before consuming any chunks to delegate back to
+ * Chat SDK's built-in post+edit fallback for the current thread.
*
* The stream can yield plain strings (text chunks) or {@link StreamChunk} objects
* for rich content like task progress cards. Adapters that don't support structured
@@ -537,13 +539,13 @@ export interface Adapter {
* @param threadId - The thread to stream to
* @param textStream - Async iterable of text chunks or structured StreamChunk objects
* @param options - Platform-specific streaming options
- * @returns The raw message after streaming completes
+ * @returns The raw message after streaming completes, or `null` to use core fallback
*/
stream?(
threadId: string,
textStream: AsyncIterable,
options?: StreamOptions
- ): Promise>;
+ ): Promise | null>;
/** Bot username (can override global userName) */
readonly userName: string;
}
@@ -1153,8 +1155,8 @@ export interface Thread, TRawMessage = unknown>
* Post a message to this thread.
*
* Supports text, markdown, cards, and streaming from async iterables.
- * When posting a stream (e.g., from AI SDK), uses platform-native streaming
- * APIs when available (Slack), or falls back to post + edit with throttling.
+ * When posting a stream (e.g., from AI SDK), uses adapter-native streaming
+ * when available, or falls back to post + edit with throttling.
*
* @param message - String, PostableMessage, JSX Card, or AsyncIterable
* @returns A SentMessage with methods to edit, delete, or add reactions