feat(FN-1764): merge fusion/fn-1764
This commit is contained in:
@@ -95,6 +95,12 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
|
||||
const addToastRef = useRef(addToast);
|
||||
const agentRef = useRef<AgentDetail | null>(null);
|
||||
|
||||
// Track the context version to detect stale events after project/agent switches.
|
||||
// Incremented whenever agentId or projectId changes, invalidating any in-flight SSE handlers.
|
||||
const contextVersionRef = useRef(0);
|
||||
const previousAgentIdRef = useRef(agentId);
|
||||
const previousProjectIdRef = useRef(projectId);
|
||||
|
||||
onCloseRef.current = onClose;
|
||||
addToastRef.current = addToast;
|
||||
agentRef.current = agent;
|
||||
@@ -117,19 +123,36 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
|
||||
}, [agentId, projectId]);
|
||||
|
||||
const loadLogs = useCallback(async () => {
|
||||
// Capture context version at callback creation - stale responses will be rejected
|
||||
const contextVersionAtCapture = contextVersionRef.current;
|
||||
const currentAgentId = agentId;
|
||||
const currentProjectId = projectId;
|
||||
|
||||
// Agent logs are tied to tasks, not agents directly.
|
||||
// If the agent has a current task, we could show those logs.
|
||||
// For now, we'll show heartbeat runs as the "activity" for the agent.
|
||||
// If the agent is working on a task, we could show task logs.
|
||||
if (agent?.taskId) {
|
||||
try {
|
||||
const data = await fetchAgentLogs(agent.taskId, projectId);
|
||||
const data = await fetchAgentLogs(agent.taskId, currentProjectId);
|
||||
// Reject stale response: check context version and current IDs
|
||||
if (contextVersionRef.current !== contextVersionAtCapture ||
|
||||
agentId !== currentAgentId ||
|
||||
projectId !== currentProjectId) {
|
||||
return;
|
||||
}
|
||||
setLogs(data);
|
||||
} catch (err: any) {
|
||||
// Reject stale error: check context version and current IDs
|
||||
if (contextVersionRef.current !== contextVersionAtCapture ||
|
||||
agentId !== currentAgentId ||
|
||||
projectId !== currentProjectId) {
|
||||
return;
|
||||
}
|
||||
console.error("Failed to load task logs:", err);
|
||||
}
|
||||
}
|
||||
}, [agent?.taskId, projectId]);
|
||||
}, [agent?.taskId, agentId, projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadAgent();
|
||||
@@ -153,6 +176,19 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
|
||||
}
|
||||
}, [agent?.taskId, loadLogs]);
|
||||
|
||||
// Detect context changes (agentId or projectId) and invalidate stale handlers
|
||||
useEffect(() => {
|
||||
if (previousAgentIdRef.current !== agentId || previousProjectIdRef.current !== projectId) {
|
||||
previousAgentIdRef.current = agentId;
|
||||
previousProjectIdRef.current = projectId;
|
||||
contextVersionRef.current++;
|
||||
|
||||
// Clear stale logs and streaming state immediately
|
||||
setLogs([]);
|
||||
setIsStreaming(false);
|
||||
}
|
||||
}, [agentId, projectId]);
|
||||
|
||||
// Set up SSE for live log streaming when viewing logs tab with a task
|
||||
useEffect(() => {
|
||||
if (activeTab !== "logs" || !agent?.taskId) {
|
||||
@@ -160,14 +196,22 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
|
||||
return;
|
||||
}
|
||||
|
||||
// Capture context version at effect start - stale events will be rejected
|
||||
const contextVersionAtStart = contextVersionRef.current;
|
||||
const currentTaskId = agent.taskId;
|
||||
const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
|
||||
const es = new EventSource(`/api/tasks/${encodeURIComponent(agent.taskId)}/logs/stream${query}`);
|
||||
const es = new EventSource(`/api/tasks/${encodeURIComponent(currentTaskId)}/logs/stream${query}`);
|
||||
|
||||
const handleAgentLog = (e: MessageEvent) => {
|
||||
// Reject events from stale contexts (agent/project switch)
|
||||
if (contextVersionRef.current !== contextVersionAtStart) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const entry: AgentLogEntry = JSON.parse(e.data);
|
||||
setLogs(prev => [entry, ...prev]);
|
||||
|
||||
|
||||
// Auto-scroll to top for new entries
|
||||
const container = logContainerRef.current;
|
||||
if (container && container.scrollTop < 50) {
|
||||
@@ -181,17 +225,27 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
|
||||
es.addEventListener("agent:log", handleAgentLog as EventListener);
|
||||
|
||||
es.onerror = () => {
|
||||
setIsStreaming(false);
|
||||
// Only update streaming state if not stale
|
||||
if (contextVersionRef.current === contextVersionAtStart) {
|
||||
setIsStreaming(false);
|
||||
}
|
||||
};
|
||||
|
||||
es.onopen = () => {
|
||||
setIsStreaming(true);
|
||||
// Only update streaming state if not stale
|
||||
if (contextVersionRef.current === contextVersionAtStart) {
|
||||
setIsStreaming(true);
|
||||
}
|
||||
};
|
||||
|
||||
return () => {
|
||||
es.removeEventListener("agent:log", handleAgentLog as EventListener);
|
||||
es.close();
|
||||
setIsStreaming(false);
|
||||
|
||||
// Only reset streaming state if not stale
|
||||
if (contextVersionRef.current === contextVersionAtStart) {
|
||||
setIsStreaming(false);
|
||||
}
|
||||
};
|
||||
}, [agent?.taskId, activeTab, projectId]);
|
||||
|
||||
|
||||
@@ -243,4 +243,155 @@ describe("useAgentLogs", () => {
|
||||
expect(result.current.entries[0].detail).toBe(longDetail);
|
||||
expect(result.current.entries[0].detail!.length).toBe(5000);
|
||||
});
|
||||
|
||||
describe("projectId support", () => {
|
||||
it("includes projectId in EventSource URL when provided", async () => {
|
||||
mockFetchAgentLogs.mockResolvedValueOnce([]);
|
||||
|
||||
renderHook(() => useAgentLogs("FN-001", true, "proj-123"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(MockEventSource.instances).toHaveLength(1);
|
||||
expect(MockEventSource.instances[0].url).toBe("/api/tasks/FN-001/logs/stream?projectId=proj-123");
|
||||
});
|
||||
});
|
||||
|
||||
it("includes projectId in fetchAgentLogs call when provided", async () => {
|
||||
mockFetchAgentLogs.mockResolvedValueOnce([]);
|
||||
|
||||
renderHook(() => useAgentLogs("FN-001", true, "proj-123"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchAgentLogs).toHaveBeenCalledWith("FN-001", "proj-123", { limit: 500 });
|
||||
});
|
||||
});
|
||||
|
||||
it("does not include projectId in URL when not provided", async () => {
|
||||
mockFetchAgentLogs.mockResolvedValueOnce([]);
|
||||
|
||||
renderHook(() => useAgentLogs("FN-001", true));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(MockEventSource.instances).toHaveLength(1);
|
||||
expect(MockEventSource.instances[0].url).toBe("/api/tasks/FN-001/logs/stream");
|
||||
});
|
||||
});
|
||||
|
||||
it("clears entries immediately when projectId changes", async () => {
|
||||
// Set up mock to return different values based on projectId
|
||||
mockFetchAgentLogs.mockImplementation((_taskId: string, projectId?: string) => {
|
||||
if (projectId === "proj-A") {
|
||||
return Promise.resolve([
|
||||
{ timestamp: "2026-01-01T00:00:00Z", taskId: "FN-001", text: "proj-A-log", type: "text" as const },
|
||||
]);
|
||||
}
|
||||
if (projectId === "proj-B") {
|
||||
return Promise.resolve([
|
||||
{ timestamp: "2026-01-01T00:00:00Z", taskId: "FN-001", text: "proj-B-log", type: "text" as const },
|
||||
]);
|
||||
}
|
||||
return Promise.resolve([]);
|
||||
});
|
||||
|
||||
// Create a hook that switches project
|
||||
const { result, rerender } = renderHook(
|
||||
({ projectId }) => useAgentLogs("FN-001", true, projectId),
|
||||
{ initialProps: { projectId: "proj-A" } },
|
||||
);
|
||||
|
||||
// Wait for initial entries to load
|
||||
await waitFor(() => {
|
||||
expect(result.current.entries).toHaveLength(1);
|
||||
expect(result.current.entries[0].text).toBe("proj-A-log");
|
||||
});
|
||||
|
||||
// Switch to proj-B
|
||||
rerender({ projectId: "proj-B" });
|
||||
|
||||
// Entries should be cleared immediately after project switch
|
||||
await waitFor(() => {
|
||||
expect(result.current.entries).toHaveLength(0);
|
||||
});
|
||||
|
||||
// New fetch should start for proj-B
|
||||
await waitFor(() => {
|
||||
expect(result.current.entries).toHaveLength(1);
|
||||
expect(result.current.entries[0].text).toBe("proj-B-log");
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects stale SSE events after project switch", async () => {
|
||||
// Initial render with proj-A
|
||||
mockFetchAgentLogs.mockResolvedValue([]);
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ projectId }) => useAgentLogs("FN-001", true, projectId),
|
||||
{ initialProps: { projectId: "proj-A" } },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(MockEventSource.instances).toHaveLength(1);
|
||||
});
|
||||
|
||||
const es = MockEventSource.instances[0];
|
||||
|
||||
// Switch to proj-B
|
||||
rerender({ projectId: "proj-B" });
|
||||
|
||||
// Wait for new connection to be established
|
||||
await waitFor(() => {
|
||||
expect(MockEventSource.instances.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
// Old connection should be closed
|
||||
expect(es.close).toHaveBeenCalled();
|
||||
|
||||
// Wait for entries to be cleared
|
||||
await waitFor(() => {
|
||||
expect(result.current.entries).toHaveLength(0);
|
||||
});
|
||||
|
||||
// Emit event on old connection (should be ignored)
|
||||
act(() => {
|
||||
es._emit("agent:log", {
|
||||
timestamp: "2026-01-01T00:01:00Z",
|
||||
taskId: "FN-001",
|
||||
text: "stale-event",
|
||||
type: "text",
|
||||
});
|
||||
});
|
||||
|
||||
// Stale event should not appear
|
||||
expect(result.current.entries.find(e => e.text === "stale-event")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("creates new connection with new projectId on project switch", async () => {
|
||||
mockFetchAgentLogs.mockResolvedValue([]);
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({ projectId }) => useAgentLogs("FN-001", true, projectId),
|
||||
{ initialProps: { projectId: "proj-A" } },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(MockEventSource.instances.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
const initialCount = MockEventSource.instances.length;
|
||||
|
||||
// Switch to proj-B
|
||||
rerender({ projectId: "proj-B" });
|
||||
|
||||
// Wait for new connection
|
||||
await waitFor(() => {
|
||||
expect(MockEventSource.instances.length).toBeGreaterThan(initialCount);
|
||||
});
|
||||
|
||||
// New connection should have correct projectId
|
||||
const newConnections = MockEventSource.instances.filter(
|
||||
es => es.url.includes("proj-B")
|
||||
);
|
||||
expect(newConnections.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,216 +1,333 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act, waitFor } from "@testing-library/react";
|
||||
import { useLiveTranscript } from "../useLiveTranscript";
|
||||
|
||||
// Mock EventSource is provided by vitest.setup.ts
|
||||
// Mock EventSource
|
||||
class MockEventSource {
|
||||
static instances: MockEventSource[] = [];
|
||||
url: string;
|
||||
listeners: Record<string, ((e: any) => void)[]> = {};
|
||||
readyState = 0;
|
||||
close = vi.fn(() => {
|
||||
this.readyState = 2;
|
||||
});
|
||||
|
||||
constructor(url: string) {
|
||||
this.url = url;
|
||||
this.readyState = 1;
|
||||
MockEventSource.instances.push(this);
|
||||
}
|
||||
|
||||
addEventListener(event: string, fn: (e: any) => void) {
|
||||
if (!this.listeners[event]) this.listeners[event] = [];
|
||||
this.listeners[event].push(fn);
|
||||
}
|
||||
|
||||
// Helper to simulate a server event
|
||||
_emit(event: string, data: any) {
|
||||
for (const fn of this.listeners[event] || []) {
|
||||
fn({ data: JSON.stringify(data) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const originalEventSource = globalThis.EventSource;
|
||||
|
||||
beforeEach(() => {
|
||||
MockEventSource.instances = [];
|
||||
(globalThis as any).EventSource = MockEventSource;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Close all instances
|
||||
for (const instance of MockEventSource.instances) {
|
||||
instance.close();
|
||||
}
|
||||
MockEventSource.instances = [];
|
||||
(globalThis as any).EventSource = originalEventSource;
|
||||
});
|
||||
|
||||
describe("useLiveTranscript", () => {
|
||||
beforeEach(() => {
|
||||
// Reset mock instances between tests
|
||||
vi.clearAllMocks();
|
||||
it("does not connect when taskId is undefined", () => {
|
||||
renderHook(() => useLiveTranscript(undefined));
|
||||
|
||||
expect(MockEventSource.instances).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("renders entries with canonical `text` field from SSE", async () => {
|
||||
const { result } = renderHook(() => useLiveTranscript("FN-001"));
|
||||
it("opens SSE connection when taskId is provided", async () => {
|
||||
renderHook(() => useLiveTranscript("FN-001"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.entries).toHaveLength(0);
|
||||
expect(MockEventSource.instances).toHaveLength(1);
|
||||
});
|
||||
|
||||
// Simulate SSE event with `text` field (matching AgentLogEntry)
|
||||
|
||||
const es = (globalThis as any).EventSource;
|
||||
const instance = es.instances[0];
|
||||
act(() => {
|
||||
instance._emit("agent:log", {
|
||||
timestamp: "2026-01-01T00:01:00Z",
|
||||
taskId: "FN-001",
|
||||
text: "Hello from agent",
|
||||
type: "text",
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.entries).toHaveLength(1);
|
||||
expect(result.current.entries[0].text).toBe("Hello from agent");
|
||||
expect(MockEventSource.instances[0].url).toBe("/api/tasks/FN-001/logs/stream");
|
||||
});
|
||||
|
||||
it("normalizes legacy `content` field to `text` for backward compatibility", async () => {
|
||||
const { result } = renderHook(() => useLiveTranscript("FN-001"));
|
||||
it("includes projectId in EventSource URL when provided", async () => {
|
||||
renderHook(() => useLiveTranscript("FN-001", "proj-123"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.entries).toHaveLength(0);
|
||||
expect(MockEventSource.instances).toHaveLength(1);
|
||||
});
|
||||
|
||||
// Simulate legacy SSE event with `content` field instead of `text`
|
||||
|
||||
const es = (globalThis as any).EventSource;
|
||||
const instance = es.instances[0];
|
||||
act(() => {
|
||||
instance._emit("agent:log", {
|
||||
timestamp: "2026-01-01T00:01:00Z",
|
||||
taskId: "FN-001",
|
||||
content: "Legacy content text",
|
||||
type: "text",
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.entries).toHaveLength(1);
|
||||
// Legacy `content` should be normalized to `text`
|
||||
expect(result.current.entries[0].text).toBe("Legacy content text");
|
||||
});
|
||||
|
||||
it("prefers `text` over `content` when both are present", async () => {
|
||||
const { result } = renderHook(() => useLiveTranscript("FN-001"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.entries).toHaveLength(0);
|
||||
});
|
||||
|
||||
|
||||
const es = (globalThis as any).EventSource;
|
||||
const instance = es.instances[0];
|
||||
act(() => {
|
||||
instance._emit("agent:log", {
|
||||
timestamp: "2026-01-01T00:01:00Z",
|
||||
taskId: "FN-001",
|
||||
text: "Primary text",
|
||||
content: "Legacy content",
|
||||
type: "text",
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.entries).toHaveLength(1);
|
||||
// `text` takes precedence
|
||||
expect(result.current.entries[0].text).toBe("Primary text");
|
||||
// Original `content` is preserved for reference
|
||||
expect(result.current.entries[0].content).toBe("Legacy content");
|
||||
});
|
||||
|
||||
it("includes projectId in stream URL when provided", async () => {
|
||||
renderHook(() => useLiveTranscript("FN-001", "project-abc"));
|
||||
|
||||
|
||||
const es = (globalThis as any).EventSource;
|
||||
expect(es.instances).toHaveLength(1);
|
||||
expect(es.instances[0].url).toContain("projectId=project-abc");
|
||||
expect(MockEventSource.instances[0].url).toBe("/api/tasks/FN-001/logs/stream?projectId=proj-123");
|
||||
});
|
||||
|
||||
it("does not include projectId in URL when not provided", async () => {
|
||||
renderHook(() => useLiveTranscript("FN-001"));
|
||||
|
||||
|
||||
const es = (globalThis as any).EventSource;
|
||||
expect(es.instances).toHaveLength(1);
|
||||
expect(es.instances[0].url).not.toContain("projectId");
|
||||
});
|
||||
|
||||
it("clears entries when taskId is undefined", async () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ taskId }) => useLiveTranscript(taskId),
|
||||
{ initialProps: { taskId: "FN-001" as string | undefined } }
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.entries).toHaveLength(0);
|
||||
expect(MockEventSource.instances).toHaveLength(1);
|
||||
});
|
||||
|
||||
// Add an entry first
|
||||
|
||||
const es = (globalThis as any).EventSource;
|
||||
const instance = es.instances[0];
|
||||
act(() => {
|
||||
instance._emit("agent:log", {
|
||||
timestamp: "2026-01-01T00:01:00Z",
|
||||
taskId: "FN-001",
|
||||
text: "Some text",
|
||||
type: "text",
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.entries).toHaveLength(1);
|
||||
|
||||
// Now clear the taskId
|
||||
rerender({ taskId: undefined });
|
||||
|
||||
expect(result.current.entries).toHaveLength(0);
|
||||
expect(MockEventSource.instances[0].url).toBe("/api/tasks/FN-001/logs/stream");
|
||||
});
|
||||
|
||||
it("closes EventSource on unmount", async () => {
|
||||
it("closes SSE connection on unmount", async () => {
|
||||
const { unmount } = renderHook(() => useLiveTranscript("FN-001"));
|
||||
|
||||
await waitFor(() => {
|
||||
|
||||
expect((globalThis as any).EventSource.instances).toHaveLength(1);
|
||||
expect(MockEventSource.instances).toHaveLength(1);
|
||||
});
|
||||
|
||||
|
||||
const es = (globalThis as any).EventSource;
|
||||
const instance = es.instances[0];
|
||||
const closeSpy = vi.spyOn(instance, "close");
|
||||
|
||||
const es = MockEventSource.instances[0];
|
||||
unmount();
|
||||
|
||||
expect(closeSpy).toHaveBeenCalled();
|
||||
expect(es.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sets isConnected to true on SSE open", async () => {
|
||||
const { result } = renderHook(() => useLiveTranscript("FN-001"));
|
||||
|
||||
expect(result.current.isConnected).toBe(false);
|
||||
|
||||
|
||||
const es = (globalThis as any).EventSource;
|
||||
const instance = es.instances[0];
|
||||
|
||||
act(() => {
|
||||
instance._emit("open");
|
||||
});
|
||||
|
||||
expect(result.current.isConnected).toBe(true);
|
||||
});
|
||||
|
||||
it("skips malformed SSE events without crashing", async () => {
|
||||
it("appends SSE entries to transcript", async () => {
|
||||
const { result } = renderHook(() => useLiveTranscript("FN-001"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.entries).toHaveLength(0);
|
||||
expect(MockEventSource.instances).toHaveLength(1);
|
||||
});
|
||||
|
||||
|
||||
const es = (globalThis as any).EventSource;
|
||||
const instance = es.instances[0];
|
||||
|
||||
// Send malformed JSON
|
||||
const es = MockEventSource.instances[0];
|
||||
act(() => {
|
||||
instance._emit("agent:log", null);
|
||||
});
|
||||
|
||||
// Should not crash, entries should remain empty
|
||||
expect(result.current.entries).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("preserves timestamp and type fields from SSE payload", async () => {
|
||||
const { result } = renderHook(() => useLiveTranscript("FN-001"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.entries).toHaveLength(0);
|
||||
});
|
||||
|
||||
|
||||
const es = (globalThis as any).EventSource;
|
||||
const instance = es.instances[0];
|
||||
act(() => {
|
||||
instance._emit("agent:log", {
|
||||
timestamp: "2026-01-01T12:00:00Z",
|
||||
taskId: "FN-001",
|
||||
text: "Thinking...",
|
||||
type: "thinking",
|
||||
es._emit("agent:log", {
|
||||
type: "text",
|
||||
text: "Hello, world!",
|
||||
timestamp: "2026-01-01T00:01:00Z",
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.entries).toHaveLength(1);
|
||||
expect(result.current.entries[0].timestamp).toBe("2026-01-01T12:00:00Z");
|
||||
expect(result.current.entries[0].type).toBe("thinking");
|
||||
expect(result.current.entries[0].text).toBe("Thinking...");
|
||||
await waitFor(() => {
|
||||
expect(result.current.entries).toHaveLength(1);
|
||||
expect(result.current.entries[0].text).toBe("Hello, world!");
|
||||
});
|
||||
});
|
||||
|
||||
it("handles legacy content field as fallback", async () => {
|
||||
const { result } = renderHook(() => useLiveTranscript("FN-001"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(MockEventSource.instances).toHaveLength(1);
|
||||
});
|
||||
|
||||
const es = MockEventSource.instances[0];
|
||||
act(() => {
|
||||
es._emit("agent:log", {
|
||||
type: "text",
|
||||
content: "Legacy content",
|
||||
timestamp: "2026-01-01T00:01:00Z",
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.entries).toHaveLength(1);
|
||||
expect(result.current.entries[0].text).toBe("Legacy content");
|
||||
});
|
||||
});
|
||||
|
||||
it("updates isConnected state based on SSE connection", async () => {
|
||||
const { result } = renderHook(() => useLiveTranscript("FN-001"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(MockEventSource.instances).toHaveLength(1);
|
||||
});
|
||||
|
||||
// isConnected starts as false; it's updated when SSE emits open/error events
|
||||
expect(result.current.isConnected).toBe(false);
|
||||
|
||||
// Simulate SSE open event
|
||||
const es = MockEventSource.instances[0];
|
||||
act(() => {
|
||||
es._emit("open", {});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isConnected).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("skips malformed SSE events", async () => {
|
||||
const { result } = renderHook(() => useLiveTranscript("FN-001"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(MockEventSource.instances).toHaveLength(1);
|
||||
});
|
||||
|
||||
const es = MockEventSource.instances[0];
|
||||
act(() => {
|
||||
// Directly call the listener with malformed data (bypasses JSON.stringify)
|
||||
const handler = es.listeners["agent:log"][0];
|
||||
handler({ data: "{ invalid json" }); // Invalid JSON - missing closing brace
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.entries).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("handles valid JSON without text field gracefully", async () => {
|
||||
const { result } = renderHook(() => useLiveTranscript("FN-001"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(MockEventSource.instances).toHaveLength(1);
|
||||
});
|
||||
|
||||
const es = MockEventSource.instances[0];
|
||||
act(() => {
|
||||
// Valid JSON but missing text/content fields
|
||||
es._emit("agent:log", { type: "text" });
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
// Entry is added but with empty text (graceful degradation)
|
||||
expect(result.current.entries).toHaveLength(1);
|
||||
expect(result.current.entries[0].text).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("project context isolation", () => {
|
||||
it("clears entries immediately when projectId changes", async () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ projectId }) => useLiveTranscript("FN-001", projectId),
|
||||
{ initialProps: { projectId: "proj-A" } },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(MockEventSource.instances).toHaveLength(1);
|
||||
});
|
||||
|
||||
// Add some entries
|
||||
const es = MockEventSource.instances[0];
|
||||
act(() => {
|
||||
es._emit("agent:log", {
|
||||
type: "text",
|
||||
text: "proj-A entry",
|
||||
timestamp: "2026-01-01T00:01:00Z",
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.entries).toHaveLength(1);
|
||||
expect(result.current.entries[0].text).toBe("proj-A entry");
|
||||
});
|
||||
|
||||
// Switch to proj-B
|
||||
rerender({ projectId: "proj-B" });
|
||||
|
||||
// Entries should be cleared immediately after project switch
|
||||
await waitFor(() => {
|
||||
expect(result.current.entries).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("creates new connection with correct projectId on project switch", async () => {
|
||||
const { rerender } = renderHook(
|
||||
({ projectId }) => useLiveTranscript("FN-001", projectId),
|
||||
{ initialProps: { projectId: "proj-A" } },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(MockEventSource.instances).toHaveLength(1);
|
||||
});
|
||||
|
||||
const initialEs = MockEventSource.instances[0];
|
||||
expect(initialEs.url).toContain("proj-A");
|
||||
|
||||
// Switch to proj-B
|
||||
rerender({ projectId: "proj-B" });
|
||||
|
||||
// Wait for new connection
|
||||
await waitFor(() => {
|
||||
// Old connection closed, new one opened
|
||||
expect(initialEs.close).toHaveBeenCalled();
|
||||
const newConnections = MockEventSource.instances.filter(
|
||||
es => es.url.includes("proj-B")
|
||||
);
|
||||
expect(newConnections.length).toBe(1);
|
||||
expect(newConnections[0].url).toBe("/api/tasks/FN-001/logs/stream?projectId=proj-B");
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects stale SSE events after project switch", async () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ projectId }) => useLiveTranscript("FN-001", projectId),
|
||||
{ initialProps: { projectId: "proj-A" } },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(MockEventSource.instances).toHaveLength(1);
|
||||
});
|
||||
|
||||
const es = MockEventSource.instances[0];
|
||||
|
||||
// Switch to proj-B
|
||||
rerender({ projectId: "proj-B" });
|
||||
|
||||
// Wait for entries to be cleared
|
||||
await waitFor(() => {
|
||||
expect(result.current.entries).toHaveLength(0);
|
||||
});
|
||||
|
||||
// Emit event on old connection (should be ignored)
|
||||
act(() => {
|
||||
es._emit("agent:log", {
|
||||
type: "text",
|
||||
text: "stale-event",
|
||||
timestamp: "2026-01-01T00:01:00Z",
|
||||
});
|
||||
});
|
||||
|
||||
// Stale event should not appear
|
||||
expect(result.current.entries.find(e => e.text === "stale-event")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("clears entries immediately when taskId changes", async () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ taskId }) => useLiveTranscript(taskId, "proj-A"),
|
||||
{ initialProps: { taskId: "FN-001" } },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(MockEventSource.instances).toHaveLength(1);
|
||||
});
|
||||
|
||||
// Add some entries
|
||||
const es = MockEventSource.instances[0];
|
||||
act(() => {
|
||||
es._emit("agent:log", {
|
||||
type: "text",
|
||||
text: "FN-001 entry",
|
||||
timestamp: "2026-01-01T00:01:00Z",
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.entries).toHaveLength(1);
|
||||
});
|
||||
|
||||
// Switch to different task
|
||||
rerender({ taskId: "FN-002" });
|
||||
|
||||
// Entries should be cleared immediately after task switch
|
||||
await waitFor(() => {
|
||||
expect(result.current.entries).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,6 +21,12 @@ function capLogEntries(entries: AgentLogEntry[]): AgentLogEntry[] {
|
||||
/**
|
||||
* Hook that manages agent log fetching and live SSE streaming for a task.
|
||||
*
|
||||
* Features project-context isolation to prevent cross-project log bleed:
|
||||
* - Treats `{projectId, taskId}` as a context key
|
||||
* - Clears entries immediately on context change (project or task switch)
|
||||
* - Rejects late fetch responses from previous contexts
|
||||
* - Rejects stale SSE events from previous EventSource instances
|
||||
*
|
||||
* When `enabled` is true:
|
||||
* 1. Fetches recent historical logs via GET /api/tasks/:id/logs?limit=500
|
||||
* 2. Opens an EventSource to /api/tasks/:id/logs/stream for live updates
|
||||
@@ -32,7 +38,46 @@ function capLogEntries(entries: AgentLogEntry[]): AgentLogEntry[] {
|
||||
export function useAgentLogs(taskId: string | null, enabled: boolean, projectId?: string) {
|
||||
const [entries, setEntries] = useState<AgentLogEntry[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Refs for state that needs to survive re-renders
|
||||
const eventSourceRef = useRef<EventSource | null>(null);
|
||||
const cancelledRef = useRef(false);
|
||||
|
||||
// Track the project context version to detect stale SSE events after project switches.
|
||||
// Incremented whenever projectId changes, invalidating any in-flight SSE handlers.
|
||||
const projectContextVersionRef = useRef(0);
|
||||
|
||||
// Track previous values to detect context changes
|
||||
const previousTaskIdRef = useRef<string | null>(taskId);
|
||||
const previousProjectIdRef = useRef<string | undefined>(projectId);
|
||||
const previousEnabledRef = useRef(enabled);
|
||||
|
||||
// Track request version to reject stale fetch completions
|
||||
const requestVersionRef = useRef(0);
|
||||
|
||||
// Detect context changes and clear state immediately
|
||||
const contextChanged =
|
||||
previousTaskIdRef.current !== taskId ||
|
||||
previousProjectIdRef.current !== projectId ||
|
||||
previousEnabledRef.current !== enabled;
|
||||
|
||||
if (contextChanged) {
|
||||
previousTaskIdRef.current = taskId;
|
||||
previousProjectIdRef.current = projectId;
|
||||
previousEnabledRef.current = enabled;
|
||||
projectContextVersionRef.current++;
|
||||
cancelledRef.current = true;
|
||||
|
||||
// Clear entries immediately on context change to prevent stale data visibility
|
||||
setEntries([]);
|
||||
setLoading(false);
|
||||
|
||||
// Close existing EventSource
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close();
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!taskId || !enabled) {
|
||||
@@ -44,32 +89,57 @@ export function useAgentLogs(taskId: string | null, enabled: boolean, projectId?
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
// Capture context version at effect start - stale SSE events will be rejected
|
||||
const contextVersionAtStart = projectContextVersionRef.current;
|
||||
const requestVersion = ++requestVersionRef.current;
|
||||
cancelledRef.current = false;
|
||||
|
||||
// Capture taskId and projectId at effect start for comparison
|
||||
const currentTaskId = taskId;
|
||||
const currentProjectId = projectId;
|
||||
|
||||
async function init() {
|
||||
// Capture taskId in a local constant to ensure it's not null
|
||||
const currentTaskId = taskId;
|
||||
if (!currentTaskId) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const historical = await fetchAgentLogs(currentTaskId, projectId, { limit: MAX_LOG_ENTRIES });
|
||||
if (cancelled) return;
|
||||
const historical = await fetchAgentLogs(currentTaskId, currentProjectId, { limit: MAX_LOG_ENTRIES });
|
||||
|
||||
// Reject stale response: check context version and request version
|
||||
if (cancelledRef.current ||
|
||||
projectContextVersionRef.current !== contextVersionAtStart ||
|
||||
requestVersionRef.current !== requestVersion) {
|
||||
return;
|
||||
}
|
||||
setEntries(capLogEntries(historical));
|
||||
} catch {
|
||||
if (cancelled) return;
|
||||
// Reject stale error: check context version and request version
|
||||
if (cancelledRef.current ||
|
||||
projectContextVersionRef.current !== contextVersionAtStart ||
|
||||
requestVersionRef.current !== requestVersion) {
|
||||
return;
|
||||
}
|
||||
setEntries([]);
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
// Only update loading state if not cancelled and not stale
|
||||
if (!cancelledRef.current &&
|
||||
projectContextVersionRef.current === contextVersionAtStart &&
|
||||
requestVersionRef.current === requestVersion) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Open SSE connection for live updates
|
||||
const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
|
||||
const query = currentProjectId ? `?projectId=${encodeURIComponent(currentProjectId)}` : "";
|
||||
const es = new EventSource(`/api/tasks/${currentTaskId}/logs/stream${query}`);
|
||||
eventSourceRef.current = es;
|
||||
|
||||
es.addEventListener("agent:log", (e) => {
|
||||
if (cancelled) return;
|
||||
// Reject events from stale contexts (project/task switch)
|
||||
if (cancelledRef.current ||
|
||||
projectContextVersionRef.current !== contextVersionAtStart) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const entry: AgentLogEntry = JSON.parse(e.data);
|
||||
setEntries((prev) => capLogEntries([...prev, entry]));
|
||||
@@ -82,7 +152,7 @@ export function useAgentLogs(taskId: string | null, enabled: boolean, projectId?
|
||||
void init();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cancelledRef.current = true;
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close();
|
||||
eventSourceRef.current = null;
|
||||
|
||||
@@ -17,11 +17,53 @@ export interface TranscriptEntry {
|
||||
content?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that manages live transcript streaming for a task.
|
||||
*
|
||||
* Features project-context isolation to prevent cross-project transcript bleed:
|
||||
* - Tracks project context version to detect stale events after project switches
|
||||
* - Resets entries and connection state immediately on context change
|
||||
* - Rejects stale SSE events from previous EventSource instances
|
||||
*
|
||||
* When `taskId` changes, a new SSE connection is opened for the new task.
|
||||
* When `projectId` changes, all state is reset and a new connection is opened.
|
||||
*/
|
||||
export function useLiveTranscript(taskId: string | undefined, projectId?: string) {
|
||||
const [entries, setEntries] = useState<TranscriptEntry[]>([]);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
|
||||
// Refs for state that needs to survive re-renders
|
||||
const esRef = useRef<EventSource | null>(null);
|
||||
|
||||
// Track the project context version to detect stale events after project switches.
|
||||
// Incremented whenever projectId changes, invalidating any in-flight SSE handlers.
|
||||
const projectContextVersionRef = useRef(0);
|
||||
|
||||
// Track previous values to detect context changes
|
||||
const previousTaskIdRef = useRef<string | undefined>(taskId);
|
||||
const previousProjectIdRef = useRef<string | undefined>(projectId);
|
||||
|
||||
// Detect context changes and reset state immediately
|
||||
const contextChanged =
|
||||
previousTaskIdRef.current !== taskId ||
|
||||
previousProjectIdRef.current !== projectId;
|
||||
|
||||
if (contextChanged) {
|
||||
previousTaskIdRef.current = taskId;
|
||||
previousProjectIdRef.current = projectId;
|
||||
projectContextVersionRef.current++;
|
||||
|
||||
// Close existing EventSource
|
||||
if (esRef.current) {
|
||||
esRef.current.close();
|
||||
esRef.current = null;
|
||||
}
|
||||
|
||||
// Reset state immediately to prevent stale data visibility
|
||||
setEntries([]);
|
||||
setIsConnected(false);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!taskId) {
|
||||
setEntries([]);
|
||||
@@ -29,6 +71,9 @@ export function useLiveTranscript(taskId: string | undefined, projectId?: string
|
||||
return;
|
||||
}
|
||||
|
||||
// Capture context version at effect start - stale events will be rejected
|
||||
const contextVersionAtStart = projectContextVersionRef.current;
|
||||
|
||||
// Build stream URL with optional projectId for multi-project support
|
||||
let url = `/api/tasks/${encodeURIComponent(taskId)}/logs/stream`;
|
||||
if (projectId) {
|
||||
@@ -39,6 +84,11 @@ export function useLiveTranscript(taskId: string | undefined, projectId?: string
|
||||
esRef.current = es;
|
||||
|
||||
es.addEventListener("agent:log", (event) => {
|
||||
// Reject events from stale contexts (project/task switch)
|
||||
if (projectContextVersionRef.current !== contextVersionAtStart) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = JSON.parse(event.data) as Partial<TranscriptEntry>;
|
||||
// Normalize: canonical `text` field, with legacy `content` fallback
|
||||
@@ -53,13 +103,28 @@ export function useLiveTranscript(taskId: string | undefined, projectId?: string
|
||||
} catch { /* skip malformed events */ }
|
||||
});
|
||||
|
||||
es.addEventListener("open", () => setIsConnected(true));
|
||||
es.addEventListener("error", () => setIsConnected(false));
|
||||
es.addEventListener("open", () => {
|
||||
// Only update connected state if not stale
|
||||
if (projectContextVersionRef.current === contextVersionAtStart) {
|
||||
setIsConnected(true);
|
||||
}
|
||||
});
|
||||
|
||||
es.addEventListener("error", () => {
|
||||
// Only update connected state if not stale
|
||||
if (projectContextVersionRef.current === contextVersionAtStart) {
|
||||
setIsConnected(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
es.close();
|
||||
esRef.current = null;
|
||||
setIsConnected(false);
|
||||
|
||||
// Only reset state if not stale
|
||||
if (projectContextVersionRef.current === contextVersionAtStart) {
|
||||
setIsConnected(false);
|
||||
}
|
||||
};
|
||||
}, [taskId, projectId]);
|
||||
|
||||
|
||||
@@ -34,6 +34,11 @@ interface InitState {
|
||||
/**
|
||||
* Hook that manages agent log fetching and live SSE streaming for multiple tasks.
|
||||
*
|
||||
* Features project-context isolation to prevent cross-project log bleed:
|
||||
* - Uses `{projectId, taskId}` identity for state isolation
|
||||
* - Clears all state immediately on project switch
|
||||
* - Rejects late fetch responses and SSE events from previous contexts
|
||||
*
|
||||
* For each task ID in the provided array:
|
||||
* 1. Fetches recent historical logs via GET /api/tasks/:id/logs?limit=500
|
||||
* 2. Opens an EventSource to /api/tasks/:id/logs/stream for live updates
|
||||
@@ -46,12 +51,39 @@ export function useMultiAgentLogs(taskIds: string[], projectId?: string): LogSta
|
||||
// Store state per task
|
||||
const [stateMap, setStateMap] = useState<Record<string, InitState>>({});
|
||||
|
||||
// Ref to track active EventSources
|
||||
// Refs for state that needs to survive re-renders
|
||||
const sourcesRef = useRef<Record<string, EventSource>>({});
|
||||
const initializingRef = useRef<Set<string>>(new Set());
|
||||
const cancelledRef = useRef<Record<string, boolean>>({});
|
||||
const pendingLiveEntriesRef = useRef<Record<string, AgentLogEntry[]>>({});
|
||||
|
||||
// Track project context version to detect stale events after project switches.
|
||||
// Incremented whenever projectId changes, invalidating any in-flight SSE handlers.
|
||||
const projectContextVersionRef = useRef(0);
|
||||
|
||||
// Track previous projectId to detect project switches
|
||||
const previousProjectIdRef = useRef<string | undefined>(projectId);
|
||||
|
||||
// Detect project switch and clear all state immediately
|
||||
const projectSwitched = previousProjectIdRef.current !== projectId;
|
||||
if (projectSwitched) {
|
||||
previousProjectIdRef.current = projectId;
|
||||
projectContextVersionRef.current++;
|
||||
|
||||
// Close all existing EventSources and reset state
|
||||
for (const [taskId, es] of Object.entries(sourcesRef.current)) {
|
||||
cancelledRef.current[taskId] = true;
|
||||
es.close();
|
||||
}
|
||||
sourcesRef.current = {};
|
||||
initializingRef.current.clear();
|
||||
cancelledRef.current = {};
|
||||
pendingLiveEntriesRef.current = {};
|
||||
|
||||
// Clear all state immediately to prevent stale data visibility
|
||||
setStateMap({});
|
||||
}
|
||||
|
||||
// Create clear function for a specific task
|
||||
const createClearFn = useCallback((taskId: string) => {
|
||||
return () => {
|
||||
@@ -78,6 +110,9 @@ export function useMultiAgentLogs(taskIds: string[], projectId?: string): LogSta
|
||||
const initializing = initializingRef.current;
|
||||
const cancelled = cancelledRef.current;
|
||||
|
||||
// Capture context version at effect start - stale events will be rejected
|
||||
const contextVersionAtStart = projectContextVersionRef.current;
|
||||
|
||||
// Track which task IDs need state initialization (not already in stateMap)
|
||||
const newTaskIds: string[] = [];
|
||||
for (const taskId of taskIds) {
|
||||
@@ -85,7 +120,7 @@ export function useMultiAgentLogs(taskIds: string[], projectId?: string): LogSta
|
||||
newTaskIds.push(taskId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Only initialize state for new tasks that aren't already in stateMap
|
||||
if (newTaskIds.length > 0) {
|
||||
setStateMap((prev) => {
|
||||
@@ -113,7 +148,7 @@ export function useMultiAgentLogs(taskIds: string[], projectId?: string): LogSta
|
||||
removedTaskIds.push(taskId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Only remove state for disconnected tasks if there are any
|
||||
if (removedTaskIds.length > 0) {
|
||||
setStateMap((prev) => {
|
||||
@@ -159,7 +194,11 @@ export function useMultiAgentLogs(taskIds: string[], projectId?: string): LogSta
|
||||
sources[taskId] = es;
|
||||
|
||||
const handleAgentLog = (e: MessageEvent) => {
|
||||
if (cancelled[taskId]) return;
|
||||
// Reject events from stale contexts (project/task switch)
|
||||
if (cancelled[taskId] ||
|
||||
projectContextVersionRef.current !== contextVersionAtStart) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const entry: AgentLogEntry = JSON.parse(e.data);
|
||||
@@ -199,7 +238,11 @@ export function useMultiAgentLogs(taskIds: string[], projectId?: string): LogSta
|
||||
// Fetch historical logs with projectId
|
||||
void fetchAgentLogs(taskId, projectId, { limit: MAX_LOG_ENTRIES })
|
||||
.then((historical) => {
|
||||
if (cancelled[taskId]) return;
|
||||
// Reject stale response from previous context
|
||||
if (cancelled[taskId] ||
|
||||
projectContextVersionRef.current !== contextVersionAtStart) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pendingLive = pendingLiveEntriesRef.current[taskId] ?? [];
|
||||
setStateMap((prev) => ({
|
||||
@@ -212,7 +255,11 @@ export function useMultiAgentLogs(taskIds: string[], projectId?: string): LogSta
|
||||
}));
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled[taskId]) return;
|
||||
// Reject stale error from previous context
|
||||
if (cancelled[taskId] ||
|
||||
projectContextVersionRef.current !== contextVersionAtStart) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pendingLive = pendingLiveEntriesRef.current[taskId] ?? [];
|
||||
setStateMap((prev) => ({
|
||||
@@ -225,7 +272,7 @@ export function useMultiAgentLogs(taskIds: string[], projectId?: string): LogSta
|
||||
initializingRef.current.delete(taskId);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Update previous task IDs ref for cleanup comparison
|
||||
const initialTaskIds = [...taskIds];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user