feat(FN-5469): add useAgentLogs resume instrumentation and test coverage
Added resume instrumentation to the `useAgentLogs` hook (`packages/dashboard/app/hooks/useAgentLogs.ts`) with test coverage and diagnostics documentation. The new test file is included in the dashboard test gate via `vitest.config.ts`. Fusion-Task-Id: FN-5469
This commit is contained in:
committed by
gsxdsm
parent
d04c7803e0
commit
4cbdd157c5
@@ -105,6 +105,7 @@ FN-5416 extends resume-correlation coverage to stream-focused hooks and their pr
|
|||||||
- `useDevServerLogs`: `project-context-change`, `sse-open`, `sse-reconnect`
|
- `useDevServerLogs`: `project-context-change`, `sse-open`, `sse-reconnect`
|
||||||
- `useResearch`: `sse-open`, `sse-reconnect`
|
- `useResearch`: `sse-open`, `sse-reconnect`
|
||||||
- `useBackgroundSessions`: `sse-open`, `sse-reconnect`
|
- `useBackgroundSessions`: `sse-open`, `sse-reconnect`
|
||||||
|
- `useAgentLogs`: `project-context-change`, `sse-open`, `sse-reconnect` on `/api/tasks/:id/logs/stream`
|
||||||
- Route shells
|
- Route shells
|
||||||
- `DevServerView`: `remount` / `route-active` / `route-inactive`
|
- `DevServerView`: `remount` / `route-active` / `route-inactive`
|
||||||
- `ResearchView`: `remount` / `route-active` / `route-inactive`
|
- `ResearchView`: `remount` / `route-active` / `route-inactive`
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const recordResumeEvent = vi.fn();
|
||||||
|
const fetchAgentLogsWithMeta = vi.fn();
|
||||||
|
|
||||||
|
type SseHandlerSet = {
|
||||||
|
url: string;
|
||||||
|
onOpen?: () => void;
|
||||||
|
onReconnect?: () => void;
|
||||||
|
events?: Record<string, (event: { data: string }) => void>;
|
||||||
|
unsubscribe: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const subscribeCalls: SseHandlerSet[] = [];
|
||||||
|
|
||||||
|
vi.mock("../../utils/resumeInstrumentation", () => ({
|
||||||
|
recordResumeEvent,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../api", () => ({
|
||||||
|
fetchAgentLogsWithMeta,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../sse-bus", () => ({
|
||||||
|
subscribeSse: (
|
||||||
|
url: string,
|
||||||
|
handlers: {
|
||||||
|
onOpen?: () => void;
|
||||||
|
onReconnect?: () => void;
|
||||||
|
events?: Record<string, (event: { data: string }) => void>;
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
const unsubscribe = vi.fn();
|
||||||
|
subscribeCalls.push({
|
||||||
|
url,
|
||||||
|
onOpen: handlers.onOpen,
|
||||||
|
onReconnect: handlers.onReconnect,
|
||||||
|
events: handlers.events,
|
||||||
|
unsubscribe,
|
||||||
|
});
|
||||||
|
return unsubscribe;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("useAgentLogs resume instrumentation", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
subscribeCalls.length = 0;
|
||||||
|
recordResumeEvent.mockReset();
|
||||||
|
fetchAgentLogsWithMeta.mockReset().mockResolvedValue({ entries: [], hasMore: false, total: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits sse-open resume event", async () => {
|
||||||
|
const { useAgentLogs } = await import("../useAgentLogs");
|
||||||
|
|
||||||
|
renderHook(() => useAgentLogs("FN-123", true, "proj-1"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(subscribeCalls[0]?.onOpen).toBeTypeOf("function");
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
subscribeCalls[0]?.onOpen?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(recordResumeEvent).toHaveBeenCalledTimes(1);
|
||||||
|
expect(recordResumeEvent).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
view: "useAgentLogs",
|
||||||
|
trigger: "sse-open",
|
||||||
|
projectId: "proj-1",
|
||||||
|
replayAttempted: false,
|
||||||
|
sseChannel: "/api/tasks/FN-123/logs/stream",
|
||||||
|
detail: { taskId: "FN-123" },
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits sse-reconnect event and keeps agent:log handler reachable", async () => {
|
||||||
|
const { useAgentLogs } = await import("../useAgentLogs");
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useAgentLogs("FN-123", true, "proj-1"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(subscribeCalls[0]?.onReconnect).toBeTypeOf("function");
|
||||||
|
expect(subscribeCalls[0]?.events?.["agent:log"]).toBeTypeOf("function");
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
subscribeCalls[0]?.onReconnect?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(recordResumeEvent).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
view: "useAgentLogs",
|
||||||
|
trigger: "sse-reconnect",
|
||||||
|
projectId: "proj-1",
|
||||||
|
replayAttempted: false,
|
||||||
|
sseChannel: "/api/tasks/FN-123/logs/stream",
|
||||||
|
detail: { taskId: "FN-123" },
|
||||||
|
}));
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
subscribeCalls[0]?.events?.["agent:log"]?.({
|
||||||
|
data: JSON.stringify({
|
||||||
|
timestamp: "2026-01-01T00:00:00Z",
|
||||||
|
taskId: "FN-123",
|
||||||
|
text: "live-log",
|
||||||
|
type: "text",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.entries.at(-1)?.text).toBe("live-log");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits project-context-change and tears down prior subscription", async () => {
|
||||||
|
const { useAgentLogs } = await import("../useAgentLogs");
|
||||||
|
|
||||||
|
const { rerender } = renderHook(
|
||||||
|
({ projectId }) => useAgentLogs("FN-123", true, projectId),
|
||||||
|
{ initialProps: { projectId: "proj-a" } },
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(subscribeCalls).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
rerender({ projectId: "proj-b" });
|
||||||
|
|
||||||
|
expect(subscribeCalls[0]?.unsubscribe).toHaveBeenCalledTimes(1);
|
||||||
|
expect(recordResumeEvent).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
view: "useAgentLogs",
|
||||||
|
trigger: "project-context-change",
|
||||||
|
projectId: "proj-b",
|
||||||
|
replayAttempted: false,
|
||||||
|
reason: "context-version-bumped",
|
||||||
|
detail: { taskId: "FN-123" },
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not emit sse events when disabled", async () => {
|
||||||
|
const { useAgentLogs } = await import("../useAgentLogs");
|
||||||
|
|
||||||
|
renderHook(() => useAgentLogs("FN-123", false, "proj-1"));
|
||||||
|
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(subscribeCalls).toHaveLength(0);
|
||||||
|
expect(recordResumeEvent).not.toHaveBeenCalledWith(expect.objectContaining({ trigger: "sse-open" }));
|
||||||
|
expect(recordResumeEvent).not.toHaveBeenCalledWith(expect.objectContaining({ trigger: "sse-reconnect" }));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef, useCallback } from "react";
|
|||||||
import type { AgentLogEntry } from "@fusion/core";
|
import type { AgentLogEntry } from "@fusion/core";
|
||||||
import { fetchAgentLogsWithMeta } from "../api";
|
import { fetchAgentLogsWithMeta } from "../api";
|
||||||
import { subscribeSse } from "../sse-bus";
|
import { subscribeSse } from "../sse-bus";
|
||||||
|
import { recordResumeEvent } from "../utils/resumeInstrumentation";
|
||||||
|
|
||||||
const INITIAL_LOAD_LIMIT = 100;
|
const INITIAL_LOAD_LIMIT = 100;
|
||||||
|
|
||||||
@@ -63,6 +64,14 @@ export function useAgentLogs(taskId: string | null, enabled: boolean, projectId?
|
|||||||
previousProjectIdRef.current = projectId;
|
previousProjectIdRef.current = projectId;
|
||||||
previousEnabledRef.current = enabled;
|
previousEnabledRef.current = enabled;
|
||||||
projectContextVersionRef.current++;
|
projectContextVersionRef.current++;
|
||||||
|
recordResumeEvent({
|
||||||
|
view: "useAgentLogs",
|
||||||
|
trigger: "project-context-change",
|
||||||
|
projectId,
|
||||||
|
replayAttempted: false,
|
||||||
|
reason: "context-version-bumped",
|
||||||
|
detail: { taskId },
|
||||||
|
});
|
||||||
cancelledRef.current = true;
|
cancelledRef.current = true;
|
||||||
|
|
||||||
// Clear entries immediately on context change to prevent stale data visibility
|
// Clear entries immediately on context change to prevent stale data visibility
|
||||||
@@ -139,6 +148,26 @@ export function useAgentLogs(taskId: string | null, enabled: boolean, projectId?
|
|||||||
unsubscribeRef.current = subscribeSse(
|
unsubscribeRef.current = subscribeSse(
|
||||||
`/api/tasks/${currentTaskId}/logs/stream${query}`,
|
`/api/tasks/${currentTaskId}/logs/stream${query}`,
|
||||||
{
|
{
|
||||||
|
onOpen: () => {
|
||||||
|
recordResumeEvent({
|
||||||
|
view: "useAgentLogs",
|
||||||
|
trigger: "sse-open",
|
||||||
|
projectId: currentProjectId,
|
||||||
|
replayAttempted: false,
|
||||||
|
sseChannel: `/api/tasks/${currentTaskId}/logs/stream`,
|
||||||
|
detail: { taskId: currentTaskId },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onReconnect: () => {
|
||||||
|
recordResumeEvent({
|
||||||
|
view: "useAgentLogs",
|
||||||
|
trigger: "sse-reconnect",
|
||||||
|
projectId: currentProjectId,
|
||||||
|
replayAttempted: false,
|
||||||
|
sseChannel: `/api/tasks/${currentTaskId}/logs/stream`,
|
||||||
|
detail: { taskId: currentTaskId },
|
||||||
|
});
|
||||||
|
},
|
||||||
events: {
|
events: {
|
||||||
"agent:log": (e) => {
|
"agent:log": (e) => {
|
||||||
if (cancelledRef.current ||
|
if (cancelledRef.current ||
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ const qualityAppTests = [
|
|||||||
"app/components/__tests__/{ActiveAgentsPanel,ActivityLogModal,AgentMentionPopup,AgentMetricsBar,AgentOnboardingModal,AgentReflectionsTab,AgentTokenStatsPanel,App,AuthTokenRecoveryDialog,Board,board-mobile,board-mobile-view-switch,ChatView,ChatView.autosize,ChatView.chat-input-autosize,ChatView.default-model-icon,ChatView.draft,ChatView.hash-mention,ChatView.rooms,ChatView.scroll-to-top,ChatView.swipe-back,Column,ConfirmDialog,ConversationHistory,DashboardLoader,DevServerView.mobile,DirectoryPicker,DuplicateWarningModal,ErrorBoundary,ExecutorStatusBar,FileBrowser,FileEditor,GitHubBadge,InlineCreateCard,LoginInstructions,MemoryView,MergeAdvanceNotice,MessageComposer,MessageComposer.autosize,MobileNavBar,NewTaskModal,NewTaskModal.shared-cache,NodeCard,NodeHealthDot,NodeStatusIndicator,PlanningModeModal.autosize,PrChecksList,PrCreateModal,PrCreateModal.layout,ProjectCard,ProjectSelector,ProviderIcon,PrPanel,PrPanel.merge,PrPanel.reviews,QuickChatFAB,QuickChatFAB.shared-cache,ReliabilityView,ResearchView,SecretsView,SecretsView.mobile,SettingsModal,SettingsModal.worktrunk,StashConflictModal,StashRecoveryView,TaskCard,TaskCard.badge-height,TaskCard.badge-wrap,TaskCard.footer-wrap,TaskChangesTab,TaskComments,TaskDetailModal,TaskDetailModal.create-pr-e2e,TaskDetailModal.create-pr-integration,TaskDetailModal.github-tracking-header,TaskDetailModal.github-tracking-stale,TaskDetailModal.rebind-banner,TaskDocumentsTab,TaskForm,TaskIdIntegrityBanner,TrackingRepoSelect,WorkflowResultsTab,WorktrunkInstallApprovalDetails}.test.tsx",
|
"app/components/__tests__/{ActiveAgentsPanel,ActivityLogModal,AgentMentionPopup,AgentMetricsBar,AgentOnboardingModal,AgentReflectionsTab,AgentTokenStatsPanel,App,AuthTokenRecoveryDialog,Board,board-mobile,board-mobile-view-switch,ChatView,ChatView.autosize,ChatView.chat-input-autosize,ChatView.default-model-icon,ChatView.draft,ChatView.hash-mention,ChatView.rooms,ChatView.scroll-to-top,ChatView.swipe-back,Column,ConfirmDialog,ConversationHistory,DashboardLoader,DevServerView.mobile,DirectoryPicker,DuplicateWarningModal,ErrorBoundary,ExecutorStatusBar,FileBrowser,FileEditor,GitHubBadge,InlineCreateCard,LoginInstructions,MemoryView,MergeAdvanceNotice,MessageComposer,MessageComposer.autosize,MobileNavBar,NewTaskModal,NewTaskModal.shared-cache,NodeCard,NodeHealthDot,NodeStatusIndicator,PlanningModeModal.autosize,PrChecksList,PrCreateModal,PrCreateModal.layout,ProjectCard,ProjectSelector,ProviderIcon,PrPanel,PrPanel.merge,PrPanel.reviews,QuickChatFAB,QuickChatFAB.shared-cache,ReliabilityView,ResearchView,SecretsView,SecretsView.mobile,SettingsModal,SettingsModal.worktrunk,StashConflictModal,StashRecoveryView,TaskCard,TaskCard.badge-height,TaskCard.badge-wrap,TaskCard.footer-wrap,TaskChangesTab,TaskComments,TaskDetailModal,TaskDetailModal.create-pr-e2e,TaskDetailModal.create-pr-integration,TaskDetailModal.github-tracking-header,TaskDetailModal.github-tracking-stale,TaskDetailModal.rebind-banner,TaskDocumentsTab,TaskForm,TaskIdIntegrityBanner,TrackingRepoSelect,WorkflowResultsTab,WorktrunkInstallApprovalDetails}.test.tsx",
|
||||||
// Hooks and utilities are fast, user-visible state/formatting behavior.
|
// Hooks and utilities are fast, user-visible state/formatting behavior.
|
||||||
"app/context/**/*.test.tsx",
|
"app/context/**/*.test.tsx",
|
||||||
"app/hooks/__tests__/{useAgents,useAgentLogs,useAppSettings,useAuthOnboarding,useConfirm,useCurrentProject,useNodes,useNodes.resume-instrumentation,useNodeSettingsSync,useProjects,useProjects.resume-instrumentation,useMeshState.resume-instrumentation,useManagedDockerNodes.resume-instrumentation,usePrChecksStream.resume-instrumentation,useDevServerLogs.resume-instrumentation,useResearch.resume-instrumentation,useBackgroundSessions.resume-instrumentation,useQuickChat,useTasks,useTasks.resume-instrumentation,useChatRooms.resume-instrumentation,useTerminalSessions,useTheme,useToast,useUsageData,useViewState}.test.{ts,tsx}",
|
"app/hooks/__tests__/{useAgents,useAgentLogs,useAgentLogs.resume-instrumentation,useAppSettings,useAuthOnboarding,useConfirm,useCurrentProject,useNodes,useNodes.resume-instrumentation,useNodeSettingsSync,useProjects,useProjects.resume-instrumentation,useMeshState.resume-instrumentation,useManagedDockerNodes.resume-instrumentation,usePrChecksStream.resume-instrumentation,useDevServerLogs.resume-instrumentation,useResearch.resume-instrumentation,useBackgroundSessions.resume-instrumentation,useQuickChat,useTasks,useTasks.resume-instrumentation,useChatRooms.resume-instrumentation,useTerminalSessions,useTheme,useToast,useUsageData,useViewState}.test.{ts,tsx}",
|
||||||
"app/utils/**/*.test.{ts,tsx}",
|
"app/utils/**/*.test.{ts,tsx}",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user