feat(FN-3549): add mailbox approvals inbox and decision flow
- Add dashboard approvals APIs and route handlers for listing requests, fetching detail, and submitting approve/deny decisions - Emit approval SSE events and subscribe mailbox refresh logic to keep approval lists and badges live - Extend Mailbox view with an Approvals tab, pending/history filters, request detail history, and approve/deny actions - Update mailbox styles, approval route tests, and docs for the new approvals workflow Fusion-Task-Id: FN-3549
This commit is contained in:
@@ -69,6 +69,7 @@ import type {
|
||||
DockerExtraCli,
|
||||
DockerNodeStatus,
|
||||
ProjectNodePathMapping,
|
||||
ApprovalRequestStatus,
|
||||
} from "@fusion/core";
|
||||
import type { PlanningQuestion, PlanningSummary } from "@fusion/core";
|
||||
import type { ScheduledTask, ScheduledTaskCreateInput, ScheduledTaskUpdateInput, AutomationRunResult, Routine, RoutineCreateInput, RoutineUpdateInput, RoutineExecutionResult } from "@fusion/core";
|
||||
@@ -7598,6 +7599,55 @@ export interface SendMessageInput {
|
||||
wakeImmediately?: boolean;
|
||||
}
|
||||
|
||||
export interface ApprovalRequestSummary {
|
||||
id: string;
|
||||
status: ApprovalRequestStatus;
|
||||
actionCategory: string;
|
||||
actionSummary: string;
|
||||
agentId: string;
|
||||
taskId?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
decidedAt?: string;
|
||||
decidedBy?: string;
|
||||
}
|
||||
|
||||
export interface ApprovalRequestDetail extends ApprovalRequestSummary {
|
||||
requester: {
|
||||
actorId: string;
|
||||
actorType: "agent" | "user" | "system";
|
||||
actorName: string;
|
||||
};
|
||||
runId?: string;
|
||||
requestedAt: string;
|
||||
completedAt?: string;
|
||||
targetAction: {
|
||||
category: string;
|
||||
action: string;
|
||||
summary: string;
|
||||
resourceType: string;
|
||||
resourceId: string;
|
||||
context?: Record<string, unknown>;
|
||||
};
|
||||
history: Array<{
|
||||
id: string;
|
||||
eventType: string;
|
||||
actor: {
|
||||
actorId: string;
|
||||
actorType: "agent" | "user" | "system";
|
||||
actorName: string;
|
||||
};
|
||||
note?: string;
|
||||
createdAt: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface ApprovalListResponse {
|
||||
requests: ApprovalRequestSummary[];
|
||||
total: number;
|
||||
pendingCount: number;
|
||||
}
|
||||
|
||||
/** Fetch inbox messages for the current user. */
|
||||
export function fetchInbox(
|
||||
options?: { limit?: number; offset?: number; unreadOnly?: boolean; type?: MessageType },
|
||||
@@ -7681,6 +7731,34 @@ export function fetchAgentMailbox(agentId: string, projectId?: string): Promise<
|
||||
return api<AgentMailboxResponse>(withProjectId(`/agents/${encodeURIComponent(agentId)}/mailbox`, projectId));
|
||||
}
|
||||
|
||||
export function fetchApprovals(
|
||||
options?: { status?: ApprovalRequestStatus; limit?: number; offset?: number },
|
||||
projectId?: string,
|
||||
): Promise<ApprovalListResponse> {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.status) params.set("status", options.status);
|
||||
if (options?.limit !== undefined) params.set("limit", String(options.limit));
|
||||
if (options?.offset !== undefined) params.set("offset", String(options.offset));
|
||||
if (projectId) params.set("projectId", projectId);
|
||||
const query = params.size > 0 ? `?${params.toString()}` : "";
|
||||
return api<ApprovalListResponse>(`/approvals${query}`);
|
||||
}
|
||||
|
||||
export function fetchApprovalDetail(id: string, projectId?: string): Promise<ApprovalRequestDetail> {
|
||||
return api<ApprovalRequestDetail>(withProjectId(`/approvals/${encodeURIComponent(id)}`, projectId));
|
||||
}
|
||||
|
||||
export function decideApproval(
|
||||
id: string,
|
||||
input: { decision: "approve" | "deny"; comment?: string },
|
||||
projectId?: string,
|
||||
): Promise<ApprovalRequestDetail> {
|
||||
return api<ApprovalRequestDetail>(withProjectId(`/approvals/${encodeURIComponent(id)}/decision`, projectId), {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
/** Fetch reflection history for an agent. */
|
||||
export function fetchAgentReflections(agentId: string, limit?: number, projectId?: string): Promise<AgentReflection[]> {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
@@ -25,10 +25,10 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
min-width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
padding: 0 var(--space-xs);
|
||||
border-radius: 10px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-error);
|
||||
color: var(--fab-text);
|
||||
font-size: var(--font-size-3xs, 0.7rem);
|
||||
@@ -51,7 +51,7 @@
|
||||
|
||||
.mailbox-tab {
|
||||
justify-content: center;
|
||||
min-height: 32px;
|
||||
min-height: 2rem;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
color: var(--text-muted);
|
||||
border-color: var(--border);
|
||||
@@ -74,8 +74,8 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
min-width: 1rem;
|
||||
height: 1rem;
|
||||
padding: 0 var(--btn-border-width);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-error);
|
||||
@@ -138,8 +138,8 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-muted);
|
||||
@@ -184,8 +184,8 @@
|
||||
}
|
||||
|
||||
.mailbox-item-unread-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
background: var(--todo);
|
||||
flex-shrink: 0;
|
||||
@@ -223,14 +223,14 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
min-width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
padding: 0 var(--space-xs);
|
||||
font-size: var(--font-size-xs, 0.8rem);
|
||||
font-weight: 600;
|
||||
background: var(--todo);
|
||||
color: var(--fab-text);
|
||||
border-radius: 10px;
|
||||
border-radius: var(--radius-pill);
|
||||
flex-shrink: 0;
|
||||
margin-top: var(--space-xs);
|
||||
}
|
||||
@@ -358,7 +358,7 @@
|
||||
|
||||
.mailbox-markdown-table th,
|
||||
.mailbox-markdown-table td {
|
||||
border: 1px solid color-mix(in srgb, var(--border) 85%, transparent);
|
||||
border: var(--btn-border-width) solid color-mix(in srgb, var(--border) 85%, transparent);
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
}
|
||||
|
||||
@@ -494,7 +494,7 @@
|
||||
|
||||
.mailbox-agent-select {
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
max-width: 18.75rem;
|
||||
}
|
||||
|
||||
.mailbox-agents-content {
|
||||
@@ -539,6 +539,43 @@
|
||||
font-size: var(--font-size-3xs, 0.7rem);
|
||||
}
|
||||
|
||||
.mailbox-approval-filters {
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
padding-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.mailbox-approval-item {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mailbox-approval-status-dot {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mailbox-approval-status-dot--pending {
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.mailbox-approval-status-dot--approved,
|
||||
.mailbox-approval-status-dot--completed {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.mailbox-approval-status-dot--denied {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.mailbox-approval-status {
|
||||
text-transform: capitalize;
|
||||
font-size: var(--font-size-xs, 0.8rem);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.mailbox-approval-comment {
|
||||
min-height: calc(var(--space-2xl) + var(--space-xl));
|
||||
}
|
||||
|
||||
/* Skeleton loading */
|
||||
.mailbox-skeleton {
|
||||
display: flex;
|
||||
@@ -554,8 +591,8 @@
|
||||
}
|
||||
|
||||
.mailbox-skeleton-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-tertiary);
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
@@ -569,7 +606,7 @@
|
||||
}
|
||||
|
||||
.mailbox-skeleton-line {
|
||||
height: 12px;
|
||||
height: 0.75rem;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-tertiary);
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
@@ -612,7 +649,7 @@
|
||||
|
||||
.mailbox-view .mailbox-split-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(280px, 38%) minmax(0, 1fr);
|
||||
grid-template-columns: minmax(17.5rem, 38%) minmax(0, 1fr);
|
||||
gap: var(--space-lg);
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
@@ -643,7 +680,7 @@
|
||||
justify-content: center;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-xl);
|
||||
border: 1px dashed color-mix(in srgb, var(--text-muted) 45%, transparent);
|
||||
border: var(--btn-border-width) dashed color-mix(in srgb, var(--text-muted) 45%, transparent);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-muted);
|
||||
background: color-mix(in srgb, var(--surface) 80%, transparent);
|
||||
@@ -678,13 +715,13 @@
|
||||
.mailbox-modal .mailbox-header-actions .modal-close,
|
||||
.mailbox-view .mailbox-header-actions .btn,
|
||||
.mailbox-view .mailbox-header-actions .btn-icon {
|
||||
min-height: 36px;
|
||||
min-height: 2.25rem;
|
||||
}
|
||||
|
||||
.mailbox-modal .mailbox-header-actions .btn-icon,
|
||||
.mailbox-modal .mailbox-header-actions .modal-close,
|
||||
.mailbox-view .mailbox-header-actions .btn-icon {
|
||||
min-width: 36px;
|
||||
min-width: 2.25rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -714,14 +751,14 @@
|
||||
.mailbox-modal .mailbox-content {
|
||||
max-height: calc(100dvh - var(--header-height) - var(--space-2xl) - var(--space-xl));
|
||||
padding: var(--space-md);
|
||||
padding-bottom: calc(var(--space-md) + env(safe-area-inset-bottom, 0px) + var(--standalone-bottom-gap));
|
||||
padding-bottom: calc(var(--space-md) + env(safe-area-inset-bottom, 0) + var(--standalone-bottom-gap));
|
||||
}
|
||||
|
||||
.mailbox-view[style*="--keyboard-overlap"],
|
||||
.mailbox-modal[style*="--keyboard-overlap"] {
|
||||
height: var(--vv-height, 100dvh);
|
||||
max-height: var(--vv-height, 100dvh);
|
||||
transform: translateY(var(--vv-offset-top, 0px));
|
||||
transform: translateY(var(--vv-offset-top, 0));
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
@@ -759,7 +796,7 @@
|
||||
}
|
||||
|
||||
.mailbox-modal .mailbox-agents {
|
||||
min-height: 200px;
|
||||
min-height: 12.5rem;
|
||||
}
|
||||
|
||||
.mailbox-modal .mailbox-empty {
|
||||
@@ -773,7 +810,7 @@
|
||||
.mailbox-modal .mailbox-agent-subtab {
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
min-height: 36px;
|
||||
min-height: 2.25rem;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
}
|
||||
|
||||
@@ -808,11 +845,11 @@
|
||||
overflow-y: auto;
|
||||
padding: var(--space-md);
|
||||
/* Account for mobile nav bar at bottom */
|
||||
padding-bottom: calc(var(--mobile-nav-height) + env(safe-area-inset-bottom, 0px) + var(--standalone-bottom-gap) + var(--space-lg));
|
||||
padding-bottom: calc(var(--mobile-nav-height) + env(safe-area-inset-bottom, 0) + var(--standalone-bottom-gap) + var(--space-lg));
|
||||
}
|
||||
|
||||
.mailbox-view[style*="--keyboard-overlap"] .mailbox-content {
|
||||
padding-bottom: calc(env(safe-area-inset-bottom, 0px) + var(--space-md));
|
||||
padding-bottom: calc(env(safe-area-inset-bottom, 0) + var(--space-md));
|
||||
}
|
||||
|
||||
.mailbox-view .mailbox-split-layout {
|
||||
@@ -857,7 +894,7 @@
|
||||
}
|
||||
|
||||
.mailbox-view .mailbox-agents {
|
||||
min-height: 200px;
|
||||
min-height: 12.5rem;
|
||||
}
|
||||
|
||||
.mailbox-view .mailbox-empty {
|
||||
@@ -871,7 +908,7 @@
|
||||
.mailbox-view .mailbox-agent-subtab {
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
min-height: 36px;
|
||||
min-height: 2.25rem;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
}
|
||||
|
||||
@@ -886,7 +923,7 @@
|
||||
}
|
||||
|
||||
.message-composer-footer .btn {
|
||||
min-height: 36px;
|
||||
min-height: 2.25rem;
|
||||
}
|
||||
|
||||
.message-composer-field {
|
||||
|
||||
@@ -23,10 +23,15 @@ import {
|
||||
deleteMessage,
|
||||
fetchConversation,
|
||||
fetchAgents,
|
||||
fetchApprovals,
|
||||
fetchApprovalDetail,
|
||||
decideApproval,
|
||||
type InboxResponse,
|
||||
type OutboxResponse,
|
||||
type AgentMailboxResponse,
|
||||
type Agent,
|
||||
type ApprovalRequestSummary,
|
||||
type ApprovalRequestDetail,
|
||||
} from "../api";
|
||||
import { MailboxMessageContent } from "./MailboxMessageContent";
|
||||
import { MessageComposer } from "./MessageComposer";
|
||||
@@ -36,7 +41,7 @@ import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────
|
||||
|
||||
type MailboxTab = "inbox" | "outbox" | "agents";
|
||||
type MailboxTab = "inbox" | "outbox" | "agents" | "approvals";
|
||||
|
||||
interface MailboxViewProps {
|
||||
projectId?: string;
|
||||
@@ -168,6 +173,12 @@ export function MailboxView({
|
||||
const [agentSubTab, setAgentSubTab] = useState<"inbox" | "outbox">("inbox");
|
||||
const [agentMailbox, setAgentMailbox] = useState<AgentMailboxResponse | null>(null);
|
||||
const [agents, setAgents] = useState<Agent[]>([]);
|
||||
const [approvalSubTab, setApprovalSubTab] = useState<"pending" | "history">("pending");
|
||||
const [approvals, setApprovals] = useState<ApprovalRequestSummary[]>([]);
|
||||
const [approvalPendingCount, setApprovalPendingCount] = useState(0);
|
||||
const [selectedApproval, setSelectedApproval] = useState<ApprovalRequestDetail | null>(null);
|
||||
const [approvalComment, setApprovalComment] = useState("");
|
||||
const [approvalDecisionLoading, setApprovalDecisionLoading] = useState<false | "approve" | "deny">(false);
|
||||
|
||||
const agentNamesById = useMemo(
|
||||
() => new Map(agents.map((agent) => [agent.id, agent.name ?? ""])),
|
||||
@@ -252,12 +263,37 @@ export function MailboxView({
|
||||
}
|
||||
}, [projectId, onUnreadCountChange]);
|
||||
|
||||
const loadApprovals = useCallback(async (status: "pending" | "history") => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const list = await fetchApprovals({ status: status === "pending" ? "pending" : undefined, limit: 100 }, projectId);
|
||||
if (status === "pending") {
|
||||
setApprovals(list.requests);
|
||||
} else {
|
||||
const [approved, denied, completed] = await Promise.all([
|
||||
fetchApprovals({ status: "approved", limit: 100 }, projectId),
|
||||
fetchApprovals({ status: "denied", limit: 100 }, projectId),
|
||||
fetchApprovals({ status: "completed", limit: 100 }, projectId),
|
||||
]);
|
||||
setApprovals([...approved.requests, ...denied.requests, ...completed.requests].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)));
|
||||
}
|
||||
setApprovalPendingCount(list.pendingCount);
|
||||
} catch {
|
||||
// Silently fail
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
// Load data on tab change
|
||||
useEffect(() => {
|
||||
if (activeTab === "inbox") loadInbox();
|
||||
else if (activeTab === "outbox") loadOutbox();
|
||||
else if (activeTab === "agents") loadAgents();
|
||||
}, [activeTab, loadInbox, loadOutbox, loadAgents]);
|
||||
else if (activeTab === "approvals") {
|
||||
void loadApprovals(approvalSubTab);
|
||||
}
|
||||
}, [activeTab, loadInbox, loadOutbox, loadAgents, loadApprovals, approvalSubTab]);
|
||||
|
||||
// Load agent mailbox when selected
|
||||
useEffect(() => {
|
||||
@@ -289,6 +325,8 @@ export function MailboxView({
|
||||
void loadInbox();
|
||||
} else if (activeTab === "outbox") {
|
||||
void loadOutbox();
|
||||
} else if (activeTab === "approvals") {
|
||||
void loadApprovals(approvalSubTab);
|
||||
}
|
||||
|
||||
if (selectedAgentId) {
|
||||
@@ -302,9 +340,12 @@ export function MailboxView({
|
||||
"message:received": onMailboxUpdate,
|
||||
"message:read": onMailboxUpdate,
|
||||
"message:deleted": onMailboxUpdate,
|
||||
"approval:requested": onMailboxUpdate,
|
||||
"approval:updated": onMailboxUpdate,
|
||||
"approval:decided": onMailboxUpdate,
|
||||
},
|
||||
});
|
||||
}, [projectId, activeTab, selectedAgentId, refreshUnreadCount, loadInbox, loadOutbox, loadAgentMailbox]);
|
||||
}, [projectId, activeTab, selectedAgentId, refreshUnreadCount, loadInbox, loadOutbox, loadAgentMailbox, loadApprovals, approvalSubTab]);
|
||||
|
||||
// ── Actions ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -465,6 +506,33 @@ export function MailboxView({
|
||||
setComposeReplyContext(null);
|
||||
}, []);
|
||||
|
||||
const handleOpenApproval = useCallback(async (request: ApprovalRequestSummary) => {
|
||||
try {
|
||||
const detail = await fetchApprovalDetail(request.id, projectId);
|
||||
setSelectedApproval(detail);
|
||||
setApprovalComment("");
|
||||
} catch {
|
||||
addToast?.("Failed to load approval request", "error");
|
||||
}
|
||||
}, [projectId, addToast]);
|
||||
|
||||
const handleApprovalDecision = useCallback(async (decision: "approve" | "deny") => {
|
||||
if (!selectedApproval || approvalDecisionLoading) return;
|
||||
setApprovalDecisionLoading(decision);
|
||||
try {
|
||||
await decideApproval(selectedApproval.id, { decision, comment: approvalComment || undefined }, projectId);
|
||||
await loadApprovals(approvalSubTab);
|
||||
const updated = await fetchApprovalDetail(selectedApproval.id, projectId);
|
||||
setSelectedApproval(updated);
|
||||
setApprovalComment("");
|
||||
addToast?.(`Request ${decision === "approve" ? "approved" : "denied"}`, "success");
|
||||
} catch {
|
||||
addToast?.("Failed to submit decision", "error");
|
||||
} finally {
|
||||
setApprovalDecisionLoading(false);
|
||||
}
|
||||
}, [selectedApproval, approvalDecisionLoading, approvalComment, projectId, loadApprovals, approvalSubTab, addToast]);
|
||||
|
||||
// ── Render ────────────────────────────────────────────────────────────
|
||||
|
||||
const renderMessageDetail = () => {
|
||||
@@ -647,6 +715,53 @@ export function MailboxView({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "approvals" && (
|
||||
<div className="mailbox-approvals" data-testid="mailbox-approvals">
|
||||
<div className="mailbox-approval-filters" data-testid="mailbox-approval-filters">
|
||||
<button
|
||||
className={`btn btn-sm btn-secondary mailbox-agent-subtab ${approvalSubTab === "pending" ? "active" : ""}`}
|
||||
onClick={() => { setApprovalSubTab("pending"); setSelectedApproval(null); }}
|
||||
data-testid="mailbox-approval-filter-pending"
|
||||
>
|
||||
Pending
|
||||
</button>
|
||||
<button
|
||||
className={`btn btn-sm btn-secondary mailbox-agent-subtab ${approvalSubTab === "history" ? "active" : ""}`}
|
||||
onClick={() => { setApprovalSubTab("history"); setSelectedApproval(null); }}
|
||||
data-testid="mailbox-approval-filter-history"
|
||||
>
|
||||
History
|
||||
</button>
|
||||
</div>
|
||||
<div className="mailbox-list" data-testid="mailbox-approval-list">
|
||||
{approvals.length === 0 && !isLoading && (
|
||||
<div className="mailbox-empty" data-testid="mailbox-approval-empty">
|
||||
<InboxIcon size={32} />
|
||||
<p>{approvalSubTab === "pending" ? "No pending approvals" : "No historical approvals"}</p>
|
||||
</div>
|
||||
)}
|
||||
{approvals.map((request) => (
|
||||
<div
|
||||
key={request.id}
|
||||
className="mailbox-item mailbox-approval-item"
|
||||
onClick={() => void handleOpenApproval(request)}
|
||||
data-testid={`mailbox-approval-item-${request.id}`}
|
||||
>
|
||||
<div className={`status-dot mailbox-approval-status-dot mailbox-approval-status-dot--${request.status}`} />
|
||||
<div className="mailbox-item-content">
|
||||
<div className="mailbox-item-header">
|
||||
<span className="mailbox-item-from">{request.agentId} · {request.actionCategory}</span>
|
||||
<span className="mailbox-item-time">{formatTimestamp(request.createdAt)}</span>
|
||||
</div>
|
||||
<div className="mailbox-item-preview">{request.actionSummary}</div>
|
||||
</div>
|
||||
<span className={`mailbox-approval-status mailbox-approval-status--${request.status}`}>{request.status}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "agents" && (
|
||||
<div className="mailbox-agents" data-testid="mailbox-agents">
|
||||
{agents.length === 0 ? (
|
||||
@@ -796,6 +911,58 @@ export function MailboxView({
|
||||
return renderMessageDetail();
|
||||
}
|
||||
|
||||
if (activeTab === "approvals" && selectedApproval) {
|
||||
return (
|
||||
<div className="mailbox-message-detail mailbox-approval-detail" data-testid="mailbox-approval-detail">
|
||||
{isMobile && (
|
||||
<button className="btn btn-sm btn-secondary" onClick={() => setSelectedApproval(null)} data-testid="mailbox-approval-back-to-list">← Back</button>
|
||||
)}
|
||||
<div className="mailbox-message-detail-header">
|
||||
<div className="mailbox-message-detail-meta">
|
||||
<span className="mailbox-message-type">{selectedApproval.actionCategory}</span>
|
||||
<span className="mailbox-message-time">{selectedApproval.status}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mailbox-message-body">
|
||||
<strong>{selectedApproval.actionSummary}</strong>
|
||||
<p>Requester: {selectedApproval.requester.actorName} ({selectedApproval.agentId})</p>
|
||||
{selectedApproval.taskId && <p>Task: {selectedApproval.taskId}</p>}
|
||||
<p>Requested: {formatTimestamp(selectedApproval.createdAt)}</p>
|
||||
</div>
|
||||
<div className="mailbox-conversation" data-testid="mailbox-approval-history">
|
||||
{selectedApproval.history.map((event) => (
|
||||
<div key={event.id} className="mailbox-conversation-msg">
|
||||
<div className="mailbox-conversation-msg-header">
|
||||
<span>{event.eventType}</span>
|
||||
<span>{event.actor.actorName}</span>
|
||||
</div>
|
||||
{event.note && <div className="mailbox-item-preview">{event.note}</div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{selectedApproval.status === "pending" && (
|
||||
<div className="mailbox-approval-decision" data-testid="mailbox-approval-decision">
|
||||
<textarea
|
||||
className="message-composer-textarea mailbox-approval-comment"
|
||||
value={approvalComment}
|
||||
onChange={(event) => setApprovalComment(event.target.value)}
|
||||
placeholder="Optional comment"
|
||||
data-testid="mailbox-approval-comment"
|
||||
/>
|
||||
<div className="mailbox-header-actions">
|
||||
<button className="btn btn-sm btn-secondary" onClick={() => void handleApprovalDecision("deny")} disabled={approvalDecisionLoading !== false} data-testid="mailbox-approval-deny">
|
||||
Deny
|
||||
</button>
|
||||
<button className="btn btn-sm btn-primary" onClick={() => void handleApprovalDecision("approve")} disabled={approvalDecisionLoading !== false} data-testid="mailbox-approval-approve">
|
||||
Approve
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mailbox-split-empty" data-testid="mailbox-split-empty">
|
||||
<Mail size={24} />
|
||||
@@ -843,6 +1010,7 @@ export function MailboxView({
|
||||
onClick={() => {
|
||||
if (activeTab === "inbox") loadInbox();
|
||||
else if (activeTab === "outbox") loadOutbox();
|
||||
else if (activeTab === "approvals") loadApprovals(approvalSubTab);
|
||||
else if (selectedAgentId) loadAgentMailbox(selectedAgentId);
|
||||
}}
|
||||
disabled={isLoading}
|
||||
@@ -875,12 +1043,21 @@ export function MailboxView({
|
||||
</button>
|
||||
<button
|
||||
className={`btn btn-sm btn-secondary mailbox-tab ${activeTab === "agents" ? "active" : ""}`}
|
||||
onClick={() => { setActiveTab("agents"); setSelectedMessage(null); }}
|
||||
onClick={() => { setActiveTab("agents"); setSelectedMessage(null); setSelectedApproval(null); }}
|
||||
data-testid="mailbox-tab-agents"
|
||||
>
|
||||
<Bot size={14} />
|
||||
<span>Agents</span>
|
||||
</button>
|
||||
<button
|
||||
className={`btn btn-sm btn-secondary mailbox-tab ${activeTab === "approvals" ? "active" : ""}`}
|
||||
onClick={() => { setActiveTab("approvals"); setSelectedMessage(null); setSelectedApproval(null); }}
|
||||
data-testid="mailbox-tab-approvals"
|
||||
>
|
||||
<CheckCheck size={14} />
|
||||
<span>Approvals</span>
|
||||
{approvalPendingCount > 0 && <span className="mailbox-tab-badge" data-testid="mailbox-approvals-pending-badge">{approvalPendingCount}</span>}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mailbox-content" data-testid="mailbox-content">
|
||||
@@ -896,6 +1073,7 @@ export function MailboxView({
|
||||
) : (
|
||||
<>
|
||||
{renderMessageDetail()}
|
||||
{activeTab === "approvals" && selectedApproval && renderDetailPane()}
|
||||
{showComposer && (
|
||||
<MessageComposer
|
||||
recipient={composeRecipient}
|
||||
@@ -907,7 +1085,7 @@ export function MailboxView({
|
||||
addToast={addToast}
|
||||
/>
|
||||
)}
|
||||
{!selectedMessage && !showComposer && renderListPane()}
|
||||
{!selectedMessage && !selectedApproval && !showComposer && renderListPane()}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { MailboxView } from "../MailboxView";
|
||||
import * as apiModule from "../../api";
|
||||
import * as viewportModule from "../../hooks/useViewportMode";
|
||||
import * as mobileKeyboardModule from "../../hooks/useMobileKeyboard";
|
||||
import * as sseBusModule from "../../sse-bus";
|
||||
import type { Agent } from "../../api";
|
||||
import type { Message } from "@fusion/core";
|
||||
|
||||
@@ -20,6 +21,9 @@ vi.mock("../../api", () => ({
|
||||
fetchConversation: vi.fn(),
|
||||
sendMessage: vi.fn(),
|
||||
fetchAgents: vi.fn(),
|
||||
fetchApprovals: vi.fn(),
|
||||
fetchApprovalDetail: vi.fn(),
|
||||
decideApproval: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useViewportMode", () => ({
|
||||
@@ -30,6 +34,14 @@ vi.mock("../../hooks/useMobileKeyboard", () => ({
|
||||
useMobileKeyboard: vi.fn(),
|
||||
}));
|
||||
|
||||
const sseSubscriptions: Array<Record<string, () => void>> = [];
|
||||
vi.mock("../../sse-bus", () => ({
|
||||
subscribeSse: vi.fn((_url: string, options: { events: Record<string, () => void> }) => {
|
||||
sseSubscriptions.push(options.events);
|
||||
return () => {};
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock lucide-react icons
|
||||
vi.mock("lucide-react", () => ({
|
||||
X: () => <span data-testid="icon-x">X</span>,
|
||||
@@ -59,6 +71,10 @@ const mockMarkAllMessagesRead = vi.mocked(apiModule.markAllMessagesRead);
|
||||
const mockDeleteMessage = vi.mocked(apiModule.deleteMessage);
|
||||
const mockFetchConversation = vi.mocked(apiModule.fetchConversation);
|
||||
const mockSendMessage = vi.mocked(apiModule.sendMessage);
|
||||
const mockFetchApprovals = vi.mocked(apiModule.fetchApprovals);
|
||||
const mockFetchApprovalDetail = vi.mocked(apiModule.fetchApprovalDetail);
|
||||
const mockDecideApproval = vi.mocked(apiModule.decideApproval);
|
||||
const mockSubscribeSse = vi.mocked(sseBusModule.subscribeSse);
|
||||
const mockUseViewportMode = vi.mocked(viewportModule.useViewportMode);
|
||||
const mockUseMobileKeyboard = vi.mocked(mobileKeyboardModule.useMobileKeyboard);
|
||||
|
||||
@@ -153,6 +169,7 @@ function makeOutboxResponse(messages: Message[]) {
|
||||
describe("MailboxView", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
sseSubscriptions.length = 0;
|
||||
mockUseViewportMode.mockReturnValue("desktop");
|
||||
mockUseMobileKeyboard.mockReturnValue({
|
||||
keyboardOverlap: 0,
|
||||
@@ -163,6 +180,7 @@ describe("MailboxView", () => {
|
||||
mockFetchUnreadCount.mockResolvedValue({ unreadCount: 2 });
|
||||
mockFetchAgents.mockResolvedValue(mockAgents);
|
||||
mockSendMessage.mockResolvedValue({ ...mockMessage, id: "msg-sent" });
|
||||
mockFetchApprovals.mockResolvedValue({ requests: [], total: 0, pendingCount: 0 });
|
||||
});
|
||||
|
||||
it("renders the mailbox view", async () => {
|
||||
@@ -192,7 +210,7 @@ describe("MailboxView", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("renders all three tabs", async () => {
|
||||
it("renders all four tabs", async () => {
|
||||
mockFetchInbox.mockResolvedValue({
|
||||
messages: [],
|
||||
unreadCount: 0,
|
||||
@@ -204,6 +222,210 @@ describe("MailboxView", () => {
|
||||
expect(screen.getByTestId("mailbox-tab-inbox")).toBeDefined();
|
||||
expect(screen.getByTestId("mailbox-tab-outbox")).toBeDefined();
|
||||
expect(screen.getByTestId("mailbox-tab-agents")).toBeDefined();
|
||||
expect(screen.getByTestId("mailbox-tab-approvals")).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows approvals pending badge and loads approvals tab", async () => {
|
||||
mockFetchInbox.mockResolvedValue({ messages: [], unreadCount: 0, total: 0 });
|
||||
mockFetchApprovals.mockResolvedValue({
|
||||
requests: [{
|
||||
id: "apr-1",
|
||||
status: "pending",
|
||||
actionCategory: "command_execution",
|
||||
actionSummary: "Run npm test",
|
||||
agentId: "agent-001",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}],
|
||||
total: 1,
|
||||
pendingCount: 2,
|
||||
});
|
||||
|
||||
render(<MailboxView {...defaultProps} />);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTestId("mailbox-tab-approvals"));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("mailbox-approvals-pending-badge")).toHaveTextContent("2");
|
||||
expect(screen.getByTestId("mailbox-approval-item-apr-1")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders approval detail metadata and history", async () => {
|
||||
const now = new Date().toISOString();
|
||||
mockFetchInbox.mockResolvedValue({ messages: [], unreadCount: 0, total: 0 });
|
||||
mockFetchApprovals.mockResolvedValue({
|
||||
requests: [{ id: "apr-1", status: "pending", actionCategory: "command_execution", actionSummary: "Run npm test", agentId: "agent-001", taskId: "FN-1", createdAt: now, updatedAt: now }],
|
||||
total: 1,
|
||||
pendingCount: 1,
|
||||
});
|
||||
mockFetchApprovalDetail.mockResolvedValue({
|
||||
id: "apr-1", status: "pending", actionCategory: "command_execution", actionSummary: "Run npm test", agentId: "agent-001", taskId: "FN-1", createdAt: now, updatedAt: now,
|
||||
requester: { actorId: "agent-001", actorType: "agent", actorName: "Agent 1" }, requestedAt: now,
|
||||
targetAction: { category: "command_execution", action: "bash", summary: "Run npm test", resourceType: "command", resourceId: "cmd" },
|
||||
history: [{ id: "evt-1", eventType: "created", actor: { actorId: "agent-001", actorType: "agent", actorName: "Agent 1" }, createdAt: now }],
|
||||
});
|
||||
|
||||
render(<MailboxView {...defaultProps} />);
|
||||
await act(async () => { fireEvent.click(screen.getByTestId("mailbox-tab-approvals")); });
|
||||
await act(async () => { fireEvent.click(await screen.findByTestId("mailbox-approval-item-apr-1")); });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("mailbox-approval-detail")).toBeDefined();
|
||||
expect(screen.getByText(/Requester: Agent 1/)).toBeDefined();
|
||||
expect(screen.getByText(/Task: FN-1/)).toBeDefined();
|
||||
expect(screen.getByTestId("mailbox-approval-history")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("allows approving a pending approval request", async () => {
|
||||
const now = new Date().toISOString();
|
||||
mockFetchInbox.mockResolvedValue({ messages: [], unreadCount: 0, total: 0 });
|
||||
mockFetchApprovals.mockResolvedValue({
|
||||
requests: [{ id: "apr-1", status: "pending", actionCategory: "command_execution", actionSummary: "Run npm test", agentId: "agent-001", createdAt: now, updatedAt: now }],
|
||||
total: 1,
|
||||
pendingCount: 1,
|
||||
});
|
||||
mockFetchApprovalDetail.mockResolvedValue({
|
||||
id: "apr-1",
|
||||
status: "pending",
|
||||
actionCategory: "command_execution",
|
||||
actionSummary: "Run npm test",
|
||||
agentId: "agent-001",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
requester: { actorId: "agent-001", actorType: "agent", actorName: "Agent 1" },
|
||||
requestedAt: now,
|
||||
targetAction: { category: "command_execution", action: "bash", summary: "Run npm test", resourceType: "command", resourceId: "cmd" },
|
||||
history: [{ id: "evt-1", eventType: "created", actor: { actorId: "agent-001", actorType: "agent", actorName: "Agent 1" }, createdAt: now }],
|
||||
});
|
||||
mockDecideApproval.mockResolvedValue({
|
||||
id: "apr-1",
|
||||
status: "approved",
|
||||
actionCategory: "command_execution",
|
||||
actionSummary: "Run npm test",
|
||||
agentId: "agent-001",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
requester: { actorId: "agent-001", actorType: "agent", actorName: "Agent 1" },
|
||||
requestedAt: now,
|
||||
targetAction: { category: "command_execution", action: "bash", summary: "Run npm test", resourceType: "command", resourceId: "cmd" },
|
||||
history: [{ id: "evt-2", eventType: "approved", actor: { actorId: "user", actorType: "user", actorName: "User" }, createdAt: now }],
|
||||
});
|
||||
|
||||
render(<MailboxView {...defaultProps} />);
|
||||
await act(async () => { fireEvent.click(screen.getByTestId("mailbox-tab-approvals")); });
|
||||
await act(async () => { fireEvent.click(await screen.findByTestId("mailbox-approval-item-apr-1")); });
|
||||
await act(async () => { fireEvent.click(await screen.findByTestId("mailbox-approval-approve")); });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDecideApproval).toHaveBeenCalledWith("apr-1", { decision: "approve", comment: undefined }, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
it("allows denying a pending approval request", async () => {
|
||||
const now = new Date().toISOString();
|
||||
mockFetchInbox.mockResolvedValue({ messages: [], unreadCount: 0, total: 0 });
|
||||
mockFetchApprovals.mockResolvedValue({ requests: [{ id: "apr-1", status: "pending", actionCategory: "command_execution", actionSummary: "Run npm test", agentId: "agent-001", createdAt: now, updatedAt: now }], total: 1, pendingCount: 1 });
|
||||
mockFetchApprovalDetail.mockResolvedValue({ id: "apr-1", status: "pending", actionCategory: "command_execution", actionSummary: "Run npm test", agentId: "agent-001", createdAt: now, updatedAt: now, requester: { actorId: "agent-001", actorType: "agent", actorName: "Agent 1" }, requestedAt: now, targetAction: { category: "command_execution", action: "bash", summary: "Run npm test", resourceType: "command", resourceId: "cmd" }, history: [] });
|
||||
mockDecideApproval.mockResolvedValue({ id: "apr-1", status: "denied", actionCategory: "command_execution", actionSummary: "Run npm test", agentId: "agent-001", createdAt: now, updatedAt: now, requester: { actorId: "agent-001", actorType: "agent", actorName: "Agent 1" }, requestedAt: now, targetAction: { category: "command_execution", action: "bash", summary: "Run npm test", resourceType: "command", resourceId: "cmd" }, history: [] });
|
||||
|
||||
render(<MailboxView {...defaultProps} />);
|
||||
await act(async () => { fireEvent.click(screen.getByTestId("mailbox-tab-approvals")); });
|
||||
await act(async () => { fireEvent.click(await screen.findByTestId("mailbox-approval-item-apr-1")); });
|
||||
await act(async () => { fireEvent.click(await screen.findByTestId("mailbox-approval-deny")); });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDecideApproval).toHaveBeenCalledWith("apr-1", { decision: "deny", comment: undefined }, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
it("disables decision buttons while submission is pending", async () => {
|
||||
const now = new Date().toISOString();
|
||||
let resolveDecision: (() => void) | undefined;
|
||||
mockFetchInbox.mockResolvedValue({ messages: [], unreadCount: 0, total: 0 });
|
||||
mockFetchApprovals.mockResolvedValue({ requests: [{ id: "apr-1", status: "pending", actionCategory: "command_execution", actionSummary: "Run npm test", agentId: "agent-001", createdAt: now, updatedAt: now }], total: 1, pendingCount: 1 });
|
||||
mockFetchApprovalDetail.mockResolvedValue({ id: "apr-1", status: "pending", actionCategory: "command_execution", actionSummary: "Run npm test", agentId: "agent-001", createdAt: now, updatedAt: now, requester: { actorId: "agent-001", actorType: "agent", actorName: "Agent 1" }, requestedAt: now, targetAction: { category: "command_execution", action: "bash", summary: "Run npm test", resourceType: "command", resourceId: "cmd" }, history: [] });
|
||||
mockDecideApproval.mockImplementation(() => new Promise((resolve) => { resolveDecision = () => resolve({} as any); }));
|
||||
|
||||
render(<MailboxView {...defaultProps} />);
|
||||
await act(async () => { fireEvent.click(screen.getByTestId("mailbox-tab-approvals")); });
|
||||
await act(async () => { fireEvent.click(await screen.findByTestId("mailbox-approval-item-apr-1")); });
|
||||
await act(async () => { fireEvent.click(await screen.findByTestId("mailbox-approval-approve")); });
|
||||
|
||||
expect(screen.getByTestId("mailbox-approval-approve")).toBeDisabled();
|
||||
expect(screen.getByTestId("mailbox-approval-deny")).toBeDisabled();
|
||||
expect(mockDecideApproval).toHaveBeenCalledTimes(1);
|
||||
resolveDecision?.();
|
||||
});
|
||||
|
||||
it("uses mobile stacked layout for approvals detail and back navigation", async () => {
|
||||
const now = new Date().toISOString();
|
||||
mockUseViewportMode.mockReturnValue("mobile");
|
||||
mockFetchInbox.mockResolvedValue({ messages: [], unreadCount: 0, total: 0 });
|
||||
mockFetchApprovals.mockResolvedValue({ requests: [{ id: "apr-1", status: "pending", actionCategory: "command_execution", actionSummary: "Run npm test", agentId: "agent-001", createdAt: now, updatedAt: now }], total: 1, pendingCount: 1 });
|
||||
mockFetchApprovalDetail.mockResolvedValue({ id: "apr-1", status: "pending", actionCategory: "command_execution", actionSummary: "Run npm test", agentId: "agent-001", createdAt: now, updatedAt: now, requester: { actorId: "agent-001", actorType: "agent", actorName: "Agent 1" }, requestedAt: now, targetAction: { category: "command_execution", action: "bash", summary: "Run npm test", resourceType: "command", resourceId: "cmd" }, history: [] });
|
||||
|
||||
render(<MailboxView {...defaultProps} />);
|
||||
await act(async () => { fireEvent.click(screen.getByTestId("mailbox-tab-approvals")); });
|
||||
await act(async () => { fireEvent.click(await screen.findByTestId("mailbox-approval-item-apr-1")); });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("mailbox-approval-detail")).toBeDefined();
|
||||
expect(screen.queryByTestId("mailbox-approval-list")).toBeNull();
|
||||
expect(screen.getByTestId("mailbox-approval-back-to-list")).toBeDefined();
|
||||
});
|
||||
|
||||
await act(async () => { fireEvent.click(screen.getByTestId("mailbox-approval-back-to-list")); });
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("mailbox-approval-list")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("refreshes approvals on approval SSE events", async () => {
|
||||
const now = new Date().toISOString();
|
||||
mockFetchInbox.mockResolvedValue({ messages: [], unreadCount: 0, total: 0 });
|
||||
mockFetchApprovals.mockResolvedValue({ requests: [{ id: "apr-1", status: "pending", actionCategory: "command_execution", actionSummary: "Run npm test", agentId: "agent-001", createdAt: now, updatedAt: now }], total: 1, pendingCount: 1 });
|
||||
|
||||
render(<MailboxView {...defaultProps} />);
|
||||
await act(async () => { fireEvent.click(screen.getByTestId("mailbox-tab-approvals")); });
|
||||
|
||||
const latest = sseSubscriptions.at(-1);
|
||||
expect(latest).toBeDefined();
|
||||
await act(async () => {
|
||||
latest?.["approval:requested"]?.();
|
||||
latest?.["approval:updated"]?.();
|
||||
latest?.["approval:decided"]?.();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchApprovals).toHaveBeenCalled();
|
||||
expect(mockSubscribeSse).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("moves decided requests into history view", async () => {
|
||||
const now = new Date().toISOString();
|
||||
mockFetchInbox.mockResolvedValue({ messages: [], unreadCount: 0, total: 0 });
|
||||
mockFetchApprovals
|
||||
.mockResolvedValueOnce({ requests: [{ id: "apr-1", status: "pending", actionCategory: "command_execution", actionSummary: "Run npm test", agentId: "agent-001", createdAt: now, updatedAt: now }], total: 1, pendingCount: 1 })
|
||||
.mockResolvedValueOnce({ requests: [], total: 0, pendingCount: 0 })
|
||||
.mockResolvedValueOnce({ requests: [{ id: "apr-1", status: "approved", actionCategory: "command_execution", actionSummary: "Run npm test", agentId: "agent-001", createdAt: now, updatedAt: now }], total: 1, pendingCount: 0 })
|
||||
.mockResolvedValue({ requests: [], total: 0, pendingCount: 0 });
|
||||
mockFetchApprovalDetail.mockResolvedValue({ id: "apr-1", status: "pending", actionCategory: "command_execution", actionSummary: "Run npm test", agentId: "agent-001", createdAt: now, updatedAt: now, requester: { actorId: "agent-001", actorType: "agent", actorName: "Agent 1" }, requestedAt: now, targetAction: { category: "command_execution", action: "bash", summary: "Run npm test", resourceType: "command", resourceId: "cmd" }, history: [] });
|
||||
mockDecideApproval.mockResolvedValue({ id: "apr-1", status: "approved", actionCategory: "command_execution", actionSummary: "Run npm test", agentId: "agent-001", createdAt: now, updatedAt: now, requester: { actorId: "agent-001", actorType: "agent", actorName: "Agent 1" }, requestedAt: now, targetAction: { category: "command_execution", action: "bash", summary: "Run npm test", resourceType: "command", resourceId: "cmd" }, history: [] });
|
||||
|
||||
render(<MailboxView {...defaultProps} />);
|
||||
await act(async () => { fireEvent.click(screen.getByTestId("mailbox-tab-approvals")); });
|
||||
await act(async () => { fireEvent.click(await screen.findByTestId("mailbox-approval-item-apr-1")); });
|
||||
await act(async () => { fireEvent.click(await screen.findByTestId("mailbox-approval-approve")); });
|
||||
await act(async () => { fireEvent.click(screen.getByTestId("mailbox-approval-filter-history")); });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchApprovals).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows inbox tab as active by default", async () => {
|
||||
|
||||
@@ -14,19 +14,27 @@ class MockApprovalRequestStore {
|
||||
list(input: any = {}) {
|
||||
let rows = [...state.requests.values()];
|
||||
if (input.status) rows = rows.filter((r) => r.status === input.status);
|
||||
if (input.requesterActorId) rows = rows.filter((r) => r.requester.actorId === input.requesterActorId);
|
||||
if (input.taskId) rows = rows.filter((r) => r.taskId === input.taskId);
|
||||
return rows;
|
||||
const offset = input.offset ?? 0;
|
||||
const limit = input.limit ?? rows.length;
|
||||
return rows.slice(offset, offset + limit);
|
||||
}
|
||||
get(id: string) {
|
||||
return state.requests.get(id) ?? null;
|
||||
}
|
||||
decide(id: string, status: "approved" | "denied") {
|
||||
decide(id: string, status: "approved" | "denied", input?: { actor?: any; note?: string }) {
|
||||
const req = state.requests.get(id);
|
||||
if (!req) throw new Error("Approval request not found");
|
||||
if (req.status !== "pending") throw new Error(`Invalid approval request transition: ${req.status} -> ${status}`);
|
||||
req.status = status;
|
||||
state.audits.set(id, [...(state.audits.get(id) ?? []), { event: status }]);
|
||||
req.decidedAt = new Date().toISOString();
|
||||
req.updatedAt = req.decidedAt;
|
||||
state.audits.set(id, [...(state.audits.get(id) ?? []), {
|
||||
id: `evt-${status}`,
|
||||
eventType: status,
|
||||
actor: input?.actor ?? { actorId: "user", actorType: "user", actorName: "User" },
|
||||
note: input?.note,
|
||||
createdAt: req.decidedAt,
|
||||
}]);
|
||||
return req;
|
||||
}
|
||||
getAuditHistory(id: string) {
|
||||
@@ -92,62 +100,106 @@ describe("approval routes", async () => {
|
||||
|
||||
beforeEach(() => {
|
||||
updateAgent.mockClear();
|
||||
const now = new Date().toISOString();
|
||||
state.task = { id: "FN-1", paused: true, pausedByAgentId: "agent-1" };
|
||||
state.agent = { id: "agent-1", state: "paused", pauseReason: "awaiting-approval" };
|
||||
state.requests = new Map([
|
||||
["apr-1", { id: "apr-1", status: "pending", requester: { actorId: "agent-1" }, taskId: "FN-1" }],
|
||||
["apr-2", { id: "apr-2", status: "denied", requester: { actorId: "agent-1" }, taskId: "FN-1" }],
|
||||
["apr-1", {
|
||||
id: "apr-1",
|
||||
status: "pending",
|
||||
requester: { actorId: "agent-1", actorType: "agent", actorName: "Agent 1" },
|
||||
targetAction: { category: "command_execution", summary: "Run command", action: "bash", resourceType: "command", resourceId: "cmd-1" },
|
||||
taskId: "FN-1",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
requestedAt: now,
|
||||
}],
|
||||
["apr-2", {
|
||||
id: "apr-2",
|
||||
status: "denied",
|
||||
requester: { actorId: "agent-1", actorType: "agent", actorName: "Agent 1" },
|
||||
targetAction: { category: "network_api", summary: "Fetch URL", action: "web_fetch", resourceType: "url", resourceId: "https://example.com" },
|
||||
taskId: "FN-1",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
requestedAt: now,
|
||||
}],
|
||||
]);
|
||||
state.audits = new Map([
|
||||
["apr-1", [{ id: "evt-created", eventType: "created", actor: { actorId: "agent-1", actorType: "agent", actorName: "Agent 1" }, createdAt: now }]],
|
||||
["apr-2", [{ id: "evt-denied", eventType: "denied", actor: { actorId: "dashboard", actorType: "user", actorName: "User" }, createdAt: now }]],
|
||||
]);
|
||||
state.audits = new Map([["apr-1", [{ event: "created" }]]]);
|
||||
});
|
||||
|
||||
it("lists and filters requests", async () => {
|
||||
it("lists with status filtering and pendingCount", async () => {
|
||||
const app = createApp();
|
||||
const res = await get(app, "/api/approval-requests?status=pending");
|
||||
const res = await get(app, "/api/approvals?status=pending");
|
||||
expect(res.status).toBe(200);
|
||||
expect((res.body as any[]).map((r) => r.id)).toEqual(["apr-1"]);
|
||||
expect(res.body.total).toBe(1);
|
||||
expect(res.body.pendingCount).toBe(1);
|
||||
expect(res.body.requests).toHaveLength(1);
|
||||
expect(res.body.requests[0]).toMatchObject({
|
||||
id: "apr-1",
|
||||
actionCategory: "command_execution",
|
||||
actionSummary: "Run command",
|
||||
agentId: "agent-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns detail with history", async () => {
|
||||
const app = createApp();
|
||||
const res = await get(app, "/api/approvals/apr-1");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.id).toBe("apr-1");
|
||||
expect(res.body.history).toHaveLength(1);
|
||||
expect(res.body.targetAction.summary).toBe("Run command");
|
||||
});
|
||||
|
||||
it("returns 404 for missing request", async () => {
|
||||
const app = createApp();
|
||||
const res = await get(app, "/api/approval-requests/missing");
|
||||
const res = await get(app, "/api/approvals/missing");
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns audit history", async () => {
|
||||
it("decides approval and unpauses task/agent", async () => {
|
||||
const app = createApp();
|
||||
const res = await get(app, "/api/approval-requests/apr-1/audit");
|
||||
const res = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/approvals/apr-1/decision",
|
||||
JSON.stringify({ decision: "approve", comment: "looks good" }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual([{ event: "created" }]);
|
||||
});
|
||||
|
||||
it("approves and unpauses task/agent", async () => {
|
||||
const app = createApp();
|
||||
const res = await request(app, "POST", "/api/approval-requests/apr-1/approve", JSON.stringify({}));
|
||||
expect(res.status).toBe(200);
|
||||
expect((res.body as any).status).toBe("approved");
|
||||
expect(res.body.status).toBe("approved");
|
||||
expect(res.body.history.at(-1)?.eventType).toBe("approved");
|
||||
expect(res.body.history.at(-1)?.note).toBe("looks good");
|
||||
expect(state.task.paused).toBe(false);
|
||||
expect(updateAgent).toHaveBeenCalledWith("agent-1", { pauseReason: undefined });
|
||||
});
|
||||
|
||||
it("denies and unpauses task/agent", async () => {
|
||||
it("supports deny decision", async () => {
|
||||
const app = createApp();
|
||||
const res = await request(app, "POST", "/api/approval-requests/apr-1/deny", JSON.stringify({}));
|
||||
expect(res.status).toBe(200);
|
||||
expect((res.body as any).status).toBe("denied");
|
||||
expect(state.task.paused).toBe(false);
|
||||
});
|
||||
|
||||
it("no-ops when task already unpaused", async () => {
|
||||
state.task.paused = false;
|
||||
const app = createApp();
|
||||
const res = await request(app, "POST", "/api/approval-requests/apr-1/deny", JSON.stringify({}));
|
||||
const res = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/approvals/apr-1/decision",
|
||||
JSON.stringify({ decision: "deny" }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.status).toBe("denied");
|
||||
});
|
||||
|
||||
it("returns 409 for invalid transition", async () => {
|
||||
const app = createApp();
|
||||
const res = await request(app, "POST", "/api/approval-requests/apr-2/approve", JSON.stringify({}));
|
||||
const res = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/approvals/apr-2/decision",
|
||||
JSON.stringify({ decision: "approve" }),
|
||||
{ "content-type": "application/json" },
|
||||
);
|
||||
expect(res.status).toBe(409);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { AgentStore, ApprovalRequestStore, type ApprovalRequestActorSnapshot, type ApprovalRequestStatus } from "@fusion/core";
|
||||
import { ApiError, badRequest, conflict, notFound } from "../api-error.js";
|
||||
import type { ApiRoutesContext } from "./types.js";
|
||||
import { emitApprovalSseEvent } from "../sse.js";
|
||||
|
||||
const DEFAULT_ACTOR: ApprovalRequestActorSnapshot = {
|
||||
actorId: "user",
|
||||
@@ -8,10 +9,39 @@ const DEFAULT_ACTOR: ApprovalRequestActorSnapshot = {
|
||||
actorName: "User",
|
||||
};
|
||||
|
||||
function parseOptionalString(value: unknown, field: string): string | undefined {
|
||||
if (value === undefined || value === null || value === "") return undefined;
|
||||
if (typeof value !== "string") throw badRequest(`${field} must be a string`);
|
||||
return value;
|
||||
interface ApprovalRequestSummaryDto {
|
||||
id: string;
|
||||
status: ApprovalRequestStatus;
|
||||
actionCategory: string;
|
||||
actionSummary: string;
|
||||
agentId: string;
|
||||
taskId?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
decidedAt?: string;
|
||||
decidedBy?: string;
|
||||
}
|
||||
|
||||
interface ApprovalRequestDetailDto extends ApprovalRequestSummaryDto {
|
||||
requester: ApprovalRequestActorSnapshot;
|
||||
runId?: string;
|
||||
requestedAt: string;
|
||||
completedAt?: string;
|
||||
targetAction: {
|
||||
category: string;
|
||||
action: string;
|
||||
summary: string;
|
||||
resourceType: string;
|
||||
resourceId: string;
|
||||
context?: Record<string, unknown>;
|
||||
};
|
||||
history: Array<{
|
||||
id: string;
|
||||
eventType: string;
|
||||
actor: ApprovalRequestActorSnapshot;
|
||||
note?: string;
|
||||
createdAt: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
function parseOptionalInt(value: unknown, field: string): number | undefined {
|
||||
@@ -27,6 +57,46 @@ function parseStatus(value: unknown): ApprovalRequestStatus | undefined {
|
||||
throw badRequest("status must be one of: pending, approved, denied, completed");
|
||||
}
|
||||
|
||||
function getDeciderActorId(
|
||||
history: Array<{ eventType: string; actor: ApprovalRequestActorSnapshot }>,
|
||||
): string | undefined {
|
||||
const decisionEvent = [...history].reverse().find((entry) => entry.eventType === "approved" || entry.eventType === "denied");
|
||||
return decisionEvent?.actor.actorId;
|
||||
}
|
||||
|
||||
function toSummaryDto(
|
||||
request: import("@fusion/core").ApprovalRequest,
|
||||
history: Array<{ eventType: string; actor: ApprovalRequestActorSnapshot }>,
|
||||
): ApprovalRequestSummaryDto {
|
||||
return {
|
||||
id: request.id,
|
||||
status: request.status,
|
||||
actionCategory: request.targetAction.category,
|
||||
actionSummary: request.targetAction.summary,
|
||||
agentId: request.requester.actorId,
|
||||
taskId: request.taskId,
|
||||
createdAt: request.createdAt,
|
||||
updatedAt: request.updatedAt,
|
||||
decidedAt: request.decidedAt,
|
||||
decidedBy: getDeciderActorId(history),
|
||||
};
|
||||
}
|
||||
|
||||
function toDetailDto(
|
||||
request: import("@fusion/core").ApprovalRequest,
|
||||
history: import("@fusion/core").ApprovalRequestAuditEvent[],
|
||||
): ApprovalRequestDetailDto {
|
||||
return {
|
||||
...toSummaryDto(request, history),
|
||||
requester: request.requester,
|
||||
runId: request.runId,
|
||||
requestedAt: request.requestedAt,
|
||||
completedAt: request.completedAt,
|
||||
targetAction: request.targetAction,
|
||||
history,
|
||||
};
|
||||
}
|
||||
|
||||
async function resumeAfterDecision(params: {
|
||||
scopedStore: import("@fusion/core").TaskStore;
|
||||
request: import("@fusion/core").ApprovalRequest;
|
||||
@@ -69,57 +139,55 @@ async function resumeAfterDecision(params: {
|
||||
export function registerApprovalRoutes(ctx: ApiRoutesContext): void {
|
||||
const { router, getProjectContext, rethrowAsApiError, runtimeLogger } = ctx;
|
||||
|
||||
router.get("/approval-requests", async (req, res) => {
|
||||
router.get("/approvals", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const approvalStore = new ApprovalRequestStore(scopedStore.getDatabase());
|
||||
const requests = approvalStore.list({
|
||||
status: parseStatus(req.query.status),
|
||||
requesterActorId: parseOptionalString(req.query.requesterActorId, "requesterActorId"),
|
||||
taskId: parseOptionalString(req.query.taskId, "taskId"),
|
||||
runId: parseOptionalString(req.query.runId, "runId"),
|
||||
limit: parseOptionalInt(req.query.limit, "limit"),
|
||||
offset: parseOptionalInt(req.query.offset, "offset"),
|
||||
const status = parseStatus(req.query.status);
|
||||
const limit = parseOptionalInt(req.query.limit, "limit") ?? 50;
|
||||
const offset = parseOptionalInt(req.query.offset, "offset") ?? 0;
|
||||
|
||||
const requests = approvalStore.list({ status, limit, offset });
|
||||
const summaries = requests.map((request) => {
|
||||
const history = approvalStore.getAuditHistory(request.id);
|
||||
return toSummaryDto(request, history);
|
||||
});
|
||||
res.json(requests);
|
||||
const total = approvalStore.list({ status, limit: Number.MAX_SAFE_INTEGER, offset: 0 }).length;
|
||||
const pendingCount = approvalStore.list({ status: "pending", limit: Number.MAX_SAFE_INTEGER, offset: 0 }).length;
|
||||
|
||||
res.json({ requests: summaries, total, pendingCount });
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/approval-requests/:id", async (req, res) => {
|
||||
router.get("/approvals/:id", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const approvalStore = new ApprovalRequestStore(scopedStore.getDatabase());
|
||||
const requestId = String(req.params.id);
|
||||
const request = approvalStore.get(requestId);
|
||||
if (!request) throw notFound("Approval request not found");
|
||||
res.json(request);
|
||||
const history = approvalStore.getAuditHistory(requestId);
|
||||
res.json(toDetailDto(request, history));
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/approval-requests/:id/audit", async (req, res) => {
|
||||
router.post("/approvals/:id/decision", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const approvalStore = new ApprovalRequestStore(scopedStore.getDatabase());
|
||||
const requestId = String(req.params.id);
|
||||
const request = approvalStore.get(requestId);
|
||||
if (!request) throw notFound("Approval request not found");
|
||||
res.json(approvalStore.getAuditHistory(requestId));
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
const body = (req.body ?? {}) as { decision?: "approve" | "deny"; comment?: string; actor?: ApprovalRequestActorSnapshot };
|
||||
if (body.decision !== "approve" && body.decision !== "deny") {
|
||||
throw badRequest("decision must be one of: approve, deny");
|
||||
}
|
||||
if (body.comment !== undefined && typeof body.comment !== "string") {
|
||||
throw badRequest("comment must be a string");
|
||||
}
|
||||
|
||||
const decideHandler = (status: "approved" | "denied") => async (req: import("express").Request, res: import("express").Response) => {
|
||||
try {
|
||||
const body = (req.body ?? {}) as { actor?: ApprovalRequestActorSnapshot; note?: string };
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const { store: scopedStore, projectId } = await getProjectContext(req);
|
||||
const approvalStore = new ApprovalRequestStore(scopedStore.getDatabase());
|
||||
const requestId = String(req.params.id);
|
||||
const existing = approvalStore.get(requestId);
|
||||
@@ -129,13 +197,11 @@ export function registerApprovalRoutes(ctx: ApiRoutesContext): void {
|
||||
if (!actor || typeof actor.actorId !== "string" || typeof actor.actorType !== "string" || typeof actor.actorName !== "string") {
|
||||
throw badRequest("actor must include actorId, actorType, and actorName");
|
||||
}
|
||||
if (body.note !== undefined && typeof body.note !== "string") {
|
||||
throw badRequest("note must be a string");
|
||||
}
|
||||
|
||||
const targetStatus = body.decision === "approve" ? "approved" : "denied";
|
||||
let updated;
|
||||
try {
|
||||
updated = approvalStore.decide(requestId, status, { actor, note: body.note });
|
||||
updated = approvalStore.decide(requestId, targetStatus, { actor, note: body.comment });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (message.includes("Invalid approval request transition")) {
|
||||
@@ -145,13 +211,14 @@ export function registerApprovalRoutes(ctx: ApiRoutesContext): void {
|
||||
}
|
||||
|
||||
await resumeAfterDecision({ scopedStore, request: updated, runtimeLogger });
|
||||
res.json(updated);
|
||||
const history = approvalStore.getAuditHistory(requestId);
|
||||
const detail = toDetailDto(updated, history);
|
||||
emitApprovalSseEvent("approval:updated", detail, projectId);
|
||||
emitApprovalSseEvent("approval:decided", detail, projectId);
|
||||
res.json(detail);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
};
|
||||
|
||||
router.post("/approval-requests/:id/approve", decideHandler("approved"));
|
||||
router.post("/approval-requests/:id/deny", decideHandler("denied"));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -215,6 +215,18 @@ export type MessageSseEventType =
|
||||
| "message:read"
|
||||
| "message:deleted";
|
||||
|
||||
export type ApprovalSseEventType = "approval:requested" | "approval:updated" | "approval:decided";
|
||||
|
||||
type ApprovalSseListener = (event: ApprovalSseEventType, payload: unknown, projectId?: string) => void;
|
||||
|
||||
const approvalSseListeners = new Set<ApprovalSseListener>();
|
||||
|
||||
export function emitApprovalSseEvent(event: ApprovalSseEventType, payload: unknown, projectId?: string): void {
|
||||
for (const listener of approvalSseListeners) {
|
||||
listener(event, payload, projectId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalized plugin lifecycle payload emitted via SSE.
|
||||
* This is the stable contract the UI can reconcile.
|
||||
@@ -556,6 +568,11 @@ export function createSSE(
|
||||
send(`event: message:deleted\ndata: ${JSON.stringify({ id: messageId })}\n\n`);
|
||||
};
|
||||
|
||||
const onApprovalEvent: ApprovalSseListener = (event, payload, eventProjectId) => {
|
||||
if (projectId && eventProjectId && eventProjectId !== projectId) return;
|
||||
send(`event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`);
|
||||
};
|
||||
|
||||
// --- Chat store event handlers ---
|
||||
const onChatSessionCreated = (session: unknown) => {
|
||||
send(`event: chat:session:created\ndata: ${JSON.stringify(session)}\n\n`);
|
||||
@@ -671,6 +688,7 @@ export function createSSE(
|
||||
messageStore.off("message:read", onMessageRead);
|
||||
messageStore.off("message:deleted", onMessageDeleted);
|
||||
}
|
||||
approvalSseListeners.delete(onApprovalEvent);
|
||||
if (chatStore) {
|
||||
chatStore.off("chat:session:created", onChatSessionCreated);
|
||||
chatStore.off("chat:session:updated", onChatSessionUpdated);
|
||||
@@ -799,6 +817,8 @@ export function createSSE(
|
||||
// Sent as a named event so the client's EventSource can detect it
|
||||
// (SSE comments starting with ":" are silently consumed and never
|
||||
// fire event listeners in the browser).
|
||||
approvalSseListeners.add(onApprovalEvent);
|
||||
|
||||
registerManagedConnection({
|
||||
id: connectionId,
|
||||
clientId,
|
||||
|
||||
Reference in New Issue
Block a user