fix(mattermost): stream each turn independently in multi-tool-call responses#43041
fix(mattermost): stream each turn independently in multi-tool-call responses#43041teconomix wants to merge 54 commits intoopenclaw:mainfrom
Conversation
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 12b0eeead3
ℹ️ 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".
| streamMessageId = null; | ||
| pendingPatchText = ""; | ||
| lastSentText = ""; | ||
| streamedTurnCount++; |
There was a problem hiding this comment.
Defer turn-skip counter until finalization succeeds
onAssistantMessageStart increments streamedTurnCount before attempting updateMattermostPost. If that update fails (for example API/network errors or the post being removed), the later final-delivery loop still skips one turn, leaving that turn stuck as partial streamed content with no authoritative final message. Increment this counter only after a successful finalization (or roll it back on failure) so failed finalization still falls through to normal final delivery.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Good catch. Fixed in 913adeb — streamedTurnCount is now decremented in the catch block so deliver() falls back to normal re-delivery when finalization fails.
| // For top-level inbound posts it is undefined, matching non-streaming deliver behaviour. | ||
| // NOTE: do NOT try to derive this from onPartialReply callbacks — those are invoked | ||
| // with only { text, mediaUrls } and carry no replyToId. | ||
| let streamReplyToId: string | undefined = threadRootId; |
There was a problem hiding this comment.
Preserve replyToId when creating streamed posts
The streaming path initializes streamReplyToId from threadRootId only, so first streamed posts ignore payload.replyToId. For top-level inbound posts where reply threading is driven by reply-to directives or reply mode, this publishes streaming output as a top-level post, and later final edits cannot relocate it to the intended thread. The stream-post target should be resolved with the same reply-root logic used by final non-streaming sends.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
This is inherited from the base branch (#33506) where streamReplyToId was deliberately set to threadRootId only — see the comment block at line 1648-1651. The payload.replyToId is not available in onPartialReply callbacks (they only carry {text, mediaUrls}), so the streaming path cannot use it. For replyToMode: "all" (our setup), threadRootId is already the correct thread root. No change needed here.
Greptile SummaryThis PR fixes a multi-turn streaming issue in the Mattermost integration where a single streaming post kept being overwritten with each successive turn's text instead of each turn getting its own independent post. The fix introduces Key changes:
Confidence Score: 5/5
Prompt To Fix All With AIThis is a comment left during a code review.
Path: extensions/mattermost/src/mattermost/monitor.ts
Line: 8
Comment:
**Unused import added by this PR**
`fetchMattermostUserTeams` is imported here but never referenced anywhere else in `monitor.ts` — a grep of the full file confirms the only occurrence is this import line. `patchMattermostPost` and `deleteMattermostPost` (the other new imports from this PR) are both used, so this looks like an accidental leftover.
```suggestion
```
(Remove the `fetchMattermostUserTeams,` line entirely.)
How can I resolve this? If you propose a fix, please make it concise.Reviews (4): Last reviewed commit: "fix(mattermost): track streamed turns by..." | Re-trigger Greptile |
| streamedTurnCount++; | ||
|
|
||
| // Wait for any in-flight patch to complete before finalizing. | ||
| const deadline = Date.now() + 2000; | ||
| while (patchSending && Date.now() < deadline) { | ||
| await new Promise<void>((r) => setTimeout(r, 20)); | ||
| } | ||
|
|
||
| // Finalize the old streaming message with its complete text. | ||
| if (!finalizeText) return; | ||
| try { | ||
| await updateMattermostPost(blockStreamingClient, finalizeId, { | ||
| message: finalizeText, | ||
| }); | ||
| runtime.log?.(`stream-patch finalized turn ${finalizeId}`); | ||
| } catch (err) { | ||
| logVerboseMessage( | ||
| `mattermost stream-patch turn finalize failed: ${String(err)}`, | ||
| ); | ||
| } |
There was a problem hiding this comment.
streamedTurnCount incremented before finalization can succeed
streamedTurnCount++ happens at line 1947, before the updateMattermostPost call at line 1958. If that network call throws (caught at line 1962), the count has already been incremented but the streaming post was never finalized with its complete text — it remains with whatever partial content was last emitted by the interval ticker.
When the core later delivers the full final reply array, deliver will see streamedTurnCount > 0, decrement it, and silently skip this turn. The user is left with a post showing truncated content, and the complete text for that turn is never sent.
The fix is to decrement streamedTurnCount in the catch block so deliver can fall back to normal re-delivery:
| streamedTurnCount++; | |
| // Wait for any in-flight patch to complete before finalizing. | |
| const deadline = Date.now() + 2000; | |
| while (patchSending && Date.now() < deadline) { | |
| await new Promise<void>((r) => setTimeout(r, 20)); | |
| } | |
| // Finalize the old streaming message with its complete text. | |
| if (!finalizeText) return; | |
| try { | |
| await updateMattermostPost(blockStreamingClient, finalizeId, { | |
| message: finalizeText, | |
| }); | |
| runtime.log?.(`stream-patch finalized turn ${finalizeId}`); | |
| } catch (err) { | |
| logVerboseMessage( | |
| `mattermost stream-patch turn finalize failed: ${String(err)}`, | |
| ); | |
| } | |
| // Wait for any in-flight patch to complete before finalizing. | |
| const deadline = Date.now() + 2000; | |
| while (patchSending && Date.now() < deadline) { | |
| await new Promise<void>((r) => setTimeout(r, 20)); | |
| } | |
| // Finalize the old streaming message with its complete text. | |
| if (!finalizeText) return; | |
| try { | |
| await updateMattermostPost(blockStreamingClient, finalizeId, { | |
| message: finalizeText, | |
| }); | |
| runtime.log?.(`stream-patch finalized turn ${finalizeId}`); | |
| } catch (err) { | |
| // Undo the increment so deliver() can re-deliver this turn's content | |
| // rather than silently skipping it after a failed finalization. | |
| streamedTurnCount--; | |
| logVerboseMessage( | |
| `mattermost stream-patch turn finalize failed: ${String(err)}`, | |
| ); | |
| } |
Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/mattermost/src/mattermost/monitor.ts
Line: 1947-1966
Comment:
**`streamedTurnCount` incremented before finalization can succeed**
`streamedTurnCount++` happens at line 1947, before the `updateMattermostPost` call at line 1958. If that network call throws (caught at line 1962), the count has already been incremented but the streaming post was never finalized with its complete text — it remains with whatever partial content was last emitted by the interval ticker.
When the core later delivers the full final reply array, `deliver` will see `streamedTurnCount > 0`, decrement it, and silently skip this turn. The user is left with a post showing truncated content, and the complete text for that turn is never sent.
The fix is to decrement `streamedTurnCount` in the catch block so `deliver` can fall back to normal re-delivery:
```suggestion
// Wait for any in-flight patch to complete before finalizing.
const deadline = Date.now() + 2000;
while (patchSending && Date.now() < deadline) {
await new Promise<void>((r) => setTimeout(r, 20));
}
// Finalize the old streaming message with its complete text.
if (!finalizeText) return;
try {
await updateMattermostPost(blockStreamingClient, finalizeId, {
message: finalizeText,
});
runtime.log?.(`stream-patch finalized turn ${finalizeId}`);
} catch (err) {
// Undo the increment so deliver() can re-deliver this turn's content
// rather than silently skipping it after a failed finalization.
streamedTurnCount--;
logVerboseMessage(
`mattermost stream-patch turn finalize failed: ${String(err)}`,
);
}
```
How can I resolve this? If you propose a fix, please make it concise.There was a problem hiding this comment.
Fixed in 913adeb — streamedTurnCount-- in the catch block.
extensions/mattermost/src/channel.ts
Outdated
| resolveAllowlistProviderRuntimeGroupPolicy, | ||
| resolveDefaultGroupPolicy, |
There was a problem hiding this comment.
Unused imports added
resolveAllowlistProviderRuntimeGroupPolicy and resolveDefaultGroupPolicy are imported here but never referenced anywhere in channel.ts (only appear in the import statement). These should be removed to avoid dead imports.
| resolveAllowlistProviderRuntimeGroupPolicy, | |
| resolveDefaultGroupPolicy, | |
| normalizeAccountId, | |
| setAccountEnabledInConfigSection, |
Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/mattermost/src/channel.ts
Line: 17-18
Comment:
**Unused imports added**
`resolveAllowlistProviderRuntimeGroupPolicy` and `resolveDefaultGroupPolicy` are imported here but never referenced anywhere in `channel.ts` (only appear in the import statement). These should be removed to avoid dead imports.
```suggestion
normalizeAccountId,
setAccountEnabledInConfigSection,
```
How can I resolve this? If you propose a fix, please make it concise.| streamedTurnCount++; | ||
|
|
||
| // Wait for any in-flight patch to complete before finalizing. | ||
| const deadline = Date.now() + 2000; | ||
| while (patchSending && Date.now() < deadline) { | ||
| await new Promise<void>((r) => setTimeout(r, 20)); | ||
| } | ||
|
|
||
| // Finalize the old streaming message with its complete text. | ||
| if (!finalizeText) return; | ||
| try { | ||
| await updateMattermostPost(blockStreamingClient, finalizeId, { | ||
| message: finalizeText, | ||
| }); | ||
| runtime.log?.(`stream-patch finalized turn ${finalizeId}`); | ||
| } catch (err) { | ||
| logVerboseMessage( | ||
| `mattermost stream-patch turn finalize failed: ${String(err)}`, | ||
| ); | ||
| } |
There was a problem hiding this comment.
streamedTurnCount incremented before confirming successful finalization
streamedTurnCount++ is called at line 1947 unconditionally, before the updateMattermostPost call that actually finalizes the turn. If that network call throws (caught at line 1962), the count is left incremented but the streaming post still contains only the last partial/in-flight text — not the complete turn text.
When deliver later runs for this turn (as part of the core's final reply array), it sees streamedTurnCount > 0 and skips re-delivery, so the complete turn text is silently dropped. The user ends up with a post showing intermediate partial content.
The count should only be incremented when finalization actually succeeds, or the catch block should decrement it to let deliver handle recovery:
// Finalize the old streaming message with its complete text.
if (!finalizeText) return;
try {
await updateMattermostPost(blockStreamingClient, finalizeId, {
message: finalizeText,
});
runtime.log?.(`stream-patch finalized turn ${finalizeId}`);
} catch (err) {
logVerboseMessage(
`mattermost stream-patch turn finalize failed: ${String(err)}`,
);
// Undo the count so deliver() can re-deliver this turn as a new post.
streamedTurnCount--;
}Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/mattermost/src/mattermost/monitor.ts
Line: 1947-1966
Comment:
**`streamedTurnCount` incremented before confirming successful finalization**
`streamedTurnCount++` is called at line 1947 unconditionally, before the `updateMattermostPost` call that actually finalizes the turn. If that network call throws (caught at line 1962), the count is left incremented but the streaming post still contains only the last partial/in-flight text — not the complete turn text.
When `deliver` later runs for this turn (as part of the core's final reply array), it sees `streamedTurnCount > 0` and skips re-delivery, so the complete turn text is silently dropped. The user ends up with a post showing intermediate partial content.
The count should only be incremented when finalization actually succeeds, or the `catch` block should decrement it to let `deliver` handle recovery:
```typescript
// Finalize the old streaming message with its complete text.
if (!finalizeText) return;
try {
await updateMattermostPost(blockStreamingClient, finalizeId, {
message: finalizeText,
});
runtime.log?.(`stream-patch finalized turn ${finalizeId}`);
} catch (err) {
logVerboseMessage(
`mattermost stream-patch turn finalize failed: ${String(err)}`,
);
// Undo the count so deliver() can re-deliver this turn as a new post.
streamedTurnCount--;
}
```
How can I resolve this? If you propose a fix, please make it concise.
extensions/mattermost/src/channel.ts
Outdated
| resolveAllowlistProviderRuntimeGroupPolicy, | ||
| resolveDefaultGroupPolicy, |
There was a problem hiding this comment.
Unused imports added to channel.ts
resolveAllowlistProviderRuntimeGroupPolicy and resolveDefaultGroupPolicy are imported here but never referenced anywhere in channel.ts. Both functions are already imported and used in monitor.ts (lines 27–28 and 702–704). These can be removed to avoid dead imports.
| resolveAllowlistProviderRuntimeGroupPolicy, | |
| resolveDefaultGroupPolicy, |
Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/mattermost/src/channel.ts
Line: 17-18
Comment:
**Unused imports added to `channel.ts`**
`resolveAllowlistProviderRuntimeGroupPolicy` and `resolveDefaultGroupPolicy` are imported here but never referenced anywhere in `channel.ts`. Both functions are already imported and used in `monitor.ts` (lines 27–28 and 702–704). These can be removed to avoid dead imports.
```suggestion
```
How can I resolve this? If you propose a fix, please make it concise.|
Pushed 913adeb addressing all review feedback:
|
Manual test results ✅Tested on a local OpenClaw dev instance with Mattermost. Test prompt: "Step 1: Write exactly 3 sentences introducing what OpenClaw is. Step 2: After writing those sentences, run Observed log sequence: Post A streamed for ~3s, remained visible. Post B appeared 1.5s later and streamed independently. Both posts persist. Single-turn regression: Works correctly — simple responses remain one post with streaming. Regarding the one failing CI check ( |
Live production test ✅Deployed to production (Mattermost + Claude Sonnet) and confirmed working:
Patch is stable on prod alongside P1 (replyToMode) and P4 (edit-in-place streaming base). |
|
@greptile-apps - pls re-review :) |
| export type MattermostAttachmentAction = { | ||
| id?: string; | ||
| name: string; | ||
| type?: "button" | "select"; | ||
| style?: "default" | "primary" | "success" | "danger"; | ||
| integration?: { | ||
| url: string; | ||
| context?: Record<string, unknown>; | ||
| }; | ||
| options?: Array<{ text: string; value: string }>; | ||
| }; | ||
|
|
||
| export type MattermostAttachment = { | ||
| fallback?: string; | ||
| color?: string; | ||
| pretext?: string; | ||
| text?: string; | ||
| title?: string; | ||
| actions?: MattermostAttachmentAction[]; | ||
| fields?: Array<{ short?: boolean; title: string; value: string }>; | ||
| }; |
There was a problem hiding this comment.
Duplicate MattermostAttachment type / unused exports
MattermostAttachment is already exported from interactions.ts (line 245), and MattermostAttachmentAction roughly mirrors MattermostButton from the same file. Neither of the two new types added here is imported or referenced anywhere in the files changed by this PR — channel.ts and monitor.ts only import createMattermostClient, deleteMattermostPost, normalizeMattermostBaseUrl, and updateMattermostPost from this module.
If these types are preparatory ground-work for an upcoming change, a comment to that effect would help reviewers understand why they're here. Otherwise, they risk confusing future contributors who stumble on two separate (and structurally different) MattermostAttachment shapes in the same package.
Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/mattermost/src/mattermost/client.ts
Line: 178-198
Comment:
**Duplicate `MattermostAttachment` type / unused exports**
`MattermostAttachment` is already exported from `interactions.ts` (line 245), and `MattermostAttachmentAction` roughly mirrors `MattermostButton` from the same file. Neither of the two new types added here is imported or referenced anywhere in the files changed by this PR — `channel.ts` and `monitor.ts` only import `createMattermostClient`, `deleteMattermostPost`, `normalizeMattermostBaseUrl`, and `updateMattermostPost` from this module.
If these types are preparatory ground-work for an upcoming change, a comment to that effect would help reviewers understand why they're here. Otherwise, they risk confusing future contributors who stumble on two separate (and structurally different) `MattermostAttachment` shapes in the same package.
How can I resolve this? If you propose a fix, please make it concise.There was a problem hiding this comment.
The MattermostAttachment and MattermostAttachmentAction types in client.ts are part of the base branch (#33506), not added by this PR. They appear as a diff here only because this branch is based on #33506. Once #33506 merges, this PR will be rebased onto main and the types will not appear in our diff. No change needed from our side.
| // Finalize the old streaming message with its complete text. | ||
| if (!finalizeText) return; |
There was a problem hiding this comment.
Misleading early-return guard after streamedTurnCount increment
if (!finalizeText) return is only reachable when streamMessageId was non-null (the earlier guard at line 1934 ensures that). Because streamMessageId is set exclusively inside schedulePatch's interval tick — which requires a non-empty pendingPatchText to proceed — and pendingPatchText is never cleared between interval ticks and onAssistantMessageStart, finalizeText will always be non-empty when this line is reached. The guard is dead code in practice.
The concern is that the pattern reads like:
streamedTurnCount++; // count is incremented …
if (!finalizeText) return; // … but we return without finalizing the post
which mirrors exactly the pre-fix footgun that was caught in previous review threads. A brief comment clarifying why the condition can never be true (or a console.assert/runtime assertion during development) would make the invariant explicit and prevent future developers from incorrectly reasoning that streamedTurnCount could be left inflated here.
Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/mattermost/src/mattermost/monitor.ts
Line: 1955-1956
Comment:
**Misleading early-return guard after `streamedTurnCount` increment**
`if (!finalizeText) return` is only reachable when `streamMessageId` was non-null (the earlier guard at line 1934 ensures that). Because `streamMessageId` is set exclusively inside `schedulePatch`'s interval tick — which requires a non-empty `pendingPatchText` to proceed — and `pendingPatchText` is never cleared between interval ticks and `onAssistantMessageStart`, `finalizeText` will always be non-empty when this line is reached. The guard is dead code in practice.
The concern is that the pattern reads like:
```
streamedTurnCount++; // count is incremented …
if (!finalizeText) return; // … but we return without finalizing the post
```
which mirrors exactly the pre-fix footgun that was caught in previous review threads. A brief comment clarifying why the condition can never be true (or a `console.assert`/runtime assertion during development) would make the invariant explicit and prevent future developers from incorrectly reasoning that `streamedTurnCount` could be left inflated here.
How can I resolve this? If you propose a fix, please make it concise.There was a problem hiding this comment.
Good catch — the ordering was misleading. Fixed in 43b14e2: streamedTurnCount++ is now moved after the if (!finalizeText) return guard, with a comment explaining the invariant. The guard itself is dead code in practice (the interval only fires when pendingPatchText is non-empty, and pendingPatchText is finalizeText), but having the increment appear before it created exactly the footgun pattern that was flagged in the earlier review round.
913adeb to
43b14e2
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 43b14e24f6
ℹ️ 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 (isFinal && streamMessageId && text) { | ||
| try { | ||
| await updateMattermostPost(blockStreamingClient!, streamMessageId, { message: text }); | ||
| runtime.log?.(`stream-patch final edit ${streamMessageId}`); |
There was a problem hiding this comment.
Avoid patching current stream with older re-delivered turn
If turn-finalization fails in onAssistantMessageStart, the catch block decrements streamedTurnCount so that older turn is re-delivered later; however this branch patches whatever streamMessageId is currently active. In a multi-turn run, that is typically the newest turn’s streaming post, so the re-delivered older turn can overwrite the latest in-progress message before its own final payload arrives, producing out-of-order/corrupted output on transient Mattermost API failures.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Good catch. Fixed in 0ce9120 — streamMessageId is now cleared before returning in the skip path. This ensures that if a turn finalization failed (catch block decremented the counter) and the turn reaches re-delivery via deliver(), it creates a new post rather than patching whatever streaming post is currently active for a newer turn.
| if (isFinal && streamedTurnCount > 0) { | ||
| streamedTurnCount--; | ||
| runtime.log?.( | ||
| `stream-patch skipping already-delivered turn (${streamedTurnCount} remaining)`, | ||
| ); | ||
| return; |
There was a problem hiding this comment.
Deliver attachments before skipping streamed final turns
This skip returns before any media handling, so previously streamed turns lose attachments whenever their final payload includes mediaUrls. The streaming path only patches text (onPartialReply ignores media), so in multi-turn responses where an earlier turn has media and a later tool call starts another turn, those attachments are dropped permanently instead of being sent as follow-up posts.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Valid edge case. onPartialReply only carries text (no mediaUrls), so streaming turns never include media. In practice, media in multi-turn responses is sent in the final turn only. However the skip path does silently drop media if it were present in an intermediate turn payload. Deferring this to a follow-up: the fix would add a media-only delivery before the return in the skip block, but the added complexity is out of scope for this PR. Opening a follow-up issue.
If onAssistantMessageStart's finalization fails, the catch block decrements streamedTurnCount so deliver() re-delivers that turn. Without clearing streamMessageId, the re-delivery would patch whatever streaming post is active for a newer turn, producing corrupted/out-of-order output on transient API failures. Clearing streamMessageId ensures the re-delivered turn creates a new post rather than overwriting the current stream. Addresses Codex P1 review feedback on PR openclaw#43041.
If onAssistantMessageStart's finalization fails, the catch block decrements streamedTurnCount so deliver() re-delivers that turn. Without clearing streamMessageId, the re-delivery would patch whatever streaming post is active for a newer turn, producing corrupted/out-of-order output on transient API failures. Clearing streamMessageId ensures the re-delivered turn creates a new post rather than overwriting the current stream. Addresses Codex P1 review feedback on PR openclaw#43041.
0ce9120 to
ab69d13
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ab69d136d8
ℹ️ 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".
| // and was re-queued via the catch-block decrement, a later re-delivery | ||
| // of this turn lands as a new post rather than patching whatever | ||
| // streaming post is currently active for a newer turn. | ||
| streamMessageId = null; |
There was a problem hiding this comment.
Preserve active stream ID while skipping finalized turns
The skip branch for previously streamed turns unconditionally sets streamMessageId = null, but this branch also runs while the newest turn is still streaming; in a normal multi-turn run, that clears the ID of the active latest post before its final payload is processed. As a result, the last final reply can no longer take the patchMattermostPost path and falls back to sending a new post, leaving the original streamed post orphaned/stale and producing duplicate output for the final turn.
Useful? React with 👍 / 👎.
| // instead of overwriting the previous one. See GH issue #43020. | ||
| onAssistantMessageStart: blockStreamingClient | ||
| ? async () => { | ||
| if (!streamMessageId) return; |
There was a problem hiding this comment.
Reset turn state even when no stream post exists yet
Returning early on if (!streamMessageId) misses assistant-turn boundaries whenever the prior turn ended before the 200ms ticker created a post (or while the first send is still in flight), which is common for short tool-call turns. In that case pendingPatchText/interval state carries into the next turn, so subsequent final delivery can patch the newer streamed post with an older turn’s text (out-of-order/corrupted turn mapping) instead of keeping turns isolated.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 3dc639d: onAssistantMessageStart now calls stopPatchInterval() and clears pendingPatchText/lastSentText unconditionally at the top, before the streamMessageId guard. Short tool-call turns where the 200ms ticker has not fired yet (no stream post) now get a proper boundary reset, preventing stale text/interval state from the previous turn carrying into the next.
|
@greptile review |
| // Clear streamMessageId so that if this turn's finalization had failed | ||
| // and was re-queued via the catch-block decrement, a later re-delivery | ||
| // of this turn lands as a new post rather than patching whatever | ||
| // streaming post is currently active for a newer turn. | ||
| streamMessageId = null; | ||
| return; |
There was a problem hiding this comment.
streamMessageId = null in skip block orphans the last turn's streaming post
In the happy path (all turn finalizations succeed), deliver is called for turns 1 through N in sequence. For a 3-turn response:
streamedTurnCount = 2andstreamMessageId = turn3Idwhendeliveris first called.deliver(turn 1)enters this skip block and setsstreamMessageId = null, clearingturn3Id.deliver(turn 2)skips similarly.deliver(turn 3)falls through withstreamMessageId = null, misses theisFinal && streamMessageId && payload.textpatch branch, and lands in normal delivery — creating a duplicate post for turn 3 while the actual streaming post is left with only the last-tick content.
The comment describes a failure-recovery scenario (finalization failed → count decremented → this turn falls through), but in that scenario streamedTurnCount would have already been decremented back to 0 before deliver runs, meaning this skip block would never even be entered for that turn. The clearing therefore does not protect against the described race and instead introduces a duplicate-post regression in the common case.
The fix is to remove the streamMessageId = null line from this block. The current active streaming post (for the latest turn) should remain valid until deliver for that turn explicitly patches or clears it.
| // Clear streamMessageId so that if this turn's finalization had failed | |
| // and was re-queued via the catch-block decrement, a later re-delivery | |
| // of this turn lands as a new post rather than patching whatever | |
| // streaming post is currently active for a newer turn. | |
| streamMessageId = null; | |
| return; | |
| if (isFinal && streamedTurnCount > 0) { | |
| streamedTurnCount--; | |
| runtime.log?.( | |
| `stream-patch skipping already-delivered turn (${streamedTurnCount} remaining)`, | |
| ); | |
| return; | |
| } |
Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/mattermost/src/mattermost/monitor.ts
Line: 1815-1820
Comment:
**`streamMessageId = null` in skip block orphans the last turn's streaming post**
In the happy path (all turn finalizations succeed), `deliver` is called for turns 1 through N in sequence. For a 3-turn response:
- `streamedTurnCount = 2` and `streamMessageId = turn3Id` when `deliver` is first called.
- `deliver(turn 1)` enters this skip block and sets `streamMessageId = null`, clearing `turn3Id`.
- `deliver(turn 2)` skips similarly.
- `deliver(turn 3)` falls through with `streamMessageId = null`, misses the `isFinal && streamMessageId && payload.text` patch branch, and lands in normal delivery — creating a **duplicate post** for turn 3 while the actual streaming post is left with only the last-tick content.
The comment describes a failure-recovery scenario (finalization failed → count decremented → this turn falls through), but in that scenario `streamedTurnCount` would have already been decremented back to 0 before `deliver` runs, meaning this skip block would never even be entered for that turn. The clearing therefore does not protect against the described race and instead introduces a duplicate-post regression in the common case.
The fix is to remove the `streamMessageId = null` line from this block. The current active streaming post (for the latest turn) should remain valid until `deliver` for that turn explicitly patches or clears it.
```suggestion
if (isFinal && streamedTurnCount > 0) {
streamedTurnCount--;
runtime.log?.(
`stream-patch skipping already-delivered turn (${streamedTurnCount} remaining)`,
);
return;
}
```
How can I resolve this? If you propose a fix, please make it concise.There was a problem hiding this comment.
Fixed in 3151cef: removed the streamMessageId = null line from the skip block.
Your analysis is correct — in the happy path, deliver(turn 1) and deliver(turn 2) both cleared the active stream ID, so deliver(turn 3) fell through with streamMessageId = null and landed in normal delivery, creating a duplicate post while the actual streaming post stayed with tick-level content.
The failure-recovery rationale in the comment was also wrong: when finalization fails, the catch block decrements streamedTurnCount back before deliver runs, so the skip block is never entered for that turn anyway. The clearing served no purpose and broke the common case.
| await updateMattermostPost(blockStreamingClient, finalizeId, { | ||
| message: finalizeText, | ||
| }); |
There was a problem hiding this comment.
updateMattermostPost vs patchMattermostPost inconsistency
onAssistantMessageStart uses updateMattermostPost (which sends a PUT /posts/{id} with {id, message}) to finalize a turn, while the rest of the streaming path (schedulePatch, flushPendingPatch, and the final-delivery branch in deliver) consistently use the newly-added patchMattermostPost (PUT /posts/{id}/patch with just {message}).
The Mattermost "update post" endpoint is a full-replace: if the post has attachments, file_ids, or custom props, sending only {id, message} may silently strip them. The /patch endpoint is specifically designed for partial updates and only touches the fields you supply. Using patchMattermostPost here would be consistent with the rest of the streaming code and safer:
| await updateMattermostPost(blockStreamingClient, finalizeId, { | |
| message: finalizeText, | |
| }); | |
| await patchMattermostPost(blockStreamingClient, { | |
| postId: finalizeId, | |
| message: finalizeText, | |
| }); |
Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/mattermost/src/mattermost/monitor.ts
Line: 1955-1957
Comment:
**`updateMattermostPost` vs `patchMattermostPost` inconsistency**
`onAssistantMessageStart` uses `updateMattermostPost` (which sends a `PUT /posts/{id}` with `{id, message}`) to finalize a turn, while the rest of the streaming path (`schedulePatch`, `flushPendingPatch`, and the final-delivery branch in `deliver`) consistently use the newly-added `patchMattermostPost` (`PUT /posts/{id}/patch` with just `{message}`).
The Mattermost "update post" endpoint is a full-replace: if the post has attachments, `file_ids`, or custom `props`, sending only `{id, message}` may silently strip them. The `/patch` endpoint is specifically designed for partial updates and only touches the fields you supply. Using `patchMattermostPost` here would be consistent with the rest of the streaming code and safer:
```suggestion
await patchMattermostPost(blockStreamingClient, {
postId: finalizeId,
message: finalizeText,
});
```
How can I resolve this? If you propose a fix, please make it concise.There was a problem hiding this comment.
Fixed in 3151cef: switched to patchMattermostPost in onAssistantMessageStart.
Good catch on the API semantics — the PUT update endpoint does a full replace and would silently strip file_ids or custom props. The PATCH endpoint is the correct partial-update path and is now used consistently across all streaming code paths (schedulePatch, flushPendingPatch, deliver final edit, and onAssistantMessageStart turn finalization).
…tPost for turn finalization - Remove streamMessageId = null from the skip block in deliver(): clearing the active stream ID here caused the last turn's streaming post to be orphaned in the happy path. For a 3-turn response, deliver(turn 1) and deliver(turn 2) both cleared streamMessageId, so deliver(turn 3) fell through with null and created a duplicate post instead of patching the existing streaming post. The original rationale (recovery when finalization fails) doesn't hold: if the catch block decremented streamedTurnCount back to 0, the skip block is never entered for that turn anyway. - Replace updateMattermostPost with patchMattermostPost in onAssistantMessageStart: the update endpoint does a full POST replace and could silently strip file_ids or custom props. The patch endpoint is the correct partial-update path and consistent with the rest of the streaming code. Addresses greptile review comments on openclaw#43041 (2026-03-14 round).
Round 3 (2026-03-14 Greptile review) — addressed in 3151cefFix 1 — Removed the clearing. In the happy path for a 3-turn response, Fix 2 — Switched to |
If onAssistantMessageStart's finalization fails, the catch block decrements streamedTurnCount so deliver() re-delivers that turn. Without clearing streamMessageId, the re-delivery would patch whatever streaming post is active for a newer turn, producing corrupted/out-of-order output on transient API failures. Clearing streamMessageId ensures the re-delivered turn creates a new post rather than overwriting the current stream. Addresses Codex P1 review feedback on PR openclaw#43041.
…tPost for turn finalization - Remove streamMessageId = null from the skip block in deliver(): clearing the active stream ID here caused the last turn's streaming post to be orphaned in the happy path. For a 3-turn response, deliver(turn 1) and deliver(turn 2) both cleared streamMessageId, so deliver(turn 3) fell through with null and created a duplicate post instead of patching the existing streaming post. The original rationale (recovery when finalization fails) doesn't hold: if the catch block decremented streamedTurnCount back to 0, the skip block is never entered for that turn anyway. - Replace updateMattermostPost with patchMattermostPost in onAssistantMessageStart: the update endpoint does a full POST replace and could silently strip file_ids or custom props. The patch endpoint is the correct partial-update path and consistent with the rest of the streaming code. Addresses greptile review comments on openclaw#43041 (2026-03-14 round).
3151cef to
f86aa5b
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f86aa5bbe9
ℹ️ 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".
|
|
||
| // Final + streaming active: patch the streamed message with authoritative | ||
| // complete text, or fall back to a new message (with orphan cleanup). | ||
| if (isFinal && streamMessageId && payload.text) { |
There was a problem hiding this comment.
Deliver media payloads when finalizing streamed replies
The final-streaming fast path edits the existing post and returns whenever payload.text is present, but it never forwards payload.mediaUrl(s). For any final reply that contains both text and media (a supported ReplyPayload shape used by deliverMattermostReplyPayload), the caption gets patched while all attachments are silently dropped. This regression only appears when edit-in-place streaming is enabled, and it causes user-visible data loss.
Useful? React with 👍 / 👎.
If onAssistantMessageStart's finalization fails, the catch block decrements streamedTurnCount so deliver() re-delivers that turn. Without clearing streamMessageId, the re-delivery would patch whatever streaming post is active for a newer turn, producing corrupted/out-of-order output on transient API failures. Clearing streamMessageId ensures the re-delivered turn creates a new post rather than overwriting the current stream. Addresses Codex P1 review feedback on PR openclaw#43041.
…tPost for turn finalization - Remove streamMessageId = null from the skip block in deliver(): clearing the active stream ID here caused the last turn's streaming post to be orphaned in the happy path. For a 3-turn response, deliver(turn 1) and deliver(turn 2) both cleared streamMessageId, so deliver(turn 3) fell through with null and created a duplicate post instead of patching the existing streaming post. The original rationale (recovery when finalization fails) doesn't hold: if the catch block decremented streamedTurnCount back to 0, the skip block is never entered for that turn anyway. - Replace updateMattermostPost with patchMattermostPost in onAssistantMessageStart: the update endpoint does a full POST replace and could silently strip file_ids or custom props. The patch endpoint is the correct partial-update path and consistent with the rest of the streaming code. Addresses greptile review comments on openclaw#43041 (2026-03-14 round).
f86aa5b to
a37fc22
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a37fc22752
ℹ️ 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".
| deliver: async (payload: ReplyPayload, info) => { | ||
| const isFinal = info.kind === "final"; |
There was a problem hiding this comment.
Clear streamed previews when the final payload is skipped
This cleanup only runs inside deliver(), but createReplyDispatcher() never calls deliver() when the final payload normalizes to silent, heartbeat, or empty (src/auto-reply/reply/normalize-reply.ts). If onPartialReply has already created or updated a Mattermost preview, those skip paths leave patchInterval alive and the partial post orphaned in-channel after withReplyDispatcher() completes. Adding an onSkip cleanup here would keep suppressed finals from leaking streamed drafts.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in 1ef86dc: added an onSettled hook that runs regardless of whether deliver() was called. If a streamed preview post exists when the dispatcher exits (silent/heartbeat/empty response), stopPatchInterval() is called and the orphaned post is deleted best-effort.
…t; apply textLimit to turn finalization Two fixes from latest Codex review on openclaw#43041: 1. Turn-state reset on boundary without stream post: onAssistantMessageStart now always calls stopPatchInterval(), clears pendingPatchText and lastSentText at the top of the callback, before the streamMessageId guard. Short tool-call turns where the 200ms ticker has not yet fired (streamMessageId is null) no longer carry stale text/interval state into the next turn. 2. textLimit in turn finalization: onAssistantMessageStart now truncates finalizeText to textLimit before the patchMattermostPost call, consistent with schedulePatch and flushPendingPatch. For turns longer than the server cap the patch would otherwise fail, leaving the post stuck at whatever partial the interval last wrote. deliver() still sends the full authoritative text.
Critical ordering bug introduced in the previous commit: pendingPatchText was cleared at the top of onAssistantMessageStart (before the streamMessageId guard) and then read into finalizeText further down — always yielding an empty string. This caused the !finalizeText guard to always fire, so onAssistantMessageStart never actually finalized any turn and streamedTurnCount was never incremented, breaking the entire multi-turn streaming mechanic. Fix: capture finalizeId and finalizeText as the very first operations in the callback, before any state resets. Then reset streamMessageId, pendingPatchText, and lastSentText. The patchSending flag is still intentionally kept until after the wait-loop.
When onAssistantMessageStart fires while the very first sendMessageMattermost for the previous turn is still in flight (streamMessageId is null, patchSending may be true), the send can resolve after the boundary reset and populate streamMessageId with the old turn's post. The next turn's schedulePatch would then patch that old post with new turn text, corrupting turn ordering. Fix: after sendMessageMattermost resolves, check whether pendingPatchText is still non-empty. If it was cleared by onAssistantMessageStart during the in-flight send, the result is stale — the post is deleted best-effort and streamMessageId stays null so the next turn creates a fresh post.
…let deliver() chunk it When a streamed turn's text exceeds textLimit, patching with a truncated version and incrementing streamedTurnCount causes deliver() to skip that turn entirely — the remainder beyond the first chunk is silently dropped and never sent. Fix: when finalizeText.length > textLimit, skip the finalization patch entirely and do NOT increment streamedTurnCount. The preview post is deleted best-effort so it does not appear alongside the normal chunked delivery. deliver() then runs the full turn through deliverMattermostReplyPayload's normal multi-chunk path, which correctly handles arbitrarily long turn text.
… preview delete Two fixes: 1. Turn-sequence guard (Codex ID=2964159512): replace the pendingPatchText-based guard with a monotonic currentTurnSeq counter. onAssistantMessageStart increments it before clearing state; schedulePatch captures the value before sendMessageMattermost and compares after. A mismatch means a boundary fired during the POST — the result is discarded regardless of what pendingPatchText contains (which turn B may have already written into). 2. Deferred over-limit preview delete (Codex ID=2964159518): instead of deleting the preview post immediately when text exceeds textLimit, wait 5s before deleting. This gives deliverMattermostReplyPayload time to successfully deliver the chunked replacement first. Mirrors the send-first/delete-after pattern used by the other fallback paths.
…onSettled guard Three fixes: 1. patchInflight promise tracking (Codex ID=2964355554): replace busy-wait loops on patchSending with direct await of the in-flight promise (patchInflight). The busy- wait had a race where patchSending could clear before the underlying network request settled, allowing a late-resolving PATCH to overwrite the finalised authoritative text. flushPendingPatch, onAssistantMessageStart, and the onSettled wait-loop all now await patchInflight directly. 2. patchInterval in onSettled guard (Codex ID=2964355558): mirrors the P4 fix — onSettled cleanup now triggers when patchInterval is non-null, covering the window between schedulePatch arming the interval and the first tick flipping patchSending. 3. Also carries forward the P4 previewSendFailed latch concept via patchInflight being cleared on failure, preventing spurious re-arms.
…964617542) The isFinal branch patched payload.text directly after convertMarkdownTables without applying textLimit. On accounts with a lower textChunkLimit (e.g. 1000 chars), the final streamed turn would be posted as one oversized message instead of going through deliverMattermostReplyPayload's multi-chunk path. Fix: apply the same textLimit cap used by the schedulePatch interval ticks. If the final text exceeds the limit, the patch truncates it and the fallback deliver() path handles the full text via normal chunked delivery.
…l streamed turns The previous fix applied textLimit as a slice to the final patch, which silently dropped everything after the first chunk. Unlike the onAssistantMessageStart path, there is no separate deliver() call to re-send the full text, so any final streamed turn longer than textLimit would lose its tail. Fix: when rawFinalText.length > textLimit, skip the patch entirely, reset streaming state, deliver the full payload via deliverMattermostReplyPayload (which handles chunking), and then delete the preview post best-effort after successful delivery. Mirrors the over-limit pre-turn path in onAssistantMessageStart. (ID=2965024964)
Two fixes: 1. Media for skipped turns (ID=2965091869): when streamedTurnCount > 0 skips a turn, still check for media attachments (mediaUrls/mediaUrl) and deliver them via deliverMattermostReplyPayload with text=undefined. Covers TTS audio and file attachments on pre-tool-call turns. 2. Reliable orphan delete (ID=2965091873): replace setTimeout(5000) with a pendingOrphanDeletes Set. onAssistantMessageStart adds the finalizeId when text exceeds textLimit; the normal deliver() path deletes them after the authoritative deliverMattermostReplyPayload call succeeds. Removes the race where a 5s timeout could delete the preview before tool calls finish.
…65255734) Replace pendingOrphanDeletes Set with a FIFO array. onAssistantMessageStart pushes the preview ID when finalizeText exceeds textLimit; deliver() shifts one entry per successful delivery. This ensures turn A's redelivery only deletes turn A's preview, not all queued previews — previously, the first successful deliver() call in a multi- tool-call run would clear the entire set and delete previews for turns not yet redelivered, leaving those turns with no visible output if their resend failed.
…dFailed per turn Two fixes: 1. Orphan queue shift gating (ID=2965341948): pendingOrphanDeletes.shift() was unconditional, so tool-summary and block payloads (which also trigger deliver()) could consume queue entries and delete over-limit previews before their own final payload arrived. Now only shifts on isFinal payloads. 2. Reset previewSendFailed per turn (ID=2965341951): the latch was never cleared at turn boundaries, so a transient 429/500 during turn A would permanently disable streaming for turns B, C, etc. for the rest of the run. Now reset in onAssistantMessageStart alongside currentTurnSeq++.
…n on patch failure Two fixes: 1. Caption for skipped-turn media (ID=2966838451): the streamedTurnCount skip path was passing text:undefined to deliverMattermostReplyPayload, dropping the text caption on media attachments. The payload now passes through unchanged so the caption is sent alongside the media (consistent with the last-turn fast path). 2. Orphan cleanup on patch failure (ID=2966838455): when onAssistantMessageStart's patchMattermostPost throws, finalizeId is now pushed to pendingOrphanDeletes. deliver() will then delete the stale preview post after the authoritative re- delivery succeeds. Without this the failed preview remained in-channel as a duplicate alongside the re-delivered reply.
…ew before skipped-turn media re-send Two fixes: 1. Orphan queue shift guarded by delivery success (ID=2967028345): wrap deliverMattermostReplyPayload in try/finally and only shift from pendingOrphanDeletes when delivery confirmed succeeded. A failed resend no longer consumes the queue entry, so the next successful turn's delivery cannot delete the wrong turn's preview. 2. Delete preview before skipped-turn media re-delivery (ID=2967028353): when a skipped turn (streamedTurnCount > 0) has media, the old code delivered payload (text+media) alongside the existing preview, creating two visible posts with the same caption. Now the queued orphan (if any) is deleted first, then the full payload is delivered as a single post.
…n onSettled Two fixes: 1. Stale send failure cross-turn latch (ID=2969630961): previewSendFailed is now only set in the initial-send catch when currentTurnSeq === sendTurnSeq. If a turn boundary fired while the send was in flight, the failure belongs to the abandoned previous turn and must not stop streaming for the new turn. 2. Drain pendingOrphanDeletes in onSettled (ID=2969630963): when a run settles without delivering the queued previews (aborted run, no-final path), the onSettled cleanup now drains pendingOrphanDeletes and deletes any remaining stale partial preview posts best-effort.
…elivery Two fixes: 1. Wrong orphan deleted in skipped-turn media branch (ID=2969665550/2969665547): add finalizedPreviewIds FIFO queue. onAssistantMessageStart pushes finalizeId after a successful patchMattermostPost. The skipped-turn media branch shifts from finalizedPreviewIds (not pendingOrphanDeletes) to get the preview ID for the specific turn being re-delivered. This prevents turn B from deleting turn A's failed-delivery orphan, and ensures the correct preview is deleted when a finalized turn later includes media. 2. Drain finalizedPreviewIds in onSettled: mirrors the existing pendingOrphanDeletes cleanup — any leftover finalized preview IDs are deleted best-effort when the run settles without delivering them.
…turn previews - Text-only skipped turns now shift() from finalizedPreviewIds to keep queue head aligned with the correct turn (ID=2969701179) - Media re-delivery uses deliver-first/delete-after-success ordering so a failed upload never silently drops the only visible copy (ID=2969701180) - onSettled no longer drains finalizedPreviewIds — those IDs represent already-delivered content, not orphan drafts (ID=2969701182)
… errors Full pnpm check hits pre-existing type errors in unrelated extensions. Scoped preflight: format + lint boundaries + mattermost tests + build smoke.
…urn-A completions Capture flushTurnSeq = currentTurnSeq at the start of flushPendingPatch. After the async send/patch resolves, only write lastSentText (and streamMessageId for the send path) when currentTurnSeq still matches the captured value. A boundary that fired while the flush was in flight increments currentTurnSeq, so the stale result is discarded instead of poisoning turn B's dedup guard (ID=2970547761).
preflight.yml belongs on fork/main only — it is our internal CI gate, not upstream code. Accidentally committed during CI debugging (2026-03-22).
…als (ID=2991100636) Co-Authored-By: Claude Sonnet 4 <[email protected]>
…Text (ID=2991100644) Co-Authored-By: Claude Sonnet 4 <[email protected]>
…nconditionally Replace scalar streamedTurnCount with a Set<number> (streamedTurnSeqs) keyed by turn sequence number so out-of-order turn finalization is handled correctly. deliver() now uses per-turn identity matching instead of decrementing a counter. Add fallback pendingOrphanDeletes drain in onSettled that fires regardless of active stream state, ensuring orphans queued after boundary finalization are always cleaned up. 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: be92e681e3
ℹ️ 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".
| // schedulePatch captures this value when it starts a send; if the | ||
| // value has changed by the time the POST resolves, the result belongs | ||
| // to the previous turn and must be discarded / the post deleted. | ||
| currentTurnSeq++; |
There was a problem hiding this comment.
Do not advance turn sequence before first streamed turn
onAssistantMessageStart increments currentTurnSeq unconditionally before checking whether there is a turn to finalize. Because the callback is fired on every assistant message_start (including the first message, where no prior stream exists), the first real finalized turn is recorded as sequence 1, while deliver() numbers finals from 0. In multi-turn runs this misaligns streamedTurnSeqs, so turn A can be re-delivered and turn B can be incorrectly skipped as “already streamed.”
Useful? React with 👍 / 👎.
| if (pendingOrphanDeletes.length > 0 && blockStreamingClient) { | ||
| const orphansToDelete = [...pendingOrphanDeletes.splice(0)]; | ||
| for (const id of orphansToDelete) { | ||
| void deleteMattermostPost(blockStreamingClient, id).catch(() => {}); |
There was a problem hiding this comment.
Keep orphan preview when final resend fails
This settle-time cleanup deletes all queued pendingOrphanDeletes even when final re-delivery failed. The deliver() path correctly avoids dequeuing on failure (deliverySucceeded guard), but onSettled then force-deletes the same queued previews, so a transient send/upload failure removes the only visible copy of that turn. This turns a recoverable resend failure into message loss.
Useful? React with 👍 / 👎.
|
abandoning — we have migrated from Mattermost to Matrix and are no longer maintaining Mattermost patches. Thanks for the reviews. maybe someone wants to take it from here. |
Problem
When edit-in-place streaming is active (introduced in #33506), multi-turn responses (agent makes tool calls between text segments) show unexpected behavior:
Expected: Each text segment streams independently in its own Mattermost post. When a turn ends (tool call starts), that post stays and the next turn creates a fresh streaming post.
Root Cause
disableBlockStreaming: true(needed to prevent the core from sending duplicate block messages alongside ouronPartialReplystreaming) also preventscreateBlockReplyDeliveryHandlerfrom delivering block replies atmessage_endboundaries between tool calls.Meanwhile,
onPartialReplyreceives each turn's text independently (the text resets between tool calls), so the single streaming post keeps changing content. The core accumulates all turns inassistantTexts[]and delivers them all as final replies when the model run completes.Fix
Use the
onAssistantMessageStartcallback to finalize the current streaming message when a new assistant turn starts (after a tool call). Each turn gets its own streamed post.A counter (
streamedTurnCount) tracks how many turns were already delivered via streaming, so thedelivercallback skips re-delivery when the core sends the full final reply array.Flow for a 3-turn response:
Race condition handling:
State is reset synchronously before any async finalization work in
onAssistantMessageStart. NewonPartialReplycalls immediately seestreamMessageId = nulland create a fresh post, preventing cross-turn interference.Changes
extensions/mattermost/src/mattermost/monitor.ts: 55 lines addedstreamedTurnCountvariable to track already-delivered turnsonAssistantMessageStartcallback to flush + reset streaming state at turn boundariesdeliverfor already-streamed final repliesHow to test
blockStreaming: falseopt-out should still disable streaming entirelyRelated