feat(FN-2465): stabilize agent log handling and add OpenClaw adapter
- Make agent log ordering deterministic in core store and dev-server retrieval paths - Stabilize AgentLogViewer row identity and hook ordering behavior to prevent regressions - Add targeted tests for store ordering, useAgentLogs hook behavior, and AgentLogViewer rendering - Introduce executable OpenClaw runtime adapter modules, types, and updated plugin packaging/docs
This commit is contained in:
@@ -3949,6 +3949,45 @@ Task with acceptance criteria
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves insertion order when multiple entries share the same timestamp", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
const tiedTimestamp = "2026-04-24T12:00:00.000Z";
|
||||||
|
|
||||||
|
insertLogEntryWithTimestamp(store, task.id, "first tied", "text", tiedTimestamp);
|
||||||
|
insertLogEntryWithTimestamp(store, task.id, "second tied", "text", tiedTimestamp);
|
||||||
|
insertLogEntryWithTimestamp(store, task.id, "third tied", "text", tiedTimestamp);
|
||||||
|
|
||||||
|
const logs = await store.getAgentLogs(task.id);
|
||||||
|
expect(logs.map((entry) => entry.text)).toEqual([
|
||||||
|
"first tied",
|
||||||
|
"second tied",
|
||||||
|
"third tied",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies deterministic ordering for tied timestamps with limit/offset pagination", async () => {
|
||||||
|
const task = await createTestTask();
|
||||||
|
const tiedTimestamp = "2026-04-24T12:00:00.000Z";
|
||||||
|
|
||||||
|
insertLogEntryWithTimestamp(store, task.id, "first tied", "text", tiedTimestamp);
|
||||||
|
insertLogEntryWithTimestamp(store, task.id, "second tied", "text", tiedTimestamp);
|
||||||
|
insertLogEntryWithTimestamp(store, task.id, "third tied", "text", tiedTimestamp);
|
||||||
|
insertLogEntryWithTimestamp(store, task.id, "fourth tied", "text", tiedTimestamp);
|
||||||
|
|
||||||
|
await expect(store.getAgentLogs(task.id, { limit: 2 })).resolves.toMatchObject([
|
||||||
|
{ text: "third tied" },
|
||||||
|
{ text: "fourth tied" },
|
||||||
|
]);
|
||||||
|
await expect(store.getAgentLogs(task.id, { limit: 2, offset: 1 })).resolves.toMatchObject([
|
||||||
|
{ text: "second tied" },
|
||||||
|
{ text: "third tied" },
|
||||||
|
]);
|
||||||
|
await expect(store.getAgentLogs(task.id, { limit: 2, offset: 2 })).resolves.toMatchObject([
|
||||||
|
{ text: "first tied" },
|
||||||
|
{ text: "second tied" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("preserves long entry fields when returning a bounded tail", async () => {
|
it("preserves long entry fields when returning a bounded tail", async () => {
|
||||||
const task = await createTestTask();
|
const task = await createTestTask();
|
||||||
const longText = [
|
const longText = [
|
||||||
|
|||||||
@@ -4667,7 +4667,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
const rows = this.db.prepare(`
|
const rows = this.db.prepare(`
|
||||||
SELECT * FROM agentLogEntries
|
SELECT * FROM agentLogEntries
|
||||||
WHERE taskId = ?
|
WHERE taskId = ?
|
||||||
ORDER BY timestamp DESC
|
ORDER BY timestamp DESC, id DESC
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
`).all(taskId, readCount) as Array<Record<string, unknown>>;
|
`).all(taskId, readCount) as Array<Record<string, unknown>>;
|
||||||
const entries = rows.map((row) => this.mapAgentLogRow(row)).reverse();
|
const entries = rows.map((row) => this.mapAgentLogRow(row)).reverse();
|
||||||
@@ -4680,7 +4680,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
const rows = this.db.prepare(`
|
const rows = this.db.prepare(`
|
||||||
SELECT * FROM agentLogEntries
|
SELECT * FROM agentLogEntries
|
||||||
WHERE taskId = ?
|
WHERE taskId = ?
|
||||||
ORDER BY timestamp ASC
|
ORDER BY timestamp ASC, id ASC
|
||||||
`).all(taskId) as Array<Record<string, unknown>>;
|
`).all(taskId) as Array<Record<string, unknown>>;
|
||||||
const entries = rows.map((row) => this.mapAgentLogRow(row));
|
const entries = rows.map((row) => this.mapAgentLogRow(row));
|
||||||
if (offset > 0) {
|
if (offset > 0) {
|
||||||
@@ -4719,7 +4719,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|||||||
const rows = this.db.prepare(`
|
const rows = this.db.prepare(`
|
||||||
SELECT * FROM agentLogEntries
|
SELECT * FROM agentLogEntries
|
||||||
WHERE taskId = ? AND timestamp >= ? AND timestamp <= ?
|
WHERE taskId = ? AND timestamp >= ? AND timestamp <= ?
|
||||||
ORDER BY timestamp ASC
|
ORDER BY timestamp ASC, id ASC
|
||||||
`).all(taskId, startIso, end) as Array<Record<string, unknown>>;
|
`).all(taskId, startIso, end) as Array<Record<string, unknown>>;
|
||||||
return rows.map((row) => this.mapAgentLogRow(row));
|
return rows.map((row) => this.mapAgentLogRow(row));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
import { EventEmitter } from "node:events";
|
|
||||||
import { execSync } from "node:child_process";
|
|
||||||
import { appendFile, mkdir, readFile, writeFile, readdir, rename, unlink } from "node:fs/promises";
|
|
||||||
import { join, sep } from "node:path";
|
|
||||||
import { existsSync, watch, type FSWatcher, readFileSync } from "node:fs";
|
|
||||||
import type { Task, TaskDetail, TaskCreateInput, TaskAttachment, AgentLogEntry, BoardConfig, Column, MergeResult, Settings, GlobalSettings, ProjectSettings, ActivityLogEntry, ActivityEventType } from "./types.js";
|
|
||||||
import { VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS } from "./types.js";
|
|
||||||
import { GlobalSettingsStore } from "./global-settings.js";
|
|
||||||
|
|
||||||
export interface TaskStoreEvents {
|
|
||||||
"task:created": [task: Task];
|
|
||||||
"task:moved": [data: { task: Task; from: Column; to: Column }];
|
|
||||||
"task:updated": [task: Task];
|
|
||||||
"task:deleted": [task: Task];
|
|
||||||
"task:merged": [result: MergeResult];
|
|
||||||
"settings:updated": [data: { settings: Settings; previous: Settings }];
|
|
||||||
"agent:log": [entry: AgentLogEntry];
|
|
||||||
}
|
|
||||||
|
|
||||||
export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
|
||||||
private kbDir: string;
|
|
||||||
private tasksDir: string;
|
|
||||||
private configPath: string;
|
|
||||||
private archiveLogPath: string;
|
|
||||||
private activityLogPath: string;
|
|
||||||
|
|
||||||
/** File-system watcher instance */
|
|
||||||
private watcher: FSWatcher | null = null;
|
|
||||||
/** In-memory cache of tasks for diffing watcher events */
|
|
||||||
private taskCache: Map<string, Task> = new Map();
|
|
||||||
/** Paths recently written by in-process mutations (suppresses duplicate events) */
|
|
||||||
private recentlyWritten: Set<string> = new Set();
|
|
||||||
/** Pending debounce timers keyed by task ID */
|
|
||||||
private debounceTimers: Map<string, ReturnType<typeof setTimeout>> = new Map();
|
|
||||||
/** Debounce interval in ms */
|
|
||||||
private debounceMs = 150;
|
|
||||||
/** Per-task promise chain for serializing writes */
|
|
||||||
private taskLocks: Map<string, Promise<void>> = new Map();
|
|
||||||
/** Promise chain for serializing config.json read-modify-write cycles */
|
|
||||||
private configLock: Promise<void> = Promise.resolve();
|
|
||||||
/** Global settings store (`~/.pi/kb/settings.json`) */
|
|
||||||
private globalSettingsStore: GlobalSettingsStore;
|
|
||||||
|
|
||||||
constructor(private rootDir: string, globalSettingsDir?: string) {
|
|
||||||
super();
|
|
||||||
this.setMaxListeners(100);
|
|
||||||
this.fusionDir = join(rootDir, ".fusion");
|
|
||||||
this.tasksDir = join(this.fusionDir, "tasks");
|
|
||||||
this.configPath = join(this.fusionDir, "config.json");
|
|
||||||
this.archiveLogPath = join(this.fusionDir, "archive.jsonl");
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { AgentLogEntry } from "@fusion/core";
|
import type { AgentLogEntry } from "@fusion/core";
|
||||||
import { ProviderIcon } from "./ProviderIcon";
|
import { ProviderIcon } from "./ProviderIcon";
|
||||||
import { useRef, useEffect, useState, useCallback, useLayoutEffect } from "react";
|
import { useRef, useEffect, useState, useCallback, useLayoutEffect, useMemo } from "react";
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
import type { Components } from "react-markdown";
|
import type { Components } from "react-markdown";
|
||||||
@@ -51,12 +51,25 @@ const markdownComponents: Components = {
|
|||||||
|
|
||||||
const TOP_FOLLOW_THRESHOLD_PX = 50;
|
const TOP_FOLLOW_THRESHOLD_PX = 50;
|
||||||
|
|
||||||
function getEntryKey(entry: AgentLogEntry | undefined): string | null {
|
function getEntrySignature(entry: AgentLogEntry): string {
|
||||||
if (!entry) {
|
return [
|
||||||
return null;
|
entry.taskId,
|
||||||
}
|
entry.timestamp,
|
||||||
|
entry.agent ?? "",
|
||||||
|
entry.type,
|
||||||
|
entry.text,
|
||||||
|
entry.detail ?? "",
|
||||||
|
].join("|");
|
||||||
|
}
|
||||||
|
|
||||||
return [entry.timestamp, entry.agent, entry.type, entry.text, entry.detail].join("|");
|
function buildEntryRenderKeys(entries: AgentLogEntry[]): string[] {
|
||||||
|
const countsBySignature = new Map<string, number>();
|
||||||
|
return entries.map((entry) => {
|
||||||
|
const signature = getEntrySignature(entry);
|
||||||
|
const occurrence = countsBySignature.get(signature) ?? 0;
|
||||||
|
countsBySignature.set(signature, occurrence + 1);
|
||||||
|
return `${signature}|${occurrence}`;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ModelInfo {
|
interface ModelInfo {
|
||||||
@@ -115,6 +128,11 @@ export function AgentLogViewer({
|
|||||||
const [renderMarkdown, setRenderMarkdown] = useState(true);
|
const [renderMarkdown, setRenderMarkdown] = useState(true);
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
|
|
||||||
|
const chronologicalEntryKeys = useMemo(
|
||||||
|
() => buildEntryRenderKeys(entries),
|
||||||
|
[entries],
|
||||||
|
);
|
||||||
|
|
||||||
// Newest entries render first. When streaming prepends content while the reader is away
|
// Newest entries render first. When streaming prepends content while the reader is away
|
||||||
// from the top, keep the viewport anchored by offsetting scrollTop with the added height.
|
// from the top, keep the viewport anchored by offsetting scrollTop with the added height.
|
||||||
// Near the top, preserve live-follow behavior by snapping back to the latest output.
|
// Near the top, preserve live-follow behavior by snapping back to the latest output.
|
||||||
@@ -125,7 +143,7 @@ export function AgentLogViewer({
|
|||||||
const newEntryCount = entries.length;
|
const newEntryCount = entries.length;
|
||||||
const previousCount = previousEntryCountRef.current;
|
const previousCount = previousEntryCountRef.current;
|
||||||
const previousScrollHeight = previousScrollHeightRef.current;
|
const previousScrollHeight = previousScrollHeightRef.current;
|
||||||
const newestEntryKey = getEntryKey(entries[entries.length - 1]);
|
const newestEntryKey = chronologicalEntryKeys[chronologicalEntryKeys.length - 1] ?? null;
|
||||||
const newestEntryChanged = previousNewestEntryKeyRef.current !== newestEntryKey;
|
const newestEntryChanged = previousNewestEntryKeyRef.current !== newestEntryKey;
|
||||||
|
|
||||||
// Only adjust scroll for streaming updates (which append to chronological data
|
// Only adjust scroll for streaming updates (which append to chronological data
|
||||||
@@ -148,7 +166,7 @@ export function AgentLogViewer({
|
|||||||
previousEntryCountRef.current = newEntryCount;
|
previousEntryCountRef.current = newEntryCount;
|
||||||
previousScrollHeightRef.current = container.scrollHeight;
|
previousScrollHeightRef.current = container.scrollHeight;
|
||||||
previousNewestEntryKeyRef.current = newestEntryKey;
|
previousNewestEntryKeyRef.current = newestEntryKey;
|
||||||
}, [entries]);
|
}, [entries, chronologicalEntryKeys]);
|
||||||
|
|
||||||
// Escape key handler to exit fullscreen mode
|
// Escape key handler to exit fullscreen mode
|
||||||
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
||||||
@@ -184,6 +202,7 @@ export function AgentLogViewer({
|
|||||||
|
|
||||||
// Reverse entries so newest appear first
|
// Reverse entries so newest appear first
|
||||||
const reversedEntries = [...entries].reverse();
|
const reversedEntries = [...entries].reverse();
|
||||||
|
const reversedEntryKeys = [...chronologicalEntryKeys].reverse();
|
||||||
|
|
||||||
const hasExecutorOverride = executorModel?.provider && executorModel?.modelId;
|
const hasExecutorOverride = executorModel?.provider && executorModel?.modelId;
|
||||||
const hasValidatorOverride = validatorModel?.provider && validatorModel?.modelId;
|
const hasValidatorOverride = validatorModel?.provider && validatorModel?.modelId;
|
||||||
@@ -262,6 +281,7 @@ export function AgentLogViewer({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{reversedEntries.map((entry, i) => {
|
{reversedEntries.map((entry, i) => {
|
||||||
|
const rowKey = reversedEntryKeys[i] ?? `${getEntrySignature(entry)}|fallback`;
|
||||||
// Look at previous entry in reversed array (= next chronologically) for deduplication
|
// Look at previous entry in reversed array (= next chronologically) for deduplication
|
||||||
const prev = reversedEntries[i - 1];
|
const prev = reversedEntries[i - 1];
|
||||||
const isBlockLevel = entry.type === "tool" || entry.type === "tool_result" || entry.type === "tool_error";
|
const isBlockLevel = entry.type === "tool" || entry.type === "tool_result" || entry.type === "tool_error";
|
||||||
@@ -284,7 +304,7 @@ export function AgentLogViewer({
|
|||||||
|
|
||||||
if (entry.type === "tool") {
|
if (entry.type === "tool") {
|
||||||
return (
|
return (
|
||||||
<div key={i} className="agent-log-tool">
|
<div key={rowKey} className="agent-log-tool">
|
||||||
{agentBadge}⚡ {entry.text}
|
{agentBadge}⚡ {entry.text}
|
||||||
{entry.detail && <span className="agent-log-tool-detail">— {entry.detail}</span>}
|
{entry.detail && <span className="agent-log-tool-detail">— {entry.detail}</span>}
|
||||||
</div>
|
</div>
|
||||||
@@ -293,7 +313,7 @@ export function AgentLogViewer({
|
|||||||
|
|
||||||
if (entry.type === "thinking") {
|
if (entry.type === "thinking") {
|
||||||
return (
|
return (
|
||||||
<span key={i} className="agent-log-thinking">
|
<span key={rowKey} className="agent-log-thinking">
|
||||||
{agentBadge}
|
{agentBadge}
|
||||||
{renderMarkdown ? (
|
{renderMarkdown ? (
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
|
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
|
||||||
@@ -308,7 +328,7 @@ export function AgentLogViewer({
|
|||||||
|
|
||||||
if (entry.type === "tool_result") {
|
if (entry.type === "tool_result") {
|
||||||
return (
|
return (
|
||||||
<div key={i} className="agent-log-tool-result">
|
<div key={rowKey} className="agent-log-tool-result">
|
||||||
{agentBadge}✓ {entry.text}
|
{agentBadge}✓ {entry.text}
|
||||||
{entry.detail && <span className="agent-log-tool-detail">— {entry.detail}</span>}
|
{entry.detail && <span className="agent-log-tool-detail">— {entry.detail}</span>}
|
||||||
</div>
|
</div>
|
||||||
@@ -317,7 +337,7 @@ export function AgentLogViewer({
|
|||||||
|
|
||||||
if (entry.type === "tool_error") {
|
if (entry.type === "tool_error") {
|
||||||
return (
|
return (
|
||||||
<div key={i} className="agent-log-tool-error">
|
<div key={rowKey} className="agent-log-tool-error">
|
||||||
{agentBadge}✗ {entry.text}
|
{agentBadge}✗ {entry.text}
|
||||||
{entry.detail && <span className="agent-log-tool-detail">— {entry.detail}</span>}
|
{entry.detail && <span className="agent-log-tool-detail">— {entry.detail}</span>}
|
||||||
</div>
|
</div>
|
||||||
@@ -326,7 +346,7 @@ export function AgentLogViewer({
|
|||||||
|
|
||||||
// Default: text entries
|
// Default: text entries
|
||||||
return (
|
return (
|
||||||
<span key={i} className="agent-log-text">
|
<span key={rowKey} className="agent-log-text">
|
||||||
{agentBadge}
|
{agentBadge}
|
||||||
{renderMarkdown ? (
|
{renderMarkdown ? (
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
|
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
|
||||||
|
|||||||
@@ -44,6 +44,70 @@ describe("AgentLogViewer", () => {
|
|||||||
expect(textSpans[1].textContent).toContain("first chunk");
|
expect(textSpans[1].textContent).toContain("first chunk");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps existing DOM rows stable when a new live entry appears at the top", () => {
|
||||||
|
const initialEntries = [
|
||||||
|
makeEntry({ text: "first chunk", timestamp: "2026-01-01T00:00:00Z" }),
|
||||||
|
makeEntry({ text: "second chunk", timestamp: "2026-01-01T00:00:01Z" }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const { container, rerender } = render(
|
||||||
|
<AgentLogViewer entries={initialEntries} loading={false} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
const initialTextRows = container.querySelectorAll(".agent-log-text");
|
||||||
|
const secondChunkNode = initialTextRows[0] as HTMLElement;
|
||||||
|
const firstChunkNode = initialTextRows[1] as HTMLElement;
|
||||||
|
expect(secondChunkNode.textContent).toContain("second chunk");
|
||||||
|
expect(firstChunkNode.textContent).toContain("first chunk");
|
||||||
|
|
||||||
|
const withLiveUpdate = [
|
||||||
|
...initialEntries,
|
||||||
|
makeEntry({ text: "third chunk", timestamp: "2026-01-01T00:00:02Z" }),
|
||||||
|
];
|
||||||
|
|
||||||
|
rerender(<AgentLogViewer entries={withLiveUpdate} loading={false} />);
|
||||||
|
|
||||||
|
const updatedTextRows = container.querySelectorAll(".agent-log-text");
|
||||||
|
expect(updatedTextRows).toHaveLength(3);
|
||||||
|
expect(updatedTextRows[0].textContent).toContain("third chunk");
|
||||||
|
expect(updatedTextRows[1].textContent).toContain("second chunk");
|
||||||
|
expect(updatedTextRows[2].textContent).toContain("first chunk");
|
||||||
|
expect(updatedTextRows[1]).toBe(secondChunkNode);
|
||||||
|
expect(updatedTextRows[2]).toBe(firstChunkNode);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("avoids duplicate-key collisions when entries are exact duplicates", () => {
|
||||||
|
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
const duplicateEntry = makeEntry({
|
||||||
|
timestamp: "2026-01-01T00:00:00Z",
|
||||||
|
taskId: "FN-001",
|
||||||
|
text: "same chunk",
|
||||||
|
type: "text",
|
||||||
|
agent: "executor",
|
||||||
|
detail: "same detail",
|
||||||
|
});
|
||||||
|
|
||||||
|
const { container, rerender } = render(
|
||||||
|
<AgentLogViewer entries={[duplicateEntry, { ...duplicateEntry }]} loading={false} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<AgentLogViewer
|
||||||
|
entries={[duplicateEntry, { ...duplicateEntry }, { ...duplicateEntry }]}
|
||||||
|
loading={false}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container.querySelectorAll(".agent-log-text")).toHaveLength(3);
|
||||||
|
expect(
|
||||||
|
consoleErrorSpy.mock.calls.some((call) =>
|
||||||
|
String(call[0]).includes("Encountered two children with the same key"),
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
it("renders tool entries with distinct styling", () => {
|
it("renders tool entries with distinct styling", () => {
|
||||||
const entries = [
|
const entries = [
|
||||||
makeEntry({ text: "Read", type: "tool" }),
|
makeEntry({ text: "Read", type: "tool" }),
|
||||||
|
|||||||
@@ -3097,7 +3097,7 @@ describe("SettingsModal", () => {
|
|||||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||||
|
|
||||||
fireEvent.click(screen.getByText("Scheduling"));
|
fireEvent.click(await screen.findByText("Scheduling"));
|
||||||
const ageInput = screen.getByLabelText("Archive Completed Tasks After (days)") as HTMLInputElement;
|
const ageInput = screen.getByLabelText("Archive Completed Tasks After (days)") as HTMLInputElement;
|
||||||
expect(ageInput.value).toBe("2");
|
expect(ageInput.value).toBe("2");
|
||||||
|
|
||||||
|
|||||||
@@ -127,6 +127,35 @@ describe("useAgentLogs", () => {
|
|||||||
expect(result.current.entries[1].text).toBe("new");
|
expect(result.current.entries[1].text).toBe("new");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps deterministic chronological order when history has tied timestamps and SSE appends tied timestamps", async () => {
|
||||||
|
mockFetchAgentLogsWithMeta.mockResolvedValueOnce({
|
||||||
|
entries: [
|
||||||
|
{ timestamp: "2026-01-01T00:00:00Z", taskId: "FN-001", text: "hist-1", type: "text" as const },
|
||||||
|
{ timestamp: "2026-01-01T00:00:00Z", taskId: "FN-001", text: "hist-2", type: "text" as const },
|
||||||
|
],
|
||||||
|
total: 3,
|
||||||
|
hasMore: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useAgentLogs("FN-001", true));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.entries.map((entry) => entry.text)).toEqual(["hist-1", "hist-2"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
const es = MockEventSource.instances[0];
|
||||||
|
act(() => {
|
||||||
|
es._emit("agent:log", {
|
||||||
|
timestamp: "2026-01-01T00:00:00Z",
|
||||||
|
taskId: "FN-001",
|
||||||
|
text: "live-3",
|
||||||
|
type: "text",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.entries.map((entry) => entry.text)).toEqual(["hist-1", "hist-2", "live-3"]);
|
||||||
|
});
|
||||||
|
|
||||||
it("closes SSE when enabled changes to false", async () => {
|
it("closes SSE when enabled changes to false", async () => {
|
||||||
mockFetchAgentLogsWithMeta.mockResolvedValueOnce({ entries: [], total: 0, hasMore: false });
|
mockFetchAgentLogsWithMeta.mockResolvedValueOnce({ entries: [], total: 0, hasMore: false });
|
||||||
|
|
||||||
@@ -309,6 +338,38 @@ describe("useAgentLogs", () => {
|
|||||||
expect(result.current.hasMore).toBe(false);
|
expect(result.current.hasMore).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("loadMore preserves chronological order with near-equal timestamps", async () => {
|
||||||
|
const initialLogs = [
|
||||||
|
{ timestamp: "2026-01-01T00:00:01.001Z", taskId: "FN-001", text: "middle", type: "text" as const },
|
||||||
|
{ timestamp: "2026-01-01T00:00:01.002Z", taskId: "FN-001", text: "newest", type: "text" as const },
|
||||||
|
];
|
||||||
|
const olderLogs = [
|
||||||
|
{ timestamp: "2026-01-01T00:00:00.999Z", taskId: "FN-001", text: "oldest-a", type: "text" as const },
|
||||||
|
{ timestamp: "2026-01-01T00:00:00.999Z", taskId: "FN-001", text: "oldest-b", type: "text" as const },
|
||||||
|
];
|
||||||
|
|
||||||
|
mockFetchAgentLogsWithMeta
|
||||||
|
.mockResolvedValueOnce({ entries: initialLogs, total: 4, hasMore: true })
|
||||||
|
.mockResolvedValueOnce({ entries: olderLogs, total: 4, hasMore: false });
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useAgentLogs("FN-001", true));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.entries.map((entry) => entry.text)).toEqual(["middle", "newest"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.loadMore();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.entries.map((entry) => entry.text)).toEqual([
|
||||||
|
"oldest-a",
|
||||||
|
"oldest-b",
|
||||||
|
"middle",
|
||||||
|
"newest",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("loadMore does not trigger when already loading more", async () => {
|
it("loadMore does not trigger when already loading more", async () => {
|
||||||
mockFetchAgentLogsWithMeta.mockResolvedValue({ entries: [], total: 200, hasMore: true });
|
mockFetchAgentLogsWithMeta.mockResolvedValue({ entries: [], total: 200, hasMore: true });
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user