April 24, 2026 โ€ข Version: 2026.2.26

WebChat Refresh Leaks Internal System/Heartbeat Entries

After refreshing WebChat, internal messages (system messages, heartbeat responses, tool chain artifacts) leak into the visible conversation due to inconsistent history filtering in buildChatItems.

๐Ÿ” Symptoms

Visual Manifestations

After a WebChat page refresh, users observe the following artifacts appearing in the conversation timeline:

  • System message blocks โ€” Internal system events rendered as chat items (e.g., "Refresh filter repro")
  • Heartbeat acknowledgment text โ€” Response payloads like HEARTBEAT_OK visible to end users
  • Internal tool/status chain items โ€” Tool execution artifacts and status messages exposed in the conversation view

CLI Reproduction Commands

To trigger internal events for testing:

openclaw system event --mode now --text "Refresh filter repro"

Expected vs Actual Behavior

StateExpectedActual (Bug)
Before refreshClean user/assistant conversationClean user/assistant conversation
After refreshClean user/assistant conversationSystem/heartbeat artifacts visible

Environment Details

  • Version: OpenClaw 2026.2.26
  • OS: macOS 26.1 (Darwin 25.1.0, arm64)
  • Install: pnpm global binary (/Users/bell/Library/pnpm/openclaw)
  • Feature flags: WebChat enabled, heartbeat enabled

๐Ÿง  Root Cause

Architectural Analysis

The WebChat refresh leak stems from a two-phase architectural inconsistency in the chat history rendering pipeline:

Phase 1: History Reload (ui/src/ui/controllers/chat.ts)

When WebChat initializes or refreshes, the chat controller loads the complete chat.history array:

// ui/src/ui/controllers/chat.ts (line ~N)
const history = chat.history; // Loads FULL history including internal entries

This includes all message types regardless of their classification as internal or user-facing.

Phase 2: Inadequate Filtering (ui/src/ui/views/chat.ts)

The buildChatItems function applies filtering logic that is insufficiently broad:

// ui/src/ui/views/chat.ts - buildChatItems function
function buildChatItems(history) {
  return history.filter(item => {
    // Current filter logic (incomplete):
    if (!item.thinking && item.type === 'toolresult') {
      return false; // Only suppresses toolresult when thinking is off
    }
    // MISSING: No filter for system/heartbeat/internal message classes
    return true;
  });
}

Failure Sequence

  1. User triggers internal event (heartbeat, system command)
  2. Internal message inserted into chat.history with classification flags (e.g., role: 'system', internal: true, messageClass: 'heartbeat')
  3. Page refresh occurs
  4. chat.ts reloads complete history including internal entries
  5. buildChatItems fails to filter these entries because filtering logic does not check messageClass or internal flags
  6. Internal artifacts render in the visible WebChat timeline

Message Classification Gap

The current implementation lacks a systematic approach to message classification. Internal messages should carry metadata flags that the rendering layer can use for filtering:

// Expected message structure with classification
{
  id: "msg_xxx",
  role: "system",
  content: "HEARTBEAT_OK",
  messageClass: "heartbeat",      // MISSING from filter check
  internal: true,                  // MISSING from filter check
  visibleInChat: false             // MISSING from filter check
}

๐Ÿ› ๏ธ Step-by-Step Fix

Solution Overview

Implement a Dev Mode toggle that controls message visibility:

  • Dev Mode OFF (default): Show only user-facing conversation items
  • Dev Mode ON: Show full internals for debugging purposes

Phase 1: Add Message Classification Flags

File: packages/core/src/types/chat.ts

// Add to message interface
export interface ChatMessage {
  id: string;
  role: 'user' | 'assistant' | 'system' | 'tool';
  content: string;
  // ... existing fields
  
  // NEW: Classification for UI filtering
  internal?: boolean;
  messageClass?: 'user' | 'assistant' | 'system' | 'heartbeat' | 'tool' | 'status';
  visibleInChat?: boolean; // Explicit override
}

Phase 2: Update buildChatItems Filtering

File: ui/src/ui/views/chat.ts

// BEFORE (incomplete filtering)
function buildChatItems(history, devMode = false) {
  return history.filter(item => {
    if (!item.thinking && item.type === 'toolresult') {
      return false;
    }
    return true;
  });
}

// AFTER (comprehensive filtering)
function buildChatItems(history, devMode = false) {
  return history.filter(item => {
    // Explicit visibility override
    if (item.visibleInChat === false && !devMode) {
      return false;
    }
    
    // Internal messages hidden in production mode
    if (item.internal && !devMode) {
      return false;
    }
    
    // Message class-based filtering
    const hiddenClasses = ['heartbeat', 'system', 'status'];
    if (hiddenClasses.includes(item.messageClass) && !devMode) {
      return false;
    }
    
    // Legacy toolresult handling (when thinking is off)
    if (!item.thinking && item.type === 'toolresult') {
      return false;
    }
    
    return true;
  });
}

Phase 3: Implement Dev Mode Toggle

File: ui/src/ui/components/DevModeToggle.tsx

import { useState, useEffect } from 'react';
import { loadSetting, saveSetting } from '../utils/settings';

const DEV_MODE_KEY = 'openclaw_dev_mode';

export function DevModeToggle() {
  const [devMode, setDevMode] = useState(() => 
    loadSetting(DEV_MODE_KEY, false)
  );

  useEffect(() => {
    saveSetting(DEV_MODE_KEY, devMode);
  }, [devMode]);

  return (
    <div className="dev-mode-toggle">
      <label>
        <input
          type="checkbox"
          checked={devMode}
          onChange={(e) => setDevMode(e.target.checked)}
        />
        Dev Mode
      </label>
      <span className="dev-mode-indicator">
        {devMode ? '๐Ÿ”ง Debugging' : '๐Ÿš€ Production'}
      </span>
    </div>
  );
}

File: ui/src/ui/utils/settings.ts

const SETTINGS_KEY = 'openclaw_ui_settings';

export function loadSetting(key: string, defaultValue: any): any {
  try {
    const settings = JSON.parse(localStorage.getItem(SETTINGS_KEY) || '{}');
    return settings[key] ?? defaultValue;
  } catch {
    return defaultValue;
  }
}

export function saveSetting(key: string, value: any): void {
  try {
    const settings = JSON.parse(localStorage.getItem(SETTINGS_KEY) || '{}');
    settings[key] = value;
    localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
  } catch (e) {
    console.error('Failed to persist setting:', key, e);
  }
}

Phase 4: Wire Dev Mode to Chat Controller

File: ui/src/ui/controllers/chat.ts

// BEFORE
const history = chat.history;

// AFTER
import { loadSetting } from '../utils/settings';

const DEV_MODE = loadSetting('openclaw_dev_mode', false);
const history = buildChatItems(chat.history, DEV_MODE);

Phase 5: Mark Internal Messages at Insertion Time

File: packages/heartbeat/src/handler.ts (or relevant heartbeat module)

// When inserting heartbeat response
chat.history.push({
  id: generateId(),
  role: 'system',
  content: 'HEARTBEAT_OK',
  messageClass: 'heartbeat',
  internal: true,
  visibleInChat: false, // Explicit suppress
  timestamp: Date.now()
});

๐Ÿงช Verification

Test Case 1: Verify Clean View After Refresh (Dev Mode OFF)

# 1. Start WebChat with heartbeat enabled
openclaw start --webchat --heartbeat

# 2. Trigger internal system event
openclaw system event --mode now --text "Refresh filter test"

# 3. Refresh WebChat page (Ctrl+Shift+R / Cmd+Shift+R)

# 4. Verify no internal artifacts in conversation
# Expected: Only user messages and assistant responses visible
# Check for absence of: HEARTBEAT_OK, system message blocks, tool chains

Expected Output: Conversation contains only user/assistant exchanges.

Test Case 2: Verify Dev Mode Shows Internals

# 1. Enable Dev Mode toggle in WebChat UI

# 2. Refresh page

# 3. Verify internal artifacts now visible
# Expected: System messages, heartbeat ACKs, tool chains visible

Expected Output: Full internal message chain visible with debug indicators.

Test Case 3: Verify Dev Mode Persists Across Sessions

# 1. Enable Dev Mode
# 2. Close WebChat tab
# 3. Reopen WebChat
# 4. Verify Dev Mode state preserved

# Check localStorage
window.localStorage.getItem('openclaw_ui_settings')
# Expected: {"openclaw_dev_mode":true}

Test Case 4: CLI Verification of Message Classification

# Verify messages have correct classification
openclaw chat history --format json | jq '.[] | select(.messageClass == "heartbeat")'

# Expected: Returns heartbeat entries (confirming classification is set)

Test Case 5: Regression Test for Tool Result Filtering

# 1. Disable thinking mode
openclaw config set thinking false

# 2. Execute a tool call (e.g., file read)
openclaw tool run read-file --path /tmp/test.txt

# 3. Refresh page

# 4. Verify tool results hidden in OFF mode, visible in ON mode

Expected Exit Code: All tests pass with exit code 0.

โš ๏ธ Common Pitfalls

1. Race Condition on History Load

Issue: Chat controller may load history before Dev Mode setting is read from localStorage.

Mitigation: Initialize Dev Mode synchronously from localStorage at module load time, not lazily.

// CORRECT: Synchronous initialization
const DEV_MODE = (() => {
  try {
    return JSON.parse(localStorage.getItem('openclaw_ui_settings') || '{}').openclaw_dev_mode ?? false;
  } catch {
    return false;
  }
})();

// INCORRECT: Lazy initialization (causes race)
const getDevMode = async () => loadSetting(...); // DON'T DO THIS

2. Message Classification Inconsistency

Issue: Some message producers (heartbeat, system events, plugins) may not set messageClass or internal flags.

Mitigation: Implement a schema validator in development mode:

// Add to buildChatItems
if (process.env.NODE_ENV === 'development') {
  history.forEach(item => {
    if (!item.messageClass && item.role === 'system') {
      console.warn('[Dev] System message missing messageClass:', item.id);
    }
  });
}

3. Docker/Container Environment localStorage

Issue: WebChat running inside Docker containers may have isolated localStorage behavior.

Workaround: Ensure Dev Mode preference persists via a backend API call in addition to localStorage:

// Fallback to server-side preferences
async function getDevMode() {
  const local = loadSetting('openclaw_dev_mode', false);
  const server = await fetch('/api/user/preferences/dev-mode').catch(() => null);
  return server ? await server.json() : local;
}

4. History Size and Memory

Issue: Loading full history with all internal messages may cause memory issues on long conversations.

Mitigation: Implement history pruning for internal messages when saving:

// When persisting chat state
function pruneInternalMessages(chat) {
  return {
    ...chat,
    history: chat.history.filter(item => 
      item.internal ? false : true // Don't persist internals
    )
  };
}

5. Multi-tab Synchronization

Issue: Dev Mode toggle in one tab may not sync to other open WebChat tabs.

Workaround: Listen to storage events:

window.addEventListener('storage', (e) => {
  if (e.key === 'openclaw_ui_settings') {
    // Re-render with updated settings
    forceUpdate();
  }
});

6. Browser Extension Conflicts

Issue: Extensions that modify localStorage or inject scripts may corrupt settings.

Detection: Add integrity check:

function validateSettings() {
  try {
    const settings = JSON.parse(localStorage.getItem('openclaw_ui_settings'));
    return typeof settings.openclaw_dev_mode === 'boolean';
  } catch {
    return false;
  }
}

Logically Connected Issues

  • #26461 โ€” History state corruption after rapid refresh cycles (related: state management during reload)
  • #21032 โ€” System messages appearing in conversation export (related: history filtering at export boundary)
  • #12186 โ€” Tool chain visibility not respecting UI preferences (closed/stale but similar filtering scope)

Similar Error Patterns

Error CodeDescriptionConnection
E_HEARTBEAT_LEAKHeartbeat responses visible in user-facing viewsDirect symptom of this bug
E_SYSTEM_MSG_LEAKSystem messages exposed in normal chat modeSame root cause as heartbeat leak
E_TOOLCHAIN_VISIBLETool execution chain visible in production UIRelated filtering issue in buildChatItems
E_HISTORY_RELOAD_ARTIFACTSStale internal entries appearing after page reloadHistory reload timing issue

Architectural Dependencies

  • ui/src/ui/controllers/chat.ts โ€” History loading logic
  • ui/src/ui/views/chat.ts โ€” buildChatItems filtering function
  • packages/heartbeat/src/handler.ts โ€” Heartbeat message insertion
  • packages/core/src/types/chat.ts โ€” Message type definitions

Evidence & Sources

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