openclaw - ✅(Solved) Fix fix(mattermost): draft preview overwrites previous content at every transition (data loss) [1 pull requests, 1 comments, 2 participants]

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…
GitHub stats
openclaw/openclaw#75252Fetched 2026-05-01 05:36:11
View on GitHub
Comments
1
Participants
2
Timeline
2
Reactions
2
Timeline (top)
commented ×1cross-referenced ×1

The Mattermost streaming draft preview (introduced in #47838 / b38988c) overwrites the visible message at every phase transition, causing previously-shown content to disappear when the agent transitions between thinking, partial replies, and tool calls. Users see content like "Thinking…" → partial reply text → "Running exec…" → next partial reply → final answer all happening on a single edited post, with each new state completely replacing the prior one.

This is functionally equivalent to deleting messages the user has already read.

The Discord adapter solves the same problem with a discordStreamMode === "block" config that splits previews at boundaries (calls draftStream.forceNewMessage() so a NEW post is created at each transition). The Mattermost adapter has the forceNewMessage() API on its draft stream but never calls it, and has no equivalent streamMode config.

Root Cause

In extensions/mattermost/src/mattermost/monitor.ts (around line 1790), the draft preview hooks call draftStream.update(...) at every transition, which routes through sendOrEditStreamMessage in draft-stream.ts. That function is structured to edit the existing streamPostId if one exists, otherwise create a new post. Because streamPostId is only reset by forceNewMessage() (or clear() / discardPending() for cleanup), and forceNewMessage() is never invoked, every update call after the first one is a PUT /posts/{id} that overwrites the previous content.

// extensions/mattermost/src/mattermost/monitor.ts (~line 1790)
onPartialReply: (payload) => {
  updateDraftFromPartial(payload.text);   // edits same post
},
onAssistantMessageStart: () => {
  lastPartialText = "";                   // does NOT split the preview
},
onReasoningEnd: () => {
  lastPartialText = "";                   // does NOT split the preview
},
onReasoningStream: async () => {
  if (!lastPartialText) {
    draftStream.update("Thinking…");      // edits same post
  }
},
onToolStart: async (payload) => {
  draftStream.update(buildMattermostToolStatusText(payload));  // edits same post
},

Compare with Discord's handling in extensions/discord/src/monitor/message-handler.draft-preview.ts:

const shouldSplitPreviewMessages = discordStreamMode === "block";

const forceNewMessageIfNeeded = () => {
  if (shouldSplitPreviewMessages && hasStreamedMessage) {
    params.log("discord: calling forceNewMessage() for draft stream");
    draftStream?.forceNewMessage();
  }
  resetProgressState();
};

// Wired into the lifecycle:
handleAssistantMessageBoundary: forceNewMessageIfNeeded,

Discord respects a stream mode setting and splits previews at boundaries so phase transitions create new posts. Mattermost has neither the config nor the wiring.

Fix Action

Fix / Workaround

  • Severity: High for users on Mattermost. Loss of conversation history every turn is jarring and breaks the channel as a permanent record. Reading speed > rewrite speed → users actively lose information they already read.
  • Affects: all Mattermost direct-message users since v2026.4.20 (PR #47838).
  • Workaround: none in current code. blockStreaming: true doesn't help (it controls a different path). blockStreamingDefault: "off" doesn't help. Downgrading to v2026.4.19 is the only escape today.

PR fix notes

PR #75256: fix(mattermost): add streaming.mode='block' to split draft preview at turn boundaries

Description (problem / solution / changelog)

Related

Closes #75252

Problem

The Mattermost streaming draft preview (introduced in #47838) uses a single editable post for the whole turn. Every phase transition — thinking → tool status → partial reply → next tool → final reply — calls updateMattermostPost on the same post, completely replacing its content.

From the user's perspective: content they started reading disappears at every transition. By the time the final reply lands, the channel contains no record of thinking, tool calls, or intermediate partial output. It is functionally equivalent to the agent deleting messages mid-conversation.

The Discord adapter solved the same problem years ago with a streaming.mode config and a forceNewMessage() call at turn boundaries. Mattermost got the edit-in-place mechanics from #47838 but never got the corresponding boundary-split option.

Fix

Add streaming.mode: "partial" | "block" to Mattermost account config:

  • "partial" (default) — preserves existing edit-in-place behavior so nothing breaks for users who like the current feel
  • "block" — new behavior: at each turn boundary (onAssistantMessageStart, onReasoningEnd, onToolStart), the current preview post is frozen and a new one is created for the next phase

The boundary logic is extracted into a tested createMattermostDraftPreviewBoundaryController() helper in draft-stream.ts that holds the "did we just stream something?" gate, so forceNewMessage() is never called twice in a row without content between (which would produce empty preview posts).

Usage

{
  "channels": {
    "mattermost": {
      "streaming": {
        "mode": "block"
      }
    }
  }
}

Per-account override also works:

{
  "channels": {
    "mattermost": {
      "streaming": { "mode": "partial" },
      "accounts": {
        "ops": {
          "streaming": { "mode": "block" }
        }
      }
    }
  }
}

What changes

extensions/mattermost/src/types.ts

  • MattermostPreviewStreamMode = "partial" | "block"
  • MattermostStreamingConfig = { mode?: MattermostPreviewStreamMode }
  • streaming?: MattermostStreamingConfig added to MattermostAccountConfig

extensions/mattermost/src/config-schema-core.ts

  • MattermostPreviewStreamModeSchema and MattermostStreamingSchema Zod schemas
  • streaming field added to MattermostAccountSchemaBase

extensions/mattermost/src/mattermost/accounts.ts

  • resolveMattermostPreviewStreamMode() — resolves effective mode from merged account config
  • previewStreamMode: MattermostPreviewStreamMode added to ResolvedMattermostAccount

extensions/mattermost/src/mattermost/draft-stream.ts

  • MattermostDraftPreviewBoundaryController type
  • createMattermostDraftPreviewBoundaryController() — encapsulates the "only split when there is something to split" guard and the onSplit reset hook

extensions/mattermost/src/mattermost/monitor.ts

  • Imports and constructs the boundary controller from account.previewStreamMode
  • onAssistantMessageStart, onReasoningEnd, onToolStart hooks call previewBoundary.signalBoundary() before their update
  • updateDraftFromPartial calls previewBoundary.markStreamedContent() after each draft update

Tests

  • draft-stream.test.ts: 8 unit tests for createMattermostDraftPreviewBoundaryController
  • accounts.test.ts: 7 unit tests for resolveMattermostPreviewStreamMode and its exposure via resolveMattermostAccount
  • config-schema.test.ts: 5 schema validation tests ("block", "partial", per-account override, unknown mode rejection, unknown property rejection)
  • monitor.inbound-system-event.test.ts: updated mock to include createMattermostDraftPreviewBoundaryController (the test mocks the whole draft-stream.js module; without this the test hung at 120 s)

387/387 tests pass.

Scope

  • Mattermost adapter only; Discord, Telegram, Slack, Matrix paths are not affected
  • Default "partial" is backward-compatible; no config migration required
  • "off" (disable preview entirely) is intentionally out of scope for this PR — use blockStreaming: true for now; a follow-up can unify that surface

Reproduction of original bug

  1. Mattermost DM with any agent that runs tool calls (e.g. file reads or shell commands)
  2. Watch the preview post in real-time
  3. Observe: Thinking… is replaced by partial text, partial text is replaced by Running \tool`…`, tool status is replaced by next partial, etc.
  4. With streaming.mode: "block": each phase creates a new post; prior content stays in the channel

Changed files

  • extensions/mattermost/src/config-schema-core.ts (modified, +26/-0)
  • extensions/mattermost/src/config-schema.test.ts (modified, +67/-0)
  • extensions/mattermost/src/mattermost/accounts.test.ts (modified, +115/-0)
  • extensions/mattermost/src/mattermost/accounts.ts (modified, +58/-0)
  • extensions/mattermost/src/mattermost/draft-stream.test.ts (modified, +215/-4)
  • extensions/mattermost/src/mattermost/draft-stream.ts (modified, +180/-1)
  • extensions/mattermost/src/mattermost/monitor.inbound-system-event.test.ts (modified, +5/-0)
  • extensions/mattermost/src/mattermost/monitor.ts (modified, +70/-2)
  • extensions/mattermost/src/types.ts (modified, +51/-0)
  • src/agents/pi-embedded-subscribe.handlers.tools.ts (modified, +6/-1)
  • src/auto-reply/get-reply-options.types.ts (modified, +10/-1)
  • src/auto-reply/reply/agent-runner-execution.ts (modified, +6/-1)
  • src/config/bundled-channel-config-metadata.generated.ts (modified, +28/-0)

Code Example

// extensions/mattermost/src/mattermost/monitor.ts (~line 1790)
onPartialReply: (payload) => {
  updateDraftFromPartial(payload.text);   // edits same post
},
onAssistantMessageStart: () => {
  lastPartialText = "";                   // does NOT split the preview
},
onReasoningEnd: () => {
  lastPartialText = "";                   // does NOT split the preview
},
onReasoningStream: async () => {
  if (!lastPartialText) {
    draftStream.update("Thinking…");      // edits same post
  }
},
onToolStart: async (payload) => {
  draftStream.update(buildMattermostToolStatusText(payload));  // edits same post
},

---

const shouldSplitPreviewMessages = discordStreamMode === "block";

const forceNewMessageIfNeeded = () => {
  if (shouldSplitPreviewMessages && hasStreamedMessage) {
    params.log("discord: calling forceNewMessage() for draft stream");
    draftStream?.forceNewMessage();
  }
  resetProgressState();
};

// Wired into the lifecycle:
handleAssistantMessageBoundary: forceNewMessageIfNeeded,
RAW_BUFFERClick to expand / collapse

Summary

The Mattermost streaming draft preview (introduced in #47838 / b38988c) overwrites the visible message at every phase transition, causing previously-shown content to disappear when the agent transitions between thinking, partial replies, and tool calls. Users see content like "Thinking…" → partial reply text → "Running exec…" → next partial reply → final answer all happening on a single edited post, with each new state completely replacing the prior one.

This is functionally equivalent to deleting messages the user has already read.

The Discord adapter solves the same problem with a discordStreamMode === "block" config that splits previews at boundaries (calls draftStream.forceNewMessage() so a NEW post is created at each transition). The Mattermost adapter has the forceNewMessage() API on its draft stream but never calls it, and has no equivalent streamMode config.

Repro

  1. Configure a Mattermost direct-message channel
  2. Send the agent a message that triggers tool calls (e.g. "Run ls -la and tell me what you see")
  3. Watch the agent's reply post in real-time

Expected

Each conceptual phase (reasoning, partial reply, tool status, next partial reply, final reply) lands as its own post (or as a clean append-only stream), so prior content stays visible.

Actual

A single post is repeatedly rewritten:

  • Thinking…
  • Some partial answer text the user starts to read
  • Running exec (the partial answer is gone)
  • New partial text after the tool (the tool status is gone)
  • → final reply (everything before is gone)

By the time the final reply lands, every transitional state the user might have read has been wiped from the channel history. From the user's perspective the agent is "deleting messages."

Root cause

In extensions/mattermost/src/mattermost/monitor.ts (around line 1790), the draft preview hooks call draftStream.update(...) at every transition, which routes through sendOrEditStreamMessage in draft-stream.ts. That function is structured to edit the existing streamPostId if one exists, otherwise create a new post. Because streamPostId is only reset by forceNewMessage() (or clear() / discardPending() for cleanup), and forceNewMessage() is never invoked, every update call after the first one is a PUT /posts/{id} that overwrites the previous content.

// extensions/mattermost/src/mattermost/monitor.ts (~line 1790)
onPartialReply: (payload) => {
  updateDraftFromPartial(payload.text);   // edits same post
},
onAssistantMessageStart: () => {
  lastPartialText = "";                   // does NOT split the preview
},
onReasoningEnd: () => {
  lastPartialText = "";                   // does NOT split the preview
},
onReasoningStream: async () => {
  if (!lastPartialText) {
    draftStream.update("Thinking…");      // edits same post
  }
},
onToolStart: async (payload) => {
  draftStream.update(buildMattermostToolStatusText(payload));  // edits same post
},

Compare with Discord's handling in extensions/discord/src/monitor/message-handler.draft-preview.ts:

const shouldSplitPreviewMessages = discordStreamMode === "block";

const forceNewMessageIfNeeded = () => {
  if (shouldSplitPreviewMessages && hasStreamedMessage) {
    params.log("discord: calling forceNewMessage() for draft stream");
    draftStream?.forceNewMessage();
  }
  resetProgressState();
};

// Wired into the lifecycle:
handleAssistantMessageBoundary: forceNewMessageIfNeeded,

Discord respects a stream mode setting and splits previews at boundaries so phase transitions create new posts. Mattermost has neither the config nor the wiring.

Proposed fix

Two changes:

  1. Add a streamMode (or equivalent) config to Mattermost matching Discord's semantics:

    • "edit" (default — preserves current behavior for users who like it)
    • "block" (new — splits at boundaries, never overwrites)
    • "off" (no preview at all — already supported via blockStreaming / disableBlockStreaming)
  2. Wire forceNewMessage() into the transition hooks in monitor.ts when streamMode === "block":

    • onAssistantMessageStart → split before the next partial reply lands
    • onReasoningEnd → split when leaving the thinking state
    • onToolStart → split before the tool status replaces partial text
    • (probably also onToolEnd so the next partial after a tool lands in a new post)

Impact

  • Severity: High for users on Mattermost. Loss of conversation history every turn is jarring and breaks the channel as a permanent record. Reading speed > rewrite speed → users actively lose information they already read.
  • Affects: all Mattermost direct-message users since v2026.4.20 (PR #47838).
  • Workaround: none in current code. blockStreaming: true doesn't help (it controls a different path). blockStreamingDefault: "off" doesn't help. Downgrading to v2026.4.19 is the only escape today.

Related

  • PR #47838 (b38988c) — feat(mattermost): keep draft previews on one visible sink per turn
  • Followup commits: fb9a21a (centralize draft preview finalization), bb43c7b (suppress reasoning previews), 367faac (suppress reasoning-only replies), 23a017b (#69927, suppress quoted reasoning replies)

I'd like to take a stab at the fix and open a PR — pairing this issue with one shortly.

extent analysis

TL;DR

To fix the issue, add a streamMode config to Mattermost and wire forceNewMessage() into transition hooks to split previews at boundaries.

Guidance

  • Add a streamMode config to Mattermost with options "edit", "block", and "off" to control preview behavior.
  • Wire forceNewMessage() into transition hooks (onAssistantMessageStart, onReasoningEnd, onToolStart) when streamMode === "block" to split previews at boundaries.
  • Consider adding forceNewMessage() to onToolEnd to ensure the next partial reply lands in a new post.
  • Test the changes with different streamMode settings to verify the fix.

Example

// extensions/mattermost/src/mattermost/monitor.ts
const streamMode = 'block'; // new config option

onAssistantMessageStart: () => {
  if (streamMode === 'block') {
    draftStream.forceNewMessage();
  }
  // ...
},
onReasoningEnd: () => {
  if (streamMode === 'block') {
    draftStream.forceNewMessage();
  }
  // ...
},
onToolStart: async (payload) => {
  if (streamMode === 'block') {
    draftStream.forceNewMessage();
  }
  // ...
},

Notes

The proposed fix requires adding a new config option and modifying the transition hooks to call forceNewMessage() when streamMode === "block". This should fix the issue for Mattermost direct-message users.

Recommendation

Apply the workaround by adding the streamMode config and wiring forceNewMessage() into transition hooks, as this will provide a more user-friendly experience by preserving conversation history.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING

openclaw - ✅(Solved) Fix fix(mattermost): draft preview overwrites previous content at every transition (data loss) [1 pull requests, 1 comments, 2 participants]