feat(memory): config embedders (LOT C2) + suggestion contextuelle (LOT C3) + contexte projet partagé

- 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>
This commit is contained in:
2026-06-09 09:24:51 +02:00
parent 32398827fb
commit 785e9935fd
118 changed files with 5793 additions and 866 deletions

View File

@ -8,7 +8,7 @@
* rather than xterm's visual output. They stay robust whether or not xterm
* bailed.
*/
import { describe, it, expect } from "vitest";
import { describe, it, expect, vi } from "vitest";
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
import type { Gateways } from "@/ports";
@ -90,8 +90,10 @@ describe("LayoutGrid (with MockLayoutGateway)", () => {
});
});
it("closing a cell removes THAT cell and keeps its sibling (closes the right terminal)", async () => {
it("closing a cell removes THAT cell and keeps its sibling without killing its session", async () => {
const layout = new MockLayoutGateway();
const terminal = new MockTerminalGateway();
const closeSpy = vi.spyOn(terminal, "closeTerminal");
const initial = await layout.loadLayout("p1");
const a = leaves(initial)[0].id;
// Split A → container c with children [A (index 0), b (index 1)].
@ -102,8 +104,18 @@ describe("LayoutGrid (with MockLayoutGateway)", () => {
newLeaf: "b",
container: "c",
});
await layout.mutateLayout("p1", {
type: "setSession",
target: "b",
session: "running-session-b",
});
renderGrid(layout);
const gateways = { layout, terminal } as unknown as Gateways;
render(
<DIProvider gateways={gateways}>
<LayoutGrid projectId="p1" cwd="/home/me/proj" />
</DIProvider>,
);
await waitFor(() =>
expect(screen.getAllByTestId("layout-leaf")).toHaveLength(2),
);
@ -117,6 +129,7 @@ describe("LayoutGrid (with MockLayoutGateway)", () => {
expect(
screen.getByTestId("layout-leaf").getAttribute("data-node-id"),
).toBe(a);
expect(closeSpy).not.toHaveBeenCalled();
});
it("closing the other cell keeps the opposite sibling", async () => {

View File

@ -28,7 +28,7 @@ import type {
} from "@/ports";
import { ResumeConversationPopup, TerminalView } from "@/features/terminals";
import { useGateways } from "@/app/di";
import { normalizeWeights, resizeAdjacent } from "./layout";
import { leaves, normalizeWeights, resizeAdjacent } from "./layout";
import { useLayout, type LayoutViewModel } from "./useLayout";
interface LayoutGridProps {
@ -56,6 +56,7 @@ export function LayoutGrid({ projectId, cwd, layoutId }: LayoutGridProps) {
</div>
);
}
const visibleNodeIds = new Set(leaves(vm.layout).map((l) => l.id));
return (
<div
@ -73,6 +74,7 @@ export function LayoutGrid({ projectId, cwd, layoutId }: LayoutGridProps) {
vm={vm}
parentSplit={null}
projectId={projectId}
visibleNodeIds={visibleNodeIds}
/>
</div>
);
@ -85,9 +87,10 @@ interface NodeViewProps {
/** The enclosing split + this node's index in it, for the merge action. */
parentSplit: { container: string; index: number; siblings: number } | null;
projectId: string;
visibleNodeIds: Set<string>;
}
function NodeView({ node, cwd, vm, parentSplit, projectId }: NodeViewProps) {
function NodeView({ node, cwd, vm, parentSplit, projectId, visibleNodeIds }: NodeViewProps) {
switch (node.type) {
case "leaf":
return (
@ -101,12 +104,29 @@ function NodeView({ node, cwd, vm, parentSplit, projectId }: NodeViewProps) {
vm={vm}
parentSplit={parentSplit}
projectId={projectId}
visibleNodeIds={visibleNodeIds}
/>
);
case "split":
return <SplitView split={node.node} cwd={cwd} vm={vm} projectId={projectId} />;
return (
<SplitView
split={node.node}
cwd={cwd}
vm={vm}
projectId={projectId}
visibleNodeIds={visibleNodeIds}
/>
);
case "grid":
return <GridView grid={node.node} cwd={cwd} vm={vm} projectId={projectId} />;
return (
<GridView
grid={node.node}
cwd={cwd}
vm={vm}
projectId={projectId}
visibleNodeIds={visibleNodeIds}
/>
);
}
}
@ -120,6 +140,7 @@ interface LeafViewProps {
vm: LayoutViewModel;
parentSplit: { container: string; index: number; siblings: number } | null;
projectId: string;
visibleNodeIds: Set<string>;
}
/**
@ -135,7 +156,7 @@ interface PendingResume {
reject: (e: unknown) => void;
}
function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm, parentSplit, projectId }: LeafViewProps) {
function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm, parentSplit, projectId, visibleNodeIds }: LeafViewProps) {
// A cell can be closed only when it lives inside a (binary) split: closing it
// collapses the parent split, keeping the *sibling*. Splits are always binary
// in this model (a split wraps a leaf into a 2-child container), so the kept
@ -204,13 +225,25 @@ function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm
// Build the terminal opener based on whether an agent is pinned.
const agentId = agent ?? null;
/**
* Whether `candidate` is currently running in a cell *other than this one*.
* Such an agent cannot be launched here (one live session per agent), so the
* dropdown disables it and `onChange` rejects selecting it.
*/
const isLiveElsewhere = (candidate: string): boolean =>
liveAgents.some((la) => la.agentId === candidate && la.nodeId !== id);
/** The live session for `candidate`, if any. */
const liveFor = (candidate: string): LiveAgent | undefined =>
liveAgents.find((la) => la.agentId === candidate);
/** True when the live session is already displayed by another visible cell. */
const visibleElsewhere = (candidate: string): LiveAgent | undefined => {
const live = liveFor(candidate);
return live && live.nodeId !== id && visibleNodeIds.has(live.nodeId)
? live
: undefined;
};
/** A live session whose previous host cell no longer exists in the layout. */
const backgroundLive = (candidate: string): LiveAgent | undefined => {
const live = liveFor(candidate);
return live && live.nodeId !== id && !visibleNodeIds.has(live.nodeId)
? live
: undefined;
};
// A transient notice shown when an action is blocked by the singleton
// invariant (selecting / launching an agent already live in another cell).
@ -231,6 +264,16 @@ function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm
onData: (bytes: Uint8Array) => void,
convId: string | undefined,
): Promise<TerminalHandle> => {
const background = backgroundLive(agentId!);
if (background?.sessionId && agentGateway!.attachLiveAgent) {
const attached = await agentGateway!.attachLiveAgent(projectId, agentId!, id);
const sessionId = attached.sessionId ?? background.sessionId;
void vm.setSession(id, sessionId);
const result = await agentGateway!.reattach(sessionId, onData);
refreshLive();
return result.handle;
}
const handle = await agentGateway!.launchAgent(
projectId,
agentId!,
@ -328,15 +371,39 @@ function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm
value={agentId ?? ""}
onChange={(e) => {
const val = e.target.value;
// Reject pinning an agent that is already running in another cell.
// (The dropdown also disables such options, but guard the change in
// case it is set programmatically.)
if (val !== "" && isLiveElsewhere(val)) {
setBusyNotice("Cet agent tourne déjà dans une autre cellule.");
if (val === "") {
setBusyNotice(null);
void vm.setCellAgent(id, null);
return;
}
setBusyNotice(null);
void vm.setCellAgent(id, val === "" ? null : val);
void (async () => {
const current = agentGateway?.listLiveAgents
? await agentGateway.listLiveAgents(projectId).catch(() => liveAgents)
: liveAgents;
setLiveAgents(current);
const live = current.find((la) => la.agentId === val);
const isVisible =
live && live.nodeId !== id && visibleNodeIds.has(live.nodeId);
if (isVisible) {
setBusyNotice("Cet agent est déjà visible dans une autre cellule.");
return;
}
const isBackground =
live && live.nodeId !== id && !visibleNodeIds.has(live.nodeId);
if (isBackground) {
if (!agentGateway?.attachLiveAgent || !live.sessionId) {
setBusyNotice("Session active introuvable pour cet agent.");
return;
}
setBusyNotice(null);
const attached = await agentGateway.attachLiveAgent(projectId, val, id);
await vm.attachLiveAgentToCell(id, val, attached.sessionId ?? live.sessionId);
refreshLive();
return;
}
setBusyNotice(null);
await vm.setCellAgent(id, val);
})().catch((err: unknown) => setBusyNotice(describeNotice(err)));
}}
style={{
fontSize: 11,
@ -350,13 +417,12 @@ function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm
>
<option value="">Plain</option>
{agents.map((a) => {
// An agent already running in another cell cannot be pinned here.
// The agent pinned on THIS cell stays selectable (same node).
const elsewhere = isLiveElsewhere(a.id);
const elsewhere = visibleElsewhere(a.id);
const background = backgroundLive(a.id);
return (
<option key={a.id} value={a.id} disabled={elsewhere}>
<option key={a.id} value={a.id} disabled={Boolean(elsewhere)}>
{a.name}
{elsewhere ? " (en cours ailleurs)" : ""}
{elsewhere ? " (visible ailleurs)" : background ? " (arrière-plan)" : ""}
</option>
);
})}
@ -437,9 +503,10 @@ interface SplitViewProps {
cwd: string;
vm: LayoutViewModel;
projectId: string;
visibleNodeIds: Set<string>;
}
function SplitView({ split, cwd, vm, projectId }: SplitViewProps) {
function SplitView({ split, cwd, vm, projectId, visibleNodeIds }: SplitViewProps) {
const isRow = split.direction === "row";
const baseWeights = split.children.map((c) => c.weight);
const containerRef = useRef<HTMLDivElement | null>(null);
@ -480,6 +547,7 @@ function SplitView({ split, cwd, vm, projectId }: SplitViewProps) {
cwd={cwd}
vm={vm}
projectId={projectId}
visibleNodeIds={visibleNodeIds}
parentSplit={{
container: split.id,
index: i,
@ -571,9 +639,10 @@ interface GridViewProps {
cwd: string;
vm: LayoutViewModel;
projectId: string;
visibleNodeIds: Set<string>;
}
function GridView({ grid, cwd, vm, projectId }: GridViewProps) {
function GridView({ grid, cwd, vm, projectId, visibleNodeIds }: GridViewProps) {
const cols = normalizeWeights(grid.colWeights)
.map((p) => `${p}fr`)
.join(" ");
@ -604,7 +673,14 @@ function GridView({ grid, cwd, vm, projectId }: GridViewProps) {
overflow: "hidden",
}}
>
<NodeView node={cell.node} cwd={cwd} vm={vm} parentSplit={null} projectId={projectId} />
<NodeView
node={cell.node}
cwd={cwd}
vm={vm}
parentSplit={null}
projectId={projectId}
visibleNodeIds={visibleNodeIds}
/>
</div>
))}
</div>
@ -615,3 +691,10 @@ function GridView({ grid, cwd, vm, projectId }: GridViewProps) {
function keyOf(node: LayoutNode, fallback: number): string {
return node.node.id ?? String(fallback);
}
function describeNotice(e: unknown): string {
if (e && typeof e === "object" && "message" in e) {
return String((e as { message: unknown }).message);
}
return String(e);
}

View File

@ -232,15 +232,17 @@ describe("setCellAgent (agent dropdown per cell)", () => {
renderGrid(layout, agent, "p1", terminal);
// Close the SECOND cell: keep the first, drop the second → its PTY dies.
// Close the SECOND cell: keep the first, drop the second. Closing a cell is
// now a detach-only view operation; it must not kill the PTY.
await waitFor(() => {
expect(screen.getByLabelText(`close ${droppedId}`)).toBeTruthy();
});
fireEvent.click(screen.getByLabelText(`close ${droppedId}`));
await waitFor(() => {
expect(closeSpy).toHaveBeenCalledWith("dropped-session");
expect(screen.getAllByTestId("layout-leaf")).toHaveLength(1);
});
expect(closeSpy).not.toHaveBeenCalled();
});
it("agent selector shows project agents as options", async () => {

View File

@ -4,11 +4,11 @@
* - The mock agent gateway mirrors the backend guard: launching an agent already
* live in another cell is refused with `AGENT_ALREADY_RUNNING`, and the same
* node is idempotent; `listLiveAgents` reports who runs where (T5).
* - The per-cell dropdown disables (and `onChange` rejects) an agent already live
* in another cell, while the agent pinned on its own cell stays selectable (T6).
* - The per-cell dropdown blocks an agent already visible in another cell, but
* can rebind a background live session when the backend exposes its session id.
*/
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import type { Gateways, LiveAgent } from "@/ports";
import {
@ -65,7 +65,13 @@ describe("singleton agent — mock gateway guard (T5)", () => {
);
const live: LiveAgent[] = await agent.listLiveAgents("p1");
expect(live).toEqual([{ agentId: a.id, nodeId: "node-A" }]);
expect(live).toEqual([
{
agentId: a.id,
nodeId: "node-A",
sessionId: "mock-agent-session-1",
},
]);
});
it("relaunching in the SAME node is allowed (idempotent), not refused", async () => {
@ -104,34 +110,68 @@ describe("singleton agent — dropdown guard (T6)", () => {
);
}
it("disables an agent already running in another cell and rejects selecting it", async () => {
it("opens an agent already running elsewhere by rebinding its live session", async () => {
const layout = new MockLayoutGateway();
const agent = new MockAgentGateway();
const a = await agent.createAgent("p1", { name: "Busy", profileId: "p" });
await agent.launchAgent(
"p1",
a.id,
{ cwd: "/x", rows: 24, cols: 80, nodeId: "old-node" },
noop,
);
const tree = await layout.loadLayout("p1");
const leafId = leaves(tree)[0].id;
renderGrid(layout, agent);
// The option is selectable: choosing it rebinds the running session here.
const option = await screen.findByRole("option", {
name: /Busy/,
});
expect((option as HTMLOptionElement).disabled).toBe(false);
const select = screen.getByRole("combobox") as HTMLSelectElement;
fireEvent.change(select, { target: { value: a.id } });
await waitFor(async () => {
const updated = await layout.loadLayout("p1");
const leaf = leaves(updated).find((l) => l.id === leafId)!;
expect(leaf.agent).toBe(a.id);
expect(leaf.session).toBe("mock-agent-session-1");
});
expect(await agent.listLiveAgents("p1")).toEqual([
{
agentId: a.id,
nodeId: leafId,
sessionId: "mock-agent-session-1",
},
]);
});
it("explains the missing backend contract when a live agent has no attachable session", async () => {
const layout = new MockLayoutGateway();
const agent = new MockAgentGateway();
const a = await agent.createAgent("p1", { name: "Busy", profileId: "p" });
// Mark the agent live in a DIFFERENT node than the (single) leaf we render.
vi.spyOn(agent, "listLiveAgents").mockResolvedValue([
{ agentId: a.id, nodeId: "some-other-node" },
]);
renderGrid(layout, agent);
// The option for the live-elsewhere agent is rendered disabled with a label.
const option = await screen.findByRole("option", {
name: /Busy \(en cours ailleurs\)/,
name: /Busy/,
});
expect((option as HTMLOptionElement).disabled).toBe(true);
expect((option as HTMLOptionElement).disabled).toBe(false);
// Programmatically selecting it must be rejected (agent not pinned).
const select = screen.getByRole("combobox") as HTMLSelectElement;
const setCellAgentSpy = vi.spyOn(layout, "mutateLayout");
fireEvent.change(select, { target: { value: a.id } });
// A notice is shown and no setCellAgent mutation was issued.
expect(await screen.findByRole("status")).toBeTruthy();
expect(
setCellAgentSpy.mock.calls.filter((c) => c[1].type === "setCellAgent"),
).toHaveLength(0);
expect((await screen.findByRole("status")).textContent).toMatch(
/Session active introuvable/,
);
});
it("the agent pinned on THIS cell stays selectable even while live (same node)", async () => {

View File

@ -17,7 +17,7 @@ import type {
LayoutTree,
} from "@/domain";
import { useGateways } from "@/app/di";
import { droppedSessions, leaves, splitOp } from "./layout";
import { leaves, splitOp } from "./layout";
/** What the layout grid UI needs from this hook. */
export interface LayoutViewModel {
@ -39,6 +39,12 @@ export interface LayoutViewModel {
setSession: (target: string, session: string | null) => Promise<void>;
/** Pins or clears an agent on a cell (persisted in layout). */
setCellAgent: (target: string, agent: string | null) => Promise<void>;
/** Shows an already-running agent session in a cell without respawning it. */
attachLiveAgentToCell: (
target: string,
agent: string,
session: string,
) => Promise<void>;
/**
* Records (or clears) the persistent CLI conversation id on a cell (T4b). Used
* to persist the id assigned at first launch so the next open resumes it.
@ -134,23 +140,12 @@ export function useLayout(
);
const merge = useCallback(
async (container: string, keepIndex: number) => {
// Closing a cell collapses its split onto the kept child; the dropped
// child's PTYs must be killed (not just detached) or they'd linger as
// orphan agent processes. Snapshot the sessions to drop BEFORE mutating
// (the tree no longer holds them afterwards), then tear them down.
const dropped = layout ? droppedSessions(layout, container, keepIndex) : [];
// Closing a cell is a VIEW operation: it collapses the layout, but the
// dropped cell's PTY keeps running. TerminalView cleanup detaches the local
// stream; the agent/session can later be re-opened in a cell via IdeA.
await mutate({ type: "merge", container, keepIndex });
if (terminal) {
for (const session of dropped) {
try {
await terminal.closeTerminal(session);
} catch {
/* already gone — ignore */
}
}
}
},
[layout, mutate, terminal],
[mutate],
);
const resize = useCallback(
(container: string, weights: number[]) =>
@ -169,8 +164,10 @@ export function useLayout(
const setCellAgent = useCallback(
async (target: string, agent: string | null) => {
// Find the cell's current agent + session. Changing the agent must reset
// the session (so the remounted TerminalView opens fresh for the new agent
// instead of reattaching to the old PTY) and kill the old PTY.
// the session so the remounted TerminalView opens/attaches for the new
// agent instead of reattaching to the old PTY. If the previous session was
// an agent session, it is only detached into the background; a cell is a
// view, not the process owner.
const cell = layout ? leaves(layout).find((l) => l.id === target) : undefined;
const oldAgent = cell?.agent ?? null;
const oldSession = cell?.session ?? null;
@ -184,8 +181,37 @@ export function useLayout(
{ type: "setCellConversation", target, conversationId: null },
]);
// Best-effort: tear down the previous PTY so no orphan process lingers.
if (oldSession && terminal) {
// Best-effort: tear down a previous plain terminal so no orphan shell
// lingers. Agent sessions keep running in the background.
if (oldSession && !oldAgent && terminal) {
try {
await terminal.closeTerminal(oldSession);
} catch {
/* already gone — ignore */
}
}
},
[layout, mutateChain, terminal],
);
const attachLiveAgentToCell = useCallback(
async (target: string, agent: string, session: string) => {
const cell = layout ? leaves(layout).find((l) => l.id === target) : undefined;
const oldAgent = cell?.agent ?? null;
const oldSession = cell?.session ?? null;
const operations: LayoutOperation[] = [];
if (agent !== oldAgent) {
operations.push(
{ type: "setCellAgent", target, agent },
{ type: "setCellConversation", target, conversationId: null },
);
}
if (session !== oldSession) {
operations.push({ type: "setSession", target, session });
}
await mutateChain(operations);
if (oldSession && oldSession !== session && !oldAgent && terminal) {
try {
await terminal.closeTerminal(oldSession);
} catch {
@ -212,6 +238,7 @@ export function useLayout(
move,
setSession,
setCellAgent,
attachLiveAgentToCell,
setCellConversation,
};
}