From 0dd462b60d94659e85ed20ec538c837d9c334c4f Mon Sep 17 00:00:00 2001 From: Cameron Date: Mon, 2 Mar 2026 16:56:16 -0800 Subject: [PATCH] fix: make heartbeat routing orthogonal to conversation mode (#463) --- src/core/bot.ts | 26 +++++++++++++++----------- src/core/conversation-key.test.ts | 25 ++++++++++++++++++++----- 2 files changed, 35 insertions(+), 16 deletions(-) diff --git a/src/core/bot.ts b/src/core/bot.ts index e20e932..a42cf1a 100644 --- a/src/core/bot.ts +++ b/src/core/bot.ts @@ -145,9 +145,10 @@ export function resolveConversationKey( /** * Pure function: resolve the conversation key for heartbeat/sendToAgent. - * In per-chat mode, uses the full channel:chatId of the last-active target. - * In per-channel mode, respects heartbeatConversation setting. - * In shared mode with overrides, respects override channels when using last-active. + * The heartbeat setting is orthogonal to conversation mode: + * - "dedicated" always returns "heartbeat" (isolated conversation) + * - "" always routes to that channel's conversation + * - "last-active" routes to wherever the user last messaged from */ export function resolveHeartbeatConversationKey( conversationMode: string | undefined, @@ -159,23 +160,26 @@ export function resolveHeartbeatConversationKey( if (conversationMode === 'disabled') return 'default'; const hb = heartbeatConversation || 'last-active'; + // "dedicated" always gets its own conversation, regardless of mode + if (hb === 'dedicated') return 'heartbeat'; + + // Explicit channel name — route to that channel's conversation + if (hb !== 'last-active') return hb; + + // "last-active" handling varies by mode if (conversationMode === 'per-chat') { - if (hb === 'dedicated') return 'heartbeat'; - if (hb === 'last-active' && lastActiveChannel && lastActiveChatId) { + if (lastActiveChannel && lastActiveChatId) { return `${lastActiveChannel.toLowerCase()}:${lastActiveChatId}`; } - // Fall back to shared if no last-active target return 'shared'; } if (conversationMode === 'per-channel') { - if (hb === 'dedicated') return 'heartbeat'; - if (hb === 'last-active') return lastActiveChannel ?? 'shared'; - return hb; + return lastActiveChannel ?? 'shared'; } - // shared mode — if last-active and overrides exist, respect the override channel - if (hb === 'last-active' && conversationOverrides.size > 0 && lastActiveChannel) { + // shared mode — if overrides exist, respect the override channel + if (conversationOverrides.size > 0 && lastActiveChannel) { return resolveConversationKey(lastActiveChannel, conversationMode, conversationOverrides); } diff --git a/src/core/conversation-key.test.ts b/src/core/conversation-key.test.ts index 6954d3d..529e603 100644 --- a/src/core/conversation-key.test.ts +++ b/src/core/conversation-key.test.ts @@ -140,10 +140,25 @@ describe('resolveHeartbeatConversationKey', () => { expect(resolveHeartbeatConversationKey('shared', 'last-active', overrides, undefined)).toBe('shared'); }); - it('returns "shared" in shared mode even with overrides when heartbeat is not last-active', () => { - // Non-last-active heartbeat in shared mode always returns 'shared' + // --- dedicated is orthogonal to mode --- + + it('returns \"heartbeat\" in shared mode with dedicated', () => { + expect(resolveHeartbeatConversationKey('shared', 'dedicated', new Set())).toBe('heartbeat'); + }); + + it('returns \"heartbeat\" in shared mode with dedicated even when overrides exist', () => { const overrides = new Set(['slack']); - expect(resolveHeartbeatConversationKey('shared', 'dedicated', overrides, 'slack')).toBe('shared'); + expect(resolveHeartbeatConversationKey('shared', 'dedicated', overrides, 'slack')).toBe('heartbeat'); + }); + + // --- explicit channel is orthogonal to mode --- + + it('returns explicit channel name in shared mode', () => { + expect(resolveHeartbeatConversationKey('shared', 'discord', new Set(), 'telegram')).toBe('discord'); + }); + + it('returns explicit channel name in per-chat mode', () => { + expect(resolveHeartbeatConversationKey('per-chat', 'discord', new Set(), 'telegram', '12345')).toBe('discord'); }); // --- per-chat mode --- @@ -152,7 +167,7 @@ describe('resolveHeartbeatConversationKey', () => { expect(resolveHeartbeatConversationKey('per-chat', 'last-active', new Set(), 'telegram', '12345')).toBe('telegram:12345'); }); - it('returns "heartbeat" in per-chat mode with dedicated', () => { + it('returns \"heartbeat\" in per-chat mode with dedicated', () => { expect(resolveHeartbeatConversationKey('per-chat', 'dedicated', new Set(), 'telegram', '12345')).toBe('heartbeat'); }); @@ -160,7 +175,7 @@ describe('resolveHeartbeatConversationKey', () => { expect(resolveHeartbeatConversationKey('per-chat', 'last-active', new Set(), 'telegram', undefined)).toBe('shared'); }); - it('falls back to "shared" in per-chat mode when no last-active target', () => { + it('falls back to \"shared\" in per-chat mode when no last-active target', () => { expect(resolveHeartbeatConversationKey('per-chat', 'last-active', new Set(), undefined, undefined)).toBe('shared'); });