diff --git a/ui/src/components/MemoryProviderConfigForm.test.tsx b/ui/src/components/MemoryProviderConfigForm.test.tsx
new file mode 100644
index 00000000000..cc9d911e745
--- /dev/null
+++ b/ui/src/components/MemoryProviderConfigForm.test.tsx
@@ -0,0 +1,173 @@
+// @vitest-environment jsdom
+
+import { act } from "react";
+import { createRoot } from "react-dom/client";
+import type { MemoryProviderDescriptor } from "@paperclipai/shared";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { MemoryProviderConfigForm } from "./MemoryProviderConfigForm";
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
+
+const provider: MemoryProviderDescriptor = {
+ key: "qmd_memory",
+ displayName: "QMD Memory",
+ description: "Markdown memory",
+ kind: "plugin",
+ pluginId: "paperclip.qmd-memory",
+ capabilities: {
+ browse: true,
+ correction: false,
+ asyncIngestion: false,
+ providerManagedExtraction: false,
+ },
+ configSchema: null,
+ configMetadata: {
+ suggestedConfig: {
+ searchMode: "query",
+ topK: 5,
+ apiToken: "",
+ autoIndexOnWrite: true,
+ },
+ fields: [
+ {
+ key: "searchMode",
+ label: "Search mode",
+ description: "How QMD should retrieve matching memory files.",
+ input: "select",
+ options: [
+ { value: "query", label: "Query" },
+ { value: "search", label: "Search" },
+ ],
+ },
+ {
+ key: "topK",
+ label: "Hydration snippets",
+ description: "Maximum snippets to include in a run preamble.",
+ input: "number",
+ min: 1,
+ max: 25,
+ },
+ {
+ key: "apiToken",
+ label: "API token",
+ input: "secret",
+ required: true,
+ },
+ {
+ key: "autoIndexOnWrite",
+ label: "Auto-index on write",
+ input: "boolean",
+ },
+ ],
+ pathSuggestions: [
+ {
+ key: "store",
+ label: "Binding directory",
+ path: "/tmp/paperclip/qmd/default",
+ description: "Markdown records are stored here.",
+ },
+ ],
+ healthChecks: [
+ {
+ key: "qmd",
+ label: "QMD binary",
+ status: "warning",
+ message: "qmd was not found on PATH.",
+ },
+ ],
+ },
+};
+
+describe("MemoryProviderConfigForm", () => {
+ let container: HTMLDivElement;
+
+ beforeEach(() => {
+ container = document.createElement("div");
+ document.body.appendChild(container);
+ });
+
+ afterEach(() => {
+ container.remove();
+ document.body.innerHTML = "";
+ });
+
+ it("renders provider metadata fields, secret inputs, paths, health, and advanced JSON", async () => {
+ const onChange = vi.fn();
+ const onValidationChange = vi.fn();
+ const root = createRoot(container);
+
+ await act(async () => {
+ root.render(
+ ,
+ );
+ await Promise.resolve();
+ });
+
+ expect(container.textContent).toContain("Search mode");
+ expect(container.textContent).toContain("Hydration snippets");
+ expect(container.textContent).toContain("Binding directory");
+ expect(container.textContent).toContain("/tmp/paperclip/qmd/default");
+ expect(container.textContent).toContain("QMD binary");
+ expect(container.textContent).toContain("warning");
+ expect(container.querySelector('input[type="password"]')).not.toBeNull();
+
+ const advancedButton = Array.from(container.querySelectorAll("button")).find((button) =>
+ button.textContent?.includes("Advanced JSON")
+ );
+ expect(advancedButton).toBeTruthy();
+
+ await act(async () => {
+ advancedButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ });
+
+ expect(container.querySelector("textarea")?.value).toContain('"topK": 5');
+
+ await act(async () => {
+ root.unmount();
+ });
+ });
+
+ it("shows field-specific validation and reports invalid config state", async () => {
+ const onChange = vi.fn();
+ const onValidationChange = vi.fn();
+ const root = createRoot(container);
+
+ await act(async () => {
+ root.render(
+ ,
+ );
+ await Promise.resolve();
+ });
+
+ expect(container.textContent).toContain("Search mode must be one of the listed options.");
+ expect(container.textContent).toContain("Hydration snippets must be at most 25.");
+ expect(container.textContent).toContain("API token is required.");
+ expect(container.textContent).toContain("Auto-index on write must be on or off.");
+ expect(onValidationChange).toHaveBeenLastCalledWith(false);
+
+ await act(async () => {
+ root.unmount();
+ });
+ });
+});
diff --git a/ui/src/components/ProjectMemorySettings.test.tsx b/ui/src/components/ProjectMemorySettings.test.tsx
new file mode 100644
index 00000000000..6c428dc06fa
--- /dev/null
+++ b/ui/src/components/ProjectMemorySettings.test.tsx
@@ -0,0 +1,135 @@
+// @vitest-environment jsdom
+
+import { act } from "react";
+import { createRoot } from "react-dom/client";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import type { MemoryBinding, MemoryResolvedBinding } from "@paperclipai/shared";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { ToastProvider } from "../context/ToastContext";
+import { ProjectMemorySettings } from "./ProjectMemorySettings";
+
+const memoryApiMocks = vi.hoisted(() => ({
+ listBindings: vi.fn(),
+ getProjectBinding: vi.fn(),
+ setProjectBinding: vi.fn(),
+}));
+
+vi.mock("../api/memory", () => ({
+ memoryApi: memoryApiMocks,
+}));
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
+
+function tick() {
+ return new Promise((resolve) => window.setTimeout(resolve, 0));
+}
+
+function createBinding(overrides: Partial = {}): MemoryBinding {
+ return {
+ id: "binding-qmd",
+ companyId: "company-1",
+ key: "qmd-default",
+ name: "QMD default",
+ providerKey: "qmd_memory",
+ config: {},
+ enabled: true,
+ createdAt: new Date("2026-04-20T00:00:00.000Z"),
+ updatedAt: new Date("2026-04-20T00:00:00.000Z"),
+ ...overrides,
+ };
+}
+
+function createResolvedBinding(overrides: Partial = {}): MemoryResolvedBinding {
+ return {
+ companyId: "company-1",
+ targetType: "company",
+ targetId: "company-1",
+ binding: createBinding(),
+ source: "company_default",
+ checkedTargetTypes: ["project", "company"],
+ ...overrides,
+ };
+}
+
+describe("ProjectMemorySettings", () => {
+ let container: HTMLDivElement;
+
+ beforeEach(() => {
+ container = document.createElement("div");
+ document.body.appendChild(container);
+ memoryApiMocks.listBindings.mockReset();
+ memoryApiMocks.getProjectBinding.mockReset();
+ memoryApiMocks.setProjectBinding.mockReset();
+ });
+
+ afterEach(() => {
+ container.remove();
+ document.body.innerHTML = "";
+ });
+
+ it("shows inherited company memory and saves a project override", async () => {
+ const binding = createBinding();
+ memoryApiMocks.listBindings.mockResolvedValue([binding]);
+ memoryApiMocks.getProjectBinding.mockResolvedValue(createResolvedBinding());
+ memoryApiMocks.setProjectBinding.mockResolvedValue({
+ id: "target-project",
+ companyId: "company-1",
+ bindingId: binding.id,
+ targetType: "project",
+ targetId: "project-1",
+ createdAt: new Date("2026-04-20T00:00:00.000Z"),
+ updatedAt: new Date("2026-04-20T00:00:00.000Z"),
+ });
+
+ const root = createRoot(container);
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false },
+ mutations: { retry: false },
+ },
+ });
+
+ await act(async () => {
+ root.render(
+
+
+
+
+ ,
+ );
+ });
+
+ await act(async () => {
+ await tick();
+ await tick();
+ });
+
+ expect(container.textContent).toContain("QMD default (qmd_memory)");
+ expect(container.textContent).toContain("Source: Company default");
+
+ const select = container.querySelector("select");
+ expect(select).not.toBeNull();
+
+ await act(async () => {
+ select!.value = binding.id;
+ select!.dispatchEvent(new Event("change", { bubbles: true }));
+ });
+
+ const saveButton = Array.from(container.querySelectorAll("button")).find((button) =>
+ button.textContent?.includes("Save override")
+ );
+ expect(saveButton).toBeTruthy();
+
+ await act(async () => {
+ saveButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ await tick();
+ });
+
+ expect(memoryApiMocks.setProjectBinding).toHaveBeenCalledWith("project-1", binding.id);
+
+ await act(async () => {
+ root.unmount();
+ });
+ });
+});