- LOT C2 (§14.5.3) : use cases de configuration des embedders déclaratifs (List/Save/Delete + DescribeEmbedderEngines : modèles ONNX recommandés, environnement local détecté, stratégies compilées). UI EmbedderSettings. - LOT C3 (§14.5.5) : suggestion contextuelle best-effort à l'activation quand la mémoire dépasse le budget de recall sans embedder configuré (event EmbedderSuggested, anti-spam 1×/session, « ne plus demander »). - Contexte projet partagé .ideai/CONTEXT.md (model-agnostic) injecté à tous les agents/profils au lancement, avant la persona. UI ProjectContextPanel. Tests : backend workspace vert (0 échec) ; frontend 306/306. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
238 lines
8.8 KiB
TypeScript
238 lines
8.8 KiB
TypeScript
/**
|
|
* L14 / lot C2 — embedder settings feature wired to the stateful
|
|
* `MockEmbedderGateway` via the real `DIProvider` (same harness as
|
|
* `memory.test.tsx`).
|
|
*
|
|
* Covers:
|
|
* - the strategy list renders on an equal footing (None, ONNX, Ollama, API,
|
|
* Custom) with the "Recommended" badge on ONNX
|
|
* - conditional fields per strategy (ONNX model picker + auto dimension;
|
|
* localServer endpoint; api endpoint + apiKeyEnv label; none → no fields)
|
|
* - a strategy whose build flag is false is shown disabled with
|
|
* "not available in this build" (None stays available)
|
|
* - Save calls the gateway with the right profile
|
|
* - "Back to None" deletes the configured profiles
|
|
*
|
|
* Plus the active-tier transparency line in MemoryPanel (Naïve vs Vector).
|
|
*/
|
|
|
|
import { describe, it, expect, vi } from "vitest";
|
|
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
|
|
|
|
import { MockEmbedderGateway, MockMemoryGateway } from "@/adapters/mock";
|
|
import type { EmbedderEngines, EmbedderProfile } from "@/domain";
|
|
import type { Gateways } from "@/ports";
|
|
import { DIProvider } from "@/app/di";
|
|
import { MemoryPanel } from "@/features/memory";
|
|
import { EmbedderSettings } from "./EmbedderSettings";
|
|
|
|
function renderEmbedder(
|
|
engines?: Partial<EmbedderEngines>,
|
|
seed: EmbedderProfile[] = [],
|
|
) {
|
|
const embedder = new MockEmbedderGateway(engines, seed);
|
|
const gateways = { embedder } as unknown as Gateways;
|
|
return {
|
|
embedder,
|
|
...render(
|
|
<DIProvider gateways={gateways}>
|
|
<EmbedderSettings />
|
|
</DIProvider>,
|
|
),
|
|
};
|
|
}
|
|
|
|
async function waitForEmbedderIdle() {
|
|
await waitFor(() => {
|
|
expect(
|
|
(screen.getByRole("button", { name: "back to none" }) as HTMLButtonElement)
|
|
.disabled,
|
|
).toBe(false);
|
|
});
|
|
}
|
|
|
|
describe("EmbedderSettings (with MockEmbedderGateway)", () => {
|
|
it("renders the strategy list on an equal footing with a Recommended badge", async () => {
|
|
renderEmbedder();
|
|
await waitForEmbedderIdle();
|
|
|
|
// Every strategy is offered.
|
|
expect(screen.getByTestId("embedder-strategy-none")).toBeTruthy();
|
|
expect(screen.getByTestId("embedder-strategy-localOnnx")).toBeTruthy();
|
|
expect(screen.getByTestId("embedder-strategy-localServer")).toBeTruthy();
|
|
expect(screen.getByTestId("embedder-strategy-api")).toBeTruthy();
|
|
expect(screen.getByTestId("embedder-strategy-custom")).toBeTruthy();
|
|
|
|
// ONNX carries the "Recommended" badge.
|
|
expect(screen.getByText("Recommended")).toBeTruthy();
|
|
|
|
// The change-takes-effect notice is visible.
|
|
expect(screen.getByTestId("embedder-restart-note").textContent).toMatch(
|
|
/next app start/i,
|
|
);
|
|
});
|
|
|
|
it("pre-selects ONNX with the recommended model and auto-fills its dimension (384)", async () => {
|
|
renderEmbedder();
|
|
await waitForEmbedderIdle();
|
|
|
|
// ONNX is the default cursor → its model picker is shown.
|
|
const modelSelect = (await screen.findByLabelText(
|
|
"onnx model",
|
|
)) as HTMLSelectElement;
|
|
expect(modelSelect.value).toBe("e5-small");
|
|
|
|
// Dimension auto-filled from the model.
|
|
expect((screen.getByLabelText("dimension") as HTMLInputElement).value).toBe(
|
|
"384",
|
|
);
|
|
});
|
|
|
|
it("shows endpoint for localServer and endpoint + apiKeyEnv (named, not the key) for api", async () => {
|
|
renderEmbedder();
|
|
await waitForEmbedderIdle();
|
|
|
|
// localServer → endpoint, no apiKeyEnv.
|
|
fireEvent.click(
|
|
screen.getByRole("radio", { name: "Ollama (local server)" }),
|
|
);
|
|
await waitFor(() => expect(screen.getByLabelText("endpoint")).toBeTruthy());
|
|
expect(screen.queryByLabelText("api key env var")).toBeNull();
|
|
|
|
// api → endpoint + apiKeyEnv with an explicit "env var" label.
|
|
fireEvent.click(screen.getByRole("radio", { name: "API" }));
|
|
await waitFor(() =>
|
|
expect(screen.getByLabelText("api key env var")).toBeTruthy(),
|
|
);
|
|
expect(screen.getByText(/environment variable/i)).toBeTruthy();
|
|
expect(screen.getByText(/never the key itself/i)).toBeTruthy();
|
|
});
|
|
|
|
it("renders no conditional fields for None", async () => {
|
|
renderEmbedder();
|
|
await waitForEmbedderIdle();
|
|
|
|
fireEvent.click(screen.getByRole("radio", { name: "None (naïve recall)" }));
|
|
await waitFor(() =>
|
|
expect(screen.queryByLabelText("dimension")).toBeNull(),
|
|
);
|
|
expect(screen.queryByLabelText("onnx model")).toBeNull();
|
|
// No Save button in None mode (only "Back to None").
|
|
expect(screen.queryByRole("button", { name: "save embedder" })).toBeNull();
|
|
});
|
|
|
|
it("disables strategies whose build flag is false with a 'not available' note", async () => {
|
|
renderEmbedder({ vectorOnnxEnabled: false, vectorHttpEnabled: false });
|
|
await waitForEmbedderIdle();
|
|
|
|
const onnxRadio = screen.getByRole("radio", {
|
|
name: "Local ONNX",
|
|
}) as HTMLInputElement;
|
|
const ollamaRadio = screen.getByRole("radio", {
|
|
name: "Ollama (local server)",
|
|
}) as HTMLInputElement;
|
|
const apiRadio = screen.getByRole("radio", { name: "API" }) as HTMLInputElement;
|
|
const noneRadio = screen.getByRole("radio", {
|
|
name: "None (naïve recall)",
|
|
}) as HTMLInputElement;
|
|
|
|
expect(onnxRadio.disabled).toBe(true);
|
|
expect(ollamaRadio.disabled).toBe(true);
|
|
expect(apiRadio.disabled).toBe(true);
|
|
// None is always available.
|
|
expect(noneRadio.disabled).toBe(false);
|
|
|
|
// The honesty note appears.
|
|
expect(
|
|
screen.getAllByText("not available in this build").length,
|
|
).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("Save sends the recommended ONNX profile (e5-small, dim 384)", async () => {
|
|
const { embedder } = renderEmbedder();
|
|
const spy = vi.spyOn(embedder, "saveEmbedderProfile");
|
|
await waitForEmbedderIdle();
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "save embedder" }));
|
|
|
|
await waitFor(() => expect(spy).toHaveBeenCalledTimes(1));
|
|
const saved = spy.mock.calls[0][0];
|
|
expect(saved.strategy).toBe("localOnnx");
|
|
expect(saved.model).toBe("e5-small");
|
|
expect(saved.dimension).toBe(384);
|
|
expect(saved.id.length).toBeGreaterThan(0);
|
|
expect(saved.name.length).toBeGreaterThan(0);
|
|
// ONNX never carries an endpoint/apiKeyEnv.
|
|
expect(saved.endpoint).toBeUndefined();
|
|
expect(saved.apiKeyEnv).toBeUndefined();
|
|
});
|
|
|
|
it("Save for api sends endpoint + apiKeyEnv (the env var NAME)", async () => {
|
|
const { embedder } = renderEmbedder();
|
|
const spy = vi.spyOn(embedder, "saveEmbedderProfile");
|
|
await waitForEmbedderIdle();
|
|
|
|
fireEvent.click(screen.getByRole("radio", { name: "API" }));
|
|
await waitFor(() =>
|
|
expect(screen.getByLabelText("api key env var")).toBeTruthy(),
|
|
);
|
|
fireEvent.change(screen.getByLabelText("api key env var"), {
|
|
target: { value: "MY_KEY_VAR" },
|
|
});
|
|
fireEvent.click(screen.getByRole("button", { name: "save embedder" }));
|
|
|
|
await waitFor(() => expect(spy).toHaveBeenCalled());
|
|
const saved = spy.mock.calls.at(-1)![0];
|
|
expect(saved.strategy).toBe("api");
|
|
expect(saved.apiKeyEnv).toBe("MY_KEY_VAR");
|
|
expect(saved.endpoint).toBeTruthy();
|
|
});
|
|
|
|
it("'Back to None' deletes the configured profiles", async () => {
|
|
const seed: EmbedderProfile[] = [
|
|
{ id: "onnx-local", name: "Local ONNX", strategy: "localOnnx", model: "e5-small", dimension: 384 },
|
|
];
|
|
const { embedder } = renderEmbedder(undefined, seed);
|
|
const spy = vi.spyOn(embedder, "deleteEmbedderProfile");
|
|
await waitForEmbedderIdle();
|
|
|
|
fireEvent.click(screen.getByRole("button", { name: "back to none" }));
|
|
|
|
await waitFor(() => expect(spy).toHaveBeenCalledWith("onnx-local"));
|
|
expect(await embedder.listEmbedderProfiles()).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// MemoryPanel — active recall tier transparency
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("MemoryPanel active tier (transparency)", () => {
|
|
function renderPanel(embedder: MockEmbedderGateway) {
|
|
const gateways = {
|
|
memory: new MockMemoryGateway(),
|
|
embedder,
|
|
} as unknown as Gateways;
|
|
return render(
|
|
<DIProvider gateways={gateways}>
|
|
<MemoryPanel projectId="p" />
|
|
</DIProvider>,
|
|
);
|
|
}
|
|
|
|
it("shows 'Naïve (None)' when no embedder is configured", async () => {
|
|
renderPanel(new MockEmbedderGateway());
|
|
const tier = await screen.findByTestId("memory-tier");
|
|
await waitFor(() => expect(tier.textContent).toMatch(/Naïve \(None\)/));
|
|
});
|
|
|
|
it("shows 'Vector — <strategy> <model>' when an embedder is configured", async () => {
|
|
const embedder = new MockEmbedderGateway(undefined, [
|
|
{ id: "onnx-local", name: "Local ONNX", strategy: "localOnnx", model: "e5-small", dimension: 384 },
|
|
]);
|
|
renderPanel(embedder);
|
|
const tier = await screen.findByTestId("memory-tier");
|
|
await waitFor(() => expect(tier.textContent).toMatch(/Vector — localOnnx e5-small/));
|
|
});
|
|
});
|