fix(msteams): reset stream state after tool calls to prevent message loss#56071
fix(msteams): reset stream state after tool calls to prevent message loss#56071BradGroux merged 4 commits intoopenclaw:mainfrom
Conversation
…very When an agent uses tools mid-response (text → tool calls → more text), the stream controller's preparePayload would suppress fallback delivery for ALL text segments because streamReceivedTokens stayed true. This caused the second text segment to be silently lost or duplicated. Fix: after preparePayload suppresses delivery for a streamed segment, finalize the stream and reset streamReceivedTokens so subsequent segments use fallback delivery. Fixes openclaw#56040 Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Greptile SummaryThis PR fixes a silent message-loss bug in the MS Teams reply stream controller where Key changes:
Concern:
Confidence Score: 4/5Safe to merge with low risk, but the unguarded The core fix is correct for the observed production flow (VM-tested), and the 4 new tests validate the intended path. Score reduced from 5 because
|
| Filename | Overview |
|---|---|
| extensions/msteams/src/reply-stream-controller.ts | Adds stream reset logic to preparePayload(): resets streamReceivedTokens and eagerly calls finalize() so second text segments use fallback delivery. Correct for the observed flow but onPartialReply() lacks a guard against re-setting the flag on a finalized stream. |
| extensions/msteams/src/reply-stream-controller.test.ts | New test file with 4 unit tests covering the multi-segment delivery path. Tests validate the intended flow but do not cover the scenario where onPartialReply() is called before the second preparePayload(). |
Comments Outside Diff (1)
-
extensions/msteams/src/reply-stream-controller.ts, line 48-54 (link)streamReceivedTokenscan be re-set totrueby a finalized streamAfter
preparePayload()resetsstreamReceivedTokens = falseand callsvoid stream.finalize(), if the LLM subsequently streams tokens for the second text segment throughonPartialReply(), the flag is set back totrue. Because the realTeamsHttpStreamnever clearsaccumulatedText,stream.hasContentremainstruefrom the first segment even after finalization. This means the secondpreparePayload()call would seestreamReceivedTokens = true+stream.hasContent = trueand suppress the second segment again — recreating the exact bug this PR is fixing.The current test (line 53-67) only validates the case where
onPartialReply()is not called before the secondpreparePayload(), which matches the assumed flow. But if the LLM pipeline does deliver streaming tokens for the second segment viaonPartialReply(), the fix is incomplete.Adding a
stream.isFinalizedguard here is the minimal defensive fix —stream.update()already no-ops after finalization, so the flag should follow suit:Prompt To Fix With AI
This is a comment left during a code review. Path: extensions/msteams/src/reply-stream-controller.ts Line: 48-54 Comment: **`streamReceivedTokens` can be re-set to `true` by a finalized stream** After `preparePayload()` resets `streamReceivedTokens = false` and calls `void stream.finalize()`, if the LLM subsequently streams tokens for the second text segment through `onPartialReply()`, the flag is set back to `true`. Because the real `TeamsHttpStream` never clears `accumulatedText`, `stream.hasContent` remains `true` from the first segment even after finalization. This means the second `preparePayload()` call would see `streamReceivedTokens = true` + `stream.hasContent = true` and suppress the second segment again — recreating the exact bug this PR is fixing. The current test (line 53-67) only validates the case where `onPartialReply()` is **not** called before the second `preparePayload()`, which matches the assumed flow. But if the LLM pipeline does deliver streaming tokens for the second segment via `onPartialReply()`, the fix is incomplete. Adding a `stream.isFinalized` guard here is the minimal defensive fix — `stream.update()` already no-ops after finalization, so the flag should follow suit: How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
This is a comment left during a code review.
Path: extensions/msteams/src/reply-stream-controller.ts
Line: 48-54
Comment:
**`streamReceivedTokens` can be re-set to `true` by a finalized stream**
After `preparePayload()` resets `streamReceivedTokens = false` and calls `void stream.finalize()`, if the LLM subsequently streams tokens for the second text segment through `onPartialReply()`, the flag is set back to `true`. Because the real `TeamsHttpStream` never clears `accumulatedText`, `stream.hasContent` remains `true` from the first segment even after finalization. This means the second `preparePayload()` call would see `streamReceivedTokens = true` + `stream.hasContent = true` and suppress the second segment again — recreating the exact bug this PR is fixing.
The current test (line 53-67) only validates the case where `onPartialReply()` is **not** called before the second `preparePayload()`, which matches the assumed flow. But if the LLM pipeline does deliver streaming tokens for the second segment via `onPartialReply()`, the fix is incomplete.
Adding a `stream.isFinalized` guard here is the minimal defensive fix — `stream.update()` already no-ops after finalization, so the flag should follow suit:
```suggestion
onPartialReply(payload: { text?: string }): void {
if (!stream || !payload.text || stream.isFinalized) {
return;
}
streamReceivedTokens = true;
stream.update(payload.text);
},
```
How can I resolve this? If you propose a fix, please make it concise.Reviews (1): Last reviewed commit: "fix(msteams): reset stream state after p..." | Re-trigger Greptile
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5be1c99104
ℹ️ About Codex in GitHub
Your team has set up Codex to 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 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…ssion When onPartialReply fires after the stream is finalized (post-tool partial tokens), streamReceivedTokens gets set back to true but the stream can't deliver. Add stream.isFinalized check so a finalized stream never suppresses fallback delivery. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 6828e56310
ℹ️ About Codex in GitHub
Your team has set up Codex to 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 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
Store the fire-and-forget finalize promise from preparePayload and await it in the controller's finalize() method. This ensures markDispatchIdle waits for the in-flight stream finalization to complete before context cleanup. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Add tests for 3+ tool call rounds (text → tool → text → tool → text) and media+text payloads after stream finalization, covering the full contract of preparePayload across all input types and cycle counts. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
BradGroux
left a comment
There was a problem hiding this comment.
Detailed Review: fix(msteams): reset stream state after tool calls
Problem
When an agent uses tools mid-response, the LLM produces discontinuous text segments (text > tool call > more text). The preparePayload() method used a one-shot streamReceivedTokens flag that was set to true on the first partial reply but never reset. Every subsequent deliver() call after the first segment was silently suppressed because the flag stayed true and the stream still reported hasContent. The second (and any further) text segments were simply lost.
This is a real-world regression for any agent that uses tools during a conversation, which is basically all of them.
Fix Analysis (3 commits)
Commit 1 (5be1c99): Core fix
- Resets
streamReceivedTokens = falseafterpreparePayloadsuppresses the first streamed segment - Calls
stream.finalize()immediately so the stream is closed and the first segment is delivered as a final message - Subsequent calls to
preparePayloadnow correctly fall through to fallback/proactive delivery becausestreamReceivedTokensis false
This is the right approach. The stream handled segment one, so finalize it and let the fallback path handle everything after that.
Commit 2 (6828e56): Finalized-stream guard
- Adds
stream.isFinalizedto the guard condition inpreparePayload - Prevents a subtle race: if
onPartialReplyfires again for post-tool tokens (which it does),streamReceivedTokensgets set back totrue, but the finalized stream cannot actually deliver anything - Without this guard, the second segment would be suppressed again because
streamReceivedTokensandhasContentare both truthy
This directly addresses the Codex bot feedback on commit 1. Good catch and clean fix.
Commit 3 (1531c53): Finalization ordering
- Stores the fire-and-forget
stream.finalize()promise aspendingFinalize - The controller
finalize()method now awaitspendingFinalizebefore callingstream.finalize()again - This closes a race where
markDispatchIdlecould tear down the turn context before the stream finalization completes
TeamsHttpStream.finalize() sets this.finalized = true synchronously at the top, so the second call from finalize() is a no-op. No double-send risk.
Edge Cases Verified
-
Group/channel conversations:
streamis undefined (only created for personal chats), sopreparePayloadreturns the payload unmodified. No regression. -
Multiple tool boundaries (text > tools > text > tools > text): After the first finalize, every subsequent
preparePayloadcall hits theisFinalizedguard and returns the payload for fallback delivery. Works correctly for any number of segments. -
Media payloads: The
hasMediabranch only executes when the stream is active, has content, and is not finalized (i.e., first segment only). Post-tool media+text payloads pass through fully via fallback. -
Finalize idempotency:
TeamsHttpStream.finalize()checksthis.finalizedinternally. The double-call path (pendingFinalizethenstream?.finalize()) is safe. -
No partial tokens before tool call: If the LLM goes straight to tool use without emitting text,
streamReceivedTokensstays false andpreparePayloadreturns the payload as-is. Correct.
Test Coverage
Five new tests in reply-stream-controller.test.ts:
| Test | What it covers |
|---|---|
| Suppresses fallback for first streamed segment | Basic streaming delivery path |
| Allows fallback for second segment after tools | The core bug fix |
| Finalizes stream when suppressing first segment | Ensures stream closes properly |
| Fallback works even when onPartialReply fires post-finalize | The commit 2 race condition |
| Strips text from media payloads when stream handled text | Media+text first-segment behavior |
The mocks are clean. They test the controller contract without requiring the full Teams SDK or Bot Framework. The vi.hoisted pattern for tracking stream instances is a nice touch for verifying finalization calls.
VM Regression
The PR body references passing VM tests: A1 (basic reply), A4 (streaming), A5 (streaming + tool use), E4 (endpoint). Full test report linked.
Verdict
Small, surgical, well-tested fix for a real bug that affects every tool-using agent on Teams. The three-commit progression is logical and each commit addresses a distinct concern. No regressions identified. Approved.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 31af10ebda
ℹ️ About Codex in GitHub
Your team has set up Codex to 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 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| streamReceivedTokens = false; | ||
| pendingFinalize = stream.finalize(); |
There was a problem hiding this comment.
Restrict early stream finalization to segment boundaries
When block streaming is enabled (agentCfg.blockStreamingDefault === "on" in src/auto-reply/reply/get-reply-directives.ts:397-409), onBlockReply can deliver multiple payloads during one assistant turn (src/auto-reply/reply/dispatch-from-config.ts:690-720). Finalizing the Teams stream on the first suppressed payload here marks the stream finalized immediately, so subsequent block payloads bypass suppression and are sent via fallback as separate messages; at the same time, final payloads are commonly dropped after successful block streaming (src/auto-reply/reply/agent-runner-payloads.ts:157-215). In that configuration this regresses from a single coherent streamed message to fragmented/duplicated delivery, so finalization should not happen for every suppressed payload.
Useful? React with 👍 / 👎.
|
Thanks Brad! Added both suggested tests in |
…loss (openclaw#56071) * fix(msteams): reset stream state after preparePayload suppresses delivery When an agent uses tools mid-response (text → tool calls → more text), the stream controller's preparePayload would suppress fallback delivery for ALL text segments because streamReceivedTokens stayed true. This caused the second text segment to be silently lost or duplicated. Fix: after preparePayload suppresses delivery for a streamed segment, finalize the stream and reset streamReceivedTokens so subsequent segments use fallback delivery. Fixes openclaw#56040 Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix(msteams): guard preparePayload against finalized stream re-suppression When onPartialReply fires after the stream is finalized (post-tool partial tokens), streamReceivedTokens gets set back to true but the stream can't deliver. Add stream.isFinalized check so a finalized stream never suppresses fallback delivery. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix(msteams): await pending finalize in controller to prevent race Store the fire-and-forget finalize promise from preparePayload and await it in the controller's finalize() method. This ensures markDispatchIdle waits for the in-flight stream finalization to complete before context cleanup. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * test(msteams): add edge case tests for multi-round and media payloads Add tests for 3+ tool call rounds (text → tool → text → tool → text) and media+text payloads after stream finalization, covering the full contract of preparePayload across all input types and cycle counts. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> --------- Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
…loss (openclaw#56071) * fix(msteams): reset stream state after preparePayload suppresses delivery When an agent uses tools mid-response (text → tool calls → more text), the stream controller's preparePayload would suppress fallback delivery for ALL text segments because streamReceivedTokens stayed true. This caused the second text segment to be silently lost or duplicated. Fix: after preparePayload suppresses delivery for a streamed segment, finalize the stream and reset streamReceivedTokens so subsequent segments use fallback delivery. Fixes openclaw#56040 Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix(msteams): guard preparePayload against finalized stream re-suppression When onPartialReply fires after the stream is finalized (post-tool partial tokens), streamReceivedTokens gets set back to true but the stream can't deliver. Add stream.isFinalized check so a finalized stream never suppresses fallback delivery. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix(msteams): await pending finalize in controller to prevent race Store the fire-and-forget finalize promise from preparePayload and await it in the controller's finalize() method. This ensures markDispatchIdle waits for the in-flight stream finalization to complete before context cleanup. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * test(msteams): add edge case tests for multi-round and media payloads Add tests for 3+ tool call rounds (text → tool → text → tool → text) and media+text payloads after stream finalization, covering the full contract of preparePayload across all input types and cycle counts. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> --------- Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
…loss (openclaw#56071) * fix(msteams): reset stream state after preparePayload suppresses delivery When an agent uses tools mid-response (text → tool calls → more text), the stream controller's preparePayload would suppress fallback delivery for ALL text segments because streamReceivedTokens stayed true. This caused the second text segment to be silently lost or duplicated. Fix: after preparePayload suppresses delivery for a streamed segment, finalize the stream and reset streamReceivedTokens so subsequent segments use fallback delivery. Fixes openclaw#56040 Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix(msteams): guard preparePayload against finalized stream re-suppression When onPartialReply fires after the stream is finalized (post-tool partial tokens), streamReceivedTokens gets set back to true but the stream can't deliver. Add stream.isFinalized check so a finalized stream never suppresses fallback delivery. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix(msteams): await pending finalize in controller to prevent race Store the fire-and-forget finalize promise from preparePayload and await it in the controller's finalize() method. This ensures markDispatchIdle waits for the in-flight stream finalization to complete before context cleanup. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * test(msteams): add edge case tests for multi-round and media payloads Add tests for 3+ tool call rounds (text → tool → text → tool → text) and media+text payloads after stream finalization, covering the full contract of preparePayload across all input types and cycle counts. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> --------- Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
…loss (openclaw#56071) * fix(msteams): reset stream state after preparePayload suppresses delivery When an agent uses tools mid-response (text → tool calls → more text), the stream controller's preparePayload would suppress fallback delivery for ALL text segments because streamReceivedTokens stayed true. This caused the second text segment to be silently lost or duplicated. Fix: after preparePayload suppresses delivery for a streamed segment, finalize the stream and reset streamReceivedTokens so subsequent segments use fallback delivery. Fixes openclaw#56040 Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix(msteams): guard preparePayload against finalized stream re-suppression When onPartialReply fires after the stream is finalized (post-tool partial tokens), streamReceivedTokens gets set back to true but the stream can't deliver. Add stream.isFinalized check so a finalized stream never suppresses fallback delivery. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix(msteams): await pending finalize in controller to prevent race Store the fire-and-forget finalize promise from preparePayload and await it in the controller's finalize() method. This ensures markDispatchIdle waits for the in-flight stream finalization to complete before context cleanup. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * test(msteams): add edge case tests for multi-round and media payloads Add tests for 3+ tool call rounds (text → tool → text → tool → text) and media+text payloads after stream finalization, covering the full contract of preparePayload across all input types and cycle counts. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> --------- Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
Summary
preparePayload()inreply-stream-controller.tsto resetstreamReceivedTokensand finalize the stream after suppressing a delivery, so subsequent text segments (after tool calls) use fallback delivery instead of being silently droppedstream.isFinalizedguard so a finalized stream never re-suppresses fallback delivery even ifonPartialReplyfires againRoot Cause
When an agent uses tools mid-response, the LLM produces discontinuous text segments.
preparePayload()used a one-shotstreamReceivedTokensflag that never reset after the first segment was streamed — so every subsequentdeliver()call was suppressed, even for text the stream never saw.Before / After
Before fix: When an agent uses tools mid-response (text → tool calls → more text),
preparePayload()suppresses fallback delivery for ALL text segments becausestreamReceivedTokensstaystrue. The second text segment after tool calls is silently lost or the response gets fragmented into duplicate messages.After fix:
preparePayload()resetsstreamReceivedTokensand finalizes the stream after suppressing the first segment. Subsequent text segments (after tool calls) are delivered via the fallback proactive messaging path. Thestream.isFinalizedguard ensures this holds even ifonPartialReplyfires again for post-tool tokens.Test plan
reply-stream-controller.test.ts— all passreply-dispatcher.test.ts(5) +streaming-message.test.ts(10) — all passFixes #56040
🤖 Generated with Claude Code