May 03, 2026

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_end fires for the entire model run
  • Batch appearance: When the model completes, all accumulated assistantTexts[] appear simultaneously via getReplyFromConfig
  • 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:

  1. Finalize the current streaming post
  2. 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 onPartialReply handler
  • Flag propagation: event.disableBlockStreaming passed to core
  • Delivery gate: createBlockReplyDeliveryHandler conditional on blockStreamingEnabled
  • Batch release: getReplyFromConfig returns accumulated assistantTexts[]

πŸ› οΈ Step-by-Step Fix

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

CriterionBefore FixAfter Fix
Post count1 (batched)N (one per segment)
Finalization timingAt model endAt each message_end
Edit-in-place behaviorSingle post updatesPer-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

  • #33506 β€” Mattermost edit-in-place streaming

    Introduces the disableBlockStreaming: true behavior that causes this issue. PR adds onPartialReply handler 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 disableBlockStreaming is 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 CodeDescriptionConnection
MBLOCK_001Block streaming disabled unexpectedlyDirectly caused by disableBlockStreaming flag
MBLOCK_002Message batched at session endSymptom of this issue
STREAM_001Partial reply overwrites previousRoot manifestation in onPartialReply
STREAM_002No finalization at message_endMissing reset between tool calls
TOOL_001Multi-turn state not persistedRelated to streaming state management

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:

  1. onPartialReply was designed for single-response streaming
  2. Multi-turn tool-call responses existed before #33506 but used different delivery path
  3. 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

Evidence & Sources

This troubleshooting guide was automatically synthesized by the FixClaw Intelligence Pipeline from community discussions.