feat(memory): clôture du sujet mémoire — bascule adaptative live + panneau UI (§14.5.5)

Pièce 1 (backend) : state.rs câble AdaptiveMemoryRecall via build_memory_recall,
piloté par le profil embedder chargé depuis embedder.json (fallback none). Défaut
none ⇒ NaiveMemoryRecall nu (comportement inchangé, StubEmbedder jamais touché,
zéro dépendance lourde). Instance de recall partagée (RecallMemory + LaunchAgent).
Chargement du profil isolé sur un runtime dédié (évite le block_on imbriqué).

Pièce 2 (frontend) : feature mémoire complète en miroir de skills —
MemoryGateway (port) + TauriMemoryGateway + MockMemoryGateway, types domaine
Memory/MemoryIndexEntry/MemoryType, MemoryPanel/MemoryEditor/useMemory, onglet
sidebar « Memory » dans ProjectsView. CRUD par slug, liens [[slug]] résolus.

Le sujet mémoire est clos : CRUD .md + index + rappel adaptatif + injection à
l'activation des agents + UI de gestion. Embedder concret ONNX/HTTP reste un
follow-up (défaut none = pleinement fonctionnel sans dépendance).

Tests: backend 57 binaires verts, frontend 285 tests verts, typecheck OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-08 13:28:27 +02:00
parent 2435857cbf
commit f3bc3f20d8
16 changed files with 1414 additions and 10 deletions

View File

@ -0,0 +1,334 @@
/**
* L14 — memory feature wired to the stateful `MockMemoryGateway` via the real
* `DIProvider` (same harness as `skills.test.tsx`).
*
* Covers:
* - index rendering (title + hook + type badge) on mount
* - create a note (New → fill name/description/type/content → Save) → appears in
* the list, and `createMemory` receives the right payload
* - edit a note (slug read-only; description/type/content editable) → persisted
* - delete a note → removed from the list
* - `[[slug]]` link resolution (existing target listed; broken link ignored)
* - basic error state (gateway rejects → alert message shown)
*
* Also includes `MockMemoryGateway` unit tests (CRUD + index derivation +
* `resolveLinks` ignoring broken links).
*/
import { describe, it, expect, vi } from "vitest";
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
import { MockMemoryGateway } from "@/adapters/mock";
import type { GatewayError } from "@/domain";
import type { Gateways } from "@/ports";
import { DIProvider } from "@/app/di";
import { MemoryPanel } from "./MemoryPanel";
const PROJECT_ID = "proj-memory-test";
function renderMemoryPanel(memory?: MockMemoryGateway) {
const mem = memory ?? new MockMemoryGateway();
const gateways = { memory: mem } as unknown as Gateways;
return {
memory: mem,
...render(
<DIProvider gateways={gateways}>
<MemoryPanel projectId={PROJECT_ID} />
</DIProvider>,
),
};
}
async function waitForMemoryIdle() {
await waitFor(() => {
expect(
(
screen.getByRole("button", { name: "create memory" }) as HTMLButtonElement
).disabled,
).toBe(false);
});
}
/** Opens the editor in create-mode, fills it, and saves. */
async function createMemory(
name: string,
description: string,
content: string,
type: "user" | "feedback" | "project" | "reference" = "project",
) {
await waitForMemoryIdle();
fireEvent.click(screen.getByRole("button", { name: "create memory" }));
await waitFor(() => expect(screen.getByLabelText("memory name")).toBeTruthy());
fireEvent.change(screen.getByLabelText("memory name"), {
target: { value: name },
});
fireEvent.change(screen.getByLabelText("memory description"), {
target: { value: description },
});
fireEvent.change(screen.getByLabelText("memory type"), {
target: { value: type },
});
fireEvent.change(screen.getByLabelText("memory content"), {
target: { value: content },
});
fireEvent.click(screen.getByRole("button", { name: "Save note" }));
}
// ---------------------------------------------------------------------------
// MemoryPanel feature tests
// ---------------------------------------------------------------------------
describe("MemoryPanel (with MockMemoryGateway)", () => {
it("shows the empty state initially", async () => {
renderMemoryPanel();
await waitForMemoryIdle();
expect(screen.getByText("No memory notes yet.")).toBeTruthy();
});
it("renders the index (title + hook + type) on mount", async () => {
const memory = new MockMemoryGateway();
await memory.createMemory({
projectId: PROJECT_ID,
name: "Build AppImage",
description: "use APPIMAGE_EXTRACT_AND_RUN=1",
type: "reference",
content: "# notes",
});
renderMemoryPanel(memory);
await screen.findByText("Build AppImage");
expect(screen.getByText("use APPIMAGE_EXTRACT_AND_RUN=1")).toBeTruthy();
// Type badge.
expect(screen.getByText("reference")).toBeTruthy();
});
it("creating a note adds it to the list with the right payload", async () => {
const { memory } = renderMemoryPanel();
const spy = vi.spyOn(memory, "createMemory");
await createMemory("My Note", "a one-line hook", "# body", "user");
await screen.findByText("My Note");
// Payload received by the gateway.
expect(spy).toHaveBeenCalledWith({
projectId: PROJECT_ID,
name: "My Note",
description: "a one-line hook",
type: "user",
content: "# body",
});
// Persisted in the gateway under the derived slug.
const note = await memory.getMemory(PROJECT_ID, "my-note");
expect(note.name).toBe("My Note");
expect(note.description).toBe("a one-line hook");
expect(note.type).toBe("user");
expect(note.content).toBe("# body");
});
it("Save is disabled until description and content are filled", async () => {
renderMemoryPanel();
await waitForMemoryIdle();
fireEvent.click(screen.getByRole("button", { name: "create memory" }));
await waitFor(() =>
expect(screen.getByRole("button", { name: "Save note" })).toBeTruthy(),
);
const save = screen.getByRole("button", {
name: "Save note",
}) as HTMLButtonElement;
expect(save.disabled).toBe(true);
// Filling everything enables Save.
fireEvent.change(screen.getByLabelText("memory name"), {
target: { value: "X" },
});
fireEvent.change(screen.getByLabelText("memory description"), {
target: { value: "h" },
});
fireEvent.change(screen.getByLabelText("memory content"), {
target: { value: "c" },
});
expect(
(screen.getByRole("button", { name: "Save note" }) as HTMLButtonElement)
.disabled,
).toBe(false);
});
it("editing a note keeps the slug read-only and persists the changes", async () => {
const { memory } = renderMemoryPanel();
await createMemory("Editable Note", "old hook", "old body", "project");
await screen.findByText("Editable Note");
fireEvent.click(
screen.getByRole("button", { name: "edit memory Editable Note" }),
);
// Slug shown read-only — there's no editable "memory name" input in edit-mode.
await waitFor(() => expect(screen.getByText("editable-note")).toBeTruthy());
expect(screen.queryByLabelText("memory name")).toBeNull();
// Description / type / content are editable.
fireEvent.change(screen.getByLabelText("memory description"), {
target: { value: "new hook" },
});
fireEvent.change(screen.getByLabelText("memory type"), {
target: { value: "feedback" },
});
fireEvent.change(screen.getByLabelText("memory content"), {
target: { value: "new body" },
});
fireEvent.click(screen.getByRole("button", { name: "Save note" }));
await waitFor(async () => {
const note = await memory.getMemory(PROJECT_ID, "editable-note");
expect(note.description).toBe("new hook");
expect(note.type).toBe("feedback");
expect(note.content).toBe("new body");
});
});
it("deleting a note removes it from the list", async () => {
renderMemoryPanel();
await createMemory("To Delete", "hook", "body", "project");
await screen.findByText("To Delete");
fireEvent.click(
screen.getByRole("button", { name: "delete memory To Delete" }),
);
await waitFor(() => expect(screen.queryByText("To Delete")).toBeNull());
});
it("resolves [[slug]] links: existing target listed, broken link ignored", async () => {
const memory = new MockMemoryGateway();
// Target note that the linker will reference.
await memory.createMemory({
projectId: PROJECT_ID,
name: "Target Note",
description: "the target",
type: "project",
content: "# target",
});
// Source note links to the existing target and to a non-existent slug.
await memory.createMemory({
projectId: PROJECT_ID,
name: "Source Note",
description: "the source",
type: "project",
content: "See [[target-note]] and [[ghost-slug]].",
});
renderMemoryPanel(memory);
fireEvent.click(
await screen.findByRole("button", { name: "edit memory Source Note" }),
);
// The resolved link to the existing note appears; the broken one does not.
await waitFor(() => expect(screen.getByText("target-note")).toBeTruthy());
expect(screen.queryByText("ghost-slug")).toBeNull();
});
it("surfaces an error message when the gateway rejects", async () => {
const memory = new MockMemoryGateway();
const err: GatewayError = { code: "INTERNAL", message: "boom from gateway" };
vi.spyOn(memory, "readIndex").mockRejectedValue(err);
renderMemoryPanel(memory);
const alert = await screen.findByRole("alert");
expect(alert.textContent).toMatch(/boom from gateway/i);
});
});
// ---------------------------------------------------------------------------
// MockMemoryGateway unit tests
// ---------------------------------------------------------------------------
describe("MockMemoryGateway (unit)", () => {
it("CRUD: create → get → update → delete by immutable slug", async () => {
const gw = new MockMemoryGateway();
await gw.createMemory({
projectId: "p",
name: "Hello World",
description: "d",
type: "project",
content: "c",
});
const got = await gw.getMemory("p", "hello-world");
expect(got.name).toBe("Hello World");
await gw.updateMemory("p", "hello-world", "d2", "user", "c2");
const updated = await gw.getMemory("p", "hello-world");
expect(updated.description).toBe("d2");
expect(updated.type).toBe("user");
expect(updated.content).toBe("c2");
// Name (and therefore slug) is immutable.
expect(updated.name).toBe("Hello World");
await gw.deleteMemory("p", "hello-world");
await expect(gw.getMemory("p", "hello-world")).rejects.toMatchObject({
code: "NOT_FOUND",
});
});
it("getMemory / updateMemory / deleteMemory throw NOT_FOUND for unknown slug", async () => {
const gw = new MockMemoryGateway();
await expect(gw.getMemory("p", "ghost")).rejects.toMatchObject({
code: "NOT_FOUND",
});
await expect(
gw.updateMemory("p", "ghost", "d", "project", "c"),
).rejects.toMatchObject({ code: "NOT_FOUND" });
await expect(gw.deleteMemory("p", "ghost")).rejects.toMatchObject({
code: "NOT_FOUND",
});
});
it("readIndex derives the recall index from the notes", async () => {
const gw = new MockMemoryGateway();
await gw.createMemory({
projectId: "p",
name: "Indexed Note",
description: "hooky",
type: "reference",
content: "x",
});
const index = await gw.readIndex("p");
expect(index).toEqual([
{ slug: "indexed-note", title: "Indexed Note", hook: "hooky", type: "reference" },
]);
});
it("notes are partitioned by project", async () => {
const gw = new MockMemoryGateway();
await gw.createMemory({
projectId: "p1",
name: "A",
description: "d",
type: "project",
content: "c",
});
expect(await gw.readIndex("p1")).toHaveLength(1);
expect(await gw.readIndex("p2")).toHaveLength(0);
});
it("resolveLinks keeps existing targets and ignores broken / self links", async () => {
const gw = new MockMemoryGateway();
await gw.createMemory({
projectId: "p",
name: "Alpha",
description: "d",
type: "project",
content: "c",
});
await gw.createMemory({
projectId: "p",
name: "Beta",
description: "d",
type: "project",
content: "Links [[alpha]], [[ghost]] and self [[beta]].",
});
const links = await gw.resolveLinks("p", "beta");
expect(links).toEqual(["alpha"]);
});
});