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();