/** * 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, 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() { 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(); const workState = new MockWorkStateGateway(); 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, } as unknown as Gateways; } function renderGrid(gateways: Gateways, projectId = "p1") { const cwd = projectId === "p1" ? "/home/me/proj" : `/home/me/${projectId}`; 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("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" }, }); 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 a project custom CLI cell after switching away and back without rewriting durable ids", 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 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", }); 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, } as unknown as Gateways; const view = renderGrid(gateways, "p1"); expect(await screen.findByTestId("custom-agent-chat-view")).toBeTruthy(); await waitFor(async () => { const updated = await layout.loadLayout("p1"); const leaf = leaves(updated)[0]; expect(leaf.preferredView).toBe("chat"); expect(leaf.session).toMatch(/^mock-agent-chat-/); expect(leaf.conversationId).toMatch(/^mock-conversation-mock-agent-chat-/); }); const persistedA = leaves(await layout.loadLayout("p1"))[0]; view.rerender( , ); await waitFor(() => expect(screen.queryByTestId("custom-agent-chat-view")).toBeNull()); view.rerender( , ); 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(); 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); }); 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("renders requester-side delegation fallback in custom mode after remount (#232)", 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(await screen.findByText("Worker attend Architect")).toBeTruthy(); view.unmount(); renderGrid(gateways); expect(await screen.findByTestId("custom-agent-chat-view")).toBeTruthy(); expect(await screen.findByText("Worker attend Architect")).toBeTruthy(); }); 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("opens the custom CLI even when the agent already has a structured background session (#169)", 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(), ); await waitFor(() => expect(launchChat).toHaveBeenCalled()); expect(launchChat.mock.calls.at(-1)?.[2]).toMatchObject({ nodeId: leafId }); expect(screen.queryByText(/déjà actif dans une autre cellule/)).toBeNull(); }); it("restores a custom CLI transcript when returning with a conversation id but no session (#217)", 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, }); await layout.mutateLayout("p1", { type: "setCellConversation", target: leafId, conversationId: "conversation-known", }); window.localStorage.setItem(`idea.agent-cell-mode.p1.${leafId}`, "custom"); const launchChat = vi.spyOn(agent, "launchAgentChat").mockResolvedValue({ sessionId: "chat-resumed", cellKind: "chat" as const, }); const reattachChat = vi.spyOn(agent, "reattachAgentChat").mockResolvedValue({ sessionId: "chat-resumed", scrollback: [ { kind: "userPrompt", text: "known prompt" }, { kind: "final", content: "known answer" }, ], }); renderGrid({ layout, agent, profile: profileGateway, terminal, system, } as unknown as Gateways); expect(await screen.findByText("known answer")).toBeTruthy(); expect(screen.getByText("known prompt")).toBeTruthy(); expect(launchChat).toHaveBeenCalledWith( "p1", created.id, expect.objectContaining({ nodeId: leafId, conversationId: "conversation-known", }), ); expect(reattachChat).toHaveBeenCalledWith("chat-resumed", expect.any(Function)); await waitFor(async () => { const updated = await layout.loadLayout("p1"); const leaf = leaves(updated)[0]; expect(leaf.conversationId).toBe("conversation-known"); expect(leaf.session).toBe("chat-resumed"); }); expect(screen.queryByText("Envoyez un message pour démarrer la conversation structurée.")) .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(), ); 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(); 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(); 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("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(); 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(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(); }); });