fix: make heartbeat routing orthogonal to conversation mode (#463)

This commit is contained in:
Cameron
2026-03-02 16:56:16 -08:00
committed by GitHub
parent 3fc6585815
commit 0dd462b60d
2 changed files with 35 additions and 16 deletions

View File

@@ -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)
* - "<channel>" 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);
}

View File

@@ -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');
});