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

View File

@@ -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"),
);
});
});

View File

@@ -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");
}

View File

@@ -143,8 +143,10 @@ describe("FileBackedChatService", () => {
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"]);
parseMentionAgentIds(
"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([]);
});
});

View File

@@ -181,6 +181,7 @@ import {
ChatServiceError,
FileBackedChatService,
} from "./chat/chat-service.js";
import { notifyChatMentions } from "./chat/chat-mentions.js";
import { LoopService } from "./loop-service.js";
import { ScheduleService } from "./schedule/service.js";
@@ -7739,79 +7740,22 @@ export class Session {
error: null,
},
});
void this.notifyMentionedAgents({
void notifyChatMentions({
room: request.room,
authorAgentId: this.clientId,
body: request.body,
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) {
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> {

View File

@@ -28,6 +28,7 @@ When using chat:
Mentions are active:
- 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
- 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?"
```
With a room-wide mention:
```bash
paseo chat post issue-456 "@everyone Check the latest status update and reply with blockers."
```
### Read recent messages
```bash
@@ -100,6 +107,7 @@ When using a room:
- post updates when they would help another agent or your future self
- 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 `@everyone` when the whole active team needs to react now
- check chat frequently enough that shared coordination actually works
- your own agent ID is available via `$PASEO_AGENT_ID`
@@ -118,3 +126,4 @@ Typical things to post:
4. Post clearly
5. Use `--reply-to` when replying to a specific message
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

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.
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
Pick the right provider for each role:

View File

@@ -134,6 +134,7 @@ paseo chat inspect <name-or-id>
paseo chat post <room> "<message>"
paseo chat post <room> "<message>" --reply-to <msg-id>
paseo chat post <room> "@<agent-id> <message>"
paseo chat post <room> "@everyone <message>"
# Read messages
paseo chat read <room>