chore(test-isolation): tolerate live fusion app noise on shared HOME
Local `pnpm test:isolated` was failing because a concurrently-running fusion app continually mutates `~/.fusion` (databases, agent sessions, memory, automations, plugins, logs). Filter those runtime-owned paths from the protected-dir signature, widen the baseline-stability sampling window, and re-sample on suspected violations so transient app activity doesn't masquerade as test pollution. Tests still cannot legitimately write into these paths — they're skipped because the *running app* is expected to. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
5
.changeset/test-isolation-runtime-noise.md
Normal file
5
.changeset/test-isolation-runtime-noise.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"fusion-workspace": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
scripts: make `check-test-isolation` resilient to a concurrently-running fusion app on the same HOME. Filter out paths the live app legitimately writes (databases, agent sessions/memory, plugins, automations, logs, config), sample the baseline over a longer window, and re-sample on suspected violations to avoid false positives during local `pnpm test:isolated`.
|
||||||
@@ -72,6 +72,10 @@ Quick Chat is an optional floating panel for fast, project-scoped assistant conv
|
|||||||
|
|
||||||
Mailbox view shows inbox/outbox communication threads and unread state.
|
Mailbox view shows inbox/outbox communication threads and unread state.
|
||||||
|
|
||||||
|
- Inbox renders one row per message (no sender-based collapsing)
|
||||||
|
- Visible message history/threading is driven by explicit `message.metadata.replyTo.messageId` links
|
||||||
|
- Separate top-level messages from the same sender remain independent in the inbox and detail pane
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Interactive Terminal
|
## Interactive Terminal
|
||||||
|
|||||||
@@ -82,6 +82,36 @@ function messagePreview(content: string, max = 80): string {
|
|||||||
return `${content.slice(0, max)}…`;
|
return `${content.slice(0, max)}…`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildReplyThread(messages: Message[], selectedMessage: Message): Message[] {
|
||||||
|
const allMessages = [...messages];
|
||||||
|
if (!allMessages.some((message) => message.id === selectedMessage.id)) {
|
||||||
|
allMessages.push(selectedMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
const threadIds = new Set<string>([selectedMessage.id]);
|
||||||
|
let changed = true;
|
||||||
|
|
||||||
|
while (changed) {
|
||||||
|
changed = false;
|
||||||
|
|
||||||
|
for (const message of allMessages) {
|
||||||
|
const replyToId = message.metadata?.replyTo?.messageId;
|
||||||
|
if (threadIds.has(message.id) && replyToId && !threadIds.has(replyToId)) {
|
||||||
|
threadIds.add(replyToId);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (replyToId && threadIds.has(replyToId) && !threadIds.has(message.id)) {
|
||||||
|
threadIds.add(message.id);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return allMessages
|
||||||
|
.filter((message) => threadIds.has(message.id))
|
||||||
|
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
||||||
|
}
|
||||||
|
|
||||||
// ── Component ─────────────────────────────────────────────────────────────
|
// ── Component ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function MailboxModal({
|
export function MailboxModal({
|
||||||
@@ -314,6 +344,8 @@ export function MailboxModal({
|
|||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const threadMessages = selectedMessage ? buildReplyThread(conversationMessages, selectedMessage) : [];
|
||||||
|
|
||||||
// ── Render ────────────────────────────────────────────────────────────
|
// ── Render ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -468,13 +500,13 @@ export function MailboxModal({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Conversation thread */}
|
{/* Conversation thread */}
|
||||||
{conversationMessages.length > 1 && (
|
{threadMessages.length > 1 && (
|
||||||
<div className="mailbox-conversation" data-testid="mailbox-conversation">
|
<div className="mailbox-conversation" data-testid="mailbox-conversation">
|
||||||
<div className="mailbox-conversation-label">Conversation</div>
|
<div className="mailbox-conversation-label">Conversation</div>
|
||||||
{conversationMessages.map((msg) => {
|
{threadMessages.map((msg) => {
|
||||||
const replyToId = msg.metadata?.replyTo?.messageId;
|
const replyToId = msg.metadata?.replyTo?.messageId;
|
||||||
const replyToMessage = replyToId
|
const replyToMessage = replyToId
|
||||||
? conversationMessages.find((candidate) => candidate.id === replyToId)
|
? threadMessages.find((candidate) => candidate.id === replyToId)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -498,7 +530,7 @@ export function MailboxModal({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Full message content */}
|
{/* Full message content */}
|
||||||
{(conversationMessages.length <= 1) && (
|
{(threadMessages.length <= 1) && (
|
||||||
<>
|
<>
|
||||||
{selectedMessage.metadata?.replyTo?.messageId && (
|
{selectedMessage.metadata?.replyTo?.messageId && (
|
||||||
<div className="mailbox-reply-context" data-testid="mailbox-selected-reply-context">
|
<div className="mailbox-reply-context" data-testid="mailbox-selected-reply-context">
|
||||||
|
|||||||
@@ -43,19 +43,6 @@ interface MailboxViewProps {
|
|||||||
onUnreadCountChange?: (count: number) => void;
|
onUnreadCountChange?: (count: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Represents a grouped conversation in the inbox */
|
|
||||||
interface ConversationGroup {
|
|
||||||
/** Unique key combining fromId and fromType */
|
|
||||||
key: string;
|
|
||||||
fromId: string;
|
|
||||||
fromType: ParticipantType;
|
|
||||||
/** Latest message in the conversation */
|
|
||||||
latestMessage: Message;
|
|
||||||
/** All messages in this conversation */
|
|
||||||
messages: Message[];
|
|
||||||
/** Count of unread messages in this conversation */
|
|
||||||
unreadCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -104,42 +91,37 @@ function messagePreview(content: string, max = 80): string {
|
|||||||
return `${content.slice(0, max)}…`;
|
return `${content.slice(0, max)}…`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Groups messages by conversation (sender) key */
|
function buildReplyThread(messages: Message[], selectedMessage: Message): Message[] {
|
||||||
function groupMessagesByConversation(messages: Message[]): ConversationGroup[] {
|
const allMessages = [...messages];
|
||||||
const groups = new Map<string, ConversationGroup>();
|
if (!allMessages.some((message) => message.id === selectedMessage.id)) {
|
||||||
|
allMessages.push(selectedMessage);
|
||||||
|
}
|
||||||
|
|
||||||
for (const msg of messages) {
|
const threadIds = new Set<string>([selectedMessage.id]);
|
||||||
const key = `${msg.fromType}:${msg.fromId}`;
|
let changed = true;
|
||||||
const existing = groups.get(key);
|
|
||||||
|
|
||||||
if (existing) {
|
while (changed) {
|
||||||
existing.messages.push(msg);
|
changed = false;
|
||||||
// Track latest by timestamp
|
|
||||||
if (new Date(msg.createdAt) > new Date(existing.latestMessage.createdAt)) {
|
for (const message of allMessages) {
|
||||||
existing.latestMessage = msg;
|
const replyToId = message.metadata?.replyTo?.messageId;
|
||||||
|
if (threadIds.has(message.id) && replyToId && !threadIds.has(replyToId)) {
|
||||||
|
threadIds.add(replyToId);
|
||||||
|
changed = true;
|
||||||
}
|
}
|
||||||
// Update unread count
|
if (replyToId && threadIds.has(replyToId) && !threadIds.has(message.id)) {
|
||||||
if (!msg.read) {
|
threadIds.add(message.id);
|
||||||
existing.unreadCount++;
|
changed = true;
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
groups.set(key, {
|
|
||||||
key,
|
|
||||||
fromId: msg.fromId,
|
|
||||||
fromType: msg.fromType,
|
|
||||||
latestMessage: msg,
|
|
||||||
messages: [msg],
|
|
||||||
unreadCount: msg.read ? 0 : 1,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by latest message timestamp, newest first
|
return allMessages
|
||||||
return Array.from(groups.values()).sort(
|
.filter((message) => threadIds.has(message.id))
|
||||||
(a, b) => new Date(b.latestMessage.createdAt).getTime() - new Date(a.latestMessage.createdAt).getTime()
|
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// ── Component ─────────────────────────────────────────────────────────────
|
// ── Component ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function MailboxView({
|
export function MailboxView({
|
||||||
@@ -407,6 +389,8 @@ export function MailboxView({
|
|||||||
const renderMessageDetail = () => {
|
const renderMessageDetail = () => {
|
||||||
if (!selectedMessage || showComposer) return null;
|
if (!selectedMessage || showComposer) return null;
|
||||||
|
|
||||||
|
const threadMessages = buildReplyThread(conversationMessages, selectedMessage);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mailbox-message-detail" data-testid="mailbox-message-detail">
|
<div className="mailbox-message-detail" data-testid="mailbox-message-detail">
|
||||||
<div className="mailbox-message-detail-header">
|
<div className="mailbox-message-detail-header">
|
||||||
@@ -460,13 +444,13 @@ export function MailboxView({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{conversationMessages.length > 1 && (
|
{threadMessages.length > 1 && (
|
||||||
<div className="mailbox-conversation" data-testid="mailbox-conversation">
|
<div className="mailbox-conversation" data-testid="mailbox-conversation">
|
||||||
<div className="mailbox-conversation-label">Conversation</div>
|
<div className="mailbox-conversation-label">Conversation</div>
|
||||||
{conversationMessages.map((msg) => {
|
{threadMessages.map((msg) => {
|
||||||
const replyToId = msg.metadata?.replyTo?.messageId;
|
const replyToId = msg.metadata?.replyTo?.messageId;
|
||||||
const replyToMessage = replyToId
|
const replyToMessage = replyToId
|
||||||
? conversationMessages.find((candidate) => candidate.id === replyToId)
|
? threadMessages.find((candidate) => candidate.id === replyToId)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -489,7 +473,7 @@ export function MailboxView({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{(conversationMessages.length <= 1) && (
|
{(threadMessages.length <= 1) && (
|
||||||
<>
|
<>
|
||||||
{selectedMessage.metadata?.replyTo?.messageId && (
|
{selectedMessage.metadata?.replyTo?.messageId && (
|
||||||
<div className="mailbox-reply-context" data-testid="mailbox-selected-reply-context">
|
<div className="mailbox-reply-context" data-testid="mailbox-selected-reply-context">
|
||||||
@@ -516,41 +500,28 @@ export function MailboxView({
|
|||||||
<p>No messages in your inbox</p>
|
<p>No messages in your inbox</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{inbox && inbox.messages.length > 0 && (
|
{inbox?.messages.map((msg) => (
|
||||||
<div className="mailbox-conversations" data-testid="mailbox-conversations">
|
<div
|
||||||
{groupMessagesByConversation(inbox.messages).map((group) => (
|
key={msg.id}
|
||||||
<div
|
className={`mailbox-item ${!msg.read ? "unread" : ""}`}
|
||||||
key={group.key}
|
onClick={() => handleOpenMessage(msg)}
|
||||||
className={`mailbox-conversation-group ${group.unreadCount > 0 ? "unread" : ""}`}
|
data-testid={`mailbox-item-${msg.id}`}
|
||||||
onClick={() => handleOpenMessage(group.latestMessage)}
|
>
|
||||||
data-testid={`mailbox-conversation-${group.key}`}
|
<div className="mailbox-item-avatar">
|
||||||
>
|
{msg.fromType === "agent" ? <Bot size={16} /> : <User size={16} />}
|
||||||
<div className="mailbox-item-avatar">
|
</div>
|
||||||
{group.fromType === "agent" ? <Bot size={16} /> : <User size={16} />}
|
<div className="mailbox-item-content">
|
||||||
</div>
|
<div className="mailbox-item-header">
|
||||||
<div className="mailbox-item-content">
|
<span className="mailbox-item-from">
|
||||||
<div className="mailbox-item-header">
|
{getParticipantLabel(msg.fromId, msg.fromType)}
|
||||||
<span className="mailbox-item-from">
|
</span>
|
||||||
{getParticipantLabel(group.fromId, group.fromType)}
|
<span className="mailbox-item-time">{formatTimestamp(msg.createdAt)}</span>
|
||||||
</span>
|
|
||||||
<span className="mailbox-item-time">
|
|
||||||
{formatTimestamp(group.latestMessage.createdAt)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="mailbox-item-preview">
|
|
||||||
{group.latestMessage.content.slice(0, 80)}
|
|
||||||
{group.latestMessage.content.length > 80 ? "…" : ""}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{group.unreadCount > 0 && (
|
|
||||||
<div className="mailbox-group-unread-badge" data-testid={`mailbox-unread-badge-${group.key}`}>
|
|
||||||
{group.unreadCount > 9 ? "9+" : group.unreadCount}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
<div className="mailbox-item-preview">{msg.content.slice(0, 80)}{msg.content.length > 80 ? "…" : ""}</div>
|
||||||
|
</div>
|
||||||
|
{!msg.read && <div className="mailbox-item-unread-dot" data-testid={`mailbox-unread-dot-${msg.id}`} />}
|
||||||
</div>
|
</div>
|
||||||
)}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -737,7 +708,7 @@ export function MailboxView({
|
|||||||
return (
|
return (
|
||||||
<div className="mailbox-split-empty" data-testid="mailbox-split-empty">
|
<div className="mailbox-split-empty" data-testid="mailbox-split-empty">
|
||||||
<Mail size={24} />
|
<Mail size={24} />
|
||||||
<p>Select a conversation to read messages</p>
|
<p>Select a message to read</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -483,6 +483,37 @@ describe("MailboxModal", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not show unrelated same-sender messages as a thread in detail", async () => {
|
||||||
|
const root: Message = {
|
||||||
|
...mockMessage,
|
||||||
|
id: "msg-modal-root-only",
|
||||||
|
content: "Primary inbox request",
|
||||||
|
};
|
||||||
|
const unrelated: Message = {
|
||||||
|
...mockMessage,
|
||||||
|
id: "msg-modal-unrelated",
|
||||||
|
content: "Unrelated top-level note",
|
||||||
|
createdAt: new Date(Date.now() + 10_000).toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
mockFetchInbox.mockResolvedValue({ messages: [root], total: 1, unreadCount: 1 });
|
||||||
|
mockFetchConversation.mockResolvedValue([root, unrelated]);
|
||||||
|
|
||||||
|
render(<MailboxModal {...defaultProps} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("mailbox-item-msg-modal-root-only")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId("mailbox-item-msg-modal-root-only"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByTestId("mailbox-conversation")).toBeNull();
|
||||||
|
expect(screen.getByTestId("mailbox-message-body")).toHaveTextContent("Primary inbox request");
|
||||||
|
expect(screen.queryByText("Unrelated top-level note")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("shows compose button in header on inbox tab", async () => {
|
it("shows compose button in header on inbox tab", async () => {
|
||||||
render(<MailboxModal {...defaultProps} />);
|
render(<MailboxModal {...defaultProps} />);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
|
|||||||
@@ -231,7 +231,7 @@ describe("MailboxView", () => {
|
|||||||
render(<MailboxView {...defaultProps} />);
|
render(<MailboxView {...defaultProps} />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByTestId("mailbox-conversations")).toBeDefined();
|
expect(screen.getByTestId("mailbox-inbox-list")).toBeDefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -263,10 +263,15 @@ describe("MailboxView", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("groups messages by sender and shows unread count per group", async () => {
|
it("renders separate inbox rows for independent messages from the same sender", async () => {
|
||||||
const secondMessage = { ...mockMessage, id: "msg-003", read: false };
|
const secondMessage = {
|
||||||
|
...mockMessage,
|
||||||
|
id: "msg-003",
|
||||||
|
content: "Second top-level message",
|
||||||
|
metadata: undefined,
|
||||||
|
};
|
||||||
mockFetchInbox.mockResolvedValue({
|
mockFetchInbox.mockResolvedValue({
|
||||||
messages: [mockMessage, secondMessage], // Same sender, both unread
|
messages: [mockMessage, secondMessage],
|
||||||
unreadCount: 2,
|
unreadCount: 2,
|
||||||
total: 2,
|
total: 2,
|
||||||
});
|
});
|
||||||
@@ -274,10 +279,42 @@ describe("MailboxView", () => {
|
|||||||
render(<MailboxView {...defaultProps} />);
|
render(<MailboxView {...defaultProps} />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
// Should show one conversation group with 2 unread
|
expect(screen.getByTestId("mailbox-item-msg-001")).toBeDefined();
|
||||||
const group = screen.getByTestId("mailbox-conversation-agent:agent-001");
|
expect(screen.getByTestId("mailbox-item-msg-003")).toBeDefined();
|
||||||
expect(group).toBeDefined();
|
expect(screen.getByTestId("mailbox-unread-dot-msg-001")).toBeDefined();
|
||||||
expect(screen.getByTestId("mailbox-unread-badge-agent:agent-001")).toBeDefined();
|
expect(screen.getByTestId("mailbox-unread-dot-msg-003")).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens the specific selected inbox row when same-sender messages are separate", async () => {
|
||||||
|
const secondMessage: Message = {
|
||||||
|
...mockMessage,
|
||||||
|
id: "msg-003",
|
||||||
|
content: "Second top-level message",
|
||||||
|
createdAt: new Date(Date.now() + 1000).toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
mockFetchInbox.mockResolvedValue({
|
||||||
|
messages: [mockMessage, secondMessage],
|
||||||
|
unreadCount: 2,
|
||||||
|
total: 2,
|
||||||
|
});
|
||||||
|
mockFetchConversation.mockResolvedValue([secondMessage]);
|
||||||
|
mockMarkMessageRead.mockResolvedValue({ ...secondMessage, read: true });
|
||||||
|
|
||||||
|
render(<MailboxView {...defaultProps} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("mailbox-item-msg-003")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByTestId("mailbox-item-msg-003"));
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("mailbox-message-body")).toHaveTextContent("Second top-level message");
|
||||||
|
expect(mockMarkMessageRead).toHaveBeenCalledWith("msg-003", undefined);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -291,7 +328,7 @@ describe("MailboxView", () => {
|
|||||||
render(<MailboxView {...defaultProps} />);
|
render(<MailboxView {...defaultProps} />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByTestId("mailbox-unread-badge-agent:agent-001")).toBeDefined();
|
expect(screen.getByTestId("mailbox-unread-dot-msg-001")).toBeDefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -362,11 +399,11 @@ describe("MailboxView", () => {
|
|||||||
render(<MailboxView {...defaultProps} />);
|
render(<MailboxView {...defaultProps} />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByTestId("mailbox-conversation-agent:agent-001")).toBeDefined();
|
expect(screen.getByTestId("mailbox-item-msg-001")).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.click(screen.getByTestId("mailbox-conversation-agent:agent-001"));
|
fireEvent.click(screen.getByTestId("mailbox-item-msg-001"));
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -391,7 +428,7 @@ describe("MailboxView", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.click(screen.getByTestId("mailbox-conversation-agent:agent-001"));
|
fireEvent.click(screen.getByTestId("mailbox-item-msg-001"));
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -434,7 +471,7 @@ describe("MailboxView", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.click(screen.getByTestId("mailbox-conversation-agent:agent-001"));
|
fireEvent.click(screen.getByTestId("mailbox-item-msg-001"));
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -465,11 +502,11 @@ describe("MailboxView", () => {
|
|||||||
render(<MailboxView {...defaultProps} />);
|
render(<MailboxView {...defaultProps} />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByTestId("mailbox-conversation-agent:agent-001")).toBeDefined();
|
expect(screen.getByTestId("mailbox-item-msg-004")).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.click(screen.getByTestId("mailbox-conversation-agent:agent-001"));
|
fireEvent.click(screen.getByTestId("mailbox-item-msg-004"));
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -491,11 +528,11 @@ describe("MailboxView", () => {
|
|||||||
render(<MailboxView {...defaultProps} onUnreadCountChange={onUnreadCountChange} />);
|
render(<MailboxView {...defaultProps} onUnreadCountChange={onUnreadCountChange} />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByTestId("mailbox-conversation-agent:agent-001")).toBeDefined();
|
expect(screen.getByTestId("mailbox-item-msg-001")).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.click(screen.getByTestId("mailbox-conversation-agent:agent-001"));
|
fireEvent.click(screen.getByTestId("mailbox-item-msg-001"));
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -543,11 +580,11 @@ describe("MailboxView", () => {
|
|||||||
render(<MailboxView {...defaultProps} />);
|
render(<MailboxView {...defaultProps} />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByTestId("mailbox-conversation-agent:agent-001")).toBeDefined();
|
expect(screen.getByTestId("mailbox-item-msg-001")).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.click(screen.getByTestId("mailbox-conversation-agent:agent-001"));
|
fireEvent.click(screen.getByTestId("mailbox-item-msg-001"));
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -579,11 +616,11 @@ describe("MailboxView", () => {
|
|||||||
render(<MailboxView {...defaultProps} />);
|
render(<MailboxView {...defaultProps} />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByTestId("mailbox-conversation-agent:agent-001")).toBeDefined();
|
expect(screen.getByTestId("mailbox-item-msg-001")).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.click(screen.getByTestId("mailbox-conversation-agent:agent-001"));
|
fireEvent.click(screen.getByTestId("mailbox-item-msg-001"));
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -653,11 +690,11 @@ describe("MailboxView", () => {
|
|||||||
render(<MailboxView {...defaultProps} />);
|
render(<MailboxView {...defaultProps} />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByTestId("mailbox-conversation-agent:agent-001")).toBeDefined();
|
expect(screen.getByTestId("mailbox-item-msg-thread-root")).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.click(screen.getByTestId("mailbox-conversation-agent:agent-001"));
|
fireEvent.click(screen.getByTestId("mailbox-item-msg-thread-root"));
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -669,6 +706,44 @@ describe("MailboxView", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not pull unrelated same-sender messages into selected detail thread", async () => {
|
||||||
|
const root: Message = {
|
||||||
|
...mockMessage,
|
||||||
|
id: "msg-thread-root-only",
|
||||||
|
content: "Root request",
|
||||||
|
};
|
||||||
|
const unrelated: Message = {
|
||||||
|
...mockMessage,
|
||||||
|
id: "msg-unrelated",
|
||||||
|
content: "Independent update",
|
||||||
|
createdAt: new Date(Date.now() + 10_000).toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
mockFetchInbox.mockResolvedValue({
|
||||||
|
messages: [root],
|
||||||
|
unreadCount: 1,
|
||||||
|
total: 1,
|
||||||
|
});
|
||||||
|
mockFetchConversation.mockResolvedValue([root, unrelated]);
|
||||||
|
mockMarkMessageRead.mockResolvedValue({ ...root, read: true });
|
||||||
|
|
||||||
|
render(<MailboxView {...defaultProps} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("mailbox-item-msg-thread-root-only")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByTestId("mailbox-item-msg-thread-root-only"));
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByTestId("mailbox-conversation")).toBeNull();
|
||||||
|
expect(screen.getByTestId("mailbox-message-body")).toHaveTextContent("Root request");
|
||||||
|
expect(screen.queryByText("Independent update")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("renders selected-message reply context with dedicated styling", async () => {
|
it("renders selected-message reply context with dedicated styling", async () => {
|
||||||
const replyMessage: Message = {
|
const replyMessage: Message = {
|
||||||
...mockMessage,
|
...mockMessage,
|
||||||
@@ -688,11 +763,11 @@ describe("MailboxView", () => {
|
|||||||
render(<MailboxView {...defaultProps} />);
|
render(<MailboxView {...defaultProps} />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByTestId("mailbox-conversation-agent:agent-001")).toBeDefined();
|
expect(screen.getByTestId("mailbox-item-msg-reply-single")).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.click(screen.getByTestId("mailbox-conversation-agent:agent-001"));
|
fireEvent.click(screen.getByTestId("mailbox-item-msg-reply-single"));
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
|
|||||||
@@ -54,6 +54,29 @@ function listProtectedFusionDirs() {
|
|||||||
return [...dirs];
|
return [...dirs];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Paths inside a protected .fusion root that a concurrently-running fusion app
|
||||||
|
// is expected to mutate. Tests still must not write to these — the filter only
|
||||||
|
// suppresses noise from a live app sharing the same HOME during local dev.
|
||||||
|
const RUNTIME_IGNORE_PATTERNS = [
|
||||||
|
/^agent(?:[\/\\]|$)/,
|
||||||
|
/^agents(?:[\/\\]|$)/,
|
||||||
|
/^agent-memory(?:[\/\\]|$)/,
|
||||||
|
/^automations(?:[\/\\]|$)/,
|
||||||
|
/^backups(?:[\/\\]|$)/,
|
||||||
|
/^plugins(?:[\/\\]|$)/,
|
||||||
|
/^cache(?:[\/\\]|$)/,
|
||||||
|
/^config\.json$/,
|
||||||
|
/^fusion-central\.db(?:-wal|-shm|-journal)?$/,
|
||||||
|
/^fusion\.db(?:-wal|-shm|-journal)?(?:\.backup-[\w-]+)?$/,
|
||||||
|
/^archive\.db(?:-wal|-shm|-journal)?(?:\.backup-[\w-]+)?$/,
|
||||||
|
/^activity-log\.jsonl$/,
|
||||||
|
/^logs(?:[\/\\]|$)/,
|
||||||
|
];
|
||||||
|
|
||||||
|
function isRuntimePath(relPath) {
|
||||||
|
return RUNTIME_IGNORE_PATTERNS.some((re) => re.test(relPath));
|
||||||
|
}
|
||||||
|
|
||||||
function collectFusionSignature(rootDir, out = []) {
|
function collectFusionSignature(rootDir, out = []) {
|
||||||
if (!existsSync(rootDir)) return out;
|
if (!existsSync(rootDir)) return out;
|
||||||
let stat;
|
let stat;
|
||||||
@@ -68,6 +91,7 @@ function collectFusionSignature(rootDir, out = []) {
|
|||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const fullPath = join(rootDir, entry.name);
|
const fullPath = join(rootDir, entry.name);
|
||||||
const relPath = fullPath.slice(rootDir.length + (rootDir.endsWith(sep) ? 0 : 1));
|
const relPath = fullPath.slice(rootDir.length + (rootDir.endsWith(sep) ? 0 : 1));
|
||||||
|
if (isRuntimePath(relPath)) continue;
|
||||||
let entryStat;
|
let entryStat;
|
||||||
try {
|
try {
|
||||||
entryStat = statSync(fullPath);
|
entryStat = statSync(fullPath);
|
||||||
@@ -94,22 +118,31 @@ function sleepMs(ms) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function recordBaseline() {
|
function recordBaseline() {
|
||||||
const firstProtected = snapshotProtectedFusion();
|
const samples = [snapshotProtectedFusion()];
|
||||||
sleepMs(250);
|
for (let i = 0; i < 4; i++) {
|
||||||
const secondProtected = snapshotProtectedFusion();
|
sleepMs(500);
|
||||||
|
samples.push(snapshotProtectedFusion());
|
||||||
|
}
|
||||||
|
|
||||||
|
const latestProtected = samples[samples.length - 1];
|
||||||
const unstableProtectedDirs = [];
|
const unstableProtectedDirs = [];
|
||||||
|
const firstProtected = samples[0];
|
||||||
for (const first of firstProtected) {
|
for (const first of firstProtected) {
|
||||||
const second = secondProtected.find((entry) => entry.dir === first.dir);
|
let unstable = false;
|
||||||
if (!second) continue;
|
for (let i = 1; i < samples.length; i++) {
|
||||||
if (JSON.stringify(first.entries) !== JSON.stringify(second.entries)) {
|
const current = samples[i].find((entry) => entry.dir === first.dir);
|
||||||
unstableProtectedDirs.push(first.dir);
|
if (!current) continue;
|
||||||
|
if (JSON.stringify(first.entries) !== JSON.stringify(current.entries)) {
|
||||||
|
unstable = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
if (unstable) unstableProtectedDirs.push(first.dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
tmpNames: snapshotTmp().map((e) => e.name),
|
tmpNames: snapshotTmp().map((e) => e.name),
|
||||||
protectedFusion: secondProtected,
|
protectedFusion: latestProtected,
|
||||||
unstableProtectedDirs,
|
unstableProtectedDirs,
|
||||||
};
|
};
|
||||||
writeFileSync(BASELINE_FILE, JSON.stringify(payload));
|
writeFileSync(BASELINE_FILE, JSON.stringify(payload));
|
||||||
@@ -136,14 +169,37 @@ function checkAgainstBaseline() {
|
|||||||
const baselineByDir = new Map((baseline.protectedFusion ?? []).map((entry) => [entry.dir, entry]));
|
const baselineByDir = new Map((baseline.protectedFusion ?? []).map((entry) => [entry.dir, entry]));
|
||||||
const unstableProtectedDirs = new Set(baseline.unstableProtectedDirs ?? []);
|
const unstableProtectedDirs = new Set(baseline.unstableProtectedDirs ?? []);
|
||||||
const currentProtected = snapshotProtectedFusion();
|
const currentProtected = snapshotProtectedFusion();
|
||||||
const protectedViolations = [];
|
const currentByDir = new Map(currentProtected.map((entry) => [entry.dir, entry]));
|
||||||
|
const candidateViolations = [];
|
||||||
for (const current of currentProtected) {
|
for (const current of currentProtected) {
|
||||||
if (unstableProtectedDirs.has(current.dir)) continue;
|
if (unstableProtectedDirs.has(current.dir)) continue;
|
||||||
const base = baselineByDir.get(current.dir) ?? { exists: false, entries: [] };
|
const base = baselineByDir.get(current.dir) ?? { exists: false, entries: [] };
|
||||||
const changedExistence = Boolean(base.exists) !== Boolean(current.exists);
|
const changedExistence = Boolean(base.exists) !== Boolean(current.exists);
|
||||||
const changedEntries = JSON.stringify(base.entries) !== JSON.stringify(current.entries);
|
const changedEntries = JSON.stringify(base.entries) !== JSON.stringify(current.entries);
|
||||||
if (changedExistence || changedEntries) {
|
if (changedExistence || changedEntries) {
|
||||||
protectedViolations.push(current.dir);
|
candidateViolations.push(current.dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const protectedViolations = [];
|
||||||
|
if (candidateViolations.length > 0) {
|
||||||
|
sleepMs(1250);
|
||||||
|
const resampledProtected = snapshotProtectedFusion();
|
||||||
|
const resampledByDir = new Map(resampledProtected.map((entry) => [entry.dir, entry]));
|
||||||
|
|
||||||
|
for (const dir of candidateViolations) {
|
||||||
|
const first = currentByDir.get(dir);
|
||||||
|
const second = resampledByDir.get(dir);
|
||||||
|
if (!first || !second) {
|
||||||
|
protectedViolations.push(dir);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the directory is still mutating across back-to-back samples,
|
||||||
|
// treat it as externally active noise (same as baseline unstable dirs).
|
||||||
|
if (JSON.stringify(first.entries) === JSON.stringify(second.entries)) {
|
||||||
|
protectedViolations.push(dir);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user