fix(FN-000): restore memory compaction and log pagination
This commit is contained in:
@@ -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 = [
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user