feat(FN-1767): add QMD memory backend with async execution and backend-aware project helpers
- Add QmdMemoryBackend class implementing MemoryBackend interface with async agent execution - Backend executes QMD memory operations via subprocess spawn with configurable command/args - Graceful fallback to readonly mode on write failures with error logging - Add readWithoutFetch() and bootstrap() helpers to ProjectMemory for backend-aware read/bootstrap semantics - Wire QMD backend into TaskStore.create() and memory initialization - Export QmdMemoryBackend and BackendCapabilities from @fusion/core - Add comprehensive tests for QMD backend and project memory helpers
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { mkdir, rm } from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import { describe, it, expect, beforeEach, afterEach, vi, beforeAll } from "vitest";
|
||||
import { mkdir, rm, writeFile, unlink } from "node:fs/promises";
|
||||
import { existsSync, readFileSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import {
|
||||
@@ -8,21 +8,28 @@ import {
|
||||
memoryFilePath,
|
||||
getDefaultMemoryScaffold,
|
||||
ensureMemoryFile,
|
||||
ensureMemoryFileWithBackend,
|
||||
buildTriageMemoryInstructions,
|
||||
buildExecutionMemoryInstructions,
|
||||
readProjectMemory,
|
||||
readProjectMemoryWithBackend,
|
||||
} from "./project-memory.js";
|
||||
|
||||
describe("project-memory", () => {
|
||||
let testDir: string;
|
||||
let memoryPath: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
testDir = join(tmpdir(), `kb-memory-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
||||
memoryPath = join(testDir, ".fusion", "memory.md");
|
||||
// Create the test directory but not the .fusion subdirectory
|
||||
// Individual tests can create .fusion as needed
|
||||
await mkdir(testDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(testDir, { recursive: true, force: true });
|
||||
// Clean up entire test directory
|
||||
rmSync(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// ── Constants ────────────────────────────────────────────────────
|
||||
@@ -204,4 +211,161 @@ describe("project-memory", () => {
|
||||
expect(instructions).toContain("`.fusion/memory.md`");
|
||||
});
|
||||
});
|
||||
|
||||
// ── ensureMemoryFileWithBackend ─────────────────────────────────────
|
||||
|
||||
describe("ensureMemoryFileWithBackend", () => {
|
||||
it("creates memory file with default backend when memory does not exist", async () => {
|
||||
// Ensure clean state - create .fusion dir if needed
|
||||
await mkdir(join(testDir, ".fusion"), { recursive: true });
|
||||
if (existsSync(memoryPath)) await unlink(memoryPath);
|
||||
expect(existsSync(memoryPath)).toBe(false);
|
||||
|
||||
const created = await ensureMemoryFileWithBackend(testDir);
|
||||
|
||||
expect(created).toBe(true);
|
||||
expect(existsSync(memoryPath)).toBe(true);
|
||||
const content = readFileSync(memoryPath, "utf-8");
|
||||
expect(content).toBe(getDefaultMemoryScaffold());
|
||||
});
|
||||
|
||||
it("does not overwrite existing memory content", async () => {
|
||||
// Create initial file with custom content
|
||||
await ensureMemoryFile(testDir);
|
||||
const customContent = "# Custom Memory\n\nMy custom content";
|
||||
await writeFile(memoryPath, customContent, "utf-8");
|
||||
|
||||
// Ensure again with backend - should NOT overwrite
|
||||
const created = await ensureMemoryFileWithBackend(testDir);
|
||||
expect(created).toBe(false);
|
||||
|
||||
const content = readFileSync(memoryPath, "utf-8");
|
||||
expect(content).toBe(customContent);
|
||||
});
|
||||
|
||||
it("returns false when file already exists", async () => {
|
||||
await ensureMemoryFile(testDir);
|
||||
const created = await ensureMemoryFileWithBackend(testDir);
|
||||
expect(created).toBe(false);
|
||||
});
|
||||
|
||||
it("works with file backend type in settings", async () => {
|
||||
// Ensure clean state
|
||||
await mkdir(join(testDir, ".fusion"), { recursive: true });
|
||||
if (existsSync(memoryPath)) await unlink(memoryPath);
|
||||
|
||||
const settings = { memoryBackendType: "file" };
|
||||
const created = await ensureMemoryFileWithBackend(testDir, settings);
|
||||
|
||||
expect(created).toBe(true);
|
||||
expect(existsSync(memoryPath)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not throw for readonly backend (non-fatal bootstrap)", async () => {
|
||||
// Ensure .fusion dir exists but no memory file
|
||||
await mkdir(join(testDir, ".fusion"), { recursive: true });
|
||||
if (existsSync(memoryPath)) await unlink(memoryPath);
|
||||
|
||||
const settings = { memoryBackendType: "readonly" };
|
||||
|
||||
// Should not throw - readonly backend is non-fatal during bootstrap
|
||||
const result = await ensureMemoryFileWithBackend(testDir, settings);
|
||||
|
||||
// Should return false since readonly can't write
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── readProjectMemoryWithBackend ─────────────────────────────────────
|
||||
|
||||
describe("readProjectMemoryWithBackend", () => {
|
||||
it("returns empty string when memory does not exist", async () => {
|
||||
// Ensure clean state
|
||||
await mkdir(join(testDir, ".fusion"), { recursive: true });
|
||||
if (existsSync(memoryPath)) await unlink(memoryPath);
|
||||
expect(existsSync(memoryPath)).toBe(false);
|
||||
|
||||
const content = await readProjectMemoryWithBackend(testDir);
|
||||
expect(content).toBe("");
|
||||
});
|
||||
|
||||
it("returns memory content when file exists", async () => {
|
||||
await ensureMemoryFile(testDir);
|
||||
const content = await readProjectMemoryWithBackend(testDir);
|
||||
expect(content).toContain("# Project Memory");
|
||||
});
|
||||
|
||||
it("returns custom content when file has been edited", async () => {
|
||||
await ensureMemoryFile(testDir);
|
||||
const customContent = "# Custom Memory\n\nSome custom content";
|
||||
await writeFile(memoryPath, customContent, "utf-8");
|
||||
|
||||
const content = await readProjectMemoryWithBackend(testDir);
|
||||
expect(content).toBe(customContent);
|
||||
});
|
||||
|
||||
it("works with file backend type in settings", async () => {
|
||||
await ensureMemoryFile(testDir);
|
||||
const settings = { memoryBackendType: "file" };
|
||||
const content = await readProjectMemoryWithBackend(testDir, settings);
|
||||
expect(content).toContain("# Project Memory");
|
||||
});
|
||||
|
||||
it("returns empty string for readonly backend", async () => {
|
||||
// Ensure clean state
|
||||
await mkdir(join(testDir, ".fusion"), { recursive: true });
|
||||
if (existsSync(memoryPath)) await unlink(memoryPath);
|
||||
|
||||
const settings = { memoryBackendType: "readonly" };
|
||||
const content = await readProjectMemoryWithBackend(testDir, settings);
|
||||
// Readonly backend always returns empty content
|
||||
expect(content).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty string on read error (graceful degradation)", async () => {
|
||||
// Ensure clean state
|
||||
await mkdir(join(testDir, ".fusion"), { recursive: true });
|
||||
if (existsSync(memoryPath)) await unlink(memoryPath);
|
||||
|
||||
const settings = { memoryBackendType: "nonexistent" };
|
||||
// Unknown backend should fall back gracefully
|
||||
const content = await readProjectMemoryWithBackend(testDir, settings);
|
||||
expect(content).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
// ── Backend-aware bootstrap integration ─────────────────────────────
|
||||
|
||||
describe("backend-aware bootstrap integration", () => {
|
||||
it("idempotent bootstrap preserves user edits regardless of backend", async () => {
|
||||
// Create file with default backend
|
||||
await ensureMemoryFile(testDir);
|
||||
|
||||
// Edit the content
|
||||
const customContent = "# User Edit\n\nI modified this";
|
||||
await writeFile(memoryPath, customContent, "utf-8");
|
||||
|
||||
// Bootstrap again with different backends - none should overwrite
|
||||
await ensureMemoryFileWithBackend(testDir, { memoryBackendType: "file" });
|
||||
expect(readFileSync(memoryPath, "utf-8")).toBe(customContent);
|
||||
|
||||
// Readonly should also preserve (even though it can't write)
|
||||
await ensureMemoryFileWithBackend(testDir, { memoryBackendType: "readonly" });
|
||||
expect(readFileSync(memoryPath, "utf-8")).toBe(customContent);
|
||||
});
|
||||
|
||||
it("backend selection is honored for new memory creation with file backend", async () => {
|
||||
// Ensure clean state
|
||||
await mkdir(join(testDir, ".fusion"), { recursive: true });
|
||||
if (existsSync(memoryPath)) await unlink(memoryPath);
|
||||
|
||||
// Create with file backend - should work reliably
|
||||
const created = await ensureMemoryFileWithBackend(testDir, { memoryBackendType: "file" });
|
||||
expect(created).toBe(true);
|
||||
|
||||
// File should exist and have default scaffold content
|
||||
const content = readFileSync(memoryPath, "utf-8");
|
||||
expect(content).toBe(getDefaultMemoryScaffold());
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user