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
This commit is contained in:
Brad Groux
2026-04-01 19:03:19 -05:00
committed by GitHub
parent 560ea25294
commit 57949397fa
3 changed files with 77 additions and 4 deletions

View File

@@ -5,6 +5,8 @@ const streamInstances = vi.hoisted(
[] as Array<{
hasContent: boolean;
isFinalized: boolean;
isFailed: boolean;
streamedLength: number;
sendInformativeUpdate: ReturnType<typeof vi.fn>;
update: ReturnType<typeof vi.fn>;
finalize: ReturnType<typeof vi.fn>;
@@ -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();

View File

@@ -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;
}

View File

@@ -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;