diff --git a/frontend/src/features/agents/CustomAgentChatView.test.tsx b/frontend/src/features/agents/CustomAgentChatView.test.tsx index a2da99b2..dce09046 100644 --- a/frontend/src/features/agents/CustomAgentChatView.test.tsx +++ b/frontend/src/features/agents/CustomAgentChatView.test.tsx @@ -72,6 +72,45 @@ function deferred() { 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", @@ -1489,6 +1528,352 @@ describe("CustomAgentChatView", () => { 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("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 = {