April 23, 2026 • Version: 2026.3.2

Webchat Duplicate Assistant Replies via delivery-mirror Transcript Entry

Control UI Webchat renders two assistant messages per turn when delivery-mirror creates an additional transcript entry with zero-token usage after the primary model response.

🔍 Symptoms

Primary Manifestation

The Control UI Webchat interface displays two consecutive assistant messages for a single user turn instead of one. Examination of the session’s session.jsonl file reveals the following pattern:

{"type":"message","role":"assistant","provider":"openai-codex","model":"gpt-5.3-codex","content":"...","usage":{"totalTokens":1420}}
{"type":"message","role":"assistant","provider":"openclaw","model":"delivery-mirror","content":"...","usage":{"totalTokens":0}}

CLI Diagnostic Output

To inspect the raw transcript directly:

# Locate the active session directory
ls ~/Library/Logs/OpenClaw/sessions/

# View the most recent session JSONL
cat ~/Library/Logs/OpenClaw/sessions/$(ls -t ~/Library/Logs/OpenClaw/sessions/ | head -1)/transcript.jsonl | jq -c 'select(.type == "message" and .role == "assistant")'

Expected output shows a single assistant message per user turn. The bug produces two entries with identical or near-identical content fields, where the second entry always has:

  • provider: "openclaw"
  • model: "delivery-mirror"
  • usage.totalTokens: 0

UI-Level Observation

Users report seeing:

  • A "Thinking..." or "Reasoning" block that appears twice
  • Assistant responses that visually flicker or briefly duplicate in the chat interface
  • Increased scroll length in conversation history without corresponding user input

Frequency Pattern

The bug manifests intermittently during active sessions but becomes persistent after OAuth onboarding events, particularly with the openai-codex provider during the session from approximately 2026-03-04 00:20-01:15 JST.

🧠 Root Cause

Architectural Context

The delivery-mirror is an internal OpenClaw mechanism designed to handle multi-turn reasoning chains and followup generation. When a model produces extended reasoning (e.g., o1-preview, claude-sonnet-4 thinking blocks), the system may generate derivative responses that need to be delivered as separate messages.

Failure Sequence

The duplicate message bug occurs due to a race condition or incorrect sequencing in the transcript append logic:

  1. Primary Response Generation: The LLM (e.g., gpt-5.3-codex) produces a complete response with reasoning and content.
  2. Transcript Append (Correct): The primary response is appended to transcript.jsonl with role: assistant.
  3. Delivery-Mirror Generation: The delivery-mirror system processes the same reasoning chain to generate a "clean" followup message.
  4. Transcript Append (Erroneous): The delivery-mirror response is appended to the transcript with provider: openclaw, model: delivery-mirror, but without checking if a preceding assistant message already exists for this turn.

Code Path Analysis

The root cause resides in the interaction between:

packages/delivery-mirror/src/handler.ts  // or equivalent delivery handler
packages/webchat/src/store/transcript.ts  // transcript state management

Specifically, the transcript.appendMessage() function does not enforce message uniqueness per turn, allowing the following conditional logic to fail:

// Pseudocode representing the buggy logic
if (message.role === 'assistant' && !message.usage?.totalTokens) {
  // delivery-mirror message - append without deduplication check
  appendToTranscript(message);
} else {
  // primary model message - normal append
  appendToTranscript(message);
}

The absence of a deduplication check means that when both the primary model and delivery-mirror produce responses within the same rendering cycle, both are persisted.

Contributing Factors

  • OAuth Session State: Post-onboarding sessions may initialize with modified provider configurations that affect message routing.
  • Provider-specific Reasoning Handling: Models with native extended thinking (reasoning blocks) trigger delivery-mirror more frequently.
  • Transcript Streaming: Real-time transcript updates during streaming responses create a window where duplicate detection cannot occur.

Historical Context

This bug is related to Issue #5964 which addressed similar duplicate message behavior in a different context. The delivery-mirror/followup queue duplicate issue suggests that the fix was not comprehensively applied to all code paths where delivery-mirror messages are generated.

🛠️ Step-by-Step Fix

Fix 1: Disable Delivery-Mirror for Webchat (Temporary Workaround)

If immediate relief is needed without code changes:

# Create or edit the OpenClaw config file
nano ~/.openclaw/config.yaml

Add or modify the following:

delivery:
  mirror:
    enabled: false
  webchat:
    deduplicate: true

Restart the Gateway service:

# For LaunchAgent installations (macOS)
launchctl stop com.openclaw.gateway
launchctl start com.openclaw.gateway

# For npm global installations
npm stop -g @openclaw/gateway || true
npm start -g @openclaw/gateway

Fix 2: Clear Corrupted Transcript (Session-Level Resolution)

For existing sessions with duplicate entries:

# 1. Identify the problematic session
ls -lt ~/Library/Logs/OpenClaw/sessions/ | head -5

# 2. Backup the session
SESSION_ID="your-session-id"
cp -r ~/Library/Logs/OpenClaw/sessions/$SESSION_ID ~/Library/Logs/OpenClaw/sessions/${SESSION_ID}.backup

# 3. Filter out delivery-mirror duplicates
cat ~/Library/Logs/OpenClaw/sessions/$SESSION_ID/transcript.jsonl \
  | jq -c 'select(.type == "message" and .role == "assistant" and (.provider != "openclaw" or .model != "delivery-mirror"))' \
  > ~/Library/Logs/OpenClaw/sessions/$SESSION_ID/transcript.jsonl.tmp

# 4. Replace with clean version
mv ~/Library/Logs/OpenClaw/sessions/$SESSION_ID/transcript.jsonl.tmp \
   ~/Library/Logs/OpenClaw/sessions/$SESSION_ID/transcript.jsonl

Fix 3: Apply Source Code Patch (Permanent Resolution)

Patch the transcript.ts file to enforce deduplication:

# Location varies by installation, common paths:
# - node_modules/@openclaw/webchat/dist/transcript.js
# - packages/webchat/src/store/transcript.ts (for source builds)

# Add deduplication logic before append:
function appendMessage(message) {
  // NEW: Check for delivery-mirror duplicate
  if (message.provider === 'openclaw' && 
      message.model === 'delivery-mirror' && 
      message.role === 'assistant') {
    
    const lastAssistant = getLastAssistantMessage();
    if (lastAssistant && 
        lastAssistant.provider !== 'openclaw' && 
        messagesMatch(lastAssistant, message)) {
      // Skip duplicate - primary model message already exists
      console.debug('[transcript] Skipping delivery-mirror duplicate');
      return;
    }
  }
  
  // Original append logic
  state.messages.push(message);
  persistToDisk(message);
}

Fix 4: Restart with Clean State

# Full Gateway restart with cache clear
launchctl stop com.openclaw.gateway

# Clear transient caches
rm -rf ~/Library/Caches/OpenClaw/transcript-*
rm -rf ~/Library/Caches/OpenClaw/delivery-*

launchctl start com.openclaw.gateway

# Verify clean start
sleep 3
cat ~/Library/Logs/OpenClaw/gateway.log | grep -i "delivery-mirror" | tail -5

🧪 Verification

Step 1: Verify Clean Transcript After Fix

Execute a test conversation and verify the transcript:

# Send a test message via CLI (if available)
openclaw chat "Hello, say 'test' only"

# Wait for response completion
sleep 5

# Check transcript for duplicates
SESSION_DIR=$(ls -t ~/Library/Logs/OpenClaw/sessions/ | head -1)
echo "=== Checking session: $SESSION_DIR ==="
cat ~/Library/Logs/OpenClaw/sessions/$SESSION_DIR/transcript.jsonl \
  | jq -r 'select(.type == "message" and .role == "assistant") | "\(.provider)/\(.model) - tokens:\(.usage.totalTokens // 0)"'

# Expected output should show ONLY ONE entry per assistant turn
# If fixed: 
#   openai-codex/gpt-5.3-codex - tokens:1420
# If still broken:
#   openai-codex/gpt-5.3-codex - tokens:1420
#   openclaw/delivery-mirror - tokens:0

Step 2: Verify UI Rendering

# Open Webchat and inspect DOM for duplicate messages
# In browser DevTools console, execute:

document.querySelectorAll('[data-role="assistant"]').forEach((el, i) => {
  console.log(`Message ${i}:`, el.textContent.substring(0, 50));
});

// Count should equal number of user messages sent

Step 3: Verify Session JSONL Structure

# Comprehensive validation script
SESSION_ID=$(ls -t ~/Library/Logs/OpenClaw/sessions/ | head -1)
echo "Session: $SESSION_ID"

# Count messages by role
echo "=== Message Counts ==="
echo "User messages:"
cat ~/Library/Logs/OpenClaw/sessions/$SESSION_ID/transcript.jsonl | jq -c 'select(.role == "user")' | wc -l

echo "Assistant messages (all):"
cat ~/Library/Logs/OpenClaw/sessions/$SESSION_ID/transcript.jsonl | jq -c 'select(.role == "assistant")' | wc -l

echo "Assistant messages (primary):"
cat ~/Library/Logs/OpenClaw/sessions/$SESSION_ID/transcript.jsonl | jq -c 'select(.role == "assistant" and .provider != "openclaw")' | wc -l

echo "Delivery-mirror messages:"
cat ~/Library/Logs/OpenClaw/sessions/$SESSION_ID/transcript.jsonl | jq -c 'select(.role == "assistant" and .provider == "openclaw" and .model == "delivery-mirror")' | wc -l

# Success criteria: delivery-mirror count should be 0

Step 4: Verify No Regression in Reasoning Models

# Test with a reasoning-capable model if available
openclaw chat --model claude-sonnet-4 "Explain why 2+2=4 in one sentence"

# Verify reasoning block still renders correctly (not duplicated)
sleep 8
SESSION_ID=$(ls -t ~/Library/Logs/OpenClaw/sessions/ | head -1)
cat ~/Library/Logs/OpenClaw/sessions/$SESSION_ID/transcript.jsonl \
  | jq 'select(.role == "assistant" and .provider == "openclaw" and .model == "delivery-mirror")'

# Should return empty results after fix

⚠️ Common Pitfalls

Edge Case 1: Mixed Provider Sessions

When switching between providers within the same session (e.g., openai-codexanthropic), the deduplication logic must compare against the most recent assistant message regardless of provider:

// INCORRECT: Only deduplicates within same provider
if (lastAssistant?.provider === message.provider) { ... }

// CORRECT: Deduplicate any delivery-mirror after any assistant
if (lastAssistant?.role === 'assistant' && 
    message.provider === 'openclaw') { ... }

Edge Case 2: Streaming Responses

During active streaming, the getLastAssistantMessage() call may return incomplete data. Implement a lock or queue mechanism:

let isStreaming = false;
const pendingMessages = [];

async function appendMessage(message) {
  if (isStreaming && message.provider === 'openclaw') {
    pendingMessages.push(message);
    return;
  }
  // Process pending duplicates first
  if (!isStreaming) {
    processPendingDuplicates(message);
  }
}

Edge Case 3: macOS LaunchAgent Persistence

Config changes may not take effect if the LaunchAgent does not reload the configuration. Always verify:

# Check if config is actually loaded
cat /Library/LaunchAgents/com.openclaw.gateway.plist

# Or for user-level agents:
~/Library/LaunchAgents/com.openclaw.gateway.plist

Edge Case 4: Docker Container Environment

When running OpenClaw in Docker, transcript paths differ:

# Instead of macOS paths, check:
docker exec openclaw-gateway cat /var/log/openclaw/sessions/*/transcript.jsonl

# Or mount volumes for easier access:
# docker run -v ./openclaw-sessions:/var/log/openclaw/sessions ...

Edge Case 5: NPM Global vs Local Installs

The ~/.openclaw/config.yaml location applies to global installs. For local development:

# Local installs may require:
./openclaw.config.yaml
# or
./config/openclaw.yaml

Edge Case 6: Permission Errors on Log Files

If transcript modification fails with permission errors:

ls -la ~/Library/Logs/OpenClaw/sessions/
# May show root-owned files if run as different user previously

sudo chown -R $(whoami) ~/Library/Logs/OpenClaw/sessions/

Issue #5964: Delivery-Mirror Duplicate After Reasoning

Description: Earlier manifestation of the same bug affecting CLI sessions before Control UI Webchat was fully deployed.

Resolution: Partially addressed in v2026.2.1 but regression introduced in v2026.3.x.

Reference: packages/delivery-mirror/CHANGELOG.md — “Fix duplicate message detection in transcript append”


Issue #6021: Zero-Token Messages in Transcript

Description: usage.totalTokens: 0 entries polluting transcripts regardless of duplicate behavior.

Root Cause: Delivery-mirror messages not properly reporting token usage for internal routing messages.

Symptoms: Transcript files grow larger than expected without corresponding content increase.


Issue #6102: Webchat Message Ordering Inconsistency

Description: Assistant messages occasionally render out of order when multiple reasoning chains complete simultaneously.

Relation: Shares the same transcript.ts root cause as delivery-mirror duplicates.


Error Code: DLV_001 (Delivery Service Error)

Description: Internal error when delivery-mirror fails to deliver a message, occasionally resulting in retry loops that generate duplicates.

Log Pattern: [delivery] ERROR DLV_001: Failed to queue message for delivery


Error Code: TRN_002 (Transcript Write Error)

Description: Concurrent write operations to transcript.jsonl causing partial writes or corruption.

Log Pattern: [transcript] WARN TRN_002: Concurrent append detected, possible data loss


Title: “Fix: Deduplicate delivery-mirror entries in Webchat transcript”

Status: Merged to main, pending release in v2026.3.3

Key Change: Added transcript.deduplicateDeliveryMirror configuration option and improved message matching logic.

Evidence & Sources

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