Files
IdeA/frontend/src/features/layout/LayoutGrid.chat.test.tsx

950 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Ticket #147 — custom CLI mode for structured/headless agent cells.
*
* Plain cells and PTY-only profiles stay on the native TUI. Structured profiles
* get a per-cell toggle; switching a live session requires confirmation, then
* the custom chat view drives `ReplyChunk` streams defensively.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
vi.mock("@xterm/xterm", () => ({
Terminal: class {
loadAddon() {}
open() {}
onData() {
return { dispose() {} };
}
onResize() {
return { dispose() {} };
}
write() {}
dispose() {}
get cols() {
return 80;
}
get rows() {
return 24;
}
},
}));
vi.mock("@xterm/addon-fit", () => ({
FitAddon: class {
fit() {}
},
}));
vi.mock("@xterm/xterm/css/xterm.css", () => ({}));
if (typeof globalThis.ResizeObserver === "undefined") {
globalThis.ResizeObserver = class {
observe() {}
unobserve() {}
disconnect() {}
} as unknown as typeof ResizeObserver;
}
import type { AgentProfile } from "@/domain";
import type { Gateways } from "@/ports";
import {
MockAgentGateway,
MockConversationGateway,
MockLayoutGateway,
MockProfileGateway,
MockSystemGateway,
MockTerminalGateway,
MockWorkStateGateway,
} from "@/adapters/mock";
import { DIProvider } from "@/app/di";
import { leaves } from "./layout";
import { LayoutGrid } from "./LayoutGrid";
const structuredProfile: AgentProfile = {
id: "mock-structured",
name: "Structured Codex",
command: "codex",
args: [],
contextInjection: { strategy: "conventionFile", target: "AGENTS.md" },
detect: null,
cwdTemplate: "{projectRoot}",
structuredAdapter: "codex",
};
const ptyProfile: AgentProfile = {
id: "mock-pty",
name: "Plain PTY",
command: "bash",
args: [],
contextInjection: { strategy: "conventionFile", target: "AGENTS.md" },
detect: null,
cwdTemplate: "{projectRoot}",
};
function deferred<T>() {
let resolve!: (value: T | PromiseLike<T>) => void;
const promise = new Promise<T>((res) => {
resolve = res;
});
return { promise, resolve };
}
beforeEach(() => {
window.localStorage.clear();
});
async function seeded(profile: AgentProfile): Promise<Gateways> {
const layout = new MockLayoutGateway();
const agent = new MockAgentGateway();
const profileGateway = new MockProfileGateway();
const terminal = new MockTerminalGateway();
const system = new MockSystemGateway();
const workState = new MockWorkStateGateway();
const conversation = new MockConversationGateway();
await profileGateway.configureProfiles([profile]);
const created = await agent.createAgent("p1", {
name: "Worker",
profileId: profile.id,
});
const tree = await layout.loadLayout("p1");
const leafId = leaves(tree)[0].id;
await layout.mutateLayout("p1", {
type: "setCellAgent",
target: leafId,
agent: created.id,
});
return {
layout,
agent,
profile: profileGateway,
terminal,
system,
workState,
conversation,
} as unknown as Gateways;
}
function renderGrid(gateways: Gateways, projectId = "p1") {
const cwd = projectId === "p1" ? "/home/me/proj" : `/home/me/${projectId}`;
return render(
<DIProvider gateways={gateways}>
<LayoutGrid projectId={projectId} cwd={cwd} />
</DIProvider>,
);
}
describe("LayoutGrid custom agent CLI (#147)", () => {
it("does not show the custom CLI toggle in a plain cell", async () => {
const gateways = {
layout: new MockLayoutGateway(),
agent: new MockAgentGateway(),
profile: new MockProfileGateway(),
terminal: new MockTerminalGateway(),
system: new MockSystemGateway(),
} as unknown as Gateways;
renderGrid(gateways);
await waitFor(() => expect(screen.getByTestId("layout-leaf")).toBeTruthy());
expect(screen.queryByText("CLI custom")).toBeNull();
expect(screen.getByTestId("terminal-view")).toBeTruthy();
});
it("does not show the custom CLI toggle for a PTY-only profile", async () => {
renderGrid(await seeded(ptyProfile));
await waitFor(() => expect(screen.getByTestId("layout-leaf")).toBeTruthy());
expect(screen.queryByText("CLI custom")).toBeNull();
expect(screen.getByTestId("terminal-view")).toBeTruthy();
});
it("shows the custom CLI toggle for structured profiles and streams a final callout", async () => {
renderGrid(await seeded(structuredProfile));
await waitFor(() => expect(screen.getByText("CLI custom")).toBeTruthy());
expect(screen.getByTestId("terminal-view")).toBeTruthy();
fireEvent.click(screen.getByText("CLI custom"));
await waitFor(() =>
expect(screen.getByRole("alertdialog", { name: "Confirmer le changement de CLI" })).toBeTruthy(),
);
fireEvent.click(screen.getByText("Basculer"));
await waitFor(() =>
expect(screen.getByTestId("custom-agent-chat-view")).toBeTruthy(),
);
fireEvent.click(screen.getByText("Joindre"));
await waitFor(() =>
expect(screen.getByText(/mock-attachment.txt/)).toBeTruthy(),
);
fireEvent.change(screen.getByLabelText(/Message CLI personnalisée/), {
target: { value: "hello there" },
});
await waitFor(() =>
expect(screen.getByRole("button", { name: "Envoyer" }).hasAttribute("disabled")).toBe(false),
);
fireEvent.click(screen.getByText("Envoyer"));
await waitFor(() => expect(screen.getByText("Terminé")).toBeTruthy());
expect(screen.getAllByText(/Agent: reçu/).length).toBeGreaterThan(0);
expect(screen.getAllByText(/hello there/).length).toBeGreaterThan(0);
expect(screen.getAllByText(/mock-attachment.txt/).length).toBeGreaterThan(0);
});
it("restores preferredView=chat ahead of stale localStorage without a resume warning", async () => {
const gateways = await seeded(structuredProfile);
const layout = gateways.layout as MockLayoutGateway;
const tree = await layout.loadLayout("p1");
const leafId = leaves(tree)[0].id;
await layout.mutateLayout("p1", {
type: "setCellPreferredView",
target: leafId,
preferredView: "chat",
});
window.localStorage.setItem(`idea.agent-cell-mode.p1.${leafId}`, "tui");
renderGrid(gateways);
expect(await screen.findByTestId("custom-agent-chat-view")).toBeTruthy();
expect(screen.queryByRole("alertdialog", { name: "Confirmer le changement de CLI" }))
.toBeNull();
expect(screen.queryByText(/Aucune conversation reprenable/)).toBeNull();
expect(window.localStorage.getItem(`idea.agent-cell-mode.p1.${leafId}`)).toBe("custom");
});
it("restores durable messages after project A → B → A without starting a provider", async () => {
const layout = new MockLayoutGateway();
const agent = new MockAgentGateway();
const profileGateway = new MockProfileGateway();
const terminal = new MockTerminalGateway();
const system = new MockSystemGateway();
const workState = new MockWorkStateGateway();
const conversation = new MockConversationGateway();
await profileGateway.configureProfiles([structuredProfile]);
const agentA = await agent.createAgent("p1", {
name: "Worker A",
profileId: structuredProfile.id,
});
const treeA = await layout.loadLayout("p1");
const leafA = leaves(treeA)[0];
await layout.mutateLayout("p1", {
type: "setCellAgent",
target: leafA.id,
agent: agentA.id,
});
await layout.mutateLayout("p1", {
type: "setCellPreferredView",
target: leafA.id,
preferredView: "chat",
});
await layout.mutateLayout("p1", {
type: "setCellConversation",
target: leafA.id,
conversationId: "saved-a",
});
conversation._setTurns("p1", "saved-a", [
{ id: "p1", atMs: 1, role: "prompt", source: { kind: "human" }, text: "Question projet A", textLen: 17 },
{ id: "r1", atMs: 2, role: "response", source: { kind: "agent", agentId: agentA.id }, text: "Réponse projet A", textLen: 16 },
]);
const agentB = await agent.createAgent("p2", {
name: "Worker B",
profileId: structuredProfile.id,
});
const treeB = await layout.loadLayout("p2");
const leafB = leaves(treeB)[0];
await layout.mutateLayout("p2", {
type: "setCellAgent",
target: leafB.id,
agent: agentB.id,
});
const gateways = {
layout,
agent,
profile: profileGateway,
terminal,
system,
workState,
conversation,
} as unknown as Gateways;
const launchChat = vi.spyOn(agent, "launchAgentChat");
const view = renderGrid(gateways, "p1");
expect(await screen.findByTestId("custom-agent-chat-view")).toBeTruthy();
expect(await screen.findByText("Question projet A")).toBeTruthy();
expect(screen.getByText("Réponse projet A")).toBeTruthy();
const persistedA = leaves(await layout.loadLayout("p1"))[0];
view.rerender(
<DIProvider gateways={gateways}>
<LayoutGrid projectId="p2" cwd="/home/me/p2" />
</DIProvider>,
);
await waitFor(() => expect(screen.queryByTestId("custom-agent-chat-view")).toBeNull());
view.rerender(
<DIProvider gateways={gateways}>
<LayoutGrid projectId="p1" cwd="/home/me/proj" />
</DIProvider>,
);
expect(await screen.findByTestId("custom-agent-chat-view")).toBeTruthy();
expect(screen.queryByTestId("terminal-view")).toBeNull();
expect(screen.queryByRole("alertdialog", { name: "Confirmer le changement de CLI" }))
.toBeNull();
expect(screen.queryByText(/Aucune conversation reprenable/)).toBeNull();
expect(await screen.findByText("Question projet A")).toBeTruthy();
expect(screen.getByText("Réponse projet A")).toBeTruthy();
const restoredA = leaves(await layout.loadLayout("p1"))[0];
expect(restoredA.preferredView).toBe("chat");
expect(restoredA.session).toBe(persistedA.session);
expect(restoredA.conversationId).toBe(persistedA.conversationId);
expect(launchChat).not.toHaveBeenCalled();
});
it("keeps custom CLI and composer active while busy, then queues without sending (#247/#352)", async () => {
const gateways = await seeded(structuredProfile);
const layout = gateways.layout as MockLayoutGateway;
const agent = gateways.agent as MockAgentGateway;
const sendAgentChat = vi.spyOn(agent, "sendAgentChat");
const workState = gateways.workState as MockWorkStateGateway;
const tree = await layout.loadLayout("p1");
const leaf = leaves(tree)[0];
const agentId = leaf.agent;
if (!agentId) throw new Error("expected seeded agent");
window.localStorage.setItem(`idea.agent-cell-mode.p1.${leaf.id}`, "custom");
workState._setProjectWorkState("p1", {
agents: [
{
agentId,
name: "Worker",
profileId: structuredProfile.id,
busy: { state: "busy", ticket: "ticket-232", sinceMs: 1 },
tickets: [
{
ticketId: "ticket-232",
conversationId: "conversation-232",
position: 0,
status: "inProgress",
source: { kind: "agent", agentId: "main" },
requesterLabel: "Main",
taskPreview: "Analyse",
taskLen: 7,
},
],
},
],
conversations: [],
});
renderGrid(gateways);
expect(await screen.findByTestId("custom-agent-chat-view")).toBeTruthy();
expect(screen.queryByTestId("target-announcements-overlay")).toBeNull();
expect(screen.queryByText("Busy · Main")).toBeNull();
expect(screen.getByLabelText(`Sélecteur d’agent ${leaf.id}`)).toBeTruthy();
expect(screen.queryByRole("button", {
name: `cancel current turn ${leaf.id}`,
})).toBeNull();
const composer = await screen.findByLabelText(/Message CLI personnalisée/);
await waitFor(() => expect(composer).toHaveProperty("disabled", false));
expect(screen.getByRole("button", { name: "Stop" })).toBeTruthy();
const queueAction = screen.getByRole("button", { name: "Mettre en attente" });
fireEvent.change(composer, { target: { value: "Question suivante" } });
fireEvent.click(queueAction);
expect(await screen.findByText("Message ajouté à la file d’attente")).toBeTruthy();
expect(screen.getByRole("button", { name: "En attente · 1" })).toBeTruthy();
expect((composer as HTMLTextAreaElement).value).toBe("");
expect(sendAgentChat).not.toHaveBeenCalled();
});
it("does not render requester announcements without a live attached session (#366)", async () => {
const gateways = await seeded(structuredProfile);
const layout = gateways.layout as MockLayoutGateway;
const workState = gateways.workState as MockWorkStateGateway;
const tree = await layout.loadLayout("p1");
const leaf = leaves(tree)[0];
const requesterId = leaf.agent;
if (!requesterId) throw new Error("expected seeded requester");
window.localStorage.setItem(`idea.agent-cell-mode.p1.${leaf.id}`, "custom");
workState._setProjectWorkState("p1", {
agents: [
{
agentId: requesterId,
name: "Worker",
profileId: structuredProfile.id,
busy: { state: "idle" },
tickets: [],
},
{
agentId: "target-agent",
name: "Architect",
profileId: structuredProfile.id,
busy: { state: "busy", ticket: "ticket-232", sinceMs: 1 },
tickets: [
{
ticketId: "ticket-232",
conversationId: "conversation-232",
position: 0,
status: "inProgress",
source: { kind: "agent", agentId: requesterId },
requesterLabel: "Worker",
taskPreview: "Répondre",
taskLen: 8,
},
],
},
],
conversations: [],
});
const view = renderGrid(gateways);
expect(await screen.findByTestId("custom-agent-chat-view")).toBeTruthy();
expect(screen.queryByText("Worker attend Architect")).toBeNull();
view.unmount();
renderGrid(gateways);
expect(await screen.findByTestId("custom-agent-chat-view")).toBeTruthy();
expect(screen.queryByText("Worker attend Architect")).toBeNull();
});
it("persists custom CLI composer height per layout cell", async () => {
const rectSpy = vi
.spyOn(HTMLElement.prototype, "getBoundingClientRect")
.mockReturnValue({
x: 0,
y: 0,
width: 600,
height: 400,
top: 0,
right: 600,
bottom: 400,
left: 0,
toJSON: () => {},
} as DOMRect);
const gateways = await seeded(structuredProfile);
const layout = gateways.layout as MockLayoutGateway;
const tree = await layout.loadLayout("p1");
const leafId = leaves(tree)[0].id;
window.localStorage.setItem(`idea.agent-cell-mode.p1.${leafId}`, "custom");
try {
renderGrid(gateways);
const composer = await screen.findByLabelText(/Message CLI personnalisée/) as HTMLTextAreaElement;
const handle = screen.getByRole("button", {
name: `Redimensionner l’éditeur ${leafId}`,
});
expect(composer.style.height).toBe("64px");
expect(window.localStorage.getItem(`idea.custom-chat-composer-height.p1.${leafId}`))
.toBeNull();
fireEvent.mouseDown(handle, { clientY: 200 });
fireEvent.mouseMove(window, { clientY: 100 });
fireEvent.mouseUp(window);
await waitFor(() => expect(composer.style.height).toBe("164px"));
expect(window.localStorage.getItem(`idea.custom-chat-composer-height.p1.${leafId}`))
.toBe("164");
} finally {
rectSpy.mockRestore();
}
});
it("does not attach an unrelated structured background session implicitly (#169/#366)", async () => {
const layout = new MockLayoutGateway();
const agent = new MockAgentGateway();
const profileGateway = new MockProfileGateway();
const terminal = new MockTerminalGateway();
const system = new MockSystemGateway();
await profileGateway.configureProfiles([structuredProfile]);
const created = await agent.createAgent("p1", {
name: "Worker",
profileId: structuredProfile.id,
});
const tree = await layout.loadLayout("p1");
const leafId = leaves(tree)[0].id;
await layout.mutateLayout("p1", {
type: "setCellAgent",
target: leafId,
agent: created.id,
});
window.localStorage.setItem(`idea.agent-cell-mode.p1.${leafId}`, "custom");
await agent.launchAgentChat("p1", created.id, {
cwd: "/home/me/proj",
rows: 24,
cols: 80,
nodeId: "structured-background",
});
const launchChat = vi.spyOn(agent, "launchAgentChat");
renderGrid({
layout,
agent,
profile: profileGateway,
terminal,
system,
} as unknown as Gateways);
await waitFor(() =>
expect(screen.getByTestId("custom-agent-chat-view")).toBeTruthy(),
);
expect(await screen.findByText("Nouvelle conversation")).toBeTruthy();
expect(launchChat).not.toHaveBeenCalled();
expect(screen.queryByText(/déjà actif dans une autre cellule/)).toBeNull();
});
it("restores a saved custom CLI conversation when returning without a live session (#366)", async () => {
const layout = new MockLayoutGateway();
const agent = new MockAgentGateway();
const profileGateway = new MockProfileGateway();
const terminal = new MockTerminalGateway();
const system = new MockSystemGateway();
const conversation = new MockConversationGateway();
await profileGateway.configureProfiles([structuredProfile]);
const created = await agent.createAgent("p1", {
name: "Worker",
profileId: structuredProfile.id,
});
const tree = await layout.loadLayout("p1");
const leafId = leaves(tree)[0].id;
await layout.mutateLayout("p1", {
type: "setCellAgent",
target: leafId,
agent: created.id,
});
await layout.mutateLayout("p1", {
type: "setCellConversation",
target: leafId,
conversationId: "conversation-known",
});
window.localStorage.setItem(`idea.agent-cell-mode.p1.${leafId}`, "custom");
conversation._setTurns("p1", "conversation-known", [
{ id: "p1", atMs: 1, role: "prompt", source: { kind: "human" }, text: "known prompt", textLen: 12 },
{ id: "r1", atMs: 2, role: "response", source: { kind: "agent", agentId: created.id }, text: "known response", textLen: 14 },
]);
const launchChat = vi.spyOn(agent, "launchAgentChat").mockResolvedValue({
sessionId: "chat-fresh",
cellKind: "chat" as const,
assignedConversationId: "conversation-fresh",
});
const reattachChat = vi.spyOn(agent, "reattachAgentChat").mockResolvedValue({
sessionId: "chat-fresh",
scrollback: [],
});
renderGrid({
layout,
agent,
profile: profileGateway,
terminal,
system,
conversation,
} as unknown as Gateways);
expect(await screen.findByText("known prompt")).toBeTruthy();
expect(screen.getByText("known response")).toBeTruthy();
expect(screen.queryByText("Nouvelle conversation")).toBeNull();
expect(launchChat).not.toHaveBeenCalled();
expect(reattachChat).not.toHaveBeenCalled();
const updated = await layout.loadLayout("p1");
expect(leaves(updated)[0].conversationId).toBe("conversation-known");
expect(leaves(updated)[0].session).toBeNull();
});
it("relaunches a stopped custom CLI agent from scratch (#197)", async () => {
const layout = new MockLayoutGateway();
const agent = new MockAgentGateway();
const profileGateway = new MockProfileGateway();
const terminal = new MockTerminalGateway();
const system = new MockSystemGateway();
const workState = new MockWorkStateGateway();
await profileGateway.configureProfiles([structuredProfile]);
const created = await agent.createAgent("p1", {
name: "Worker",
profileId: structuredProfile.id,
});
const tree = await layout.loadLayout("p1");
const leafId = leaves(tree)[0].id;
await layout.mutateLayout("p1", {
type: "setCellAgent",
target: leafId,
agent: created.id,
});
await layout.mutateLayout("p1", {
type: "setSession",
target: leafId,
session: "dead-structured-session",
});
await layout.mutateLayout("p1", {
type: "setCellConversation",
target: leafId,
conversationId: "old-structured-conversation",
});
window.localStorage.setItem(`idea.agent-cell-mode.p1.${leafId}`, "custom");
workState._setProjectWorkState("p1", {
agents: [
{
agentId: created.id,
name: "Worker",
profileId: structuredProfile.id,
runtimeState: "stopped",
busy: { state: "idle" },
},
],
});
const launchChat = vi.spyOn(agent, "launchAgentChat");
renderGrid({
layout,
agent,
profile: profileGateway,
terminal,
system,
workState,
} as unknown as Gateways);
fireEvent.click(await screen.findByRole("button", { name: "Relancer" }));
await waitFor(() => expect(launchChat).toHaveBeenCalledTimes(1));
expect(launchChat.mock.calls[0]?.[2]).toMatchObject({
cwd: "/home/me/proj",
nodeId: leafId,
freshStart: true,
});
expect(launchChat.mock.calls[0]?.[2].conversationId).toBeUndefined();
await waitFor(async () => {
const updated = await layout.loadLayout("p1");
const leaf = leaves(updated)[0];
expect(leaf.session).toMatch(/^mock-agent-chat-/);
expect(leaf.conversationId).toMatch(/^mock-conversation-/);
});
});
it("closes the structured chat session by id before switching back to native TUI (#170)", async () => {
const layout = new MockLayoutGateway();
const agent = new MockAgentGateway();
const profileGateway = new MockProfileGateway();
const terminal = new MockTerminalGateway();
const system = new MockSystemGateway();
await profileGateway.configureProfiles([structuredProfile]);
const created = await agent.createAgent("p1", {
name: "Worker",
profileId: structuredProfile.id,
});
const tree = await layout.loadLayout("p1");
const leafId = leaves(tree)[0].id;
await layout.mutateLayout("p1", {
type: "setCellAgent",
target: leafId,
agent: created.id,
});
window.localStorage.setItem(`idea.agent-cell-mode.p1.${leafId}`, "custom");
const closeChat = vi.spyOn(agent, "closeAgentChat");
const stopLive = vi.spyOn(agent, "stopLiveAgent");
renderGrid({
layout,
agent,
profile: profileGateway,
terminal,
system,
} as unknown as Gateways);
await waitFor(() =>
expect(screen.getByTestId("custom-agent-chat-view")).toBeTruthy(),
);
fireEvent.change(screen.getByLabelText(/Message CLI personnalisée/), {
target: { value: "Start live chat" },
});
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
await waitFor(async () => {
const updated = await layout.loadLayout("p1");
expect(leaves(updated)[0].session).toMatch(/^mock-agent-chat-/);
});
fireEvent.click(screen.getByText("TUI native"));
await waitFor(() =>
expect(screen.getByRole("alertdialog", { name: "Confirmer le changement de CLI" })).toBeTruthy(),
);
fireEvent.click(screen.getByText("Basculer"));
await waitFor(() =>
expect(closeChat).toHaveBeenCalledWith(expect.stringMatching(/^mock-agent-chat-/)),
);
expect(stopLive).not.toHaveBeenCalled();
await waitFor(() => expect(screen.getByTestId("terminal-view")).toBeTruthy());
const updated = await layout.loadLayout("p1");
expect(leaves(updated)[0].preferredView).toBeUndefined();
expect(window.localStorage.getItem(`idea.agent-cell-mode.p1.${leafId}`)).toBe("tui");
});
it("switches a live native TUI session to custom CLI without stopping it first (#189)", async () => {
const gateways = await seeded(structuredProfile);
const agent = gateways.agent as MockAgentGateway;
const terminal = gateways.terminal as MockTerminalGateway;
const layout = gateways.layout as MockLayoutGateway;
const leafId = leaves(await layout.loadLayout("p1"))[0].id;
const stopLive = vi.spyOn(agent, "stopLiveAgent");
const closeTerminal = vi.spyOn(terminal, "closeTerminal");
const launchChat = vi.spyOn(agent, "launchAgentChat");
renderGrid(gateways);
await waitFor(() => expect(screen.getByText("CLI custom")).toBeTruthy());
await waitFor(() =>
expect(screen.getByTestId("terminal-view")).toBeTruthy(),
);
await waitFor(async () => {
const updated = await layout.loadLayout("p1");
expect(leaves(updated)[0].session).toMatch(/^mock-agent-session-/);
});
fireEvent.click(screen.getByText("CLI custom"));
await waitFor(() =>
expect(screen.getByRole("alertdialog", { name: "Confirmer le changement de CLI" })).toBeTruthy(),
);
expect(screen.getByText(/Une reprise est possible/)).toBeTruthy();
fireEvent.click(screen.getByText("Basculer"));
await waitFor(() =>
expect(screen.getByTestId("custom-agent-chat-view")).toBeTruthy(),
);
expect(stopLive).not.toHaveBeenCalled();
expect(closeTerminal).not.toHaveBeenCalled();
fireEvent.change(screen.getByLabelText(/Message CLI personnalisée/), {
target: { value: "Start structured chat" },
});
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
await waitFor(() => expect(launchChat).toHaveBeenCalled());
expect(launchChat.mock.calls.at(-1)?.[2]).toMatchObject({ nodeId: leafId });
await waitFor(async () => {
const updated = await layout.loadLayout("p1");
expect(leaves(updated)[0].session).toMatch(/^mock-agent-chat-/);
});
});
it("keeps a restored custom CLI mode while the agent/profile catalog is still loading (#149)", async () => {
const layout = new MockLayoutGateway();
const agent = new MockAgentGateway();
const profileGateway = new MockProfileGateway();
const terminal = new MockTerminalGateway();
const system = new MockSystemGateway();
await profileGateway.configureProfiles([structuredProfile]);
const created = await agent.createAgent("p1", {
name: "Worker",
profileId: structuredProfile.id,
});
const tree = await layout.loadLayout("p1");
const leafId = leaves(tree)[0].id;
await layout.mutateLayout("p1", {
type: "setCellAgent",
target: leafId,
agent: created.id,
});
window.localStorage.setItem(`idea.agent-cell-mode.p1.${leafId}`, "custom");
const agentsLoaded = deferred<void>();
const profilesLoaded = deferred<void>();
const originalListAgents = agent.listAgents.bind(agent);
const originalListProfiles = profileGateway.listProfiles.bind(profileGateway);
vi.spyOn(agent, "listAgents").mockImplementation(async (projectId) => {
await agentsLoaded.promise;
return originalListAgents(projectId);
});
vi.spyOn(profileGateway, "listProfiles").mockImplementation(async () => {
await profilesLoaded.promise;
return originalListProfiles();
});
renderGrid({
layout,
agent,
profile: profileGateway,
terminal,
system,
} as unknown as Gateways);
await waitFor(() => expect(screen.getByTestId("layout-leaf")).toBeTruthy());
expect(window.localStorage.getItem(`idea.agent-cell-mode.p1.${leafId}`)).toBe("custom");
expect(screen.queryByTestId("custom-agent-chat-view")).toBeNull();
agentsLoaded.resolve();
profilesLoaded.resolve();
await waitFor(() =>
expect(screen.getByTestId("custom-agent-chat-view")).toBeTruthy(),
);
expect(window.localStorage.getItem(`idea.agent-cell-mode.p1.${leafId}`)).toBe("custom");
});
it("keeps restored custom CLI mode until a stale catalog refresh sees the agent/profile (#149)", async () => {
const layout = new MockLayoutGateway();
const agent = new MockAgentGateway();
const profileGateway = new MockProfileGateway();
const terminal = new MockTerminalGateway();
const system = new MockSystemGateway();
await profileGateway.configureProfiles([structuredProfile]);
const created = await agent.createAgent("p1", {
name: "Worker",
profileId: structuredProfile.id,
});
const tree = await layout.loadLayout("p1");
const leafId = leaves(tree)[0].id;
await layout.mutateLayout("p1", {
type: "setCellAgent",
target: leafId,
agent: created.id,
});
window.localStorage.setItem(`idea.agent-cell-mode.p1.${leafId}`, "custom");
let staleCatalog = true;
const originalListAgents = agent.listAgents.bind(agent);
const originalListProfiles = profileGateway.listProfiles.bind(profileGateway);
vi.spyOn(agent, "listAgents").mockImplementation(async (projectId) => {
if (staleCatalog) return [];
return originalListAgents(projectId);
});
vi.spyOn(profileGateway, "listProfiles").mockImplementation(async () => {
if (staleCatalog) return [];
return originalListProfiles();
});
renderGrid({
layout,
agent,
profile: profileGateway,
terminal,
system,
} as unknown as Gateways);
await waitFor(() => expect(screen.getByTestId("layout-leaf")).toBeTruthy());
await waitFor(() => expect(agent.listAgents).toHaveBeenCalled());
expect(window.localStorage.getItem(`idea.agent-cell-mode.p1.${leafId}`)).toBe("custom");
expect(screen.queryByTestId("custom-agent-chat-view")).toBeNull();
expect(screen.getByTestId("custom-agent-chat-unavailable")).toBeTruthy();
staleCatalog = false;
system.emit({
type: "agentProfileChanged",
agentId: created.id,
profileId: structuredProfile.id,
});
await waitFor(() =>
expect(screen.getByTestId("custom-agent-chat-view")).toBeTruthy(),
);
expect(window.localStorage.getItem(`idea.agent-cell-mode.p1.${leafId}`)).toBe("custom");
});
it("keeps the custom preference and shows a non-destructive state when the profile is incompatible", async () => {
const layout = new MockLayoutGateway();
const agent = new MockAgentGateway();
const profileGateway = new MockProfileGateway();
const terminal = new MockTerminalGateway();
const system = new MockSystemGateway();
await profileGateway.configureProfiles([ptyProfile]);
const created = await agent.createAgent("p1", {
name: "Worker",
profileId: ptyProfile.id,
});
const tree = await layout.loadLayout("p1");
const leafId = leaves(tree)[0].id;
await layout.mutateLayout("p1", {
type: "setCellAgent",
target: leafId,
agent: created.id,
});
window.localStorage.setItem(`idea.agent-cell-mode.p1.${leafId}`, "custom");
const agentsLoaded = deferred<void>();
const profilesLoaded = deferred<void>();
const originalListAgents = agent.listAgents.bind(agent);
const originalListProfiles = profileGateway.listProfiles.bind(profileGateway);
vi.spyOn(agent, "listAgents").mockImplementation(async (projectId) => {
await agentsLoaded.promise;
return originalListAgents(projectId);
});
vi.spyOn(profileGateway, "listProfiles").mockImplementation(async () => {
await profilesLoaded.promise;
return originalListProfiles();
});
renderGrid({
layout,
agent,
profile: profileGateway,
terminal,
system,
} as unknown as Gateways);
await waitFor(() => expect(screen.getByTestId("layout-leaf")).toBeTruthy());
expect(window.localStorage.getItem(`idea.agent-cell-mode.p1.${leafId}`)).toBe("custom");
agentsLoaded.resolve();
profilesLoaded.resolve();
await waitFor(() =>
expect(screen.getByTestId("custom-agent-chat-unavailable")).toBeTruthy(),
);
expect(window.localStorage.getItem(`idea.agent-cell-mode.p1.${leafId}`)).toBe("custom");
const updated = await layout.loadLayout("p1");
expect(leaves(updated)[0].preferredView).toBe("chat");
expect(screen.queryByTestId("terminal-view")).toBeNull();
expect(screen.queryByTestId("custom-agent-chat-view")).toBeNull();
});
it("does not overwrite the custom preference after a profile-change downgrade", async () => {
const layout = new MockLayoutGateway();
const agent = new MockAgentGateway();
const profileGateway = new MockProfileGateway();
const terminal = new MockTerminalGateway();
const system = new MockSystemGateway();
await profileGateway.configureProfiles([structuredProfile, ptyProfile]);
const created = await agent.createAgent("p1", {
name: "Worker",
profileId: structuredProfile.id,
});
const tree = await layout.loadLayout("p1");
const leafId = leaves(tree)[0].id;
await layout.mutateLayout("p1", {
type: "setCellAgent",
target: leafId,
agent: created.id,
});
window.localStorage.setItem(`idea.agent-cell-mode.p1.${leafId}`, "custom");
renderGrid({
layout,
agent,
profile: profileGateway,
terminal,
system,
} as unknown as Gateways);
await waitFor(() =>
expect(screen.getByTestId("custom-agent-chat-view")).toBeTruthy(),
);
await agent.changeAgentProfile("p1", created.id, ptyProfile.id, 24, 80);
system.emit({
type: "agentProfileChanged",
agentId: created.id,
profileId: ptyProfile.id,
});
await waitFor(() =>
expect(screen.getByTestId("custom-agent-chat-unavailable")).toBeTruthy(),
);
expect(window.localStorage.getItem(`idea.agent-cell-mode.p1.${leafId}`)).toBe("custom");
const updated = await layout.loadLayout("p1");
expect(leaves(updated)[0].preferredView).toBe("chat");
expect(screen.queryByTestId("terminal-view")).toBeNull();
expect(screen.queryByTestId("custom-agent-chat-view")).toBeNull();
});
});