fix(discord): isolate conversations per-thread in thread-only mode
Thread-only mode previously relied on the global conversationMode to determine whether threads got separate conversations. In shared or per-channel mode, all threads shared one conversation causing crosstalk. Add forcePerChat flag on InboundMessage that the Discord adapter sets when thread-only mode is active. resolveConversationKey treats flagged messages as per-chat regardless of the configured mode, giving each thread its own isolated conversation while still sharing agent memory. Written by Cameron ◯ Letta Code "Time is an illusion. Lunchtime doubly so." -- Douglas Adams
This commit is contained in:
@@ -461,6 +461,8 @@ channels:
|
||||
|
||||
Thread messages inherit parent channel config, so child threads under `EZRA_CHANNEL_ID` use the same group rules.
|
||||
|
||||
When `threadMode: thread-only` is set, each thread automatically gets its own isolated conversation (message history), regardless of the global `conversations.mode` setting. This prevents messages from different threads from being interleaved in the same conversation. Agent memory (blocks) is still shared across all threads.
|
||||
|
||||
### Finding Group IDs
|
||||
|
||||
Each channel uses different identifiers for groups:
|
||||
|
||||
@@ -209,6 +209,7 @@ Behavior summary:
|
||||
- Top-level messages are ignored in `thread-only` mode.
|
||||
- Top-level @mentions create a thread and are answered in that thread when `autoCreateThreadOnMention` is enabled.
|
||||
- Thread messages inherit parent channel config. If `EZRA_CHANNEL_ID` is configured, replies in its child threads use that same config.
|
||||
- Each thread gets its own isolated conversation (message history), regardless of the global `conversations.mode` setting. This prevents crosstalk between threads. Agent memory (blocks) is still shared.
|
||||
|
||||
Required Discord permissions for auto-create:
|
||||
|
||||
|
||||
@@ -381,6 +381,7 @@ Ask the bot owner to approve with:
|
||||
let isListeningMode = false;
|
||||
let effectiveChatId = message.channel.id;
|
||||
let effectiveGroupName = groupName;
|
||||
let isThreadOnly = false;
|
||||
|
||||
// Group gating: config-based allowlist + mode
|
||||
if (isGroup && this.config.groups) {
|
||||
@@ -414,7 +415,8 @@ Ask the bot owner to approve with:
|
||||
}
|
||||
|
||||
const threadMode = resolveDiscordThreadMode(this.config.groups, keys);
|
||||
if (threadMode === 'thread-only' && !isThreadMessage) {
|
||||
isThreadOnly = threadMode === 'thread-only';
|
||||
if (isThreadOnly && !isThreadMessage) {
|
||||
const shouldCreateThread =
|
||||
wasMentioned && resolveDiscordAutoCreateThreadOnMention(this.config.groups, keys);
|
||||
if (!shouldCreateThread) {
|
||||
@@ -444,6 +446,7 @@ Ask the bot owner to approve with:
|
||||
serverId: message.guildId || undefined,
|
||||
wasMentioned,
|
||||
isListeningMode,
|
||||
forcePerChat: isThreadOnly || undefined,
|
||||
attachments,
|
||||
formatterHints: this.getFormatterHints(),
|
||||
});
|
||||
|
||||
@@ -150,10 +150,11 @@ export function resolveConversationKey(
|
||||
conversationMode: string | undefined,
|
||||
conversationOverrides: Set<string>,
|
||||
chatId?: string,
|
||||
forcePerChat?: boolean,
|
||||
): string {
|
||||
if (conversationMode === 'disabled') return 'default';
|
||||
const normalized = channel.toLowerCase();
|
||||
if (conversationMode === 'per-chat' && chatId) return `${normalized}:${chatId}`;
|
||||
if ((conversationMode === 'per-chat' || forcePerChat) && chatId) return `${normalized}:${chatId}`;
|
||||
if (conversationMode === 'per-channel') return normalized;
|
||||
if (conversationOverrides.has(normalized)) return normalized;
|
||||
return 'shared';
|
||||
@@ -562,8 +563,8 @@ export class LettaBot implements AgentSession {
|
||||
* Returns 'shared' in shared mode (unless channel is in perChannel overrides).
|
||||
* Returns channel id in per-channel mode or for override channels.
|
||||
*/
|
||||
private resolveConversationKey(channel: string, chatId?: string): string {
|
||||
return resolveConversationKey(channel, this.config.conversationMode, this.conversationOverrides, chatId);
|
||||
private resolveConversationKey(channel: string, chatId?: string, forcePerChat?: boolean): string {
|
||||
return resolveConversationKey(channel, this.config.conversationMode, this.conversationOverrides, chatId, forcePerChat);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -650,7 +651,7 @@ export class LettaBot implements AgentSession {
|
||||
}
|
||||
}
|
||||
|
||||
const convKey = this.resolveConversationKey(effective.channel, effective.chatId);
|
||||
const convKey = this.resolveConversationKey(effective.channel, effective.chatId, effective.forcePerChat);
|
||||
if (convKey !== 'shared') {
|
||||
this.enqueueForKey(convKey, effective, adapter);
|
||||
} else {
|
||||
@@ -889,7 +890,7 @@ export class LettaBot implements AgentSession {
|
||||
// queuing it for normal processing. This prevents a deadlock where
|
||||
// the stream is paused waiting for user input while the processing
|
||||
// flag blocks new messages from being handled.
|
||||
const incomingConvKey = this.resolveConversationKey(msg.channel, msg.chatId);
|
||||
const incomingConvKey = this.resolveConversationKey(msg.channel, msg.chatId, msg.forcePerChat);
|
||||
const pendingResolver = this.pendingQuestionResolvers.get(incomingConvKey);
|
||||
if (pendingResolver) {
|
||||
log.info(`Intercepted message as AskUserQuestion answer from ${msg.userId} (key=${incomingConvKey})`);
|
||||
@@ -909,7 +910,7 @@ export class LettaBot implements AgentSession {
|
||||
return;
|
||||
}
|
||||
|
||||
const convKey = this.resolveConversationKey(msg.channel, msg.chatId);
|
||||
const convKey = this.resolveConversationKey(msg.channel, msg.chatId, msg.forcePerChat);
|
||||
if (convKey !== 'shared') {
|
||||
// Per-channel, per-chat, or override mode: messages on different keys can run in parallel.
|
||||
this.enqueueForKey(convKey, msg, adapter);
|
||||
@@ -994,7 +995,7 @@ export class LettaBot implements AgentSession {
|
||||
|
||||
// Wait for the user's next message (intercepted by handleMessage).
|
||||
// Key by convKey so each chat resolves independently in per-chat mode.
|
||||
const questionConvKey = this.resolveConversationKey(msg.channel, msg.chatId);
|
||||
const questionConvKey = this.resolveConversationKey(msg.channel, msg.chatId, msg.forcePerChat);
|
||||
const answer = await new Promise<string>((resolve) => {
|
||||
this.pendingQuestionResolvers.set(questionConvKey, resolve);
|
||||
});
|
||||
@@ -1169,7 +1170,7 @@ export class LettaBot implements AgentSession {
|
||||
// Run session
|
||||
let session: Session | null = null;
|
||||
try {
|
||||
const convKey = this.resolveConversationKey(msg.channel, msg.chatId);
|
||||
const convKey = this.resolveConversationKey(msg.channel, msg.chatId, msg.forcePerChat);
|
||||
const seq = ++this.sendSequence;
|
||||
const userText = msg.text || '';
|
||||
log.info(`processMessage seq=${seq} key=${convKey} retried=${retried} user=${msg.userId} textLen=${userText.length}`);
|
||||
@@ -1605,7 +1606,7 @@ export class LettaBot implements AgentSession {
|
||||
// Only retry if we never sent anything to the user. hasResponse tracks
|
||||
// the current buffer, but finalizeMessage() clears it on type changes.
|
||||
// sentAnyMessage is the authoritative "did we deliver output" flag.
|
||||
const retryConvKey = this.resolveConversationKey(msg.channel, msg.chatId);
|
||||
const retryConvKey = this.resolveConversationKey(msg.channel, msg.chatId, msg.forcePerChat);
|
||||
const retryConvIdFromStore = (retryConvKey === 'shared'
|
||||
? this.store.conversationId
|
||||
: this.store.getConversationId(retryConvKey)) ?? undefined;
|
||||
@@ -1815,7 +1816,7 @@ export class LettaBot implements AgentSession {
|
||||
log.error('Failed to send error message to channel:', sendError);
|
||||
}
|
||||
} finally {
|
||||
const finalConvKey = this.resolveConversationKey(msg.channel, msg.chatId);
|
||||
const finalConvKey = this.resolveConversationKey(msg.channel, msg.chatId, msg.forcePerChat);
|
||||
// When session reuse is disabled, invalidate after every message to
|
||||
// eliminate any possibility of stream state bleed between sequential
|
||||
// sends. Costs ~5s subprocess init overhead per message.
|
||||
|
||||
@@ -85,10 +85,33 @@ describe('resolveConversationKey', () => {
|
||||
expect(resolveConversationKey('telegram', 'disabled', new Set(), '12345')).toBe('default');
|
||||
});
|
||||
|
||||
it('returns "default" in disabled mode regardless of overrides', () => {
|
||||
it('returns \"default\" in disabled mode regardless of overrides', () => {
|
||||
const overrides = new Set(['telegram']);
|
||||
expect(resolveConversationKey('telegram', 'disabled', overrides)).toBe('default');
|
||||
});
|
||||
|
||||
// --- forcePerChat ---
|
||||
|
||||
it('forcePerChat overrides shared mode to per-chat', () => {
|
||||
expect(resolveConversationKey('discord', 'shared', new Set(), '99999', true)).toBe('discord:99999');
|
||||
});
|
||||
|
||||
it('forcePerChat overrides per-channel mode to per-chat', () => {
|
||||
expect(resolveConversationKey('discord', 'per-channel', new Set(), '99999', true)).toBe('discord:99999');
|
||||
});
|
||||
|
||||
it('forcePerChat without chatId falls back normally', () => {
|
||||
expect(resolveConversationKey('discord', 'shared', new Set(), undefined, true)).toBe('shared');
|
||||
});
|
||||
|
||||
it('forcePerChat still respects disabled mode', () => {
|
||||
expect(resolveConversationKey('discord', 'disabled', new Set(), '99999', true)).toBe('default');
|
||||
});
|
||||
|
||||
it('forcePerChat=false does not change behavior', () => {
|
||||
expect(resolveConversationKey('discord', 'shared', new Set(), '99999', false)).toBe('shared');
|
||||
expect(resolveConversationKey('discord', 'per-channel', new Set(), '99999', false)).toBe('discord');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -84,6 +84,7 @@ export interface InboundMessage {
|
||||
isBatch?: boolean; // Is this a batched group message?
|
||||
batchedMessages?: InboundMessage[]; // Original individual messages (for batch formatting)
|
||||
isListeningMode?: boolean; // Listening mode: agent processes for memory but response is suppressed
|
||||
forcePerChat?: boolean; // Force per-chat conversation routing (e.g., Discord thread-only mode)
|
||||
formatterHints?: FormatterHints; // Channel capabilities for directive rendering
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user