feat(domain,application,infrastructure,backend,web-server,app-tauri,frontend): persistance et reprise des conversations de CLI custom par agent — index durable <projet>/.ideai/conversations/custom-index.json borné aux conversations non vides par agent (conversation_id logique, titre dérivé de la première invite, created/updated_at_ms), port CustomConversationIndex + adaptateur FsCustomConversationIndex, persistence des messages utilisateur et finals agent dans le log de conversation existant, DTO/commandes app-tauri et routes web-server pour lister/rouvrir, CustomAgentChatView: ouverture par défaut sur une nouvelle conversation, menu reprise d'une conversation passée ou nouvelle conversation, replay de l'historique sauvegardé (user+finals) à la réouverture, affichage normal des nouveaux messages après reprise, /clean ouvre une nouvelle conversation, aucune entrée créée si la conversation courante est vide, injection de l'historique dans le contexte agent différée au premier nouveau message après reprise — #363 (QA verte: git diff --check, cargo fmt --all -- --check, cargo tests ciblés infrastructure/application/web-server/app-tauri/backend, npm run typecheck, tests frontend ciblés et CustomAgentChatView, npm run build)

This commit is contained in:
2026-09-16 23:20:31 +02:00
parent 83b78f438f
commit 943c4525d7
30 changed files with 1794 additions and 109 deletions

View File

@ -5585,7 +5585,7 @@ describe("CustomAgentChatView", () => {
expect(shell.className).toContain("h-full");
expect(shell.className).toContain("min-h-0");
expect(shell.className).toContain("overflow-hidden");
expect(toolbar.className).toContain("overflow-hidden");
expect(toolbar.className).not.toContain("overflow-hidden");
expect(screen.queryByRole("button", { name: "Cancel" })).toBeNull();
expect(scroll.className).toContain("flex-1");
expect(scroll.className).toContain("basis-0");
@ -6322,4 +6322,120 @@ describe("CustomAgentChatView", () => {
);
expect(agent.launchAgentChat).not.toHaveBeenCalled();
});
it("lists and resumes durable custom conversations without recreating technical history", async () => {
const history = [
{ id: "p1", atMs: 10, role: "prompt" as const, source: { kind: "human" as const }, text: "Saved question", textLen: 14 },
{ id: "tool", atMs: 11, role: "toolActivity" as const, source: { kind: "agent" as const, agentId: "agent-1" }, text: "Hidden tool", textLen: 11 },
{ id: "r1", atMs: 12, role: "response" as const, source: { kind: "agent" as const, agentId: "agent-1" }, text: "Saved final", textLen: 11 },
];
const agent = {
launchAgentChat: vi.fn()
.mockResolvedValueOnce({ sessionId: "fresh-session", assignedConversationId: "fresh-conversation" })
.mockResolvedValueOnce({
sessionId: "resumed-session",
assignedConversationId: "past-conversation",
history,
historyHasMore: false,
resumed: true,
}),
reattachAgentChat: vi.fn(async (sessionId: string) => ({ sessionId, scrollback: [] })),
listCustomAgentConversations: vi.fn(async () => [{
conversationId: "past-conversation",
title: "Saved question",
createdAtMs: 10,
updatedAtMs: 12,
}]),
sendAgentChat: vi.fn(async (_sid: string, _prompt: string, onChunk: (chunk: ReplyChunk) => void) => {
onChunk({ kind: "final", content: "New final" });
}),
cancelAgentChat: vi.fn(async () => ({ sessionId: "resumed-session", outcome: "cancelled" as const })),
closeAgentChat: vi.fn(async () => {}),
};
render(
<DIProvider gateways={{
agent,
conversation: { readPage: vi.fn() },
system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways}>
<CustomAgentChatView
freshOnOpen
projectId="project-1"
agentId="agent-1"
agentName="Worker"
profile={profile}
cwd="/repo"
nodeId="node-1"
sessionId={null}
conversationId="stale-layout-conversation"
onSessionId={vi.fn()}
onConversationId={vi.fn()}
/>
</DIProvider>,
);
await waitFor(() => expect(agent.launchAgentChat).toHaveBeenCalledTimes(1));
expect(agent.launchAgentChat.mock.calls[0]?.[2].conversationId).toBeUndefined();
fireEvent.click(screen.getByRole("button", { name: "Conversations" }));
const past = await screen.findByRole("menuitem", { name: /Saved question/ });
fireEvent.click(past);
expect(await screen.findByText("Conversation reprise")).toBeTruthy();
expect(screen.getByText("Saved question")).toBeTruthy();
expect(screen.getByText("Saved final")).toBeTruthy();
expect(screen.queryByText("Hidden tool")).toBeNull();
expect(agent.sendAgentChat).not.toHaveBeenCalled();
fireEvent.change(screen.getByRole("textbox"), { target: { value: "Continue" } });
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
expect(await screen.findByText("Conversation poursuivie")).toBeTruthy();
expect(await screen.findByText("New final")).toBeTruthy();
});
it("uses /clean for a new conversation and disables conversation changes during a turn", async () => {
let finishSend: (() => void) | undefined;
const agent = {
launchAgentChat: vi.fn(async () => ({ sessionId: "chat-session", assignedConversationId: "conversation-1" })),
reattachAgentChat: vi.fn(async (sessionId: string) => ({ sessionId, scrollback: [] })),
listCustomAgentConversations: vi.fn(async () => [{
conversationId: "past-conversation",
title: "Past conversation",
createdAtMs: 10,
updatedAtMs: 20,
}]),
sendAgentChat: vi.fn(() => new Promise<void>((resolve) => { finishSend = resolve; })),
executeSlashCommand: vi.fn(async () => ({
command: { name: "/clean", shortDescription: "Clean", requiresConfirmation: false, availability: { status: "available" as const }, source: "native" as const, native: "clean" as const },
effect: { kind: "cleanConversation" as const, sessionId: "clean-session", clearedChunks: 0, conversationId: "conversation-2" },
})),
cancelAgentChat: vi.fn(async () => ({ sessionId: "chat-session", outcome: "cancelled" as const })),
closeAgentChat: vi.fn(async () => {}),
};
const onConversationId = vi.fn();
render(
<DIProvider gateways={{ agent, system: { pickFile: vi.fn(async () => null) } } as unknown as Gateways}>
<CustomAgentChatView
freshOnOpen projectId="project-1" agentId="agent-1" agentName="Worker"
profile={profile} cwd="/repo" nodeId="node-1" sessionId={null}
conversationId={null} onSessionId={vi.fn()} onConversationId={onConversationId}
/>
</DIProvider>,
);
await waitFor(() => expect(agent.launchAgentChat).toHaveBeenCalledTimes(1));
fireEvent.click(screen.getByRole("button", { name: "Conversations" }));
fireEvent.click(screen.getByRole("menuitem", { name: "Nouvelle conversation" }));
await waitFor(() => expect(agent.executeSlashCommand).toHaveBeenCalledWith("/clean", {
sessionId: "chat-session",
}));
expect(await screen.findByText("Nouvelle conversation prête.")).toBeTruthy();
expect(onConversationId).toHaveBeenCalledWith("conversation-2");
fireEvent.change(screen.getByRole("textbox"), { target: { value: "Working" } });
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
fireEvent.click(screen.getByRole("button", { name: "Conversations" }));
expect(await screen.findByText("Arrêtez la réponse avant de changer de conversation.")).toBeTruthy();
expect(screen.getByRole("menuitem", { name: "Nouvelle conversation" }).hasAttribute("disabled")).toBe(true);
expect((await screen.findByRole("menuitem", { name: /Past conversation/ })).hasAttribute("disabled")).toBe(true);
finishSend?.();
});
});