import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { useState } from "react"; import { describe, expect, it, vi } from "vitest"; import { DIProvider } from "@/app/di"; import type { AgentProfile } from "@/domain"; import { PluginRuntimeProvider } from "@/features/plugins"; import { PluginCommandRegistry, PluginLayoutRegistry, PluginMenuRegistry, PluginRuntimeRegistry, } from "@/plugins/runtime"; import type { Gateways } from "@/ports"; import { CustomAgentChatView } from "./CustomAgentChatView"; const profile: AgentProfile = { id: "structured", name: "Structured Codex", command: "codex", args: [], contextInjection: { strategy: "conventionFile", target: "AGENTS.md" }, detect: null, cwdTemplate: "{projectRoot}", structuredAdapter: "codex", }; function addPluginCommand( registry: PluginRuntimeRegistry, pluginId: string, commandId: string, handler: (...args: unknown[]) => unknown | Promise, ) { const commands = new PluginCommandRegistry(pluginId, new Set([commandId])); commands.register(commandId, handler); registry.add({ pluginId, displayName: "Acme Plugin", contributes: { menus: [], menuItems: [], slashCommands: [ { name: "/explain", shortDescription: "Explain selection", command: commandId, }, ], layouts: [], mcpServers: [], }, commands, layouts: new PluginLayoutRegistry(pluginId, new Set()), menu: new PluginMenuRegistry(pluginId), dispose: async () => {}, }); } describe("CustomAgentChatView", () => { it("cancels only the current turn and keeps the structured session alive", async () => { const agent = { launchAgentChat: vi.fn(), reattachAgentChat: vi.fn(async (sessionId: string) => ({ sessionId, scrollback: [], })), sendAgentChat: vi.fn(() => new Promise(() => {})), cancelAgentChat: vi.fn(async () => {}), closeAgentChat: vi.fn(async () => {}), }; const onSessionId = vi.fn(); render( null) }, } as unknown as Gateways} > , ); await waitFor(() => expect(agent.reattachAgentChat).toHaveBeenCalledWith( "chat-session-1", expect.any(Function), ), ); fireEvent.change(screen.getByLabelText(/message CLI custom/), { target: { value: "first turn" }, }); fireEvent.click(screen.getByRole("button", { name: "Envoyer" })); await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalledWith( "chat-session-1", "first turn", expect.any(Function), ), ); expect(screen.queryByRole("button", { name: "Cancel" })).toBeNull(); fireEvent.click(await screen.findByRole("button", { name: "Stop" })); await waitFor(() => expect(agent.cancelAgentChat).toHaveBeenCalledWith("chat-session-1"), ); expect(agent.closeAgentChat).not.toHaveBeenCalled(); expect(onSessionId).not.toHaveBeenCalledWith(null); expect(screen.getByText("Tour interrompu.")).toBeTruthy(); fireEvent.change(screen.getByLabelText(/message CLI custom/), { target: { value: "second turn" }, }); fireEvent.click(screen.getByRole("button", { name: "Envoyer" })); await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalledTimes(2)); expect(agent.sendAgentChat).toHaveBeenLastCalledWith( "chat-session-1", "second turn", expect.any(Function), ); }); it("falls back to a fresh launch when reattach reports NOT_FOUND", async () => { const agent = { launchAgentChat: vi.fn(async () => ({ sessionId: "chat-session-2", assignedConversationId: "conversation-2", })), reattachAgentChat: vi .fn() .mockRejectedValueOnce({ code: "NOT_FOUND", message: "structured session gone" }) .mockImplementationOnce(async (sessionId: string) => ({ sessionId, scrollback: [], })), sendAgentChat: vi.fn(() => new Promise(() => {})), cancelAgentChat: vi.fn(async () => {}), closeAgentChat: vi.fn(async () => {}), }; const onSessionId = vi.fn(); render( null) }, } as unknown as Gateways} > , ); await waitFor(() => expect(agent.launchAgentChat).toHaveBeenCalledWith("project-1", "agent-1", { cwd: "/repo", rows: 24, cols: 80, conversationId: "conversation-1", nodeId: "node-1", }), ); await waitFor(() => expect(onSessionId).toHaveBeenCalledWith("chat-session-2"), ); expect(screen.queryByText(/structured session gone/)).toBeNull(); }); it("does not duplicate the user prompt when the stream echoes it back", async () => { const agent = { launchAgentChat: vi.fn(), reattachAgentChat: vi.fn(async (sessionId: string) => ({ sessionId, scrollback: [], })), sendAgentChat: vi.fn(async (_sessionId: string, _prompt: string, onChunk) => { onChunk({ kind: "userPrompt", text: "hello agent" }); onChunk({ kind: "final", content: "done" }); }), cancelAgentChat: vi.fn(async () => {}), closeAgentChat: vi.fn(async () => {}), }; render( null) }, } as unknown as Gateways} > , ); await waitFor(() => expect(agent.reattachAgentChat).toHaveBeenCalledWith( "chat-session-1", expect.any(Function), ), ); fireEvent.change(screen.getByLabelText(/message CLI custom/), { target: { value: "hello agent" }, }); fireEvent.click(screen.getByRole("button", { name: "Envoyer" })); await screen.findByText("done"); expect(screen.getAllByText("hello agent")).toHaveLength(1); expect(screen.getByText("Terminé").parentElement?.parentElement?.className).toContain( "justify-end", ); }); it("renders canonical progress chunks as live agent, MCP, and delegation activity", async () => { const agent = { launchAgentChat: vi.fn(), reattachAgentChat: vi.fn(async (sessionId: string) => ({ sessionId, scrollback: [], })), sendAgentChat: vi.fn(async (_sessionId: string, _prompt: string, onChunk) => { onChunk({ kind: "progress", progress: { source: "providerNative", kind: "turn", stage: "started", label: "turn.started", provider: "codex", nativeEvent: "turn.started", }, }); onChunk({ kind: "progress", progress: { source: "providerNative", kind: "message", stage: "delta", label: "Analyse", text: "Lecture du contexte disponible.", provider: "codex", nativeEvent: "agent.message.delta", }, }); onChunk({ kind: "progress", progress: { source: "ideaLocal", kind: "mcp", stage: "started", label: "idea_ticket_read", toolName: "idea_ticket_read", }, }); onChunk({ kind: "progress", progress: { source: "ideaLocal", kind: "mcp", stage: "started", label: "idea_ask_agent", text: "target=QA task=Valider le rendu progress", toolName: "idea_ask_agent", }, }); onChunk({ kind: "progress", progress: { source: "providerNative", kind: "turn", stage: "completed", label: "turn.completed", provider: "codex", nativeEvent: "turn.completed", }, }); onChunk({ kind: "final", content: "done" }); }), cancelAgentChat: vi.fn(async () => {}), closeAgentChat: vi.fn(async () => {}), }; render( null) }, } as unknown as Gateways} > , ); await waitFor(() => expect(agent.reattachAgentChat).toHaveBeenCalledWith( "chat-session-1", expect.any(Function), ), ); fireEvent.change(screen.getByLabelText(/message CLI custom/), { target: { value: "ship progress UI" }, }); fireEvent.click(screen.getByRole("button", { name: "Envoyer" })); await screen.findByText("Progress"); expect(screen.getAllByTestId("chat-turn-marker")).toHaveLength(2); expect(screen.getByText("turn.started")).toBeTruthy(); expect(screen.getByText("turn.completed")).toBeTruthy(); expect(screen.getByText("Progress").closest("[data-testid='chat-progress-status']")).toBeTruthy(); expect(screen.getByText("Analyse")).toBeTruthy(); expect(screen.getByText("Lecture du contexte disponible.")).toBeTruthy(); expect(screen.getByText("MCP")).toBeTruthy(); expect(screen.getByText("MCP").closest("[data-testid='chat-tool-progress']")).toBeTruthy(); expect(screen.getByText("idea_ticket_read")).toBeTruthy(); expect(screen.getByText("Worker ← QA")).toBeTruthy(); expect(screen.getByText("Worker ← QA").closest("[data-testid='chat-tool-progress']")).toBeTruthy(); expect(screen.getByText("Valider le rendu progress")).toBeTruthy(); expect((await screen.findAllByText("done")).length).toBeGreaterThan(0); }); it("renders available pre-MCP progress before the first MCP card", async () => { let emitChunk: ((chunk: unknown) => void) | null = null; const agent = { launchAgentChat: vi.fn(), reattachAgentChat: vi.fn(async (sessionId: string) => ({ sessionId, scrollback: [], })), sendAgentChat: vi.fn( (_sessionId: string, _prompt: string, onChunk: (chunk: unknown) => void) => new Promise((resolve) => { emitChunk = (chunk: unknown) => { onChunk(chunk); if ((chunk as { kind?: string }).kind === "final") resolve(); }; }), ), cancelAgentChat: vi.fn(async () => {}), closeAgentChat: vi.fn(async () => {}), }; const { container } = render( null) }, } as unknown as Gateways} > , ); await waitFor(() => expect(agent.reattachAgentChat).toHaveBeenCalledWith( "chat-session-1", expect.any(Function), ), ); fireEvent.change(screen.getByLabelText(/message CLI custom/), { target: { value: "delegate after intent" }, }); fireEvent.click(screen.getByRole("button", { name: "Envoyer" })); await waitFor(() => expect(emitChunk).not.toBeNull()); act(() => { emitChunk?.({ kind: "progress", progress: { source: "providerNative", kind: "message", stage: "delta", label: "Intention", text: "Je vais d'abord regarder les outils MCP disponibles.", provider: "codex", nativeEvent: "agent.message.delta", }, }); }); expect( screen.getByText("Je vais d'abord regarder les outils MCP disponibles."), ).toBeTruthy(); expect(screen.queryByText("idea_ask_agent")).toBeNull(); act(() => { emitChunk?.({ kind: "progress", progress: { source: "ideaLocal", kind: "mcp", stage: "started", label: "idea_ask_agent", toolName: "idea_ask_agent", }, }); }); const intent = screen.getByText( "Je vais d'abord regarder les outils MCP disponibles.", ); const delegation = screen.getByText("idea_ask_agent"); expect( Boolean(intent.compareDocumentPosition(delegation) & Node.DOCUMENT_POSITION_FOLLOWING), ).toBe(true); act(() => { emitChunk?.({ kind: "final", content: "done" }); }); await screen.findByText("done"); expect(container.textContent).toContain("Je vais d'abord regarder"); }); it("deduplicates delegated tool progress from tap and authoritative stream", async () => { let emitChunk: ((chunk: unknown) => void) | null = null; const agent = { launchAgentChat: vi.fn(), reattachAgentChat: vi.fn(async (sessionId: string) => ({ sessionId, scrollback: [], })), sendAgentChat: vi.fn( (_sessionId: string, _prompt: string, onChunk: (chunk: unknown) => void) => new Promise((resolve) => { emitChunk = (chunk: unknown) => { onChunk(chunk); if ((chunk as { kind?: string }).kind === "final") resolve(); }; }), ), cancelAgentChat: vi.fn(async () => {}), closeAgentChat: vi.fn(async () => {}), }; render( null) }, } as unknown as Gateways} > , ); await waitFor(() => expect(agent.reattachAgentChat).toHaveBeenCalledWith( "chat-session-1", expect.any(Function), ), ); fireEvent.change(screen.getByLabelText(/message CLI custom/), { target: { value: "delegate once" }, }); fireEvent.click(screen.getByRole("button", { name: "Envoyer" })); await waitFor(() => expect(emitChunk).not.toBeNull()); const progress = (source: "providerNative" | "ideaLocal", stage: "started" | "completed") => ({ kind: "progress" as const, progress: { source, kind: source === "ideaLocal" ? ("mcp" as const) : ("tool" as const), stage, label: "idea_ask_agent", text: stage === "started" ? "target=QA task=validate" : "reply delivered", provider: source === "providerNative" ? "opencode" : undefined, nativeEvent: source === "providerNative" ? stage === "started" ? "tool_use" : "tool_done" : stage === "started" ? "conversation.item.added" : "conversation.item.done", toolName: "idea_ask_agent", }, }); act(() => { emitChunk?.(progress("providerNative", "started")); emitChunk?.(progress("ideaLocal", "started")); }); expect(screen.getByText("Worker → QA")).toBeTruthy(); expect(screen.getByText("Worker → QA").parentElement?.textContent).toContain("started"); expect(screen.getAllByRole("status", { name: "Loading" }).length).toBeGreaterThan(0); act(() => { emitChunk?.(progress("providerNative", "completed")); emitChunk?.(progress("ideaLocal", "completed")); emitChunk?.(progress("providerNative", "started")); emitChunk?.(progress("ideaLocal", "started")); emitChunk?.(progress("providerNative", "completed")); emitChunk?.(progress("ideaLocal", "completed")); emitChunk?.({ kind: "final", content: "done" }); }); await screen.findByText("done"); expect(screen.getAllByText("Worker ← QA")).toHaveLength(2); expect(screen.getAllByText("validate")).toHaveLength(2); expect(screen.queryByText("Appel inter-agent")).toBeNull(); expect(screen.queryByText("reply delivered")).toBeNull(); expect(screen.queryByText("Tool")).toBeNull(); expect(screen.queryByText("started")).toBeNull(); await waitFor(() => expect(screen.queryByRole("status", { name: "Loading" })).toBeNull(), ); }); it("stops animating running progress rows once the turn reaches a final chunk", async () => { let emitChunk: ((chunk: unknown) => void) | null = null; const agent = { launchAgentChat: vi.fn(), reattachAgentChat: vi.fn(async (sessionId: string) => ({ sessionId, scrollback: [], })), sendAgentChat: vi.fn( (_sessionId: string, _prompt: string, onChunk: (chunk: unknown) => void) => new Promise((resolve) => { emitChunk = (chunk: unknown) => { onChunk(chunk); if ((chunk as { kind?: string }).kind === "final") resolve(); }; }), ), cancelAgentChat: vi.fn(async () => {}), closeAgentChat: vi.fn(async () => {}), }; render( null) }, } as unknown as Gateways} > , ); await waitFor(() => expect(agent.reattachAgentChat).toHaveBeenCalledWith( "chat-session-1", expect.any(Function), ), ); fireEvent.change(screen.getByLabelText(/message CLI custom/), { target: { value: "ship progress UI" }, }); fireEvent.click(screen.getByRole("button", { name: "Envoyer" })); await waitFor(() => expect(emitChunk).not.toBeNull()); act(() => { emitChunk?.({ kind: "progress", progress: { source: "providerNative", kind: "message", stage: "delta", label: "Analyse", text: "Lecture du contexte disponible.", }, }); }); expect(screen.getAllByRole("status", { name: "Loading" }).length).toBeGreaterThan(0); expect(screen.getByText("Progress").parentElement?.textContent).toContain("running"); act(() => { emitChunk?.({ kind: "final", content: "done" }); }); await screen.findByText("done"); await waitFor(() => expect(screen.queryByRole("status", { name: "Loading" })).toBeNull(), ); expect(screen.getByText("Lecture du contexte disponible.")).toBeTruthy(); expect(screen.getByText("Progress").parentElement?.textContent).toContain("done"); }); it("does not append late progress chunks after the final answer", async () => { let emitChunk: ((chunk: unknown) => void) | null = null; const agent = { launchAgentChat: vi.fn(), reattachAgentChat: vi.fn(async (sessionId: string) => ({ sessionId, scrollback: [], })), sendAgentChat: vi.fn( (_sessionId: string, _prompt: string, onChunk: (chunk: unknown) => void) => new Promise((resolve) => { emitChunk = (chunk: unknown) => { onChunk(chunk); if ((chunk as { kind?: string }).kind === "final") resolve(); }; }), ), cancelAgentChat: vi.fn(async () => {}), closeAgentChat: vi.fn(async () => {}), }; render( null) }, } as unknown as Gateways} > , ); await waitFor(() => expect(agent.reattachAgentChat).toHaveBeenCalledWith( "chat-session-1", expect.any(Function), ), ); fireEvent.change(screen.getByLabelText(/message CLI custom/), { target: { value: "ship ordered stream" }, }); fireEvent.click(screen.getByRole("button", { name: "Envoyer" })); await waitFor(() => expect(emitChunk).not.toBeNull()); act(() => { emitChunk?.({ kind: "final", content: "done" }); }); await screen.findByText("done"); act(() => { emitChunk?.({ kind: "progress", progress: { source: "ideaLocal", kind: "mcp", stage: "started", label: "idea_ticket_read", toolName: "idea_ticket_read", }, }); }); expect(screen.queryByText("idea_ticket_read")).toBeNull(); }); it("shows slash-command suggestions from the gateway, filters by prefix, and inserts the selected command", async () => { const agent = { launchAgentChat: vi.fn(), reattachAgentChat: vi.fn(async (sessionId: string) => ({ sessionId, scrollback: [], })), sendAgentChat: vi.fn(async () => {}), listSlashCommands: vi.fn(async (prefix?: string) => [ { name: "/clean", shortDescription: "Nettoyer la conversation courante", requiresConfirmation: false, availability: { status: "available" as const }, source: "native" as const, native: "clean" as const, }, { name: "/commit", shortDescription: "Préparer un commit", requiresConfirmation: true, availability: { status: "available" as const }, source: { plugin: { pluginId: "git" } }, }, ].filter((command) => !prefix || command.name.startsWith(prefix)), ), cancelAgentChat: vi.fn(async () => {}), closeAgentChat: vi.fn(async () => {}), }; render( null) }, } as unknown as Gateways} > , ); await waitFor(() => expect(agent.reattachAgentChat).toHaveBeenCalledWith( "chat-session-1", expect.any(Function), ), ); const composer = screen.getByLabelText(/message CLI custom/) as HTMLTextAreaElement; fireEvent.change(composer, { target: { value: "/c" } }); const menu = await screen.findByRole("listbox", { name: "suggestions commandes slash", }); expect(agent.listSlashCommands).toHaveBeenLastCalledWith("/c"); expect(screen.getByRole("option", { name: /\/clean/ })).toBeTruthy(); expect(screen.getByRole("option", { name: /\/commit/ })).toBeTruthy(); fireEvent.keyDown(composer, { key: "ArrowDown" }); fireEvent.keyDown(composer, { key: "Enter" }); await waitFor(() => expect(composer.value).toBe("/commit ")); expect(menu).toBeTruthy(); expect(screen.queryByRole("listbox", { name: "suggestions commandes slash" })).toBeNull(); expect(agent.sendAgentChat).not.toHaveBeenCalled(); }); it("only opens slash-command suggestions at the start of an unfinished slash token", async () => { const agent = { launchAgentChat: vi.fn(), reattachAgentChat: vi.fn(async (sessionId: string) => ({ sessionId, scrollback: [], })), sendAgentChat: vi.fn(async () => {}), listSlashCommands: vi.fn(async () => [ { name: "/clean", shortDescription: "Nettoyer la conversation courante", requiresConfirmation: false, availability: { status: "available" as const }, source: "native" as const, native: "clean" as const, }, ]), cancelAgentChat: vi.fn(async () => {}), closeAgentChat: vi.fn(async () => {}), }; render( null) }, } as unknown as Gateways} > , ); await waitFor(() => expect(agent.reattachAgentChat).toHaveBeenCalledWith( "chat-session-1", expect.any(Function), ), ); const composer = screen.getByLabelText(/message CLI custom/) as HTMLTextAreaElement; fireEvent.change(composer, { target: { value: "hello /c" } }); expect(screen.queryByRole("listbox", { name: "suggestions commandes slash" })).toBeNull(); expect(agent.listSlashCommands).not.toHaveBeenCalled(); fireEvent.change(composer, { target: { value: "/" } }); await screen.findByRole("listbox", { name: "suggestions commandes slash" }); fireEvent.change(composer, { target: { value: "/clean now" } }); expect(screen.queryByRole("listbox", { name: "suggestions commandes slash" })).toBeNull(); }); it("dispatches plugin slash-command callback effects through the plugin runtime", async () => { const commandHandler = vi.fn(async () => {}); const registry = new PluginRuntimeRegistry(); addPluginCommand( registry, "dev.acme.explain", "dev.acme.explain.run", commandHandler, ); const agent = { launchAgentChat: vi.fn(), reattachAgentChat: vi.fn(async (sessionId: string) => ({ sessionId, scrollback: [], })), sendAgentChat: vi.fn(async () => {}), executeSlashCommand: vi.fn(async (_name: string, options) => ({ command: { name: "/explain", shortDescription: "Explain selection", requiresConfirmation: false, availability: { status: "available" as const }, source: { plugin: { pluginId: "dev.acme.explain" } }, plugin: { pluginId: "dev.acme.explain", commandId: "dev.acme.explain.run", }, }, effect: { kind: "pluginCallback" as const, pluginId: "dev.acme.explain", commandId: "dev.acme.explain.run", sessionId: options.sessionId, arguments: options.arguments ?? [], }, })), cancelAgentChat: vi.fn(async () => {}), closeAgentChat: vi.fn(async () => {}), }; render( null) }, } as unknown as Gateways} > , ); await waitFor(() => expect(agent.reattachAgentChat).toHaveBeenCalledWith( "chat-session-1", expect.any(Function), ), ); const composer = screen.getByLabelText(/message CLI custom/) as HTMLTextAreaElement; fireEvent.change(composer, { target: { value: "/explain focus this" } }); fireEvent.keyDown(composer, { key: "Enter" }); await waitFor(() => expect(agent.executeSlashCommand).toHaveBeenCalledWith("/explain", { sessionId: "chat-session-1", arguments: ["focus this"], }), ); await waitFor(() => expect(commandHandler).toHaveBeenCalledWith("focus this")); expect(agent.sendAgentChat).not.toHaveBeenCalled(); expect( await screen.findByText("Commande plugin /explain exécutée."), ).toBeTruthy(); expect(composer.value).toBe(""); }); it("executes /clean as a native conversation clear effect", async () => { const agent = { launchAgentChat: vi.fn(), reattachAgentChat: vi.fn(async (sessionId: string) => ({ sessionId, scrollback: [{ kind: "userPrompt", text: "old conversation" }], })), sendAgentChat: vi.fn(async () => {}), executeSlashCommand: vi.fn(async () => ({ command: { name: "/clean", shortDescription: "Nettoyer la conversation courante", requiresConfirmation: false, availability: { status: "available" as const }, source: "native" as const, native: "clean" as const, }, effect: { kind: "cleanConversation" as const, sessionId: "chat-session-1", clearedChunks: 1, }, })), cancelAgentChat: vi.fn(async () => {}), closeAgentChat: vi.fn(async () => {}), }; render( null) }, } as unknown as Gateways} > , ); expect(await screen.findByText("old conversation")).toBeTruthy(); const composer = screen.getByLabelText(/message CLI custom/) as HTMLTextAreaElement; fireEvent.change(composer, { target: { value: "/clean" } }); fireEvent.keyDown(composer, { key: "Enter" }); await waitFor(() => expect(agent.executeSlashCommand).toHaveBeenCalledWith("/clean", { sessionId: "chat-session-1", }), ); await waitFor(() => expect(screen.queryByText("old conversation")).toBeNull()); expect(screen.queryByRole("alert")).toBeNull(); expect(agent.sendAgentChat).not.toHaveBeenCalled(); expect(composer.value).toBe(""); }); it("executes /help as a native help effect", async () => { const agent = { launchAgentChat: vi.fn(), reattachAgentChat: vi.fn(async (sessionId: string) => ({ sessionId, scrollback: [], })), sendAgentChat: vi.fn(async () => {}), executeSlashCommand: vi.fn(async () => ({ command: { name: "/help", shortDescription: "Afficher les commandes disponibles", requiresConfirmation: false, availability: { status: "available" as const }, source: "native" as const, native: "help" as const, }, effect: { kind: "help" as const, commands: [ { name: "/help", shortDescription: "Afficher les commandes disponibles", requiresConfirmation: false, availability: { status: "available" as const }, source: "native" as const, native: "help" as const, }, { name: "/clean", shortDescription: "Nettoyer la conversation courante", requiresConfirmation: false, availability: { status: "available" as const }, source: "native" as const, native: "clean" as const, }, { name: "/profile", shortDescription: "Changer le profil de l'agent", requiresConfirmation: true, availability: { status: "available" as const }, source: "native" as const, native: "profile" as const, }, ], }, })), cancelAgentChat: vi.fn(async () => {}), closeAgentChat: vi.fn(async () => {}), }; render( null) }, } as unknown as Gateways} > , ); await waitFor(() => expect(agent.reattachAgentChat).toHaveBeenCalledWith( "chat-session-1", expect.any(Function), ), ); const composer = screen.getByLabelText(/message CLI custom/) as HTMLTextAreaElement; fireEvent.change(composer, { target: { value: "/help" } }); fireEvent.keyDown(composer, { key: "Enter" }); await waitFor(() => expect(agent.executeSlashCommand).toHaveBeenCalledWith("/help", { sessionId: "chat-session-1", }), ); expect( await screen.findByText(/\/clean - Nettoyer la conversation courante/), ).toBeTruthy(); expect(screen.queryByRole("alert")).toBeNull(); expect(agent.sendAgentChat).not.toHaveBeenCalled(); expect(composer.value).toBe(""); }); it("shows feedback when a plugin slash-command callback cannot be dispatched", async () => { const registry = new PluginRuntimeRegistry(); const agent = { launchAgentChat: vi.fn(), reattachAgentChat: vi.fn(async (sessionId: string) => ({ sessionId, scrollback: [], })), sendAgentChat: vi.fn(async () => {}), executeSlashCommand: vi.fn(async () => ({ command: { name: "/explain", shortDescription: "Explain selection", requiresConfirmation: false, availability: { status: "available" as const }, source: { plugin: { pluginId: "dev.acme.missing" } }, plugin: { pluginId: "dev.acme.missing", commandId: "dev.acme.missing.run", }, }, effect: { kind: "pluginCallback" as const, pluginId: "dev.acme.missing", commandId: "dev.acme.missing.run", arguments: [], }, })), cancelAgentChat: vi.fn(async () => {}), closeAgentChat: vi.fn(async () => {}), }; render( null) }, } as unknown as Gateways} > , ); await waitFor(() => expect(agent.reattachAgentChat).toHaveBeenCalledWith( "chat-session-1", expect.any(Function), ), ); const composer = screen.getByLabelText(/message CLI custom/); fireEvent.change(composer, { target: { value: "/explain" } }); fireEvent.keyDown(composer, { key: "Enter" }); const alert = await screen.findByRole("alert"); expect(alert.textContent).toContain("Commande /explain échouée"); expect(alert.textContent).toContain('plugin "dev.acme.missing" is not loaded'); expect(screen.getAllByText(/Commande \/explain échouée/)).toHaveLength(2); expect(agent.sendAgentChat).not.toHaveBeenCalled(); }); it("executes /profile as a confirmed profile-switch flow that resets the current session", async () => { const agent = { launchAgentChat: vi.fn(async () => ({ sessionId: "chat-session-2", cellKind: "chat" as const, assignedConversationId: "conversation-2", })), reattachAgentChat: vi.fn(async (sessionId: string) => ({ sessionId, scrollback: [], })), sendAgentChat: vi.fn(async () => {}), executeSlashCommand: vi.fn(async () => ({ command: { name: "/profile", shortDescription: "Changer le profil de l'agent", requiresConfirmation: true, availability: { status: "available" as const }, source: "native" as const, native: "profile" as const, }, effect: { kind: "profileSwitch" as const, sessionId: "chat-session-1", }, })), changeAgentProfile: vi.fn(async () => ({ agent: { id: "agent-1" } })), cancelAgentChat: vi.fn(async () => {}), closeAgentChat: vi.fn(async () => {}), }; const profileGateway = { listProfiles: vi.fn(async () => [ profile, { ...profile, id: "codex-high", name: "Codex High", }, ]), }; const onSessionId = vi.fn(); const onConversationId = vi.fn(); render( null) }, } as unknown as Gateways} > , ); await waitFor(() => expect(agent.reattachAgentChat).toHaveBeenCalledWith( "chat-session-1", expect.any(Function), ), ); const composer = screen.getByLabelText(/message CLI custom/) as HTMLTextAreaElement; fireEvent.change(composer, { target: { value: "/profile" } }); fireEvent.keyDown(composer, { key: "Enter" }); const dialog = await screen.findByRole("dialog", { name: "Changer de profil" }); expect(dialog.textContent).toContain("réinitialiser la session courante"); expect(dialog.textContent).toContain("L'historique de cette CLI custom sera perdu"); expect(agent.executeSlashCommand).toHaveBeenCalledWith("/profile", { sessionId: "chat-session-1", }); expect(agent.sendAgentChat).not.toHaveBeenCalled(); const confirmButton = screen.getByRole("button", { name: "Reset et changer le profil", }) as HTMLButtonElement; expect(confirmButton.disabled).toBe(true); fireEvent.change(screen.getByLabelText("profil cible"), { target: { value: "codex-high" }, }); fireEvent.click(screen.getByLabelText("Je confirme que la session courante sera reset.")); expect(confirmButton.disabled).toBe(false); fireEvent.click(confirmButton); await waitFor(() => expect(agent.changeAgentProfile).toHaveBeenCalledWith( "project-1", "agent-1", "codex-high", 24, 80, ), ); await waitFor(() => expect(agent.launchAgentChat).toHaveBeenCalledWith("project-1", "agent-1", { cwd: "/repo", rows: 24, cols: 80, conversationId: undefined, nodeId: "node-1", }), ); expect(onSessionId).toHaveBeenCalledWith(null); expect(onConversationId).toHaveBeenCalledWith(null); expect(onSessionId).toHaveBeenCalledWith("chat-session-2"); expect(onConversationId).toHaveBeenCalledWith("conversation-2"); expect(screen.getByText("Profil changé vers Codex High.")).toBeTruthy(); expect(screen.queryByRole("dialog", { name: "Changer de profil" })).toBeNull(); }); it("pastes a clipboard image as a removable preview chip", async () => { const agent = { launchAgentChat: vi.fn(), reattachAgentChat: vi.fn(async (sessionId: string) => ({ sessionId, scrollback: [], })), sendAgentChat: vi.fn(() => new Promise(() => {})), cancelAgentChat: vi.fn(async () => {}), closeAgentChat: vi.fn(async () => {}), }; render( null) }, } as unknown as Gateways} > , ); await waitFor(() => expect(agent.reattachAgentChat).toHaveBeenCalledWith( "chat-session-1", expect.any(Function), ), ); const file = new File(["ignored"], "clipboard.png", { type: "image/png" }); Object.defineProperty(file, "arrayBuffer", { value: vi.fn(async () => new Uint8Array([1, 2, 3]).buffer), }); fireEvent.paste(screen.getByLabelText(/message CLI custom/), { clipboardData: { items: [ { kind: "file", type: "image/png", getAsFile: () => file, }, ], getData: () => "", }, }); expect(await screen.findByText("Fichier joint: clipboard.png")).toBeTruthy(); expect(screen.getByTestId("attachment-preview-clipboard.png")).toBeTruthy(); fireEvent.click(screen.getByRole("button", { name: "retirer clipboard.png" })); await waitFor(() => expect(screen.queryByText("Fichier joint: clipboard.png")).toBeNull(), ); expect( (screen.getByRole("button", { name: "Envoyer" }) as HTMLButtonElement) .disabled, ).toBe(true); }); it("sends a pasted clipboard image without requiring text", async () => { const agent = { launchAgentChat: vi.fn(), reattachAgentChat: vi.fn(async (sessionId: string) => ({ sessionId, scrollback: [], })), sendAgentChat: vi.fn(async () => {}), cancelAgentChat: vi.fn(async () => {}), closeAgentChat: vi.fn(async () => {}), }; render( null) }, } as unknown as Gateways} > , ); await waitFor(() => expect(agent.reattachAgentChat).toHaveBeenCalledWith( "chat-session-1", expect.any(Function), ), ); const file = new File(["ignored"], "paste-image.png", { type: "image/png" }); Object.defineProperty(file, "arrayBuffer", { value: vi.fn(async () => new Uint8Array([1, 2, 3]).buffer), }); fireEvent.paste(screen.getByLabelText(/message CLI custom/), { clipboardData: { items: [ { kind: "file", type: "image/png", getAsFile: () => file, }, ], getData: () => "", }, }); await screen.findByText("Fichier joint: paste-image.png"); fireEvent.click(screen.getByRole("button", { name: "Envoyer" })); await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalledTimes(1)); expect(agent.sendAgentChat).toHaveBeenCalledWith( "chat-session-1", "", expect.any(Function), { attachments: [ { filename: "paste-image.png", contentBase64: "AQID", mime: "image/png", sourceKind: "clipboard", }, ], }, ); expect(screen.getByText("Pièce jointe")).toBeTruthy(); expect(screen.getByText("Fichier: paste-image.png")).toBeTruthy(); }); it("accepts pasted generic files exposed only through clipboardData.files", async () => { const agent = { launchAgentChat: vi.fn(), reattachAgentChat: vi.fn(async (sessionId: string) => ({ sessionId, scrollback: [], })), sendAgentChat: vi.fn(async () => {}), cancelAgentChat: vi.fn(async () => {}), closeAgentChat: vi.fn(async () => {}), }; render( null) }, } as unknown as Gateways} > , ); await waitFor(() => expect(agent.reattachAgentChat).toHaveBeenCalledWith( "chat-session-1", expect.any(Function), ), ); const file = new File(["ignored"], "files-only.pdf", { type: "application/pdf" }); Object.defineProperty(file, "arrayBuffer", { value: vi.fn(async () => new Uint8Array([4, 5, 6]).buffer), }); fireEvent.paste(screen.getByLabelText(/message CLI custom/), { clipboardData: { items: [], files: [file], getData: () => "", }, }); await screen.findByText("Fichier joint: files-only.pdf"); expect(screen.queryByTestId("attachment-preview-files-only.pdf")).toBeNull(); fireEvent.click(screen.getByRole("button", { name: "Envoyer" })); await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalledTimes(1)); expect(agent.sendAgentChat).toHaveBeenCalledWith( "chat-session-1", "", expect.any(Function), { attachments: [ { filename: "files-only.pdf", contentBase64: "BAUG", mime: "application/pdf", sourceKind: "clipboard", }, ], }, ); }); it("keeps the chat shell bounded with a scrollable message area and fixed composer", async () => { const agent = { launchAgentChat: vi.fn(() => new Promise(() => {})), reattachAgentChat: vi.fn(), sendAgentChat: vi.fn(() => new Promise(() => {})), cancelAgentChat: vi.fn(async () => {}), closeAgentChat: vi.fn(async () => {}), }; render( null) }, } as unknown as Gateways} > , ); const shell = screen.getByTestId("custom-agent-chat-view"); const toolbar = await screen.findByRole("toolbar", { name: "custom agent chat actions", }); const scroll = screen.getByTestId("custom-agent-chat-scroll"); const composer = screen.getByTestId("custom-agent-chat-composer"); expect(shell.className).toContain("h-full"); expect(shell.className).toContain("min-h-0"); expect(shell.className).toContain("overflow-hidden"); expect(toolbar.className).toContain("overflow-hidden"); expect(screen.queryByRole("button", { name: "Cancel" })).toBeNull(); expect(scroll.className).toContain("flex-1"); expect(scroll.className).toContain("basis-0"); expect(scroll.className).toContain("overflow-y-auto"); expect(composer.className).toContain("shrink-0"); }); it("falls back when Tauri surfaces structured session NOT_FOUND as a raw string", async () => { const agent = { launchAgentChat: vi.fn(async () => ({ sessionId: "chat-session-2", assignedConversationId: "conversation-2", })), reattachAgentChat: vi .fn() .mockRejectedValueOnce( "not found: structured session 69b57638-0ac7-4a79-8252-e0436a3265f6", ) .mockImplementationOnce(async (sessionId: string) => ({ sessionId, scrollback: [], })), sendAgentChat: vi.fn(() => new Promise(() => {})), cancelAgentChat: vi.fn(async () => {}), closeAgentChat: vi.fn(async () => {}), }; const onSessionId = vi.fn(); render( null) }, } as unknown as Gateways} > , ); await waitFor(() => expect(agent.launchAgentChat).toHaveBeenCalledTimes(1)); expect(agent.reattachAgentChat).toHaveBeenNthCalledWith( 1, "69b57638-0ac7-4a79-8252-e0436a3265f6", expect.any(Function), ); expect(agent.reattachAgentChat).toHaveBeenNthCalledWith( 2, "chat-session-2", expect.any(Function), ); expect(onSessionId).toHaveBeenCalledWith(null); expect(onSessionId).toHaveBeenCalledWith("chat-session-2"); expect(screen.queryByText(/not found: structured session/)).toBeNull(); }); it("does not reopen when the parent echoes a launched session id", async () => { const agent = { launchAgentChat: vi.fn(async () => ({ sessionId: "chat-session-echo", assignedConversationId: "conversation-2", })), reattachAgentChat: vi.fn(async (sessionId: string) => ({ sessionId, scrollback: [], })), sendAgentChat: vi.fn(() => new Promise(() => {})), cancelAgentChat: vi.fn(async () => {}), closeAgentChat: vi.fn(async () => {}), }; const onSessionId = vi.fn(); function EchoingParent() { const [echoedSessionId, setEchoedSessionId] = useState(null); return ( null) }, } as unknown as Gateways} > { onSessionId(nextSessionId); setEchoedSessionId(nextSessionId); }} onConversationId={vi.fn()} /> ); } render(); await waitFor(() => expect(onSessionId).toHaveBeenCalledWith("chat-session-echo"), ); await waitFor(() => expect(agent.launchAgentChat).toHaveBeenCalledTimes(1)); await waitFor(() => expect(agent.reattachAgentChat).toHaveBeenCalledTimes(1)); await new Promise((resolve) => setTimeout(resolve, 20)); expect(agent.launchAgentChat).toHaveBeenCalledTimes(1); expect(agent.reattachAgentChat).toHaveBeenCalledTimes(1); }); it("recovers and retries the prompt when send reports NOT_FOUND", async () => { const agent = { launchAgentChat: vi.fn(async () => ({ sessionId: "chat-session-2", assignedConversationId: "conversation-2", })), reattachAgentChat: vi.fn(async (sessionId: string) => ({ sessionId, scrollback: [], })), sendAgentChat: vi .fn() .mockRejectedValueOnce({ code: "NOT_FOUND", message: "not found: structured session stale-session", }) .mockImplementationOnce(async (_sessionId: string, _prompt: string, onChunk) => { onChunk({ kind: "final", content: "Recovered reply" }); }), cancelAgentChat: vi.fn(async () => {}), closeAgentChat: vi.fn(async () => {}), }; const onSessionId = vi.fn(); render( null) }, } as unknown as Gateways} > , ); await waitFor(() => expect(agent.reattachAgentChat).toHaveBeenCalledWith( "stale-session", expect.any(Function), ), ); fireEvent.change(screen.getByLabelText(/message CLI custom/), { target: { value: "please recover" }, }); fireEvent.click(screen.getByRole("button", { name: "Envoyer" })); await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalledTimes(2)); expect(agent.sendAgentChat).toHaveBeenNthCalledWith( 1, "stale-session", "please recover", expect.any(Function), ); expect(agent.sendAgentChat).toHaveBeenNthCalledWith( 2, "chat-session-2", "please recover", expect.any(Function), ); expect(onSessionId).toHaveBeenCalledWith(null); expect(onSessionId).toHaveBeenCalledWith("chat-session-2"); expect(await screen.findByText("Recovered reply")).toBeTruthy(); expect(screen.queryByText(/not found: structured session/)).toBeNull(); }); it("recovers and retries the prompt when send reports structured session NOT_FOUND as a raw string", async () => { const agent = { launchAgentChat: vi.fn(async () => ({ sessionId: "chat-session-2", assignedConversationId: "conversation-2", })), reattachAgentChat: vi.fn(async (sessionId: string) => ({ sessionId, scrollback: [], })), sendAgentChat: vi .fn() .mockRejectedValueOnce("not found: structured session stale-session") .mockImplementationOnce(async (_sessionId: string, _prompt: string, onChunk) => { onChunk({ kind: "final", content: "Recovered reply" }); }), cancelAgentChat: vi.fn(async () => {}), closeAgentChat: vi.fn(async () => {}), }; const onSessionId = vi.fn(); render( null) }, } as unknown as Gateways} > , ); await waitFor(() => expect(agent.reattachAgentChat).toHaveBeenCalledWith( "stale-session", expect.any(Function), ), ); fireEvent.change(screen.getByLabelText(/message CLI custom/), { target: { value: "please recover" }, }); fireEvent.click(screen.getByRole("button", { name: "Envoyer" })); await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalledTimes(2)); expect(agent.sendAgentChat).toHaveBeenNthCalledWith( 2, "chat-session-2", "please recover", expect.any(Function), ); expect(onSessionId).toHaveBeenCalledWith(null); expect(onSessionId).toHaveBeenCalledWith("chat-session-2"); expect(await screen.findByText("Recovered reply")).toBeTruthy(); expect(screen.queryByText(/not found: structured session/)).toBeNull(); }); it("treats NOT_FOUND during cancel as an already-gone session", async () => { const agent = { launchAgentChat: vi.fn(), reattachAgentChat: vi.fn(async (sessionId: string) => ({ sessionId, scrollback: [], })), sendAgentChat: vi.fn(() => new Promise(() => {})), cancelAgentChat: vi.fn().mockRejectedValueOnce({ code: "NOT_FOUND", message: "not found: structured session gone-session", }), closeAgentChat: vi.fn(async () => {}), }; const onSessionId = vi.fn(); render( null) }, } as unknown as Gateways} > , ); await waitFor(() => expect(agent.reattachAgentChat).toHaveBeenCalledWith( "gone-session", expect.any(Function), ), ); fireEvent.change(screen.getByLabelText(/message CLI custom/), { target: { value: "long turn" }, }); fireEvent.click(screen.getByRole("button", { name: "Envoyer" })); await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalled()); fireEvent.click(await screen.findByRole("button", { name: "Stop" })); expect(await screen.findByText("Session déjà fermée.")).toBeTruthy(); expect(onSessionId).toHaveBeenCalledWith(null); expect(agent.closeAgentChat).not.toHaveBeenCalled(); expect(screen.queryByText(/not found: structured session/)).toBeNull(); }); it("does not swallow post-launch reattach errors", async () => { const agent = { launchAgentChat: vi.fn(async () => ({ sessionId: "new-session", cellKind: "chat", assignedConversationId: "conversation-2", })), reattachAgentChat: vi.fn().mockRejectedValueOnce({ code: "PROCESS", message: "post-launch attach failed", }), sendAgentChat: vi.fn(() => new Promise(() => {})), cancelAgentChat: vi.fn(async () => {}), closeAgentChat: vi.fn(async () => {}), }; render( null) }, } as unknown as Gateways} > , ); expect((await screen.findByRole("alert")).textContent).toContain( "post-launch attach failed", ); }); it("does not publish a session id when custom launch is refused as PTY-routed", async () => { const agent = { launchAgentChat: vi.fn().mockRejectedValueOnce({ code: "STRUCTURED_ROUTED_TO_PTY", message: "custom CLI launch for agent agent-1 in project project-1 returned cellKind=pty; expected chat. sessionId=pty-session-1; nodeId=node-1", }), reattachAgentChat: vi.fn(), sendAgentChat: vi.fn(() => new Promise(() => {})), cancelAgentChat: vi.fn(async () => {}), closeAgentChat: vi.fn(async () => {}), }; const onSessionId = vi.fn(); render( null) }, } as unknown as Gateways} > , ); const alert = await screen.findByRole("alert"); expect(alert.textContent).toContain("cellKind=pty"); expect(agent.launchAgentChat).toHaveBeenCalledTimes(1); expect(agent.reattachAgentChat).not.toHaveBeenCalled(); expect(onSessionId).not.toHaveBeenCalled(); }); it("recovers when post-launch reattach reports NOT_FOUND", async () => { const agent = { launchAgentChat: vi .fn() .mockResolvedValueOnce({ sessionId: "new-session-1", assignedConversationId: "conversation-2", }) .mockResolvedValueOnce({ sessionId: "new-session-2", assignedConversationId: "conversation-2", }), reattachAgentChat: vi .fn() .mockRejectedValueOnce({ code: "NOT_FOUND", message: "not found: structured session new-session-1", }) .mockImplementationOnce(async (sessionId: string) => ({ sessionId, scrollback: [], })), sendAgentChat: vi.fn(() => new Promise(() => {})), cancelAgentChat: vi.fn(async () => {}), closeAgentChat: vi.fn(async () => {}), }; const onSessionId = vi.fn(); render( null) }, } as unknown as Gateways} > , ); await waitFor(() => expect(agent.launchAgentChat).toHaveBeenCalledTimes(2)); expect(agent.reattachAgentChat).toHaveBeenNthCalledWith( 1, "new-session-1", expect.any(Function), ); expect(agent.reattachAgentChat).toHaveBeenNthCalledWith( 2, "new-session-2", expect.any(Function), ); expect(onSessionId).toHaveBeenCalledWith(null); expect(onSessionId).toHaveBeenCalledWith("new-session-2"); expect(screen.queryByText(/not found: structured session/)).toBeNull(); }); it("surfaces non-NOT_FOUND reattach errors instead of launching a new session", async () => { const agent = { launchAgentChat: vi.fn(), reattachAgentChat: vi.fn().mockRejectedValueOnce({ code: "PROCESS", message: "structured session failed to attach", }), sendAgentChat: vi.fn(() => new Promise(() => {})), cancelAgentChat: vi.fn(async () => {}), closeAgentChat: vi.fn(async () => {}), }; render( null) }, } as unknown as Gateways} > , ); expect((await screen.findByRole("alert")).textContent).toContain( "structured session failed to attach", ); expect(agent.launchAgentChat).not.toHaveBeenCalled(); }); });