fix(FN-3886): resolve peer agent names in mail and notifications
- Resolve participant display names in AgentDetailView mail tab for peer agents - Update MailboxModal labeling to use readable agent names instead of raw IDs - Propagate resolved agent names through notification service, notifier, and provider payloads - Add and update dashboard/engine tests plus a changeset for @runfusion/fusion Fusion-Task-Id: FN-3886
This commit is contained in:
5
.changeset/FN-3886-agent-name-in-notifications.md
Normal file
5
.changeset/FN-3886-agent-name-in-notifications.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Use agent names (with ID fallback) in agent message notifications and mailbox labels across ntfy/webhook outputs and dashboard mailbox views.
|
||||||
@@ -1211,9 +1211,17 @@ function formatMailboxTimestamp(ts: string): string {
|
|||||||
return date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
return date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
||||||
}
|
}
|
||||||
|
|
||||||
function mailboxParticipantLabel(id: string, type: ParticipantType): string {
|
function mailboxParticipantLabel(
|
||||||
|
id: string,
|
||||||
|
type: ParticipantType,
|
||||||
|
agentNamesById?: ReadonlyMap<string, string>,
|
||||||
|
): string {
|
||||||
if (type === "user") return id === "dashboard" ? "You" : `User: ${id}`;
|
if (type === "user") return id === "dashboard" ? "You" : `User: ${id}`;
|
||||||
if (type === "agent") return `Agent: ${id}`;
|
if (type === "agent") {
|
||||||
|
const name = agentNamesById?.get(id)?.trim();
|
||||||
|
if (!name || name === id) return `Agent: ${id}`;
|
||||||
|
return `Agent: ${name}`;
|
||||||
|
}
|
||||||
return "System";
|
return "System";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1235,6 +1243,43 @@ function MailTab({
|
|||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [activeSubtab, setActiveSubtab] = useState<"inbox" | "outbox">("inbox");
|
const [activeSubtab, setActiveSubtab] = useState<"inbox" | "outbox">("inbox");
|
||||||
|
const [knownAgents, setKnownAgents] = useState<Agent[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
fetchAgents(undefined, projectId)
|
||||||
|
.then((agents) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setKnownAgents(agents);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setKnownAgents([]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [projectId]);
|
||||||
|
|
||||||
|
const agentNamesById = useMemo(() => {
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
for (const knownAgent of knownAgents) {
|
||||||
|
if (!knownAgent.id) continue;
|
||||||
|
const name = typeof knownAgent.name === "string" ? knownAgent.name.trim() : "";
|
||||||
|
if (name.length > 0) {
|
||||||
|
map.set(knownAgent.id, name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const currentAgentName = typeof agent.name === "string" ? agent.name.trim() : "";
|
||||||
|
if (currentAgentName.length > 0) {
|
||||||
|
map.set(agent.id, currentAgentName);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [knownAgents, agent.id, agent.name]);
|
||||||
const [selectedMessageId, setSelectedMessageId] = useState<string | null>(null);
|
const [selectedMessageId, setSelectedMessageId] = useState<string | null>(null);
|
||||||
const messages = activeSubtab === "inbox" ? (mailbox?.inbox ?? []) : (mailbox?.outbox ?? []);
|
const messages = activeSubtab === "inbox" ? (mailbox?.inbox ?? []) : (mailbox?.outbox ?? []);
|
||||||
const selectedMessage = selectedMessageId ? messages.find((message) => message.id === selectedMessageId) ?? null : null;
|
const selectedMessage = selectedMessageId ? messages.find((message) => message.id === selectedMessageId) ?? null : null;
|
||||||
@@ -1282,9 +1327,9 @@ function MailTab({
|
|||||||
<div className="mailbox-item-content">
|
<div className="mailbox-item-content">
|
||||||
<div className="mailbox-item-header">
|
<div className="mailbox-item-header">
|
||||||
{activeSubtab === "inbox" ? (
|
{activeSubtab === "inbox" ? (
|
||||||
<span className="mailbox-item-from">{mailboxParticipantLabel(message.fromId, message.fromType)}</span>
|
<span className="mailbox-item-from">{mailboxParticipantLabel(message.fromId, message.fromType, agentNamesById)}</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="mailbox-item-to">To: {mailboxParticipantLabel(message.toId, message.toType)}</span>
|
<span className="mailbox-item-to">To: {mailboxParticipantLabel(message.toId, message.toType, agentNamesById)}</span>
|
||||||
)}
|
)}
|
||||||
<span className="mailbox-item-time">{formatMailboxTimestamp(message.createdAt)}</span>
|
<span className="mailbox-item-time">{formatMailboxTimestamp(message.createdAt)}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -1351,11 +1396,11 @@ function MailTab({
|
|||||||
<div className="agent-mail-tab-detail-meta">
|
<div className="agent-mail-tab-detail-meta">
|
||||||
<div className="agent-mail-tab-detail-row">
|
<div className="agent-mail-tab-detail-row">
|
||||||
<span className="agent-mail-tab-detail-label">From</span>
|
<span className="agent-mail-tab-detail-label">From</span>
|
||||||
<span>{mailboxParticipantLabel(selectedMessage.fromId, selectedMessage.fromType)}</span>
|
<span>{mailboxParticipantLabel(selectedMessage.fromId, selectedMessage.fromType, agentNamesById)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="agent-mail-tab-detail-row">
|
<div className="agent-mail-tab-detail-row">
|
||||||
<span className="agent-mail-tab-detail-label">To</span>
|
<span className="agent-mail-tab-detail-label">To</span>
|
||||||
<span>{mailboxParticipantLabel(selectedMessage.toId, selectedMessage.toType)}</span>
|
<span>{mailboxParticipantLabel(selectedMessage.toId, selectedMessage.toType, agentNamesById)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="agent-mail-tab-detail-row">
|
<div className="agent-mail-tab-detail-row">
|
||||||
<span className="agent-mail-tab-detail-label">Type</span>
|
<span className="agent-mail-tab-detail-label">Type</span>
|
||||||
|
|||||||
@@ -68,9 +68,17 @@ function formatTimestamp(ts: string): string {
|
|||||||
return date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
return date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
||||||
}
|
}
|
||||||
|
|
||||||
function participantLabel(id: string, type: ParticipantType): string {
|
function participantLabel(
|
||||||
|
id: string,
|
||||||
|
type: ParticipantType,
|
||||||
|
agentNamesById?: ReadonlyMap<string, string>,
|
||||||
|
): string {
|
||||||
if (type === "user") return id === "dashboard" ? "You" : `User: ${id}`;
|
if (type === "user") return id === "dashboard" ? "You" : `User: ${id}`;
|
||||||
if (type === "agent") return `Agent: ${id}`;
|
if (type === "agent") {
|
||||||
|
const name = agentNamesById?.get(id)?.trim();
|
||||||
|
if (!name || name === id) return `Agent: ${id}`;
|
||||||
|
return `Agent: ${name}`;
|
||||||
|
}
|
||||||
return "System";
|
return "System";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,6 +168,18 @@ export function MailboxModal({
|
|||||||
const [replyContextLoading, setReplyContextLoading] = useState<Record<string, boolean>>({});
|
const [replyContextLoading, setReplyContextLoading] = useState<Record<string, boolean>>({});
|
||||||
const [replyContextErrors, setReplyContextErrors] = useState<Record<string, string>>({});
|
const [replyContextErrors, setReplyContextErrors] = useState<Record<string, string>>({});
|
||||||
const [replyContextCache, setReplyContextCache] = useState<Map<string, Message>>(new Map());
|
const [replyContextCache, setReplyContextCache] = useState<Map<string, Message>>(new Map());
|
||||||
|
const agentNamesById = useMemo(() => {
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
for (const agent of agents) {
|
||||||
|
if (!agent.id) continue;
|
||||||
|
const name = typeof agent.name === "string" ? agent.name.trim() : "";
|
||||||
|
if (name.length > 0) {
|
||||||
|
map.set(agent.id, name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [agents]);
|
||||||
|
|
||||||
const viewportMode = useViewportMode();
|
const viewportMode = useViewportMode();
|
||||||
const isMobile = viewportMode === "mobile";
|
const isMobile = viewportMode === "mobile";
|
||||||
const { keyboardOverlap, viewportHeight, viewportOffsetTop, keyboardOpen } = useMobileKeyboard({ enabled: isMobile });
|
const { keyboardOverlap, viewportHeight, viewportOffsetTop, keyboardOpen } = useMobileKeyboard({ enabled: isMobile });
|
||||||
@@ -532,7 +552,7 @@ export function MailboxModal({
|
|||||||
{cacheMessage && (
|
{cacheMessage && (
|
||||||
<>
|
<>
|
||||||
<div className="mailbox-conversation-msg-header">
|
<div className="mailbox-conversation-msg-header">
|
||||||
<span>{participantLabel(cacheMessage.fromId, cacheMessage.fromType)}</span>
|
<span>{participantLabel(cacheMessage.fromId, cacheMessage.fromType, agentNamesById)}</span>
|
||||||
<span className="mailbox-message-time">{formatTimestamp(cacheMessage.createdAt)}</span>
|
<span className="mailbox-message-time">{formatTimestamp(cacheMessage.createdAt)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mailbox-conversation-msg-body">{cacheMessage.content}</div>
|
<div className="mailbox-conversation-msg-body">{cacheMessage.content}</div>
|
||||||
@@ -693,14 +713,14 @@ export function MailboxModal({
|
|||||||
<span className="mailbox-participant-label">From:</span>
|
<span className="mailbox-participant-label">From:</span>
|
||||||
<span className="mailbox-participant-value">
|
<span className="mailbox-participant-value">
|
||||||
{selectedMessage.fromType === "agent" ? <Bot size={14} /> : <User size={14} />}
|
{selectedMessage.fromType === "agent" ? <Bot size={14} /> : <User size={14} />}
|
||||||
{participantLabel(selectedMessage.fromId, selectedMessage.fromType)}
|
{participantLabel(selectedMessage.fromId, selectedMessage.fromType, agentNamesById)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mailbox-participant">
|
<div className="mailbox-participant">
|
||||||
<span className="mailbox-participant-label">To:</span>
|
<span className="mailbox-participant-label">To:</span>
|
||||||
<span className="mailbox-participant-value">
|
<span className="mailbox-participant-value">
|
||||||
{selectedMessage.toType === "agent" ? <Bot size={14} /> : <User size={14} />}
|
{selectedMessage.toType === "agent" ? <Bot size={14} /> : <User size={14} />}
|
||||||
{participantLabel(selectedMessage.toId, selectedMessage.toType)}
|
{participantLabel(selectedMessage.toId, selectedMessage.toType, agentNamesById)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -721,7 +741,7 @@ export function MailboxModal({
|
|||||||
className={`mailbox-conversation-msg ${msg.id === selectedMessage.id ? "current" : ""}`}
|
className={`mailbox-conversation-msg ${msg.id === selectedMessage.id ? "current" : ""}`}
|
||||||
>
|
>
|
||||||
<div className="mailbox-conversation-msg-header">
|
<div className="mailbox-conversation-msg-header">
|
||||||
<span>{participantLabel(msg.fromId, msg.fromType)}</span>
|
<span>{participantLabel(msg.fromId, msg.fromType, agentNamesById)}</span>
|
||||||
<span className="mailbox-message-time">{formatTimestamp(msg.createdAt)}</span>
|
<span className="mailbox-message-time">{formatTimestamp(msg.createdAt)}</span>
|
||||||
</div>
|
</div>
|
||||||
{replyToId && (
|
{replyToId && (
|
||||||
@@ -804,7 +824,7 @@ export function MailboxModal({
|
|||||||
<div className="mailbox-item-content">
|
<div className="mailbox-item-content">
|
||||||
<div className="mailbox-item-header">
|
<div className="mailbox-item-header">
|
||||||
<span className="mailbox-item-from">
|
<span className="mailbox-item-from">
|
||||||
{participantLabel(msg.fromId, msg.fromType)}
|
{participantLabel(msg.fromId, msg.fromType, agentNamesById)}
|
||||||
</span>
|
</span>
|
||||||
<span className="mailbox-item-time">{formatTimestamp(msg.createdAt)}</span>
|
<span className="mailbox-item-time">{formatTimestamp(msg.createdAt)}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -840,7 +860,7 @@ export function MailboxModal({
|
|||||||
<div className="mailbox-item-content">
|
<div className="mailbox-item-content">
|
||||||
<div className="mailbox-item-header">
|
<div className="mailbox-item-header">
|
||||||
<span className="mailbox-item-to">
|
<span className="mailbox-item-to">
|
||||||
To: {participantLabel(msg.toId, msg.toType)}
|
To: {participantLabel(msg.toId, msg.toType, agentNamesById)}
|
||||||
</span>
|
</span>
|
||||||
<span className="mailbox-item-time">{formatTimestamp(msg.createdAt)}</span>
|
<span className="mailbox-item-time">{formatTimestamp(msg.createdAt)}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -945,9 +965,7 @@ export function MailboxModal({
|
|||||||
<div className="mailbox-item-content">
|
<div className="mailbox-item-content">
|
||||||
<div className="mailbox-item-header">
|
<div className="mailbox-item-header">
|
||||||
<span className="mailbox-item-from">
|
<span className="mailbox-item-from">
|
||||||
{msg.fromType === "agent"
|
{participantLabel(msg.fromId, msg.fromType, agentNamesById)}
|
||||||
? participantLabel(msg.toId, msg.toType)
|
|
||||||
: participantLabel(msg.fromId, msg.fromType)}
|
|
||||||
</span>
|
</span>
|
||||||
<span className="mailbox-item-time">{formatTimestamp(msg.createdAt)}</span>
|
<span className="mailbox-item-time">{formatTimestamp(msg.createdAt)}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -969,7 +987,7 @@ export function MailboxModal({
|
|||||||
<div className="mailbox-item-content">
|
<div className="mailbox-item-content">
|
||||||
<div className="mailbox-item-header">
|
<div className="mailbox-item-header">
|
||||||
<span className="mailbox-item-to">
|
<span className="mailbox-item-to">
|
||||||
To: {participantLabel(msg.toId, msg.toType)}
|
To: {participantLabel(msg.toId, msg.toType, agentNamesById)}
|
||||||
</span>
|
</span>
|
||||||
<span className="mailbox-item-time">{formatTimestamp(msg.createdAt)}</span>
|
<span className="mailbox-item-time">{formatTimestamp(msg.createdAt)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -867,6 +867,52 @@ describe("AgentDetailView", () => {
|
|||||||
expect(await screen.findByTestId("agent-detail-mail-list")).toBeInTheDocument();
|
expect(await screen.findByTestId("agent-detail-mail-list")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders agent mailbox participant labels with known agent names", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
mockFetchAgentMailbox.mockResolvedValue({
|
||||||
|
ownerId: "agent-001",
|
||||||
|
ownerType: "agent",
|
||||||
|
unreadCount: 1,
|
||||||
|
messages: [],
|
||||||
|
inbox: [
|
||||||
|
{
|
||||||
|
id: "msg-1",
|
||||||
|
fromId: "agent-001",
|
||||||
|
fromType: "agent",
|
||||||
|
toId: "dashboard",
|
||||||
|
toType: "user",
|
||||||
|
content: "Self message",
|
||||||
|
type: "agent-to-user",
|
||||||
|
createdAt: "2024-01-01T00:00:00.000Z",
|
||||||
|
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||||
|
read: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
outbox: [
|
||||||
|
{
|
||||||
|
id: "msg-2",
|
||||||
|
fromId: "agent-001",
|
||||||
|
fromType: "agent",
|
||||||
|
toId: "agent-002",
|
||||||
|
toType: "agent",
|
||||||
|
content: "Known recipient",
|
||||||
|
type: "agent-to-agent",
|
||||||
|
createdAt: "2024-01-01T00:00:00.000Z",
|
||||||
|
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||||
|
read: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
render(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={vi.fn()} />);
|
||||||
|
|
||||||
|
await user.click(await screen.findByText("Mail"));
|
||||||
|
expect(await screen.findByText("Agent: Test Agent")).toBeInTheDocument();
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "Outbox" }));
|
||||||
|
expect(await screen.findByText("To: Agent: Manager Agent")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("marks unread inbox messages as read and refreshes mailbox", async () => {
|
it("marks unread inbox messages as read and refreshes mailbox", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
mockFetchAgentMailbox.mockResolvedValue({
|
mockFetchAgentMailbox.mockResolvedValue({
|
||||||
|
|||||||
@@ -207,6 +207,42 @@ describe("MailboxModal", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders agent participant labels with name and id, then falls back to id", async () => {
|
||||||
|
mockFetchInbox.mockResolvedValue({
|
||||||
|
messages: [
|
||||||
|
{ ...mockMessage, id: "msg-known", fromId: "agent-001" },
|
||||||
|
{ ...mockMessage, id: "msg-unknown", fromId: "agent-999" },
|
||||||
|
],
|
||||||
|
total: 2,
|
||||||
|
unreadCount: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<MailboxModal {...defaultProps} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("mailbox-item-msg-known")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId("mailbox-item-msg-known"));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Agent: Test Agent 1")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId("mailbox-back-to-list"));
|
||||||
|
fireEvent.click(screen.getByTestId("mailbox-item-msg-unknown"));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Agent: agent-999")).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows unread dot for unread messages", async () => {
|
||||||
|
render(<MailboxModal {...defaultProps} />);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("mailbox-item-msg-001")).toBeDefined();
|
||||||
|
expect(screen.getByTestId("mailbox-item-msg-002")).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("shows unread dot for unread messages", async () => {
|
it("shows unread dot for unread messages", async () => {
|
||||||
render(<MailboxModal {...defaultProps} />);
|
render(<MailboxModal {...defaultProps} />);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -745,7 +781,7 @@ describe("MailboxModal", () => {
|
|||||||
expect(screen.getByTestId("message-composer")).toBeDefined();
|
expect(screen.getByTestId("message-composer")).toBeDefined();
|
||||||
});
|
});
|
||||||
// Should show pre-filled recipient (not dropdown)
|
// Should show pre-filled recipient (not dropdown)
|
||||||
expect(screen.getByText("agent-001")).toBeDefined();
|
expect(screen.getByText("Test Agent 1")).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("successful send from Agents tab keeps user on Agents tab and preserves selected agent", async () => {
|
it("successful send from Agents tab keeps user on Agents tab and preserves selected agent", async () => {
|
||||||
@@ -1055,8 +1091,8 @@ describe("MailboxModal", () => {
|
|||||||
expect(mailboxMobileSection).toContain(".mailbox-modal .mailbox-title");
|
expect(mailboxMobileSection).toContain(".mailbox-modal .mailbox-title");
|
||||||
expect(mailboxMobileSection).toContain("flex-shrink: 0;");
|
expect(mailboxMobileSection).toContain("flex-shrink: 0;");
|
||||||
expect(mailboxMobileSection).toMatch(/\.mailbox-modal \.mailbox-header-actions,\s*\.mailbox-view \.mailbox-header-actions\s*\{[^}]*gap:\s*var\(--space-sm\);[^}]*\}/);
|
expect(mailboxMobileSection).toMatch(/\.mailbox-modal \.mailbox-header-actions,\s*\.mailbox-view \.mailbox-header-actions\s*\{[^}]*gap:\s*var\(--space-sm\);[^}]*\}/);
|
||||||
expect(mailboxMobileSection).toMatch(/\.mailbox-modal \.mailbox-header-actions \.btn,[^}]*\.mailbox-view \.mailbox-header-actions \.btn-icon\s*\{[^}]*min-height:\s*36px;[^}]*\}/);
|
expect(mailboxMobileSection).toMatch(/\.mailbox-modal \.mailbox-header-actions \.btn,[^}]*\.mailbox-view \.mailbox-header-actions \.btn-icon\s*\{[^}]*min-height:\s*2\.25rem;[^}]*\}/);
|
||||||
expect(mailboxMobileSection).toMatch(/\.mailbox-modal \.mailbox-header-actions \.btn-icon,[^}]*\.mailbox-view \.mailbox-header-actions \.btn-icon\s*\{[^}]*min-width:\s*36px;[^}]*display:\s*inline-flex;[^}]*\}/);
|
expect(mailboxMobileSection).toMatch(/\.mailbox-modal \.mailbox-header-actions \.btn-icon,[^}]*\.mailbox-view \.mailbox-header-actions \.btn-icon\s*\{[^}]*min-width:\s*2\.25rem;[^}]*display:\s*inline-flex;[^}]*\}/);
|
||||||
expect(mailboxMobileSection).toMatch(/\.mailbox-modal \.mailbox-header-actions \.modal-close\s*\{[^}]*padding:\s*0;[^}]*border-radius:\s*var\(--radius-sm\);[^}]*\}/);
|
expect(mailboxMobileSection).toMatch(/\.mailbox-modal \.mailbox-header-actions \.modal-close\s*\{[^}]*padding:\s*0;[^}]*border-radius:\s*var\(--radius-sm\);[^}]*\}/);
|
||||||
expect(mailboxMobileSection).toContain("overflow-x: auto;");
|
expect(mailboxMobileSection).toContain("overflow-x: auto;");
|
||||||
expect(mailboxMobileSection).toContain("-webkit-overflow-scrolling: touch;");
|
expect(mailboxMobileSection).toContain("-webkit-overflow-scrolling: touch;");
|
||||||
@@ -1078,7 +1114,7 @@ describe("MailboxModal", () => {
|
|||||||
expect(mailboxMobileSection).toContain(".mailbox-modal .mailbox-agent-select");
|
expect(mailboxMobileSection).toContain(".mailbox-modal .mailbox-agent-select");
|
||||||
expect(mailboxMobileSection).toContain("max-width: 100%;");
|
expect(mailboxMobileSection).toContain("max-width: 100%;");
|
||||||
expect(mailboxMobileSection).toContain(".mailbox-modal .mailbox-agents");
|
expect(mailboxMobileSection).toContain(".mailbox-modal .mailbox-agents");
|
||||||
expect(mailboxMobileSection).toContain("min-height: 200px;");
|
expect(mailboxMobileSection).toContain("min-height: 12.5rem;");
|
||||||
expect(mailboxMobileSection).toContain(".mailbox-modal .mailbox-empty");
|
expect(mailboxMobileSection).toContain(".mailbox-modal .mailbox-empty");
|
||||||
expect(mailboxMobileSection).toContain("padding: var(--space-2xl) var(--space-md);");
|
expect(mailboxMobileSection).toContain("padding: var(--space-2xl) var(--space-md);");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -40,7 +40,10 @@ describe("message notification pipeline", () => {
|
|||||||
const store = createStore({ ntfyEvents: ["message:agent-to-user", "message:agent-to-agent"] });
|
const store = createStore({ ntfyEvents: ["message:agent-to-user", "message:agent-to-agent"] });
|
||||||
const messageStore = new TestMessageStore();
|
const messageStore = new TestMessageStore();
|
||||||
|
|
||||||
const service = new NotificationService(store as any, { messageStore: messageStore as any });
|
const service = new NotificationService(store as any, {
|
||||||
|
messageStore: messageStore as any,
|
||||||
|
agentNameResolver: (agentId) => (agentId === "agent-1" ? "Triage Bot" : "Executor Bot"),
|
||||||
|
});
|
||||||
await service.start();
|
await service.start();
|
||||||
|
|
||||||
messageStore.sendMessage("agent-to-user", "hi");
|
messageStore.sendMessage("agent-to-user", "hi");
|
||||||
@@ -48,6 +51,8 @@ describe("message notification pipeline", () => {
|
|||||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
expect(fetchSpy.mock.calls[0]?.[0]).toBe("https://ntfy.sh/test-topic");
|
expect(fetchSpy.mock.calls[0]?.[0]).toBe("https://ntfy.sh/test-topic");
|
||||||
|
const firstHeaders = (fetchSpy.mock.calls[0]?.[1] as RequestInit).headers as Record<string, string>;
|
||||||
|
expect(firstHeaders.Title).toContain("Triage Bot");
|
||||||
const firstBody = String((fetchSpy.mock.calls[0]?.[1] as RequestInit).body);
|
const firstBody = String((fetchSpy.mock.calls[0]?.[1] as RequestInit).body);
|
||||||
expect(firstBody).toContain("hi");
|
expect(firstBody).toContain("hi");
|
||||||
|
|
||||||
@@ -56,6 +61,8 @@ describe("message notification pipeline", () => {
|
|||||||
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const secondHeaders = (fetchSpy.mock.calls[1]?.[1] as RequestInit).headers as Record<string, string>;
|
||||||
|
expect(secondHeaders.Title).toContain("Triage Bot → Executor Bot");
|
||||||
const secondBody = String((fetchSpy.mock.calls[1]?.[1] as RequestInit).body);
|
const secondBody = String((fetchSpy.mock.calls[1]?.[1] as RequestInit).body);
|
||||||
expect(secondBody).toContain("relay");
|
expect(secondBody).toContain("relay");
|
||||||
|
|
||||||
|
|||||||
@@ -165,7 +165,9 @@ describe("NotificationService", () => {
|
|||||||
await service.start();
|
await service.start();
|
||||||
|
|
||||||
messageStore.emit("message:sent", createMessage());
|
messageStore.emit("message:sent", createMessage());
|
||||||
await Promise.resolve();
|
await vi.waitFor(() => {
|
||||||
|
expect(sendNotification).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
expect(sendNotification).toHaveBeenCalledWith(
|
expect(sendNotification).toHaveBeenCalledWith(
|
||||||
"message:agent-to-user",
|
"message:agent-to-user",
|
||||||
@@ -181,6 +183,74 @@ describe("NotificationService", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("includes resolved agent names in message metadata", async () => {
|
||||||
|
const store = createStore({ ntfyEnabled: true, ntfyTopic: "topic" });
|
||||||
|
const messageStore = new EventEmitter();
|
||||||
|
const sendNotification = vi.fn(async () => ({ success: true, providerId: "mock" }));
|
||||||
|
const provider: NotificationProvider = {
|
||||||
|
getProviderId: () => "mock",
|
||||||
|
isEventSupported: () => true,
|
||||||
|
sendNotification,
|
||||||
|
};
|
||||||
|
|
||||||
|
const service = new NotificationService(store as any, {
|
||||||
|
messageStore: messageStore as any,
|
||||||
|
agentNameResolver: (agentId) => (agentId === "agent-1" ? "Triage Bot" : "Executor Bot"),
|
||||||
|
});
|
||||||
|
service.registerProvider(provider);
|
||||||
|
await service.start();
|
||||||
|
|
||||||
|
messageStore.emit(
|
||||||
|
"message:sent",
|
||||||
|
createMessage({ type: "agent-to-agent", toId: "agent-2", toType: "agent" }),
|
||||||
|
);
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(sendNotification).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sendNotification).toHaveBeenCalledWith(
|
||||||
|
"message:agent-to-agent",
|
||||||
|
expect.objectContaining({
|
||||||
|
metadata: expect.objectContaining({ fromName: "Triage Bot", toName: "Executor Bot" }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dispatches even when agent name resolution fails", async () => {
|
||||||
|
const store = createStore({ ntfyEnabled: true, ntfyTopic: "topic" });
|
||||||
|
const messageStore = new EventEmitter();
|
||||||
|
const sendNotification = vi.fn(async () => ({ success: true, providerId: "mock" }));
|
||||||
|
const provider: NotificationProvider = {
|
||||||
|
getProviderId: () => "mock",
|
||||||
|
isEventSupported: () => true,
|
||||||
|
sendNotification,
|
||||||
|
};
|
||||||
|
|
||||||
|
const service = new NotificationService(store as any, {
|
||||||
|
messageStore: messageStore as any,
|
||||||
|
agentNameResolver: () => {
|
||||||
|
throw new Error("boom");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
service.registerProvider(provider);
|
||||||
|
await service.start();
|
||||||
|
|
||||||
|
messageStore.emit("message:sent", createMessage({ type: "agent-to-user" }));
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(sendNotification).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sendNotification).toHaveBeenCalledWith(
|
||||||
|
"message:agent-to-user",
|
||||||
|
expect.objectContaining({
|
||||||
|
metadata: expect.not.objectContaining({ fromName: expect.any(String), toName: expect.any(String) }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(schedulerLog.log).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("failed to resolve from agent name"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("dispatches message:agent-to-agent with reply metadata", async () => {
|
it("dispatches message:agent-to-agent with reply metadata", async () => {
|
||||||
const store = createStore({ ntfyEnabled: true, ntfyTopic: "topic" });
|
const store = createStore({ ntfyEnabled: true, ntfyTopic: "topic" });
|
||||||
const messageStore = new EventEmitter();
|
const messageStore = new EventEmitter();
|
||||||
@@ -205,7 +275,9 @@ describe("NotificationService", () => {
|
|||||||
metadata: { replyTo: { messageId: "msg-1" } },
|
metadata: { replyTo: { messageId: "msg-1" } },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
await Promise.resolve();
|
await vi.waitFor(() => {
|
||||||
|
expect(sendNotification).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
expect(sendNotification).toHaveBeenCalledWith(
|
expect(sendNotification).toHaveBeenCalledWith(
|
||||||
"message:agent-to-agent",
|
"message:agent-to-agent",
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ vi.mock("../notifier.js", async (importOriginal) => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
import { NtfyNotificationProvider } from "../notification/ntfy-provider.js";
|
import { NtfyNotificationProvider, resolveParticipantLabel } from "../notification/ntfy-provider.js";
|
||||||
|
|
||||||
describe("NtfyNotificationProvider", () => {
|
describe("NtfyNotificationProvider", () => {
|
||||||
let provider: NtfyNotificationProvider;
|
let provider: NtfyNotificationProvider;
|
||||||
@@ -43,14 +43,21 @@ describe("NtfyNotificationProvider", () => {
|
|||||||
["awaiting-user-review", "User review needed for FN-1", "needs human review", "high"],
|
["awaiting-user-review", "User review needed for FN-1", "needs human review", "high"],
|
||||||
["planning-awaiting-input", "Planning input needed for FN-1", "awaiting your input", "high"],
|
["planning-awaiting-input", "Planning input needed for FN-1", "awaiting your input", "high"],
|
||||||
["fallback-used", "Fallback model used for FN-1", "switched from", "high"],
|
["fallback-used", "Fallback model used for FN-1", "switched from", "high"],
|
||||||
["message:agent-to-user", "New message from agent-1", "agent-1 → you: preview text", "high"],
|
["message:agent-to-user", "New message from Triage Bot", "Triage Bot → you: preview text", "high"],
|
||||||
["message:agent-to-agent", "agent-1 → agent-2", "agent-1 messaged agent-2: preview text", "default"],
|
["message:agent-to-agent", "Triage Bot → Executor Bot", "Triage Bot messaged Executor Bot: preview text", "default"],
|
||||||
])("maps %s event correctly", async (event, expectedTitle, messagePart, priority) => {
|
])("maps %s event correctly", async (event, expectedTitle, messagePart, priority) => {
|
||||||
await provider.sendNotification(event as any, {
|
await provider.sendNotification(event as any, {
|
||||||
taskId: "FN-1",
|
taskId: "FN-1",
|
||||||
taskTitle: "T",
|
taskTitle: "T",
|
||||||
event: event as any,
|
event: event as any,
|
||||||
metadata: { fromId: "agent-1", toId: "agent-2", preview: "preview text", messageId: "msg-1" },
|
metadata: {
|
||||||
|
fromId: "agent-1",
|
||||||
|
toId: "agent-2",
|
||||||
|
fromName: "Triage Bot",
|
||||||
|
toName: "Executor Bot",
|
||||||
|
preview: "preview text",
|
||||||
|
messageId: "msg-1",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(mocks.sendNtfyNotificationWithResult).toHaveBeenCalledWith(
|
expect(mocks.sendNtfyNotificationWithResult).toHaveBeenCalledWith(
|
||||||
@@ -63,6 +70,11 @@ describe("NtfyNotificationProvider", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("resolveParticipantLabel prefers names and falls back to ids", () => {
|
||||||
|
expect(resolveParticipantLabel({ fromName: "Triage Bot", fromId: "agent-1" }, "from")).toBe("Triage Bot");
|
||||||
|
expect(resolveParticipantLabel({ toId: "agent-2" }, "to")).toBe("agent-2");
|
||||||
|
});
|
||||||
|
|
||||||
it("supports known events and rejects unknown", () => {
|
it("supports known events and rejects unknown", () => {
|
||||||
expect(provider.isEventSupported("in-review" as any)).toBe(true);
|
expect(provider.isEventSupported("in-review" as any)).toBe(true);
|
||||||
expect(provider.isEventSupported("merged" as any)).toBe(true);
|
expect(provider.isEventSupported("merged" as any)).toBe(true);
|
||||||
|
|||||||
@@ -76,7 +76,14 @@ describe("WebhookNotificationProvider", () => {
|
|||||||
taskId: "FN-1",
|
taskId: "FN-1",
|
||||||
taskTitle: "My Task",
|
taskTitle: "My Task",
|
||||||
event: "message:agent-to-user",
|
event: "message:agent-to-user",
|
||||||
metadata: { messageId: "msg-1", fromId: "agent-1", toId: "user:dashboard", preview: "hello" },
|
metadata: {
|
||||||
|
messageId: "msg-1",
|
||||||
|
fromId: "agent-1",
|
||||||
|
toId: "user:dashboard",
|
||||||
|
fromName: "Triage Bot",
|
||||||
|
toName: "Dashboard User",
|
||||||
|
preview: "hello",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const [, requestInit] = fetchMock.mock.calls[0] as [string, RequestInit];
|
const [, requestInit] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||||
@@ -84,7 +91,15 @@ describe("WebhookNotificationProvider", () => {
|
|||||||
expect(payload.event).toBe("message:agent-to-user");
|
expect(payload.event).toBe("message:agent-to-user");
|
||||||
expect(payload.timestamp).toEqual(expect.any(String));
|
expect(payload.timestamp).toEqual(expect.any(String));
|
||||||
expect(payload.task).toEqual({ id: "FN-1", title: "My Task" });
|
expect(payload.task).toEqual({ id: "FN-1", title: "My Task" });
|
||||||
expect(payload.metadata).toEqual(expect.objectContaining({ messageId: "msg-1" }));
|
expect(payload.metadata).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
messageId: "msg-1",
|
||||||
|
fromId: "agent-1",
|
||||||
|
toId: "user:dashboard",
|
||||||
|
fromName: "Triage Bot",
|
||||||
|
toName: "Dashboard User",
|
||||||
|
}),
|
||||||
|
);
|
||||||
expect(payload.clickUrl).toBe("http://dash/?project=p1&task=FN-1#message-msg-1");
|
expect(payload.clickUrl).toBe("http://dash/?project=p1&task=FN-1#message-msg-1");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -150,14 +165,19 @@ describe("WebhookNotificationProvider", () => {
|
|||||||
["planning-awaiting-input", "is awaiting your input during planning"],
|
["planning-awaiting-input", "is awaiting your input during planning"],
|
||||||
["gridlock", "Pipeline gridlocked"],
|
["gridlock", "Pipeline gridlocked"],
|
||||||
["fallback-used", "Fusion recovered by switching from"],
|
["fallback-used", "Fusion recovered by switching from"],
|
||||||
["message:agent-to-user", 'Event "message:agent-to-user" for task My Task'],
|
["message:agent-to-user", "From: Triage Bot → You: hello"],
|
||||||
["message:agent-to-agent", 'Event "message:agent-to-agent" for task My Task'],
|
["message:agent-to-agent", "From: Triage Bot → To: Executor Bot: hello"],
|
||||||
["unknown-event", 'Event "unknown-event" for task My Task'],
|
["unknown-event", 'Event "unknown-event" for task My Task'],
|
||||||
])("message formatting for %s", async (event, expectedPart) => {
|
])("message formatting for %s", async (event, expectedPart) => {
|
||||||
fetchMock.mockResolvedValue({ ok: true, status: 200, statusText: "OK" });
|
fetchMock.mockResolvedValue({ ok: true, status: 200, statusText: "OK" });
|
||||||
await provider.initialize({ webhookUrl: "https://example.com/hook", webhookFormat: "slack" });
|
await provider.initialize({ webhookUrl: "https://example.com/hook", webhookFormat: "slack" });
|
||||||
|
|
||||||
await provider.sendNotification(event, { taskId: "FN-1", taskTitle: "My Task", event });
|
await provider.sendNotification(event, {
|
||||||
|
taskId: "FN-1",
|
||||||
|
taskTitle: "My Task",
|
||||||
|
event,
|
||||||
|
metadata: { fromId: "agent-1", toId: "agent-2", fromName: "Triage Bot", toName: "Executor Bot", preview: "hello" },
|
||||||
|
});
|
||||||
|
|
||||||
const [, requestInit] = fetchMock.mock.calls[0] as [string, RequestInit];
|
const [, requestInit] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||||
const body = JSON.parse(String(requestInit.body));
|
const body = JSON.parse(String(requestInit.body));
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ export interface NotificationServiceOptions {
|
|||||||
ntfyBaseUrl?: string;
|
ntfyBaseUrl?: string;
|
||||||
/** Optional message store for mailbox message notifications */
|
/** Optional message store for mailbox message notifications */
|
||||||
messageStore?: NotificationMessageStore;
|
messageStore?: NotificationMessageStore;
|
||||||
|
/** Resolve human-readable name for an agent ID used in message notifications */
|
||||||
|
agentNameResolver?: (agentId: string) => Promise<string | null> | string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NotificationServiceStore {
|
interface NotificationServiceStore {
|
||||||
@@ -267,6 +269,9 @@ export class NotificationService {
|
|||||||
|
|
||||||
const taskId = typeof message.metadata?.taskId === "string" ? message.metadata.taskId : undefined;
|
const taskId = typeof message.metadata?.taskId === "string" ? message.metadata.taskId : undefined;
|
||||||
|
|
||||||
|
const fromName = await this.resolveAgentName(message.fromType, message.fromId, "from");
|
||||||
|
const toName = await this.resolveAgentName(message.toType, message.toId, "to");
|
||||||
|
|
||||||
this.maybeNotify(message.id, eventType, {
|
this.maybeNotify(message.id, eventType, {
|
||||||
taskId,
|
taskId,
|
||||||
taskTitle: undefined,
|
taskTitle: undefined,
|
||||||
@@ -275,8 +280,10 @@ export class NotificationService {
|
|||||||
messageId: message.id,
|
messageId: message.id,
|
||||||
fromId: message.fromId,
|
fromId: message.fromId,
|
||||||
fromType: message.fromType,
|
fromType: message.fromType,
|
||||||
|
...(fromName ? { fromName } : {}),
|
||||||
toId: message.toId,
|
toId: message.toId,
|
||||||
toType: message.toType,
|
toType: message.toType,
|
||||||
|
...(toName ? { toName } : {}),
|
||||||
type: message.type,
|
type: message.type,
|
||||||
replyToMessageId: message.metadata?.replyTo?.messageId,
|
replyToMessageId: message.metadata?.replyTo?.messageId,
|
||||||
preview,
|
preview,
|
||||||
@@ -288,6 +295,33 @@ export class NotificationService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async resolveAgentName(
|
||||||
|
participantType: Message["fromType"],
|
||||||
|
participantId: string,
|
||||||
|
direction: "from" | "to",
|
||||||
|
): Promise<string | null> {
|
||||||
|
if (participantType !== "agent") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolver = this.options.agentNameResolver;
|
||||||
|
if (!resolver) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resolved = await resolver(participantId);
|
||||||
|
const trimmed = typeof resolved === "string" ? resolved.trim() : "";
|
||||||
|
return trimmed.length > 0 ? trimmed : null;
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
schedulerLog.log(
|
||||||
|
`NotificationService.handleMessageSent failed to resolve ${direction} agent name agentId=${participantId} error=${message}`,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private setNotificationsEnabledFromSettings(settings: Settings): void {
|
private setNotificationsEnabledFromSettings(settings: Settings): void {
|
||||||
this.notificationsEnabled = Boolean(
|
this.notificationsEnabled = Boolean(
|
||||||
(settings.ntfyEnabled && settings.ntfyTopic) ||
|
(settings.ntfyEnabled && settings.ntfyTopic) ||
|
||||||
|
|||||||
@@ -51,6 +51,20 @@ const SUPPORTED_EVENTS = new Set<SupportedNtfyEvent>([
|
|||||||
"message:agent-to-agent",
|
"message:agent-to-agent",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
export function resolveParticipantLabel(
|
||||||
|
metadata: NotificationPayload["metadata"] | undefined,
|
||||||
|
kind: "from" | "to",
|
||||||
|
): string {
|
||||||
|
const nameKey = kind === "from" ? "fromName" : "toName";
|
||||||
|
const idKey = kind === "from" ? "fromId" : "toId";
|
||||||
|
const name = typeof metadata?.[nameKey] === "string" ? metadata[nameKey].trim() : "";
|
||||||
|
if (name.length > 0) {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
const id = typeof metadata?.[idKey] === "string" ? metadata[idKey].trim() : "";
|
||||||
|
return id.length > 0 ? id : kind === "from" ? "agent" : "recipient";
|
||||||
|
}
|
||||||
|
|
||||||
export class NtfyNotificationProvider implements NotificationProvider {
|
export class NtfyNotificationProvider implements NotificationProvider {
|
||||||
private config?: NtfyProviderConfig;
|
private config?: NtfyProviderConfig;
|
||||||
private abortController: AbortController | null = null;
|
private abortController: AbortController | null = null;
|
||||||
@@ -113,8 +127,8 @@ export class NtfyNotificationProvider implements NotificationProvider {
|
|||||||
|
|
||||||
const identifier = formatTaskIdentifier(taskLike);
|
const identifier = formatTaskIdentifier(taskLike);
|
||||||
const messageId = typeof payload.metadata?.messageId === "string" ? payload.metadata.messageId : undefined;
|
const messageId = typeof payload.metadata?.messageId === "string" ? payload.metadata.messageId : undefined;
|
||||||
const senderLabel = typeof payload.metadata?.fromId === "string" ? payload.metadata.fromId : "agent";
|
const senderLabel = resolveParticipantLabel(payload.metadata, "from");
|
||||||
const recipientLabel = typeof payload.metadata?.toId === "string" ? payload.metadata.toId : "recipient";
|
const recipientLabel = resolveParticipantLabel(payload.metadata, "to");
|
||||||
const preview = typeof payload.metadata?.preview === "string"
|
const preview = typeof payload.metadata?.preview === "string"
|
||||||
? payload.metadata.preview
|
? payload.metadata.preview
|
||||||
: "(no preview)";
|
: "(no preview)";
|
||||||
|
|||||||
@@ -20,6 +20,20 @@ export interface WebhookProviderConfig {
|
|||||||
projectId?: string;
|
projectId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveParticipantLabel(
|
||||||
|
metadata: NotificationPayload["metadata"] | undefined,
|
||||||
|
kind: "from" | "to",
|
||||||
|
): string {
|
||||||
|
const nameKey = kind === "from" ? "fromName" : "toName";
|
||||||
|
const idKey = kind === "from" ? "fromId" : "toId";
|
||||||
|
const name = typeof metadata?.[nameKey] === "string" ? metadata[nameKey].trim() : "";
|
||||||
|
if (name.length > 0) {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
const id = typeof metadata?.[idKey] === "string" ? metadata[idKey].trim() : "";
|
||||||
|
return id.length > 0 ? id : kind === "from" ? "agent" : "recipient";
|
||||||
|
}
|
||||||
|
|
||||||
export class WebhookNotificationProvider implements NotificationProvider {
|
export class WebhookNotificationProvider implements NotificationProvider {
|
||||||
private config: WebhookProviderConfig | null = null;
|
private config: WebhookProviderConfig | null = null;
|
||||||
private abortController: AbortController | null = null;
|
private abortController: AbortController | null = null;
|
||||||
@@ -142,6 +156,17 @@ export class WebhookNotificationProvider implements NotificationProvider {
|
|||||||
return "Pipeline gridlocked";
|
return "Pipeline gridlocked";
|
||||||
case "fallback-used":
|
case "fallback-used":
|
||||||
return `Fusion recovered by switching from ${String(payload.metadata?.primaryModel ?? "primary model")} to ${String(payload.metadata?.fallbackModel ?? "fallback model")} (${String(payload.metadata?.triggerPoint ?? "unknown trigger")})`;
|
return `Fusion recovered by switching from ${String(payload.metadata?.primaryModel ?? "primary model")} to ${String(payload.metadata?.fallbackModel ?? "fallback model")} (${String(payload.metadata?.triggerPoint ?? "unknown trigger")})`;
|
||||||
|
case "message:agent-to-user": {
|
||||||
|
const from = resolveParticipantLabel(payload.metadata, "from");
|
||||||
|
const preview = typeof payload.metadata?.preview === "string" ? payload.metadata.preview : "(no preview)";
|
||||||
|
return `From: ${from} → You: ${preview}`;
|
||||||
|
}
|
||||||
|
case "message:agent-to-agent": {
|
||||||
|
const from = resolveParticipantLabel(payload.metadata, "from");
|
||||||
|
const to = resolveParticipantLabel(payload.metadata, "to");
|
||||||
|
const preview = typeof payload.metadata?.preview === "string" ? payload.metadata.preview : "(no preview)";
|
||||||
|
return `From: ${from} → To: ${to}: ${preview}`;
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return `Event "${event}" for task ${identifier}`;
|
return `Event "${event}" for task ${identifier}`;
|
||||||
}
|
}
|
||||||
@@ -172,6 +197,9 @@ export class WebhookNotificationProvider implements NotificationProvider {
|
|||||||
|
|
||||||
const messageId = typeof payload.metadata?.messageId === "string" ? payload.metadata.messageId : undefined;
|
const messageId = typeof payload.metadata?.messageId === "string" ? payload.metadata.messageId : undefined;
|
||||||
|
|
||||||
|
const fromLabel = resolveParticipantLabel(payload.metadata, "from");
|
||||||
|
const toLabel = resolveParticipantLabel(payload.metadata, "to");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
event: payload.event,
|
event: payload.event,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
@@ -179,7 +207,15 @@ export class WebhookNotificationProvider implements NotificationProvider {
|
|||||||
id: payload.taskId,
|
id: payload.taskId,
|
||||||
title: payload.taskTitle,
|
title: payload.taskTitle,
|
||||||
},
|
},
|
||||||
metadata: payload.metadata,
|
metadata: {
|
||||||
|
...payload.metadata,
|
||||||
|
...(payload.event === "message:agent-to-user" || payload.event === "message:agent-to-agent"
|
||||||
|
? {
|
||||||
|
fromName: typeof payload.metadata?.fromName === "string" ? payload.metadata.fromName : fromLabel,
|
||||||
|
toName: typeof payload.metadata?.toName === "string" ? payload.metadata.toName : toLabel,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
clickUrl: buildNtfyClickUrl({
|
clickUrl: buildNtfyClickUrl({
|
||||||
dashboardHost: this.config.dashboardHost,
|
dashboardHost: this.config.dashboardHost,
|
||||||
projectId: this.config.projectId,
|
projectId: this.config.projectId,
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ export interface NtfyNotifierOptions {
|
|||||||
ntfyBaseUrl?: string;
|
ntfyBaseUrl?: string;
|
||||||
/** Project identifier for deep links in notifications */
|
/** Project identifier for deep links in notifications */
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
|
/** Resolve human-readable agent names for message notifications */
|
||||||
|
agentNameResolver?: (agentId: string) => Promise<string | null> | string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type NtfyNotificationPriority = "low" | "default" | "high" | "urgent";
|
export type NtfyNotificationPriority = "low" | "default" | "high" | "urgent";
|
||||||
@@ -249,6 +251,7 @@ export class NtfyNotifier {
|
|||||||
this.notificationService = notificationService ?? new NotificationService(store, {
|
this.notificationService = notificationService ?? new NotificationService(store, {
|
||||||
projectId: this.projectId,
|
projectId: this.projectId,
|
||||||
ntfyBaseUrl: options.ntfyBaseUrl,
|
ntfyBaseUrl: options.ntfyBaseUrl,
|
||||||
|
agentNameResolver: options.agentNameResolver,
|
||||||
});
|
});
|
||||||
activeNotificationService = this.notificationService;
|
activeNotificationService = this.notificationService;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -291,10 +291,20 @@ export class ProjectEngine {
|
|||||||
|
|
||||||
// 3. Initialize notification services (unless caller manages them externally)
|
// 3. Initialize notification services (unless caller manages them externally)
|
||||||
if (!this.options.skipNotifier) {
|
if (!this.options.skipNotifier) {
|
||||||
|
const agentStore = this.runtime.getAgentStore();
|
||||||
|
const agentNameResolver = agentStore
|
||||||
|
? async (agentId: string): Promise<string | null> => {
|
||||||
|
const agent = await agentStore.getAgent(agentId);
|
||||||
|
const name = typeof agent?.name === "string" ? agent.name.trim() : "";
|
||||||
|
return name.length > 0 ? name : null;
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
this.notificationService = new NotificationService(store, {
|
this.notificationService = new NotificationService(store, {
|
||||||
projectId: this.options.projectId,
|
projectId: this.options.projectId,
|
||||||
ntfyBaseUrl: this.options.ntfyBaseUrl,
|
ntfyBaseUrl: this.options.ntfyBaseUrl,
|
||||||
messageStore: this.runtime.getMessageStore(),
|
messageStore: this.runtime.getMessageStore(),
|
||||||
|
agentNameResolver,
|
||||||
});
|
});
|
||||||
await this.notificationService.start();
|
await this.notificationService.start();
|
||||||
|
|
||||||
@@ -304,6 +314,7 @@ export class ProjectEngine {
|
|||||||
{
|
{
|
||||||
projectId: this.options.projectId,
|
projectId: this.options.projectId,
|
||||||
ntfyBaseUrl: this.options.ntfyBaseUrl,
|
ntfyBaseUrl: this.options.ntfyBaseUrl,
|
||||||
|
agentNameResolver,
|
||||||
},
|
},
|
||||||
this.notificationService,
|
this.notificationService,
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user