@@ -13,11 +13,12 @@ import type {
1313 IMessageReactionActivity ,
1414 ITaskFetchInvokeActivity ,
1515 ITaskSubmitInvokeActivity ,
16+ SentActivity ,
1617 TaskModuleResponse ,
1718} from "@microsoft/teams.api" ;
1819import { 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" ;
2122import { users } from "@microsoft/teams.graph-endpoints" ;
2223import 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 > {
0 commit comments