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:
Fusion
2026-04-15 02:53:06 -07:00
committed by gsxdsm
parent d2bf84538f
commit 0d6535a952
11 changed files with 594 additions and 131 deletions

View File

@@ -3734,20 +3734,76 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
* (`MAX_LOG_ENTRIES`) in the dashboard hooks is a whole-list limit only. * (`MAX_LOG_ENTRIES`) in the dashboard hooks is a whole-list limit only.
* *
* @param taskId - The task ID (e.g. "KB-001") * @param taskId - The task ID (e.g. "KB-001")
* @param options - Optional pagination options
* @param options.limit - Maximum number of entries to return (most recent)
* @param options.offset - Number of most-recent entries to skip (for pagination)
* @returns Array of agent log entries, empty if no log file exists * @returns Array of agent log entries, empty if no log file exists
*/ */
async getAgentLogs(taskId: string, options?: { limit?: number }): Promise<AgentLogEntry[]> { async getAgentLogs(
taskId: string,
options?: { limit?: number; offset?: number },
): Promise<AgentLogEntry[]> {
const dir = this.taskDir(taskId); const dir = this.taskDir(taskId);
const logPath = join(dir, "agent.log"); const logPath = join(dir, "agent.log");
if (!existsSync(logPath)) return []; if (!existsSync(logPath)) return [];
if (options?.limit !== undefined) {
const limit = Number.isFinite(options.limit) ? Math.max(0, Math.floor(options.limit)) : 0; const limit = options?.limit !== undefined
? (Number.isFinite(options.limit) ? Math.max(0, Math.floor(options.limit)) : 0)
: undefined;
const offset = options?.offset !== undefined
? (Number.isFinite(options.offset) ? Math.max(0, Math.floor(options.offset)) : 0)
: 0;
// If limit is specified, use readAgentLogTail for efficiency
if (limit !== undefined) {
if (limit === 0) return []; if (limit === 0) return [];
return this.readAgentLogTail(logPath, limit); // When offset is provided, read limit + offset entries and slice off the first offset
const readCount = offset > 0 ? limit + offset : limit;
const entries = await this.readAgentLogTailAsync(logPath, readCount);
if (offset > 0) {
// Slice off the first 'offset' entries (oldest in the returned batch)
// This skips the most recent entries to get older entries
return entries.slice(offset);
}
return entries;
} }
// No limit specified - read entire file
const content = await readFile(logPath, "utf-8"); const content = await readFile(logPath, "utf-8");
return this.parseAgentLogContent(content); const entries = this.parseAgentLogContent(content);
if (offset > 0) {
return entries.slice(0, -offset);
}
return entries;
}
/**
* Async version of readAgentLogTail that reads entries from end of file.
* Returns entries in chronological order (oldest first).
* Uses async file operations for consistency with the rest of the codebase.
*/
private async readAgentLogTailAsync(logPath: string, limit: number): Promise<AgentLogEntry[]> {
const content = await readFile(logPath, "utf-8");
const allEntries = this.parseAgentLogContent(content);
return allEntries.slice(-limit);
}
/**
* Count total number of log entries in the agent log file.
* Uses efficient newline counting to avoid parsing entire file.
*
* @param taskId - The task ID (e.g. "KB-001")
* @returns Total number of log entries, or 0 if no log file exists
*/
async getAgentLogCount(taskId: string): Promise<number> {
const dir = this.taskDir(taskId);
const logPath = join(dir, "agent.log");
if (!existsSync(logPath)) return 0;
// Count newlines efficiently - each entry is a JSON line ending with \n
const content = await readFile(logPath, "utf-8");
if (!content.trim()) return 0;
return content.split("\n").filter((line) => line.trim()).length;
} }
/** /**

View File

@@ -381,15 +381,62 @@ export async function deleteAttachment(id: string, filename: string, projectId?:
return api<Task>(withProjectId(`/tasks/${id}/attachments/${filename}`, projectId), { method: "DELETE" }); 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(); const params = new URLSearchParams();
if (options?.limit !== undefined) { if (options?.limit !== undefined) {
params.set("limit", String(options.limit)); params.set("limit", String(options.limit));
} }
if (options?.offset !== undefined) {
params.set("offset", String(options.offset));
}
const suffix = params.toString() ? `?${params.toString()}` : ""; const suffix = params.toString() ? `?${params.toString()}` : "";
return api<AgentLogEntry[]>(withProjectId(`/tasks/${taskId}/logs${suffix}`, projectId)); 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[]> { export function fetchSessionFiles(taskId: string, projectId?: string): Promise<string[]> {
return api<string[]>(withProjectId(`/tasks/${taskId}/session-files`, projectId)); return api<string[]>(withProjectId(`/tasks/${taskId}/session-files`, projectId));
} }

View File

@@ -6,7 +6,7 @@ import {
ChevronDown, ChevronRight, BarChart3, Star, BookOpen ChevronDown, ChevronRight, BarChart3, Star, BookOpen
} from "lucide-react"; } from "lucide-react";
import type { AgentDetail, AgentState, AgentHeartbeatRun, AgentBudgetStatus } from "../api"; 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 { Agent } from "../api";
import type { AgentLogEntry, Task } from "@fusion/core"; import type { AgentLogEntry, Task } from "@fusion/core";
import { AgentLogViewer } from "./AgentLogViewer"; 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 the agent is working on a task, we could show task logs.
if (agent?.taskId) { if (agent?.taskId) {
try { 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 // Reject stale response: check context version and current IDs
if (contextVersionRef.current !== contextVersionAtCapture || if (contextVersionRef.current !== contextVersionAtCapture ||
agentId !== currentAgentId || agentId !== currentAgentId ||
projectId !== currentProjectId) { projectId !== currentProjectId) {
return; return;
} }
setLogs(data); setLogs(result.entries);
} catch (err: any) { } catch (err: any) {
// Reject stale error: check context version and current IDs // Reject stale error: check context version and current IDs
if (contextVersionRef.current !== contextVersionAtCapture || if (contextVersionRef.current !== contextVersionAtCapture ||

View File

@@ -4,7 +4,7 @@ import { useRef, useEffect, useState, useCallback } from "react";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
import type { Components } from "react-markdown"; import type { Components } from "react-markdown";
import { Maximize2, Minimize2 } from "lucide-react"; import { Maximize2, Minimize2, Loader2 } from "lucide-react";
function formatTimestamp(iso: string): string { function formatTimestamp(iso: string): string {
const date = new Date(iso); const date = new Date(iso);
@@ -60,15 +60,44 @@ interface AgentLogViewerProps {
executorModel?: ModelInfo | null; executorModel?: ModelInfo | null;
validatorModel?: ModelInfo | null; validatorModel?: ModelInfo | null;
planningModel?: 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. * 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. * Features:
* Supports toggling between markdown-formatted and plain-text rendering. * - 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 containerRef = useRef<HTMLDivElement>(null);
const previousEntryCountRef = useRef<number>(0); const previousEntryCountRef = useRef<number>(0);
const [renderMarkdown, setRenderMarkdown] = useState(true); const [renderMarkdown, setRenderMarkdown] = useState(true);
@@ -198,6 +227,14 @@ export function AgentLogViewer({ entries, loading, executorModel, validatorModel
</button> </button>
</div> </div>
</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) => { {reversedEntries.map((entry, i) => {
// Look at previous entry in reversed array (= next chronologically) for deduplication // Look at previous entry in reversed array (= next chronologically) for deduplication
const prev = reversedEntries[i - 1]; const prev = reversedEntries[i - 1];
@@ -275,6 +312,27 @@ export function AgentLogViewer({ entries, loading, executorModel, validatorModel
</span> </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> </div>
); );
} }

View File

@@ -594,7 +594,14 @@ export function TaskDetailModal({
}, [isEditing, handleEditKeyDown]); }, [isEditing, handleEditKeyDown]);
const fileInputRef = useRef<HTMLInputElement>(null); 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, task.id,
activeTab === "logs" && logSubview === "agent-log", activeTab === "logs" && logSubview === "agent-log",
projectId, projectId,
@@ -1194,6 +1201,10 @@ export function TaskDetailModal({
executorModel={resolveEffectiveExecutor(task, settings)} executorModel={resolveEffectiveExecutor(task, settings)}
validatorModel={resolveEffectiveValidator(task, settings)} validatorModel={resolveEffectiveValidator(task, settings)}
planningModel={resolveEffectivePlanning(task, agentLogEntries, settings)} planningModel={resolveEffectivePlanning(task, agentLogEntries, settings)}
hasMore={agentLogHasMore}
onLoadMore={loadMoreAgentLogs}
loadingMore={agentLogLoadingMore}
totalCount={agentLogTotal}
/> />
) : ( ) : (
<div className="detail-activity"> <div className="detail-activity">

View File

@@ -1,14 +1,14 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act, waitFor } from "@testing-library/react"; import { renderHook, act, waitFor } from "@testing-library/react";
import { MAX_LOG_ENTRIES, useAgentLogs } from "../useAgentLogs"; import { MAX_LOG_ENTRIES, useAgentLogs } from "../useAgentLogs";
import { fetchAgentLogs } from "../../api"; import { fetchAgentLogsWithMeta } from "../../api";
// Mock the api module // Mock the api module
vi.mock("../../api", () => ({ 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 // Mock EventSource
class MockEventSource { class MockEventSource {
@@ -41,10 +41,12 @@ class MockEventSource {
const originalEventSource = globalThis.EventSource; const originalEventSource = globalThis.EventSource;
const INITIAL_LOAD_LIMIT = 100;
beforeEach(() => { beforeEach(() => {
MockEventSource.instances = []; MockEventSource.instances = [];
(globalThis as any).EventSource = MockEventSource; (globalThis as any).EventSource = MockEventSource;
mockFetchAgentLogs.mockReset().mockResolvedValue([]); mockFetchAgentLogsWithMeta.mockReset().mockResolvedValue({ entries: [], total: 0, hasMore: false });
}); });
afterEach(() => { afterEach(() => {
@@ -55,7 +57,7 @@ describe("useAgentLogs", () => {
it("does not fetch or connect when enabled=false", () => { it("does not fetch or connect when enabled=false", () => {
const { result } = renderHook(() => useAgentLogs("FN-001", false)); const { result } = renderHook(() => useAgentLogs("FN-001", false));
expect(mockFetchAgentLogs).not.toHaveBeenCalled(); expect(mockFetchAgentLogsWithMeta).not.toHaveBeenCalled();
expect(MockEventSource.instances).toHaveLength(0); expect(MockEventSource.instances).toHaveLength(0);
expect(result.current.entries).toEqual([]); expect(result.current.entries).toEqual([]);
}); });
@@ -64,7 +66,11 @@ describe("useAgentLogs", () => {
const historicalLogs = [ const historicalLogs = [
{ timestamp: "2026-01-01T00:00:00Z", taskId: "FN-001", text: "old", type: "text" as const }, { 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)); const { result } = renderHook(() => useAgentLogs("FN-001", true));
@@ -72,15 +78,34 @@ describe("useAgentLogs", () => {
expect(result.current.entries).toEqual(historicalLogs); 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).toHaveLength(1);
expect(MockEventSource.instances[0].url).toBe("/api/tasks/FN-001/logs/stream"); 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 () => { it("appends live SSE entries to historical entries", async () => {
mockFetchAgentLogs.mockResolvedValueOnce([ mockFetchAgentLogsWithMeta.mockResolvedValueOnce({
{ timestamp: "2026-01-01T00:00:00Z", taskId: "FN-001", text: "old", type: "text" as const }, 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)); const { result } = renderHook(() => useAgentLogs("FN-001", true));
@@ -103,7 +128,7 @@ describe("useAgentLogs", () => {
}); });
it("closes SSE when enabled changes to false", async () => { it("closes SSE when enabled changes to false", async () => {
mockFetchAgentLogs.mockResolvedValueOnce([]); mockFetchAgentLogsWithMeta.mockResolvedValueOnce({ entries: [], total: 0, hasMore: false });
const { rerender } = renderHook( const { rerender } = renderHook(
({ enabled }) => useAgentLogs("FN-001", enabled), ({ enabled }) => useAgentLogs("FN-001", enabled),
@@ -122,7 +147,7 @@ describe("useAgentLogs", () => {
}); });
it("closes SSE on unmount", async () => { it("closes SSE on unmount", async () => {
mockFetchAgentLogs.mockResolvedValueOnce([]); mockFetchAgentLogsWithMeta.mockResolvedValueOnce({ entries: [], total: 0, hasMore: false });
const { unmount } = renderHook(() => useAgentLogs("FN-001", true)); const { unmount } = renderHook(() => useAgentLogs("FN-001", true));
@@ -144,7 +169,11 @@ describe("useAgentLogs", () => {
text: `entry-${index}`, text: `entry-${index}`,
type: "text" as const, type: "text" as const,
})); }));
mockFetchAgentLogs.mockResolvedValueOnce(historicalLogs); mockFetchAgentLogsWithMeta.mockResolvedValueOnce({
entries: historicalLogs,
total: historicalLogs.length,
hasMore: false,
});
const { result } = renderHook(() => useAgentLogs("FN-001", true)); const { result } = renderHook(() => useAgentLogs("FN-001", true));
@@ -157,7 +186,7 @@ describe("useAgentLogs", () => {
}); });
it("truncates live SSE entries to the most recent entries", async () => { 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)); const { result } = renderHook(() => useAgentLogs("FN-001", true));
@@ -188,17 +217,21 @@ describe("useAgentLogs", () => {
it("does not fetch when taskId is null", () => { it("does not fetch when taskId is null", () => {
renderHook(() => useAgentLogs(null, true)); renderHook(() => useAgentLogs(null, true));
expect(mockFetchAgentLogs).not.toHaveBeenCalled(); expect(mockFetchAgentLogsWithMeta).not.toHaveBeenCalled();
expect(MockEventSource.instances).toHaveLength(0); expect(MockEventSource.instances).toHaveLength(0);
}); });
it("preserves long text and detail in historical log entries without truncation", async () => { it("preserves long text and detail in historical log entries without truncation", async () => {
const longText = "A".repeat(5000); const longText = "A".repeat(5000);
const longDetail = "B".repeat(5000); const longDetail = "B".repeat(5000);
mockFetchAgentLogs.mockResolvedValueOnce([ mockFetchAgentLogsWithMeta.mockResolvedValueOnce({
{ timestamp: "2026-01-01T00:00:00Z", taskId: "FN-001", text: longText, type: "text" as const }, entries: [
{ timestamp: "2026-01-01T00:00:01Z", taskId: "FN-001", text: "Read", type: "tool" as const, detail: longDetail }, { 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)); 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 () => { 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)); const { result } = renderHook(() => useAgentLogs("FN-001", true));
@@ -244,9 +277,67 @@ describe("useAgentLogs", () => {
expect(result.current.entries[0].detail!.length).toBe(5000); 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", () => { describe("projectId support", () => {
it("includes projectId in EventSource URL when provided", async () => { 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")); renderHook(() => useAgentLogs("FN-001", true, "proj-123"));
@@ -256,18 +347,18 @@ describe("useAgentLogs", () => {
}); });
}); });
it("includes projectId in fetchAgentLogs call when provided", async () => { it("includes projectId in fetchAgentLogsWithMeta call when provided", async () => {
mockFetchAgentLogs.mockResolvedValueOnce([]); mockFetchAgentLogsWithMeta.mockResolvedValueOnce({ entries: [], total: 0, hasMore: false });
renderHook(() => useAgentLogs("FN-001", true, "proj-123")); renderHook(() => useAgentLogs("FN-001", true, "proj-123"));
await waitFor(() => { 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 () => { 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)); renderHook(() => useAgentLogs("FN-001", true));
@@ -279,18 +370,22 @@ describe("useAgentLogs", () => {
it("clears entries immediately when projectId changes", async () => { it("clears entries immediately when projectId changes", async () => {
// Set up mock to return different values based on projectId // 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") { if (projectId === "proj-A") {
return Promise.resolve([ return Promise.resolve({
{ timestamp: "2026-01-01T00:00:00Z", taskId: "FN-001", text: "proj-A-log", type: "text" as const }, 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") { if (projectId === "proj-B") {
return Promise.resolve([ return Promise.resolve({
{ timestamp: "2026-01-01T00:00:00Z", taskId: "FN-001", text: "proj-B-log", type: "text" as const }, 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 // Create a hook that switches project
@@ -322,7 +417,7 @@ describe("useAgentLogs", () => {
it("rejects stale SSE events after project switch", async () => { it("rejects stale SSE events after project switch", async () => {
// Initial render with proj-A // Initial render with proj-A
mockFetchAgentLogs.mockResolvedValue([]); mockFetchAgentLogsWithMeta.mockResolvedValue({ entries: [], total: 0, hasMore: false });
const { result, rerender } = renderHook( const { result, rerender } = renderHook(
({ projectId }) => useAgentLogs("FN-001", true, projectId), ({ projectId }) => useAgentLogs("FN-001", true, projectId),
@@ -366,7 +461,7 @@ describe("useAgentLogs", () => {
}); });
it("creates new connection with new projectId on project switch", async () => { it("creates new connection with new projectId on project switch", async () => {
mockFetchAgentLogs.mockResolvedValue([]); mockFetchAgentLogsWithMeta.mockResolvedValue({ entries: [], total: 0, hasMore: false });
const { rerender } = renderHook( const { rerender } = renderHook(
({ projectId }) => useAgentLogs("FN-001", true, projectId), ({ projectId }) => useAgentLogs("FN-001", true, projectId),

View File

@@ -16,15 +16,17 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act, waitFor } from "@testing-library/react"; import { renderHook, act, waitFor } from "@testing-library/react";
import { MAX_LOG_ENTRIES, useMultiAgentLogs } from "../useMultiAgentLogs"; import { MAX_LOG_ENTRIES, useMultiAgentLogs } from "../useMultiAgentLogs";
import { fetchAgentLogs } from "../../api"; import { fetchAgentLogsWithMeta } from "../../api";
import { MockEventSource } from "../../../vitest.setup"; import { MockEventSource } from "../../../vitest.setup";
// Mock the api module // Mock the api module
vi.mock("../../api", () => ({ 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 // Helper to get the last connection for a specific task ID
function getConnection(taskId: string): MockEventSource | undefined { function getConnection(taskId: string): MockEventSource | undefined {
@@ -41,7 +43,7 @@ function getConnections(taskId: string): MockEventSource[] {
beforeEach(() => { beforeEach(() => {
MockEventSource.instances = []; MockEventSource.instances = [];
mockFetchAgentLogs.mockReset().mockResolvedValue([]); mockFetchAgentLogsWithMeta.mockReset().mockResolvedValue({ entries: [], total: 0, hasMore: false });
// Ensure we start with real timers for every test // Ensure we start with real timers for every test
vi.useRealTimers(); vi.useRealTimers();
@@ -85,10 +87,10 @@ describe("useMultiAgentLogs", () => {
{ timestamp: "2026-01-01T00:00:00Z", taskId: "FN-002", text: "log2", type: "text" as const }, { timestamp: "2026-01-01T00:00:00Z", taskId: "FN-002", text: "log2", type: "text" as const },
]; ];
mockFetchAgentLogs.mockImplementation((taskId) => { mockFetchAgentLogsWithMeta.mockImplementation((taskId) => {
if (taskId === "FN-001") return Promise.resolve(logs1); if (taskId === "FN-001") return Promise.resolve({ entries: logs1, total: logs1.length, hasMore: false });
if (taskId === "FN-002") return Promise.resolve(logs2); if (taskId === "FN-002") return Promise.resolve({ entries: logs2, total: logs2.length, hasMore: false });
return Promise.resolve([]); return Promise.resolve({ entries: [], total: 0, hasMore: false });
}); });
const { result } = renderHook(() => useMultiAgentLogs(["FN-001", "FN-002"])); const { result } = renderHook(() => useMultiAgentLogs(["FN-001", "FN-002"]));
@@ -98,12 +100,12 @@ describe("useMultiAgentLogs", () => {
expect(result.current["FN-002"].entries).toEqual(logs2); expect(result.current["FN-002"].entries).toEqual(logs2);
}); });
expect(mockFetchAgentLogs).toHaveBeenCalledWith("FN-001", undefined, { limit: 500 }); expect(mockFetchAgentLogsWithMeta).toHaveBeenCalledWith("FN-001", undefined, { limit: INITIAL_LOAD_LIMIT });
expect(mockFetchAgentLogs).toHaveBeenCalledWith("FN-002", undefined, { limit: 500 }); expect(mockFetchAgentLogsWithMeta).toHaveBeenCalledWith("FN-002", undefined, { limit: INITIAL_LOAD_LIMIT });
}); });
it("opens SSE EventSource for each task ID", async () => { it("opens SSE EventSource for each task ID", async () => {
mockFetchAgentLogs.mockResolvedValue([]); mockFetchAgentLogsWithMeta.mockResolvedValue({ entries: [], total: 0, hasMore: false });
renderHook(() => useMultiAgentLogs(["FN-001", "FN-002"])); 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 }, { 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 // 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"])); const { result } = renderHook(() => useMultiAgentLogs(["FN-001"]));
@@ -148,7 +150,7 @@ describe("useMultiAgentLogs", () => {
}); });
it("closes all SSE connections on unmount (memory leak prevention)", async () => { 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"])); 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 () => { it("closes specific connection when task ID removed from array", async () => {
mockFetchAgentLogs.mockResolvedValue([]); mockFetchAgentLogsWithMeta.mockResolvedValue({ entries: [], total: 0, hasMore: false });
const { rerender } = renderHook( const { rerender } = renderHook(
({ taskIds }: { taskIds: string[] }) => useMultiAgentLogs(taskIds), ({ taskIds }: { taskIds: string[] }) => useMultiAgentLogs(taskIds),
@@ -199,7 +201,7 @@ describe("useMultiAgentLogs", () => {
}); });
it("opens new connection when task ID added to array", async () => { it("opens new connection when task ID added to array", async () => {
mockFetchAgentLogs.mockResolvedValue([]); mockFetchAgentLogsWithMeta.mockResolvedValue({ entries: [], total: 0, hasMore: false });
const { rerender } = renderHook( const { rerender } = renderHook(
({ taskIds }: { taskIds: string[] }) => useMultiAgentLogs(taskIds), ({ 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 }, { 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 // 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"])); const { result } = renderHook(() => useMultiAgentLogs(["FN-001", "FN-002"]));
@@ -243,7 +245,7 @@ describe("useMultiAgentLogs", () => {
}); });
it("handles errors gracefully when fetching historical logs", async () => { 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"])); 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 () => { it("does not create duplicate connections while historical fetch is still pending", async () => {
let resolveFetch: ((value: never[]) => void) | undefined; let resolveFetch: ((value: { entries: never[]; total: number; hasMore: boolean }) => void) | undefined;
mockFetchAgentLogs.mockImplementation( mockFetchAgentLogsWithMeta.mockImplementation(
() => new Promise((resolve) => { () => new Promise((resolve) => {
resolveFetch = resolve; resolveFetch = resolve;
}), }),
@@ -281,15 +283,15 @@ describe("useMultiAgentLogs", () => {
// Should not create additional connections on rerender with same IDs // Should not create additional connections on rerender with same IDs
expect(getConnections("FN-001").length).toBe(initialCount); expect(getConnections("FN-001").length).toBe(initialCount);
resolveFetch?.([]); resolveFetch?.({ entries: [], total: 0, hasMore: false });
await waitFor(() => { await waitFor(() => {
expect(mockFetchAgentLogs).toHaveBeenCalledTimes(1); expect(mockFetchAgentLogsWithMeta).toHaveBeenCalledTimes(1);
}); });
}); });
it("closes a task connection when its stream emits an error", async () => { 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"])); renderHook(() => useMultiAgentLogs(["FN-001"]));
@@ -315,7 +317,7 @@ describe("useMultiAgentLogs", () => {
type: "text" as const, type: "text" as const,
})); }));
mockFetchAgentLogs.mockResolvedValue(oversized); mockFetchAgentLogsWithMeta.mockResolvedValue({ entries: oversized, total: oversized.length, hasMore: false });
const { result } = renderHook(() => useMultiAgentLogs(["FN-001"])); const { result } = renderHook(() => useMultiAgentLogs(["FN-001"]));
@@ -328,8 +330,8 @@ describe("useMultiAgentLogs", () => {
}); });
it("preserves streamed entries that arrive before historical fetch resolves", async () => { 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; let resolveFetch: ((value: { entries: Array<{ timestamp: string; taskId: string; text: string; type: "text" }>; total: number; hasMore: boolean }) => void) | undefined;
mockFetchAgentLogs.mockImplementation( mockFetchAgentLogsWithMeta.mockImplementation(
() => new Promise((resolve) => { () => new Promise((resolve) => {
resolveFetch = resolve; resolveFetch = resolve;
}), }),
@@ -354,14 +356,18 @@ describe("useMultiAgentLogs", () => {
}); });
act(() => { act(() => {
resolveFetch?.([ resolveFetch?.({
{ entries: [
timestamp: "2026-01-01T00:00:00Z", {
taskId: "FN-001", timestamp: "2026-01-01T00:00:00Z",
text: "historical", taskId: "FN-001",
type: "text", text: "historical",
}, type: "text",
]); },
],
total: 2,
hasMore: false,
});
}); });
await waitFor(() => { await waitFor(() => {
@@ -373,7 +379,7 @@ describe("useMultiAgentLogs", () => {
}); });
it("truncates live SSE entries per task to the most recent entries", async () => { 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"])); 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 }, { timestamp: "2026-01-01T00:00:00Z", taskId: "FN-002", text: "task2-old", type: "text" as const },
]; ];
mockFetchAgentLogs.mockImplementation((taskId) => { mockFetchAgentLogsWithMeta.mockImplementation((taskId) => {
if (taskId === "FN-001") return Promise.resolve(logs1); if (taskId === "FN-001") return Promise.resolve({ entries: logs1, total: logs1.length, hasMore: false });
if (taskId === "FN-002") return Promise.resolve(logs2); if (taskId === "FN-002") return Promise.resolve({ entries: logs2, total: logs2.length, hasMore: false });
return Promise.resolve([]); return Promise.resolve({ entries: [], total: 0, hasMore: false });
}); });
const { result } = renderHook(() => useMultiAgentLogs(["FN-001", "FN-002"])); 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 () => { it("preserves long text and detail in historical log entries without truncation", async () => {
const longText = "A".repeat(5000); const longText = "A".repeat(5000);
const longDetail = "B".repeat(5000); const longDetail = "B".repeat(5000);
mockFetchAgentLogs.mockResolvedValue([ mockFetchAgentLogsWithMeta.mockResolvedValue({
{ timestamp: "2026-01-01T00:00:00Z", taskId: "FN-001", text: longText, type: "text" as const }, entries: [
{ timestamp: "2026-01-01T00:00:01Z", taskId: "FN-001", text: "Read", type: "tool" as const, detail: longDetail }, { 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"])); 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 () => { 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"])); const { result } = renderHook(() => useMultiAgentLogs(["FN-001"]));
@@ -517,7 +527,7 @@ describe("useMultiAgentLogs", () => {
describe("projectId support", () => { describe("projectId support", () => {
it("includes projectId in EventSource URL when provided", async () => { 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")); renderHook(() => useMultiAgentLogs(["FN-001", "FN-002"], "proj-123"));
@@ -528,18 +538,18 @@ describe("useMultiAgentLogs", () => {
}); });
}); });
it("includes projectId in fetchAgentLogs call when provided", async () => { it("includes projectId in fetchAgentLogsWithMeta call when provided", async () => {
mockFetchAgentLogs.mockResolvedValue([]); mockFetchAgentLogsWithMeta.mockResolvedValue({ entries: [], total: 0, hasMore: false });
renderHook(() => useMultiAgentLogs(["FN-001"], "proj-123")); renderHook(() => useMultiAgentLogs(["FN-001"], "proj-123"));
await waitFor(() => { 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 () => { it("does not include projectId in URL when not provided", async () => {
mockFetchAgentLogs.mockResolvedValue([]); mockFetchAgentLogsWithMeta.mockResolvedValue({ entries: [], total: 0, hasMore: false });
renderHook(() => useMultiAgentLogs(["FN-001"])); renderHook(() => useMultiAgentLogs(["FN-001"]));
@@ -550,7 +560,7 @@ describe("useMultiAgentLogs", () => {
}); });
it("creates new EventSource when taskIds change with projectId", async () => { it("creates new EventSource when taskIds change with projectId", async () => {
mockFetchAgentLogs.mockResolvedValue([]); mockFetchAgentLogsWithMeta.mockResolvedValue({ entries: [], total: 0, hasMore: false });
const { rerender } = renderHook( const { rerender } = renderHook(
({ taskIds, projectId }: { taskIds: string[]; projectId?: string }) => ({ taskIds, projectId }: { taskIds: string[]; projectId?: string }) =>
@@ -578,7 +588,7 @@ describe("useMultiAgentLogs", () => {
it("fetches with correct projectId based on when effect runs", async () => { it("fetches with correct projectId based on when effect runs", async () => {
// This test verifies that projectId is used at the time the effect runs // 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 // Render with projectId proj-A
const { result: result1 } = renderHook( const { result: result1 } = renderHook(
@@ -593,7 +603,7 @@ describe("useMultiAgentLogs", () => {
}); });
// Capture calls made so far // Capture calls made so far
const initialCallCount = mockFetchAgentLogs.mock.calls.length; const initialCallCount = mockFetchAgentLogsWithMeta.mock.calls.length;
// Create new hook instance with proj-B // Create new hook instance with proj-B
const { result: result2, rerender: rerender2 } = renderHook( const { result: result2, rerender: rerender2 } = renderHook(
@@ -608,8 +618,8 @@ describe("useMultiAgentLogs", () => {
}); });
// The new hook should have made a fetch with proj-B // The new hook should have made a fetch with proj-B
expect(mockFetchAgentLogs.mock.calls.length).toBeGreaterThan(initialCallCount); expect(mockFetchAgentLogsWithMeta.mock.calls.length).toBeGreaterThan(initialCallCount);
const lastCall = mockFetchAgentLogs.mock.calls.at(-1); const lastCall = mockFetchAgentLogsWithMeta.mock.calls.at(-1);
expect(lastCall?.[1]).toBe("proj-B"); expect(lastCall?.[1]).toBe("proj-B");
}); });
}); });

View File

@@ -1,8 +1,9 @@
import { useState, useEffect, useRef, useCallback } from "react"; import { useState, useEffect, useRef, useCallback } from "react";
import type { AgentLogEntry } from "@fusion/core"; import type { AgentLogEntry } from "@fusion/core";
import { fetchAgentLogs } from "../api"; import { fetchAgentLogsWithMeta } from "../api";
export const MAX_LOG_ENTRIES = 500; export const MAX_LOG_ENTRIES = 500;
const INITIAL_LOAD_LIMIT = 100;
/** /**
* Cap the total number of log entries to `MAX_LOG_ENTRIES`. * 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. * Hook that manages agent log fetching and live SSE streaming for a task.
* *
* Features project-context isolation to prevent cross-project log bleed: * Features:
* - Treats `{projectId, taskId}` as a context key * - **Pagination**: Initial load fetches 100 entries. Use `loadMore()` to fetch older entries.
* - Clears entries immediately on context change (project or task switch) * - **Project-context isolation**: Prevents cross-project log bleed via context versioning.
* - Rejects late fetch responses from previous contexts * - **Live streaming**: SSE events append new entries to the end of the list.
* - Rejects stale SSE events from previous EventSource instances *
* **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: * 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 * 2. Opens an EventSource to /api/tasks/:id/logs/stream for live updates
* 3. Merges historical + live entries in order * 3. Merges historical + live entries in order
* *
* When `enabled` becomes false or the component unmounts, the EventSource * When `enabled` becomes false or the component unmounts, the EventSource
* is closed to avoid unnecessary SSE connections. * 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) { export function useAgentLogs(taskId: string | null, enabled: boolean, projectId?: string) {
const [entries, setEntries] = useState<AgentLogEntry[]>([]); const [entries, setEntries] = useState<AgentLogEntry[]>([]);
const [loading, setLoading] = useState(false); 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 // Refs for state that needs to survive re-renders
const eventSourceRef = useRef<EventSource | null>(null); 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 // Clear entries immediately on context change to prevent stale data visibility
setEntries([]); setEntries([]);
setLoading(false); setLoading(false);
setHasMore(false);
setTotal(null);
setLoadingMore(false);
// Close existing EventSource // Close existing EventSource
if (eventSourceRef.current) { if (eventSourceRef.current) {
@@ -102,8 +116,9 @@ export function useAgentLogs(taskId: string | null, enabled: boolean, projectId?
if (!currentTaskId) return; if (!currentTaskId) return;
setLoading(true); setLoading(true);
setLoadingMore(false);
try { 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 // Reject stale response: check context version and request version
if (cancelledRef.current || if (cancelledRef.current ||
@@ -111,7 +126,9 @@ export function useAgentLogs(taskId: string | null, enabled: boolean, projectId?
requestVersionRef.current !== requestVersion) { requestVersionRef.current !== requestVersion) {
return; return;
} }
setEntries(capLogEntries(historical)); setEntries(capLogEntries(result.entries));
setHasMore(result.hasMore);
setTotal(result.total);
} catch { } catch {
// Reject stale error: check context version and request version // Reject stale error: check context version and request version
if (cancelledRef.current || if (cancelledRef.current ||
@@ -120,6 +137,8 @@ export function useAgentLogs(taskId: string | null, enabled: boolean, projectId?
return; return;
} }
setEntries([]); setEntries([]);
setHasMore(false);
setTotal(null);
} finally { } finally {
// Only update loading state if not cancelled and not stale // Only update loading state if not cancelled and not stale
if (!cancelledRef.current && if (!cancelledRef.current &&
@@ -143,6 +162,8 @@ export function useAgentLogs(taskId: string | null, enabled: boolean, projectId?
try { try {
const entry: AgentLogEntry = JSON.parse(e.data); const entry: AgentLogEntry = JSON.parse(e.data);
setEntries((prev) => capLogEntries([...prev, entry])); setEntries((prev) => capLogEntries([...prev, entry]));
// Update total if we know it (increment since new entry added)
setTotal((prev) => (prev !== null ? prev + 1 : null));
} catch { } catch {
// skip malformed events // skip malformed events
} }
@@ -160,7 +181,45 @@ export function useAgentLogs(taskId: string | null, enabled: boolean, projectId?
}; };
}, [taskId, enabled, 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([]), []); const clear = useCallback(() => setEntries([]), []);
return { entries, loading, clear }; return { entries, loading, clear, loadMore, hasMore, total, loadingMore };
} }

View File

@@ -1,8 +1,9 @@
import { useState, useEffect, useRef, useCallback } from "react"; import { useState, useEffect, useRef, useCallback } from "react";
import type { AgentLogEntry } from "@fusion/core"; import type { AgentLogEntry } from "@fusion/core";
import { fetchAgentLogs } from "../api"; import { fetchAgentLogsWithMeta } from "../api";
export const MAX_LOG_ENTRIES = 500; export const MAX_LOG_ENTRIES = 500;
const INITIAL_LOAD_LIMIT = 100;
/** /**
* Cap the total number of log entries to `MAX_LOG_ENTRIES`. * Cap the total number of log entries to `MAX_LOG_ENTRIES`.
@@ -21,7 +22,11 @@ function capLogEntries(entries: AgentLogEntry[]): AgentLogEntry[] {
export interface TaskLogState { export interface TaskLogState {
entries: AgentLogEntry[]; entries: AgentLogEntry[];
loading: boolean; loading: boolean;
loadingMore: boolean;
hasMore: boolean;
total: number | null;
clear: () => void; clear: () => void;
loadMore: () => Promise<void>;
} }
export type LogStateMap = Record<string, TaskLogState>; export type LogStateMap = Record<string, TaskLogState>;
@@ -29,18 +34,21 @@ export type LogStateMap = Record<string, TaskLogState>;
interface InitState { interface InitState {
entries: AgentLogEntry[]; entries: AgentLogEntry[];
loading: boolean; loading: boolean;
loadingMore: boolean;
hasMore: boolean;
total: number | null;
} }
/** /**
* Hook that manages agent log fetching and live SSE streaming for multiple tasks. * Hook that manages agent log fetching and live SSE streaming for multiple tasks.
* *
* Features project-context isolation to prevent cross-project log bleed: * Features:
* - Uses `{projectId, taskId}` identity for state isolation * - **Pagination**: Initial load fetches 100 entries per task. Use `loadMore()` to fetch older entries per task.
* - Clears all state immediately on project switch * - **Project-context isolation**: Prevents cross-project log bleed via context versioning.
* - Rejects late fetch responses and SSE events from previous contexts * - **Live streaming**: SSE events append new entries to the end of each task's list.
* *
* For each task ID in the provided array: * 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 * 2. Opens an EventSource to /api/tasks/:id/logs/stream for live updates
* 3. Merges historical + live entries in order * 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 initializingRef = useRef<Set<string>>(new Set());
const cancelledRef = useRef<Record<string, boolean>>({}); const cancelledRef = useRef<Record<string, boolean>>({});
const pendingLiveEntriesRef = useRef<Record<string, AgentLogEntry[]>>({}); const pendingLiveEntriesRef = useRef<Record<string, AgentLogEntry[]>>({});
const loadingMoreRef = useRef<Record<string, boolean>>({});
// Track project context version to detect stale events after project switches. // Track project context version to detect stale events after project switches.
// Incremented whenever projectId changes, invalidating any in-flight SSE handlers. // 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(); initializingRef.current.clear();
cancelledRef.current = {}; cancelledRef.current = {};
pendingLiveEntriesRef.current = {}; pendingLiveEntriesRef.current = {};
loadingMoreRef.current = {};
// Clear all state immediately to prevent stale data visibility // Clear all state immediately to prevent stale data visibility
setStateMap({}); 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 // Stable comparison of task IDs and projectId to prevent effect re-runs on every render
const taskIdsKey = taskIds.join(","); const taskIdsKey = taskIds.join(",");
const stableKey = [taskIdsKey, projectId ?? ""].join("|"); const stableKey = [taskIdsKey, projectId ?? ""].join("|");
@@ -127,7 +194,7 @@ export function useMultiAgentLogs(taskIds: string[], projectId?: string): LogSta
const updates: Record<string, InitState> = {}; const updates: Record<string, InitState> = {};
for (const taskId of newTaskIds) { for (const taskId of newTaskIds) {
if (!prev[taskId]) { 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; if (Object.keys(updates).length === 0) return prev;
@@ -145,6 +212,7 @@ export function useMultiAgentLogs(taskIds: string[], projectId?: string): LogSta
initializing.delete(taskId); initializing.delete(taskId);
delete cancelled[taskId]; delete cancelled[taskId];
delete pendingLiveEntriesRef.current[taskId]; delete pendingLiveEntriesRef.current[taskId];
delete loadingMoreRef.current[taskId];
removedTaskIds.push(taskId); removedTaskIds.push(taskId);
} }
} }
@@ -176,6 +244,7 @@ export function useMultiAgentLogs(taskIds: string[], projectId?: string): LogSta
cancelled[taskId] = true; cancelled[taskId] = true;
initializing.delete(taskId); initializing.delete(taskId);
delete pendingLiveEntriesRef.current[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; if (!current) return prev;
return { return {
...prev, ...prev,
[taskId]: { ...current, entries: capLogEntries([...current.entries, entry]) }, [taskId]: {
...current,
entries: capLogEntries([...current.entries, entry]),
total: current.total !== null ? current.total + 1 : null,
},
}; };
}); });
} catch { } catch {
@@ -235,9 +308,9 @@ export function useMultiAgentLogs(taskIds: string[], projectId?: string): LogSta
es.addEventListener("agent:log", handleAgentLog); es.addEventListener("agent:log", handleAgentLog);
es.addEventListener("error", handleError); es.addEventListener("error", handleError);
// Fetch historical logs with projectId // Fetch historical logs with projectId using pagination
void fetchAgentLogs(taskId, projectId, { limit: MAX_LOG_ENTRIES }) void fetchAgentLogsWithMeta(taskId, projectId, { limit: INITIAL_LOAD_LIMIT })
.then((historical) => { .then((result) => {
// Reject stale response from previous context // Reject stale response from previous context
if (cancelled[taskId] || if (cancelled[taskId] ||
projectContextVersionRef.current !== contextVersionAtStart) { projectContextVersionRef.current !== contextVersionAtStart) {
@@ -249,8 +322,10 @@ export function useMultiAgentLogs(taskIds: string[], projectId?: string): LogSta
...prev, ...prev,
[taskId]: { [taskId]: {
...prev[taskId], ...prev[taskId],
entries: capLogEntries([...historical, ...pendingLive]), entries: capLogEntries([...result.entries, ...pendingLive]),
loading: false, 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] ?? []; const pendingLive = pendingLiveEntriesRef.current[taskId] ?? [];
setStateMap((prev) => ({ setStateMap((prev) => ({
...prev, ...prev,
[taskId]: { ...prev[taskId], entries: capLogEntries(pendingLive), loading: false }, [taskId]: {
...prev[taskId],
entries: capLogEntries(pendingLive),
loading: false,
hasMore: false,
total: null,
},
})); }));
}) })
.finally(() => { .finally(() => {
@@ -278,7 +359,7 @@ export function useMultiAgentLogs(taskIds: string[], projectId?: string): LogSta
// Cleanup on effect re-run or unmount // Cleanup on effect re-run or unmount
return () => { 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) { for (const taskId of initialTaskIds) {
if (!currentIds.has(taskId)) { if (!currentIds.has(taskId)) {
cancelledRef.current[taskId] = true; cancelledRef.current[taskId] = true;
@@ -310,6 +391,7 @@ export function useMultiAgentLogs(taskIds: string[], projectId?: string): LogSta
initializingRef.current.clear(); initializingRef.current.clear();
cancelledRef.current = {}; cancelledRef.current = {};
pendingLiveEntriesRef.current = {}; pendingLiveEntriesRef.current = {};
loadingMoreRef.current = {};
}; };
}, []); }, []);
@@ -317,10 +399,15 @@ export function useMultiAgentLogs(taskIds: string[], projectId?: string): LogSta
const result: LogStateMap = {}; const result: LogStateMap = {};
for (const taskId of taskIds) { for (const taskId of taskIds) {
const state = stateMap[taskId]; const state = stateMap[taskId];
const entries = state?.entries ?? [];
result[taskId] = { result[taskId] = {
entries: state?.entries ?? [], entries,
loading: state?.loading ?? true, loading: state?.loading ?? true,
loadingMore: state?.loadingMore ?? false,
hasMore: state?.hasMore ?? false,
total: state?.total ?? null,
clear: createClearFn(taskId), clear: createClearFn(taskId),
loadMore: createLoadMoreFn(taskId, entries),
}; };
} }

View File

@@ -4036,6 +4036,26 @@ body {
border-color: var(--accent); 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 */ /* Fullscreen mode for agent log viewer */
.agent-log-viewer--fullscreen { .agent-log-viewer--fullscreen {
position: fixed; position: fixed;

View File

@@ -2988,15 +2988,35 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
// Get historical agent logs for a task. // Get historical agent logs for a task.
// Per-entry text and detail fields are returned in full — no truncation. // Per-entry text and detail fields are returned in full — no truncation.
// The 500-entry cap (MAX_LOG_ENTRIES) is a client-side whole-list limit. // The 500-entry cap (MAX_LOG_ENTRIES) is a client-side whole-list limit.
// When offset query param is provided, includes X-Total-Count and X-Has-More headers for pagination.
router.get("/tasks/:id/logs", async (req, res) => { router.get("/tasks/:id/logs", async (req, res) => {
try { try {
const { store: scopedStore } = await getProjectContext(req); const { store: scopedStore } = await getProjectContext(req);
const limit = typeof req.query.limit === "string" const limit = typeof req.query.limit === "string"
? Number.parseInt(req.query.limit, 10) ? Number.parseInt(req.query.limit, 10)
: undefined; : undefined;
const logs = await scopedStore.getAgentLogs(req.params.id, limit !== undefined && Number.isFinite(limit) const offset = typeof req.query.offset === "string"
? { limit } ? Number.parseInt(req.query.offset, 10)
: undefined); : undefined;
// Only include options object when we have explicit parameters
const options: { limit?: number; offset?: number } | undefined =
limit !== undefined && Number.isFinite(limit)
? { limit, ...(offset !== undefined && Number.isFinite(offset) ? { offset } : {}) }
: undefined;
const logs = await scopedStore.getAgentLogs(req.params.id, options);
// Include pagination headers when offset is explicitly provided (even if 0)
// This enables the frontend to know total count and whether more entries exist
if (offset !== undefined && Number.isFinite(offset)) {
const total = await scopedStore.getAgentLogCount(req.params.id);
res.setHeader("X-Total-Count", String(total));
const hasMore = total > (offset + logs.length);
res.setHeader("X-Has-More", hasMore ? "true" : "false");
}
res.json(logs); res.json(logs);
} catch (err: any) { } catch (err: any) {
if (err instanceof ApiError) { if (err instanceof ApiError) {