fix(FN-3187): stabilize agent run log streaming

This commit is contained in:
gsxdsm
2026-05-01 18:37:28 -07:00
parent 2f9364460a
commit a63e819ad0
6 changed files with 350 additions and 34 deletions

View File

@@ -4,7 +4,7 @@ import { render, screen, waitFor, fireEvent, act } from "@testing-library/react"
import userEvent from "@testing-library/user-event";
import "@testing-library/jest-dom";
import { AgentDetailView } from "../AgentDetailView";
import type { AgentCapability, AgentDetail } from "../../api";
import type { AgentCapability, AgentDetail, AgentHeartbeatRun } from "../../api";
import type { AgentLogEntry } from "@fusion/core";
import { DEFAULT_HEARTBEAT_INTERVAL_MS } from "../../utils/heartbeatIntervals";
@@ -93,6 +93,10 @@ vi.mock("../SkillMultiselect", () => ({
),
}));
vi.mock("../../sse-bus", () => ({
subscribeSse: vi.fn(() => () => {}),
}));
const mockConfirm = vi.fn();
vi.mock("../../hooks/useConfirm", () => ({
@@ -100,6 +104,7 @@ vi.mock("../../hooks/useConfirm", () => ({
}));
import { fetchAgent, fetchAgents, updateAgent, updateAgentState, deleteAgent, fetchAgentChildren, fetchAgentRunLogs, fetchAgentRuns, fetchAgentRunDetail, fetchAgentTasks, fetchChainOfCommand, fetchAgentBudgetStatus, resetAgentBudget, updateAgentInstructions, updateAgentSoul, updateAgentMemory, fetchWorkspaceFileContent, saveWorkspaceFileContent, fetchDiscoveredSkills, fetchModels, fetchPluginRuntimes, fetchAgentLogsWithMeta, upgradeAgentHeartbeatProcedure } from "../../api";
import { subscribeSse } from "../../sse-bus";
const mockFetchAgent = vi.mocked(fetchAgent);
const mockFetchAgents = vi.mocked(fetchAgents);
@@ -124,6 +129,7 @@ const mockFetchModels = vi.mocked(fetchModels);
const mockFetchPluginRuntimes = vi.mocked(fetchPluginRuntimes);
const mockFetchAgentLogsWithMeta = vi.mocked(fetchAgentLogsWithMeta);
const mockUpgradeAgentHeartbeatProcedure = vi.mocked(upgradeAgentHeartbeatProcedure);
const mockSubscribeSse = vi.mocked(subscribeSse);
const MOCK_SKILLS = [
{ id: "skill-1", name: "Skill One", path: "/path/skill-1", relativePath: "skills/skill-1", enabled: true, metadata: { source: "*", scope: "user" as const, origin: "top-level" as const } },
@@ -166,6 +172,8 @@ describe("AgentDetailView", () => {
vi.clearAllMocks();
mockConfirm.mockReset();
mockConfirm.mockResolvedValue(true);
mockSubscribeSse.mockReset();
mockSubscribeSse.mockReturnValue(vi.fn());
const mockAgent = createMockAgent();
mockFetchAgent.mockResolvedValue(mockAgent);
mockFetchAgents.mockResolvedValue([
@@ -181,6 +189,7 @@ describe("AgentDetailView", () => {
...(mockAgent.activeRun ? [mockAgent.activeRun] : []),
...mockAgent.completedRuns,
]);
mockFetchAgentRunLogs.mockResolvedValue([]);
mockFetchAgentRunDetail.mockResolvedValue(mockAgent.completedRuns[0]);
mockFetchAgentChildren.mockResolvedValue([]);
mockFetchAgentTasks.mockResolvedValue([]);
@@ -1055,6 +1064,55 @@ describe("AgentDetailView", () => {
});
});
describe("Logs tab", () => {
it("loads latest run logs lazily for agents without a current task", async () => {
const latestRun = {
id: "run-1001",
agentId: "agent-001",
startedAt: "2024-01-01T00:00:00.000Z",
endedAt: null,
status: "active",
} as AgentHeartbeatRun;
mockFetchAgent.mockResolvedValue(createMockAgent({
taskId: undefined,
activeRun: latestRun,
completedRuns: [],
}));
mockFetchAgentRuns.mockResolvedValue([latestRun]);
mockFetchAgentRunLogs.mockResolvedValue([
{ timestamp: "2024-01-01T00:01:00.000Z", taskId: "agent-run", text: "First entry", type: "text" },
{ timestamp: "2024-01-01T00:02:00.000Z", taskId: "agent-run", text: "Second entry", type: "text" },
]);
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
await waitFor(() => {
expect(screen.getByText("Dashboard")).toBeInTheDocument();
});
expect(mockFetchAgentRuns).not.toHaveBeenCalled();
expect(mockFetchAgentRunLogs).not.toHaveBeenCalled();
fireEvent.click(screen.getByText("Logs"));
await waitFor(() => {
expect(mockFetchAgentRuns).toHaveBeenCalledWith("agent-001", 1, undefined);
expect(mockFetchAgentRunLogs).toHaveBeenCalledWith("agent-001", "run-1001", undefined);
});
expect(screen.getByText("Latest run · run-1001")).toBeInTheDocument();
expect(
Array.from(document.querySelectorAll(".log-text")).map((node) => node.textContent?.trim()),
).toEqual(["Second entry", "First entry"]);
});
});
describe("Tasks tab", () => {
it("renders tasks returned by fetchAgentTasks", async () => {
const user = userEvent.setup();
@@ -2625,6 +2683,84 @@ describe("AgentDetailView", () => {
});
});
it("keeps the active run log stream subscribed across run-list polling", async () => {
const intervalCallbacks: Array<() => void> = [];
const setIntervalSpy = vi.spyOn(globalThis, "setInterval").mockImplementation(((callback: TimerHandler) => {
if (typeof callback === "function") {
intervalCallbacks.push(callback as () => void);
}
return 1 as ReturnType<typeof setInterval>;
}) as typeof setInterval);
const clearIntervalSpy = vi.spyOn(globalThis, "clearInterval").mockImplementation(((id?: ReturnType<typeof setInterval>) => {
void id;
}) as typeof clearInterval);
try {
const activeRun = {
id: "run-live-1",
agentId: "agent-001",
startedAt: "2024-01-01T00:00:00.000Z",
endedAt: null,
status: "active",
} as AgentHeartbeatRun;
mockFetchAgent.mockResolvedValue(createMockAgent({
activeRun,
completedRuns: [],
}));
mockFetchAgentRuns.mockResolvedValue([activeRun]);
mockFetchAgentRunLogs.mockResolvedValue([]);
mockFetchAgentRunDetail.mockResolvedValue(activeRun);
render(
<AgentDetailView
agentId="agent-001"
onClose={vi.fn()}
addToast={vi.fn()}
/>
);
await waitFor(() => {
expect(screen.getByText("Runs")).toBeInTheDocument();
});
fireEvent.click(screen.getByText("Runs"));
await waitFor(() => {
expect(screen.getByText("Live Run")).toBeInTheDocument();
});
const activeRunButton = screen.getAllByRole("button").find(
(btn) => btn.getAttribute("aria-label")?.includes("run-live")
&& btn.getAttribute("aria-label")?.includes("active"),
);
expect(activeRunButton).toBeTruthy();
fireEvent.click(activeRunButton!);
await waitFor(() => {
expect(mockFetchAgentRunLogs).toHaveBeenCalledWith("agent-001", "run-live-1", undefined);
});
const streamUrl = "/api/agents/agent-001/runs/run-live-1/logs/stream";
expect(
mockSubscribeSse.mock.calls.filter(([url]) => url === streamUrl),
).toHaveLength(1);
await act(async () => {
intervalCallbacks.forEach((callback) => callback());
await Promise.resolve();
});
await waitFor(() => {
expect(mockFetchAgentRuns.mock.calls.length).toBeGreaterThanOrEqual(2);
});
expect(
mockSubscribeSse.mock.calls.filter(([url]) => url === streamUrl),
).toHaveLength(1);
} finally {
setIntervalSpy.mockRestore();
clearIntervalSpy.mockRestore();
}
});
it("fetches and displays logs when clicking a completed run", async () => {
const mockLogs: AgentLogEntry[] = [
{ timestamp: "2024-01-01T00:01:00.000Z", taskId: "FN-001", text: "Starting task execution", type: "text" },