Skip to content

Bug: AI Devtools ignores client-sourced assistant messages and streaming chunks #339

@tecoad

Description

@tecoad

Describe the bug

The AI Devtools panel only shows user messages. Assistant messages and streaming content never appear, even though the events are being emitted correctly by the ChatClient and flowing through the event bus.

This affects any setup where the server-side chat() events don't reach the devtools (e.g. Cloudflare Workers, or any isolated runtime where globalThis.__TANSTACK_EVENT_TARGET__ isn't shared with the Vite process).

Evidence

With debug: true enabled on both the Vite plugin and the eventBusConfig, we can see that:

  • The WebSocket connection between client and server bus works
  • text:chunk:content events are emitted by the ChatClient with source: 'client', full content, streamId, messageId, and clientId
  • These events flow correctly: browser → server bus → broadcast back to clients
  • But the devtools UI still shows nothing for the assistant

Root cause

Two issues in @tanstack/ai-devtools-core (ai-context.js):

1. text:chunk:content handler ignores clientId

// ai-context.js — text:chunk:content handler
aiEventClient.on("text:chunk:content", (e) => {
  const streamId = e.payload.streamId;
  const conversationId = streamToConversation.get(streamId); // ← only lookup
  // ...
});

streamToConversation is only populated by text:request:started events, which are emitted exclusively by the server-side TextEngine. The ChatClient never emits text:request:started, so client stream IDs are never mapped.

Other handlers (like text:message:created) use clientId || streamToConversation.get(streamId) as fallback — but all 6 text:chunk:* handlers do not.

2. text:message:created filters out empty assistant messages from client

// ai-context.js — text:message:created handler
if (role === "assistant" && source === "client" && !content && (!toolCalls || toolCalls.length === 0)) {
  return; // ← skips the message
}

ChatClient.processStream() calls messageAppended() right after the MESSAGE_START chunk — before any text content exists. So content is "", and the assistant message entry is never created in the devtools store.

Combined effect

  1. Assistant message creation → skipped (empty content)
  2. Streaming chunks → can't find conversation (no clientId fallback)
  3. Only user messages survive → devtools shows only user side

Workaround applied and confirmed working

We patched ai-context.tsx locally with two changes:

Fix 1 — Only skip empty assistant messages when server conversations exist (meaning server events are available):

if (role === 'assistant' && source === 'client' && !content && (!toolCalls || toolCalls.length === 0)) {
  const hasServerConversations = Object.values(state.conversations).some((c) => c.type === 'server')
  if (hasServerConversations) {
    return
  }
}

Fix 2 — Add clientId fallback to all 6 text:chunk:* handlers (matching the pattern already used by text:message:created):

const { streamId, clientId } = e.payload
const conversationId = clientId || (streamId ? streamToConversation.get(streamId) : undefined)

Both fixes together: assistant message entry is created (fix 1), and streaming chunks find the conversation via clientId to update it (fix 2). Confirmed working.

Root cause analysis — beyond the workaround

The workaround above makes the devtools resilient to missing server events, but the deeper issue is an asymmetry in the event protocol.

Why server events don't arrive in isolated runtimes

The chat() function emits events (including text:request:started) via globalThis.__TANSTACK_EVENT_TARGET__. The ServerEventBus (Vite plugin, port 4206) listens on that same globalThis. In Node.js this works because everything shares one process.

In Cloudflare Workers (workerd), SSR code runs in an isolated V8 context with its own globalThis. Events emitted by chat() never reach the Vite process — they're dispatched to an event target nobody listens to.

@tanstack/devtools-vite has no handling for isolated runtimes

We inspected the full source of @tanstack/devtools-vite, @tanstack/devtools-event-bus, and @tanstack/devtools-event-client. There is zero reference to import.meta.hot, HotChannel, environments.ssr, cloudflare, or workerd anywhere in the TanStack devtools stack.

The entire server-side event pipeline assumes SSR runs in the same Node.js process as Vite — all communication goes through globalThis.__TANSTACK_EVENT_TARGET__.

The infrastructure to fix this already exists

Vite's Environment API provides import.meta.hot custom events for cross-runtime communication. The @cloudflare/vite-plugin already uses this internally (e.g. import.meta.hot.send("vite-plugin-cloudflare:worker-export-types", ...) to send data from workerd to the Vite process).

This is the same WebSocket that the Cloudflare plugin already establishes for HMR — no new transport is needed.

Possible solutions (from least to most impactful)

Approach Scope Owner
Workaround (applied above) Devtools tolerates absence of server events ai-devtools-core
ChatClient emits text:request:started Client becomes self-sufficient in the event protocol @tanstack/ai-client
import.meta.hot bridge in devtools-vite All TanStack devtools work in isolated runtimes (not just AI) devtools-vite + devtools-event-client

Option 3 is the most impactful: modify @tanstack/devtools-event-client so that when globalThis.__TANSTACK_EVENT_TARGET__ doesn't exist (isolated runtime), it routes events through import.meta.hot.send() instead. Then add a listener in @tanstack/devtools-vite via server.environments.ssr.hot.on() to forward those events to the local EventTarget. This would fix the problem for all TanStack devtools (Router, Query, AI) in any isolated runtime — not just AI in Cloudflare Workers.

Steps to reproduce

  1. Use useChat + fetchServerSentEvents + chat() in any setup where server events don't reach the devtools store (e.g. Cloudflare Workers runtime)
  2. Enable debug: true on eventBusConfig to confirm events are flowing
  3. Send a message — assistant responds in the UI, but devtools only shows user messages

Expected behavior

Assistant messages and streaming content from client-sourced events should appear in the devtools, using clientId as fallback when streamToConversation has no mapping.

Package versions

  • @tanstack/ai: 0.6.1
  • @tanstack/ai-react: 0.6.1
  • @tanstack/react-ai-devtools: 0.2.10
  • @tanstack/react-devtools: 0.9.9

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions