fix(msteams): prevent duplicate text when stream exceeds 4000 char limit#59297
Conversation
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
There was a problem hiding this comment.
💡 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".
| if (stream.isFailed) { | ||
| streamReceivedTokens = false; |
There was a problem hiding this comment.
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 SummaryThis PR fixes duplicate text in Teams when a streamed response exceeds the 4000-char limit by adding Confidence Score: 4/5
Prompt To Fix All With AIThis 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 |
| get streamedLength(): number { | ||
| return this.lastStreamedText.length; | ||
| } |
There was a problem hiding this 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.
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.There was a problem hiding this comment.
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) onTeamsHttpStream. - Updated
preparePayloadto 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; |
There was a problem hiding this comment.
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.
| streamReceivedTokens = false; | |
| streamReceivedTokens = false; | |
| if (!pendingFinalize && !stream.isFinalized) { | |
| // Best-effort stop of the underlying stream loop once failure is detected. | |
| pendingFinalize = stream.finalize(); | |
| } |
…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
…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
When a streamed response exceeds
TEAMS_MAX_CHARS(4000), the stream setsstreamFailed = trueand finalizes. However,hasContentreturnsfalsewhenstreamFailedis true, which causespreparePayloadin 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: