Skip to content

msteams: streaming + block delivery duplicate text when response exceeds 4000 chars #58601

@jlian

Description

@jlian

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

  1. Configure msteams with blockStreaming: true and streaming enabled (DM, personal conversation)
  2. Trigger a long response (>4000 chars) from the agent
  3. 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

cc @BradGroux @SidU

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions