Skip to content

fix(mattermost): stream each turn independently in multi-tool-call responses#43041

Open
teconomix wants to merge 54 commits intoopenclaw:mainfrom
teconomix:fix/mattermost-multi-turn-streaming
Open

fix(mattermost): stream each turn independently in multi-tool-call responses#43041
teconomix wants to merge 54 commits intoopenclaw:mainfrom
teconomix:fix/mattermost-multi-turn-streaming

Conversation

@teconomix
Copy link
Copy Markdown
Contributor

Problem

When edit-in-place streaming is active (introduced in #33506), multi-turn responses (agent makes tool calls between text segments) show unexpected behavior:

  1. A single streaming message appears and gets edited in-place ✓
  2. When a tool call happens and the next text segment starts, the same streaming message content is replaced with the new segment's text
  3. Only when the entire model run completes do all messages appear at once

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 our onPartialReply streaming) also prevents createBlockReplyDeliveryHandler from delivering block replies at message_end boundaries between tool calls.

Meanwhile, onPartialReply receives each turn's text independently (the text resets between tool calls), so the single streaming post keeps changing content. The core accumulates all turns in assistantTexts[] and delivers them all as final replies when the model run completes.

Fix

Use the onAssistantMessageStart callback 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 the deliver callback skips re-delivery when the core sends the full final reply array.

Flow for a 3-turn response:

Turn 1: streams in post A → finalized when turn 2 starts
Turn 2: streams in post B → finalized when turn 3 starts  
Turn 3: streams in post C → patched with final text on completion

Race condition handling:

State is reset synchronously before any async finalization work in onAssistantMessageStart. New onPartialReply calls immediately see streamMessageId = null and create a fresh post, preventing cross-turn interference.

Changes

  • extensions/mattermost/src/mattermost/monitor.ts: 55 lines added
    • streamedTurnCount variable to track already-delivered turns
    • onAssistantMessageStart callback to flush + reset streaming state at turn boundaries
    • Skip logic in deliver for already-streamed final replies

How to test

  1. Send a request that triggers multiple tool calls (e.g., code analysis requiring file reads)
  2. Each text segment should stream in its own post, staying visible after the tool call
  3. Single-turn responses (no tool calls) should work exactly as before
  4. blockStreaming: false opt-out should still disable streaming entirely

Related

@openclaw-barnacle openclaw-barnacle bot added channel: mattermost Channel integration: mattermost size: M labels Mar 11, 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: 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++;
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 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 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch. Fixed in 913adebstreamedTurnCount 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;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 11, 2026

Greptile Summary

This 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 onAssistantMessageStart to finalize the current streaming post at each tool-call boundary, along with a currentTurnSeq / streamedTurnSeqs tracking mechanism that prevents the core's final reply array from re-delivering turns that were already streamed.

Key changes:

  • client.ts: Adds patchMattermostPost (PUT /posts/{id}/patch — correct partial-update endpoint) and deleteMattermostPost for orphan cleanup.
  • monitor.ts: Introduces turn-sequence tracking (currentTurnSeq, scheduledTurnSeq, streamedTurnSeqs, deliveryTurnSeq, finalizedPreviewIds, pendingOrphanDeletes) with careful synchronous-before-async state resets on each turn boundary to prevent cross-turn corruption. Handles over-limit text, media payloads, divergent reply threads, and onSettled cleanup for aborted runs.
  • Previous review rounds addressed the streamedTurnCount++ before-success ordering, streamMessageId = null in the skip block orphaning the last turn's post, and updateMattermostPost vs patchMattermostPost inconsistency — all resolved.
  • One minor leftover: fetchMattermostUserTeams is imported in monitor.ts but never used.

Confidence Score: 5/5

  • Safe to merge — all previously identified P1 bugs have been addressed and the only remaining finding is a dead import.
  • Multiple review rounds have resolved all correctness issues (increment ordering, orphan-post cleanup, API consistency). The streaming state machine is well-guarded and thoroughly commented. The sole remaining finding (unused fetchMattermostUserTeams import) is a P2 style nit that does not affect runtime behavior.
  • No files require special attention.
Prompt To Fix All With AI
This 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

Comment on lines +1947 to +1966
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)}`,
);
}
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.

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:

Suggested change
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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 913adebstreamedTurnCount-- in the catch block.

Comment on lines +17 to +18
resolveAllowlistProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
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.

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.

Suggested change
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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Removed in 913adeb.

Comment on lines +1947 to +1966
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)}`,
);
}
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.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 913adeb — same as above.

Comment on lines +17 to +18
resolveAllowlistProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
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.

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.

Suggested change
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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Removed in 913adeb.

@teconomix
Copy link
Copy Markdown
Contributor Author

Pushed 913adeb addressing all review feedback:

  • streamedTurnCount race (Codex P1 + Greptile x2): decremented in catch block so deliver() falls back to normal re-delivery when finalization fails
  • Unused imports (Greptile x2): removed resolveAllowlistProviderRuntimeGroupPolicy and resolveDefaultGroupPolicy from channel.ts
  • streamReplyToId / replyToId (Codex P2): no change needed — inherited from feat(mattermost): block streaming edit-in-place (rebased) #33506, deliberately uses threadRootId only (see code comment at line 1648-1651)

@teconomix
Copy link
Copy Markdown
Contributor Author

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 ls /data/workspace using exec and show the output. Step 3: Write 2 sentences summarizing what you found."

Observed log sequence:

stream-patch started   Post A
stream-patch edited    Post A  × 7  (live streaming)
stream-patch finalized turn Post A  ← stays visible
stream-patch started   Post B  (1.5s later)
stream-patch edited    Post B  × 5  (live streaming)
stream-patch skipping already-delivered turn  ← skip logic works
stream-patch final edit Post B

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 (checks-windows (node, test, 4, 6)): this is an infrastructure flake — the same shard passed on the first commit (12b0eee) and on the base branch. All 27 other checks are green. The two commits in this PR only touch monitor.ts (turn-boundary streaming logic) and channel.ts (unused import removal) — neither affects Windows-specific test paths.

@teconomix
Copy link
Copy Markdown
Contributor Author

Live production test ✅

Deployed to production (Mattermost + Claude Sonnet) and confirmed working:

  • Multi-turn responses (text → tool call → text) now produce separate streaming posts, each appearing and streaming independently
  • Single-turn responses unchanged — one post, streaming as before
  • No flickering, no batching at the end

Patch is stable on prod alongside P1 (replyToMode) and P4 (edit-in-place streaming base).

@teconomix
Copy link
Copy Markdown
Contributor Author

@greptile-apps - pls re-review :)

Comment on lines +178 to +198
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 }>;
};
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.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Comment on lines +1955 to +1956
// Finalize the old streaming message with its complete text.
if (!finalizeText) return;
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.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

@teconomix teconomix force-pushed the fix/mattermost-multi-turn-streaming branch from 913adeb to 43b14e2 Compare March 11, 2026 19:08
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: 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".

Comment on lines +1794 to +1797
if (isFinal && streamMessageId && text) {
try {
await updateMattermostPost(blockStreamingClient!, streamMessageId, { message: text });
runtime.log?.(`stream-patch final edit ${streamMessageId}`);
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 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 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch. Fixed in 0ce9120streamMessageId 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.

Comment on lines +1769 to +1774
if (isFinal && streamedTurnCount > 0) {
streamedTurnCount--;
runtime.log?.(
`stream-patch skipping already-delivered turn (${streamedTurnCount} remaining)`,
);
return;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

teconomix added a commit to teconomix/openclaw that referenced this pull request Mar 11, 2026
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.
teconomix added a commit to teconomix/openclaw that referenced this pull request Mar 14, 2026
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.
@teconomix teconomix force-pushed the fix/mattermost-multi-turn-streaming branch from 0ce9120 to ab69d13 Compare March 14, 2026 06:54
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: 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;
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 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;
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 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 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

@teconomix
Copy link
Copy Markdown
Contributor Author

@greptile review

Comment on lines +1815 to +1820
// 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;
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.

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.

Suggested change
// 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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Comment on lines +1955 to +1957
await updateMattermostPost(blockStreamingClient, finalizeId, {
message: finalizeText,
});
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.

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:

Suggested change
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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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).

teconomix added a commit to teconomix/openclaw that referenced this pull request Mar 15, 2026
…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).
@teconomix
Copy link
Copy Markdown
Contributor Author

Round 3 (2026-03-14 Greptile review) — addressed in 3151cef

Fix 1 — streamMessageId = null in skip block (#2934875540)

Removed the clearing. In the happy path for a 3-turn response, deliver(turn 1) and deliver(turn 2) both nulled the active stream ID, causing deliver(turn 3) to miss the isFinal && streamMessageId patch branch and create a duplicate post. The failure-recovery rationale was also incorrect: if finalization fails and the catch block decrements streamedTurnCount, the skip block is never entered for that turn — so the clearing had no effect in the one scenario it was meant to protect.

Fix 2 — updateMattermostPostpatchMattermostPost in onAssistantMessageStart (#2934875560)

Switched to patchMattermostPost (PUT /posts/{id}/patch). Consistent with schedulePatch, flushPendingPatch, and the final-delivery branch. Avoids the silent field-stripping risk of the full-replace endpoint.

teconomix added a commit to teconomix/openclaw that referenced this pull request Mar 17, 2026
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.
teconomix added a commit to teconomix/openclaw that referenced this pull request Mar 17, 2026
…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).
@teconomix teconomix force-pushed the fix/mattermost-multi-turn-streaming branch from 3151cef to f86aa5b Compare March 17, 2026 20:34
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: 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) {
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 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 👍 / 👎.

teconomix added a commit to teconomix/openclaw that referenced this pull request Mar 18, 2026
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.
teconomix added a commit to teconomix/openclaw that referenced this pull request Mar 18, 2026
…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).
@teconomix teconomix force-pushed the fix/mattermost-multi-turn-streaming branch from f86aa5b to a37fc22 Compare March 18, 2026 10:23
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: 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".

Comment on lines +1853 to +1854
deliver: async (payload: ReplyPayload, info) => {
const isFinal = info.kind === "final";
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

teconomix and others added 28 commits March 31, 2026 06:51
…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).
…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]>
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: 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++;
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 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 👍 / 👎.

Comment on lines +1967 to +1970
if (pendingOrphanDeletes.length > 0 && blockStreamingClient) {
const orphansToDelete = [...pendingOrphanDeletes.splice(0)];
for (const id of orphansToDelete) {
void deleteMattermostPost(blockStreamingClient, id).catch(() => {});
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 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 👍 / 👎.

@teconomix
Copy link
Copy Markdown
Contributor Author

teconomix commented Apr 1, 2026

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.

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

Labels

channel: mattermost Channel integration: mattermost size: L

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Mattermost: multi-turn responses batch all messages at end when edit-in-place streaming is active

1 participant