fix(FN-000): restore memory compaction and log pagination

This commit is contained in:
gsxdsm
2026-04-17 08:43:13 -07:00
parent 2425b6b6bb
commit 2956998eb5
9 changed files with 117 additions and 16 deletions

View File

@@ -3634,6 +3634,26 @@ Task with acceptance criteria
expect(logs.map((entry) => entry.text)).toEqual(["chunk 3", "chunk 4"]);
});
it("returns older agent log pages when offset skips recent entries", async () => {
const task = await createTestTask();
for (let i = 0; i < 5; i++) {
await store.appendAgentLog(task.id, `chunk ${i}`, "text");
}
await expect(store.getAgentLogs(task.id, { limit: 2 })).resolves.toMatchObject([
{ text: "chunk 3" },
{ text: "chunk 4" },
]);
await expect(store.getAgentLogs(task.id, { limit: 2, offset: 2 })).resolves.toMatchObject([
{ text: "chunk 1" },
{ text: "chunk 2" },
]);
await expect(store.getAgentLogs(task.id, { limit: 2, offset: 4 })).resolves.toMatchObject([
{ text: "chunk 0" },
]);
});
it("preserves long entry fields when returning a bounded tail", async () => {
const task = await createTestTask();
const longText = [

View File

@@ -4054,16 +4054,15 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
? (Number.isFinite(options.offset) ? Math.max(0, Math.floor(options.offset)) : 0)
: 0;
// If limit is specified, use readAgentLogTail for efficiency
// If limit is specified, use readAgentLogTail for efficiency.
if (limit !== undefined) {
if (limit === 0) return [];
// When offset is provided, read limit + offset entries and slice off the first offset
// Offset means "skip this many most-recent entries", so read enough
// tail entries to include the requested older page.
const readCount = offset > 0 ? limit + offset : limit;
const entries = await this.readAgentLogTail(logPath, readCount);
if (offset > 0) {
// Slice off the first 'offset' entries (oldest in the returned batch)
// This skips the most recent entries to get older entries
return entries.slice(offset);
return entries.slice(0, Math.max(0, entries.length - offset));
}
return entries;
}

View File

@@ -566,6 +566,36 @@ describe("SettingsModal", () => {
expect(addToast).toHaveBeenCalledWith("Memory saved", "success");
});
it("compacts memory and updates the long-term memory editor", async () => {
const addToast = vi.fn();
mockFetchMemoryFile.mockImplementation((path: string) =>
Promise.resolve({
path,
content: path.endsWith("MEMORY.md")
? "# Compacted Memory\n\nImportant content."
: "## Existing dreams\n- Running pattern",
}),
);
renderModal({ addToast });
await waitFor(() => {
expect(mockFetchSettings).toHaveBeenCalled();
});
await userEvent.click(screen.getByText("Memory"));
const compactButton = await screen.findByRole("button", { name: "Compact Memory" });
await userEvent.click(compactButton);
await waitFor(() => {
expect(mockCompactMemory).toHaveBeenCalledWith(undefined);
});
const editor = await screen.findByLabelText("Editor for .fusion/memory/MEMORY.md") as HTMLTextAreaElement;
expect(editor.value).toContain("Compacted Memory");
expect(addToast).toHaveBeenCalledWith("Memory compacted", "success");
});
it("handles empty memory content from API", async () => {
mockFetchMemoryFile.mockResolvedValueOnce({ path: ".fusion/memory/DREAMS.md", content: "" });
renderModal();

View File

@@ -2,7 +2,7 @@ import { useState, useEffect, useCallback, useRef } from "react";
import { Globe, Folder } from "lucide-react";
import { THINKING_LEVELS, PROMPT_KEY_CATALOG, isGlobalSettingsKey, isProjectSettingsKey } from "@fusion/core";
import type { Settings, GlobalSettings, ThemeMode, ColorTheme, ModelPreset, NtfyNotificationEvent, PromptKey, AgentPromptsConfig } from "@fusion/core";
import { fetchSettings, fetchSettingsByScope, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, saveApiKey, clearApiKey, fetchModels, testNtfyNotification, fetchBackups, createBackup, exportSettings, importSettings, fetchMemoryFile, fetchMemoryFiles, saveMemoryFile, fetchGlobalConcurrency, updateGlobalConcurrency, fetchPiExtensions, updatePiExtensions, installQmd, testMemoryRetrieval } from "../api";
import { fetchSettings, fetchSettingsByScope, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, saveApiKey, clearApiKey, fetchModels, testNtfyNotification, fetchBackups, createBackup, exportSettings, importSettings, fetchMemoryFile, fetchMemoryFiles, saveMemoryFile, compactMemory, fetchGlobalConcurrency, updateGlobalConcurrency, fetchPiExtensions, updatePiExtensions, installQmd, testMemoryRetrieval } from "../api";
import type { AuthProvider, ModelInfo, BackupListResponse, SettingsExportData, MemoryBackendCapabilities, MemoryFileInfo, MemoryRetrievalTestResult, PiExtensionSettings } from "../api";
import { useMemoryBackendStatus } from "../hooks/useMemoryBackendStatus";
import type { ToastType } from "../hooks/useToast";
@@ -58,6 +58,7 @@ type SettingsSection = {
const MOBILE_SETTINGS_MEDIA_QUERY = "(max-width: 768px)";
const DEFAULT_MEMORY_EDITOR_PATH = ".fusion/memory/DREAMS.md";
const LONG_TERM_MEMORY_EDITOR_PATH = ".fusion/memory/MEMORY.md";
const SETTINGS_SECTIONS: SettingsSection[] = [
// Global group
@@ -190,6 +191,7 @@ export function SettingsModal({
const [memoryTestQuery, setMemoryTestQuery] = useState("");
const [memoryTestLoading, setMemoryTestLoading] = useState(false);
const [memoryTestResult, setMemoryTestResult] = useState<MemoryRetrievalTestResult | null>(null);
const [memoryCompactLoading, setMemoryCompactLoading] = useState(false);
const [qmdInstallLoading, setQmdInstallLoading] = useState(false);
// Global concurrency state
@@ -881,6 +883,25 @@ export function SettingsModal({
}
}, [selectedMemoryPath, memoryContent, projectId, addToast]);
const handleCompactMemory = useCallback(async () => {
setMemoryCompactLoading(true);
try {
const { content } = await compactMemory(projectId);
setSelectedMemoryPath(LONG_TERM_MEMORY_EDITOR_PATH);
setMemoryContent(content);
setMemoryDirty(false);
const { files } = await fetchMemoryFiles(projectId);
setMemoryFiles(files);
addToast("Memory compacted", "success");
} catch (err: any) {
addToast(err?.message || "Failed to compact memory", "error");
} finally {
setMemoryCompactLoading(false);
}
}, [projectId, addToast]);
const handleTestMemoryRetrieval = useCallback(async () => {
setMemoryTestLoading(true);
setMemoryTestResult(null);
@@ -2286,6 +2307,18 @@ export function SettingsModal({
</div>
)}
<div className="form-group">
<button
type="button"
className="btn btn-secondary btn-sm"
onClick={handleCompactMemory}
disabled={!isEditingAllowed || memoryCompactLoading}
>
{memoryCompactLoading ? "Compacting…" : "Compact Memory"}
</button>
<small>Compacts the long-term project memory file and updates MEMORY.md.</small>
</div>
<div className="memory-retrieval-test">
<div className="form-group">
<label htmlFor="memoryRetrievalQuery">Test Retrieval</label>

View File

@@ -4081,7 +4081,7 @@ describe("Prompts section", () => {
const selector = await screen.findByLabelText("Memory File") as HTMLSelectElement;
expect(selector.value).toBe(".fusion/memory/DREAMS.md");
expect(screen.getByText(/Choose any project memory file to view or edit/i)).toBeInTheDocument();
expect(screen.queryByText("Compact Memory")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Compact Memory" })).toBeInTheDocument();
});
});
});

View File

@@ -302,10 +302,10 @@ describe("useAgentLogs", () => {
await result.current.loadMore();
});
// Should now have 2 entries: initial + older
// Should now have 2 entries in chronological order: older + initial
expect(result.current.entries).toHaveLength(2);
expect(result.current.entries[0].text).toBe("newer");
expect(result.current.entries[1].text).toBe("older");
expect(result.current.entries[0].text).toBe("older");
expect(result.current.entries[1].text).toBe("newer");
expect(result.current.hasMore).toBe(false);
});

View File

@@ -207,7 +207,7 @@ export function useAgentLogs(taskId: string | null, enabled: boolean, projectId?
// Prepend older entries to the existing list
setEntries((prev) => {
const combined = [...prev, ...result.entries];
const combined = [...result.entries, ...prev];
return capLogEntries(combined);
});
setHasMore(result.hasMore);

View File

@@ -117,6 +117,7 @@ function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
getGlobalSettingsStore: vi.fn().mockReturnValue(createMockGlobalSettingsStore()),
logEntry: vi.fn().mockResolvedValue(undefined),
getAgentLogs: vi.fn().mockResolvedValue([]),
getAgentLogCount: vi.fn().mockResolvedValue(0),
getAgentLogsByTimeRange: vi.fn().mockResolvedValue([]),
addSteeringComment: vi.fn(),
addTaskComment: vi.fn(),
@@ -3228,6 +3229,23 @@ describe("Attachment routes", () => {
expect(store.getAgentLogs).toHaveBeenCalledWith("KB-001", undefined);
});
it("GET /tasks/:id/logs — includes pagination headers on bounded initial load", async () => {
const fakeLogs = [
{ timestamp: "2026-01-01T00:00:00Z", taskId: "FN-001", text: "Hello", type: "text" },
{ timestamp: "2026-01-01T00:00:01Z", taskId: "FN-001", text: "Read", type: "tool" },
];
(store.getAgentLogs as ReturnType<typeof vi.fn>).mockResolvedValue(fakeLogs);
(store.getAgentLogCount as ReturnType<typeof vi.fn>).mockResolvedValue(5);
const res = await performGet(buildApp(), "/api/tasks/KB-001/logs?limit=2");
expect(res.status).toBe(200);
expect(res.body).toEqual(fakeLogs);
expect(store.getAgentLogs).toHaveBeenCalledWith("KB-001", { limit: 2 });
expect(res.headers["x-total-count"]).toBe("5");
expect(res.headers["x-has-more"]).toBe("true");
});
it("GET /tasks/:id/logs — returns empty array when no logs", async () => {
(store.getAgentLogs as ReturnType<typeof vi.fn>).mockResolvedValue([]);

View File

@@ -3895,7 +3895,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
// Get historical agent logs for a task.
// Per-entry text and detail fields are returned in full — no truncation.
// The 500-entry cap (MAX_LOG_ENTRIES) is a client-side whole-list limit.
// When offset query param is provided, includes X-Total-Count and X-Has-More headers for pagination.
// When limit is provided, includes X-Total-Count and X-Has-More headers for pagination.
router.get("/tasks/:id/logs", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
@@ -3914,13 +3914,14 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
const logs = await scopedStore.getAgentLogs(req.params.id, options);
// Include pagination headers when offset is explicitly provided (even if 0)
// This enables the frontend to know total count and whether more entries exist
if (offset !== undefined && Number.isFinite(offset)) {
// Include pagination headers for bounded reads, including the initial page.
// This enables the frontend to show Load More before an offset is present.
if (limit !== undefined && Number.isFinite(limit)) {
const total = await scopedStore.getAgentLogCount(req.params.id);
res.setHeader("X-Total-Count", String(total));
const hasMore = total > (offset + logs.length);
const effectiveOffset = offset !== undefined && Number.isFinite(offset) ? offset : 0;
const hasMore = total > (effectiveOffset + logs.length);
res.setHeader("X-Has-More", hasMore ? "true" : "false");
}