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:
@ -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?.();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user