-
-
Notifications
You must be signed in to change notification settings - Fork 69.5k
msteams: streaming + block delivery duplicate text when response exceeds 4000 chars #58601
Description
Bug
When a streamed DM response exceeds the Teams 4000-character limit, the user receives duplicate text: the first ~4000 chars appear via streaming, then the full text is re-sent via block/fallback delivery.
Reproduction
- Configure msteams with
blockStreaming: trueand streaming enabled (DM, personal conversation) - Trigger a long response (>4000 chars) from the agent
- Observe: the first portion of the response appears twice in the Teams chat
Root Cause
In extensions/msteams/src/streaming-message.ts, when accumulatedText exceeds TEAMS_MAX_CHARS (4000), the stream sets streamFailed = true and calls finalize(). The finalize() method sends a "final" message with lastStreamedText to close the stream cleanly.
However, hasContent returns false when streamFailed is true:
get hasContent(): boolean {
return this.accumulatedText.length > 0 && !this.streamFailed;
}In reply-stream-controller.ts, preparePayload checks hasContent:
preparePayload(payload: ReplyPayload): ReplyPayload | undefined {
if (!stream || !streamReceivedTokens || !stream.hasContent || stream.isFinalized) {
return payload; // ← full payload goes through block delivery
}
// ...
}Since hasContent is false (due to streamFailed), preparePayload returns the full payload, which gets sent via normal block delivery. But the stream already delivered the first ~4000 chars before failing! Result: the streamed portion appears twice.
Expected Behavior
When streaming fails mid-delivery, the block fallback should only send the text that was NOT already streamed, or the stream should not send any "final" message and let block delivery handle everything.
Suggested Fix
Option A (preferred): Track how much text was successfully streamed. In preparePayload, when streamFailed, strip the already-streamed prefix from the fallback payload:
// streaming-message.ts
get streamedLength(): number {
return this.lastStreamedText.length;
}
// reply-stream-controller.ts - preparePayload
if (stream.isFinalized && stream.streamedLength > 0 && payload.text) {
const remaining = payload.text.slice(stream.streamedLength);
if (!remaining.trim()) return undefined;
return { ...payload, text: remaining };
}Option B (simpler): When streamFailed, skip sending the "final" message in finalize() and let the stream auto-timeout. Then hasContent = false correctly triggers full block delivery with no overlap. Downside: user sees partial streaming → pause → full re-send, which is less ideal but no duplication.
Environment
- OpenClaw v2026.3.28
- msteams channel, DM (personal) conversation
blockStreaming: true- Response length: ~6000+ chars
Related
- fix(msteams): reset stream state after tool calls to prevent message loss #56071 - msteams streaming + tool calls message loss (fixed, different scenario)
- msteams: Teams streaming protocol causes lost messages with tool-using agents #56040 - original streaming message loss report
- fix(msteams): wire blockStreaming config and onBlockReply for progressive message delivery #56134 - blockStreaming wiring for msteams
cc @BradGroux @SidU