diff --git a/frontend/src/features/agents/CustomAgentChatView.test.tsx b/frontend/src/features/agents/CustomAgentChatView.test.tsx index 01acf7b6..a2da99b2 100644 --- a/frontend/src/features/agents/CustomAgentChatView.test.tsx +++ b/frontend/src/features/agents/CustomAgentChatView.test.tsx @@ -1176,6 +1176,119 @@ describe("CustomAgentChatView", () => { 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(), @@ -2293,7 +2406,7 @@ describe("CustomAgentChatView", () => { expect(screen.getByText("Progress").parentElement?.textContent).toContain("Terminé"); }); - it("renders late progress chunks after the final answer without reopening the turn", async () => { + 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(), @@ -2368,7 +2481,7 @@ describe("CustomAgentChatView", () => { }); expect(screen.getByText("done")).toBeTruthy(); - expect(screen.getByText("idea_ticket_read")).toBeTruthy(); + expect(screen.queryByText("idea_ticket_read")).toBeNull(); expect(screen.getByRole("button", { name: "Envoyer" })).toBeTruthy(); expect(screen.queryByRole("button", { name: "Stop" })).toBeNull(); }); diff --git a/frontend/src/features/agents/CustomAgentChatView.tsx b/frontend/src/features/agents/CustomAgentChatView.tsx index f09dabca..dc80bb9d 100644 --- a/frontend/src/features/agents/CustomAgentChatView.tsx +++ b/frontend/src/features/agents/CustomAgentChatView.tsx @@ -236,24 +236,31 @@ function appendUserPrompt( text: string, attachments: string[] = [], receivedAtMs = Date.now(), -): ChatTurn[] { - const last = turns[turns.length - 1]; - if ( - last?.role === "user" && - last.text === text && - JSON.stringify(last.attachments ?? []) === JSON.stringify(attachments) - ) { - return turns; +): { turns: ChatTurn[]; appended: boolean } { + for (let index = turns.length - 1; index >= 0; index -= 1) { + const turn = turns[index]; + if (turn.role === "final" || turn.role === "error") break; + if (turn.role !== "user") continue; + if ( + turn.text === text && + JSON.stringify(turn.attachments ?? []) === JSON.stringify(attachments) + ) { + return { turns, appended: false }; + } + break; } - return [ - ...turns, - { - role: "user", - text, - receivedAtMs, - ...(attachments.length > 0 ? { attachments } : {}), - }, - ]; + return { + turns: [ + ...turns, + { + role: "user", + text, + receivedAtMs, + ...(attachments.length > 0 ? { attachments } : {}), + }, + ], + appended: true, + }; } function isReplyProgressSource(value: unknown): value is ReplyProgressSource { @@ -775,14 +782,6 @@ function foldChunk( if (isRecoverableNoResponseTurn(last) && raw.kind !== "error") { return foldChunk({ ...state, turns: turns.slice(0, -1) }, raw, receivedAtMs); } - if (raw.kind === "progress") { - return appendProgress(state, normalizeProgress(raw.progress) ?? { - source: "ideaLocal", - kind: "other", - stage: "info", - label: unknownChunkLabel(raw), - }); - } return state; } switch (raw.kind) { @@ -838,17 +837,21 @@ function foldChunk( return { ...completedState, turns: next }; } case "userPrompt": - case "UserPrompt": + case "UserPrompt": { + const appended = appendUserPrompt( + turns, + String(raw.text ?? raw.prompt ?? ""), + attachmentLabels(raw.attachments), + receivedAtMs, + ); return { ...state, - turns: appendUserPrompt( - turns, - String(raw.text ?? raw.prompt ?? ""), - attachmentLabels(raw.attachments), - receivedAtMs, - ), - progressScope: state.progressScope + 1, + turns: appended.turns, + progressScope: appended.appended + ? state.progressScope + 1 + : state.progressScope, }; + } default: return { ...state,