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:
@@ -381,15 +381,62 @@ export async function deleteAttachment(id: string, filename: string, projectId?:
|
||||
return api<Task>(withProjectId(`/tasks/${id}/attachments/${filename}`, projectId), { method: "DELETE" });
|
||||
}
|
||||
|
||||
export function fetchAgentLogs(taskId: string, projectId?: string, options?: { limit?: number }): Promise<AgentLogEntry[]> {
|
||||
export function fetchAgentLogs(
|
||||
taskId: string,
|
||||
projectId?: string,
|
||||
options?: { limit?: number; offset?: number },
|
||||
): Promise<AgentLogEntry[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.limit !== undefined) {
|
||||
params.set("limit", String(options.limit));
|
||||
}
|
||||
if (options?.offset !== undefined) {
|
||||
params.set("offset", String(options.offset));
|
||||
}
|
||||
const suffix = params.toString() ? `?${params.toString()}` : "";
|
||||
return api<AgentLogEntry[]>(withProjectId(`/tasks/${taskId}/logs${suffix}`, projectId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch agent logs with pagination metadata.
|
||||
* Returns entries along with total count and hasMore flag from response headers.
|
||||
*/
|
||||
export async function fetchAgentLogsWithMeta(
|
||||
taskId: string,
|
||||
projectId?: string,
|
||||
options?: { limit?: number; offset?: number },
|
||||
): Promise<{ entries: AgentLogEntry[]; total: number; hasMore: boolean }> {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.limit !== undefined) {
|
||||
params.set("limit", String(options.limit));
|
||||
}
|
||||
if (options?.offset !== undefined) {
|
||||
params.set("offset", String(options.offset));
|
||||
}
|
||||
const suffix = params.toString() ? `?${params.toString()}` : "";
|
||||
const url = withProjectId(`/tasks/${taskId}/logs${suffix}`, projectId);
|
||||
|
||||
// Call api function to get the fetch-compatible URL
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({ error: "Failed to fetch agent logs" }));
|
||||
throw new Error((data as { error?: string }).error || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const entries = await response.json() as AgentLogEntry[];
|
||||
|
||||
// Read pagination headers
|
||||
const total = response.headers.has("X-Total-Count")
|
||||
? parseInt(response.headers.get("X-Total-Count")!, 10)
|
||||
: entries.length;
|
||||
const hasMore = response.headers.has("X-Has-More")
|
||||
? response.headers.get("X-Has-More") === "true"
|
||||
: false;
|
||||
|
||||
return { entries, total, hasMore };
|
||||
}
|
||||
|
||||
export function fetchSessionFiles(taskId: string, projectId?: string): Promise<string[]> {
|
||||
return api<string[]>(withProjectId(`/tasks/${taskId}/session-files`, projectId));
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
ChevronDown, ChevronRight, BarChart3, Star, BookOpen
|
||||
} from "lucide-react";
|
||||
import type { AgentDetail, AgentState, AgentHeartbeatRun, AgentBudgetStatus } from "../api";
|
||||
import { fetchAgent, updateAgent, updateAgentState, deleteAgent, fetchAgentLogs, fetchAgentRunLogs, fetchAgentChildren, fetchAgentRuns, fetchAgentRunDetail, startAgentRun, stopAgentRun, updateAgentInstructions, updateAgentSoul, updateAgentMemory, fetchAgentTasks, fetchChainOfCommand, fetchAgentBudgetStatus, resetAgentBudget, fetchWorkspaceFileContent, saveWorkspaceFileContent } from "../api";
|
||||
import { fetchAgent, updateAgent, updateAgentState, deleteAgent, fetchAgentLogsWithMeta, fetchAgentRunLogs, fetchAgentChildren, fetchAgentRuns, fetchAgentRunDetail, startAgentRun, stopAgentRun, updateAgentInstructions, updateAgentSoul, updateAgentMemory, fetchAgentTasks, fetchChainOfCommand, fetchAgentBudgetStatus, resetAgentBudget, fetchWorkspaceFileContent, saveWorkspaceFileContent } from "../api";
|
||||
import type { Agent } from "../api";
|
||||
import type { AgentLogEntry, Task } from "@fusion/core";
|
||||
import { AgentLogViewer } from "./AgentLogViewer";
|
||||
@@ -134,14 +134,14 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
|
||||
// If the agent is working on a task, we could show task logs.
|
||||
if (agent?.taskId) {
|
||||
try {
|
||||
const data = await fetchAgentLogs(agent.taskId, currentProjectId);
|
||||
const result = await fetchAgentLogsWithMeta(agent.taskId, currentProjectId, { limit: 100 });
|
||||
// Reject stale response: check context version and current IDs
|
||||
if (contextVersionRef.current !== contextVersionAtCapture ||
|
||||
agentId !== currentAgentId ||
|
||||
projectId !== currentProjectId) {
|
||||
return;
|
||||
}
|
||||
setLogs(data);
|
||||
setLogs(result.entries);
|
||||
} catch (err: any) {
|
||||
// Reject stale error: check context version and current IDs
|
||||
if (contextVersionRef.current !== contextVersionAtCapture ||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useRef, useEffect, useState, useCallback } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import type { Components } from "react-markdown";
|
||||
import { Maximize2, Minimize2 } from "lucide-react";
|
||||
import { Maximize2, Minimize2, Loader2 } from "lucide-react";
|
||||
|
||||
function formatTimestamp(iso: string): string {
|
||||
const date = new Date(iso);
|
||||
@@ -60,15 +60,44 @@ interface AgentLogViewerProps {
|
||||
executorModel?: ModelInfo | null;
|
||||
validatorModel?: ModelInfo | null;
|
||||
planningModel?: ModelInfo | null;
|
||||
/** Whether more entries exist beyond what's currently loaded */
|
||||
hasMore?: boolean;
|
||||
/** Callback to load older entries */
|
||||
onLoadMore?: () => void;
|
||||
/** Whether a load more request is in progress */
|
||||
loadingMore?: boolean;
|
||||
/** Total number of entries (when known) for "Showing X of Y" summary */
|
||||
totalCount?: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders agent log entries in a scrollable, monospace container.
|
||||
* Displays entries in reverse chronological order (newest first).
|
||||
* Auto-scrolls to keep latest entries visible when streaming.
|
||||
* Supports toggling between markdown-formatted and plain-text rendering.
|
||||
*
|
||||
* Features:
|
||||
* - Displays entries in reverse chronological order (newest first)
|
||||
* - Auto-scrolls to keep latest entries visible when streaming
|
||||
* - Supports toggling between markdown-formatted and plain-text rendering
|
||||
* - "Load More" button to fetch older entries when pagination is enabled
|
||||
* - Shows "Showing X of Y entries" summary when totalCount is provided
|
||||
*
|
||||
* @param entries - Array of log entries (in chronological order, oldest first)
|
||||
* @param loading - Whether initial load is in progress
|
||||
* @param hasMore - Whether more older entries exist beyond the current page
|
||||
* @param onLoadMore - Callback to load older entries
|
||||
* @param loadingMore - Whether a load more request is in progress
|
||||
* @param totalCount - Total number of entries (when known) for summary display
|
||||
*/
|
||||
export function AgentLogViewer({ entries, loading, executorModel, validatorModel, planningModel }: AgentLogViewerProps) {
|
||||
export function AgentLogViewer({
|
||||
entries,
|
||||
loading,
|
||||
executorModel,
|
||||
validatorModel,
|
||||
planningModel,
|
||||
hasMore = false,
|
||||
onLoadMore,
|
||||
loadingMore = false,
|
||||
totalCount = null,
|
||||
}: AgentLogViewerProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const previousEntryCountRef = useRef<number>(0);
|
||||
const [renderMarkdown, setRenderMarkdown] = useState(true);
|
||||
@@ -198,6 +227,14 @@ export function AgentLogViewer({ entries, loading, executorModel, validatorModel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pagination summary */}
|
||||
{totalCount !== null && (
|
||||
<div className="agent-log-summary" data-testid="agent-log-summary">
|
||||
Showing {entries.length} of {totalCount} entries
|
||||
</div>
|
||||
)}
|
||||
|
||||
{reversedEntries.map((entry, i) => {
|
||||
// Look at previous entry in reversed array (= next chronologically) for deduplication
|
||||
const prev = reversedEntries[i - 1];
|
||||
@@ -275,6 +312,27 @@ export function AgentLogViewer({ entries, loading, executorModel, validatorModel
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Load More button */}
|
||||
{hasMore && onLoadMore && (
|
||||
<div className="agent-log-load-more" data-testid="agent-log-load-more">
|
||||
<button
|
||||
className="agent-log-mode-toggle"
|
||||
onClick={onLoadMore}
|
||||
disabled={loadingMore}
|
||||
data-testid="agent-log-load-more-button"
|
||||
>
|
||||
{loadingMore ? (
|
||||
<>
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
Loading…
|
||||
</>
|
||||
) : (
|
||||
"Load More"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -594,7 +594,14 @@ export function TaskDetailModal({
|
||||
}, [isEditing, handleEditKeyDown]);
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const { entries: agentLogEntries, loading: agentLogLoading } = useAgentLogs(
|
||||
const {
|
||||
entries: agentLogEntries,
|
||||
loading: agentLogLoading,
|
||||
loadMore: loadMoreAgentLogs,
|
||||
hasMore: agentLogHasMore,
|
||||
total: agentLogTotal,
|
||||
loadingMore: agentLogLoadingMore,
|
||||
} = useAgentLogs(
|
||||
task.id,
|
||||
activeTab === "logs" && logSubview === "agent-log",
|
||||
projectId,
|
||||
@@ -1194,6 +1201,10 @@ export function TaskDetailModal({
|
||||
executorModel={resolveEffectiveExecutor(task, settings)}
|
||||
validatorModel={resolveEffectiveValidator(task, settings)}
|
||||
planningModel={resolveEffectivePlanning(task, agentLogEntries, settings)}
|
||||
hasMore={agentLogHasMore}
|
||||
onLoadMore={loadMoreAgentLogs}
|
||||
loadingMore={agentLogLoadingMore}
|
||||
totalCount={agentLogTotal}
|
||||
/>
|
||||
) : (
|
||||
<div className="detail-activity">
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import type { AgentLogEntry } from "@fusion/core";
|
||||
import { fetchAgentLogs } from "../api";
|
||||
import { fetchAgentLogsWithMeta } from "../api";
|
||||
|
||||
export const MAX_LOG_ENTRIES = 500;
|
||||
const INITIAL_LOAD_LIMIT = 100;
|
||||
|
||||
/**
|
||||
* Cap the total number of log entries to `MAX_LOG_ENTRIES`.
|
||||
@@ -21,23 +22,33 @@ function capLogEntries(entries: AgentLogEntry[]): AgentLogEntry[] {
|
||||
/**
|
||||
* Hook that manages agent log fetching and live SSE streaming for a task.
|
||||
*
|
||||
* Features project-context isolation to prevent cross-project log bleed:
|
||||
* - Treats `{projectId, taskId}` as a context key
|
||||
* - Clears entries immediately on context change (project or task switch)
|
||||
* - Rejects late fetch responses from previous contexts
|
||||
* - Rejects stale SSE events from previous EventSource instances
|
||||
* Features:
|
||||
* - **Pagination**: Initial load fetches 100 entries. Use `loadMore()` to fetch older entries.
|
||||
* - **Project-context isolation**: Prevents cross-project log bleed via context versioning.
|
||||
* - **Live streaming**: SSE events append new entries to the end of the list.
|
||||
*
|
||||
* **Pagination semantics**:
|
||||
* - Entries are returned in chronological order (oldest first) from the API
|
||||
* - Entries are stored in chronological order
|
||||
* - The UI displays newest first by reversing the array
|
||||
* - `loadMore()` fetches the next 100 older entries and prepends them
|
||||
*
|
||||
* When `enabled` is true:
|
||||
* 1. Fetches recent historical logs via GET /api/tasks/:id/logs?limit=500
|
||||
* 1. Fetches recent historical logs via GET /api/tasks/:id/logs?limit=100
|
||||
* 2. Opens an EventSource to /api/tasks/:id/logs/stream for live updates
|
||||
* 3. Merges historical + live entries in order
|
||||
*
|
||||
* When `enabled` becomes false or the component unmounts, the EventSource
|
||||
* is closed to avoid unnecessary SSE connections.
|
||||
*
|
||||
* @returns Object with entries, loading, clear, loadMore, hasMore, total
|
||||
*/
|
||||
export function useAgentLogs(taskId: string | null, enabled: boolean, projectId?: string) {
|
||||
const [entries, setEntries] = useState<AgentLogEntry[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [total, setTotal] = useState<number | null>(null);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
|
||||
// Refs for state that needs to survive re-renders
|
||||
const eventSourceRef = useRef<EventSource | null>(null);
|
||||
@@ -71,6 +82,9 @@ export function useAgentLogs(taskId: string | null, enabled: boolean, projectId?
|
||||
// Clear entries immediately on context change to prevent stale data visibility
|
||||
setEntries([]);
|
||||
setLoading(false);
|
||||
setHasMore(false);
|
||||
setTotal(null);
|
||||
setLoadingMore(false);
|
||||
|
||||
// Close existing EventSource
|
||||
if (eventSourceRef.current) {
|
||||
@@ -102,8 +116,9 @@ export function useAgentLogs(taskId: string | null, enabled: boolean, projectId?
|
||||
if (!currentTaskId) return;
|
||||
|
||||
setLoading(true);
|
||||
setLoadingMore(false);
|
||||
try {
|
||||
const historical = await fetchAgentLogs(currentTaskId, currentProjectId, { limit: MAX_LOG_ENTRIES });
|
||||
const result = await fetchAgentLogsWithMeta(currentTaskId, currentProjectId, { limit: INITIAL_LOAD_LIMIT });
|
||||
|
||||
// Reject stale response: check context version and request version
|
||||
if (cancelledRef.current ||
|
||||
@@ -111,7 +126,9 @@ export function useAgentLogs(taskId: string | null, enabled: boolean, projectId?
|
||||
requestVersionRef.current !== requestVersion) {
|
||||
return;
|
||||
}
|
||||
setEntries(capLogEntries(historical));
|
||||
setEntries(capLogEntries(result.entries));
|
||||
setHasMore(result.hasMore);
|
||||
setTotal(result.total);
|
||||
} catch {
|
||||
// Reject stale error: check context version and request version
|
||||
if (cancelledRef.current ||
|
||||
@@ -120,6 +137,8 @@ export function useAgentLogs(taskId: string | null, enabled: boolean, projectId?
|
||||
return;
|
||||
}
|
||||
setEntries([]);
|
||||
setHasMore(false);
|
||||
setTotal(null);
|
||||
} finally {
|
||||
// Only update loading state if not cancelled and not stale
|
||||
if (!cancelledRef.current &&
|
||||
@@ -143,6 +162,8 @@ export function useAgentLogs(taskId: string | null, enabled: boolean, projectId?
|
||||
try {
|
||||
const entry: AgentLogEntry = JSON.parse(e.data);
|
||||
setEntries((prev) => capLogEntries([...prev, entry]));
|
||||
// Update total if we know it (increment since new entry added)
|
||||
setTotal((prev) => (prev !== null ? prev + 1 : null));
|
||||
} catch {
|
||||
// skip malformed events
|
||||
}
|
||||
@@ -160,7 +181,45 @@ export function useAgentLogs(taskId: string | null, enabled: boolean, projectId?
|
||||
};
|
||||
}, [taskId, enabled, projectId]);
|
||||
|
||||
/**
|
||||
* Load more older entries.
|
||||
* Fetches the next 100 older entries and prepends them to the existing list.
|
||||
*/
|
||||
const loadMore = useCallback(async () => {
|
||||
if (!taskId || loadingMore) return;
|
||||
|
||||
const contextVersionAtStart = projectContextVersionRef.current;
|
||||
const currentEntriesCount = entries.length;
|
||||
const currentTaskId = taskId;
|
||||
|
||||
setLoadingMore(true);
|
||||
try {
|
||||
const result = await fetchAgentLogsWithMeta(currentTaskId, projectId, {
|
||||
limit: INITIAL_LOAD_LIMIT,
|
||||
offset: currentEntriesCount,
|
||||
});
|
||||
|
||||
// Reject stale response
|
||||
if (cancelledRef.current ||
|
||||
projectContextVersionRef.current !== contextVersionAtStart) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Prepend older entries to the existing list
|
||||
setEntries((prev) => {
|
||||
const combined = [...prev, ...result.entries];
|
||||
return capLogEntries(combined);
|
||||
});
|
||||
setHasMore(result.hasMore);
|
||||
setTotal(result.total);
|
||||
} catch {
|
||||
// Silently fail on load more errors
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}, [taskId, projectId, entries.length, loadingMore]);
|
||||
|
||||
const clear = useCallback(() => setEntries([]), []);
|
||||
|
||||
return { entries, loading, clear };
|
||||
return { entries, loading, clear, loadMore, hasMore, total, loadingMore };
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import type { AgentLogEntry } from "@fusion/core";
|
||||
import { fetchAgentLogs } from "../api";
|
||||
import { fetchAgentLogsWithMeta } from "../api";
|
||||
|
||||
export const MAX_LOG_ENTRIES = 500;
|
||||
const INITIAL_LOAD_LIMIT = 100;
|
||||
|
||||
/**
|
||||
* Cap the total number of log entries to `MAX_LOG_ENTRIES`.
|
||||
@@ -21,7 +22,11 @@ function capLogEntries(entries: AgentLogEntry[]): AgentLogEntry[] {
|
||||
export interface TaskLogState {
|
||||
entries: AgentLogEntry[];
|
||||
loading: boolean;
|
||||
loadingMore: boolean;
|
||||
hasMore: boolean;
|
||||
total: number | null;
|
||||
clear: () => void;
|
||||
loadMore: () => Promise<void>;
|
||||
}
|
||||
|
||||
export type LogStateMap = Record<string, TaskLogState>;
|
||||
@@ -29,18 +34,21 @@ export type LogStateMap = Record<string, TaskLogState>;
|
||||
interface InitState {
|
||||
entries: AgentLogEntry[];
|
||||
loading: boolean;
|
||||
loadingMore: boolean;
|
||||
hasMore: boolean;
|
||||
total: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that manages agent log fetching and live SSE streaming for multiple tasks.
|
||||
*
|
||||
* Features project-context isolation to prevent cross-project log bleed:
|
||||
* - Uses `{projectId, taskId}` identity for state isolation
|
||||
* - Clears all state immediately on project switch
|
||||
* - Rejects late fetch responses and SSE events from previous contexts
|
||||
* Features:
|
||||
* - **Pagination**: Initial load fetches 100 entries per task. Use `loadMore()` to fetch older entries per task.
|
||||
* - **Project-context isolation**: Prevents cross-project log bleed via context versioning.
|
||||
* - **Live streaming**: SSE events append new entries to the end of each task's list.
|
||||
*
|
||||
* For each task ID in the provided array:
|
||||
* 1. Fetches recent historical logs via GET /api/tasks/:id/logs?limit=500
|
||||
* 1. Fetches recent historical logs via GET /api/tasks/:id/logs?limit=100
|
||||
* 2. Opens an EventSource to /api/tasks/:id/logs/stream for live updates
|
||||
* 3. Merges historical + live entries in order
|
||||
*
|
||||
@@ -56,6 +64,7 @@ export function useMultiAgentLogs(taskIds: string[], projectId?: string): LogSta
|
||||
const initializingRef = useRef<Set<string>>(new Set());
|
||||
const cancelledRef = useRef<Record<string, boolean>>({});
|
||||
const pendingLiveEntriesRef = useRef<Record<string, AgentLogEntry[]>>({});
|
||||
const loadingMoreRef = useRef<Record<string, boolean>>({});
|
||||
|
||||
// Track project context version to detect stale events after project switches.
|
||||
// Incremented whenever projectId changes, invalidating any in-flight SSE handlers.
|
||||
@@ -79,6 +88,7 @@ export function useMultiAgentLogs(taskIds: string[], projectId?: string): LogSta
|
||||
initializingRef.current.clear();
|
||||
cancelledRef.current = {};
|
||||
pendingLiveEntriesRef.current = {};
|
||||
loadingMoreRef.current = {};
|
||||
|
||||
// Clear all state immediately to prevent stale data visibility
|
||||
setStateMap({});
|
||||
@@ -99,6 +109,63 @@ export function useMultiAgentLogs(taskIds: string[], projectId?: string): LogSta
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Create loadMore function for a specific task
|
||||
const createLoadMoreFn = useCallback((taskId: string, currentEntries: AgentLogEntry[]) => {
|
||||
return async () => {
|
||||
if (loadingMoreRef.current[taskId]) return;
|
||||
if (!projectContextVersionRef.current) return;
|
||||
|
||||
const contextVersionAtStart = projectContextVersionRef.current;
|
||||
loadingMoreRef.current[taskId] = true;
|
||||
|
||||
// Update loading state
|
||||
setStateMap((prev) => {
|
||||
const current = prev[taskId];
|
||||
if (!current) return prev;
|
||||
return { ...prev, [taskId]: { ...current, loadingMore: true } };
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await fetchAgentLogsWithMeta(taskId, projectId, {
|
||||
limit: INITIAL_LOAD_LIMIT,
|
||||
offset: currentEntries.length,
|
||||
});
|
||||
|
||||
// Reject stale response
|
||||
if (cancelledRef.current[taskId] ||
|
||||
projectContextVersionRef.current !== contextVersionAtStart) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Prepend older entries to the existing list
|
||||
setStateMap((prev) => {
|
||||
const current = prev[taskId];
|
||||
if (!current) return prev;
|
||||
const combined = [...current.entries, ...result.entries];
|
||||
return {
|
||||
...prev,
|
||||
[taskId]: {
|
||||
...current,
|
||||
entries: capLogEntries(combined),
|
||||
hasMore: result.hasMore,
|
||||
total: result.total,
|
||||
loadingMore: false,
|
||||
},
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
// Silently fail on load more errors
|
||||
setStateMap((prev) => {
|
||||
const current = prev[taskId];
|
||||
if (!current) return prev;
|
||||
return { ...prev, [taskId]: { ...current, loadingMore: false } };
|
||||
});
|
||||
} finally {
|
||||
loadingMoreRef.current[taskId] = false;
|
||||
}
|
||||
};
|
||||
}, [projectId]);
|
||||
|
||||
// Stable comparison of task IDs and projectId to prevent effect re-runs on every render
|
||||
const taskIdsKey = taskIds.join(",");
|
||||
const stableKey = [taskIdsKey, projectId ?? ""].join("|");
|
||||
@@ -127,7 +194,7 @@ export function useMultiAgentLogs(taskIds: string[], projectId?: string): LogSta
|
||||
const updates: Record<string, InitState> = {};
|
||||
for (const taskId of newTaskIds) {
|
||||
if (!prev[taskId]) {
|
||||
updates[taskId] = { entries: [], loading: true };
|
||||
updates[taskId] = { entries: [], loading: true, loadingMore: false, hasMore: false, total: null };
|
||||
}
|
||||
}
|
||||
if (Object.keys(updates).length === 0) return prev;
|
||||
@@ -145,6 +212,7 @@ export function useMultiAgentLogs(taskIds: string[], projectId?: string): LogSta
|
||||
initializing.delete(taskId);
|
||||
delete cancelled[taskId];
|
||||
delete pendingLiveEntriesRef.current[taskId];
|
||||
delete loadingMoreRef.current[taskId];
|
||||
removedTaskIds.push(taskId);
|
||||
}
|
||||
}
|
||||
@@ -176,6 +244,7 @@ export function useMultiAgentLogs(taskIds: string[], projectId?: string): LogSta
|
||||
cancelled[taskId] = true;
|
||||
initializing.delete(taskId);
|
||||
delete pendingLiveEntriesRef.current[taskId];
|
||||
delete loadingMoreRef.current[taskId];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,7 +281,11 @@ export function useMultiAgentLogs(taskIds: string[], projectId?: string): LogSta
|
||||
if (!current) return prev;
|
||||
return {
|
||||
...prev,
|
||||
[taskId]: { ...current, entries: capLogEntries([...current.entries, entry]) },
|
||||
[taskId]: {
|
||||
...current,
|
||||
entries: capLogEntries([...current.entries, entry]),
|
||||
total: current.total !== null ? current.total + 1 : null,
|
||||
},
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
@@ -235,9 +308,9 @@ export function useMultiAgentLogs(taskIds: string[], projectId?: string): LogSta
|
||||
es.addEventListener("agent:log", handleAgentLog);
|
||||
es.addEventListener("error", handleError);
|
||||
|
||||
// Fetch historical logs with projectId
|
||||
void fetchAgentLogs(taskId, projectId, { limit: MAX_LOG_ENTRIES })
|
||||
.then((historical) => {
|
||||
// Fetch historical logs with projectId using pagination
|
||||
void fetchAgentLogsWithMeta(taskId, projectId, { limit: INITIAL_LOAD_LIMIT })
|
||||
.then((result) => {
|
||||
// Reject stale response from previous context
|
||||
if (cancelled[taskId] ||
|
||||
projectContextVersionRef.current !== contextVersionAtStart) {
|
||||
@@ -249,8 +322,10 @@ export function useMultiAgentLogs(taskIds: string[], projectId?: string): LogSta
|
||||
...prev,
|
||||
[taskId]: {
|
||||
...prev[taskId],
|
||||
entries: capLogEntries([...historical, ...pendingLive]),
|
||||
entries: capLogEntries([...result.entries, ...pendingLive]),
|
||||
loading: false,
|
||||
hasMore: result.hasMore,
|
||||
total: result.total,
|
||||
},
|
||||
}));
|
||||
})
|
||||
@@ -264,7 +339,13 @@ export function useMultiAgentLogs(taskIds: string[], projectId?: string): LogSta
|
||||
const pendingLive = pendingLiveEntriesRef.current[taskId] ?? [];
|
||||
setStateMap((prev) => ({
|
||||
...prev,
|
||||
[taskId]: { ...prev[taskId], entries: capLogEntries(pendingLive), loading: false },
|
||||
[taskId]: {
|
||||
...prev[taskId],
|
||||
entries: capLogEntries(pendingLive),
|
||||
loading: false,
|
||||
hasMore: false,
|
||||
total: null,
|
||||
},
|
||||
}));
|
||||
})
|
||||
.finally(() => {
|
||||
@@ -278,7 +359,7 @@ export function useMultiAgentLogs(taskIds: string[], projectId?: string): LogSta
|
||||
|
||||
// Cleanup on effect re-run or unmount
|
||||
return () => {
|
||||
// Only close connections for tasks that were removed (not in current taskIds)
|
||||
// Only close connections for tasks that were removed (not-in current taskIds)
|
||||
for (const taskId of initialTaskIds) {
|
||||
if (!currentIds.has(taskId)) {
|
||||
cancelledRef.current[taskId] = true;
|
||||
@@ -310,6 +391,7 @@ export function useMultiAgentLogs(taskIds: string[], projectId?: string): LogSta
|
||||
initializingRef.current.clear();
|
||||
cancelledRef.current = {};
|
||||
pendingLiveEntriesRef.current = {};
|
||||
loadingMoreRef.current = {};
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -317,10 +399,15 @@ export function useMultiAgentLogs(taskIds: string[], projectId?: string): LogSta
|
||||
const result: LogStateMap = {};
|
||||
for (const taskId of taskIds) {
|
||||
const state = stateMap[taskId];
|
||||
const entries = state?.entries ?? [];
|
||||
result[taskId] = {
|
||||
entries: state?.entries ?? [],
|
||||
entries,
|
||||
loading: state?.loading ?? true,
|
||||
loadingMore: state?.loadingMore ?? false,
|
||||
hasMore: state?.hasMore ?? false,
|
||||
total: state?.total ?? null,
|
||||
clear: createClearFn(taskId),
|
||||
loadMore: createLoadMoreFn(taskId, entries),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -4036,6 +4036,26 @@ body {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* Agent log pagination summary */
|
||||
.agent-log-summary {
|
||||
padding: var(--space-xs) var(--space-md);
|
||||
font-size: var(--text-xs, 12px);
|
||||
color: var(--text-muted, #8b949e);
|
||||
border-bottom: 1px solid var(--border);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Agent log load more button */
|
||||
.agent-log-load-more {
|
||||
padding: var(--space-md);
|
||||
text-align: center;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.agent-log-load-more .agent-log-mode-toggle {
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
/* Fullscreen mode for agent log viewer */
|
||||
.agent-log-viewer--fullscreen {
|
||||
position: fixed;
|
||||
|
||||
Reference in New Issue
Block a user