/** * 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, MockLayoutGateway, MockProfileGateway, MockSystemGateway, MockTerminalGateway, } 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() { let resolve!: (value: T | PromiseLike) => void; const promise = new Promise((res) => { resolve = res; }); return { promise, resolve }; } beforeEach(() => { window.localStorage.clear(); }); async function seeded(profile: AgentProfile): Promise { const layout = new MockLayoutGateway(); const agent = new MockAgentGateway(); const profileGateway = new MockProfileGateway(); const terminal = new MockTerminalGateway(); const system = new MockSystemGateway(); 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, } as unknown as Gateways; } function renderGrid(gateways: Gateways) { return render( , ); } 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("Arrêter et relancer")); 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 custom/), { target: { value: "hello there" }, }); fireEvent.click(screen.getByText("Envoyer")); await waitFor(() => expect(screen.getByText("Task Complete")).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("requires confirmation before switching a live native TUI session", async () => { renderGrid(await seeded(structuredProfile)); await waitFor(() => expect(screen.getByText("CLI custom")).toBeTruthy()); await waitFor(() => 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(), ); expect(screen.getByText(/Une reprise est possible/)).toBeTruthy(); fireEvent.click(screen.getByText("Arrêter et relancer")); await waitFor(() => expect(screen.getByTestId("custom-agent-chat-view")).toBeTruthy(), ); }); 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(); const profilesLoaded = deferred(); 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("falls back to native TUI once the catalog confirms the pinned 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(); const profilesLoaded = deferred(); 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(window.localStorage.getItem(`idea.agent-cell-mode.p1.${leafId}`)).toBe("tui"), ); expect(screen.getByTestId("terminal-view")).toBeTruthy(); expect(screen.queryByTestId("custom-agent-chat-view")).toBeNull(); }); });