feat(HAI-072): add agent log viewer with persistence, streaming, and UI
- Add agent log persistence layer with JSONL append/read and event emission in core store - Add server-side SSE log streaming endpoint and REST route for fetching logs - Create AgentLogViewer component and useAgentLogs hook for real-time log display - Integrate log viewer into TaskDetailModal - Fix pre-existing build and test errors
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS } from "./types.js";
|
||||
export type { Column, Task, TaskAttachment, TaskCreateInput, TaskDetail, BoardConfig, MergeResult, Settings, TaskStep, StepStatus, TaskLogEntry } from "./types.js";
|
||||
export type { Column, Task, TaskAttachment, TaskCreateInput, TaskDetail, AgentLogEntry, BoardConfig, MergeResult, Settings, TaskStep, StepStatus, TaskLogEntry } from "./types.js";
|
||||
export { TaskStore } from "./store.js";
|
||||
export { canTransition, getValidTransitions, resolveDependencyOrder } from "./board.js";
|
||||
|
||||
@@ -400,4 +400,50 @@ describe("TaskStore", () => {
|
||||
expect(updated.blockedBy).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("agent log persistence", () => {
|
||||
it("appendAgentLog creates agent.log and getAgentLogs reads it back", async () => {
|
||||
const task = await createTestTask();
|
||||
|
||||
await store.appendAgentLog(task.id, "Hello world", "text");
|
||||
await store.appendAgentLog(task.id, "Read", "tool");
|
||||
|
||||
const logs = await store.getAgentLogs(task.id);
|
||||
expect(logs).toHaveLength(2);
|
||||
expect(logs[0].text).toBe("Hello world");
|
||||
expect(logs[0].type).toBe("text");
|
||||
expect(logs[0].taskId).toBe(task.id);
|
||||
expect(logs[1].text).toBe("Read");
|
||||
expect(logs[1].type).toBe("tool");
|
||||
});
|
||||
|
||||
it("getAgentLogs returns empty array when no log file exists", async () => {
|
||||
const task = await createTestTask();
|
||||
const logs = await store.getAgentLogs(task.id);
|
||||
expect(logs).toEqual([]);
|
||||
});
|
||||
|
||||
it("appendAgentLog emits agent:log event", async () => {
|
||||
const task = await createTestTask();
|
||||
const events: any[] = [];
|
||||
store.on("agent:log", (entry) => events.push(entry));
|
||||
|
||||
await store.appendAgentLog(task.id, "delta text", "text");
|
||||
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].text).toBe("delta text");
|
||||
expect(events[0].type).toBe("text");
|
||||
expect(events[0].taskId).toBe(task.id);
|
||||
});
|
||||
|
||||
it("handles multiple appends correctly (JSONL format)", async () => {
|
||||
const task = await createTestTask();
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await store.appendAgentLog(task.id, `chunk ${i}`, "text");
|
||||
}
|
||||
const logs = await store.getAgentLogs(task.id);
|
||||
expect(logs).toHaveLength(5);
|
||||
expect(logs[4].text).toBe("chunk 4");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { execSync } from "node:child_process";
|
||||
import { mkdir, readFile, writeFile, readdir, rename, unlink } from "node:fs/promises";
|
||||
import { appendFile, mkdir, readFile, writeFile, readdir, rename, unlink } from "node:fs/promises";
|
||||
import { join, sep } from "node:path";
|
||||
import { existsSync, watch, type FSWatcher } from "node:fs";
|
||||
import type { Task, TaskDetail, TaskCreateInput, TaskAttachment, BoardConfig, Column, MergeResult, Settings } from "./types.js";
|
||||
import type { Task, TaskDetail, TaskCreateInput, TaskAttachment, AgentLogEntry, BoardConfig, Column, MergeResult, Settings } from "./types.js";
|
||||
import { VALID_TRANSITIONS, DEFAULT_SETTINGS } from "./types.js";
|
||||
|
||||
export interface TaskStoreEvents {
|
||||
@@ -12,6 +12,7 @@ export interface TaskStoreEvents {
|
||||
"task:updated": [task: Task];
|
||||
"task:deleted": [task: Task];
|
||||
"task:merged": [result: MergeResult];
|
||||
"agent:log": [entry: AgentLogEntry];
|
||||
}
|
||||
|
||||
export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
@@ -839,6 +840,52 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Append an agent log entry to the task's agent log file (JSONL format).
|
||||
* Each entry is a single JSON line appended to `.hai/tasks/{ID}/agent.log`.
|
||||
* Also emits an `agent:log` event for live streaming.
|
||||
*
|
||||
* @param taskId - The task ID (e.g. "HAI-001")
|
||||
* @param text - The text content (delta for "text", tool name for "tool")
|
||||
* @param type - Whether this is a "text" delta or a "tool" invocation marker
|
||||
*/
|
||||
async appendAgentLog(taskId: string, text: string, type: "text" | "tool"): Promise<void> {
|
||||
const entry: AgentLogEntry = {
|
||||
timestamp: new Date().toISOString(),
|
||||
taskId,
|
||||
text,
|
||||
type,
|
||||
};
|
||||
const dir = this.taskDir(taskId);
|
||||
const logPath = join(dir, "agent.log");
|
||||
await appendFile(logPath, JSON.stringify(entry) + "\n");
|
||||
this.emit("agent:log", entry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read all historical agent log entries for a task from its agent log file.
|
||||
* Returns entries in chronological order (oldest first).
|
||||
*
|
||||
* @param taskId - The task ID (e.g. "HAI-001")
|
||||
* @returns Array of agent log entries, empty if no log file exists
|
||||
*/
|
||||
async getAgentLogs(taskId: string): Promise<AgentLogEntry[]> {
|
||||
const dir = this.taskDir(taskId);
|
||||
const logPath = join(dir, "agent.log");
|
||||
if (!existsSync(logPath)) return [];
|
||||
const content = await readFile(logPath, "utf-8");
|
||||
const entries: AgentLogEntry[] = [];
|
||||
for (const line of content.split("\n")) {
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
entries.push(JSON.parse(line) as AgentLogEntry);
|
||||
} catch {
|
||||
// skip malformed lines
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
getRootDir(): string {
|
||||
return this.rootDir;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,18 @@ export interface TaskLogEntry {
|
||||
outcome?: string;
|
||||
}
|
||||
|
||||
/** A single chunk of agent output (text delta or tool invocation) persisted to disk. */
|
||||
export interface AgentLogEntry {
|
||||
/** ISO-8601 timestamp of when the entry was recorded */
|
||||
timestamp: string;
|
||||
/** The task this log entry belongs to */
|
||||
taskId: string;
|
||||
/** The text content (delta for "text", tool name for "tool") */
|
||||
text: string;
|
||||
/** Whether this is a text delta or a tool invocation marker */
|
||||
type: "text" | "tool";
|
||||
}
|
||||
|
||||
export interface TaskAttachment {
|
||||
filename: string;
|
||||
originalName: string;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Task, TaskDetail, TaskAttachment, TaskCreateInput, Column, MergeResult, Settings } from "@hai/core";
|
||||
import type { Task, TaskDetail, TaskAttachment, TaskCreateInput, AgentLogEntry, Column, MergeResult, Settings } from "@hai/core";
|
||||
|
||||
async function api<T = unknown>(path: string, opts: RequestInit = {}): Promise<T> {
|
||||
const res = await fetch(`/api${path}`, {
|
||||
@@ -93,3 +93,7 @@ export async function uploadAttachment(id: string, file: File): Promise<TaskAtta
|
||||
export async function deleteAttachment(id: string, filename: string): Promise<Task> {
|
||||
return api<Task>(`/tasks/${id}/attachments/${filename}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
export function fetchAgentLogs(taskId: string): Promise<AgentLogEntry[]> {
|
||||
return api<AgentLogEntry[]>(`/tasks/${taskId}/logs`);
|
||||
}
|
||||
|
||||
91
packages/dashboard/app/components/AgentLogViewer.tsx
Normal file
91
packages/dashboard/app/components/AgentLogViewer.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { AgentLogEntry } from "@hai/core";
|
||||
|
||||
interface AgentLogViewerProps {
|
||||
entries: AgentLogEntry[];
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders agent log entries in a scrollable, monospace container.
|
||||
* Auto-scrolls to the bottom as new entries arrive, but pauses
|
||||
* auto-scroll when the user scrolls up (scroll-lock).
|
||||
*/
|
||||
export function AgentLogViewer({ entries, loading }: AgentLogViewerProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
|
||||
// Auto-scroll to bottom when new entries arrive (if scroll-lock is not active)
|
||||
useEffect(() => {
|
||||
if (autoScroll && containerRef.current) {
|
||||
containerRef.current.scrollTop = containerRef.current.scrollHeight;
|
||||
}
|
||||
}, [entries, autoScroll]);
|
||||
|
||||
const handleScroll = () => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
// If the user is within 50px of the bottom, re-enable auto-scroll
|
||||
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 50;
|
||||
setAutoScroll(atBottom);
|
||||
};
|
||||
|
||||
if (loading && entries.length === 0) {
|
||||
return (
|
||||
<div className="agent-log-viewer" data-testid="agent-log-viewer">
|
||||
<div className="agent-log-loading">Loading agent logs…</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
<div className="agent-log-viewer" data-testid="agent-log-viewer">
|
||||
<div className="agent-log-empty">No agent output yet.</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="agent-log-viewer"
|
||||
data-testid="agent-log-viewer"
|
||||
ref={containerRef}
|
||||
onScroll={handleScroll}
|
||||
style={{
|
||||
fontFamily: "monospace",
|
||||
fontSize: "13px",
|
||||
lineHeight: "1.5",
|
||||
overflowY: "auto",
|
||||
maxHeight: "500px",
|
||||
padding: "12px",
|
||||
background: "var(--bg-secondary, #1a1a2e)",
|
||||
borderRadius: "6px",
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
}}
|
||||
>
|
||||
{entries.map((entry, i) =>
|
||||
entry.type === "tool" ? (
|
||||
<div
|
||||
key={i}
|
||||
className="agent-log-tool"
|
||||
style={{
|
||||
color: "var(--accent, #7c5cbf)",
|
||||
margin: "4px 0",
|
||||
padding: "2px 6px",
|
||||
borderLeft: "3px solid var(--accent, #7c5cbf)",
|
||||
background: "rgba(124, 92, 191, 0.08)",
|
||||
}}
|
||||
>
|
||||
⚡ {entry.text}
|
||||
</div>
|
||||
) : (
|
||||
<span key={i} className="agent-log-text">
|
||||
{entry.text}
|
||||
</span>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import type { Task, TaskDetail, TaskAttachment, Column, MergeResult } from "@hai
|
||||
import { COLUMN_LABELS, VALID_TRANSITIONS } from "@hai/core";
|
||||
import { uploadAttachment, deleteAttachment, updateTask } from "../api";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { useAgentLogs } from "../hooks/useAgentLogs";
|
||||
import { AgentLogViewer } from "./AgentLogViewer";
|
||||
|
||||
function formatTimestamp(iso: string): string {
|
||||
const date = new Date(iso);
|
||||
@@ -52,11 +54,16 @@ export function TaskDetailModal({
|
||||
onRetryTask,
|
||||
addToast,
|
||||
}: TaskDetailModalProps) {
|
||||
const [activeTab, setActiveTab] = useState<"definition" | "agent-log">("definition");
|
||||
const [attachments, setAttachments] = useState<TaskAttachment[]>(task.attachments || []);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [dependencies, setDependencies] = useState<string[]>(task.dependencies || []);
|
||||
const [showDepDropdown, setShowDepDropdown] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const { entries: agentLogEntries, loading: agentLogLoading } = useAgentLogs(
|
||||
task.id,
|
||||
activeTab === "agent-log",
|
||||
);
|
||||
useEffect(() => {
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
@@ -235,6 +242,46 @@ export function TaskDetailModal({
|
||||
Created {new Date(task.createdAt).toLocaleDateString()} · Updated{" "}
|
||||
{new Date(task.updatedAt).toLocaleDateString()}
|
||||
</div>
|
||||
<div className="detail-tabs" style={{ display: "flex", gap: "0", borderBottom: "1px solid var(--border, #333)", marginBottom: "12px" }}>
|
||||
<button
|
||||
className={`detail-tab${activeTab === "definition" ? " detail-tab-active" : ""}`}
|
||||
onClick={() => setActiveTab("definition")}
|
||||
style={{
|
||||
padding: "8px 16px",
|
||||
background: "none",
|
||||
border: "none",
|
||||
borderBottom: activeTab === "definition" ? "2px solid var(--accent, #7c5cbf)" : "2px solid transparent",
|
||||
color: activeTab === "definition" ? "var(--text-primary, #fff)" : "var(--text-secondary, #888)",
|
||||
cursor: "pointer",
|
||||
fontSize: "14px",
|
||||
fontWeight: activeTab === "definition" ? 600 : 400,
|
||||
}}
|
||||
>
|
||||
Definition
|
||||
</button>
|
||||
<button
|
||||
className={`detail-tab${activeTab === "agent-log" ? " detail-tab-active" : ""}`}
|
||||
onClick={() => setActiveTab("agent-log")}
|
||||
style={{
|
||||
padding: "8px 16px",
|
||||
background: "none",
|
||||
border: "none",
|
||||
borderBottom: activeTab === "agent-log" ? "2px solid var(--accent, #7c5cbf)" : "2px solid transparent",
|
||||
color: activeTab === "agent-log" ? "var(--text-primary, #fff)" : "var(--text-secondary, #888)",
|
||||
cursor: "pointer",
|
||||
fontSize: "14px",
|
||||
fontWeight: activeTab === "agent-log" ? 600 : 400,
|
||||
}}
|
||||
>
|
||||
Agent Log
|
||||
</button>
|
||||
</div>
|
||||
{activeTab === "agent-log" ? (
|
||||
<div className="detail-section">
|
||||
<AgentLogViewer entries={agentLogEntries} loading={agentLogLoading} />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="detail-section">
|
||||
{task.prompt ? (
|
||||
<div className="markdown-body">
|
||||
@@ -400,6 +447,8 @@ export function TaskDetailModal({
|
||||
<div className="detail-log-empty">(no activity)</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<button className="btn btn-danger btn-sm" onClick={handleDelete}>
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { AgentLogViewer } from "../AgentLogViewer";
|
||||
import type { AgentLogEntry } from "@hai/core";
|
||||
|
||||
function makeEntry(overrides: Partial<AgentLogEntry> = {}): AgentLogEntry {
|
||||
return {
|
||||
timestamp: "2026-01-01T00:00:00Z",
|
||||
taskId: "HAI-001",
|
||||
text: "Hello world",
|
||||
type: "text",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("AgentLogViewer", () => {
|
||||
it("shows loading message when loading with no entries", () => {
|
||||
render(<AgentLogViewer entries={[]} loading={true} />);
|
||||
expect(screen.getByText("Loading agent logs…")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows empty message when no entries and not loading", () => {
|
||||
render(<AgentLogViewer entries={[]} loading={false} />);
|
||||
expect(screen.getByText("No agent output yet.")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders text entries as spans", () => {
|
||||
const entries = [
|
||||
makeEntry({ text: "first chunk" }),
|
||||
makeEntry({ text: "second chunk" }),
|
||||
];
|
||||
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
|
||||
const textSpans = container.querySelectorAll(".agent-log-text");
|
||||
expect(textSpans).toHaveLength(2);
|
||||
expect(textSpans[0].textContent).toBe("first chunk");
|
||||
expect(textSpans[1].textContent).toBe("second chunk");
|
||||
});
|
||||
|
||||
it("renders tool entries with distinct styling", () => {
|
||||
const entries = [
|
||||
makeEntry({ text: "Read", type: "tool" }),
|
||||
];
|
||||
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
|
||||
const toolDiv = container.querySelector(".agent-log-tool");
|
||||
expect(toolDiv).toBeTruthy();
|
||||
expect(toolDiv!.textContent).toContain("Read");
|
||||
});
|
||||
|
||||
it("renders a mix of text and tool entries", () => {
|
||||
const entries = [
|
||||
makeEntry({ text: "Starting...", type: "text" }),
|
||||
makeEntry({ text: "Bash", type: "tool" }),
|
||||
makeEntry({ text: "Done!", type: "text" }),
|
||||
];
|
||||
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
|
||||
expect(container.querySelectorAll(".agent-log-text")).toHaveLength(2);
|
||||
expect(container.querySelectorAll(".agent-log-tool")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("has a monospace font family", () => {
|
||||
const entries = [makeEntry()];
|
||||
const { container } = render(<AgentLogViewer entries={entries} loading={false} />);
|
||||
const viewer = container.querySelector("[data-testid='agent-log-viewer']") as HTMLElement;
|
||||
expect(viewer.style.fontFamily).toBe("monospace");
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,11 @@ vi.mock("../../api", () => ({
|
||||
uploadAttachment: vi.fn(),
|
||||
deleteAttachment: vi.fn(),
|
||||
updateTask: vi.fn().mockResolvedValue({}),
|
||||
fetchAgentLogs: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useAgentLogs", () => ({
|
||||
useAgentLogs: vi.fn(() => ({ entries: [], loading: false, clear: vi.fn() })),
|
||||
}));
|
||||
|
||||
function makeTask(overrides: Partial<TaskDetail> = {}): TaskDetail {
|
||||
@@ -481,8 +486,6 @@ describe("TaskDetailModal", () => {
|
||||
expect(updateTask).toHaveBeenCalledWith("HAI-099", { dependencies: ["HAI-002"] });
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("activity list does not have nested scroll constraints", () => {
|
||||
const { container } = render(
|
||||
@@ -508,4 +511,84 @@ describe("TaskDetailModal", () => {
|
||||
expect(style.overflowY).not.toBe("auto");
|
||||
expect(style.maxHeight).toBe("");
|
||||
});
|
||||
|
||||
describe("tab toggle", () => {
|
||||
it("defaults to the Definition tab", () => {
|
||||
const { container } = render(
|
||||
<TaskDetailModal
|
||||
task={makeTask({ prompt: "# Hello\n\nContent" })}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Definition")).toBeTruthy();
|
||||
expect(screen.getByText("Agent Log")).toBeTruthy();
|
||||
// Definition content should be visible
|
||||
expect(container.querySelector(".markdown-body")).toBeTruthy();
|
||||
// Agent log viewer should not be visible
|
||||
expect(container.querySelector("[data-testid='agent-log-viewer']")).toBeNull();
|
||||
});
|
||||
|
||||
it("switches to Agent Log tab and back", async () => {
|
||||
const { useAgentLogs } = await import("../../hooks/useAgentLogs");
|
||||
const mockUseAgentLogs = vi.mocked(useAgentLogs);
|
||||
|
||||
const { container } = render(
|
||||
<TaskDetailModal
|
||||
task={makeTask({ prompt: "# Hello\n\nContent" })}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Click Agent Log tab
|
||||
fireEvent.click(screen.getByText("Agent Log"));
|
||||
|
||||
// Agent log viewer should appear
|
||||
expect(container.querySelector("[data-testid='agent-log-viewer']")).toBeTruthy();
|
||||
// Definition content should be hidden
|
||||
expect(container.querySelector(".markdown-body")).toBeNull();
|
||||
|
||||
// Click Definition tab to go back
|
||||
fireEvent.click(screen.getByText("Definition"));
|
||||
|
||||
// Definition content should reappear
|
||||
expect(container.querySelector(".markdown-body")).toBeTruthy();
|
||||
expect(container.querySelector("[data-testid='agent-log-viewer']")).toBeNull();
|
||||
});
|
||||
|
||||
it("passes enabled=true to useAgentLogs only when Agent Log tab is active", async () => {
|
||||
const { useAgentLogs } = await import("../../hooks/useAgentLogs");
|
||||
const mockUseAgentLogs = vi.mocked(useAgentLogs);
|
||||
mockUseAgentLogs.mockClear();
|
||||
|
||||
const { rerender } = render(
|
||||
<TaskDetailModal
|
||||
task={makeTask()}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Default: Definition tab active → enabled should be false
|
||||
const initialCall = mockUseAgentLogs.mock.calls[mockUseAgentLogs.mock.calls.length - 1];
|
||||
expect(initialCall[1]).toBe(false);
|
||||
|
||||
// Switch to Agent Log tab
|
||||
fireEvent.click(screen.getByText("Agent Log"));
|
||||
|
||||
const afterSwitch = mockUseAgentLogs.mock.calls[mockUseAgentLogs.mock.calls.length - 1];
|
||||
expect(afterSwitch[1]).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
144
packages/dashboard/app/hooks/__tests__/useAgentLogs.test.ts
Normal file
144
packages/dashboard/app/hooks/__tests__/useAgentLogs.test.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act, waitFor } from "@testing-library/react";
|
||||
import { useAgentLogs } from "../useAgentLogs";
|
||||
import { fetchAgentLogs } from "../../api";
|
||||
|
||||
// Mock the api module
|
||||
vi.mock("../../api", () => ({
|
||||
fetchAgentLogs: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
const mockFetchAgentLogs = vi.mocked(fetchAgentLogs);
|
||||
|
||||
// Mock EventSource
|
||||
class MockEventSource {
|
||||
static instances: MockEventSource[] = [];
|
||||
url: string;
|
||||
listeners: Record<string, ((e: any) => void)[]> = {};
|
||||
readyState = 0;
|
||||
close = vi.fn();
|
||||
|
||||
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;
|
||||
mockFetchAgentLogs.mockReset().mockResolvedValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
(globalThis as any).EventSource = originalEventSource;
|
||||
});
|
||||
|
||||
describe("useAgentLogs", () => {
|
||||
it("does not fetch or connect when enabled=false", () => {
|
||||
const { result } = renderHook(() => useAgentLogs("HAI-001", false));
|
||||
|
||||
expect(mockFetchAgentLogs).not.toHaveBeenCalled();
|
||||
expect(MockEventSource.instances).toHaveLength(0);
|
||||
expect(result.current.entries).toEqual([]);
|
||||
});
|
||||
|
||||
it("fetches historical logs and opens SSE when enabled=true", async () => {
|
||||
const historicalLogs = [
|
||||
{ timestamp: "2026-01-01T00:00:00Z", taskId: "HAI-001", text: "old", type: "text" as const },
|
||||
];
|
||||
mockFetchAgentLogs.mockResolvedValueOnce(historicalLogs);
|
||||
|
||||
const { result } = renderHook(() => useAgentLogs("HAI-001", true));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.entries).toEqual(historicalLogs);
|
||||
});
|
||||
|
||||
expect(mockFetchAgentLogs).toHaveBeenCalledWith("HAI-001");
|
||||
expect(MockEventSource.instances).toHaveLength(1);
|
||||
expect(MockEventSource.instances[0].url).toBe("/api/tasks/HAI-001/logs/stream");
|
||||
});
|
||||
|
||||
it("appends live SSE entries to historical entries", async () => {
|
||||
mockFetchAgentLogs.mockResolvedValueOnce([
|
||||
{ timestamp: "2026-01-01T00:00:00Z", taskId: "HAI-001", text: "old", type: "text" as const },
|
||||
]);
|
||||
|
||||
const { result } = renderHook(() => useAgentLogs("HAI-001", true));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.entries).toHaveLength(1);
|
||||
});
|
||||
|
||||
const es = MockEventSource.instances[0];
|
||||
act(() => {
|
||||
es._emit("agent:log", {
|
||||
timestamp: "2026-01-01T00:01:00Z",
|
||||
taskId: "HAI-001",
|
||||
text: "new",
|
||||
type: "text",
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.entries).toHaveLength(2);
|
||||
expect(result.current.entries[1].text).toBe("new");
|
||||
});
|
||||
|
||||
it("closes SSE when enabled changes to false", async () => {
|
||||
mockFetchAgentLogs.mockResolvedValueOnce([]);
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({ enabled }) => useAgentLogs("HAI-001", enabled),
|
||||
{ initialProps: { enabled: true } },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(MockEventSource.instances).toHaveLength(1);
|
||||
});
|
||||
|
||||
const es = MockEventSource.instances[0];
|
||||
|
||||
rerender({ enabled: false });
|
||||
|
||||
expect(es.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("closes SSE on unmount", async () => {
|
||||
mockFetchAgentLogs.mockResolvedValueOnce([]);
|
||||
|
||||
const { unmount } = renderHook(() => useAgentLogs("HAI-001", true));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(MockEventSource.instances).toHaveLength(1);
|
||||
});
|
||||
|
||||
const es = MockEventSource.instances[0];
|
||||
|
||||
unmount();
|
||||
|
||||
expect(es.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not fetch when taskId is null", () => {
|
||||
renderHook(() => useAgentLogs(null, true));
|
||||
|
||||
expect(mockFetchAgentLogs).not.toHaveBeenCalled();
|
||||
expect(MockEventSource.instances).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
75
packages/dashboard/app/hooks/useAgentLogs.ts
Normal file
75
packages/dashboard/app/hooks/useAgentLogs.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import type { AgentLogEntry } from "@hai/core";
|
||||
import { fetchAgentLogs } from "../api";
|
||||
|
||||
/**
|
||||
* Hook that manages agent log fetching and live SSE streaming for a task.
|
||||
*
|
||||
* When `enabled` is true:
|
||||
* 1. Fetches historical logs via GET /api/tasks/:id/logs
|
||||
* 2. Opens an EventSource to /api/tasks/:id/logs/stream for live updates
|
||||
* 3. Merges historical + live entries in order
|
||||
*
|
||||
* When `enabled` becomes false or the component unmounts, the EventSource
|
||||
* is closed to avoid unnecessary SSE connections.
|
||||
*/
|
||||
export function useAgentLogs(taskId: string | null, enabled: boolean) {
|
||||
const [entries, setEntries] = useState<AgentLogEntry[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const eventSourceRef = useRef<EventSource | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!taskId || !enabled) {
|
||||
// Close any existing connection when disabled
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close();
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
async function init() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const historical = await fetchAgentLogs(taskId!);
|
||||
if (cancelled) return;
|
||||
setEntries(historical);
|
||||
} catch {
|
||||
if (cancelled) return;
|
||||
setEntries([]);
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
|
||||
// Open SSE connection for live updates
|
||||
const es = new EventSource(`/api/tasks/${taskId}/logs/stream`);
|
||||
eventSourceRef.current = es;
|
||||
|
||||
es.addEventListener("agent:log", (e) => {
|
||||
if (cancelled) return;
|
||||
try {
|
||||
const entry: AgentLogEntry = JSON.parse(e.data);
|
||||
setEntries((prev) => [...prev, entry]);
|
||||
} catch {
|
||||
// skip malformed events
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
init();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close();
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [taskId, enabled]);
|
||||
|
||||
const clear = useCallback(() => setEntries([]), []);
|
||||
|
||||
return { entries, loading, clear };
|
||||
}
|
||||
@@ -17,6 +17,7 @@ function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
|
||||
getSettings: vi.fn().mockResolvedValue({}),
|
||||
updateSettings: vi.fn(),
|
||||
logEntry: vi.fn().mockResolvedValue(undefined),
|
||||
getAgentLogs: vi.fn().mockResolvedValue([]),
|
||||
...overrides,
|
||||
} as unknown as TaskStore;
|
||||
}
|
||||
@@ -340,4 +341,36 @@ describe("Attachment routes", () => {
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("GET /tasks/:id/logs — returns agent logs", async () => {
|
||||
const fakeLogs = [
|
||||
{ timestamp: "2026-01-01T00:00:00Z", taskId: "HAI-001", text: "Hello", type: "text" },
|
||||
{ timestamp: "2026-01-01T00:00:01Z", taskId: "HAI-001", text: "Read", type: "tool" },
|
||||
];
|
||||
(store.getAgentLogs as ReturnType<typeof vi.fn>).mockResolvedValue(fakeLogs);
|
||||
|
||||
const res = await GET(buildApp(), "/api/tasks/HAI-001/logs");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual(fakeLogs);
|
||||
expect(store.getAgentLogs).toHaveBeenCalledWith("HAI-001");
|
||||
});
|
||||
|
||||
it("GET /tasks/:id/logs — returns empty array when no logs", async () => {
|
||||
(store.getAgentLogs as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||
|
||||
const res = await GET(buildApp(), "/api/tasks/HAI-001/logs");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual([]);
|
||||
});
|
||||
|
||||
it("GET /tasks/:id/logs — returns 500 on store error", async () => {
|
||||
(store.getAgentLogs as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("disk error"));
|
||||
|
||||
const res = await GET(buildApp(), "/api/tasks/HAI-001/logs");
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
expect(res.body.error).toBe("disk error");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -174,6 +174,20 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
}
|
||||
});
|
||||
|
||||
// Get historical agent logs for a task
|
||||
router.get("/tasks/:id/logs", async (req, res) => {
|
||||
try {
|
||||
const logs = await store.getAgentLogs(req.params.id);
|
||||
res.json(logs);
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT") {
|
||||
res.status(404).json({ error: `Task ${req.params.id} not found` });
|
||||
} else {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Get single task with prompt content
|
||||
router.get("/tasks/:id", async (req, res) => {
|
||||
try {
|
||||
|
||||
@@ -43,6 +43,35 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
|
||||
// Rate limiting — stricter limit on SSE connections
|
||||
app.get("/api/events", rateLimit(RATE_LIMITS.sse), createSSE(store));
|
||||
|
||||
// Per-task SSE endpoint for live agent log streaming
|
||||
app.get("/api/tasks/:id/logs/stream", (req, res) => {
|
||||
const taskId = req.params.id;
|
||||
|
||||
res.setHeader("Content-Type", "text/event-stream");
|
||||
res.setHeader("Cache-Control", "no-cache");
|
||||
res.setHeader("Connection", "keep-alive");
|
||||
res.setHeader("X-Accel-Buffering", "no");
|
||||
res.flushHeaders();
|
||||
|
||||
res.write(": connected\n\n");
|
||||
|
||||
const onAgentLog = (entry: { taskId: string; text: string; type: string; timestamp: string }) => {
|
||||
if (entry.taskId !== taskId) return;
|
||||
res.write(`event: agent:log\ndata: ${JSON.stringify(entry)}\n\n`);
|
||||
};
|
||||
|
||||
store.on("agent:log", onAgentLog);
|
||||
|
||||
const heartbeat = setInterval(() => {
|
||||
res.write(": heartbeat\n\n");
|
||||
}, 30_000);
|
||||
|
||||
req.on("close", () => {
|
||||
clearInterval(heartbeat);
|
||||
store.off("agent:log", onAgentLog);
|
||||
});
|
||||
});
|
||||
|
||||
// Rate limiting — mutation endpoints (POST/PUT/PATCH/DELETE)
|
||||
app.use("/api", rateLimit(RATE_LIMITS.api));
|
||||
|
||||
|
||||
@@ -314,14 +314,54 @@ export class TaskExecutor {
|
||||
this.createReviewStepTool(task.id, worktreePath, detail.prompt),
|
||||
];
|
||||
|
||||
// ── Agent log buffering ──────────────────────────────────────────
|
||||
// Buffer text deltas and flush to disk periodically to avoid
|
||||
// excessive I/O from many small writes.
|
||||
let textBuffer = "";
|
||||
let flushTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const FLUSH_INTERVAL_MS = 500;
|
||||
const FLUSH_SIZE_BYTES = 1024;
|
||||
|
||||
const flushTextBuffer = async () => {
|
||||
if (textBuffer.length === 0) return;
|
||||
const chunk = textBuffer;
|
||||
textBuffer = "";
|
||||
try {
|
||||
await this.store.appendAgentLog(task.id, chunk, "text");
|
||||
} catch { /* best-effort persistence */ }
|
||||
};
|
||||
|
||||
const scheduleFlush = () => {
|
||||
if (flushTimer) return;
|
||||
flushTimer = setTimeout(async () => {
|
||||
flushTimer = null;
|
||||
await flushTextBuffer();
|
||||
}, FLUSH_INTERVAL_MS);
|
||||
};
|
||||
|
||||
const agentWork = async () => {
|
||||
const { session } = await createHaiAgent({
|
||||
cwd: worktreePath,
|
||||
systemPrompt: EXECUTOR_SYSTEM_PROMPT,
|
||||
tools: "coding",
|
||||
customTools,
|
||||
onText: (delta) => this.options.onAgentText?.(task.id, delta),
|
||||
onToolStart: (name) => this.options.onAgentTool?.(task.id, name),
|
||||
onText: (delta) => {
|
||||
this.options.onAgentText?.(task.id, delta);
|
||||
textBuffer += delta;
|
||||
if (textBuffer.length >= FLUSH_SIZE_BYTES) {
|
||||
if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
|
||||
flushTextBuffer();
|
||||
} else {
|
||||
scheduleFlush();
|
||||
}
|
||||
},
|
||||
onToolStart: (name) => {
|
||||
this.options.onAgentTool?.(task.id, name);
|
||||
// Flush any pending text before recording the tool entry
|
||||
if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
|
||||
flushTextBuffer();
|
||||
this.store.appendAgentLog(task.id, name, "tool").catch(() => {});
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -339,6 +379,9 @@ export class TaskExecutor {
|
||||
this.options.onComplete?.(task);
|
||||
}
|
||||
} finally {
|
||||
// Flush remaining buffered text before disposing the session
|
||||
if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
|
||||
await flushTextBuffer();
|
||||
session.dispose();
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user