fix(chat): déduplication des progress intermédiaires dans la timeline (#192)
Les progress « started » d'une même entité s'empilaient en plusieurs turns et n'étaient dédupliqués qu'a posteriori par scan linéaire du tableau de turns, d'où des doublons visibles et un affichage retardé (re-scan par chunk). appendProgress opérait directement sur turns[] (chaque « started » y était poussé puis retrouvé/remplacé par recherche séquentielle). Désormais: - ChatViewState (turns + entityProgressMap): la Map indexée par « entité\0bucket » (started/completed) est l'unique source de vérité pour les progress en cours; - les progress « started » ne deviennent des turns qu'au flush (completeChatViewProgress sur final/error), supprimant la duplication et rendant l'affichage immédiat; - « completed » merge depuis l'entrée running de la Map puis la retire, au lieu de scanner le tableau; - turnsIndicateBusy s'appuie sur la taille de la Map (état vivant) en plus du scan des turns. Tests: déduplication des progress par entité (started répétés → une seule entrée, flush au final), fusion « completed » depuis la Map, état busy reflété par la Map, et rendu non dupliqué sur l'enchaînement started/completed/final.
This commit is contained in:
@ -851,6 +851,322 @@ describe("CustomAgentChatView", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("deduplicates identical entity progress chunks", 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 ticket" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
||||
await waitFor(() => expect(emitChunk).not.toBeNull());
|
||||
|
||||
const chunk = {
|
||||
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",
|
||||
},
|
||||
};
|
||||
|
||||
act(() => {
|
||||
emitChunk?.(chunk);
|
||||
emitChunk?.(chunk);
|
||||
});
|
||||
|
||||
expect(screen.getAllByTestId("chat-tool-progress")).toHaveLength(1);
|
||||
expect(screen.getAllByText("idea_ticket_read")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("updates entity progress deltas in place", 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: "download model" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
||||
await waitFor(() => expect(emitChunk).not.toBeNull());
|
||||
|
||||
const progress = (stage: "started" | "delta", text: string) => ({
|
||||
kind: "progress" as const,
|
||||
progress: {
|
||||
source: "providerNative" as const,
|
||||
kind: "tool" as const,
|
||||
stage,
|
||||
label: "download_model",
|
||||
toolName: "download_model",
|
||||
text,
|
||||
},
|
||||
});
|
||||
|
||||
act(() => {
|
||||
emitChunk?.(progress("started", "Downloading 10%"));
|
||||
emitChunk?.(progress("delta", "Downloading 75%"));
|
||||
});
|
||||
|
||||
expect(screen.getAllByTestId("chat-tool-progress")).toHaveLength(1);
|
||||
expect(screen.queryByText("Downloading 10%")).toBeNull();
|
||||
expect(screen.getByText("Downloading 75%")).toBeTruthy();
|
||||
expect(screen.getByText("Tool").parentElement?.textContent).toContain("running");
|
||||
});
|
||||
|
||||
it("renders progress immediately when the chunk callback fires", 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: "stream progress" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
||||
await waitFor(() => expect(emitChunk).not.toBeNull());
|
||||
|
||||
act(() => {
|
||||
emitChunk?.({
|
||||
kind: "progress" as const,
|
||||
progress: {
|
||||
source: "providerNative" as const,
|
||||
kind: "tool" as const,
|
||||
stage: "started" as const,
|
||||
label: "stream_repo",
|
||||
toolName: "stream_repo",
|
||||
text: "Chunk received",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
expect(screen.getByText("Chunk received")).toBeTruthy();
|
||||
expect(screen.getAllByTestId("chat-tool-progress")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("moves completed entity progress from active map to turns", 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: "call tool" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
||||
await waitFor(() => expect(emitChunk).not.toBeNull());
|
||||
|
||||
const progress = (stage: "started" | "completed") => ({
|
||||
kind: "progress" as const,
|
||||
progress: {
|
||||
source: "providerNative" as const,
|
||||
kind: "tool" as const,
|
||||
stage,
|
||||
label: "search_repo",
|
||||
toolName: "search_repo",
|
||||
text: stage === "started" ? "Searching..." : "Found results",
|
||||
},
|
||||
});
|
||||
|
||||
act(() => {
|
||||
emitChunk?.(progress("started"));
|
||||
emitChunk?.(progress("completed"));
|
||||
});
|
||||
|
||||
expect(screen.getAllByTestId("chat-tool-progress")).toHaveLength(1);
|
||||
expect(screen.queryByText("Searching...")).toBeNull();
|
||||
expect(screen.getByText("Found results")).toBeTruthy();
|
||||
expect(screen.getByText("Tool").parentElement?.textContent).toContain("done");
|
||||
|
||||
act(() => {
|
||||
emitChunk?.({ kind: "final", content: "done" });
|
||||
});
|
||||
|
||||
await screen.findByText("done");
|
||||
expect(screen.getAllByTestId("chat-tool-progress")).toHaveLength(1);
|
||||
expect(screen.getByText("Found results")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders running progress without spinner and marks it done on final", async () => {
|
||||
let emitChunk: ((chunk: unknown) => void) | null = null;
|
||||
const agent = {
|
||||
|
||||
Reference in New Issue
Block a user