- Attachments: projection canonique ChatPromptAttachmentDto (référence [file n]/[image n] par compteur de type, ordre d'import) insérée dans le prompt modèle et transportée par ReplyChunk::UserPrompt (attachmentRefs) ; rendu compact côté CustomAgentChatView. - Resize: la zone chat (LeafView/LayoutGrid) est redimensionnable, taille actuelle conservée par défaut, préférence locale persistée. - Tests: commands (projection des refs), dto_chat, chat_bridge, CustomAgentChatView, LayoutGrid.chat.
3517 lines
106 KiB
TypeScript
3517 lines
106 KiB
TypeScript
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
import { useState } from "react";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import { DIProvider } from "@/app/di";
|
|
import type { AgentProfile, ReplyChunk } from "@/domain";
|
|
import { PluginRuntimeProvider } from "@/features/plugins";
|
|
import {
|
|
__resetProjectCliInteractionActivityForTests,
|
|
hasProjectCliInteraction,
|
|
} from "@/features/projects/projectCliInteractionActivity";
|
|
import {
|
|
PluginCommandRegistry,
|
|
PluginLayoutRegistry,
|
|
PluginMenuRegistry,
|
|
PluginRuntimeRegistry,
|
|
} from "@/plugins/runtime";
|
|
import type { ChatDraftScope, Gateways } from "@/ports";
|
|
import {
|
|
CustomAgentChatView,
|
|
__clearCustomAgentChatViewCacheForTests,
|
|
} 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 () => {},
|
|
});
|
|
}
|
|
|
|
function deferred<T>() {
|
|
let resolve!: (value: T | PromiseLike<T>) => void;
|
|
const promise = new Promise<T>((res) => {
|
|
resolve = res;
|
|
});
|
|
return { promise, resolve };
|
|
}
|
|
|
|
describe("CustomAgentChatView", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
__clearCustomAgentChatViewCacheForTests();
|
|
__resetProjectCliInteractionActivityForTests();
|
|
});
|
|
|
|
it("repaints compact attachment refs from legacy user prompt scrollback", async () => {
|
|
const agent = {
|
|
launchAgentChat: vi.fn(),
|
|
reattachAgentChat: vi.fn(async (sessionId: string) => ({
|
|
sessionId,
|
|
scrollback: [
|
|
{
|
|
kind: "userPrompt" as const,
|
|
text: "describe this",
|
|
attachments: ["clipboard.png"],
|
|
},
|
|
],
|
|
})),
|
|
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>,
|
|
);
|
|
|
|
expect(await screen.findByText("describe this")).toBeTruthy();
|
|
expect(screen.getByText("[image 1] clipboard.png")).toBeTruthy();
|
|
});
|
|
|
|
it("prefers canonical attachment refs from user prompt scrollback", async () => {
|
|
const agent = {
|
|
launchAgentChat: vi.fn(),
|
|
reattachAgentChat: vi.fn(async (sessionId: string) => ({
|
|
sessionId,
|
|
scrollback: [
|
|
{
|
|
kind: "userPrompt" as const,
|
|
text: "compare these",
|
|
attachments: ["screenshot.png", "error.log"],
|
|
attachmentRefs: [
|
|
{
|
|
reference: "[image 1]",
|
|
filename: "screenshot.png",
|
|
mime: "image/png",
|
|
sizeBytes: 12,
|
|
readablePath: "/repo/.ideai/attachments/screenshot.png",
|
|
},
|
|
{
|
|
reference: "[file 1]",
|
|
filename: "error.log",
|
|
mime: "text/plain",
|
|
sizeBytes: 34,
|
|
readablePath: "/repo/.ideai/attachments/error.log",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
})),
|
|
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>,
|
|
);
|
|
|
|
expect(await screen.findByText("compare these")).toBeTruthy();
|
|
expect(screen.getByText("[image 1] screenshot.png")).toBeTruthy();
|
|
expect(screen.getByText("[file 1] error.log")).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.queryByText(/Reprise effectuée:/),
|
|
).toBeNull();
|
|
});
|
|
|
|
it("keeps the composer locked on layout return until reattach replay settles (#190)", async () => {
|
|
let scrollback: ReplyChunk[] = [
|
|
{ kind: "userPrompt" as const, text: "Initial prompt" },
|
|
{ kind: "final" as const, content: "Initial reply" },
|
|
];
|
|
const secondReattach = deferred<{ sessionId: string; scrollback: ReplyChunk[] }>();
|
|
const agent = {
|
|
launchAgentChat: vi.fn(),
|
|
reattachAgentChat: vi
|
|
.fn()
|
|
.mockImplementationOnce(async (sessionId: string) => ({
|
|
sessionId,
|
|
scrollback,
|
|
}))
|
|
.mockImplementationOnce(() => secondReattach.promise),
|
|
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 = [
|
|
{ kind: "userPrompt" as const, text: "Initial prompt" },
|
|
{ kind: "final" as const, content: "Initial reply" },
|
|
{ kind: "userPrompt" as const, text: "Question during graph view" },
|
|
{ kind: "textDelta" as const, text: "Partial answer while 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(screen.getByRole("button", { name: "Envoyer" }).hasAttribute("disabled")).toBe(true);
|
|
expect(screen.getByLabelText(/message CLI custom/).hasAttribute("disabled")).toBe(true);
|
|
|
|
act(() => {
|
|
secondReattach.resolve({ sessionId: "chat-session-1", scrollback });
|
|
});
|
|
|
|
expect(await screen.findByText("Question during graph view")).toBeTruthy();
|
|
expect(screen.getByText("Partial answer while away")).toBeTruthy();
|
|
expect(screen.queryByText(/Reprise effectuée:/)).toBeNull();
|
|
expect(screen.getByRole("button", { name: "Stop" })).toBeTruthy();
|
|
expect(screen.getByLabelText(/message CLI custom/).hasAttribute("disabled")).toBe(true);
|
|
});
|
|
|
|
it("drops a stale no-response chunk when replay contains a later real answer (#190)", async () => {
|
|
const agent = {
|
|
launchAgentChat: vi.fn(),
|
|
reattachAgentChat: vi.fn(async (sessionId: string) => ({
|
|
sessionId,
|
|
scrollback: [
|
|
{ kind: "userPrompt" as const, text: "resume the turn" },
|
|
{
|
|
kind: "error" as const,
|
|
message: "Le modèle n'a renvoyé aucune réponse.",
|
|
},
|
|
{ kind: "textDelta" as const, text: "Real answer " },
|
|
{ kind: "final" as const, content: "Real answer completed" },
|
|
],
|
|
})),
|
|
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>,
|
|
);
|
|
|
|
expect(await screen.findByText("Real answer completed")).toBeTruthy();
|
|
expect(screen.getByText("Real answer")).toBeTruthy();
|
|
expect(screen.queryByText("Le modèle n'a renvoyé aucune réponse.")).toBeNull();
|
|
});
|
|
|
|
it("stops the live agent with cascade feedback and releases the structured session", async () => {
|
|
let resolveStop: (value: {
|
|
agentId: string;
|
|
sessionId: string;
|
|
kind: "structured";
|
|
}) => void = () => {};
|
|
const agent = {
|
|
launchAgentChat: vi.fn(),
|
|
reattachAgentChat: vi.fn(async (sessionId: string) => ({
|
|
sessionId,
|
|
scrollback: [],
|
|
})),
|
|
sendAgentChat: vi.fn(() => new Promise<void>(() => {})),
|
|
stopLiveAgent: vi.fn(
|
|
() =>
|
|
new Promise<{ agentId: string; sessionId: string; kind: "structured" }>(
|
|
(resolve) => {
|
|
resolveStop = resolve;
|
|
},
|
|
),
|
|
),
|
|
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(hasProjectCliInteraction("project-1")).toBe(true);
|
|
expect(screen.queryByRole("button", { name: "Cancel" })).toBeNull();
|
|
|
|
fireEvent.click(await screen.findByRole("button", { name: "Stop" }));
|
|
|
|
const stoppingLabel = await screen.findByText("Arrêt en cours...");
|
|
expect(stoppingLabel.closest("button")?.hasAttribute("disabled")).toBe(true);
|
|
await waitFor(() =>
|
|
expect(agent.stopLiveAgent).toHaveBeenCalledWith("project-1", "agent-1"),
|
|
);
|
|
resolveStop({
|
|
agentId: "agent-1",
|
|
sessionId: "chat-session-1",
|
|
kind: "structured",
|
|
});
|
|
await waitFor(() => expect(onSessionId).toHaveBeenCalledWith(null));
|
|
expect(hasProjectCliInteraction("project-1")).toBe(false);
|
|
expect(agent.cancelAgentChat).not.toHaveBeenCalled();
|
|
expect(agent.closeAgentChat).not.toHaveBeenCalled();
|
|
expect(
|
|
screen.getByText(
|
|
"Agent arrêté. Cascade d'arrêt demandée pour les tâches et appels MCP liés.",
|
|
),
|
|
).toBeTruthy();
|
|
expect(
|
|
screen.getByRole("button", { name: "Envoyer" }).hasAttribute("disabled"),
|
|
).toBe(true);
|
|
});
|
|
|
|
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(hasProjectCliInteraction("project-1")).toBe(false);
|
|
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.queryByRole("status", { name: "Loading" })).toBeNull();
|
|
|
|
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("deduplicates identical entity progress chunks", 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: "read ticket" },
|
|
});
|
|
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
|
await waitFor(() => expect(emitChunk).not.toBeNull());
|
|
|
|
const chunk = {
|
|
kind: "progress" as const,
|
|
progress: {
|
|
source: "ideaLocal" as const,
|
|
kind: "mcp" as const,
|
|
stage: "started" as const,
|
|
label: "idea_ticket_read",
|
|
toolName: "idea_ticket_read",
|
|
},
|
|
};
|
|
|
|
act(() => {
|
|
emitChunk?.(chunk);
|
|
emitChunk?.(chunk);
|
|
});
|
|
|
|
expect(screen.getAllByTestId("chat-tool-progress")).toHaveLength(1);
|
|
expect(screen.getAllByText("idea_ticket_read")).toHaveLength(1);
|
|
});
|
|
|
|
it("updates entity progress deltas in place", 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: "download model" },
|
|
});
|
|
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
|
await waitFor(() => expect(emitChunk).not.toBeNull());
|
|
|
|
const progress = (stage: "started" | "delta", text: string) => ({
|
|
kind: "progress" as const,
|
|
progress: {
|
|
source: "providerNative" as const,
|
|
kind: "tool" as const,
|
|
stage,
|
|
label: "download_model",
|
|
toolName: "download_model",
|
|
text,
|
|
},
|
|
});
|
|
|
|
act(() => {
|
|
emitChunk?.(progress("started", "Downloading 10%"));
|
|
emitChunk?.(progress("delta", "Downloading 75%"));
|
|
});
|
|
|
|
expect(screen.getAllByTestId("chat-tool-progress")).toHaveLength(1);
|
|
expect(screen.queryByText("Downloading 10%")).toBeNull();
|
|
expect(screen.getByText("Downloading 75%")).toBeTruthy();
|
|
expect(screen.getByText("Tool").parentElement?.textContent).toContain("running");
|
|
});
|
|
|
|
it("renders progress immediately when the chunk callback fires", 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: "stream progress" },
|
|
});
|
|
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
|
await waitFor(() => expect(emitChunk).not.toBeNull());
|
|
|
|
act(() => {
|
|
emitChunk?.({
|
|
kind: "progress" as const,
|
|
progress: {
|
|
source: "providerNative" as const,
|
|
kind: "tool" as const,
|
|
stage: "started" as const,
|
|
label: "stream_repo",
|
|
toolName: "stream_repo",
|
|
text: "Chunk received",
|
|
},
|
|
});
|
|
});
|
|
|
|
expect(screen.getByText("Chunk received")).toBeTruthy();
|
|
expect(screen.getAllByTestId("chat-tool-progress")).toHaveLength(1);
|
|
});
|
|
|
|
it("moves completed entity progress from active map to turns", 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: "call tool" },
|
|
});
|
|
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
|
await waitFor(() => expect(emitChunk).not.toBeNull());
|
|
|
|
const progress = (stage: "started" | "completed") => ({
|
|
kind: "progress" as const,
|
|
progress: {
|
|
source: "providerNative" as const,
|
|
kind: "tool" as const,
|
|
stage,
|
|
label: "search_repo",
|
|
toolName: "search_repo",
|
|
text: stage === "started" ? "Searching..." : "Found results",
|
|
},
|
|
});
|
|
|
|
act(() => {
|
|
emitChunk?.(progress("started"));
|
|
emitChunk?.(progress("completed"));
|
|
});
|
|
|
|
expect(screen.getAllByTestId("chat-tool-progress")).toHaveLength(1);
|
|
expect(screen.queryByText("Searching...")).toBeNull();
|
|
expect(screen.getByText("Found results")).toBeTruthy();
|
|
expect(screen.getByText("Tool").parentElement?.textContent).toContain("done");
|
|
|
|
act(() => {
|
|
emitChunk?.({ kind: "final", content: "done" });
|
|
});
|
|
|
|
await screen.findByText("done");
|
|
expect(screen.getAllByTestId("chat-tool-progress")).toHaveLength(1);
|
|
expect(screen.getByText("Found results")).toBeTruthy();
|
|
});
|
|
|
|
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(),
|
|
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.queryByRole("status", { name: "Loading" })).toBeNull();
|
|
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("updates message progress by progressId instead of appending running and done rows", 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 ids" },
|
|
});
|
|
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
|
await waitFor(() => expect(emitChunk).not.toBeNull());
|
|
|
|
const progress = (stage: "delta" | "completed", text: string) => ({
|
|
kind: "progress" as const,
|
|
progress: {
|
|
progressId: "codex:message:item-1",
|
|
source: "providerNative" as const,
|
|
kind: "message" as const,
|
|
stage,
|
|
label: "Analyse",
|
|
text,
|
|
},
|
|
});
|
|
|
|
act(() => {
|
|
emitChunk?.(progress("delta", "Lecture du contexte disponible."));
|
|
});
|
|
|
|
expect(screen.getAllByTestId("chat-progress-status")).toHaveLength(1);
|
|
expect(screen.getByText("Lecture du contexte disponible.")).toBeTruthy();
|
|
expect(screen.getByText("Progress").parentElement?.textContent).toContain("running");
|
|
|
|
act(() => {
|
|
emitChunk?.(progress("completed", "Contexte lu."));
|
|
});
|
|
|
|
expect(screen.getAllByTestId("chat-progress-status")).toHaveLength(1);
|
|
expect(screen.queryByText("Lecture du contexte disponible.")).toBeNull();
|
|
expect(screen.getByText("Contexte lu.")).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("falls back to the async clipboard API when pasted images are absent from DataTransfer", async () => {
|
|
const originalClipboard = Object.getOwnPropertyDescriptor(
|
|
window.navigator,
|
|
"clipboard",
|
|
);
|
|
const read = vi.fn(async () => [
|
|
{
|
|
types: ["image/png"],
|
|
getType: vi.fn(async () => new Blob([new Uint8Array([1, 2, 3])], {
|
|
type: "image/png",
|
|
})),
|
|
},
|
|
]);
|
|
Object.defineProperty(window.navigator, "clipboard", {
|
|
configurable: true,
|
|
value: { read },
|
|
});
|
|
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 () => {}),
|
|
};
|
|
|
|
try {
|
|
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.paste(screen.getByLabelText(/message CLI custom/), {
|
|
clipboardData: {
|
|
items: [],
|
|
files: [],
|
|
getData: () => "",
|
|
},
|
|
});
|
|
|
|
await screen.findByText(/Fichier joint: clipboard-image-\d+\.png/);
|
|
expect(read).toHaveBeenCalledTimes(1);
|
|
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: expect.stringMatching(/^clipboard-image-\d+\.png$/),
|
|
contentBase64: "AQID",
|
|
mime: "image/png",
|
|
sourceKind: "clipboard",
|
|
},
|
|
],
|
|
},
|
|
);
|
|
} finally {
|
|
if (originalClipboard) {
|
|
Object.defineProperty(window.navigator, "clipboard", originalClipboard);
|
|
} else {
|
|
Object.defineProperty(window.navigator, "clipboard", {
|
|
configurable: true,
|
|
value: undefined,
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
it("shows a non-blocking error when the async clipboard image read fails", async () => {
|
|
const originalClipboard = Object.getOwnPropertyDescriptor(
|
|
window.navigator,
|
|
"clipboard",
|
|
);
|
|
Object.defineProperty(window.navigator, "clipboard", {
|
|
configurable: true,
|
|
value: { read: vi.fn(async () => { throw new Error("clipboard denied"); }) },
|
|
});
|
|
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 () => {}),
|
|
};
|
|
|
|
try {
|
|
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.paste(screen.getByLabelText(/message CLI custom/), {
|
|
clipboardData: {
|
|
items: [],
|
|
files: [],
|
|
getData: () => "",
|
|
},
|
|
});
|
|
|
|
expect((await screen.findByRole("alert")).textContent).toContain(
|
|
"clipboard denied",
|
|
);
|
|
expect(
|
|
(screen.getByRole("button", { name: "Envoyer" }) as HTMLButtonElement)
|
|
.disabled,
|
|
).toBe(true);
|
|
} finally {
|
|
if (originalClipboard) {
|
|
Object.defineProperty(window.navigator, "clipboard", originalClipboard);
|
|
} else {
|
|
Object.defineProperty(window.navigator, "clipboard", {
|
|
configurable: true,
|
|
value: undefined,
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
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("[image 1] 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 pasted text insertion when clipboard files are attached", 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 composer = screen.getByLabelText(/message CLI custom/) as HTMLTextAreaElement;
|
|
fireEvent.change(composer, { target: { value: "prefix suffix" } });
|
|
composer.setSelectionRange(7, 13);
|
|
|
|
const file = new File(["ignored"], "mixed.txt", { type: "text/plain" });
|
|
Object.defineProperty(file, "arrayBuffer", {
|
|
value: vi.fn(async () => new Uint8Array([7, 8, 9]).buffer),
|
|
});
|
|
fireEvent.paste(composer, {
|
|
clipboardData: {
|
|
items: [
|
|
{
|
|
kind: "file",
|
|
type: "text/plain",
|
|
getAsFile: () => file,
|
|
},
|
|
],
|
|
getData: () => "inserted",
|
|
},
|
|
});
|
|
|
|
await screen.findByText("Fichier joint: mixed.txt");
|
|
await waitFor(() => expect(composer.value).toBe("prefix inserted"));
|
|
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
|
|
|
await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalledTimes(1));
|
|
expect(agent.sendAgentChat).toHaveBeenCalledWith(
|
|
"chat-session-1",
|
|
"prefix inserted",
|
|
expect.any(Function),
|
|
{
|
|
attachments: [
|
|
{
|
|
filename: "mixed.txt",
|
|
contentBase64: "BwgJ",
|
|
mime: "text/plain",
|
|
sourceKind: "clipboard",
|
|
},
|
|
],
|
|
},
|
|
);
|
|
expect(screen.getByText("prefix inserted")).toBeTruthy();
|
|
expect(screen.getByText("[file 1] mixed.txt")).toBeTruthy();
|
|
});
|
|
|
|
it("deduplicates clipboard files by metadata across clipboard sources and repeated paste", 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"], "duplicate.pdf", {
|
|
type: "application/pdf",
|
|
lastModified: 123,
|
|
});
|
|
Object.defineProperty(file, "arrayBuffer", {
|
|
value: vi.fn(async () => new Uint8Array([10, 11, 12]).buffer),
|
|
});
|
|
const clipboardData = {
|
|
items: [
|
|
{
|
|
kind: "file",
|
|
type: "application/pdf",
|
|
getAsFile: () => file,
|
|
},
|
|
],
|
|
files: [file],
|
|
getData: () => "",
|
|
};
|
|
const composer = screen.getByLabelText(/message CLI custom/);
|
|
|
|
fireEvent.paste(composer, { clipboardData });
|
|
await screen.findByText("Fichier joint: duplicate.pdf");
|
|
fireEvent.paste(composer, { clipboardData });
|
|
|
|
await waitFor(() =>
|
|
expect(screen.getAllByText("Fichier joint: duplicate.pdf")).toHaveLength(1),
|
|
);
|
|
});
|
|
|
|
it("restores and saves drafts scoped by project, layout, and node", async () => {
|
|
const drafts = new Map<string, string>([
|
|
["project-1::layout-a::node-1", "restored draft"],
|
|
]);
|
|
const key = (scope: ChatDraftScope) =>
|
|
`${scope.projectId}::${scope.layoutId}::${scope.nodeId}`;
|
|
const agent = {
|
|
launchAgentChat: vi.fn(),
|
|
reattachAgentChat: vi.fn(async (sessionId: string) => ({
|
|
sessionId,
|
|
scrollback: [],
|
|
})),
|
|
sendAgentChat: vi.fn(async () => {}),
|
|
readChatDraft: vi.fn(async (scope: ChatDraftScope) => ({
|
|
text: drafts.get(key(scope)) ?? null,
|
|
updatedAtMs: null,
|
|
})),
|
|
saveChatDraft: vi.fn(async (scope: ChatDraftScope, text: string) => {
|
|
if (text) drafts.set(key(scope), text);
|
|
else drafts.delete(key(scope));
|
|
}),
|
|
clearChatDraft: vi.fn(async (scope: ChatDraftScope) => {
|
|
drafts.delete(key(scope));
|
|
}),
|
|
cancelAgentChat: vi.fn(async () => {}),
|
|
closeAgentChat: vi.fn(async () => {}),
|
|
};
|
|
const baseProps = {
|
|
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 { rerender } = render(
|
|
<DIProvider
|
|
gateways={{
|
|
agent,
|
|
system: { pickFile: vi.fn(async () => null) },
|
|
} as unknown as Gateways}
|
|
>
|
|
<CustomAgentChatView
|
|
{...baseProps}
|
|
projectId="project-1"
|
|
layoutId="layout-a"
|
|
/>
|
|
</DIProvider>,
|
|
);
|
|
|
|
const composer = await screen.findByDisplayValue("restored draft");
|
|
fireEvent.change(composer, { target: { value: "edited draft" } });
|
|
|
|
await waitFor(() =>
|
|
expect(agent.saveChatDraft).toHaveBeenCalledWith(
|
|
{ projectId: "project-1", layoutId: "layout-a", nodeId: "node-1" },
|
|
"edited draft",
|
|
),
|
|
);
|
|
|
|
rerender(
|
|
<DIProvider
|
|
gateways={{
|
|
agent,
|
|
system: { pickFile: vi.fn(async () => null) },
|
|
} as unknown as Gateways}
|
|
>
|
|
<CustomAgentChatView
|
|
{...baseProps}
|
|
projectId="project-1"
|
|
layoutId="layout-b"
|
|
/>
|
|
</DIProvider>,
|
|
);
|
|
await waitFor(() =>
|
|
expect((screen.getByLabelText(/message CLI custom/) as HTMLTextAreaElement).value).toBe(""),
|
|
);
|
|
|
|
rerender(
|
|
<DIProvider
|
|
gateways={{
|
|
agent,
|
|
system: { pickFile: vi.fn(async () => null) },
|
|
} as unknown as Gateways}
|
|
>
|
|
<CustomAgentChatView
|
|
{...baseProps}
|
|
projectId="project-1"
|
|
layoutId="layout-a"
|
|
/>
|
|
</DIProvider>,
|
|
);
|
|
await screen.findByDisplayValue("edited draft");
|
|
|
|
rerender(
|
|
<DIProvider
|
|
gateways={{
|
|
agent,
|
|
system: { pickFile: vi.fn(async () => null) },
|
|
} as unknown as Gateways}
|
|
>
|
|
<CustomAgentChatView
|
|
{...baseProps}
|
|
projectId="project-2"
|
|
layoutId="layout-a"
|
|
/>
|
|
</DIProvider>,
|
|
);
|
|
await waitFor(() =>
|
|
expect((screen.getByLabelText(/message CLI custom/) as HTMLTextAreaElement).value).toBe(""),
|
|
);
|
|
});
|
|
|
|
it("does not persist attachments with the restored draft", async () => {
|
|
const drafts = new Map<string, string>();
|
|
const scope = { projectId: "project-1", layoutId: "layout-a", nodeId: "node-1" };
|
|
const agent = {
|
|
launchAgentChat: vi.fn(),
|
|
reattachAgentChat: vi.fn(async (sessionId: string) => ({
|
|
sessionId,
|
|
scrollback: [],
|
|
})),
|
|
sendAgentChat: vi.fn(async () => {}),
|
|
readChatDraft: vi.fn(async () => ({
|
|
text: drafts.get("draft") ?? null,
|
|
updatedAtMs: null,
|
|
})),
|
|
saveChatDraft: vi.fn(async (_scope: ChatDraftScope, text: string) => {
|
|
drafts.set("draft", text);
|
|
}),
|
|
clearChatDraft: vi.fn(async () => {
|
|
drafts.delete("draft");
|
|
}),
|
|
cancelAgentChat: vi.fn(async () => {}),
|
|
closeAgentChat: vi.fn(async () => {}),
|
|
};
|
|
const renderView = () =>
|
|
render(
|
|
<DIProvider
|
|
gateways={{
|
|
agent,
|
|
system: { pickFile: vi.fn(async () => null) },
|
|
} as unknown as Gateways}
|
|
>
|
|
<CustomAgentChatView
|
|
projectId={scope.projectId}
|
|
layoutId={scope.layoutId}
|
|
agentId="agent-1"
|
|
agentName="Worker"
|
|
profile={profile}
|
|
cwd="/repo"
|
|
nodeId={scope.nodeId}
|
|
sessionId="chat-session-1"
|
|
conversationId="conversation-1"
|
|
onSessionId={vi.fn()}
|
|
onConversationId={vi.fn()}
|
|
/>
|
|
</DIProvider>,
|
|
);
|
|
|
|
const first = renderView();
|
|
await waitFor(() =>
|
|
expect(agent.readChatDraft).toHaveBeenCalledWith(scope),
|
|
);
|
|
const composer = screen.getByLabelText(/message CLI custom/) as HTMLTextAreaElement;
|
|
fireEvent.change(composer, { target: { value: "draft with file" } });
|
|
|
|
const file = new File(["ignored"], "not-persisted.png", { type: "image/png" });
|
|
Object.defineProperty(file, "arrayBuffer", {
|
|
value: vi.fn(async () => new Uint8Array([1, 2, 3]).buffer),
|
|
});
|
|
fireEvent.paste(composer, {
|
|
clipboardData: {
|
|
items: [
|
|
{
|
|
kind: "file",
|
|
type: "image/png",
|
|
getAsFile: () => file,
|
|
},
|
|
],
|
|
getData: () => "",
|
|
},
|
|
});
|
|
await screen.findByText("Fichier joint: not-persisted.png");
|
|
await waitFor(() =>
|
|
expect(agent.saveChatDraft).toHaveBeenCalledWith(scope, "draft with file"),
|
|
);
|
|
|
|
first.unmount();
|
|
renderView();
|
|
|
|
await screen.findByDisplayValue("draft with file");
|
|
expect(screen.queryByText("Fichier joint: not-persisted.png")).toBeNull();
|
|
});
|
|
|
|
it("clears the persisted draft when sending the composer", async () => {
|
|
const agent = {
|
|
launchAgentChat: vi.fn(),
|
|
reattachAgentChat: vi.fn(async (sessionId: string) => ({
|
|
sessionId,
|
|
scrollback: [],
|
|
})),
|
|
sendAgentChat: vi.fn(async () => {}),
|
|
readChatDraft: vi.fn(async () => ({ text: "to send", updatedAtMs: null })),
|
|
saveChatDraft: vi.fn(async () => {}),
|
|
clearChatDraft: vi.fn(async () => {}),
|
|
cancelAgentChat: vi.fn(async () => {}),
|
|
closeAgentChat: vi.fn(async () => {}),
|
|
};
|
|
const scope = { projectId: "project-1", layoutId: "layout-a", nodeId: "node-1" };
|
|
|
|
render(
|
|
<DIProvider
|
|
gateways={{
|
|
agent,
|
|
system: { pickFile: vi.fn(async () => null) },
|
|
} as unknown as Gateways}
|
|
>
|
|
<CustomAgentChatView
|
|
projectId={scope.projectId}
|
|
layoutId={scope.layoutId}
|
|
agentId="agent-1"
|
|
agentName="Worker"
|
|
profile={profile}
|
|
cwd="/repo"
|
|
nodeId={scope.nodeId}
|
|
sessionId="chat-session-1"
|
|
conversationId="conversation-1"
|
|
onSessionId={vi.fn()}
|
|
onConversationId={vi.fn()}
|
|
/>
|
|
</DIProvider>,
|
|
);
|
|
|
|
await screen.findByDisplayValue("to send");
|
|
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
|
|
|
await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalledTimes(1));
|
|
expect(agent.clearChatDraft).toHaveBeenCalledWith(scope);
|
|
expect((screen.getByLabelText(/message CLI custom/) as HTMLTextAreaElement).value).toBe("");
|
|
});
|
|
|
|
it("clears the persisted draft when a slash command is consumed", 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: [],
|
|
},
|
|
})),
|
|
readChatDraft: vi.fn(async () => ({ text: "/help", updatedAtMs: null })),
|
|
saveChatDraft: vi.fn(async () => {}),
|
|
clearChatDraft: vi.fn(async () => {}),
|
|
cancelAgentChat: vi.fn(async () => {}),
|
|
closeAgentChat: vi.fn(async () => {}),
|
|
};
|
|
const scope = { projectId: "project-1", layoutId: "layout-a", nodeId: "node-1" };
|
|
|
|
render(
|
|
<DIProvider
|
|
gateways={{
|
|
agent,
|
|
system: { pickFile: vi.fn(async () => null) },
|
|
} as unknown as Gateways}
|
|
>
|
|
<CustomAgentChatView
|
|
projectId={scope.projectId}
|
|
layoutId={scope.layoutId}
|
|
agentId="agent-1"
|
|
agentName="Worker"
|
|
profile={profile}
|
|
cwd="/repo"
|
|
nodeId={scope.nodeId}
|
|
sessionId="chat-session-1"
|
|
conversationId="conversation-1"
|
|
onSessionId={vi.fn()}
|
|
onConversationId={vi.fn()}
|
|
/>
|
|
</DIProvider>,
|
|
);
|
|
|
|
await screen.findByDisplayValue("/help");
|
|
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
|
|
|
await waitFor(() =>
|
|
expect(agent.executeSlashCommand).toHaveBeenCalledWith("/help", {
|
|
sessionId: "chat-session-1",
|
|
}),
|
|
);
|
|
expect(agent.clearChatDraft).toHaveBeenCalledWith(scope);
|
|
expect((screen.getByLabelText(/message CLI custom/) as HTMLTextAreaElement).value).toBe("");
|
|
});
|
|
|
|
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>(() => {})),
|
|
stopLiveAgent: vi.fn().mockRejectedValueOnce({
|
|
code: "NOT_FOUND",
|
|
message: "agent agent-1 has no live session",
|
|
}),
|
|
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="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à arrêtée.")).toBeTruthy();
|
|
expect(onSessionId).toHaveBeenCalledWith(null);
|
|
expect(agent.cancelAgentChat).not.toHaveBeenCalled();
|
|
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();
|
|
});
|
|
});
|