mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-03 21:31:26 +00:00
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:
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user