fix: align commands, batching, reactions, and LRU with forcePerChat
Address review findings from self-review and codex: - Commands (/reset, /cancel) now receive forcePerChat from the Discord adapter and resolve the correct per-thread conversation key - Group batcher propagates forcePerChat to synthetic batch messages so debounced thread messages don't fall back to shared routing - Reaction handler sets forcePerChat for thread-only reactions - Session LRU eviction fires regardless of conversationMode so forcePerChat sessions don't accumulate without bounds - Docs now correctly state thread-only overrides shared/per-channel modes (not disabled mode) Written by Cameron ◯ Letta Code "The best way to predict the future is to implement it." -- David Heinemeier Hansson
This commit is contained in:
@@ -185,7 +185,7 @@ describe('DiscordAdapter command gating', () => {
|
||||
await client!.emit('messageCreate', message);
|
||||
|
||||
expect(onCommand).toHaveBeenCalledTimes(1);
|
||||
expect(onCommand).toHaveBeenCalledWith('status', 'thread-1', undefined);
|
||||
expect(onCommand).toHaveBeenCalledWith('status', 'thread-1', undefined, true);
|
||||
expect(message.channel.send).toHaveBeenCalledWith('ok');
|
||||
await adapter.stop();
|
||||
});
|
||||
@@ -222,7 +222,7 @@ describe('DiscordAdapter command gating', () => {
|
||||
|
||||
expect(message.startThread).toHaveBeenCalledTimes(1);
|
||||
expect(onCommand).toHaveBeenCalledTimes(1);
|
||||
expect(onCommand).toHaveBeenCalledWith('status', 'thread-created', undefined);
|
||||
expect(onCommand).toHaveBeenCalledWith('status', 'thread-created', undefined, true);
|
||||
expect(threadSend).toHaveBeenCalledWith('ok');
|
||||
expect(message.channel.send).not.toHaveBeenCalled();
|
||||
await adapter.stop();
|
||||
|
||||
@@ -115,7 +115,7 @@ export class DiscordAdapter implements ChannelAdapter {
|
||||
private attachmentsMaxBytes?: number;
|
||||
|
||||
onMessage?: (msg: InboundMessage) => Promise<void>;
|
||||
onCommand?: (command: string, chatId?: string, args?: string) => Promise<string | null>;
|
||||
onCommand?: (command: string, chatId?: string, args?: string, forcePerChat?: boolean) => Promise<string | null>;
|
||||
|
||||
constructor(config: DiscordConfig) {
|
||||
this.config = {
|
||||
@@ -330,9 +330,11 @@ Ask the bot owner to approve with:
|
||||
? (message.channel as { send: (content: string) => Promise<unknown> })
|
||||
: null;
|
||||
|
||||
let commandForcePerChat = false;
|
||||
if (isGroup && this.config.groups) {
|
||||
const threadMode = resolveDiscordThreadMode(this.config.groups, keys);
|
||||
if (threadMode === 'thread-only' && !isThreadMessage) {
|
||||
commandForcePerChat = threadMode === 'thread-only';
|
||||
if (commandForcePerChat && !isThreadMessage) {
|
||||
const shouldCreateThread =
|
||||
wasMentioned && resolveDiscordAutoCreateThreadOnMention(this.config.groups, keys);
|
||||
if (!shouldCreateThread) {
|
||||
@@ -365,7 +367,7 @@ Ask the bot owner to approve with:
|
||||
}
|
||||
|
||||
if (this.onCommand && isManagedCommand) {
|
||||
const result = await this.onCommand(command, commandChatId, cmdArgs);
|
||||
const result = await this.onCommand(command, commandChatId, cmdArgs, commandForcePerChat || undefined);
|
||||
if (result) {
|
||||
if (!commandSendTarget) return;
|
||||
await commandSendTarget.send(result);
|
||||
@@ -619,6 +621,7 @@ Ask the bot owner to approve with:
|
||||
}
|
||||
|
||||
let isListeningMode = false;
|
||||
let reactionForcePerChat = false;
|
||||
if (isGroup && this.config.groups) {
|
||||
if (!isGroupAllowed(this.config.groups, keys)) {
|
||||
log.info(`Reaction group ${channelId} not in allowlist, ignoring`);
|
||||
@@ -639,6 +642,7 @@ Ask the bot owner to approve with:
|
||||
if (threadMode === 'thread-only' && !isThreadMessage) {
|
||||
return;
|
||||
}
|
||||
reactionForcePerChat = threadMode === 'thread-only';
|
||||
|
||||
const limits = resolveDailyLimits(this.config.groups, keys);
|
||||
const counterScope = limits.matchedKey ?? channelId;
|
||||
@@ -677,6 +681,7 @@ Ask the bot owner to approve with:
|
||||
groupName,
|
||||
serverId: message.guildId || undefined,
|
||||
isListeningMode,
|
||||
forcePerChat: reactionForcePerChat || undefined,
|
||||
reaction: {
|
||||
emoji,
|
||||
messageId: message.id,
|
||||
|
||||
@@ -33,7 +33,7 @@ export interface ChannelAdapter {
|
||||
|
||||
// Event handlers (set by bot core)
|
||||
onMessage?: (msg: InboundMessage) => Promise<void>;
|
||||
onCommand?: (command: string, chatId?: string, args?: string) => Promise<string | null>;
|
||||
onCommand?: (command: string, chatId?: string, args?: string, forcePerChat?: boolean) => Promise<string | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -605,7 +605,7 @@ export class LettaBot implements AgentSession {
|
||||
|
||||
registerChannel(adapter: ChannelAdapter): void {
|
||||
adapter.onMessage = (msg) => this.handleMessage(msg, adapter);
|
||||
adapter.onCommand = (cmd, chatId, args) => this.handleCommand(cmd, adapter.id, chatId, args);
|
||||
adapter.onCommand = (cmd, chatId, args, forcePerChat) => this.handleCommand(cmd, adapter.id, chatId, args, forcePerChat);
|
||||
|
||||
// Wrap outbound methods when any redaction layer is active.
|
||||
// Secrets are enabled by default unless explicitly disabled.
|
||||
@@ -666,7 +666,7 @@ export class LettaBot implements AgentSession {
|
||||
// Commands
|
||||
// =========================================================================
|
||||
|
||||
private async handleCommand(command: string, channelId?: string, chatId?: string, args?: string): Promise<string | null> {
|
||||
private async handleCommand(command: string, channelId?: string, chatId?: string, args?: string, forcePerChat?: boolean): Promise<string | null> {
|
||||
log.info(`Received: /${command}${args ? ` ${args}` : ''}`);
|
||||
switch (command) {
|
||||
case 'status': {
|
||||
@@ -694,7 +694,7 @@ export class LettaBot implements AgentSession {
|
||||
// other channels/chats' conversations are never silently destroyed.
|
||||
// resolveConversationKey returns 'shared' for non-override channels,
|
||||
// the channel id for per-channel, or channel:chatId for per-chat.
|
||||
const convKey = channelId ? this.resolveConversationKey(channelId, chatId) : 'shared';
|
||||
const convKey = channelId ? this.resolveConversationKey(channelId, chatId, forcePerChat) : 'shared';
|
||||
|
||||
// In disabled mode the bot always uses the agent's built-in default
|
||||
// conversation -- there's nothing to reset locally.
|
||||
@@ -725,7 +725,7 @@ export class LettaBot implements AgentSession {
|
||||
}
|
||||
}
|
||||
case 'cancel': {
|
||||
const convKey = channelId ? this.resolveConversationKey(channelId, chatId) : 'shared';
|
||||
const convKey = channelId ? this.resolveConversationKey(channelId, chatId, forcePerChat) : 'shared';
|
||||
|
||||
// Check if there's actually an active run for this conversation key
|
||||
if (!this.processingKeys.has(convKey) && !this.processing) {
|
||||
|
||||
@@ -93,6 +93,7 @@ export class GroupBatcher {
|
||||
wasMentioned: messages.some((m) => m.wasMentioned),
|
||||
// Preserve listening-mode intent only if every message in the batch is non-mentioned listen mode.
|
||||
isListeningMode: messages.every((m) => m.isListeningMode === true) ? true : undefined,
|
||||
forcePerChat: last.forcePerChat,
|
||||
isBatch: true,
|
||||
batchedMessages: messages,
|
||||
formatterHints: last.formatterHints,
|
||||
|
||||
@@ -360,10 +360,11 @@ export class SessionManager {
|
||||
return this.ensureSessionForKey(key, bootstrapRetried);
|
||||
}
|
||||
|
||||
// LRU eviction: in per-chat mode, limit concurrent sessions to avoid
|
||||
// unbounded subprocess growth.
|
||||
// LRU eviction: limit concurrent sessions to avoid unbounded subprocess
|
||||
// growth. Applies in per-chat mode and when forcePerChat (e.g., Discord
|
||||
// thread-only) creates per-thread keys in other modes.
|
||||
const maxSessions = this.config.maxSessions ?? 10;
|
||||
if (this.config.conversationMode === 'per-chat' && this.sessions.size >= maxSessions) {
|
||||
if (this.sessions.size >= maxSessions) {
|
||||
let oldestKey: string | null = null;
|
||||
let oldestTime = Infinity;
|
||||
for (const [k, ts] of this.sessionLastUsed) {
|
||||
|
||||
Reference in New Issue
Block a user