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:
Fusion (runfusion.ai)
2026-05-21 19:59:32 -07:00
committed by gsxdsm
parent d04c7803e0
commit 4cbdd157c5
4 changed files with 181 additions and 1 deletions

View File

@@ -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`

View File

@@ -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" }));
});
});

View File

@@ -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 ||

View File

@@ -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}",
]; ];