{"slug": "one-conversation-four-cards-the-dashboard-bug-with-three-root-causes-stacked-on", "title": "One conversation, four cards: the dashboard bug with three root causes stacked on top of each other", "summary": "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.", "body_md": "*This is a submission for DEV's Summer Bug Smash: Clear the Lineup powered by Sentry.*\n\n[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).\n\nThe 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.\n\n**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.\n\nWhat 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.\n\n**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()`\n\n— so every conversation produced two events, written from different vantage points, milliseconds apart.\n\n**Root cause 2 — the two copies didn't even look alike.** The two workers labeled speakers differently: one wrote `Assistant:`\n\n, the other wrote `AI:`\n\n. The dashboard's transcript parser knew `Assistant`\n\nbut not `AI`\n\n— 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.\n\n**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.\n\nThe fixes are deployed in our production repo (private — it's our live business), so here are the exact changes.\n\n**Fix 1 — one logger, not two.** The Durable Object's logger became a no-op; the AI engine is now the single writer:\n\n```\n// ChatRoom DO — was double-logging alongside the engine's logger.\n// The engine is the single source of truth for chat events now.\nasync maybeLogChat() { this.loggedChat = true; return; }\n```\n\n**Fix 2 — teach the parser every label the system has ever used** (one line, half the bug):\n\n```\n// before: /^\\s*(Agent|Assistant|Caller|Visitor|User|You|Customer|Neverclosed team)\\s*:\\s*/i\n// after — AI and Bot recognized, replies no longer glue onto the customer's turn:\nconst re = /^\\s*(Agent|Assistant|AI|Bot|Caller|Visitor|User|You|Customer|Neverclosed team)\\s*:\\s*([\\s\\S]*)$/i;\n```\n\n**Fix 3 — key on the room ID, not on drifting content:**\n\n```\n// Every snapshot carries the takeover link: .../agent?room=<id>&...\n// The room ID is stable across sliding windows AND across which worker logged it.\nconst roomOf = (it) => {\n  const m = String(it.console || \"\").match(/[?&]room=([^&]+)/);\n  return m ? decodeURIComponent(m[1]) : \"\";\n};\n// Group by room when present; fall back to first-visitor-line\n// (2h window) for legacy events logged before links existed.\n```\n\nThe approach that actually cracked it, and the decisions worth stealing:\n\n**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:`\n\nvs `Assistant:`\n\nmismatch was visible in thirty seconds of reading real data — and invisible in any amount of code review.\n\n**2. Test against the shipping code, not a copy.** The test harness extracts `parseTurns`\n\n/ `groupConversations`\n\nfrom the deployed worker source at runtime, so the tests exercise the exact functions in production — no drift between \"what we tested\" and \"what runs.\"\n\n**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.\n\n**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.\n\n**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.\n\n**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.", "url": "https://wpnews.pro/news/one-conversation-four-cards-the-dashboard-bug-with-three-root-causes-stacked-on", "canonical_source": "https://dev.to/bryanw/one-conversation-four-cards-the-dashboard-bug-with-three-root-causes-stacked-on-top-of-each-other-294p", "published_at": "2026-08-22 21:43:21+00:00", "updated_at": "2026-08-22 22:13:17.247372+00:00", "lang": "en", "topics": ["artificial-intelligence", "ai-products", "developer-tools"], "entities": ["Neverclosed", "Cloudflare Workers", "Durable Objects", "KV", "Sentry"], "alternates": {"html": "https://wpnews.pro/news/one-conversation-four-cards-the-dashboard-bug-with-three-root-causes-stacked-on", "markdown": "https://wpnews.pro/news/one-conversation-four-cards-the-dashboard-bug-with-three-root-causes-stacked-on.md", "text": "https://wpnews.pro/news/one-conversation-four-cards-the-dashboard-bug-with-three-root-causes-stacked-on.txt", "jsonld": "https://wpnews.pro/news/one-conversation-four-cards-the-dashboard-bug-with-three-root-causes-stacked-on.jsonld"}}