cd /news/artificial-intelligence/one-conversation-four-cards-the-dash… Β· home β€Ί topics β€Ί artificial-intelligence β€Ί article
[ARTICLE Β· art-107390] src=dev.to β†— pub= topic=artificial-intelligence verified=true sentiment=Β· neutral

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

Neverclosed, a 24/7 AI receptionist for small businesses, fixed a dashboard bug where a single conversation appeared as multiple cards due to three stacked root causes: two workers logging the same chat, inconsistent speaker labels breaking the parser, and an unstable merge key based on sliding content. The team deployed fixes including a single logger, expanded parser labels, and grouping by stable room ID.

read5 min views1 publishedAug 22, 2026

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

Neverclosed 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. Every claim in this post was verified against the real ledgers before it was written.

── more in #artificial-intelligence 4 stories Β· sorted by recency
── more on @neverclosed 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain β€” perfect for shipping the agent you just read about.

$git push zahid main
β†’ Live at https://your-agent.zahid.host βœ“
Get free account β†’ Pricing
from €0/mo Β· no card required
LIVE [news/one-conversation-fou…] indexed:0 read:5min 2026-08-22 Β· β€”