fix(dashboard): don't mark agent inbox messages read when user views them

When the dashboard user browses another agent's mailbox (e.g. the CEO's
inbox), opening a message no longer triggers POST /messages/:id/read.
The previous behavior silently consumed the agent's unread state, so the
agent's heartbeat never surfaced the message and fn_read_messages (which
defaults to unread_only=true) returned nothing. Auto-mark-read now only
fires on the dashboard user's own inbox tab. Adds regression tests in
MailboxView.test.tsx and MailboxModal.test.tsx.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-05-06 08:01:30 -07:00
parent 4dc91edfb2
commit 6f46ab017f
5 changed files with 121 additions and 6 deletions

View File

@@ -0,0 +1,12 @@
---
"@runfusion/fusion": patch
---
Stop the dashboard from auto-marking another agent's messages as read when
the user opens them while browsing that agent's mailbox. Previously, viewing
a message in an agent's inbox (e.g. the CEO's mailbox) would call
`POST /messages/:id/read`, which silently consumed the agent's unread state.
The agent's heartbeat would then never see the message as pending, and the
agent's `fn_read_messages` tool (which defaults to `unread_only: true`)
returned nothing. The mark-as-read call now only fires for the dashboard
user's own inbox tab.

View File

@@ -207,8 +207,10 @@ export function MailboxModal({
const handleOpenMessage = useCallback(async (message: Message) => { const handleOpenMessage = useCallback(async (message: Message) => {
setSelectedMessage(message); setSelectedMessage(message);
// Mark as read if unread // Only auto-mark as read when viewing the dashboard user's own inbox.
if (!message.read) { // Browsing another agent's mailbox must not consume their unread messages
// out from under them — the agent's heartbeat is the one that reads + acks.
if (!message.read && activeTab === "inbox") {
try { try {
const updated = await markMessageRead(message.id, projectId); const updated = await markMessageRead(message.id, projectId);
// Update inbox state // Update inbox state
@@ -233,7 +235,7 @@ export function MailboxModal({
} catch { } catch {
setConversationMessages([message]); setConversationMessages([message]);
} }
}, [projectId]); }, [projectId, activeTab]);
const handleCloseMessage = useCallback(() => { const handleCloseMessage = useCallback(() => {
setSelectedMessage(null); setSelectedMessage(null);

View File

@@ -291,8 +291,10 @@ export function MailboxView({
const handleOpenMessage = useCallback(async (message: Message) => { const handleOpenMessage = useCallback(async (message: Message) => {
setSelectedMessage(message); setSelectedMessage(message);
// Mark as read if unread // Only auto-mark as read when viewing the dashboard user's own inbox.
if (!message.read) { // Browsing another agent's mailbox must not consume their unread messages
// out from under them — the agent's heartbeat is the one that reads + acks.
if (!message.read && activeTab === "inbox") {
try { try {
const updated = await markMessageRead(message.id, projectId); const updated = await markMessageRead(message.id, projectId);
// Update inbox state // Update inbox state
@@ -321,7 +323,7 @@ export function MailboxView({
} catch { } catch {
setConversationMessages([message]); setConversationMessages([message]);
} }
}, [projectId, unreadCount, onUnreadCountChange]); }, [projectId, unreadCount, onUnreadCountChange, activeTab]);
const handleCloseMessage = useCallback(() => { const handleCloseMessage = useCallback(() => {
setSelectedMessage(null); setSelectedMessage(null);

View File

@@ -285,6 +285,53 @@ describe("MailboxModal", () => {
}); });
}); });
it("does not mark agent inbox messages as read when the dashboard user opens them", async () => {
const agentInboxMessage: Message = {
id: "msg-agent-unread",
fromId: "user-001",
fromType: "user",
toId: "agent-001",
toType: "agent",
content: "Important — please reply",
type: "user-to-agent",
read: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
mockFetchAgentMailbox.mockResolvedValue({
ownerId: "agent-001",
ownerType: "agent",
unreadCount: 1,
messages: [agentInboxMessage],
inbox: [agentInboxMessage],
outbox: [],
});
mockFetchConversation.mockResolvedValue([agentInboxMessage]);
render(<MailboxModal {...defaultProps} />);
fireEvent.click(screen.getByTestId("mailbox-tab-agents"));
await waitFor(() => {
expect(screen.getByTestId("mailbox-agent-select")).toBeDefined();
});
fireEvent.change(screen.getByTestId("mailbox-agent-select"), { target: { value: "agent-001" } });
await waitFor(() => {
expect(screen.getByTestId("mailbox-item-msg-agent-unread")).toBeDefined();
});
await act(async () => {
fireEvent.click(screen.getByTestId("mailbox-item-msg-agent-unread"));
});
await waitFor(() => {
expect(screen.getByTestId("mailbox-message-detail")).toBeDefined();
});
// Critical: the dashboard user browsing an agent's mailbox MUST NOT
// consume the agent's unread state — the agent's heartbeat is the
// authoritative reader.
expect(mockMarkMessageRead).not.toHaveBeenCalled();
});
it("shows back button in message detail", async () => { it("shows back button in message detail", async () => {
render(<MailboxModal {...defaultProps} />); render(<MailboxModal {...defaultProps} />);
await waitFor(() => { await waitFor(() => {

View File

@@ -974,6 +974,58 @@ describe("MailboxView", () => {
}); });
}); });
it("does not mark agent inbox messages as read when the dashboard user opens them", async () => {
const agentInboxMessage: Message = {
id: "msg-agent-unread",
fromId: "user-001",
fromType: "user",
toId: "agent-001",
toType: "agent",
content: "Important — please reply",
type: "user-to-agent",
read: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
mockFetchInbox.mockResolvedValue({ messages: [], unreadCount: 0, total: 0 });
mockFetchAgentMailbox.mockResolvedValue({
ownerId: "agent-001",
ownerType: "agent",
unreadCount: 1,
messages: [agentInboxMessage],
inbox: [agentInboxMessage],
outbox: [],
});
mockFetchConversation.mockResolvedValue([agentInboxMessage]);
render(<MailboxView {...defaultProps} />);
const agentsTab = screen.getByTestId("mailbox-tab-agents");
await act(async () => {
fireEvent.click(agentsTab);
});
fireEvent.change(screen.getByTestId("mailbox-agent-select"), { target: { value: "agent-001" } });
await waitFor(() => {
expect(screen.getByTestId("mailbox-item-msg-agent-unread")).toBeDefined();
});
await act(async () => {
fireEvent.click(screen.getByTestId("mailbox-item-msg-agent-unread"));
});
await waitFor(() => {
expect(screen.getByTestId("mailbox-message-detail")).toBeDefined();
});
// Critical: the dashboard user browsing an agent's mailbox MUST NOT
// consume the agent's unread state — the agent's heartbeat is the
// authoritative reader.
expect(mockMarkMessageRead).not.toHaveBeenCalled();
});
it("switches to outbox view when clicking outbox sub-tab", async () => { it("switches to outbox view when clicking outbox sub-tab", async () => {
mockFetchInbox.mockResolvedValue({ mockFetchInbox.mockResolvedValue({
messages: [], messages: [],