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:
Fusion
2026-04-24 11:30:27 -07:00
committed by gsxdsm
parent 968e6e7e2d
commit e480b8d411
7 changed files with 201 additions and 67 deletions

View File

@@ -1,6 +1,6 @@
import type { AgentLogEntry } from "@fusion/core";
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 remarkGfm from "remark-gfm";
import type { Components } from "react-markdown";
@@ -51,12 +51,25 @@ const markdownComponents: Components = {
const TOP_FOLLOW_THRESHOLD_PX = 50;
function getEntryKey(entry: AgentLogEntry | undefined): string | null {
if (!entry) {
return null;
}
function getEntrySignature(entry: AgentLogEntry): string {
return [
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 {
@@ -115,6 +128,11 @@ export function AgentLogViewer({
const [renderMarkdown, setRenderMarkdown] = useState(true);
const [isFullscreen, setIsFullscreen] = useState(false);
const chronologicalEntryKeys = useMemo(
() => buildEntryRenderKeys(entries),
[entries],
);
// 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.
// 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 previousCount = previousEntryCountRef.current;
const previousScrollHeight = previousScrollHeightRef.current;
const newestEntryKey = getEntryKey(entries[entries.length - 1]);
const newestEntryKey = chronologicalEntryKeys[chronologicalEntryKeys.length - 1] ?? null;
const newestEntryChanged = previousNewestEntryKeyRef.current !== newestEntryKey;
// Only adjust scroll for streaming updates (which append to chronological data
@@ -148,7 +166,7 @@ export function AgentLogViewer({
previousEntryCountRef.current = newEntryCount;
previousScrollHeightRef.current = container.scrollHeight;
previousNewestEntryKeyRef.current = newestEntryKey;
}, [entries]);
}, [entries, chronologicalEntryKeys]);
// Escape key handler to exit fullscreen mode
const handleKeyDown = useCallback((e: KeyboardEvent) => {
@@ -184,6 +202,7 @@ export function AgentLogViewer({
// Reverse entries so newest appear first
const reversedEntries = [...entries].reverse();
const reversedEntryKeys = [...chronologicalEntryKeys].reverse();
const hasExecutorOverride = executorModel?.provider && executorModel?.modelId;
const hasValidatorOverride = validatorModel?.provider && validatorModel?.modelId;
@@ -262,6 +281,7 @@ export function AgentLogViewer({
)}
{reversedEntries.map((entry, i) => {
const rowKey = reversedEntryKeys[i] ?? `${getEntrySignature(entry)}|fallback`;
// Look at previous entry in reversed array (= next chronologically) for deduplication
const prev = reversedEntries[i - 1];
const isBlockLevel = entry.type === "tool" || entry.type === "tool_result" || entry.type === "tool_error";
@@ -284,7 +304,7 @@ export function AgentLogViewer({
if (entry.type === "tool") {
return (
<div key={i} className="agent-log-tool">
<div key={rowKey} className="agent-log-tool">
{agentBadge} {entry.text}
{entry.detail && <span className="agent-log-tool-detail"> {entry.detail}</span>}
</div>
@@ -293,7 +313,7 @@ export function AgentLogViewer({
if (entry.type === "thinking") {
return (
<span key={i} className="agent-log-thinking">
<span key={rowKey} className="agent-log-thinking">
{agentBadge}
{renderMarkdown ? (
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
@@ -308,7 +328,7 @@ export function AgentLogViewer({
if (entry.type === "tool_result") {
return (
<div key={i} className="agent-log-tool-result">
<div key={rowKey} className="agent-log-tool-result">
{agentBadge} {entry.text}
{entry.detail && <span className="agent-log-tool-detail"> {entry.detail}</span>}
</div>
@@ -317,7 +337,7 @@ export function AgentLogViewer({
if (entry.type === "tool_error") {
return (
<div key={i} className="agent-log-tool-error">
<div key={rowKey} className="agent-log-tool-error">
{agentBadge} {entry.text}
{entry.detail && <span className="agent-log-tool-detail"> {entry.detail}</span>}
</div>
@@ -326,7 +346,7 @@ export function AgentLogViewer({
// Default: text entries
return (
<span key={i} className="agent-log-text">
<span key={rowKey} className="agent-log-text">
{agentBadge}
{renderMarkdown ? (
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>

View File

@@ -44,6 +44,70 @@ describe("AgentLogViewer", () => {
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", () => {
const entries = [
makeEntry({ text: "Read", type: "tool" }),

View File

@@ -3097,7 +3097,7 @@ describe("SettingsModal", () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
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;
expect(ageInput.value).toBe("2");

View File

@@ -127,6 +127,35 @@ describe("useAgentLogs", () => {
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 () => {
mockFetchAgentLogsWithMeta.mockResolvedValueOnce({ entries: [], total: 0, hasMore: false });
@@ -309,6 +338,38 @@ describe("useAgentLogs", () => {
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 () => {
mockFetchAgentLogsWithMeta.mockResolvedValue({ entries: [], total: 200, hasMore: true });