Extract chat mention handling from session
This commit is contained in:
@@ -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([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user