fix(chat): réattache persistante CLI custom et rendu tools/MCP/progress enrichi (#188)

Corrige la perte de session à la navigation: le démontage du CustomAgentChatView
appelait close_agent_session (destructeur) au lieu de simplement détacher le
transport. Désormais:

- nouvelle commande Tauri detach_agent_chat (+ ChatBridge::detach) qui libère le
  canal sans tuer la session ni le scrollback retenu; distinct de
  close_agent_session qui reste le close destructeur explicite;
- le frontend appelle detachAgentChat à l'unmount puis restore sa vue depuis un
  cache de session (turns + chunkCount) à la réattache;
- message « Reprise effectuée: N événements rattrapés… » quand la réattache
  rejoue plus de chunks que le cache local n'en avait conservé.

Enrichit le rendu tools/MCP/progress:

- ProgressSettler (chat bridge) émet un progress completed synthétique quand un
  spinner reste ouvert (nouvelle entité progress, chunk text/final/error): fini
  les spinners fantômes;
- les adapters claude/codex/opencode/openai_compat extraient un aperçu du
  payload (arguments/input/command/message/params) attaché au progress;
- bounded_reply_progress_preview (domain) garde les 4 premières lignes non
  vides, normalisation CR/LF;
- previewText (frontend) multiligne whitespace-pre-wrap, source/nativeEvent
  affichés sous le titre; suppression du flag settled et du Spinner inline
  devenus redondants (l'état est porté par le stage + le settler).

Tests: detach_agent_chat gateway (Tauri + mock), ProgressSettler, bornage
reply_progress, item_preview/tool_preview par adapter, réattache avec cache et
notice de rattrapage.
This commit is contained in:
2026-08-11 18:49:12 +02:00
parent bc9a034249
commit ee0ffd4e2d
16 changed files with 654 additions and 61 deletions

View File

@ -1,9 +1,9 @@
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { useState } from "react";
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { DIProvider } from "@/app/di";
import type { AgentProfile } from "@/domain";
import type { AgentProfile, ReplyChunk } from "@/domain";
import { PluginRuntimeProvider } from "@/features/plugins";
import {
PluginCommandRegistry,
@ -12,7 +12,10 @@ import {
PluginRuntimeRegistry,
} from "@/plugins/runtime";
import type { ChatDraftScope, Gateways } from "@/ports";
import { CustomAgentChatView } from "./CustomAgentChatView";
import {
CustomAgentChatView,
__clearCustomAgentChatViewCacheForTests,
} from "./CustomAgentChatView";
const profile: AgentProfile = {
id: "structured",
@ -57,6 +60,11 @@ function addPluginCommand(
}
describe("CustomAgentChatView", () => {
beforeEach(() => {
vi.clearAllMocks();
__clearCustomAgentChatViewCacheForTests();
});
it("repaints attachment labels from user prompt scrollback", async () => {
const agent = {
launchAgentChat: vi.fn(),
@ -101,6 +109,75 @@ describe("CustomAgentChatView", () => {
expect(screen.getByText("Fichier: clipboard.png")).toBeTruthy();
});
it("detaches on unmount then reattaches with missed scrollback chunks", async () => {
let scrollback: ReplyChunk[] = [
{ kind: "userPrompt" as const, text: "Initial prompt" },
{ kind: "textDelta" as const, text: "Initial reply" },
];
const agent = {
launchAgentChat: vi.fn(),
reattachAgentChat: vi.fn(async (sessionId: string) => ({
sessionId,
scrollback,
})),
detachAgentChat: vi.fn(async () => {}),
sendAgentChat: vi.fn(async () => {}),
cancelAgentChat: vi.fn(async () => {}),
closeAgentChat: vi.fn(async () => {}),
};
const props = {
projectId: "project-1",
agentId: "agent-1",
agentName: "Worker",
profile,
cwd: "/repo",
nodeId: "node-1",
sessionId: "chat-session-1",
conversationId: "conversation-1",
onSessionId: vi.fn(),
onConversationId: vi.fn(),
};
const first = render(
<DIProvider
gateways={{
agent,
system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways}
>
<CustomAgentChatView {...props} />
</DIProvider>,
);
expect(await screen.findByText("Initial reply")).toBeTruthy();
first.unmount();
await waitFor(() => expect(agent.detachAgentChat).toHaveBeenCalledWith("chat-session-1"));
scrollback = [
...scrollback,
{ kind: "toolActivity" as const, label: "Background tool" },
{ kind: "final" as const, content: "Completed away" },
];
render(
<DIProvider
gateways={{
agent,
system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways}
>
<CustomAgentChatView {...props} />
</DIProvider>,
);
expect(screen.getByText("Initial reply")).toBeTruthy();
expect(await screen.findByText("Background tool")).toBeTruthy();
expect(screen.getByText("Completed away")).toBeTruthy();
expect(
screen.getByText("Reprise effectuée: 2 événements rattrapés pendant l'absence."),
).toBeTruthy();
});
it("stops the live agent with cascade feedback and releases the structured session", async () => {
let resolveStop: (value: {
agentId: string;
@ -614,7 +691,7 @@ describe("CustomAgentChatView", () => {
expect(screen.getByText("Worker → QA")).toBeTruthy();
expect(screen.getByText("Worker → QA").parentElement?.textContent).toContain("started");
expect(screen.getAllByRole("status", { name: "Loading" }).length).toBeGreaterThan(0);
expect(screen.queryByRole("status", { name: "Loading" })).toBeNull();
act(() => {
emitChunk?.(progress("providerNative", "completed"));
@ -638,7 +715,7 @@ describe("CustomAgentChatView", () => {
);
});
it("stops animating running progress rows once the turn reaches a final chunk", async () => {
it("renders running progress without spinner and marks it done on final", async () => {
let emitChunk: ((chunk: unknown) => void) | null = null;
const agent = {
launchAgentChat: vi.fn(),
@ -707,7 +784,7 @@ describe("CustomAgentChatView", () => {
});
});
expect(screen.getAllByRole("status", { name: "Loading" }).length).toBeGreaterThan(0);
expect(screen.queryByRole("status", { name: "Loading" })).toBeNull();
expect(screen.getByText("Progress").parentElement?.textContent).toContain("running");
act(() => {