mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Extract chat mention handling from session
This commit is contained in:
83
packages/server/src/server/chat/chat-mentions.test.ts
Normal file
83
packages/server/src/server/chat/chat-mentions.test.ts
Normal 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"),
|
||||
);
|
||||
});
|
||||
});
|
||||
114
packages/server/src/server/chat/chat-mentions.ts
Normal file
114
packages/server/src/server/chat/chat-mentions.ts
Normal 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");
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user