From 93845db70a3e7d2c73e99519ce8474dd9eb66803 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 28 Mar 2026 21:38:53 +0700 Subject: [PATCH] Extract chat mention handling from session --- .../src/server/chat/chat-mentions.test.ts | 83 +++++++++++++ .../server/src/server/chat/chat-mentions.ts | 114 ++++++++++++++++++ .../src/server/chat/chat-service.test.ts | 6 +- packages/server/src/server/session.ts | 70 ++--------- skills/paseo-chat/SKILL.md | 9 ++ skills/paseo-orchestrator/SKILL.md | 6 + skills/paseo/SKILL.md | 1 + 7 files changed, 224 insertions(+), 65 deletions(-) create mode 100644 packages/server/src/server/chat/chat-mentions.test.ts create mode 100644 packages/server/src/server/chat/chat-mentions.ts diff --git a/packages/server/src/server/chat/chat-mentions.test.ts b/packages/server/src/server/chat/chat-mentions.test.ts new file mode 100644 index 000000000..95b3129e4 --- /dev/null +++ b/packages/server/src/server/chat/chat-mentions.test.ts @@ -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"), + ); + }); +}); diff --git a/packages/server/src/server/chat/chat-mentions.ts b/packages/server/src/server/chat/chat-mentions.ts new file mode 100644 index 000000000..9740c1b43 --- /dev/null +++ b/packages/server/src/server/chat/chat-mentions.ts @@ -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; + listLiveAgents: () => ManagedAgent[]; + resolveAgentIdentifier: ( + identifier: string, + ) => Promise<{ ok: true; agentId: string } | { ok: false; error: string }>; + sendAgentMessage: (agentId: string, text: string) => Promise; +} + +export async function notifyChatMentions(input: NotifyChatMentionsInput): Promise { + 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(); + 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"); +} diff --git a/packages/server/src/server/chat/chat-service.test.ts b/packages/server/src/server/chat/chat-service.test.ts index dfec8009d..2c8456e33 100644 --- a/packages/server/src/server/chat/chat-service.test.ts +++ b/packages/server/src/server/chat/chat-service.test.ts @@ -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([]); }); }); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 0abbd5072..d897ff8d8 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -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 { - 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, ): Promise { diff --git a/skills/paseo-chat/SKILL.md b/skills/paseo-chat/SKILL.md index ead8f1e48..764ef54b3 100644 --- a/skills/paseo-chat/SKILL.md +++ b/skills/paseo-chat/SKILL.md @@ -28,6 +28,7 @@ When using chat: Mentions are active: - write mentions inline in the message body as `@` 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 "@ 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 `@` 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 `@` mentions when you want to notify someone directly +7. Use `@everyone` when you need to notify all active non-archived agents diff --git a/skills/paseo-orchestrator/SKILL.md b/skills/paseo-orchestrator/SKILL.md index bf8fcb76e..a246675bb 100644 --- a/skills/paseo-orchestrator/SKILL.md +++ b/skills/paseo-orchestrator/SKILL.md @@ -102,6 +102,12 @@ Post your progress here. @$PASEO_AGENT_ID when done, and start on this now @ "@everyone Stop current work and post a one-line status update plus blockers." +``` + ### Role-based provider selection Pick the right provider for each role: diff --git a/skills/paseo/SKILL.md b/skills/paseo/SKILL.md index 77c483526..41f77c6c4 100644 --- a/skills/paseo/SKILL.md +++ b/skills/paseo/SKILL.md @@ -134,6 +134,7 @@ paseo chat inspect paseo chat post "" paseo chat post "" --reply-to paseo chat post "@ " +paseo chat post "@everyone " # Read messages paseo chat read