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_OKvisible 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
| State | Expected | Actual (Bug) |
|---|---|---|
| Before refresh | Clean user/assistant conversation | Clean user/assistant conversation |
| After refresh | Clean user/assistant conversation | System/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
- User triggers internal event (heartbeat, system command)
- Internal message inserted into
chat.historywith classification flags (e.g.,role: 'system',internal: true,messageClass: 'heartbeat') - Page refresh occurs
chat.tsreloads complete history including internal entriesbuildChatItemsfails to filter these entries because filtering logic does not checkmessageClassorinternalflags- 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;
}
}
๐ Related Errors
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 Code | Description | Connection |
|---|---|---|
E_HEARTBEAT_LEAK | Heartbeat responses visible in user-facing views | Direct symptom of this bug |
E_SYSTEM_MSG_LEAK | System messages exposed in normal chat mode | Same root cause as heartbeat leak |
E_TOOLCHAIN_VISIBLE | Tool execution chain visible in production UI | Related filtering issue in buildChatItems |
E_HISTORY_RELOAD_ARTIFACTS | Stale internal entries appearing after page reload | History reload timing issue |
Architectural Dependencies
ui/src/ui/controllers/chat.tsโ History loading logicui/src/ui/views/chat.tsโbuildChatItemsfiltering functionpackages/heartbeat/src/handler.tsโ Heartbeat message insertionpackages/core/src/types/chat.tsโ Message type definitions