feat(FN-1629): add pagination to agent logs
- Add pagination support to TaskStore.getAgentLogs() with limit/offset parameters - Add GET /tasks/:id/logs API route with pagination support - Add fetchAgentLogs API client with pagination parameters - Refactor useAgentLogs hook for incremental loading with loadMore callback - Update useMultiAgentLogs for pagination consistency across multiple task IDs - Add Load More UI to AgentLogViewer component - Wire up Load More in TaskDetailModal agent log section - Fix offset slice direction bug in getAgentLogs - Remove incorrect reverse() call in getAgentLogs offset logic - Add comprehensive tests for useAgentLogs and useMultiAgentLogs pagination
This commit is contained in:
@@ -1,14 +1,14 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act, waitFor } from "@testing-library/react";
|
||||
import { MAX_LOG_ENTRIES, useAgentLogs } from "../useAgentLogs";
|
||||
import { fetchAgentLogs } from "../../api";
|
||||
import { fetchAgentLogsWithMeta } from "../../api";
|
||||
|
||||
// Mock the api module
|
||||
vi.mock("../../api", () => ({
|
||||
fetchAgentLogs: vi.fn().mockResolvedValue([]),
|
||||
fetchAgentLogsWithMeta: vi.fn().mockResolvedValue({ entries: [], total: 0, hasMore: false }),
|
||||
}));
|
||||
|
||||
const mockFetchAgentLogs = vi.mocked(fetchAgentLogs);
|
||||
const mockFetchAgentLogsWithMeta = vi.mocked(fetchAgentLogsWithMeta);
|
||||
|
||||
// Mock EventSource
|
||||
class MockEventSource {
|
||||
@@ -41,10 +41,12 @@ class MockEventSource {
|
||||
|
||||
const originalEventSource = globalThis.EventSource;
|
||||
|
||||
const INITIAL_LOAD_LIMIT = 100;
|
||||
|
||||
beforeEach(() => {
|
||||
MockEventSource.instances = [];
|
||||
(globalThis as any).EventSource = MockEventSource;
|
||||
mockFetchAgentLogs.mockReset().mockResolvedValue([]);
|
||||
mockFetchAgentLogsWithMeta.mockReset().mockResolvedValue({ entries: [], total: 0, hasMore: false });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -55,7 +57,7 @@ describe("useAgentLogs", () => {
|
||||
it("does not fetch or connect when enabled=false", () => {
|
||||
const { result } = renderHook(() => useAgentLogs("FN-001", false));
|
||||
|
||||
expect(mockFetchAgentLogs).not.toHaveBeenCalled();
|
||||
expect(mockFetchAgentLogsWithMeta).not.toHaveBeenCalled();
|
||||
expect(MockEventSource.instances).toHaveLength(0);
|
||||
expect(result.current.entries).toEqual([]);
|
||||
});
|
||||
@@ -64,7 +66,11 @@ describe("useAgentLogs", () => {
|
||||
const historicalLogs = [
|
||||
{ timestamp: "2026-01-01T00:00:00Z", taskId: "FN-001", text: "old", type: "text" as const },
|
||||
];
|
||||
mockFetchAgentLogs.mockResolvedValueOnce(historicalLogs);
|
||||
mockFetchAgentLogsWithMeta.mockResolvedValueOnce({
|
||||
entries: historicalLogs,
|
||||
total: historicalLogs.length,
|
||||
hasMore: false,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useAgentLogs("FN-001", true));
|
||||
|
||||
@@ -72,15 +78,34 @@ describe("useAgentLogs", () => {
|
||||
expect(result.current.entries).toEqual(historicalLogs);
|
||||
});
|
||||
|
||||
expect(mockFetchAgentLogs).toHaveBeenCalledWith("FN-001", undefined, { limit: 500 });
|
||||
expect(mockFetchAgentLogsWithMeta).toHaveBeenCalledWith("FN-001", undefined, { limit: INITIAL_LOAD_LIMIT });
|
||||
expect(MockEventSource.instances).toHaveLength(1);
|
||||
expect(MockEventSource.instances[0].url).toBe("/api/tasks/FN-001/logs/stream");
|
||||
});
|
||||
|
||||
it("sets hasMore and total from API response", async () => {
|
||||
mockFetchAgentLogsWithMeta.mockResolvedValueOnce({
|
||||
entries: [],
|
||||
total: 150,
|
||||
hasMore: true,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useAgentLogs("FN-001", true));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.total).toBe(150);
|
||||
expect(result.current.hasMore).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("appends live SSE entries to historical entries", async () => {
|
||||
mockFetchAgentLogs.mockResolvedValueOnce([
|
||||
{ timestamp: "2026-01-01T00:00:00Z", taskId: "FN-001", text: "old", type: "text" as const },
|
||||
]);
|
||||
mockFetchAgentLogsWithMeta.mockResolvedValueOnce({
|
||||
entries: [
|
||||
{ timestamp: "2026-01-01T00:00:00Z", taskId: "FN-001", text: "old", type: "text" as const },
|
||||
],
|
||||
total: 2,
|
||||
hasMore: false,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useAgentLogs("FN-001", true));
|
||||
|
||||
@@ -103,7 +128,7 @@ describe("useAgentLogs", () => {
|
||||
});
|
||||
|
||||
it("closes SSE when enabled changes to false", async () => {
|
||||
mockFetchAgentLogs.mockResolvedValueOnce([]);
|
||||
mockFetchAgentLogsWithMeta.mockResolvedValueOnce({ entries: [], total: 0, hasMore: false });
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({ enabled }) => useAgentLogs("FN-001", enabled),
|
||||
@@ -122,7 +147,7 @@ describe("useAgentLogs", () => {
|
||||
});
|
||||
|
||||
it("closes SSE on unmount", async () => {
|
||||
mockFetchAgentLogs.mockResolvedValueOnce([]);
|
||||
mockFetchAgentLogsWithMeta.mockResolvedValueOnce({ entries: [], total: 0, hasMore: false });
|
||||
|
||||
const { unmount } = renderHook(() => useAgentLogs("FN-001", true));
|
||||
|
||||
@@ -144,7 +169,11 @@ describe("useAgentLogs", () => {
|
||||
text: `entry-${index}`,
|
||||
type: "text" as const,
|
||||
}));
|
||||
mockFetchAgentLogs.mockResolvedValueOnce(historicalLogs);
|
||||
mockFetchAgentLogsWithMeta.mockResolvedValueOnce({
|
||||
entries: historicalLogs,
|
||||
total: historicalLogs.length,
|
||||
hasMore: false,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useAgentLogs("FN-001", true));
|
||||
|
||||
@@ -157,7 +186,7 @@ describe("useAgentLogs", () => {
|
||||
});
|
||||
|
||||
it("truncates live SSE entries to the most recent entries", async () => {
|
||||
mockFetchAgentLogs.mockResolvedValueOnce([]);
|
||||
mockFetchAgentLogsWithMeta.mockResolvedValueOnce({ entries: [], total: MAX_LOG_ENTRIES + 20, hasMore: false });
|
||||
|
||||
const { result } = renderHook(() => useAgentLogs("FN-001", true));
|
||||
|
||||
@@ -188,17 +217,21 @@ describe("useAgentLogs", () => {
|
||||
it("does not fetch when taskId is null", () => {
|
||||
renderHook(() => useAgentLogs(null, true));
|
||||
|
||||
expect(mockFetchAgentLogs).not.toHaveBeenCalled();
|
||||
expect(mockFetchAgentLogsWithMeta).not.toHaveBeenCalled();
|
||||
expect(MockEventSource.instances).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("preserves long text and detail in historical log entries without truncation", async () => {
|
||||
const longText = "A".repeat(5000);
|
||||
const longDetail = "B".repeat(5000);
|
||||
mockFetchAgentLogs.mockResolvedValueOnce([
|
||||
{ timestamp: "2026-01-01T00:00:00Z", taskId: "FN-001", text: longText, type: "text" as const },
|
||||
{ timestamp: "2026-01-01T00:00:01Z", taskId: "FN-001", text: "Read", type: "tool" as const, detail: longDetail },
|
||||
]);
|
||||
mockFetchAgentLogsWithMeta.mockResolvedValueOnce({
|
||||
entries: [
|
||||
{ timestamp: "2026-01-01T00:00:00Z", taskId: "FN-001", text: longText, type: "text" as const },
|
||||
{ timestamp: "2026-01-01T00:00:01Z", taskId: "FN-001", text: "Read", type: "tool" as const, detail: longDetail },
|
||||
],
|
||||
total: 2,
|
||||
hasMore: false,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useAgentLogs("FN-001", true));
|
||||
|
||||
@@ -213,7 +246,7 @@ describe("useAgentLogs", () => {
|
||||
});
|
||||
|
||||
it("preserves long text and detail in live SSE entries without truncation", async () => {
|
||||
mockFetchAgentLogs.mockResolvedValueOnce([]);
|
||||
mockFetchAgentLogsWithMeta.mockResolvedValueOnce({ entries: [], total: 1, hasMore: false });
|
||||
|
||||
const { result } = renderHook(() => useAgentLogs("FN-001", true));
|
||||
|
||||
@@ -244,9 +277,67 @@ describe("useAgentLogs", () => {
|
||||
expect(result.current.entries[0].detail!.length).toBe(5000);
|
||||
});
|
||||
|
||||
describe("loadMore", () => {
|
||||
it("loadMore fetches older entries and prepends them", async () => {
|
||||
const initialLogs = [
|
||||
{ timestamp: "2026-01-01T00:00:00Z", taskId: "FN-001", text: "newer", type: "text" as const },
|
||||
];
|
||||
const olderLogs = [
|
||||
{ timestamp: "2026-01-01T00:00:00Z", taskId: "FN-001", text: "older", type: "text" as const },
|
||||
];
|
||||
|
||||
mockFetchAgentLogsWithMeta
|
||||
.mockResolvedValueOnce({ entries: initialLogs, total: 2, hasMore: true })
|
||||
.mockResolvedValueOnce({ entries: olderLogs, total: 2, hasMore: false });
|
||||
|
||||
const { result } = renderHook(() => useAgentLogs("FN-001", true));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.entries).toHaveLength(1);
|
||||
expect(result.current.hasMore).toBe(true);
|
||||
});
|
||||
|
||||
// Call loadMore
|
||||
await act(async () => {
|
||||
await result.current.loadMore();
|
||||
});
|
||||
|
||||
// Should now have 2 entries: initial + older
|
||||
expect(result.current.entries).toHaveLength(2);
|
||||
expect(result.current.entries[0].text).toBe("newer");
|
||||
expect(result.current.entries[1].text).toBe("older");
|
||||
expect(result.current.hasMore).toBe(false);
|
||||
});
|
||||
|
||||
it("loadMore does not trigger when already loading more", async () => {
|
||||
mockFetchAgentLogsWithMeta.mockResolvedValue({ entries: [], total: 200, hasMore: true });
|
||||
|
||||
const { result } = renderHook(() => useAgentLogs("FN-001", true));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.hasMore).toBe(true);
|
||||
});
|
||||
|
||||
// Start loading more
|
||||
const loadMorePromise = act(async () => {
|
||||
await result.current.loadMore();
|
||||
});
|
||||
|
||||
// While loading, try to load more again - should be ignored
|
||||
act(() => {
|
||||
result.current.loadMore();
|
||||
});
|
||||
|
||||
await loadMorePromise;
|
||||
|
||||
// Initial call + loadMore call (2 total), ignoring re-render calls
|
||||
expect(mockFetchAgentLogsWithMeta.mock.calls.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("projectId support", () => {
|
||||
it("includes projectId in EventSource URL when provided", async () => {
|
||||
mockFetchAgentLogs.mockResolvedValueOnce([]);
|
||||
mockFetchAgentLogsWithMeta.mockResolvedValueOnce({ entries: [], total: 0, hasMore: false });
|
||||
|
||||
renderHook(() => useAgentLogs("FN-001", true, "proj-123"));
|
||||
|
||||
@@ -256,18 +347,18 @@ describe("useAgentLogs", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("includes projectId in fetchAgentLogs call when provided", async () => {
|
||||
mockFetchAgentLogs.mockResolvedValueOnce([]);
|
||||
it("includes projectId in fetchAgentLogsWithMeta call when provided", async () => {
|
||||
mockFetchAgentLogsWithMeta.mockResolvedValueOnce({ entries: [], total: 0, hasMore: false });
|
||||
|
||||
renderHook(() => useAgentLogs("FN-001", true, "proj-123"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchAgentLogs).toHaveBeenCalledWith("FN-001", "proj-123", { limit: 500 });
|
||||
expect(mockFetchAgentLogsWithMeta).toHaveBeenCalledWith("FN-001", "proj-123", { limit: INITIAL_LOAD_LIMIT });
|
||||
});
|
||||
});
|
||||
|
||||
it("does not include projectId in URL when not provided", async () => {
|
||||
mockFetchAgentLogs.mockResolvedValueOnce([]);
|
||||
mockFetchAgentLogsWithMeta.mockResolvedValueOnce({ entries: [], total: 0, hasMore: false });
|
||||
|
||||
renderHook(() => useAgentLogs("FN-001", true));
|
||||
|
||||
@@ -279,18 +370,22 @@ describe("useAgentLogs", () => {
|
||||
|
||||
it("clears entries immediately when projectId changes", async () => {
|
||||
// Set up mock to return different values based on projectId
|
||||
mockFetchAgentLogs.mockImplementation((_taskId: string, projectId?: string) => {
|
||||
mockFetchAgentLogsWithMeta.mockImplementation((_taskId: string, projectId?: string) => {
|
||||
if (projectId === "proj-A") {
|
||||
return Promise.resolve([
|
||||
{ timestamp: "2026-01-01T00:00:00Z", taskId: "FN-001", text: "proj-A-log", type: "text" as const },
|
||||
]);
|
||||
return Promise.resolve({
|
||||
entries: [{ timestamp: "2026-01-01T00:00:00Z", taskId: "FN-001", text: "proj-A-log", type: "text" as const }],
|
||||
total: 1,
|
||||
hasMore: false,
|
||||
});
|
||||
}
|
||||
if (projectId === "proj-B") {
|
||||
return Promise.resolve([
|
||||
{ timestamp: "2026-01-01T00:00:00Z", taskId: "FN-001", text: "proj-B-log", type: "text" as const },
|
||||
]);
|
||||
return Promise.resolve({
|
||||
entries: [{ timestamp: "2026-01-01T00:00:00Z", taskId: "FN-001", text: "proj-B-log", type: "text" as const }],
|
||||
total: 1,
|
||||
hasMore: false,
|
||||
});
|
||||
}
|
||||
return Promise.resolve([]);
|
||||
return Promise.resolve({ entries: [], total: 0, hasMore: false });
|
||||
});
|
||||
|
||||
// Create a hook that switches project
|
||||
@@ -322,7 +417,7 @@ describe("useAgentLogs", () => {
|
||||
|
||||
it("rejects stale SSE events after project switch", async () => {
|
||||
// Initial render with proj-A
|
||||
mockFetchAgentLogs.mockResolvedValue([]);
|
||||
mockFetchAgentLogsWithMeta.mockResolvedValue({ entries: [], total: 0, hasMore: false });
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ projectId }) => useAgentLogs("FN-001", true, projectId),
|
||||
@@ -366,7 +461,7 @@ describe("useAgentLogs", () => {
|
||||
});
|
||||
|
||||
it("creates new connection with new projectId on project switch", async () => {
|
||||
mockFetchAgentLogs.mockResolvedValue([]);
|
||||
mockFetchAgentLogsWithMeta.mockResolvedValue({ entries: [], total: 0, hasMore: false });
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({ projectId }) => useAgentLogs("FN-001", true, projectId),
|
||||
|
||||
@@ -16,15 +16,17 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act, waitFor } from "@testing-library/react";
|
||||
import { MAX_LOG_ENTRIES, useMultiAgentLogs } from "../useMultiAgentLogs";
|
||||
import { fetchAgentLogs } from "../../api";
|
||||
import { fetchAgentLogsWithMeta } from "../../api";
|
||||
import { MockEventSource } from "../../../vitest.setup";
|
||||
|
||||
// Mock the api module
|
||||
vi.mock("../../api", () => ({
|
||||
fetchAgentLogs: vi.fn().mockResolvedValue([]),
|
||||
fetchAgentLogsWithMeta: vi.fn().mockResolvedValue({ entries: [], total: 0, hasMore: false }),
|
||||
}));
|
||||
|
||||
const mockFetchAgentLogs = vi.mocked(fetchAgentLogs);
|
||||
const mockFetchAgentLogsWithMeta = vi.mocked(fetchAgentLogsWithMeta);
|
||||
|
||||
const INITIAL_LOAD_LIMIT = 100;
|
||||
|
||||
// Helper to get the last connection for a specific task ID
|
||||
function getConnection(taskId: string): MockEventSource | undefined {
|
||||
@@ -41,7 +43,7 @@ function getConnections(taskId: string): MockEventSource[] {
|
||||
|
||||
beforeEach(() => {
|
||||
MockEventSource.instances = [];
|
||||
mockFetchAgentLogs.mockReset().mockResolvedValue([]);
|
||||
mockFetchAgentLogsWithMeta.mockReset().mockResolvedValue({ entries: [], total: 0, hasMore: false });
|
||||
|
||||
// Ensure we start with real timers for every test
|
||||
vi.useRealTimers();
|
||||
@@ -85,10 +87,10 @@ describe("useMultiAgentLogs", () => {
|
||||
{ timestamp: "2026-01-01T00:00:00Z", taskId: "FN-002", text: "log2", type: "text" as const },
|
||||
];
|
||||
|
||||
mockFetchAgentLogs.mockImplementation((taskId) => {
|
||||
if (taskId === "FN-001") return Promise.resolve(logs1);
|
||||
if (taskId === "FN-002") return Promise.resolve(logs2);
|
||||
return Promise.resolve([]);
|
||||
mockFetchAgentLogsWithMeta.mockImplementation((taskId) => {
|
||||
if (taskId === "FN-001") return Promise.resolve({ entries: logs1, total: logs1.length, hasMore: false });
|
||||
if (taskId === "FN-002") return Promise.resolve({ entries: logs2, total: logs2.length, hasMore: false });
|
||||
return Promise.resolve({ entries: [], total: 0, hasMore: false });
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useMultiAgentLogs(["FN-001", "FN-002"]));
|
||||
@@ -98,12 +100,12 @@ describe("useMultiAgentLogs", () => {
|
||||
expect(result.current["FN-002"].entries).toEqual(logs2);
|
||||
});
|
||||
|
||||
expect(mockFetchAgentLogs).toHaveBeenCalledWith("FN-001", undefined, { limit: 500 });
|
||||
expect(mockFetchAgentLogs).toHaveBeenCalledWith("FN-002", undefined, { limit: 500 });
|
||||
expect(mockFetchAgentLogsWithMeta).toHaveBeenCalledWith("FN-001", undefined, { limit: INITIAL_LOAD_LIMIT });
|
||||
expect(mockFetchAgentLogsWithMeta).toHaveBeenCalledWith("FN-002", undefined, { limit: INITIAL_LOAD_LIMIT });
|
||||
});
|
||||
|
||||
it("opens SSE EventSource for each task ID", async () => {
|
||||
mockFetchAgentLogs.mockResolvedValue([]);
|
||||
mockFetchAgentLogsWithMeta.mockResolvedValue({ entries: [], total: 0, hasMore: false });
|
||||
|
||||
renderHook(() => useMultiAgentLogs(["FN-001", "FN-002"]));
|
||||
|
||||
@@ -120,7 +122,7 @@ describe("useMultiAgentLogs", () => {
|
||||
{ timestamp: "2026-01-01T00:00:00Z", taskId: "FN-001", text: "old", type: "text" as const },
|
||||
];
|
||||
// Use mockResolvedValue (not Once) to handle Strict Mode double-run
|
||||
mockFetchAgentLogs.mockResolvedValue(historical);
|
||||
mockFetchAgentLogsWithMeta.mockResolvedValue({ entries: historical, total: historical.length, hasMore: false });
|
||||
|
||||
const { result } = renderHook(() => useMultiAgentLogs(["FN-001"]));
|
||||
|
||||
@@ -148,7 +150,7 @@ describe("useMultiAgentLogs", () => {
|
||||
});
|
||||
|
||||
it("closes all SSE connections on unmount (memory leak prevention)", async () => {
|
||||
mockFetchAgentLogs.mockResolvedValue([]);
|
||||
mockFetchAgentLogsWithMeta.mockResolvedValue({ entries: [], total: 0, hasMore: false });
|
||||
|
||||
const { unmount } = renderHook(() => useMultiAgentLogs(["FN-001", "FN-002"]));
|
||||
|
||||
@@ -175,7 +177,7 @@ describe("useMultiAgentLogs", () => {
|
||||
});
|
||||
|
||||
it("closes specific connection when task ID removed from array", async () => {
|
||||
mockFetchAgentLogs.mockResolvedValue([]);
|
||||
mockFetchAgentLogsWithMeta.mockResolvedValue({ entries: [], total: 0, hasMore: false });
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({ taskIds }: { taskIds: string[] }) => useMultiAgentLogs(taskIds),
|
||||
@@ -199,7 +201,7 @@ describe("useMultiAgentLogs", () => {
|
||||
});
|
||||
|
||||
it("opens new connection when task ID added to array", async () => {
|
||||
mockFetchAgentLogs.mockResolvedValue([]);
|
||||
mockFetchAgentLogsWithMeta.mockResolvedValue({ entries: [], total: 0, hasMore: false });
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({ taskIds }: { taskIds: string[] }) => useMultiAgentLogs(taskIds),
|
||||
@@ -224,7 +226,7 @@ describe("useMultiAgentLogs", () => {
|
||||
{ timestamp: "2026-01-01T00:01:00Z", taskId: "FN-001", text: "log2", type: "text" as const },
|
||||
];
|
||||
// Use mockResolvedValue (not Once) to handle Strict Mode double-run
|
||||
mockFetchAgentLogs.mockResolvedValue(logs);
|
||||
mockFetchAgentLogsWithMeta.mockResolvedValue({ entries: logs, total: logs.length, hasMore: false });
|
||||
|
||||
const { result } = renderHook(() => useMultiAgentLogs(["FN-001", "FN-002"]));
|
||||
|
||||
@@ -243,7 +245,7 @@ describe("useMultiAgentLogs", () => {
|
||||
});
|
||||
|
||||
it("handles errors gracefully when fetching historical logs", async () => {
|
||||
mockFetchAgentLogs.mockRejectedValue(new Error("Network error"));
|
||||
mockFetchAgentLogsWithMeta.mockRejectedValue(new Error("Network error"));
|
||||
|
||||
const { result } = renderHook(() => useMultiAgentLogs(["FN-001"]));
|
||||
|
||||
@@ -255,8 +257,8 @@ describe("useMultiAgentLogs", () => {
|
||||
});
|
||||
|
||||
it("does not create duplicate connections while historical fetch is still pending", async () => {
|
||||
let resolveFetch: ((value: never[]) => void) | undefined;
|
||||
mockFetchAgentLogs.mockImplementation(
|
||||
let resolveFetch: ((value: { entries: never[]; total: number; hasMore: boolean }) => void) | undefined;
|
||||
mockFetchAgentLogsWithMeta.mockImplementation(
|
||||
() => new Promise((resolve) => {
|
||||
resolveFetch = resolve;
|
||||
}),
|
||||
@@ -281,15 +283,15 @@ describe("useMultiAgentLogs", () => {
|
||||
// Should not create additional connections on rerender with same IDs
|
||||
expect(getConnections("FN-001").length).toBe(initialCount);
|
||||
|
||||
resolveFetch?.([]);
|
||||
resolveFetch?.({ entries: [], total: 0, hasMore: false });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchAgentLogs).toHaveBeenCalledTimes(1);
|
||||
expect(mockFetchAgentLogsWithMeta).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("closes a task connection when its stream emits an error", async () => {
|
||||
mockFetchAgentLogs.mockResolvedValue([]);
|
||||
mockFetchAgentLogsWithMeta.mockResolvedValue({ entries: [], total: 0, hasMore: false });
|
||||
|
||||
renderHook(() => useMultiAgentLogs(["FN-001"]));
|
||||
|
||||
@@ -315,7 +317,7 @@ describe("useMultiAgentLogs", () => {
|
||||
type: "text" as const,
|
||||
}));
|
||||
|
||||
mockFetchAgentLogs.mockResolvedValue(oversized);
|
||||
mockFetchAgentLogsWithMeta.mockResolvedValue({ entries: oversized, total: oversized.length, hasMore: false });
|
||||
|
||||
const { result } = renderHook(() => useMultiAgentLogs(["FN-001"]));
|
||||
|
||||
@@ -328,8 +330,8 @@ describe("useMultiAgentLogs", () => {
|
||||
});
|
||||
|
||||
it("preserves streamed entries that arrive before historical fetch resolves", async () => {
|
||||
let resolveFetch: ((value: Array<{ timestamp: string; taskId: string; text: string; type: "text" }>) => void) | undefined;
|
||||
mockFetchAgentLogs.mockImplementation(
|
||||
let resolveFetch: ((value: { entries: Array<{ timestamp: string; taskId: string; text: string; type: "text" }>; total: number; hasMore: boolean }) => void) | undefined;
|
||||
mockFetchAgentLogsWithMeta.mockImplementation(
|
||||
() => new Promise((resolve) => {
|
||||
resolveFetch = resolve;
|
||||
}),
|
||||
@@ -354,14 +356,18 @@ describe("useMultiAgentLogs", () => {
|
||||
});
|
||||
|
||||
act(() => {
|
||||
resolveFetch?.([
|
||||
{
|
||||
timestamp: "2026-01-01T00:00:00Z",
|
||||
taskId: "FN-001",
|
||||
text: "historical",
|
||||
type: "text",
|
||||
},
|
||||
]);
|
||||
resolveFetch?.({
|
||||
entries: [
|
||||
{
|
||||
timestamp: "2026-01-01T00:00:00Z",
|
||||
taskId: "FN-001",
|
||||
text: "historical",
|
||||
type: "text",
|
||||
},
|
||||
],
|
||||
total: 2,
|
||||
hasMore: false,
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -373,7 +379,7 @@ describe("useMultiAgentLogs", () => {
|
||||
});
|
||||
|
||||
it("truncates live SSE entries per task to the most recent entries", async () => {
|
||||
mockFetchAgentLogs.mockResolvedValue([]);
|
||||
mockFetchAgentLogsWithMeta.mockResolvedValue({ entries: [], total: MAX_LOG_ENTRIES + 15, hasMore: false });
|
||||
|
||||
const { result } = renderHook(() => useMultiAgentLogs(["FN-001"]));
|
||||
|
||||
@@ -411,10 +417,10 @@ describe("useMultiAgentLogs", () => {
|
||||
{ timestamp: "2026-01-01T00:00:00Z", taskId: "FN-002", text: "task2-old", type: "text" as const },
|
||||
];
|
||||
|
||||
mockFetchAgentLogs.mockImplementation((taskId) => {
|
||||
if (taskId === "FN-001") return Promise.resolve(logs1);
|
||||
if (taskId === "FN-002") return Promise.resolve(logs2);
|
||||
return Promise.resolve([]);
|
||||
mockFetchAgentLogsWithMeta.mockImplementation((taskId) => {
|
||||
if (taskId === "FN-001") return Promise.resolve({ entries: logs1, total: logs1.length, hasMore: false });
|
||||
if (taskId === "FN-002") return Promise.resolve({ entries: logs2, total: logs2.length, hasMore: false });
|
||||
return Promise.resolve({ entries: [], total: 0, hasMore: false });
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useMultiAgentLogs(["FN-001", "FN-002"]));
|
||||
@@ -464,10 +470,14 @@ describe("useMultiAgentLogs", () => {
|
||||
it("preserves long text and detail in historical log entries without truncation", async () => {
|
||||
const longText = "A".repeat(5000);
|
||||
const longDetail = "B".repeat(5000);
|
||||
mockFetchAgentLogs.mockResolvedValue([
|
||||
{ timestamp: "2026-01-01T00:00:00Z", taskId: "FN-001", text: longText, type: "text" as const },
|
||||
{ timestamp: "2026-01-01T00:00:01Z", taskId: "FN-001", text: "Read", type: "tool" as const, detail: longDetail },
|
||||
]);
|
||||
mockFetchAgentLogsWithMeta.mockResolvedValue({
|
||||
entries: [
|
||||
{ timestamp: "2026-01-01T00:00:00Z", taskId: "FN-001", text: longText, type: "text" as const },
|
||||
{ timestamp: "2026-01-01T00:00:01Z", taskId: "FN-001", text: "Read", type: "tool" as const, detail: longDetail },
|
||||
],
|
||||
total: 2,
|
||||
hasMore: false,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useMultiAgentLogs(["FN-001"]));
|
||||
|
||||
@@ -482,7 +492,7 @@ describe("useMultiAgentLogs", () => {
|
||||
});
|
||||
|
||||
it("preserves long text and detail in live SSE entries without truncation", async () => {
|
||||
mockFetchAgentLogs.mockResolvedValue([]);
|
||||
mockFetchAgentLogsWithMeta.mockResolvedValue({ entries: [], total: 1, hasMore: false });
|
||||
|
||||
const { result } = renderHook(() => useMultiAgentLogs(["FN-001"]));
|
||||
|
||||
@@ -517,7 +527,7 @@ describe("useMultiAgentLogs", () => {
|
||||
|
||||
describe("projectId support", () => {
|
||||
it("includes projectId in EventSource URL when provided", async () => {
|
||||
mockFetchAgentLogs.mockResolvedValue([]);
|
||||
mockFetchAgentLogsWithMeta.mockResolvedValue({ entries: [], total: 0, hasMore: false });
|
||||
|
||||
renderHook(() => useMultiAgentLogs(["FN-001", "FN-002"], "proj-123"));
|
||||
|
||||
@@ -528,18 +538,18 @@ describe("useMultiAgentLogs", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("includes projectId in fetchAgentLogs call when provided", async () => {
|
||||
mockFetchAgentLogs.mockResolvedValue([]);
|
||||
it("includes projectId in fetchAgentLogsWithMeta call when provided", async () => {
|
||||
mockFetchAgentLogsWithMeta.mockResolvedValue({ entries: [], total: 0, hasMore: false });
|
||||
|
||||
renderHook(() => useMultiAgentLogs(["FN-001"], "proj-123"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchAgentLogs).toHaveBeenCalledWith("FN-001", "proj-123", { limit: 500 });
|
||||
expect(mockFetchAgentLogsWithMeta).toHaveBeenCalledWith("FN-001", "proj-123", { limit: INITIAL_LOAD_LIMIT });
|
||||
});
|
||||
});
|
||||
|
||||
it("does not include projectId in URL when not provided", async () => {
|
||||
mockFetchAgentLogs.mockResolvedValue([]);
|
||||
mockFetchAgentLogsWithMeta.mockResolvedValue({ entries: [], total: 0, hasMore: false });
|
||||
|
||||
renderHook(() => useMultiAgentLogs(["FN-001"]));
|
||||
|
||||
@@ -550,7 +560,7 @@ describe("useMultiAgentLogs", () => {
|
||||
});
|
||||
|
||||
it("creates new EventSource when taskIds change with projectId", async () => {
|
||||
mockFetchAgentLogs.mockResolvedValue([]);
|
||||
mockFetchAgentLogsWithMeta.mockResolvedValue({ entries: [], total: 0, hasMore: false });
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({ taskIds, projectId }: { taskIds: string[]; projectId?: string }) =>
|
||||
@@ -578,7 +588,7 @@ describe("useMultiAgentLogs", () => {
|
||||
|
||||
it("fetches with correct projectId based on when effect runs", async () => {
|
||||
// This test verifies that projectId is used at the time the effect runs
|
||||
mockFetchAgentLogs.mockResolvedValue([]);
|
||||
mockFetchAgentLogsWithMeta.mockResolvedValue({ entries: [], total: 0, hasMore: false });
|
||||
|
||||
// Render with projectId proj-A
|
||||
const { result: result1 } = renderHook(
|
||||
@@ -593,7 +603,7 @@ describe("useMultiAgentLogs", () => {
|
||||
});
|
||||
|
||||
// Capture calls made so far
|
||||
const initialCallCount = mockFetchAgentLogs.mock.calls.length;
|
||||
const initialCallCount = mockFetchAgentLogsWithMeta.mock.calls.length;
|
||||
|
||||
// Create new hook instance with proj-B
|
||||
const { result: result2, rerender: rerender2 } = renderHook(
|
||||
@@ -608,8 +618,8 @@ describe("useMultiAgentLogs", () => {
|
||||
});
|
||||
|
||||
// The new hook should have made a fetch with proj-B
|
||||
expect(mockFetchAgentLogs.mock.calls.length).toBeGreaterThan(initialCallCount);
|
||||
const lastCall = mockFetchAgentLogs.mock.calls.at(-1);
|
||||
expect(mockFetchAgentLogsWithMeta.mock.calls.length).toBeGreaterThan(initialCallCount);
|
||||
const lastCall = mockFetchAgentLogsWithMeta.mock.calls.at(-1);
|
||||
expect(lastCall?.[1]).toBe("proj-B");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user