merge(cli): les messages progress/MCP ne disparaissent plus après un certain temps — #236 (QA verte)

This commit is contained in:
2026-08-21 17:47:05 +02:00
2 changed files with 229 additions and 41 deletions

View File

@ -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<void>((resolve) => {
emitChunk = (chunk: unknown) => {
onChunk(chunk);
if ((chunk as { kind?: string }).kind === "final") resolve();
};
}),
),
cancelAgentChat: vi.fn(async () => {}),
closeAgentChat: vi.fn(async () => {}),
};
render(
<DIProvider
gateways={{
agent,
system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways}
>
<CustomAgentChatView
projectId="project-1"
agentId="agent-1"
agentName="Worker"
profile={profile}
cwd="/repo"
nodeId="node-1"
sessionId="chat-session-1"
conversationId="conversation-1"
onSessionId={vi.fn()}
onConversationId={vi.fn()}
/>
</DIProvider>,
);
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 = {

View File

@ -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<ChatTurn, { role: "progress" }>): 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<ChatTurn, { role: "progress" }>) => 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<string, ProgressIndexEntry>;
@ -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<ChatTurn, { role: "progress" }>,
completedEntities: Set<string | null>,
): 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<ChatTurn, { role: "progress" }>,
): 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<ChatTurn, { role: "progress" }> =>
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 (
<ProgressBubble
progress={turn.progress}