Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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
38 changes: 30 additions & 8 deletions src/modules/triage.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,16 +23,21 @@ import { buildMemoryContext, extractAndStoreMemories } from './memory.js';

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

import { getConfig } from './config.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 +550,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 +609,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
1 change: 1 addition & 0 deletions tests/modules/ai.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ describe('ai module', () => {
'hello',
'testuser',
null,
null,
]);
});
});
Expand Down
11 changes: 11 additions & 0 deletions tests/modules/triage.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,17 @@
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(),
}));

import { info, warn } from '../../src/logger.js';
import { isSpam } from '../../src/modules/spam.js';
Expand Down Expand Up @@ -219,7 +230,7 @@
accumulateMessage(makeMessage('ch1', 'hello'), config);
await evaluateNow('ch1', config, client, healthMonitor);

expect(mockClassifierSend).toHaveBeenCalled();

Check failure on line 233 in tests/modules/triage.test.js

View workflow job for this annotation

GitHub Actions / Test (Vitest Coverage)

tests/modules/triage.test.js > triage module > accumulateMessage > should add message to the channel buffer and classify on evaluate

AssertionError: expected "vi.fn()" to be called at least once ❯ tests/modules/triage.test.js:233:34
expect(mockResponderSend).toHaveBeenCalled();
});

Expand Down Expand Up @@ -258,7 +269,7 @@
accumulateMessage(makeMessage('any-channel', 'hello'), config);
await evaluateNow('any-channel', config, client, healthMonitor);

expect(mockClassifierSend).toHaveBeenCalled();

Check failure on line 272 in tests/modules/triage.test.js

View workflow job for this annotation

GitHub Actions / Test (Vitest Coverage)

tests/modules/triage.test.js > triage module > accumulateMessage > should allow any channel when allow list is empty

AssertionError: expected "vi.fn()" to be called at least once ❯ tests/modules/triage.test.js:272:34
});

it('should skip empty messages', async () => {
Expand Down Expand Up @@ -291,7 +302,7 @@
await evaluateNow('ch1', smallConfig, client, healthMonitor);

// The classifier prompt should contain only messages 2, 3, 4 (oldest dropped)
const prompt = mockClassifierSend.mock.calls[0][0];

Check failure on line 305 in tests/modules/triage.test.js

View workflow job for this annotation

GitHub Actions / Test (Vitest Coverage)

tests/modules/triage.test.js > triage module > accumulateMessage > should respect maxBufferSize cap

TypeError: Cannot read properties of undefined (reading '0') ❯ tests/modules/triage.test.js:305:54
expect(prompt).toContain('msg 2');
expect(prompt).toContain('msg 4');
expect(prompt).not.toContain('msg 0');
Expand Down Expand Up @@ -319,7 +330,7 @@
accumulateMessage(makeMessage('ch1', 'I need help please'), twConfig);

await vi.waitFor(() => {
expect(mockClassifierSend).toHaveBeenCalled();

Check failure on line 333 in tests/modules/triage.test.js

View workflow job for this annotation

GitHub Actions / Test (Vitest Coverage)

tests/modules/triage.test.js > triage module > checkTriggerWords > should force evaluation when trigger words match

AssertionError: expected "vi.fn()" to be called at least once ❯ tests/modules/triage.test.js:333:36
});
});

Expand Down
Loading