fix(custom-cli): busy/progress/tools/chatbox bloqués après le premier appel — #245 (QA verte)

Backend : StructuredHumanTurnGuard pour partager la comptabilité busy/FIFO entre chat direct humain (agent_send, web chat.send) et asks inter-agents structurés, avec liveness mark_alive et retrait idempotent du tour. Web-server : tap d'événements sur chat.send pour le scrollback temps réel. Frontend : scoping des clés de progression par tour pour éviter la déduplication/ghosting des progress et outils entre appels.
This commit is contained in:
2026-08-23 13:34:56 +02:00
parent 55637abd6d
commit f1d85dfc5a
5 changed files with 728 additions and 58 deletions

View File

@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { DIProvider } from "@/app/di";
import type { AgentProfile, ReplyChunk } from "@/domain";
import { MockSystemGateway, MockWorkStateGateway } from "@/adapters/mock";
import { PluginRuntimeProvider } from "@/features/plugins";
import {
__resetProjectCliInteractionActivityForTests,
@ -912,6 +913,158 @@ describe("CustomAgentChatView", () => {
).toBe(true);
});
it("keeps the composer locked from canonical work-state busy even without local stream activity", async () => {
const system = new MockSystemGateway();
const workState = new MockWorkStateGateway();
workState._setProjectWorkState("project-1", {
agents: [
{
agentId: "agent-1",
name: "Main",
profileId: "structured",
busy: { state: "busy", ticket: "turn-1", sinceMs: 1 },
tickets: [],
},
],
conversations: [],
});
const agent = {
launchAgentChat: vi.fn(),
reattachAgentChat: vi.fn(async (sessionId: string) => ({
sessionId,
scrollback: [],
})),
sendAgentChat: vi.fn(async () => {}),
stopLiveAgent: vi.fn(async () => ({
agentId: "agent-1",
sessionId: "chat-session-1",
kind: "structured" as const,
})),
cancelAgentChat: vi.fn(async () => {}),
closeAgentChat: vi.fn(async () => {}),
};
render(
<DIProvider
gateways={{
agent,
system,
workState,
} as unknown as Gateways}
>
<CustomAgentChatView
projectId="project-1"
agentId="agent-1"
agentName="Main"
profile={profile}
cwd="/repo"
nodeId="node-1"
sessionId="chat-session-1"
conversationId="conversation-1"
onSessionId={vi.fn()}
onConversationId={vi.fn()}
/>
</DIProvider>,
);
const composer = await screen.findByLabelText(/message CLI custom/);
await waitFor(() => expect(composer).toHaveProperty("disabled", true));
expect(screen.getByRole("button", { name: "Stop" })).toBeTruthy();
workState._setProjectWorkState("project-1", {
agents: [
{
agentId: "agent-1",
name: "Main",
profileId: "structured",
busy: { state: "idle" },
tickets: [],
},
],
conversations: [],
});
system.emit({ type: "agentBusyChanged", agentId: "agent-1", busy: false });
await waitFor(() => expect(composer).toHaveProperty("disabled", false));
expect(screen.getByRole("button", { name: "Envoyer" })).toBeTruthy();
});
it("shows the requester as waiting on the delegated target and keeps input locked", async () => {
const system = new MockSystemGateway();
const workState = new MockWorkStateGateway();
workState._setProjectWorkState("project-1", {
agents: [
{
agentId: "main",
name: "Main",
profileId: "structured",
busy: { state: "idle" },
tickets: [],
},
{
agentId: "architect",
name: "Architect",
profileId: "structured",
busy: { state: "busy", ticket: "delegated-1", sinceMs: 1 },
tickets: [
{
ticketId: "delegated-1",
conversationId: "conversation-2",
position: 0,
status: "inProgress",
source: { kind: "agent", agentId: "main" },
requesterLabel: "Main",
taskPreview: "Analyse l'architecture",
taskLen: 22,
},
],
},
],
conversations: [],
});
const agent = {
launchAgentChat: vi.fn(),
reattachAgentChat: vi.fn(async (sessionId: string) => ({
sessionId,
scrollback: [],
})),
sendAgentChat: vi.fn(async () => {}),
stopLiveAgent: vi.fn(async () => ({
agentId: "main",
sessionId: "chat-session-1",
kind: "structured" as const,
})),
cancelAgentChat: vi.fn(async () => {}),
closeAgentChat: vi.fn(async () => {}),
};
render(
<DIProvider
gateways={{
agent,
system,
workState,
} as unknown as Gateways}
>
<CustomAgentChatView
projectId="project-1"
agentId="main"
agentName="Main"
profile={profile}
cwd="/repo"
nodeId="node-1"
sessionId="chat-session-1"
conversationId="conversation-1"
onSessionId={vi.fn()}
onConversationId={vi.fn()}
/>
</DIProvider>,
);
expect(await screen.findByText("Main attend Architect")).toBeTruthy();
expect(screen.getByLabelText(/message CLI custom/)).toHaveProperty("disabled", true);
});
it("falls back to a fresh launch when reattach reports NOT_FOUND", async () => {
const agent = {
launchAgentChat: vi.fn(async () => ({
@ -1142,6 +1295,87 @@ describe("CustomAgentChatView", () => {
expect((await screen.findAllByText("done")).length).toBeGreaterThan(0);
});
it("renders repeated legacy tool progress as fresh activity on each turn", async () => {
const agent = {
launchAgentChat: vi.fn(),
reattachAgentChat: vi.fn(async (sessionId: string) => ({
sessionId,
scrollback: [],
})),
sendAgentChat: vi.fn(async (_sessionId: string, prompt: string, onChunk) => {
onChunk({
kind: "progress",
progress: {
source: "ideaLocal",
kind: "mcp",
stage: "started",
label: "idea_context_read",
toolName: "idea_context_read",
text: "Lecture du contexte",
},
});
onChunk({
kind: "progress",
progress: {
source: "ideaLocal",
kind: "mcp",
stage: "completed",
label: "idea_context_read",
toolName: "idea_context_read",
text: "Lecture du contexte",
},
});
onChunk({ kind: "final", content: `done ${prompt}` });
}),
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: "first" },
});
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
await screen.findByText("done first");
fireEvent.change(screen.getByLabelText(/message CLI custom/), {
target: { value: "second" },
});
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
await screen.findByText("done second");
expect(screen.getAllByTestId("chat-tool-progress")).toHaveLength(2);
expect(screen.getAllByText("idea_context_read")).toHaveLength(2);
});
it("renders available pre-MCP progress before the first MCP card", async () => {
let emitChunk: ((chunk: unknown) => void) | null = null;
const agent = {