feat(FN-3550): add pending approval badges to agent views and header
Added pending-approval badge indicators across the agent detail view, agents overview, mailbox, header, and mobile nav, wired through new and existing agent/mailbox APIs, with test coverage and documentation updates. Fusion-Task-Id: FN-3550
This commit is contained in:
@@ -4175,6 +4175,8 @@ export interface Agent {
|
||||
totalOutputTokens?: number;
|
||||
/** Last error message */
|
||||
lastError?: string;
|
||||
/** Number of currently pending approvals requested by this agent. */
|
||||
pendingApprovalCount?: number;
|
||||
/** Path to a markdown file containing custom instructions (resolved relative to project root).
|
||||
* Must end in `.md`, no `..` traversal. Max 500 chars. */
|
||||
instructionsPath?: string;
|
||||
|
||||
@@ -383,12 +383,14 @@ function AppInner() {
|
||||
|
||||
// App-level mailbox/chat unread state (used for header/mobile nav badges)
|
||||
const [mailboxUnreadCount, setMailboxUnreadCount] = useState(0);
|
||||
const [mailboxPendingApprovalCount, setMailboxPendingApprovalCount] = useState(0);
|
||||
const [chatHasUnreadResponse, setChatHasUnreadResponse] = useState(false);
|
||||
|
||||
const refreshMailboxUnreadCount = useCallback(() => {
|
||||
fetchUnreadCount(currentProject?.id)
|
||||
.then((data: { unreadCount: number }) => {
|
||||
.then((data: { unreadCount: number; pendingApprovalCount?: number }) => {
|
||||
setMailboxUnreadCount(data.unreadCount);
|
||||
setMailboxPendingApprovalCount(data.pendingApprovalCount ?? 0);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn("[App] Failed to fetch mailbox unread count:", err);
|
||||
@@ -411,6 +413,9 @@ function AppInner() {
|
||||
"message:received": refreshMailboxUnreadCount,
|
||||
"message:read": refreshMailboxUnreadCount,
|
||||
"message:deleted": refreshMailboxUnreadCount,
|
||||
"approval:requested": refreshMailboxUnreadCount,
|
||||
"approval:updated": refreshMailboxUnreadCount,
|
||||
"approval:decided": refreshMailboxUnreadCount,
|
||||
},
|
||||
});
|
||||
}, [currentProject?.id, refreshMailboxUnreadCount]);
|
||||
@@ -1336,6 +1341,7 @@ function AppInner() {
|
||||
onOpenSystemStats={openSystemStatsWithNav}
|
||||
onOpenMailbox={() => handleTaskViewChange("mailbox")}
|
||||
mailboxUnreadCount={mailboxUnreadCount}
|
||||
mailboxPendingApprovalCount={mailboxPendingApprovalCount}
|
||||
chatHasUnreadResponse={chatHasUnreadResponse}
|
||||
onOpenSchedules={openSchedulesWithNav}
|
||||
onOpenGitManager={openGitManagerWithNav}
|
||||
@@ -1470,6 +1476,7 @@ function AppInner() {
|
||||
onOpenMailbox={() => handleTaskViewChange("mailbox")}
|
||||
onOpenNodes={handleOpenNodesWithNav}
|
||||
mailboxUnreadCount={mailboxUnreadCount}
|
||||
mailboxPendingApprovalCount={mailboxPendingApprovalCount}
|
||||
chatHasUnreadResponse={chatHasUnreadResponse}
|
||||
onOpenGitManager={openGitManagerWithNav}
|
||||
onOpenWorkflowSteps={openWorkflowStepsWithNav}
|
||||
|
||||
@@ -7571,6 +7571,7 @@ export interface OutboxResponse {
|
||||
/** Response shape for GET /messages/unread-count */
|
||||
export interface UnreadCountResponse {
|
||||
unreadCount: number;
|
||||
pendingApprovalCount?: number;
|
||||
}
|
||||
|
||||
/** Response shape for POST /messages/read-all */
|
||||
|
||||
@@ -304,6 +304,13 @@
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.agent-detail-approval-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.dashboard-summary-skills {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -355,24 +355,37 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
|
||||
const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
|
||||
const contextVersionAtStart = contextVersionRef.current;
|
||||
|
||||
const refreshAgentForApprovalEvent = (event: MessageEvent) => {
|
||||
if (contextVersionRef.current !== contextVersionAtStart) return;
|
||||
try {
|
||||
const payload: unknown = JSON.parse(event.data);
|
||||
if (!payload || typeof payload !== "object") return;
|
||||
const approvalAgentId = (payload as { agentId?: unknown }).agentId;
|
||||
if (approvalAgentId !== agentId) return;
|
||||
void loadAgent();
|
||||
} catch {
|
||||
// Ignore malformed events
|
||||
}
|
||||
};
|
||||
|
||||
return subscribeSse(`/api/events${query}`, {
|
||||
events: {
|
||||
"agent:updated": (event) => {
|
||||
if (contextVersionRef.current !== contextVersionAtStart) return;
|
||||
|
||||
try {
|
||||
const payload: unknown = JSON.parse(event.data);
|
||||
if (!payload || typeof payload !== "object") return;
|
||||
|
||||
const updatedId = (payload as { id?: unknown }).id;
|
||||
if (updatedId !== agentId) return;
|
||||
if (hasConfigChangesRef.current) return;
|
||||
|
||||
void loadAgent();
|
||||
} catch {
|
||||
// Ignore malformed events
|
||||
}
|
||||
},
|
||||
"approval:requested": refreshAgentForApprovalEvent,
|
||||
"approval:updated": refreshAgentForApprovalEvent,
|
||||
"approval:decided": refreshAgentForApprovalEvent,
|
||||
},
|
||||
});
|
||||
}, [agentId, projectId, loadAgent]);
|
||||
@@ -962,6 +975,12 @@ function DashboardTab({
|
||||
</div>
|
||||
<div className="dashboard-summary-hero__meta">
|
||||
<span className="dashboard-summary-hero__health" title={health.reason ?? health.label}>{health.icon} {health.label}</span>
|
||||
{(agent.pendingApprovalCount ?? 0) > 0 ? (
|
||||
<span className="badge agent-detail-approval-badge" title="Pending approvals">
|
||||
<span className="status-dot status-dot--pending" />
|
||||
{agent.pendingApprovalCount} pending approvals
|
||||
</span>
|
||||
) : null}
|
||||
<span>Role: {agent.role}</span>
|
||||
<span>
|
||||
<span className="dashboard-summary-label">{runtimeHint ? "Runtime" : "Model"}</span>
|
||||
|
||||
@@ -574,6 +574,13 @@
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.agent-approval-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.agent-badges .badge-skill {
|
||||
min-width: 0;
|
||||
max-width: min(100%, calc(var(--space-2xl) * 6));
|
||||
|
||||
@@ -1099,6 +1099,12 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
<span className="agent-board-icon"><AgentAvatar agent={agent} size={20} /></span>
|
||||
<span className="agent-board-badge badge text-secondary">{getRoleLabel(agent.role)}</span>
|
||||
<span className={`agent-board-badge badge ${stateBadgeClass}`}>{agent.state}</span>
|
||||
{(agent.pendingApprovalCount ?? 0) > 0 ? (
|
||||
<span className="agent-board-badge badge agent-approval-badge" title="Pending approvals">
|
||||
<span className="status-dot status-dot--pending" />
|
||||
{agent.pendingApprovalCount}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="agent-board-name">{agent.name}</div>
|
||||
<div className="agent-board-id">{agent.id}</div>
|
||||
@@ -1213,6 +1219,12 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
<span className="badge text-secondary">
|
||||
{getRoleLabel(agent.role)}
|
||||
</span>
|
||||
{(agent.pendingApprovalCount ?? 0) > 0 ? (
|
||||
<span className="badge agent-approval-badge" title="Pending approvals">
|
||||
<span className="status-dot status-dot--pending" />
|
||||
{agent.pendingApprovalCount}
|
||||
</span>
|
||||
) : null}
|
||||
{/* List view: up to 2 skill badges */}
|
||||
{(() => {
|
||||
const skills = getSkillBadges(agent);
|
||||
|
||||
@@ -184,6 +184,8 @@ export interface HeaderProps {
|
||||
onOpenMailbox?: () => void;
|
||||
/** Unread message count for badge display */
|
||||
mailboxUnreadCount?: number;
|
||||
/** Pending approval count for mailbox indicator */
|
||||
mailboxPendingApprovalCount?: number;
|
||||
/** Whether chat has an unread assistant response */
|
||||
chatHasUnreadResponse?: boolean;
|
||||
onOpenSchedules?: () => void;
|
||||
@@ -253,6 +255,7 @@ export function Header({
|
||||
onOpenSystemStats,
|
||||
onOpenMailbox,
|
||||
mailboxUnreadCount = 0,
|
||||
mailboxPendingApprovalCount = 0,
|
||||
chatHasUnreadResponse = false,
|
||||
onOpenSchedules,
|
||||
onOpenGitManager,
|
||||
@@ -1148,6 +1151,9 @@ export function Header({
|
||||
aria-pressed={view === "mailbox"}
|
||||
>
|
||||
<Mail size={16} />
|
||||
{mailboxPendingApprovalCount > 0 && view !== "mailbox" && (
|
||||
<span className="status-dot status-dot--pending header-chat-unread-dot" aria-label="Pending approvals" />
|
||||
)}
|
||||
</button>
|
||||
{pluginDashboardViews
|
||||
.filter((entry) => entry.view.placement === "primary")
|
||||
@@ -1834,6 +1840,9 @@ export function Header({
|
||||
>
|
||||
<Mail size={16} />
|
||||
<span>Mailbox{mailboxUnreadCount > 0 ? ` (${mailboxUnreadCount})` : ""}</span>
|
||||
{mailboxPendingApprovalCount > 0 && (
|
||||
<span className="header-badge" data-testid="overflow-mailbox-approval-badge">{mailboxPendingApprovalCount}</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{/* Usage - in overflow on mobile */}
|
||||
|
||||
@@ -53,6 +53,7 @@ export interface MobileNavBarProps {
|
||||
onOpenSystemStats?: () => void;
|
||||
onOpenMailbox?: () => void;
|
||||
mailboxUnreadCount?: number;
|
||||
mailboxPendingApprovalCount?: number;
|
||||
chatHasUnreadResponse?: boolean;
|
||||
onOpenGitManager?: () => void;
|
||||
onOpenWorkflowSteps?: () => void;
|
||||
@@ -117,6 +118,7 @@ export function MobileNavBar({
|
||||
onOpenSystemStats,
|
||||
onOpenMailbox,
|
||||
mailboxUnreadCount = 0,
|
||||
mailboxPendingApprovalCount = 0,
|
||||
chatHasUnreadResponse = false,
|
||||
onOpenGitManager,
|
||||
onOpenWorkflowSteps,
|
||||
@@ -311,7 +313,12 @@ export function MobileNavBar({
|
||||
aria-selected={view === "mailbox"}
|
||||
onClick={() => onChangeView("mailbox")}
|
||||
>
|
||||
<Mail />
|
||||
<span className="mobile-nav-tab-icon-wrapper">
|
||||
<Mail />
|
||||
{mailboxPendingApprovalCount > 0 && view !== "mailbox" && (
|
||||
<span className="status-dot status-dot--pending mobile-nav-chat-unread-dot" aria-label="Pending approvals" />
|
||||
)}
|
||||
</span>
|
||||
<span className="mobile-nav-tab-label">Mailbox</span>
|
||||
{mailboxUnreadCount > 0 && (
|
||||
<span className="mobile-nav-tab-badge">{formatCount(mailboxUnreadCount)}</span>
|
||||
@@ -392,6 +399,9 @@ export function MobileNavBar({
|
||||
{mailboxUnreadCount > 0 && (
|
||||
<span className="mobile-more-item-badge mobile-more-item-badge--unread">{formatCount(mailboxUnreadCount)}</span>
|
||||
)}
|
||||
{mailboxPendingApprovalCount > 0 && (
|
||||
<span className="mobile-more-item-badge">{formatCount(mailboxPendingApprovalCount)}</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
|
||||
@@ -349,6 +349,22 @@ describe("AgentDetailView", () => {
|
||||
expect(screen.getByText(/Loading agent/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders pending approval badge when agent has pending approvals", async () => {
|
||||
mockFetchAgent.mockResolvedValueOnce(createMockAgent({ pendingApprovalCount: 3 }));
|
||||
|
||||
render(
|
||||
<AgentDetailView
|
||||
agentId="agent-001"
|
||||
onClose={vi.fn()}
|
||||
addToast={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("3 pending approvals")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders inline mode as a region without overlay or close button", async () => {
|
||||
render(
|
||||
<AgentDetailView
|
||||
|
||||
@@ -219,6 +219,20 @@ describe("AgentsView", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("shows pending approval badge when agent has pending approvals", async () => {
|
||||
mockFetchAgents.mockResolvedValueOnce([
|
||||
{ ...mockAgents[0], id: "agent-pending", name: "Pending Agent", pendingApprovalCount: 2 },
|
||||
]);
|
||||
mockFetchAgentStats.mockResolvedValueOnce({ total: 1, byState: {}, byRole: {} });
|
||||
|
||||
render(<AgentsView addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTitle("Pending approvals")).toBeInTheDocument();
|
||||
expect(screen.getByText("2")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("formats skill badge labels from SKILL.md paths", async () => {
|
||||
mockFetchAgents.mockResolvedValueOnce([
|
||||
{
|
||||
|
||||
@@ -168,6 +168,16 @@ describe("Header", () => {
|
||||
expect(screen.getByLabelText("Unread chat response")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows mailbox pending-approval indicator when mailbox is not active", () => {
|
||||
renderHeader({ onChangeView: noop, view: "board", mailboxPendingApprovalCount: 2 });
|
||||
expect(screen.getByLabelText("Pending approvals")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides mailbox pending-approval indicator when mailbox view is active", () => {
|
||||
renderHeader({ onChangeView: noop, view: "mailbox", mailboxPendingApprovalCount: 2 });
|
||||
expect(screen.queryByLabelText("Pending approvals")).toBeNull();
|
||||
});
|
||||
|
||||
it("hides chat unread indicator when chat view is active", () => {
|
||||
renderHeader({ onChangeView: noop, view: "chat", chatHasUnreadResponse: true });
|
||||
expect(screen.queryByLabelText("Unread chat response")).toBeNull();
|
||||
|
||||
@@ -36,6 +36,7 @@ const createDefaultProps = () => ({
|
||||
onOpenMailbox: vi.fn(),
|
||||
onOpenNodes: vi.fn(),
|
||||
mailboxUnreadCount: 0,
|
||||
mailboxPendingApprovalCount: 0,
|
||||
onOpenGitManager: vi.fn(),
|
||||
onOpenWorkflowSteps: vi.fn(),
|
||||
onOpenSchedules: vi.fn(),
|
||||
@@ -120,6 +121,16 @@ describe("MobileNavBar", () => {
|
||||
expect(onOpenTodos).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows mailbox pending-approval indicator when mailbox tab is inactive", () => {
|
||||
render(<MobileNavBar {...createDefaultProps()} mailboxPendingApprovalCount={2} />);
|
||||
expect(screen.getByLabelText("Pending approvals")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides mailbox pending-approval indicator when mailbox tab is active", () => {
|
||||
render(<MobileNavBar {...createDefaultProps()} mailboxPendingApprovalCount={2} view="mailbox" />);
|
||||
expect(screen.queryByLabelText("Pending approvals")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps dependency graph in More and routes to canonical graph task view", () => {
|
||||
const props = createDefaultProps();
|
||||
render(
|
||||
|
||||
@@ -184,15 +184,15 @@ describe("useAgents", () => {
|
||||
mockFetchAgents.mockClear();
|
||||
mockFetchAgentStats.mockClear();
|
||||
|
||||
for (const event of ["agent:created", "agent:updated", "agent:deleted", "agent:stateChanged"]) {
|
||||
for (const event of ["agent:created", "agent:updated", "agent:deleted", "agent:stateChanged", "approval:requested", "approval:updated", "approval:decided"]) {
|
||||
act(() => {
|
||||
es._emit(event);
|
||||
});
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchAgents).toHaveBeenCalledTimes(4);
|
||||
expect(mockFetchAgentStats).toHaveBeenCalledTimes(4);
|
||||
expect(mockFetchAgents).toHaveBeenCalledTimes(7);
|
||||
expect(mockFetchAgentStats).toHaveBeenCalledTimes(7);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -75,6 +75,9 @@ export function useAgents(projectId?: string, options?: UseAgentsOptions) {
|
||||
"agent:updated": refresh,
|
||||
"agent:deleted": refresh,
|
||||
"agent:stateChanged": refresh,
|
||||
"approval:requested": refresh,
|
||||
"approval:updated": refresh,
|
||||
"approval:decided": refresh,
|
||||
},
|
||||
});
|
||||
}, [projectId, loadAgents, loadStats]);
|
||||
|
||||
@@ -3473,6 +3473,7 @@ describe("Messaging Routes", () => {
|
||||
|
||||
const unread = await GET(app, "/api/messages/unread-count");
|
||||
expect(unread.body.unreadCount).toBe(3);
|
||||
expect(unread.body.pendingApprovalCount).toBe(0);
|
||||
|
||||
const readAll = await REQUEST(app, "POST", "/api/messages/read-all");
|
||||
expect(readAll.status).toBe(200);
|
||||
@@ -3526,6 +3527,42 @@ describe("Messaging Routes", () => {
|
||||
expect(unread).toBeDefined();
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.unreadCount).toBe(1);
|
||||
expect(res.body.pendingApprovalCount).toBe(0);
|
||||
});
|
||||
|
||||
it("GET /api/messages/unread-count includes pendingApprovalCount and excludes resolved approvals", async () => {
|
||||
const { ApprovalRequestStore } = await import("@fusion/core");
|
||||
const approvalStore = new ApprovalRequestStore(store.getDatabase());
|
||||
|
||||
const pending = approvalStore.create({
|
||||
requester: { actorId: "agent-1", actorType: "agent", actorName: "Agent One" },
|
||||
targetAction: {
|
||||
category: "command_execution",
|
||||
action: "npm test",
|
||||
summary: "Run tests",
|
||||
resourceType: "command",
|
||||
resourceId: "npm test",
|
||||
},
|
||||
});
|
||||
const resolved = approvalStore.create({
|
||||
requester: { actorId: "agent-2", actorType: "agent", actorName: "Agent Two" },
|
||||
targetAction: {
|
||||
category: "command_execution",
|
||||
action: "npm run lint",
|
||||
summary: "Run lint",
|
||||
resourceType: "command",
|
||||
resourceId: "npm run lint",
|
||||
},
|
||||
});
|
||||
approvalStore.decide(resolved.id, "denied", {
|
||||
actor: { actorId: "user", actorType: "user", actorName: "User" },
|
||||
});
|
||||
|
||||
const res = await GET(app, "/api/messages/unread-count");
|
||||
|
||||
expect(pending).toBeDefined();
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.pendingApprovalCount).toBe(1);
|
||||
});
|
||||
|
||||
it("POST /api/messages validates required fields and creates messages", async () => {
|
||||
@@ -3761,11 +3798,14 @@ describe("Agent stale task-link sanitization", () => {
|
||||
let tempDir: string;
|
||||
let fusionDir: string;
|
||||
let agentId: string;
|
||||
let routeDb: Database;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), "kb-routes-agent-stale-"));
|
||||
fusionDir = join(tempDir, ".fusion");
|
||||
mkdirSync(fusionDir, { recursive: true });
|
||||
routeDb = new Database(fusionDir, { inMemory: false });
|
||||
routeDb.init();
|
||||
|
||||
const { AgentStore } = await import("@fusion/core");
|
||||
const agentStore = new AgentStore({ rootDir: fusionDir });
|
||||
@@ -3778,12 +3818,14 @@ describe("Agent stale task-link sanitization", () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
routeDb.close();
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function buildAgentApp() {
|
||||
const store = createMockStore({
|
||||
getFusionDir: vi.fn().mockReturnValue(fusionDir),
|
||||
getDatabase: vi.fn().mockReturnValue(routeDb),
|
||||
} as any);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
@@ -3791,6 +3833,88 @@ describe("Agent stale task-link sanitization", () => {
|
||||
return app;
|
||||
}
|
||||
|
||||
async function createPendingApproval(requesterId: string) {
|
||||
const { ApprovalRequestStore } = await import("@fusion/core");
|
||||
const approvalStore = new ApprovalRequestStore(routeDb);
|
||||
approvalStore.create({
|
||||
requester: { actorId: requesterId, actorType: "agent", actorName: "Executor" },
|
||||
targetAction: {
|
||||
category: "command_execution",
|
||||
action: "npm test",
|
||||
summary: "Run tests",
|
||||
resourceType: "command",
|
||||
resourceId: "npm test",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
it("GET /api/agents returns pendingApprovalCount=0 when no pending approvals exist", async () => {
|
||||
const app = buildAgentApp();
|
||||
|
||||
const res = await GET(app, "/api/agents");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const agents = Array.isArray(res.body) ? res.body : [res.body];
|
||||
const testAgent = agents.find((a: { id: string }) => a.id === agentId);
|
||||
expect(testAgent).toBeDefined();
|
||||
expect(testAgent.pendingApprovalCount).toBe(0);
|
||||
});
|
||||
|
||||
it("GET /api/agents and /api/agents/:id include pendingApprovalCount for pending approvals", async () => {
|
||||
const app = buildAgentApp();
|
||||
await createPendingApproval(agentId);
|
||||
await createPendingApproval(agentId);
|
||||
|
||||
const listRes = await GET(app, "/api/agents");
|
||||
expect(listRes.status).toBe(200);
|
||||
const agents = Array.isArray(listRes.body) ? listRes.body : [listRes.body];
|
||||
const listed = agents.find((a: { id: string }) => a.id === agentId);
|
||||
expect(listed).toBeDefined();
|
||||
expect(listed.pendingApprovalCount).toBe(2);
|
||||
|
||||
const detailRes = await GET(app, `/api/agents/${agentId}`);
|
||||
expect(detailRes.status).toBe(200);
|
||||
expect(detailRes.body.pendingApprovalCount).toBe(2);
|
||||
});
|
||||
|
||||
it("GET /api/agents pendingApprovalCount excludes approvals that are no longer pending", async () => {
|
||||
const app = buildAgentApp();
|
||||
|
||||
const { ApprovalRequestStore } = await import("@fusion/core");
|
||||
const approvalStore = new ApprovalRequestStore(routeDb);
|
||||
const request = approvalStore.create({
|
||||
requester: { actorId: agentId, actorType: "agent", actorName: "Executor" },
|
||||
targetAction: {
|
||||
category: "command_execution",
|
||||
action: "npm test",
|
||||
summary: "Run tests",
|
||||
resourceType: "command",
|
||||
resourceId: "npm test",
|
||||
},
|
||||
});
|
||||
approvalStore.decide(request.id, "approved", {
|
||||
actor: { actorId: "user", actorType: "user", actorName: "User" },
|
||||
});
|
||||
const res = await GET(app, "/api/agents");
|
||||
expect(res.status).toBe(200);
|
||||
const agents = Array.isArray(res.body) ? res.body : [res.body];
|
||||
const listed = agents.find((a: { id: string }) => a.id === agentId);
|
||||
expect(listed).toBeDefined();
|
||||
expect(listed.pendingApprovalCount).toBe(0);
|
||||
});
|
||||
|
||||
it("GET /api/agents pendingApprovalCount ignores approvals for missing agents", async () => {
|
||||
const app = buildAgentApp();
|
||||
await createPendingApproval("agent-missing");
|
||||
|
||||
const res = await GET(app, "/api/agents");
|
||||
expect(res.status).toBe(200);
|
||||
const agents = Array.isArray(res.body) ? res.body : [res.body];
|
||||
const listed = agents.find((a: { id: string }) => a.id === agentId);
|
||||
expect(listed).toBeDefined();
|
||||
expect(listed.pendingApprovalCount).toBe(0);
|
||||
});
|
||||
|
||||
it("GET /api/agents omits taskId when linked task is done", async () => {
|
||||
const doneTaskId = "FN-DONE";
|
||||
const store = createMockStore({
|
||||
|
||||
@@ -3,6 +3,7 @@ import path from "node:path";
|
||||
import type { Request, Response } from "express";
|
||||
import type { Agent, AgentCapability, AgentUpdateInput, TaskStore } from "@fusion/core";
|
||||
import {
|
||||
ApprovalRequestStore,
|
||||
getDefaultHeartbeatProcedurePath,
|
||||
isAgentPermissionPolicyPresetId,
|
||||
normalizeAgentPermissionPolicyFromPreset,
|
||||
@@ -40,6 +41,29 @@ function isCompatibleDefaultHeartbeatPath(path: string | undefined, agent: Agent
|
||||
return new RegExp(`^\\.fusion/agents/[^/]+-${safeId}/HEARTBEAT\\.md$`).test(trimmed);
|
||||
}
|
||||
|
||||
function withPendingApprovalCounts<T extends Agent>(agents: T[], scopedStore: TaskStore): Array<T & { pendingApprovalCount: number }> {
|
||||
try {
|
||||
const approvalStore = new ApprovalRequestStore(scopedStore.getDatabase());
|
||||
const pendingRequests = approvalStore.list({ status: "pending", limit: Number.MAX_SAFE_INTEGER, offset: 0 });
|
||||
const counts = new Map<string, number>();
|
||||
|
||||
for (const request of pendingRequests) {
|
||||
const agentId = request.requester.actorId;
|
||||
counts.set(agentId, (counts.get(agentId) ?? 0) + 1);
|
||||
}
|
||||
|
||||
return agents.map((agent) => ({
|
||||
...agent,
|
||||
pendingApprovalCount: counts.get(agent.id) ?? 0,
|
||||
}));
|
||||
} catch {
|
||||
return agents.map((agent) => ({
|
||||
...agent,
|
||||
pendingApprovalCount: 0,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
export function registerAgentCoreListCreateRoutes(ctx: ApiRoutesContext, deps: AgentCoreRouteDeps): void {
|
||||
const { router, getProjectContext, rethrowAsApiError } = ctx;
|
||||
const { sanitizeAgentTaskLinks, validateAgentInstructionsPayload } = deps;
|
||||
@@ -69,7 +93,7 @@ export function registerAgentCoreListCreateRoutes(ctx: ApiRoutesContext, deps: A
|
||||
|
||||
const agents = await agentStore.listAgents(filter as { state?: "idle" | "active" | "running" | "paused" | "error"; role?: AgentCapability; includeEphemeral?: boolean });
|
||||
const sanitizedAgents = await sanitizeAgentTaskLinks(agents, scopedStore);
|
||||
res.json(sanitizedAgents);
|
||||
res.json(withPendingApprovalCounts(sanitizedAgents, scopedStore));
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
@@ -345,7 +369,8 @@ export function registerAgentCoreRoutes(ctx: ApiRoutesContext, deps: AgentCoreRo
|
||||
}
|
||||
// Sanitize taskId for single-agent responses (omit if linked task is terminal)
|
||||
const [sanitizedAgent] = await sanitizeAgentTaskLinks([agent], scopedStore);
|
||||
res.json(sanitizedAgent);
|
||||
const [agentWithPendingApprovals] = withPendingApprovalCounts([sanitizedAgent], scopedStore);
|
||||
res.json(agentWithPendingApprovals);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Request } from "express";
|
||||
import { resolve } from "node:path";
|
||||
import { DASHBOARD_USER_ID, MessageStore, type MessageType, type ParticipantType, validateMessageMetadata } from "@fusion/core";
|
||||
import { ApprovalRequestStore, DASHBOARD_USER_ID, MessageStore, type MessageType, type ParticipantType, validateMessageMetadata } from "@fusion/core";
|
||||
import { ApiError, badRequest, notFound } from "../api-error.js";
|
||||
import { getTerminalService } from "../terminal-service.js";
|
||||
import type { ApiRoutesContext } from "./types.js";
|
||||
@@ -262,8 +262,16 @@ export function registerMessagingScriptRoutes(ctx: ApiRoutesContext): void {
|
||||
router.get("/messages/unread-count", async (req, res) => {
|
||||
try {
|
||||
const msgStore = await getMessageStore(req);
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const mailbox = await msgStore.getMailbox(DASHBOARD_USER_ID, "user");
|
||||
res.json({ unreadCount: mailbox.unreadCount });
|
||||
let pendingApprovalCount = 0;
|
||||
try {
|
||||
const approvalStore = new ApprovalRequestStore(scopedStore.getDatabase());
|
||||
pendingApprovalCount = approvalStore.list({ status: "pending", limit: Number.MAX_SAFE_INTEGER, offset: 0 }).length;
|
||||
} catch {
|
||||
pendingApprovalCount = 0;
|
||||
}
|
||||
res.json({ unreadCount: mailbox.unreadCount, pendingApprovalCount });
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
|
||||
Reference in New Issue
Block a user