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. 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=