5827 lines
184 KiB
TypeScript
5827 lines
184 KiB
TypeScript
import { act, fireEvent, render, screen, waitFor, within } 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 { MockSystemGateway, MockWorkStateGateway } from "@/adapters/mock";
|
||
import { AnnouncementsProvider } from "@/features/announcements";
|
||
import { PluginRuntimeProvider } from "@/features/plugins";
|
||
import {
|
||
__resetProjectCliInteractionActivityForTests,
|
||
hasProjectCliInteraction,
|
||
} from "@/features/projects/projectCliInteractionActivity";
|
||
import { publishProjectIconChanged } from "@/features/projects/projectIcons";
|
||
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 };
|
||
}
|
||
|
||
function createStreamingAgentHarness() {
|
||
const emits: Array<((chunk: ReplyChunk) => void) | null> = [];
|
||
const sendDone = new Map<number, () => void>();
|
||
const sendAgentChat = vi.fn(
|
||
(_sessionId: string, _prompt: string, onChunk: (chunk: ReplyChunk) => void) =>
|
||
new Promise<void>((resolve) => {
|
||
const index = emits.length;
|
||
emits.push((chunk: ReplyChunk) => {
|
||
onChunk(chunk);
|
||
if (chunk.kind === "final" || chunk.kind === "error") {
|
||
sendDone.get(index)?.();
|
||
}
|
||
});
|
||
sendDone.set(index, resolve);
|
||
}),
|
||
);
|
||
return {
|
||
agent: {
|
||
launchAgentChat: vi.fn(),
|
||
reattachAgentChat: vi.fn(async (sessionId: string) => ({
|
||
sessionId,
|
||
scrollback: [],
|
||
})),
|
||
sendAgentChat,
|
||
cancelAgentChat: vi.fn(async () => {}),
|
||
closeAgentChat: vi.fn(async () => {}),
|
||
},
|
||
sendAgentChat,
|
||
emit(index: number, chunk: ReplyChunk) {
|
||
const emit = emits[index];
|
||
if (!emit) throw new Error(`Missing emitter for send call #${index + 1}`);
|
||
emit(chunk);
|
||
},
|
||
emitterCount() {
|
||
return emits.length;
|
||
},
|
||
};
|
||
}
|
||
|
||
function expectedChatTime(ms: number): string {
|
||
const parts = new Intl.DateTimeFormat(undefined, {
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
hourCycle: "h23",
|
||
}).formatToParts(new Date(ms));
|
||
const hour = parts.find((part) => part.type === "hour")?.value;
|
||
const minute = parts.find((part) => part.type === "minute")?.value;
|
||
return `${hour}:${minute}`;
|
||
}
|
||
|
||
function setMeasuredTextOverflow(
|
||
element: Element,
|
||
{ scrollHeight, clientHeight }: { scrollHeight: number; clientHeight: number },
|
||
) {
|
||
Object.defineProperty(element, "scrollHeight", {
|
||
value: scrollHeight,
|
||
configurable: true,
|
||
});
|
||
Object.defineProperty(element, "clientHeight", {
|
||
value: clientHeight,
|
||
configurable: true,
|
||
});
|
||
fireEvent(window, new Event("resize"));
|
||
}
|
||
|
||
function renderHistoryChat() {
|
||
const agent = {
|
||
launchAgentChat: vi.fn(),
|
||
reattachAgentChat: vi.fn(async (sessionId: string) => ({
|
||
sessionId,
|
||
scrollback: [],
|
||
})),
|
||
sendAgentChat: vi.fn(
|
||
async (_sessionId: string, prompt: string, onChunk: (chunk: ReplyChunk) => void) => {
|
||
onChunk({ kind: "final", content: `answer ${prompt}` });
|
||
},
|
||
),
|
||
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"
|
||
layoutId="layout-1"
|
||
nodeId="node-1"
|
||
sessionId="chat-session-1"
|
||
conversationId="conversation-1"
|
||
onSessionId={vi.fn()}
|
||
onConversationId={vi.fn()}
|
||
/>
|
||
</DIProvider>,
|
||
);
|
||
return agent;
|
||
}
|
||
|
||
async function sendHistoryPrompt(
|
||
composer: HTMLTextAreaElement,
|
||
agent: ReturnType<typeof renderHistoryChat>,
|
||
prompt: string,
|
||
) {
|
||
const expectedCalls = agent.sendAgentChat.mock.calls.length + 1;
|
||
fireEvent.change(composer, { target: { value: prompt } });
|
||
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
||
await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalledTimes(expectedCalls));
|
||
}
|
||
|
||
describe("CustomAgentChatView", () => {
|
||
beforeEach(() => {
|
||
vi.clearAllMocks();
|
||
window.localStorage.clear();
|
||
__clearCustomAgentChatViewCacheForTests();
|
||
__resetProjectCliInteractionActivityForTests();
|
||
});
|
||
|
||
it("renders the project icon as a non-interactive custom-CLI watermark and updates it immediately", async () => {
|
||
const agent = {
|
||
reattachAgentChat: vi.fn(async (sessionId: string) => ({ sessionId, scrollback: [] })),
|
||
sendAgentChat: vi.fn(), cancelAgentChat: vi.fn(), closeAgentChat: vi.fn(),
|
||
};
|
||
const project = { readProjectSettings: vi.fn(async () => ({ themeColor: null, icon: { set: "devicon" as const, name: "python" } })) };
|
||
render(<DIProvider gateways={{ agent, project, 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-1" conversationId="conversation-1" onSessionId={vi.fn()} onConversationId={vi.fn()} />
|
||
</DIProvider>);
|
||
|
||
const watermark = await screen.findByTestId("project-icon-watermark");
|
||
expect(watermark.style.pointerEvents).toBe("none");
|
||
expect(watermark.getAttribute("aria-hidden")).toBe("true");
|
||
expect((watermark.firstElementChild as HTMLElement | null)?.style.width).toBe("92%");
|
||
expect((watermark.firstElementChild as HTMLElement | null)?.style.height).toBe("92%");
|
||
expect(watermark.textContent).toBe("Py");
|
||
publishProjectIconChanged({ projectId: "project-1", icon: { set: "lucide", name: "star" } });
|
||
await waitFor(() => expect(watermark.textContent).toBe("★"));
|
||
});
|
||
|
||
it("settles an isolated conversation id without recovered turns as an actionable unavailable state (#307/#314)", async () => {
|
||
const agent = {
|
||
launchAgentChat: vi.fn(async () => ({
|
||
sessionId: "fresh-session",
|
||
transcriptUnavailableReason: "missingTranscript" as const,
|
||
})),
|
||
reattachAgentChat: vi.fn(async (sessionId: string) => ({
|
||
sessionId,
|
||
scrollback: [],
|
||
transcriptUnavailableReason: "missingTranscript" as const,
|
||
})),
|
||
sendAgentChat: vi.fn(async () => {}),
|
||
cancelAgentChat: vi.fn(async () => {}),
|
||
closeAgentChat: vi.fn(async () => {}),
|
||
};
|
||
|
||
const onConversationId = 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="orphan-conversation"
|
||
onSessionId={vi.fn()}
|
||
onConversationId={onConversationId}
|
||
/>
|
||
</DIProvider>,
|
||
);
|
||
|
||
await waitFor(() =>
|
||
expect(agent.launchAgentChat).toHaveBeenCalledWith(
|
||
"project-1",
|
||
"agent-1",
|
||
expect.objectContaining({ conversationId: "orphan-conversation" }),
|
||
),
|
||
);
|
||
expect(
|
||
await screen.findByText("La conversation liée n’a pas pu être restaurée."),
|
||
).toBeTruthy();
|
||
expect(screen.getByText("Sa transcription n’est plus disponible après la reprise.")).toBeTruthy();
|
||
expect(
|
||
screen.queryByText("Conversation liée — récupération du transcript en cours."),
|
||
).toBeNull();
|
||
expect(screen.queryByRole("progressbar")).toBeNull();
|
||
expect(screen.getByRole("button", { name: "Réessayer" })).toBeTruthy();
|
||
fireEvent.click(screen.getByRole("button", { name: "Délier la conversation" }));
|
||
expect(onConversationId).toHaveBeenCalledWith(null);
|
||
expect(await screen.findByText("Envoyez un message pour démarrer la conversation structurée.")).toBeTruthy();
|
||
});
|
||
|
||
it("keeps an empty linked custom CLI continuation usable without explicit transcript loss", async () => {
|
||
const agent = {
|
||
launchAgentChat: vi.fn(async () => ({ sessionId: "valid-session" })),
|
||
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={null} conversationId="valid-conversation" onSessionId={vi.fn()} onConversationId={vi.fn()} />
|
||
</DIProvider>,
|
||
);
|
||
|
||
expect(
|
||
await screen.findByText("Envoyez un message pour démarrer la conversation structurée."),
|
||
).toBeTruthy();
|
||
expect(screen.queryByText("La conversation liée n’a pas pu être restaurée.")).toBeNull();
|
||
});
|
||
|
||
it("treats emptyConversation as a normal fresh conversation", async () => {
|
||
const agent = {
|
||
launchAgentChat: vi.fn(async () => ({
|
||
sessionId: "fresh-session",
|
||
transcriptUnavailableReason: "emptyConversation" as const,
|
||
})),
|
||
reattachAgentChat: vi.fn(async (sessionId: string) => ({
|
||
sessionId,
|
||
scrollback: [],
|
||
transcriptUnavailableReason: "emptyConversation" as const,
|
||
})),
|
||
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={null} conversationId="conversation-1" onSessionId={vi.fn()} onConversationId={vi.fn()} />
|
||
</DIProvider>,
|
||
);
|
||
expect(await screen.findByText("Envoyez un message pour démarrer la conversation structurée.")).toBeTruthy();
|
||
expect(screen.queryByText("La conversation liée n’a pas pu être restaurée.")).toBeNull();
|
||
});
|
||
|
||
it("maps NOT_FOUND to an actionable missing conversation without automatic looping", async () => {
|
||
const agent = {
|
||
launchAgentChat: vi.fn(async () => { throw { code: "NOT_FOUND", message: "conversation not found" }; }),
|
||
reattachAgentChat: vi.fn(),
|
||
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={null} conversationId="missing-conversation" onSessionId={vi.fn()} onConversationId={vi.fn()} />
|
||
</DIProvider>,
|
||
);
|
||
expect(await screen.findByText("Cette conversation est introuvable dans le stockage disponible.")).toBeTruthy();
|
||
expect(agent.launchAgentChat).toHaveBeenCalledTimes(1);
|
||
expect(screen.getByRole("button", { name: "Réessayer" })).toBeTruthy();
|
||
});
|
||
|
||
it("projects self-restart lifecycle separators in the custom CLI only once", async () => {
|
||
const system = new MockSystemGateway();
|
||
const agent = {
|
||
launchAgentChat: vi.fn(async () => ({ sessionId: "chat-session-restart" })),
|
||
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 } as unknown as Gateways}>
|
||
<AnnouncementsProvider projectId="project-1">
|
||
<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()}
|
||
/>
|
||
</AnnouncementsProvider>
|
||
</DIProvider>,
|
||
);
|
||
await waitFor(() => expect(agent.launchAgentChat).toHaveBeenCalled());
|
||
|
||
act(() => {
|
||
system.emit({
|
||
type: "agentRestartRequested",
|
||
agentId: "agent-1",
|
||
restartId: "restart-1",
|
||
reason: null,
|
||
});
|
||
system.emit({
|
||
type: "agentRestartCompleted",
|
||
agentId: "agent-1",
|
||
restartId: "restart-1",
|
||
});
|
||
});
|
||
|
||
expect(
|
||
await screen.findByText("------- redémarrage de l’agent -------"),
|
||
).not.toBeNull();
|
||
expect(
|
||
screen.getByText("------- redémarrage de l’agent terminé -------"),
|
||
).not.toBeNull();
|
||
expect(screen.getAllByTestId("agent-restart-separator")).toHaveLength(2);
|
||
|
||
act(() => {
|
||
system.emit({
|
||
type: "agentRestartCompleted",
|
||
agentId: "agent-1",
|
||
restartId: "restart-1",
|
||
});
|
||
});
|
||
expect(screen.getAllByTestId("agent-restart-separator")).toHaveLength(2);
|
||
|
||
act(() => {
|
||
system.emit({
|
||
type: "agentRestartRequested",
|
||
agentId: "agent-1",
|
||
restartId: "restart-2",
|
||
reason: "new capability",
|
||
});
|
||
system.emit({
|
||
type: "agentRestartFailed",
|
||
agentId: "agent-1",
|
||
restartId: "restart-2",
|
||
phase: "launching",
|
||
code: "LAUNCH_FAILED",
|
||
message: "runtime unavailable",
|
||
});
|
||
});
|
||
expect(
|
||
screen.getByText("------- redémarrage de l’agent échoué -------"),
|
||
).not.toBeNull();
|
||
expect(screen.getByText("runtime unavailable")).not.toBeNull();
|
||
});
|
||
|
||
it("reconciles a restarted chat to its replacement session and keeps receiving chunks", async () => {
|
||
const system = new MockSystemGateway();
|
||
const liveReceivers: Array<(chunk: ReplyChunk) => void> = [];
|
||
const agent = {
|
||
launchAgentChat: vi.fn(),
|
||
reattachAgentChat: vi
|
||
.fn()
|
||
.mockImplementationOnce(
|
||
async (_sessionId: string, onChunk: (chunk: ReplyChunk) => void) => {
|
||
liveReceivers.push(onChunk);
|
||
return {
|
||
sessionId: "old-session",
|
||
conversationId: "old-conversation",
|
||
scrollback: [{ kind: "final", content: "Avant redémarrage" }],
|
||
};
|
||
},
|
||
)
|
||
.mockImplementationOnce(
|
||
async (_sessionId: string, onChunk: (chunk: ReplyChunk) => void) => {
|
||
liveReceivers.push(onChunk);
|
||
return {
|
||
sessionId: "replacement-session",
|
||
conversationId: "replacement-conversation",
|
||
scrollback: [
|
||
{ kind: "final", content: "Avant redémarrage" },
|
||
{ kind: "userPrompt", text: "Continue" },
|
||
{ kind: "textDelta", text: "Reprise conservée" },
|
||
],
|
||
};
|
||
},
|
||
),
|
||
sendAgentChat: vi.fn(async () => {}),
|
||
cancelAgentChat: vi.fn(async () => {}),
|
||
closeAgentChat: vi.fn(async () => {}),
|
||
};
|
||
const onSessionId = vi.fn();
|
||
const onConversationId = vi.fn();
|
||
|
||
render(
|
||
<DIProvider gateways={{ agent, system } as unknown as Gateways}>
|
||
<AnnouncementsProvider projectId="project-1">
|
||
<CustomAgentChatView
|
||
projectId="project-1"
|
||
agentId="agent-1"
|
||
agentName="Worker"
|
||
profile={profile}
|
||
cwd="/repo"
|
||
nodeId="node-1"
|
||
sessionId="old-session"
|
||
conversationId="old-conversation"
|
||
onSessionId={onSessionId}
|
||
onConversationId={onConversationId}
|
||
/>
|
||
</AnnouncementsProvider>
|
||
</DIProvider>,
|
||
);
|
||
|
||
expect(await screen.findByText("Avant redémarrage")).toBeTruthy();
|
||
act(() => {
|
||
system.emit({
|
||
type: "agentRestartRequested",
|
||
agentId: "agent-1",
|
||
restartId: "restart-295",
|
||
reason: "reload plugin",
|
||
});
|
||
system.emit({
|
||
type: "agentRestartCompleted",
|
||
agentId: "agent-1",
|
||
restartId: "restart-295",
|
||
});
|
||
});
|
||
|
||
await waitFor(() => expect(agent.reattachAgentChat).toHaveBeenCalledTimes(2));
|
||
expect(agent.reattachAgentChat).toHaveBeenNthCalledWith(
|
||
2,
|
||
"old-session",
|
||
expect.any(Function),
|
||
{ projectId: "project-1", agentId: "agent-1" },
|
||
);
|
||
expect(await screen.findByText("Reprise conservée")).toBeTruthy();
|
||
expect(onSessionId).toHaveBeenCalledWith("replacement-session");
|
||
expect(onConversationId).toHaveBeenCalledWith("replacement-conversation");
|
||
|
||
act(() => {
|
||
liveReceivers[1]?.({ kind: "final", content: "Chunk après redémarrage" });
|
||
});
|
||
expect(await screen.findByText("Chunk après redémarrage")).toBeTruthy();
|
||
expect(screen.queryByText(/Envoyez un message/)).toBeNull();
|
||
});
|
||
|
||
it("retries a stale session attach with stable project and agent identity", async () => {
|
||
const agent = {
|
||
launchAgentChat: vi.fn(),
|
||
reattachAgentChat: vi
|
||
.fn()
|
||
.mockRejectedValueOnce({ code: "NOT_FOUND", message: "stale session" })
|
||
.mockResolvedValueOnce({
|
||
sessionId: "replacement-session",
|
||
conversationId: "replacement-conversation",
|
||
scrollback: [{ kind: "final", content: "Transcript restauré" }],
|
||
}),
|
||
sendAgentChat: vi.fn(async () => {}),
|
||
cancelAgentChat: vi.fn(async () => {}),
|
||
closeAgentChat: vi.fn(async () => {}),
|
||
};
|
||
const onSessionId = vi.fn();
|
||
|
||
render(
|
||
<DIProvider
|
||
gateways={{ agent, system: new MockSystemGateway() } 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>,
|
||
);
|
||
|
||
expect(await screen.findByText("Transcript restauré")).toBeTruthy();
|
||
expect(agent.reattachAgentChat).toHaveBeenNthCalledWith(
|
||
2,
|
||
"stale-session",
|
||
expect.any(Function),
|
||
{ projectId: "project-1", agentId: "agent-1" },
|
||
);
|
||
expect(agent.launchAgentChat).not.toHaveBeenCalled();
|
||
expect(onSessionId).toHaveBeenCalledWith("replacement-session");
|
||
});
|
||
|
||
it("shows the technical open failure on mobile with retry and native TUI fallback", async () => {
|
||
const openNativeTui = vi.fn();
|
||
const agent = {
|
||
launchAgentChat: vi
|
||
.fn()
|
||
.mockRejectedValueOnce(new Error("spawn codex ENOENT"))
|
||
.mockResolvedValueOnce({ sessionId: "chat-session-1" }),
|
||
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={null}
|
||
conversationId={null}
|
||
mobile
|
||
onOpenNativeTui={openNativeTui}
|
||
onSessionId={vi.fn()}
|
||
onConversationId={vi.fn()}
|
||
/>
|
||
</DIProvider>,
|
||
);
|
||
|
||
const alert = await screen.findByRole("alert");
|
||
expect(within(alert).getByText("Le CLI custom n'a pas pu démarrer.")).toBeTruthy();
|
||
expect(within(alert).getByText("Cause: spawn codex ENOENT")).toBeTruthy();
|
||
expect(screen.queryByText(/Vérifiez la configuration du profil/)).toBeNull();
|
||
|
||
fireEvent.click(within(alert).getByRole("button", { name: "Ouvrir en TUI native" }));
|
||
expect(openNativeTui).toHaveBeenCalledTimes(1);
|
||
|
||
fireEvent.click(within(alert).getByRole("button", { name: "Réessayer" }));
|
||
await waitFor(() => expect(agent.launchAgentChat).toHaveBeenCalledTimes(2));
|
||
await waitFor(() => expect(screen.queryByRole("alert")).toBeNull());
|
||
});
|
||
|
||
it("uses the same technical open failure on desktop", async () => {
|
||
const agent = {
|
||
launchAgentChat: vi.fn(async () => {
|
||
throw new Error("permission denied: /opt/codex");
|
||
}),
|
||
reattachAgentChat: vi.fn(),
|
||
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={null}
|
||
conversationId={null}
|
||
onSessionId={vi.fn()}
|
||
onConversationId={vi.fn()}
|
||
/>
|
||
</DIProvider>,
|
||
);
|
||
|
||
const alert = await screen.findByRole("alert");
|
||
expect(within(alert).getByText("Cause: permission denied: /opt/codex")).toBeTruthy();
|
||
expect(screen.getByRole("button", { name: "Réessayer" })).toBeTruthy();
|
||
expect(screen.queryByRole("button", { name: "Ouvrir en TUI native" })).toBeNull();
|
||
});
|
||
|
||
it("recalls previously sent prompts with arrow up and down", async () => {
|
||
const agent = {
|
||
launchAgentChat: vi.fn(),
|
||
reattachAgentChat: vi.fn(async (sessionId: string) => ({
|
||
sessionId,
|
||
scrollback: [],
|
||
})),
|
||
sendAgentChat: vi.fn(
|
||
async (_sessionId: string, prompt: string, onChunk: (chunk: ReplyChunk) => void) => {
|
||
onChunk({ kind: "final", content: `answer ${prompt}` });
|
||
},
|
||
),
|
||
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"
|
||
layoutId="layout-1"
|
||
nodeId="node-1"
|
||
sessionId="chat-session-1"
|
||
conversationId="conversation-1"
|
||
onSessionId={vi.fn()}
|
||
onConversationId={vi.fn()}
|
||
/>
|
||
</DIProvider>,
|
||
);
|
||
|
||
const composer = await screen.findByLabelText(/Message CLI personnalisée/) as HTMLTextAreaElement;
|
||
await waitFor(() => expect(agent.reattachAgentChat).toHaveBeenCalled());
|
||
|
||
fireEvent.change(composer, { target: { value: "first prompt" } });
|
||
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
||
await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalledWith(
|
||
"chat-session-1",
|
||
"first prompt",
|
||
expect.any(Function),
|
||
));
|
||
|
||
fireEvent.change(composer, { target: { value: "second prompt" } });
|
||
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
||
await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalledWith(
|
||
"chat-session-1",
|
||
"second prompt",
|
||
expect.any(Function),
|
||
));
|
||
|
||
fireEvent.keyDown(composer, { key: "ArrowUp" });
|
||
await waitFor(() => expect(composer.value).toBe("second prompt"));
|
||
fireEvent.keyDown(composer, { key: "ArrowUp" });
|
||
await waitFor(() => expect(composer.value).toBe("first prompt"));
|
||
fireEvent.keyDown(composer, { key: "ArrowDown" });
|
||
await waitFor(() => expect(composer.value).toBe("second prompt"));
|
||
|
||
fireEvent.change(composer, { target: { value: "draft in progress" } });
|
||
fireEvent.keyDown(composer, { key: "ArrowUp" });
|
||
await waitFor(() => expect(composer.value).toBe("second prompt"));
|
||
fireEvent.keyDown(composer, { key: "ArrowDown" });
|
||
await waitFor(() => expect(composer.value).toBe("draft in progress"));
|
||
});
|
||
|
||
it("recalls history from the exact end of a multiline draft and restores it verbatim", async () => {
|
||
const agent = renderHistoryChat();
|
||
const composer = (await screen.findByLabelText(
|
||
/Message CLI personnalisée/,
|
||
)) as HTMLTextAreaElement;
|
||
await waitFor(() => expect(agent.reattachAgentChat).toHaveBeenCalled());
|
||
await sendHistoryPrompt(composer, agent, "historic prompt");
|
||
|
||
const multilineDraft = "ligne 1\nligne 2\nfin 🧪 ";
|
||
fireEvent.change(composer, { target: { value: multilineDraft } });
|
||
composer.setSelectionRange(multilineDraft.length, multilineDraft.length);
|
||
Object.defineProperty(composer, "scrollHeight", {
|
||
value: 480,
|
||
configurable: true,
|
||
});
|
||
composer.scrollTop = 0;
|
||
|
||
expect(fireEvent.keyDown(composer, { key: "ArrowUp" })).toBe(false);
|
||
await waitFor(() => expect(composer.value).toBe("historic prompt"));
|
||
expect(composer.selectionStart).toBe(composer.value.length);
|
||
expect(composer.selectionEnd).toBe(composer.value.length);
|
||
expect(composer.scrollTop).toBe(480);
|
||
|
||
expect(fireEvent.keyDown(composer, { key: "ArrowDown" })).toBe(false);
|
||
await waitFor(() => expect(composer.value).toBe(multilineDraft));
|
||
expect(composer.selectionStart).toBe(multilineDraft.length);
|
||
expect(composer.selectionEnd).toBe(multilineDraft.length);
|
||
expect(composer.scrollTop).toBe(480);
|
||
});
|
||
|
||
it("keeps ArrowUp native away from a simple end caret or during modified/IME input", async () => {
|
||
const agent = renderHistoryChat();
|
||
const composer = (await screen.findByLabelText(
|
||
/Message CLI personnalisée/,
|
||
)) as HTMLTextAreaElement;
|
||
await waitFor(() => expect(agent.reattachAgentChat).toHaveBeenCalled());
|
||
await sendHistoryPrompt(composer, agent, "historic prompt");
|
||
|
||
const draft = "première\ndeuxième";
|
||
const nativeCases: Array<{
|
||
selection: [number, number];
|
||
event?: Record<string, unknown>;
|
||
}> = [
|
||
{ selection: [4, 4] },
|
||
{ selection: [0, draft.length] },
|
||
{ selection: [draft.length, draft.length], event: { shiftKey: true } },
|
||
{ selection: [draft.length, draft.length], event: { ctrlKey: true } },
|
||
{ selection: [draft.length, draft.length], event: { altKey: true } },
|
||
{ selection: [draft.length, draft.length], event: { metaKey: true } },
|
||
{ selection: [draft.length, draft.length], event: { isComposing: true } },
|
||
{ selection: [draft.length, draft.length], event: { keyCode: 229 } },
|
||
];
|
||
for (const testCase of nativeCases) {
|
||
fireEvent.change(composer, { target: { value: draft } });
|
||
composer.setSelectionRange(...testCase.selection);
|
||
expect(
|
||
fireEvent.keyDown(composer, { key: "ArrowUp", ...testCase.event }),
|
||
).toBe(true);
|
||
expect(composer.value).toBe(draft);
|
||
}
|
||
|
||
composer.setSelectionRange(draft.length, draft.length);
|
||
expect(fireEvent.keyDown(composer, { key: "ArrowUp" })).toBe(false);
|
||
await waitFor(() => expect(composer.value).toBe("historic prompt"));
|
||
});
|
||
|
||
it("does not consume ArrowUp with empty history and never mutates recalled entries", async () => {
|
||
const agent = renderHistoryChat();
|
||
const composer = (await screen.findByLabelText(
|
||
/Message CLI personnalisée/,
|
||
)) as HTMLTextAreaElement;
|
||
await waitFor(() => expect(agent.reattachAgentChat).toHaveBeenCalled());
|
||
|
||
expect(fireEvent.keyDown(composer, { key: "ArrowUp" })).toBe(true);
|
||
await sendHistoryPrompt(composer, agent, "original history");
|
||
fireEvent.keyDown(composer, { key: "ArrowUp" });
|
||
await waitFor(() => expect(composer.value).toBe("original history"));
|
||
|
||
fireEvent.change(composer, { target: { value: "edited recall" } });
|
||
composer.setSelectionRange(composer.value.length, composer.value.length);
|
||
fireEvent.keyDown(composer, { key: "ArrowUp" });
|
||
await waitFor(() => expect(composer.value).toBe("original history"));
|
||
|
||
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
||
await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalledTimes(2));
|
||
expect(composer.value).toBe("");
|
||
expect(fireEvent.keyDown(composer, { key: "ArrowDown" })).toBe(true);
|
||
});
|
||
|
||
it("persists prompt history across a custom chat remount", async () => {
|
||
const agent = {
|
||
launchAgentChat: vi.fn(),
|
||
reattachAgentChat: vi.fn(async (sessionId: string) => ({
|
||
sessionId,
|
||
scrollback: [],
|
||
})),
|
||
sendAgentChat: vi.fn(
|
||
async (_sessionId: string, prompt: string, onChunk: (chunk: ReplyChunk) => void) => {
|
||
onChunk({ kind: "final", content: `answer ${prompt}` });
|
||
},
|
||
),
|
||
cancelAgentChat: vi.fn(async () => {}),
|
||
closeAgentChat: vi.fn(async () => {}),
|
||
};
|
||
const props = {
|
||
projectId: "project-1",
|
||
agentId: "agent-1",
|
||
agentName: "Worker",
|
||
profile,
|
||
cwd: "/repo",
|
||
layoutId: "layout-1",
|
||
nodeId: "node-1",
|
||
sessionId: "chat-session-1",
|
||
conversationId: "conversation-1",
|
||
onSessionId: vi.fn(),
|
||
onConversationId: vi.fn(),
|
||
};
|
||
|
||
const view = render(
|
||
<DIProvider
|
||
gateways={{
|
||
agent,
|
||
system: { pickFile: vi.fn(async () => null) },
|
||
} as unknown as Gateways}
|
||
>
|
||
<CustomAgentChatView {...props} />
|
||
</DIProvider>,
|
||
);
|
||
|
||
let composer = await screen.findByLabelText(/Message CLI personnalisée/) as HTMLTextAreaElement;
|
||
fireEvent.change(composer, { target: { value: "remember me" } });
|
||
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
||
await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalledWith(
|
||
"chat-session-1",
|
||
"remember me",
|
||
expect.any(Function),
|
||
));
|
||
|
||
view.unmount();
|
||
render(
|
||
<DIProvider
|
||
gateways={{
|
||
agent,
|
||
system: { pickFile: vi.fn(async () => null) },
|
||
} as unknown as Gateways}
|
||
>
|
||
<CustomAgentChatView {...props} />
|
||
</DIProvider>,
|
||
);
|
||
|
||
composer = await screen.findByLabelText(/Message CLI personnalisée/) as HTMLTextAreaElement;
|
||
fireEvent.keyDown(composer, { key: "ArrowUp" });
|
||
await waitFor(() => expect(composer.value).toBe("remember me"));
|
||
});
|
||
|
||
it("persists an accepted text prompt before invoking the agent", async () => {
|
||
const historyKey = "idea.custom-chat-history.project-1.layout-1.node-1.agent-1";
|
||
let historyAtSend: string[] = [];
|
||
const agent = {
|
||
launchAgentChat: vi.fn(),
|
||
reattachAgentChat: vi.fn(async (sessionId: string) => ({ sessionId, scrollback: [] })),
|
||
sendAgentChat: vi.fn(
|
||
async (_sessionId: string, _prompt: string, onChunk: (chunk: ReplyChunk) => void) => {
|
||
historyAtSend = JSON.parse(window.localStorage.getItem(historyKey) ?? "[]");
|
||
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"
|
||
layoutId="layout-1"
|
||
nodeId="node-1"
|
||
sessionId="chat-session-1"
|
||
conversationId="conversation-1"
|
||
onSessionId={vi.fn()}
|
||
onConversationId={vi.fn()}
|
||
/>
|
||
</DIProvider>,
|
||
);
|
||
|
||
const composer = await screen.findByLabelText(/Message CLI personnalisée/);
|
||
fireEvent.change(composer, { target: { value: "persist before send" } });
|
||
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
||
|
||
await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalledOnce());
|
||
expect(historyAtSend).toEqual(["persist before send"]);
|
||
});
|
||
|
||
it("keeps sending when prompt-history persistence fails and reports it discreetly", async () => {
|
||
const setItem = vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
|
||
throw new Error("storage unavailable");
|
||
});
|
||
try {
|
||
const agent = renderHistoryChat();
|
||
const composer = await screen.findByLabelText(/Message CLI personnalisée/);
|
||
fireEvent.change(composer, { target: { value: "still send" } });
|
||
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
||
|
||
await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalledOnce());
|
||
expect(screen.getByRole("status").textContent).toContain(
|
||
"Historique des prompts non sauvegardé.",
|
||
);
|
||
} finally {
|
||
setItem.mockRestore();
|
||
}
|
||
});
|
||
|
||
it("repaints attachment labels from 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("Fichier: clipboard.png")).toBeTruthy();
|
||
});
|
||
|
||
it("renders local reception time in message meta without changing streamed turn grouping", async () => {
|
||
const userReceivedAtMs = new Date("2026-08-15T13:05:00.000Z").getTime();
|
||
const firstDeltaReceivedAtMs = new Date("2026-08-15T13:06:00.000Z").getTime();
|
||
const finalChunkReceivedAtMs = new Date("2026-08-15T13:07:00.000Z").getTime();
|
||
let nowMs = userReceivedAtMs;
|
||
const nowSpy = vi.spyOn(Date, "now").mockImplementation(() => nowMs);
|
||
const agent = {
|
||
launchAgentChat: vi.fn(),
|
||
reattachAgentChat: vi.fn(async (sessionId: string) => ({
|
||
sessionId,
|
||
scrollback: [],
|
||
})),
|
||
sendAgentChat: vi.fn(async (_sessionId: string, _prompt: string, onChunk) => {
|
||
nowMs = firstDeltaReceivedAtMs;
|
||
onChunk({ kind: "textDelta", text: "Streaming reply", atMs: firstDeltaReceivedAtMs });
|
||
nowMs = finalChunkReceivedAtMs;
|
||
onChunk({ kind: "final", content: "Completed reply", atMs: finalChunkReceivedAtMs });
|
||
}),
|
||
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.change(screen.getByLabelText(/Message CLI personnalisée/), {
|
||
target: { value: "Initial prompt" },
|
||
});
|
||
nowMs = userReceivedAtMs;
|
||
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
||
|
||
const userBubble = (await screen.findByText("Initial prompt")).closest("div");
|
||
const agentBubble = screen.getByText("Streaming reply").closest("div");
|
||
const finalBubble = screen.getByText("Completed reply").closest("div");
|
||
|
||
expect(userBubble).toBeTruthy();
|
||
expect(agentBubble).toBeTruthy();
|
||
expect(finalBubble).toBeTruthy();
|
||
expect(within(userBubble!).getByLabelText("Heure indisponible")).toBeTruthy();
|
||
expect(
|
||
within(agentBubble!).getByText(expectedChatTime(firstDeltaReceivedAtMs)),
|
||
).toBeTruthy();
|
||
expect(
|
||
within(finalBubble!).getByText(expectedChatTime(finalChunkReceivedAtMs)),
|
||
).toBeTruthy();
|
||
expect(screen.getByText("Streaming reply")).toBeTruthy();
|
||
expect(screen.getByText("Completed reply")).toBeTruthy();
|
||
expect(screen.getByText(expectedChatTime(finalChunkReceivedAtMs))).toBeTruthy();
|
||
} finally {
|
||
nowSpy.mockRestore();
|
||
}
|
||
});
|
||
|
||
it("keeps a single reception time on error bubbles using the first streamed chunk time", async () => {
|
||
const userReceivedAtMs = new Date("2026-08-15T13:05:00.000Z").getTime();
|
||
const firstDeltaReceivedAtMs = new Date("2026-08-15T13:06:00.000Z").getTime();
|
||
const errorReceivedAtMs = new Date("2026-08-15T13:07:00.000Z").getTime();
|
||
let nowMs = userReceivedAtMs;
|
||
const nowSpy = vi.spyOn(Date, "now").mockImplementation(() => nowMs);
|
||
const agent = {
|
||
launchAgentChat: vi.fn(),
|
||
reattachAgentChat: vi.fn(async (sessionId: string) => ({
|
||
sessionId,
|
||
scrollback: [],
|
||
})),
|
||
sendAgentChat: vi.fn(async (_sessionId: string, _prompt: string, onChunk) => {
|
||
nowMs = firstDeltaReceivedAtMs;
|
||
onChunk({ kind: "textDelta", text: "Streaming reply", atMs: firstDeltaReceivedAtMs });
|
||
nowMs = errorReceivedAtMs;
|
||
onChunk({ kind: "error", message: "Broken reply", atMs: errorReceivedAtMs });
|
||
}),
|
||
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.change(screen.getByLabelText(/Message CLI personnalisée/), {
|
||
target: { value: "Initial prompt" },
|
||
});
|
||
nowMs = userReceivedAtMs;
|
||
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
||
|
||
const agentBubble = (await screen.findByText("Streaming reply")).closest("div");
|
||
const errorBubble = screen.getByText("Broken reply").closest("div");
|
||
|
||
expect(agentBubble).toBeTruthy();
|
||
expect(errorBubble).toBeTruthy();
|
||
expect(within(errorBubble!).getByText("Session Codex")).toBeTruthy();
|
||
expect(
|
||
within(agentBubble!).getByText(expectedChatTime(firstDeltaReceivedAtMs)),
|
||
).toBeTruthy();
|
||
expect(
|
||
within(errorBubble!).getByText(expectedChatTime(errorReceivedAtMs)),
|
||
).toBeTruthy();
|
||
expect(screen.getByText(expectedChatTime(errorReceivedAtMs))).toBeTruthy();
|
||
} finally {
|
||
nowSpy.mockRestore();
|
||
}
|
||
});
|
||
|
||
it("replays scrollback with the current reception time instead of the reduce index", async () => {
|
||
const replayReceivedAtMs = new Date("2026-08-15T13:08:00.000Z").getTime();
|
||
const indexOneTime = expectedChatTime(1);
|
||
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(replayReceivedAtMs);
|
||
const agent = {
|
||
launchAgentChat: vi.fn(),
|
||
reattachAgentChat: vi.fn(async (sessionId: string) => ({
|
||
sessionId,
|
||
scrollback: [
|
||
{ kind: "userPrompt" as const, text: "Replay prompt" },
|
||
{ kind: "textDelta" as const, text: "Replay answer" },
|
||
{ kind: "final" as const, content: "Replay completed" },
|
||
],
|
||
})),
|
||
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>,
|
||
);
|
||
|
||
const agentBubble = (await screen.findByText("Replay answer")).closest("div");
|
||
const finalBubble = screen.getByText("Replay completed").closest("div");
|
||
|
||
expect(agentBubble).toBeTruthy();
|
||
expect(finalBubble).toBeTruthy();
|
||
expect(within(agentBubble!).getByLabelText("Heure indisponible")).toBeTruthy();
|
||
expect(within(finalBubble!).getByLabelText("Heure indisponible")).toBeTruthy();
|
||
expect(within(agentBubble!).queryByText(indexOneTime)).toBeNull();
|
||
expect(within(finalBubble!).queryByText(indexOneTime)).toBeNull();
|
||
} finally {
|
||
nowSpy.mockRestore();
|
||
}
|
||
});
|
||
|
||
it("resizes the composer vertically with mouse, keyboard, and double-click reset", async () => {
|
||
const rectSpy = vi
|
||
.spyOn(HTMLElement.prototype, "getBoundingClientRect")
|
||
.mockReturnValue({
|
||
x: 0,
|
||
y: 0,
|
||
width: 600,
|
||
height: 400,
|
||
top: 0,
|
||
right: 600,
|
||
bottom: 400,
|
||
left: 0,
|
||
toJSON: () => {},
|
||
} as DOMRect);
|
||
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 () => {}),
|
||
};
|
||
|
||
function Harness() {
|
||
const [height, setHeight] = useState(64);
|
||
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="chat-session-1"
|
||
conversationId="conversation-1"
|
||
composerHeightPx={height}
|
||
composerDefaultHeightPx={64}
|
||
onComposerHeightPxChange={setHeight}
|
||
onSessionId={vi.fn()}
|
||
onConversationId={vi.fn()}
|
||
/>
|
||
</DIProvider>
|
||
);
|
||
}
|
||
|
||
try {
|
||
render(<Harness />);
|
||
const composer = await screen.findByLabelText(/Message CLI personnalisée/) as HTMLTextAreaElement;
|
||
const handle = screen.getByRole("button", {
|
||
name: "Redimensionner l’éditeur node-1",
|
||
});
|
||
expect(composer.style.height).toBe("64px");
|
||
expect(handle.style.cursor).toBe("ns-resize");
|
||
|
||
fireEvent.mouseDown(handle, { clientY: 200 });
|
||
fireEvent.mouseMove(window, { clientY: 100 });
|
||
fireEvent.mouseUp(window);
|
||
await waitFor(() => expect(composer.style.height).toBe("164px"));
|
||
|
||
fireEvent.keyDown(handle, { key: "ArrowUp", shiftKey: true });
|
||
await waitFor(() => expect(composer.style.height).toBe("176px"));
|
||
fireEvent.keyDown(handle, { key: "ArrowDown", shiftKey: true });
|
||
await waitFor(() => expect(composer.style.height).toBe("164px"));
|
||
|
||
fireEvent.mouseDown(handle, { clientY: 200 });
|
||
fireEvent.mouseMove(window, { clientY: -200 });
|
||
fireEvent.mouseUp(window);
|
||
await waitFor(() => expect(composer.style.height).toBe("200px"));
|
||
|
||
fireEvent.doubleClick(handle);
|
||
await waitFor(() => expect(composer.style.height).toBe("64px"));
|
||
} finally {
|
||
rectSpy.mockRestore();
|
||
}
|
||
});
|
||
|
||
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 personnalisée/).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 personnalisée/).hasAttribute("disabled")).toBe(true);
|
||
});
|
||
|
||
it("restores visible conversation from conversationId cache when layout returns before sessionId", 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" as string | null,
|
||
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: "final" as const, content: "Answer restored from replay" },
|
||
];
|
||
|
||
render(
|
||
<DIProvider
|
||
gateways={{
|
||
agent,
|
||
system: { pickFile: vi.fn(async () => null) },
|
||
} as unknown as Gateways}
|
||
>
|
||
<CustomAgentChatView {...props} sessionId={null} />
|
||
</DIProvider>,
|
||
);
|
||
|
||
expect(screen.getByText("Initial reply")).toBeTruthy();
|
||
expect(agent.launchAgentChat).not.toHaveBeenCalled();
|
||
expect(screen.getByRole("button", { name: "Envoyer" }).hasAttribute("disabled")).toBe(true);
|
||
|
||
act(() => {
|
||
secondReattach.resolve({ sessionId: "chat-session-1", scrollback });
|
||
});
|
||
|
||
expect(await screen.findByText("Answer restored from replay")).toBeTruthy();
|
||
expect(screen.getByText("Question during graph view")).toBeTruthy();
|
||
expect(agent.launchAgentChat).not.toHaveBeenCalled();
|
||
});
|
||
|
||
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("cancels only the current request and keeps the structured session usable", async () => {
|
||
let resolveCancel: () => void = () => {};
|
||
const agent = {
|
||
launchAgentChat: vi.fn(),
|
||
reattachAgentChat: vi.fn(async (sessionId: string) => ({
|
||
sessionId,
|
||
scrollback: [],
|
||
})),
|
||
sendAgentChat: vi
|
||
.fn()
|
||
.mockImplementationOnce(() => new Promise<void>(() => {}))
|
||
.mockImplementationOnce(async (_sessionId: string, _prompt: string, onChunk) => {
|
||
onChunk({ kind: "final", content: "Next answer" });
|
||
}),
|
||
stopLiveAgent: vi.fn(),
|
||
cancelAgentChat: vi.fn(
|
||
() =>
|
||
new Promise<void>((resolve) => {
|
||
resolveCancel = resolve;
|
||
}),
|
||
),
|
||
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 personnalisée/), {
|
||
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("Stopping...");
|
||
expect(stoppingLabel.closest("button")?.hasAttribute("disabled")).toBe(true);
|
||
fireEvent.click(stoppingLabel.closest("button")!);
|
||
await waitFor(() =>
|
||
expect(agent.cancelAgentChat).toHaveBeenCalledWith("chat-session-1"),
|
||
);
|
||
expect(agent.cancelAgentChat).toHaveBeenCalledTimes(1);
|
||
resolveCancel();
|
||
await waitFor(() => expect(screen.getByText("Demande arrêtée.")).toBeTruthy());
|
||
expect(hasProjectCliInteraction("project-1")).toBe(false);
|
||
expect(onSessionId).not.toHaveBeenCalledWith(null);
|
||
expect(agent.stopLiveAgent).not.toHaveBeenCalled();
|
||
expect(agent.closeAgentChat).not.toHaveBeenCalled();
|
||
const composer = screen.getByLabelText(/Message CLI personnalisée/);
|
||
expect(composer).toHaveProperty("disabled", false);
|
||
|
||
fireEvent.change(composer, {
|
||
target: { value: "next turn" },
|
||
});
|
||
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
||
|
||
await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalledTimes(2));
|
||
expect(agent.sendAgentChat).toHaveBeenNthCalledWith(
|
||
2,
|
||
"chat-session-1",
|
||
"next turn",
|
||
expect.any(Function),
|
||
);
|
||
expect(await screen.findByText("Next answer")).toBeTruthy();
|
||
});
|
||
|
||
it("keeps the composer locked from canonical work-state busy even without local stream activity", async () => {
|
||
const system = new MockSystemGateway();
|
||
const workState = new MockWorkStateGateway();
|
||
workState._setProjectWorkState("project-1", {
|
||
agents: [
|
||
{
|
||
agentId: "agent-1",
|
||
name: "Main",
|
||
profileId: "structured",
|
||
busy: { state: "busy", ticket: "turn-1", sinceMs: 1 },
|
||
tickets: [],
|
||
},
|
||
],
|
||
conversations: [],
|
||
});
|
||
const agent = {
|
||
launchAgentChat: vi.fn(),
|
||
reattachAgentChat: vi.fn(async (sessionId: string) => ({
|
||
sessionId,
|
||
scrollback: [],
|
||
})),
|
||
sendAgentChat: vi.fn(async () => {}),
|
||
stopLiveAgent: vi.fn(async () => ({
|
||
agentId: "agent-1",
|
||
sessionId: "chat-session-1",
|
||
kind: "structured" as const,
|
||
})),
|
||
cancelAgentChat: vi.fn(async () => {}),
|
||
closeAgentChat: vi.fn(async () => {}),
|
||
};
|
||
|
||
render(
|
||
<DIProvider
|
||
gateways={{
|
||
agent,
|
||
system,
|
||
workState,
|
||
} as unknown as Gateways}
|
||
>
|
||
<CustomAgentChatView
|
||
projectId="project-1"
|
||
agentId="agent-1"
|
||
agentName="Main"
|
||
profile={profile}
|
||
cwd="/repo"
|
||
nodeId="node-1"
|
||
sessionId="chat-session-1"
|
||
conversationId="conversation-1"
|
||
onSessionId={vi.fn()}
|
||
onConversationId={vi.fn()}
|
||
/>
|
||
</DIProvider>,
|
||
);
|
||
|
||
const composer = await screen.findByLabelText(/Message CLI personnalisée/);
|
||
await waitFor(() => expect(composer).toHaveProperty("disabled", true));
|
||
expect(screen.getByRole("button", { name: "Stop" })).toBeTruthy();
|
||
|
||
workState._setProjectWorkState("project-1", {
|
||
agents: [
|
||
{
|
||
agentId: "agent-1",
|
||
name: "Main",
|
||
profileId: "structured",
|
||
busy: { state: "idle" },
|
||
tickets: [],
|
||
},
|
||
],
|
||
conversations: [],
|
||
});
|
||
system.emit({ type: "agentBusyChanged", agentId: "agent-1", busy: false });
|
||
|
||
await waitFor(() => expect(composer).toHaveProperty("disabled", false));
|
||
expect(screen.getByRole("button", { name: "Envoyer" })).toBeTruthy();
|
||
});
|
||
|
||
it("shows the requester as waiting on the delegated target and keeps input locked", async () => {
|
||
const system = new MockSystemGateway();
|
||
const workState = new MockWorkStateGateway();
|
||
workState._setProjectWorkState("project-1", {
|
||
agents: [
|
||
{
|
||
agentId: "main",
|
||
name: "Main",
|
||
profileId: "structured",
|
||
busy: { state: "idle" },
|
||
tickets: [],
|
||
},
|
||
{
|
||
agentId: "architect",
|
||
name: "Architect",
|
||
profileId: "structured",
|
||
busy: { state: "busy", ticket: "delegated-1", sinceMs: 1 },
|
||
tickets: [
|
||
{
|
||
ticketId: "delegated-1",
|
||
conversationId: "conversation-2",
|
||
position: 0,
|
||
status: "inProgress",
|
||
source: { kind: "agent", agentId: "main" },
|
||
requesterLabel: "Main",
|
||
taskPreview: "Analyse l'architecture",
|
||
taskLen: 22,
|
||
},
|
||
],
|
||
},
|
||
],
|
||
conversations: [],
|
||
});
|
||
const agent = {
|
||
launchAgentChat: vi.fn(),
|
||
reattachAgentChat: vi.fn(async (sessionId: string) => ({
|
||
sessionId,
|
||
scrollback: [],
|
||
})),
|
||
sendAgentChat: vi.fn(async () => {}),
|
||
stopLiveAgent: vi.fn(async () => ({
|
||
agentId: "main",
|
||
sessionId: "chat-session-1",
|
||
kind: "structured" as const,
|
||
})),
|
||
cancelAgentChat: vi.fn(async () => {}),
|
||
closeAgentChat: vi.fn(async () => {}),
|
||
};
|
||
|
||
render(
|
||
<DIProvider
|
||
gateways={{
|
||
agent,
|
||
system,
|
||
workState,
|
||
} as unknown as Gateways}
|
||
>
|
||
<CustomAgentChatView
|
||
projectId="project-1"
|
||
agentId="main"
|
||
agentName="Main"
|
||
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("Main attend Architect")).toBeTruthy();
|
||
expect(screen.getByLabelText(/Message CLI personnalisée/)).toHaveProperty("disabled", 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" })
|
||
.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 personnalisée/), {
|
||
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é").closest(".justify-end")).toBeTruthy();
|
||
});
|
||
|
||
it("keeps successive turns isolated when backend userPrompt echoes arrive after progress (#251)", async () => {
|
||
const agent = {
|
||
launchAgentChat: vi.fn(),
|
||
reattachAgentChat: vi.fn(async (sessionId: string) => ({
|
||
sessionId,
|
||
scrollback: [],
|
||
})),
|
||
sendAgentChat: vi.fn(
|
||
async (_sessionId: string, prompt: string, onChunk: (chunk: ReplyChunk) => void) => {
|
||
onChunk({
|
||
kind: "progress",
|
||
progress: {
|
||
source: "providerNative",
|
||
kind: "tool",
|
||
stage: "started",
|
||
label: "shell",
|
||
toolName: "shell",
|
||
text: `Running ${prompt}`,
|
||
},
|
||
});
|
||
onChunk({ kind: "userPrompt", text: prompt });
|
||
onChunk({
|
||
kind: "progress",
|
||
progress: {
|
||
source: "providerNative",
|
||
kind: "tool",
|
||
stage: "completed",
|
||
label: "shell",
|
||
toolName: "shell",
|
||
text: `Done ${prompt}`,
|
||
},
|
||
});
|
||
onChunk({ kind: "final", content: `final ${prompt}` });
|
||
},
|
||
),
|
||
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 personnalisée/), {
|
||
target: { value: "first request" },
|
||
});
|
||
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
||
await screen.findByText("final first request");
|
||
|
||
fireEvent.change(screen.getByLabelText(/Message CLI personnalisée/), {
|
||
target: { value: "second request" },
|
||
});
|
||
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
||
await screen.findByText("final second request");
|
||
|
||
expect(screen.getAllByText("first request")).toHaveLength(1);
|
||
expect(screen.getAllByText("second request")).toHaveLength(1);
|
||
expect(screen.getAllByTestId("chat-tool-progress")).toHaveLength(2);
|
||
expect(screen.queryByText("Running first request")).toBeNull();
|
||
expect(screen.queryByText("Running second request")).toBeNull();
|
||
expect(screen.getByText("Done first request")).toBeTruthy();
|
||
expect(screen.getByText("Done second request")).toBeTruthy();
|
||
expect(screen.getAllByText("Terminé")).toHaveLength(2);
|
||
|
||
const firstPrompt = screen.getByText("first request");
|
||
const firstProgress = screen.getByText("Done first request");
|
||
const firstFinal = screen.getByText("final first request");
|
||
const secondPrompt = screen.getByText("second request");
|
||
const secondProgress = screen.getByText("Done second request");
|
||
const secondFinal = screen.getByText("final second request");
|
||
|
||
expect(
|
||
Boolean(firstPrompt.compareDocumentPosition(firstProgress) & Node.DOCUMENT_POSITION_FOLLOWING),
|
||
).toBe(true);
|
||
expect(
|
||
Boolean(firstProgress.compareDocumentPosition(firstFinal) & Node.DOCUMENT_POSITION_FOLLOWING),
|
||
).toBe(true);
|
||
expect(
|
||
Boolean(firstFinal.compareDocumentPosition(secondPrompt) & Node.DOCUMENT_POSITION_FOLLOWING),
|
||
).toBe(true);
|
||
expect(
|
||
Boolean(secondPrompt.compareDocumentPosition(secondProgress) & Node.DOCUMENT_POSITION_FOLLOWING),
|
||
).toBe(true);
|
||
expect(
|
||
Boolean(secondProgress.compareDocumentPosition(secondFinal) & Node.DOCUMENT_POSITION_FOLLOWING),
|
||
).toBe(true);
|
||
});
|
||
|
||
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 personnalisée/), {
|
||
target: { value: "ship progress UI" },
|
||
});
|
||
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
||
|
||
await screen.findByText("Progression");
|
||
expect(screen.getAllByTestId("chat-turn-marker")).toHaveLength(2);
|
||
expect(screen.getByText("turn.started")).toBeTruthy();
|
||
expect(screen.getByText("turn.completed")).toBeTruthy();
|
||
expect(screen.getByText("Progression").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 repeated legacy tool progress as fresh activity on each turn", 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: "ideaLocal",
|
||
kind: "mcp",
|
||
stage: "started",
|
||
label: "idea_context_read",
|
||
toolName: "idea_context_read",
|
||
text: "Lecture du contexte",
|
||
},
|
||
});
|
||
onChunk({
|
||
kind: "progress",
|
||
progress: {
|
||
source: "ideaLocal",
|
||
kind: "mcp",
|
||
stage: "completed",
|
||
label: "idea_context_read",
|
||
toolName: "idea_context_read",
|
||
text: "Lecture du contexte",
|
||
},
|
||
});
|
||
onChunk({ kind: "final", content: `done ${prompt}` });
|
||
}),
|
||
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 personnalisée/), {
|
||
target: { value: "first" },
|
||
});
|
||
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
||
await screen.findByText("done first");
|
||
|
||
fireEvent.change(screen.getByLabelText(/Message CLI personnalisée/), {
|
||
target: { value: "second" },
|
||
});
|
||
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
||
await screen.findByText("done second");
|
||
|
||
expect(screen.getAllByTestId("chat-tool-progress")).toHaveLength(2);
|
||
expect(screen.getAllByText("idea_context_read")).toHaveLength(2);
|
||
});
|
||
|
||
it("surfaces second-turn streaming progress through a distinct callback after the first turn completed", async () => {
|
||
const harness = createStreamingAgentHarness();
|
||
|
||
render(
|
||
<DIProvider
|
||
gateways={{
|
||
agent: harness.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(harness.agent.reattachAgentChat).toHaveBeenCalledWith(
|
||
"chat-session-1",
|
||
expect.any(Function),
|
||
),
|
||
);
|
||
|
||
fireEvent.change(screen.getByLabelText(/Message CLI personnalisée/), {
|
||
target: { value: "first request" },
|
||
});
|
||
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
||
await waitFor(() => expect(harness.emitterCount()).toBe(1));
|
||
|
||
act(() => {
|
||
harness.emit(0, {
|
||
kind: "progress",
|
||
progress: {
|
||
source: "providerNative",
|
||
kind: "tool",
|
||
stage: "started",
|
||
label: "shell",
|
||
toolName: "shell",
|
||
text: "Running first request",
|
||
},
|
||
});
|
||
});
|
||
expect(screen.getByText("Running first request")).toBeTruthy();
|
||
expect(screen.getByText("Outil").parentElement?.textContent).toContain("En cours");
|
||
|
||
act(() => {
|
||
harness.emit(0, {
|
||
kind: "progress",
|
||
progress: {
|
||
source: "providerNative",
|
||
kind: "tool",
|
||
stage: "completed",
|
||
label: "shell",
|
||
toolName: "shell",
|
||
text: "Done first request",
|
||
},
|
||
});
|
||
harness.emit(0, { kind: "final", content: "final first request" });
|
||
});
|
||
await screen.findByText("final first request");
|
||
|
||
fireEvent.change(screen.getByLabelText(/Message CLI personnalisée/), {
|
||
target: { value: "second request" },
|
||
});
|
||
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
||
await waitFor(() => expect(harness.emitterCount()).toBe(2));
|
||
|
||
act(() => {
|
||
harness.emit(1, {
|
||
kind: "progress",
|
||
progress: {
|
||
source: "providerNative",
|
||
kind: "message",
|
||
stage: "delta",
|
||
label: "Analyse",
|
||
text: "Second request context loaded.",
|
||
},
|
||
});
|
||
});
|
||
expect(screen.getByText("Second request context loaded.")).toBeTruthy();
|
||
expect(screen.getByText("Progression").parentElement?.textContent).toContain("En cours");
|
||
|
||
act(() => {
|
||
harness.emit(1, {
|
||
kind: "progress",
|
||
progress: {
|
||
source: "ideaLocal",
|
||
kind: "mcp",
|
||
stage: "started",
|
||
label: "idea_ticket_read",
|
||
toolName: "idea_ticket_read",
|
||
text: "Reading ticket for second request",
|
||
},
|
||
});
|
||
});
|
||
expect(screen.getByText("Reading ticket for second request")).toBeTruthy();
|
||
expect(screen.getAllByTestId("chat-tool-progress")).toHaveLength(2);
|
||
|
||
act(() => {
|
||
harness.emit(1, {
|
||
kind: "progress",
|
||
progress: {
|
||
source: "ideaLocal",
|
||
kind: "mcp",
|
||
stage: "completed",
|
||
label: "idea_ticket_read",
|
||
toolName: "idea_ticket_read",
|
||
text: "Ticket read complete",
|
||
},
|
||
});
|
||
harness.emit(1, { kind: "final", content: "final second request" });
|
||
});
|
||
|
||
await screen.findByText("final second request");
|
||
expect(screen.getByText("Ticket read complete")).toBeTruthy();
|
||
expect(screen.getByText("Second request context loaded.")).toBeTruthy();
|
||
expect(screen.getAllByText("Terminé")).toHaveLength(2);
|
||
});
|
||
|
||
it("treats same-tool progress on the second turn as fresh streaming activity with a distinct callback", async () => {
|
||
const harness = createStreamingAgentHarness();
|
||
|
||
render(
|
||
<DIProvider
|
||
gateways={{
|
||
agent: harness.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(harness.agent.reattachAgentChat).toHaveBeenCalledWith(
|
||
"chat-session-1",
|
||
expect.any(Function),
|
||
),
|
||
);
|
||
|
||
fireEvent.change(screen.getByLabelText(/Message CLI personnalisée/), {
|
||
target: { value: "first request" },
|
||
});
|
||
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
||
await waitFor(() => expect(harness.emitterCount()).toBe(1));
|
||
|
||
act(() => {
|
||
harness.emit(0, {
|
||
kind: "progress",
|
||
progress: {
|
||
source: "ideaLocal",
|
||
kind: "mcp",
|
||
stage: "started",
|
||
label: "idea_context_read",
|
||
toolName: "idea_context_read",
|
||
text: "Reading shared context for first request",
|
||
},
|
||
});
|
||
harness.emit(0, {
|
||
kind: "progress",
|
||
progress: {
|
||
source: "ideaLocal",
|
||
kind: "mcp",
|
||
stage: "completed",
|
||
label: "idea_context_read",
|
||
toolName: "idea_context_read",
|
||
text: "Context read complete for first request",
|
||
},
|
||
});
|
||
harness.emit(0, { kind: "final", content: "done first request" });
|
||
});
|
||
await screen.findByText("done first request");
|
||
|
||
fireEvent.change(screen.getByLabelText(/Message CLI personnalisée/), {
|
||
target: { value: "second request" },
|
||
});
|
||
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
||
await waitFor(() => expect(harness.emitterCount()).toBe(2));
|
||
|
||
act(() => {
|
||
harness.emit(1, {
|
||
kind: "progress",
|
||
progress: {
|
||
source: "ideaLocal",
|
||
kind: "mcp",
|
||
stage: "started",
|
||
label: "idea_context_read",
|
||
toolName: "idea_context_read",
|
||
text: "Reading shared context for second request",
|
||
},
|
||
});
|
||
});
|
||
expect(screen.getByText("Reading shared context for second request")).toBeTruthy();
|
||
expect(screen.getAllByText("idea_context_read")).toHaveLength(2);
|
||
|
||
act(() => {
|
||
harness.emit(1, {
|
||
kind: "progress",
|
||
progress: {
|
||
source: "ideaLocal",
|
||
kind: "mcp",
|
||
stage: "completed",
|
||
label: "idea_context_read",
|
||
toolName: "idea_context_read",
|
||
text: "Context read complete for second request",
|
||
},
|
||
});
|
||
harness.emit(1, { kind: "final", content: "done second request" });
|
||
});
|
||
|
||
await screen.findByText("done second request");
|
||
expect(screen.getByText("Context read complete for second request")).toBeTruthy();
|
||
expect(screen.getByText("Context read complete for first request")).toBeTruthy();
|
||
expect(screen.getAllByTestId("chat-tool-progress")).toHaveLength(2);
|
||
});
|
||
|
||
it("keeps second-turn progress visible when the provider reuses the same progressId across turns", async () => {
|
||
const harness = createStreamingAgentHarness();
|
||
|
||
render(
|
||
<DIProvider
|
||
gateways={{
|
||
agent: harness.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(harness.agent.reattachAgentChat).toHaveBeenCalledWith(
|
||
"chat-session-1",
|
||
expect.any(Function),
|
||
),
|
||
);
|
||
|
||
fireEvent.change(screen.getByLabelText(/Message CLI personnalisée/), {
|
||
target: { value: "first request" },
|
||
});
|
||
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
||
await waitFor(() => expect(harness.emitterCount()).toBe(1));
|
||
|
||
act(() => {
|
||
harness.emit(0, {
|
||
kind: "progress",
|
||
progress: {
|
||
progressId: "codex:item:call_1",
|
||
source: "providerNative",
|
||
kind: "tool",
|
||
stage: "started",
|
||
label: "shell",
|
||
toolName: "shell",
|
||
text: "Running first request",
|
||
},
|
||
});
|
||
harness.emit(0, {
|
||
kind: "progress",
|
||
progress: {
|
||
progressId: "codex:item:call_1",
|
||
source: "providerNative",
|
||
kind: "tool",
|
||
stage: "completed",
|
||
label: "shell",
|
||
toolName: "shell",
|
||
text: "Done first request",
|
||
},
|
||
});
|
||
harness.emit(0, { kind: "final", content: "done first request" });
|
||
});
|
||
await screen.findByText("done first request");
|
||
|
||
fireEvent.change(screen.getByLabelText(/Message CLI personnalisée/), {
|
||
target: { value: "second request" },
|
||
});
|
||
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
||
await waitFor(() => expect(harness.emitterCount()).toBe(2));
|
||
|
||
act(() => {
|
||
harness.emit(1, {
|
||
kind: "progress",
|
||
progress: {
|
||
progressId: "codex:item:call_1",
|
||
source: "providerNative",
|
||
kind: "tool",
|
||
stage: "started",
|
||
label: "shell",
|
||
toolName: "shell",
|
||
text: "Running second request",
|
||
},
|
||
});
|
||
});
|
||
|
||
expect(screen.getByText("Running second request")).toBeTruthy();
|
||
|
||
act(() => {
|
||
harness.emit(1, {
|
||
kind: "progress",
|
||
progress: {
|
||
progressId: "codex:item:call_1",
|
||
source: "providerNative",
|
||
kind: "tool",
|
||
stage: "completed",
|
||
label: "shell",
|
||
toolName: "shell",
|
||
text: "Done second request",
|
||
},
|
||
});
|
||
harness.emit(1, { kind: "final", content: "done second request" });
|
||
});
|
||
|
||
await screen.findByText("done second request");
|
||
expect(screen.getByText("Done first request")).toBeTruthy();
|
||
expect(screen.getByText("Done second request")).toBeTruthy();
|
||
expect(screen.getAllByTestId("chat-tool-progress")).toHaveLength(2);
|
||
});
|
||
|
||
it("ignores a late first-turn progress chunk but still shows the real second-turn progress from the next callback", async () => {
|
||
const harness = createStreamingAgentHarness();
|
||
|
||
render(
|
||
<DIProvider
|
||
gateways={{
|
||
agent: harness.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(harness.agent.reattachAgentChat).toHaveBeenCalledWith(
|
||
"chat-session-1",
|
||
expect.any(Function),
|
||
),
|
||
);
|
||
|
||
fireEvent.change(screen.getByLabelText(/Message CLI personnalisée/), {
|
||
target: { value: "first request" },
|
||
});
|
||
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
||
await waitFor(() => expect(harness.emitterCount()).toBe(1));
|
||
|
||
act(() => {
|
||
harness.emit(0, {
|
||
kind: "progress",
|
||
progress: {
|
||
source: "providerNative",
|
||
kind: "tool",
|
||
stage: "started",
|
||
label: "shell",
|
||
toolName: "shell",
|
||
text: "Running first request",
|
||
},
|
||
});
|
||
harness.emit(0, { kind: "final", content: "done first request" });
|
||
});
|
||
await screen.findByText("done first request");
|
||
|
||
act(() => {
|
||
harness.emit(0, {
|
||
kind: "progress",
|
||
progress: {
|
||
source: "providerNative",
|
||
kind: "tool",
|
||
stage: "completed",
|
||
label: "shell",
|
||
toolName: "shell",
|
||
text: "Late first request completion",
|
||
},
|
||
});
|
||
});
|
||
expect(screen.queryByText("Late first request completion")).toBeNull();
|
||
|
||
fireEvent.change(screen.getByLabelText(/Message CLI personnalisée/), {
|
||
target: { value: "second request" },
|
||
});
|
||
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
||
await waitFor(() => expect(harness.emitterCount()).toBe(2));
|
||
|
||
act(() => {
|
||
harness.emit(1, {
|
||
kind: "progress",
|
||
progress: {
|
||
source: "providerNative",
|
||
kind: "tool",
|
||
stage: "started",
|
||
label: "shell",
|
||
toolName: "shell",
|
||
text: "Running second request",
|
||
},
|
||
});
|
||
});
|
||
expect(screen.getByText("Running second request")).toBeTruthy();
|
||
|
||
act(() => {
|
||
harness.emit(1, {
|
||
kind: "progress",
|
||
progress: {
|
||
source: "providerNative",
|
||
kind: "tool",
|
||
stage: "completed",
|
||
label: "shell",
|
||
toolName: "shell",
|
||
text: "Done second request",
|
||
},
|
||
});
|
||
harness.emit(1, { kind: "final", content: "done second request" });
|
||
});
|
||
|
||
await screen.findByText("done second request");
|
||
expect(screen.getByText("Done second request")).toBeTruthy();
|
||
expect(screen.queryByText("Late first request completion")).toBeNull();
|
||
});
|
||
|
||
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 personnalisée/), {
|
||
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 personnalisée/), {
|
||
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("En cours");
|
||
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("Outil")).toBeNull();
|
||
expect(screen.queryByText("started")).toBeNull();
|
||
await waitFor(() =>
|
||
expect(screen.queryByRole("status", { name: "Loading" })).toBeNull(),
|
||
);
|
||
});
|
||
|
||
it("keeps repeated MCP cycles visible when legacy progress has no progressId", 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 personnalisée/), {
|
||
target: { value: "read many tickets" },
|
||
});
|
||
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
||
await waitFor(() => expect(emitChunk).not.toBeNull());
|
||
|
||
act(() => {
|
||
for (let index = 1; index <= 8; index += 1) {
|
||
emitChunk?.({
|
||
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",
|
||
text: `reading ticket #${index}`,
|
||
},
|
||
});
|
||
emitChunk?.({
|
||
kind: "progress" as const,
|
||
progress: {
|
||
source: "ideaLocal" as const,
|
||
kind: "mcp" as const,
|
||
stage: "completed" as const,
|
||
label: "idea_ticket_read",
|
||
toolName: "idea_ticket_read",
|
||
text: `read ticket #${index}`,
|
||
},
|
||
});
|
||
}
|
||
emitChunk?.({ kind: "final", content: "done" });
|
||
});
|
||
|
||
await screen.findByText("done");
|
||
expect(screen.getAllByTestId("chat-tool-progress")).toHaveLength(8);
|
||
expect(screen.getAllByText("MCP")).toHaveLength(8);
|
||
expect(screen.getAllByText("idea_ticket_read")).toHaveLength(8);
|
||
expect(screen.getByText("read ticket #1")).toBeTruthy();
|
||
expect(screen.getByText("read ticket #8")).toBeTruthy();
|
||
expect(screen.queryByText("reading ticket #1")).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 personnalisée/), {
|
||
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 personnalisée/), {
|
||
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("Outil").parentElement?.textContent).toContain("En cours");
|
||
});
|
||
|
||
it("expands only truncated MCP progress details and preserves expansion across updates", 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 personnalisée/), {
|
||
target: { value: "inspect mcp payloads" },
|
||
});
|
||
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
||
await waitFor(() => expect(emitChunk).not.toBeNull());
|
||
|
||
act(() => {
|
||
emitChunk?.({
|
||
kind: "progress" as const,
|
||
progress: {
|
||
progressId: "mcp-large",
|
||
source: "ideaLocal" as const,
|
||
kind: "mcp" as const,
|
||
stage: "started" as const,
|
||
label: "idea_ticket_read",
|
||
toolName: "idea_ticket_read",
|
||
text: "line 1\nline 2\nline 3\nline 4\nline 5",
|
||
},
|
||
});
|
||
emitChunk?.({
|
||
kind: "progress" as const,
|
||
progress: {
|
||
progressId: "mcp-small",
|
||
source: "ideaLocal" as const,
|
||
kind: "mcp" as const,
|
||
stage: "started" as const,
|
||
label: "idea_memory_read",
|
||
toolName: "idea_memory_read",
|
||
text: "short payload",
|
||
},
|
||
});
|
||
});
|
||
|
||
const details = await screen.findAllByTestId("chat-progress-detail");
|
||
expect(details).toHaveLength(2);
|
||
setMeasuredTextOverflow(details[0], { scrollHeight: 120, clientHeight: 48 });
|
||
setMeasuredTextOverflow(details[1], { scrollHeight: 24, clientHeight: 24 });
|
||
|
||
const expand = await screen.findByRole("button", { name: "Afficher plus" });
|
||
expect(expand.getAttribute("aria-expanded")).toBe("false");
|
||
expect(expand.getAttribute("aria-controls")).toBe(details[0].id);
|
||
expect(screen.queryAllByRole("button", { name: "Afficher plus" })).toHaveLength(1);
|
||
|
||
fireEvent.click(expand);
|
||
expect(
|
||
screen.getByRole("button", { name: "Afficher moins" }).getAttribute("aria-expanded"),
|
||
).toBe("true");
|
||
expect(screen.queryByRole("button", { name: "Afficher plus" })).toBeNull();
|
||
|
||
act(() => {
|
||
emitChunk?.({
|
||
kind: "progress" as const,
|
||
progress: {
|
||
progressId: "mcp-large",
|
||
source: "ideaLocal" as const,
|
||
kind: "mcp" as const,
|
||
stage: "delta" as const,
|
||
label: "idea_ticket_read",
|
||
toolName: "idea_ticket_read",
|
||
text: "updated line 1\nupdated line 2\nupdated line 3\nupdated line 4\nupdated line 5",
|
||
},
|
||
});
|
||
});
|
||
|
||
expect(screen.getByText(/updated line 5/)).toBeTruthy();
|
||
expect(
|
||
screen.getByRole("button", { name: "Afficher moins" }).getAttribute("aria-expanded"),
|
||
).toBe("true");
|
||
expect(screen.queryByRole("button", { name: "Afficher plus" })).toBeNull();
|
||
});
|
||
|
||
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 personnalisée/), {
|
||
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("upserts progress by progressId and keeps first emission chronological order", 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 personnalisée/), {
|
||
target: { value: "run tools" },
|
||
});
|
||
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
||
await waitFor(() => expect(emitChunk).not.toBeNull());
|
||
|
||
const progress = (
|
||
progressId: string,
|
||
occurredAtMs: number,
|
||
stage: "started" | "completed",
|
||
text: string,
|
||
) => ({
|
||
kind: "progress" as const,
|
||
progress: {
|
||
progressId,
|
||
occurredAtMs,
|
||
source: "providerNative" as const,
|
||
kind: "tool" as const,
|
||
stage,
|
||
label: "shell",
|
||
toolName: "shell",
|
||
text,
|
||
},
|
||
});
|
||
|
||
act(() => {
|
||
emitChunk?.(progress("process-2", 20, "started", "Process 2 start"));
|
||
emitChunk?.(progress("process-1", 10, "started", "Process 1 start"));
|
||
emitChunk?.(progress("process-1", 10, "completed", "Process 1 done"));
|
||
});
|
||
|
||
expect(screen.getAllByTestId("chat-tool-progress")).toHaveLength(2);
|
||
expect(screen.queryByText("Process 1 start")).toBeNull();
|
||
expect(screen.getByText("Process 1 done")).toBeTruthy();
|
||
const first = screen.getByText("Process 1 done");
|
||
const second = screen.getByText("Process 2 start");
|
||
expect(
|
||
Boolean(first.compareDocumentPosition(second) & Node.DOCUMENT_POSITION_FOLLOWING),
|
||
).toBe(true);
|
||
});
|
||
|
||
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 personnalisée/), {
|
||
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("Outil").parentElement?.textContent).toContain("Terminé");
|
||
|
||
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 personnalisée/), {
|
||
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("Progression").parentElement?.textContent).toContain("En cours");
|
||
|
||
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("Progression").parentElement?.textContent).toContain("Terminé");
|
||
});
|
||
|
||
it("ignores late progress chunks after the final answer without reopening the turn", 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 personnalisée/), {
|
||
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.getByText("done")).toBeTruthy();
|
||
expect(screen.queryByText("idea_ticket_read")).toBeNull();
|
||
expect(screen.getByRole("button", { name: "Envoyer" })).toBeTruthy();
|
||
expect(screen.queryByRole("button", { name: "Stop" })).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 personnalisée/) 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: "ArrowUp" });
|
||
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 personnalisée/) 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 personnalisée/) 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 personnalisée/) 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 personnalisée/) 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 personnalisée/);
|
||
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 personnalisée/) 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: "Réinitialiser 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 réinitialisée."));
|
||
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),
|
||
});
|
||
const composer = screen.getByLabelText(/Message CLI personnalisée/) as HTMLTextAreaElement;
|
||
fireEvent.paste(composer, {
|
||
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();
|
||
await waitFor(() => expect(composer.value).toBe("[Image 1]"));
|
||
|
||
fireEvent.click(screen.getByRole("button", { name: "Retirer clipboard.png" }));
|
||
|
||
await waitFor(() =>
|
||
expect(screen.queryByText("Fichier joint: clipboard.png")).toBeNull(),
|
||
);
|
||
expect(composer.value).toBe("");
|
||
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 personnalisée/), {
|
||
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",
|
||
"[Image 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 personnalisée/), {
|
||
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 personnalisée/), {
|
||
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",
|
||
"[Image 1]",
|
||
expect.any(Function),
|
||
{
|
||
attachments: [
|
||
{
|
||
filename: "paste-image.png",
|
||
contentBase64: "AQID",
|
||
mime: "image/png",
|
||
sourceKind: "clipboard",
|
||
},
|
||
],
|
||
},
|
||
);
|
||
expect(screen.getByText("[Image 1]")).toBeTruthy();
|
||
expect(screen.getByText("Fichier: paste-image.png")).toBeTruthy();
|
||
});
|
||
|
||
it("accepts pasted generic files exposed only through clipboardData.files", async () => {
|
||
const agent = {
|
||
launchAgentChat: vi.fn(),
|
||
reattachAgentChat: vi.fn(async (sessionId: string) => ({
|
||
sessionId,
|
||
scrollback: [],
|
||
})),
|
||
sendAgentChat: vi.fn(async () => {}),
|
||
cancelAgentChat: vi.fn(async () => {}),
|
||
closeAgentChat: vi.fn(async () => {}),
|
||
};
|
||
|
||
render(
|
||
<DIProvider
|
||
gateways={{
|
||
agent,
|
||
system: { pickFile: vi.fn(async () => null) },
|
||
} as unknown as Gateways}
|
||
>
|
||
<CustomAgentChatView
|
||
projectId="project-1"
|
||
agentId="agent-1"
|
||
agentName="Worker"
|
||
profile={profile}
|
||
cwd="/repo"
|
||
nodeId="node-1"
|
||
sessionId="chat-session-1"
|
||
conversationId="conversation-1"
|
||
onSessionId={vi.fn()}
|
||
onConversationId={vi.fn()}
|
||
/>
|
||
</DIProvider>,
|
||
);
|
||
|
||
await waitFor(() =>
|
||
expect(agent.reattachAgentChat).toHaveBeenCalledWith(
|
||
"chat-session-1",
|
||
expect.any(Function),
|
||
),
|
||
);
|
||
|
||
const file = new File(["ignored"], "files-only.pdf", { type: "application/pdf" });
|
||
Object.defineProperty(file, "arrayBuffer", {
|
||
value: vi.fn(async () => new Uint8Array([4, 5, 6]).buffer),
|
||
});
|
||
fireEvent.paste(screen.getByLabelText(/Message CLI personnalisée/), {
|
||
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 personnalisée/) 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("Fichier: mixed.txt")).toBeTruthy();
|
||
});
|
||
|
||
it("inserts pasted image tokens at the cursor and sends the tokenized projection", 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 personnalisée/) as HTMLTextAreaElement;
|
||
fireEvent.change(composer, { target: { value: "voir maintenant" } });
|
||
composer.setSelectionRange(5, 5);
|
||
const file = new File(["ignored"], "cursor.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: cursor.png");
|
||
await waitFor(() => expect(composer.value).toBe("voir [Image 1] maintenant"));
|
||
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
||
|
||
await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalledTimes(1));
|
||
expect(agent.sendAgentChat).toHaveBeenCalledWith(
|
||
"chat-session-1",
|
||
"voir [Image 1] maintenant",
|
||
expect.any(Function),
|
||
{
|
||
attachments: [
|
||
{
|
||
filename: "cursor.png",
|
||
contentBase64: "AQID",
|
||
mime: "image/png",
|
||
sourceKind: "clipboard",
|
||
},
|
||
],
|
||
},
|
||
);
|
||
});
|
||
|
||
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 personnalisée/);
|
||
|
||
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 personnalisée/) 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 personnalisée/) as HTMLTextAreaElement).value).toBe(""),
|
||
);
|
||
});
|
||
|
||
it("keeps attachment drafts in memory across project unmount and return", 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 personnalisée/) 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[Image 1]"),
|
||
);
|
||
|
||
first.unmount();
|
||
renderView();
|
||
|
||
await screen.findByDisplayValue("draft with file[Image 1]");
|
||
expect(screen.getByText("Fichier joint: not-persisted.png")).toBeTruthy();
|
||
expect(screen.getByTestId("attachment-preview-not-persisted.png")).toBeTruthy();
|
||
});
|
||
|
||
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 personnalisée/) 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 personnalisée/) 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",
|
||
)
|
||
.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,
|
||
"69b57638-0ac7-4a79-8252-e0436a3265f6",
|
||
expect.any(Function),
|
||
{ projectId: "project-1", agentId: "agent-1" },
|
||
);
|
||
expect(agent.reattachAgentChat).toHaveBeenNthCalledWith(
|
||
3,
|
||
"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 personnalisée/), {
|
||
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 personnalisée/), {
|
||
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 request cancel as an already-stopped request", async () => {
|
||
const agent = {
|
||
launchAgentChat: vi.fn(),
|
||
reattachAgentChat: vi.fn(async (sessionId: string) => ({
|
||
sessionId,
|
||
scrollback: [],
|
||
})),
|
||
sendAgentChat: vi.fn(() => new Promise<void>(() => {})),
|
||
stopLiveAgent: vi.fn(),
|
||
cancelAgentChat: vi.fn().mockRejectedValueOnce({
|
||
code: "NOT_FOUND",
|
||
message: "agent agent-1 has no live session",
|
||
}),
|
||
closeAgentChat: vi.fn(async () => {}),
|
||
};
|
||
const onSessionId = vi.fn();
|
||
|
||
render(
|
||
<DIProvider
|
||
gateways={{
|
||
agent,
|
||
system: { pickFile: vi.fn(async () => null) },
|
||
} as unknown as Gateways}
|
||
>
|
||
<CustomAgentChatView
|
||
projectId="project-1"
|
||
agentId="agent-1"
|
||
agentName="Worker"
|
||
profile={profile}
|
||
cwd="/repo"
|
||
nodeId="node-1"
|
||
sessionId="gone-session"
|
||
conversationId="conversation-1"
|
||
onSessionId={onSessionId}
|
||
onConversationId={vi.fn()}
|
||
/>
|
||
</DIProvider>,
|
||
);
|
||
|
||
await waitFor(() =>
|
||
expect(agent.reattachAgentChat).toHaveBeenCalledWith(
|
||
"gone-session",
|
||
expect.any(Function),
|
||
),
|
||
);
|
||
|
||
fireEvent.change(screen.getByLabelText(/Message CLI personnalisée/), {
|
||
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("Demande déjà arrêtée.")).toBeTruthy();
|
||
expect(onSessionId).not.toHaveBeenCalledWith(null);
|
||
expect(agent.cancelAgentChat).toHaveBeenCalledWith("gone-session");
|
||
expect(agent.stopLiveAgent).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();
|
||
});
|
||
});
|