Skip to content

fix(msteams): prevent duplicate text when stream exceeds 4000 char limit#59297

Merged
BradGroux merged 1 commit intoopenclaw:mainfrom
BradGroux:fix/msteams-streaming-duplicate-text
Apr 2, 2026
Merged

fix(msteams): prevent duplicate text when stream exceeds 4000 char limit#59297
BradGroux merged 1 commit intoopenclaw:mainfrom
BradGroux:fix/msteams-streaming-duplicate-text

Conversation

@BradGroux
Copy link
Copy Markdown
Contributor

When a streamed response exceeds TEAMS_MAX_CHARS (4000), the stream sets streamFailed = true and finalizes. However, hasContent returns false when streamFailed is true, which causes preparePayload in the reply stream controller to pass through the full payload for block delivery and duplicate already streamed text.

This fix adds stream failure metadata (failed flag and streamed length) and updates fallback payload preparation to trim the already streamed prefix when streaming failed mid delivery. If the streamed prefix covers the full text, fallback delivery is suppressed. If no content was streamed before failure, full block delivery is preserved.

Fixes #58601


Test plan:

  • Existing normal streaming path unchanged: streamed first segment is suppressed from fallback, later segments after tool calls still use fallback
  • Added: stream fails after partial delivery (4000 chars), fallback sends only remaining suffix
  • Added: stream fails before sending any content, fallback sends full text
  • Existing stream completion behavior remains: no duplicate block delivery for successfully streamed segment

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.

Fixes openclaw#58601
@BradGroux BradGroux added the channel: msteams Channel integration: msteams label Apr 1, 2026
Copilot AI review requested due to automatic review settings April 1, 2026 23:52
@openclaw-barnacle openclaw-barnacle bot added size: S maintainer Maintainer-authored PR labels Apr 1, 2026
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 87b2ce4b62

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +66 to +67
if (stream.isFailed) {
streamReceivedTokens = false;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Restrict failed-stream trimming to the first fallback payload

When stream.isFailed is true, this branch always slices payload.text by stream.streamedLength, but isFailed remains true for the rest of the turn. After an overflowed first segment, later onPartialReply calls (for post-tool segments) still set streamReceivedTokens = true even though stream.update() is a no-op on a finalized stream, so preparePayload re-enters this block and drops/truncates unrelated later segments (e.g., second segment becomes undefined if shorter than the prior streamed prefix). This turns a dedup fix into content loss whenever a long first segment is followed by additional text segments.

Useful? React with 👍 / 👎.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Apr 1, 2026

Greptile Summary

This PR fixes duplicate text in Teams when a streamed response exceeds the 4000-char limit by adding isFailed and streamedLength getters to TeamsHttpStream and updating preparePayload to trim the already-streamed prefix before block delivery. The logic is sound and the new tests cover the main failure scenarios well.

Confidence Score: 4/5

  • Safe to merge; the fix correctly addresses the duplicate-text bug with one minor timing caveat worth reviewing.
  • The only finding is P2: streamedLength reads lastStreamedText.length which could theoretically be stale if the in-flight push hasn't settled before preparePayload is called. In practice the narrow async window makes this unlikely, but the correctness guarantee is not airtight.
  • extensions/msteams/src/streaming-message.ts — streamedLength getter and its relationship to the async pushStreamChunk completion.
Prompt To Fix All With AI
This is a comment left during a code review.
Path: extensions/msteams/src/streaming-message.ts
Line: 223-225

Comment:
**`streamedLength` may be stale at read time**

`lastStreamedText` is updated inside the async `pushStreamChunk` callback, which runs as part of `loop.waitForInFlight()` inside `finalize()`. Because `finalize()` is kicked off with `void` (not awaited) from `update()`, and `finalize()`'s idempotency guard sets `this.finalized = true` synchronously before the `await`, a subsequent `stream.finalize()` call from the controller returns immediately without waiting for the first call to finish. If `preparePayload` reads `stream.streamedLength` before `waitForInFlight()` resolves, it sees a stale `lastStreamedText` and the suffix trim will be incorrect — potentially leaving some already-streamed chars in the fallback payload.

In practice the window is narrow (many `await` points exist between the failing `update()` call and `preparePayload` being invoked), but the correctness guarantee is absent. One way to close it: store `lastStreamedText` as a snapshot on `streamFailed` being set (since at that moment no new successful pushes can start), rather than lazily reading from the field that a still-running in-flight push may update.

How can I resolve this? If you propose a fix, please make it concise.

Reviews (1): Last reviewed commit: "fix(msteams): prevent duplicate text whe..." | Re-trigger Greptile

Comment on lines +223 to +225
get streamedLength(): number {
return this.lastStreamedText.length;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 streamedLength may be stale at read time

lastStreamedText is updated inside the async pushStreamChunk callback, which runs as part of loop.waitForInFlight() inside finalize(). Because finalize() is kicked off with void (not awaited) from update(), and finalize()'s idempotency guard sets this.finalized = true synchronously before the await, a subsequent stream.finalize() call from the controller returns immediately without waiting for the first call to finish. If preparePayload reads stream.streamedLength before waitForInFlight() resolves, it sees a stale lastStreamedText and the suffix trim will be incorrect — potentially leaving some already-streamed chars in the fallback payload.

In practice the window is narrow (many await points exist between the failing update() call and preparePayload being invoked), but the correctness guarantee is absent. One way to close it: store lastStreamedText as a snapshot on streamFailed being set (since at that moment no new successful pushes can start), rather than lazily reading from the field that a still-running in-flight push may update.

Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/msteams/src/streaming-message.ts
Line: 223-225

Comment:
**`streamedLength` may be stale at read time**

`lastStreamedText` is updated inside the async `pushStreamChunk` callback, which runs as part of `loop.waitForInFlight()` inside `finalize()`. Because `finalize()` is kicked off with `void` (not awaited) from `update()`, and `finalize()`'s idempotency guard sets `this.finalized = true` synchronously before the `await`, a subsequent `stream.finalize()` call from the controller returns immediately without waiting for the first call to finish. If `preparePayload` reads `stream.streamedLength` before `waitForInFlight()` resolves, it sees a stale `lastStreamedText` and the suffix trim will be incorrect — potentially leaving some already-streamed chars in the fallback payload.

In practice the window is narrow (many `await` points exist between the failing `update()` call and `preparePayload` being invoked), but the correctness guarantee is absent. One way to close it: store `lastStreamedText` as a snapshot on `streamFailed` being set (since at that moment no new successful pushes can start), rather than lazily reading from the field that a still-running in-flight push may update.

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes an MS Teams streaming edge case where responses exceeding the 4000-char streaming limit were being duplicated by block/fallback delivery.

Changes:

  • Exposed stream failure metadata (isFailed, streamedLength) on TeamsHttpStream.
  • Updated preparePayload to trim the already-streamed prefix from fallback payloads when streaming fails mid-delivery (and suppress fallback if nothing remains).
  • Added unit tests covering mid-stream failure vs failure-before-any-content scenarios.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

File Description
extensions/msteams/src/streaming-message.ts Adds failure metadata accessors so downstream fallback logic can make informed trimming decisions.
extensions/msteams/src/reply-stream-controller.ts Trims fallback text by the streamed prefix when streaming fails, preventing duplicate delivery.
extensions/msteams/src/reply-stream-controller.test.ts Adds regression tests for partial-stream failure fallback behavior.

// 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;
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the stream.isFailed fallback path we reset streamReceivedTokens, but we don’t stop/finalize the underlying TeamsHttpStream. Since TeamsHttpStream.update() doesn’t short-circuit on streamFailed and createDraftStreamLoop will keep retrying when pushStreamChunk() returns false, the stream can continue attempting POSTs (and potentially deliver additional chunks) until markDispatchIdle() calls finalize(). Consider triggering a best-effort stream.finalize() (or otherwise stopping the loop) as soon as failure is detected here to avoid ongoing stream retries while fallback delivery is happening.

Suggested change
streamReceivedTokens = false;
streamReceivedTokens = false;
if (!pendingFinalize && !stream.isFinalized) {
// Best-effort stop of the underlying stream loop once failure is detected.
pendingFinalize = stream.finalize();
}

Copilot uses AI. Check for mistakes.
@BradGroux BradGroux merged commit 5794939 into openclaw:main Apr 2, 2026
48 of 49 checks passed
@BradGroux BradGroux self-assigned this Apr 2, 2026
ancientitguybot-dev pushed a commit to KaiWalter/openclaw that referenced this pull request Apr 3, 2026
…mit (openclaw#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 openclaw#58601

thanks @BradGroux
steipete pushed a commit to duncanita/openclaw that referenced this pull request Apr 4, 2026
…mit (openclaw#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 openclaw#58601

thanks @BradGroux
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

channel: msteams Channel integration: msteams maintainer Maintainer-authored PR size: S

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants