fix(application,backend,web-server,app-tauri,frontend): libère l'état busy et le ticket médiatisé avant le Final visible — web-server ws_send_chat: guard.finish() sur Complete et Failed avant l'émission des frames terminales client, et sur l'absence d'ack, pour qu'un read workstate déclenché par Final/Error ne puisse plus voir le tour comme Busy ; app-tauri agent_send: dès la transition Complete, retire le ticket médiatisé exact (guard.finish) et termine le tour structuré (finish_turn) avant send_output_settled, opérations idempotentes et clôturées contre un successeur, épilogue conservé en backstop drop/error — corrige LED projet orange persistante et queue non drainée après Final ; frontend CustomAgentChatView: terminalWorkStatePending maintient effectiveBusy jusqu'à la résolution du refresh workstate post-Final au lieu de forcer busy=false et stopTerminalOverride (plus d'état busy fantôme ni de Stop incorrectement neutralisé) — #369 (QA verte: cargo fmt --all --check OK, cargo test application structured OK, web-server websocket_chat_send_releases_busy_before_visible_final et websocket_chat_send --test-threads=1 OK, app-tauri chat_bridge 36 tests OK, application drain_with_readiness_lot1/session_limit_t4/send_blocking_d1/cancel_agent_chat OK, backend human_turn OK, npm test CustomAgentChatView 92 OK, npm run build OK)

This commit is contained in:
2026-09-17 09:46:21 +02:00
parent 4c86f9bb6f
commit 2d95e20d91
5 changed files with 217 additions and 15 deletions

View File

@ -1681,6 +1681,19 @@ describe("CustomAgentChatView", () => {
nativeEvent: "turn.completed",
},
});
workState._setProjectWorkState("project-1", {
agents: [
{
agentId: "agent-1",
name: "Worker",
profileId: "structured",
busy: { state: "idle" },
tickets: [],
},
],
conversations: [],
});
system.emit({ type: "agentBusyChanged", agentId: "agent-1", busy: false });
onChunk({ kind: "final", content: "Next answer" });
}),
stopLiveAgent: vi.fn(),
@ -6225,6 +6238,81 @@ describe("CustomAgentChatView", () => {
);
});
it("waits for canonical work-state idle after Final before draining the next queued message in FIFO order (#369)", async () => {
const harness = createStreamingAgentHarness();
const system = new MockSystemGateway();
const workState = new MockWorkStateGateway();
const stateFor = (busy: boolean) => ({
agents: [
{
agentId: "agent-1",
name: "Worker",
profileId: "structured",
busy: busy
? ({ state: "busy", ticket: "turn-369", sinceMs: 1 } as const)
: ({ state: "idle" } as const),
tickets: [],
},
],
conversations: [],
});
workState._setProjectWorkState("project-1", stateFor(false));
render(
<DIProvider
gateways={{ agent: harness.agent, system, workState } as unknown as Gateways}
>
<CustomAgentChatView
projectId="project-1"
agentId="agent-1"
agentName="Worker"
profile={profile}
cwd="/repo"
layoutId="layout-1"
nodeId="node-1"
sessionId="chat-session-1"
conversationId="conversation-1"
onSessionId={vi.fn()}
onConversationId={vi.fn()}
/>
</DIProvider>,
);
const composer = await screen.findByLabelText(/Message CLI personnalisée/);
await waitFor(() => expect(screen.getByRole("button", { name: "Envoyer" })).toBeTruthy());
fireEvent.change(composer, { target: { value: "active request" } });
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
await waitFor(() => expect(harness.sendAgentChat).toHaveBeenCalledTimes(1));
fireEvent.change(composer, { target: { value: "second queued" } });
fireEvent.click(screen.getByRole("button", { name: "Mettre en attente" }));
fireEvent.change(composer, { target: { value: "third queued" } });
fireEvent.click(screen.getByRole("button", { name: "Mettre en attente" }));
expect(screen.getByRole("button", { name: "En attente · 2" })).toBeTruthy();
workState._setProjectWorkState("project-1", stateFor(true));
act(() => system.emit({ type: "agentBusyChanged", agentId: "agent-1", busy: true }));
await waitFor(() => expect(screen.getByRole("button", { name: "Mettre en attente" })).toBeTruthy());
act(() => harness.emit(0, { kind: "final", content: "first final" }));
await screen.findByText("first final");
await waitFor(() => expect(screen.getByRole("button", { name: "Mettre en attente" })).toBeTruthy());
expect(harness.sendAgentChat).toHaveBeenCalledTimes(1);
expect(screen.getByRole("button", { name: "En attente · 2" })).toBeTruthy();
workState._setProjectWorkState("project-1", stateFor(false));
act(() => system.emit({ type: "agentBusyChanged", agentId: "agent-1", busy: false }));
await waitFor(() => expect(harness.sendAgentChat).toHaveBeenCalledTimes(2));
expect(harness.sendAgentChat).toHaveBeenNthCalledWith(
2,
"chat-session-1",
"second queued",
expect.any(Function),
);
expect(screen.getByRole("button", { name: "En attente · 1" })).toBeTruthy();
});
it("edits, deletes, restores and clears queued messages while Stop preserves the queue", async () => {
const harness = createStreamingAgentHarness();
const cancelAgentChat = vi.fn(async () => ({

View File

@ -1104,6 +1104,7 @@ export function CustomAgentChatView({
} | null>(null);
const [queueAnnouncement, setQueueAnnouncement] = useState("");
const [queueDrainReady, setQueueDrainReady] = useState(false);
const [terminalWorkStatePending, setTerminalWorkStatePending] = useState(false);
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
const [slashMenuOpen, setSlashMenuOpen] = useState(false);
const [slashActiveIndex, setSlashActiveIndex] = useState(0);
@ -1341,6 +1342,7 @@ export function CustomAgentChatView({
const effectiveBusy =
busy ||
stopping ||
terminalWorkStatePending ||
Boolean(restart && restart.outcome !== "failed") ||
(!stopTerminalOverride &&
(requesterDelegation.currentAgentBusy || delegationActive));
@ -1708,17 +1710,21 @@ export function CustomAgentChatView({
markProjectCliInteractionFinished(projectId, activeInteractionIdRef.current);
activeInteractionIdRef.current = null;
}
stopTerminalOverrideRef.current = true;
setStopTerminalOverride(true);
setRequesterDelegation({
currentAgentBusy: false,
delegationTargetName: null,
});
setBusy(false);
void refreshRequesterDelegation();
if (workState) {
setTerminalWorkStatePending(true);
void refreshRequesterDelegation().finally(() => {
if (mountedRef.current) setTerminalWorkStatePending(false);
});
} else {
setRequesterDelegation({
currentAgentBusy: false,
delegationTargetName: null,
});
}
}
},
[projectId, refreshRequesterDelegation, updateChatViewState],
[projectId, refreshRequesterDelegation, updateChatViewState, workState],
);
useEffect(() => {