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:
@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user