Make chat mentions inline

This commit is contained in:
Mohamed Boudra
2026-03-28 10:51:22 +07:00
parent 05a4e8f5a3
commit 7ca428677c
12 changed files with 123 additions and 45 deletions
@@ -370,7 +370,6 @@ export type PostChatMessageOptions = {
room: string;
body: string;
replyToMessageId?: string | null;
mentionAgentIds?: string[];
requestId?: string;
};
export type ReadChatMessagesOptions = {
@@ -2913,9 +2912,6 @@ export class DaemonClient {
room: options.room,
body: options.body,
...(options.replyToMessageId ? { replyToMessageId: options.replyToMessageId } : {}),
...(options.mentionAgentIds && options.mentionAgentIds.length > 0
? { mentionAgentIds: options.mentionAgentIds }
: {}),
},
responseType: "chat/post/response",
timeout: 10000,
@@ -31,7 +31,6 @@ export const ChatPostRequestSchema = z.object({
room: z.string(),
body: z.string(),
replyToMessageId: z.string().optional(),
mentionAgentIds: z.array(z.string()).optional(),
});
export const ChatReadRequestSchema = z.object({
@@ -3,7 +3,7 @@ import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import pino from "pino";
import { ChatServiceError, FileBackedChatService } from "./chat-service.js";
import { ChatServiceError, FileBackedChatService, parseMentionAgentIds } from "./chat-service.js";
describe("FileBackedChatService", () => {
let paseoHome: string;
@@ -48,8 +48,7 @@ describe("FileBackedChatService", () => {
const first = await service.postMessage({
room: room.name,
authorAgentId: "agent-a",
body: "first message",
mentionAgentIds: ["agent-b", "agent-b", "agent-c"],
body: "first message for @agent-b and @agent-c and again @agent-b",
});
await service.postMessage({
room: room.id,
@@ -141,4 +140,11 @@ describe("FileBackedChatService", () => {
code: "chat_room_not_found",
});
});
test("extracts inline mentions from chat bodies", () => {
expect(
parseMentionAgentIds("Checking with @agent-a, (@agent_b), and duplicate @agent-a again."),
).toEqual(["agent-a", "agent_b"]);
expect(parseMentionAgentIds("email@example.com is not a mention")).toEqual([]);
});
});
@@ -31,6 +31,19 @@ function trimToNull(value: string | null | undefined): string | null {
return trimmed.length > 0 ? trimmed : null;
}
const CHAT_MENTION_PATTERN = /(?:^|[\s(])@([A-Za-z0-9][A-Za-z0-9._-]*)/g;
export function parseMentionAgentIds(body: string): string[] {
const mentionAgentIds = new Set<string>();
for (const match of body.matchAll(CHAT_MENTION_PATTERN)) {
const agentId = match[1]?.trim();
if (agentId) {
mentionAgentIds.add(agentId);
}
}
return Array.from(mentionAgentIds).sort();
}
export class ChatServiceError extends Error {
readonly code: string;
@@ -67,7 +80,6 @@ export interface PostChatMessageInput {
authorAgentId: string;
body: string;
replyToMessageId?: string | null;
mentionAgentIds?: string[];
}
export interface ReadChatMessagesInput {
@@ -195,9 +207,7 @@ export class FileBackedChatService {
authorAgentId,
body,
replyToMessageId,
mentionAgentIds: [...new Set((input.mentionAgentIds ?? []).map((value) => value.trim()))]
.filter((value) => value.length > 0)
.sort(),
mentionAgentIds: parseMentionAgentIds(body),
createdAt,
});
+68 -1
View File
@@ -7730,7 +7730,6 @@ export class Session {
authorAgentId: this.clientId,
body: request.body,
replyToMessageId: request.replyToMessageId,
mentionAgentIds: request.mentionAgentIds,
});
this.emit({
type: "chat/post/response",
@@ -7740,11 +7739,79 @@ export class Session {
error: null,
},
});
void this.notifyMentionedAgents({
room: request.room,
authorAgentId: this.clientId,
body: request.body,
mentionAgentIds: message.mentionAgentIds,
});
} catch (error) {
this.emitChatRpcError(request, error);
}
}
private async notifyMentionedAgents(input: {
room: string;
authorAgentId: string;
body: string;
mentionAgentIds: string[];
}): Promise<void> {
const mentionAgentIds = input.mentionAgentIds.filter(
(agentId) => agentId !== input.authorAgentId,
);
if (mentionAgentIds.length === 0) {
return;
}
const notification = this.buildChatMentionNotification({
...input,
mentionAgentIds,
});
await Promise.all(
mentionAgentIds.map(async (mentionedAgentId) => {
const resolved = await this.resolveAgentIdentifier(mentionedAgentId);
if (!resolved.ok) {
this.sessionLogger.warn(
{ mentionedAgentId, room: input.room, error: resolved.error },
"Failed to resolve chat mention target",
);
return;
}
try {
await this.handleSendAgentMessage(resolved.agentId, notification);
} catch (error) {
this.sessionLogger.warn(
{ err: error, mentionedAgentId: resolved.agentId, room: input.room },
"Failed to notify mentioned agent about chat message",
);
}
}),
);
}
private buildChatMentionNotification(input: {
room: string;
authorAgentId: string;
body: string;
mentionAgentIds: string[];
}): string {
const mentioned = input.mentionAgentIds.map((agentId) => `@${agentId}`).join(", ");
const bodyWithoutMentions = input.body
.replace(/(^|\s)@[A-Za-z0-9][A-Za-z0-9._-]*/g, "$1")
.trim();
const body = bodyWithoutMentions.length > 0 ? bodyWithoutMentions : input.body;
return [
`Chat mention from ${input.authorAgentId} in room "${input.room}".`,
`Mentioned agents: ${mentioned}.`,
"Message:",
body,
`Read the room with: paseo chat read ${input.room} --limit 20`,
].join("\n");
}
private async handleChatReadRequest(
request: Extract<SessionInboundMessage, { type: "chat/read" }>,
): Promise<void> {