feat(FN-1650): merge fusion/fn-1650
This commit is contained in:
5
.changeset/fix-agent-panel-logs.md
Normal file
5
.changeset/fix-agent-panel-logs.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@gsxdsm/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix empty agent logs in the Agents panel. The `ActiveAgentsPanel` and `useLiveTranscript` hook were wired to `entry.content`, while the SSE payload from `/api/tasks/:id/logs/stream` sends `AgentLogEntry` objects with a `text` field. This caused all live agent transcript lines to appear blank. The fix normalizes the transcript interface to use `text` as the canonical field, adds backward compatibility for any legacy `content` payloads, and threads `projectId` through the component hierarchy for multi-project support.
|
||||||
@@ -4922,7 +4922,10 @@ export function streamChatResponse(
|
|||||||
const lines = buffer.split("\n");
|
const lines = buffer.split("\n");
|
||||||
buffer = lines.pop() || "";
|
buffer = lines.pop() || "";
|
||||||
|
|
||||||
if (flushPendingEvent && buffer.length > 0) {
|
// Only push complete SSE lines (ending with \n) to lines for processing.
|
||||||
|
// Incomplete lines (no trailing \n) are intentionally left in the buffer
|
||||||
|
// to be continued by the next chunk or properly handled at stream end.
|
||||||
|
if (flushPendingEvent && buffer.length > 0 && buffer.endsWith("\n")) {
|
||||||
lines.push(buffer);
|
lines.push(buffer);
|
||||||
buffer = "";
|
buffer = "";
|
||||||
}
|
}
|
||||||
@@ -4943,7 +4946,9 @@ export function streamChatResponse(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (flushPendingEvent) {
|
// Flush any pending event/data at stream end.
|
||||||
|
// Only dispatch if we have both a valid event type and accumulated data.
|
||||||
|
if (flushPendingEvent && currentEvent && currentDataLines.length > 0) {
|
||||||
const trailingData = currentDataLines.join("\n");
|
const trailingData = currentDataLines.join("\n");
|
||||||
dispatchEvent(currentEvent, trailingData);
|
dispatchEvent(currentEvent, trailingData);
|
||||||
currentEvent = "";
|
currentEvent = "";
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ import { useLiveTranscript } from "../hooks/useLiveTranscript";
|
|||||||
|
|
||||||
interface LiveAgentCardProps {
|
interface LiveAgentCardProps {
|
||||||
agent: Agent;
|
agent: Agent;
|
||||||
|
projectId?: string;
|
||||||
onSelect?: (agentId: string) => void;
|
onSelect?: (agentId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function LiveAgentCard({ agent, onSelect }: LiveAgentCardProps) {
|
function LiveAgentCard({ agent, projectId, onSelect }: LiveAgentCardProps) {
|
||||||
const { entries, isConnected } = useLiveTranscript(agent.taskId);
|
const { entries, isConnected } = useLiveTranscript(agent.taskId, projectId);
|
||||||
const elapsed = agent.lastHeartbeatAt
|
const elapsed = agent.lastHeartbeatAt
|
||||||
? Math.floor((Date.now() - new Date(agent.lastHeartbeatAt).getTime()) / 1000)
|
? Math.floor((Date.now() - new Date(agent.lastHeartbeatAt).getTime()) / 1000)
|
||||||
: 0;
|
: 0;
|
||||||
@@ -52,7 +53,7 @@ function LiveAgentCard({ agent, onSelect }: LiveAgentCardProps) {
|
|||||||
) : (
|
) : (
|
||||||
entries.slice(0, 20).map((entry, i) => (
|
entries.slice(0, 20).map((entry, i) => (
|
||||||
<div key={i} className="live-agent-card-line">
|
<div key={i} className="live-agent-card-line">
|
||||||
{entry.content}
|
{entry.text}
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
@@ -73,10 +74,11 @@ function formatElapsed(seconds: number): string {
|
|||||||
|
|
||||||
interface ActiveAgentsPanelProps {
|
interface ActiveAgentsPanelProps {
|
||||||
agents: Agent[];
|
agents: Agent[];
|
||||||
|
projectId?: string;
|
||||||
onAgentSelect?: (agentId: string) => void;
|
onAgentSelect?: (agentId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ActiveAgentsPanel({ agents, onAgentSelect }: ActiveAgentsPanelProps) {
|
export function ActiveAgentsPanel({ agents, projectId, onAgentSelect }: ActiveAgentsPanelProps) {
|
||||||
if (agents.length === 0) return null;
|
if (agents.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -87,7 +89,7 @@ export function ActiveAgentsPanel({ agents, onAgentSelect }: ActiveAgentsPanelPr
|
|||||||
</div>
|
</div>
|
||||||
<div className="active-agents-grid">
|
<div className="active-agents-grid">
|
||||||
{agents.map(agent => (
|
{agents.map(agent => (
|
||||||
<LiveAgentCard key={agent.id} agent={agent} onSelect={onAgentSelect} />
|
<LiveAgentCard key={agent.id} agent={agent} projectId={projectId} onSelect={onAgentSelect} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -496,7 +496,7 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
|
|||||||
<AgentMetricsBar stats={stats} />
|
<AgentMetricsBar stats={stats} />
|
||||||
|
|
||||||
{/* Active Agents Panel - Live streaming cards */}
|
{/* Active Agents Panel - Live streaming cards */}
|
||||||
<ActiveAgentsPanel agents={activeAgents} onAgentSelect={setSelectedAgentId} />
|
<ActiveAgentsPanel agents={activeAgents} projectId={projectId} onAgentSelect={setSelectedAgentId} />
|
||||||
|
|
||||||
{/* Agent List */}
|
{/* Agent List */}
|
||||||
{agentView === "tree" ? (
|
{agentView === "tree" ? (
|
||||||
|
|||||||
@@ -0,0 +1,290 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
import { ActiveAgentsPanel } from "../ActiveAgentsPanel";
|
||||||
|
import type { Agent } from "../../api";
|
||||||
|
import { useLiveTranscript } from "../../hooks/useLiveTranscript";
|
||||||
|
|
||||||
|
// Mock useLiveTranscript
|
||||||
|
vi.mock("../../hooks/useLiveTranscript", () => ({
|
||||||
|
useLiveTranscript: vi.fn().mockReturnValue({
|
||||||
|
entries: [],
|
||||||
|
isConnected: false,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockUseLiveTranscript = vi.mocked(useLiveTranscript);
|
||||||
|
|
||||||
|
describe("ActiveAgentsPanel", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockUseLiveTranscript.mockReturnValue({
|
||||||
|
entries: [],
|
||||||
|
isConnected: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders live transcript text from entries", async () => {
|
||||||
|
mockUseLiveTranscript.mockReturnValue({
|
||||||
|
entries: [
|
||||||
|
{ type: "text", text: "Processing request...", timestamp: "2026-01-01T00:01:00Z" },
|
||||||
|
{ type: "text", text: "Analyzing code...", timestamp: "2026-01-01T00:02:00Z" },
|
||||||
|
],
|
||||||
|
isConnected: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockAgent: Agent = {
|
||||||
|
id: "agent-001",
|
||||||
|
name: "Test Agent",
|
||||||
|
role: "executor",
|
||||||
|
state: "running",
|
||||||
|
taskId: "FN-001",
|
||||||
|
lastHeartbeatAt: new Date().toISOString(),
|
||||||
|
} as Agent;
|
||||||
|
|
||||||
|
render(<ActiveAgentsPanel agents={[mockAgent]} />);
|
||||||
|
|
||||||
|
expect(screen.getByText("Processing request...")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Analyzing code...")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes projectId from props to useLiveTranscript hook", async () => {
|
||||||
|
const mockAgent: Agent = {
|
||||||
|
id: "agent-001",
|
||||||
|
name: "Test Agent",
|
||||||
|
role: "executor",
|
||||||
|
state: "running",
|
||||||
|
taskId: "FN-001",
|
||||||
|
lastHeartbeatAt: new Date().toISOString(),
|
||||||
|
} as Agent;
|
||||||
|
|
||||||
|
render(<ActiveAgentsPanel agents={[mockAgent]} projectId="my-project" />);
|
||||||
|
|
||||||
|
// Verify the hook was called with the projectId
|
||||||
|
expect(mockUseLiveTranscript).toHaveBeenCalledWith("FN-001", "my-project");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes undefined projectId when not provided", async () => {
|
||||||
|
const mockAgent: Agent = {
|
||||||
|
id: "agent-001",
|
||||||
|
name: "Test Agent",
|
||||||
|
role: "executor",
|
||||||
|
state: "running",
|
||||||
|
taskId: "FN-001",
|
||||||
|
lastHeartbeatAt: new Date().toISOString(),
|
||||||
|
} as Agent;
|
||||||
|
|
||||||
|
render(<ActiveAgentsPanel agents={[mockAgent]} />);
|
||||||
|
|
||||||
|
// Verify the hook was called without projectId
|
||||||
|
expect(mockUseLiveTranscript).toHaveBeenCalledWith("FN-001", undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders empty state when no entries yet", async () => {
|
||||||
|
mockUseLiveTranscript.mockReturnValue({
|
||||||
|
entries: [],
|
||||||
|
isConnected: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockAgent: Agent = {
|
||||||
|
id: "agent-001",
|
||||||
|
name: "Test Agent",
|
||||||
|
role: "executor",
|
||||||
|
state: "running",
|
||||||
|
taskId: "FN-001",
|
||||||
|
lastHeartbeatAt: new Date().toISOString(),
|
||||||
|
} as Agent;
|
||||||
|
|
||||||
|
render(<ActiveAgentsPanel agents={[mockAgent]} />);
|
||||||
|
|
||||||
|
expect(screen.getByText("Connecting...")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders 'Waiting for output...' when connected but no entries", async () => {
|
||||||
|
mockUseLiveTranscript.mockReturnValue({
|
||||||
|
entries: [],
|
||||||
|
isConnected: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockAgent: Agent = {
|
||||||
|
id: "agent-001",
|
||||||
|
name: "Test Agent",
|
||||||
|
role: "executor",
|
||||||
|
state: "running",
|
||||||
|
taskId: "FN-001",
|
||||||
|
lastHeartbeatAt: new Date().toISOString(),
|
||||||
|
} as Agent;
|
||||||
|
|
||||||
|
render(<ActiveAgentsPanel agents={[mockAgent]} />);
|
||||||
|
|
||||||
|
expect(screen.getByText("Waiting for output...")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders multiple agent cards with separate transcript streams", async () => {
|
||||||
|
mockUseLiveTranscript
|
||||||
|
.mockReturnValueOnce({
|
||||||
|
entries: [{ type: "text", text: "Agent 1 output", timestamp: "2026-01-01T00:01:00Z" }],
|
||||||
|
isConnected: true,
|
||||||
|
})
|
||||||
|
.mockReturnValueOnce({
|
||||||
|
entries: [{ type: "text", text: "Agent 2 output", timestamp: "2026-01-01T00:02:00Z" }],
|
||||||
|
isConnected: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockAgent1: Agent = {
|
||||||
|
id: "agent-001",
|
||||||
|
name: "Agent One",
|
||||||
|
role: "executor",
|
||||||
|
state: "running",
|
||||||
|
taskId: "FN-001",
|
||||||
|
lastHeartbeatAt: new Date().toISOString(),
|
||||||
|
} as Agent;
|
||||||
|
|
||||||
|
const mockAgent2: Agent = {
|
||||||
|
id: "agent-002",
|
||||||
|
name: "Agent Two",
|
||||||
|
role: "reviewer",
|
||||||
|
state: "running",
|
||||||
|
taskId: "FN-002",
|
||||||
|
lastHeartbeatAt: new Date().toISOString(),
|
||||||
|
} as Agent;
|
||||||
|
|
||||||
|
render(<ActiveAgentsPanel agents={[mockAgent1, mockAgent2]} />);
|
||||||
|
|
||||||
|
expect(screen.getByText("Agent 1 output")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Agent 2 output")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders up to 20 transcript lines per card", async () => {
|
||||||
|
// The component receives entries and slices to first 20
|
||||||
|
// In real usage, the hook prepends new entries, so most recent first
|
||||||
|
// For the mock, we simulate this by providing entries in reverse order
|
||||||
|
const manyEntries = Array.from({ length: 25 }, (_, i) => ({
|
||||||
|
type: "text" as const,
|
||||||
|
text: `Line ${24 - i}`, // Reversed: 24, 23, 22, ..., 1, 0
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
mockUseLiveTranscript.mockReturnValue({
|
||||||
|
entries: manyEntries,
|
||||||
|
isConnected: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockAgent: Agent = {
|
||||||
|
id: "agent-001",
|
||||||
|
name: "Test Agent",
|
||||||
|
role: "executor",
|
||||||
|
state: "running",
|
||||||
|
taskId: "FN-001",
|
||||||
|
lastHeartbeatAt: new Date().toISOString(),
|
||||||
|
} as Agent;
|
||||||
|
|
||||||
|
render(<ActiveAgentsPanel agents={[mockAgent]} />);
|
||||||
|
|
||||||
|
// Should show the first 20 entries (most recent first)
|
||||||
|
// With reversed entries, slice(0, 20) gives us Line 24 through Line 5
|
||||||
|
expect(screen.getByText("Line 24")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("Line 4")).not.toBeInTheDocument(); // Line 4 is beyond index 20
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when agents array is empty", async () => {
|
||||||
|
const { container } = render(<ActiveAgentsPanel agents={[]} />);
|
||||||
|
expect(container.firstChild).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("displays agent name and task badge", async () => {
|
||||||
|
mockUseLiveTranscript.mockReturnValue({
|
||||||
|
entries: [],
|
||||||
|
isConnected: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockAgent: Agent = {
|
||||||
|
id: "agent-001",
|
||||||
|
name: "My Agent",
|
||||||
|
role: "executor",
|
||||||
|
state: "running",
|
||||||
|
taskId: "FN-042",
|
||||||
|
lastHeartbeatAt: new Date().toISOString(),
|
||||||
|
} as Agent;
|
||||||
|
|
||||||
|
render(<ActiveAgentsPanel agents={[mockAgent]} />);
|
||||||
|
|
||||||
|
expect(screen.getByText("My Agent")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("FN-042")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onAgentSelect with agent ID when card is clicked", async () => {
|
||||||
|
mockUseLiveTranscript.mockReturnValue({
|
||||||
|
entries: [],
|
||||||
|
isConnected: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockAgent: Agent = {
|
||||||
|
id: "agent-001",
|
||||||
|
name: "Test Agent",
|
||||||
|
role: "executor",
|
||||||
|
state: "running",
|
||||||
|
taskId: "FN-001",
|
||||||
|
lastHeartbeatAt: new Date().toISOString(),
|
||||||
|
} as Agent;
|
||||||
|
|
||||||
|
const handleSelect = vi.fn();
|
||||||
|
render(<ActiveAgentsPanel agents={[mockAgent]} onAgentSelect={handleSelect} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /select agent test agent/i }));
|
||||||
|
|
||||||
|
expect(handleSelect).toHaveBeenCalledWith("agent-001");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows active indicator when connected", async () => {
|
||||||
|
mockUseLiveTranscript.mockReturnValue({
|
||||||
|
entries: [{ type: "text", text: "Test", timestamp: "2026-01-01T00:00:00Z" }],
|
||||||
|
isConnected: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockAgent: Agent = {
|
||||||
|
id: "agent-001",
|
||||||
|
name: "Test Agent",
|
||||||
|
role: "executor",
|
||||||
|
state: "running",
|
||||||
|
taskId: "FN-001",
|
||||||
|
lastHeartbeatAt: new Date().toISOString(),
|
||||||
|
} as Agent;
|
||||||
|
|
||||||
|
const { container } = render(<ActiveAgentsPanel agents={[mockAgent]} />);
|
||||||
|
|
||||||
|
// The streaming dot should be present when connected
|
||||||
|
const streamingDot = container.querySelector(".live-agent-streaming-dot");
|
||||||
|
expect(streamingDot).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes projectId through to hook for each agent card", async () => {
|
||||||
|
mockUseLiveTranscript.mockReturnValue({
|
||||||
|
entries: [],
|
||||||
|
isConnected: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockAgent1: Agent = {
|
||||||
|
id: "agent-001",
|
||||||
|
name: "Agent One",
|
||||||
|
role: "executor",
|
||||||
|
state: "running",
|
||||||
|
taskId: "FN-001",
|
||||||
|
lastHeartbeatAt: new Date().toISOString(),
|
||||||
|
} as Agent;
|
||||||
|
|
||||||
|
const mockAgent2: Agent = {
|
||||||
|
id: "agent-002",
|
||||||
|
name: "Agent Two",
|
||||||
|
role: "executor",
|
||||||
|
state: "running",
|
||||||
|
taskId: "FN-002",
|
||||||
|
lastHeartbeatAt: new Date().toISOString(),
|
||||||
|
} as Agent;
|
||||||
|
|
||||||
|
render(<ActiveAgentsPanel agents={[mockAgent1, mockAgent2]} projectId="shared-project" />);
|
||||||
|
|
||||||
|
// Both agents should receive the same projectId
|
||||||
|
expect(mockUseLiveTranscript).toHaveBeenCalledWith("FN-001", "shared-project");
|
||||||
|
expect(mockUseLiveTranscript).toHaveBeenCalledWith("FN-002", "shared-project");
|
||||||
|
});
|
||||||
|
});
|
||||||
216
packages/dashboard/app/hooks/__tests__/useLiveTranscript.test.ts
Normal file
216
packages/dashboard/app/hooks/__tests__/useLiveTranscript.test.ts
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { renderHook, act, waitFor } from "@testing-library/react";
|
||||||
|
import { useLiveTranscript } from "../useLiveTranscript";
|
||||||
|
|
||||||
|
// Mock EventSource is provided by vitest.setup.ts
|
||||||
|
|
||||||
|
describe("useLiveTranscript", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Reset mock instances between tests
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders entries with canonical `text` field from SSE", async () => {
|
||||||
|
const { result } = renderHook(() => useLiveTranscript("FN-001"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.entries).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Simulate SSE event with `text` field (matching AgentLogEntry)
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes legacy `content` field to `text` for backward compatibility", async () => {
|
||||||
|
const { result } = renderHook(() => useLiveTranscript("FN-001"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.entries).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Simulate legacy SSE event with `content` field instead of `text`
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
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"));
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const es = (globalThis as any).EventSource;
|
||||||
|
expect(es.instances).toHaveLength(1);
|
||||||
|
expect(es.instances[0].url).toContain("projectId=project-abc");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not include projectId in URL when not provided", async () => {
|
||||||
|
renderHook(() => useLiveTranscript("FN-001"));
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add an entry first
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes EventSource on unmount", async () => {
|
||||||
|
const { unmount } = renderHook(() => useLiveTranscript("FN-001"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
expect((globalThis as any).EventSource.instances).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const es = (globalThis as any).EventSource;
|
||||||
|
const instance = es.instances[0];
|
||||||
|
const closeSpy = vi.spyOn(instance, "close");
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
expect(closeSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets isConnected to true on SSE open", async () => {
|
||||||
|
const { result } = renderHook(() => useLiveTranscript("FN-001"));
|
||||||
|
|
||||||
|
expect(result.current.isConnected).toBe(false);
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
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 () => {
|
||||||
|
const { result } = renderHook(() => useLiveTranscript("FN-001"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.entries).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const es = (globalThis as any).EventSource;
|
||||||
|
const instance = es.instances[0];
|
||||||
|
|
||||||
|
// Send malformed JSON
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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...");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,13 +1,23 @@
|
|||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
|
|
||||||
/** Log entry from an agent's execution stream */
|
/**
|
||||||
|
* Log entry from an agent's execution stream.
|
||||||
|
*
|
||||||
|
* Note: SSE payloads from `/api/tasks/:id/logs/stream` contain `text` field
|
||||||
|
* (matching `AgentLogEntry` from `@fusion/core`). This interface normalizes
|
||||||
|
* to `text` for rendering. Legacy payloads with `content` are also supported
|
||||||
|
* for backward compatibility.
|
||||||
|
*/
|
||||||
export interface TranscriptEntry {
|
export interface TranscriptEntry {
|
||||||
type: string;
|
type: string;
|
||||||
content: string;
|
/** Canonical text content — matches `AgentLogEntry.text` */
|
||||||
|
text: string;
|
||||||
timestamp?: string;
|
timestamp?: string;
|
||||||
|
/** Legacy field — normalized to `text` if present */
|
||||||
|
content?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useLiveTranscript(taskId: string | undefined) {
|
export function useLiveTranscript(taskId: string | undefined, projectId?: string) {
|
||||||
const [entries, setEntries] = useState<TranscriptEntry[]>([]);
|
const [entries, setEntries] = useState<TranscriptEntry[]>([]);
|
||||||
const [isConnected, setIsConnected] = useState(false);
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
const esRef = useRef<EventSource | null>(null);
|
const esRef = useRef<EventSource | null>(null);
|
||||||
@@ -19,14 +29,28 @@ export function useLiveTranscript(taskId: string | undefined) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const es = new EventSource(`/api/tasks/${encodeURIComponent(taskId)}/logs/stream`);
|
// Build stream URL with optional projectId for multi-project support
|
||||||
|
let url = `/api/tasks/${encodeURIComponent(taskId)}/logs/stream`;
|
||||||
|
if (projectId) {
|
||||||
|
url += `?projectId=${encodeURIComponent(projectId)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const es = new EventSource(url);
|
||||||
esRef.current = es;
|
esRef.current = es;
|
||||||
|
|
||||||
es.addEventListener("agent:log", (event) => {
|
es.addEventListener("agent:log", (event) => {
|
||||||
try {
|
try {
|
||||||
const entry = JSON.parse(event.data) as TranscriptEntry;
|
const raw = JSON.parse(event.data) as Partial<TranscriptEntry>;
|
||||||
|
// Normalize: canonical `text` field, with legacy `content` fallback
|
||||||
|
// This ensures both current SSE payloads and any legacy payloads render correctly
|
||||||
|
const entry: TranscriptEntry = {
|
||||||
|
type: raw.type ?? "text",
|
||||||
|
text: raw.text ?? raw.content ?? "",
|
||||||
|
timestamp: raw.timestamp,
|
||||||
|
content: raw.content,
|
||||||
|
};
|
||||||
setEntries(prev => [entry, ...prev]);
|
setEntries(prev => [entry, ...prev]);
|
||||||
} catch { /* skip */ }
|
} catch { /* skip malformed events */ }
|
||||||
});
|
});
|
||||||
|
|
||||||
es.addEventListener("open", () => setIsConnected(true));
|
es.addEventListener("open", () => setIsConnected(true));
|
||||||
@@ -37,7 +61,7 @@ export function useLiveTranscript(taskId: string | undefined) {
|
|||||||
esRef.current = null;
|
esRef.current = null;
|
||||||
setIsConnected(false);
|
setIsConnected(false);
|
||||||
};
|
};
|
||||||
}, [taskId]);
|
}, [taskId, projectId]);
|
||||||
|
|
||||||
return { entries, isConnected };
|
return { entries, isConnected };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user