diff --git a/config.json b/config.json index 6e42ab34..cc8b32d2 100644 --- a/config.json +++ b/config.json @@ -6,7 +6,12 @@ "systemPrompt": "You are Volvox Bot, the friendly AI assistant for the Volvox developer community Discord server.\n\nYou're witty, snarky (but warm), and deeply knowledgeable about programming, software development, and tech.\n\nKey traits:\n- Helpful but not boring\n- Can roast people lightly when appropriate\n- Enthusiastic about cool tech and projects\n- Supportive of beginners learning to code\n- Concise - this is Discord, not an essay\n\n⚠️ CRITICAL RULES:\n- NEVER type @.everyone or @.here (remove the dots) - these ping hundreds of people\n- NEVER use mass mention pings under any circumstances\n- If you need to address the group, say \"everyone\" or \"folks\" without the @ symbol\n\nKeep responses under 2000 chars. Use Discord markdown when helpful.", "channels": [], "historyLength": 20, - "historyTTLDays": 30 + "historyTTLDays": 30, + "threadMode": { + "enabled": false, + "autoArchiveMinutes": 60, + "reuseWindowMinutes": 30 + } }, "chimeIn": { "enabled": false, diff --git a/src/modules/events.js b/src/modules/events.js index 9de7d358..88fbb157 100644 --- a/src/modules/events.js +++ b/src/modules/events.js @@ -10,6 +10,7 @@ import { needsSplitting, splitMessage } from '../utils/splitMessage.js'; import { generateResponse } from './ai.js'; import { accumulate, resetCounter } from './chimeIn.js'; import { isSpam, sendSpamAlert } from './spam.js'; +import { getOrCreateThread, shouldUseThread } from './threading.js'; import { recordCommunityActivity, sendWelcomeMessage } from './welcome.js'; /** @type {boolean} Guard against duplicate process-level handler registration */ @@ -54,10 +55,10 @@ export function registerGuildMemberAddHandler(client, config) { } /** - * Register message create event handler - * @param {Client} client - Discord client - * @param {Object} config - Bot configuration - * @param {Object} healthMonitor - Health monitor instance + * Register the MessageCreate event handler that processes incoming messages for spam detection, community activity recording, AI-driven replies (mentions/replies, optional threading, channel whitelisting), and organic chime-in accumulation. + * @param {Client} client - Discord client instance used to listen and respond to message events. + * @param {Object} config - Bot configuration (reads moderation.enabled, ai.enabled, ai.channels and other settings referenced by handlers). + * @param {Object} healthMonitor - Optional health monitor used when generating AI responses to record metrics. */ export function registerMessageCreateHandler(client, config, healthMonitor) { client.on(Events.MessageCreate, async (message) => { @@ -81,9 +82,14 @@ export function registerMessageCreateHandler(client, config, healthMonitor) { const isReply = message.reference && message.mentions.repliedUser?.id === client.user.id; // Check if in allowed channel (if configured) + // When inside a thread, check the parent channel ID against the allowlist + // so thread replies aren't blocked by the whitelist. const allowedChannels = config.ai?.channels || []; + const channelIdToCheck = message.channel.isThread?.() + ? message.channel.parentId + : message.channel.id; const isAllowedChannel = - allowedChannels.length === 0 || allowedChannels.includes(message.channel.id); + allowedChannels.length === 0 || allowedChannels.includes(channelIdToCheck); if ((isMentioned || isReply) && isAllowedChannel) { // Reset chime-in counter so we don't double-respond @@ -100,10 +106,25 @@ export function registerMessageCreateHandler(client, config, healthMonitor) { return; } - await message.channel.sendTyping(); + // Determine whether to use threading + const useThread = shouldUseThread(message); + let targetChannel = message.channel; + + if (useThread) { + const { thread } = await getOrCreateThread(message, cleanContent); + if (thread) { + targetChannel = thread; + } + // If thread is null, fall back to inline reply (targetChannel stays as message.channel) + } + + await targetChannel.sendTyping(); + + // Use thread ID for conversation history when in a thread, otherwise channel ID + const historyId = targetChannel.id; const response = await generateResponse( - message.channel.id, + historyId, cleanContent, message.author.username, config, @@ -114,10 +135,14 @@ export function registerMessageCreateHandler(client, config, healthMonitor) { if (needsSplitting(response)) { const chunks = splitMessage(response); for (const chunk of chunks) { - await message.channel.send(chunk); + await targetChannel.send(chunk); } - } else { + } else if (targetChannel === message.channel) { + // Inline reply — use message.reply for the reference await message.reply(response); + } else { + // Thread reply — send directly to the thread + await targetChannel.send(response); } } catch (sendErr) { logError('Failed to send AI response', { diff --git a/src/modules/threading.js b/src/modules/threading.js new file mode 100644 index 00000000..613c7ef2 --- /dev/null +++ b/src/modules/threading.js @@ -0,0 +1,430 @@ +/** + * Threading Module + * Manages Discord thread creation and reuse for AI conversations. + * + * When the bot is @mentioned in a regular channel, instead of replying inline, + * it creates (or reuses) a thread and continues the conversation there. + * This keeps channels clean while preserving conversation context. + */ + +import { ChannelType, PermissionFlagsBits } from 'discord.js'; +import { info, error as logError, warn } from '../logger.js'; +import { getConfig } from './config.js'; + +/** + * Active thread tracker: Map<`${userId}:${channelId}`, { threadId, lastActive, threadName }> + * Tracks which thread to reuse for a given user+channel combination. + * Entries are evicted by a periodic sweep and a max-size cap. + */ +const activeThreads = new Map(); + +/** Maximum number of entries in the activeThreads cache */ +const MAX_CACHE_SIZE = 1000; + +/** Eviction sweep interval in milliseconds (5 minutes) */ +const EVICTION_INTERVAL_MS = 5 * 60 * 1000; + +/** Default thread auto-archive duration in minutes */ +const DEFAULT_AUTO_ARCHIVE_MINUTES = 60; + +/** Default thread reuse window in milliseconds (30 minutes) */ +const DEFAULT_REUSE_WINDOW_MS = 30 * 60 * 1000; + +/** Maximum thread name length (Discord limit) */ +const MAX_THREAD_NAME_LENGTH = 100; + +/** Discord's allowed autoArchiveDuration values (minutes) */ +const VALID_AUTO_ARCHIVE_DURATIONS = [60, 1440, 4320, 10080]; + +/** + * Snap a value to the nearest valid Discord autoArchiveDuration. + * @param {number} minutes - Desired archive duration in minutes + * @returns {number} Nearest valid Discord autoArchiveDuration + */ +export function snapAutoArchiveDuration(minutes) { + if (typeof minutes !== 'number' || Number.isNaN(minutes) || minutes <= 0) { + return DEFAULT_AUTO_ARCHIVE_MINUTES; + } + let closest = VALID_AUTO_ARCHIVE_DURATIONS[0]; + let minDiff = Math.abs(minutes - closest); + for (const valid of VALID_AUTO_ARCHIVE_DURATIONS) { + const diff = Math.abs(minutes - valid); + if (diff < minDiff) { + minDiff = diff; + closest = valid; + } + } + return closest; +} + +/** + * Retrieve threading configuration derived from the bot config, falling back to sensible defaults. + * @returns {{ enabled: boolean, autoArchiveMinutes: number, reuseWindowMs: number }} An object where `enabled` is `true` if threading is enabled; `autoArchiveMinutes` is the thread auto-archive duration in minutes; and `reuseWindowMs` is the thread reuse window in milliseconds. + */ +export function getThreadConfig() { + try { + const config = getConfig(); + const threadMode = config?.ai?.threadMode; + + const rawArchive = threadMode?.autoArchiveMinutes; + const autoArchiveMinutes = snapAutoArchiveDuration( + typeof rawArchive === 'number' && !Number.isNaN(rawArchive) + ? rawArchive + : DEFAULT_AUTO_ARCHIVE_MINUTES, + ); + + const rawReuse = threadMode?.reuseWindowMinutes; + const reuseMinutes = + typeof rawReuse === 'number' && !Number.isNaN(rawReuse) && rawReuse > 0 ? rawReuse : 30; + + return { + enabled: threadMode?.enabled ?? false, + autoArchiveMinutes, + reuseWindowMs: reuseMinutes * 60 * 1000, + }; + } catch { + return { + enabled: false, + autoArchiveMinutes: DEFAULT_AUTO_ARCHIVE_MINUTES, + reuseWindowMs: DEFAULT_REUSE_WINDOW_MS, + }; + } +} + +/** + * Determine whether a given Discord message should be handled in a thread. + * @param {import('discord.js').Message} message - The message to evaluate. + * @returns {boolean} `true` if the message is eligible for thread handling, `false` otherwise. + */ +export function shouldUseThread(message) { + const threadConfig = getThreadConfig(); + if (!threadConfig.enabled) return false; + + // Don't create threads in DMs + if (!message.guild) return false; + + // Don't create threads inside existing threads — reply inline + if (message.channel.isThread()) return false; + + // Channel must be a text-based guild channel that supports threads + const threadableTypes = [ChannelType.GuildText, ChannelType.GuildAnnouncement]; + if (!threadableTypes.includes(message.channel.type)) return false; + + return true; +} + +/** + * Determines whether the bot can create public threads and send messages in threads for the message's channel. + * @param {import('discord.js').Message} message - The triggering Discord message. + * @returns {boolean} `true` if the bot has CreatePublicThreads and SendMessagesInThreads permissions in the channel and the message is in a guild, `false` otherwise. + */ +export function canCreateThread(message) { + if (!message.guild) return false; + + try { + const botMember = message.guild.members.me; + if (!botMember) return false; + + const permissions = message.channel.permissionsFor(botMember); + if (!permissions) return false; + + return ( + permissions.has(PermissionFlagsBits.CreatePublicThreads) && + permissions.has(PermissionFlagsBits.SendMessagesInThreads) + ); + } catch (err) { + warn('Failed to check thread permissions', { error: err.message }); + return false; + } +} + +/** + * Build a Discord thread name from a user's display name and the first line of their message. + * @param {string} username - The user's display name used as a prefix. + * @param {string} messageContent - The cleaned message content; only its first line is used. + * @returns {string} The constructed thread name, truncated to fit Discord's length limit. + */ +export function generateThreadName(username, messageContent) { + // Use first line of message content, truncated + const firstLine = messageContent.split('\n')[0].trim(); + + // Clamp username to leave room for at least a few content chars or the fallback format + const maxUsernameLength = MAX_THREAD_NAME_LENGTH - 10; // reserve 10 chars minimum + const safeUsername = + username.length > maxUsernameLength + ? `${username.substring(0, maxUsernameLength - 1)}…` + : username; + + let name; + if (firstLine.length > 0) { + const prefix = `${safeUsername}: `; + const maxContentLength = MAX_THREAD_NAME_LENGTH - prefix.length; + if (maxContentLength <= 0) { + // Username is so long that even with truncation we can't fit content — use fallback + name = `Chat with ${safeUsername}`; + } else { + const truncatedContent = + firstLine.length > maxContentLength + ? `${firstLine.substring(0, maxContentLength - 1)}…` + : firstLine; + name = `${prefix}${truncatedContent}`; + } + } else { + name = `Chat with ${safeUsername}`; + } + + // Final safety clamp — should never be needed but guarantees the contract + if (name.length > MAX_THREAD_NAME_LENGTH) { + name = `${name.substring(0, MAX_THREAD_NAME_LENGTH - 1)}…`; + } + + return name; +} + +/** + * Build the cache key for active thread tracking + * @param {string} userId - User ID + * @param {string} channelId - Channel ID + * @returns {string} Cache key + */ +export function buildThreadKey(userId, channelId) { + return `${userId}:${channelId}`; +} + +/** + * Locate a previously cached thread for the message author in the same channel and prepare it for reuse. + * + * If a valid, non-expired thread is found it will be returned; the function will update the thread's last-active timestamp + * and attempt to unarchive the thread if necessary. Stale, missing, or inaccessible entries are removed from the cache. + * @param {import('discord.js').Message} message - The triggering Discord message (used to identify user and channel). + * @returns {Promise} `ThreadChannel` if a reusable thread was found and prepared, `null` otherwise. + */ +export async function findExistingThread(message) { + const threadConfig = getThreadConfig(); + const key = buildThreadKey(message.author.id, message.channel.id); + const entry = activeThreads.get(key); + + if (!entry) return null; + + // Check if the thread is still within the reuse window + const now = Date.now(); + if (now - entry.lastActive > threadConfig.reuseWindowMs) { + activeThreads.delete(key); + return null; + } + + // Try to fetch the thread — it may have been deleted or archived + try { + const thread = await message.channel.threads.fetch(entry.threadId); + if (!thread) { + activeThreads.delete(key); + return null; + } + + // If thread is archived, try to unarchive it + if (thread.archived) { + try { + await thread.setArchived(false); + info('Unarchived thread for reuse', { + threadId: thread.id, + userId: message.author.id, + }); + } catch (err) { + warn('Failed to unarchive thread, creating new one', { + threadId: thread.id, + error: err.message, + }); + activeThreads.delete(key); + return null; + } + } + + // Update last active time + entry.lastActive = now; + return thread; + } catch (_err) { + // Thread not found or inaccessible + activeThreads.delete(key); + return null; + } +} + +/** + * Start a new thread for the triggering message and record it for reuse. + * @param {import('discord.js').Message} message - The message that triggers thread creation. + * @param {string} cleanContent - The cleaned message content used to generate the thread name. + * @returns {Promise} The created thread channel. + */ +export async function createThread(message, cleanContent) { + const threadConfig = getThreadConfig(); + const threadName = generateThreadName( + message.author.displayName || message.author.username, + cleanContent, + ); + + const thread = await message.startThread({ + name: threadName, + autoArchiveDuration: threadConfig.autoArchiveMinutes, + }); + + // Track this thread for reuse + const key = buildThreadKey(message.author.id, message.channel.id); + activeThreads.set(key, { + threadId: thread.id, + lastActive: Date.now(), + threadName, + }); + + info('Created conversation thread', { + threadId: thread.id, + threadName, + userId: message.author.id, + channelId: message.channel.id, + }); + + return thread; +} + +/** + * Obtain an existing thread for the user in the channel or create a new one for the AI conversation. + * @param {import('discord.js').Message} message - The triggering message. + * @param {string} cleanContent - Cleaned content used to generate the thread name when creating a new thread. + * @returns {Promise<{ thread: import('discord.js').ThreadChannel|null, isNew: boolean }>} An object containing the thread to use (or `null` if threading was skipped) and `isNew` set to `true` when a new thread was created, `false` otherwise. + */ +export async function getOrCreateThread(message, cleanContent) { + // Check permissions first + if (!canCreateThread(message)) { + warn('Missing thread creation permissions, falling back to inline reply', { + channelId: message.channel.id, + guildId: message.guild.id, + }); + return { thread: null, isNew: false }; + } + + // Serialize concurrent calls for the same user+channel to prevent duplicate threads + const key = buildThreadKey(message.author.id, message.channel.id); + const pending = pendingThreadCreations.get(key); + if (pending) { + // Another call is already in flight — wait for it, then try to reuse its result + await pending.catch(() => {}); // ignore errors from the other call + const existingThread = await findExistingThread(message); + if (existingThread) { + return { thread: existingThread, isNew: false }; + } + // The other call failed or expired — fall through to create our own + } + + const resultPromise = _getOrCreateThreadInner(message, cleanContent); + pendingThreadCreations.set(key, resultPromise); + try { + return await resultPromise; + } finally { + // Only delete if it's still our promise (not replaced by another call) + if (pendingThreadCreations.get(key) === resultPromise) { + pendingThreadCreations.delete(key); + } + } +} + +/** + * Internal implementation of getOrCreateThread (without locking). + * @private + */ +async function _getOrCreateThreadInner(message, cleanContent) { + // Try to reuse an existing thread + const existingThread = await findExistingThread(message); + if (existingThread) { + info('Reusing existing thread', { + threadId: existingThread.id, + userId: message.author.id, + channelId: message.channel.id, + }); + return { thread: existingThread, isNew: false }; + } + + // Create a new thread + try { + const thread = await createThread(message, cleanContent); + return { thread, isNew: true }; + } catch (err) { + logError('Failed to create thread, falling back to inline reply', { + channelId: message.channel.id, + error: err.message, + }); + return { thread: null, isNew: false }; + } +} + +/** + * Sweep expired entries from the activeThreads cache. + * Removes entries older than the configured reuse window and + * enforces the MAX_CACHE_SIZE cap by evicting oldest entries. + */ +export function sweepExpiredThreads() { + const config = getThreadConfig(); + const now = Date.now(); + + // Remove expired entries + for (const [key, entry] of activeThreads) { + if (now - entry.lastActive > config.reuseWindowMs) { + activeThreads.delete(key); + } + } + + // Enforce max-size cap — evict oldest entries first + if (activeThreads.size > MAX_CACHE_SIZE) { + const entries = [...activeThreads.entries()].sort((a, b) => a[1].lastActive - b[1].lastActive); + const toRemove = entries.slice(0, activeThreads.size - MAX_CACHE_SIZE); + for (const [key] of toRemove) { + activeThreads.delete(key); + } + } +} + +/** + * Per-key lock map to prevent concurrent thread creation for the same user+channel. + * Maps cache key -> Promise that resolves when the in-flight getOrCreateThread completes. + * @type {Map} + */ +const pendingThreadCreations = new Map(); + +/** Timer ID for the periodic eviction sweep */ +let evictionTimer = null; + +/** + * Start the periodic eviction sweep (idempotent). + */ +export function startEvictionTimer() { + if (evictionTimer) return; + evictionTimer = setInterval(sweepExpiredThreads, EVICTION_INTERVAL_MS); + // Allow the Node.js process to exit even if the timer is running + if (evictionTimer.unref) evictionTimer.unref(); +} + +/** + * Stop the periodic eviction sweep (for testing / shutdown). + */ +export function stopEvictionTimer() { + if (evictionTimer) { + clearInterval(evictionTimer); + evictionTimer = null; + } +} + +// Start the eviction timer on module load +startEvictionTimer(); + +/** + * Get the active threads map (for testing) + * @returns {Map} Active threads map + */ +export function getActiveThreads() { + return activeThreads; +} + +/** + * Clear all active thread tracking (for testing) + */ +export function clearActiveThreads() { + activeThreads.clear(); +} diff --git a/tests/modules/events.test.js b/tests/modules/events.test.js index 8fac352d..3823c447 100644 --- a/tests/modules/events.test.js +++ b/tests/modules/events.test.js @@ -42,6 +42,12 @@ vi.mock('../../src/utils/splitMessage.js', () => ({ splitMessage: vi.fn().mockReturnValue(['chunk1', 'chunk2']), })); +// Mock threading module +vi.mock('../../src/modules/threading.js', () => ({ + shouldUseThread: vi.fn().mockReturnValue(false), + getOrCreateThread: vi.fn().mockResolvedValue({ thread: null, isNew: false }), +})); + import { generateResponse } from '../../src/modules/ai.js'; import { accumulate, resetCounter } from '../../src/modules/chimeIn.js'; import { @@ -52,6 +58,7 @@ import { registerReadyHandler, } from '../../src/modules/events.js'; import { isSpam, sendSpamAlert } from '../../src/modules/spam.js'; +import { getOrCreateThread, shouldUseThread } from '../../src/modules/threading.js'; import { recordCommunityActivity, sendWelcomeMessage } from '../../src/modules/welcome.js'; import { getUserFriendlyMessage } from '../../src/utils/errors.js'; import { needsSplitting, splitMessage } from '../../src/utils/splitMessage.js'; @@ -182,7 +189,12 @@ describe('events module', () => { author: { bot: false, username: 'user' }, guild: { id: 'g1' }, content: `<@bot-user-id> hello`, - channel: { id: 'c1', sendTyping: mockSendTyping, send: vi.fn() }, + channel: { + id: 'c1', + sendTyping: mockSendTyping, + send: vi.fn(), + isThread: vi.fn().mockReturnValue(false), + }, mentions: { has: vi.fn().mockReturnValue(true), repliedUser: null }, reference: null, reply: mockReply, @@ -200,7 +212,12 @@ describe('events module', () => { author: { bot: false, username: 'user' }, guild: { id: 'g1' }, content: 'follow up', - channel: { id: 'c1', sendTyping: mockSendTyping, send: vi.fn() }, + channel: { + id: 'c1', + sendTyping: mockSendTyping, + send: vi.fn(), + isThread: vi.fn().mockReturnValue(false), + }, mentions: { has: vi.fn().mockReturnValue(false), repliedUser: { id: 'bot-user-id' } }, reference: { messageId: 'ref-123' }, reply: mockReply, @@ -216,7 +233,12 @@ describe('events module', () => { author: { bot: false, username: 'user' }, guild: { id: 'g1' }, content: `<@bot-user-id>`, - channel: { id: 'c1', sendTyping: vi.fn(), send: vi.fn() }, + channel: { + id: 'c1', + sendTyping: vi.fn(), + send: vi.fn(), + isThread: vi.fn().mockReturnValue(false), + }, mentions: { has: vi.fn().mockReturnValue(true), repliedUser: null }, reference: null, reply: mockReply, @@ -234,7 +256,12 @@ describe('events module', () => { author: { bot: false, username: 'user' }, guild: { id: 'g1' }, content: `<@bot-user-id> tell me a story`, - channel: { id: 'c1', sendTyping: vi.fn(), send: mockSend }, + channel: { + id: 'c1', + sendTyping: vi.fn(), + send: mockSend, + isThread: vi.fn().mockReturnValue(false), + }, mentions: { has: vi.fn().mockReturnValue(true), repliedUser: null }, reference: null, reply: vi.fn(), @@ -251,7 +278,12 @@ describe('events module', () => { author: { bot: false, username: 'user' }, guild: { id: 'g1' }, content: `<@bot-user-id> hello`, - channel: { id: 'c1', sendTyping: vi.fn().mockResolvedValue(undefined), send: vi.fn() }, + channel: { + id: 'c1', + sendTyping: vi.fn().mockResolvedValue(undefined), + send: vi.fn(), + isThread: vi.fn().mockReturnValue(false), + }, mentions: { has: vi.fn().mockReturnValue(true), repliedUser: null }, reference: null, reply: mockReply, @@ -271,7 +303,12 @@ describe('events module', () => { author: { bot: false, username: 'user' }, guild: { id: 'g1' }, content: `<@bot-user-id> tell me a story`, - channel: { id: 'c1', sendTyping: vi.fn().mockResolvedValue(undefined), send: mockSend }, + channel: { + id: 'c1', + sendTyping: vi.fn().mockResolvedValue(undefined), + send: mockSend, + isThread: vi.fn().mockReturnValue(false), + }, mentions: { has: vi.fn().mockReturnValue(true), repliedUser: null }, reference: null, reply: mockReply, @@ -287,7 +324,12 @@ describe('events module', () => { author: { bot: false, username: 'user' }, guild: { id: 'g1' }, content: '<@bot-user-id> hello', - channel: { id: 'not-allowed-ch', sendTyping: vi.fn(), send: vi.fn() }, + channel: { + id: 'not-allowed-ch', + sendTyping: vi.fn(), + send: vi.fn(), + isThread: vi.fn().mockReturnValue(false), + }, mentions: { has: vi.fn().mockReturnValue(true), repliedUser: null }, reference: null, reply: mockReply, @@ -297,6 +339,154 @@ describe('events module', () => { expect(generateResponse).not.toHaveBeenCalled(); }); + it('should allow thread messages when parent channel is in the allowlist', async () => { + setup({ ai: { enabled: true, channels: ['allowed-ch'] } }); + const mockReply = vi.fn().mockResolvedValue(undefined); + const mockSendTyping = vi.fn().mockResolvedValue(undefined); + const message = { + author: { bot: false, username: 'user' }, + guild: { id: 'g1' }, + content: '<@bot-user-id> hello from thread', + channel: { + id: 'thread-id-999', + parentId: 'allowed-ch', + sendTyping: mockSendTyping, + send: vi.fn(), + isThread: vi.fn().mockReturnValue(true), + }, + mentions: { has: vi.fn().mockReturnValue(true), repliedUser: null }, + reference: null, + reply: mockReply, + }; + await onCallbacks.messageCreate(message); + // Should respond because parent channel is in the allowlist + expect(generateResponse).toHaveBeenCalled(); + expect(mockReply).toHaveBeenCalledWith('AI response'); + }); + + it('should block thread messages when parent channel is NOT in the allowlist', async () => { + setup({ ai: { enabled: true, channels: ['allowed-ch'] } }); + const mockReply = vi.fn(); + const message = { + author: { bot: false, username: 'user' }, + guild: { id: 'g1' }, + content: '<@bot-user-id> hello from thread', + channel: { + id: 'thread-id-999', + parentId: 'some-other-ch', + sendTyping: vi.fn(), + send: vi.fn(), + isThread: vi.fn().mockReturnValue(true), + }, + mentions: { has: vi.fn().mockReturnValue(true), repliedUser: null }, + reference: null, + reply: mockReply, + }; + await onCallbacks.messageCreate(message); + // Should NOT respond (parent channel not in allowed list) + expect(generateResponse).not.toHaveBeenCalled(); + }); + + it('should use threading when shouldUseThread returns true', async () => { + setup(); + shouldUseThread.mockReturnValueOnce(true); + const mockThread = { + id: 'thread-123', + sendTyping: vi.fn().mockResolvedValue(undefined), + send: vi.fn().mockResolvedValue(undefined), + }; + getOrCreateThread.mockResolvedValueOnce({ thread: mockThread, isNew: true }); + + const message = { + author: { bot: false, username: 'user' }, + guild: { id: 'g1' }, + content: '<@bot-user-id> hello from channel', + channel: { + id: 'c1', + sendTyping: vi.fn(), + send: vi.fn(), + isThread: vi.fn().mockReturnValue(false), + }, + mentions: { has: vi.fn().mockReturnValue(true), repliedUser: null }, + reference: null, + reply: vi.fn(), + }; + await onCallbacks.messageCreate(message); + + expect(shouldUseThread).toHaveBeenCalledWith(message); + expect(getOrCreateThread).toHaveBeenCalledWith(message, 'hello from channel'); + expect(mockThread.sendTyping).toHaveBeenCalled(); + expect(mockThread.send).toHaveBeenCalledWith('AI response'); + // generateResponse should use thread ID for history + expect(generateResponse).toHaveBeenCalledWith( + 'thread-123', + 'hello from channel', + 'user', + config, + null, + ); + }); + + it('should fall back to inline reply when thread creation fails', async () => { + setup(); + shouldUseThread.mockReturnValueOnce(true); + getOrCreateThread.mockResolvedValueOnce({ thread: null, isNew: false }); + + const mockReply = vi.fn().mockResolvedValue(undefined); + const mockSendTyping = vi.fn().mockResolvedValue(undefined); + const message = { + author: { bot: false, username: 'user' }, + guild: { id: 'g1' }, + content: '<@bot-user-id> hello', + channel: { + id: 'c1', + sendTyping: mockSendTyping, + send: vi.fn(), + isThread: vi.fn().mockReturnValue(false), + }, + mentions: { has: vi.fn().mockReturnValue(true), repliedUser: null }, + reference: null, + reply: mockReply, + }; + await onCallbacks.messageCreate(message); + + // Should fall back to inline reply + expect(mockSendTyping).toHaveBeenCalled(); + expect(mockReply).toHaveBeenCalledWith('AI response'); + }); + + it('should split long responses in threads', async () => { + setup(); + shouldUseThread.mockReturnValueOnce(true); + needsSplitting.mockReturnValueOnce(true); + splitMessage.mockReturnValueOnce(['chunk1', 'chunk2']); + const mockThread = { + id: 'thread-456', + sendTyping: vi.fn().mockResolvedValue(undefined), + send: vi.fn().mockResolvedValue(undefined), + }; + getOrCreateThread.mockResolvedValueOnce({ thread: mockThread, isNew: true }); + + const message = { + author: { bot: false, username: 'user' }, + guild: { id: 'g1' }, + content: '<@bot-user-id> tell me a long story', + channel: { + id: 'c1', + sendTyping: vi.fn(), + send: vi.fn(), + isThread: vi.fn().mockReturnValue(false), + }, + mentions: { has: vi.fn().mockReturnValue(true), repliedUser: null }, + reference: null, + reply: vi.fn(), + }; + await onCallbacks.messageCreate(message); + + expect(mockThread.send).toHaveBeenCalledWith('chunk1'); + expect(mockThread.send).toHaveBeenCalledWith('chunk2'); + }); + it('should accumulate messages for chimeIn', async () => { setup({ ai: { enabled: false } }); const message = { diff --git a/tests/modules/threading.test.js b/tests/modules/threading.test.js new file mode 100644 index 00000000..8e52f91c --- /dev/null +++ b/tests/modules/threading.test.js @@ -0,0 +1,809 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock logger +vi.mock('../../src/logger.js', () => ({ + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), +})); + +// Mock config module +vi.mock('../../src/modules/config.js', () => ({ + getConfig: vi.fn(() => ({ + ai: { + threadMode: { + enabled: true, + autoArchiveMinutes: 60, + reuseWindowMinutes: 30, + }, + }, + })), +})); + +import { ChannelType, PermissionFlagsBits } from 'discord.js'; +import { getConfig } from '../../src/modules/config.js'; +import { + buildThreadKey, + canCreateThread, + clearActiveThreads, + createThread, + findExistingThread, + generateThreadName, + getActiveThreads, + getOrCreateThread, + getThreadConfig, + shouldUseThread, + snapAutoArchiveDuration, + stopEvictionTimer, +} from '../../src/modules/threading.js'; + +describe('threading module', () => { + beforeEach(() => { + clearActiveThreads(); + vi.clearAllMocks(); + // Reset to enabled by default for most tests + getConfig.mockReturnValue({ + ai: { + threadMode: { + enabled: true, + autoArchiveMinutes: 60, + reuseWindowMinutes: 30, + }, + }, + }); + }); + + afterEach(() => { + clearActiveThreads(); + }); + + describe('getThreadConfig', () => { + it('should return config from bot config', () => { + const config = getThreadConfig(); + expect(config.enabled).toBe(true); + expect(config.autoArchiveMinutes).toBe(60); + expect(config.reuseWindowMs).toBe(30 * 60 * 1000); + }); + + it('should return defaults when config is missing', () => { + getConfig.mockReturnValue({}); + const config = getThreadConfig(); + expect(config.enabled).toBe(false); + expect(config.autoArchiveMinutes).toBe(60); + expect(config.reuseWindowMs).toBe(30 * 60 * 1000); + }); + + it('should return defaults when getConfig throws', () => { + getConfig.mockImplementation(() => { + throw new Error('Config not loaded'); + }); + const config = getThreadConfig(); + expect(config.enabled).toBe(false); + expect(config.autoArchiveMinutes).toBe(60); + }); + + it('should respect custom autoArchiveMinutes', () => { + getConfig.mockReturnValue({ + ai: { + threadMode: { + enabled: true, + autoArchiveMinutes: 1440, + reuseWindowMinutes: 15, + }, + }, + }); + const config = getThreadConfig(); + expect(config.autoArchiveMinutes).toBe(1440); + expect(config.reuseWindowMs).toBe(15 * 60 * 1000); + }); + + it('should snap invalid autoArchiveMinutes to nearest valid value', () => { + getConfig.mockReturnValue({ + ai: { + threadMode: { + enabled: true, + autoArchiveMinutes: 100, + reuseWindowMinutes: 30, + }, + }, + }); + const config = getThreadConfig(); + expect(config.autoArchiveMinutes).toBe(60); + }); + + it('should fall back to default for NaN autoArchiveMinutes', () => { + getConfig.mockReturnValue({ + ai: { + threadMode: { + enabled: true, + autoArchiveMinutes: 'invalid', + reuseWindowMinutes: 30, + }, + }, + }); + const config = getThreadConfig(); + expect(config.autoArchiveMinutes).toBe(60); + }); + + it('should fall back to default for NaN reuseWindowMinutes', () => { + getConfig.mockReturnValue({ + ai: { + threadMode: { + enabled: true, + autoArchiveMinutes: 60, + reuseWindowMinutes: 'bad', + }, + }, + }); + const config = getThreadConfig(); + expect(config.reuseWindowMs).toBe(30 * 60 * 1000); + }); + + it('should fall back to default for negative reuseWindowMinutes', () => { + getConfig.mockReturnValue({ + ai: { + threadMode: { + enabled: true, + autoArchiveMinutes: 60, + reuseWindowMinutes: -5, + }, + }, + }); + const config = getThreadConfig(); + expect(config.reuseWindowMs).toBe(30 * 60 * 1000); + }); + + it('should fall back to default for zero reuseWindowMinutes', () => { + getConfig.mockReturnValue({ + ai: { + threadMode: { + enabled: true, + autoArchiveMinutes: 60, + reuseWindowMinutes: 0, + }, + }, + }); + const config = getThreadConfig(); + expect(config.reuseWindowMs).toBe(30 * 60 * 1000); + }); + }); + + describe('snapAutoArchiveDuration', () => { + it('should return 60 for values close to 60', () => { + expect(snapAutoArchiveDuration(60)).toBe(60); + expect(snapAutoArchiveDuration(30)).toBe(60); + expect(snapAutoArchiveDuration(100)).toBe(60); + }); + + it('should return 1440 for values close to 1440', () => { + expect(snapAutoArchiveDuration(1440)).toBe(1440); + expect(snapAutoArchiveDuration(1000)).toBe(1440); + }); + + it('should return 4320 for values close to 4320', () => { + expect(snapAutoArchiveDuration(4320)).toBe(4320); + expect(snapAutoArchiveDuration(3000)).toBe(4320); + }); + + it('should return 10080 for values close to 10080', () => { + expect(snapAutoArchiveDuration(10080)).toBe(10080); + expect(snapAutoArchiveDuration(9000)).toBe(10080); + }); + + it('should return default for NaN', () => { + expect(snapAutoArchiveDuration(Number.NaN)).toBe(60); + }); + + it('should return default for non-number', () => { + expect(snapAutoArchiveDuration('bad')).toBe(60); + }); + + it('should return default for negative', () => { + expect(snapAutoArchiveDuration(-100)).toBe(60); + }); + + it('should return default for zero', () => { + expect(snapAutoArchiveDuration(0)).toBe(60); + }); + }); + + describe('shouldUseThread', () => { + it('should return true for regular text channel mention', () => { + const message = { + guild: { id: 'g1' }, + channel: { + type: ChannelType.GuildText, + isThread: () => false, + }, + }; + expect(shouldUseThread(message)).toBe(true); + }); + + it('should return true for announcement channel', () => { + const message = { + guild: { id: 'g1' }, + channel: { + type: ChannelType.GuildAnnouncement, + isThread: () => false, + }, + }; + expect(shouldUseThread(message)).toBe(true); + }); + + it('should return false when threading is disabled', () => { + getConfig.mockReturnValue({ + ai: { threadMode: { enabled: false } }, + }); + const message = { + guild: { id: 'g1' }, + channel: { + type: ChannelType.GuildText, + isThread: () => false, + }, + }; + expect(shouldUseThread(message)).toBe(false); + }); + + it('should return false for DMs', () => { + const message = { + guild: null, + channel: { + type: ChannelType.DM, + isThread: () => false, + }, + }; + expect(shouldUseThread(message)).toBe(false); + }); + + it('should return false when already in a thread', () => { + const message = { + guild: { id: 'g1' }, + channel: { + type: ChannelType.PublicThread, + isThread: () => true, + }, + }; + expect(shouldUseThread(message)).toBe(false); + }); + + it('should return false for voice channels', () => { + const message = { + guild: { id: 'g1' }, + channel: { + type: ChannelType.GuildVoice, + isThread: () => false, + }, + }; + expect(shouldUseThread(message)).toBe(false); + }); + }); + + describe('canCreateThread', () => { + it('should return true when bot has required permissions', () => { + const message = { + guild: { + members: { + me: { id: 'bot-id' }, + }, + }, + channel: { + permissionsFor: vi.fn().mockReturnValue({ + has: vi.fn().mockReturnValue(true), + }), + }, + }; + expect(canCreateThread(message)).toBe(true); + }); + + it('should return false when missing CREATE_PUBLIC_THREADS', () => { + const message = { + guild: { + members: { + me: { id: 'bot-id' }, + }, + }, + channel: { + permissionsFor: vi.fn().mockReturnValue({ + has: vi.fn((perm) => perm !== PermissionFlagsBits.CreatePublicThreads), + }), + }, + }; + expect(canCreateThread(message)).toBe(false); + }); + + it('should return false when missing SendMessagesInThreads', () => { + const message = { + guild: { + members: { + me: { id: 'bot-id' }, + }, + }, + channel: { + permissionsFor: vi.fn().mockReturnValue({ + has: vi.fn((perm) => perm !== PermissionFlagsBits.SendMessagesInThreads), + }), + }, + }; + expect(canCreateThread(message)).toBe(false); + }); + + it('should return false for DMs (no guild)', () => { + const message = { guild: null }; + expect(canCreateThread(message)).toBe(false); + }); + + it('should return false when bot member is not cached', () => { + const message = { + guild: { + members: { me: null }, + }, + channel: { + permissionsFor: vi.fn(), + }, + }; + expect(canCreateThread(message)).toBe(false); + }); + + it('should return false when permissionsFor returns null', () => { + const message = { + guild: { + members: { + me: { id: 'bot-id' }, + }, + }, + channel: { + permissionsFor: vi.fn().mockReturnValue(null), + }, + }; + expect(canCreateThread(message)).toBe(false); + }); + + it('should return false and warn when permissionsFor throws', () => { + const message = { + guild: { + members: { + me: { id: 'bot-id' }, + }, + }, + channel: { + permissionsFor: vi.fn().mockImplementation(() => { + throw new Error('Permission check failed'); + }), + }, + }; + expect(canCreateThread(message)).toBe(false); + }); + }); + + describe('generateThreadName', () => { + it('should generate name from username and message', () => { + const name = generateThreadName('Alice', 'How do I use async/await?'); + expect(name).toBe('Alice: How do I use async/await?'); + }); + + it('should truncate long messages', () => { + const longMessage = 'A'.repeat(200); + const name = generateThreadName('Bob', longMessage); + expect(name.length).toBeLessThanOrEqual(100); + expect(name).toContain('Bob: '); + expect(name.endsWith('…')).toBe(true); + }); + + it('should use first line only for multiline messages', () => { + const name = generateThreadName('Charlie', 'First line\nSecond line\nThird line'); + expect(name).toBe('Charlie: First line'); + }); + + it('should fallback for empty content', () => { + const name = generateThreadName('Dave', ''); + expect(name).toBe('Chat with Dave'); + }); + + it('should fallback for whitespace-only content', () => { + const name = generateThreadName('Eve', ' '); + expect(name).toBe('Chat with Eve'); + }); + + it('should handle very long username (98+ chars) and stay within 100 chars', () => { + const longUsername = 'A'.repeat(98); + const name = generateThreadName(longUsername, 'Hello world'); + expect(name.length).toBeLessThanOrEqual(100); + // Should truncate the username and still produce a valid name + expect(name.length).toBeGreaterThan(0); + }); + + it('should handle very long username with empty content', () => { + const longUsername = 'B'.repeat(120); + const name = generateThreadName(longUsername, ''); + expect(name.length).toBeLessThanOrEqual(100); + expect(name).toContain('Chat with'); + }); + + it('should handle username exactly at the max length boundary', () => { + const longUsername = 'C'.repeat(95); + const name = generateThreadName(longUsername, 'Short msg'); + expect(name.length).toBeLessThanOrEqual(100); + }); + }); + + describe('buildThreadKey', () => { + it('should combine userId and channelId', () => { + expect(buildThreadKey('user123', 'channel456')).toBe('user123:channel456'); + }); + }); + + describe('findExistingThread', () => { + it('should return null when no active thread exists', async () => { + const message = { + author: { id: 'user1' }, + channel: { id: 'ch1', threads: { fetch: vi.fn() } }, + }; + const thread = await findExistingThread(message); + expect(thread).toBeNull(); + }); + + it('should return thread when found and within reuse window', async () => { + const key = buildThreadKey('user1', 'ch1'); + const mockThread = { id: 'thread1', archived: false }; + getActiveThreads().set(key, { + threadId: 'thread1', + lastActive: Date.now(), + threadName: 'Test thread', + }); + + const message = { + author: { id: 'user1' }, + channel: { + id: 'ch1', + threads: { fetch: vi.fn().mockResolvedValue(mockThread) }, + }, + }; + + const thread = await findExistingThread(message); + expect(thread).toBe(mockThread); + }); + + it('should delete and return null when thread is expired', async () => { + const key = buildThreadKey('user1', 'ch1'); + getActiveThreads().set(key, { + threadId: 'thread1', + lastActive: Date.now() - 31 * 60 * 1000, // 31 minutes ago + threadName: 'Old thread', + }); + + const message = { + author: { id: 'user1' }, + channel: { + id: 'ch1', + threads: { fetch: vi.fn() }, + }, + }; + + const thread = await findExistingThread(message); + expect(thread).toBeNull(); + expect(getActiveThreads().has(key)).toBe(false); + }); + + it('should delete and return null when thread fetch returns null', async () => { + const key = buildThreadKey('user1', 'ch1'); + getActiveThreads().set(key, { + threadId: 'thread1', + lastActive: Date.now(), + threadName: 'Test thread', + }); + + const message = { + author: { id: 'user1' }, + channel: { + id: 'ch1', + threads: { fetch: vi.fn().mockResolvedValue(null) }, + }, + }; + + const thread = await findExistingThread(message); + expect(thread).toBeNull(); + expect(getActiveThreads().has(key)).toBe(false); + }); + + it('should delete and return null when thread fetch throws', async () => { + const key = buildThreadKey('user1', 'ch1'); + getActiveThreads().set(key, { + threadId: 'thread1', + lastActive: Date.now(), + threadName: 'Test thread', + }); + + const message = { + author: { id: 'user1' }, + channel: { + id: 'ch1', + threads: { fetch: vi.fn().mockRejectedValue(new Error('Unknown Thread')) }, + }, + }; + + const thread = await findExistingThread(message); + expect(thread).toBeNull(); + expect(getActiveThreads().has(key)).toBe(false); + }); + + it('should unarchive an archived thread', async () => { + const key = buildThreadKey('user1', 'ch1'); + const mockThread = { + id: 'thread1', + archived: true, + setArchived: vi.fn().mockResolvedValue(undefined), + }; + getActiveThreads().set(key, { + threadId: 'thread1', + lastActive: Date.now(), + threadName: 'Test thread', + }); + + const message = { + author: { id: 'user1' }, + channel: { + id: 'ch1', + threads: { fetch: vi.fn().mockResolvedValue(mockThread) }, + }, + }; + + const thread = await findExistingThread(message); + expect(thread).toBe(mockThread); + expect(mockThread.setArchived).toHaveBeenCalledWith(false); + }); + + it('should return null if unarchive fails', async () => { + const key = buildThreadKey('user1', 'ch1'); + const mockThread = { + id: 'thread1', + archived: true, + setArchived: vi.fn().mockRejectedValue(new Error('Missing permissions')), + }; + getActiveThreads().set(key, { + threadId: 'thread1', + lastActive: Date.now(), + threadName: 'Test thread', + }); + + const message = { + author: { id: 'user1' }, + channel: { + id: 'ch1', + threads: { fetch: vi.fn().mockResolvedValue(mockThread) }, + }, + }; + + const thread = await findExistingThread(message); + expect(thread).toBeNull(); + expect(getActiveThreads().has(key)).toBe(false); + }); + }); + + describe('createThread', () => { + it('should create a thread and track it', async () => { + const mockThread = { id: 'new-thread-1' }; + const message = { + author: { id: 'user1', displayName: 'Alice', username: 'alice' }, + channel: { id: 'ch1' }, + startThread: vi.fn().mockResolvedValue(mockThread), + }; + + const thread = await createThread(message, 'What is JavaScript?'); + expect(thread).toBe(mockThread); + expect(message.startThread).toHaveBeenCalledWith({ + name: 'Alice: What is JavaScript?', + autoArchiveDuration: 60, + }); + + const key = buildThreadKey('user1', 'ch1'); + const tracked = getActiveThreads().get(key); + expect(tracked).toBeDefined(); + expect(tracked.threadId).toBe('new-thread-1'); + }); + + it('should use username when displayName is not available', async () => { + const mockThread = { id: 'new-thread-2' }; + const message = { + author: { id: 'user2', displayName: undefined, username: 'bob' }, + channel: { id: 'ch1' }, + startThread: vi.fn().mockResolvedValue(mockThread), + }; + + await createThread(message, 'Hello'); + expect(message.startThread).toHaveBeenCalledWith({ + name: 'bob: Hello', + autoArchiveDuration: 60, + }); + }); + }); + + describe('getOrCreateThread', () => { + function makeMessage(overrides = {}) { + return { + author: { id: 'user1', displayName: 'Alice', username: 'alice' }, + guild: { + id: 'g1', + members: { me: { id: 'bot-id' } }, + }, + channel: { + id: 'ch1', + permissionsFor: vi.fn().mockReturnValue({ + has: vi.fn().mockReturnValue(true), + }), + threads: { fetch: vi.fn().mockResolvedValue(null) }, + }, + startThread: vi.fn().mockResolvedValue({ id: 'new-thread' }), + ...overrides, + }; + } + + it('should create a new thread when no existing thread', async () => { + const message = makeMessage(); + const result = await getOrCreateThread(message, 'Hello world'); + expect(result.thread).toEqual({ id: 'new-thread' }); + expect(result.isNew).toBe(true); + }); + + it('should reuse existing thread', async () => { + const key = buildThreadKey('user1', 'ch1'); + const existingThread = { id: 'existing-thread', archived: false }; + getActiveThreads().set(key, { + threadId: 'existing-thread', + lastActive: Date.now(), + threadName: 'Previous conversation', + }); + + const message = makeMessage({ + channel: { + id: 'ch1', + permissionsFor: vi.fn().mockReturnValue({ + has: vi.fn().mockReturnValue(true), + }), + threads: { fetch: vi.fn().mockResolvedValue(existingThread) }, + }, + }); + + const result = await getOrCreateThread(message, 'Follow-up question'); + expect(result.thread).toBe(existingThread); + expect(result.isNew).toBe(false); + }); + + it('should fall back to null when missing permissions', async () => { + const message = makeMessage({ + guild: { + id: 'g1', + members: { me: { id: 'bot-id' } }, + }, + channel: { + id: 'ch1', + permissionsFor: vi.fn().mockReturnValue({ + has: vi.fn().mockReturnValue(false), + }), + threads: { fetch: vi.fn() }, + }, + }); + + const result = await getOrCreateThread(message, 'Hello'); + expect(result.thread).toBeNull(); + expect(result.isNew).toBe(false); + }); + + it('should fall back to null when thread creation throws', async () => { + const message = makeMessage({ + startThread: vi.fn().mockRejectedValue(new Error('Thread creation failed')), + }); + + const result = await getOrCreateThread(message, 'Hello'); + expect(result.thread).toBeNull(); + expect(result.isNew).toBe(false); + }); + + it('should not create duplicate threads for concurrent calls from the same user+channel', async () => { + const createdThread = { id: 'single-thread' }; + let callCount = 0; + const message = makeMessage({ + startThread: vi.fn().mockImplementation(async () => { + callCount++; + // Simulate async delay + await new Promise((r) => setTimeout(r, 50)); + return createdThread; + }), + channel: { + id: 'ch1', + permissionsFor: vi.fn().mockReturnValue({ + has: vi.fn().mockReturnValue(true), + }), + threads: { + fetch: vi.fn().mockImplementation(async (threadId) => { + // After first thread is created, subsequent fetches find it + if (threadId === 'single-thread') { + return { id: 'single-thread', archived: false }; + } + return null; + }), + }, + }, + }); + + // Fire two concurrent calls + const [result1, result2] = await Promise.all([ + getOrCreateThread(message, 'Hello'), + getOrCreateThread(message, 'Hello again'), + ]); + + // Only one thread should have been created via startThread + expect(callCount).toBe(1); + // Both should reference the same thread + expect(result1.thread.id).toBe('single-thread'); + expect(result2.thread.id).toBe('single-thread'); + }); + }); + + describe('clearActiveThreads', () => { + it('should clear all tracked threads', () => { + getActiveThreads().set('key1', { threadId: 't1', lastActive: Date.now() }); + getActiveThreads().set('key2', { threadId: 't2', lastActive: Date.now() }); + expect(getActiveThreads().size).toBe(2); + + clearActiveThreads(); + expect(getActiveThreads().size).toBe(0); + }); + }); + + describe('sweepExpiredThreads', () => { + let sweepExpiredThreads; + + beforeEach(async () => { + // Dynamic import to get the function after mocks are set up + const mod = await import('../../src/modules/threading.js'); + sweepExpiredThreads = mod.sweepExpiredThreads; + }); + + it('should remove entries older than the reuse window', () => { + const now = Date.now(); + getActiveThreads().set('expired', { + threadId: 't1', + lastActive: now - 31 * 60 * 1000, // 31 min ago (default reuse = 30 min) + threadName: 'Old', + }); + getActiveThreads().set('fresh', { + threadId: 't2', + lastActive: now, + threadName: 'New', + }); + + sweepExpiredThreads(); + + expect(getActiveThreads().has('expired')).toBe(false); + expect(getActiveThreads().has('fresh')).toBe(true); + }); + + it('should enforce max-size cap by evicting oldest entries', () => { + const now = Date.now(); + // Add 1002 entries (over the 1000 cap), all fresh + for (let i = 0; i < 1002; i++) { + getActiveThreads().set(`key${i}`, { + threadId: `t${i}`, + lastActive: now - (1002 - i), // oldest first + threadName: `Thread ${i}`, + }); + } + + sweepExpiredThreads(); + + expect(getActiveThreads().size).toBeLessThanOrEqual(1000); + // Oldest 2 should have been evicted + expect(getActiveThreads().has('key0')).toBe(false); + expect(getActiveThreads().has('key1')).toBe(false); + // Newest should survive + expect(getActiveThreads().has('key1001')).toBe(true); + }); + }); + + describe('eviction timer', () => { + it('stopEvictionTimer should not throw when called', () => { + expect(() => stopEvictionTimer()).not.toThrow(); + }); + }); +});