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:
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ||
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user