Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 10 additions & 7 deletions src/modules/ai.js
Original file line number Diff line number Diff line change
Expand Up @@ -197,14 +197,16 @@ export async function getHistoryAsync(channelId) {
/**
* Append a message to the in-memory conversation history for a channel and attempt to persist it to the database.
*
* Also attempts a fire-and-forget write to the DB; database errors are logged and do not throw.
* @param {string} channelId - Channel identifier used to scope the conversation.
* The in-memory history is trimmed to the configured maximum length. If a database pool is configured, the message
* is written to the conversations table in a fire-and-forget manner; DB errors are logged and do not throw.
* @param {string} channelId - Channel identifier that scopes the conversation.
* @param {string} role - Message role (e.g., "user" or "assistant").
* @param {string} content - Message text content.
* @param {string} [username] - Optional display name associated with the message.
* @param {string} [discordMessageId] - Optional native Discord message ID (used to construct jump URLs in the dashboard).
* @param {string} [discordMessageId] - Optional native Discord message ID.
* @param {string} [guildId] - Optional guild ID for the conversation (used for dashboard/jump URLs).
*/
export function addToHistory(channelId, role, content, username, discordMessageId) {
export function addToHistory(channelId, role, content, username, discordMessageId, guildId) {
if (!conversationHistory.has(channelId)) {
conversationHistory.set(channelId, []);
}
Expand All @@ -223,15 +225,16 @@ export function addToHistory(channelId, role, content, username, discordMessageI
if (pool) {
pool
.query(
`INSERT INTO conversations (channel_id, role, content, username, discord_message_id)
VALUES ($1, $2, $3, $4, $5)`,
[channelId, role, content, username || null, discordMessageId || null],
`INSERT INTO conversations (channel_id, role, content, username, discord_message_id, guild_id)
VALUES ($1, $2, $3, $4, $5, $6)`,
[channelId, role, content, username || null, discordMessageId || null, guildId || null],
)
.catch((err) => {
logError('Failed to persist message to DB', {
channelId,
role,
username: username || null,
guildId: guildId || null,
error: err.message,
});
});
Expand Down
31 changes: 29 additions & 2 deletions src/modules/triage-respond.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,35 @@ import { info, error as logError, warn } from '../logger.js';
import { buildDebugEmbed, extractStats, logAiUsage } from '../utils/debugFooter.js';
import { safeSend } from '../utils/safeSend.js';
import { splitMessage } from '../utils/splitMessage.js';
import { addToHistory } from './ai.js';
import { resolveMessageId, sanitizeText } from './triage-filter.js';

/** Maximum characters to keep from fetched context messages. */
const CONTEXT_MESSAGE_CHAR_LIMIT = 500;

// ── History helpers ──────────────────────────────────────────────────────────

/**
* Log an assistant message (or multiple messages when safeSend splits into an array)
* to conversation history.
*
* `safeSend` can return either a single Message object or an array of Message objects
* when the content was split across multiple Discord messages. Both cases are handled
* here so history is never silently dropped.
*
* @param {string} channelId - The channel the message was sent in.
* @param {string|null} guildId - The guild ID, or null for DMs.
* @param {string} fallbackContent - Text to use when the sent message has no `.content`.
* @param {import('discord.js').Message|import('discord.js').Message[]|null} sentMsg - Return value of safeSend.
*/
function logAssistantHistory(channelId, guildId, fallbackContent, sentMsg) {
const sentMessages = Array.isArray(sentMsg) ? sentMsg : [sentMsg];
for (const m of sentMessages) {
if (!m?.id) continue;
addToHistory(channelId, 'assistant', m.content || fallbackContent, null, m.id, guildId || null);
}
}
Comment on lines +19 to +38
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

History logging for assistant responses was added via logAssistantHistory/addToHistory, but existing triage-respond tests don’t assert that history is recorded (including the safeSend single-message vs array return cases). Adding a focused unit test that mocks addToHistory and verifies the expected calls (channelId, role, content, discordMessageId, guildId) would prevent regressions in this production-critical logging path.

Copilot generated this review using guidance from organization custom instructions.

// ── Channel context fetching ─────────────────────────────────────────────────

/**
Expand Down Expand Up @@ -177,7 +201,8 @@ export async function sendResponses(
const msgOpts = { content: chunks[i] };
if (debugEmbed && i === 0) msgOpts.embeds = [debugEmbed];
if (replyRef && i === 0) msgOpts.reply = { messageReference: replyRef };
await safeSend(channel, msgOpts);
const sentMsg = await safeSend(channel, msgOpts);
logAssistantHistory(channelId, channel.guild?.id || null, chunks[i], sentMsg);
}
}
} catch (err) {
Expand Down Expand Up @@ -214,7 +239,9 @@ export async function sendResponses(
const msgOpts = { content: chunks[i] };
if (debugEmbed && i === 0) msgOpts.embeds = [debugEmbed];
if (replyRef && i === 0) msgOpts.reply = { messageReference: replyRef };
await safeSend(channel, msgOpts);
const sentMsg = await safeSend(channel, msgOpts);
// Log AI response to conversation history
logAssistantHistory(channelId, channel.guild?.id || null, chunks[i], sentMsg);
}

info('Triage response sent', {
Expand Down
39 changes: 31 additions & 8 deletions src/modules/triage.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,16 +23,22 @@ import { buildMemoryContext, extractAndStoreMemories } from './memory.js';

// ── Sub-module imports ───────────────────────────────────────────────────────

import { getConfig } from './config.js';
import { addToHistory } from './ai.js';
import {
channelBuffers,
clearEvaluatedMessages,
consumePendingReeval,
pushToBuffer,
} from './triage-buffer.js';
import { getDynamicInterval, isChannelEligible, resolveTriageConfig } from './triage-config.js';

import { checkTriggerWords, sanitizeText } from './triage-filter.js';

import { parseClassifyResult, parseRespondResult } from './triage-parse.js';

import { buildClassifyPrompt, buildRespondPrompt } from './triage-prompt.js';

import {
buildStatsAndLog,
fetchChannelContext,
Expand Down Expand Up @@ -545,13 +551,20 @@ export function stopTriage() {
}

/**
* Append a Discord message to the channel's triage buffer and trigger evaluation when necessary.
* Append a Discord message to the channel's triage buffer and trigger evaluation when conditions are met.
*
* Skips processing if triage is disabled, the channel is not eligible, or the message is empty/attachment-only.
* Truncates message content to 1000 characters and, when the message is a reply, captures up to 500 characters of the referenced message as reply context.
* Adds the entry to the per-channel bounded ring buffer and records the message in conversation history.
* If configured trigger words are present, forces an immediate evaluation (and falls back to scheduling if forcing fails); otherwise schedules a dynamic evaluation timer for the channel.
*
* @param {import('discord.js').Message} message - The Discord message to accumulate.
* @param {Object} msgConfig - Bot configuration containing the `triage` settings.
* @param {Object} _msgConfig - Ignored; retained for backwards compatibility. Live config is
* fetched via {@link getConfig} on each invocation to avoid stale references.
*/
export async function accumulateMessage(message, msgConfig) {
const triageConfig = msgConfig.triage;
export async function accumulateMessage(message, _msgConfig) {
const liveConfig = getConfig(message.guild?.id || null);
const triageConfig = liveConfig.triage;
if (!triageConfig?.enabled) return;
if (!isChannelEligible(message.channel.id, triageConfig)) return;

Expand Down Expand Up @@ -597,18 +610,28 @@ export async function accumulateMessage(message, msgConfig) {
// Push to ring buffer (with truncation warning)
pushToBuffer(channelId, entry, maxBufferSize);

// Log user message to conversation history
addToHistory(
channelId,
'user',
entry.content,
entry.author,
entry.messageId,
message.guild?.id || null,
);

// Check for trigger words -- instant evaluation
if (checkTriggerWords(message.content, msgConfig)) {
if (checkTriggerWords(message.content, liveConfig)) {
info('Trigger word detected, forcing evaluation', { channelId });
evaluateNow(channelId, msgConfig, client, healthMonitor).catch((err) => {
evaluateNow(channelId, liveConfig, client, healthMonitor).catch((err) => {
logError('Trigger word evaluateNow failed', { channelId, error: err.message });
scheduleEvaluation(channelId, msgConfig);
scheduleEvaluation(channelId, liveConfig);
});
return;
}

// Schedule or reset the dynamic timer
scheduleEvaluation(channelId, msgConfig);
scheduleEvaluation(channelId, liveConfig);
}

const MAX_REEVAL_DEPTH = 3;
Expand Down
1 change: 1 addition & 0 deletions tests/modules/ai.coverage.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ describe('ai module coverage', () => {
'hello',
'testuser',
null,
null,
]);
});

Expand Down
18 changes: 18 additions & 0 deletions tests/modules/ai.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,24 @@ describe('ai module', () => {
'hello',
'testuser',
null,
null,
]);
});

it('should write guildId to DB when provided', () => {
const mockQuery = vi.fn().mockResolvedValue({});
const mockPool = { query: mockQuery };
setPool(mockPool);

addToHistory('ch1', 'user', 'hello', 'testuser', 'msg-123', 'guild-456');

expect(mockQuery).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO conversations'), [
'ch1',
'user',
'hello',
'testuser',
'msg-123',
'guild-456',
]);
});
});
Expand Down
53 changes: 53 additions & 0 deletions tests/modules/triage.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,30 @@ vi.mock('../../src/logger.js', () => ({
warn: vi.fn(),
debug: vi.fn(),
}));
vi.mock('../../src/modules/ai.js', () => ({
addToHistory: vi.fn(),
_setPoolGetter: vi.fn(),
setPool: vi.fn(),
getConversationHistory: vi.fn().mockReturnValue(new Map()),
setConversationHistory: vi.fn(),
getHistoryAsync: vi.fn().mockResolvedValue([]),
initConversationHistory: vi.fn().mockResolvedValue(undefined),
startConversationCleanup: vi.fn(),
stopConversationCleanup: vi.fn(),
}));

let mockGlobalConfig = {};

vi.mock('../../src/modules/config.js', () => ({
getConfig: vi.fn((_guildId) => mockGlobalConfig),
loadConfigFromFile: vi.fn(),
loadConfig: vi.fn().mockResolvedValue(undefined),
onConfigChange: vi.fn(),
offConfigChange: vi.fn(),
clearConfigListeners: vi.fn(),
setConfigValue: vi.fn().mockResolvedValue(undefined),
resetConfig: vi.fn().mockResolvedValue(undefined),
}));

import { info, warn } from '../../src/logger.js';
import { isSpam } from '../../src/modules/spam.js';
Expand All @@ -59,6 +83,7 @@ import {
startTriage,
stopTriage,
} from '../../src/modules/triage.js';
import { addToHistory } from '../../src/modules/ai.js';
import { safeSend } from '../../src/utils/safeSend.js';

// ── Helpers ─────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -194,6 +219,7 @@ describe('triage module', () => {
config = makeConfig();
healthMonitor = makeHealthMonitor();
await startTriage(client, config, healthMonitor);
mockGlobalConfig = config;
});

afterEach(() => {
Expand Down Expand Up @@ -223,8 +249,22 @@ describe('triage module', () => {
expect(mockResponderSend).toHaveBeenCalled();
});

it('should call addToHistory with correct args for guild message', () => {
const msg = makeMessage('ch1', 'hello world', { id: 'msg-99', username: 'alice', userId: 'u99', guild: { id: 'g1' } });
accumulateMessage(msg, config);
expect(addToHistory).toHaveBeenCalledWith('ch1', 'user', 'hello world', 'alice', 'msg-99', 'g1');
});

it('should call addToHistory with null guildId for DM (no guild)', () => {
const msg = makeMessage('ch1', 'dm message', { id: 'msg-dm', username: 'bob', userId: 'u2' });
// No guild property — guild?.id resolves to undefined, coerced to null
accumulateMessage(msg, config);
expect(addToHistory).toHaveBeenCalledWith('ch1', 'user', 'dm message', 'bob', 'msg-dm', null);
});

it('should skip when triage is disabled', async () => {
const disabledConfig = makeConfig({ triage: { enabled: false } });
mockGlobalConfig = disabledConfig;
accumulateMessage(makeMessage('ch1', 'hello'), disabledConfig);
await evaluateNow('ch1', config, client, healthMonitor);

Expand All @@ -233,6 +273,7 @@ describe('triage module', () => {

it('should skip excluded channels', async () => {
const excConfig = makeConfig({ triage: { excludeChannels: ['ch1'] } });
mockGlobalConfig = excConfig;
accumulateMessage(makeMessage('ch1', 'hello'), excConfig);
await evaluateNow('ch1', config, client, healthMonitor);

Expand All @@ -241,6 +282,7 @@ describe('triage module', () => {

it('should skip channels not in allow list when allow list is non-empty', async () => {
const restrictedConfig = makeConfig({ triage: { channels: ['allowed-ch'] } });
mockGlobalConfig = restrictedConfig;
accumulateMessage(makeMessage('not-allowed-ch', 'hello'), restrictedConfig);
await evaluateNow('not-allowed-ch', config, client, healthMonitor);

Expand Down Expand Up @@ -277,6 +319,7 @@ describe('triage module', () => {

it('should respect maxBufferSize cap', async () => {
const smallConfig = makeConfig({ triage: { maxBufferSize: 3 } });
mockGlobalConfig = smallConfig;
for (let i = 0; i < 5; i++) {
accumulateMessage(makeMessage('ch1', `msg ${i}`), smallConfig);
}
Expand All @@ -303,6 +346,7 @@ describe('triage module', () => {
describe('checkTriggerWords', () => {
it('should force evaluation when trigger words match', async () => {
const twConfig = makeConfig({ triage: { triggerWords: ['help'] } });
mockGlobalConfig = twConfig;
const classResult = {
classification: 'respond',
reasoning: 'test',
Expand All @@ -325,6 +369,7 @@ describe('triage module', () => {

it('should trigger on moderation keywords', async () => {
const modConfig = makeConfig({ triage: { moderationKeywords: ['badword'] } });
mockGlobalConfig = modConfig;
const classResult = {
classification: 'moderate',
reasoning: 'bad content',
Expand Down Expand Up @@ -504,6 +549,7 @@ describe('triage module', () => {

it('should suppress moderation response when moderationResponse is false', async () => {
const modConfig = makeConfig({ triage: { moderationResponse: false } });
mockGlobalConfig = modConfig;
const classResult = {
classification: 'moderate',
reasoning: 'spam detected',
Expand Down Expand Up @@ -872,6 +918,7 @@ describe('triage module', () => {

it('should use config.triage.defaultInterval as base interval', () => {
const customConfig = makeConfig({ triage: { defaultInterval: 20000 } });
mockGlobalConfig = customConfig;
accumulateMessage(makeMessage('ch1', 'single'), customConfig);
vi.advanceTimersByTime(19999);
expect(mockClassifierSend).not.toHaveBeenCalled();
Expand Down Expand Up @@ -926,6 +973,7 @@ describe('triage module', () => {

it('should evict oldest channels when over 100-channel cap', async () => {
const longConfig = makeConfig({ triage: { defaultInterval: 999999 } });
mockGlobalConfig = longConfig;

const classResult = {
classification: 'ignore',
Expand Down Expand Up @@ -1003,6 +1051,7 @@ describe('triage module', () => {

it('should NOT add 👀 reaction when statusReactions is false', async () => {
const noReactConfig = makeConfig({ triage: { statusReactions: false } });
mockGlobalConfig = noReactConfig;
const classResult = {
classification: 'respond',
reasoning: 'test',
Expand Down Expand Up @@ -1053,6 +1102,7 @@ describe('triage module', () => {

it('should NOT add 🔍 reaction when statusReactions is false', async () => {
const noReactConfig = makeConfig({ triage: { statusReactions: false } });
mockGlobalConfig = noReactConfig;
const classResult = {
classification: 'respond',
reasoning: 'test',
Expand Down Expand Up @@ -1082,6 +1132,7 @@ describe('triage module', () => {

it('should transition 👀 → 💬 → removed (no thinking tokens)', async () => {
const noThinkConfig = makeConfig({ triage: { thinkingTokens: 0 } });
mockGlobalConfig = noThinkConfig;
const classResult = {
classification: 'respond',
reasoning: 'test',
Expand Down Expand Up @@ -1128,6 +1179,7 @@ describe('triage module', () => {

it('should NOT add or remove reactions when statusReactions is false', async () => {
const noReactConfig = makeConfig({ triage: { statusReactions: false } });
mockGlobalConfig = noReactConfig;
const classResult = {
classification: 'respond',
reasoning: 'test',
Expand Down Expand Up @@ -1174,6 +1226,7 @@ describe('triage module', () => {
describe('trigger word evaluation', () => {
it('should call evaluateNow on trigger word detection', async () => {
const twConfig = makeConfig({ triage: { triggerWords: ['urgent'] } });
mockGlobalConfig = twConfig;
const classResult = {
classification: 'respond',
reasoning: 'trigger',
Expand Down
Loading