fix(channels): isolate sessions per chat and serialize prompts per session

Two bugs caused cross-talk between DM and group conversations:

1. Session routing key only used senderId, so the same user in DM and
   group shared one ACP session (and conversation context). Now includes
   chatId: `channelName:senderId:chatId`.

2. Concurrent messages on the same session caused textChunk listener
   pollution in AcpBridge.prompt(), leaking response text across chats.
   Added per-session promise queue in ChannelBase to serialize prompts.
This commit is contained in:
tanzhenxin 2026-03-26 11:58:03 +00:00
parent 33901fb9cd
commit f3a03d0bdc
2 changed files with 63 additions and 15 deletions

View file

@ -21,6 +21,8 @@ export abstract class ChannelBase {
protected name: string;
private instructedSessions: Set<string> = new Set();
private commands: Map<string, CommandHandler> = new Map();
/** Per-session promise chain to serialize prompt + send. */
private sessionQueues: Map<string, Promise<void>> = new Map();
constructor(
name: string,
@ -82,7 +84,11 @@ export abstract class ChannelBase {
/** Register shared slash commands. Called from constructor. */
private registerSharedCommands(): void {
const clearHandler: CommandHandler = async (envelope) => {
const removed = this.router.removeSession(this.name, envelope.senderId);
const removed = this.router.removeSession(
this.name,
envelope.senderId,
envelope.chatId,
);
if (removed) {
this.instructedSessions.clear();
await this.sendMessage(
@ -132,7 +138,11 @@ export abstract class ChannelBase {
});
this.registerCommand('status', async (envelope) => {
const hasSession = this.router.hasSession(this.name, envelope.senderId);
const hasSession = this.router.hasSession(
this.name,
envelope.senderId,
envelope.chatId,
);
const policy = this.config.senderPolicy;
const lines = [
`Session: ${hasSession ? 'active' : 'none'}`,
@ -209,14 +219,24 @@ export abstract class ChannelBase {
this.instructedSessions.add(sessionId);
}
const response = await this.bridge.prompt(sessionId, promptText, {
imageBase64: envelope.imageBase64,
imageMimeType: envelope.imageMimeType,
});
// Serialize prompt + send per session to prevent textChunk listener
// pollution when concurrent messages hit the same session.
const prev = this.sessionQueues.get(sessionId) ?? Promise.resolve();
const current = prev.then(async () => {
const response = await this.bridge.prompt(sessionId, promptText, {
imageBase64: envelope.imageBase64,
imageMimeType: envelope.imageMimeType,
});
if (response) {
await this.sendMessage(envelope.chatId, response);
}
if (response) {
await this.sendMessage(envelope.chatId, response);
}
});
this.sessionQueues.set(
sessionId,
current.catch(() => {}),
);
await current;
}
protected async onPairingRequired(

View file

@ -48,7 +48,7 @@ export class SessionRouter {
return `${channelName}:__single__`;
case 'user':
default:
return `${channelName}:${senderId}`;
return `${channelName}:${senderId}:${chatId}`;
}
}
@ -78,18 +78,46 @@ export class SessionRouter {
return this.toTarget.get(sessionId);
}
hasSession(channelName: string, senderId: string): boolean {
return this.toSession.has(`${channelName}:${senderId}`);
hasSession(channelName: string, senderId: string, chatId?: string): boolean {
const key = chatId
? this.routingKey(channelName, senderId, chatId)
: `${channelName}:${senderId}`;
// If chatId is provided, do exact lookup; otherwise prefix-scan for any match
if (chatId) return this.toSession.has(key);
for (const k of this.toSession.keys()) {
if (k.startsWith(`${channelName}:${senderId}`)) return true;
}
return false;
}
removeSession(channelName: string, senderId: string): boolean {
const key = `${channelName}:${senderId}`;
removeSession(
channelName: string,
senderId: string,
chatId?: string,
): boolean {
if (chatId) {
const key = this.routingKey(channelName, senderId, chatId);
return this.deleteByKey(key);
}
// No chatId: remove all sessions for this sender on this channel
let removed = false;
const prefix = `${channelName}:${senderId}`;
for (const k of [...this.toSession.keys()]) {
if (k.startsWith(prefix)) {
this.deleteByKey(k);
removed = true;
}
}
if (removed) this.persist();
return removed;
}
private deleteByKey(key: string): boolean {
const sessionId = this.toSession.get(key);
if (!sessionId) return false;
this.toSession.delete(key);
this.toTarget.delete(sessionId);
this.toCwd.delete(sessionId);
this.persist();
return true;
}