Mattermost: Multi-Turn Responses Batch All Messages at End When Edit-in-Place Streaming is Active
When block streaming is enabled, multi-turn tool-call responses accumulate in a single streaming message and appear all at once at session end, instead of each segment streaming independently and being finalized between tool calls.
π Symptoms
Primary Manifestation
When a Mattermost agent handles a multi-turn conversation with tool calls, all text segments between tool calls appear as a single, continuously-edited message and are only posted when the model run completes.
CLI Observation: bash
User sends a request that triggers multiple tool calls (e.g., code analysis)
Expected: Each text segment appears as its own streaming post, finalized between tool calls
Actual: One message appears and gets edited in-place, content changes at tool call boundaries
After model finishes: ALL messages appear at once
Secondary Manifestations
- Single streaming post: One Mattermost post remains active and its content updates as each new tool-call turn produces text
- No intermediate finalization: No message is posted/finished between tool callsβonly when
message_endfires for the entire model run - Batch appearance: When the model completes, all accumulated
assistantTexts[]appear simultaneously viagetReplyFromConfig - Streaming state persists: The single active post remains in editing state until the final batch release
Diagnostic Query
javascript // Check Mattermost extension configuration extensionRegistry.get(‘mattermost’)?.config.blockStreaming // Expected: Should allow block delivery at message_end boundaries // Actual when broken: blockStreamingEnabled is effectively false due to disableBlockStreaming
π§ Root Cause
Architectural Failure Sequence
The issue stems from an interaction between three components: the Mattermost extension’s streaming handler, the disableBlockStreaming flag, and createBlockReplyDeliveryHandler.
1. Extension-Level Flag Setting
When the Mattermost extension handles streaming via onPartialReply, it sets disableBlockStreaming: true to prevent the core from sending duplicate block messages:
javascript // Mattermost extension streaming handler (simplified) function handlePartialReply(event) { // Edit-in-place via onPartialReply updateOrCreateStreamingPost(event.text);
// Prevent core block handling to avoid duplicates
event.disableBlockStreaming = true; // β This flag affects downstream handlers
}
2. Block Reply Delivery Handler Conditionally Skips
In createBlockReplyDeliveryHandler, the blockStreamingEnabled flag gates all delivery paths:
javascript function createBlockReplyDeliveryHandler(params) { return async function deliverBlock(blockPayload) { if (params.blockStreamingEnabled && params.blockReplyPipeline) { params.blockReplyPipeline.enqueue(blockPayload); // β SKIPPED when flag is false } else if (params.blockStreamingEnabled) { await params.onBlockReply(blockPayload); // β SKIPPED when flag is false } // When blockStreamingEnabled=false β block is silently dropped }; }
3. Reset Behavior Between Tool Calls
The onPartialReply callback receives only the current turn’s text. Between tool calls, the streaming state resets:
Turn 1 (text: “Let me analyze…”) β onPartialReply fires β streaming post created Tool call executes (file read) Turn 2 (text: “I see the issue…”) β onPartialReply fires β SAME post updated Tool call executes Turn 3 (text: “Here’s the fix…”) β onPartialReply fires β SAME post updated again Model completes β getReplyFromConfig returns all assistantTexts[] β ALL POSTED AT ONCE
4. Message Boundary Loss
At each message_end boundary (between tool calls), the system should:
- Finalize the current streaming post
- Start a new post for the next segment
But with disableBlockStreaming: true, the delivery handler silently drops the block, and onPartialReply only updates the existing post without finalizing it.
Flow Diagram
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β CORRECT BEHAVIOR (per-issue) β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€ β Turn 1 text β onPartialReply β streaming post A β β message_end β finalize post A, start new streaming post B β β Tool call executes β β Turn 2 text β onPartialReply β streaming post B updates β β message_end β finalize post B, start new streaming post C β β … β β Model complete β last post finalized β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β ACTUAL BEHAVIOR (broken) β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€ β Turn 1 text β onPartialReply β streaming post A (disableBlock=true)β β message_end β block dropped, post NOT finalized β β Tool call executes β β Turn 2 text β onPartialReply β SAME post A content updated β β message_end β block dropped, post NOT finalized β β … β β Model complete β getReplyFromConfig returns ALL β ALL POSTED ONCE β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Code References
- Entry point: Mattermost extension
onPartialReplyhandler - Flag propagation:
event.disableBlockStreamingpassed to core - Delivery gate:
createBlockReplyDeliveryHandlerconditional onblockStreamingEnabled - Batch release:
getReplyFromConfigreturns accumulatedassistantTexts[]
π οΈ Step-by-Step Fix
Option A: Conditional Block Streaming Flag (Recommended)
Modify the Mattermost extension to only set disableBlockStreaming: true when the core cannot handle edit-in-place streaming natively, allowing createBlockReplyDeliveryHandler to still deliver blocks at message_end boundaries.
Before (problematic): javascript // In Mattermost extension’s streaming handler function handleStreamingEvent(event) { if (event.type === ’text’ || event.type === ‘partial’) { // Always override to use onPartialReply for edit-in-place event.disableBlockStreaming = true; // β Overrides everywhere this.onPartialReply(event); } }
After (fixed): javascript // In Mattermost extension’s streaming handler function handleStreamingEvent(event) { if (event.type === ’text’ || event.type === ‘partial’) { // Only disable core block streaming if we’re handling it // Allow core to deliver blocks at message_end boundaries if (this.config.useEditInPlace && !event.isFinalSegment) { event.disableBlockStreaming = true; } this.onPartialReply(event); }
// Handle message_end to finalize and allow next segment
if (event.type === 'message_end') {
// Finalize current streaming post if one exists
this.finalizeCurrentStreamingPost();
// Reset state for next segmentβdo NOT disable block streaming
// This allows createBlockReplyDeliveryHandler to start new post
}
}
Option B: Explicit Multi-Turn Coordination
Add explicit coordination between onPartialReply handling and block delivery at message boundaries.
Implementation: javascript // Mattermost extension class MattermostStreamingHandler { constructor() { this.currentPostId = null; this.pendingFinalization = null; }
handleStreamingEvent(event) {
switch (event.type) {
case 'text':
case 'partial':
if (!this.currentPostId) {
// Start new streaming post (edit-in-place)
this.currentPostId = this.createStreamingPost(event);
} else {
// Update existing post (edit-in-place)
this.updateStreamingPost(this.currentPostId, event);
}
// Allow core to track state but disable duplicate blocks
event.disableBlockStreaming = true;
break;
case 'message_end':
// Finalize current post BEFORE allowing next block
if (this.currentPostId) {
this.finalizePost(this.currentPostId);
this.currentPostId = null; // Reset for next segment
}
// DO NOT set disableBlockStreaming here
// Allow createBlockReplyDeliveryHandler to start fresh for next turn
break;
}
}
}
Option C: Separate Streaming and Block Delivery Paths
Explicitly separate the concerns of streaming (edit-in-place) and block delivery (message boundaries).
Implementation: javascript // In createBlockReplyDeliveryHandler: Add tracking for multi-turn state function createBlockReplyDeliveryHandler(params) { let pendingBlock = null;
return async function deliverBlock(blockPayload) {
// Handle message_end specifically for multi-turn
if (blockPayload.type === 'message_end' && pendingBlock) {
// Clear any in-progress streaming post
params.clearStreamingPost?.();
pendingBlock = null;
return;
}
// Standard delivery logic
if (params.blockStreamingEnabled && params.blockReplyPipeline) {
params.blockReplyPipeline.enqueue(blockPayload);
} else if (params.blockStreamingEnabled) {
await params.onBlockReply(blockPayload);
}
pendingBlock = blockPayload;
};
}
Configuration Verification
Ensure blockStreaming is properly configured:
javascript // Mattermost extension config const config = { blockStreaming: true, // Enable edit-in-place streaming useEditInPlace: true, // Allow partial updates // Ensure these are not conflicting };
π§ͺ Verification
Test Case: Multi-Turn Tool Call Response
Step 1: Configure Mattermost Extension
bash
Verify blockStreaming is enabled in config
grep -r “blockStreaming” ./extensions/mattermost/config.js
Expected output: blockStreaming: true (or auto-detected from baseUrl + botToken)
Step 2: Send Multi-Turn Request
javascript // Trigger agent that performs multiple tool calls const response = await agent.process({ message: “Analyze the repository structure, find all TypeScript files, and summarize the architecture”, channel: ’test-channel’ });
Step 3: Verify Streaming Behavior
Check logs for streaming events:
bash
Enable debug logging for streaming
LOG_LEVEL=debug node your-app.js 2>&1 | grep -E “(onPartialReply|message_end|blockStreaming)”
Expected output pattern (fixed behavior):
[DEBUG] onPartialReply: text segment 1 β post created [DEBUG] message_end: segment 1 finalized, new post started [DEBUG] onPartialReply: text segment 2 β existing post updated [DEBUG] message_end: segment 2 finalized, new post started [DEBUG] onPartialReply: text segment 3 β existing post updated [DEBUG] final: all segments posted independently
Actual (broken): All segments update single post, batch at end
[DEBUG] onPartialReply: text segment 1 [DEBUG] onPartialReply: text segment 2 (SAME post updated) [DEBUG] onPartialReply: text segment 3 (SAME post updated) [DEBUG] final: getReplyFromConfig β ALL POSTED
Step 4: Verify Mattermost Posts
In Mattermost UI, verify:
β Post 1: “Let me analyze the repository…” (finalized) β Post 2: “Found 47 TypeScript files…” (finalized) β Post 3: “The architecture consists of…” (finalized)
vs broken behavior:
β Single post that started with Turn 1 text, ended with Turn N text β All other segments missing until batch release
Step 5: Automated Integration Test
javascript // test/mattermost-streaming.test.js async function testMultiTurnStreaming() { const messages = [];
// Mock Mattermost post creation
const mockPost = { id: 'post-1', content: '' };
// Capture onPartialReply calls
const originalHandler = mattermostExtension.onPartialReply;
mattermostExtension.onPartialReply = async (event) => {
messages.push({
text: event.text,
timestamp: Date.now()
});
return originalHandler.call(mattermostExtension, event);
};
// Send multi-turn request
const result = await agent.process({
message: "Perform file read, then code analysis, then summary",
channel: 'test'
});
// Verify: Each segment should have finalized post
const postChanges = messages.length;
// FIXED: Expect N posts for N segments (finalized between tool calls)
// BROKEN: Expect 1 post that got updated N times
expect(postChanges).toBeGreaterThan(1);
expect(messages[0].timestamp).toBeLessThan(messages[1].timestamp);
}
Exit Criteria
| Criterion | Before Fix | After Fix |
|---|---|---|
| Post count | 1 (batched) | N (one per segment) |
| Finalization timing | At model end | At each message_end |
| Edit-in-place behavior | Single post updates | Per-segment updates |
| Block delivery logs | “silently dropped” | “delivered to pipeline” |
β οΈ Common Pitfalls
Pitfall 1: Conflicting Streaming Flags
Setting both blockStreaming and disableBlockStreaming creates conflicting behaviors.
javascript // WRONG: Conflicting configuration const config = { blockStreaming: true, // Enable streaming disableBlockStreaming: true // β Conflicts, blocks will be dropped };
// CORRECT: Let extension decide based on context const config = { blockStreaming: true // disableBlockStreaming set dynamically per event };
Pitfall 2: Not Resetting State at message_end
Failing to reset the streaming post identifier causes the same post to be edited instead of creating a new one.
javascript // WRONG: No state reset case ‘message_end’: // Missing: this.currentPostId = null; break;
// CORRECT: Reset for next segment case ‘message_end’: this.finalizeCurrentPost(); this.currentPostId = null; // Allow new post creation break;
Pitfall 3: macOS-Specific Path Handling
When testing locally on macOS, ensure the Mattermost extension uses the correct config path.
bash
macOS: Config at ~/Library/Application Support/
Linux: Config at ~/.config/
Verify config loading
DEBUG=mattermost:* node your-app.js
Should see: Loading config from: ~/.config/openclaw/extensions/mattermost.json
Pitfall 4: Docker Volume Mount Conflicts
In Docker environments, streaming state may not persist correctly between container restarts.
dockerfile
WRONG: Ephemeral state
VOLUME ["/app/data"]
CORRECT: Persistent state
VOLUME ["/app/data", “/app/logs”]
Pitfall 5: Race Condition in Block Delivery
If onPartialReply and createBlockReplyDeliveryHandler both fire, duplicate posts may occur.
javascript // Ensure mutual exclusion let isHandlingPartial = false;
async function handleStreamingEvent(event) { isHandlingPartial = true; await onPartialReply(event); isHandlingPartial = false; }
async function deliverBlock(blockPayload) { if (isHandlingPartial) { // Skipβpartial handler is managing this return; } // Standard block delivery }
Pitfall 6: Version Mismatch with #33506
The edit-in-place streaming feature was introduced in a specific commit. Ensure your Mattermost extension is at the correct version.
bash
Check if edit-in-place is available
grep -r “onPartialReply” ./extensions/mattermost/
If not found: Update to version containing #33506
Verify version
cat ./extensions/mattermost/package.json | grep version
Expected: >= version containing PR #33506
π Related Errors
Related Issues
- #33506 β Mattermost edit-in-place streaming
Introduces the
disableBlockStreaming: truebehavior that causes this issue. PR addsonPartialReplyhandler but doesn't account for multi-turn scenarios. - #39491 β Stream text when response includes media
Related streaming enhancement. May have similar message boundary handling issues if
disableBlockStreamingis involved. - #38912 β Mattermost streaming loses messages on reconnect
Streaming state management issue. Shares common code paths with multi-turn streaming.
Logically Connected Error Codes
| Error Code | Description | Connection |
|---|---|---|
MBLOCK_001 | Block streaming disabled unexpectedly | Directly caused by disableBlockStreaming flag |
MBLOCK_002 | Message batched at session end | Symptom of this issue |
STREAM_001 | Partial reply overwrites previous | Root manifestation in onPartialReply |
STREAM_002 | No finalization at message_end | Missing reset between tool calls |
TOOL_001 | Multi-turn state not persisted | Related to streaming state management |
Related Configuration Options
javascript // Conflicting options that may cause similar issues blockStreaming: boolean // Enable edit-in-place streaming disableBlockStreaming: boolean // Override to prevent core handling blockReplyPipeline: Pipeline // Alternative delivery path
// Ensure blockStreaming=true without disableBlockStreaming=true // when multi-turn behavior is expected
Historical Context
The issue manifests specifically because:
onPartialReplywas designed for single-response streaming- Multi-turn tool-call responses existed before #33506 but used different delivery path
- When #33506 added edit-in-place, it didn’t account for message_end boundaries in multi-turn contexts
References:
- Original implementation:
extensions/mattermost/src/streaming.ts - Block delivery:
packages/core/src/block-reply-handler.ts - Related PR:
#33506