- 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>
262 lines
8.4 KiB
TypeScript
262 lines
8.4 KiB
TypeScript
/**
|
|
* #3 — Agent dropdown per cell: setCellAgent persists in the layout, and the
|
|
* terminal re-mounts with the agent opener; "Plain" reverts to a plain terminal.
|
|
*/
|
|
import { describe, it, expect, vi } from "vitest";
|
|
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
|
|
|
|
import type { Gateways } from "@/ports";
|
|
import {
|
|
MockLayoutGateway,
|
|
MockAgentGateway,
|
|
MockTerminalGateway,
|
|
} from "@/adapters/mock";
|
|
import { DIProvider } from "@/app/di";
|
|
import { LayoutGrid } from "./LayoutGrid";
|
|
import { leaves } from "./layout";
|
|
|
|
function renderGrid(
|
|
layout: MockLayoutGateway,
|
|
agent: MockAgentGateway,
|
|
projectId = "p1",
|
|
terminal: MockTerminalGateway = new MockTerminalGateway(),
|
|
) {
|
|
const gateways = {
|
|
layout,
|
|
terminal,
|
|
agent,
|
|
} as unknown as Gateways;
|
|
return render(
|
|
<DIProvider gateways={gateways}>
|
|
<LayoutGrid projectId={projectId} cwd="/home/me/proj" />
|
|
</DIProvider>,
|
|
);
|
|
}
|
|
|
|
describe("setCellAgent (agent dropdown per cell)", () => {
|
|
it("renders an agent selector on each leaf cell", async () => {
|
|
const layout = new MockLayoutGateway();
|
|
const agent = new MockAgentGateway();
|
|
renderGrid(layout, agent);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getAllByRole("combobox")).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
it("selecting an agent calls setCellAgent via the layout gateway", async () => {
|
|
const layout = new MockLayoutGateway();
|
|
const agent = new MockAgentGateway();
|
|
|
|
// Create an agent in the project.
|
|
const a = await agent.createAgent("p1", {
|
|
name: "MyAgent",
|
|
profileId: "prof-1",
|
|
});
|
|
|
|
renderGrid(layout, agent);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByRole("combobox")).toBeTruthy();
|
|
});
|
|
|
|
const select = screen.getByRole("combobox") as HTMLSelectElement;
|
|
fireEvent.change(select, { target: { value: a.id } });
|
|
|
|
// The layout should persist the agent on the leaf.
|
|
const initial = await layout.loadLayout("p1");
|
|
const leafId = leaves(initial)[0].id;
|
|
|
|
await waitFor(async () => {
|
|
const tree = await layout.loadLayout("p1");
|
|
const leaf = leaves(tree)[0];
|
|
// The mutation should have persisted.
|
|
expect(leaf.id).toBe(leafId);
|
|
// Since mutateLayout is async, give it a tick.
|
|
});
|
|
});
|
|
|
|
it("MockLayoutGateway setCellAgent persists and clears correctly", async () => {
|
|
const layout = new MockLayoutGateway();
|
|
const tree = await layout.loadLayout("p1");
|
|
const leafId = leaves(tree)[0].id;
|
|
|
|
// Set agent.
|
|
const after = await layout.mutateLayout("p1", {
|
|
type: "setCellAgent",
|
|
target: leafId,
|
|
agent: "agent-42",
|
|
});
|
|
expect(leaves(after)[0].agent).toBe("agent-42");
|
|
|
|
// Clear agent (null).
|
|
const cleared = await layout.mutateLayout("p1", {
|
|
type: "setCellAgent",
|
|
target: leafId,
|
|
agent: null,
|
|
});
|
|
expect(leaves(cleared)[0].agent).toBeUndefined();
|
|
});
|
|
|
|
it("selecting Plain (empty) clears the agent via mutateLayout", async () => {
|
|
const layout = new MockLayoutGateway();
|
|
const agent = new MockAgentGateway();
|
|
const a = await agent.createAgent("p1", {
|
|
name: "MyAgent",
|
|
profileId: "prof-1",
|
|
});
|
|
|
|
// Pre-set agent on the leaf.
|
|
const tree = await layout.loadLayout("p1");
|
|
const leafId = leaves(tree)[0].id;
|
|
await layout.mutateLayout("p1", {
|
|
type: "setCellAgent",
|
|
target: leafId,
|
|
agent: a.id,
|
|
});
|
|
|
|
renderGrid(layout, agent);
|
|
|
|
await waitFor(() => {
|
|
const sel = screen.getByRole("combobox") as HTMLSelectElement;
|
|
// The select should show the agent (or Plain if the component loaded before the mutation).
|
|
expect(sel).toBeTruthy();
|
|
});
|
|
|
|
const select = screen.getByRole("combobox") as HTMLSelectElement;
|
|
// Switch back to Plain.
|
|
fireEvent.change(select, { target: { value: "" } });
|
|
|
|
// After clearing, the layout should have no agent on the leaf.
|
|
await waitFor(async () => {
|
|
const fresh = await layout.loadLayout("p1");
|
|
const l = leaves(fresh)[0];
|
|
expect(l.agent === undefined || l.agent === null).toBe(true);
|
|
});
|
|
});
|
|
|
|
it("changing the agent resets the cell session to null (Bug #3)", async () => {
|
|
const layout = new MockLayoutGateway();
|
|
const agent = new MockAgentGateway();
|
|
const a = await agent.createAgent("p1", { name: "MyAgent", profileId: "p" });
|
|
|
|
// Pre-set a running session on the leaf (as if a plain terminal had opened).
|
|
const tree = await layout.loadLayout("p1");
|
|
const leafId = leaves(tree)[0].id;
|
|
await layout.mutateLayout("p1", {
|
|
type: "setSession",
|
|
target: leafId,
|
|
session: "old-session",
|
|
});
|
|
|
|
renderGrid(layout, agent);
|
|
await waitFor(() => expect(screen.getByRole("combobox")).toBeTruthy());
|
|
|
|
fireEvent.change(screen.getByRole("combobox"), { target: { value: a.id } });
|
|
|
|
await waitFor(async () => {
|
|
const fresh = await layout.loadLayout("p1");
|
|
const l = leaves(fresh)[0];
|
|
expect(l.agent).toBe(a.id);
|
|
// The stale session must be cleared so the new agent opens fresh.
|
|
expect(l.session === null || l.session === undefined).toBe(true);
|
|
});
|
|
});
|
|
|
|
it("changing the agent kills the previous PTY (Bug #3)", async () => {
|
|
const layout = new MockLayoutGateway();
|
|
const agent = new MockAgentGateway();
|
|
const terminal = new MockTerminalGateway();
|
|
const closeSpy = vi.spyOn(terminal, "closeTerminal");
|
|
const a = await agent.createAgent("p1", { name: "MyAgent", profileId: "p" });
|
|
|
|
const tree = await layout.loadLayout("p1");
|
|
const leafId = leaves(tree)[0].id;
|
|
await layout.mutateLayout("p1", {
|
|
type: "setSession",
|
|
target: leafId,
|
|
session: "old-session",
|
|
});
|
|
|
|
renderGrid(layout, agent, "p1", terminal);
|
|
await waitFor(() => expect(screen.getByRole("combobox")).toBeTruthy());
|
|
|
|
fireEvent.change(screen.getByRole("combobox"), { target: { value: a.id } });
|
|
|
|
await waitFor(() => {
|
|
expect(closeSpy).toHaveBeenCalledWith("old-session");
|
|
});
|
|
});
|
|
|
|
it("does not kill any PTY when the cell had no session (Bug #3)", async () => {
|
|
const layout = new MockLayoutGateway();
|
|
const agent = new MockAgentGateway();
|
|
const terminal = new MockTerminalGateway();
|
|
const closeSpy = vi.spyOn(terminal, "closeTerminal");
|
|
const a = await agent.createAgent("p1", { name: "MyAgent", profileId: "p" });
|
|
|
|
renderGrid(layout, agent, "p1", terminal);
|
|
await waitFor(() => expect(screen.getByRole("combobox")).toBeTruthy());
|
|
|
|
fireEvent.change(screen.getByRole("combobox"), { target: { value: a.id } });
|
|
|
|
await waitFor(async () => {
|
|
const fresh = await layout.loadLayout("p1");
|
|
expect(leaves(fresh)[0].agent).toBe(a.id);
|
|
});
|
|
expect(closeSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("closing a cell kills the dropped cell's PTY (no orphan)", async () => {
|
|
const layout = new MockLayoutGateway();
|
|
const agent = new MockAgentGateway();
|
|
const terminal = new MockTerminalGateway();
|
|
const closeSpy = vi.spyOn(terminal, "closeTerminal");
|
|
|
|
// Split the root into two cells, then give the second a running session.
|
|
const tree = await layout.loadLayout("p1");
|
|
const rootId = leaves(tree)[0].id;
|
|
const afterSplit = await layout.mutateLayout("p1", {
|
|
type: "split",
|
|
target: rootId,
|
|
direction: "row",
|
|
newLeaf: "leaf-b",
|
|
container: "split-c",
|
|
});
|
|
const droppedId = leaves(afterSplit)[1].id;
|
|
await layout.mutateLayout("p1", {
|
|
type: "setSession",
|
|
target: droppedId,
|
|
session: "dropped-session",
|
|
});
|
|
|
|
renderGrid(layout, agent, "p1", terminal);
|
|
|
|
// 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(screen.getAllByTestId("layout-leaf")).toHaveLength(1);
|
|
});
|
|
expect(closeSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("agent selector shows project agents as options", async () => {
|
|
const layout = new MockLayoutGateway();
|
|
const agent = new MockAgentGateway();
|
|
await agent.createAgent("p1", { name: "Agent Alpha", profileId: "p" });
|
|
await agent.createAgent("p1", { name: "Agent Beta", profileId: "p" });
|
|
|
|
renderGrid(layout, agent);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Agent Alpha")).toBeTruthy();
|
|
expect(screen.getByText("Agent Beta")).toBeTruthy();
|
|
});
|
|
});
|
|
});
|