import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { useState } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { DIProvider } from "@/app/di"; import type { AgentProfile, ReplyChunk } from "@/domain"; import { MockSystemGateway, MockWorkStateGateway } from "@/adapters/mock"; import { AnnouncementsProvider } from "@/features/announcements"; import { PluginRuntimeProvider } from "@/features/plugins"; import { __resetProjectCliInteractionActivityForTests, hasProjectCliInteraction, } from "@/features/projects/projectCliInteractionActivity"; import { PluginCommandRegistry, PluginLayoutRegistry, PluginMenuRegistry, PluginRuntimeRegistry, } from "@/plugins/runtime"; import type { ChatDraftScope, Gateways } from "@/ports"; import { CustomAgentChatView, __clearCustomAgentChatViewCacheForTests, } 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 () => {}, }); } function deferred() { let resolve!: (value: T | PromiseLike) => void; const promise = new Promise((res) => { resolve = res; }); return { promise, resolve }; } function createStreamingAgentHarness() { const emits: Array<((chunk: ReplyChunk) => void) | null> = []; const sendDone = new Map void>(); const sendAgentChat = vi.fn( (_sessionId: string, _prompt: string, onChunk: (chunk: ReplyChunk) => void) => new Promise((resolve) => { const index = emits.length; emits.push((chunk: ReplyChunk) => { onChunk(chunk); if (chunk.kind === "final" || chunk.kind === "error") { sendDone.get(index)?.(); } }); sendDone.set(index, resolve); }), ); return { agent: { launchAgentChat: vi.fn(), reattachAgentChat: vi.fn(async (sessionId: string) => ({ sessionId, scrollback: [], })), sendAgentChat, cancelAgentChat: vi.fn(async () => {}), closeAgentChat: vi.fn(async () => {}), }, sendAgentChat, emit(index: number, chunk: ReplyChunk) { const emit = emits[index]; if (!emit) throw new Error(`Missing emitter for send call #${index + 1}`); emit(chunk); }, emitterCount() { return emits.length; }, }; } function expectedChatTime(ms: number): string { const parts = new Intl.DateTimeFormat(undefined, { hour: "2-digit", minute: "2-digit", hourCycle: "h23", }).formatToParts(new Date(ms)); const hour = parts.find((part) => part.type === "hour")?.value; const minute = parts.find((part) => part.type === "minute")?.value; return `${hour}:${minute}`; } function setMeasuredTextOverflow( element: Element, { scrollHeight, clientHeight }: { scrollHeight: number; clientHeight: number }, ) { Object.defineProperty(element, "scrollHeight", { value: scrollHeight, configurable: true, }); Object.defineProperty(element, "clientHeight", { value: clientHeight, configurable: true, }); fireEvent(window, new Event("resize")); } function renderHistoryChat() { const agent = { launchAgentChat: vi.fn(), reattachAgentChat: vi.fn(async (sessionId: string) => ({ sessionId, scrollback: [], })), sendAgentChat: vi.fn( async (_sessionId: string, prompt: string, onChunk: (chunk: ReplyChunk) => void) => { onChunk({ kind: "final", content: `answer ${prompt}` }); }, ), cancelAgentChat: vi.fn(async () => {}), closeAgentChat: vi.fn(async () => {}), }; render( null) }, } as unknown as Gateways} > , ); return agent; } async function sendHistoryPrompt( composer: HTMLTextAreaElement, agent: ReturnType, prompt: string, ) { const expectedCalls = agent.sendAgentChat.mock.calls.length + 1; fireEvent.change(composer, { target: { value: prompt } }); fireEvent.click(screen.getByRole("button", { name: "Envoyer" })); await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalledTimes(expectedCalls)); } describe("CustomAgentChatView", () => { beforeEach(() => { vi.clearAllMocks(); window.localStorage.clear(); __clearCustomAgentChatViewCacheForTests(); __resetProjectCliInteractionActivityForTests(); }); it("projects self-restart lifecycle separators in the custom CLI only once", async () => { const system = new MockSystemGateway(); const agent = { launchAgentChat: vi.fn(async () => ({ sessionId: "chat-session-restart" })), reattachAgentChat: vi.fn(async (sessionId: string) => ({ sessionId, scrollback: [], })), sendAgentChat: vi.fn(async () => {}), cancelAgentChat: vi.fn(async () => {}), closeAgentChat: vi.fn(async () => {}), }; render( , ); await waitFor(() => expect(agent.launchAgentChat).toHaveBeenCalled()); act(() => { system.emit({ type: "agentRestartRequested", agentId: "agent-1", restartId: "restart-1", reason: null, }); system.emit({ type: "agentRestartCompleted", agentId: "agent-1", restartId: "restart-1", }); }); expect( await screen.findByText("------- redemarrage de l'agent -------"), ).not.toBeNull(); expect( screen.getByText("------- redemarrage de l'agent termine -------"), ).not.toBeNull(); expect(screen.getAllByTestId("agent-restart-separator")).toHaveLength(2); act(() => { system.emit({ type: "agentRestartCompleted", agentId: "agent-1", restartId: "restart-1", }); }); expect(screen.getAllByTestId("agent-restart-separator")).toHaveLength(2); act(() => { system.emit({ type: "agentRestartRequested", agentId: "agent-1", restartId: "restart-2", reason: "new capability", }); system.emit({ type: "agentRestartFailed", agentId: "agent-1", restartId: "restart-2", phase: "launching", message: "runtime unavailable", }); }); expect( screen.getByText("------- redemarrage de l'agent echoue -------"), ).not.toBeNull(); expect(screen.getByText("runtime unavailable")).not.toBeNull(); }); it("reconciles a restarted chat to its replacement session and keeps receiving chunks", async () => { const system = new MockSystemGateway(); const liveReceivers: Array<(chunk: ReplyChunk) => void> = []; const agent = { launchAgentChat: vi.fn(), reattachAgentChat: vi .fn() .mockImplementationOnce( async (_sessionId: string, onChunk: (chunk: ReplyChunk) => void) => { liveReceivers.push(onChunk); return { sessionId: "old-session", conversationId: "old-conversation", scrollback: [{ kind: "final", content: "Avant redémarrage" }], }; }, ) .mockImplementationOnce( async (_sessionId: string, onChunk: (chunk: ReplyChunk) => void) => { liveReceivers.push(onChunk); return { sessionId: "replacement-session", conversationId: "replacement-conversation", scrollback: [ { kind: "final", content: "Avant redémarrage" }, { kind: "userPrompt", text: "Continue" }, { kind: "textDelta", text: "Reprise conservée" }, ], }; }, ), sendAgentChat: vi.fn(async () => {}), cancelAgentChat: vi.fn(async () => {}), closeAgentChat: vi.fn(async () => {}), }; const onSessionId = vi.fn(); const onConversationId = vi.fn(); render( , ); expect(await screen.findByText("Avant redémarrage")).toBeTruthy(); act(() => { system.emit({ type: "agentRestartRequested", agentId: "agent-1", restartId: "restart-295", reason: "reload plugin", }); system.emit({ type: "agentRestartCompleted", agentId: "agent-1", restartId: "restart-295", }); }); await waitFor(() => expect(agent.reattachAgentChat).toHaveBeenCalledTimes(2)); expect(agent.reattachAgentChat).toHaveBeenNthCalledWith( 2, "old-session", expect.any(Function), { projectId: "project-1", agentId: "agent-1" }, ); expect(await screen.findByText("Reprise conservée")).toBeTruthy(); expect(onSessionId).toHaveBeenCalledWith("replacement-session"); expect(onConversationId).toHaveBeenCalledWith("replacement-conversation"); act(() => { liveReceivers[1]?.({ kind: "final", content: "Chunk après redémarrage" }); }); expect(await screen.findByText("Chunk après redémarrage")).toBeTruthy(); expect(screen.queryByText(/Envoyez un message/)).toBeNull(); }); it("retries a stale session attach with stable project and agent identity", async () => { const agent = { launchAgentChat: vi.fn(), reattachAgentChat: vi .fn() .mockRejectedValueOnce({ code: "NOT_FOUND", message: "stale session" }) .mockResolvedValueOnce({ sessionId: "replacement-session", conversationId: "replacement-conversation", scrollback: [{ kind: "final", content: "Transcript restauré" }], }), sendAgentChat: vi.fn(async () => {}), cancelAgentChat: vi.fn(async () => {}), closeAgentChat: vi.fn(async () => {}), }; const onSessionId = vi.fn(); render( , ); expect(await screen.findByText("Transcript restauré")).toBeTruthy(); expect(agent.reattachAgentChat).toHaveBeenNthCalledWith( 2, "stale-session", expect.any(Function), { projectId: "project-1", agentId: "agent-1" }, ); expect(agent.launchAgentChat).not.toHaveBeenCalled(); expect(onSessionId).toHaveBeenCalledWith("replacement-session"); }); it("shows the technical open failure on mobile with retry and native TUI fallback", async () => { const openNativeTui = vi.fn(); const agent = { launchAgentChat: vi .fn() .mockRejectedValueOnce(new Error("spawn codex ENOENT")) .mockResolvedValueOnce({ sessionId: "chat-session-1" }), 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} > , ); const alert = await screen.findByRole("alert"); expect(within(alert).getByText("Le CLI custom n'a pas pu démarrer.")).toBeTruthy(); expect(within(alert).getByText("Cause: spawn codex ENOENT")).toBeTruthy(); expect(screen.queryByText(/Vérifiez la configuration du profil/)).toBeNull(); fireEvent.click(within(alert).getByRole("button", { name: "Ouvrir en TUI native" })); expect(openNativeTui).toHaveBeenCalledTimes(1); fireEvent.click(within(alert).getByRole("button", { name: "Réessayer" })); await waitFor(() => expect(agent.launchAgentChat).toHaveBeenCalledTimes(2)); await waitFor(() => expect(screen.queryByRole("alert")).toBeNull()); }); it("uses the same technical open failure on desktop", async () => { const agent = { launchAgentChat: vi.fn(async () => { throw new Error("permission denied: /opt/codex"); }), reattachAgentChat: vi.fn(), sendAgentChat: vi.fn(async () => {}), cancelAgentChat: vi.fn(async () => {}), closeAgentChat: vi.fn(async () => {}), }; render( null) }, } as unknown as Gateways} > , ); const alert = await screen.findByRole("alert"); expect(within(alert).getByText("Cause: permission denied: /opt/codex")).toBeTruthy(); expect(screen.getByRole("button", { name: "Réessayer" })).toBeTruthy(); expect(screen.queryByRole("button", { name: "Ouvrir en TUI native" })).toBeNull(); }); it("recalls previously sent prompts with arrow up and down", async () => { const agent = { launchAgentChat: vi.fn(), reattachAgentChat: vi.fn(async (sessionId: string) => ({ sessionId, scrollback: [], })), sendAgentChat: vi.fn( async (_sessionId: string, prompt: string, onChunk: (chunk: ReplyChunk) => void) => { onChunk({ kind: "final", content: `answer ${prompt}` }); }, ), cancelAgentChat: vi.fn(async () => {}), closeAgentChat: vi.fn(async () => {}), }; render( null) }, } as unknown as Gateways} > , ); const composer = await screen.findByLabelText(/message CLI custom/) as HTMLTextAreaElement; await waitFor(() => expect(agent.reattachAgentChat).toHaveBeenCalled()); fireEvent.change(composer, { target: { value: "first prompt" } }); fireEvent.click(screen.getByRole("button", { name: "Envoyer" })); await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalledWith( "chat-session-1", "first prompt", expect.any(Function), )); fireEvent.change(composer, { target: { value: "second prompt" } }); fireEvent.click(screen.getByRole("button", { name: "Envoyer" })); await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalledWith( "chat-session-1", "second prompt", expect.any(Function), )); fireEvent.keyDown(composer, { key: "ArrowUp" }); await waitFor(() => expect(composer.value).toBe("second prompt")); fireEvent.keyDown(composer, { key: "ArrowUp" }); await waitFor(() => expect(composer.value).toBe("first prompt")); fireEvent.keyDown(composer, { key: "ArrowDown" }); await waitFor(() => expect(composer.value).toBe("second prompt")); fireEvent.change(composer, { target: { value: "draft in progress" } }); fireEvent.keyDown(composer, { key: "ArrowUp" }); await waitFor(() => expect(composer.value).toBe("second prompt")); fireEvent.keyDown(composer, { key: "ArrowDown" }); await waitFor(() => expect(composer.value).toBe("draft in progress")); }); it("recalls history from the exact end of a multiline draft and restores it verbatim", async () => { const agent = renderHistoryChat(); const composer = (await screen.findByLabelText( /message CLI custom/, )) as HTMLTextAreaElement; await waitFor(() => expect(agent.reattachAgentChat).toHaveBeenCalled()); await sendHistoryPrompt(composer, agent, "historic prompt"); const multilineDraft = "ligne 1\nligne 2\nfin 🧪 "; fireEvent.change(composer, { target: { value: multilineDraft } }); composer.setSelectionRange(multilineDraft.length, multilineDraft.length); Object.defineProperty(composer, "scrollHeight", { value: 480, configurable: true, }); composer.scrollTop = 0; expect(fireEvent.keyDown(composer, { key: "ArrowUp" })).toBe(false); await waitFor(() => expect(composer.value).toBe("historic prompt")); expect(composer.selectionStart).toBe(composer.value.length); expect(composer.selectionEnd).toBe(composer.value.length); expect(composer.scrollTop).toBe(480); expect(fireEvent.keyDown(composer, { key: "ArrowDown" })).toBe(false); await waitFor(() => expect(composer.value).toBe(multilineDraft)); expect(composer.selectionStart).toBe(multilineDraft.length); expect(composer.selectionEnd).toBe(multilineDraft.length); expect(composer.scrollTop).toBe(480); }); it("keeps ArrowUp native away from a simple end caret or during modified/IME input", async () => { const agent = renderHistoryChat(); const composer = (await screen.findByLabelText( /message CLI custom/, )) as HTMLTextAreaElement; await waitFor(() => expect(agent.reattachAgentChat).toHaveBeenCalled()); await sendHistoryPrompt(composer, agent, "historic prompt"); const draft = "première\ndeuxième"; const nativeCases: Array<{ selection: [number, number]; event?: Record; }> = [ { selection: [4, 4] }, { selection: [0, draft.length] }, { selection: [draft.length, draft.length], event: { shiftKey: true } }, { selection: [draft.length, draft.length], event: { ctrlKey: true } }, { selection: [draft.length, draft.length], event: { altKey: true } }, { selection: [draft.length, draft.length], event: { metaKey: true } }, { selection: [draft.length, draft.length], event: { isComposing: true } }, { selection: [draft.length, draft.length], event: { keyCode: 229 } }, ]; for (const testCase of nativeCases) { fireEvent.change(composer, { target: { value: draft } }); composer.setSelectionRange(...testCase.selection); expect( fireEvent.keyDown(composer, { key: "ArrowUp", ...testCase.event }), ).toBe(true); expect(composer.value).toBe(draft); } composer.setSelectionRange(draft.length, draft.length); expect(fireEvent.keyDown(composer, { key: "ArrowUp" })).toBe(false); await waitFor(() => expect(composer.value).toBe("historic prompt")); }); it("does not consume ArrowUp with empty history and never mutates recalled entries", async () => { const agent = renderHistoryChat(); const composer = (await screen.findByLabelText( /message CLI custom/, )) as HTMLTextAreaElement; await waitFor(() => expect(agent.reattachAgentChat).toHaveBeenCalled()); expect(fireEvent.keyDown(composer, { key: "ArrowUp" })).toBe(true); await sendHistoryPrompt(composer, agent, "original history"); fireEvent.keyDown(composer, { key: "ArrowUp" }); await waitFor(() => expect(composer.value).toBe("original history")); fireEvent.change(composer, { target: { value: "edited recall" } }); composer.setSelectionRange(composer.value.length, composer.value.length); fireEvent.keyDown(composer, { key: "ArrowUp" }); await waitFor(() => expect(composer.value).toBe("original history")); fireEvent.click(screen.getByRole("button", { name: "Envoyer" })); await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalledTimes(2)); expect(composer.value).toBe(""); expect(fireEvent.keyDown(composer, { key: "ArrowDown" })).toBe(true); }); it("persists prompt history across a custom chat remount", async () => { const agent = { launchAgentChat: vi.fn(), reattachAgentChat: vi.fn(async (sessionId: string) => ({ sessionId, scrollback: [], })), sendAgentChat: vi.fn( async (_sessionId: string, prompt: string, onChunk: (chunk: ReplyChunk) => void) => { onChunk({ kind: "final", content: `answer ${prompt}` }); }, ), cancelAgentChat: vi.fn(async () => {}), closeAgentChat: vi.fn(async () => {}), }; const props = { projectId: "project-1", agentId: "agent-1", agentName: "Worker", profile, cwd: "/repo", layoutId: "layout-1", nodeId: "node-1", sessionId: "chat-session-1", conversationId: "conversation-1", onSessionId: vi.fn(), onConversationId: vi.fn(), }; const view = render( null) }, } as unknown as Gateways} > , ); let composer = await screen.findByLabelText(/message CLI custom/) as HTMLTextAreaElement; fireEvent.change(composer, { target: { value: "remember me" } }); fireEvent.click(screen.getByRole("button", { name: "Envoyer" })); await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalledWith( "chat-session-1", "remember me", expect.any(Function), )); view.unmount(); render( null) }, } as unknown as Gateways} > , ); composer = await screen.findByLabelText(/message CLI custom/) as HTMLTextAreaElement; fireEvent.keyDown(composer, { key: "ArrowUp" }); await waitFor(() => expect(composer.value).toBe("remember me")); }); it("repaints attachment labels from user prompt scrollback", async () => { const agent = { launchAgentChat: vi.fn(), reattachAgentChat: vi.fn(async (sessionId: string) => ({ sessionId, scrollback: [ { kind: "userPrompt" as const, text: "describe this", attachments: ["clipboard.png"], }, ], })), sendAgentChat: vi.fn(async () => {}), cancelAgentChat: vi.fn(async () => {}), closeAgentChat: vi.fn(async () => {}), }; render( null) }, } as unknown as Gateways} > , ); expect(await screen.findByText("describe this")).toBeTruthy(); expect(screen.getByText("Fichier: clipboard.png")).toBeTruthy(); }); it("renders local reception time in message meta without changing streamed turn grouping", async () => { const userReceivedAtMs = new Date("2026-08-15T13:05:00.000Z").getTime(); const firstDeltaReceivedAtMs = new Date("2026-08-15T13:06:00.000Z").getTime(); const finalChunkReceivedAtMs = new Date("2026-08-15T13:07:00.000Z").getTime(); let nowMs = userReceivedAtMs; const nowSpy = vi.spyOn(Date, "now").mockImplementation(() => nowMs); const agent = { launchAgentChat: vi.fn(), reattachAgentChat: vi.fn(async (sessionId: string) => ({ sessionId, scrollback: [], })), sendAgentChat: vi.fn(async (_sessionId: string, _prompt: string, onChunk) => { nowMs = firstDeltaReceivedAtMs; onChunk({ kind: "textDelta", text: "Streaming reply" }); nowMs = finalChunkReceivedAtMs; onChunk({ kind: "final", content: "Completed reply" }); }), cancelAgentChat: vi.fn(async () => {}), closeAgentChat: vi.fn(async () => {}), }; try { 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: "Initial prompt" }, }); nowMs = userReceivedAtMs; fireEvent.click(screen.getByRole("button", { name: "Envoyer" })); const userBubble = (await screen.findByText("Initial prompt")).closest("div"); const agentBubble = screen.getByText("Streaming reply").closest("div"); const finalBubble = screen.getByText("Completed reply").closest("div"); expect(userBubble).toBeTruthy(); expect(agentBubble).toBeTruthy(); expect(finalBubble).toBeTruthy(); expect(within(userBubble!).getByText(expectedChatTime(userReceivedAtMs))).toBeTruthy(); expect( within(agentBubble!).getByText(expectedChatTime(firstDeltaReceivedAtMs)), ).toBeTruthy(); expect( within(finalBubble!).getByText(expectedChatTime(firstDeltaReceivedAtMs)), ).toBeTruthy(); expect(screen.getByText("Streaming reply")).toBeTruthy(); expect(screen.getByText("Completed reply")).toBeTruthy(); expect(screen.queryByText(expectedChatTime(finalChunkReceivedAtMs))).toBeNull(); } finally { nowSpy.mockRestore(); } }); it("keeps a single reception time on error bubbles using the first streamed chunk time", async () => { const userReceivedAtMs = new Date("2026-08-15T13:05:00.000Z").getTime(); const firstDeltaReceivedAtMs = new Date("2026-08-15T13:06:00.000Z").getTime(); const errorReceivedAtMs = new Date("2026-08-15T13:07:00.000Z").getTime(); let nowMs = userReceivedAtMs; const nowSpy = vi.spyOn(Date, "now").mockImplementation(() => nowMs); const agent = { launchAgentChat: vi.fn(), reattachAgentChat: vi.fn(async (sessionId: string) => ({ sessionId, scrollback: [], })), sendAgentChat: vi.fn(async (_sessionId: string, _prompt: string, onChunk) => { nowMs = firstDeltaReceivedAtMs; onChunk({ kind: "textDelta", text: "Streaming reply" }); nowMs = errorReceivedAtMs; onChunk({ kind: "error", message: "Broken reply" }); }), cancelAgentChat: vi.fn(async () => {}), closeAgentChat: vi.fn(async () => {}), }; try { 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: "Initial prompt" }, }); nowMs = userReceivedAtMs; fireEvent.click(screen.getByRole("button", { name: "Envoyer" })); const agentBubble = (await screen.findByText("Streaming reply")).closest("div"); const errorBubble = screen.getByText("Broken reply").closest("div"); expect(agentBubble).toBeTruthy(); expect(errorBubble).toBeTruthy(); expect(within(errorBubble!).getByText("Session Codex")).toBeTruthy(); expect( within(agentBubble!).getByText(expectedChatTime(firstDeltaReceivedAtMs)), ).toBeTruthy(); expect( within(errorBubble!).getByText(expectedChatTime(firstDeltaReceivedAtMs)), ).toBeTruthy(); expect(screen.queryByText(expectedChatTime(errorReceivedAtMs))).toBeNull(); } finally { nowSpy.mockRestore(); } }); it("replays scrollback with the current reception time instead of the reduce index", async () => { const replayReceivedAtMs = new Date("2026-08-15T13:08:00.000Z").getTime(); const indexOneTime = expectedChatTime(1); const nowSpy = vi.spyOn(Date, "now").mockReturnValue(replayReceivedAtMs); const agent = { launchAgentChat: vi.fn(), reattachAgentChat: vi.fn(async (sessionId: string) => ({ sessionId, scrollback: [ { kind: "userPrompt" as const, text: "Replay prompt" }, { kind: "textDelta" as const, text: "Replay answer" }, { kind: "final" as const, content: "Replay completed" }, ], })), sendAgentChat: vi.fn(async () => {}), cancelAgentChat: vi.fn(async () => {}), closeAgentChat: vi.fn(async () => {}), }; try { render( null) }, } as unknown as Gateways} > , ); const agentBubble = (await screen.findByText("Replay answer")).closest("div"); const finalBubble = screen.getByText("Replay completed").closest("div"); expect(agentBubble).toBeTruthy(); expect(finalBubble).toBeTruthy(); expect( within(agentBubble!).getByText(expectedChatTime(replayReceivedAtMs)), ).toBeTruthy(); expect( within(finalBubble!).getByText(expectedChatTime(replayReceivedAtMs)), ).toBeTruthy(); expect(within(agentBubble!).queryByText(indexOneTime)).toBeNull(); expect(within(finalBubble!).queryByText(indexOneTime)).toBeNull(); } finally { nowSpy.mockRestore(); } }); it("resizes the composer vertically with mouse, keyboard, and double-click reset", 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 agent = { launchAgentChat: vi.fn(), reattachAgentChat: vi.fn(async (sessionId: string) => ({ sessionId, scrollback: [], })), sendAgentChat: vi.fn(async () => {}), cancelAgentChat: vi.fn(async () => {}), closeAgentChat: vi.fn(async () => {}), }; function Harness() { const [height, setHeight] = useState(64); return ( null) }, } as unknown as Gateways} > ); } try { render(); const composer = await screen.findByLabelText(/message CLI custom/) as HTMLTextAreaElement; const handle = screen.getByRole("button", { name: "redimensionner le composer node-1", }); expect(composer.style.height).toBe("64px"); expect(handle.style.cursor).toBe("ns-resize"); fireEvent.mouseDown(handle, { clientY: 200 }); fireEvent.mouseMove(window, { clientY: 100 }); fireEvent.mouseUp(window); await waitFor(() => expect(composer.style.height).toBe("164px")); fireEvent.keyDown(handle, { key: "ArrowUp", shiftKey: true }); await waitFor(() => expect(composer.style.height).toBe("176px")); fireEvent.keyDown(handle, { key: "ArrowDown", shiftKey: true }); await waitFor(() => expect(composer.style.height).toBe("164px")); fireEvent.mouseDown(handle, { clientY: 200 }); fireEvent.mouseMove(window, { clientY: -200 }); fireEvent.mouseUp(window); await waitFor(() => expect(composer.style.height).toBe("200px")); fireEvent.doubleClick(handle); await waitFor(() => expect(composer.style.height).toBe("64px")); } finally { rectSpy.mockRestore(); } }); it("detaches on unmount then reattaches with missed scrollback chunks", async () => { let scrollback: ReplyChunk[] = [ { kind: "userPrompt" as const, text: "Initial prompt" }, { kind: "textDelta" as const, text: "Initial reply" }, ]; const agent = { launchAgentChat: vi.fn(), reattachAgentChat: vi.fn(async (sessionId: string) => ({ sessionId, scrollback, })), detachAgentChat: vi.fn(async () => {}), sendAgentChat: vi.fn(async () => {}), cancelAgentChat: vi.fn(async () => {}), closeAgentChat: vi.fn(async () => {}), }; const props = { projectId: "project-1", agentId: "agent-1", agentName: "Worker", profile, cwd: "/repo", nodeId: "node-1", sessionId: "chat-session-1", conversationId: "conversation-1", onSessionId: vi.fn(), onConversationId: vi.fn(), }; const first = render( null) }, } as unknown as Gateways} > , ); expect(await screen.findByText("Initial reply")).toBeTruthy(); first.unmount(); await waitFor(() => expect(agent.detachAgentChat).toHaveBeenCalledWith("chat-session-1")); scrollback = [ ...scrollback, { kind: "toolActivity" as const, label: "Background tool" }, { kind: "final" as const, content: "Completed away" }, ]; render( null) }, } as unknown as Gateways} > , ); expect(screen.getByText("Initial reply")).toBeTruthy(); expect(await screen.findByText("Background tool")).toBeTruthy(); expect(screen.getByText("Completed away")).toBeTruthy(); expect( screen.queryByText(/Reprise effectuée:/), ).toBeNull(); }); it("keeps the composer locked on layout return until reattach replay settles (#190)", async () => { let scrollback: ReplyChunk[] = [ { kind: "userPrompt" as const, text: "Initial prompt" }, { kind: "final" as const, content: "Initial reply" }, ]; const secondReattach = deferred<{ sessionId: string; scrollback: ReplyChunk[] }>(); const agent = { launchAgentChat: vi.fn(), reattachAgentChat: vi .fn() .mockImplementationOnce(async (sessionId: string) => ({ sessionId, scrollback, })) .mockImplementationOnce(() => secondReattach.promise), detachAgentChat: vi.fn(async () => {}), sendAgentChat: vi.fn(async () => {}), cancelAgentChat: vi.fn(async () => {}), closeAgentChat: vi.fn(async () => {}), }; const props = { projectId: "project-1", agentId: "agent-1", agentName: "Worker", profile, cwd: "/repo", nodeId: "node-1", sessionId: "chat-session-1", conversationId: "conversation-1", onSessionId: vi.fn(), onConversationId: vi.fn(), }; const first = render( null) }, } as unknown as Gateways} > , ); expect(await screen.findByText("Initial reply")).toBeTruthy(); first.unmount(); await waitFor(() => expect(agent.detachAgentChat).toHaveBeenCalledWith("chat-session-1")); scrollback = [ { kind: "userPrompt" as const, text: "Initial prompt" }, { kind: "final" as const, content: "Initial reply" }, { kind: "userPrompt" as const, text: "Question during graph view" }, { kind: "textDelta" as const, text: "Partial answer while away" }, ]; render( null) }, } as unknown as Gateways} > , ); expect(screen.getByText("Initial reply")).toBeTruthy(); expect(screen.getByRole("button", { name: "Envoyer" }).hasAttribute("disabled")).toBe(true); expect(screen.getByLabelText(/message CLI custom/).hasAttribute("disabled")).toBe(true); act(() => { secondReattach.resolve({ sessionId: "chat-session-1", scrollback }); }); expect(await screen.findByText("Question during graph view")).toBeTruthy(); expect(screen.getByText("Partial answer while away")).toBeTruthy(); expect(screen.queryByText(/Reprise effectuée:/)).toBeNull(); expect(screen.getByRole("button", { name: "Stop" })).toBeTruthy(); expect(screen.getByLabelText(/message CLI custom/).hasAttribute("disabled")).toBe(true); }); it("restores visible conversation from conversationId cache when layout returns before sessionId", async () => { let scrollback: ReplyChunk[] = [ { kind: "userPrompt" as const, text: "Initial prompt" }, { kind: "final" as const, content: "Initial reply" }, ]; const secondReattach = deferred<{ sessionId: string; scrollback: ReplyChunk[] }>(); const agent = { launchAgentChat: vi.fn(), reattachAgentChat: vi .fn() .mockImplementationOnce(async (sessionId: string) => ({ sessionId, scrollback, })) .mockImplementationOnce(() => secondReattach.promise), detachAgentChat: vi.fn(async () => {}), sendAgentChat: vi.fn(async () => {}), cancelAgentChat: vi.fn(async () => {}), closeAgentChat: vi.fn(async () => {}), }; const props = { projectId: "project-1", agentId: "agent-1", agentName: "Worker", profile, cwd: "/repo", nodeId: "node-1", sessionId: "chat-session-1" as string | null, conversationId: "conversation-1", onSessionId: vi.fn(), onConversationId: vi.fn(), }; const first = render( null) }, } as unknown as Gateways} > , ); expect(await screen.findByText("Initial reply")).toBeTruthy(); first.unmount(); await waitFor(() => expect(agent.detachAgentChat).toHaveBeenCalledWith("chat-session-1")); scrollback = [ { kind: "userPrompt" as const, text: "Initial prompt" }, { kind: "final" as const, content: "Initial reply" }, { kind: "userPrompt" as const, text: "Question during graph view" }, { kind: "final" as const, content: "Answer restored from replay" }, ]; render( null) }, } as unknown as Gateways} > , ); expect(screen.getByText("Initial reply")).toBeTruthy(); expect(agent.launchAgentChat).not.toHaveBeenCalled(); expect(screen.getByRole("button", { name: "Envoyer" }).hasAttribute("disabled")).toBe(true); act(() => { secondReattach.resolve({ sessionId: "chat-session-1", scrollback }); }); expect(await screen.findByText("Answer restored from replay")).toBeTruthy(); expect(screen.getByText("Question during graph view")).toBeTruthy(); expect(agent.launchAgentChat).not.toHaveBeenCalled(); }); it("drops a stale no-response chunk when replay contains a later real answer (#190)", async () => { const agent = { launchAgentChat: vi.fn(), reattachAgentChat: vi.fn(async (sessionId: string) => ({ sessionId, scrollback: [ { kind: "userPrompt" as const, text: "resume the turn" }, { kind: "error" as const, message: "Le modèle n'a renvoyé aucune réponse.", }, { kind: "textDelta" as const, text: "Real answer " }, { kind: "final" as const, content: "Real answer completed" }, ], })), sendAgentChat: vi.fn(async () => {}), cancelAgentChat: vi.fn(async () => {}), closeAgentChat: vi.fn(async () => {}), }; render( null) }, } as unknown as Gateways} > , ); expect(await screen.findByText("Real answer completed")).toBeTruthy(); expect(screen.getByText("Real answer")).toBeTruthy(); expect(screen.queryByText("Le modèle n'a renvoyé aucune réponse.")).toBeNull(); }); it("cancels only the current request and keeps the structured session usable", async () => { let resolveCancel: () => void = () => {}; const agent = { launchAgentChat: vi.fn(), reattachAgentChat: vi.fn(async (sessionId: string) => ({ sessionId, scrollback: [], })), sendAgentChat: vi .fn() .mockImplementationOnce(() => new Promise(() => {})) .mockImplementationOnce(async (_sessionId: string, _prompt: string, onChunk) => { onChunk({ kind: "final", content: "Next answer" }); }), stopLiveAgent: vi.fn(), cancelAgentChat: vi.fn( () => new Promise((resolve) => { resolveCancel = resolve; }), ), 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(hasProjectCliInteraction("project-1")).toBe(true); expect(screen.queryByRole("button", { name: "Cancel" })).toBeNull(); fireEvent.click(await screen.findByRole("button", { name: "Stop" })); const stoppingLabel = await screen.findByText("Stopping..."); expect(stoppingLabel.closest("button")?.hasAttribute("disabled")).toBe(true); fireEvent.click(stoppingLabel.closest("button")!); await waitFor(() => expect(agent.cancelAgentChat).toHaveBeenCalledWith("chat-session-1"), ); expect(agent.cancelAgentChat).toHaveBeenCalledTimes(1); resolveCancel(); await waitFor(() => expect(screen.getByText("Request stopped.")).toBeTruthy()); expect(hasProjectCliInteraction("project-1")).toBe(false); expect(onSessionId).not.toHaveBeenCalledWith(null); expect(agent.stopLiveAgent).not.toHaveBeenCalled(); expect(agent.closeAgentChat).not.toHaveBeenCalled(); const composer = screen.getByLabelText(/message CLI custom/); expect(composer).toHaveProperty("disabled", false); fireEvent.change(composer, { target: { value: "next turn" }, }); fireEvent.click(screen.getByRole("button", { name: "Envoyer" })); await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalledTimes(2)); expect(agent.sendAgentChat).toHaveBeenNthCalledWith( 2, "chat-session-1", "next turn", expect.any(Function), ); expect(await screen.findByText("Next answer")).toBeTruthy(); }); it("keeps the composer locked from canonical work-state busy even without local stream activity", async () => { const system = new MockSystemGateway(); const workState = new MockWorkStateGateway(); workState._setProjectWorkState("project-1", { agents: [ { agentId: "agent-1", name: "Main", profileId: "structured", busy: { state: "busy", ticket: "turn-1", sinceMs: 1 }, tickets: [], }, ], conversations: [], }); const agent = { launchAgentChat: vi.fn(), reattachAgentChat: vi.fn(async (sessionId: string) => ({ sessionId, scrollback: [], })), sendAgentChat: vi.fn(async () => {}), stopLiveAgent: vi.fn(async () => ({ agentId: "agent-1", sessionId: "chat-session-1", kind: "structured" as const, })), cancelAgentChat: vi.fn(async () => {}), closeAgentChat: vi.fn(async () => {}), }; render( , ); const composer = await screen.findByLabelText(/message CLI custom/); await waitFor(() => expect(composer).toHaveProperty("disabled", true)); expect(screen.getByRole("button", { name: "Stop" })).toBeTruthy(); workState._setProjectWorkState("project-1", { agents: [ { agentId: "agent-1", name: "Main", profileId: "structured", busy: { state: "idle" }, tickets: [], }, ], conversations: [], }); system.emit({ type: "agentBusyChanged", agentId: "agent-1", busy: false }); await waitFor(() => expect(composer).toHaveProperty("disabled", false)); expect(screen.getByRole("button", { name: "Envoyer" })).toBeTruthy(); }); it("shows the requester as waiting on the delegated target and keeps input locked", async () => { const system = new MockSystemGateway(); const workState = new MockWorkStateGateway(); workState._setProjectWorkState("project-1", { agents: [ { agentId: "main", name: "Main", profileId: "structured", busy: { state: "idle" }, tickets: [], }, { agentId: "architect", name: "Architect", profileId: "structured", busy: { state: "busy", ticket: "delegated-1", sinceMs: 1 }, tickets: [ { ticketId: "delegated-1", conversationId: "conversation-2", position: 0, status: "inProgress", source: { kind: "agent", agentId: "main" }, requesterLabel: "Main", taskPreview: "Analyse l'architecture", taskLen: 22, }, ], }, ], conversations: [], }); const agent = { launchAgentChat: vi.fn(), reattachAgentChat: vi.fn(async (sessionId: string) => ({ sessionId, scrollback: [], })), sendAgentChat: vi.fn(async () => {}), stopLiveAgent: vi.fn(async () => ({ agentId: "main", sessionId: "chat-session-1", kind: "structured" as const, })), cancelAgentChat: vi.fn(async () => {}), closeAgentChat: vi.fn(async () => {}), }; render( , ); expect(await screen.findByText("Main attend Architect")).toBeTruthy(); expect(screen.getByLabelText(/message CLI custom/)).toHaveProperty("disabled", true); }); 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" }) .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(hasProjectCliInteraction("project-1")).toBe(false); expect(screen.getAllByText("hello agent")).toHaveLength(1); expect(screen.getByText("Terminé").closest(".justify-end")).toBeTruthy(); }); it("keeps successive turns isolated when backend userPrompt echoes arrive after progress (#251)", async () => { const agent = { launchAgentChat: vi.fn(), reattachAgentChat: vi.fn(async (sessionId: string) => ({ sessionId, scrollback: [], })), sendAgentChat: vi.fn( async (_sessionId: string, prompt: string, onChunk: (chunk: ReplyChunk) => void) => { onChunk({ kind: "progress", progress: { source: "providerNative", kind: "tool", stage: "started", label: "shell", toolName: "shell", text: `Running ${prompt}`, }, }); onChunk({ kind: "userPrompt", text: prompt }); onChunk({ kind: "progress", progress: { source: "providerNative", kind: "tool", stage: "completed", label: "shell", toolName: "shell", text: `Done ${prompt}`, }, }); onChunk({ kind: "final", content: `final ${prompt}` }); }, ), 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: "first request" }, }); fireEvent.click(screen.getByRole("button", { name: "Envoyer" })); await screen.findByText("final first request"); fireEvent.change(screen.getByLabelText(/message CLI custom/), { target: { value: "second request" }, }); fireEvent.click(screen.getByRole("button", { name: "Envoyer" })); await screen.findByText("final second request"); expect(screen.getAllByText("first request")).toHaveLength(1); expect(screen.getAllByText("second request")).toHaveLength(1); expect(screen.getAllByTestId("chat-tool-progress")).toHaveLength(2); expect(screen.queryByText("Running first request")).toBeNull(); expect(screen.queryByText("Running second request")).toBeNull(); expect(screen.getByText("Done first request")).toBeTruthy(); expect(screen.getByText("Done second request")).toBeTruthy(); expect(screen.getAllByText("Terminé")).toHaveLength(2); const firstPrompt = screen.getByText("first request"); const firstProgress = screen.getByText("Done first request"); const firstFinal = screen.getByText("final first request"); const secondPrompt = screen.getByText("second request"); const secondProgress = screen.getByText("Done second request"); const secondFinal = screen.getByText("final second request"); expect( Boolean(firstPrompt.compareDocumentPosition(firstProgress) & Node.DOCUMENT_POSITION_FOLLOWING), ).toBe(true); expect( Boolean(firstProgress.compareDocumentPosition(firstFinal) & Node.DOCUMENT_POSITION_FOLLOWING), ).toBe(true); expect( Boolean(firstFinal.compareDocumentPosition(secondPrompt) & Node.DOCUMENT_POSITION_FOLLOWING), ).toBe(true); expect( Boolean(secondPrompt.compareDocumentPosition(secondProgress) & Node.DOCUMENT_POSITION_FOLLOWING), ).toBe(true); expect( Boolean(secondProgress.compareDocumentPosition(secondFinal) & Node.DOCUMENT_POSITION_FOLLOWING), ).toBe(true); }); 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 repeated legacy tool progress as fresh activity on each turn", 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: "ideaLocal", kind: "mcp", stage: "started", label: "idea_context_read", toolName: "idea_context_read", text: "Lecture du contexte", }, }); onChunk({ kind: "progress", progress: { source: "ideaLocal", kind: "mcp", stage: "completed", label: "idea_context_read", toolName: "idea_context_read", text: "Lecture du contexte", }, }); onChunk({ kind: "final", content: `done ${prompt}` }); }), 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: "first" }, }); fireEvent.click(screen.getByRole("button", { name: "Envoyer" })); await screen.findByText("done first"); fireEvent.change(screen.getByLabelText(/message CLI custom/), { target: { value: "second" }, }); fireEvent.click(screen.getByRole("button", { name: "Envoyer" })); await screen.findByText("done second"); expect(screen.getAllByTestId("chat-tool-progress")).toHaveLength(2); expect(screen.getAllByText("idea_context_read")).toHaveLength(2); }); it("surfaces second-turn streaming progress through a distinct callback after the first turn completed", async () => { const harness = createStreamingAgentHarness(); render( null) }, } as unknown as Gateways} > , ); await waitFor(() => expect(harness.agent.reattachAgentChat).toHaveBeenCalledWith( "chat-session-1", expect.any(Function), ), ); fireEvent.change(screen.getByLabelText(/message CLI custom/), { target: { value: "first request" }, }); fireEvent.click(screen.getByRole("button", { name: "Envoyer" })); await waitFor(() => expect(harness.emitterCount()).toBe(1)); act(() => { harness.emit(0, { kind: "progress", progress: { source: "providerNative", kind: "tool", stage: "started", label: "shell", toolName: "shell", text: "Running first request", }, }); }); expect(screen.getByText("Running first request")).toBeTruthy(); expect(screen.getByText("Tool").parentElement?.textContent).toContain("En cours"); act(() => { harness.emit(0, { kind: "progress", progress: { source: "providerNative", kind: "tool", stage: "completed", label: "shell", toolName: "shell", text: "Done first request", }, }); harness.emit(0, { kind: "final", content: "final first request" }); }); await screen.findByText("final first request"); fireEvent.change(screen.getByLabelText(/message CLI custom/), { target: { value: "second request" }, }); fireEvent.click(screen.getByRole("button", { name: "Envoyer" })); await waitFor(() => expect(harness.emitterCount()).toBe(2)); act(() => { harness.emit(1, { kind: "progress", progress: { source: "providerNative", kind: "message", stage: "delta", label: "Analyse", text: "Second request context loaded.", }, }); }); expect(screen.getByText("Second request context loaded.")).toBeTruthy(); expect(screen.getByText("Progress").parentElement?.textContent).toContain("En cours"); act(() => { harness.emit(1, { kind: "progress", progress: { source: "ideaLocal", kind: "mcp", stage: "started", label: "idea_ticket_read", toolName: "idea_ticket_read", text: "Reading ticket for second request", }, }); }); expect(screen.getByText("Reading ticket for second request")).toBeTruthy(); expect(screen.getAllByTestId("chat-tool-progress")).toHaveLength(2); act(() => { harness.emit(1, { kind: "progress", progress: { source: "ideaLocal", kind: "mcp", stage: "completed", label: "idea_ticket_read", toolName: "idea_ticket_read", text: "Ticket read complete", }, }); harness.emit(1, { kind: "final", content: "final second request" }); }); await screen.findByText("final second request"); expect(screen.getByText("Ticket read complete")).toBeTruthy(); expect(screen.getByText("Second request context loaded.")).toBeTruthy(); expect(screen.getAllByText("Terminé")).toHaveLength(2); }); it("treats same-tool progress on the second turn as fresh streaming activity with a distinct callback", async () => { const harness = createStreamingAgentHarness(); render( null) }, } as unknown as Gateways} > , ); await waitFor(() => expect(harness.agent.reattachAgentChat).toHaveBeenCalledWith( "chat-session-1", expect.any(Function), ), ); fireEvent.change(screen.getByLabelText(/message CLI custom/), { target: { value: "first request" }, }); fireEvent.click(screen.getByRole("button", { name: "Envoyer" })); await waitFor(() => expect(harness.emitterCount()).toBe(1)); act(() => { harness.emit(0, { kind: "progress", progress: { source: "ideaLocal", kind: "mcp", stage: "started", label: "idea_context_read", toolName: "idea_context_read", text: "Reading shared context for first request", }, }); harness.emit(0, { kind: "progress", progress: { source: "ideaLocal", kind: "mcp", stage: "completed", label: "idea_context_read", toolName: "idea_context_read", text: "Context read complete for first request", }, }); harness.emit(0, { kind: "final", content: "done first request" }); }); await screen.findByText("done first request"); fireEvent.change(screen.getByLabelText(/message CLI custom/), { target: { value: "second request" }, }); fireEvent.click(screen.getByRole("button", { name: "Envoyer" })); await waitFor(() => expect(harness.emitterCount()).toBe(2)); act(() => { harness.emit(1, { kind: "progress", progress: { source: "ideaLocal", kind: "mcp", stage: "started", label: "idea_context_read", toolName: "idea_context_read", text: "Reading shared context for second request", }, }); }); expect(screen.getByText("Reading shared context for second request")).toBeTruthy(); expect(screen.getAllByText("idea_context_read")).toHaveLength(2); act(() => { harness.emit(1, { kind: "progress", progress: { source: "ideaLocal", kind: "mcp", stage: "completed", label: "idea_context_read", toolName: "idea_context_read", text: "Context read complete for second request", }, }); harness.emit(1, { kind: "final", content: "done second request" }); }); await screen.findByText("done second request"); expect(screen.getByText("Context read complete for second request")).toBeTruthy(); expect(screen.getByText("Context read complete for first request")).toBeTruthy(); expect(screen.getAllByTestId("chat-tool-progress")).toHaveLength(2); }); it("keeps second-turn progress visible when the provider reuses the same progressId across turns", async () => { const harness = createStreamingAgentHarness(); render( null) }, } as unknown as Gateways} > , ); await waitFor(() => expect(harness.agent.reattachAgentChat).toHaveBeenCalledWith( "chat-session-1", expect.any(Function), ), ); fireEvent.change(screen.getByLabelText(/message CLI custom/), { target: { value: "first request" }, }); fireEvent.click(screen.getByRole("button", { name: "Envoyer" })); await waitFor(() => expect(harness.emitterCount()).toBe(1)); act(() => { harness.emit(0, { kind: "progress", progress: { progressId: "codex:item:call_1", source: "providerNative", kind: "tool", stage: "started", label: "shell", toolName: "shell", text: "Running first request", }, }); harness.emit(0, { kind: "progress", progress: { progressId: "codex:item:call_1", source: "providerNative", kind: "tool", stage: "completed", label: "shell", toolName: "shell", text: "Done first request", }, }); harness.emit(0, { kind: "final", content: "done first request" }); }); await screen.findByText("done first request"); fireEvent.change(screen.getByLabelText(/message CLI custom/), { target: { value: "second request" }, }); fireEvent.click(screen.getByRole("button", { name: "Envoyer" })); await waitFor(() => expect(harness.emitterCount()).toBe(2)); act(() => { harness.emit(1, { kind: "progress", progress: { progressId: "codex:item:call_1", source: "providerNative", kind: "tool", stage: "started", label: "shell", toolName: "shell", text: "Running second request", }, }); }); expect(screen.getByText("Running second request")).toBeTruthy(); act(() => { harness.emit(1, { kind: "progress", progress: { progressId: "codex:item:call_1", source: "providerNative", kind: "tool", stage: "completed", label: "shell", toolName: "shell", text: "Done second request", }, }); harness.emit(1, { kind: "final", content: "done second request" }); }); await screen.findByText("done second request"); expect(screen.getByText("Done first request")).toBeTruthy(); expect(screen.getByText("Done second request")).toBeTruthy(); expect(screen.getAllByTestId("chat-tool-progress")).toHaveLength(2); }); it("ignores a late first-turn progress chunk but still shows the real second-turn progress from the next callback", async () => { const harness = createStreamingAgentHarness(); render( null) }, } as unknown as Gateways} > , ); await waitFor(() => expect(harness.agent.reattachAgentChat).toHaveBeenCalledWith( "chat-session-1", expect.any(Function), ), ); fireEvent.change(screen.getByLabelText(/message CLI custom/), { target: { value: "first request" }, }); fireEvent.click(screen.getByRole("button", { name: "Envoyer" })); await waitFor(() => expect(harness.emitterCount()).toBe(1)); act(() => { harness.emit(0, { kind: "progress", progress: { source: "providerNative", kind: "tool", stage: "started", label: "shell", toolName: "shell", text: "Running first request", }, }); harness.emit(0, { kind: "final", content: "done first request" }); }); await screen.findByText("done first request"); act(() => { harness.emit(0, { kind: "progress", progress: { source: "providerNative", kind: "tool", stage: "completed", label: "shell", toolName: "shell", text: "Late first request completion", }, }); }); expect(screen.queryByText("Late first request completion")).toBeNull(); fireEvent.change(screen.getByLabelText(/message CLI custom/), { target: { value: "second request" }, }); fireEvent.click(screen.getByRole("button", { name: "Envoyer" })); await waitFor(() => expect(harness.emitterCount()).toBe(2)); act(() => { harness.emit(1, { kind: "progress", progress: { source: "providerNative", kind: "tool", stage: "started", label: "shell", toolName: "shell", text: "Running second request", }, }); }); expect(screen.getByText("Running second request")).toBeTruthy(); act(() => { harness.emit(1, { kind: "progress", progress: { source: "providerNative", kind: "tool", stage: "completed", label: "shell", toolName: "shell", text: "Done second request", }, }); harness.emit(1, { kind: "final", content: "done second request" }); }); await screen.findByText("done second request"); expect(screen.getByText("Done second request")).toBeTruthy(); expect(screen.queryByText("Late first request completion")).toBeNull(); }); 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("En cours"); expect(screen.queryByRole("status", { name: "Loading" })).toBeNull(); 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("keeps repeated MCP cycles visible when legacy progress has no progressId", 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: "read many tickets" }, }); fireEvent.click(screen.getByRole("button", { name: "Envoyer" })); await waitFor(() => expect(emitChunk).not.toBeNull()); act(() => { for (let index = 1; index <= 8; index += 1) { emitChunk?.({ kind: "progress" as const, progress: { source: "ideaLocal" as const, kind: "mcp" as const, stage: "started" as const, label: "idea_ticket_read", toolName: "idea_ticket_read", text: `reading ticket #${index}`, }, }); emitChunk?.({ kind: "progress" as const, progress: { source: "ideaLocal" as const, kind: "mcp" as const, stage: "completed" as const, label: "idea_ticket_read", toolName: "idea_ticket_read", text: `read ticket #${index}`, }, }); } emitChunk?.({ kind: "final", content: "done" }); }); await screen.findByText("done"); expect(screen.getAllByTestId("chat-tool-progress")).toHaveLength(8); expect(screen.getAllByText("MCP")).toHaveLength(8); expect(screen.getAllByText("idea_ticket_read")).toHaveLength(8); expect(screen.getByText("read ticket #1")).toBeTruthy(); expect(screen.getByText("read ticket #8")).toBeTruthy(); expect(screen.queryByText("reading ticket #1")).toBeNull(); }); it("deduplicates identical entity progress chunks", 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: "read ticket" }, }); fireEvent.click(screen.getByRole("button", { name: "Envoyer" })); await waitFor(() => expect(emitChunk).not.toBeNull()); const chunk = { kind: "progress" as const, progress: { source: "ideaLocal" as const, kind: "mcp" as const, stage: "started" as const, label: "idea_ticket_read", toolName: "idea_ticket_read", }, }; act(() => { emitChunk?.(chunk); emitChunk?.(chunk); }); expect(screen.getAllByTestId("chat-tool-progress")).toHaveLength(1); expect(screen.getAllByText("idea_ticket_read")).toHaveLength(1); }); it("updates entity progress deltas in place", 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: "download model" }, }); fireEvent.click(screen.getByRole("button", { name: "Envoyer" })); await waitFor(() => expect(emitChunk).not.toBeNull()); const progress = (stage: "started" | "delta", text: string) => ({ kind: "progress" as const, progress: { source: "providerNative" as const, kind: "tool" as const, stage, label: "download_model", toolName: "download_model", text, }, }); act(() => { emitChunk?.(progress("started", "Downloading 10%")); emitChunk?.(progress("delta", "Downloading 75%")); }); expect(screen.getAllByTestId("chat-tool-progress")).toHaveLength(1); expect(screen.queryByText("Downloading 10%")).toBeNull(); expect(screen.getByText("Downloading 75%")).toBeTruthy(); expect(screen.getByText("Tool").parentElement?.textContent).toContain("En cours"); }); it("expands only truncated MCP progress details and preserves expansion across updates", 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: "inspect mcp payloads" }, }); fireEvent.click(screen.getByRole("button", { name: "Envoyer" })); await waitFor(() => expect(emitChunk).not.toBeNull()); act(() => { emitChunk?.({ kind: "progress" as const, progress: { progressId: "mcp-large", source: "ideaLocal" as const, kind: "mcp" as const, stage: "started" as const, label: "idea_ticket_read", toolName: "idea_ticket_read", text: "line 1\nline 2\nline 3\nline 4\nline 5", }, }); emitChunk?.({ kind: "progress" as const, progress: { progressId: "mcp-small", source: "ideaLocal" as const, kind: "mcp" as const, stage: "started" as const, label: "idea_memory_read", toolName: "idea_memory_read", text: "short payload", }, }); }); const details = await screen.findAllByTestId("chat-progress-detail"); expect(details).toHaveLength(2); setMeasuredTextOverflow(details[0], { scrollHeight: 120, clientHeight: 48 }); setMeasuredTextOverflow(details[1], { scrollHeight: 24, clientHeight: 24 }); const expand = await screen.findByRole("button", { name: "Afficher plus" }); expect(expand.getAttribute("aria-expanded")).toBe("false"); expect(expand.getAttribute("aria-controls")).toBe(details[0].id); expect(screen.queryAllByRole("button", { name: "Afficher plus" })).toHaveLength(1); fireEvent.click(expand); expect( screen.getByRole("button", { name: "Afficher moins" }).getAttribute("aria-expanded"), ).toBe("true"); expect(screen.queryByRole("button", { name: "Afficher plus" })).toBeNull(); act(() => { emitChunk?.({ kind: "progress" as const, progress: { progressId: "mcp-large", source: "ideaLocal" as const, kind: "mcp" as const, stage: "delta" as const, label: "idea_ticket_read", toolName: "idea_ticket_read", text: "updated line 1\nupdated line 2\nupdated line 3\nupdated line 4\nupdated line 5", }, }); }); expect(screen.getByText(/updated line 5/)).toBeTruthy(); expect( screen.getByRole("button", { name: "Afficher moins" }).getAttribute("aria-expanded"), ).toBe("true"); expect(screen.queryByRole("button", { name: "Afficher plus" })).toBeNull(); }); it("renders progress immediately when the chunk callback fires", 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: "stream progress" }, }); fireEvent.click(screen.getByRole("button", { name: "Envoyer" })); await waitFor(() => expect(emitChunk).not.toBeNull()); act(() => { emitChunk?.({ kind: "progress" as const, progress: { source: "providerNative" as const, kind: "tool" as const, stage: "started" as const, label: "stream_repo", toolName: "stream_repo", text: "Chunk received", }, }); }); expect(screen.getByText("Chunk received")).toBeTruthy(); expect(screen.getAllByTestId("chat-tool-progress")).toHaveLength(1); }); it("upserts progress by progressId and keeps first emission chronological order", 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: "run tools" }, }); fireEvent.click(screen.getByRole("button", { name: "Envoyer" })); await waitFor(() => expect(emitChunk).not.toBeNull()); const progress = ( progressId: string, occurredAtMs: number, stage: "started" | "completed", text: string, ) => ({ kind: "progress" as const, progress: { progressId, occurredAtMs, source: "providerNative" as const, kind: "tool" as const, stage, label: "shell", toolName: "shell", text, }, }); act(() => { emitChunk?.(progress("process-2", 20, "started", "Process 2 start")); emitChunk?.(progress("process-1", 10, "started", "Process 1 start")); emitChunk?.(progress("process-1", 10, "completed", "Process 1 done")); }); expect(screen.getAllByTestId("chat-tool-progress")).toHaveLength(2); expect(screen.queryByText("Process 1 start")).toBeNull(); expect(screen.getByText("Process 1 done")).toBeTruthy(); const first = screen.getByText("Process 1 done"); const second = screen.getByText("Process 2 start"); expect( Boolean(first.compareDocumentPosition(second) & Node.DOCUMENT_POSITION_FOLLOWING), ).toBe(true); }); it("moves completed entity progress from active map to turns", 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: "call tool" }, }); fireEvent.click(screen.getByRole("button", { name: "Envoyer" })); await waitFor(() => expect(emitChunk).not.toBeNull()); const progress = (stage: "started" | "completed") => ({ kind: "progress" as const, progress: { source: "providerNative" as const, kind: "tool" as const, stage, label: "search_repo", toolName: "search_repo", text: stage === "started" ? "Searching..." : "Found results", }, }); act(() => { emitChunk?.(progress("started")); emitChunk?.(progress("completed")); }); expect(screen.getAllByTestId("chat-tool-progress")).toHaveLength(1); expect(screen.queryByText("Searching...")).toBeNull(); expect(screen.getByText("Found results")).toBeTruthy(); expect(screen.getByText("Tool").parentElement?.textContent).toContain("Terminé"); act(() => { emitChunk?.({ kind: "final", content: "done" }); }); await screen.findByText("done"); expect(screen.getAllByTestId("chat-tool-progress")).toHaveLength(1); expect(screen.getByText("Found results")).toBeTruthy(); }); it("renders running progress without spinner and marks it done on final", 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.queryByRole("status", { name: "Loading" })).toBeNull(); expect(screen.getByText("Progress").parentElement?.textContent).toContain("En cours"); 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("Terminé"); }); it("ignores late progress chunks after the final answer without reopening the turn", 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.getByText("done")).toBeTruthy(); expect(screen.queryByText("idea_ticket_read")).toBeNull(); expect(screen.getByRole("button", { name: "Envoyer" })).toBeTruthy(); expect(screen.queryByRole("button", { name: "Stop" })).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: "ArrowUp" }); 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), }); const composer = screen.getByLabelText(/message CLI custom/) as HTMLTextAreaElement; fireEvent.paste(composer, { 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(); await waitFor(() => expect(composer.value).toBe("[Image 1]")); fireEvent.click(screen.getByRole("button", { name: "retirer clipboard.png" })); await waitFor(() => expect(screen.queryByText("Fichier joint: clipboard.png")).toBeNull(), ); expect(composer.value).toBe(""); expect( (screen.getByRole("button", { name: "Envoyer" }) as HTMLButtonElement) .disabled, ).toBe(true); }); it("falls back to the async clipboard API when pasted images are absent from DataTransfer", async () => { const originalClipboard = Object.getOwnPropertyDescriptor( window.navigator, "clipboard", ); const read = vi.fn(async () => [ { types: ["image/png"], getType: vi.fn(async () => new Blob([new Uint8Array([1, 2, 3])], { type: "image/png", })), }, ]); Object.defineProperty(window.navigator, "clipboard", { configurable: true, value: { read }, }); 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 () => {}), }; try { render( null) }, } as unknown as Gateways} > , ); await waitFor(() => expect(agent.reattachAgentChat).toHaveBeenCalledWith( "chat-session-1", expect.any(Function), ), ); fireEvent.paste(screen.getByLabelText(/message CLI custom/), { clipboardData: { items: [], files: [], getData: () => "", }, }); await screen.findByText(/Fichier joint: clipboard-image-\d+\.png/); expect(read).toHaveBeenCalledTimes(1); fireEvent.click(screen.getByRole("button", { name: "Envoyer" })); await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalledTimes(1)); expect(agent.sendAgentChat).toHaveBeenCalledWith( "chat-session-1", "[Image 1]", expect.any(Function), { attachments: [ { filename: expect.stringMatching(/^clipboard-image-\d+\.png$/), contentBase64: "AQID", mime: "image/png", sourceKind: "clipboard", }, ], }, ); } finally { if (originalClipboard) { Object.defineProperty(window.navigator, "clipboard", originalClipboard); } else { Object.defineProperty(window.navigator, "clipboard", { configurable: true, value: undefined, }); } } }); it("shows a non-blocking error when the async clipboard image read fails", async () => { const originalClipboard = Object.getOwnPropertyDescriptor( window.navigator, "clipboard", ); Object.defineProperty(window.navigator, "clipboard", { configurable: true, value: { read: vi.fn(async () => { throw new Error("clipboard denied"); }) }, }); 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 () => {}), }; try { render( null) }, } as unknown as Gateways} > , ); await waitFor(() => expect(agent.reattachAgentChat).toHaveBeenCalledWith( "chat-session-1", expect.any(Function), ), ); fireEvent.paste(screen.getByLabelText(/message CLI custom/), { clipboardData: { items: [], files: [], getData: () => "", }, }); expect((await screen.findByRole("alert")).textContent).toContain( "clipboard denied", ); expect( (screen.getByRole("button", { name: "Envoyer" }) as HTMLButtonElement) .disabled, ).toBe(true); } finally { if (originalClipboard) { Object.defineProperty(window.navigator, "clipboard", originalClipboard); } else { Object.defineProperty(window.navigator, "clipboard", { configurable: true, value: undefined, }); } } }); 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", "[Image 1]", expect.any(Function), { attachments: [ { filename: "paste-image.png", contentBase64: "AQID", mime: "image/png", sourceKind: "clipboard", }, ], }, ); expect(screen.getByText("[Image 1]")).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 pasted text insertion when clipboard files are attached", 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 composer = screen.getByLabelText(/message CLI custom/) as HTMLTextAreaElement; fireEvent.change(composer, { target: { value: "prefix suffix" } }); composer.setSelectionRange(7, 13); const file = new File(["ignored"], "mixed.txt", { type: "text/plain" }); Object.defineProperty(file, "arrayBuffer", { value: vi.fn(async () => new Uint8Array([7, 8, 9]).buffer), }); fireEvent.paste(composer, { clipboardData: { items: [ { kind: "file", type: "text/plain", getAsFile: () => file, }, ], getData: () => "inserted", }, }); await screen.findByText("Fichier joint: mixed.txt"); await waitFor(() => expect(composer.value).toBe("prefix inserted")); fireEvent.click(screen.getByRole("button", { name: "Envoyer" })); await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalledTimes(1)); expect(agent.sendAgentChat).toHaveBeenCalledWith( "chat-session-1", "prefix inserted", expect.any(Function), { attachments: [ { filename: "mixed.txt", contentBase64: "BwgJ", mime: "text/plain", sourceKind: "clipboard", }, ], }, ); expect(screen.getByText("prefix inserted")).toBeTruthy(); expect(screen.getByText("Fichier: mixed.txt")).toBeTruthy(); }); it("inserts pasted image tokens at the cursor and sends the tokenized projection", 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 composer = screen.getByLabelText(/message CLI custom/) as HTMLTextAreaElement; fireEvent.change(composer, { target: { value: "voir maintenant" } }); composer.setSelectionRange(5, 5); const file = new File(["ignored"], "cursor.png", { type: "image/png" }); Object.defineProperty(file, "arrayBuffer", { value: vi.fn(async () => new Uint8Array([1, 2, 3]).buffer), }); fireEvent.paste(composer, { clipboardData: { items: [ { kind: "file", type: "image/png", getAsFile: () => file, }, ], getData: () => "", }, }); await screen.findByText("Fichier joint: cursor.png"); await waitFor(() => expect(composer.value).toBe("voir [Image 1] maintenant")); fireEvent.click(screen.getByRole("button", { name: "Envoyer" })); await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalledTimes(1)); expect(agent.sendAgentChat).toHaveBeenCalledWith( "chat-session-1", "voir [Image 1] maintenant", expect.any(Function), { attachments: [ { filename: "cursor.png", contentBase64: "AQID", mime: "image/png", sourceKind: "clipboard", }, ], }, ); }); it("deduplicates clipboard files by metadata across clipboard sources and repeated paste", 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"], "duplicate.pdf", { type: "application/pdf", lastModified: 123, }); Object.defineProperty(file, "arrayBuffer", { value: vi.fn(async () => new Uint8Array([10, 11, 12]).buffer), }); const clipboardData = { items: [ { kind: "file", type: "application/pdf", getAsFile: () => file, }, ], files: [file], getData: () => "", }; const composer = screen.getByLabelText(/message CLI custom/); fireEvent.paste(composer, { clipboardData }); await screen.findByText("Fichier joint: duplicate.pdf"); fireEvent.paste(composer, { clipboardData }); await waitFor(() => expect(screen.getAllByText("Fichier joint: duplicate.pdf")).toHaveLength(1), ); }); it("restores and saves drafts scoped by project, layout, and node", async () => { const drafts = new Map([ ["project-1::layout-a::node-1", "restored draft"], ]); const key = (scope: ChatDraftScope) => `${scope.projectId}::${scope.layoutId}::${scope.nodeId}`; const agent = { launchAgentChat: vi.fn(), reattachAgentChat: vi.fn(async (sessionId: string) => ({ sessionId, scrollback: [], })), sendAgentChat: vi.fn(async () => {}), readChatDraft: vi.fn(async (scope: ChatDraftScope) => ({ text: drafts.get(key(scope)) ?? null, updatedAtMs: null, })), saveChatDraft: vi.fn(async (scope: ChatDraftScope, text: string) => { if (text) drafts.set(key(scope), text); else drafts.delete(key(scope)); }), clearChatDraft: vi.fn(async (scope: ChatDraftScope) => { drafts.delete(key(scope)); }), cancelAgentChat: vi.fn(async () => {}), closeAgentChat: vi.fn(async () => {}), }; const baseProps = { agentId: "agent-1", agentName: "Worker", profile, cwd: "/repo", nodeId: "node-1", sessionId: "chat-session-1", conversationId: "conversation-1", onSessionId: vi.fn(), onConversationId: vi.fn(), }; const { rerender } = render( null) }, } as unknown as Gateways} > , ); const composer = await screen.findByDisplayValue("restored draft"); fireEvent.change(composer, { target: { value: "edited draft" } }); await waitFor(() => expect(agent.saveChatDraft).toHaveBeenCalledWith( { projectId: "project-1", layoutId: "layout-a", nodeId: "node-1" }, "edited draft", ), ); rerender( null) }, } as unknown as Gateways} > , ); await waitFor(() => expect((screen.getByLabelText(/message CLI custom/) as HTMLTextAreaElement).value).toBe(""), ); rerender( null) }, } as unknown as Gateways} > , ); await screen.findByDisplayValue("edited draft"); rerender( null) }, } as unknown as Gateways} > , ); await waitFor(() => expect((screen.getByLabelText(/message CLI custom/) as HTMLTextAreaElement).value).toBe(""), ); }); it("does not persist attachments with the restored draft", async () => { const drafts = new Map(); const scope = { projectId: "project-1", layoutId: "layout-a", nodeId: "node-1" }; const agent = { launchAgentChat: vi.fn(), reattachAgentChat: vi.fn(async (sessionId: string) => ({ sessionId, scrollback: [], })), sendAgentChat: vi.fn(async () => {}), readChatDraft: vi.fn(async () => ({ text: drafts.get("draft") ?? null, updatedAtMs: null, })), saveChatDraft: vi.fn(async (_scope: ChatDraftScope, text: string) => { drafts.set("draft", text); }), clearChatDraft: vi.fn(async () => { drafts.delete("draft"); }), cancelAgentChat: vi.fn(async () => {}), closeAgentChat: vi.fn(async () => {}), }; const renderView = () => render( null) }, } as unknown as Gateways} > , ); const first = renderView(); await waitFor(() => expect(agent.readChatDraft).toHaveBeenCalledWith(scope), ); const composer = screen.getByLabelText(/message CLI custom/) as HTMLTextAreaElement; fireEvent.change(composer, { target: { value: "draft with file" } }); const file = new File(["ignored"], "not-persisted.png", { type: "image/png" }); Object.defineProperty(file, "arrayBuffer", { value: vi.fn(async () => new Uint8Array([1, 2, 3]).buffer), }); fireEvent.paste(composer, { clipboardData: { items: [ { kind: "file", type: "image/png", getAsFile: () => file, }, ], getData: () => "", }, }); await screen.findByText("Fichier joint: not-persisted.png"); await waitFor(() => expect(agent.saveChatDraft).toHaveBeenCalledWith(scope, "draft with file[Image 1]"), ); first.unmount(); renderView(); await screen.findByDisplayValue("draft with file[Image 1]"); expect(screen.queryByText("Fichier joint: not-persisted.png")).toBeNull(); }); it("clears the persisted draft when sending the composer", async () => { const agent = { launchAgentChat: vi.fn(), reattachAgentChat: vi.fn(async (sessionId: string) => ({ sessionId, scrollback: [], })), sendAgentChat: vi.fn(async () => {}), readChatDraft: vi.fn(async () => ({ text: "to send", updatedAtMs: null })), saveChatDraft: vi.fn(async () => {}), clearChatDraft: vi.fn(async () => {}), cancelAgentChat: vi.fn(async () => {}), closeAgentChat: vi.fn(async () => {}), }; const scope = { projectId: "project-1", layoutId: "layout-a", nodeId: "node-1" }; render( null) }, } as unknown as Gateways} > , ); await screen.findByDisplayValue("to send"); fireEvent.click(screen.getByRole("button", { name: "Envoyer" })); await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalledTimes(1)); expect(agent.clearChatDraft).toHaveBeenCalledWith(scope); expect((screen.getByLabelText(/message CLI custom/) as HTMLTextAreaElement).value).toBe(""); }); it("clears the persisted draft when a slash command is consumed", 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: [], }, })), readChatDraft: vi.fn(async () => ({ text: "/help", updatedAtMs: null })), saveChatDraft: vi.fn(async () => {}), clearChatDraft: vi.fn(async () => {}), cancelAgentChat: vi.fn(async () => {}), closeAgentChat: vi.fn(async () => {}), }; const scope = { projectId: "project-1", layoutId: "layout-a", nodeId: "node-1" }; render( null) }, } as unknown as Gateways} > , ); await screen.findByDisplayValue("/help"); fireEvent.click(screen.getByRole("button", { name: "Envoyer" })); await waitFor(() => expect(agent.executeSlashCommand).toHaveBeenCalledWith("/help", { sessionId: "chat-session-1", }), ); expect(agent.clearChatDraft).toHaveBeenCalledWith(scope); expect((screen.getByLabelText(/message CLI custom/) as HTMLTextAreaElement).value).toBe(""); }); 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", ) .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, "69b57638-0ac7-4a79-8252-e0436a3265f6", expect.any(Function), { projectId: "project-1", agentId: "agent-1" }, ); expect(agent.reattachAgentChat).toHaveBeenNthCalledWith( 3, "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 request cancel as an already-stopped request", async () => { const agent = { launchAgentChat: vi.fn(), reattachAgentChat: vi.fn(async (sessionId: string) => ({ sessionId, scrollback: [], })), sendAgentChat: vi.fn(() => new Promise(() => {})), stopLiveAgent: vi.fn(), cancelAgentChat: vi.fn().mockRejectedValueOnce({ code: "NOT_FOUND", message: "agent agent-1 has no live 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("Request already stopped.")).toBeTruthy(); expect(onSessionId).not.toHaveBeenCalledWith(null); expect(agent.cancelAgentChat).toHaveBeenCalledWith("gone-session"); expect(agent.stopLiveAgent).not.toHaveBeenCalled(); 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(); }); });