Extract chat mention handling from session

This commit is contained in:
Mohamed Boudra
2026-03-28 21:38:53 +07:00
parent d5085294df
commit 93845db70a
7 changed files with 224 additions and 65 deletions
@@ -0,0 +1,83 @@
import { describe, expect, test, vi } from "vitest";
import {
buildChatMentionNotification,
notifyChatMentions,
resolveChatMentionTargetAgentIds,
} from "./chat-mentions.js";
describe("chat mentions", () => {
test("@everyone expands to active non-archived agents", () => {
const targets = resolveChatMentionTargetAgentIds({
authorAgentId: "author-agent",
mentionAgentIds: ["everyone"],
storedAgents: [
{ id: "agent-a", internal: false, archivedAt: null } as any,
{ id: "agent-b", internal: false, archivedAt: "2026-03-28T00:00:00.000Z" } as any,
{ id: "author-agent", internal: false, archivedAt: null } as any,
{ id: "internal-agent", internal: true, archivedAt: null } as any,
],
liveAgents: [
{ id: "agent-c", internal: false } as any,
{ id: "internal-live", internal: true } as any,
],
});
expect(targets.sort()).toEqual(["agent-a", "agent-c"]);
});
test("@everyone deduplicates with explicit mentions and keeps explicit non-everyone mentions", () => {
const targets = resolveChatMentionTargetAgentIds({
authorAgentId: "author-agent",
mentionAgentIds: ["everyone", "agent-a", "custom-title"],
storedAgents: [{ id: "agent-a", internal: false, archivedAt: null } as any],
liveAgents: [{ id: "agent-b", internal: false } as any],
});
expect(targets.sort()).toEqual(["agent-a", "agent-b", "custom-title"]);
});
test("notification body strips inline mentions but keeps the room context", () => {
expect(
buildChatMentionNotification({
room: "coord-room",
authorAgentId: "author-agent",
body: "@agent-a @everyone Check the latest status.",
mentionAgentIds: ["agent-a", "everyone"],
}),
).toContain("Check the latest status.");
});
test("notifyChatMentions delegates sends for resolved targets", async () => {
const resolveAgentIdentifier = vi.fn(async (identifier: string) => ({
ok: true as const,
agentId: identifier,
}));
const sendAgentMessage = vi.fn(async () => {});
const logger = {
warn: vi.fn(),
} as any;
await notifyChatMentions({
room: "coord-room",
authorAgentId: "author-agent",
body: "@everyone Check status",
mentionAgentIds: ["everyone"],
logger,
listStoredAgents: async () => [{ id: "agent-a", internal: false, archivedAt: null } as any],
listLiveAgents: () => [{ id: "agent-b", internal: false } as any],
resolveAgentIdentifier,
sendAgentMessage,
});
expect(resolveAgentIdentifier).toHaveBeenCalledTimes(2);
expect(sendAgentMessage).toHaveBeenCalledTimes(2);
expect(sendAgentMessage).toHaveBeenCalledWith(
"agent-a",
expect.stringContaining('room "coord-room"'),
);
expect(sendAgentMessage).toHaveBeenCalledWith(
"agent-b",
expect.stringContaining("Check status"),
);
});
});
@@ -0,0 +1,114 @@
import type pino from "pino";
import type { StoredAgentRecord } from "../agent/agent-storage.js";
import type { ManagedAgent } from "../agent/agent-manager.js";
export interface ChatMentionNotificationInput {
room: string;
authorAgentId: string;
body: string;
mentionAgentIds: string[];
}
export interface NotifyChatMentionsInput extends ChatMentionNotificationInput {
logger: pino.Logger;
listStoredAgents: () => Promise<StoredAgentRecord[]>;
listLiveAgents: () => ManagedAgent[];
resolveAgentIdentifier: (
identifier: string,
) => Promise<{ ok: true; agentId: string } | { ok: false; error: string }>;
sendAgentMessage: (agentId: string, text: string) => Promise<void>;
}
export async function notifyChatMentions(input: NotifyChatMentionsInput): Promise<void> {
const mentionAgentIds = await resolveChatMentionTargetAgentIds({
authorAgentId: input.authorAgentId,
mentionAgentIds: input.mentionAgentIds,
storedAgents: await input.listStoredAgents(),
liveAgents: input.listLiveAgents(),
});
if (mentionAgentIds.length === 0) {
return;
}
const notification = buildChatMentionNotification({
room: input.room,
authorAgentId: input.authorAgentId,
body: input.body,
mentionAgentIds,
});
await Promise.all(
mentionAgentIds.map(async (mentionedAgentId) => {
const resolved = await input.resolveAgentIdentifier(mentionedAgentId);
if (!resolved.ok) {
input.logger.warn(
{ mentionedAgentId, room: input.room, error: resolved.error },
"Failed to resolve chat mention target",
);
return;
}
try {
await input.sendAgentMessage(resolved.agentId, notification);
} catch (error) {
input.logger.warn(
{ err: error, mentionedAgentId: resolved.agentId, room: input.room },
"Failed to notify mentioned agent about chat message",
);
}
}),
);
}
export function resolveChatMentionTargetAgentIds(input: {
authorAgentId: string;
mentionAgentIds: string[];
storedAgents: StoredAgentRecord[];
liveAgents: ManagedAgent[];
}): string[] {
const targets = new Set<string>();
const mentionsEveryone = input.mentionAgentIds.includes("everyone");
for (const mentionAgentId of input.mentionAgentIds) {
if (mentionAgentId === "everyone") {
continue;
}
if (mentionAgentId !== input.authorAgentId) {
targets.add(mentionAgentId);
}
}
if (!mentionsEveryone) {
return Array.from(targets);
}
for (const record of input.storedAgents) {
if (record.internal || record.archivedAt || record.id === input.authorAgentId) {
continue;
}
targets.add(record.id);
}
for (const agent of input.liveAgents) {
if (agent.internal || agent.id === input.authorAgentId) {
continue;
}
targets.add(agent.id);
}
return Array.from(targets);
}
export function buildChatMentionNotification(input: ChatMentionNotificationInput): 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");
}
@@ -143,8 +143,10 @@ describe("FileBackedChatService", () => {
test("extracts inline mentions from chat bodies", () => { test("extracts inline mentions from chat bodies", () => {
expect( expect(
parseMentionAgentIds("Checking with @agent-a, (@agent_b), and duplicate @agent-a again."), parseMentionAgentIds(
).toEqual(["agent-a", "agent_b"]); "Checking with @agent-a, (@agent_b), @everyone, and duplicate @agent-a again.",
),
).toEqual(["agent-a", "agent_b", "everyone"]);
expect(parseMentionAgentIds("email@example.com is not a mention")).toEqual([]); expect(parseMentionAgentIds("email@example.com is not a mention")).toEqual([]);
}); });
}); });
+7 -63
View File
@@ -181,6 +181,7 @@ import {
ChatServiceError, ChatServiceError,
FileBackedChatService, FileBackedChatService,
} from "./chat/chat-service.js"; } from "./chat/chat-service.js";
import { notifyChatMentions } from "./chat/chat-mentions.js";
import { LoopService } from "./loop-service.js"; import { LoopService } from "./loop-service.js";
import { ScheduleService } from "./schedule/service.js"; import { ScheduleService } from "./schedule/service.js";
@@ -7739,79 +7740,22 @@ export class Session {
error: null, error: null,
}, },
}); });
void this.notifyMentionedAgents({ void notifyChatMentions({
room: request.room, room: request.room,
authorAgentId: this.clientId, authorAgentId: this.clientId,
body: request.body, body: request.body,
mentionAgentIds: message.mentionAgentIds, mentionAgentIds: message.mentionAgentIds,
logger: this.sessionLogger,
listStoredAgents: () => this.agentStorage.list(),
listLiveAgents: () => this.agentManager.listAgents(),
resolveAgentIdentifier: (identifier) => this.resolveAgentIdentifier(identifier),
sendAgentMessage: (agentId, text) => this.handleSendAgentMessage(agentId, text),
}); });
} catch (error) { } catch (error) {
this.emitChatRpcError(request, 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( private async handleChatReadRequest(
request: Extract<SessionInboundMessage, { type: "chat/read" }>, request: Extract<SessionInboundMessage, { type: "chat/read" }>,
): Promise<void> { ): Promise<void> {
+9
View File
@@ -28,6 +28,7 @@ When using chat:
Mentions are active: Mentions are active:
- write mentions inline in the message body as `@<agent-id>` to notify a specific agent immediately - write mentions inline in the message body as `@<agent-id>` to notify a specific agent immediately
- use `@everyone` to notify all non-archived, non-internal agents
- notifications are sent to the target agent without blocking the chat post - notifications are sent to the target agent without blocking the chat post
- if a normal post is enough and no one needs to act right now, skip the mention - if a normal post is enough and no one needs to act right now, skip the mention
@@ -69,6 +70,12 @@ With a direct mention:
paseo chat post issue-456 "@<agent-id> Can you verify the relay path next?" paseo chat post issue-456 "@<agent-id> Can you verify the relay path next?"
``` ```
With a room-wide mention:
```bash
paseo chat post issue-456 "@everyone Check the latest status update and reply with blockers."
```
### Read recent messages ### Read recent messages
```bash ```bash
@@ -100,6 +107,7 @@ When using a room:
- post updates when they would help another agent or your future self - post updates when they would help another agent or your future self
- use `--reply-to` when responding to a specific message - use `--reply-to` when responding to a specific message
- use inline `@<agent-id>` mentions when you want to get a specific agent's attention - use inline `@<agent-id>` mentions when you want to get a specific agent's attention
- use `@everyone` when the whole active team needs to react now
- check chat frequently enough that shared coordination actually works - check chat frequently enough that shared coordination actually works
- your own agent ID is available via `$PASEO_AGENT_ID` - your own agent ID is available via `$PASEO_AGENT_ID`
@@ -118,3 +126,4 @@ Typical things to post:
4. Post clearly 4. Post clearly
5. Use `--reply-to` when replying to a specific message 5. Use `--reply-to` when replying to a specific message
6. Use inline `@<agent-id>` mentions when you want to notify someone directly 6. Use inline `@<agent-id>` mentions when you want to notify someone directly
7. Use `@everyone` when you need to notify all active non-archived agents
+6
View File
@@ -102,6 +102,12 @@ Post your progress here. @$PASEO_AGENT_ID when done, and start on this now @<age
The agent gets notified with the message and starts working. When done, it mentions you back in chat. The agent gets notified with the message and starts working. When done, it mentions you back in chat.
Use `@everyone` when you need all active, non-archived agents in the room to react:
```bash
paseo chat post <room> "@everyone Stop current work and post a one-line status update plus blockers."
```
### Role-based provider selection ### Role-based provider selection
Pick the right provider for each role: Pick the right provider for each role:
+1
View File
@@ -134,6 +134,7 @@ paseo chat inspect <name-or-id>
paseo chat post <room> "<message>" paseo chat post <room> "<message>"
paseo chat post <room> "<message>" --reply-to <msg-id> paseo chat post <room> "<message>" --reply-to <msg-id>
paseo chat post <room> "@<agent-id> <message>" paseo chat post <room> "@<agent-id> <message>"
paseo chat post <room> "@everyone <message>"
# Read messages # Read messages
paseo chat read <room> paseo chat read <room>