feat(FN-925): add memoryEnabled setting with UI toggle and engine integration

- Add memoryEnabled boolean setting to core types and default settings
- Add Memory section toggle to SettingsModal dashboard UI
- Integrate memoryEnabled check in executor and triage engine prompts
- Add tests for settings UI, executor prompt behavior, and triage prompt behavior
- Update SettingsModal section count test for the new Memory section
This commit is contained in:
gsxdsm
2026-04-04 15:06:57 -07:00
parent 3d1cfda517
commit ea4fe6fe0f
8 changed files with 198 additions and 4 deletions

View File

@@ -872,6 +872,11 @@ export interface ProjectSettings {
* Extraction only runs if BOTH this time has elapsed AND memory has grown
* by more than MIN_INSIGHT_GROWTH_CHARS characters. Default: 86400000 (24h). */
insightExtractionMinIntervalMs?: number;
/** When enabled, agents will consult and update .fusion/memory.md with durable
* project learnings. When disabled, agents will not include memory instructions
* in their prompts and will not read or write to .fusion/memory.md.
* Default: true (enabled for backward compatibility). */
memoryEnabled?: boolean;
}
/**
@@ -958,6 +963,7 @@ export const DEFAULT_PROJECT_SETTINGS: ProjectSettings = {
insightExtractionEnabled: false,
insightExtractionSchedule: "0 2 * * *",
insightExtractionMinIntervalMs: 86_400_000,
memoryEnabled: true,
};
/**
@@ -1034,6 +1040,7 @@ export const PROJECT_SETTINGS_KEYS: ReadonlyArray<keyof ProjectSettings> = [
"insightExtractionEnabled",
"insightExtractionSchedule",
"insightExtractionMinIntervalMs",
"memoryEnabled",
] as const;
export interface BoardConfig {

View File

@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { SettingsModal } from "./SettingsModal";
import type { SettingsExportData } from "../api";
@@ -198,4 +199,74 @@ describe("SettingsModal", () => {
}
});
});
describe("Memory section", () => {
it("renders the Memory section in the sidebar", async () => {
renderModal();
await waitFor(() => {
expect(mockFetchSettings).toHaveBeenCalled();
});
expect(screen.getByText("Memory")).toBeDefined();
});
it("shows the memory toggle with default enabled", async () => {
renderModal();
await waitFor(() => {
expect(mockFetchSettings).toHaveBeenCalled();
});
// Click the Memory section in the sidebar
await userEvent.click(screen.getByText("Memory"));
const checkbox = screen.getByRole("checkbox", { name: /enable project memory/i });
expect(checkbox).toBeDefined();
// Default is enabled, so checkbox should be checked
expect(checkbox).toBeChecked();
});
it("shows memory toggle unchecked when memoryEnabled is false", async () => {
mockFetchSettings.mockResolvedValue({
...defaultSettings,
memoryEnabled: false,
});
renderModal();
await waitFor(() => {
expect(mockFetchSettings).toHaveBeenCalled();
});
// Click the Memory section in the sidebar
await userEvent.click(screen.getByText("Memory"));
const checkbox = screen.getByRole("checkbox", { name: /enable project memory/i });
expect(checkbox).toBeDefined();
expect(checkbox).not.toBeChecked();
});
it("toggles the memory setting when checkbox is clicked", async () => {
renderModal();
await waitFor(() => {
expect(mockFetchSettings).toHaveBeenCalled();
});
// Click the Memory section in the sidebar
await userEvent.click(screen.getByText("Memory"));
const checkbox = screen.getByRole("checkbox", { name: /enable project memory/i });
expect(checkbox).toBeChecked();
// Uncheck it
await userEvent.click(checkbox);
expect(checkbox).not.toBeChecked();
// Check it again
await userEvent.click(checkbox);
expect(checkbox).toBeChecked();
});
});
});

View File

@@ -41,6 +41,7 @@ const SETTINGS_SECTIONS = [
{ id: "worktrees", label: "Worktrees", scope: "project" as const },
{ id: "commands", label: "Commands", scope: "project" as const },
{ id: "merge", label: "Merge", scope: "project" as const },
{ id: "memory", label: "Memory", scope: "project" as const },
{ id: "backups", label: "Backups", scope: "project" as const },
{ id: "notifications", label: "Notifications", scope: "global" as const },
{ id: "authentication", label: "Authentication", scope: undefined },
@@ -1397,6 +1398,27 @@ export function SettingsModal({
</div>
</>
);
case "memory":
return (
<>
{renderScopeBanner()}
<h4 className="settings-section-heading">Memory</h4>
<div className="form-group">
<label htmlFor="memoryEnabled" className="checkbox-label">
<input
id="memoryEnabled"
type="checkbox"
checked={form.memoryEnabled !== false}
onChange={(e) =>
setForm((f) => ({ ...f, memoryEnabled: e.target.checked }))
}
/>
Enable project memory
</label>
<small>When enabled, agents will consult and update .fusion/memory.md with durable project learnings</small>
</div>
</>
);
case "backups":
return (
<>

View File

@@ -1262,14 +1262,14 @@ describe("SettingsModal", () => {
expect(layout!.querySelector(".settings-content")).toBeTruthy();
});
it("has .settings-sidebar with 10 .settings-nav-item buttons for all sections", async () => {
it("has .settings-sidebar with 11 .settings-nav-item buttons for all sections", async () => {
const { container } = render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
const sidebar = container.querySelector(".settings-sidebar");
expect(sidebar).toBeTruthy();
const navItems = sidebar!.querySelectorAll(".settings-nav-item");
expect(navItems.length).toBe(10);
expect(navItems.length).toBe(11);
// Labels include scope emoji indicators (🌐 for global, 📁 for project)
const labels = Array.from(navItems).map((el) => el.textContent);
@@ -1281,6 +1281,7 @@ describe("SettingsModal", () => {
"📁Worktrees",
"📁Commands",
"📁Merge",
"📁Memory",
"📁Backups",
"🌐Notifications",
"Authentication",

View File

@@ -2125,6 +2125,31 @@ describe("buildExecutionPrompt", () => {
expect(agentPrompt).toContain("- **Test:** `npm test`");
expect(agentPrompt).toContain("- **Build:** `npm run build`");
});
describe("memoryEnabled setting", () => {
it("accepts memoryEnabled: true without error", () => {
const task = createMockTaskDetail();
const result = buildExecutionPrompt(task, "/project", {
memoryEnabled: true,
} as any);
// Memory instructions are a placeholder until FN-810; just verify no crash
expect(result).toContain("Execute this task.");
});
it("accepts memoryEnabled: false without error", () => {
const task = createMockTaskDetail();
const result = buildExecutionPrompt(task, "/project", {
memoryEnabled: false,
} as any);
expect(result).toContain("Execute this task.");
});
it("accepts undefined memoryEnabled (default enabled) without error", () => {
const task = createMockTaskDetail();
const result = buildExecutionPrompt(task, "/project", {} as any);
expect(result).toContain("Execute this task.");
});
});
});
// Import the summarizeToolArgs helper directly (not affected by mocks above)

View File

@@ -2224,6 +2224,17 @@ git log --oneline
commandsSection = "\n" + lines.join("\n") + "\n";
}
// Build project memory section from settings
// When enabled, agents consult and update .fusion/memory.md for durable project learnings.
// Actual memory instructions will be injected by FN-810; this placeholder establishes
// the conditional integration point.
const memoryEnabled = settings?.memoryEnabled !== false;
let memorySection = "";
if (memoryEnabled && rootDir) {
// TODO(FN-810): Call buildMemoryInstructions(rootDir) to populate memory context
memorySection = "";
}
// Build steering comments section (last 10 comments only to avoid context bloat)
let steeringSection = "";
if (task.steeringComments && task.steeringComments.length > 0) {
@@ -2253,7 +2264,7 @@ ${task.dependencies.length > 0 ? `Dependencies: ${task.dependencies.join(", ")}`
## PROMPT.md
${task.prompt}
${attachmentsSection}${commandsSection}${progressSection}${steeringSection}
${attachmentsSection}${commandsSection}${memorySection}${progressSection}${steeringSection}
## Review level: ${reviewLevel}
${reviewLevel === 0 ? "No reviews required. Implement directly." : ""}

View File

@@ -214,6 +214,52 @@ describe("buildSpecificationPrompt", () => {
expect(prompt).toContain("If splitting: use the \\\`task_create\\\` tool");
expect(prompt).not.toContain("## Subtask Consideration");
});
describe("memoryEnabled setting", () => {
it("accepts memoryEnabled: true without error", () => {
const settings: Settings = {
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 10000,
groupOverlappingFiles: false,
autoMerge: true,
memoryEnabled: true,
};
const prompt = buildSpecificationPrompt(
baseTask,
".fusion/tasks/KB-001/PROMPT.md",
settings,
);
// Memory instructions are a placeholder until FN-810; just verify no crash
expect(prompt).toContain("Specify this task");
});
it("accepts memoryEnabled: false without error", () => {
const settings: Settings = {
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 10000,
groupOverlappingFiles: false,
autoMerge: true,
memoryEnabled: false,
};
const prompt = buildSpecificationPrompt(
baseTask,
".fusion/tasks/KB-001/PROMPT.md",
settings,
);
expect(prompt).toContain("Specify this task");
});
it("accepts undefined memoryEnabled (default enabled) without error", () => {
const prompt = buildSpecificationPrompt(
baseTask,
".fusion/tasks/KB-001/PROMPT.md",
undefined,
);
expect(prompt).toContain("Specify this task");
});
});
});
describe("TRIAGE_SYSTEM_PROMPT", () => {

View File

@@ -1193,6 +1193,17 @@ export function buildSpecificationPrompt(
commandsSection = "\n\n" + lines.join("\n");
}
// Build project memory section from settings.
// When enabled, agents consult .fusion/memory.md for durable project learnings.
// Actual memory instructions will be injected by FN-810; this placeholder
// establishes the conditional integration point.
const memoryEnabled = settings?.memoryEnabled !== false;
let memorySection = "";
if (memoryEnabled) {
// TODO(FN-810): Call buildMemoryInstructions(rootDir) to populate memory context
memorySection = "";
}
let attachmentsSection = "";
if (attachmentContents && attachmentContents.length > 0) {
const parts = ["## Attachments", ""];
@@ -1276,5 +1287,5 @@ ${task.dependencies.length > 0 ? `- **Dependencies:** ${task.dependencies.join("
## Instructions
${isRevision ? "1. Review the existing specification and user feedback carefully\n2. Revise the PROMPT.md to address the feedback while maintaining the structure\n3. Ensure the specification is detailed enough for an AI agent to execute" : "1. Read the project structure to understand context (package.json, source files, etc.)\n2. Write a complete PROMPT.md specification to the given path following the format in your system prompt\n3. The specification must be detailed enough for an autonomous AI agent to implement without asking questions\n4. Name actual files, functions, and patterns from the codebase — be specific"}
Use the write tool to write the specification file.${commandsSection}${attachmentsSection}`;
Use the write tool to write the specification file.${commandsSection}${memorySection}${attachmentsSection}`;
}