Skip to content

Commit ed46bae

Browse files
authored
feat(adapter-teams): native streaming for DMs via emit (#416)
* feat(adapter-teams): use native Teams SDK streaming for DMs Use ctx.stream.emit() from the Teams SDK for DM streaming instead of manual post+edit. This sends proper typing activities with streamType channelData, giving the native streaming UI in Teams. - Capture IStreamer from activity context in handleMessageActivity - Block handler with deferred promise so stream stays alive during processing - streamViaEmit() for DMs: uses stream.emit() with incremental text deltas - Group chats: accumulate full response and post as single message (no flicker) - Handle StreamCancelledError and stream.canceled for graceful cancellation Co-Authored-By: Claude <noreply@anthropic.com> * fix(tests): update Teams streaming assertions for accumulate-and-post Group chats now accumulate streamed chunks and post as a single message instead of post+edit, so assertions should check sentActivities not updatedActivities. * style: format replay-streaming test * chore: add changeset for teams native streaming ---------
1 parent 7a67ff5 commit ed46bae

3 files changed

Lines changed: 122 additions & 39 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@chat-adapter/teams": minor
3+
---
4+
5+
Use native Teams SDK streaming for DMs via `stream.emit()`, with accumulate-and-post fallback for group chats

packages/adapter-teams/src/index.ts

Lines changed: 113 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,12 @@ import type {
1313
IMessageReactionActivity,
1414
ITaskFetchInvokeActivity,
1515
ITaskSubmitInvokeActivity,
16+
SentActivity,
1617
TaskModuleResponse,
1718
} from "@microsoft/teams.api";
1819
import { MessageActivity, TypingActivity } from "@microsoft/teams.api";
19-
import type { IActivityContext } from "@microsoft/teams.apps";
20-
import { App } from "@microsoft/teams.apps";
20+
import type { IActivityContext, IStreamer } from "@microsoft/teams.apps";
21+
import { App, StreamCancelledError } from "@microsoft/teams.apps";
2122
import { users } from "@microsoft/teams.graph-endpoints";
2223
import type {
2324
ActionEvent,
@@ -93,6 +94,7 @@ export class TeamsAdapter implements Adapter<TeamsThreadId, unknown> {
9394
private readonly formatConverter = new TeamsFormatConverter();
9495
private readonly config: TeamsAdapterConfig;
9596
private readonly graphReader: TeamsGraphReader;
97+
private readonly activeStreams = new Map<string, IStreamer>();
9698

9799
constructor(config: TeamsAdapterConfig = {}) {
98100
this.config = config;
@@ -303,6 +305,10 @@ export class TeamsAdapter implements Adapter<TeamsThreadId, unknown> {
303305

304306
/**
305307
* Handle message activities (normal messages + Action.Submit button clicks).
308+
*
309+
* For DMs we block the handler until chat processing completes so that
310+
* ctx.stream (the Teams SDK's native IStreamer) stays alive for streaming.
311+
* The Teams SDK auto-closes the stream after the handler returns.
306312
*/
307313
private async handleMessageActivity(
308314
ctx: IActivityContext<IMessageActivity>
@@ -342,12 +348,41 @@ export class TeamsAdapter implements Adapter<TeamsThreadId, unknown> {
342348
message.isMention = true;
343349
}
344350

345-
this.chat.processMessage(
346-
this,
347-
threadId,
348-
message,
349-
this.bridgeAdapter.getWebhookOptions(activity.id)
350-
);
351+
// For DMs, capture ctx.stream and await processing so the stream stays
352+
// alive for native streaming via emit(). Group chats use fire-and-forget.
353+
if (this.isDM(threadId)) {
354+
this.activeStreams.set(threadId, ctx.stream);
355+
356+
let resolveProcessing: () => void;
357+
const processingDone = new Promise<void>((resolve) => {
358+
resolveProcessing = resolve;
359+
});
360+
361+
const baseOptions = this.bridgeAdapter.getWebhookOptions(activity.id);
362+
this.chat.processMessage(this, threadId, message, {
363+
...baseOptions,
364+
waitUntil: (task: Promise<unknown>) => {
365+
baseOptions?.waitUntil?.(task);
366+
task.then(
367+
() => resolveProcessing(),
368+
() => resolveProcessing()
369+
);
370+
},
371+
});
372+
373+
try {
374+
await processingDone;
375+
} finally {
376+
this.activeStreams.delete(threadId);
377+
}
378+
} else {
379+
this.chat.processMessage(
380+
this,
381+
threadId,
382+
message,
383+
this.bridgeAdapter.getWebhookOptions(activity.id)
384+
);
385+
}
351386
}
352387

353388
/**
@@ -1132,46 +1167,92 @@ export class TeamsAdapter implements Adapter<TeamsThreadId, unknown> {
11321167
}
11331168

11341169
/**
1135-
* Stream responses via post+edit.
1136-
* TODO: Use native HttpStream for DMs once @microsoft/teams.apps exports it.
1170+
* Stream responses using the Teams SDK's native streaming protocol when
1171+
* an active IStreamer exists (DMs), falling back to post+edit otherwise.
11371172
*/
11381173
async stream(
11391174
threadId: string,
11401175
textStream: AsyncIterable<string | StreamChunk>,
11411176
_options?: StreamOptions
11421177
): Promise<RawMessage<unknown>> {
1143-
const { conversationId } = this.decodeThreadId(threadId);
1144-
let accumulated = "";
1145-
let messageId: string | undefined;
1178+
const activeStream = this.activeStreams.get(threadId);
1179+
1180+
if (activeStream && !activeStream.canceled) {
1181+
return this.streamViaEmit(threadId, textStream, activeStream);
1182+
}
11461183

1184+
// No native streamer available (group chats, proactive messages) —
1185+
// accumulate and post as a single message instead of post+edit.
1186+
let accumulated = "";
11471187
for await (const chunk of textStream) {
1148-
let text = "";
11491188
if (typeof chunk === "string") {
1150-
text = chunk;
1189+
accumulated += chunk;
11511190
} else if (chunk.type === "markdown_text") {
1152-
text = chunk.text;
1153-
}
1154-
if (!text) {
1155-
continue;
1191+
accumulated += chunk.text;
11561192
}
1193+
}
1194+
if (!accumulated) {
1195+
return { id: "", threadId, raw: { text: "" } };
1196+
}
1197+
return this.postMessage(threadId, { markdown: accumulated });
1198+
}
11571199

1158-
accumulated += text;
1200+
/**
1201+
* Native streaming using the Teams SDK's IStreamer.emit().
1202+
* Sends typing activities with streamType: 'streaming', then a final
1203+
* message with streamType: 'final' on close (handled by the framework).
1204+
*
1205+
* We do NOT call stream.close() — the Teams SDK calls it automatically
1206+
* after the handler returns.
1207+
*/
1208+
private async streamViaEmit(
1209+
threadId: string,
1210+
textStream: AsyncIterable<string | StreamChunk>,
1211+
stream: IStreamer
1212+
): Promise<RawMessage<unknown>> {
1213+
let accumulated = "";
1214+
let messageId = "";
11591215

1160-
if (messageId) {
1161-
const activity = new MessageActivity(accumulated);
1162-
activity.textFormat = "markdown";
1163-
await this.app.api.conversations
1164-
.activities(conversationId)
1165-
.update(messageId, activity);
1166-
} else {
1167-
const activity = new MessageActivity(accumulated);
1168-
activity.textFormat = "markdown";
1169-
const res = await this.app.send(conversationId, activity);
1170-
messageId = res.id ?? "";
1216+
const idCaptured = new Promise<string>((resolve) => {
1217+
stream.events.once("chunk", (activity: SentActivity) => {
1218+
resolve(activity.id || "");
1219+
});
1220+
});
1221+
1222+
try {
1223+
for await (const chunk of textStream) {
1224+
if (stream.canceled) {
1225+
this.logger.debug("Teams stream canceled by user", { threadId });
1226+
break;
1227+
}
1228+
1229+
let text = "";
1230+
if (typeof chunk === "string") {
1231+
text = chunk;
1232+
} else if (chunk.type === "markdown_text") {
1233+
text = chunk.text;
1234+
}
1235+
if (!text) {
1236+
continue;
1237+
}
1238+
1239+
stream.emit(text);
1240+
accumulated += text;
11711241
}
1242+
} catch (error) {
1243+
if (!(error instanceof StreamCancelledError)) {
1244+
throw error;
1245+
}
1246+
this.logger.debug("Teams stream canceled during iteration", { threadId });
1247+
}
1248+
1249+
// Only await the chunk ID if we emitted text and the stream wasn't
1250+
// canceled before any chunk was delivered (which would hang forever).
1251+
if (accumulated && !stream.canceled) {
1252+
messageId = await idCaptured;
11721253
}
11731254

1174-
return { id: messageId ?? "", threadId, raw: { text: accumulated } };
1255+
return { id: messageId, threadId, raw: { text: accumulated } };
11751256
}
11761257

11771258
async openDM(userId: string): Promise<string> {

packages/integration-tests/src/replay-streaming.test.ts

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -194,8 +194,8 @@ describe("Streaming Replay Tests", () => {
194194
// Verify initial message was sent
195195
expectSentMessage(ctx.mockTeamsApp, "AI Mode Enabled!");
196196

197-
// Verify streaming completed with final message
198-
expectUpdatedMessage(ctx.mockTeamsApp, "Love is a complex emotion.");
197+
// Group chats accumulate and post as single message (no post+edit)
198+
expectSentMessage(ctx.mockTeamsApp, "Love is a complex emotion.");
199199
});
200200

201201
it("should stream response to follow-up message in AI mode", async () => {
@@ -211,11 +211,8 @@ describe("Streaming Replay Tests", () => {
211211
adapterName: "teams",
212212
});
213213

214-
// Verify streaming response
215-
expectUpdatedMessage(
216-
ctx.mockTeamsApp,
217-
"I am an AI assistant here to help."
218-
);
214+
// Group chats accumulate and post as single message (no post+edit)
215+
expectSentMessage(ctx.mockTeamsApp, "I am an AI assistant here to help.");
219216
});
220217
});
221218

0 commit comments

Comments
 (0)