Files
IdeA/frontend/src/features/agents/CustomAgentChatView.test.tsx

2129 lines
65 KiB
TypeScript

import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { useState } from "react";
import { describe, expect, it, vi } from "vitest";
import { DIProvider } from "@/app/di";
import type { AgentProfile } from "@/domain";
import { PluginRuntimeProvider } from "@/features/plugins";
import {
PluginCommandRegistry,
PluginLayoutRegistry,
PluginMenuRegistry,
PluginRuntimeRegistry,
} from "@/plugins/runtime";
import type { Gateways } from "@/ports";
import { CustomAgentChatView } from "./CustomAgentChatView";
const profile: AgentProfile = {
id: "structured",
name: "Structured Codex",
command: "codex",
args: [],
contextInjection: { strategy: "conventionFile", target: "AGENTS.md" },
detect: null,
cwdTemplate: "{projectRoot}",
structuredAdapter: "codex",
};
function addPluginCommand(
registry: PluginRuntimeRegistry,
pluginId: string,
commandId: string,
handler: (...args: unknown[]) => unknown | Promise<unknown>,
) {
const commands = new PluginCommandRegistry(pluginId, new Set([commandId]));
commands.register(commandId, handler);
registry.add({
pluginId,
displayName: "Acme Plugin",
contributes: {
menus: [],
menuItems: [],
slashCommands: [
{
name: "/explain",
shortDescription: "Explain selection",
command: commandId,
},
],
layouts: [],
mcpServers: [],
},
commands,
layouts: new PluginLayoutRegistry(pluginId, new Set()),
menu: new PluginMenuRegistry(pluginId),
dispose: async () => {},
});
}
describe("CustomAgentChatView", () => {
it("cancels only the current turn and keeps the structured session alive", async () => {
const agent = {
launchAgentChat: vi.fn(),
reattachAgentChat: vi.fn(async (sessionId: string) => ({
sessionId,
scrollback: [],
})),
sendAgentChat: vi.fn(() => new Promise<void>(() => {})),
cancelAgentChat: vi.fn(async () => {}),
closeAgentChat: vi.fn(async () => {}),
};
const onSessionId = vi.fn();
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={onSessionId}
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 turn" },
});
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
await waitFor(() =>
expect(agent.sendAgentChat).toHaveBeenCalledWith(
"chat-session-1",
"first turn",
expect.any(Function),
),
);
expect(screen.queryByRole("button", { name: "Cancel" })).toBeNull();
fireEvent.click(await screen.findByRole("button", { name: "Stop" }));
await waitFor(() =>
expect(agent.cancelAgentChat).toHaveBeenCalledWith("chat-session-1"),
);
expect(agent.closeAgentChat).not.toHaveBeenCalled();
expect(onSessionId).not.toHaveBeenCalledWith(null);
expect(screen.getByText("Tour interrompu.")).toBeTruthy();
fireEvent.change(screen.getByLabelText(/message CLI custom/), {
target: { value: "second turn" },
});
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalledTimes(2));
expect(agent.sendAgentChat).toHaveBeenLastCalledWith(
"chat-session-1",
"second turn",
expect.any(Function),
);
});
it("falls back to a fresh launch when reattach reports NOT_FOUND", async () => {
const agent = {
launchAgentChat: vi.fn(async () => ({
sessionId: "chat-session-2",
assignedConversationId: "conversation-2",
})),
reattachAgentChat: vi
.fn()
.mockRejectedValueOnce({ code: "NOT_FOUND", message: "structured session gone" })
.mockImplementationOnce(async (sessionId: string) => ({
sessionId,
scrollback: [],
})),
sendAgentChat: vi.fn(() => new Promise<void>(() => {})),
cancelAgentChat: vi.fn(async () => {}),
closeAgentChat: vi.fn(async () => {}),
};
const onSessionId = vi.fn();
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="stale-session"
conversationId="conversation-1"
onSessionId={onSessionId}
onConversationId={vi.fn()}
/>
</DIProvider>,
);
await waitFor(() =>
expect(agent.launchAgentChat).toHaveBeenCalledWith("project-1", "agent-1", {
cwd: "/repo",
rows: 24,
cols: 80,
conversationId: "conversation-1",
nodeId: "node-1",
}),
);
await waitFor(() =>
expect(onSessionId).toHaveBeenCalledWith("chat-session-2"),
);
expect(screen.queryByText(/structured session gone/)).toBeNull();
});
it("does not duplicate the user prompt when the stream echoes it back", 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: "userPrompt", text: "hello agent" });
onChunk({ kind: "final", content: "done" });
}),
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: "hello agent" },
});
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
await screen.findByText("done");
expect(screen.getAllByText("hello agent")).toHaveLength(1);
expect(screen.getByText("Terminé").parentElement?.parentElement?.className).toContain(
"justify-end",
);
});
it("renders canonical progress chunks as live agent, MCP, and delegation activity", 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: "providerNative",
kind: "turn",
stage: "started",
label: "turn.started",
provider: "codex",
nativeEvent: "turn.started",
},
});
onChunk({
kind: "progress",
progress: {
source: "providerNative",
kind: "message",
stage: "delta",
label: "Analyse",
text: "Lecture du contexte disponible.",
provider: "codex",
nativeEvent: "agent.message.delta",
},
});
onChunk({
kind: "progress",
progress: {
source: "ideaLocal",
kind: "mcp",
stage: "started",
label: "idea_ticket_read",
toolName: "idea_ticket_read",
},
});
onChunk({
kind: "progress",
progress: {
source: "ideaLocal",
kind: "mcp",
stage: "started",
label: "idea_ask_agent",
text: "target=QA task=Valider le rendu progress",
toolName: "idea_ask_agent",
},
});
onChunk({
kind: "progress",
progress: {
source: "providerNative",
kind: "turn",
stage: "completed",
label: "turn.completed",
provider: "codex",
nativeEvent: "turn.completed",
},
});
onChunk({ kind: "final", content: "done" });
}),
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: "ship progress UI" },
});
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
await screen.findByText("Progress");
expect(screen.getAllByTestId("chat-turn-marker")).toHaveLength(2);
expect(screen.getByText("turn.started")).toBeTruthy();
expect(screen.getByText("turn.completed")).toBeTruthy();
expect(screen.getByText("Progress").closest("[data-testid='chat-progress-status']")).toBeTruthy();
expect(screen.getByText("Analyse")).toBeTruthy();
expect(screen.getByText("Lecture du contexte disponible.")).toBeTruthy();
expect(screen.getByText("MCP")).toBeTruthy();
expect(screen.getByText("MCP").closest("[data-testid='chat-tool-progress']")).toBeTruthy();
expect(screen.getByText("idea_ticket_read")).toBeTruthy();
expect(screen.getByText("Worker ← QA")).toBeTruthy();
expect(screen.getByText("Worker ← QA").closest("[data-testid='chat-tool-progress']")).toBeTruthy();
expect(screen.getByText("Valider le rendu progress")).toBeTruthy();
expect((await screen.findAllByText("done")).length).toBeGreaterThan(0);
});
it("renders available pre-MCP progress before the first MCP card", async () => {
let emitChunk: ((chunk: unknown) => void) | null = null;
const agent = {
launchAgentChat: vi.fn(),
reattachAgentChat: vi.fn(async (sessionId: string) => ({
sessionId,
scrollback: [],
})),
sendAgentChat: vi.fn(
(_sessionId: string, _prompt: string, onChunk: (chunk: unknown) => void) =>
new Promise<void>((resolve) => {
emitChunk = (chunk: unknown) => {
onChunk(chunk);
if ((chunk as { kind?: string }).kind === "final") resolve();
};
}),
),
cancelAgentChat: vi.fn(async () => {}),
closeAgentChat: vi.fn(async () => {}),
};
const { container } = 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: "delegate after intent" },
});
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
await waitFor(() => expect(emitChunk).not.toBeNull());
act(() => {
emitChunk?.({
kind: "progress",
progress: {
source: "providerNative",
kind: "message",
stage: "delta",
label: "Intention",
text: "Je vais d'abord regarder les outils MCP disponibles.",
provider: "codex",
nativeEvent: "agent.message.delta",
},
});
});
expect(
screen.getByText("Je vais d'abord regarder les outils MCP disponibles."),
).toBeTruthy();
expect(screen.queryByText("idea_ask_agent")).toBeNull();
act(() => {
emitChunk?.({
kind: "progress",
progress: {
source: "ideaLocal",
kind: "mcp",
stage: "started",
label: "idea_ask_agent",
toolName: "idea_ask_agent",
},
});
});
const intent = screen.getByText(
"Je vais d'abord regarder les outils MCP disponibles.",
);
const delegation = screen.getByText("idea_ask_agent");
expect(
Boolean(intent.compareDocumentPosition(delegation) & Node.DOCUMENT_POSITION_FOLLOWING),
).toBe(true);
act(() => {
emitChunk?.({ kind: "final", content: "done" });
});
await screen.findByText("done");
expect(container.textContent).toContain("Je vais d'abord regarder");
});
it("deduplicates delegated tool progress from tap and authoritative stream", async () => {
let emitChunk: ((chunk: unknown) => void) | null = null;
const agent = {
launchAgentChat: vi.fn(),
reattachAgentChat: vi.fn(async (sessionId: string) => ({
sessionId,
scrollback: [],
})),
sendAgentChat: vi.fn(
(_sessionId: string, _prompt: string, onChunk: (chunk: unknown) => void) =>
new Promise<void>((resolve) => {
emitChunk = (chunk: unknown) => {
onChunk(chunk);
if ((chunk as { kind?: string }).kind === "final") resolve();
};
}),
),
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: "delegate once" },
});
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
await waitFor(() => expect(emitChunk).not.toBeNull());
const progress = (source: "providerNative" | "ideaLocal", stage: "started" | "completed") => ({
kind: "progress" as const,
progress: {
source,
kind: source === "ideaLocal" ? ("mcp" as const) : ("tool" as const),
stage,
label: "idea_ask_agent",
text: stage === "started" ? "target=QA task=validate" : "reply delivered",
provider: source === "providerNative" ? "opencode" : undefined,
nativeEvent:
source === "providerNative"
? stage === "started"
? "tool_use"
: "tool_done"
: stage === "started"
? "conversation.item.added"
: "conversation.item.done",
toolName: "idea_ask_agent",
},
});
act(() => {
emitChunk?.(progress("providerNative", "started"));
emitChunk?.(progress("ideaLocal", "started"));
});
expect(screen.getByText("Worker → QA")).toBeTruthy();
expect(screen.getByText("Worker → QA").parentElement?.textContent).toContain("started");
expect(screen.getAllByRole("status", { name: "Loading" }).length).toBeGreaterThan(0);
act(() => {
emitChunk?.(progress("providerNative", "completed"));
emitChunk?.(progress("ideaLocal", "completed"));
emitChunk?.(progress("providerNative", "started"));
emitChunk?.(progress("ideaLocal", "started"));
emitChunk?.(progress("providerNative", "completed"));
emitChunk?.(progress("ideaLocal", "completed"));
emitChunk?.({ kind: "final", content: "done" });
});
await screen.findByText("done");
expect(screen.getAllByText("Worker ← QA")).toHaveLength(2);
expect(screen.getAllByText("validate")).toHaveLength(2);
expect(screen.queryByText("Appel inter-agent")).toBeNull();
expect(screen.queryByText("reply delivered")).toBeNull();
expect(screen.queryByText("Tool")).toBeNull();
expect(screen.queryByText("started")).toBeNull();
await waitFor(() =>
expect(screen.queryByRole("status", { name: "Loading" })).toBeNull(),
);
});
it("stops animating running progress rows once the turn reaches a final chunk", async () => {
let emitChunk: ((chunk: unknown) => void) | null = null;
const agent = {
launchAgentChat: vi.fn(),
reattachAgentChat: vi.fn(async (sessionId: string) => ({
sessionId,
scrollback: [],
})),
sendAgentChat: vi.fn(
(_sessionId: string, _prompt: string, onChunk: (chunk: unknown) => void) =>
new Promise<void>((resolve) => {
emitChunk = (chunk: unknown) => {
onChunk(chunk);
if ((chunk as { kind?: string }).kind === "final") resolve();
};
}),
),
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: "ship progress UI" },
});
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
await waitFor(() => expect(emitChunk).not.toBeNull());
act(() => {
emitChunk?.({
kind: "progress",
progress: {
source: "providerNative",
kind: "message",
stage: "delta",
label: "Analyse",
text: "Lecture du contexte disponible.",
},
});
});
expect(screen.getAllByRole("status", { name: "Loading" }).length).toBeGreaterThan(0);
expect(screen.getByText("Progress").parentElement?.textContent).toContain("running");
act(() => {
emitChunk?.({ kind: "final", content: "done" });
});
await screen.findByText("done");
await waitFor(() =>
expect(screen.queryByRole("status", { name: "Loading" })).toBeNull(),
);
expect(screen.getByText("Lecture du contexte disponible.")).toBeTruthy();
expect(screen.getByText("Progress").parentElement?.textContent).toContain("done");
});
it("does not append late progress chunks after the final answer", async () => {
let emitChunk: ((chunk: unknown) => void) | null = null;
const agent = {
launchAgentChat: vi.fn(),
reattachAgentChat: vi.fn(async (sessionId: string) => ({
sessionId,
scrollback: [],
})),
sendAgentChat: vi.fn(
(_sessionId: string, _prompt: string, onChunk: (chunk: unknown) => void) =>
new Promise<void>((resolve) => {
emitChunk = (chunk: unknown) => {
onChunk(chunk);
if ((chunk as { kind?: string }).kind === "final") resolve();
};
}),
),
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: "ship ordered stream" },
});
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
await waitFor(() => expect(emitChunk).not.toBeNull());
act(() => {
emitChunk?.({ kind: "final", content: "done" });
});
await screen.findByText("done");
act(() => {
emitChunk?.({
kind: "progress",
progress: {
source: "ideaLocal",
kind: "mcp",
stage: "started",
label: "idea_ticket_read",
toolName: "idea_ticket_read",
},
});
});
expect(screen.queryByText("idea_ticket_read")).toBeNull();
});
it("shows slash-command suggestions from the gateway, filters by prefix, and inserts the selected command", async () => {
const agent = {
launchAgentChat: vi.fn(),
reattachAgentChat: vi.fn(async (sessionId: string) => ({
sessionId,
scrollback: [],
})),
sendAgentChat: vi.fn(async () => {}),
listSlashCommands: vi.fn(async (prefix?: string) =>
[
{
name: "/clean",
shortDescription: "Nettoyer la conversation courante",
requiresConfirmation: false,
availability: { status: "available" as const },
source: "native" as const,
native: "clean" as const,
},
{
name: "/commit",
shortDescription: "Préparer un commit",
requiresConfirmation: true,
availability: { status: "available" as const },
source: { plugin: { pluginId: "git" } },
},
].filter((command) => !prefix || command.name.startsWith(prefix)),
),
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),
),
);
const composer = screen.getByLabelText(/message CLI custom/) as HTMLTextAreaElement;
fireEvent.change(composer, { target: { value: "/c" } });
const menu = await screen.findByRole("listbox", {
name: "suggestions commandes slash",
});
expect(agent.listSlashCommands).toHaveBeenLastCalledWith("/c");
expect(screen.getByRole("option", { name: /\/clean/ })).toBeTruthy();
expect(screen.getByRole("option", { name: /\/commit/ })).toBeTruthy();
fireEvent.keyDown(composer, { key: "ArrowDown" });
fireEvent.keyDown(composer, { key: "Enter" });
await waitFor(() => expect(composer.value).toBe("/commit "));
expect(menu).toBeTruthy();
expect(screen.queryByRole("listbox", { name: "suggestions commandes slash" })).toBeNull();
expect(agent.sendAgentChat).not.toHaveBeenCalled();
});
it("only opens slash-command suggestions at the start of an unfinished slash token", async () => {
const agent = {
launchAgentChat: vi.fn(),
reattachAgentChat: vi.fn(async (sessionId: string) => ({
sessionId,
scrollback: [],
})),
sendAgentChat: vi.fn(async () => {}),
listSlashCommands: vi.fn(async () => [
{
name: "/clean",
shortDescription: "Nettoyer la conversation courante",
requiresConfirmation: false,
availability: { status: "available" as const },
source: "native" as const,
native: "clean" as const,
},
]),
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),
),
);
const composer = screen.getByLabelText(/message CLI custom/) as HTMLTextAreaElement;
fireEvent.change(composer, { target: { value: "hello /c" } });
expect(screen.queryByRole("listbox", { name: "suggestions commandes slash" })).toBeNull();
expect(agent.listSlashCommands).not.toHaveBeenCalled();
fireEvent.change(composer, { target: { value: "/" } });
await screen.findByRole("listbox", { name: "suggestions commandes slash" });
fireEvent.change(composer, { target: { value: "/clean now" } });
expect(screen.queryByRole("listbox", { name: "suggestions commandes slash" })).toBeNull();
});
it("dispatches plugin slash-command callback effects through the plugin runtime", async () => {
const commandHandler = vi.fn(async () => {});
const registry = new PluginRuntimeRegistry();
addPluginCommand(
registry,
"dev.acme.explain",
"dev.acme.explain.run",
commandHandler,
);
const agent = {
launchAgentChat: vi.fn(),
reattachAgentChat: vi.fn(async (sessionId: string) => ({
sessionId,
scrollback: [],
})),
sendAgentChat: vi.fn(async () => {}),
executeSlashCommand: vi.fn(async (_name: string, options) => ({
command: {
name: "/explain",
shortDescription: "Explain selection",
requiresConfirmation: false,
availability: { status: "available" as const },
source: { plugin: { pluginId: "dev.acme.explain" } },
plugin: {
pluginId: "dev.acme.explain",
commandId: "dev.acme.explain.run",
},
},
effect: {
kind: "pluginCallback" as const,
pluginId: "dev.acme.explain",
commandId: "dev.acme.explain.run",
sessionId: options.sessionId,
arguments: options.arguments ?? [],
},
})),
cancelAgentChat: vi.fn(async () => {}),
closeAgentChat: vi.fn(async () => {}),
};
render(
<DIProvider
gateways={{
agent,
system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways}
>
<PluginRuntimeProvider
value={{ registry, failures: [], pending: [], loading: false }}
>
<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()}
/>
</PluginRuntimeProvider>
</DIProvider>,
);
await waitFor(() =>
expect(agent.reattachAgentChat).toHaveBeenCalledWith(
"chat-session-1",
expect.any(Function),
),
);
const composer = screen.getByLabelText(/message CLI custom/) as HTMLTextAreaElement;
fireEvent.change(composer, { target: { value: "/explain focus this" } });
fireEvent.keyDown(composer, { key: "Enter" });
await waitFor(() =>
expect(agent.executeSlashCommand).toHaveBeenCalledWith("/explain", {
sessionId: "chat-session-1",
arguments: ["focus this"],
}),
);
await waitFor(() => expect(commandHandler).toHaveBeenCalledWith("focus this"));
expect(agent.sendAgentChat).not.toHaveBeenCalled();
expect(
await screen.findByText("Commande plugin /explain exécutée."),
).toBeTruthy();
expect(composer.value).toBe("");
});
it("executes /clean as a native conversation clear effect", async () => {
const agent = {
launchAgentChat: vi.fn(),
reattachAgentChat: vi.fn(async (sessionId: string) => ({
sessionId,
scrollback: [{ kind: "userPrompt", text: "old conversation" }],
})),
sendAgentChat: vi.fn(async () => {}),
executeSlashCommand: vi.fn(async () => ({
command: {
name: "/clean",
shortDescription: "Nettoyer la conversation courante",
requiresConfirmation: false,
availability: { status: "available" as const },
source: "native" as const,
native: "clean" as const,
},
effect: {
kind: "cleanConversation" as const,
sessionId: "chat-session-1",
clearedChunks: 1,
},
})),
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>,
);
expect(await screen.findByText("old conversation")).toBeTruthy();
const composer = screen.getByLabelText(/message CLI custom/) as HTMLTextAreaElement;
fireEvent.change(composer, { target: { value: "/clean" } });
fireEvent.keyDown(composer, { key: "Enter" });
await waitFor(() =>
expect(agent.executeSlashCommand).toHaveBeenCalledWith("/clean", {
sessionId: "chat-session-1",
}),
);
await waitFor(() => expect(screen.queryByText("old conversation")).toBeNull());
expect(screen.queryByRole("alert")).toBeNull();
expect(agent.sendAgentChat).not.toHaveBeenCalled();
expect(composer.value).toBe("");
});
it("executes /help as a native help effect", async () => {
const agent = {
launchAgentChat: vi.fn(),
reattachAgentChat: vi.fn(async (sessionId: string) => ({
sessionId,
scrollback: [],
})),
sendAgentChat: vi.fn(async () => {}),
executeSlashCommand: vi.fn(async () => ({
command: {
name: "/help",
shortDescription: "Afficher les commandes disponibles",
requiresConfirmation: false,
availability: { status: "available" as const },
source: "native" as const,
native: "help" as const,
},
effect: {
kind: "help" as const,
commands: [
{
name: "/help",
shortDescription: "Afficher les commandes disponibles",
requiresConfirmation: false,
availability: { status: "available" as const },
source: "native" as const,
native: "help" as const,
},
{
name: "/clean",
shortDescription: "Nettoyer la conversation courante",
requiresConfirmation: false,
availability: { status: "available" as const },
source: "native" as const,
native: "clean" as const,
},
{
name: "/profile",
shortDescription: "Changer le profil de l'agent",
requiresConfirmation: true,
availability: { status: "available" as const },
source: "native" as const,
native: "profile" as const,
},
],
},
})),
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),
),
);
const composer = screen.getByLabelText(/message CLI custom/) as HTMLTextAreaElement;
fireEvent.change(composer, { target: { value: "/help" } });
fireEvent.keyDown(composer, { key: "Enter" });
await waitFor(() =>
expect(agent.executeSlashCommand).toHaveBeenCalledWith("/help", {
sessionId: "chat-session-1",
}),
);
expect(
await screen.findByText(/\/clean - Nettoyer la conversation courante/),
).toBeTruthy();
expect(screen.queryByRole("alert")).toBeNull();
expect(agent.sendAgentChat).not.toHaveBeenCalled();
expect(composer.value).toBe("");
});
it("shows feedback when a plugin slash-command callback cannot be dispatched", async () => {
const registry = new PluginRuntimeRegistry();
const agent = {
launchAgentChat: vi.fn(),
reattachAgentChat: vi.fn(async (sessionId: string) => ({
sessionId,
scrollback: [],
})),
sendAgentChat: vi.fn(async () => {}),
executeSlashCommand: vi.fn(async () => ({
command: {
name: "/explain",
shortDescription: "Explain selection",
requiresConfirmation: false,
availability: { status: "available" as const },
source: { plugin: { pluginId: "dev.acme.missing" } },
plugin: {
pluginId: "dev.acme.missing",
commandId: "dev.acme.missing.run",
},
},
effect: {
kind: "pluginCallback" as const,
pluginId: "dev.acme.missing",
commandId: "dev.acme.missing.run",
arguments: [],
},
})),
cancelAgentChat: vi.fn(async () => {}),
closeAgentChat: vi.fn(async () => {}),
};
render(
<DIProvider
gateways={{
agent,
system: { pickFile: vi.fn(async () => null) },
} as unknown as Gateways}
>
<PluginRuntimeProvider
value={{ registry, failures: [], pending: [], loading: false }}
>
<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()}
/>
</PluginRuntimeProvider>
</DIProvider>,
);
await waitFor(() =>
expect(agent.reattachAgentChat).toHaveBeenCalledWith(
"chat-session-1",
expect.any(Function),
),
);
const composer = screen.getByLabelText(/message CLI custom/);
fireEvent.change(composer, { target: { value: "/explain" } });
fireEvent.keyDown(composer, { key: "Enter" });
const alert = await screen.findByRole("alert");
expect(alert.textContent).toContain("Commande /explain échouée");
expect(alert.textContent).toContain('plugin "dev.acme.missing" is not loaded');
expect(screen.getAllByText(/Commande \/explain échouée/)).toHaveLength(2);
expect(agent.sendAgentChat).not.toHaveBeenCalled();
});
it("executes /profile as a confirmed profile-switch flow that resets the current session", async () => {
const agent = {
launchAgentChat: vi.fn(async () => ({
sessionId: "chat-session-2",
cellKind: "chat" as const,
assignedConversationId: "conversation-2",
})),
reattachAgentChat: vi.fn(async (sessionId: string) => ({
sessionId,
scrollback: [],
})),
sendAgentChat: vi.fn(async () => {}),
executeSlashCommand: vi.fn(async () => ({
command: {
name: "/profile",
shortDescription: "Changer le profil de l'agent",
requiresConfirmation: true,
availability: { status: "available" as const },
source: "native" as const,
native: "profile" as const,
},
effect: {
kind: "profileSwitch" as const,
sessionId: "chat-session-1",
},
})),
changeAgentProfile: vi.fn(async () => ({ agent: { id: "agent-1" } })),
cancelAgentChat: vi.fn(async () => {}),
closeAgentChat: vi.fn(async () => {}),
};
const profileGateway = {
listProfiles: vi.fn(async () => [
profile,
{
...profile,
id: "codex-high",
name: "Codex High",
},
]),
};
const onSessionId = vi.fn();
const onConversationId = vi.fn();
render(
<DIProvider
gateways={{
agent,
profile: profileGateway,
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={onSessionId}
onConversationId={onConversationId}
/>
</DIProvider>,
);
await waitFor(() =>
expect(agent.reattachAgentChat).toHaveBeenCalledWith(
"chat-session-1",
expect.any(Function),
),
);
const composer = screen.getByLabelText(/message CLI custom/) as HTMLTextAreaElement;
fireEvent.change(composer, { target: { value: "/profile" } });
fireEvent.keyDown(composer, { key: "Enter" });
const dialog = await screen.findByRole("dialog", { name: "Changer de profil" });
expect(dialog.textContent).toContain("réinitialiser la session courante");
expect(dialog.textContent).toContain("L'historique de cette CLI custom sera perdu");
expect(agent.executeSlashCommand).toHaveBeenCalledWith("/profile", {
sessionId: "chat-session-1",
});
expect(agent.sendAgentChat).not.toHaveBeenCalled();
const confirmButton = screen.getByRole("button", {
name: "Reset et changer le profil",
}) as HTMLButtonElement;
expect(confirmButton.disabled).toBe(true);
fireEvent.change(screen.getByLabelText("profil cible"), {
target: { value: "codex-high" },
});
fireEvent.click(screen.getByLabelText("Je confirme que la session courante sera reset."));
expect(confirmButton.disabled).toBe(false);
fireEvent.click(confirmButton);
await waitFor(() =>
expect(agent.changeAgentProfile).toHaveBeenCalledWith(
"project-1",
"agent-1",
"codex-high",
24,
80,
),
);
await waitFor(() =>
expect(agent.launchAgentChat).toHaveBeenCalledWith("project-1", "agent-1", {
cwd: "/repo",
rows: 24,
cols: 80,
conversationId: undefined,
nodeId: "node-1",
}),
);
expect(onSessionId).toHaveBeenCalledWith(null);
expect(onConversationId).toHaveBeenCalledWith(null);
expect(onSessionId).toHaveBeenCalledWith("chat-session-2");
expect(onConversationId).toHaveBeenCalledWith("conversation-2");
expect(screen.getByText("Profil changé vers Codex High.")).toBeTruthy();
expect(screen.queryByRole("dialog", { name: "Changer de profil" })).toBeNull();
});
it("pastes a clipboard image as a removable preview chip", async () => {
const agent = {
launchAgentChat: vi.fn(),
reattachAgentChat: vi.fn(async (sessionId: string) => ({
sessionId,
scrollback: [],
})),
sendAgentChat: vi.fn(() => new Promise<void>(() => {})),
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),
),
);
const file = new File(["ignored"], "clipboard.png", { type: "image/png" });
Object.defineProperty(file, "arrayBuffer", {
value: vi.fn(async () => new Uint8Array([1, 2, 3]).buffer),
});
fireEvent.paste(screen.getByLabelText(/message CLI custom/), {
clipboardData: {
items: [
{
kind: "file",
type: "image/png",
getAsFile: () => file,
},
],
getData: () => "",
},
});
expect(await screen.findByText("Fichier joint: clipboard.png")).toBeTruthy();
expect(screen.getByTestId("attachment-preview-clipboard.png")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "retirer clipboard.png" }));
await waitFor(() =>
expect(screen.queryByText("Fichier joint: clipboard.png")).toBeNull(),
);
expect(
(screen.getByRole("button", { name: "Envoyer" }) as HTMLButtonElement)
.disabled,
).toBe(true);
});
it("sends a pasted clipboard image without requiring text", async () => {
const agent = {
launchAgentChat: vi.fn(),
reattachAgentChat: vi.fn(async (sessionId: string) => ({
sessionId,
scrollback: [],
})),
sendAgentChat: vi.fn(async () => {}),
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),
),
);
const file = new File(["ignored"], "paste-image.png", { type: "image/png" });
Object.defineProperty(file, "arrayBuffer", {
value: vi.fn(async () => new Uint8Array([1, 2, 3]).buffer),
});
fireEvent.paste(screen.getByLabelText(/message CLI custom/), {
clipboardData: {
items: [
{
kind: "file",
type: "image/png",
getAsFile: () => file,
},
],
getData: () => "",
},
});
await screen.findByText("Fichier joint: paste-image.png");
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalledTimes(1));
expect(agent.sendAgentChat).toHaveBeenCalledWith(
"chat-session-1",
"",
expect.any(Function),
{
attachments: [
{
filename: "paste-image.png",
contentBase64: "AQID",
mime: "image/png",
sourceKind: "clipboard",
},
],
},
);
expect(screen.getByText("Pièce jointe")).toBeTruthy();
expect(screen.getByText("Fichier: paste-image.png")).toBeTruthy();
});
it("accepts pasted generic files exposed only through clipboardData.files", async () => {
const agent = {
launchAgentChat: vi.fn(),
reattachAgentChat: vi.fn(async (sessionId: string) => ({
sessionId,
scrollback: [],
})),
sendAgentChat: vi.fn(async () => {}),
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),
),
);
const file = new File(["ignored"], "files-only.pdf", { type: "application/pdf" });
Object.defineProperty(file, "arrayBuffer", {
value: vi.fn(async () => new Uint8Array([4, 5, 6]).buffer),
});
fireEvent.paste(screen.getByLabelText(/message CLI custom/), {
clipboardData: {
items: [],
files: [file],
getData: () => "",
},
});
await screen.findByText("Fichier joint: files-only.pdf");
expect(screen.queryByTestId("attachment-preview-files-only.pdf")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalledTimes(1));
expect(agent.sendAgentChat).toHaveBeenCalledWith(
"chat-session-1",
"",
expect.any(Function),
{
attachments: [
{
filename: "files-only.pdf",
contentBase64: "BAUG",
mime: "application/pdf",
sourceKind: "clipboard",
},
],
},
);
});
it("keeps the chat shell bounded with a scrollable message area and fixed composer", async () => {
const agent = {
launchAgentChat: vi.fn(() => new Promise<never>(() => {})),
reattachAgentChat: vi.fn(),
sendAgentChat: vi.fn(() => new Promise<void>(() => {})),
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 with a very long name that must not collide with controls"
profile={{
...profile,
name: "Structured profile with a very long display name",
}}
cwd="/repo"
nodeId="node-1"
sessionId={null}
conversationId="conversation-1"
onSessionId={vi.fn()}
onConversationId={vi.fn()}
/>
</DIProvider>,
);
const shell = screen.getByTestId("custom-agent-chat-view");
const toolbar = await screen.findByRole("toolbar", {
name: "custom agent chat actions",
});
const scroll = screen.getByTestId("custom-agent-chat-scroll");
const composer = screen.getByTestId("custom-agent-chat-composer");
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(screen.queryByRole("button", { name: "Cancel" })).toBeNull();
expect(scroll.className).toContain("flex-1");
expect(scroll.className).toContain("basis-0");
expect(scroll.className).toContain("overflow-y-auto");
expect(composer.className).toContain("shrink-0");
});
it("falls back when Tauri surfaces structured session NOT_FOUND as a raw string", async () => {
const agent = {
launchAgentChat: vi.fn(async () => ({
sessionId: "chat-session-2",
assignedConversationId: "conversation-2",
})),
reattachAgentChat: vi
.fn()
.mockRejectedValueOnce(
"not found: structured session 69b57638-0ac7-4a79-8252-e0436a3265f6",
)
.mockImplementationOnce(async (sessionId: string) => ({
sessionId,
scrollback: [],
})),
sendAgentChat: vi.fn(() => new Promise<void>(() => {})),
cancelAgentChat: vi.fn(async () => {}),
closeAgentChat: vi.fn(async () => {}),
};
const onSessionId = vi.fn();
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="69b57638-0ac7-4a79-8252-e0436a3265f6"
conversationId="conversation-1"
onSessionId={onSessionId}
onConversationId={vi.fn()}
/>
</DIProvider>,
);
await waitFor(() => expect(agent.launchAgentChat).toHaveBeenCalledTimes(1));
expect(agent.reattachAgentChat).toHaveBeenNthCalledWith(
1,
"69b57638-0ac7-4a79-8252-e0436a3265f6",
expect.any(Function),
);
expect(agent.reattachAgentChat).toHaveBeenNthCalledWith(
2,
"chat-session-2",
expect.any(Function),
);
expect(onSessionId).toHaveBeenCalledWith(null);
expect(onSessionId).toHaveBeenCalledWith("chat-session-2");
expect(screen.queryByText(/not found: structured session/)).toBeNull();
});
it("does not reopen when the parent echoes a launched session id", async () => {
const agent = {
launchAgentChat: vi.fn(async () => ({
sessionId: "chat-session-echo",
assignedConversationId: "conversation-2",
})),
reattachAgentChat: vi.fn(async (sessionId: string) => ({
sessionId,
scrollback: [],
})),
sendAgentChat: vi.fn(() => new Promise<void>(() => {})),
cancelAgentChat: vi.fn(async () => {}),
closeAgentChat: vi.fn(async () => {}),
};
const onSessionId = vi.fn();
function EchoingParent() {
const [echoedSessionId, setEchoedSessionId] = useState<string | null>(null);
return (
<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={echoedSessionId}
conversationId="conversation-1"
onSessionId={(nextSessionId) => {
onSessionId(nextSessionId);
setEchoedSessionId(nextSessionId);
}}
onConversationId={vi.fn()}
/>
</DIProvider>
);
}
render(<EchoingParent />);
await waitFor(() =>
expect(onSessionId).toHaveBeenCalledWith("chat-session-echo"),
);
await waitFor(() => expect(agent.launchAgentChat).toHaveBeenCalledTimes(1));
await waitFor(() => expect(agent.reattachAgentChat).toHaveBeenCalledTimes(1));
await new Promise((resolve) => setTimeout(resolve, 20));
expect(agent.launchAgentChat).toHaveBeenCalledTimes(1);
expect(agent.reattachAgentChat).toHaveBeenCalledTimes(1);
});
it("recovers and retries the prompt when send reports NOT_FOUND", async () => {
const agent = {
launchAgentChat: vi.fn(async () => ({
sessionId: "chat-session-2",
assignedConversationId: "conversation-2",
})),
reattachAgentChat: vi.fn(async (sessionId: string) => ({
sessionId,
scrollback: [],
})),
sendAgentChat: vi
.fn()
.mockRejectedValueOnce({
code: "NOT_FOUND",
message: "not found: structured session stale-session",
})
.mockImplementationOnce(async (_sessionId: string, _prompt: string, onChunk) => {
onChunk({ kind: "final", content: "Recovered reply" });
}),
cancelAgentChat: vi.fn(async () => {}),
closeAgentChat: vi.fn(async () => {}),
};
const onSessionId = vi.fn();
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="stale-session"
conversationId="conversation-1"
onSessionId={onSessionId}
onConversationId={vi.fn()}
/>
</DIProvider>,
);
await waitFor(() =>
expect(agent.reattachAgentChat).toHaveBeenCalledWith(
"stale-session",
expect.any(Function),
),
);
fireEvent.change(screen.getByLabelText(/message CLI custom/), {
target: { value: "please recover" },
});
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalledTimes(2));
expect(agent.sendAgentChat).toHaveBeenNthCalledWith(
1,
"stale-session",
"please recover",
expect.any(Function),
);
expect(agent.sendAgentChat).toHaveBeenNthCalledWith(
2,
"chat-session-2",
"please recover",
expect.any(Function),
);
expect(onSessionId).toHaveBeenCalledWith(null);
expect(onSessionId).toHaveBeenCalledWith("chat-session-2");
expect(await screen.findByText("Recovered reply")).toBeTruthy();
expect(screen.queryByText(/not found: structured session/)).toBeNull();
});
it("recovers and retries the prompt when send reports structured session NOT_FOUND as a raw string", async () => {
const agent = {
launchAgentChat: vi.fn(async () => ({
sessionId: "chat-session-2",
assignedConversationId: "conversation-2",
})),
reattachAgentChat: vi.fn(async (sessionId: string) => ({
sessionId,
scrollback: [],
})),
sendAgentChat: vi
.fn()
.mockRejectedValueOnce("not found: structured session stale-session")
.mockImplementationOnce(async (_sessionId: string, _prompt: string, onChunk) => {
onChunk({ kind: "final", content: "Recovered reply" });
}),
cancelAgentChat: vi.fn(async () => {}),
closeAgentChat: vi.fn(async () => {}),
};
const onSessionId = vi.fn();
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="stale-session"
conversationId="conversation-1"
onSessionId={onSessionId}
onConversationId={vi.fn()}
/>
</DIProvider>,
);
await waitFor(() =>
expect(agent.reattachAgentChat).toHaveBeenCalledWith(
"stale-session",
expect.any(Function),
),
);
fireEvent.change(screen.getByLabelText(/message CLI custom/), {
target: { value: "please recover" },
});
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalledTimes(2));
expect(agent.sendAgentChat).toHaveBeenNthCalledWith(
2,
"chat-session-2",
"please recover",
expect.any(Function),
);
expect(onSessionId).toHaveBeenCalledWith(null);
expect(onSessionId).toHaveBeenCalledWith("chat-session-2");
expect(await screen.findByText("Recovered reply")).toBeTruthy();
expect(screen.queryByText(/not found: structured session/)).toBeNull();
});
it("treats NOT_FOUND during cancel as an already-gone session", async () => {
const agent = {
launchAgentChat: vi.fn(),
reattachAgentChat: vi.fn(async (sessionId: string) => ({
sessionId,
scrollback: [],
})),
sendAgentChat: vi.fn(() => new Promise<void>(() => {})),
cancelAgentChat: vi.fn().mockRejectedValueOnce({
code: "NOT_FOUND",
message: "not found: structured session gone-session",
}),
closeAgentChat: vi.fn(async () => {}),
};
const onSessionId = vi.fn();
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="gone-session"
conversationId="conversation-1"
onSessionId={onSessionId}
onConversationId={vi.fn()}
/>
</DIProvider>,
);
await waitFor(() =>
expect(agent.reattachAgentChat).toHaveBeenCalledWith(
"gone-session",
expect.any(Function),
),
);
fireEvent.change(screen.getByLabelText(/message CLI custom/), {
target: { value: "long turn" },
});
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalled());
fireEvent.click(await screen.findByRole("button", { name: "Stop" }));
expect(await screen.findByText("Session déjà fermée.")).toBeTruthy();
expect(onSessionId).toHaveBeenCalledWith(null);
expect(agent.closeAgentChat).not.toHaveBeenCalled();
expect(screen.queryByText(/not found: structured session/)).toBeNull();
});
it("does not swallow post-launch reattach errors", async () => {
const agent = {
launchAgentChat: vi.fn(async () => ({
sessionId: "new-session",
cellKind: "chat",
assignedConversationId: "conversation-2",
})),
reattachAgentChat: vi.fn().mockRejectedValueOnce({
code: "PROCESS",
message: "post-launch attach failed",
}),
sendAgentChat: vi.fn(() => new Promise<void>(() => {})),
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={null}
conversationId="conversation-1"
onSessionId={vi.fn()}
onConversationId={vi.fn()}
/>
</DIProvider>,
);
expect((await screen.findByRole("alert")).textContent).toContain(
"post-launch attach failed",
);
});
it("does not publish a session id when custom launch is refused as PTY-routed", async () => {
const agent = {
launchAgentChat: vi.fn().mockRejectedValueOnce({
code: "STRUCTURED_ROUTED_TO_PTY",
message:
"custom CLI launch for agent agent-1 in project project-1 returned cellKind=pty; expected chat. sessionId=pty-session-1; nodeId=node-1",
}),
reattachAgentChat: vi.fn(),
sendAgentChat: vi.fn(() => new Promise<void>(() => {})),
cancelAgentChat: vi.fn(async () => {}),
closeAgentChat: vi.fn(async () => {}),
};
const onSessionId = vi.fn();
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={null}
conversationId="conversation-1"
onSessionId={onSessionId}
onConversationId={vi.fn()}
/>
</DIProvider>,
);
const alert = await screen.findByRole("alert");
expect(alert.textContent).toContain("cellKind=pty");
expect(agent.launchAgentChat).toHaveBeenCalledTimes(1);
expect(agent.reattachAgentChat).not.toHaveBeenCalled();
expect(onSessionId).not.toHaveBeenCalled();
});
it("recovers when post-launch reattach reports NOT_FOUND", async () => {
const agent = {
launchAgentChat: vi
.fn()
.mockResolvedValueOnce({
sessionId: "new-session-1",
assignedConversationId: "conversation-2",
})
.mockResolvedValueOnce({
sessionId: "new-session-2",
assignedConversationId: "conversation-2",
}),
reattachAgentChat: vi
.fn()
.mockRejectedValueOnce({
code: "NOT_FOUND",
message: "not found: structured session new-session-1",
})
.mockImplementationOnce(async (sessionId: string) => ({
sessionId,
scrollback: [],
})),
sendAgentChat: vi.fn(() => new Promise<void>(() => {})),
cancelAgentChat: vi.fn(async () => {}),
closeAgentChat: vi.fn(async () => {}),
};
const onSessionId = vi.fn();
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={null}
conversationId="conversation-1"
onSessionId={onSessionId}
onConversationId={vi.fn()}
/>
</DIProvider>,
);
await waitFor(() => expect(agent.launchAgentChat).toHaveBeenCalledTimes(2));
expect(agent.reattachAgentChat).toHaveBeenNthCalledWith(
1,
"new-session-1",
expect.any(Function),
);
expect(agent.reattachAgentChat).toHaveBeenNthCalledWith(
2,
"new-session-2",
expect.any(Function),
);
expect(onSessionId).toHaveBeenCalledWith(null);
expect(onSessionId).toHaveBeenCalledWith("new-session-2");
expect(screen.queryByText(/not found: structured session/)).toBeNull();
});
it("surfaces non-NOT_FOUND reattach errors instead of launching a new session", async () => {
const agent = {
launchAgentChat: vi.fn(),
reattachAgentChat: vi.fn().mockRejectedValueOnce({
code: "PROCESS",
message: "structured session failed to attach",
}),
sendAgentChat: vi.fn(() => new Promise<void>(() => {})),
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="existing-session"
conversationId="conversation-1"
onSessionId={vi.fn()}
onConversationId={vi.fn()}
/>
</DIProvider>,
);
expect((await screen.findByRole("alert")).textContent).toContain(
"structured session failed to attach",
);
expect(agent.launchAgentChat).not.toHaveBeenCalled();
});
});