{"repo":"NousResearch/hermes-agent","public_id":"33360","display_label":"WhatsApp bridge cross-chat message contamination fix","summary":"These items concern a bug where messages or replies intended for one WhatsApp chat appear in another, and work to serialize sendMessage calls and improve session isolation to prevent this cross-chat contamination.","subsystem":"gateway","counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":54062,"merged_at":"2026-06-28T08:10:15+00:00"},"resolution":{"state":"resolved","by":"pr-54062"},"freshness":{"state":"verified","last_verified_at":"2026-06-28T08:10:15+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":33360,"pr_number":54062,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-06-28T08:10:15+00:00"}],"members":[{"kind":"issue","number":33360,"title":"WhatsApp: Cross-chat response contamination — replies from one chat appear in another","url":null,"state":"closed"},{"kind":"pr","number":33419,"title":"fix(whatsapp-bridge): serialize sendMessage to prevent cross-chat contamination (#33360)","url":null,"state":"closed","close_reason":{"category":"silent_close","implication":"active"},"merged":false},{"kind":"pr","number":35503,"title":"fix(whatsapp-bridge): serialize sendMessage to prevent cross-chat contamination (#33360)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"0800f1c28b80","number":0},{"kind":"pr","value":"#54062","number":54062}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":54062,"title":"fix(whatsapp-bridge): serialize sendMessage to prevent cross-chat contamination (#33360)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["infographic/whatsapp-send-queue/infographic.png","scripts/whatsapp-bridge/bridge.js","scripts/whatsapp-bridge/bridge.sendqueue.test.mjs"],"fix_hunks":[{"file":"scripts/whatsapp-bridge/bridge.js","hunks":["@@ -83,6 +83,19 @@ const CHUNK_DELAY_MS = parseInt(process.env.WHATSAPP_CHUNK_DELAY_MS || '300', 10\n // fires. Fail fast instead so the gateway can surface a real error and retry.\n const SEND_TIMEOUT_MS = parseInt(process.env.WHATSAPP_SEND_TIMEOUT_MS || '60000', 10);\n \n+// --- Send queue: serialise all sock.sendMessage() calls across concurrent\n+//     HTTP handlers so a single Baileys socket never has overlapping sends.\n+//     Overlapping sends are the root cause of cross-chat contamination\n+//     (#33360) — the WhatsApp protocol-level routing can misdeliver when\n+//     two sendMessage() Promises race on the same socket. ---\n+let _sendQueue = Promise.resolve();\n+\n+function enqueueSend(fn) {\n+  const task = _sendQueue.then(() => fn(), () => fn());\n+  _sendQueue = task.catch(() => {});\n+  return task;\n+}\n+\n function sleep(ms) {\n   return new Promise(resolve => setTimeout(resolve, ms));\n }","@@ -95,8 +108,10 @@ function sendWithTimeout(chatId, payload, timeoutMs = SEND_TIMEOUT_MS) {\n       timeoutMs,\n     );\n   });\n-  return Promise.race([sock.sendMessage(chatId, payload), timeoutPromise])\n-    .finally(() => clearTimeout(timer));\n+  return enqueueSend(() =>\n+    Promise.race([sock.sendMessage(chatId, payload), timeoutPromise])\n+      .finally(() => clearTimeout(timer))\n+  );\n }\n \n function formatOutgoingMessage(message) {"]},{"file":"scripts/whatsapp-bridge/bridge.sendqueue.test.mjs","hunks":["@@ -0,0 +1,112 @@\n+/**\n+ * Regression tests for the WhatsApp bridge send queue (#33360).\n+ *\n+ * The bridge must serialise all sock.sendMessage() calls through a\n+ * promise-based queue so that concurrent HTTP /send requests never\n+ * produce overlapping Baileys socket writes.  Overlapping writes are\n+ * the confirmed root cause of cross-chat contamination.\n+ *\n+ * These tests exercise the queue itself — they do NOT require a live\n+ * WhatsApp socket.\n+ */\n+\n+import { strict as assert } from 'node:assert';\n+\n+// ------------------------------------------------------------------\n+// 1.  Unit test for the queue primitives\n+// ------------------------------------------------------------------\n+\n+/**\n+ * Replicate the queue logic from bridge.js so we can test it in\n+ * isolation without importing the full module (which would trigger\n+ * Baileys / express side effects).\n+ */\n+function createSendQueue() {\n+  let _sendQueue = Promise.resolve();\n+\n+  function enqueueSend(fn) {\n+    const task = _sendQueue.then(() => fn(), () => fn());\n+    _sendQueue = task.catch(() => {});\n+    return task;\n+  }\n+\n+  return { enqueueSend };\n+}\n+\n+// -- serial ordering -------------------------------------------------\n+{\n+  const { enqueueSend } = createSendQueue();\n+  const order = [];\n+\n+  const a = enqueueSend(async () => {\n+    await new Promise(r => setTimeout(r, 30));\n+    order.push('a');\n+    return 'A';\n+  });\n+  const b = enqueueSend(async () => {\n+    order.push('b');\n+    return 'B';\n+  });\n+  const c = enqueueSend(async () => {\n+    await new Promise(r => setTimeout(r, 10));\n+    order.push('c');\n+    return 'C';\n+  });\n+\n+  const results = await Promise.all([a, b, c]);\n+  assert.deepStrictEqual(results, ['A', 'B', 'C'], 'all tasks resolve');\n+  assert.deepStrictEqual(order, ['a', 'b', 'c'], 'tasks execute in FIFO order');\n+  console.log('  ✓ serial ordering');\n+}\n+\n+// -- error isolation (one rejection does not stall the queue) --------\n+{\n+  const { enqueueSend } = createSendQueue();\n+  const order = [];\n+\n+  const bad = enqueueSend(async () => {\n+    order.push('bad');\n+    throw new Error('boom');\n+  });\n+  const good = enqueueSend(async () => {\n+    order.push('good');\n+    return 'ok';\n+  });\n+\n+  await assert.rejects(() => bad, /boom/, 'bad task rejects');\n+  const g = await good;\n+  assert.strictEqual(g, 'ok', 'good task still resolves');\n+  assert.deepStrictEqual(order, ['bad', 'good'], 'good runs after bad');\n+  console.log('  ✓ error isolation');\n+}\n+\n+// -- timeout still fires (wrapped inside enqueueSend) ----------------\n+{\n+  const { enqueueSend } = createSendQueue();\n+  const timedOut = enqueueSend(async () => {\n+    await new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 20));\n+  });\n+  await assert.rejects(() => timedOut, /timeout/, 'inner timeout propagates');\n+  console.log('  ✓ timeout propagation');\n+}\n+\n+// -- concurrent enqueues maintain single-consumer semantics ----------\n+{\n+  const { enqueueSend } = createSendQueue();\n+  let concurrent = 0;\n+  let maxConcurrent = 0;\n+\n+  async function tracked() {\n+    concurrent += 1;\n+    if (concurrent > maxConcurrent) maxConcurrent = concurrent;\n+    await new Promise(r => setTimeout(r, 5));\n+    concurrent -= 1;\n+  }\n+\n+  await Promise.all(Array.from({ length: 20 }, () => enqueueSend(tracked)));\n+  assert.strictEqual(maxConcurrent, 1, 'never more than one in-flight');\n+  assert.strictEqual(concurrent, 0, 'all finished');\n+  console.log('  ✓ single-consumer concurrency');\n+}\n+\n+console.log('\\n✅ All send-queue tests passed.');\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}