diff --git a/frontend/src/features/agents/CustomAgentChatView.test.tsx b/frontend/src/features/agents/CustomAgentChatView.test.tsx index 606cadaa..dfbea426 100644 --- a/frontend/src/features/agents/CustomAgentChatView.test.tsx +++ b/frontend/src/features/agents/CustomAgentChatView.test.tsx @@ -1342,8 +1342,8 @@ describe("CustomAgentChatView", () => { }); await screen.findByText("done"); - expect(screen.getAllByText("Worker ← QA")).toHaveLength(1); - expect(screen.getAllByText("validate")).toHaveLength(1); + 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(); @@ -1353,6 +1353,99 @@ describe("CustomAgentChatView", () => { ); }); + 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 = { diff --git a/frontend/src/features/agents/CustomAgentChatView.tsx b/frontend/src/features/agents/CustomAgentChatView.tsx index 1f37b881..1c691a9d 100644 --- a/frontend/src/features/agents/CustomAgentChatView.tsx +++ b/frontend/src/features/agents/CustomAgentChatView.tsx @@ -64,7 +64,7 @@ type ChatTurn = | { role: "user"; text: string; attachments?: string[]; receivedAtMs?: number } | { role: "agent"; text: string; pending?: boolean; receivedAtMs?: number } | { role: "tool"; label: string; receivedAtMs?: number } - | { role: "progress"; progress: ReplyProgress; settled?: boolean } + | { role: "progress"; progress: ReplyProgress; settled?: boolean; progressKey?: string } | { role: "final"; text: string; receivedAtMs?: number } | { role: "error"; text: string; receivedAtMs?: number } | { role: "unknown"; text: string; receivedAtMs?: number }; @@ -291,11 +291,13 @@ function progressKey(progress: ReplyProgress): string { function progressUpsertKey(progress: ReplyProgress): string { const progressId = progress.progressId?.trim(); if (progressId) return progressId; - const entity = progressEntityKey(progress); - if (entity) return `legacy-entity:${entity}`; return `legacy:${progressDisplayKey(progress)}`; } +function turnProgressKey(turn: Extract): string { + return turn.progressKey ?? progressUpsertKey(turn.progress); +} + function normalizedProgressToolName(progress: ReplyProgress): string | null { const name = progress.toolName ?? progress.label; const normalized = name.trim().toLowerCase(); @@ -328,6 +330,38 @@ function progressDisplayKey(progress: ReplyProgress): string { return progressKey(progress); } +function legacyActiveProgressKey(progress: ReplyProgress): string | null { + const entity = progressEntityKey(progress); + if (!entity) return null; + if (progress.stage !== "started" && progress.stage !== "delta") return null; + return `legacy:${entity}\u0000started`; +} + +function legacyCompletedDedupKey(progress: ReplyProgress): string | null { + const entity = progressEntityKey(progress); + if (!entity || progress.stage !== "completed") return null; + if (isDelegationProgress(progress)) return entity; + return `${entity}\u0000${progress.text ?? ""}`; +} + +function findLastProgressIndex( + turns: ChatTurn[], + predicate: (turn: Extract) => boolean, +): number { + for (let index = turns.length - 1; index >= 0; index -= 1) { + const turn = turns[index]; + if (turn.role === "progress" && predicate(turn)) return index; + } + return -1; +} + +function legacyCompletedOccurrenceKey( + progress: ReplyProgress, + entry: ProgressIndexEntry, +): string { + return `legacy-completed:${progressDisplayKey(progress)}\u0000${entry.firstOccurredAtMs}\u0000${entry.sequence}`; +} + interface ChatViewState { turns: ChatTurn[]; progressIndex: Map; @@ -346,13 +380,41 @@ function conversationCacheKey( } function appendProgress(state: ChatViewState, progress: ReplyProgress): ChatViewState { - const key = progressUpsertKey(progress); - const existingIndex = state.turns.findIndex( - (turn) => turn.role === "progress" && progressUpsertKey(turn.progress) === key, + let key = progressUpsertKey(progress); + let existingIndex = state.turns.findIndex( + (turn) => turn.role === "progress" && turnProgressKey(turn) === key, ); + + const activeKey = + !progress.progressId && progress.stage === "completed" + ? legacyActiveProgressKey({ ...progress, stage: "started" }) + : null; + if (activeKey) { + const activeIndex = state.turns.findIndex( + (turn) => turn.role === "progress" && turnProgressKey(turn) === activeKey, + ); + if (activeIndex >= 0) { + existingIndex = activeIndex; + key = activeKey; + } + } + + const completedDedupKey = existingIndex < 0 ? legacyCompletedDedupKey(progress) : null; + if (completedDedupKey) { + existingIndex = findLastProgressIndex( + state.turns, + (turn) => legacyCompletedDedupKey(turn.progress) === completedDedupKey, + ); + const existingTurn = existingIndex >= 0 ? state.turns[existingIndex] : null; + if (existingTurn?.role === "progress") { + key = turnProgressKey(existingTurn); + } + } + + const existingTurn = existingIndex >= 0 ? state.turns[existingIndex] : null; const existingProgress = - existingIndex >= 0 && state.turns[existingIndex].role === "progress" - ? state.turns[existingIndex].progress + existingTurn?.role === "progress" + ? existingTurn.progress : null; const merged = existingProgress && progress.stage === "completed" @@ -365,24 +427,30 @@ function appendProgress(state: ChatViewState, progress: ReplyProgress): ChatView progress.occurredAtMs ?? Date.now(); const sequence = existingEntry?.sequence ?? nextIndex.size; - nextIndex.set(key, { firstOccurredAtMs, sequence }); + const nextEntry = { firstOccurredAtMs, sequence }; + let nextKey = key; + if (!progress.progressId && progress.stage === "completed" && activeKey && key === activeKey) { + nextKey = legacyCompletedOccurrenceKey(merged, nextEntry); + nextIndex.delete(activeKey); + } + nextIndex.set(nextKey, nextEntry); if (existingIndex >= 0) { const nextTurns = [...state.turns]; - nextTurns[existingIndex] = { role: "progress", progress: merged }; + nextTurns[existingIndex] = { role: "progress", progress: merged, progressKey: nextKey }; return { turns: nextTurns, progressIndex: nextIndex }; } const insertAt = state.turns.findIndex((turn) => { if (turn.role !== "progress") return false; - const candidate = state.progressIndex.get(progressUpsertKey(turn.progress)); + const candidate = state.progressIndex.get(turnProgressKey(turn)); if (!candidate) return false; if (candidate.firstOccurredAtMs !== firstOccurredAtMs) { return candidate.firstOccurredAtMs > firstOccurredAtMs; } return candidate.sequence > sequence; }); - const nextTurn = { role: "progress" as const, progress: merged }; + const nextTurn = { role: "progress" as const, progress: merged, progressKey: nextKey }; if (insertAt < 0) { return { turns: [...state.turns, nextTurn], progressIndex: nextIndex }; } @@ -408,9 +476,32 @@ function mergeCompletedProgress( }; } -function completeRunningProgress(turns: ChatTurn[]): ChatTurn[] { +function progressShouldSettle( + turn: Extract, + completedEntities: Set, +): boolean { + if ( + turn.progress.kind === "turn" || + (turn.progress.stage !== "started" && turn.progress.stage !== "delta") + ) { + return false; + } + const entity = progressEntityKey(turn.progress); + return Boolean(entity && completedEntities.has(entity)); +} + +function shouldCompleteProgress( + turn: Extract, +): boolean { + return ( + turn.progress.kind !== "turn" && + (turn.progress.stage === "started" || turn.progress.stage === "delta") + ); +} + +function completeChatViewProgress(state: ChatViewState): ChatViewState { const completedEntities = new Set( - turns + state.turns .filter( (turn): turn is Extract => turn.role === "progress" && @@ -419,40 +510,44 @@ function completeRunningProgress(turns: ChatTurn[]): ChatTurn[] { ) .map((turn) => progressEntityKey(turn.progress)), ); - return turns.map((turn) => { - if ( - turn.role !== "progress" || - turn.progress.kind === "turn" || - (turn.progress.stage !== "started" && turn.progress.stage !== "delta") - ) { - return turn; - } - const entity = progressEntityKey(turn.progress); - if (entity && completedEntities.has(entity)) { + + const nextIndex = new Map(state.progressIndex); + const turns = state.turns.map((turn) => { + if (turn.role !== "progress" || !shouldCompleteProgress(turn)) return turn; + + const previousKey = turnProgressKey(turn); + const previousEntry = nextIndex.get(previousKey); + const entry = + previousEntry ?? { + firstOccurredAtMs: turn.progress.occurredAtMs ?? Date.now(), + sequence: nextIndex.size, + }; + const completedProgress = { + ...turn.progress, + stage: "completed" as const, + }; + const nextKey = turn.progress.progressId + ? progressUpsertKey(completedProgress) + : legacyCompletedOccurrenceKey(completedProgress, entry); + if (previousKey !== nextKey) nextIndex.delete(previousKey); + nextIndex.set(nextKey, entry); + + if (progressShouldSettle(turn, completedEntities)) { return { ...turn, settled: true, - progress: { - ...turn.progress, - stage: "completed", - }, + progress: completedProgress, + progressKey: nextKey, }; } return { ...turn, - progress: { - ...turn.progress, - stage: "completed", - }, + progress: completedProgress, + progressKey: nextKey, }; }); -} -function completeChatViewProgress(state: ChatViewState): ChatViewState { - return { - turns: completeRunningProgress(state.turns), - progressIndex: state.progressIndex, - }; + return { turns, progressIndex: nextIndex }; } function isRecoverableNoResponseTurn(turn: ChatTurn | undefined): boolean { @@ -2206,7 +2301,7 @@ function ChatBubble({ ); } if (turn.role === "progress") { - const key = progressUpsertKey(turn.progress); + const key = turnProgressKey(turn); return (