test(custom-cli): tests à deux callbacks couvrant le streaming progress/outils sur la seconde requête au même agent — #252 (QA verte)

This commit is contained in:
2026-08-24 08:04:49 +02:00
parent d553d137ea
commit 23c47eac96

View File

@ -72,6 +72,45 @@ function deferred<T>() {
return { promise, resolve };
}
function createStreamingAgentHarness() {
const emits: Array<((chunk: ReplyChunk) => void) | null> = [];
const sendDone = new Map<number, () => void>();
const sendAgentChat = vi.fn(
(_sessionId: string, _prompt: string, onChunk: (chunk: ReplyChunk) => void) =>
new Promise<void>((resolve) => {
const index = emits.length;
emits.push((chunk: ReplyChunk) => {
onChunk(chunk);
if (chunk.kind === "final" || chunk.kind === "error") {
sendDone.get(index)?.();
}
});
sendDone.set(index, resolve);
}),
);
return {
agent: {
launchAgentChat: vi.fn(),
reattachAgentChat: vi.fn(async (sessionId: string) => ({
sessionId,
scrollback: [],
})),
sendAgentChat,
cancelAgentChat: vi.fn(async () => {}),
closeAgentChat: vi.fn(async () => {}),
},
sendAgentChat,
emit(index: number, chunk: ReplyChunk) {
const emit = emits[index];
if (!emit) throw new Error(`Missing emitter for send call #${index + 1}`);
emit(chunk);
},
emitterCount() {
return emits.length;
},
};
}
function expectedChatTime(ms: number): string {
const parts = new Intl.DateTimeFormat(undefined, {
hour: "2-digit",
@ -1489,6 +1528,352 @@ describe("CustomAgentChatView", () => {
expect(screen.getAllByText("idea_context_read")).toHaveLength(2);
});
it("surfaces second-turn streaming progress through a distinct callback after the first turn completed", async () => {
const harness = createStreamingAgentHarness();
render(
<DIProvider
gateways={{
agent: harness.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(harness.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 waitFor(() => expect(harness.emitterCount()).toBe(1));
act(() => {
harness.emit(0, {
kind: "progress",
progress: {
source: "providerNative",
kind: "tool",
stage: "started",
label: "shell",
toolName: "shell",
text: "Running first request",
},
});
});
expect(screen.getByText("Running first request")).toBeTruthy();
expect(screen.getByText("Tool").parentElement?.textContent).toContain("En cours");
act(() => {
harness.emit(0, {
kind: "progress",
progress: {
source: "providerNative",
kind: "tool",
stage: "completed",
label: "shell",
toolName: "shell",
text: "Done first request",
},
});
harness.emit(0, { kind: "final", content: "final first request" });
});
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 waitFor(() => expect(harness.emitterCount()).toBe(2));
act(() => {
harness.emit(1, {
kind: "progress",
progress: {
source: "providerNative",
kind: "message",
stage: "delta",
label: "Analyse",
text: "Second request context loaded.",
},
});
});
expect(screen.getByText("Second request context loaded.")).toBeTruthy();
expect(screen.getByText("Progress").parentElement?.textContent).toContain("En cours");
act(() => {
harness.emit(1, {
kind: "progress",
progress: {
source: "ideaLocal",
kind: "mcp",
stage: "started",
label: "idea_ticket_read",
toolName: "idea_ticket_read",
text: "Reading ticket for second request",
},
});
});
expect(screen.getByText("Reading ticket for second request")).toBeTruthy();
expect(screen.getAllByTestId("chat-tool-progress")).toHaveLength(2);
act(() => {
harness.emit(1, {
kind: "progress",
progress: {
source: "ideaLocal",
kind: "mcp",
stage: "completed",
label: "idea_ticket_read",
toolName: "idea_ticket_read",
text: "Ticket read complete",
},
});
harness.emit(1, { kind: "final", content: "final second request" });
});
await screen.findByText("final second request");
expect(screen.getByText("Ticket read complete")).toBeTruthy();
expect(screen.getByText("Second request context loaded.")).toBeTruthy();
expect(screen.getAllByText("Terminé")).toHaveLength(2);
});
it("treats same-tool progress on the second turn as fresh streaming activity with a distinct callback", async () => {
const harness = createStreamingAgentHarness();
render(
<DIProvider
gateways={{
agent: harness.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(harness.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 waitFor(() => expect(harness.emitterCount()).toBe(1));
act(() => {
harness.emit(0, {
kind: "progress",
progress: {
source: "ideaLocal",
kind: "mcp",
stage: "started",
label: "idea_context_read",
toolName: "idea_context_read",
text: "Reading shared context for first request",
},
});
harness.emit(0, {
kind: "progress",
progress: {
source: "ideaLocal",
kind: "mcp",
stage: "completed",
label: "idea_context_read",
toolName: "idea_context_read",
text: "Context read complete for first request",
},
});
harness.emit(0, { kind: "final", content: "done first request" });
});
await screen.findByText("done first request");
fireEvent.change(screen.getByLabelText(/message CLI custom/), {
target: { value: "second request" },
});
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
await waitFor(() => expect(harness.emitterCount()).toBe(2));
act(() => {
harness.emit(1, {
kind: "progress",
progress: {
source: "ideaLocal",
kind: "mcp",
stage: "started",
label: "idea_context_read",
toolName: "idea_context_read",
text: "Reading shared context for second request",
},
});
});
expect(screen.getByText("Reading shared context for second request")).toBeTruthy();
expect(screen.getAllByText("idea_context_read")).toHaveLength(2);
act(() => {
harness.emit(1, {
kind: "progress",
progress: {
source: "ideaLocal",
kind: "mcp",
stage: "completed",
label: "idea_context_read",
toolName: "idea_context_read",
text: "Context read complete for second request",
},
});
harness.emit(1, { kind: "final", content: "done second request" });
});
await screen.findByText("done second request");
expect(screen.getByText("Context read complete for second request")).toBeTruthy();
expect(screen.getByText("Context read complete for first request")).toBeTruthy();
expect(screen.getAllByTestId("chat-tool-progress")).toHaveLength(2);
});
it("ignores a late first-turn progress chunk but still shows the real second-turn progress from the next callback", async () => {
const harness = createStreamingAgentHarness();
render(
<DIProvider
gateways={{
agent: harness.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(harness.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 waitFor(() => expect(harness.emitterCount()).toBe(1));
act(() => {
harness.emit(0, {
kind: "progress",
progress: {
source: "providerNative",
kind: "tool",
stage: "started",
label: "shell",
toolName: "shell",
text: "Running first request",
},
});
harness.emit(0, { kind: "final", content: "done first request" });
});
await screen.findByText("done first request");
act(() => {
harness.emit(0, {
kind: "progress",
progress: {
source: "providerNative",
kind: "tool",
stage: "completed",
label: "shell",
toolName: "shell",
text: "Late first request completion",
},
});
});
expect(screen.queryByText("Late first request completion")).toBeNull();
fireEvent.change(screen.getByLabelText(/message CLI custom/), {
target: { value: "second request" },
});
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
await waitFor(() => expect(harness.emitterCount()).toBe(2));
act(() => {
harness.emit(1, {
kind: "progress",
progress: {
source: "providerNative",
kind: "tool",
stage: "started",
label: "shell",
toolName: "shell",
text: "Running second request",
},
});
});
expect(screen.getByText("Running second request")).toBeTruthy();
act(() => {
harness.emit(1, {
kind: "progress",
progress: {
source: "providerNative",
kind: "tool",
stage: "completed",
label: "shell",
toolName: "shell",
text: "Done second request",
},
});
harness.emit(1, { kind: "final", content: "done second request" });
});
await screen.findByText("done second request");
expect(screen.getByText("Done second request")).toBeTruthy();
expect(screen.queryByText("Late first request completion")).toBeNull();
});
it("renders available pre-MCP progress before the first MCP card", async () => {
let emitChunk: ((chunk: unknown) => void) | null = null;
const agent = {