April 24, 2026 β€’ Version: latest

Control UI Sessions Dropdown Shows Deleted Sessions from Stale Cache

After deleting a Discord session via API, the Control UI continues to display the deleted session in the dropdown due to stale frontend caching and missing cache invalidation on session deletion.

πŸ” Symptoms

Visual Manifestation

The user navigates to the Control UI at http://127.0.0.1:18789/ and observes the following behavior:

  • The sessions dropdown menu displays a Discord session that was previously deleted
  • The session key `agent:main:discord:channel:1481906920334950492` appears selectable despite no longer existing in the backend
  • Selecting the stale session entry produces no action or an error response

Technical Reproduction Steps

Step 1: Verify initial state

cat ~/.openclaw/agents/main/sessions/sessions.json | jq '.sessions | length'
# Returns: 3

Step 2: Delete a session via API

curl -X PATCH http://127.0.0.1:18792/api/sessions \
  -H "Content-Type: application/json" \
  -d '{"action": "delete", "sessionId": "agent:main:discord:channel:1481906920334950492"}'

Step 3: Confirm backend deletion

cat ~/.openclaw/agents/main/sessions/sessions.json | jq '.sessions[] | select(.id == "agent:main:discord:channel:1481906920334950492")'
# Returns: null (session no longer exists)

Step 4: Refresh Control UI dropdown

# After refreshing http://127.0.0.1:18789/
# The dropdown still shows the deleted session

Error Indicators

  • Network Tab: GET request to `/api/sessions` returns updated list without deleted session
  • Console Warning: No JavaScript errors thrown, but session selection fails silently
  • WebSocket: No session removal event broadcasted to connected UI clients

🧠 Root Cause

Architectural Analysis

The bug stems from a dual-layer caching problem in the OpenClaw Control UI architecture:

Layer 1: Frontend State Cache

The React/Vue frontend maintains a local state cache of sessions in useSessions or equivalent hook:

// Pseudocode representation of the problematic pattern
const [sessions, setSessions] = useState([]);
const [lastFetch, setLastFetch] = useState(0);

// Problem: Fetch only occurs on mount, not on external changes
useEffect(() => {
  fetch('/api/sessions').then(setSessions);
}, []);

The component lacks:

  • Polling interval to refresh session list
  • WebSocket subscription to `session:deleted` events
  • Invalidation trigger when deletion API returns success

Layer 2: In-Memory Server Cache

The Gateway server caches the sessions list in memory:

// src/gateway/session-manager.ts (hypothetical)
class SessionManager {
  private cache: Map<string, Session>;
  
  async deleteSession(id: string) {
    await this.backend.delete(id);  // Updates sessions.json
    this.cache.delete(id);          // Updates in-memory cache
    // BUG: Does not emit 'session:deleted' WebSocket event
  }
}

The deleteSession method fails to:

  • Broadcast a `session:deleted` event via WebSocket to all connected UI clients
  • Expose an SSE endpoint for real-time session updates
  • Implement a pub/sub mechanism for cache invalidation

Failure Sequence

  1. User deletes session via `/new` command or `sessions.patch` API
  2. Gateway updates `sessions.json` on disk
  3. Gateway invalidates internal in-memory cache
  4. FAILURE: No WebSocket `session:deleted` event is emitted
  5. FAILURE: Connected Control UI clients retain stale `sessions` state
  6. User sees ghost session in dropdown until page refresh (which may also use cached data)

Relevant Code Paths

ComponentFileIssue
Gateway Session Deletionsrc/gateway/session-manager.tsMissing emit('session:deleted') call
Frontend Session Hooksrc/ui/hooks/useSessions.tsNo WebSocket subscription for live updates
Session API Routesrc/gateway/api/sessions.tsReturns success without broadcasting event

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

Fix A: Add WebSocket Event Broadcast (Gateway Side)

File: src/gateway/session-manager.ts

Before:

async deleteSession(id: string): Promise<boolean> {
  const session = this.cache.get(id);
  if (!session) return false;
  
  await this.persist();  // Updates sessions.json
  this.cache.delete(id);
  return true;
}

After:

async deleteSession(id: string): Promise<boolean> {
  const session = this.cache.get(id);
  if (!session) return false;
  
  await this.persist();  // Updates sessions.json
  this.cache.delete(id);
  
  // Broadcast deletion event to all connected UI clients
  this.emit('session:deleted', { sessionId: id, timestamp: Date.now() });
  
  return true;
}

Fix B: Subscribe to WebSocket Events (Frontend Side)

File: src/ui/hooks/useSessions.ts

Before:

export function useSessions() {
  const [sessions, setSessions] = useState<Session[]>([]);
  
  useEffect(() => {
    fetch('/api/sessions').then(r => r.json()).then(setSessions);
  }, []);
  
  return { sessions };
}

After:

export function useSessions() {
  const [sessions, setSessions] = useState<Session[]>([]);
  
  useEffect(() => {
    // Initial fetch
    fetch('/api/sessions').then(r => r.json()).then(setSessions);
    
    // WebSocket subscription for real-time updates
    const ws = new WebSocket('ws://127.0.0.1:18793/events');
    
    ws.onmessage = (event) => {
      const data = JSON.parse(event.data);
      
      if (data.type === 'session:deleted') {
        setSessions(prev => prev.filter(s => s.id !== data.sessionId));
      }
      
      if (data.type === 'session:created') {
        setSessions(prev => [...prev, data.session]);
      }
    };
    
    return () => ws.close();
  }, []);
  
  return { sessions };
}

Fix C: Client-Side Cache Busting (Alternative/Additional)

File: src/ui/components/SessionsDropdown.tsx

Before:

const sessions = useSessions();  // Static snapshot

return (
  <select>
    {sessions.map(s => <option key={s.id}>{s.name}</option>)}
  </select>
);

After:

const sessions = useSessions();
const [lastRefresh, setLastRefresh] = useState(Date.now());

// Force refresh when gaining focus (user clicks dropdown)
const handleDropdownFocus = async () => {
  const response = await fetch('/api/sessions?_=' + Date.now());
  const freshSessions = await response.json();
  setSessions(freshSessions);
  setLastRefresh(Date.now());
};

return (
  <select onFocus={handleDropdownFocus}>
    {sessions.map(s => <option key={s.id}>{s.name}</option>)}
  </select>
);

Fix D: API Response Enhancement (Optional)

File: src/gateway/api/sessions.ts

Before:

router.delete('/:sessionId', async (ctx) => {
  await sessionManager.deleteSession(ctx.params.sessionId);
  ctx.status = 204;
});

After:

router.delete('/:sessionId', async (ctx) => {
  const deleted = await sessionManager.deleteSession(ctx.params.sessionId);
  
  if (!deleted) {
    ctx.status = 404;
    ctx.body = { error: 'Session not found' };
    return;
  }
  
  // Emit WebSocket event for real-time UI updates
  ctx.ws.send(JSON.stringify({
    type: 'session:deleted',
    sessionId: ctx.params.sessionId
  }));
  
  ctx.status = 204;
});

πŸ§ͺ Verification

Test 1: Backend Session Deletion

# 1. Create a test session (if needed)
curl -X POST http://127.0.0.1:18792/api/sessions \
  -H "Content-Type: application/json" \
  -d '{"type": "discord:channel", "name": "test-session"}'

# 2. Verify session exists
curl http://127.0.0.1:18792/api/sessions | jq '.[] | .id'

# 3. Delete the session
curl -X DELETE http://127.0.0.1:18792/api/sessions/TEST_SESSION_ID

# 4. Verify deletion in backend storage
cat ~/.openclaw/agents/main/sessions/sessions.json | jq '.sessions'
# Expected: Session ID should not appear in output

Test 2: WebSocket Event Emission

# 1. Connect WebSocket client to event stream
node -e "
const ws = new WebSocket('ws://127.0.0.1:18793/events');
ws.onmessage = (e) => console.log('Event:', JSON.parse(e.data));
ws.onopen = () => console.log('Connected');
"

# 2. Delete a session from another terminal
curl -X DELETE http://127.0.0.1:18792/api/sessions/TARGET_SESSION_ID

# 3. Expected WebSocket output:
# Event: { type: 'session:deleted', sessionId: 'TARGET_SESSION_ID', timestamp: 1234567890 }

Test 3: Control UI Real-Time Update

# 1. Open Control UI in browser: http://127.0.0.1:18789/
# 2. Open Browser DevTools > Console

# 3. Verify session list loads
console.log('Sessions:', window.__OPENCLAW_SESSIONS__);

# 4. Delete session via API
curl -X DELETE http://127.0.0.1:18792/api/sessions/TARGET_SESSION_ID

# 5. Verify dropdown updates automatically (within 500ms)
# Expected: Deleted session disappears from dropdown without page refresh

Test 4: Verification Checklist

CheckpointCommand/ActionExpected Result
Backend deletioncat sessions.jsonSession key absent
WebSocket eventWebSocket listenersession:deleted event received
UI state updateObserve dropdownSession removed within 500ms
Error resilienceDelete non-existent session404 response, no UI crash

Exit Code Verification

# Successful deletion returns 204
curl -s -o /dev/null -w "%{http_code}" -X DELETE \
  http://127.0.0.1:18792/api/sessions/EXISTING_ID
# Expected: 204

# Non-existent session returns 404
curl -s -o /dev/null -w "%{http_code}" -X DELETE \
  http://127.0.0.1:18792/api/sessions/NONEXISTENT_ID
# Expected: 404

⚠️ Common Pitfalls

1. WebSocket Connection Not Initializing

Symptom: UI shows “Connecting…” indefinitely, sessions never load.

Cause: WebSocket server not running on expected port (18793).

# Verify WebSocket port availability
lsof -i :18793
# Expected: Node process listening on port 18793

Resolution: Ensure Gateway starts WebSocket server before HTTP API:

// Correct startup order in src/gateway/index.ts
const wsServer = new WebSocketServer({ port: 18793 });
const httpServer = new HttpServer({ port: 18792 });
await Promise.all([wsServer.start(), httpServer.start()]);

2. Race Condition on Rapid Deletions

Symptom: Multiple rapid deletions cause UI to show inconsistent state.

Cause: WebSocket events fire before React state batch update completes.

Resolution: Implement optimistic UI updates with reconciliation:

ws.onmessage = (e) => {
  const { type, sessionId } = JSON.parse(e.data);
  
  if (type === 'session:deleted') {
    // Optimistically remove, then verify with fresh fetch
    setSessions(prev => prev.filter(s => s.id !== sessionId));
    fetchFreshSessions();  // Debounced to prevent flooding
  }
};

3. CORS Blocking WebSocket on Remote Access

Symptom: Works on localhost but fails when accessing via network IP.

Cause: WebSocket CORS headers not configured.

# Test CORS preflight
curl -H "Origin: http://192.168.1.100:18789" \
     -X OPTIONS ws://127.0.0.1:18793/events
# Expected: 101 Switching Protocols

Resolution: Configure WebSocket server with CORS:

const wsServer = new WebSocketServer({
  port: 18793,
  cors: {
    origin: ['http://127.0.0.1:18789', 'http://localhost:18789'],
    credentials: true
  }
});

4. Sessions File Permissions (Linux/macOS)

Symptom: “Permission denied” when deleting sessions.

Cause: sessions.json owned by different user or has incorrect permissions.

# Check permissions
ls -la ~/.openclaw/agents/main/sessions/sessions.json

# Fix permissions (if needed)
chmod 644 ~/.openclaw/agents/main/sessions/sessions.json
chown $USER:staff ~/.openclaw/agents/main/sessions/sessions.json

5. Docker Volume Mounting Issues

Symptom: Sessions persist after container restart but changes not reflected in UI.

Cause: Docker volume not properly syncing file changes.

# Verify volume mount
docker inspect openclaw-agent --format '{{json .Mounts}}' | jq

# Check if sessions.json exists inside container
docker exec openclaw-agent ls -la /app/.openclaw/agents/main/sessions/

Resolution: Use bind mounts with proper synchronization:

docker run -v $(pwd)/openclaw-data:/app/.openclaw:rw ...
# NOT: docker run -v openclaw-data:/app/.openclaw:ro

6. Browser Cache (Force Refresh Required)

Symptom: Old UI JavaScript bundles served, missing WebSocket subscription code.

Cause: Browser caches old build assets.

# Hard refresh instructions for users
# Chrome: Ctrl+Shift+R (Windows/Linux) or Cmd+Shift+R (macOS)
# Firefox: Ctrl+Shift+R (Windows/Linux) or Cmd+Shift+R (macOS)

# Clear service worker cache
chrome://inspect/#service-workers -> Unregister
  • E_SESSION_NOT_FOUND β€” Returned when attempting operations on deleted session. Verify session exists via `GET /api/sessions` before performing actions.
  • ECONNREFUSED β€” Gateway not listening on expected port. Check that `openclaw-gateway` process is running: `ps aux | grep openclaw-gateway`.
  • WS_CONNECTION_FAILED β€” WebSocket handshake rejected. Ensure WebSocket server started successfully and CORS headers are configured for the UI origin.
  • E_SESSION_CACHE_STALE β€” Internal error when in-memory cache diverges from `sessions.json`. Restart gateway service to resync: `openclaw-cli gateway restart`.
  • Issue: Control UI sessions dropdown unresponsive after session switch β€” Related to the same caching mechanism. Fix involves identical WebSocket broadcast on `session:switched` event.
  • Issue: New sessions not appearing in Control UI until refresh β€” Inverse manifestation of this bug. Sessions created via CLI `/new` not reflected in UI dropdown until page refresh.
  • Issue: Race condition in session list updates β€” Multiple rapid create/delete operations can cause UI to temporarily show incorrect session counts. Implement debounced fetch with optimistic locking.
  • Historical: sessions.json schema mismatch β€” Prior versions used different session key format (`discord:channel:ID` vs `agent:main:discord:channel:ID`). Ensure UI correctly parses legacy format during migration.

Cross-Reference Matrix

IssueRoot CauseFix Area
Ghost sessions in dropdownMissing WebSocket broadcastGateway event emitter
UI not updating on deleteNo subscription to eventsFrontend hook
Stale data on page reloadMissing cache headersHTTP API response
Inconsistent multi-tab stateNo broadcast coordinationWebSocket pub/sub

Evidence & Sources

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