merge(cli): les messages progress/MCP ne disparaissent plus après un certain temps — #236 (QA verte)
This commit is contained in:
@ -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 = {
|
||||
|
||||
@ -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}
|
||||
|
||||
Reference in New Issue
Block a user