From 57949397fa181dc56fbfdb86fa1fa292bb25faea Mon Sep 17 00:00:00 2001 From: Brad Groux <3053586+BradGroux@users.noreply.github.com> Date: Wed, 1 Apr 2026 19:03:19 -0500 Subject: [PATCH] fix(msteams): prevent duplicate text when stream exceeds 4000 char limit (#59297) When a streamed response exceeds TEAMS_MAX_CHARS, the stream sets streamFailed=true and finalizes. Previously, hasContent returned false when streamFailed was true, causing preparePayload to pass through the full payload for block delivery, duplicating already-streamed text. Now tracks streamed length and strips the already-delivered prefix from fallback payloads. Fixes #58601 thanks @bradgroux --- .../src/reply-stream-controller.test.ts | 38 ++++++++++++++++++- .../msteams/src/reply-stream-controller.ts | 33 ++++++++++++++-- extensions/msteams/src/streaming-message.ts | 10 +++++ 3 files changed, 77 insertions(+), 4 deletions(-) diff --git a/extensions/msteams/src/reply-stream-controller.test.ts b/extensions/msteams/src/reply-stream-controller.test.ts index 9582a48bbe0..8e5adc5c775 100644 --- a/extensions/msteams/src/reply-stream-controller.test.ts +++ b/extensions/msteams/src/reply-stream-controller.test.ts @@ -5,6 +5,8 @@ const streamInstances = vi.hoisted( [] as Array<{ hasContent: boolean; isFinalized: boolean; + isFailed: boolean; + streamedLength: number; sendInformativeUpdate: ReturnType; update: ReturnType; finalize: ReturnType; @@ -15,9 +17,15 @@ vi.mock("./streaming-message.js", () => ({ TeamsHttpStream: class { hasContent = false; isFinalized = false; + isFailed = false; + streamedLength = 0; sendInformativeUpdate = vi.fn(async () => {}); - update = vi.fn(function (this: { hasContent: boolean }) { + update = vi.fn(function ( + this: { hasContent: boolean; streamedLength: number }, + payloadText?: string, + ) { this.hasContent = true; + this.streamedLength = payloadText?.length ?? 0; }); finalize = vi.fn(async function (this: { isFinalized: boolean }) { this.isFinalized = true; @@ -50,6 +58,34 @@ describe("createTeamsReplyStreamController", () => { expect(result).toBeUndefined(); }); + it("when stream fails after partial delivery, fallback sends only remaining text", () => { + const ctrl = createController(); + const fullText = "a".repeat(4000) + "b".repeat(200); + + ctrl.onPartialReply({ text: fullText }); + streamInstances[0]!.hasContent = false; + streamInstances[0]!.isFailed = true; + streamInstances[0]!.isFinalized = true; + streamInstances[0]!.streamedLength = 4000; + + const result = ctrl.preparePayload({ text: fullText }); + expect(result).toEqual({ text: "b".repeat(200) }); + }); + + it("when stream fails before sending content, fallback sends full text", () => { + const ctrl = createController(); + const fullText = "Failure at first chunk"; + + ctrl.onPartialReply({ text: fullText }); + streamInstances[0]!.hasContent = false; + streamInstances[0]!.isFailed = true; + streamInstances[0]!.isFinalized = true; + streamInstances[0]!.streamedLength = 0; + + const result = ctrl.preparePayload({ text: fullText }); + expect(result).toEqual({ text: fullText }); + }); + it("allows fallback delivery for second text segment after tool calls", () => { const ctrl = createController(); diff --git a/extensions/msteams/src/reply-stream-controller.ts b/extensions/msteams/src/reply-stream-controller.ts index f0299f14b99..5436e2fe4e8 100644 --- a/extensions/msteams/src/reply-stream-controller.ts +++ b/extensions/msteams/src/reply-stream-controller.ts @@ -55,17 +55,44 @@ export function createTeamsReplyStreamController(params: { }, preparePayload(payload: ReplyPayload): ReplyPayload | undefined { - if (!stream || !streamReceivedTokens || !stream.hasContent || stream.isFinalized) { + if (!stream || !streamReceivedTokens) { return payload; } - // Stream handled this text segment — finalize it and reset so any + const hasMedia = Boolean(payload.mediaUrl || payload.mediaUrls?.length); + + // Stream failed after partial delivery (e.g. > 4000 chars). Send only + // the unstreamed suffix via block delivery to avoid duplicate text. + if (stream.isFailed) { + streamReceivedTokens = false; + + if (!payload.text) { + return payload; + } + + const streamedLength = stream.streamedLength; + if (streamedLength <= 0) { + return payload; + } + + const remainingText = payload.text.slice(streamedLength); + if (!remainingText) { + return hasMedia ? { ...payload, text: undefined } : undefined; + } + + return { ...payload, text: remainingText }; + } + + if (!stream.hasContent || stream.isFinalized) { + return payload; + } + + // Stream handled this text segment. Finalize it and reset so any // subsequent text segments (after tool calls) use fallback delivery. // finalize() is idempotent; the later call in markDispatchIdle is a no-op. streamReceivedTokens = false; pendingFinalize = stream.finalize(); - const hasMedia = Boolean(payload.mediaUrl || payload.mediaUrls?.length); if (!hasMedia) { return undefined; } diff --git a/extensions/msteams/src/streaming-message.ts b/extensions/msteams/src/streaming-message.ts index 61b5e180cf9..5ddb19851ac 100644 --- a/extensions/msteams/src/streaming-message.ts +++ b/extensions/msteams/src/streaming-message.ts @@ -214,6 +214,16 @@ export class TeamsHttpStream { return this.accumulatedText.length > 0 && !this.streamFailed; } + /** Whether streaming failed and fallback delivery is needed. */ + get isFailed(): boolean { + return this.streamFailed; + } + + /** Number of characters successfully streamed before failure. */ + get streamedLength(): number { + return this.lastStreamedText.length; + } + /** Whether the stream has been finalized. */ get isFinalized(): boolean { return this.finalized;