# One conversation, four cards: the dashboard bug with three root causes stacked on top of each other

> Source: <https://dev.to/bryanw/one-conversation-four-cards-the-dashboard-bug-with-three-root-causes-stacked-on-top-of-each-other-294p>
> Published: 2026-08-22 21:43:21+00:00

*This is a submission for DEV's Summer Bug Smash: Clear the Lineup powered by Sentry.*

[Neverclosed](https://tryneverclosed.com) is a 24/7 AI receptionist for small businesses — it answers website chats and phone calls, books appointments, and hands off to a human the moment a customer asks. It runs on Cloudflare Workers (Durable Objects for live chat, KV for the event log).

The piece this bug lives in is the **owner dashboard**: every conversation the AI handles gets logged so the business owner can read exactly what was said. For a product whose whole pitch is *"an AI you can audit,"* the dashboard being trustworthy isn't a nice-to-have — it IS the product.

**Symptom:** one conversation was showing up as two, three, sometimes four separate cards. A single customer chat looked like a crowd. The owner-facing view of "what happened today" was unreadable clutter, and worse — it made the log look untrustworthy.

What made this one nasty: it wasn't one bug. It was **three independent root causes stacked**, so fixing any single one didn't fix the symptom. Fix the parser? Still duplicated. Fix the grouping? Still duplicated. Each fix looked like a failure until all three were found. Here's the autopsy, in the order we found them.

**Root cause 1 — two workers were logging the same chat.** Our website chat has two layers: the live-chat Durable Object (handles the WebSocket, human takeover) and the AI engine worker it calls for replies. A refactor left BOTH layers calling their own `maybeLogChat()`

— so every conversation produced two events, written from different vantage points, milliseconds apart.

**Root cause 2 — the two copies didn't even look alike.** The two workers labeled speakers differently: one wrote `Assistant:`

, the other wrote `AI:`

. The dashboard's transcript parser knew `Assistant`

but not `AI`

— so in one copy of the conversation, the AI's replies weren't recognized as a speaker line at all and got **glued onto the end of the customer's message**. That broke the transcript rendering AND any hope of content-based deduplication, because the two copies of the "same" conversation now had genuinely different text.

**Root cause 3 — the merge key itself was unstable.** To keep events small, the engine logs a rolling snapshot: only the **last 8 messages** of the conversation. Our dedup keyed anonymous chats on "first visitor line" — but in a long conversation, the window slides, so the "first line" of snapshot #3 isn't the first line of snapshot #1. Long chats fragmented into multiple cards *by design*. The fix: every snapshot already carries the owner's live-takeover link, which contains the chat's **room ID** — a key that never changes no matter how the window slides or which worker wrote the event.

The fixes are deployed in our production repo (private — it's our live business), so here are the exact changes.

**Fix 1 — one logger, not two.** The Durable Object's logger became a no-op; the AI engine is now the single writer:

```
// ChatRoom DO — was double-logging alongside the engine's logger.
// The engine is the single source of truth for chat events now.
async maybeLogChat() { this.loggedChat = true; return; }
```

**Fix 2 — teach the parser every label the system has ever used** (one line, half the bug):

```
// before: /^\s*(Agent|Assistant|Caller|Visitor|User|You|Customer|Neverclosed team)\s*:\s*/i
// after — AI and Bot recognized, replies no longer glue onto the customer's turn:
const re = /^\s*(Agent|Assistant|AI|Bot|Caller|Visitor|User|You|Customer|Neverclosed team)\s*:\s*([\s\S]*)$/i;
```

**Fix 3 — key on the room ID, not on drifting content:**

```
// Every snapshot carries the takeover link: .../agent?room=<id>&...
// The room ID is stable across sliding windows AND across which worker logged it.
const roomOf = (it) => {
  const m = String(it.console || "").match(/[?&]room=([^&]+)/);
  return m ? decodeURIComponent(m[1]) : "";
};
// Group by room when present; fall back to first-visitor-line
// (2h window) for legacy events logged before links existed.
```

The approach that actually cracked it, and the decisions worth stealing:

**1. Measure before patching.** The first instinct ("just dedupe by transcript text") would have shipped and failed — root cause 2 meant the two copies had *different text*. Before writing any fix, we pulled the real duplicated events out of KV and looked at them side by side. The `AI:`

vs `Assistant:`

mismatch was visible in thirty seconds of reading real data — and invisible in any amount of code review.

**2. Test against the shipping code, not a copy.** The test harness extracts `parseTurns`

/ `groupConversations`

from the deployed worker source at runtime, so the tests exercise the exact functions in production — no drift between "what we tested" and "what runs."

**3. Synthetic controls for the sliding window.** Real dup events weren't enough — we built synthetic cases: a long chat logged as 3 different 8-message windows (must collapse to ONE card via room ID), and two different visitors with identical opening lines (must stay SEPARATE). Both directions matter: recall without precision is just a different bug.

**4. Verify at the data layer, live.** After deploy: a fresh conversation on the production site, then read the KV event log directly — exactly **1 event** for the new chat where the same flow used to write 2. The dashboard now shows one card per conversation.

**One honest caveat**, because our whole operation runs on receipts: events logged *before* the fix can't gain room IDs retroactively. The display-layer merge catches most legacy duplicates; a handful of old fragmented chats remain separate until they age out. Forward-looking, the dedup is structural.

**Disclosure, proudly:** I'm self-taught (April 2026 → now) and I build with my AI partner — I supply direction, corrections, and the standard that nothing ships unverified; it drives the debugging and writes code faster than I ever will. This bug hunt was the two of us at 6 AM reading production events. If that's interesting to you, the whole story is in [my first post](https://dev.to/bryanw/4-months-ago-i-couldnt-code-then-i-started-treating-my-ai-like-a-partner-instead-of-a-tool-p46). Every claim in this post was verified against the real ledgers before it was written.
